mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-16 19:59:56 +02:00
La façade (rust/loki-rs/src/backends) sert le statut du moteur llama.cpp (dépôt git, binaire, moteur de l'image, précompilé, moteur téléchargé, plan de build et conseil), la vérification git, la dernière release et l'installation des binaires précompilés (extraction avec liens, VERSION, ménage), la bascule fast/opt, les backends custom et leur désinstallation, et les GPU vus par un moteur (caches mémoire et disque). Elle détient LE job des moteurs (state/build_job + loki-build.log, repris au démarrage). Les jobs que le Go fait encore tourner (compilation depuis les sources, moteur de l'image, Strata) lui rapportent chaque étape par /internal/facade/v1/build-job/* ; le Go garde le garde de l'optimiseur et ne restaure plus de job quand la façade tient le lot. Correction du Go : prebuiltVersionFormat lisait le format comme 3e champ du marqueur VERSION ; hors CUDA Windows (« b300 fmt2 ») il n'y en a que deux, et chaque vérification retéléchargeait la release au lieu de dire « déjà à jour ». Le port suit. Parité : parity_backends_test.go (Linux) → tests/parity_backends, rejoué par tests/backends.rs (fausses machines dans le PATH, faux GitHub, dépôt git reproductible) ; TestFacadeBackends pour les étapes transmises. Mutations : 55 tuées sur 57, deux inobservables sous Linux (cudart Windows, garde d'un avertissement). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
1067 lines
38 KiB
Go
1067 lines
38 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": "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
|
|
}
|