Files
Loki/internal/loki/facade_chat_test.go
T
Claude 0f3a3d52c1 Lot 23 : sonde de gabarit en Rust (avec LOKI_CHAT=rust)
Port de chat_tplprobe.go : la façade sonde le gabarit du moteur après ses
complétions (/health, /props, /apply-template), avec les verdicts
preserves_history, prefix_stable et renders_reasoning, le cache par
build/modèle/gabarit/forme et la relance bornée.

- l'écho du raisonnement suit le verdict de la sonde (echo_policy_for) ;
- NUDGE_IN_TOOL ne garde plus la conversation au Go : le rappel de budget
  va dans le dernier résultat d'outil quand le préfixe n'est pas stable ;
- le Go lit le verdict de la façade (/internal/facade/v1/chat/tpl) quand
  elle tient la conversation.

Parité : parity_tplprobe_test.go tire du Go un journal d'échanges rejoué
octet pour octet par tests/tplprobe.rs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 21:34:57 +00:00

1105 lines
40 KiB
Go

package loki
import (
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
// fausseFacade : enregistre ce qui lui arrive ; /api/chat rend un flux.
type fausseFacade struct {
mu sync.Mutex
vu []string // "METHODE chemin jeton-ok corps"
srv *httptest.Server
t *testing.T
sse string
reply string
// Réponse de l'API interne (0 : 204 sans corps).
internalStatus int
internalBody string
}
// oublierDecisionFacade : la décision « qui tient la conversation » est prise
// une fois par process ; chaque test repart de zéro.
func oublierDecisionFacade(t *testing.T) {
t.Helper()
reset := func() {
facadeDecisionMu.Lock()
facadeDecisionDone, facadeDecisionWhy = false, ""
facadeDecisionMu.Unlock()
}
reset()
t.Cleanup(reset)
}
func demarrerFacade(t *testing.T) *fausseFacade {
t.Helper()
oublierDecisionFacade(t)
f := &fausseFacade{t: t, reply: `{"ok":true,"de":"facade"}`}
f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
tok := r.Header.Get(internalTokenHeader) == jetonTest
f.mu.Lock()
f.vu = append(f.vu, r.Method+" "+r.URL.Path+" "+map[bool]string{true: "jeton", false: "sans-jeton"}[tok]+" "+string(b))
f.mu.Unlock()
if r.URL.Path == "/api/chat" {
w.Header().Set("Content-Type", "text/event-stream")
io.WriteString(w, f.sse)
return
}
if strings.HasPrefix(r.URL.Path, "/internal/facade/") {
f.mu.Lock()
st, body := f.internalStatus, f.internalBody
f.mu.Unlock()
if st == 0 {
w.WriteHeader(http.StatusNoContent)
return
}
w.WriteHeader(st)
io.WriteString(w, body)
return
}
io.WriteString(w, f.reply)
}))
t.Cleanup(f.srv.Close)
t.Setenv(internalTokenEnv, jetonTest)
t.Setenv(facadeChatEnv, "rust")
t.Setenv(facadeAddrEnv, strings.TrimPrefix(f.srv.URL, "http://"))
return f
}
func (f *fausseFacade) recu() []string {
f.mu.Lock()
defer f.mu.Unlock()
return append([]string(nil), f.vu...)
}
// Les routes du chat arrivées ici (tunnel, redistribution E2E) partent à la
// façade avec le jeton interne ; les autres restent ici ; sans la variable,
// rien ne change.
func TestFacadeRelaieLesRoutesDuChat(t *testing.T) {
testHome(t)
f := demarrerFacade(t)
local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") }
w := httptest.NewRecorder()
withFacadeChat("/api/chat/send", local)(w, httptest.NewRequest("POST", "/api/chat/send", strings.NewReader(`{"message":"salut"}`)))
if w.Body.String() != f.reply {
t.Fatalf("réponse : %q", w.Body.String())
}
if got := f.recu(); len(got) != 1 || got[0] != `POST /api/chat/send jeton {"message":"salut"}` {
t.Fatalf("reçu par la façade : %v", got)
}
// Mode Code, critères, export : à la façade aussi (lot 19).
for _, p := range []string{"/api/chat/mode", "/api/chat/criteria", "/api/chat/export"} {
w = httptest.NewRecorder()
withFacadeChat(p, local)(w, httptest.NewRequest("POST", p, nil))
if w.Body.String() != f.reply {
t.Fatalf("%s non relayé : %q", p, w.Body.String())
}
}
// L'aperçu du navigateur piloté reste ici.
w = httptest.NewRecorder()
withFacadeChat("/api/chat/cushot", local)(w, httptest.NewRequest("GET", "/api/chat/cushot", nil))
if w.Body.String() != "go" {
t.Fatalf("aperçu relayé : %q", w.Body.String())
}
// Sans LOKI_CHAT=rust : tout reste ici.
t.Setenv(facadeChatEnv, "")
w = httptest.NewRecorder()
withFacadeChat("/api/chat/send", local)(w, httptest.NewRequest("POST", "/api/chat/send", nil))
if w.Body.String() != "go" || len(f.recu()) != 4 {
t.Fatalf("relayé sans la variable : %q", w.Body.String())
}
}
// /api/e2e/chat suit le journal de la façade : un événement par trame, les
// battements de la façade ignorés, les nombres repartent tels quels.
func TestFacadeFluxE2E(t *testing.T) {
testHome(t)
f := demarrerFacade(t)
f.sse = "data: {\"choices\":[{\"delta\":{\"pad\":\"··\"}}]}\n\n" +
": ping\n\n" +
"data: {\"choices\":[{\"delta\":{\"seq\":12345678901,\"user\":\"salut\"}}]}\n\n" +
"data: {\"choices\":[{\"delta\":{\"caught_up\":true,\"id\":\"c1\"}}]}\n\n"
w := httptest.NewRecorder()
var trames []string
out := &sseStream{w: w, flusher: w, mu: &sync.Mutex{}, frame: func(obj map[string]any) ([]byte, bool) {
b, _ := json.Marshal(obj)
trames = append(trames, string(b))
return []byte("data: " + string(b) + "\n\n"), true
}}
facadeChatStream(t.Context(), []byte(`{"from":0,"tail":20}`), out)
want := []string{`{"pad":"··"}`, `{"seq":12345678901,"user":"salut"}`, `{"caught_up":true,"id":"c1"}`}
if strings.Join(trames, "|") != strings.Join(want, "|") {
t.Fatalf("trames : %v", trames)
}
if got := f.recu(); len(got) != 1 || got[0] != `POST /api/chat jeton {"from":0,"tail":20}` {
t.Fatalf("abonnement : %v", got)
}
}
// Les événements d'affichage nés ici vont au journal de la façade, pas à
// celui du Go.
func TestFacadeEvenementsEtExport(t *testing.T) {
testHome(t)
f := demarrerFacade(t)
resetConvForTest()
critNotify([]Criterion{{ID: 1, Text: "les tests passent", Status: "pending"}})
got := f.recu()
if len(got) != 1 || !strings.HasPrefix(got[0], "POST /internal/facade/v1/delta jeton ") || !strings.Contains(got[0], "les tests passent") {
t.Fatalf("critères : %v", got)
}
conv.mu.Lock()
n := len(conv.Log)
conv.mu.Unlock()
if n != 0 {
t.Fatal("événement journalisé aussi dans la conversation du Go")
}
// Export : la discussion enregistrée, pas la conversation du Go.
id := convEnsureActive()
b, _ := json.Marshal(map[string]any{"messages": []Message{{Role: "user", Content: "fil de la façade"}}, "log": []any{}, "seq": 0, "ctx_used": 0})
if err := putStoreBytes(bkChat, convKey(id), b); err != nil {
t.Fatal(err)
}
body, _, _, err := exportBody(exportOpts{Format: "json"})
if err != nil || !strings.Contains(string(body), "fil de la façade") {
t.Fatalf("export : %v %s", err, body)
}
}
// Changer de projet quand la façade tient la conversation : c'est elle qui
// enregistre sa discussion et en ouvre une autre (la copie d'ici est périmée,
// l'enregistrer écraserait la sienne). Son refus (tour en cours) remonte tel
// quel ; un projet inconnu est refusé ici sans la déranger.
func TestFacadeChangeDeProjet(t *testing.T) {
testHome(t)
ensureDefaultProject()
p, err := createProject("Deux")
if err != nil {
t.Fatal(err)
}
f := demarrerFacade(t)
post := func(body string) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
handleProjects(w, httptest.NewRequest("POST", "/api/projects", strings.NewReader(body)))
return w
}
before := activeProjectSlug()
if w := post(`{"action":"switch","slug":"` + p.Slug + `"}`); w.Code != 200 {
t.Fatalf("bascule : %d %s", w.Code, w.Body.String())
}
if got := f.recu(); len(got) != 1 || got[0] != `POST /internal/facade/v1/project/switch jeton {"slug":"`+p.Slug+`"}` {
t.Fatalf("reçu par la façade : %v", got)
}
// Le projet actif ne change que par la façade (/internal/v1/project/activate).
if activeProjectSlug() != before {
t.Fatalf("projet actif changé sans la façade : %s", activeProjectSlug())
}
if w := post(`{"action":"switch","slug":"inconnu"}`); w.Code != 400 || len(f.recu()) != 1 {
t.Fatalf("projet inconnu : %d, façade %v", w.Code, f.recu())
}
f.mu.Lock()
f.internalStatus, f.internalBody = 409, `{"ok":false,"error":"une génération est en cours"}`
f.mu.Unlock()
w := post(`{"action":"switch","slug":"` + p.Slug + `"}`)
if w.Code != 409 || !strings.Contains(w.Body.String(), "une génération est en cours") {
t.Fatalf("refus de la façade : %d %s", w.Code, w.Body.String())
}
}
// Supprimer le projet actif : la façade le quitte d'abord (discussion
// enregistrée, une du projet restant ouverte), puis supprime ses discussions.
func TestFacadeSuppressionDeProjet(t *testing.T) {
testHome(t)
ensureDefaultProject()
p, err := createProject("Deux")
if err != nil {
t.Fatal(err)
}
if err := setActiveProject(p.Slug); err != nil {
t.Fatal(err)
}
f := demarrerFacade(t)
w := httptest.NewRecorder()
handleProjects(w, httptest.NewRequest("POST", "/api/projects", strings.NewReader(`{"action":"delete","slug":"`+p.Slug+`"}`)))
if w.Code != 200 {
t.Fatalf("suppression : %d %s", w.Code, w.Body.String())
}
got := f.recu()
if len(got) != 2 ||
got[0] != `POST /internal/facade/v1/project/switch jeton {"slug":"`+defaultProjectSlug+`"}` ||
got[1] != `POST /internal/facade/v1/project/drop jeton {"slug":"`+p.Slug+`"}` {
t.Fatalf("reçu par la façade : %v", got)
}
if projectExists(p.Slug) {
t.Fatal("projet toujours là")
}
}
// /internal/v1/project/activate : le projet actif change, s'il existe.
func TestFacadeActivationDeProjet(t *testing.T) {
testHome(t)
ensureDefaultProject()
p, err := createProject("Trois")
if err != nil {
t.Fatal(err)
}
t.Setenv(internalTokenEnv, jetonTest)
if w := appelOAIInterne(t, "POST", "/internal/v1/project/activate", `{"slug":"`+p.Slug+`"}`); w.Code != 204 {
t.Fatalf("activation : %d %s", w.Code, w.Body.String())
}
if activeProjectSlug() != p.Slug {
t.Fatalf("actif : %s", activeProjectSlug())
}
if w := appelOAIInterne(t, "POST", "/internal/v1/project/activate", `{"slug":"nulle-part"}`); w.Code != 400 {
t.Fatalf("inconnu : %d", w.Code)
}
}
// Un réglage que la façade ne sait pas honorer garde la conversation ici :
// rien ne lui est remis, et elle l'apprend au démarrage. La décision tient
// jusqu'au redémarrage.
func TestFacadeReglageQuiGardeLaConversation(t *testing.T) {
testHome(t)
f := demarrerFacade(t)
t.Setenv(internalTokenEnv, jetonTest)
if w := appelOAIInterne(t, "GET", "/internal/v1/chat/holder", ""); w.Code != 200 || !strings.Contains(w.Body.String(), `"holder":"rust"`) {
t.Fatalf("détenteur : %d %s", w.Code, w.Body.String())
}
// Posé après la décision : sans effet avant le redémarrage.
if err := SetConfigKey("KEEP_TURN_IMAGES", "on"); err != nil {
t.Fatal(err)
}
if !facadeChat() {
t.Fatal("décision changée en cours de route")
}
oublierDecisionFacade(t)
if facadeChat() {
t.Fatal("KEEP_TURN_IMAGES posé : la conversation doit rester au Go")
}
w := appelOAIInterne(t, "GET", "/internal/v1/chat/holder", "")
if !strings.Contains(w.Body.String(), `"holder":"go"`) || !strings.Contains(w.Body.String(), "KEEP_TURN_IMAGES") {
t.Fatalf("détenteur : %s", w.Body.String())
}
local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") }
rec := httptest.NewRecorder()
withFacadeChat("/api/chat/send", local)(rec, httptest.NewRequest("POST", "/api/chat/send", strings.NewReader(`{}`)))
if rec.Body.String() != "go" || len(f.recu()) != 0 {
t.Fatalf("remis à la façade malgré le réglage : %q %v", rec.Body.String(), f.recu())
}
for k, want := range map[string]string{
"PROJ_SNAPSHOT": "PROJ_SNAPSHOT", "NUDGE_IN_TOOL": "", "SIDE_SLOT": "SIDE_SLOT",
"SLOT_PERSIST": "SLOT_PERSIST", "PREWARM": "PREWARM",
} {
if got := facadeChatBlocker(map[string]string{k: "on"}); got != want {
t.Errorf("%s : %q", k, got)
}
}
if got := facadeChatBlocker(map[string]string{}); got != "" {
t.Errorf("sans réglage : %q", got)
}
// Sans LOKI_CHAT=rust : le Go, et pourquoi.
oublierDecisionFacade(t)
t.Setenv(facadeChatEnv, "")
if w := appelOAIInterne(t, "GET", "/internal/v1/chat/holder", ""); !strings.Contains(w.Body.String(), "LOKI_CHAT absent") {
t.Fatalf("sans la variable : %s", w.Body.String())
}
}
// LOKI_MODELS=rust : les routes des modèles partent à la façade (avec ou sans
// LOKI_CHAT), les autres restent ici ; sans la variable, rien ne change.
// Façade muette : erreur qui le dit.
func TestFacadeRelaieLesModeles(t *testing.T) {
testHome(t)
f := demarrerFacade(t)
t.Setenv(facadeChatEnv, "")
t.Setenv(facadeModelsEnv, "rust")
local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") }
routes := []string{"/api/models/download", "/api/models/download/probe",
"/api/models/download/status", "/api/models/download/cancel", "/api/models",
"/api/models/delete", "/api/models/dirs", "/api/hf/search", "/api/hf/files", "/api/hf/token"}
for _, p := range routes {
w := httptest.NewRecorder()
withFacadeChat(p, local)(w, httptest.NewRequest("POST", p, strings.NewReader(`{"url":"u"}`)))
if w.Body.String() != f.reply {
t.Fatalf("%s : réponse %q", p, w.Body.String())
}
}
if got := f.recu(); len(got) != len(routes) || got[0] != `POST /api/models/download jeton {"url":"u"}` {
t.Fatalf("reçu par la façade : %v", got)
}
// Le chat, lui, reste ici (LOKI_CHAT absent), et les routes voisines aussi.
for _, p := range []string{"/api/chat/send", "/api/model/layers", "/api/presets"} {
w := httptest.NewRecorder()
withFacadeChat(p, local)(w, httptest.NewRequest("POST", p, nil))
if w.Body.String() != "go" {
t.Fatalf("%s relayé : %q", p, w.Body.String())
}
}
// Façade injoignable : 502 qui nomme ce qui manque.
f.srv.Close()
w := httptest.NewRecorder()
withFacadeChat("/api/models/download/status", local)(w, httptest.NewRequest("GET", "/api/models/download/status", nil))
if w.Code != http.StatusBadGateway || !strings.Contains(w.Body.String(), "modèles indisponibles : ") {
t.Fatalf("façade muette : %d %s", w.Code, w.Body.String())
}
// Sans LOKI_MODELS=rust : tout reste ici.
t.Setenv(facadeModelsEnv, "")
w = httptest.NewRecorder()
withFacadeChat("/api/models/download", local)(w, httptest.NewRequest("POST", "/api/models/download", nil))
if w.Body.String() != "go" {
t.Fatalf("relayé sans la variable : %q", w.Body.String())
}
}
// Les .part orphelins ne sont plus vidés par ce Go quand la façade tient les
// modèles : elle les vide à son démarrage, et un Go relancé seul
// effacerait celui qu'elle mène.
func TestFacadeTelechargementsGardeLesPart(t *testing.T) {
home := testHome(t)
dir := filepath.Join(home, "models")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
part := filepath.Join(dir, "m.gguf.part")
if err := os.WriteFile(part, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
t.Setenv(internalTokenEnv, jetonTest)
t.Setenv(facadeAddrEnv, "127.0.0.1:1")
t.Setenv(facadeModelsEnv, "rust")
cleanStartupPartFiles()
if _, err := os.Stat(part); err != nil {
t.Fatalf(".part effacé alors que la façade tient les téléchargements : %v", err)
}
t.Setenv(facadeModelsEnv, "")
cleanStartupPartFiles()
if _, err := os.Stat(part); !os.IsNotExist(err) {
t.Fatalf(".part orphelin gardé : %v", err)
}
}
// LOKI_DICTATE=rust : les routes de la dictée partent à la façade, et arrêter
// la dictée (libération de la VRAM, optimiseur) le lui demande au lieu de
// toucher au serveur de ce process. Sans la variable, rien ne change.
func TestFacadeDictee(t *testing.T) {
testHome(t)
f := demarrerFacade(t)
t.Setenv(facadeChatEnv, "")
t.Setenv(facadeDictateEnv, "rust")
local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") }
for p := range facadeDictateRoutes {
w := httptest.NewRecorder()
withFacadeChat(p, local)(w, httptest.NewRequest("POST", p, strings.NewReader("{}")))
if w.Body.String() != f.reply {
t.Fatalf("%s : réponse %q", p, w.Body.String())
}
}
asrShutdownVite()
got := f.recu()
if len(got) != len(facadeDictateRoutes)+1 || got[len(got)-1] != "POST /internal/facade/v1/dictate/stop jeton {}" {
t.Fatalf("reçu par la façade : %v", got)
}
t.Setenv(facadeDictateEnv, "")
w := httptest.NewRecorder()
withFacadeChat("/api/transcribe", local)(w, httptest.NewRequest("POST", "/api/transcribe", nil))
if w.Body.String() != "go" {
t.Fatalf("relayé sans la variable : %q", w.Body.String())
}
asrShutdownVite()
if n := len(f.recu()); n != len(facadeDictateRoutes)+1 {
t.Fatalf("arrêt demandé à la façade sans la variable (%d appels)", n)
}
}
// LOKI_SETTINGS=rust : les réglages partagés partent à la façade ; sans la
// variable, ils restent ici.
func TestFacadeReglages(t *testing.T) {
testHome(t)
f := demarrerFacade(t)
t.Setenv(facadeChatEnv, "")
t.Setenv(facadeSettingsEnv, "rust")
local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") }
for p := range facadeSettingsRoutes {
w := httptest.NewRecorder()
withFacadeChat(p, local)(w, httptest.NewRequest("GET", p, nil))
if w.Body.String() != f.reply {
t.Fatalf("%s : réponse %q", p, w.Body.String())
}
}
if n := len(f.recu()); n != len(facadeSettingsRoutes) {
t.Fatalf("%d appels reçus par la façade", n)
}
t.Setenv(facadeSettingsEnv, "")
w := httptest.NewRecorder()
withFacadeChat("/api/prefs", local)(w, httptest.NewRequest("GET", "/api/prefs", nil))
if w.Body.String() != "go" {
t.Fatalf("relayé sans la variable : %q", w.Body.String())
}
}
// LOKI_FILES=rust : les routes des fichiers de la discussion partent à la
// façade ; /internal/v1/conv/workspace lui donne le dossier de la discussion
// active ; les .part d'envois ne sont plus vidés ici au démarrage.
func TestFacadeFichiers(t *testing.T) {
home := testHome(t)
f := demarrerFacade(t)
t.Setenv(facadeChatEnv, "")
t.Setenv(facadeFilesEnv, "rust")
local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") }
for p := range facadeFilesRoutes {
w := httptest.NewRecorder()
withFacadeChat(p, local)(w, httptest.NewRequest("GET", p, nil))
if w.Body.String() != f.reply {
t.Fatalf("%s : réponse %q", p, w.Body.String())
}
}
prev := agentWorkspace()
workspacePath = filepath.Join(home, "ws")
t.Cleanup(func() { workspacePath = prev })
convActiveRemember("c-test")
w := httptest.NewRecorder()
handleInternalConvWorkspace(w, httptest.NewRequest("POST", "/internal/v1/conv/workspace", strings.NewReader("{}")))
var ws map[string]string
if err := json.Unmarshal(w.Body.Bytes(), &ws); err != nil {
t.Fatal(err)
}
if ws["dir"] != filepath.Join(home, "ws", "discussions", "c-test") || ws["root"] != filepath.Join(home, "ws") || ws["id"] != "c-test" {
t.Fatalf("dossier de la discussion : %v", ws)
}
if st, err := os.Stat(ws["dir"]); err != nil || !st.IsDir() {
t.Fatalf("dossier non créé : %v", err)
}
part := filepath.Join(ws["dir"], "uploads", ".upload-1.part")
_ = os.MkdirAll(filepath.Dir(part), 0o755)
_ = os.WriteFile(part, []byte("x"), 0o644)
cleanStartupUploadParts()
if _, err := os.Stat(part); err != nil {
t.Fatalf(".part effacé alors que la façade tient les fichiers : %v", err)
}
t.Setenv(facadeFilesEnv, "")
cleanStartupUploadParts()
if _, err := os.Stat(part); !os.IsNotExist(err) {
t.Fatalf(".part gardé sans la variable : %v", err)
}
}
// LOKI_PUSH=rust : les routes des notifications partent à la façade, et les
// envois de ce process (fin de tour, fin de tâche) lui sont délégués.
func TestFacadeNotifications(t *testing.T) {
testHome(t)
f := demarrerFacade(t)
t.Setenv(facadeChatEnv, "")
t.Setenv(facadePushEnv, "rust")
local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") }
for p := range facadePushRoutes {
w := httptest.NewRecorder()
withFacadeChat(p, local)(w, httptest.NewRequest("POST", p, strings.NewReader("{}")))
if w.Body.String() != f.reply {
t.Fatalf("%s : réponse %q", p, w.Body.String())
}
}
sendPushToAll("Loki", "Réponse prête · 3s")
got := f.recu()
if len(got) != len(facadePushRoutes)+1 || got[len(got)-1] != `POST /internal/facade/v1/push/send jeton {"body":"Réponse prête · 3s","title":"Loki"}` {
t.Fatalf("reçu par la façade : %v", got)
}
t.Setenv(facadePushEnv, "")
sendPushToAll("Loki", "x") // aucun abonné : rien à envoyer, rien de délégué
if n := len(f.recu()); n != len(facadePushRoutes)+1 {
t.Fatalf("envoi délégué sans la variable (%d appels)", n)
}
}
func TestFacadeMemoire(t *testing.T) {
testHome(t)
f := demarrerFacade(t)
t.Setenv(facadeChatEnv, "")
local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") }
// Sans la variable : le Go sert lui-même.
for p := range facadeMemRoutes {
w := httptest.NewRecorder()
withFacadeChat(p, local)(w, httptest.NewRequest("GET", p, nil))
if w.Body.String() != "go" {
t.Fatalf("%s servi ailleurs sans LOKI_MEM : %q", p, w.Body.String())
}
}
t.Setenv(facadeMemEnv, "rust")
for p := range facadeMemRoutes {
w := httptest.NewRecorder()
withFacadeChat(p, local)(w, httptest.NewRequest("POST", p, strings.NewReader("{}")))
if w.Body.String() != f.reply {
t.Fatalf("%s : réponse %q", p, w.Body.String())
}
}
if n := len(f.recu()); n != len(facadeMemRoutes) {
t.Fatalf("reçu par la façade : %d appels", n)
}
// Les routes voisines (coffre, projets) restent au Go.
for _, p := range []string{"/api/mem/health", "/api/mem/unlock", "/api/projects", "/api/backup/export"} {
w := httptest.NewRecorder()
withFacadeChat(p, local)(w, httptest.NewRequest("GET", p, nil))
if w.Body.String() != "go" {
t.Fatalf("%s remis à la façade : %q", p, w.Body.String())
}
}
}
func TestFacadePresets(t *testing.T) {
testHome(t)
f := demarrerFacade(t)
t.Setenv(facadeChatEnv, "")
local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") }
for p := range facadePresetsRoutes {
w := httptest.NewRecorder()
withFacadeChat(p, local)(w, httptest.NewRequest("GET", p, nil))
if w.Body.String() != "go" {
t.Fatalf("%s servi ailleurs sans LOKI_PRESETS : %q", p, w.Body.String())
}
}
t.Setenv(facadePresetsEnv, "rust")
for p := range facadePresetsRoutes {
w := httptest.NewRecorder()
withFacadeChat(p, local)(w, httptest.NewRequest("POST", p, strings.NewReader("{}")))
if w.Body.String() != f.reply {
t.Fatalf("%s : réponse %q", p, w.Body.String())
}
}
if n := len(f.recu()); n != len(facadePresetsRoutes) {
t.Fatalf("reçu par la façade : %d appels", n)
}
}
// Le pilotage du moteur (LOKI_ENGINE=rust, lot 16) part à la façade, et l'API
// interne lui rend ce qui reste ici : verrou de l'optimiseur avant un
// démarrage, génération en cours, arrêt de la dictée.
func TestFacadeMoteur(t *testing.T) {
testHome(t)
f := demarrerFacade(t)
t.Setenv(facadeChatEnv, "")
local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") }
for p := range facadeEngineRoutes {
w := httptest.NewRecorder()
withFacadeChat(p, local)(w, httptest.NewRequest("POST", p, nil))
if w.Body.String() != "go" {
t.Fatalf("%s servi ailleurs sans LOKI_ENGINE : %q", p, w.Body.String())
}
}
t.Setenv(facadeEngineEnv, "rust")
for p := range facadeEngineRoutes {
w := httptest.NewRecorder()
withFacadeChat(p, local)(w, httptest.NewRequest("POST", p, strings.NewReader("{}")))
if w.Body.String() != f.reply {
t.Fatalf("%s : réponse %q", p, w.Body.String())
}
}
if n := len(f.recu()); n != len(facadeEngineRoutes) {
t.Fatalf("reçu par la façade : %d appels", n)
}
// Avant un démarrage : le verrou, puis le ménage.
appels := 0
old := enginePrestart
t.Cleanup(func() { enginePrestart = old })
enginePrestart = func() error {
appels++
if appels == 2 {
return errTuneBusy
}
return nil
}
for i, want := range []string{`{"error":""}`, `{"error":"` + errTuneBusy.Error() + `"}`} {
w := httptest.NewRecorder()
handleInternalServicePrestart(w, httptest.NewRequest("POST", "/internal/v1/service/prestart", nil))
if got := strings.TrimSpace(w.Body.String()); w.Code != 200 || got != want {
t.Fatalf("prestart %d : %d %s", i, w.Code, got)
}
}
w := httptest.NewRecorder()
handleInternalServicePrestart(w, httptest.NewRequest("GET", "/internal/v1/service/prestart", nil))
if w.Code != 405 || appels != 2 {
t.Fatalf("prestart en GET : %d (%d appels)", w.Code, appels)
}
// La génération en cours de la conversation tenue ici.
for _, gen := range []bool{false, true} {
conv.mu.Lock()
conv.Generating = gen
conv.mu.Unlock()
w := httptest.NewRecorder()
handleInternalChatGenerating(w, httptest.NewRequest("GET", "/internal/v1/chat/generating", nil))
var out struct{ Generating bool }
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil || out.Generating != gen {
t.Fatalf("generating=%v : %s", gen, w.Body.String())
}
}
conv.mu.Lock()
conv.Generating = false
conv.mu.Unlock()
// L'arrêt de la dictée (rien ne tourne : immédiat).
w = httptest.NewRecorder()
handleInternalDictateStop(w, httptest.NewRequest("POST", "/internal/v1/dictate/stop", nil))
if w.Code != 204 {
t.Fatalf("arrêt de la dictée : %d", w.Code)
}
}
// Une clé de complétion d'un ou deux octets ne fait plus tomber /api/apikey.
func TestMaskAPIKeyCourte(t *testing.T) {
for k, want := range map[string]string{
"": "",
"k": "…",
"ab": "…",
"abc": "…bc",
"abcdefghijklm": "…lm",
"sk-loki-0123456789abcdef": "sk-loki-0…cdef",
} {
if got := maskAPIKey(k); got != want {
t.Errorf("maskAPIKey(%q) = %q, attendu %q", k, got, want)
}
}
}
// Les interrupteurs système (LOKI_SYSTEM=rust, lot 17) partent à la façade ;
// l'API interne lui rend ce qui vit ici : cache de joignabilité des outils
// web, navigateur piloté, redémarrage du moteur.
func TestFacadeSysteme(t *testing.T) {
testHome(t)
t.Setenv("LOKI_CONTAINER", "1")
f := demarrerFacade(t)
t.Setenv(facadeChatEnv, "")
local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") }
for p := range facadeSystemRoutes {
w := httptest.NewRecorder()
withFacadeChat(p, local)(w, httptest.NewRequest("GET", p, nil))
if w.Body.String() != "go" {
t.Fatalf("%s servi ailleurs sans LOKI_SYSTEM : %q", p, w.Body.String())
}
}
t.Setenv(facadeSystemEnv, "rust")
for p := range facadeSystemRoutes {
w := httptest.NewRecorder()
withFacadeChat(p, local)(w, httptest.NewRequest("POST", p, strings.NewReader("{}")))
if w.Body.String() != f.reply {
t.Fatalf("%s : réponse %q", p, w.Body.String())
}
}
if n := len(f.recu()); n != len(facadeSystemRoutes) {
t.Fatalf("reçu par la façade : %d appels", n)
}
// Cache de joignabilité vidé.
reachMu.Lock()
reachURL = "http://ancien"
reachMu.Unlock()
w := httptest.NewRecorder()
handleInternalReachReset(w, httptest.NewRequest("POST", "/internal/v1/web/reach/reset", nil))
reachMu.Lock()
vide := reachURL == ""
reachMu.Unlock()
if w.Code != 204 || !vide {
t.Fatalf("reach/reset : %d, cache vidé : %v", w.Code, vide)
}
w = httptest.NewRecorder()
handleInternalCDPShutdown(w, httptest.NewRequest("POST", "/internal/v1/cdp/shutdown", nil))
if w.Code != 204 {
t.Fatalf("cdp/shutdown : %d", w.Code)
}
// Redémarrage : seulement si le moteur tourne quand if_active est posé.
var actions []string
old := internalServiceAction
t.Cleanup(func() { internalServiceAction = old })
internalServiceAction = func(a string) error {
actions = append(actions, a)
if a == "start" {
return errTuneBusy
}
return nil
}
for _, c := range []struct {
body, want string
code int
}{
{`{"action":"restart","if_active":true}`, `{"error":"","ran":false}`, 200},
{`{"action":"restart"}`, `{"error":"","ran":true}`, 200},
{`{"action":"start"}`, `{"error":"` + errTuneBusy.Error() + `","ran":true}`, 200},
{`{"action":"reload"}`, `{"error":"action inconnue : reload"}`, 400},
{`{"action":`, `{"error":"unexpected EOF"}`, 400},
} {
w := httptest.NewRecorder()
handleInternalServiceAction(w, httptest.NewRequest("POST", "/internal/v1/service/action", strings.NewReader(c.body)))
if got := strings.TrimSpace(w.Body.String()); w.Code != c.code || got != c.want {
t.Fatalf("%s : %d %s", c.body, w.Code, got)
}
}
if strings.Join(actions, ",") != "restart,start" {
t.Fatalf("actions : %v", actions)
}
}
// Le relais vers la façade garde l'hôte et le schéma du client : la clé de
// complétion y calcule l'adresse à coller dans un client tiers.
func TestFacadeRelaisGardeHote(t *testing.T) {
var host, proto string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
host, proto = r.Host, r.Header.Get("X-Forwarded-Proto")
}))
defer srv.Close()
t.Setenv(internalTokenEnv, jetonTest)
t.Setenv(facadeAddrEnv, strings.TrimPrefix(srv.URL, "http://"))
r := httptest.NewRequest("GET", "/api/apikey", nil)
r.Host = "loki.exemple.fr"
r.Header.Set("X-Forwarded-Proto", "https")
facadeRelayAs(httptest.NewRecorder(), r, "")
if host != "loki.exemple.fr" || proto != "https" {
t.Fatalf("hôte %q, schéma %q", host, proto)
}
}
// Le hub des projets (LOKI_PROJECTS=rust, lot 18) part à la façade ; quand le
// Go tient la conversation, la bascule et les discussions d'un projet
// supprimé restent ici (API interne).
func TestFacadeProjets(t *testing.T) {
testHome(t)
// Le projet actif est gardé en RAM d'un test à l'autre.
activeProjMu.Lock()
activeProjCache = ""
activeProjMu.Unlock()
t.Cleanup(func() {
activeProjMu.Lock()
activeProjCache = ""
activeProjMu.Unlock()
})
ensureDefaultProject()
f := demarrerFacade(t)
t.Setenv(facadeChatEnv, "")
local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") }
w := httptest.NewRecorder()
withFacadeChat("/api/projects", local)(w, httptest.NewRequest("GET", "/api/projects", nil))
if w.Body.String() != "go" {
t.Fatalf("servi ailleurs sans LOKI_PROJECTS : %q", w.Body.String())
}
t.Setenv(facadeProjectsEnv, "rust")
w = httptest.NewRecorder()
withFacadeChat("/api/projects", local)(w, httptest.NewRequest("POST", "/api/projects", strings.NewReader("{}")))
if w.Body.String() != f.reply || len(f.recu()) != 1 {
t.Fatalf("relais : %q, %d appels", w.Body.String(), len(f.recu()))
}
if _, err := createProject("Alpha"); err != nil {
t.Fatal(err)
}
convIndexSave([]convMeta{{ID: "a1", Project: "alpha", Updated: 1}, {ID: "g1", Project: "generale", Updated: 2}})
_ = putBytes(bkChat, convKey("a1"), []byte(`{"id":"a1"}`))
_ = putBytes(bkChat, convKey("g1"), []byte(`{"id":"g1"}`))
post := func(h http.HandlerFunc, body string) (int, string) {
w := httptest.NewRecorder()
h(w, httptest.NewRequest("POST", "/internal/v1/project/x", strings.NewReader(body)))
return w.Code, strings.TrimSpace(w.Body.String())
}
if c, b := post(handleInternalProjectSwitch, `{"slug":"nope"}`); c != 400 || b != `{"error":"projet introuvable"}` {
t.Fatalf("bascule inconnue : %d %s", c, b)
}
if c, _ := post(handleInternalProjectSwitch, `{"slug":" alpha "}`); c != 204 || activeProjectSlug() != "alpha" {
t.Fatalf("bascule : %d, actif %q", c, activeProjectSlug())
}
conv.mu.Lock()
conv.Generating = true
conv.mu.Unlock()
c1, _ := post(handleInternalProjectSwitch, `{"slug":"generale"}`)
c2, _ := post(handleInternalProjectDropConvs, `{"slug":"alpha"}`)
conv.mu.Lock()
conv.Generating = false
conv.mu.Unlock()
if c1 != 409 || c2 != 409 {
t.Fatalf("pendant une génération : %d, %d", c1, c2)
}
if c, b := post(handleInternalProjectDropConvs, `{"slug":"alpha"}`); c != 200 || b != `{"dropped":1}` {
t.Fatalf("discussions supprimées : %d %s", c, b)
}
if n := len(convIndexForProject("alpha")); n != 0 || len(convIndexForProject("generale")) == 0 {
t.Fatalf("index après : %d d'Alpha", n)
}
if c, _ := post(handleInternalProjectDropConvs, `nimp`); c != 400 {
t.Fatalf("corps illisible : %d", c)
}
}
// Les serveurs MCP (LOKI_MCP=rust, lot 20) : routes relayées, et le Go
// demande à la façade les outils MCP et leurs appels (un seul pool).
func TestFacadeMCP(t *testing.T) {
testHome(t)
var calls []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get(internalTokenHeader) != jetonTest {
w.WriteHeader(403)
return
}
b, _ := io.ReadAll(r.Body)
calls = append(calls, r.URL.Path+" "+string(b))
switch r.URL.Path {
case "/internal/facade/v1/mcp/tools":
io.WriteString(w, `{"tools":[{"type":"function","function":{"name":"mcp__a__t","description":"[MCP: a] d","parameters":{"type":"object"}}}]}`)
case "/internal/facade/v1/mcp/call":
io.WriteString(w, `{"result":"fait"}`)
case "/internal/facade/v1/mcp/prewarm":
io.WriteString(w, `{"ok":true}`)
default:
io.WriteString(w, `{"ok":true,"de":"facade"}`)
}
}))
defer srv.Close()
t.Setenv(internalTokenEnv, jetonTest)
t.Setenv(facadeAddrEnv, strings.TrimPrefix(srv.URL, "http://"))
local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") }
t.Setenv(facadeMCPEnv, "")
w := httptest.NewRecorder()
withFacadeChat("/api/mcp", local)(w, httptest.NewRequest("GET", "/api/mcp", nil))
if w.Body.String() != "go" || len(calls) != 0 {
t.Fatalf("sans LOKI_MCP : %q", w.Body.String())
}
t.Setenv(facadeMCPEnv, "rust")
for p := range facadeMCPRoutes {
w := httptest.NewRecorder()
withFacadeChat(p, local)(w, httptest.NewRequest("POST", p, strings.NewReader("{}")))
if !strings.Contains(w.Body.String(), "facade") {
t.Fatalf("%s non relayé : %q", p, w.Body.String())
}
}
tools := mcpTools()
if len(tools) != 1 || tools[0].Function.Name != "mcp__a__t" || mcpPromptLine(tools) == "" {
t.Fatalf("outils : %+v", tools)
}
if got := mcpCall("mcp__a__t", map[string]any{"x": 1}); got != "fait" {
t.Fatalf("appel : %q", got)
}
if got := mcpCall("mcp__a__t", nil); got != "fait" || !strings.HasSuffix(calls[len(calls)-1], `{"args":{},"name":"mcp__a__t"}`) {
t.Fatalf("appel sans arguments : %q (%v)", got, calls)
}
MCPPrewarm()
for i := 0; i < 50 && !strings.HasPrefix(calls[len(calls)-1], "/internal/facade/v1/mcp/prewarm"); i++ {
time.Sleep(20 * time.Millisecond)
}
if !strings.HasPrefix(calls[len(calls)-1], "/internal/facade/v1/mcp/prewarm") {
t.Fatalf("pré-chauffage non demandé : %v", calls)
}
// Façade injoignable : une erreur lisible, pas de panique.
t.Setenv(facadeAddrEnv, "127.0.0.1:1")
if got := mcpCall("mcp__a__t", nil); !strings.HasPrefix(got, "[erreur MCP] façade injoignable") {
t.Fatalf("façade injoignable : %q", got)
}
if tools := mcpTools(); tools != nil {
t.Fatalf("outils sans façade : %v", tools)
}
}
// La mise à jour (LOKI_UPDATE=rust, lot 21) part à la façade.
func TestFacadeMiseAJour(t *testing.T) {
testHome(t)
f := demarrerFacade(t)
t.Setenv(facadeChatEnv, "")
local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") }
for _, on := range []string{"", "rust"} {
t.Setenv(facadeUpdateEnv, on)
for p := range facadeUpdateRoutes {
w := httptest.NewRecorder()
withFacadeChat(p, local)(w, httptest.NewRequest("GET", p, nil))
want := "go"
if on == "rust" {
want = f.reply
}
if w.Body.String() != want {
t.Fatalf("%s (LOKI_UPDATE=%q) : %q", p, on, w.Body.String())
}
}
}
}
// Lot 22 : les routes des backends partent à la façade, et les jobs du Go
// (compilation, moteur de l'image, Strata) lui rapportent chaque étape — le
// job vit chez elle.
func TestFacadeBackends(t *testing.T) {
testHome(t)
resetBackendsState()
t.Cleanup(resetBackendsState)
var mu sync.Mutex
var calls []string
busy := false
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get(internalTokenHeader) != jetonTest {
w.WriteHeader(403)
return
}
b, _ := io.ReadAll(r.Body)
mu.Lock()
calls = append(calls, r.Method+" "+r.URL.RequestURI()+" "+string(b))
refuse := busy
mu.Unlock()
switch r.URL.Path {
case "/internal/facade/v1/build-job/start":
if refuse {
w.WriteHeader(409)
io.WriteString(w, `{"ok":false,"error":"un job engine est déjà en cours"}`)
return
}
io.WriteString(w, `{"ok":true}`)
case "/internal/facade/v1/build-job/snapshot":
io.WriteString(w, `{"exists":true,"action":"engine","running":true}`)
default:
io.WriteString(w, `{"ok":true,"de":"facade"}`)
}
}))
defer srv.Close()
t.Setenv(internalTokenEnv, jetonTest)
t.Setenv(facadeAddrEnv, strings.TrimPrefix(srv.URL, "http://"))
local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") }
t.Setenv(facadeBackendsEnv, "")
w := httptest.NewRecorder()
withFacadeChat("/api/llamacpp", local)(w, httptest.NewRequest("GET", "/api/llamacpp", nil))
if w.Body.String() != "go" || len(calls) != 0 {
t.Fatalf("sans LOKI_BACKENDS : %q", w.Body.String())
}
t.Setenv(facadeBackendsEnv, "rust")
for p := range facadeBackendsRoutes {
w := httptest.NewRecorder()
withFacadeChat(p, local)(w, httptest.NewRequest("POST", p, strings.NewReader("{}")))
if !strings.Contains(w.Body.String(), "facade") {
t.Fatalf("%s non relayé : %q", p, w.Body.String())
}
}
// La compilation reste au Go.
for _, p := range []string{"/api/llamacpp/install", "/api/llamacpp/update", "/api/llamacpp/install-custom"} {
if facadeBackendsRoutes[p] {
t.Fatalf("%s ne doit pas partir à la façade", p)
}
}
mu.Lock()
calls = nil
mu.Unlock()
// Un job du Go : chaque étape part à la façade, dans l'ordre.
done := make(chan struct{})
if err := startLcJob("engine", func() {
lcPhase("recherche…")
emitBuildLine("[ 10%] Building CXX object a/b.cpp.o")
lcSetCommit("old", "")
lcSetCommit("new", "bbb")
lcFail(errors.New("raté"))
lcDone("fini")
close(done)
}); err != nil {
t.Fatal(err)
}
<-done
want := []string{
`POST /internal/facade/v1/build-job/start {"action":"engine"}`,
`POST /internal/facade/v1/build-job/phase {"phase":"recherche…"}`,
`POST /internal/facade/v1/build-job/line {"line":"[ 10%] Building CXX object a/b.cpp.o"}`,
`POST /internal/facade/v1/build-job/commits {"old":""}`,
`POST /internal/facade/v1/build-job/commits {"new":"bbb"}`,
`POST /internal/facade/v1/build-job/fail {"error":"raté"}`,
`POST /internal/facade/v1/build-job/done {"msg":"fini"}`,
`POST /internal/facade/v1/build-job/end {}`,
}
for i := 0; i < 100; i++ {
mu.Lock()
n := len(calls)
mu.Unlock()
if n >= len(want) {
break
}
time.Sleep(10 * time.Millisecond)
}
mu.Lock()
got := append([]string(nil), calls...)
calls = nil
busy = true
mu.Unlock()
if strings.Join(got, "\n") != strings.Join(want, "\n") {
t.Fatalf("étapes transmises :\n%s", strings.Join(got, "\n"))
}
// Rien ne reste dans le job du Go, et le sink est retiré.
if lcCur != nil {
t.Fatalf("job tenu par le Go : %+v", lcCur)
}
// Un second job : la façade le refuse, le Go le dit.
if err := startLcJob("strata", func() { t.Error("ne doit pas tourner") }); err == nil || err.Error() != "un job engine est déjà en cours" {
t.Fatalf("refus : %v", err)
}
// La vue du job (statut du moteur de l'image) vient de la façade.
if v := lcJobSnapshot(0, false); v["action"] != "engine" || v["running"] != true {
t.Fatalf("vue : %v", v)
}
mu.Lock()
last := calls[len(calls)-1]
mu.Unlock()
if last != "GET /internal/facade/v1/build-job/snapshot?from=0&lines=0 " {
t.Fatalf("vue demandée : %q", last)
}
if v := lcJobSnapshot(3, true); v["exists"] != true {
t.Fatalf("vue avec lignes : %v", v)
}
// Façade injoignable : le job ne démarre pas, la vue dit « aucun job ».
t.Setenv(facadeAddrEnv, "127.0.0.1:1")
if err := startLcJob("engine", func() { t.Error("ne doit pas tourner") }); err == nil {
t.Fatal("job démarré sans façade")
}
if v := lcJobSnapshot(0, true); v["exists"] != false {
t.Fatalf("vue sans façade : %v", v)
}
lcAppend("perdue") // tracée, sans panique
}
// Lot 23 : quand la façade tient la conversation, c'est elle qui sonde le
// gabarit ; le résumé des performances relit son verdict.
func TestFacadeSondeDeGabarit(t *testing.T) {
testHome(t)
freshTplProbe(t)
oublierDecisionFacade(t)
verdict := `{"ok":true,"result":{"preserves_history":"no","prefix_stable":"no","renders_reasoning":"yes","model":"m1.gguf","tools":2,"at":"2026-10-15T20:00:00.5Z"}}`
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get(internalTokenHeader) != jetonTest || r.URL.Path != "/internal/facade/v1/chat/tpl" {
w.WriteHeader(404)
return
}
io.WriteString(w, verdict)
}))
defer srv.Close()
t.Setenv(internalTokenEnv, jetonTest)
t.Setenv(facadeAddrEnv, strings.TrimPrefix(srv.URL, "http://"))
t.Setenv(facadeChatEnv, "rust")
r, ok := tplCapsCurrent()
if !ok || r.PrefixStable != tplNo || r.Model != "m1.gguf" || r.Tools != 2 || r.At.IsZero() {
t.Fatalf("verdict de la façade : %+v %v", r, ok)
}
rec := httptest.NewRecorder()
handlePerfSummary(rec, httptest.NewRequest(http.MethodGet, "/api/perf/summary", nil))
if !strings.Contains(rec.Body.String(), `"prefix_stable":"no"`) {
t.Fatalf("résumé : %s", rec.Body.String())
}
// Aucun verdict encore, ou façade injoignable : rien.
verdict = `{"ok":false}`
if _, ok := tplCapsCurrent(); ok {
t.Fatal("verdict inventé")
}
t.Setenv(facadeAddrEnv, "127.0.0.1:1")
if _, ok := tplCapsCurrent(); ok {
t.Fatal("verdict sans façade")
}
}