From 0ecef2a9bd786f0b2a72bdd9a9a0ec7a154f87e3 Mon Sep 17 00:00:00 2001 From: Raphael Westphal Date: Tue, 18 Aug 2026 14:56:08 +0200 Subject: [PATCH] =?UTF-8?q?test:=20=E2=89=A595%=20coverage=20=E2=80=94=20d?= =?UTF-8?q?aemon=2096.9%=20(go),=20web=2095.5-99%=20(vitest=20142=20tests)?= =?UTF-8?q?;=20store.ts=20hooks-order=20crash=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 + daemon/api_extra_test.go | 383 +++++++++ daemon/docker_test.go | 351 +++++++++ daemon/gitlab_extra_test.go | 133 ++++ daemon/hub_extra_test.go | 493 ++++++++++++ daemon/main.go | 41 +- daemon/main_test.go | 121 +++ daemon/spawner_test.go | 787 +++++++++++++++++++ daemon/store_extra_test.go | 102 +++ e2e/scenarios/auth.mjs | 74 +- web/package-lock.json | 1364 ++++++++++++++++++++++++++++++++- web/package.json | 12 +- web/src/App.test.tsx | 155 ++++ web/src/ChatStream.test.tsx | 179 +++++ web/src/ChatView.test.tsx | 297 +++++++ web/src/SessionsView.test.tsx | 151 ++++ web/src/SettingsGate.test.tsx | 87 +++ web/src/SpawnView.test.tsx | 339 ++++++++ web/src/TaskPanel.test.tsx | 71 ++ web/src/api.test.ts | 73 ++ web/src/derive.test.ts | 422 ++++++++++ web/src/main.test.tsx | 41 + web/src/protocol.test.ts | 41 + web/src/settings.test.ts | 60 ++ web/src/store.test.tsx | 168 ++++ web/src/store.ts | 6 +- web/src/test/setup.ts | 156 ++++ web/src/ws.test.ts | 207 +++++ web/tsconfig.json | 2 +- web/vite.config.ts | 41 +- 30 files changed, 6305 insertions(+), 54 deletions(-) create mode 100644 daemon/api_extra_test.go create mode 100644 daemon/docker_test.go create mode 100644 daemon/gitlab_extra_test.go create mode 100644 daemon/hub_extra_test.go create mode 100644 daemon/main_test.go create mode 100644 daemon/spawner_test.go create mode 100644 daemon/store_extra_test.go create mode 100644 web/src/App.test.tsx create mode 100644 web/src/ChatStream.test.tsx create mode 100644 web/src/ChatView.test.tsx create mode 100644 web/src/SessionsView.test.tsx create mode 100644 web/src/SettingsGate.test.tsx create mode 100644 web/src/SpawnView.test.tsx create mode 100644 web/src/TaskPanel.test.tsx create mode 100644 web/src/api.test.ts create mode 100644 web/src/derive.test.ts create mode 100644 web/src/main.test.tsx create mode 100644 web/src/protocol.test.ts create mode 100644 web/src/settings.test.ts create mode 100644 web/src/store.test.tsx create mode 100644 web/src/test/setup.ts create mode 100644 web/src/ws.test.ts diff --git a/.gitignore b/.gitignore index a0b04f6..3cc740c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,6 @@ dist *.db .pi/ daemon/.pi-scratch/ +coverage daemon/lvmh-daemon +e2e-integration-report.md diff --git a/daemon/api_extra_test.go b/daemon/api_extra_test.go new file mode 100644 index 0000000..7a2a0a5 --- /dev/null +++ b/daemon/api_extra_test.go @@ -0,0 +1,383 @@ +package main + +// api_extra_test.go — spawn/status/container REST, gitlab handlers, webdist +// serving, body/token validation edges. + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +// newSpawnAPIServer builds a full Server (spawner → fake docker, gitlab → +// fake upstream) for REST tests that need the spawn pipeline. +func newSpawnAPIServer(t *testing.T) (*httptest.Server, *fakeDocker) { + t.Helper() + useFakeGit(t, fakeGitModeOK) + f := newFakeDocker() + ts := f.server(t) + t.Setenv("DOCKER_HOST", "tcp://"+ts.Listener.Addr().String()) + buildCtx := t.TempDir() + dockerfile := filepath.Join(buildCtx, "docker", "worker.Dockerfile") + if err := os.MkdirAll(filepath.Dir(dockerfile), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(dockerfile, []byte("FROM scratch\n"), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + t.Setenv(envWorkerDockerfile, dockerfile) + t.Setenv(envWorkerContext, buildCtx) + t.Setenv(envRepoDir, t.TempDir()) + + daemonToken = testToken + store := openTestStore(t) + hub := NewHub(store) + sp, err := NewSpawner(store, hub, "https://gitlab.example") + if err != nil { + t.Fatalf("NewSpawner: %v", err) + } + gl := NewGitLab(store, "https://gitlab.example") + srv := NewServer(store, hub, sp, gl) + api := httptest.NewServer(srv.Routes("")) + t.Cleanup(api.Close) + return api, f +} + +func apiReq(t *testing.T, method, url, token, body string) (int, string) { + t.Helper() + var rdr io.Reader + if body != "" { + rdr = strings.NewReader(body) + } + req, err := http.NewRequest(method, url, rdr) + if err != nil { + t.Fatalf("new req: %v", err) + } + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("%s %s: %v", method, url, err) + } + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + return resp.StatusCode, string(b) +} + +func TestAPISpawnStatusAndContainerLifecycle(t *testing.T) { + ts, _ := newSpawnAPIServer(t) + auth := testToken + + code, body := apiReq(t, http.MethodPost, ts.URL+"/api/spawn", auth, `{"repo":"group/project","branch":"main"}`) + if code != http.StatusCreated { + t.Fatalf("spawn = %d %s", code, body) + } + var res SpawnResult + if err := json.Unmarshal([]byte(body), &res); err != nil { + t.Fatalf("decode spawn result: %v", err) + } + if res.SessionID == "" || res.ContainerID != "" { + t.Fatalf("spawn result = %+v", res) + } + + // async job reaches running; status endpoint lists it + deadlineHit := false + for i := 0; i < 200; i++ { + code, body := apiReq(t, http.MethodGet, ts.URL+"/api/spawn/status", auth, "") + if code != http.StatusOK { + t.Fatalf("status = %d", code) + } + var jobs []SpawnJob + if err := json.Unmarshal([]byte(body), &jobs); err != nil { + t.Fatalf("decode jobs: %v", err) + } + done := false + for _, j := range jobs { + if j.SessionID == res.SessionID && j.State == stateRunning { + done = true + } + } + if done { + deadlineHit = true + break + } + } + if !deadlineHit { + t.Fatal("job never reached running") + } + + // delete container: happy path then 404 after row deleted + code, _ = apiReq(t, http.MethodDelete, ts.URL+"/api/sessions/"+res.SessionID+"/container", auth, "") + if code != http.StatusOK { + t.Fatalf("delete container = %d", code) + } + code, body = apiReq(t, http.MethodDelete, ts.URL+"/api/sessions/"+res.SessionID+"/container", auth, "") + if code != http.StatusNotFound { + t.Fatalf("second delete = %d %s, want 404", code, body) + } + + // docker stop failure → 500 covered in TestAPIDeleteContainerStopFailure +} + +func TestAPISpawnFailures(t *testing.T) { + // docker unreachable from the start → POST /api/spawn surfaces 500 + dead := httptest.NewServer(http.NotFoundHandler()) + dead.Close() + useFakeGit(t, fakeGitModeOK) + t.Setenv("DOCKER_HOST", "tcp://"+dead.Listener.Addr().String()) + buildCtx := t.TempDir() + t.Setenv(envWorkerDockerfile, filepath.Join(buildCtx, "docker", "worker.Dockerfile")) + t.Setenv(envWorkerContext, buildCtx) + t.Setenv(envRepoDir, t.TempDir()) + + daemonToken = testToken + store := openTestStore(t) + hub := NewHub(store) + sp, err := NewSpawner(store, hub, "https://gitlab.example") + if err != nil { + t.Fatalf("NewSpawner: %v", err) + } + api := httptest.NewServer(NewServer(store, hub, sp, NewGitLab(store, "https://gitlab.example")).Routes("")) + t.Cleanup(api.Close) + + if code, body := apiReq(t, http.MethodPost, api.URL+"/api/spawn", testToken, `not json`); code != http.StatusBadRequest { + t.Fatalf("bad body = %d %s", code, body) + } + if code, body := apiReq(t, http.MethodPost, api.URL+"/api/spawn", testToken, `{"repo":"group/project"}`); code != http.StatusInternalServerError { + t.Fatalf("dead docker spawn = %d %s, want 500", code, body) + } +} + +func TestAPIDeleteContainerStopFailure(t *testing.T) { + ts, f := newSpawnAPIServer(t) + f.failStop = true + auth := testToken + if code, body := apiReq(t, http.MethodPost, ts.URL+"/api/spawn", auth, `{"repo":"group/project"}`); code != http.StatusCreated { + t.Fatalf("spawn = %d %s", code, body) + } + // wait for a running job, then expect the DELETE to 500 on stop failure + var sid string + for i := 0; i < 200; i++ { + _, body := apiReq(t, http.MethodGet, ts.URL+"/api/spawn/status", auth, "") + var jobs []SpawnJob + _ = json.Unmarshal([]byte(body), &jobs) + for _, j := range jobs { + if j.State == stateRunning { + sid = j.SessionID + } + } + if sid != "" { + break + } + } + if sid == "" { + t.Fatal("no running job") + } + if code, body := apiReq(t, http.MethodDelete, ts.URL+"/api/sessions/"+sid+"/container", auth, ""); code != http.StatusInternalServerError { + t.Fatalf("delete with stop failure = %d %s, want 500", code, body) + } +} + +// newGitLabAPIServer wires a Server whose GitLab points at a fake upstream. +func newGitLabAPIServer(t *testing.T, broken bool) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("/api/v4/user", func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Private-Token") != "pat-good" { + w.WriteHeader(http.StatusUnauthorized) + return + } + _, _ = io.WriteString(w, `{"username":"alice"}`) + }) + mux.HandleFunc("/api/v4/projects", func(w http.ResponseWriter, r *http.Request) { + if broken { + w.WriteHeader(http.StatusInternalServerError) + return + } + _, _ = io.WriteString(w, `[{"path_with_namespace":"g/p","name":"P","namespace":{"path":"g"}, + "last_activity_at":"2024-01-01T00:00:00Z","web_url":"https://gl/g/p","default_branch":"main"}]`) + }) + up := httptest.NewServer(mux) + t.Cleanup(up.Close) + + daemonToken = testToken + store := openTestStore(t) + hub := NewHub(store) + srv := NewServer(store, hub, nil, NewGitLab(store, up.URL)) + api := httptest.NewServer(srv.Routes("")) + t.Cleanup(api.Close) + return api +} + +func TestAPIGitLabHandlers(t *testing.T) { + ts := newGitLabAPIServer(t, false) + auth := testToken + + code, body := apiReq(t, http.MethodGet, ts.URL+"/api/gitlab/status", auth, "") + if code != http.StatusOK || !strings.Contains(body, `"connected":false`) { + t.Fatalf("status = %d %s", code, body) + } + // auth still required + if code, _ := apiReq(t, http.MethodGet, ts.URL+"/api/gitlab/status", "", ""); code != http.StatusUnauthorized { + t.Fatalf("unauth status = %d", code) + } + + if code, body := apiReq(t, http.MethodPost, ts.URL+"/api/gitlab/connect", auth, `not json`); code != http.StatusBadRequest { + t.Fatalf("connect bad body = %d %s", code, body) + } + if code, _ := apiReq(t, http.MethodPost, ts.URL+"/api/gitlab/connect", auth, `{"token":" "}`); code != http.StatusBadRequest { + t.Fatal("connect empty token must 400") + } + if code, _ := apiReq(t, http.MethodPost, ts.URL+"/api/gitlab/connect", auth, `{"token":"pat-bad"}`); code != http.StatusInternalServerError { + t.Fatal("connect invalid PAT must 500") + } + code, body = apiReq(t, http.MethodPost, ts.URL+"/api/gitlab/connect", auth, `{"token":"pat-good"}`) + if code != http.StatusOK || !strings.Contains(body, `"username":"alice"`) { + t.Fatalf("connect = %d %s", code, body) + } + + code, body = apiReq(t, http.MethodGet, ts.URL+"/api/gitlab/repos", auth, "") + if code != http.StatusOK || !strings.Contains(body, `"path":"g/p"`) { + t.Fatalf("repos = %d %s", code, body) + } + + if code, _ := apiReq(t, http.MethodDelete, ts.URL+"/api/gitlab/connect", auth, ""); code != http.StatusOK { + t.Fatalf("disconnect = %d", code) + } + if code, _ := apiReq(t, http.MethodGet, ts.URL+"/api/gitlab/repos", auth, ""); code != http.StatusConflict { + t.Fatalf("repos after disconnect = %d, want 409", code) + } + + // upstream 500 → 502 bad gateway + broken := newGitLabAPIServer(t, true) + apiReq(t, http.MethodPost, broken.URL+"/api/gitlab/connect", auth, `{"token":"pat-good"}`) + if code, _ := apiReq(t, http.MethodGet, broken.URL+"/api/gitlab/repos", auth, ""); code != http.StatusBadGateway { + t.Fatalf("repos with broken upstream = %d, want 502", code) + } +} + +func TestAPIPromptBodyValidation(t *testing.T) { + ts, _ := newTestServer(t) + auth := testToken + + if code, _ := apiReq(t, http.MethodPost, ts.URL+"/api/sessions/s1/prompt", auth, `not json`); code != http.StatusBadRequest { + t.Fatal("bad json must 400") + } + if code, _ := apiReq(t, http.MethodPost, ts.URL+"/api/sessions/s1/prompt", auth, `{"message":" "}`); code != http.StatusBadRequest { + t.Fatal("blank message must 400") + } + // bodies over 1 MiB are rejected by MaxBytesReader + big := `{"message":"` + strings.Repeat("x", 1<<20) + `"}` + if code, _ := apiReq(t, http.MethodPost, ts.URL+"/api/sessions/s1/prompt", auth, big); code != http.StatusBadRequest { + t.Fatal("oversized body must 400") + } +} + +func TestAPIEventsEdgeCases(t *testing.T) { + ts, store := newTestServer(t) + auth := testToken + + if code, _ := apiReq(t, http.MethodGet, ts.URL+"/api/sessions/s1/events?after=-1", auth, ""); code != http.StatusBadRequest { + t.Fatal("after=-1 must 400") + } + // limit clamped above the max: still 200 with all events + if code, _ := apiReq(t, http.MethodGet, ts.URL+"/api/sessions/s1/events?limit=99999999", auth, ""); code != http.StatusOK { + t.Fatal("huge limit must be clamped, not 400") + } + // non-object payload degrades to an envelope-only frame + if err := store.AppendEvent(Event{SessionID: "s1", Seq: 1, TS: 5, Type: evMessageEnd, Payload: []byte(`[1,2]`)}); err != nil { + t.Fatalf("append: %v", err) + } + _, body := apiReq(t, http.MethodGet, ts.URL+"/api/sessions/s1/events", auth, "") + var frames []map[string]any + if err := json.Unmarshal([]byte(body), &frames); err != nil { + t.Fatalf("decode: %v", err) + } + if len(frames) != 1 || frames[0]["message"] != nil || frames[0]["type"] != evMessageEnd { + t.Fatalf("frame with non-object payload = %v", frames[0]) + } +} + +func TestAPITokenEmptyRejected(t *testing.T) { + ts, _ := newTestServer(t) + daemonToken = "" + t.Cleanup(func() { daemonToken = testToken }) + if code, _ := apiReq(t, http.MethodGet, ts.URL+"/api/sessions", "anything", ""); code != http.StatusUnauthorized { + t.Fatal("empty configured token must reject everything") + } +} + +func TestAPIAbortOffline409(t *testing.T) { + ts, _ := newTestServer(t) + if code, _ := apiReq(t, http.MethodPost, ts.URL+"/api/sessions/s1/abort", testToken, ""); code != http.StatusConflict { + t.Fatal("abort offline must 409") + } +} + +func TestAPIHandlersWithClosedStore(t *testing.T) { + // events endpoint surfaces store errors as 500; gitlab disconnect too + ts, store := newTestServer(t) + if err := store.Close(); err != nil { + t.Fatalf("close store: %v", err) + } + if code, _ := apiReq(t, http.MethodGet, ts.URL+"/api/sessions/s1/events", testToken, ""); code != http.StatusInternalServerError { + t.Fatal("events with broken store must 500") + } + gl := NewGitLab(store, "https://gitlab.example") + daemonToken = testToken + hub2 := NewHub(store) + ts2 := httptest.NewServer(NewServer(store, hub2, nil, gl).Routes("")) + t.Cleanup(ts2.Close) + if code, _ := apiReq(t, http.MethodDelete, ts2.URL+"/api/gitlab/connect", testToken, ""); code != http.StatusInternalServerError { + t.Fatal("gitlab disconnect with broken store must 500") + } +} + +func TestAPIWebHandlerDirOverride(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("INDEX"), 0o644); err != nil { + t.Fatalf("write index: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "app.js"), []byte("APP"), 0o644); err != nil { + t.Fatalf("write app: %v", err) + } + daemonToken = testToken + store := openTestStore(t) + hub := NewHub(store) + srv := NewServer(store, hub, nil, NewGitLab(store, "https://gitlab.example")) + ts := httptest.NewServer(srv.Routes(dir)) + t.Cleanup(ts.Close) + + code, body := apiReq(t, http.MethodGet, ts.URL+"/app.js", "", "") + if code != http.StatusOK || body != "APP" { + t.Fatalf("app.js = %d %q", code, body) + } + code, body = apiReq(t, http.MethodGet, ts.URL+"/missing/route", "", "") + if code != http.StatusOK || body != "INDEX" { + t.Fatalf("SPA fallback = %d %q", code, body) + } + code, body = apiReq(t, http.MethodGet, ts.URL+"/", "", "") + if code != http.StatusOK || body != "INDEX" { + t.Fatalf("root = %d %q", code, body) + } +} + +func TestAPIWebHandlerEmbedded(t *testing.T) { + ts, _ := newTestServer(t) // Routes("") → embedded webdist + + for path, wantStatus := range map[string]int{"/index.html": 200, "/no-such-page": 200, "/": 200} { + code, body := apiReq(t, http.MethodGet, ts.URL+path, "", "") + if code != wantStatus { + t.Fatalf("%s = %d", path, code) + } + if !strings.Contains(body, "<") { + t.Fatalf("%s body = %q", path, body) + } + } +} diff --git a/daemon/docker_test.go b/daemon/docker_test.go new file mode 100644 index 0000000..05071d9 --- /dev/null +++ b/daemon/docker_test.go @@ -0,0 +1,351 @@ +package main + +// docker_test.go — spawner pipeline against a fake docker REST API and a +// fake git binary (PATH shim recording invocations). + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +// --- fake docker daemon (just enough REST for the spawner pipeline) --- + +type dockerCall struct { + Method string + Path string + Query string + Body string +} + +func (c dockerCall) String() string { return c.Method + " " + c.Path + "?" + c.Query } + +// recordedCreate is the parsed POST /containers/create request body. +type recordedCreate struct { + Name string + Image string `json:"Image"` + Env []string `json:"Env"` + Labels map[string]string `json:"Labels"` + Cmd []string `json:"Cmd"` + HostConfig struct { + Binds []string `json:"Binds"` + NetworkMode string `json:"NetworkMode"` + } `json:"HostConfig"` +} + +type fakeDocker struct { + mu sync.Mutex + + calls []dockerCall + images int // entries served by GET /images/json + volume map[string]bool // existing volumes + nextID int + create []recordedCreate + + failBuild bool + failBuildHTTP bool + failCreate bool + failStart bool + failStop bool + failWait bool + failVolumeCreate bool + waitHang bool + + unknown []string +} + +func newFakeDocker() *fakeDocker { + return &fakeDocker{images: 1, volume: map[string]bool{}} +} + +func (f *fakeDocker) server(t *testing.T) *httptest.Server { + t.Helper() + ts := httptest.NewServer(f) + t.Cleanup(ts.Close) + return ts +} + +// record appends a call; the /vX.Y version prefix negotiated by the SDK is +// stripped so assertions are version-agnostic. +func (f *fakeDocker) record(r *http.Request, body string) dockerCall { + path := r.URL.Path + if strings.HasPrefix(path, "/v") { + if i := strings.Index(path[1:], "/"); i >= 0 { + path = path[i+1:] + } + } + c := dockerCall{Method: r.Method, Path: path, Query: r.URL.RawQuery, Body: body} + f.mu.Lock() + f.calls = append(f.calls, c) + f.mu.Unlock() + return c +} + +func (f *fakeDocker) hasCall(method, pathPrefix string) bool { + f.mu.Lock() + defer f.mu.Unlock() + for _, c := range f.calls { + if c.Method == method && strings.HasPrefix(c.Path, pathPrefix) { + return true + } + } + return false +} + +func (f *fakeDocker) hasCallSuffix(method, suffix string) bool { + f.mu.Lock() + defer f.mu.Unlock() + for _, c := range f.calls { + if c.Method == method && strings.HasSuffix(c.Path, suffix) { + return true + } + } + return false +} + +func (f *fakeDocker) countCalls(method, pathPrefix string) int { + f.mu.Lock() + defer f.mu.Unlock() + n := 0 + for _, c := range f.calls { + if c.Method == method && strings.HasPrefix(c.Path, pathPrefix) { + n++ + } + } + return n +} + +func (f *fakeDocker) createsByName(prefix string) []recordedCreate { + f.mu.Lock() + defer f.mu.Unlock() + var out []recordedCreate + for _, c := range f.create { + if strings.HasPrefix(c.Name, prefix) { + out = append(out, c) + } + } + return out +} + +func (f *fakeDocker) volumeExists(name string) bool { + f.mu.Lock() + defer f.mu.Unlock() + return f.volume[name] +} + +func (f *fakeDocker) assertNoUnknown(t *testing.T) { + t.Helper() + f.mu.Lock() + defer f.mu.Unlock() + if len(f.unknown) > 0 { + t.Fatalf("fake docker got unhandled requests: %v", f.unknown) + } +} + +func writeJSONNow(w http.ResponseWriter, status int, v string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = io.WriteString(w, v) +} + +func (f *fakeDocker) ServeHTTP(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + call := f.record(r, string(body)) + switch { + case call.Path == "/_ping": + w.Header().Set("Api-Version", "1.44") + w.WriteHeader(http.StatusOK) + case call.Method == http.MethodGet && call.Path == "/images/json": + list := "[]" + if f.images > 0 { + list = `[{"Id":"sha256:abc","RepoTags":["lvmh-worker:latest"]}]` + } + writeJSONNow(w, http.StatusOK, list) + case call.Method == http.MethodPost && call.Path == "/build": + if f.failBuildHTTP { + writeJSONNow(w, http.StatusInternalServerError, `{"message":"build endpoint broken"}`) + return + } + if f.failBuild { + writeJSONNow(w, http.StatusOK, `{"errorDetail":{"message":"build exploded"},"error":"build exploded"}`+"\n") + return + } + writeJSONNow(w, http.StatusOK, `{"stream":"Successfully tagged lvmh-worker:latest"}`+"\n") + case call.Method == http.MethodPost && call.Path == "/volumes/create": + var req struct { + Name string `json:"Name"` + } + _ = json.Unmarshal(body, &req) + f.mu.Lock() + f.volume[req.Name] = true + f.mu.Unlock() + if f.failVolumeCreate { + writeJSONNow(w, http.StatusInternalServerError, `{"message":"volume create failed"}`) + return + } + writeJSONNow(w, http.StatusCreated, fmt.Sprintf(`{"Name":%q,"CreatedAt":"2024-01-01T00:00:00Z"}`, req.Name)) + case call.Method == http.MethodGet && strings.HasPrefix(call.Path, "/volumes/"): + name := strings.TrimPrefix(call.Path, "/volumes/") + f.mu.Lock() + exists := f.volume[name] + f.mu.Unlock() + if exists { + writeJSONNow(w, http.StatusOK, fmt.Sprintf(`{"Name":%q,"CreatedAt":"2024-01-01T00:00:00Z"}`, name)) + return + } + writeJSONNow(w, http.StatusNotFound, `{"message":"no such volume"}`) + case call.Method == http.MethodPost && call.Path == "/containers/create": + var rc recordedCreate + if err := json.Unmarshal(body, &rc); err != nil { + writeJSONNow(w, http.StatusBadRequest, `{"message":"bad create body"}`) + return + } + rc.Name = r.URL.Query().Get("name") + if f.failCreate { + writeJSONNow(w, http.StatusInternalServerError, `{"message":"create failed"}`) + return + } + f.mu.Lock() + f.nextID++ + id := fmt.Sprintf("cid-%d", f.nextID) + f.create = append(f.create, rc) + f.mu.Unlock() + writeJSONNow(w, http.StatusCreated, fmt.Sprintf(`{"Id":%q,"Warnings":null}`, id)) + case call.Method == http.MethodPost && strings.HasSuffix(call.Path, "/start"): + if f.failStart { + writeJSONNow(w, http.StatusInternalServerError, `{"message":"start failed"}`) + return + } + w.WriteHeader(http.StatusNoContent) + case call.Method == http.MethodPost && strings.HasSuffix(call.Path, "/stop"): + if f.failStop { + writeJSONNow(w, http.StatusInternalServerError, `{"message":"stop failed"}`) + return + } + w.WriteHeader(http.StatusNoContent) + case call.Method == http.MethodPost && strings.HasSuffix(call.Path, "/wait"): + if f.failWait { + writeJSONNow(w, http.StatusInternalServerError, `{"message":"wait failed"}`) + return + } + if f.waitHang { + <-r.Context().Done() // hold the connection until the client gives up + return + } + writeJSONNow(w, http.StatusOK, `{"StatusCode":0,"Error":null}`) + case call.Method == http.MethodDelete && strings.HasPrefix(call.Path, "/containers/"): + w.WriteHeader(http.StatusNoContent) + default: + f.mu.Lock() + f.unknown = append(f.unknown, call.String()) + f.mu.Unlock() + writeJSONNow(w, http.StatusNotFound, `{"message":"not implemented: `+call.String()+`"}`) + } +} + +// --- fake git (PATH shim): logs invocations, optionally fails --- + +const ( + fakeGitModeOK string = "ok" + fakeGitModeFail string = "fail" + fakeGitModeNoisy string = "noisy" + fakeGitModeSilent string = "silent" +) + +// useFakeGit puts a shim `git` first on PATH. Modes: ok (exit 0), fail +// (stderr + exit 1), noisy (600-byte stderr + exit 1, for truncation tests). +func useFakeGit(t *testing.T, mode string) string { + t.Helper() + dir := t.TempDir() + logPath := filepath.Join(t.TempDir(), "git-calls.log") + script := "#!/bin/sh\n" + + "printf '%s\\n' \"$*\" >> \"" + logPath + "\"\n" + + "case \"$FAKE_GIT_MODE\" in\n" + + " fail) echo 'fatal: repository not found'; exit 1;;\n" + + " noisy) printf '%s\\n' \"" + strings.Repeat("x", 600) + "\"; exit 1;;\n" + + " silent) exit 1;;\n" + + "esac\n" + + "exit 0\n" + if err := os.WriteFile(filepath.Join(dir, "git"), []byte(script), 0o755); err != nil { + t.Fatalf("write fake git: %v", err) + } + t.Setenv("PATH", dir) + t.Setenv("FAKE_GIT_MODE", mode) + return logPath +} + +func readGitLog(t *testing.T, path string) []string { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + t.Fatalf("read git log: %v", err) + } + var out []string + for _, line := range strings.Split(strings.TrimSpace(string(b)), "\n") { + if line != "" { + out = append(out, line) + } + } + return out +} + +// --- spawner construction wired at the fake daemon --- + +func newTestSpawner(t *testing.T, f *fakeDocker) (*Spawner, *Store) { + t.Helper() + ts := f.server(t) + t.Setenv("DOCKER_HOST", "tcp://"+ts.Listener.Addr().String()) + + buildCtx := t.TempDir() + dockerfile := filepath.Join(buildCtx, "docker", "worker.Dockerfile") + if err := os.MkdirAll(filepath.Dir(dockerfile), 0o755); err != nil { + t.Fatalf("mkdir dockerfile dir: %v", err) + } + if err := os.WriteFile(dockerfile, []byte("FROM scratch\n"), 0o644); err != nil { + t.Fatalf("write dockerfile: %v", err) + } + if err := os.WriteFile(filepath.Join(buildCtx, "app.txt"), []byte("app"), 0o644); err != nil { + t.Fatalf("write app.txt: %v", err) + } + t.Setenv(envWorkerDockerfile, dockerfile) + t.Setenv(envWorkerContext, buildCtx) + t.Setenv(envRepoDir, t.TempDir()) + t.Setenv(envProviderAPIKey, "key-123") + t.Setenv(envWorkerModels, "") // fall back to default path (absent) → no models bind + + daemonToken = testToken + store := openTestStore(t) + hub := NewHub(store) + sp, err := NewSpawner(store, hub, "https://gitlab.example/") + if err != nil { + t.Fatalf("NewSpawner: %v", err) + } + return sp, store +} + +// waitJobState polls JobsSnapshot until the job for sessionID reaches want. +func waitJobState(t *testing.T, sp *Spawner, sessionID, want string) SpawnJob { + t.Helper() + var job SpawnJob + waitFor(t, 5*time.Second, func() bool { + for _, j := range sp.JobsSnapshot() { + if j.SessionID == sessionID { + job = j + return j.State == want + } + } + return false + }) + return job +} diff --git a/daemon/gitlab_extra_test.go b/daemon/gitlab_extra_test.go new file mode 100644 index 0000000..77e5988 --- /dev/null +++ b/daemon/gitlab_extra_test.go @@ -0,0 +1,133 @@ +package main + +// gitlab_extra_test.go — error taxonomy, insecure client seam, decode failures. + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestGitLabErrorType(t *testing.T) { + err := &GitLabError{msg: "upstream sad"} + if err.Error() != "upstream sad" { + t.Fatalf("GitLabError.Error = %q", err.Error()) + } +} + +func TestNewGitLabWithClientInsecure(t *testing.T) { + store := openTestStore(t) + gl := NewGitLabWithClient(store, "https://gl.example/", true) + if !gl.insecure { + t.Fatal("insecure flag not set") + } + hc := gl.httpClient() + if hc.Transport == nil { + t.Fatal("insecure client must install a custom transport") + } + plain := NewGitLab(store, "https://gl.example/") + if plain.httpClient().Transport != nil { + t.Fatal("default client must not override the transport") + } + if !strings.HasSuffix(gl.baseURL, "example") || strings.HasSuffix(gl.baseURL, "/") { + t.Fatalf("baseURL trailing slash not trimmed: %q", gl.baseURL) + } +} + +func TestGitLabConnectDecodeAndEmptyUser(t *testing.T) { + cases := []struct { + name string + body string + want string + }{ + {"decode-failure", "not json", "decode"}, + {"empty-username", `{}`, "no username"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(tc.body)) + })) + t.Cleanup(up.Close) + gl := NewGitLab(openTestStore(t), up.URL) + _, err := gl.Connect(context.Background(), "pat") + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("Connect = %v, want containing %q", err, tc.want) + } + }) + } +} + +func TestGitLabRequestFailures(t *testing.T) { + store := openTestStore(t) + + // unparseable base URL → request construction error + gl := NewGitLab(store, "https://gl.example/\n") + if _, err := gl.Connect(context.Background(), "pat"); err == nil { + t.Fatal("connect against unparseable URL must fail") + } + + // transport failure (cancelled context) wraps into GitLabError + gl2 := NewGitLab(store, "https://gl.example") + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err := gl2.Connect(ctx, "pat") + if err == nil || !strings.Contains(err.Error(), "gitlab request failed") { + t.Fatalf("connect with dead context = %v, want GitLabError", err) + } + + // store failures surface from token() and Disconnect + dead := openTestStore(t) + _ = dead.Close() + gl3 := NewGitLab(dead, "https://gl.example") + if _, err := gl3.Repos(context.Background()); err == nil || !strings.Contains(err.Error(), "closed") { + t.Fatalf("repos with closed store = %v", err) + } + if err := gl3.Disconnect(); err == nil { + t.Fatal("disconnect with closed store must fail") + } +} + +func TestGitLabStatusStoreFailure(t *testing.T) { + dead := openTestStore(t) + _ = dead.Close() + gl := NewGitLab(dead, "https://gl.example") + status := gl.Status() + if status["connected"] != false { + t.Fatalf("status with broken store = %v, want connected=false", status) + } +} + +func TestGitLabConnectStoreFailures(t *testing.T) { + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"username":"alice"}`)) + })) + t.Cleanup(up.Close) + dead := openTestStore(t) + _ = dead.Close() + gl := NewGitLab(dead, up.URL) + if _, err := gl.Connect(context.Background(), "pat"); err == nil { + t.Fatal("Connect with closed store must fail at SetSetting") + } +} + +func TestGitLabReposSkipsEmptyPaths(t *testing.T) { + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`[{"path_with_namespace":"","name":"X"}]`)) + })) + t.Cleanup(up.Close) + store := openTestStore(t) + gl := NewGitLab(store, up.URL) + if err := store.SetSetting(settingGitLabToken, "pat"); err != nil { + t.Fatalf("set token: %v", err) + } + repos, err := gl.Repos(context.Background()) + if err != nil { + t.Fatalf("repos: %v", err) + } + if len(repos) != 0 { + t.Fatalf("repos = %+v, want empty (blank path skipped)", repos) + } +} diff --git a/daemon/hub_extra_test.go b/daemon/hub_extra_test.go new file mode 100644 index 0000000..1d6efd5 --- /dev/null +++ b/daemon/hub_extra_test.go @@ -0,0 +1,493 @@ +package main + +// hub_extra_test.go — session_info, spawn_status push, send/overflow +// branches, upgrade failures, closed-store resilience. + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gorilla/websocket" +) + +func TestHubSessionInfoUpdatesSession(t *testing.T) { + ts, store := newTestServer(t) + ws := dialAgent(t, ts) + _ = ws.WriteJSON(helloFrame("s1")) + _ = readFrame(t, ws) + + // malformed session_info (no session payload) is ignored, conn stays up + _ = ws.WriteJSON(map[string]any{"v": 1, "type": evSessionInfo, "sessionId": "s1", "seq": 1, "ts": 2}) + renamed := "renamed" + // envelope sessionId missing → falls back to session.id + info := map[string]any{"id": "s1", "name": renamed, "cwd": "/w", "model": "glm-4", "provider": "p", "startedAt": 99} + _ = ws.WriteJSON(map[string]any{"v": 1, "type": evSessionInfo, "seq": 2, "ts": 3, "session": info}) + + waitFor(t, 2*time.Second, func() bool { + rows, err := store.Sessions() + if err != nil || len(rows) != 1 { + return false + } + return rows[0].Info.Model == "glm-4" + }) + rows, _ := store.Sessions() + if rows[0].Info.ID != "s1" || rows[0].Info.Name == nil || *rows[0].Info.Name != renamed { + t.Fatalf("session after session_info = %+v", rows[0].Info) + } +} + +func TestHubHelloFallbacksAndBadPayload(t *testing.T) { + ts, _ := newTestServer(t) + ws := dialAgent(t, ts) + + // hello without session payload → logged, ignored, conn lives on + _ = ws.WriteJSON(map[string]any{"v": 1, "type": evHello, "sessionId": "s1", "seq": 0, "ts": 1}) + // hello with session.id but no envelope sessionId → registers under session.id + frame := helloFrame("ignored") + frame["sessionId"] = nil + _ = ws.WriteJSON(frame) + welcome := readFrame(t, ws) + if welcome["type"] != evWelcome || welcome["sessionId"] != "ignored" { + t.Fatalf("welcome = %v, want fallback to session.id", welcome) + } +} + +func TestHubIsOnline(t *testing.T) { + hub := NewHub(openTestStore(t)) + if hub.IsOnline("nope") { + t.Fatal("unknown session cannot be online") + } + ac := &agentConn{hub: hub, conn: throwawayConn(t), send: make(chan []byte, 1), done: make(chan struct{})} + hub.agents["s1"] = ac + if !hub.IsOnline("s1") { + t.Fatal("registered conn must report online") + } +} + +func TestHubBroadcastSpawnStatus(t *testing.T) { + ts, _, hub := newTestServerHub(t) + hub.SpawnStatus = func() []SpawnJob { + return []SpawnJob{{Repo: "group/proj", State: stateRunning, ContainerID: "cid-1"}} + } + web := dialWeb(t, ts) + if first := readFrame(t, web); first["type"] != frameSessionList { + t.Fatalf("first frame = %v", first) + } + hub.BroadcastSpawnStatus() + _ = web.SetReadDeadline(time.Now().Add(2 * time.Second)) + var got map[string]any + for got == nil { + if m := readFrame(t, web); m["type"] == frameSpawnStatus { + got = m + } + } + jobs := got["jobs"].([]any) + if len(jobs) != 1 || jobs[0].(map[string]any)["repo"] != "group/proj" { + t.Fatalf("spawn_status frame = %v", got) + } +} + +// newTestServerHub is newTestServer but also hands back the hub so tests can +// wire SpawnStatus themselves (NewServer normally does). +func newTestServerHub(t *testing.T) (*httptest.Server, *Store, *Hub) { + t.Helper() + store := openTestStore(t) + daemonToken = testToken + hub := NewHub(store) + srv := &Server{store: store, hub: hub, gitlab: NewGitLab(store, "https://gitlab.example")} + ts := httptest.NewServer(srv.Routes("")) + t.Cleanup(ts.Close) + return ts, store, hub +} + +// throwawayConn is a client-side websocket whose only job is being closeable +// (agentConn.drop touches conn). +func throwawayConn(t *testing.T) *websocket.Conn { + t.Helper() + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c, err := (&websocket.Upgrader{}).Upgrade(w, r, nil) + if err != nil { + return + } + _ = c.Close() + })) + t.Cleanup(up.Close) + conn, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(up.URL, "http"), nil) + if err != nil { + t.Fatalf("dial throwaway: %v", err) + } + t.Cleanup(func() { _ = conn.Close() }) + return conn +} + +func dialWeb(t *testing.T, ts *httptest.Server) *websocket.Conn { + t.Helper() + wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/ws?token=" + testToken + web, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("dial web ws: %v", err) + } + t.Cleanup(func() { _ = web.Close() }) + return web +} + +func TestHubUpgradeFailures(t *testing.T) { + ts, _ := newTestServer(t) + + get := func(path string) int { + req, _ := http.NewRequest(http.MethodGet, ts.URL+path, nil) + req.Header.Set("Authorization", "Bearer "+testToken) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("get %s: %v", path, err) + } + resp.Body.Close() + return resp.StatusCode + } + // plain HTTP GETs (no upgrade headers) must not 500 + if code := get("/agent/ws"); code != http.StatusBadRequest { + t.Fatalf("agent ws non-upgrade = %d, want 400", code) + } + if code := get("/ws?token=" + testToken); code != http.StatusBadRequest { + t.Fatalf("web ws non-upgrade = %d, want 400", code) + } +} + +func TestSendToAgentBranches(t *testing.T) { + hub := NewHub(openTestStore(t)) + + // dropped conn: done closed → ErrOffline via done branch (send kept full + // so the send case cannot win the select race) + dead := &agentConn{hub: hub, conn: throwawayConn(t), send: make(chan []byte, 1), done: make(chan struct{})} + dead.send <- []byte("fill") + dead.drop() + hub.agents["gone"] = dead + if err := hub.sendToAgent("gone", map[string]any{"type": evAbort}); err != ErrOffline { + t.Fatalf("sendToAgent dropped conn = %v, want ErrOffline", err) + } + + // live conn but nobody drains send: timeout branch (3s) + stuck := &agentConn{hub: hub, conn: throwawayConn(t), send: make(chan []byte, 1), done: make(chan struct{})} + stuck.send <- []byte("fill") + hub.agents["stuck"] = stuck + start := time.Now() + if err := hub.sendToAgent("stuck", map[string]any{"type": evAbort}); err != ErrOffline { + t.Fatalf("sendToAgent stuck conn = %v, want ErrOffline", err) + } + if elapsed := time.Since(start); elapsed < promptSendTimeout-100*time.Millisecond { + t.Fatalf("sendToAgent returned after %v, want full timeout", elapsed) + } +} + +func TestWebClientOverflowDrops(t *testing.T) { + // real ws conn so drop() has something to close + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c, err := (&websocket.Upgrader{}).Upgrade(w, r, nil) + if err != nil { + return + } + _ = c.Close() + })) + t.Cleanup(up.Close) + wsURL := "ws" + strings.TrimPrefix(up.URL, "http") + clientConn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer clientConn.Close() + + hub := NewHub(openTestStore(t)) + c := &webClient{hub: hub, conn: clientConn, done: make(chan struct{})} + for i := 0; i < webMaxPending; i++ { + c.deliverEvent(pendingEvent{sessionID: "s", seq: int64(i), raw: []byte("{}")}) + } + c.deliverEvent(pendingEvent{sessionID: "s", seq: 1, raw: []byte("{}")}) // overflow → dropped + select { + case <-c.done: + case <-time.After(2 * time.Second): + t.Fatal("overflowed web client was not dropped") + } + if !c.dropped { + t.Fatal("client must be marked dropped") + } + // deliveries to a dropped client are no-ops + c.deliverControl([]byte("{}")) + if len(c.control) != 0 { + t.Fatal("dropped client must not queue control frames") + } + + // control-frame overflow path on a fresh client + c2 := &webClient{hub: hub, conn: clientConn, done: make(chan struct{})} + for i := 0; i < webMaxPending; i++ { + c2.deliverControl([]byte("{}")) + } + c2.deliverControl([]byte("{}")) + select { + case <-c2.done: + case <-time.After(2 * time.Second): + t.Fatal("control-overflowed web client was not dropped") + } +} + +func TestAgentWritePumpDropsOnWriteError(t *testing.T) { + serverConn := make(chan *websocket.Conn, 1) + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c, err := (&websocket.Upgrader{}).Upgrade(w, r, nil) + if err != nil { + return + } + serverConn <- c + for { // keep server side alive until client vanishes + if _, _, err := c.ReadMessage(); err != nil { + return + } + } + })) + t.Cleanup(up.Close) + clientConn, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(up.URL, "http"), nil) + if err != nil { + t.Fatalf("dial: %v", err) + } + t.Cleanup(func() { _ = clientConn.Close() }) + var server *websocket.Conn + select { + case server = <-serverConn: + case <-time.After(2 * time.Second): + t.Fatal("server conn never upgraded") + } + + hub := NewHub(openTestStore(t)) + ac := &agentConn{hub: hub, conn: server, send: make(chan []byte, 4), done: make(chan struct{})} + go ac.writePump() + // healthy write first + ac.send <- []byte(`{"type":"welcome"}`) + _ = clientConn.SetReadDeadline(time.Now().Add(2 * time.Second)) + if _, _, err := clientConn.ReadMessage(); err != nil { + t.Fatalf("first write lost: %v", err) + } + // kill the TCP conn underneath gorilla, then keep writing + if err := clientConn.UnderlyingConn().Close(); err != nil { + t.Fatalf("close tcp: %v", err) + } + deadline := time.After(3 * time.Second) + for { + select { + case ac.send <- []byte(`{"type":"abort"}`): + case <-ac.done: + return // writePump hit the write error and dropped + case <-deadline: + t.Fatal("writePump did not drop after write failure") + } + time.Sleep(10 * time.Millisecond) + } +} + +func TestHubClosedStoreKeepsServing(t *testing.T) { + ts, store := newTestServer(t) + if err := store.Close(); err != nil { + t.Fatalf("close store: %v", err) + } + + ws := dialAgent(t, ts) + _ = ws.WriteJSON(helloFrame("s1")) + // welcome still arrives with lastSeq 0 despite store failures + if welcome := readFrame(t, ws); welcome["type"] != evWelcome || welcome["lastSeq"].(float64) != 0 { + t.Fatalf("welcome with broken store = %v", welcome) + } + // events are not persisted but fan-out still happens; conn must survive + _ = ws.WriteJSON(map[string]any{"v": 1, "type": evAgentSettled, "sessionId": "s1", "seq": 1, "ts": 5}) + + hub := NewHub(store) // store already closed + if views := hub.SessionsView(); len(views) != 0 { + t.Fatalf("SessionsView with broken store = %v, want empty", views) + } +} + +func TestHubBroadcastSpawnStatusNilProvider(t *testing.T) { + hub := NewHub(openTestStore(t)) + hub.BroadcastSpawnStatus() // SpawnStatus nil → no-op, must not panic + hub.SpawnStatus = func() []SpawnJob { return nil } + hub.BroadcastSpawnStatus() // provider returning nil jobs → empty frame +} + +func TestHubByeClosesConnAndEventWithoutSessionIgnored(t *testing.T) { + ts, _ := newTestServer(t) + ws := dialAgent(t, ts) + _ = ws.WriteJSON(helloFrame("s1")) + _ = readFrame(t, ws) + + // event frames without sessionId are dropped silently + _ = ws.WriteJSON(map[string]any{"v": 1, "type": evMessageEnd, "seq": 9, "ts": 9}) + // bye closes the conn from the server side + _ = ws.WriteJSON(map[string]any{"v": 1, "type": evBye, "sessionId": "s1", "seq": 10, "ts": 10}) + _ = ws.SetReadDeadline(time.Now().Add(2 * time.Second)) + if _, _, err := ws.ReadMessage(); err == nil { + t.Fatal("conn must close after bye") + } +} + +func TestHubHelloWelcomeSendDropWhenQueueFull(t *testing.T) { + hub := NewHub(openTestStore(t)) + ac := &agentConn{hub: hub, conn: throwawayConn(t), send: make(chan []byte, 1), done: make(chan struct{})} + ac.send <- []byte("full") // nobody drains → welcome select takes default → drop + hello := helloFrame("s1") + raw, _ := json.Marshal(hello) + hub.handleHello(ac, frame{typ: evHello, sessionID: "s1", raw: raw}) + select { + case <-ac.done: + case <-time.After(2 * time.Second): + t.Fatal("agent with full send queue must be dropped at hello") + } +} + +func TestHubWebReadWritePumpEdges(t *testing.T) { + ts, _, hub := newTestServerHub(t) + web := dialWeb(t, ts) + if first := readFrame(t, web); first["type"] != frameSessionList { + t.Fatalf("first frame = %v", first) + } + // garbage frames must not kill the read pump + if err := web.WriteMessage(websocket.TextMessage, []byte("not json")); err != nil { + t.Fatalf("write garbage: %v", err) + } + if err := web.WriteJSON(map[string]any{"type": frameSubscribe, "sessionId": "s1"}); err != nil { + t.Fatalf("subscribe: %v", err) + } + waitFor(t, 2*time.Second, func() bool { + hub.mu.Lock() + defer hub.mu.Unlock() + for c := range hub.webs { + return c.subscription() == "s1" + } + return false + }) + if err := web.WriteJSON(map[string]any{"type": frameUnsubscribe}); err != nil { + t.Fatalf("unsubscribe: %v", err) + } + waitFor(t, 2*time.Second, func() bool { + hub.mu.Lock() + defer hub.mu.Unlock() + for c := range hub.webs { + return c.subscription() == "" + } + return false + }) + // client disconnects → readPump exits, client unregistered + _ = web.Close() + waitFor(t, 2*time.Second, func() bool { + hub.mu.Lock() + defer hub.mu.Unlock() + return len(hub.webs) == 0 + }) +} + +func TestHubDropWebIdempotent(t *testing.T) { + hub := NewHub(openTestStore(t)) + c := &webClient{hub: hub, conn: throwawayConn(t), done: make(chan struct{})} + hub.mu.Lock() + hub.webs[c] = struct{}{} + hub.mu.Unlock() + hub.dropWeb(c) + hub.dropWeb(c) // second drop hits the already-removed early return + select { + case <-c.done: + case <-time.After(2 * time.Second): + t.Fatal("web client was not dropped") + } + hub.mu.Lock() + left := len(hub.webs) + hub.mu.Unlock() + if left != 0 { + t.Fatalf("webs after drop = %d, want 0", left) + } +} + +func TestWebWritePumpDropsOnWriteError(t *testing.T) { + for _, tc := range []struct { + name string + ctrl bool // deliver control frames (vs events only) + }{ + {"control-write", true}, + {"events-write", false}, + } { + t.Run(tc.name, func(t *testing.T) { + serverConn := make(chan *websocket.Conn, 1) + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c, err := (&websocket.Upgrader{}).Upgrade(w, r, nil) + if err != nil { + return + } + serverConn <- c + for { + if _, _, err := c.ReadMessage(); err != nil { + return + } + } + })) + t.Cleanup(up.Close) + clientConn, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(up.URL, "http"), nil) + if err != nil { + t.Fatalf("dial: %v", err) + } + t.Cleanup(func() { _ = clientConn.Close() }) + var server *websocket.Conn + select { + case server = <-serverConn: + case <-time.After(2 * time.Second): + t.Fatal("server conn never upgraded") + } + + hub := NewHub(openTestStore(t)) + c := &webClient{hub: hub, conn: server, done: make(chan struct{})} + hub.mu.Lock() + hub.webs[c] = struct{}{} // writePump errors route through dropWeb + hub.mu.Unlock() + go c.writePump() + if tc.ctrl { + c.deliverControl([]byte(`{"type":"session_list"}`)) + } else { + c.deliverEvent(pendingEvent{sessionID: "s", seq: 1, raw: []byte("{}")}) + } + _ = clientConn.SetReadDeadline(time.Now().Add(2 * time.Second)) + if _, _, err := clientConn.ReadMessage(); err != nil { + t.Fatalf("first frame lost: %v", err) + } + if err := clientConn.UnderlyingConn().Close(); err != nil { + t.Fatalf("close tcp: %v", err) + } + // flush cycle hits the dead conn → writePump drops the client + deadline := time.After(3 * time.Second) + for { + if tc.ctrl { + c.deliverControl([]byte(`{"type":"session_list"}`)) + } else { + c.deliverEvent(pendingEvent{sessionID: "s", seq: 2, raw: []byte("{}")}) + } + select { + case <-c.done: + return + case <-deadline: + t.Fatal("writePump did not drop after write failure") + default: + } + time.Sleep(2 * time.Millisecond) + } + }) + } +} + +func TestWebClientDeliverToDropped(t *testing.T) { + hub := NewHub(openTestStore(t)) + c := &webClient{hub: hub, conn: throwawayConn(t), done: make(chan struct{})} + for i := 0; i < webMaxPending+1; i++ { // overflow sets the dropped flag + c.deliverEvent(pendingEvent{sessionID: "s", seq: int64(i), raw: []byte("{}")}) + } + c.deliverEvent(pendingEvent{sessionID: "s", seq: 1, raw: []byte("{}")}) + if len(c.events) != webMaxPending { + t.Fatalf("dropped client queued %d events, want %d", len(c.events), webMaxPending) + } +} diff --git a/daemon/main.go b/daemon/main.go index c4a4b89..d4e1c23 100644 --- a/daemon/main.go +++ b/daemon/main.go @@ -5,6 +5,7 @@ package main import ( "context" "flag" + "fmt" "log" "net/http" "os" @@ -26,12 +27,23 @@ const ( ) func main() { + if err := run(os.Args[1:]); err != nil { + log.Fatal(err) + } +} + +// run is the testable main: parses args, wires the server, blocks on SIGINT/ +// SIGTERM, then shuts down. Split out of main for coverage of the wiring. +func run(args []string) error { + fs := flag.NewFlagSet("lvmh-daemon", flag.ContinueOnError) var ( - addr = flag.String("addr", defaultListenAddr, "listen address") - dbPath = flag.String("db", envOr(envDB, defaultDBPath), "sqlite database path") - webdist = flag.String("webdist", webDistDefault, "serve web UI from this directory instead of the embedded build") + addr = fs.String("addr", defaultListenAddr, "listen address") + dbPath = fs.String("db", envOr(envDB, defaultDBPath), "sqlite database path") + webdist = fs.String("webdist", webDistDefault, "serve web UI from this directory instead of the embedded build") ) - flag.Parse() + if err := fs.Parse(args); err != nil { + return err + } // Fall back to the embedded UI when the override directory is absent. if st, err := os.Stat(*webdist); err != nil || !st.IsDir() { *webdist = "" @@ -39,17 +51,17 @@ func main() { daemonToken = os.Getenv(envToken) if daemonToken == "" { - log.Fatalf("LVMH_TOKEN must be set") + return fmt.Errorf("LVMH_TOKEN must be set") } if dir := filepath.Dir(*dbPath); dir != "" && dir != "." { if err := os.MkdirAll(dir, 0o755); err != nil { - log.Fatalf("create db dir: %v", err) + return fmt.Errorf("create db dir: %w", err) } } store, err := OpenStore(*dbPath) if err != nil { - log.Fatalf("open store: %v", err) + return fmt.Errorf("open store: %w", err) } defer store.Close() @@ -57,7 +69,7 @@ func main() { gitlab := NewGitLab(store, envOr(envGitLabBaseURL, defaultGitLabBase)) spawner, err := NewSpawner(store, hub, envOr(envGitLabBaseURL, defaultGitLabBase)) if err != nil { - log.Fatalf("docker client: %v", err) + return fmt.Errorf("docker client: %w", err) } srv := NewServer(store, hub, spawner, gitlab) @@ -67,20 +79,29 @@ func main() { ReadHeaderTimeout: 10 * time.Second, } + serveErr := make(chan error, 1) go func() { log.Printf("lvmh daemon listening on %s (db %s)", *addr, *dbPath) if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { - log.Fatalf("listen: %v", err) + serveErr <- err + return } + serveErr <- nil }() stop := make(chan os.Signal, 1) signal.Notify(stop, os.Interrupt, syscall.SIGTERM) - <-stop + defer signal.Stop(stop) + select { + case err := <-serveErr: + return fmt.Errorf("listen: %w", err) + case <-stop: + } log.Printf("shutting down") ctx, cancel := context.WithTimeout(context.Background(), shutdownGrace) defer cancel() if err := httpServer.Shutdown(ctx); err != nil { log.Printf("shutdown: %v", err) } + return nil } diff --git a/daemon/main_test.go b/daemon/main_test.go new file mode 100644 index 0000000..e79fffc --- /dev/null +++ b/daemon/main_test.go @@ -0,0 +1,121 @@ +package main + +// main_test.go — run() wiring: flag errors, missing token, db dir creation, +// listen, graceful SIGTERM shutdown. + +import ( + "fmt" + "net" + "net/http" + "os" + "path/filepath" + "strings" + "syscall" + "testing" + "time" +) + +func freeTestAddr(t *testing.T) string { + t.Helper() + ls, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("probe listener: %v", err) + } + defer ls.Close() + return fmt.Sprintf("127.0.0.1:%d", ls.Addr().(*net.TCPAddr).Port) +} + +func TestRunFlagError(t *testing.T) { + if err := run([]string{"--nope"}); err == nil { + t.Fatal("unknown flag must error") + } +} + +func TestRunRequiresToken(t *testing.T) { + t.Setenv(envToken, "") + if err := run(nil); err == nil || err.Error() != "LVMH_TOKEN must be set" { + t.Fatalf("run without token = %v", err) + } +} + +func TestRunBadDBPath(t *testing.T) { + t.Setenv(envToken, "s3cret") + occupied := filepath.Join(t.TempDir(), "file.db") + if err := os.WriteFile(occupied, nil, 0o644); err != nil { + t.Fatalf("write: %v", err) + } + if err := run([]string{"--db", filepath.Join(occupied, "sub", "lvmh.db")}); err == nil { + t.Fatal("run with db under a file must fail") + } +} + +func TestRunListenAndServeShutdown(t *testing.T) { + addr := freeTestAddr(t) + dir := t.TempDir() + db := filepath.Join(dir, "nested", "lvmh.db") + t.Setenv(envToken, "s3cret") + t.Setenv(envDB, db) + + errCh := make(chan error, 1) + go func() { errCh <- run([]string{"--addr", addr, "--webdist", filepath.Join(dir, "absent")}) }() + + // server comes up: /api answers 401 without a token + deadline := time.Now().Add(5 * time.Second) + responded := false + for time.Now().Before(deadline) { + if resp, err := http.Get("http://" + addr + "/api/sessions"); err == nil { + resp.Body.Close() + responded = resp.StatusCode == 401 + break + } + time.Sleep(20 * time.Millisecond) + } + if !responded { + t.Fatal("daemon never answered /api/sessions with 401") + } + if _, err := os.Stat(db); err != nil { + t.Fatalf("db not created at %s: %v", db, err) + } + + // SIGTERM → graceful shutdown, run returns nil + if err := syscall.Kill(syscall.Getpid(), syscall.SIGTERM); err != nil { + t.Fatalf("kill: %v", err) + } + select { + case err := <-errCh: + if err != nil { + t.Fatalf("run returned %v, want nil", err) + } + case <-time.After(10 * time.Second): + t.Fatal("run did not return after SIGTERM") + } +} + +func TestRunListenFailure(t *testing.T) { + t.Setenv(envToken, "s3cret") + ls, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("occupy listener: %v", err) + } + defer ls.Close() + port := ls.Addr().(*net.TCPAddr).Port + db := filepath.Join(t.TempDir(), "lvmh.db") + done := make(chan error, 1) + go func() { done <- run([]string{"--addr", fmt.Sprintf("127.0.0.1:%d", port), "--db", db}) }() + select { + case err := <-done: + if err == nil || !strings.Contains(err.Error(), "listen") { + t.Fatalf("run on occupied port = %v, want listen error", err) + } + case <-time.After(10 * time.Second): + t.Fatal("run never returned on listen failure") + } +} + +func TestRunOpenStoreFailure(t *testing.T) { + t.Setenv(envToken, "s3cret") + // --db pointing at an existing directory: schema exec fails + if err := run([]string{"--db", t.TempDir(), "--addr", "127.0.0.1:0"}); err == nil || !strings.Contains(err.Error(), "open store") { + t.Fatalf("run with directory db = %v, want open store error", err) + } +} diff --git a/daemon/spawner_test.go b/daemon/spawner_test.go new file mode 100644 index 0000000..63c2f69 --- /dev/null +++ b/daemon/spawner_test.go @@ -0,0 +1,787 @@ +package main + +// spawner_test.go — clone→build→create/start pipeline, git failures, env wiring. + +import ( + "archive/tar" + "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/docker/docker/client" +) + +func TestSpawnerStartHappyPath(t *testing.T) { + gitLog := useFakeGit(t, fakeGitModeOK) + f := newFakeDocker() + sp, store := newTestSpawner(t, f) + + res, err := sp.Start(context.Background(), "group/project", "main") + if err != nil { + t.Fatalf("Start: %v", err) + } + if res.SessionID == "" || res.ContainerID != "" { + t.Fatalf("Start result = %+v, want sessionId set and empty containerId", res) + } + + job := waitJobState(t, sp, res.SessionID, stateRunning) + if job.Repo != "group/project" || job.ContainerID == "" { + t.Fatalf("running job = %+v, want containerId set", job) + } + + // clone URL carries the PAT only when none is stored (it isn't here) + calls := readGitLog(t, gitLog) + if len(calls) != 1 || !strings.HasPrefix(calls[0], "clone --branch main -- https://gitlab.example/group/project.git ") { + t.Fatalf("git calls = %v", calls) + } + + // container created with the right image, env, labels, mounts, network + creates := f.createsByName("lvmh-agent-") + if len(creates) != 1 { + t.Fatalf("agent containers created = %+v", creates) + } + c := creates[0] + if c.Image != imageRefWorker { + t.Fatalf("image = %q", c.Image) + } + if c.Labels[labelSession] != res.SessionID { + t.Fatalf("labels = %v", c.Labels) + } + wantEnv := map[string]bool{ + envProviderAPIKey + "=key-123": true, + envToken + "=" + testToken: true, + "LVMH_URL=" + defaultContainerURL: true, + envLVMHSessionID + "=" + res.SessionID: true, + envLVMHAgent + "=1": true, + envLVMHRepo + "=group/project": true, + } + for _, e := range c.Env { + if !wantEnv[e] { + t.Fatalf("unexpected env %q in %v", e, c.Env) + } + delete(wantEnv, e) + } + if len(wantEnv) != 0 { + t.Fatalf("missing env %v", wantEnv) + } + wantBinds := []string{ + "lvmh-repo-group-project:" + workspaceMount, + volumeSessions + ":" + sessionsMount, + } + if len(c.HostConfig.Binds) != 2 { + t.Fatalf("binds = %v, want %v (no models file)", c.HostConfig.Binds, wantBinds) + } + for i, b := range wantBinds { + if c.HostConfig.Binds[i] != b { + t.Fatalf("binds = %v, want %v", c.HostConfig.Binds, wantBinds) + } + } + if c.HostConfig.NetworkMode != defaultNetwork { + t.Fatalf("network = %q", c.HostConfig.NetworkMode) + } + + // fresh repo volume seeded from the clone via a one-shot container + seed := f.createsByName("lvmh-seed-") + if len(seed) != 1 { + t.Fatalf("seed containers = %+v", seed) + } + if seed[0].Image != imageRefWorker || len(seed[0].Cmd) == 0 || !strings.Contains(seed[0].Cmd[len(seed[0].Cmd)-1], workspaceMount) { + t.Fatalf("seed create = %+v", seed[0]) + } + if !strings.HasPrefix(seed[0].HostConfig.Binds[1], sp.reposDir) { + t.Fatalf("seed binds = %v, want clone dir mounted at /src", seed[0].HostConfig.Binds) + } + + // no build expected (fake daemon already has the image) + if f.hasCall(http.MethodPost, "/build") { + t.Fatal("image present but build was called") + } + // session volume created too + if !f.volumeExists(volumeSessions) { + t.Fatalf("volume %q missing", volumeSessions) + } + + // container row persisted + row, ok, err := store.GetContainer(res.SessionID) + if err != nil || !ok || row.Repo != "group/project" { + t.Fatalf("container row = %+v ok=%v err=%v", row, ok, err) + } + f.assertNoUnknown(t) +} + +func TestSpawnerBuildsImageWhenMissing(t *testing.T) { + useFakeGit(t, fakeGitModeOK) + f := newFakeDocker() + f.images = 0 // image absent → ensureImage must build + sp, _ := newTestSpawner(t, f) + + res, err := sp.Start(context.Background(), "group/project", "") + if err != nil { + t.Fatalf("Start: %v", err) + } + waitJobState(t, sp, res.SessionID, stateRunning) + + builds := f.countCalls(http.MethodPost, "/build") + if builds != 1 { + t.Fatalf("build calls = %d, want 1", builds) + } + f.assertNoUnknown(t) +} + +func TestSpawnerStartValidatesDockerAndDockerfile(t *testing.T) { + f := newFakeDocker() + f.images = 0 + sp, _ := newTestSpawner(t, f) // dockerfile exists in the fake context + + // docker reachable but image absent and no Dockerfile anywhere → clear error + t.Setenv(envWorkerDockerfile, filepath.Join(t.TempDir(), "missing.Dockerfile")) + sp2, err := NewSpawner(sp.store, sp.hub, "https://gitlab.example/") + if err != nil { + t.Fatalf("NewSpawner: %v", err) + } + _, err = sp2.Start(context.Background(), "group/project", "") + if err == nil || !strings.Contains(err.Error(), "no worker Dockerfile") { + t.Fatalf("Start without dockerfile err = %v", err) + } + if len(sp2.JobsSnapshot()) != 0 { + t.Fatalf("failed Start must not leave a job: %+v", sp2.JobsSnapshot()) + } +} + +func TestSpawnerRunJobErrorStates(t *testing.T) { + cases := []struct { + name string + setup func(t *testing.T, f *fakeDocker) + gitMode string + wantMsg string + }{ + { + name: "clone-fails", + gitMode: fakeGitModeFail, + wantMsg: "fatal: repository not found", + }, + { + name: "noisy-clone-failure-truncated-to-tail", + gitMode: fakeGitModeNoisy, + wantMsg: "xxx", + }, + { + name: "build-fails", + setup: func(t *testing.T, f *fakeDocker) { + f.images = 0 + f.failBuild = true + }, + wantMsg: "build exploded", + }, + { + name: "create-fails", + setup: func(t *testing.T, f *fakeDocker) { + f.failCreate = true + }, + wantMsg: "create failed", + }, + { + name: "start-fails-and-cleans-up", + setup: func(t *testing.T, f *fakeDocker) { + f.failStart = true + }, + wantMsg: "start failed", + }, + { + name: "volume-create-fails", + setup: func(t *testing.T, f *fakeDocker) { + f.failVolumeCreate = true + }, + wantMsg: "volume create failed", + }, + { + name: "seed-wait-fails", + setup: func(t *testing.T, f *fakeDocker) { + f.failWait = true + }, + wantMsg: "wait failed", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + gitMode := tc.gitMode + if gitMode == "" { + gitMode = fakeGitModeOK + } + useFakeGit(t, gitMode) + f := newFakeDocker() + if tc.setup != nil { + tc.setup(t, f) + } + sp, _ := newTestSpawner(t, f) + res, err := sp.Start(context.Background(), "group/project", "") + if err != nil { + t.Fatalf("Start: %v", err) + } + job := waitJobState(t, sp, res.SessionID, stateError) + if !strings.Contains(job.Message, tc.wantMsg) { + t.Fatalf("job message = %q, want containing %q", job.Message, tc.wantMsg) + } + if job.ContainerID != "" { + t.Fatalf("error job has containerId %q", job.ContainerID) + } + }) + } +} + +func TestSpawnerCloneOrUpdatePullsExisting(t *testing.T) { + gitLog := useFakeGit(t, fakeGitModeOK) + f := newFakeDocker() + sp, _ := newTestSpawner(t, f) + + // existing clone → pull --ff-only in that dir, no clone + dir := filepath.Join(sp.reposDir, "group-project") + if err := os.MkdirAll(filepath.Join(dir, ".git"), 0o755); err != nil { + t.Fatalf("mkdir .git: %v", err) + } + if err := sp.cloneOrUpdate("group/project", "", repoSlug("group/project")); err != nil { + t.Fatalf("cloneOrUpdate: %v", err) + } + calls := readGitLog(t, gitLog) + if len(calls) != 1 || calls[0] != "pull --ff-only" { + t.Fatalf("git calls = %v, want pull --ff-only", calls) + } +} + +func TestSpawnerCloneURLInjectsPAT(t *testing.T) { + f := newFakeDocker() + sp, store := newTestSpawner(t, f) + + if u, err := sp.cloneURL("group/project"); err != nil || u != "https://gitlab.example/group/project.git" { + t.Fatalf("cloneURL without PAT = %q err=%v", u, err) + } + if err := store.SetSetting(settingGitLabToken, "pat-1"); err != nil { + t.Fatalf("set token: %v", err) + } + u, err := sp.cloneURL("group/project") + if err != nil { + t.Fatalf("cloneURL: %v", err) + } + if u != "https://oauth2:pat-1@gitlab.example/group/project.git" { + t.Fatalf("cloneURL with PAT = %q", u) + } +} + +func TestSpawnerModelsBindWhenPresent(t *testing.T) { + useFakeGit(t, fakeGitModeOK) + f := newFakeDocker() + sp, _ := newTestSpawner(t, f) + models := filepath.Join(t.TempDir(), "models.json") + if err := os.WriteFile(models, []byte("{}"), 0o644); err != nil { + t.Fatalf("write models: %v", err) + } + sp.modelsPath = models + + res, err := sp.Start(context.Background(), "group/project", "") + if err != nil { + t.Fatalf("Start: %v", err) + } + waitJobState(t, sp, res.SessionID, stateRunning) + creates := f.createsByName("lvmh-agent-") + if len(creates) != 1 { + t.Fatalf("creates = %d", len(creates)) + } + roFound := false + for _, b := range creates[0].HostConfig.Binds { + if b == models+":"+modelsMountTarget+":ro" { + roFound = true + } + } + if !roFound { + t.Fatalf("models read-only bind missing: %v", creates[0].HostConfig.Binds) + } +} + +func TestSpawnerRemoveSession(t *testing.T) { + useFakeGit(t, fakeGitModeOK) + f := newFakeDocker() + sp, store := newTestSpawner(t, f) + + if err := sp.RemoveSession(context.Background(), "nope"); err != errNoContainer { + t.Fatalf("remove unknown = %v, want errNoContainer", err) + } + + if err := store.UpsertContainer("s1", "cid-9", "group/project"); err != nil { + t.Fatalf("upsert container: %v", err) + } + sp.setJob("s1", "group/project", stateRunning, "cid-9", "") + if err := sp.RemoveSession(context.Background(), "s1"); err != nil { + t.Fatalf("RemoveSession: %v", err) + } + if !f.hasCall(http.MethodPost, "/containers/cid-9/stop") || !f.hasCall(http.MethodDelete, "/containers/cid-9") { + t.Fatal("container not stopped+removed") + } + if _, ok, _ := store.GetContainer("s1"); ok { + t.Fatal("container row must be deleted") + } + for _, j := range sp.JobsSnapshot() { + if j.SessionID == "s1" && j.State != stateError { + t.Fatalf("job after removal = %+v, want error state", j) + } + } + + // stop failure surfaces, row kept + if err := store.UpsertContainer("s2", "cid-10", "group/project"); err != nil { + t.Fatalf("upsert: %v", err) + } + f.failStop = true + if err := sp.RemoveSession(context.Background(), "s2"); err == nil || !strings.Contains(err.Error(), "docker stop") { + t.Fatalf("remove with stop failure = %v", err) + } + f.assertNoUnknown(t) +} + +func TestSpawnerSeedVolumeWaitContextCancel(t *testing.T) { + useFakeGit(t, fakeGitModeOK) + f := newFakeDocker() + f.waitHang = true // server never answers wait + sp, _ := newTestSpawner(t, f) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan error, 1) + go func() { done <- sp.seedVolume(ctx, "group-project", "lvmh-repo-group-project") }() + waitFor(t, 5*time.Second, func() bool { return f.hasCallSuffix(http.MethodPost, "/wait") }) + cancel() + select { + case err := <-done: + if !errors.Is(err, context.Canceled) { + t.Fatalf("seedVolume after cancel = %v, want context.Canceled", err) + } + case <-time.After(5 * time.Second): + t.Fatal("seedVolume did not return after context cancel") + } +} + +func TestSpawnerSameRepoSpawnsSerialize(t *testing.T) { + useFakeGit(t, fakeGitModeOK) + f := newFakeDocker() + sp, _ := newTestSpawner(t, f) + + l1 := sp.slugLock("a") + l2 := sp.slugLock("a") + if l1 != l2 { + t.Fatal("slugLock must return the same mutex per slug") + } + if sp.slugLock("b") == l1 { + t.Fatal("slugLock must return distinct mutexes per slug") + } + + res1, err := sp.Start(context.Background(), "group/project", "") + if err != nil { + t.Fatalf("Start 1: %v", err) + } + res2, err := sp.Start(context.Background(), "group/project", "") + if err != nil { + t.Fatalf("Start 2: %v", err) + } + waitJobState(t, sp, res1.SessionID, stateRunning) + waitJobState(t, sp, res2.SessionID, stateRunning) + if len(f.createsByName("lvmh-agent-")) != 2 { + t.Fatalf("agent containers = %d, want 2", len(f.createsByName("lvmh-agent-"))) + } +} + +func TestRepoSlugAndUUID(t *testing.T) { + if got := repoSlug("a/b/c"); got != "a-b-c" { + t.Fatalf("repoSlug = %q", got) + } + id := newUUID() + if len(id) != 36 || id[8] != '-' || id[13] != '-' || id[18] != '-' || id[23] != '-' { + t.Fatalf("newUUID shape = %q", id) + } + if id2 := newUUID(); id2 == id { + t.Fatal("newUUID must not repeat") + } +} + +func TestEnvOr(t *testing.T) { + t.Setenv("LVMH_TEST_ENV_OR", " value ") + if got := envOr("LVMH_TEST_ENV_OR", "def"); got != "value" { + t.Fatalf("envOr trimmed = %q", got) + } + t.Setenv("LVMH_TEST_ENV_OR", " ") + if got := envOr("LVMH_TEST_ENV_OR", "def"); got != "def" { + t.Fatalf("envOr whitespace-only = %q", got) + } + if got := envOr("LVMH_TEST_ENV_OR_UNSET", "def"); got != "def" { + t.Fatalf("envOr unset = %q", got) + } +} + +func TestExtractBuildError(t *testing.T) { + cases := []struct { + name string + body string + want string + }{ + {"empty", "", ""}, + {"no-error", `{"stream":"Step 1/3"}` + "\n" + `{"stream":"done"}`, ""}, + {"error-field", `{"error":"nope"}`, "nope"}, + {"error-detail-wins", `{"errorDetail":{"message":"boom"},"error":"generic"}`, "boom"}, + {"mixed-stream", "{\"stream\":\"...\"}\n{\"error\":\"late failure\"}", "late failure"}, + {"non-json-line", "garbage line\n{\"error\":\"after garbage\"}", "after garbage"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := extractBuildError([]byte(tc.body)); got != tc.want { + t.Fatalf("extractBuildError(%q) = %q, want %q", tc.body, got, tc.want) + } + }) + } +} + +func TestTarDir(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "one.txt"), []byte("one"), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + if err := os.MkdirAll(filepath.Join(dir, "sub"), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "sub", "two.txt"), []byte("two"), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + var buf bytes.Buffer + if err := tarDir(&buf, dir); err != nil { + t.Fatalf("tarDir: %v", err) + } + tr := tar.NewReader(bytes.NewReader(buf.Bytes())) + names := map[string]string{} + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + t.Fatalf("tar next: %v", err) + } + body, _ := io.ReadAll(tr) + names[hdr.Name] = string(body) + if hdr.Uname != "root" || hdr.Gname != "root" { + t.Fatalf("header %q owner = %s/%s, want root/root", hdr.Name, hdr.Uname, hdr.Gname) + } + } + for name, want := range map[string]string{"one.txt": "one", "sub/two.txt": "two"} { + if names[name] != want { + t.Fatalf("archive missing %q (have %v)", name, names) + } + } + if _, ok := names["sub/"]; !ok { + t.Fatalf("directories must be archived with trailing slash: %v", names) + } + + if err := tarDir(&buf, filepath.Join(dir, "does-not-exist")); err == nil { + t.Fatal("tarDir of missing dir must fail") + } +} + +func TestGitRunSilentFailure(t *testing.T) { + useFakeGit(t, fakeGitModeSilent) + err := gitRun("", "clone", "x") + if err == nil || strings.Contains(err.Error(), "clone x") { + // silent failure surfaces the bare exec error, not a padded message + t.Fatalf("gitRun silent failure = %v", err) + } +} + +func TestSpawnerNewBadDockerHost(t *testing.T) { + t.Setenv("DOCKER_HOST", "http://") + store := openTestStore(t) + if _, err := NewSpawner(store, NewHub(store), "https://gitlab.example"); err == nil { + t.Fatal("NewSpawner must fail on an unparseable DOCKER_HOST") + } +} + +func TestSpawnerStartDockerUnavailable(t *testing.T) { + f := newFakeDocker() + sp, _ := newTestSpawner(t, f) + // point the spawner at a dead endpoint: last Start request kills nothing + // because the client is already built; close the fake server instead. + sp.cli.Close() + closeDocker(t, sp) + if _, err := sp.Start(context.Background(), "group/project", ""); err == nil || !strings.Contains(err.Error(), "docker unavailable") { + t.Fatalf("Start with dead docker = %v, want docker unavailable", err) + } +} + +// closeDocker re-aims the spawner at a closed server (dial refused). +func closeDocker(t *testing.T, sp *Spawner) { + t.Helper() + dead := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + writeJSONNow(w, http.StatusInternalServerError, `{"message":"dead"}`) + })) + dead.Close() // listener gone: connections refused + if err := sp.cli.Close(); err != nil { + t.Fatalf("close docker client: %v", err) + } + cli, err := client.NewClientWithOpts(client.WithHost("tcp://" + dead.Listener.Addr().String())) + if err != nil { + t.Fatalf("rebuild docker client: %v", err) + } + sp.cli = cli +} + +func TestSpawnerCloneOrUpdateErrors(t *testing.T) { + f := newFakeDocker() + sp, _ := newTestSpawner(t, f) + + // unparseable repo path (control char) → cloneURL parse error + if err := sp.cloneOrUpdate("bad\nrepo", "", repoSlug("bad\nrepo")); err == nil { + t.Fatal("cloneOrUpdate with control-char repo must fail") + } + + // reposDir path occupied by a file → MkdirAll fails + file := filepath.Join(t.TempDir(), "not-a-dir") + if err := os.WriteFile(file, nil, 0o644); err != nil { + t.Fatalf("write file: %v", err) + } + sp.reposDir = file + if err := sp.cloneOrUpdate("group/project", "", "group-project"); err == nil { + t.Fatal("cloneOrUpdate with file reposDir must fail") + } +} + +func TestSpawnerEnsureImageErrors(t *testing.T) { + f := newFakeDocker() + f.images = 0 + sp, _ := newTestSpawner(t, f) + ctx := context.Background() + + // dockerfile missing on disk + sp.dockerfile = filepath.Join(t.TempDir(), "gone.Dockerfile") + if err := sp.ensureImage(ctx); err == nil || !strings.Contains(err.Error(), "worker Dockerfile missing") { + t.Fatalf("ensureImage without dockerfile = %v", err) + } + + // dockerfile exists but cannot be made relative to the build context + sp.dockerfile = filepath.Join(t.TempDir(), "worker.Dockerfile") + if err := os.WriteFile(sp.dockerfile, []byte("FROM scratch"), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + sp.buildContext = "relative-context" + if err := sp.ensureImage(ctx); err == nil { + t.Fatal("ensureImage with relative-context vs absolute dockerfile must fail") + } + + // tar of a missing build context fails + sp.buildContext = filepath.Join(t.TempDir(), "no-such-context") + if err := sp.ensureImage(ctx); err == nil || !strings.Contains(err.Error(), "build context") { + t.Fatalf("ensureImage with missing context = %v", err) + } +} + +func TestSpawnerSessionsVolumeCreateFails(t *testing.T) { + useFakeGit(t, fakeGitModeOK) + f := newFakeDocker() + f.volume["lvmh-repo-group-project"] = true // repo volume exists → skip seed + f.failVolumeCreate = true + sp, _ := newTestSpawner(t, f) + res, err := sp.Start(context.Background(), "group/project", "") + if err != nil { + t.Fatalf("Start: %v", err) + } + job := waitJobState(t, sp, res.SessionID, stateError) + if !strings.Contains(job.Message, volumeSessions) { + t.Fatalf("job message = %q, want sessions volume failure", job.Message) + } +} + +func TestSpawnerSeedVolumeCreateStartFail(t *testing.T) { + useFakeGit(t, fakeGitModeOK) + f := newFakeDocker() + sp, _ := newTestSpawner(t, f) + ctx := context.Background() + + f.failCreate = true + if err := sp.seedVolume(ctx, "group-project", "lvmh-repo-group-project"); err == nil { + t.Fatal("seedVolume with failing create must fail") + } + f.failCreate = false + f.failStart = true + if err := sp.seedVolume(ctx, "group-project", "lvmh-repo-group-project"); err == nil { + t.Fatal("seedVolume with failing start must fail") + } +} + +func TestSpawnerStoreFailures(t *testing.T) { + useFakeGit(t, fakeGitModeOK) + f := newFakeDocker() + sp, _ := newTestSpawner(t, f) + + // closed store: GetContainer errors + deadStore := openTestStore(t) + _ = deadStore.Close() + sp.store = deadStore + if err := sp.RemoveSession(context.Background(), "s1"); err == nil || !strings.Contains(err.Error(), "closed") { + t.Fatalf("RemoveSession with closed store = %v", err) + } + + // runJob completes container start but cannot persist the row + sp2, _ := newTestSpawner(t, newFakeDocker()) + dead2 := openTestStore(t) + _ = dead2.Close() + sp2.store = dead2 + res, err := sp2.Start(context.Background(), "group/project", "") + if err != nil { + t.Fatalf("Start: %v", err) + } + job := waitJobState(t, sp2, res.SessionID, stateError) + if !strings.Contains(job.Message, "not persisted") { + t.Fatalf("job message = %q, want persistence failure", job.Message) + } +} + +func TestSpawnerCloneOrUpdatePullFails(t *testing.T) { + useFakeGit(t, fakeGitModeFail) + f := newFakeDocker() + sp, _ := newTestSpawner(t, f) + dir := filepath.Join(sp.reposDir, "group-project") + if err := os.MkdirAll(filepath.Join(dir, ".git"), 0o755); err != nil { + t.Fatalf("mkdir .git: %v", err) + } + err := sp.cloneOrUpdate("group/project", "", repoSlug("group/project")) + if err == nil || !strings.Contains(err.Error(), "git pull") { + t.Fatalf("cloneOrUpdate pull failure = %v, want git pull error", err) + } +} + +func TestSpawnerWorkerCreateStartFailures(t *testing.T) { + // repo volume pre-exists → seeding skipped → failures surface at the + // worker container create/start instead of at the seed container. + useFakeGit(t, fakeGitModeOK) + f := newFakeDocker() + f.volume["lvmh-repo-group-project"] = true + sp, _ := newTestSpawner(t, f) + + f.failCreate = true + res, err := sp.Start(context.Background(), "group/project", "") + if err != nil { + t.Fatalf("Start: %v", err) + } + if job := waitJobState(t, sp, res.SessionID, stateError); !strings.Contains(job.Message, "docker create") { + t.Fatalf("job = %+v, want docker create failure", job) + } + + f.failCreate = false + f.failStart = true + res2, err := sp.Start(context.Background(), "group/project", "") + if err != nil { + t.Fatalf("Start 2: %v", err) + } + if job := waitJobState(t, sp, res2.SessionID, stateError); !strings.Contains(job.Message, "docker start") { + t.Fatalf("job = %+v, want docker start failure", job) + } + // failed worker start must remove the created container + if f.countCalls(http.MethodDelete, "/containers/") == 0 { + t.Fatal("failed start must clean up the created container") + } +} + +func TestSpawnerBuildHTTPErrors(t *testing.T) { + useFakeGit(t, fakeGitModeOK) + f := newFakeDocker() + f.images = 0 + f.failBuildHTTP = true // /build endpoint itself 500s + sp, _ := newTestSpawner(t, f) + res, err := sp.Start(context.Background(), "group/project", "") + if err != nil { + t.Fatalf("Start: %v", err) + } + if job := waitJobState(t, sp, res.SessionID, stateError); !strings.Contains(job.Message, "docker build") { + t.Fatalf("job = %+v, want docker build failure", job) + } + + // dead docker endpoint: ensureImage cannot even list images + sp2, _ := newTestSpawner(t, newFakeDocker()) + sp2.images0AndDead(t) + if err := sp2.ensureImage(context.Background()); err == nil { + t.Fatal("ensureImage with dead docker must fail") + } +} + +func (sp *Spawner) images0AndDead(t *testing.T) { + t.Helper() + dead := httptest.NewServer(http.NotFoundHandler()) + t.Cleanup(dead.Close) + dead.Close() + if err := sp.cli.Close(); err != nil { + t.Fatalf("close client: %v", err) + } + cli, err := client.NewClientWithOpts(client.WithHost("tcp://" + dead.Listener.Addr().String())) + if err != nil { + t.Fatalf("client: %v", err) + } + sp.cli = cli +} + +// failingWriter errors on the Nth write onward. +type failingWriter struct { + n, failAt int +} + +func (w *failingWriter) Write(p []byte) (int, error) { + w.n++ + if w.n >= w.failAt { + return 0, fmt.Errorf("write boom %d", w.n) + } + return len(p), nil +} + +func TestTarDirWriteErrors(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "f.txt"), []byte("content"), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + if err := tarDir(&failingWriter{failAt: 1}, dir); err == nil { + t.Fatal("tarDir into failing writer must fail") + } + if err := tarDir(&failingWriter{failAt: 4}, dir); err == nil { + t.Fatal("tarDir copy into failing writer must fail") + } +} + +func TestTarDirUnreadableFile(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root ignores file permissions") + } + dir := t.TempDir() + secret := filepath.Join(dir, "secret.txt") + if err := os.WriteFile(secret, []byte("x"), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + if err := os.Chmod(secret, 0o000); err != nil { + t.Fatalf("chmod: %v", err) + } + t.Cleanup(func() { _ = os.Chmod(secret, 0o644) }) + var buf bytes.Buffer + if err := tarDir(&buf, dir); err == nil { + t.Fatal("tarDir with unreadable file must fail") + } +} + +func TestGitRunUnderivableExit(t *testing.T) { + // gitRun is the exec seam: with real git present, invoking a nonexistent + // subcommand must surface stderr, and trimming applies to long output. + if err := gitRun("", "version"); err != nil { + t.Fatalf("git version: %v", err) + } + err := gitRun("", "this-subcommand-does-not-exist") + if err == nil || !strings.Contains(err.Error(), "this-subcommand-does-not-exist") { + t.Fatalf("gitRun unknown subcommand = %v", err) + } +} diff --git a/daemon/store_extra_test.go b/daemon/store_extra_test.go new file mode 100644 index 0000000..92e680d --- /dev/null +++ b/daemon/store_extra_test.go @@ -0,0 +1,102 @@ +package main + +// store_extra_test.go — open failure, closed-handle error branches, corrupt rows. + +import ( + "path/filepath" + "strings" + "testing" +) + +func TestOpenStoreBadPath(t *testing.T) { + if _, err := OpenStore(filepath.Join(t.TempDir(), "occupied.db", "x.db")); err == nil { + t.Fatal("OpenStore under a file path must fail") + } +} + +func TestStoreClosedHandleErrors(t *testing.T) { + store := openTestStore(t) + if err := store.Close(); err != nil { + t.Fatalf("close: %v", err) + } + errStr := func(err error) string { + if err == nil { + return "" + } + return err.Error() + } + if err := store.AppendEvent(Event{SessionID: "s", Seq: 1, Type: evAgentSettled}); errStr(err) == "" { + t.Fatal("AppendEvent on closed store must error") + } + if _, err := store.LastSeq("s"); errStr(err) == "" { + t.Fatal("LastSeq on closed store must error") + } + if _, err := store.EventsAfter("s", 0, 10); errStr(err) == "" { + t.Fatal("EventsAfter on closed store must error") + } + if err := store.UpsertSession(SessionInfo{ID: "s"}); errStr(err) == "" { + t.Fatal("UpsertSession on closed store must error") + } + if err := store.TouchSession("s", 1, 1); errStr(err) == "" { + t.Fatal("TouchSession on closed store must error") + } + if err := store.SetOnline("s", true); errStr(err) == "" { + t.Fatal("SetOnline on closed store must error") + } + if _, err := store.Sessions(); errStr(err) == "" { + t.Fatal("Sessions on closed store must error") + } + if _, _, err := store.GetSetting("k"); errStr(err) == "" { + t.Fatal("GetSetting on closed store must error") + } + if err := store.SetSetting("k", "v"); errStr(err) == "" { + t.Fatal("SetSetting on closed store must error") + } + if err := store.DeleteSetting("k"); errStr(err) == "" { + t.Fatal("DeleteSetting on closed store must error") + } + if err := store.UpsertContainer("s", "c", "r"); errStr(err) == "" { + t.Fatal("UpsertContainer on closed store must error") + } + if _, _, err := store.GetContainer("s"); errStr(err) == "" { + t.Fatal("GetContainer on closed store must error") + } + if err := store.DeleteContainer("s"); errStr(err) == "" { + t.Fatal("DeleteContainer on closed store must error") + } +} + +func TestStoreCorruptRows(t *testing.T) { + store := openTestStore(t) + + // undecodable info blob → Sessions errors + if _, err := store.db.Exec(`INSERT INTO sessions(id, info) VALUES('bad', 'not json')`); err != nil { + t.Fatalf("insert corrupt session: %v", err) + } + if _, err := store.Sessions(); err == nil || !strings.Contains(err.Error(), "decode session info") { + t.Fatalf("Sessions with corrupt info = %v", err) + } + + // non-integer seq → EventsAfter scan error + if _, err := store.db.Exec(`INSERT INTO events(sessionId, seq, ts, type, payload) + VALUES('s1', 'not-a-number', 1, 'x', '{}')`); err != nil { + t.Fatalf("insert corrupt event: %v", err) + } + if _, err := store.EventsAfter("s1", 0, 10); err == nil { + t.Fatal("EventsAfter with text seq must scan-error") + } +} + +func TestStoreAppendEventDefaultPayload(t *testing.T) { + store := openTestStore(t) + if err := store.AppendEvent(Event{SessionID: "s1", Seq: 1, TS: 1, Type: evBye}); err != nil { + t.Fatalf("append empty payload: %v", err) + } + events, err := store.EventsAfter("s1", 0, 10) + if err != nil || len(events) != 1 { + t.Fatalf("events = %v %v", events, err) + } + if string(events[0].Payload) != "{}" { + t.Fatalf("payload = %q, want {}", events[0].Payload) + } +} diff --git a/e2e/scenarios/auth.mjs b/e2e/scenarios/auth.mjs index 77d1fcd..314f0e0 100644 --- a/e2e/scenarios/auth.mjs +++ b/e2e/scenarios/auth.mjs @@ -6,37 +6,55 @@ import { agentHello, rest, sessionSnapshot, sid, WSSock } from "../lib.mjs"; export async function run(ctx) { - const { r, base, token, agentUrl, webUrl } = ctx; + const { r, base, token, agentUrl, webUrl } = ctx; - const noToken = await rest(base, null, "/api/sessions"); - r.check("REST without token → 401", noToken.status === 401, `got ${noToken.status}`); + const noToken = await rest(base, null, "/api/sessions"); + r.check( + "REST without token → 401", + noToken.status === 401, + `got ${noToken.status}`, + ); - const badToken = await rest(base, "definitely-wrong", "/api/sessions"); - r.check("REST with bad token → 401", badToken.status === 401, `got ${badToken.status}`); + const badToken = await rest(base, "definitely-wrong", "/api/sessions"); + r.check( + "REST with bad token → 401", + badToken.status === 401, + `got ${badToken.status}`, + ); - const goodToken = await rest(base, token, "/api/sessions"); - r.check("REST with correct token → 200", goodToken.status === 200, `got ${goodToken.status}`); + const goodToken = await rest(base, token, "/api/sessions"); + r.check( + "REST with correct token → 200", + goodToken.status === 200, + `got ${goodToken.status}`, + ); - // Web WS: token arrives as ?token= query param (browsers cannot set headers). - const badWeb = new WSSock(`${webUrl}?token=wrong-token`); - const webOpened = await badWeb.opened(); - r.check("web WS with bad ?token= rejected before upgrade", !webOpened, "connection accepted"); - badWeb.close(); + // Web WS: token arrives as ?token= query param (browsers cannot set headers). + const badWeb = new WSSock(`${webUrl}?token=wrong-token`); + const webOpened = await badWeb.opened(); + r.check( + "web WS with bad ?token= rejected before upgrade", + !webOpened, + "connection accepted", + ); + badWeb.close(); - // Agent WS with bad bearer must be rejected: PROTOCOL.md §Auth requires - // closing the upgrade on mismatch (fixed: Routes() wraps /agent/ws in - // s.bearerAuth). - const badAgent = new WSSock(agentUrl, { headers: { Authorization: `Bearer wrong-token` } }); - const agentOpened = await badAgent.opened(); - let welcomed = null; - if (agentOpened) { - agentHello(badAgent, sessionSnapshot(sid("e2e-auth-bad"))); - welcomed = await badAgent.waitForFrame((f) => f.type === "welcome"); - } - r.check( - "agent WS with bad token rejected (closed before welcome)", - !agentOpened || welcomed === null, - welcomed ? "welcome received despite wrong bearer" : "", - ); - badAgent.close(); + // Agent WS with bad bearer must be rejected: PROTOCOL.md §Auth requires + // closing the upgrade on mismatch (fixed: Routes() wraps /agent/ws in + // s.bearerAuth). + const badAgent = new WSSock(agentUrl, { + headers: { Authorization: `Bearer wrong-token` }, + }); + const agentOpened = await badAgent.opened(); + let welcomed = null; + if (agentOpened) { + agentHello(badAgent, sessionSnapshot(sid("e2e-auth-bad"))); + welcomed = await badAgent.waitForFrame((f) => f.type === "welcome"); + } + r.check( + "agent WS with bad token rejected (closed before welcome)", + !agentOpened || welcomed === null, + welcomed ? "welcome received despite wrong bearer" : "", + ); + badAgent.close(); } diff --git a/web/package-lock.json b/web/package-lock.json index 7329536..6f7a71f 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -13,11 +13,77 @@ "react-router-dom": "^7.6.0" }, "devDependencies": { + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.5", "@types/react": "^19.1.0", "@types/react-dom": "^19.1.0", "@vitejs/plugin-react": "^4.4.1", + "@vitest/coverage-v8": "^4.1.10", + "jsdom": "^30.0.1", "typescript": "^5.8.3", - "vite": "^6.3.5" + "vite": "^6.3.5", + "vitest": "^4.1.10" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "6.0.7", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.7.tgz", + "integrity": "sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.3.0", + "@csstools/css-color-parser": "^4.1.10", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz", + "integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" } }, "node_modules/@babel/code-frame": { @@ -254,6 +320,16 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", @@ -302,6 +378,169 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", + "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.0.tgz", + "integrity": "sha512-5+5LEmFuY1AjXdYhmgjTJogtQnP1evJ1zrBZGUNZ0thkpwnnmKxcHdAMn/OtFjAb25zA+jKDVYVRl+5G7rjv1A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.1", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.8.tgz", + "integrity": "sha512-CpMLjAvwQg3BL5S0IeqsZNMH7EQrEWi0kLKOC13ZBF0ZwERiLWlibNPJr8G1kdU3Ms/r2KiNrF81pUh2HwAHdg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -744,6 +983,24 @@ "node": ">=18" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1168,6 +1425,120 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.1.tgz", + "integrity": "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=22", + "npm": ">=6", + "yarn": ">=1" + }, + "peerDependencies": { + "@testing-library/dom": ">=10 <11", + "vitest": ">= 0.32" + }, + "peerDependenciesMeta": { + "vitest": { + "optional": true + } + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.5", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.5.tgz", + "integrity": "sha512-FhqjldLTpteueBaKflhNFlMT3+PM0O5fiBUivht6b9CZ1eesJyy7+g3Jr7XwJzt/Hip3ZG5hWwK1MX1FuDiE4w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1213,6 +1584,24 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1261,6 +1650,214 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz", + "integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.10", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.10", + "vitest": "4.1.10" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.5.tgz", + "integrity": "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.11.15", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.15.tgz", @@ -1274,6 +1871,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/browserslist": { "version": "4.28.8", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", @@ -1329,6 +1936,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1349,6 +1966,27 @@ "url": "https://opencollective.com/express" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1356,6 +1994,35 @@ "dev": true, "license": "MIT" }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1374,6 +2041,31 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/electron-to-chromium": { "version": "1.5.409", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.409.tgz", @@ -1381,6 +2073,26 @@ "dev": true, "license": "ISC" }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -1433,6 +2145,26 @@ "node": ">=6" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1476,6 +2208,92 @@ "node": ">=6.9.0" } }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -1483,6 +2301,57 @@ "dev": true, "license": "MIT" }, + "node_modules/jsdom": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz", + "integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^6.0.5", + "@asamuzakjp/dom-selector": "^8.3.0", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.7", + "@exodus/bytes": "^1.15.1", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.2", + "undici": "^8.9.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -1519,6 +2388,85 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.4.tgz", + "integrity": "sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1555,6 +2503,40 @@ "node": ">=18" } }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1604,6 +2586,32 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/react": { "version": "19.2.8", "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", @@ -1625,6 +2633,14 @@ "react": "^19.2.8" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -1673,6 +2689,30 @@ "react-dom": ">=18" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rollup": { "version": "4.62.4", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", @@ -1719,6 +2759,19 @@ "fsevents": "~2.3.2" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -1741,6 +2794,13 @@ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "license": "MIT" }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1751,6 +2811,70 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -1768,6 +2892,62 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.10.tgz", + "integrity": "sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.10" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.10.tgz", + "integrity": "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -1782,6 +2962,16 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz", + "integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/update-browserslist-db": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz", @@ -1888,6 +3078,178 @@ } } }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz", + "integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/web/package.json b/web/package.json index 0b4c53d..9584afb 100644 --- a/web/package.json +++ b/web/package.json @@ -6,7 +6,9 @@ "scripts": { "dev": "vite", "build": "tsc --noEmit && vite build", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run", + "coverage": "vitest run --coverage" }, "dependencies": { "react": "^19.1.0", @@ -14,10 +16,16 @@ "react-router-dom": "^7.6.0" }, "devDependencies": { + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.5", "@types/react": "^19.1.0", "@types/react-dom": "^19.1.0", "@vitejs/plugin-react": "^4.4.1", + "@vitest/coverage-v8": "^4.1.10", + "jsdom": "^30.0.1", "typescript": "^5.8.3", - "vite": "^6.3.5" + "vite": "^6.3.5", + "vitest": "^4.1.10" } } diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx new file mode 100644 index 0000000..6b1c466 --- /dev/null +++ b/web/src/App.test.tsx @@ -0,0 +1,155 @@ +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { afterEach, describe, expect, it } from "vitest"; +import App from "./App"; +import { clearSettings } from "./settings"; +import { FakeWebSocket, jsonResponse, mockFetchJson, seedSettings, stubReload } from "./test/setup"; +import type { SessionListItem } from "./protocol"; + +const sessions: SessionListItem[] = [ + { id: "s1", name: "alpha", cwd: "/w", model: "glm-5.3", provider: "zai-renaud", agent: false, repo: "g/a", startedAt: 10, online: true, lastEventAt: null }, + { id: "s2", name: null, cwd: "/w", model: "glm-5.3", provider: "zai-renaud", agent: true, repo: "g/b", startedAt: 20, online: false, lastEventAt: null }, + // bare session: exercises null-name/null-repo fallbacks + { id: "s3", name: null, cwd: "/w", model: "glm-5.3", provider: "zai-renaud", agent: false, repo: null, startedAt: 30, online: false, lastEventAt: null }, +]; + +function seedApi(): void { + mockFetchJson((url) => { + if (url.includes("/api/sessions")) return url.includes("/events") ? [] : sessions; + if (url.includes("/api/gitlab/status")) return { connected: false, baseUrl: "https://gl" }; + return []; + }); +} + +function renderApp(path = "/"): ReturnType { + return render( + + + + ); +} + +afterEach(() => { + clearSettings(); +}); + +describe("App gate", () => { + it("shows the settings gate when unconfigured", () => { + renderApp(); + expect(screen.getByText("Connect to your lvmh daemon.")).toBeInTheDocument(); + expect(screen.queryByLabelText("Sessions")).toBeNull(); + }); + + it("after saving settings the shell renders", async () => { + seedApi(); + renderApp(); + fireEvent.input(screen.getByLabelText("Bearer token"), { target: { value: "tok" } }); + fireEvent.submit(screen.getByRole("button", { name: "Connect" }).closest("form") as HTMLFormElement); + await screen.findByLabelText("Sessions"); + }); +}); + +describe("App shell", () => { + it("renders sidebar sessions sorted by activity, conn state, routes and toasts", async () => { + seedApi(); + seedSettings(); + renderApp("/"); + + await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1)); + const sock = FakeWebSocket.last(); + act(() => sock.serverOpen()); + expect(screen.getByRole("img", { name: "connection open" })).toBeInTheDocument(); + + const sidebar = screen.getByLabelText("Sessions"); + const links = Array.from(sidebar.querySelectorAll("a.session-link")).map((a) => a.getAttribute("href")); + expect(links).toEqual(["/s/s3", "/s/s2", "/s/s1"]); // all null lastEventAt -> startedAt desc + expect(screen.getByTitle("ws open")).toBeInTheDocument(); + + // root route lists sessions + expect(screen.getByRole("heading", { name: "Sessions" })).toBeInTheDocument(); + expect(screen.getByLabelText("Open session alpha")).toBeInTheDocument(); + + // spawn link navigates + fireEvent.click(screen.getByText("+ Spawn session")); + await screen.findByRole("heading", { name: "Spawn" }); + + // unknown route redirects to sessions + const { container: c2 } = renderApp("/nowhere"); + await waitFor(() => expect(c2.querySelector(".session-card, .empty")).not.toBeNull()); + }); + + it("navigates to a session chat route", async () => { + seedApi(); + seedSettings(); + renderApp("/s/s1"); + await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1)); + act(() => FakeWebSocket.last().serverOpen()); + await screen.findByLabelText("Message"); + expect(screen.getAllByText("alpha").length).toBeGreaterThan(0); + }); + + it("menu button toggles the sidebar and it closes on navigation", async () => { + seedApi(); + seedSettings(); + renderApp("/"); + await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1)); + act(() => FakeWebSocket.last().serverOpen()); + await screen.findByRole("heading", { name: "Sessions" }); + + const menu = screen.getByLabelText("Open menu"); + fireEvent.click(menu); + expect(screen.getByLabelText("Sessions").className).toContain("open"); + + // backdrop closes it + fireEvent.click(container_backdrop()!); + expect(screen.getByLabelText("Sessions").className).not.toContain("open"); + + fireEvent.click(menu); + fireEvent.click(screen.getByText("+ Spawn session")); + await waitFor(() => expect(screen.getByLabelText("Sessions").className).not.toContain("open")); + }); + + it("disconnect clears settings and reloads", async () => { + const loc = stubReload(); + seedApi(); + seedSettings(); + renderApp("/"); + await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1)); + act(() => FakeWebSocket.last().serverOpen()); + await screen.findByRole("heading", { name: "Sessions" }); + + fireEvent.click(screen.getByLabelText("Disconnect and clear settings")); + expect(loc.reload).toHaveBeenCalled(); + expect(localStorage.getItem("lvmh.settings")).toBeNull(); + loc.restore(); + }); + + it("ws auth failure clears settings and re-gates", async () => { + const loc = stubReload(); + seedApi(); + seedSettings(); + renderApp("/"); + await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1)); + act(() => FakeWebSocket.last().serverOpen()); + await screen.findByRole("heading", { name: "Sessions" }); + + act(() => FakeWebSocket.last().serverClose(1008)); + expect(loc.reload).toHaveBeenCalled(); + await waitFor(() => expect(screen.getByText("Connect to your lvmh daemon.")).toBeInTheDocument()); + loc.restore(); + }); + + it("REST seed failure surfaces a toast", async () => { + mockFetchJson((url) => { + if (url.includes("/api/sessions") && !url.includes("/events")) return jsonResponse({ error: "seed fail" }, 500); + return []; + }); + seedSettings(); + renderApp("/"); + await screen.findByText("sessions: seed fail"); + }); +}); + +function container_backdrop(): HTMLElement | null { + return document.querySelector(".sidebar-backdrop"); +} diff --git a/web/src/ChatStream.test.tsx b/web/src/ChatStream.test.tsx new file mode 100644 index 0000000..2a395c2 --- /dev/null +++ b/web/src/ChatStream.test.tsx @@ -0,0 +1,179 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; +import type { ChatMessage, ToolState } from "./derive"; +import ChatStream, { Bubble, TypingIndicator } from "./ChatStream"; + +function msg(partial: Partial): ChatMessage { + return { + key: `k-${Math.random()}`, + role: "assistant", + text: "", + thinking: null, + toolCalls: [], + toolCallId: null, + streaming: false, + ...partial, + }; +} + +const tool = (p: Partial): ToolState => ({ + id: "c1", + name: "bash", + args: "", + running: false, + isError: false, + preview: "", + ...p, +}); + +describe("Bubble", () => { + it("renders plain text per role class", () => { + const { container } = render( + + ); + expect(container.querySelector(".bubble-row.user")).not.toBeNull(); + expect(container.textContent).toContain("hi there"); + }); + + it("renders empty assistant text as nothing but shows nothing when empty", () => { + const { container } = render(); + expect(container.querySelector(".bubble")?.children).toHaveLength(0); + }); + + it("toolResult collapses to a one-line preview, expands to full text", async () => { + const long = `${"x".repeat(200)}`; + render(); + const details = screen.getByText("result").closest("details") as HTMLDetailsElement; + expect(details.open).toBe(false); + expect(details.textContent).toContain("…"); + await userEvent.click(screen.getByText("result")); + expect(details.open).toBe(true); + expect(details.textContent).toContain(long); + }); + + it("toolResult with short text keeps full one-line preview", () => { + render(); + const details = screen.getByText("result").closest("details") as HTMLDetailsElement; + expect(details.textContent).toContain("short out"); + expect(details.textContent).not.toContain("…"); + }); + + it("flattens whitespace in previews", () => { + render(); + expect(screen.getAllByText("a b c").length).toBeGreaterThan(0); + }); + + it("assistant tool calls attach tool cards", async () => { + const tools = new Map([ + ["c1", tool({ id: "c1", name: "bash", args: "ls -la", running: false, isError: false, preview: "file" })], + ]); + render( + + ); + expect(screen.getByText("🛠 bash")).toBeInTheDocument(); + expect(screen.getByText("finished")).toBeInTheDocument(); + + const summary = screen.getByText("🛠 bash").closest("summary") as HTMLElement; + const card = summary.closest("details") as HTMLDetailsElement; + expect(card.open).toBe(false); + await userEvent.click(summary); + expect(card.open).toBe(true); + expect(card.textContent).toContain("ls -la"); + expect(card.textContent).toContain("file"); + }); + + it("tool card status variants: running, error, done", () => { + const tools = new Map([ + ["c1", tool({ id: "c1", running: true })], + ["c2", tool({ id: "c2", running: false, isError: true })], + ["c3", tool({ id: "c3", running: false, isError: false })], + ]); + render( + ({ id, name: `t-${id}`, argsJson: "{}" })), + })} + tools={tools} + /> + ); + expect(screen.getByText("working…")).toBeInTheDocument(); + expect(screen.getByText("error")).toBeInTheDocument(); + expect(screen.getByText("done")).toBeInTheDocument(); + }); + + it("tool call with no matching state renders no card", () => { + const { container } = render( + + ); + expect(container.querySelectorAll(".tool-card")).toHaveLength(0); + }); + + it("thinking block only for non-empty thinking", async () => { + render(); + const details = screen.getByText("thinking").closest("details") as HTMLDetailsElement; + await userEvent.click(screen.getByText("thinking")); + expect(details.open).toBe(true); + expect(details.textContent).toContain("because"); + + const { container } = render(); + expect(container.querySelector(".thinking")).toBeNull(); + }); + + it("streaming bubble shows the caret", () => { + const { container } = render(); + expect(container.querySelector(".stream-caret")).not.toBeNull(); + }); +}); + +describe("TypingIndicator", () => { + it("renders three dots with aria-live", () => { + const { container } = render(); + expect(container.querySelector('[aria-live="polite"]')).not.toBeNull(); + expect(container.querySelectorAll(".dot")).toHaveLength(3); + }); +}); + +describe("ChatStream", () => { + it("renders messages and typing indicator while busy with no open stream", () => { + const { container, rerender } = render( + + ); + expect(container.querySelector(".typing")).not.toBeNull(); + + rerender( + + ); + expect(container.querySelector(".typing")).toBeNull(); + rerender(); + expect(container.querySelector(".typing")).toBeNull(); + }); + + it("sticks to bottom on new messages, un-pins on user scroll up, re-pins near bottom", () => { + const { container, rerender } = render(); + const scroller = container.querySelector(".chat-scroll") as HTMLElement; + Object.defineProperty(scroller, "scrollHeight", { configurable: true, value: 1000 }); + Object.defineProperty(scroller, "clientHeight", { configurable: true, value: 300 }); + + scroller.dispatchEvent(new Event("scroll")); + // pinned: scrollTop at bottom + scroller.scrollTop = 700; + Object.defineProperty(scroller, "scrollTop", { configurable: true, writable: true, value: 700 }); + scroller.dispatchEvent(new Event("scroll")); + rerender(); + expect(scroller.scrollTop).toBe(1000); + + // scroll far up -> unpin + Object.defineProperty(scroller, "scrollTop", { configurable: true, writable: true, value: 0 }); + scroller.dispatchEvent(new Event("scroll")); + rerender(); + expect(scroller.scrollTop).toBe(0); + + // scroll near bottom (within 80px) -> pinned again + Object.defineProperty(scroller, "scrollTop", { configurable: true, writable: true, value: 940 }); + scroller.dispatchEvent(new Event("scroll")); + rerender(); + expect(scroller.scrollTop).toBe(1000); + }); +}); diff --git a/web/src/ChatView.test.tsx b/web/src/ChatView.test.tsx new file mode 100644 index 0000000..d96325a --- /dev/null +++ b/web/src/ChatView.test.tsx @@ -0,0 +1,297 @@ +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { EventFrame } from "./protocol"; +import ChatView from "./ChatView"; +import type { SessionsStore } from "./store"; +import { jsonResponse, mockFetchJson, seedSettings } from "./test/setup"; +import type { SessionListItem } from "./protocol"; + +let seq: number = 0; +function ev(type: string, extra: Partial = {}): EventFrame { + seq += 1; + return { v: 1, sessionId: "s1", seq, ts: 0, type, ...extra } as EventFrame; +} + +const sessions: SessionListItem[] = [ + { + id: "s1", + name: "worker", + cwd: "/w", + model: "glm-5.3", + provider: "zai-renaud", + agent: false, + repo: null, + startedAt: 0, + online: true, + lastEventAt: 1, + }, +]; + +function makeStore(over: Partial = {}): SessionsStore { + return { + sessions, + state: "open", + spawnJobs: [], + refresh: async () => undefined, + subscribe: (sessionId: string, onEvents: (events: EventFrame[]) => void): (() => void) => { + currentSub = { sessionId, onEvents }; + return () => { + if (currentSub !== null && currentSub.sessionId === sessionId) currentSub = null; + }; + }, + ...over, + }; +} + +interface ActiveSub { + sessionId: string; + onEvents: (events: EventFrame[]) => void; +} +let currentSub: ActiveSub | null = null; + +function push(events: EventFrame[]): void { + act(() => currentSub?.onEvents(events)); +} + +function renderChat(store: SessionsStore, path = "/s/s1"): ReturnType { + return render( + + + } /> + OTHER} /> + + + ); +} + +const pushToast = vi.fn(); + +function historyEvents(): EventFrame[] { + seq = 0; + return [ + ev("message_end", { message: { role: "user", id: "u1", text: "hello there", thinking: null, toolCalls: [], toolCallId: null } }), + ev("agent_start"), + ev("message_end", { message: { role: "assistant", id: "a1", text: "hi!", thinking: "hmm", toolCalls: [], toolCallId: null } }), + ev("agent_settled"), + ]; +} + +beforeEach(() => { + seq = 0; + currentSub = null; + pushToast.mockClear(); + seedSettings(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("ChatView", () => { + it("renders history from REST on mount and subscribes to the WS stream", async () => { + const fetchMock = mockFetchJson((url) => { + if (url.startsWith("http://srv/api/sessions/s1/events")) return historyEvents(); + return []; + }); + const store = makeStore(); + const { rerender } = renderChat(store); + + await screen.findByText("hello there"); + expect(screen.getByText("hi!")).toBeInTheDocument(); + + await waitFor(() => expect(currentSub).not.toBeNull()); + expect(currentSub?.sessionId).toBe("s1"); + expect(fetchMock).toHaveBeenCalled(); + + // header shows session name + model + online dot + expect(screen.getByText("worker")).toBeInTheDocument(); + expect(screen.getByText("glm-5.3")).toBeInTheDocument(); + expect(screen.getByTitle("online")).toBeInTheDocument(); + + rerender( + + + } /> + OTHER} /> + + + ); + }); + + it("live delta streaming appends into a streaming bubble and ends it", async () => { + mockFetchJson(() => []); + renderChat(makeStore()); + await waitFor(() => expect(currentSub).not.toBeNull()); + + push([ + ev("message_start", { message: { role: "assistant", id: "a9", text: "", thinking: null, toolCalls: [], toolCallId: null } }), + ]); + push([ev("message_update", { delta: "Hel" })]); + push([ev("message_update", { delta: "lo" })]); + expect(screen.getByText("Hello")).toBeInTheDocument(); + expect(screen.queryByLabelText("Send message")).toBeNull(); + expect(screen.getByLabelText("Abort current run")).toBeInTheDocument(); + + push([ + ev("message_end", { message: { role: "assistant", id: "a9", text: "Hello world", thinking: null, toolCalls: [], toolCallId: null } }), + ev("agent_settled"), + ]); + expect(screen.getByText("Hello world")).toBeInTheDocument(); + expect(screen.getByLabelText("Send message")).toBeInTheDocument(); + }); + + it("typing indicator shows while busy with no open stream", async () => { + mockFetchJson(() => []); + const { container } = renderChat(makeStore()); + await waitFor(() => expect(currentSub).not.toBeNull()); + + push([ev("agent_start")]); + expect(container.querySelector(".typing")).not.toBeNull(); + + push([ev("agent_settled")]); + expect(container.querySelector(".typing")).toBeNull(); + }); + + it("409 on send toasts 'session offline'", async () => { + let n = 0; + mockFetchJson((_url, init) => { + if (init?.method === "POST") { + n += 1; + return jsonResponse({ error: "session offline" }, n === 1 ? 409 : 200); + } + return []; + }); + renderChat(makeStore()); + const ta = screen.getByLabelText("Message"); + await userEvent.type(ta, "go"); + await userEvent.click(screen.getByLabelText("Send message")); + await vi.waitFor(() => expect(pushToast).toHaveBeenCalledWith("session offline")); + }); + + it("non-409 send failure toasts the error message", async () => { + mockFetchJson((_url, init) => { + if (init?.method === "POST") return jsonResponse({ error: "nope" }, 500); + return []; + }); + renderChat(makeStore()); + await userEvent.type(screen.getByLabelText("Message"), "go"); + await userEvent.click(screen.getByLabelText("Send message")); + await vi.waitFor(() => expect(pushToast).toHaveBeenCalledWith("nope")); + }); + + it("Enter sends, Shift+Enter adds a newline, send disabled while empty or sending", async () => { + const fetchMock = mockFetchJson((_url, init) => (init?.method === "POST" ? { ok: true } : [])); + renderChat(makeStore()); + const ta = screen.getByLabelText("Message") as HTMLTextAreaElement; + const send = screen.getByLabelText("Send message") as HTMLButtonElement; + expect(send).toBeDisabled(); + + await userEvent.type(ta, "hello"); + expect(send).not.toBeDisabled(); + + fireEvent.keyDown(ta, { key: "Enter", shiftKey: true }); + fireEvent.click(send); + await userEvent.clear(ta); + fireEvent.keyDown(ta, { key: "Enter" }); // empty draft: send() early-returns + await vi.waitFor(() => { + const post = fetchMock.mock.calls.find((c) => (c[1] as RequestInit | undefined)?.method === "POST"); + expect(post).toBeDefined(); + expect((post?.[1] as RequestInit).body).toBe(JSON.stringify({ message: "hello" })); + }); + expect(ta.value).toBe(""); + }); + + it("abort posts to the abort route", async () => { + const fetchMock = mockFetchJson((_url, init) => (init?.method === "POST" ? { ok: true } : [])); + renderChat(makeStore()); + push([ev("agent_start")]); + await userEvent.click(screen.getByLabelText("Abort current run")); + await vi.waitFor(() => { + const abortCall = fetchMock.mock.calls.find( + (c) => (c[1] as RequestInit | undefined)?.method === "POST" && String(c[0]).endsWith("/abort") + ); + expect(abortCall).toBeDefined(); + }); + }); + + it("abort failure toasts", async () => { + mockFetchJson((url, init) => { + if (init?.method === "POST" && url.endsWith("/abort")) return jsonResponse({ error: "abort failed" }, 500); + return []; + }); + renderChat(makeStore()); + push([ev("agent_start")]); + await userEvent.click(screen.getByLabelText("Abort current run")); + await vi.waitFor(() => expect(pushToast).toHaveBeenCalledWith("abort failed")); + }); + + it("history load failure shows the error page with a back link", async () => { + mockFetchJson((url) => { + if (url.includes("/events")) return jsonResponse({ error: "db gone" }, 500); + return []; + }); + renderChat(makeStore()); + expect(await screen.findByText(/Failed to load history: db gone/)).toBeInTheDocument(); + expect(screen.getByText("Back to sessions")).toBeInTheDocument(); + }); + + it("refetches missed persisted events when the ws (re)opens", async () => { + let after = ""; + mockFetchJson((url) => { + const m = /[?&]after=(\d+)/.exec(url); + if (m !== null) after = m[1] ?? ""; + if (url.includes("/events")) return [ev("message_end", { message: { role: "user", id: "u2", text: "caught up", thinking: null, toolCalls: [], toolCallId: null } })]; + return []; + }); + const store = makeStore({ state: "closed" }); + const { rerender } = render( + + + } /> + + + ); + await screen.findByText("caught up"); + expect(after).toBe("0"); + + // reconnect: state closed -> open triggers the after=N refetch + act(() => { + rerender( + + + } /> + + + ); + }); + await vi.waitFor(() => expect(after).toBe("1")); + }); + + it("task panel toggle and tool render in both the aside and mobile view", async () => { + mockFetchJson((url) => (url.includes("/events") ? historyEvents() : [])); + const { container } = renderChat(makeStore()); + + // running tool start with no end shows in the Working section + await screen.findByText("hello there"); + const toggle = screen.getByLabelText("Toggle task panel"); + expect(toggle).toHaveAttribute("aria-expanded", "false"); + await userEvent.click(toggle); + expect(screen.getByLabelText("Toggle task panel")).toHaveAttribute("aria-expanded", "true"); + expect(container.querySelectorAll(".task-panel")).toHaveLength(2); // aside + mobile + expect(screen.getAllByText("No tasks yet.")).toHaveLength(2); + }); + + it("no session id param renders the empty page", () => { + render( + + + } /> + CHAT} /> + + + ); + expect(screen.getByText("No session selected.")).toBeInTheDocument(); + }); +}); diff --git a/web/src/SessionsView.test.tsx b/web/src/SessionsView.test.tsx new file mode 100644 index 0000000..7ae594c --- /dev/null +++ b/web/src/SessionsView.test.tsx @@ -0,0 +1,151 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { MemoryRouter, Route, Routes, useParams } from "react-router-dom"; +import { describe, expect, it, vi } from "vitest"; +import type { SessionListItem } from "./protocol"; +import SessionsView from "./SessionsView"; +import { jsonResponse, mockFetchJson, seedSettings } from "./test/setup"; + +function session(p: Partial): SessionListItem { + return { + id: "s1", + name: null, + cwd: "/w", + model: "glm-5.3", + provider: "zai-renaud", + agent: false, + repo: null, + startedAt: 100, + online: false, + lastEventAt: null, + ...p, + }; +} + +function renderView(props: Partial[0]> = {}): ReturnType { + return render( + + undefined} pushToast={() => undefined} {...props} /> + + ); +} + +function Probe({ onVisit }: { onVisit: (path: string) => void }): null { + const { id } = useParams(); + onVisit(`/s/${id ?? ""}`); + return null; +} + +describe("SessionsView", () => { + it("empty state message", () => { + renderView(); + expect(screen.getByText(/No sessions yet/i)).toBeInTheDocument(); + }); + + it("renders cards sorted by last activity with fallbacks", () => { + renderView({ + sessions: [ + session({ id: "a", name: null, repo: "g/p", lastEventAt: 5, startedAt: 1 }), + session({ id: "b", name: "named", lastEventAt: null, startedAt: 10 }), + session({ id: "c", name: null, repo: null, cwd: "/fallback", startedAt: 100, online: true }), + ], + }); + const cards = screen.getAllByRole("button", { name: /^Open session/ }); + expect(cards.map((c) => c.getAttribute("aria-label"))).toEqual([ + "Open session c", + "Open session named", + "Open session a", + ]); + expect(cards[0]?.textContent).toContain("/fallback"); + expect(screen.getAllByTitle("online")).toHaveLength(1); + expect(screen.getAllByTitle("offline")).toHaveLength(2); + }); + + it("shows repo, model, relative time and agent badge", () => { + renderView({ sessions: [session({ id: "s1", name: "named", repo: "g/p", lastEventAt: Date.now() - 5000, agent: true })] }); + expect(screen.getAllByText("g/p")).toHaveLength(1); + expect(screen.getByText("glm-5.3")).toBeInTheDocument(); + expect(screen.getByText("just now")).toBeInTheDocument(); + expect(screen.getByText("agent")).toBeInTheDocument(); + expect(screen.getByLabelText("Stop container for named")).toBeInTheDocument(); + }); + + it("no badge/stop for non-agent sessions", () => { + renderView({ sessions: [session({ id: "s1", name: "local", repo: "g/p" })] }); + expect(screen.queryByText("agent")).toBeNull(); + expect(screen.queryByLabelText(/Stop container/)).toBeNull(); + }); + + it("keyboard Enter and Space open the session; other keys ignored", () => { + const probe = vi.fn(); + render( + + + undefined} pushToast={() => undefined} />} /> + } /> + + + ); + const card = screen.getByRole("button", { name: "Open session kb" }); + fireEvent.keyDown(card, { key: "Tab" }); + expect(probe).not.toHaveBeenCalled(); + fireEvent.keyDown(card, { key: "Enter" }); + expect(probe).toHaveBeenCalledWith("/s/s9"); + }); + + it("Space key opens the session", () => { + const probe = vi.fn(); + render( + + + undefined} pushToast={() => undefined} />} /> + } /> + + + ); + fireEvent.keyDown(screen.getByRole("button", { name: "Open session s8" }), { key: " " }); + expect(probe).toHaveBeenCalledWith("/s/s8"); + }); + + it("stop with unnamed session toasts the id", async () => { + seedSettings(); + mockFetchJson(() => ({ ok: true })); + const pushToast = vi.fn(); + renderView({ sessions: [session({ id: "s1", name: null, agent: true })], pushToast, onChanged: () => undefined }); + fireEvent.click(screen.getByLabelText("Stop container for s1")); + await vi.waitFor(() => expect(pushToast).toHaveBeenCalledWith("stopped s1")); + }); + + it("stop button deletes container, toasts and refreshes", async () => { + seedSettings(); + const fetchMock = mockFetchJson(() => ({ ok: true })); + const onChanged = vi.fn(); + const pushToast = vi.fn(); + renderView({ sessions: [session({ id: "s1", name: "worker", agent: true })], onChanged, pushToast }); + fireEvent.click(screen.getByLabelText("Stop container for worker")); + await vi.waitFor(() => { + const call = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + expect(call[0]).toBe("http://srv/api/sessions/s1/container"); + expect(call[1].method).toBe("DELETE"); + }); + await vi.waitFor(() => expect(pushToast).toHaveBeenCalledWith("stopped worker")); + expect(onChanged).toHaveBeenCalled(); + }); + + it("stop failure toasts the error", async () => { + seedSettings(); + vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse({ error: "nope" }, 500)); + const pushToast = vi.fn(); + renderView({ sessions: [session({ id: "s1", name: "w", agent: true })], pushToast, onChanged: () => undefined }); + fireEvent.click(screen.getByLabelText("Stop container for w")); + await vi.waitFor(() => expect(pushToast).toHaveBeenCalledWith("stop failed: nope")); + }); + + it("non-error stop failure path stringifies non-Error throws", async () => { + seedSettings(); + vi.spyOn(globalThis, "fetch").mockRejectedValue("plain-string"); + const pushToast = vi.fn(); + renderView({ sessions: [session({ id: "s1", name: "w", agent: true })], pushToast, onChanged: () => undefined }); + fireEvent.click(screen.getByLabelText("Stop container for w")); + await vi.waitFor(() => expect(pushToast).toHaveBeenCalledWith("stop failed: plain-string")); + }); +}); diff --git a/web/src/SettingsGate.test.tsx b/web/src/SettingsGate.test.tsx new file mode 100644 index 0000000..30060f5 --- /dev/null +++ b/web/src/SettingsGate.test.tsx @@ -0,0 +1,87 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { getSettings } from "./settings"; +import SettingsGate from "./SettingsGate"; +import { jsonResponse } from "./test/setup"; + +afterEach(() => { + vi.restoreAllMocks(); + localStorage.clear(); +}); + +function setup(): { connect: () => Promise } { + render( undefined} />); + return { + connect: async (): Promise => { + await userEvent.click(screen.getByRole("button", { name: "Connect" })); + }, + }; +} + +describe("SettingsGate", () => { + it("defaults the server url to the current origin", () => { + setup(); + const input = screen.getByLabelText("Server URL") as HTMLInputElement; + expect(input.value).toBe("http://localhost:3000"); + }); + + it("requires a token", async () => { + setup(); + await userEvent.click(screen.getByRole("button", { name: "Connect" })); + expect(screen.getByRole("alert")).toHaveTextContent("token required"); + expect(getSettings()).toBeNull(); + }); + + it("validation failure shows the server error and does not save", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse({ error: "unauthorized" }, 401)); + setup(); + await userEvent.type(screen.getByLabelText("Bearer token"), "bad"); + await userEvent.click(screen.getByRole("button", { name: "Connect" })); + expect(screen.getByRole("alert")).toHaveTextContent("connection failed (401)"); + expect(getSettings()).toBeNull(); + expect(fetchMock).toHaveBeenCalledWith( + "http://localhost:3000/api/sessions", + expect.objectContaining({ headers: { Authorization: "Bearer bad" } }) + ); + }); + + it("network rejection surfaces the thrown message", async () => { + vi.spyOn(globalThis, "fetch").mockRejectedValue(new TypeError("fetch failed")); + setup(); + await userEvent.type(screen.getByLabelText("Bearer token"), "tok"); + await userEvent.click(screen.getByRole("button", { name: "Connect" })); + expect(screen.getByRole("alert")).toHaveTextContent("fetch failed"); + }); + + it("saves normalized url + trimmed token and calls onSaved", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse([])); + const onSaved = vi.fn(); + render(); + await userEvent.clear(screen.getByLabelText("Server URL")); + await userEvent.type(screen.getByLabelText("Server URL"), "http://daemon:8686///"); + await userEvent.type(screen.getByLabelText("Bearer token"), " tok "); + await userEvent.click(screen.getByRole("button", { name: "Connect" })); + expect(fetchMock).toHaveBeenCalledWith("http://daemon:8686/api/sessions", expect.anything()); + expect(getSettings()).toEqual({ serverUrl: "http://daemon:8686", token: "tok" }); + expect(onSaved).toHaveBeenCalledTimes(1); + }); + + it("non-Error rejections stringify via String(err)", async () => { + vi.spyOn(globalThis, "fetch").mockRejectedValue("plain-string" as never); + setup(); + await userEvent.type(screen.getByLabelText("Bearer token"), "tok"); + await userEvent.click(screen.getByRole("button", { name: "Connect" })); + expect(screen.getByRole("alert")).toHaveTextContent("plain-string"); + }); + + it("empty server url falls back to the current origin", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse([])); + setup(); + await userEvent.clear(screen.getByLabelText("Server URL")); + await userEvent.type(screen.getByLabelText("Bearer token"), "tok"); + await userEvent.click(screen.getByRole("button", { name: "Connect" })); + expect(fetchMock).toHaveBeenCalledWith("http://localhost:3000/api/sessions", expect.anything()); + expect(getSettings()?.serverUrl).toBe("http://localhost:3000"); + }); +}); diff --git a/web/src/SpawnView.test.tsx b/web/src/SpawnView.test.tsx new file mode 100644 index 0000000..77c81cd --- /dev/null +++ b/web/src/SpawnView.test.tsx @@ -0,0 +1,339 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { Repo } from "./protocol"; +import SpawnView from "./SpawnView"; +import type { SessionsStore } from "./store"; +import { jsonResponse, mockFetchJson, seedSettings } from "./test/setup"; + +const repo = (path: string, branch = "main"): Repo => ({ + path, + name: path.split("/")[1] ?? path, + namespace: path.split("/")[0] ?? "g", + lastActivityAt: "2024-05-01T00:00:00Z", + webUrl: `https://gl/${path}`, + defaultBranch: branch, +}); + +const PUSH_TOAST = vi.fn(); + +function makeStore(over: Partial = {}): SessionsStore { + return { + sessions: [], + state: "open", + spawnJobs: [], + refresh: async (): Promise => undefined, + subscribe: (): (() => void) => () => undefined, + ...over, + }; +} + +function tree(store: SessionsStore): React.ReactElement { + return ( + + + } /> + } /> + + + ); +} + +/** Flush pending microtasks + React effects (works under fake timers). */ +async function flush(ticks = 4): Promise { + for (let i = 0; i < ticks; i += 1) { + // eslint-disable-next-line no-await-in-loop + await act(async () => { + await Promise.resolve(); + }); + } +} + +beforeEach(() => { + PUSH_TOAST.mockClear(); + seedSettings(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("SpawnView status", () => { + it("shows checking state, then error when gitlab status fails", async () => { + mockFetchJson((url) => { + if (url.includes("/api/gitlab/status")) return jsonResponse({ error: "down" }, 500); + return []; + }); + render(tree(makeStore())); + expect(screen.getByText("checking gitlab…")).toBeInTheDocument(); + await flush(); + expect(screen.getByText(/gitlab status failed: down/)).toBeInTheDocument(); + }); +}); + +describe("SpawnView connect flow", () => { + it("requires a token", async () => { + mockFetchJson((url) => { + if (url.endsWith("/api/gitlab/status")) return { connected: false, baseUrl: "https://gl" }; + return []; + }); + render(tree(makeStore())); + await flush(); + fireEvent.click(screen.getByRole("button", { name: "Connect" })); + await flush(); + expect(screen.getByText("token required")).toBeInTheDocument(); + }); + + it("connects, clears the PAT, loads repos and shows the picker", async () => { + let connected = false; + mockFetchJson((url, init) => { + if (url.endsWith("/api/gitlab/status")) return { connected, baseUrl: "https://gl", username: connected ? "alice" : undefined }; + if (url.endsWith("/api/gitlab/connect") && init?.method === "POST") { + connected = true; + return { username: "alice" }; + } + if (url.endsWith("/api/gitlab/repos")) return [repo("g/one"), repo("g/two")]; + return []; + }); + render(tree(makeStore())); + await flush(); + expect(screen.getByText("Connect GitLab")).toBeInTheDocument(); + + const pat = screen.getByLabelText("GitLab personal access token") as HTMLInputElement; + fireEvent.input(pat, { target: { value: "glpat-x" } }); + fireEvent.click(screen.getByRole("button", { name: "Connect" })); + await flush(); + + expect(screen.getByText("g/one")).toBeInTheDocument(); + expect(screen.getByText("g/two")).toBeInTheDocument(); + expect(screen.queryByLabelText("GitLab personal access token")).toBeNull(); + expect(screen.getByText("Repository")).toBeInTheDocument(); + }); + + it("connect failure shows the error and keeps the gate", async () => { + mockFetchJson((url, init) => { + if (url.endsWith("/api/gitlab/status")) return { connected: false, baseUrl: "https://gl" }; + if (url.endsWith("/api/gitlab/connect") && init?.method === "POST") return jsonResponse({ error: "bad pat" }, 401); + return []; + }); + render(tree(makeStore())); + await flush(); + fireEvent.input(screen.getByLabelText("GitLab personal access token"), { target: { value: "glpat-bad" } }); + fireEvent.click(screen.getByRole("button", { name: "Connect" })); + await flush(); + expect(screen.getByText("bad pat")).toBeInTheDocument(); + expect(screen.getByText("Connect GitLab")).toBeInTheDocument(); + }); + + it("connected on load fetches repos immediately", async () => { + mockFetchJson((url) => { + if (url.endsWith("/api/gitlab/status")) return { connected: true, baseUrl: "https://gl", username: "alice" }; + if (url.endsWith("/api/gitlab/repos")) return [repo("g/quick")]; + return []; + }); + render(tree(makeStore())); + await flush(); + expect(screen.getByText("g/quick")).toBeInTheDocument(); + }); +}); + +describe("SpawnView repo picker", () => { + function connectedMock(): void { + mockFetchJson((url) => { + if (url.endsWith("/api/gitlab/status")) return { connected: true, baseUrl: "https://gl", username: "alice" }; + if (url.endsWith("/api/gitlab/repos")) return [repo("g/alpha"), repo("g/beta", "dev")]; + return []; + }); + } + + it("filters repos by query, keyboard selects, empty filter message", async () => { + connectedMock(); + render(tree(makeStore())); + await flush(); + expect(screen.getByText("g/alpha")).toBeInTheDocument(); + + const filter = screen.getByLabelText("Filter repositories"); + fireEvent.input(filter, { target: { value: "beta" } }); + expect(screen.queryByText("g/alpha")).toBeNull(); + expect(screen.getByText("g/beta")).toBeInTheDocument(); + + const item = screen.getByText("g/beta").closest(".repo-item") as HTMLElement; + fireEvent.keyDown(item, { key: "Enter" }); + expect(screen.getByLabelText("Branch")).toHaveValue("dev"); + + fireEvent.input(filter, { target: { value: "zzz" } }); + expect(screen.getByText("no matching repos")).toBeInTheDocument(); + }); + + it("spawn without selection shows pick-a-repo error", async () => { + connectedMock(); + render(tree(makeStore())); + await flush(); + expect(screen.getByLabelText("Spawn container")).toBeDisabled(); + expect(screen.getByText("select a repo above")).toBeInTheDocument(); + }); +}); + +describe("SpawnView spawn+poll", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + it("spawns, polls until online, then navigates to the chat", async () => { + const posts: Array<[string, RequestInit | undefined]> = []; + mockFetchJson((url, init) => { + if (init?.method === "POST" && url.endsWith("/api/spawn")) { + posts.push([url, init]); + return { sessionId: "new-1", containerId: "abc123def456" }; + } + if (url.endsWith("/api/spawn/status")) return []; + if (url.endsWith("/api/gitlab/status")) return { connected: true, baseUrl: "https://gl", username: "alice" }; + if (url.endsWith("/api/gitlab/repos")) return [repo("g/proj")]; + return []; + }); + const store = makeStore(); + const { unmount } = render(tree(store)); + await flush(); + fireEvent.click(screen.getByText("g/proj")); + fireEvent.click(screen.getByLabelText("Spawn container")); + await flush(); + + expect(posts).toHaveLength(1); + expect((posts[0]?.[1] as RequestInit).body).toBe(JSON.stringify({ repo: "g/proj", branch: "main" })); + expect(screen.getByText("Spawning…")).toBeInTheDocument(); + expect(screen.getByText(/container abc123def456/)).toBeInTheDocument(); + expect(screen.getByText("waiting for session to come online…")).toBeInTheDocument(); + + // first tick: session not online yet + await act(async () => { + vi.advanceTimersByTime(1500); + }); + await flush(); + expect(screen.queryByTestId("chat-route")).toBeNull(); + + // session comes online -> next tick navigates + store.sessions = [ + { + id: "new-1", + name: "spawned", + cwd: "/w", + model: "m", + provider: "p", + agent: true, + repo: "g/proj", + startedAt: 1, + online: true, + lastEventAt: 1, + }, + ]; + await act(async () => { + vi.advanceTimersByTime(1500); + }); + await flush(); + expect(screen.getByTestId("chat-route")).toBeInTheDocument(); + unmount(); + }); + + it("branch left blank sends repo only; poll refresh failure toasts", async () => { + const bodies: string[] = []; + mockFetchJson((url, init) => { + if (init?.method === "POST" && url.endsWith("/api/spawn")) { + bodies.push(String(init.body)); + return { sessionId: "new-2", containerId: "cccccccccccc" }; + } + if (url.endsWith("/api/spawn/status")) return []; + if (url.endsWith("/api/gitlab/status")) return { connected: true, baseUrl: "https://gl", username: "alice" }; + if (url.endsWith("/api/gitlab/repos")) return [repo("g/blank")]; + return []; + }); + const failingRefresh = makeStore({ + refresh: async (): Promise => { + throw new Error("boom"); + }, + }); + render(tree(failingRefresh)); + await flush(); + fireEvent.click(screen.getByText("g/blank")); + fireEvent.change(screen.getByLabelText("Branch"), { target: { value: "" } }); + fireEvent.click(screen.getByLabelText("Spawn container")); + await flush(); + expect(bodies).toEqual([JSON.stringify({ repo: "g/blank" })]); + + await act(async () => { + vi.advanceTimersByTime(1500); + }); + await flush(); + expect(PUSH_TOAST).toHaveBeenCalledWith("boom"); + }); + + it("spawn POST failure shows the error", async () => { + mockFetchJson((url, init) => { + if (init?.method === "POST" && url.endsWith("/api/spawn")) return jsonResponse({ error: "no docker" }, 500); + if (url.endsWith("/api/gitlab/status")) return { connected: true, baseUrl: "https://gl", username: "alice" }; + if (url.endsWith("/api/gitlab/repos")) return [repo("g/x")]; + return []; + }); + render(tree(makeStore())); + await flush(); + fireEvent.click(screen.getByText("g/x")); + fireEvent.click(screen.getByLabelText("Spawn container")); + await flush(); + expect(screen.getByText("no docker")).toBeInTheDocument(); + }); + + it("polling gives up after the tick cap and stays on the page", async () => { + let statusCalls = 0; + mockFetchJson((url) => { + if (url.endsWith("/api/spawn/status")) { + statusCalls += 1; + return []; + } + if (url.endsWith("/api/gitlab/status")) return { connected: true, baseUrl: "https://gl", username: "alice" }; + if (url.endsWith("/api/gitlab/repos")) return [repo("g/slow")]; + if (url.endsWith("/api/spawn")) return { sessionId: "slow-1", containerId: "d" }; + return []; + }); + const { unmount } = render(tree(makeStore())); + await flush(); + fireEvent.click(screen.getByText("g/slow")); + fireEvent.click(screen.getByLabelText("Spawn container")); + await flush(); + expect(screen.getByText("Spawning…")).toBeInTheDocument(); + + await act(async () => { + vi.advanceTimersByTime(1500 * 402); + }); + await flush(); + const afterCap = statusCalls; + await act(async () => { + vi.advanceTimersByTime(1500 * 10); + }); + await flush(); + expect(statusCalls).toBe(afterCap); + expect(screen.getByText("Spawning…")).toBeInTheDocument(); + unmount(); + }); + + it("spawn job line renders from store.spawnJobs", async () => { + mockFetchJson((url, init) => { + if (init?.method === "POST" && url.endsWith("/api/spawn")) return { sessionId: "sj-1", containerId: "cid" }; + if (url.endsWith("/api/spawn/status")) return []; + if (url.endsWith("/api/gitlab/status")) return { connected: true, baseUrl: "https://gl", username: "alice" }; + if (url.endsWith("/api/gitlab/repos")) return [repo("g/p")]; + return []; + }); + const store = makeStore(); + const { rerender } = render(tree(store)); + await flush(); + fireEvent.click(screen.getByText("g/p")); + fireEvent.click(screen.getByLabelText("Spawn container")); + await flush(); + expect(screen.getByText("Spawning…")).toBeInTheDocument(); + + store.spawnJobs = [{ repo: "g/p", state: "cloning", sessionId: "sj-1" }]; + act(() => { + rerender(tree(store)); + }); + expect(screen.getByText("g/p: cloning")).toBeInTheDocument(); + }); +}); diff --git a/web/src/TaskPanel.test.tsx b/web/src/TaskPanel.test.tsx new file mode 100644 index 0000000..0e0ad6f --- /dev/null +++ b/web/src/TaskPanel.test.tsx @@ -0,0 +1,71 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import type { TaskDerivation } from "./derive"; +import TaskPanel from "./TaskPanel"; + +const base: TaskDerivation = { todos: [], subagents: [], workingTools: [] }; + +describe("TaskPanel", () => { + it("empty state", () => { + render(); + expect(screen.getByText("No tasks yet.")).toBeInTheDocument(); + }); + + it("todo rows render icons per status and deleted styling", () => { + const tasks: TaskDerivation = { + ...base, + todos: [ + { content: "write tests", status: "pending", deleted: false }, + { content: "run them", status: "in-progress", deleted: false }, + { content: "ship it", status: "completed", deleted: false }, + { content: "old task", status: "pending", deleted: true }, + ], + }; + const { container } = render(); + expect(container.querySelector(".todo-icon.pending")?.textContent).toBe("○"); + expect(container.querySelector(".todo-icon.in-progress")?.textContent).toBe("◺"); + expect(container.querySelector(".todo-icon.completed")?.textContent).toBe("●"); + const deleted = container.querySelector(".todo-item.deleted .todo-text") as HTMLElement; + expect(deleted).not.toBeNull(); + expect(deleted.style.textDecoration).toContain("line-through"); + }); + + it("subagent rows: running spinner, done check, failed cross", () => { + const tasks: TaskDerivation = { + ...base, + subagents: [ + { key: "a", name: "scout", running: true, isError: false }, + { key: "b", name: "worker", running: false, isError: false }, + { key: "c", name: "reviewer", running: false, isError: true }, + ], + }; + render(); + expect(screen.getByLabelText("running")).toBeInTheDocument(); + expect(screen.getByText("scout")).toBeInTheDocument(); + expect(screen.getByText("running")).toBeInTheDocument(); + expect(screen.getByText("✓")).toBeInTheDocument(); + expect(screen.getByText("✕")).toBeInTheDocument(); + expect(screen.getByText("done")).toBeInTheDocument(); + expect(screen.getByText("failed")).toBeInTheDocument(); + }); + + it("working tools section", () => { + const tasks: TaskDerivation = { + ...base, + workingTools: [{ id: "c1", name: "bash", args: "ls", running: true, isError: false, preview: "" }], + }; + render(); + expect(screen.getByText("Working")).toBeInTheDocument(); + expect(screen.getByText("bash…")).toBeInTheDocument(); + }); + + it("sections appear only when populated", () => { + const { rerender, queryByText } = render(); + expect(queryByText("Tasks")).not.toBeNull(); + expect(queryByText("Subagents")).toBeNull(); + expect(queryByText("Working")).toBeNull(); + rerender(); + expect(queryByText("Tasks")).toBeNull(); + expect(queryByText("Subagents")).not.toBeNull(); + }); +}); diff --git a/web/src/api.test.ts b/web/src/api.test.ts new file mode 100644 index 0000000..f3fdfa5 --- /dev/null +++ b/web/src/api.test.ts @@ -0,0 +1,73 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { ApiError, errMessage, fetchJson } from "./api"; +import { saveSettings } from "./settings"; +import { jsonResponse } from "./test/setup"; + +describe("api", () => { + beforeEach(() => { + localStorage.clear(); + saveSettings({ serverUrl: "http://srv", token: "sekret" }); + }); + + it("throws 401 when not configured", async () => { + localStorage.clear(); + await expect(fetchJson("/api/sessions")).rejects.toMatchObject({ status: 401 }); + }); + + it("GET sends bearer header and parses JSON", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse([{ id: "s1" }])); + const out = await fetchJson>("/api/sessions"); + expect(out).toEqual([{ id: "s1" }]); + const [input, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + expect(input).toBe("http://srv/api/sessions"); + expect(init.headers).toEqual({ Authorization: "Bearer sekret" }); + expect(init.body).toBeUndefined(); + }); + + it("POST sends JSON content-type with body", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse({ ok: true })); + await fetchJson("/api/sessions/s1/prompt", { method: "POST", body: JSON.stringify({ message: "hi" }) }); + const init = fetchMock.mock.calls[0]?.[1] as RequestInit; + expect(init.method).toBe("POST"); + expect(init.headers).toEqual({ Authorization: "Bearer sekret", "Content-Type": "application/json" }); + }); + + it("throws ApiError with server error message", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse({ error: "boom" }, 500)); + const err: unknown = await fetchJson("/api/sessions").catch((e: unknown) => e); + expect(err).toBeInstanceOf(ApiError); + expect((err as ApiError).message).toBe("boom"); + expect((err as ApiError).status).toBe(500); + }); + + it("falls back to status text when body has no error string", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse({ other: 1 }, 404)); + const err: unknown = await fetchJson("/api/sessions").catch((e: unknown) => e); + expect((err as ApiError).message).toBe("404 StatusText"); + }); + + it("falls back to status text when body is not JSON", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: false, + status: 502, + statusText: "Bad Gateway", + json: async () => { + throw new SyntaxError("bad json"); + }, + } as unknown as Response); + const err: unknown = await fetchJson("/api/sessions").catch((e: unknown) => e); + expect((err as ApiError).message).toBe("502 Bad Gateway"); + }); + + it("rejects null JSON bodies gracefully in error path", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse(null, 409)); + const err: unknown = await fetchJson("/api/sessions").catch((e: unknown) => e); + expect((err as ApiError).message).toBe("409 StatusText"); + }); + + it("errMessage maps Error and non-Error values", () => { + expect(errMessage(new Error("oops"))).toBe("oops"); + expect(errMessage(42)).toBe("42"); + expect(errMessage(null)).toBe("null"); + }); +}); diff --git a/web/src/derive.test.ts b/web/src/derive.test.ts new file mode 100644 index 0000000..e1a3420 --- /dev/null +++ b/web/src/derive.test.ts @@ -0,0 +1,422 @@ +import { describe, expect, it } from "vitest"; +import type { EventFrame, Message } from "./protocol"; +import { deriveChat, deriveTasks, lastPersistedSeq, mergeEvents } from "./derive"; + +let seq: number = 0; +function ev(partial: Partial & { type: string }): EventFrame { + seq += 1; + return { v: 1, sessionId: "s1", seq: partial.seq ?? seq, ts: 0, ...partial } as EventFrame; +} +function msg(m: Partial): Message { + return { + role: "assistant", + id: "m1", + text: "", + thinking: null, + toolCalls: [], + toolCallId: null, + ...m, + }; +} +function toolStart(id: string, name: string, args?: string): EventFrame { + return ev({ type: "tool_execution_start", toolCallId: id, toolName: name, args: args ?? "" }); +} +function toolEnd(id: string, isError?: boolean, preview?: string): EventFrame { + return ev({ type: "tool_execution_end", toolCallId: id, isError: isError ?? false, resultPreview: preview ?? "" }); +} + +// ---------- mergeEvents / lastPersistedSeq ---------- + +describe("mergeEvents", () => { + it("merges and sorts by seq, dedupes by seq", () => { + const a = [ev({ type: "hello", seq: 5 }), ev({ type: "hello", seq: 1 })]; + seq = 0; + const b = [ev({ type: "hello", seq: 3 }), ev({ type: "hello", seq: 1 })]; + const merged = mergeEvents(a, b); + expect(merged.map((e) => e.seq)).toEqual([1, 3, 5]); + }); + + it("incoming wins on duplicate seq", () => { + const a = [ev({ type: "hello", seq: 1, reason: "a" })]; + const b: EventFrame[] = a.map((e) => ({ ...e, reason: "b" })); + expect(mergeEvents(a, b)[0]?.reason).toBe("b"); + }); +}); + +describe("lastPersistedSeq", () => { + it("ignores message_update deltas", () => { + const events = [ + ev({ type: "message_end", seq: 7 }), + ev({ type: "message_update", seq: 99 }), + ev({ type: "agent_settled", seq: 8 }), + ]; + expect(lastPersistedSeq(events)).toBe(8); + }); + + it("returns 0 for empty or delta-only streams", () => { + expect(lastPersistedSeq([])).toBe(0); + expect(lastPersistedSeq([ev({ type: "message_update", delta: "x", seq: 5 })])).toBe(0); + }); +}); + +// ---------- deriveChat ---------- + +describe("deriveChat", () => { + it("message_end renders user/assistant/system/toolResult messages", () => { + const events = [ + ev({ type: "message_end", message: msg({ id: "u1", role: "user", text: "hi" }) }), + ev({ type: "message_end", message: msg({ id: "a1", role: "assistant", text: "hello" }) }), + ev({ type: "message_end", message: msg({ id: "t1", role: "toolResult", text: "out", toolCallId: "c1" }) }), + ev({ type: "message_end", message: msg({ id: "s1", role: "system", text: "sys" }) }), + ]; + const { messages } = deriveChat(events); + expect(messages.map((m) => m.role)).toEqual(["user", "assistant", "toolResult", "system"]); + expect(messages[1]?.text).toBe("hello"); + expect(messages.every((m) => m.streaming === false)).toBe(true); + }); + + it("message_update deltas append into a streaming bubble ended by matching message_end", () => { + const id = "a9"; + const events = [ + ev({ type: "message_start", message: msg({ id, role: "assistant" }) }), + ev({ type: "message_update", delta: "Hel" }), + ev({ type: "message_update", delta: "lo" }), + ]; + let chat = deriveChat(events); + expect(chat.messages).toHaveLength(1); + expect(chat.messages[0]).toMatchObject({ role: "assistant", text: "Hello", streaming: true }); + expect(chat.busy).toBe(true); + + chat = deriveChat([ + ...events, + ev({ type: "message_end", message: msg({ id, role: "assistant", text: "Hello world" }) }), + ]); + expect(chat.messages).toHaveLength(1); + expect(chat.messages[0]).toMatchObject({ text: "Hello world", streaming: false }); + expect(chat.busy).toBe(false); + }); + + it("non-assistant message_start does not open a stream", () => { + const chat = deriveChat([ev({ type: "message_start", message: msg({ id: "u1", role: "user" }) })]); + expect(chat.messages).toHaveLength(0); + expect(chat.busy).toBe(false); + }); + + it("missing delta does not append 'undefined'", () => { + const chat = deriveChat([ + ev({ type: "message_start", message: msg({ id: "a", role: "assistant" }) }), + ev({ type: "message_update" }), + ]); + expect(chat.messages[0]?.text).toBe(""); + }); + + it("agent_start/agent_settled drive busy", () => { + expect(deriveChat([ev({ type: "agent_start" })]).busy).toBe(true); + expect(deriveChat([ev({ type: "agent_start" }), ev({ type: "agent_settled" })]).busy).toBe(false); + }); + + it("message_start for a new assistant id drops the old stream", () => { + const chat = deriveChat([ + ev({ type: "message_start", message: msg({ id: "a1", role: "assistant" }) }), + ev({ type: "message_update", delta: "x" }), + ev({ type: "message_start", message: msg({ id: "a2", role: "assistant" }) }), + ev({ type: "message_update", delta: "y" }), + ]); + expect(chat.messages).toHaveLength(1); + expect(chat.messages[0]?.key).toBe("stream-a2"); + expect(chat.messages[0]?.text).toBe("y"); + }); + + it("message_end without message payload is ignored", () => { + const chat = deriveChat([ev({ type: "message_end" })]); + expect(chat.messages).toHaveLength(0); + }); + + it("tool lifecycle states", () => { + const chat = deriveChat([ + toolStart("c1", "bash", "ls"), + toolStart("c2", "read"), + toolEnd("c1", true, "boom"), + toolEnd("c2", false, "ok"), + ]); + expect(chat.tools.get("c1")).toMatchObject({ name: "bash", args: "ls", running: false, isError: true, preview: "boom" }); + expect(chat.tools.get("c2")).toMatchObject({ running: false, isError: false, preview: "ok" }); + // defaults + const chat2 = deriveChat([ + ev({ type: "tool_execution_start", toolCallId: "c3", toolName: "t" }), + ev({ type: "tool_execution_end", toolCallId: "c3" }), + ]); + expect(chat2.tools.get("c3")).toMatchObject({ running: false, isError: false, preview: "" }); + }); + + it("tool_execution_start without id and end for unknown id are ignored", () => { + const chat = deriveChat([ + ev({ type: "tool_execution_start", toolName: "x" }), + ev({ type: "tool_execution_end", toolCallId: "ghost" }), + ]); + expect(chat.tools.size).toBe(0); + }); + + it("tool execution defaults missing names/args", () => { + const chat = deriveChat([ev({ type: "tool_execution_start", toolCallId: "c" })]); + expect(chat.tools.get("c")).toMatchObject({ name: "tool", args: "" }); + }); +}); + +// ---------- deriveTasks: todo derivation truth table ---------- + +function todoSnap(items: unknown): string { + return JSON.stringify(items); +} + +describe("deriveTasks todos", () => { + it("latest snapshot wins; statuses from latest; deletion marks earlier items", () => { + const events = [ + toolStart("c1", "todo", todoSnap([{ content: "a" }, { content: "b" }])), + toolEnd("c1", false, todoSnap([{ content: "a", status: "in-progress" }, { content: "b" }])), + toolStart("c2", "todo", todoSnap([{ content: "b", status: "completed" }])), + ]; + const { todos } = deriveTasks(events); + expect(todos).toEqual([ + { content: "b", status: "completed", deleted: false }, + { content: "a", status: "pending", deleted: true }, + ]); + }); + + it("status alias normalization table", () => { + const aliases: Array<[unknown, string]> = [ + ["in_progress", "in-progress"], + ["in-progress", "in-progress"], + ["inprogress", "in-progress"], + ["in progress", "in-progress"], + ["doing", "in-progress"], + ["started", "in-progress"], + ["completed", "completed"], + ["complete", "completed"], + ["done", "completed"], + ["weird", "pending"], + [7, "pending"], + ]; + for (const [raw, expected] of aliases) { + seq = 0; + const events = [toolStart("c1", "todo", todoSnap([{ content: "t", status: raw }]))]; + expect(deriveTasks(events).todos[0]?.status, `status ${String(raw)}`).toBe(expected); + } + }); + + it("snapshot sources: args of start, preview of end, toolResult message text", () => { + const events = [ + toolStart("c1", "todo", todoSnap([{ content: "from-args" }])), + toolEnd("c1", false, todoSnap([{ content: "from-preview" }])), + ev({ + type: "message_end", + message: msg({ id: "t1", role: "toolResult", text: todoSnap([{ content: "from-msg" }]), toolCallId: "c2" }), + }), + ]; + // c2 has no tool_execution_start before its toolResult message; the second + // pass only harvests toolResult text for known todo calls + const eventsKnown = [ + ...events, + toolStart("c2", "todo"), + ]; + const { todos } = deriveTasks(eventsKnown); + // latest snapshot (highest seq) is the toolResult message; earlier-only + // items survive as deleted markers + expect(todos.map((t) => t.content)).toEqual(["from-msg", "from-args", "from-preview"]); + expect(todos.map((t) => t.deleted)).toEqual([false, true, true]); + }); + + it("accepts wrapper objects and string arrays", () => { + const wrapped = deriveTasks([ + toolStart("c1", "todo", JSON.stringify({ todos: [{ content: "w" }] })), + ]); + expect(wrapped.todos.map((t) => t.content)).toEqual(["w"]); + const nestedItems = deriveTasks([toolStart("c1", "todo", JSON.stringify({ items: ["plain string"] }))]); + expect(nestedItems.todos).toEqual([{ content: "plain string", status: "pending", deleted: false }]); + const nestedTasks = deriveTasks([toolStart("c1", "todo", JSON.stringify({ tasks: [{ title: "tt" }] }))]); + expect(nestedTasks.todos.map((t) => t.content)).toEqual(["tt"]); + const nestedList = deriveTasks([toolStart("c1", "todo", JSON.stringify({ list: [{ text: "tx" }] }))]); + expect(nestedList.todos.map((t) => t.content)).toEqual(["tx"]); + const subject = deriveTasks([toolStart("c1", "todo", JSON.stringify([{ subject: "sj" }]))]); + expect(subject.todos.map((t) => t.content)).toEqual(["sj"]); + const summary = deriveTasks([toolStart("c1", "todo", JSON.stringify([{ summary: "sm" }]))]); + expect(summary.todos.map((t) => t.content)).toEqual(["sm"]); + }); + + it("invalid snapshots are skipped: bad JSON, non-array, empty entries, empty content", () => { + const cases: Array = [ + "{bad json", + JSON.stringify({ nope: 1 }), + JSON.stringify([]), + JSON.stringify([{ content: "" }]), + JSON.stringify([42]), + "", + undefined, + ]; + const events = cases.map((c, i) => toolStart(`c${i}`, "todo", c)); + const { todos } = deriveTasks(events); + expect(todos).toHaveLength(0); + }); + + it("no todo tool calls yields no todos", () => { + expect(deriveTasks([]).todos).toHaveLength(0); + expect(deriveTasks([toolStart("c1", "bash")]).todos).toHaveLength(0); + }); +}); + +// ---------- deriveTasks: subagents ---------- + +describe("deriveTasks subagents", () => { + it("name fields fallback table", () => { + const events = [ + toolStart("c1", "subagent", JSON.stringify({ agentName: "scout" })), + toolStart("c2", "subagent", JSON.stringify({ agent: "worker" })), + toolStart("c3", "subagent", JSON.stringify({ name: "planner" })), + toolStart("c4", "subagent", JSON.stringify({ agentType: "reviewer" })), + toolStart("c5", "subagent", JSON.stringify({ role: "oracle" })), + toolStart("c6", "subagent", JSON.stringify({ agentName: "" })), + toolStart("c7", "subagent", "not json"), + ]; + const { subagents } = deriveTasks(events); + expect(subagents.map((s) => s.name)).toEqual(["scout", "worker", "planner", "reviewer", "oracle", "subagent", "subagent"]); + expect(subagents.every((s) => s.running && !s.isError)).toBe(true); + }); + + it("tool_execution_end marks done / failed", () => { + const events = [ + toolStart("c1", "subagent", JSON.stringify({ agentName: "a" })), + toolStart("c2", "subagent", JSON.stringify({ agentName: "b" })), + toolEnd("c1", false), + toolEnd("c2", true), + ]; + const { subagents } = deriveTasks(events); + expect(subagents.find((s) => s.key === "c1")).toMatchObject({ running: false, isError: false }); + expect(subagents.find((s) => s.key === "c2")).toMatchObject({ running: false, isError: true }); + }); + + it("end for unknown subagent id ignored", () => { + const { subagents } = deriveTasks([ + toolStart("c1", "subagent", JSON.stringify({ agentName: "a" })), + toolEnd("ghost"), + ]); + expect(subagents[0]?.running).toBe(true); + }); +}); + +// ---------- deriveTasks: working tools ---------- + +describe("deriveTasks workingTools", () => { + it("only running non-todo non-subagent tools are listed", () => { + const events = [ + toolStart("c1", "bash", "ls"), + toolStart("c2", "read", "f"), + toolEnd("c1", false, "out"), + toolStart("c3", "todo", todoSnap([{ content: "x" }])), + toolStart("c4", "subagent", JSON.stringify({ agentName: "a" })), + ]; + const { workingTools } = deriveTasks(events); + expect(workingTools.map((w) => w.id)).toEqual(["c2"]); + expect(workingTools[0]).toMatchObject({ name: "read", args: "f", running: true }); + }); + + it("tool_execution_update events do not affect derivation", () => { + const { workingTools } = deriveTasks([ + toolStart("c1", "bash"), + ev({ type: "tool_execution_update", toolCallId: "c1", partial: "…" }), + ]); + expect(workingTools).toHaveLength(1); + }); +}); + +describe("deriveChat/deriveTasks edge branches", () => { + it("unhandled event types fall through the switch", () => { + const chat = deriveChat([ + ev({ type: "hello" }), + ev({ type: "bye", reason: "shutdown" }), + ev({ type: "session_info" }), + ev({ type: "agent_end", usage: {} }), + ev({ type: "tool_execution_update", toolCallId: "c", partial: "x" }), + ]); + expect(chat.messages).toHaveLength(0); + expect(chat.busy).toBe(false); + expect(chat.tools.size).toBe(0); + }); + + it("message_end keeps toolCalls and ends only the matching stream", () => { + const withTools = deriveChat([ + ev({ + type: "message_end", + message: msg({ id: "a1", toolCalls: [{ id: "c1", name: "bash", argsJson: "{}" }] }), + }), + ]); + expect(withTools.messages[0]?.toolCalls).toHaveLength(1); + + // message_end for a different id does not close the open stream + const mismatch = deriveChat([ + ev({ type: "message_start", message: msg({ id: "streaming" }) }), + ev({ type: "message_update", delta: "par" }), + ev({ type: "message_end", message: msg({ id: "other", text: "done" }) }), + ]); + expect(mismatch.messages[0]?.key).toMatch(/^msg-\d+$/); + expect(mismatch.messages[1]?.text).toBe("par"); + expect(mismatch.busy).toBe(true); + }); + + it("tool_execution_start without toolName maps to empty name in deriveTasks", () => { + const { workingTools } = deriveTasks([ev({ type: "tool_execution_start", toolCallId: "c" })]); + expect(workingTools).toHaveLength(1); + expect(workingTools[0]?.name).toBe(""); + }); + + it("tool_execution_end without isError/resultPreview finishes the tool cleanly", () => { + const { workingTools } = deriveTasks([ + toolStart("c1", "bash"), + ev({ type: "tool_execution_end", toolCallId: "c1" }), + ]); + expect(workingTools).toHaveLength(0); + const chat = deriveChat([toolStart("c1", "bash"), ev({ type: "tool_execution_end", toolCallId: "c1" })]); + expect(chat.tools.get("c1")).toMatchObject({ running: false, isError: false, preview: "" }); + }); + + it("snapshot from literal null args is ignored", () => { + const { todos } = deriveTasks([toolStart("c1", "todo", "null")]); + expect(todos).toHaveLength(0); + }); + + it("content fallback: null content falls through to title", () => { + const { todos } = deriveTasks([ + toolStart("c1", "todo", todoSnap([{ content: null, title: "t1" }])), + ]); + expect(todos.map((t) => t.content)).toEqual(["t1"]); + }); + + it("optional-field null sides", () => { + // tool_execution_end without toolCallId: deriveChat ternary else + expect(() => deriveChat([ev({ type: "tool_execution_end" })])).not.toThrow(); + + // message without toolCalls: ?? [] fallback + const bare = deriveChat([ + ev({ + type: "message_end", + message: { role: "assistant", id: "a", text: "x", thinking: null, toolCallId: null } as Message, + }), + ]); + expect(bare.messages[0]?.toolCalls).toEqual([]); + + // subagent end without isError: ?? false + const sa = deriveTasks([ + toolStart("c1", "subagent", JSON.stringify({ agentName: "a" })), + ev({ type: "tool_execution_end", toolCallId: "c1" }), + ]); + expect(sa.subagents[0]).toMatchObject({ running: false, isError: false }); + + // toolResult message for a call id never seen: name lookup ?? "" + const orphan = deriveTasks([ + ev({ + type: "message_end", + message: msg({ id: "t", role: "toolResult", text: todoSnap([{ content: "z" }]), toolCallId: "ghost" }), + }), + ]); + expect(orphan.todos).toHaveLength(0); + }); +}); diff --git a/web/src/main.test.tsx b/web/src/main.test.tsx new file mode 100644 index 0000000..8d79ca9 --- /dev/null +++ b/web/src/main.test.tsx @@ -0,0 +1,41 @@ +import { describe, expect, it, vi } from "vitest"; +import type { ReactElement } from "react"; + +const mocks = vi.hoisted(() => ({ + render: vi.fn(), + createRoot: vi.fn(() => ({ render: mocks.render })), +})); +vi.mock("react-dom/client", () => ({ createRoot: mocks.createRoot })); +vi.mock("./App", () => ({ default: (): null => null })); +vi.mock("./index.css", () => ({})); + +async function freshImport(): Promise { + vi.resetModules(); + await import("./main"); +} + +function rootElement(): HTMLElement { + const el = document.createElement("div"); + el.id = "root"; + document.body.appendChild(el); + return el; +} + +describe("main", () => { + it("renders the app into #root", async () => { + const el = rootElement(); + await freshImport(); + expect(mocks.createRoot).toHaveBeenCalledWith(el); + expect(mocks.render).toHaveBeenCalledTimes(1); + const tree = mocks.render.mock.calls[0]?.[0] as { props: { children: ReactElement } }; + expect(tree.props.children).not.toBeNull(); + el.remove(); + mocks.createRoot.mockClear(); + mocks.render.mockClear(); + }); + + it("throws when #root is missing", async () => { + await expect(freshImport()).rejects.toThrow("#root missing in index.html"); + expect(mocks.createRoot).not.toHaveBeenCalled(); + }); +}); diff --git a/web/src/protocol.test.ts b/web/src/protocol.test.ts new file mode 100644 index 0000000..7c11ae4 --- /dev/null +++ b/web/src/protocol.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { EventType, Route } from "./protocol"; + +describe("protocol", () => { + it("PROTOCOL_VERSION is 1", async () => { + const mod = await import("./protocol"); + expect(mod.PROTOCOL_VERSION).toBe(1); + }); + + it("EventType mirrors PROTOCOL.md", () => { + expect(EventType.Hello).toBe("hello"); + expect(EventType.MessageStart).toBe("message_start"); + expect(EventType.MessageUpdate).toBe("message_update"); + expect(EventType.MessageEnd).toBe("message_end"); + expect(EventType.ToolExecutionStart).toBe("tool_execution_start"); + expect(EventType.ToolExecutionUpdate).toBe("tool_execution_update"); + expect(EventType.ToolExecutionEnd).toBe("tool_execution_end"); + expect(EventType.AgentStart).toBe("agent_start"); + expect(EventType.AgentEnd).toBe("agent_end"); + expect(EventType.AgentSettled).toBe("agent_settled"); + expect(EventType.SessionInfo).toBe("session_info"); + expect(EventType.Bye).toBe("bye"); + }); + + it("Route constants", () => { + expect(Route.Sessions).toBe("/api/sessions"); + expect(Route.Spawn).toBe("/api/spawn"); + expect(Route.SpawnStatus).toBe("/api/spawn/status"); + expect(Route.GitlabStatus).toBe("/api/gitlab/status"); + expect(Route.GitlabConnect).toBe("/api/gitlab/connect"); + expect(Route.GitlabRepos).toBe("/api/gitlab/repos"); + }); + + it("session routes interpolate and encode ids", () => { + expect(Route.SessionEvents("abc")).toBe("/api/sessions/abc/events"); + expect(Route.SessionPrompt("abc")).toBe("/api/sessions/abc/prompt"); + expect(Route.SessionAbort("abc")).toBe("/api/sessions/abc/abort"); + expect(Route.SessionContainer("abc")).toBe("/api/sessions/abc/container"); + expect(Route.SessionEvents("a/b c")).toBe("/api/sessions/a%2Fb%20c/events"); + }); +}); diff --git a/web/src/settings.test.ts b/web/src/settings.test.ts new file mode 100644 index 0000000..2bab73e --- /dev/null +++ b/web/src/settings.test.ts @@ -0,0 +1,60 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + buildWsUrl, + clearSettings, + getSettings, + saveSettings, +} from "./settings"; + +describe("settings", () => { + beforeEach(() => { + localStorage.clear(); + }); + + it("getSettings returns null when absent", () => { + expect(getSettings()).toBeNull(); + }); + + it("saveSettings then getSettings round-trips", () => { + saveSettings({ serverUrl: "http://x:8686", token: "t1" }); + expect(getSettings()).toEqual({ serverUrl: "http://x:8686", token: "t1" }); + }); + + it("clearSettings removes stored settings", () => { + saveSettings({ serverUrl: "http://x", token: "t" }); + clearSettings(); + expect(getSettings()).toBeNull(); + }); + + it("rejects corrupt JSON", () => { + localStorage.setItem("lvmh.settings", "{not json"); + expect(getSettings()).toBeNull(); + }); + + it("rejects wrong shapes", () => { + localStorage.setItem( + "lvmh.settings", + JSON.stringify({ serverUrl: 1, token: "x" }), + ); + expect(getSettings()).toBeNull(); + localStorage.setItem("lvmh.settings", JSON.stringify("nope")); + expect(getSettings()).toBeNull(); + localStorage.setItem( + "lvmh.settings", + JSON.stringify({ serverUrl: "http://x" }), + ); + expect(getSettings()).toBeNull(); + }); + + it("buildWsUrl converts scheme and appends encoded token", () => { + expect(buildWsUrl({ serverUrl: "http://a:1", token: "tok" })).toBe( + "ws://a:1/ws?token=tok", + ); + expect(buildWsUrl({ serverUrl: "https://a:1/", token: "tok" })).toBe( + "wss://a:1/ws?token=tok", + ); + expect(buildWsUrl({ serverUrl: "http://a:1//", token: "a b/c" })).toBe( + "ws://a:1/ws?token=a%20b%2Fc", + ); + }); +}); diff --git a/web/src/store.test.tsx b/web/src/store.test.tsx new file mode 100644 index 0000000..68cedb5 --- /dev/null +++ b/web/src/store.test.tsx @@ -0,0 +1,168 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { getSettings, saveSettings } from "./settings"; +import { classNames, relativeTime, useSessions, useToasts } from "./store"; +import { FakeWebSocket, mockFetchJson, seedSettings, stubReload } from "./test/setup"; +import type { EventFrame } from "./protocol"; + +const NOW = new Date("2024-05-01T12:00:00Z").getTime(); + +describe("relativeTime", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it("formats all buckets", () => { + expect(relativeTime(null)).toBe("never"); + expect(relativeTime(NOW - 10_000)).toBe("just now"); + expect(relativeTime(NOW + 30_000)).toBe("just now"); + expect(relativeTime(NOW - 5 * 60_000)).toBe("5m ago"); + expect(relativeTime(NOW - 3 * 3_600_000)).toBe("3h ago"); + expect(relativeTime(NOW - 2 * 86_400_000)).toBe("2d ago"); + }); +}); + +describe("classNames", () => { + it("joins truthy parts", () => { + expect(classNames("a", false, "b", null, undefined, "c")).toBe("a b c"); + expect(classNames()).toBe(""); + }); +}); + +describe("useToasts", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it("pushes a toast and removes it after TTL", () => { + const { result } = renderHook(() => useToasts()); + act(() => result.current.push("hello")); + expect(result.current.toasts.map((t) => t.text)).toEqual(["hello"]); + + act(() => result.current.push("second")); + expect(result.current.toasts).toHaveLength(2); + + act(() => { + vi.advanceTimersByTime(4000); + }); + expect(result.current.toasts).toHaveLength(0); + }); +}); + +function listEvent(seq: number): EventFrame { + return { v: 1, sessionId: "s1", seq, ts: 0, type: "message_end" }; +} + +describe("useSessions", () => { + it("returns null when unconfigured", () => { + const push = vi.fn(); + const { result } = renderHook(() => useSessions(push)); + expect(result.current).toBeNull(); + }); + + it("seeds via REST, updates from session_list and spawn_status frames, refresh works", async () => { + seedSettings(); + const sessions = [{ id: "s1", name: "one", online: true }]; + const fetchMock = mockFetchJson((url) => { + if (url.includes("/api/sessions")) return url.includes("/events") ? [] : sessions; + return []; + }); + const push = vi.fn(); + const { result } = renderHook(() => useSessions(push)); + + expect(result.current?.state).toBe("connecting"); + await waitFor(() => expect(result.current?.sessions).toEqual(sessions)); + + const sock = FakeWebSocket.last(); + act(() => sock.serverOpen()); + expect(result.current?.state).toBe("open"); + + act(() => sock.serverMessage({ type: "session_list", sessions: [{ id: "s2", name: "two", online: false }] })); + expect(result.current?.sessions.map((s) => s.id)).toEqual(["s2"]); + + act(() => sock.serverMessage({ type: "spawn_status", jobs: [{ repo: "g/p", state: "cloning" }] })); + expect(result.current?.spawnJobs).toEqual([{ repo: "g/p", state: "cloning" }]); + + await act(async () => { + await result.current?.refresh(); + }); + expect(fetchMock).toHaveBeenCalled(); + }); + + it("refresh failure pushes a toast", async () => { + seedSettings(); + mockFetchJson(() => { + throw new Error("network down"); + }); + const push = vi.fn(); + const { result } = renderHook(() => useSessions(push)); + await waitFor(() => expect(push).toHaveBeenCalledWith("sessions: network down")); + expect(result.current).not.toBeNull(); + }); + + it("subscribe routes events frames for the session and detaches on unsubscribe", async () => { + seedSettings(); + mockFetchJson(() => []); + const push = vi.fn(); + const { result } = renderHook(() => useSessions(push)); + const sock = FakeWebSocket.last(); + act(() => sock.serverOpen()); + + const seen: EventFrame[][] = []; + let off: (() => void) | undefined; + act(() => { + off = result.current?.subscribe("s1", (events) => seen.push(events)); + }); + expect(sock.sent).toContain(JSON.stringify({ type: "subscribe", sessionId: "s1" })); + + act(() => sock.serverMessage({ type: "events", sessionId: "s1", after: 0, events: [listEvent(1)] })); + act(() => sock.serverMessage({ type: "events", sessionId: "other", after: 0, events: [listEvent(2)] })); + expect(seen).toEqual([[listEvent(1)]]); + + act(() => off?.()); + act(() => sock.serverMessage({ type: "events", sessionId: "s1", after: 1, events: [listEvent(3)] })); + expect(seen).toHaveLength(1); + expect(sock.sent).toContain(JSON.stringify({ type: "unsubscribe", sessionId: "s1" })); + }); + + it("auth failure clears settings and reloads", async () => { + seedSettings(); + mockFetchJson(() => []); + const loc = stubReload(); + const push = vi.fn(); + const { result } = renderHook(() => useSessions(push)); + const sock = FakeWebSocket.last(); + act(() => sock.serverOpen()); + act(() => sock.serverClose(1008)); + expect(loc.reload).toHaveBeenCalled(); + expect(getSettings()).toBeNull(); + await waitFor(() => expect(result.current).toBeNull()); + loc.restore(); + }); + + it("unmount closes the manager", async () => { + seedSettings(); + mockFetchJson(() => []); + const push = vi.fn(); + const { unmount } = renderHook(() => useSessions(push)); + await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1)); + const sock = FakeWebSocket.last(); + unmount(); + expect(sock.closeCode).toBe(4900); + expect(sock.onclose).toBeNull(); + }); +}); + +describe("settings persistence used by the store", () => { + it("saved settings are visible", () => { + saveSettings({ serverUrl: "http://a", token: "t" }); + expect(getSettings()?.serverUrl).toBe("http://a"); + }); +}); diff --git a/web/src/store.ts b/web/src/store.ts index 6ab8543..42a41cd 100644 --- a/web/src/store.ts +++ b/web/src/store.ts @@ -95,8 +95,6 @@ export function useSessions(pushToast: (text: string) => void): SessionsStore | }; }, [pushToast]); - if (getSettings() === null) return null; - const refresh = useCallback(async (): Promise => { try { setSessions(await fetchJson(Route.Sessions)); @@ -120,5 +118,9 @@ export function useSessions(pushToast: (text: string) => void): SessionsStore | [manager] ); + // hooks above must all run before any early return: clearing settings + // mid-flight (ws auth failure) must not change the hook order on re-render + if (getSettings() === null) return null; + return { sessions, state, spawnJobs, refresh, subscribe }; } diff --git a/web/src/test/setup.ts b/web/src/test/setup.ts new file mode 100644 index 0000000..c4261a7 --- /dev/null +++ b/web/src/test/setup.ts @@ -0,0 +1,156 @@ +import "@testing-library/jest-dom/vitest"; +import { afterEach, beforeEach, vi } from "vitest"; +import type { MockInstance } from "vitest"; + +// ---------- fake WebSocket ---------- + +export type WsHandler = ((ev: never) => void) | null; + +export class FakeWebSocket { + static readonly CONNECTING = 0; + static readonly OPEN = 1; + static readonly CLOSING = 2; + static readonly CLOSED = 3; + static instances: FakeWebSocket[] = []; + + readonly url: string; + readyState: number = FakeWebSocket.CONNECTING; + closeCode: number | null = null; + sent: string[] = []; + onopen: ((ev: Event) => void) | null = null; + onmessage: ((ev: MessageEvent) => void) | null = null; + onclose: ((ev: CloseEvent) => void) | null = null; + onerror: ((ev: Event) => void) | null = null; + + constructor(url: string) { + this.url = url; + FakeWebSocket.instances.push(this); + } + + send(data: string): void { + this.sent.push(data); + } + + close(code = 1000): void { + this.readyState = FakeWebSocket.CLOSED; + this.closeCode = code; + } + + // ---- test-side server simulation ---- + serverOpen(): void { + this.readyState = FakeWebSocket.OPEN; + this.onopen?.(new Event("open")); + } + + serverMessage(data: unknown): void { + this.onmessage?.({ data: JSON.stringify(data) } as MessageEvent); + } + + serverClose(code = 1006): void { + this.readyState = FakeWebSocket.CLOSED; + this.onclose?.({ code } as CloseEvent); + } + + static reset(): void { + FakeWebSocket.instances = []; + } + + static last(): FakeWebSocket { + const inst = FakeWebSocket.instances[FakeWebSocket.instances.length - 1]; + if (inst === undefined) throw new Error("no FakeWebSocket instance"); + return inst; + } +} + +globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket; +window.WebSocket = FakeWebSocket as unknown as typeof WebSocket; + +// ---------- fetch mocking helpers ---------- + +export function jsonResponse(body: unknown, status = 200): Response { + return { + ok: status >= 200 && status < 300, + status, + statusText: "StatusText", + json: async () => body, + } as unknown as Response; +} + +type FetchHandler = (url: string, init?: RequestInit) => unknown; + +export function mockFetchJson(handler: FetchHandler): MockInstance { + return vi.spyOn(globalThis, "fetch").mockImplementation((async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + const out = handler(url, init); + return isResponseLike(out) ? out : jsonResponse(out); + }) as typeof fetch); +} + +function isResponseLike(value: unknown): value is Response { + return ( + value !== null && + typeof value === "object" && + typeof (value as { json?: unknown }).json === "function" && + "ok" in (value as object) + ); +} + +// ---------- settings seed ---------- + +export function seedSettings(serverUrl = "http://srv", token = "tok"): void { + localStorage.setItem("lvmh.settings", JSON.stringify({ serverUrl, token })); +} + +// ---------- window.location.reload stub ---------- + +export function stubReload(): { reload: ReturnType; restore: () => void } { + const original = window.location; + const reload = vi.fn(); + Object.defineProperty(window, "location", { value: { reload }, writable: true, configurable: true }); + return { + reload, + restore: (): void => { + Object.defineProperty(window, "location", { value: original, writable: true, configurable: true }); + }, + }; +} + +// Node's experimental localStorage getter (returns undefined without +// --localstorage-file) shadows jsdom's under vitest; replace it with a plain +// in-memory Storage so src modules see a working global. +class MemoryStorage implements Storage { + private readonly map = new Map(); + get length(): number { + return this.map.size; + } + key(index: number): string | null { + return Array.from(this.map.keys())[index] ?? null; + } + getItem(key: string): string | null { + return this.map.get(key) ?? null; + } + setItem(key: string, value: string): void { + this.map.set(String(key), String(value)); + } + removeItem(key: string): void { + this.map.delete(String(key)); + } + clear(): void { + this.map.clear(); + } +} + +const memoryStorage = new MemoryStorage(); +Object.defineProperty(globalThis, "localStorage", { configurable: true, writable: true, value: memoryStorage }); + +// ---------- global hygiene ---------- + +beforeEach(() => { + FakeWebSocket.reset(); + memoryStorage.clear(); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); +}); diff --git a/web/src/ws.test.ts b/web/src/ws.test.ts new file mode 100644 index 0000000..d193c22 --- /dev/null +++ b/web/src/ws.test.ts @@ -0,0 +1,207 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createWsManager } from "./ws"; +import { FakeWebSocket } from "./test/setup"; + +const URL = "ws://srv/ws?token=t"; + +describe("createWsManager", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("subscribe/unsubscribe/resubscribeAll are silent while the socket is not open", () => { + const m = createWsManager(URL); + const sock = FakeWebSocket.last(); // still CONNECTING + m.subscribe("s1"); + m.unsubscribe("s1"); + m.resubscribeAll(); + expect(sock.sent).toEqual([]); + sock.serverClose(); + m.resubscribeAll(); + expect(sock.sent).toEqual([]); + m.close(); + }); + + it("frames received after close() are ignored (handlers nulled)", () => { + const m = createWsManager(URL); + const sock = FakeWebSocket.last(); + sock.serverOpen(); + m.close(); + expect(() => + sock.serverMessage({ type: "session_list", sessions: [] }), + ).not.toThrow(); + expect(FakeWebSocket.instances.length).toBe(1); + }); + + it("onState receives transitions", () => { + const states: string[] = []; + const m = createWsManager(URL); + const off = m.onState((s) => states.push(s)); + expect(states).toEqual(["connecting"]); + expect(FakeWebSocket.last().url).toBe(URL); + + FakeWebSocket.last().serverOpen(); + expect(states).toEqual(["connecting", "open"]); + + off(); + FakeWebSocket.last().serverClose(); + expect(states).toEqual(["connecting", "open"]); + m.close(); + }); + + it("accepts url as function", () => { + const m = createWsManager(() => URL); + expect(FakeWebSocket.last().url).toBe(URL); + m.close(); + }); + + it("onFrame receives parsed frames; malformed frames dropped", () => { + const m = createWsManager(URL); + const frames: unknown[] = []; + m.onFrame((f) => frames.push(f)); + const sock = FakeWebSocket.last(); + sock.serverOpen(); + + sock.serverMessage({ type: "session_list", sessions: [] }); + expect(frames).toEqual([{ type: "session_list", sessions: [] }]); + + sock.onmessage?.({ data: "{bad json" } as MessageEvent); + sock.onmessage?.({ data: JSON.stringify(null) } as MessageEvent); + sock.onmessage?.({ + data: JSON.stringify({ noType: true }), + } as MessageEvent); + sock.onmessage?.({ data: JSON.stringify(42) } as MessageEvent); + expect(frames).toHaveLength(1); + + m.close(); + }); + + it("subscribe/unsubscribe send frames while open, resubscribe on reopen", () => { + const m = createWsManager(URL); + const sock = FakeWebSocket.last(); + sock.serverOpen(); + + m.subscribe("s1"); + expect(sock.sent).toEqual([ + JSON.stringify({ type: "subscribe", sessionId: "s1" }), + ]); + m.subscribe("s2"); + m.unsubscribe("s2"); + expect(sock.sent).toHaveLength(3); + + sock.serverClose(); + // silent drop: reconnect timer scheduled with jitter + vi.spyOn(Math, "random").mockReturnValue(1); + vi.advanceTimersByTime(500); + const sock2 = FakeWebSocket.last(); + sock2.serverOpen(); + expect(sock2.sent).toEqual([ + JSON.stringify({ type: "subscribe", sessionId: "s1" }), + ]); + expect(m.subscriptions()).toEqual(new Set(["s1"])); + m.close(); + }); + + it("resubscribeAll replays live subscriptions over an open socket", () => { + const m = createWsManager(URL); + const sock = FakeWebSocket.last(); + sock.serverOpen(); + m.subscribe("a"); + m.subscribe("b"); + sock.sent.length = 0; + m.resubscribeAll(); + expect(sock.sent).toEqual([ + JSON.stringify({ type: "subscribe", sessionId: "a" }), + JSON.stringify({ type: "subscribe", sessionId: "b" }), + ]); + m.close(); + }); + + it("full backoff ladder reaches the 30s cap", () => { + vi.spyOn(Math, "random").mockReturnValue(1); + const m = createWsManager(URL); + const ladder = [500, 1000, 2000, 4000, 8000, 16000, 30000, 30000]; + let elapsed = 0; + for (const delay of ladder) { + FakeWebSocket.last().serverClose(); + elapsed += delay; + vi.advanceTimersByTime(delay); + FakeWebSocket.last().serverOpen(); + } + expect(FakeWebSocket.instances.length).toBe(ladder.length + 1); + void elapsed; + m.close(); + }); + + it("successful open resets the backoff attempt counter", () => { + vi.spyOn(Math, "random").mockReturnValue(1); + const m = createWsManager(URL); + FakeWebSocket.last().serverClose(); + vi.advanceTimersByTime(500); + FakeWebSocket.last().serverOpen(); + FakeWebSocket.last().serverClose(); + vi.advanceTimersByTime(500); // attempt reset to 0 by open + expect(FakeWebSocket.instances.length).toBe(3); + m.close(); + }); + + it("auth failure (close 1008) fires onAuthError and never reconnects", () => { + const m = createWsManager(URL); + let authErrors = 0; + m.onAuthError(() => { + authErrors += 1; + }); + const sock = FakeWebSocket.last(); + sock.serverOpen(); + sock.serverClose(1008); + expect(authErrors).toBe(1); + vi.advanceTimersByTime(120_000); + expect(FakeWebSocket.instances.length).toBe(1); + expect(m.subscriptions() instanceof Set).toBe(true); + m.close(); + }); + + it("close() tears the socket down and stops reconnection", () => { + vi.spyOn(Math, "random").mockReturnValue(1); + const m = createWsManager(URL); + const sock = FakeWebSocket.last(); + sock.serverOpen(); + m.subscribe("s1"); + m.close(); + expect(sock.closeCode).toBe(4900); + expect(sock.onclose).toBeNull(); + expect(sock.onmessage).toBeNull(); + vi.advanceTimersByTime(120_000); + expect(FakeWebSocket.instances.length).toBe(1); + }); + + it("callbacks unsubscribe via returned off functions", () => { + const m = createWsManager(URL); + const offFrame = m.onFrame(() => undefined); + const offAuth = m.onAuthError(() => undefined); + const offState = m.onState(() => undefined); + offFrame(); + offAuth(); + offState(); + const sock = FakeWebSocket.last(); + sock.serverOpen(); + sock.serverMessage({ type: "spawn_status", jobs: [] }); + sock.serverClose(1008); + m.close(); + expect(true).toBe(true); + }); + + it("non-string ws data does not throw", () => { + const m = createWsManager(URL); + const sock = FakeWebSocket.last(); + sock.serverOpen(); + expect(() => + sock.onmessage?.({ data: {} } as unknown as MessageEvent), + ).not.toThrow(); + m.close(); + }); +}); diff --git a/web/tsconfig.json b/web/tsconfig.json index 26d09e8..9c7e9c1 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -15,7 +15,7 @@ "useDefineForClassFields": true, "skipLibCheck": true, "noEmit": true, - "types": ["vite/client"] + "types": ["vite/client", "vitest/globals"] }, "include": ["src"] } diff --git a/web/vite.config.ts b/web/vite.config.ts index 8ad3167..0460b25 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -1,17 +1,38 @@ +/// import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; const DEV_PROXY_TARGET: string = "http://localhost:8686"; export default defineConfig({ - plugins: [react()], - build: { - outDir: "dist", - }, - server: { - proxy: { - "/api": DEV_PROXY_TARGET, - "/ws": { target: DEV_PROXY_TARGET, ws: true }, - }, - }, + plugins: [react()], + build: { + outDir: "dist", + }, + server: { + proxy: { + "/api": DEV_PROXY_TARGET, + "/ws": { target: DEV_PROXY_TARGET, ws: true }, + }, + }, + test: { + environment: "jsdom", + globals: true, + setupFiles: ["./src/test/setup.ts"], + css: false, + pool: "forks", + fileParallelism: false, + coverage: { + provider: "v8", + include: ["src/**/*.{ts,tsx}"], + exclude: ["src/**/*.test.*", "src/test/**"], + reporter: ["text", "html"], + thresholds: { + lines: 95, + branches: 95, + functions: 95, + statements: 95, + }, + }, + }, });