From f8bfaeaa215bbd8274e1e0f03bc1a02b4f4b61e6 Mon Sep 17 00:00:00 2001 From: Raphael Westphal Date: Tue, 18 Aug 2026 15:33:02 +0200 Subject: [PATCH] map: tickets 06+07 resolved; 05 partial (live spawn pending gitea token) --- daemon/gitlab.go | 6 +- daemon/gitlab_extra_test.go | 2 +- deploy/deploy.sh | 18 +- e2e/fake-gitlab.mjs | 101 ++-- e2e/scenarios/gitlab.mjs | 118 +++-- web/src/App.test.tsx | 296 +++++++----- web/src/ChatStream.test.tsx | 409 ++++++++++------ web/src/ChatView.test.tsx | 569 +++++++++++++---------- web/src/SessionsView.test.tsx | 330 ++++++++----- web/src/SettingsGate.test.tsx | 154 +++--- web/src/SpawnView.test.tsx | 592 ++++++++++++----------- web/src/TaskPanel.test.tsx | 147 +++--- web/src/api.test.ts | 143 +++--- web/src/derive.test.ts | 851 ++++++++++++++++++++-------------- web/src/main.test.tsx | 48 +- web/src/store.test.tsx | 297 +++++++----- web/src/store.ts | 185 ++++---- web/src/test/setup.ts | 215 +++++---- 18 files changed, 2623 insertions(+), 1858 deletions(-) diff --git a/daemon/gitlab.go b/daemon/gitlab.go index a540375..0f22f23 100644 --- a/daemon/gitlab.go +++ b/daemon/gitlab.go @@ -41,9 +41,9 @@ type GitLabRepo struct { // giteaRepo is the subset of the upstream Gitea /api/v1/user/repos item we // map from. type giteaRepo struct { - FullName string `json:"full_name"` - Name string `json:"name"` - Owner struct { + FullName string `json:"full_name"` + Name string `json:"name"` + Owner struct { Login string `json:"login"` } `json:"owner"` UpdatedAt string `json:"updated_at"` diff --git a/daemon/gitlab_extra_test.go b/daemon/gitlab_extra_test.go index b54dce3..6779718 100644 --- a/daemon/gitlab_extra_test.go +++ b/daemon/gitlab_extra_test.go @@ -102,7 +102,7 @@ func TestGitLabStatusStoreFailure(t *testing.T) { func TestGitLabConnectStoreFailures(t *testing.T) { up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte(`{"login":"alice"}`)) + _, _ = w.Write([]byte(`{"login":"alice"}`)) })) t.Cleanup(up.Close) dead := openTestStore(t) diff --git a/deploy/deploy.sh b/deploy/deploy.sh index 7c17c60..aaff7c4 100755 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -7,22 +7,22 @@ REMOTE="${1:-alarm}" REMOTE_DIR="${2:-/zdata/root/dockerFiles/lvmh}" if ! ssh "$REMOTE" "test -f $REMOTE_DIR/.env" 2>/dev/null; then - echo "ERROR: $REMOTE:$REMOTE_DIR/.env missing. Create it first (see .env.example):" - echo " ssh $REMOTE 'mkdir -p $REMOTE_DIR && nano $REMOTE_DIR/.env'" - echo " # LVMH_TOKEN=, ZAI_RENAUD_API_KEY=" - exit 1 + echo "ERROR: $REMOTE:$REMOTE_DIR/.env missing. Create it first (see .env.example):" + echo " ssh $REMOTE 'mkdir -p $REMOTE_DIR && nano $REMOTE_DIR/.env'" + echo " # LVMH_TOKEN=, ZAI_RENAUD_API_KEY=" + exit 1 fi # web/dist must exist (daemon serves it) if [ ! -f web/dist/index.html ]; then - echo "building web/dist first..." - (cd web && bun install && bun run build) + echo "building web/dist first..." + (cd web && bun install && bun run build) fi rsync -az --delete \ - --exclude .git --exclude node_modules --exclude web/node_modules \ - --exclude .env --exclude .scratch --exclude .pi --exclude coverage \ - ./ "$REMOTE:$REMOTE_DIR/" + --exclude .git --exclude node_modules --exclude web/node_modules \ + --exclude .env --exclude .scratch --exclude .pi --exclude coverage \ + ./ "$REMOTE:$REMOTE_DIR/" echo "building daemon image + worker image on $REMOTE..." ssh "$REMOTE" "cd $REMOTE_DIR \ diff --git a/e2e/fake-gitlab.mjs b/e2e/fake-gitlab.mjs index c266271..bdf1fb4 100644 --- a/e2e/fake-gitlab.mjs +++ b/e2e/fake-gitlab.mjs @@ -13,54 +13,55 @@ const HTTP_UNAUTHORIZED = 401; const HTTP_NOT_FOUND = 404; export function startFakeGitLab() { - const seen = { userAuths: [], repoAuths: [] }; - const server = http.createServer((req, res) => { - const auth = String(req.headers["authorization"] ?? ""); - const send = (code, obj) => { - res.writeHead(code, { "Content-Type": "application/json" }); - res.end(JSON.stringify(obj)); - }; - if (req.url.startsWith("/api/v1/user/repos")) { - seen.repoAuths.push(auth); - if (auth !== `token ${GOOD_PAT}`) return send(HTTP_UNAUTHORIZED, { message: "401 Unauthorized" }); - const base = `http://127.0.0.1:${server.address().port}`; - return send(HTTP_OK, [ - { - full_name: "lvmh/beta", - name: "beta", - owner: { login: "lvmh" }, - updated_at: "2025-07-01T10:00:00Z", - html_url: `${base}/lvmh/beta`, - default_branch: "main", - }, - { - full_name: "lvmh/alpha", - name: "alpha", - owner: { login: "lvmh" }, - updated_at: "2025-08-01T10:00:00Z", - html_url: `${base}/lvmh/alpha`, - default_branch: "trunk", - }, - ]); - } - if (req.url.startsWith("/api/v1/user")) { - seen.userAuths.push(auth); - return auth === `token ${GOOD_PAT}` - ? send(HTTP_OK, { id: 1, login: "e2e-user", name: "E2E User" }) - : send(HTTP_UNAUTHORIZED, { message: "401 Unauthorized" }); - } - return send(HTTP_NOT_FOUND, { message: "404 Not Found" }); - }); - return new Promise((resolve) => { - server.listen(0, "127.0.0.1", () => { - resolve({ - url: `http://127.0.0.1:${server.address().port}`, - pat: GOOD_PAT, - seen, - close() { - server.close(); - }, - }); - }); - }); + const seen = { userAuths: [], repoAuths: [] }; + const server = http.createServer((req, res) => { + const auth = String(req.headers["authorization"] ?? ""); + const send = (code, obj) => { + res.writeHead(code, { "Content-Type": "application/json" }); + res.end(JSON.stringify(obj)); + }; + if (req.url.startsWith("/api/v1/user/repos")) { + seen.repoAuths.push(auth); + if (auth !== `token ${GOOD_PAT}`) + return send(HTTP_UNAUTHORIZED, { message: "401 Unauthorized" }); + const base = `http://127.0.0.1:${server.address().port}`; + return send(HTTP_OK, [ + { + full_name: "lvmh/beta", + name: "beta", + owner: { login: "lvmh" }, + updated_at: "2025-07-01T10:00:00Z", + html_url: `${base}/lvmh/beta`, + default_branch: "main", + }, + { + full_name: "lvmh/alpha", + name: "alpha", + owner: { login: "lvmh" }, + updated_at: "2025-08-01T10:00:00Z", + html_url: `${base}/lvmh/alpha`, + default_branch: "trunk", + }, + ]); + } + if (req.url.startsWith("/api/v1/user")) { + seen.userAuths.push(auth); + return auth === `token ${GOOD_PAT}` + ? send(HTTP_OK, { id: 1, login: "e2e-user", name: "E2E User" }) + : send(HTTP_UNAUTHORIZED, { message: "401 Unauthorized" }); + } + return send(HTTP_NOT_FOUND, { message: "404 Not Found" }); + }); + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + resolve({ + url: `http://127.0.0.1:${server.address().port}`, + pat: GOOD_PAT, + seen, + close() { + server.close(); + }, + }); + }); + }); } diff --git a/e2e/scenarios/gitlab.mjs b/e2e/scenarios/gitlab.mjs index 8102596..b41b0ca 100644 --- a/e2e/scenarios/gitlab.mjs +++ b/e2e/scenarios/gitlab.mjs @@ -5,56 +5,80 @@ import { rest } from "../lib.mjs"; export async function run(ctx) { - const { r, base, token, gitlab } = ctx; + const { r, base, token, gitlab } = ctx; - const connect = await rest(base, token, "/api/gitlab/connect", { - method: "POST", - body: { token: gitlab.pat }, - }); - r.check( - "POST /api/gitlab/connect → 200 {username}", - connect.status === 200 && connect.json?.username === "e2e-user", - `got ${connect.status} ${connect.text}`, - ); - r.check( - "daemon validated token against fake /api/v1/user", - gitlab.seen.userAuths.length === 1 && gitlab.seen.userAuths[0] === `token ${gitlab.pat}`, - JSON.stringify(gitlab.seen.userAuths), - ); + const connect = await rest(base, token, "/api/gitlab/connect", { + method: "POST", + body: { token: gitlab.pat }, + }); + r.check( + "POST /api/gitlab/connect → 200 {username}", + connect.status === 200 && connect.json?.username === "e2e-user", + `got ${connect.status} ${connect.text}`, + ); + r.check( + "daemon validated token against fake /api/v1/user", + gitlab.seen.userAuths.length === 1 && + gitlab.seen.userAuths[0] === `token ${gitlab.pat}`, + JSON.stringify(gitlab.seen.userAuths), + ); - const status = await rest(base, token, "/api/gitlab/status"); - r.check( - "status shows connected with username + baseUrl", - status.json?.connected === true && status.json?.username === "e2e-user" && status.json?.baseUrl === gitlab.url, - JSON.stringify(status.json), - ); + const status = await rest(base, token, "/api/gitlab/status"); + r.check( + "status shows connected with username + baseUrl", + status.json?.connected === true && + status.json?.username === "e2e-user" && + status.json?.baseUrl === gitlab.url, + JSON.stringify(status.json), + ); - const repos = await rest(base, token, "/api/gitlab/repos"); - r.check( - "repos mapped to protocol shape", - Array.isArray(repos.json) && repos.json.length === 2 && - repos.json.every((p) => typeof p.path === "string" && typeof p.name === "string" && typeof p.namespace === "string" && typeof p.lastActivityAt === "string" && typeof p.webUrl === "string" && typeof p.defaultBranch === "string"), - JSON.stringify(repos.json ?? null), - ); - r.check( - "repos sorted by lastActivityAt desc (daemon-side sort)", - repos.json?.[0]?.path === "lvmh/alpha" && repos.json?.[1]?.path === "lvmh/beta" && - repos.json?.[0]?.defaultBranch === "trunk", - JSON.stringify((repos.json ?? []).map((p) => p.path)), - ); + const repos = await rest(base, token, "/api/gitlab/repos"); + r.check( + "repos mapped to protocol shape", + Array.isArray(repos.json) && + repos.json.length === 2 && + repos.json.every( + (p) => + typeof p.path === "string" && + typeof p.name === "string" && + typeof p.namespace === "string" && + typeof p.lastActivityAt === "string" && + typeof p.webUrl === "string" && + typeof p.defaultBranch === "string", + ), + JSON.stringify(repos.json ?? null), + ); + r.check( + "repos sorted by lastActivityAt desc (daemon-side sort)", + repos.json?.[0]?.path === "lvmh/alpha" && + repos.json?.[1]?.path === "lvmh/beta" && + repos.json?.[0]?.defaultBranch === "trunk", + JSON.stringify((repos.json ?? []).map((p) => p.path)), + ); - const leaky = [connect.text, status.text, repos.text].filter((t) => t.includes(gitlab.pat)); - r.check("PAT never appears in any daemon response", leaky.length === 0, `leaked in ${leaky.length} response(s)`); + const leaky = [connect.text, status.text, repos.text].filter((t) => + t.includes(gitlab.pat), + ); + r.check( + "PAT never appears in any daemon response", + leaky.length === 0, + `leaked in ${leaky.length} response(s)`, + ); - const badConnect = await rest(base, token, "/api/gitlab/connect", { - method: "POST", - body: { token: "glpat-wrong" }, - }); - r.check("connect with bad PAT → error status", badConnect.status >= 400, `got ${badConnect.status}`); - const statusAfter = await rest(base, token, "/api/gitlab/status"); - r.check( - "failed connect does not clobber the stored PAT", - statusAfter.json?.connected === true && statusAfter.json?.username === "e2e-user", - JSON.stringify(statusAfter.json), - ); + const badConnect = await rest(base, token, "/api/gitlab/connect", { + method: "POST", + body: { token: "glpat-wrong" }, + }); + r.check( + "connect with bad PAT → error status", + badConnect.status >= 400, + `got ${badConnect.status}`, + ); + const statusAfter = await rest(base, token, "/api/gitlab/status"); + r.check( + "failed connect does not clobber the stored PAT", + statusAfter.json?.connected === true && + statusAfter.json?.username === "e2e-user", + JSON.stringify(statusAfter.json), + ); } diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index 6b1c466..86c3d44 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -1,155 +1,225 @@ -import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +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 { + 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 }, + { + 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 []; - }); + 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( - - - - ); + return render( + + + , + ); } afterEach(() => { - clearSettings(); + 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("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"); - }); + 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("/"); + 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(); + 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(); + 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(); + // 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" }); + // 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()); - }); + // 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("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" }); + 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"); + 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"); + // 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")); - }); + 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" }); + 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(); - }); + 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" }); + 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(); - }); + 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"); - }); + 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"); + return document.querySelector(".sidebar-backdrop"); } diff --git a/web/src/ChatStream.test.tsx b/web/src/ChatStream.test.tsx index 2a395c2..d223058 100644 --- a/web/src/ChatStream.test.tsx +++ b/web/src/ChatStream.test.tsx @@ -5,175 +5,298 @@ 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, - }; + 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, + 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 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("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 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("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("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(); + 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"); - }); + 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 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("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"); + 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(); - }); + const { container } = render( + , + ); + expect(container.querySelector(".thinking")).toBeNull(); + }); - it("streaming bubble shows the caret", () => { - const { container } = render(); - expect(container.querySelector(".stream-caret")).not.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); - }); + 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(); + 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(); - }); + 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 }); + 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); + 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 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); - }); + // 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 index d96325a..5af3ba9 100644 --- a/web/src/ChatView.test.tsx +++ b/web/src/ChatView.test.tsx @@ -1,4 +1,10 @@ -import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +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"; @@ -10,288 +16,385 @@ 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; + 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, - }, + { + 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, - }; + 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; + sessionId: string; + onEvents: (events: EventFrame[]) => void; } let currentSub: ActiveSub | null = null; function push(events: EventFrame[]): void { - act(() => currentSub?.onEvents(events)); + act(() => currentSub?.onEvents(events)); } -function renderChat(store: SessionsStore, path = "/s/s1"): ReturnType { - return render( - - - } /> - OTHER} /> - - - ); +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"), - ]; + 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(); + seq = 0; + currentSub = null; + pushToast.mockClear(); + seedSettings(); }); afterEach(() => { - vi.restoreAllMocks(); + 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); + 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 screen.findByText("hello there"); + expect(screen.getByText("hi!")).toBeInTheDocument(); - await waitFor(() => expect(currentSub).not.toBeNull()); - expect(currentSub?.sessionId).toBe("s1"); - expect(fetchMock).toHaveBeenCalled(); + 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(); + // 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} /> - - - ); - }); + rerender( + + + } + /> + OTHER} /> + + , + ); + }); - it("live delta streaming appends into a streaming bubble and ends it", async () => { - mockFetchJson(() => []); - renderChat(makeStore()); - await waitFor(() => expect(currentSub).not.toBeNull()); + 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_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(); - }); + 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()); + 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_start")]); + expect(container.querySelector(".typing")).not.toBeNull(); - push([ev("agent_settled")]); - expect(container.querySelector(".typing")).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("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("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(); + 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(); + 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(""); - }); + 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 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("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("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"); + 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")); - }); + // 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()); + 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); - }); + // 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(); - }); + 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 index 7ae594c..46c6854 100644 --- a/web/src/SessionsView.test.tsx +++ b/web/src/SessionsView.test.tsx @@ -6,146 +6,226 @@ 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, - }; + 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 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; + const { id } = useParams(); + onVisit(`/s/${id ?? ""}`); + return null; } describe("SessionsView", () => { - it("empty state message", () => { - renderView(); - expect(screen.getByText(/No sessions yet/i)).toBeInTheDocument(); - }); + 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("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("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("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("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("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 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 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("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")); - }); + 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 index 30060f5..95f681f 100644 --- a/web/src/SettingsGate.test.tsx +++ b/web/src/SettingsGate.test.tsx @@ -6,82 +6,104 @@ import SettingsGate from "./SettingsGate"; import { jsonResponse } from "./test/setup"; afterEach(() => { - vi.restoreAllMocks(); - localStorage.clear(); + vi.restoreAllMocks(); + localStorage.clear(); }); function setup(): { connect: () => Promise } { - render( undefined} />); - return { - connect: async (): Promise => { - await userEvent.click(screen.getByRole("button", { name: "Connect" })); - }, - }; + 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("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("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("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("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("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("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"); - }); + 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 index 77c81cd..bd6d39d 100644 --- a/web/src/SpawnView.test.tsx +++ b/web/src/SpawnView.test.tsx @@ -7,333 +7,369 @@ 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, + 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, - }; + return { + sessions: [], + state: "open", + spawnJobs: [], + refresh: async (): Promise => undefined, + subscribe: (): (() => void) => () => undefined, + ...over, + }; } function tree(store: SessionsStore): React.ReactElement { - return ( - - - } /> - } /> - - - ); + 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(); - }); - } + 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(); + PUSH_TOAST.mockClear(); + seedSettings(); }); afterEach(() => { - vi.restoreAllMocks(); + 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(); - }); + 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("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(); + 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(); + 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(); - }); + 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("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(); - }); + 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 []; - }); - } + 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(); + 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 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"); + 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(); - }); + 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(); - }); + 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(); - }); + 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(); + 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(); + 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(); + // 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(); - }); + // 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" })]); + 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"); - }); + 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("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(); + 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(); - }); + 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(); + 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(); - }); + 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 index 0e0ad6f..d2eb288 100644 --- a/web/src/TaskPanel.test.tsx +++ b/web/src/TaskPanel.test.tsx @@ -6,66 +6,97 @@ import TaskPanel from "./TaskPanel"; const base: TaskDerivation = { todos: [], subagents: [], workingTools: [] }; describe("TaskPanel", () => { - it("empty state", () => { - render(); - expect(screen.getByText("No tasks yet.")).toBeInTheDocument(); - }); + 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("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("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("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(); - }); + 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 index f3fdfa5..55d0916 100644 --- a/web/src/api.test.ts +++ b/web/src/api.test.ts @@ -4,70 +4,97 @@ import { saveSettings } from "./settings"; import { jsonResponse } from "./test/setup"; describe("api", () => { - beforeEach(() => { - localStorage.clear(); - saveSettings({ serverUrl: "http://srv", token: "sekret" }); - }); + 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("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("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("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("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 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("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("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"); - }); + 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 index e1a3420..50a708c 100644 --- a/web/src/derive.test.ts +++ b/web/src/derive.test.ts @@ -1,422 +1,581 @@ import { describe, expect, it } from "vitest"; import type { EventFrame, Message } from "./protocol"; -import { deriveChat, deriveTasks, lastPersistedSeq, mergeEvents } from "./derive"; +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; + 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, - }; + 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 ?? "" }); + 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 ?? "" }); + 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("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"); - }); + 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("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); - }); + 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_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); + 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); - }); + 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("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("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("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_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("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 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_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: "" }); - }); + 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); + 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("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("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("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("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("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); - }); + 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("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("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); - }); + 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("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); - }); + 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("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); + 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); - }); + // 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_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("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("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("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(); + 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([]); + // 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 }); + // 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); - }); + // 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 index 8d79ca9..b399d44 100644 --- a/web/src/main.test.tsx +++ b/web/src/main.test.tsx @@ -2,40 +2,42 @@ 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 })), + 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"); + vi.resetModules(); + await import("./main"); } function rootElement(): HTMLElement { - const el = document.createElement("div"); - el.id = "root"; - document.body.appendChild(el); - return el; + 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("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(); - }); + 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/store.test.tsx b/web/src/store.test.tsx index 68cedb5..2b2cb30 100644 --- a/web/src/store.test.tsx +++ b/web/src/store.test.tsx @@ -2,167 +2,212 @@ 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 { + 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(); - }); + 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"); - }); + 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(""); - }); + 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(); - }); + 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"]); + 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(() => result.current.push("second")); + expect(result.current.toasts).toHaveLength(2); - act(() => { - vi.advanceTimersByTime(4000); - }); - expect(result.current.toasts).toHaveLength(0); - }); + 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" }; + 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("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)); + 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)); + 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"); + 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: "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" }]); + 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(); - }); + 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("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()); + 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" })); + 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(() => + 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" })); - }); + 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("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(); - }); + 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"); - }); + 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 42a41cd..1979d87 100644 --- a/web/src/store.ts +++ b/web/src/store.ts @@ -1,5 +1,10 @@ import { useCallback, useEffect, useState } from "react"; -import type { EventFrame, ServerFrame, SessionListItem, SpawnJob } from "./protocol"; +import type { + EventFrame, + ServerFrame, + SessionListItem, + SpawnJob, +} from "./protocol"; import { Route } from "./protocol"; import { buildWsUrl, getSettings, clearSettings } from "./settings"; import { createWsManager, type WsManager, type WsState } from "./ws"; @@ -12,115 +17,129 @@ const HOUR_MS: number = 60 * MINUTE_MS; const DAY_MS: number = 24 * HOUR_MS; export function relativeTime(ts: number | null): string { - if (ts === null) return "never"; - const abs: number = Math.abs(Date.now() - ts); - if (abs < MINUTE_MS) return "just now"; - if (abs < HOUR_MS) return `${Math.floor(abs / MINUTE_MS)}m ago`; - if (abs < DAY_MS) return `${Math.floor(abs / HOUR_MS)}h ago`; - return `${Math.floor(abs / DAY_MS)}d ago`; + if (ts === null) return "never"; + const abs: number = Math.abs(Date.now() - ts); + if (abs < MINUTE_MS) return "just now"; + if (abs < HOUR_MS) return `${Math.floor(abs / MINUTE_MS)}m ago`; + if (abs < DAY_MS) return `${Math.floor(abs / HOUR_MS)}h ago`; + return `${Math.floor(abs / DAY_MS)}d ago`; } -export function classNames(...parts: Array): string { - return parts.filter(Boolean).join(" "); +export function classNames( + ...parts: Array +): string { + return parts.filter(Boolean).join(" "); } // ---------- toasts ---------- export interface Toast { - id: number; - text: string; + id: number; + text: string; } let toastSeq: number = 0; const TOAST_TTL_MS: number = 4000; export function useToasts(): { toasts: Toast[]; push: (text: string) => void } { - const [toasts, setToasts] = useState([]); - const push = useCallback((text: string): void => { - const id: number = ++toastSeq; - setToasts((prev) => [...prev, { id, text }]); - window.setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), TOAST_TTL_MS); - }, []); - return { toasts, push }; + const [toasts, setToasts] = useState([]); + const push = useCallback((text: string): void => { + const id: number = ++toastSeq; + setToasts((prev) => [...prev, { id, text }]); + window.setTimeout( + () => setToasts((prev) => prev.filter((t) => t.id !== id)), + TOAST_TTL_MS, + ); + }, []); + return { toasts, push }; } // ---------- sessions store (REST seed + WS updates) ---------- export interface SessionsStore { - sessions: SessionListItem[]; - state: WsState; - spawnJobs: SpawnJob[]; - refresh: () => Promise; - /** Subscribe to one session's event stream (protocol allows one at a time). */ - subscribe: (sessionId: string, onEvents: (events: EventFrame[]) => void) => () => void; + sessions: SessionListItem[]; + state: WsState; + spawnJobs: SpawnJob[]; + refresh: () => Promise; + /** Subscribe to one session's event stream (protocol allows one at a time). */ + subscribe: ( + sessionId: string, + onEvents: (events: EventFrame[]) => void, + ) => () => void; } -export function useSessions(pushToast: (text: string) => void): SessionsStore | null { - const [sessions, setSessions] = useState([]); - const [spawnJobs, setSpawnJobs] = useState([]); - const [state, setState] = useState("connecting"); - const [manager, setManager] = useState(null); +export function useSessions( + pushToast: (text: string) => void, +): SessionsStore | null { + const [sessions, setSessions] = useState([]); + const [spawnJobs, setSpawnJobs] = useState([]); + const [state, setState] = useState("connecting"); + const [manager, setManager] = useState(null); - useEffect(() => { - const settings = getSettings(); - if (settings === null) return; + useEffect(() => { + const settings = getSettings(); + if (settings === null) return; - const m = createWsManager(buildWsUrl(settings)); - setManager(m); + const m = createWsManager(buildWsUrl(settings)); + setManager(m); - const offState = m.onState(setState); - const offFrames = m.onFrame((frame: ServerFrame) => { - if (frame.type === "session_list") setSessions(frame.sessions); - else if (frame.type === "spawn_status") setSpawnJobs(frame.jobs); - }); - m.onAuthError(() => { - clearSettings(); - window.location.reload(); - }); + const offState = m.onState(setState); + const offFrames = m.onFrame((frame: ServerFrame) => { + if (frame.type === "session_list") setSessions(frame.sessions); + else if (frame.type === "spawn_status") setSpawnJobs(frame.jobs); + }); + m.onAuthError(() => { + clearSettings(); + window.location.reload(); + }); - const refresh = async (): Promise => { - try { - setSessions(await fetchJson(Route.Sessions)); - } catch (err) { - pushToast(`sessions: ${errMessage(err)}`); - } - }; - void refresh(); + const refresh = async (): Promise => { + try { + setSessions(await fetchJson(Route.Sessions)); + } catch (err) { + pushToast(`sessions: ${errMessage(err)}`); + } + }; + void refresh(); - return () => { - offState(); - offFrames(); - m.close(); - setManager(null); - }; - }, [pushToast]); + return () => { + offState(); + offFrames(); + m.close(); + setManager(null); + }; + }, [pushToast]); - const refresh = useCallback(async (): Promise => { - try { - setSessions(await fetchJson(Route.Sessions)); - } catch (err) { - pushToast(`sessions: ${errMessage(err)}`); - } - }, [pushToast]); + const refresh = useCallback(async (): Promise => { + try { + setSessions(await fetchJson(Route.Sessions)); + } catch (err) { + pushToast(`sessions: ${errMessage(err)}`); + } + }, [pushToast]); - const subscribe = useCallback( - (sessionId: string, onEvents: (events: EventFrame[]) => void): (() => void) => { - if (manager === null) return () => undefined; - const off = manager.onFrame((frame: ServerFrame) => { - if (frame.type === "events" && frame.sessionId === sessionId) onEvents(frame.events); - }); - manager.subscribe(sessionId); - return () => { - manager.unsubscribe(sessionId); - off(); - }; - }, - [manager] - ); + const subscribe = useCallback( + ( + sessionId: string, + onEvents: (events: EventFrame[]) => void, + ): (() => void) => { + if (manager === null) return () => undefined; + const off = manager.onFrame((frame: ServerFrame) => { + if (frame.type === "events" && frame.sessionId === sessionId) + onEvents(frame.events); + }); + manager.subscribe(sessionId); + return () => { + manager.unsubscribe(sessionId); + off(); + }; + }, + [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; + // 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 }; + return { sessions, state, spawnJobs, refresh, subscribe }; } diff --git a/web/src/test/setup.ts b/web/src/test/setup.ts index c4261a7..d19feed 100644 --- a/web/src/test/setup.ts +++ b/web/src/test/setup.ts @@ -7,59 +7,59 @@ import type { MockInstance } from "vitest"; 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[] = []; + 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; + 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); - } + constructor(url: string) { + this.url = url; + FakeWebSocket.instances.push(this); + } - send(data: string): void { - this.sent.push(data); - } + send(data: string): void { + this.sent.push(data); + } - close(code = 1000): void { - this.readyState = FakeWebSocket.CLOSED; - this.closeCode = code; - } + 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")); - } + // ---- 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); - } + 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); - } + serverClose(code = 1006): void { + this.readyState = FakeWebSocket.CLOSED; + this.onclose?.({ code } as CloseEvent); + } - static reset(): void { - FakeWebSocket.instances = []; - } + 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; - } + 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; @@ -68,89 +68,112 @@ 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; + 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); + 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) - ); + 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 })); + 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 }); - }, - }; +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(); - } + 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 }); +Object.defineProperty(globalThis, "localStorage", { + configurable: true, + writable: true, + value: memoryStorage, +}); // ---------- global hygiene ---------- beforeEach(() => { - FakeWebSocket.reset(); - memoryStorage.clear(); + FakeWebSocket.reset(); + memoryStorage.clear(); }); afterEach(() => { - vi.restoreAllMocks(); - vi.useRealTimers(); + vi.restoreAllMocks(); + vi.useRealTimers(); });