map: tickets 06+07 resolved; 05 partial (live spawn pending gitea token)

This commit is contained in:
Raphael Westphal
2026-08-18 15:33:02 +02:00
parent 1a267bf489
commit f8bfaeaa21
18 changed files with 2623 additions and 1858 deletions
+3 -3
View File
@@ -41,9 +41,9 @@ type GitLabRepo struct {
// giteaRepo is the subset of the upstream Gitea /api/v1/user/repos item we // giteaRepo is the subset of the upstream Gitea /api/v1/user/repos item we
// map from. // map from.
type giteaRepo struct { type giteaRepo struct {
FullName string `json:"full_name"` FullName string `json:"full_name"`
Name string `json:"name"` Name string `json:"name"`
Owner struct { Owner struct {
Login string `json:"login"` Login string `json:"login"`
} `json:"owner"` } `json:"owner"`
UpdatedAt string `json:"updated_at"` UpdatedAt string `json:"updated_at"`
+1 -1
View File
@@ -102,7 +102,7 @@ func TestGitLabStatusStoreFailure(t *testing.T) {
func TestGitLabConnectStoreFailures(t *testing.T) { func TestGitLabConnectStoreFailures(t *testing.T) {
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 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) t.Cleanup(up.Close)
dead := openTestStore(t) dead := openTestStore(t)
+9 -9
View File
@@ -7,22 +7,22 @@ REMOTE="${1:-alarm}"
REMOTE_DIR="${2:-/zdata/root/dockerFiles/lvmh}" REMOTE_DIR="${2:-/zdata/root/dockerFiles/lvmh}"
if ! ssh "$REMOTE" "test -f $REMOTE_DIR/.env" 2>/dev/null; then 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 "ERROR: $REMOTE:$REMOTE_DIR/.env missing. Create it first (see .env.example):"
echo " ssh $REMOTE 'mkdir -p $REMOTE_DIR && nano $REMOTE_DIR/.env'" echo " ssh $REMOTE 'mkdir -p $REMOTE_DIR && nano $REMOTE_DIR/.env'"
echo " # LVMH_TOKEN=<random>, ZAI_RENAUD_API_KEY=<key>" echo " # LVMH_TOKEN=<random>, ZAI_RENAUD_API_KEY=<key>"
exit 1 exit 1
fi fi
# web/dist must exist (daemon serves it) # web/dist must exist (daemon serves it)
if [ ! -f web/dist/index.html ]; then if [ ! -f web/dist/index.html ]; then
echo "building web/dist first..." echo "building web/dist first..."
(cd web && bun install && bun run build) (cd web && bun install && bun run build)
fi fi
rsync -az --delete \ rsync -az --delete \
--exclude .git --exclude node_modules --exclude web/node_modules \ --exclude .git --exclude node_modules --exclude web/node_modules \
--exclude .env --exclude .scratch --exclude .pi --exclude coverage \ --exclude .env --exclude .scratch --exclude .pi --exclude coverage \
./ "$REMOTE:$REMOTE_DIR/" ./ "$REMOTE:$REMOTE_DIR/"
echo "building daemon image + worker image on $REMOTE..." echo "building daemon image + worker image on $REMOTE..."
ssh "$REMOTE" "cd $REMOTE_DIR \ ssh "$REMOTE" "cd $REMOTE_DIR \
+51 -50
View File
@@ -13,54 +13,55 @@ const HTTP_UNAUTHORIZED = 401;
const HTTP_NOT_FOUND = 404; const HTTP_NOT_FOUND = 404;
export function startFakeGitLab() { export function startFakeGitLab() {
const seen = { userAuths: [], repoAuths: [] }; const seen = { userAuths: [], repoAuths: [] };
const server = http.createServer((req, res) => { const server = http.createServer((req, res) => {
const auth = String(req.headers["authorization"] ?? ""); const auth = String(req.headers["authorization"] ?? "");
const send = (code, obj) => { const send = (code, obj) => {
res.writeHead(code, { "Content-Type": "application/json" }); res.writeHead(code, { "Content-Type": "application/json" });
res.end(JSON.stringify(obj)); res.end(JSON.stringify(obj));
}; };
if (req.url.startsWith("/api/v1/user/repos")) { if (req.url.startsWith("/api/v1/user/repos")) {
seen.repoAuths.push(auth); seen.repoAuths.push(auth);
if (auth !== `token ${GOOD_PAT}`) return send(HTTP_UNAUTHORIZED, { message: "401 Unauthorized" }); if (auth !== `token ${GOOD_PAT}`)
const base = `http://127.0.0.1:${server.address().port}`; return send(HTTP_UNAUTHORIZED, { message: "401 Unauthorized" });
return send(HTTP_OK, [ const base = `http://127.0.0.1:${server.address().port}`;
{ return send(HTTP_OK, [
full_name: "lvmh/beta", {
name: "beta", full_name: "lvmh/beta",
owner: { login: "lvmh" }, name: "beta",
updated_at: "2025-07-01T10:00:00Z", owner: { login: "lvmh" },
html_url: `${base}/lvmh/beta`, updated_at: "2025-07-01T10:00:00Z",
default_branch: "main", html_url: `${base}/lvmh/beta`,
}, default_branch: "main",
{ },
full_name: "lvmh/alpha", {
name: "alpha", full_name: "lvmh/alpha",
owner: { login: "lvmh" }, name: "alpha",
updated_at: "2025-08-01T10:00:00Z", owner: { login: "lvmh" },
html_url: `${base}/lvmh/alpha`, updated_at: "2025-08-01T10:00:00Z",
default_branch: "trunk", html_url: `${base}/lvmh/alpha`,
}, default_branch: "trunk",
]); },
} ]);
if (req.url.startsWith("/api/v1/user")) { }
seen.userAuths.push(auth); if (req.url.startsWith("/api/v1/user")) {
return auth === `token ${GOOD_PAT}` seen.userAuths.push(auth);
? send(HTTP_OK, { id: 1, login: "e2e-user", name: "E2E User" }) return auth === `token ${GOOD_PAT}`
: send(HTTP_UNAUTHORIZED, { message: "401 Unauthorized" }); ? 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 send(HTTP_NOT_FOUND, { message: "404 Not Found" });
return new Promise((resolve) => { });
server.listen(0, "127.0.0.1", () => { return new Promise((resolve) => {
resolve({ server.listen(0, "127.0.0.1", () => {
url: `http://127.0.0.1:${server.address().port}`, resolve({
pat: GOOD_PAT, url: `http://127.0.0.1:${server.address().port}`,
seen, pat: GOOD_PAT,
close() { seen,
server.close(); close() {
}, server.close();
}); },
}); });
}); });
});
} }
+71 -47
View File
@@ -5,56 +5,80 @@
import { rest } from "../lib.mjs"; import { rest } from "../lib.mjs";
export async function run(ctx) { 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", { const connect = await rest(base, token, "/api/gitlab/connect", {
method: "POST", method: "POST",
body: { token: gitlab.pat }, body: { token: gitlab.pat },
}); });
r.check( r.check(
"POST /api/gitlab/connect → 200 {username}", "POST /api/gitlab/connect → 200 {username}",
connect.status === 200 && connect.json?.username === "e2e-user", connect.status === 200 && connect.json?.username === "e2e-user",
`got ${connect.status} ${connect.text}`, `got ${connect.status} ${connect.text}`,
); );
r.check( r.check(
"daemon validated token against fake /api/v1/user", "daemon validated token against fake /api/v1/user",
gitlab.seen.userAuths.length === 1 && gitlab.seen.userAuths[0] === `token ${gitlab.pat}`, gitlab.seen.userAuths.length === 1 &&
JSON.stringify(gitlab.seen.userAuths), gitlab.seen.userAuths[0] === `token ${gitlab.pat}`,
); JSON.stringify(gitlab.seen.userAuths),
);
const status = await rest(base, token, "/api/gitlab/status"); const status = await rest(base, token, "/api/gitlab/status");
r.check( r.check(
"status shows connected with username + baseUrl", "status shows connected with username + baseUrl",
status.json?.connected === true && status.json?.username === "e2e-user" && status.json?.baseUrl === gitlab.url, status.json?.connected === true &&
JSON.stringify(status.json), status.json?.username === "e2e-user" &&
); status.json?.baseUrl === gitlab.url,
JSON.stringify(status.json),
);
const repos = await rest(base, token, "/api/gitlab/repos"); const repos = await rest(base, token, "/api/gitlab/repos");
r.check( r.check(
"repos mapped to protocol shape", "repos mapped to protocol shape",
Array.isArray(repos.json) && repos.json.length === 2 && Array.isArray(repos.json) &&
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"), repos.json.length === 2 &&
JSON.stringify(repos.json ?? null), repos.json.every(
); (p) =>
r.check( typeof p.path === "string" &&
"repos sorted by lastActivityAt desc (daemon-side sort)", typeof p.name === "string" &&
repos.json?.[0]?.path === "lvmh/alpha" && repos.json?.[1]?.path === "lvmh/beta" && typeof p.namespace === "string" &&
repos.json?.[0]?.defaultBranch === "trunk", typeof p.lastActivityAt === "string" &&
JSON.stringify((repos.json ?? []).map((p) => p.path)), 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)); const leaky = [connect.text, status.text, repos.text].filter((t) =>
r.check("PAT never appears in any daemon response", leaky.length === 0, `leaked in ${leaky.length} response(s)`); 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", { const badConnect = await rest(base, token, "/api/gitlab/connect", {
method: "POST", method: "POST",
body: { token: "glpat-wrong" }, body: { token: "glpat-wrong" },
}); });
r.check("connect with bad PAT → error status", badConnect.status >= 400, `got ${badConnect.status}`); r.check(
const statusAfter = await rest(base, token, "/api/gitlab/status"); "connect with bad PAT → error status",
r.check( badConnect.status >= 400,
"failed connect does not clobber the stored PAT", `got ${badConnect.status}`,
statusAfter.json?.connected === true && statusAfter.json?.username === "e2e-user", );
JSON.stringify(statusAfter.json), 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),
);
} }
+183 -113
View File
@@ -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 { MemoryRouter } from "react-router-dom";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import App from "./App"; import App from "./App";
import { clearSettings } from "./settings"; 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"; import type { SessionListItem } from "./protocol";
const sessions: SessionListItem[] = [ 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 }, id: "s1",
// bare session: exercises null-name/null-repo fallbacks name: "alpha",
{ id: "s3", name: null, cwd: "/w", model: "glm-5.3", provider: "zai-renaud", agent: false, repo: null, startedAt: 30, online: false, lastEventAt: null }, 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 { function seedApi(): void {
mockFetchJson((url) => { mockFetchJson((url) => {
if (url.includes("/api/sessions")) return url.includes("/events") ? [] : sessions; if (url.includes("/api/sessions"))
if (url.includes("/api/gitlab/status")) return { connected: false, baseUrl: "https://gl" }; return url.includes("/events") ? [] : sessions;
return []; if (url.includes("/api/gitlab/status"))
}); return { connected: false, baseUrl: "https://gl" };
return [];
});
} }
function renderApp(path = "/"): ReturnType<typeof render> { function renderApp(path = "/"): ReturnType<typeof render> {
return render( return render(
<MemoryRouter initialEntries={[path]}> <MemoryRouter initialEntries={[path]}>
<App /> <App />
</MemoryRouter> </MemoryRouter>,
); );
} }
afterEach(() => { afterEach(() => {
clearSettings(); clearSettings();
}); });
describe("App gate", () => { describe("App gate", () => {
it("shows the settings gate when unconfigured", () => { it("shows the settings gate when unconfigured", () => {
renderApp(); renderApp();
expect(screen.getByText("Connect to your lvmh daemon.")).toBeInTheDocument(); expect(
expect(screen.queryByLabelText("Sessions")).toBeNull(); screen.getByText("Connect to your lvmh daemon."),
}); ).toBeInTheDocument();
expect(screen.queryByLabelText("Sessions")).toBeNull();
});
it("after saving settings the shell renders", async () => { it("after saving settings the shell renders", async () => {
seedApi(); seedApi();
renderApp(); renderApp();
fireEvent.input(screen.getByLabelText("Bearer token"), { target: { value: "tok" } }); fireEvent.input(screen.getByLabelText("Bearer token"), {
fireEvent.submit(screen.getByRole("button", { name: "Connect" }).closest("form") as HTMLFormElement); target: { value: "tok" },
await screen.findByLabelText("Sessions"); });
}); fireEvent.submit(
screen
.getByRole("button", { name: "Connect" })
.closest("form") as HTMLFormElement,
);
await screen.findByLabelText("Sessions");
});
}); });
describe("App shell", () => { describe("App shell", () => {
it("renders sidebar sessions sorted by activity, conn state, routes and toasts", async () => { it("renders sidebar sessions sorted by activity, conn state, routes and toasts", async () => {
seedApi(); seedApi();
seedSettings(); seedSettings();
renderApp("/"); renderApp("/");
await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1)); await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1));
const sock = FakeWebSocket.last(); const sock = FakeWebSocket.last();
act(() => sock.serverOpen()); act(() => sock.serverOpen());
expect(screen.getByRole("img", { name: "connection open" })).toBeInTheDocument(); expect(
screen.getByRole("img", { name: "connection open" }),
).toBeInTheDocument();
const sidebar = screen.getByLabelText("Sessions"); const sidebar = screen.getByLabelText("Sessions");
const links = Array.from(sidebar.querySelectorAll("a.session-link")).map((a) => a.getAttribute("href")); const links = Array.from(sidebar.querySelectorAll("a.session-link")).map(
expect(links).toEqual(["/s/s3", "/s/s2", "/s/s1"]); // all null lastEventAt -> startedAt desc (a) => a.getAttribute("href"),
expect(screen.getByTitle("ws open")).toBeInTheDocument(); );
expect(links).toEqual(["/s/s3", "/s/s2", "/s/s1"]); // all null lastEventAt -> startedAt desc
expect(screen.getByTitle("ws open")).toBeInTheDocument();
// root route lists sessions // root route lists sessions
expect(screen.getByRole("heading", { name: "Sessions" })).toBeInTheDocument(); expect(
expect(screen.getByLabelText("Open session alpha")).toBeInTheDocument(); screen.getByRole("heading", { name: "Sessions" }),
).toBeInTheDocument();
expect(screen.getByLabelText("Open session alpha")).toBeInTheDocument();
// spawn link navigates // spawn link navigates
fireEvent.click(screen.getByText("+ Spawn session")); fireEvent.click(screen.getByText("+ Spawn session"));
await screen.findByRole("heading", { name: "Spawn" }); await screen.findByRole("heading", { name: "Spawn" });
// unknown route redirects to sessions // unknown route redirects to sessions
const { container: c2 } = renderApp("/nowhere"); const { container: c2 } = renderApp("/nowhere");
await waitFor(() => expect(c2.querySelector(".session-card, .empty")).not.toBeNull()); await waitFor(() =>
}); expect(c2.querySelector(".session-card, .empty")).not.toBeNull(),
);
});
it("navigates to a session chat route", async () => { it("navigates to a session chat route", async () => {
seedApi(); seedApi();
seedSettings(); seedSettings();
renderApp("/s/s1"); renderApp("/s/s1");
await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1)); await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1));
act(() => FakeWebSocket.last().serverOpen()); act(() => FakeWebSocket.last().serverOpen());
await screen.findByLabelText("Message"); await screen.findByLabelText("Message");
expect(screen.getAllByText("alpha").length).toBeGreaterThan(0); expect(screen.getAllByText("alpha").length).toBeGreaterThan(0);
}); });
it("menu button toggles the sidebar and it closes on navigation", async () => { it("menu button toggles the sidebar and it closes on navigation", async () => {
seedApi(); seedApi();
seedSettings(); seedSettings();
renderApp("/"); renderApp("/");
await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1)); await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1));
act(() => FakeWebSocket.last().serverOpen()); act(() => FakeWebSocket.last().serverOpen());
await screen.findByRole("heading", { name: "Sessions" }); await screen.findByRole("heading", { name: "Sessions" });
const menu = screen.getByLabelText("Open menu"); const menu = screen.getByLabelText("Open menu");
fireEvent.click(menu); fireEvent.click(menu);
expect(screen.getByLabelText("Sessions").className).toContain("open"); expect(screen.getByLabelText("Sessions").className).toContain("open");
// backdrop closes it // backdrop closes it
fireEvent.click(container_backdrop()!); fireEvent.click(container_backdrop()!);
expect(screen.getByLabelText("Sessions").className).not.toContain("open"); expect(screen.getByLabelText("Sessions").className).not.toContain("open");
fireEvent.click(menu); fireEvent.click(menu);
fireEvent.click(screen.getByText("+ Spawn session")); fireEvent.click(screen.getByText("+ Spawn session"));
await waitFor(() => expect(screen.getByLabelText("Sessions").className).not.toContain("open")); await waitFor(() =>
}); expect(screen.getByLabelText("Sessions").className).not.toContain("open"),
);
});
it("disconnect clears settings and reloads", async () => { it("disconnect clears settings and reloads", async () => {
const loc = stubReload(); const loc = stubReload();
seedApi(); seedApi();
seedSettings(); seedSettings();
renderApp("/"); renderApp("/");
await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1)); await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1));
act(() => FakeWebSocket.last().serverOpen()); act(() => FakeWebSocket.last().serverOpen());
await screen.findByRole("heading", { name: "Sessions" }); await screen.findByRole("heading", { name: "Sessions" });
fireEvent.click(screen.getByLabelText("Disconnect and clear settings")); fireEvent.click(screen.getByLabelText("Disconnect and clear settings"));
expect(loc.reload).toHaveBeenCalled(); expect(loc.reload).toHaveBeenCalled();
expect(localStorage.getItem("lvmh.settings")).toBeNull(); expect(localStorage.getItem("lvmh.settings")).toBeNull();
loc.restore(); loc.restore();
}); });
it("ws auth failure clears settings and re-gates", async () => { it("ws auth failure clears settings and re-gates", async () => {
const loc = stubReload(); const loc = stubReload();
seedApi(); seedApi();
seedSettings(); seedSettings();
renderApp("/"); renderApp("/");
await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1)); await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1));
act(() => FakeWebSocket.last().serverOpen()); act(() => FakeWebSocket.last().serverOpen());
await screen.findByRole("heading", { name: "Sessions" }); await screen.findByRole("heading", { name: "Sessions" });
act(() => FakeWebSocket.last().serverClose(1008)); act(() => FakeWebSocket.last().serverClose(1008));
expect(loc.reload).toHaveBeenCalled(); expect(loc.reload).toHaveBeenCalled();
await waitFor(() => expect(screen.getByText("Connect to your lvmh daemon.")).toBeInTheDocument()); await waitFor(() =>
loc.restore(); expect(
}); screen.getByText("Connect to your lvmh daemon."),
).toBeInTheDocument(),
);
loc.restore();
});
it("REST seed failure surfaces a toast", async () => { it("REST seed failure surfaces a toast", async () => {
mockFetchJson((url) => { mockFetchJson((url) => {
if (url.includes("/api/sessions") && !url.includes("/events")) return jsonResponse({ error: "seed fail" }, 500); if (url.includes("/api/sessions") && !url.includes("/events"))
return []; return jsonResponse({ error: "seed fail" }, 500);
}); return [];
seedSettings(); });
renderApp("/"); seedSettings();
await screen.findByText("sessions: seed fail"); renderApp("/");
}); await screen.findByText("sessions: seed fail");
});
}); });
function container_backdrop(): HTMLElement | null { function container_backdrop(): HTMLElement | null {
return document.querySelector(".sidebar-backdrop"); return document.querySelector(".sidebar-backdrop");
} }
+266 -143
View File
@@ -5,175 +5,298 @@ import type { ChatMessage, ToolState } from "./derive";
import ChatStream, { Bubble, TypingIndicator } from "./ChatStream"; import ChatStream, { Bubble, TypingIndicator } from "./ChatStream";
function msg(partial: Partial<ChatMessage>): ChatMessage { function msg(partial: Partial<ChatMessage>): ChatMessage {
return { return {
key: `k-${Math.random()}`, key: `k-${Math.random()}`,
role: "assistant", role: "assistant",
text: "", text: "",
thinking: null, thinking: null,
toolCalls: [], toolCalls: [],
toolCallId: null, toolCallId: null,
streaming: false, streaming: false,
...partial, ...partial,
}; };
} }
const tool = (p: Partial<ToolState>): ToolState => ({ const tool = (p: Partial<ToolState>): ToolState => ({
id: "c1", id: "c1",
name: "bash", name: "bash",
args: "", args: "",
running: false, running: false,
isError: false, isError: false,
preview: "", preview: "",
...p, ...p,
}); });
describe("Bubble", () => { describe("Bubble", () => {
it("renders plain text per role class", () => { it("renders plain text per role class", () => {
const { container } = render( const { container } = render(
<Bubble msg={msg({ role: "user", text: "hi there" })} tools={new Map()} /> <Bubble
); msg={msg({ role: "user", text: "hi there" })}
expect(container.querySelector(".bubble-row.user")).not.toBeNull(); tools={new Map()}
expect(container.textContent).toContain("hi there"); />,
}); );
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", () => { it("renders empty assistant text as nothing but shows nothing when empty", () => {
const { container } = render(<Bubble msg={msg({ role: "assistant", text: "" })} tools={new Map()} />); const { container } = render(
expect(container.querySelector(".bubble")?.children).toHaveLength(0); <Bubble msg={msg({ role: "assistant", text: "" })} tools={new Map()} />,
}); );
expect(container.querySelector(".bubble")?.children).toHaveLength(0);
});
it("toolResult collapses to a one-line preview, expands to full text", async () => { it("toolResult collapses to a one-line preview, expands to full text", async () => {
const long = `${"x".repeat(200)}`; const long = `${"x".repeat(200)}`;
render(<Bubble msg={msg({ role: "toolResult", text: long })} tools={new Map()} />); render(
const details = screen.getByText("result").closest("details") as HTMLDetailsElement; <Bubble
expect(details.open).toBe(false); msg={msg({ role: "toolResult", text: long })}
expect(details.textContent).toContain("…"); tools={new Map()}
await userEvent.click(screen.getByText("result")); />,
expect(details.open).toBe(true); );
expect(details.textContent).toContain(long); 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", () => { it("toolResult with short text keeps full one-line preview", () => {
render(<Bubble msg={msg({ role: "toolResult", text: "short out" })} tools={new Map()} />); render(
const details = screen.getByText("result").closest("details") as HTMLDetailsElement; <Bubble
expect(details.textContent).toContain("short out"); msg={msg({ role: "toolResult", text: "short out" })}
expect(details.textContent).not.toContain("…"); tools={new Map()}
}); />,
);
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", () => { it("flattens whitespace in previews", () => {
render(<Bubble msg={msg({ role: "toolResult", text: "a\n\n b c" })} tools={new Map()} />); render(
expect(screen.getAllByText("a b c").length).toBeGreaterThan(0); <Bubble
}); msg={msg({ role: "toolResult", text: "a\n\n b c" })}
tools={new Map()}
/>,
);
expect(screen.getAllByText("a b c").length).toBeGreaterThan(0);
});
it("assistant tool calls attach tool cards", async () => { it("assistant tool calls attach tool cards", async () => {
const tools = new Map<string, ToolState>([ const tools = new Map<string, ToolState>([
["c1", tool({ id: "c1", name: "bash", args: "ls -la", running: false, isError: false, preview: "file" })], [
]); "c1",
render( tool({
<Bubble msg={msg({ role: "assistant", text: "finished", toolCalls: [{ id: "c1", name: "bash", argsJson: "{}" }] })} tools={tools} /> id: "c1",
); name: "bash",
expect(screen.getByText("🛠 bash")).toBeInTheDocument(); args: "ls -la",
expect(screen.getByText("finished")).toBeInTheDocument(); running: false,
isError: false,
preview: "file",
}),
],
]);
render(
<Bubble
msg={msg({
role: "assistant",
text: "finished",
toolCalls: [{ id: "c1", name: "bash", argsJson: "{}" }],
})}
tools={tools}
/>,
);
expect(screen.getByText("🛠 bash")).toBeInTheDocument();
expect(screen.getByText("finished")).toBeInTheDocument();
const summary = screen.getByText("🛠 bash").closest("summary") as HTMLElement; const summary = screen
const card = summary.closest("details") as HTMLDetailsElement; .getByText("🛠 bash")
expect(card.open).toBe(false); .closest("summary") as HTMLElement;
await userEvent.click(summary); const card = summary.closest("details") as HTMLDetailsElement;
expect(card.open).toBe(true); expect(card.open).toBe(false);
expect(card.textContent).toContain("ls -la"); await userEvent.click(summary);
expect(card.textContent).toContain("file"); expect(card.open).toBe(true);
}); expect(card.textContent).toContain("ls -la");
expect(card.textContent).toContain("file");
});
it("tool card status variants: running, error, done", () => { it("tool card status variants: running, error, done", () => {
const tools = new Map<string, ToolState>([ const tools = new Map<string, ToolState>([
["c1", tool({ id: "c1", running: true })], ["c1", tool({ id: "c1", running: true })],
["c2", tool({ id: "c2", running: false, isError: true })], ["c2", tool({ id: "c2", running: false, isError: true })],
["c3", tool({ id: "c3", running: false, isError: false })], ["c3", tool({ id: "c3", running: false, isError: false })],
]); ]);
render( render(
<Bubble <Bubble
msg={msg({ msg={msg({
role: "assistant", role: "assistant",
toolCalls: ["c1", "c2", "c3"].map((id) => ({ id, name: `t-${id}`, argsJson: "{}" })), toolCalls: ["c1", "c2", "c3"].map((id) => ({
})} id,
tools={tools} name: `t-${id}`,
/> argsJson: "{}",
); })),
expect(screen.getByText("working…")).toBeInTheDocument(); })}
expect(screen.getByText("error")).toBeInTheDocument(); tools={tools}
expect(screen.getByText("done")).toBeInTheDocument(); />,
}); );
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", () => { it("tool call with no matching state renders no card", () => {
const { container } = render( const { container } = render(
<Bubble msg={msg({ role: "assistant", toolCalls: [{ id: "ghost", name: "x", argsJson: "{}" }] })} tools={new Map()} /> <Bubble
); msg={msg({
expect(container.querySelectorAll(".tool-card")).toHaveLength(0); role: "assistant",
}); toolCalls: [{ id: "ghost", name: "x", argsJson: "{}" }],
})}
tools={new Map()}
/>,
);
expect(container.querySelectorAll(".tool-card")).toHaveLength(0);
});
it("thinking block only for non-empty thinking", async () => { it("thinking block only for non-empty thinking", async () => {
render(<Bubble msg={msg({ thinking: "because" })} tools={new Map()} />); render(<Bubble msg={msg({ thinking: "because" })} tools={new Map()} />);
const details = screen.getByText("thinking").closest("details") as HTMLDetailsElement; const details = screen
await userEvent.click(screen.getByText("thinking")); .getByText("thinking")
expect(details.open).toBe(true); .closest("details") as HTMLDetailsElement;
expect(details.textContent).toContain("because"); await userEvent.click(screen.getByText("thinking"));
expect(details.open).toBe(true);
expect(details.textContent).toContain("because");
const { container } = render(<Bubble msg={msg({ thinking: null })} tools={new Map()} />); const { container } = render(
expect(container.querySelector(".thinking")).toBeNull(); <Bubble msg={msg({ thinking: null })} tools={new Map()} />,
}); );
expect(container.querySelector(".thinking")).toBeNull();
});
it("streaming bubble shows the caret", () => { it("streaming bubble shows the caret", () => {
const { container } = render(<Bubble msg={msg({ text: "par", streaming: true })} tools={new Map()} />); const { container } = render(
expect(container.querySelector(".stream-caret")).not.toBeNull(); <Bubble msg={msg({ text: "par", streaming: true })} tools={new Map()} />,
}); );
expect(container.querySelector(".stream-caret")).not.toBeNull();
});
}); });
describe("TypingIndicator", () => { describe("TypingIndicator", () => {
it("renders three dots with aria-live", () => { it("renders three dots with aria-live", () => {
const { container } = render(<TypingIndicator />); const { container } = render(<TypingIndicator />);
expect(container.querySelector('[aria-live="polite"]')).not.toBeNull(); expect(container.querySelector('[aria-live="polite"]')).not.toBeNull();
expect(container.querySelectorAll(".dot")).toHaveLength(3); expect(container.querySelectorAll(".dot")).toHaveLength(3);
}); });
}); });
describe("ChatStream", () => { describe("ChatStream", () => {
it("renders messages and typing indicator while busy with no open stream", () => { it("renders messages and typing indicator while busy with no open stream", () => {
const { container, rerender } = render( const { container, rerender } = render(
<ChatStream messages={[msg({ key: "a", text: "one" })]} tools={new Map()} busy /> <ChatStream
); messages={[msg({ key: "a", text: "one" })]}
expect(container.querySelector(".typing")).not.toBeNull(); tools={new Map()}
busy
/>,
);
expect(container.querySelector(".typing")).not.toBeNull();
rerender( rerender(
<ChatStream messages={[msg({ key: "a", text: "one" }), msg({ key: "b", streaming: true })]} tools={new Map()} busy /> <ChatStream
); messages={[
expect(container.querySelector(".typing")).toBeNull(); msg({ key: "a", text: "one" }),
rerender(<ChatStream messages={[msg({ key: "a", text: "one" })]} tools={new Map()} busy={false} />); msg({ key: "b", streaming: true }),
expect(container.querySelector(".typing")).toBeNull(); ]}
}); tools={new Map()}
busy
/>,
);
expect(container.querySelector(".typing")).toBeNull();
rerender(
<ChatStream
messages={[msg({ key: "a", text: "one" })]}
tools={new Map()}
busy={false}
/>,
);
expect(container.querySelector(".typing")).toBeNull();
});
it("sticks to bottom on new messages, un-pins on user scroll up, re-pins near bottom", () => { it("sticks to bottom on new messages, un-pins on user scroll up, re-pins near bottom", () => {
const { container, rerender } = render(<ChatStream messages={[msg({ key: "a" })]} tools={new Map()} busy={false} />); const { container, rerender } = render(
const scroller = container.querySelector(".chat-scroll") as HTMLElement; <ChatStream
Object.defineProperty(scroller, "scrollHeight", { configurable: true, value: 1000 }); messages={[msg({ key: "a" })]}
Object.defineProperty(scroller, "clientHeight", { configurable: true, value: 300 }); tools={new Map()}
busy={false}
/>,
);
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")); scroller.dispatchEvent(new Event("scroll"));
// pinned: scrollTop at bottom // pinned: scrollTop at bottom
scroller.scrollTop = 700; scroller.scrollTop = 700;
Object.defineProperty(scroller, "scrollTop", { configurable: true, writable: true, value: 700 }); Object.defineProperty(scroller, "scrollTop", {
scroller.dispatchEvent(new Event("scroll")); configurable: true,
rerender(<ChatStream messages={[msg({ key: "a" }), msg({ key: "b" })]} tools={new Map()} busy={false} />); writable: true,
expect(scroller.scrollTop).toBe(1000); value: 700,
});
scroller.dispatchEvent(new Event("scroll"));
rerender(
<ChatStream
messages={[msg({ key: "a" }), msg({ key: "b" })]}
tools={new Map()}
busy={false}
/>,
);
expect(scroller.scrollTop).toBe(1000);
// scroll far up -> unpin // scroll far up -> unpin
Object.defineProperty(scroller, "scrollTop", { configurable: true, writable: true, value: 0 }); Object.defineProperty(scroller, "scrollTop", {
scroller.dispatchEvent(new Event("scroll")); configurable: true,
rerender(<ChatStream messages={[msg({ key: "a" }), msg({ key: "b" }), msg({ key: "c" })]} tools={new Map()} busy={false} />); writable: true,
expect(scroller.scrollTop).toBe(0); value: 0,
});
scroller.dispatchEvent(new Event("scroll"));
rerender(
<ChatStream
messages={[msg({ key: "a" }), msg({ key: "b" }), msg({ key: "c" })]}
tools={new Map()}
busy={false}
/>,
);
expect(scroller.scrollTop).toBe(0);
// scroll near bottom (within 80px) -> pinned again // scroll near bottom (within 80px) -> pinned again
Object.defineProperty(scroller, "scrollTop", { configurable: true, writable: true, value: 940 }); Object.defineProperty(scroller, "scrollTop", {
scroller.dispatchEvent(new Event("scroll")); configurable: true,
rerender(<ChatStream messages={[msg({ key: "a" }), msg({ key: "b" }), msg({ key: "c" }), msg({ key: "d" })]} tools={new Map()} busy={false} />); writable: true,
expect(scroller.scrollTop).toBe(1000); value: 940,
}); });
scroller.dispatchEvent(new Event("scroll"));
rerender(
<ChatStream
messages={[
msg({ key: "a" }),
msg({ key: "b" }),
msg({ key: "c" }),
msg({ key: "d" }),
]}
tools={new Map()}
busy={false}
/>,
);
expect(scroller.scrollTop).toBe(1000);
});
}); });
+336 -233
View File
@@ -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 userEvent from "@testing-library/user-event";
import { MemoryRouter, Route, Routes } from "react-router-dom"; import { MemoryRouter, Route, Routes } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -10,288 +16,385 @@ import type { SessionListItem } from "./protocol";
let seq: number = 0; let seq: number = 0;
function ev(type: string, extra: Partial<EventFrame> = {}): EventFrame { function ev(type: string, extra: Partial<EventFrame> = {}): EventFrame {
seq += 1; seq += 1;
return { v: 1, sessionId: "s1", seq, ts: 0, type, ...extra } as EventFrame; return { v: 1, sessionId: "s1", seq, ts: 0, type, ...extra } as EventFrame;
} }
const sessions: SessionListItem[] = [ const sessions: SessionListItem[] = [
{ {
id: "s1", id: "s1",
name: "worker", name: "worker",
cwd: "/w", cwd: "/w",
model: "glm-5.3", model: "glm-5.3",
provider: "zai-renaud", provider: "zai-renaud",
agent: false, agent: false,
repo: null, repo: null,
startedAt: 0, startedAt: 0,
online: true, online: true,
lastEventAt: 1, lastEventAt: 1,
}, },
]; ];
function makeStore(over: Partial<SessionsStore> = {}): SessionsStore { function makeStore(over: Partial<SessionsStore> = {}): SessionsStore {
return { return {
sessions, sessions,
state: "open", state: "open",
spawnJobs: [], spawnJobs: [],
refresh: async () => undefined, refresh: async () => undefined,
subscribe: (sessionId: string, onEvents: (events: EventFrame[]) => void): (() => void) => { subscribe: (
currentSub = { sessionId, onEvents }; sessionId: string,
return () => { onEvents: (events: EventFrame[]) => void,
if (currentSub !== null && currentSub.sessionId === sessionId) currentSub = null; ): (() => void) => {
}; currentSub = { sessionId, onEvents };
}, return () => {
...over, if (currentSub !== null && currentSub.sessionId === sessionId)
}; currentSub = null;
};
},
...over,
};
} }
interface ActiveSub { interface ActiveSub {
sessionId: string; sessionId: string;
onEvents: (events: EventFrame[]) => void; onEvents: (events: EventFrame[]) => void;
} }
let currentSub: ActiveSub | null = null; let currentSub: ActiveSub | null = null;
function push(events: EventFrame[]): void { function push(events: EventFrame[]): void {
act(() => currentSub?.onEvents(events)); act(() => currentSub?.onEvents(events));
} }
function renderChat(store: SessionsStore, path = "/s/s1"): ReturnType<typeof render> { function renderChat(
return render( store: SessionsStore,
<MemoryRouter initialEntries={[path]}> path = "/s/s1",
<Routes> ): ReturnType<typeof render> {
<Route path="/s/:id" element={<ChatView store={store} pushToast={pushToast} />} /> return render(
<Route path="*" element={<div>OTHER</div>} /> <MemoryRouter initialEntries={[path]}>
</Routes> <Routes>
</MemoryRouter> <Route
); path="/s/:id"
element={<ChatView store={store} pushToast={pushToast} />}
/>
<Route path="*" element={<div>OTHER</div>} />
</Routes>
</MemoryRouter>,
);
} }
const pushToast = vi.fn(); const pushToast = vi.fn();
function historyEvents(): EventFrame[] { function historyEvents(): EventFrame[] {
seq = 0; seq = 0;
return [ return [
ev("message_end", { message: { role: "user", id: "u1", text: "hello there", thinking: null, toolCalls: [], toolCallId: null } }), ev("message_end", {
ev("agent_start"), message: {
ev("message_end", { message: { role: "assistant", id: "a1", text: "hi!", thinking: "hmm", toolCalls: [], toolCallId: null } }), role: "user",
ev("agent_settled"), 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(() => { beforeEach(() => {
seq = 0; seq = 0;
currentSub = null; currentSub = null;
pushToast.mockClear(); pushToast.mockClear();
seedSettings(); seedSettings();
}); });
afterEach(() => { afterEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
describe("ChatView", () => { describe("ChatView", () => {
it("renders history from REST on mount and subscribes to the WS stream", async () => { it("renders history from REST on mount and subscribes to the WS stream", async () => {
const fetchMock = mockFetchJson((url) => { const fetchMock = mockFetchJson((url) => {
if (url.startsWith("http://srv/api/sessions/s1/events")) return historyEvents(); if (url.startsWith("http://srv/api/sessions/s1/events"))
return []; return historyEvents();
}); return [];
const store = makeStore(); });
const { rerender } = renderChat(store); const store = makeStore();
const { rerender } = renderChat(store);
await screen.findByText("hello there"); await screen.findByText("hello there");
expect(screen.getByText("hi!")).toBeInTheDocument(); expect(screen.getByText("hi!")).toBeInTheDocument();
await waitFor(() => expect(currentSub).not.toBeNull()); await waitFor(() => expect(currentSub).not.toBeNull());
expect(currentSub?.sessionId).toBe("s1"); expect(currentSub?.sessionId).toBe("s1");
expect(fetchMock).toHaveBeenCalled(); expect(fetchMock).toHaveBeenCalled();
// header shows session name + model + online dot // header shows session name + model + online dot
expect(screen.getByText("worker")).toBeInTheDocument(); expect(screen.getByText("worker")).toBeInTheDocument();
expect(screen.getByText("glm-5.3")).toBeInTheDocument(); expect(screen.getByText("glm-5.3")).toBeInTheDocument();
expect(screen.getByTitle("online")).toBeInTheDocument(); expect(screen.getByTitle("online")).toBeInTheDocument();
rerender( rerender(
<MemoryRouter initialEntries={["/s/s1"]}> <MemoryRouter initialEntries={["/s/s1"]}>
<Routes> <Routes>
<Route path="/s/:id" element={<ChatView store={store} pushToast={pushToast} />} /> <Route
<Route path="*" element={<div>OTHER</div>} /> path="/s/:id"
</Routes> element={<ChatView store={store} pushToast={pushToast} />}
</MemoryRouter> />
); <Route path="*" element={<div>OTHER</div>} />
}); </Routes>
</MemoryRouter>,
);
});
it("live delta streaming appends into a streaming bubble and ends it", async () => { it("live delta streaming appends into a streaming bubble and ends it", async () => {
mockFetchJson(() => []); mockFetchJson(() => []);
renderChat(makeStore()); renderChat(makeStore());
await waitFor(() => expect(currentSub).not.toBeNull()); await waitFor(() => expect(currentSub).not.toBeNull());
push([ push([
ev("message_start", { message: { role: "assistant", id: "a9", text: "", thinking: null, toolCalls: [], toolCallId: null } }), ev("message_start", {
]); message: {
push([ev("message_update", { delta: "Hel" })]); role: "assistant",
push([ev("message_update", { delta: "lo" })]); id: "a9",
expect(screen.getByText("Hello")).toBeInTheDocument(); text: "",
expect(screen.queryByLabelText("Send message")).toBeNull(); thinking: null,
expect(screen.getByLabelText("Abort current run")).toBeInTheDocument(); 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([ push([
ev("message_end", { message: { role: "assistant", id: "a9", text: "Hello world", thinking: null, toolCalls: [], toolCallId: null } }), ev("message_end", {
ev("agent_settled"), message: {
]); role: "assistant",
expect(screen.getByText("Hello world")).toBeInTheDocument(); id: "a9",
expect(screen.getByLabelText("Send message")).toBeInTheDocument(); 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 () => { it("typing indicator shows while busy with no open stream", async () => {
mockFetchJson(() => []); mockFetchJson(() => []);
const { container } = renderChat(makeStore()); const { container } = renderChat(makeStore());
await waitFor(() => expect(currentSub).not.toBeNull()); await waitFor(() => expect(currentSub).not.toBeNull());
push([ev("agent_start")]); push([ev("agent_start")]);
expect(container.querySelector(".typing")).not.toBeNull(); expect(container.querySelector(".typing")).not.toBeNull();
push([ev("agent_settled")]); push([ev("agent_settled")]);
expect(container.querySelector(".typing")).toBeNull(); expect(container.querySelector(".typing")).toBeNull();
}); });
it("409 on send toasts 'session offline'", async () => { it("409 on send toasts 'session offline'", async () => {
let n = 0; let n = 0;
mockFetchJson((_url, init) => { mockFetchJson((_url, init) => {
if (init?.method === "POST") { if (init?.method === "POST") {
n += 1; n += 1;
return jsonResponse({ error: "session offline" }, n === 1 ? 409 : 200); return jsonResponse({ error: "session offline" }, n === 1 ? 409 : 200);
} }
return []; return [];
}); });
renderChat(makeStore()); renderChat(makeStore());
const ta = screen.getByLabelText("Message"); const ta = screen.getByLabelText("Message");
await userEvent.type(ta, "go"); await userEvent.type(ta, "go");
await userEvent.click(screen.getByLabelText("Send message")); await userEvent.click(screen.getByLabelText("Send message"));
await vi.waitFor(() => expect(pushToast).toHaveBeenCalledWith("session offline")); await vi.waitFor(() =>
}); expect(pushToast).toHaveBeenCalledWith("session offline"),
);
});
it("non-409 send failure toasts the error message", async () => { it("non-409 send failure toasts the error message", async () => {
mockFetchJson((_url, init) => { mockFetchJson((_url, init) => {
if (init?.method === "POST") return jsonResponse({ error: "nope" }, 500); if (init?.method === "POST") return jsonResponse({ error: "nope" }, 500);
return []; return [];
}); });
renderChat(makeStore()); renderChat(makeStore());
await userEvent.type(screen.getByLabelText("Message"), "go"); await userEvent.type(screen.getByLabelText("Message"), "go");
await userEvent.click(screen.getByLabelText("Send message")); await userEvent.click(screen.getByLabelText("Send message"));
await vi.waitFor(() => expect(pushToast).toHaveBeenCalledWith("nope")); await vi.waitFor(() => expect(pushToast).toHaveBeenCalledWith("nope"));
}); });
it("Enter sends, Shift+Enter adds a newline, send disabled while empty or sending", async () => { 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 } : [])); const fetchMock = mockFetchJson((_url, init) =>
renderChat(makeStore()); init?.method === "POST" ? { ok: true } : [],
const ta = screen.getByLabelText("Message") as HTMLTextAreaElement; );
const send = screen.getByLabelText("Send message") as HTMLButtonElement; renderChat(makeStore());
expect(send).toBeDisabled(); const ta = screen.getByLabelText("Message") as HTMLTextAreaElement;
const send = screen.getByLabelText("Send message") as HTMLButtonElement;
expect(send).toBeDisabled();
await userEvent.type(ta, "hello"); await userEvent.type(ta, "hello");
expect(send).not.toBeDisabled(); expect(send).not.toBeDisabled();
fireEvent.keyDown(ta, { key: "Enter", shiftKey: true }); fireEvent.keyDown(ta, { key: "Enter", shiftKey: true });
fireEvent.click(send); fireEvent.click(send);
await userEvent.clear(ta); await userEvent.clear(ta);
fireEvent.keyDown(ta, { key: "Enter" }); // empty draft: send() early-returns fireEvent.keyDown(ta, { key: "Enter" }); // empty draft: send() early-returns
await vi.waitFor(() => { await vi.waitFor(() => {
const post = fetchMock.mock.calls.find((c) => (c[1] as RequestInit | undefined)?.method === "POST"); const post = fetchMock.mock.calls.find(
expect(post).toBeDefined(); (c) => (c[1] as RequestInit | undefined)?.method === "POST",
expect((post?.[1] as RequestInit).body).toBe(JSON.stringify({ message: "hello" })); );
}); expect(post).toBeDefined();
expect(ta.value).toBe(""); expect((post?.[1] as RequestInit).body).toBe(
}); JSON.stringify({ message: "hello" }),
);
});
expect(ta.value).toBe("");
});
it("abort posts to the abort route", async () => { it("abort posts to the abort route", async () => {
const fetchMock = mockFetchJson((_url, init) => (init?.method === "POST" ? { ok: true } : [])); const fetchMock = mockFetchJson((_url, init) =>
renderChat(makeStore()); init?.method === "POST" ? { ok: true } : [],
push([ev("agent_start")]); );
await userEvent.click(screen.getByLabelText("Abort current run")); renderChat(makeStore());
await vi.waitFor(() => { push([ev("agent_start")]);
const abortCall = fetchMock.mock.calls.find( await userEvent.click(screen.getByLabelText("Abort current run"));
(c) => (c[1] as RequestInit | undefined)?.method === "POST" && String(c[0]).endsWith("/abort") await vi.waitFor(() => {
); const abortCall = fetchMock.mock.calls.find(
expect(abortCall).toBeDefined(); (c) =>
}); (c[1] as RequestInit | undefined)?.method === "POST" &&
}); String(c[0]).endsWith("/abort"),
);
expect(abortCall).toBeDefined();
});
});
it("abort failure toasts", async () => { it("abort failure toasts", async () => {
mockFetchJson((url, init) => { mockFetchJson((url, init) => {
if (init?.method === "POST" && url.endsWith("/abort")) return jsonResponse({ error: "abort failed" }, 500); if (init?.method === "POST" && url.endsWith("/abort"))
return []; return jsonResponse({ error: "abort failed" }, 500);
}); return [];
renderChat(makeStore()); });
push([ev("agent_start")]); renderChat(makeStore());
await userEvent.click(screen.getByLabelText("Abort current run")); push([ev("agent_start")]);
await vi.waitFor(() => expect(pushToast).toHaveBeenCalledWith("abort failed")); 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 () => { it("history load failure shows the error page with a back link", async () => {
mockFetchJson((url) => { mockFetchJson((url) => {
if (url.includes("/events")) return jsonResponse({ error: "db gone" }, 500); if (url.includes("/events"))
return []; return jsonResponse({ error: "db gone" }, 500);
}); return [];
renderChat(makeStore()); });
expect(await screen.findByText(/Failed to load history: db gone/)).toBeInTheDocument(); renderChat(makeStore());
expect(screen.getByText("Back to sessions")).toBeInTheDocument(); 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 () => { it("refetches missed persisted events when the ws (re)opens", async () => {
let after = ""; let after = "";
mockFetchJson((url) => { mockFetchJson((url) => {
const m = /[?&]after=(\d+)/.exec(url); const m = /[?&]after=(\d+)/.exec(url);
if (m !== null) after = m[1] ?? ""; 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 } })]; if (url.includes("/events"))
return []; return [
}); ev("message_end", {
const store = makeStore({ state: "closed" }); message: {
const { rerender } = render( role: "user",
<MemoryRouter initialEntries={["/s/s1"]}> id: "u2",
<Routes> text: "caught up",
<Route path="/s/:id" element={<ChatView store={store} pushToast={pushToast} />} /> thinking: null,
</Routes> toolCalls: [],
</MemoryRouter> toolCallId: null,
); },
await screen.findByText("caught up"); }),
expect(after).toBe("0"); ];
return [];
});
const store = makeStore({ state: "closed" });
const { rerender } = render(
<MemoryRouter initialEntries={["/s/s1"]}>
<Routes>
<Route
path="/s/:id"
element={<ChatView store={store} pushToast={pushToast} />}
/>
</Routes>
</MemoryRouter>,
);
await screen.findByText("caught up");
expect(after).toBe("0");
// reconnect: state closed -> open triggers the after=N refetch // reconnect: state closed -> open triggers the after=N refetch
act(() => { act(() => {
rerender( rerender(
<MemoryRouter initialEntries={["/s/s1"]}> <MemoryRouter initialEntries={["/s/s1"]}>
<Routes> <Routes>
<Route path="/s/:id" element={<ChatView store={makeStore({ state: "open" })} pushToast={pushToast} />} /> <Route
</Routes> path="/s/:id"
</MemoryRouter> element={
); <ChatView
}); store={makeStore({ state: "open" })}
await vi.waitFor(() => expect(after).toBe("1")); pushToast={pushToast}
}); />
}
/>
</Routes>
</MemoryRouter>,
);
});
await vi.waitFor(() => expect(after).toBe("1"));
});
it("task panel toggle and tool render in both the aside and mobile view", async () => { it("task panel toggle and tool render in both the aside and mobile view", async () => {
mockFetchJson((url) => (url.includes("/events") ? historyEvents() : [])); mockFetchJson((url) => (url.includes("/events") ? historyEvents() : []));
const { container } = renderChat(makeStore()); const { container } = renderChat(makeStore());
// running tool start with no end shows in the Working section // running tool start with no end shows in the Working section
await screen.findByText("hello there"); await screen.findByText("hello there");
const toggle = screen.getByLabelText("Toggle task panel"); const toggle = screen.getByLabelText("Toggle task panel");
expect(toggle).toHaveAttribute("aria-expanded", "false"); expect(toggle).toHaveAttribute("aria-expanded", "false");
await userEvent.click(toggle); await userEvent.click(toggle);
expect(screen.getByLabelText("Toggle task panel")).toHaveAttribute("aria-expanded", "true"); expect(screen.getByLabelText("Toggle task panel")).toHaveAttribute(
expect(container.querySelectorAll(".task-panel")).toHaveLength(2); // aside + mobile "aria-expanded",
expect(screen.getAllByText("No tasks yet.")).toHaveLength(2); "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", () => { it("no session id param renders the empty page", () => {
render( render(
<MemoryRouter initialEntries={["/s/"]}> <MemoryRouter initialEntries={["/s/"]}>
<Routes> <Routes>
<Route path="/s/" element={<ChatView store={makeStore()} pushToast={pushToast} />} /> <Route
<Route path="/s/:id" element={<div>CHAT</div>} /> path="/s/"
</Routes> element={<ChatView store={makeStore()} pushToast={pushToast} />}
</MemoryRouter> />
); <Route path="/s/:id" element={<div>CHAT</div>} />
expect(screen.getByText("No session selected.")).toBeInTheDocument(); </Routes>
}); </MemoryRouter>,
);
expect(screen.getByText("No session selected.")).toBeInTheDocument();
});
}); });
+205 -125
View File
@@ -6,146 +6,226 @@ import SessionsView from "./SessionsView";
import { jsonResponse, mockFetchJson, seedSettings } from "./test/setup"; import { jsonResponse, mockFetchJson, seedSettings } from "./test/setup";
function session(p: Partial<SessionListItem>): SessionListItem { function session(p: Partial<SessionListItem>): SessionListItem {
return { return {
id: "s1", id: "s1",
name: null, name: null,
cwd: "/w", cwd: "/w",
model: "glm-5.3", model: "glm-5.3",
provider: "zai-renaud", provider: "zai-renaud",
agent: false, agent: false,
repo: null, repo: null,
startedAt: 100, startedAt: 100,
online: false, online: false,
lastEventAt: null, lastEventAt: null,
...p, ...p,
}; };
} }
function renderView(props: Partial<Parameters<typeof SessionsView>[0]> = {}): ReturnType<typeof render> { function renderView(
return render( props: Partial<Parameters<typeof SessionsView>[0]> = {},
<MemoryRouter> ): ReturnType<typeof render> {
<SessionsView sessions={[]} onChanged={() => undefined} pushToast={() => undefined} {...props} /> return render(
</MemoryRouter> <MemoryRouter>
); <SessionsView
sessions={[]}
onChanged={() => undefined}
pushToast={() => undefined}
{...props}
/>
</MemoryRouter>,
);
} }
function Probe({ onVisit }: { onVisit: (path: string) => void }): null { function Probe({ onVisit }: { onVisit: (path: string) => void }): null {
const { id } = useParams(); const { id } = useParams();
onVisit(`/s/${id ?? ""}`); onVisit(`/s/${id ?? ""}`);
return null; return null;
} }
describe("SessionsView", () => { describe("SessionsView", () => {
it("empty state message", () => { it("empty state message", () => {
renderView(); renderView();
expect(screen.getByText(/No sessions yet/i)).toBeInTheDocument(); expect(screen.getByText(/No sessions yet/i)).toBeInTheDocument();
}); });
it("renders cards sorted by last activity with fallbacks", () => { it("renders cards sorted by last activity with fallbacks", () => {
renderView({ renderView({
sessions: [ sessions: [
session({ id: "a", name: null, repo: "g/p", lastEventAt: 5, startedAt: 1 }), session({
session({ id: "b", name: "named", lastEventAt: null, startedAt: 10 }), id: "a",
session({ id: "c", name: null, repo: null, cwd: "/fallback", startedAt: 100, online: true }), name: null,
], repo: "g/p",
}); lastEventAt: 5,
const cards = screen.getAllByRole("button", { name: /^Open session/ }); startedAt: 1,
expect(cards.map((c) => c.getAttribute("aria-label"))).toEqual([ }),
"Open session c", session({ id: "b", name: "named", lastEventAt: null, startedAt: 10 }),
"Open session named", session({
"Open session a", id: "c",
]); name: null,
expect(cards[0]?.textContent).toContain("/fallback"); repo: null,
expect(screen.getAllByTitle("online")).toHaveLength(1); cwd: "/fallback",
expect(screen.getAllByTitle("offline")).toHaveLength(2); 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", () => { 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 })] }); renderView({
expect(screen.getAllByText("g/p")).toHaveLength(1); sessions: [
expect(screen.getByText("glm-5.3")).toBeInTheDocument(); session({
expect(screen.getByText("just now")).toBeInTheDocument(); id: "s1",
expect(screen.getByText("agent")).toBeInTheDocument(); name: "named",
expect(screen.getByLabelText("Stop container for named")).toBeInTheDocument(); 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", () => { it("no badge/stop for non-agent sessions", () => {
renderView({ sessions: [session({ id: "s1", name: "local", repo: "g/p" })] }); renderView({
expect(screen.queryByText("agent")).toBeNull(); sessions: [session({ id: "s1", name: "local", repo: "g/p" })],
expect(screen.queryByLabelText(/Stop container/)).toBeNull(); });
}); expect(screen.queryByText("agent")).toBeNull();
expect(screen.queryByLabelText(/Stop container/)).toBeNull();
});
it("keyboard Enter and Space open the session; other keys ignored", () => { it("keyboard Enter and Space open the session; other keys ignored", () => {
const probe = vi.fn(); const probe = vi.fn();
render( render(
<MemoryRouter initialEntries={["/"]}> <MemoryRouter initialEntries={["/"]}>
<Routes> <Routes>
<Route path="/" element={<SessionsView sessions={[session({ id: "s9", name: "kb" })]} onChanged={() => undefined} pushToast={() => undefined} />} /> <Route
<Route path="/s/:id" element={<Probe onVisit={probe} />} /> path="/"
</Routes> element={
</MemoryRouter> <SessionsView
); sessions={[session({ id: "s9", name: "kb" })]}
const card = screen.getByRole("button", { name: "Open session kb" }); onChanged={() => undefined}
fireEvent.keyDown(card, { key: "Tab" }); pushToast={() => undefined}
expect(probe).not.toHaveBeenCalled(); />
fireEvent.keyDown(card, { key: "Enter" }); }
expect(probe).toHaveBeenCalledWith("/s/s9"); />
}); <Route path="/s/:id" element={<Probe onVisit={probe} />} />
</Routes>
</MemoryRouter>,
);
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", () => { it("Space key opens the session", () => {
const probe = vi.fn(); const probe = vi.fn();
render( render(
<MemoryRouter initialEntries={["/"]}> <MemoryRouter initialEntries={["/"]}>
<Routes> <Routes>
<Route path="/" element={<SessionsView sessions={[session({ id: "s8" })]} onChanged={() => undefined} pushToast={() => undefined} />} /> <Route
<Route path="/s/:id" element={<Probe onVisit={probe} />} /> path="/"
</Routes> element={
</MemoryRouter> <SessionsView
); sessions={[session({ id: "s8" })]}
fireEvent.keyDown(screen.getByRole("button", { name: "Open session s8" }), { key: " " }); onChanged={() => undefined}
expect(probe).toHaveBeenCalledWith("/s/s8"); pushToast={() => undefined}
}); />
}
/>
<Route path="/s/:id" element={<Probe onVisit={probe} />} />
</Routes>
</MemoryRouter>,
);
fireEvent.keyDown(screen.getByRole("button", { name: "Open session s8" }), {
key: " ",
});
expect(probe).toHaveBeenCalledWith("/s/s8");
});
it("stop with unnamed session toasts the id", async () => { it("stop with unnamed session toasts the id", async () => {
seedSettings(); seedSettings();
mockFetchJson(() => ({ ok: true })); mockFetchJson(() => ({ ok: true }));
const pushToast = vi.fn(); const pushToast = vi.fn();
renderView({ sessions: [session({ id: "s1", name: null, agent: true })], pushToast, onChanged: () => undefined }); renderView({
fireEvent.click(screen.getByLabelText("Stop container for s1")); sessions: [session({ id: "s1", name: null, agent: true })],
await vi.waitFor(() => expect(pushToast).toHaveBeenCalledWith("stopped s1")); 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 () => { it("stop button deletes container, toasts and refreshes", async () => {
seedSettings(); seedSettings();
const fetchMock = mockFetchJson(() => ({ ok: true })); const fetchMock = mockFetchJson(() => ({ ok: true }));
const onChanged = vi.fn(); const onChanged = vi.fn();
const pushToast = vi.fn(); const pushToast = vi.fn();
renderView({ sessions: [session({ id: "s1", name: "worker", agent: true })], onChanged, pushToast }); renderView({
fireEvent.click(screen.getByLabelText("Stop container for worker")); sessions: [session({ id: "s1", name: "worker", agent: true })],
await vi.waitFor(() => { onChanged,
const call = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; pushToast,
expect(call[0]).toBe("http://srv/api/sessions/s1/container"); });
expect(call[1].method).toBe("DELETE"); fireEvent.click(screen.getByLabelText("Stop container for worker"));
}); await vi.waitFor(() => {
await vi.waitFor(() => expect(pushToast).toHaveBeenCalledWith("stopped worker")); const call = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
expect(onChanged).toHaveBeenCalled(); 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 () => { it("stop failure toasts the error", async () => {
seedSettings(); seedSettings();
vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse({ error: "nope" }, 500)); vi.spyOn(globalThis, "fetch").mockResolvedValue(
const pushToast = vi.fn(); jsonResponse({ error: "nope" }, 500),
renderView({ sessions: [session({ id: "s1", name: "w", agent: true })], pushToast, onChanged: () => undefined }); );
fireEvent.click(screen.getByLabelText("Stop container for w")); const pushToast = vi.fn();
await vi.waitFor(() => expect(pushToast).toHaveBeenCalledWith("stop failed: nope")); 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 () => { it("non-error stop failure path stringifies non-Error throws", async () => {
seedSettings(); seedSettings();
vi.spyOn(globalThis, "fetch").mockRejectedValue("plain-string"); vi.spyOn(globalThis, "fetch").mockRejectedValue("plain-string");
const pushToast = vi.fn(); const pushToast = vi.fn();
renderView({ sessions: [session({ id: "s1", name: "w", agent: true })], pushToast, onChanged: () => undefined }); renderView({
fireEvent.click(screen.getByLabelText("Stop container for w")); sessions: [session({ id: "s1", name: "w", agent: true })],
await vi.waitFor(() => expect(pushToast).toHaveBeenCalledWith("stop failed: plain-string")); pushToast,
}); onChanged: () => undefined,
});
fireEvent.click(screen.getByLabelText("Stop container for w"));
await vi.waitFor(() =>
expect(pushToast).toHaveBeenCalledWith("stop failed: plain-string"),
);
});
}); });
+88 -66
View File
@@ -6,82 +6,104 @@ import SettingsGate from "./SettingsGate";
import { jsonResponse } from "./test/setup"; import { jsonResponse } from "./test/setup";
afterEach(() => { afterEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
localStorage.clear(); localStorage.clear();
}); });
function setup(): { connect: () => Promise<void> } { function setup(): { connect: () => Promise<void> } {
render(<SettingsGate onSaved={() => undefined} />); render(<SettingsGate onSaved={() => undefined} />);
return { return {
connect: async (): Promise<void> => { connect: async (): Promise<void> => {
await userEvent.click(screen.getByRole("button", { name: "Connect" })); await userEvent.click(screen.getByRole("button", { name: "Connect" }));
}, },
}; };
} }
describe("SettingsGate", () => { describe("SettingsGate", () => {
it("defaults the server url to the current origin", () => { it("defaults the server url to the current origin", () => {
setup(); setup();
const input = screen.getByLabelText("Server URL") as HTMLInputElement; const input = screen.getByLabelText("Server URL") as HTMLInputElement;
expect(input.value).toBe("http://localhost:3000"); expect(input.value).toBe("http://localhost:3000");
}); });
it("requires a token", async () => { it("requires a token", async () => {
setup(); setup();
await userEvent.click(screen.getByRole("button", { name: "Connect" })); await userEvent.click(screen.getByRole("button", { name: "Connect" }));
expect(screen.getByRole("alert")).toHaveTextContent("token required"); expect(screen.getByRole("alert")).toHaveTextContent("token required");
expect(getSettings()).toBeNull(); expect(getSettings()).toBeNull();
}); });
it("validation failure shows the server error and does not save", async () => { it("validation failure shows the server error and does not save", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse({ error: "unauthorized" }, 401)); const fetchMock = vi
setup(); .spyOn(globalThis, "fetch")
await userEvent.type(screen.getByLabelText("Bearer token"), "bad"); .mockResolvedValue(jsonResponse({ error: "unauthorized" }, 401));
await userEvent.click(screen.getByRole("button", { name: "Connect" })); setup();
expect(screen.getByRole("alert")).toHaveTextContent("connection failed (401)"); await userEvent.type(screen.getByLabelText("Bearer token"), "bad");
expect(getSettings()).toBeNull(); await userEvent.click(screen.getByRole("button", { name: "Connect" }));
expect(fetchMock).toHaveBeenCalledWith( expect(screen.getByRole("alert")).toHaveTextContent(
"http://localhost:3000/api/sessions", "connection failed (401)",
expect.objectContaining({ headers: { Authorization: "Bearer bad" } }) );
); 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 () => { it("network rejection surfaces the thrown message", async () => {
vi.spyOn(globalThis, "fetch").mockRejectedValue(new TypeError("fetch failed")); vi.spyOn(globalThis, "fetch").mockRejectedValue(
setup(); new TypeError("fetch failed"),
await userEvent.type(screen.getByLabelText("Bearer token"), "tok"); );
await userEvent.click(screen.getByRole("button", { name: "Connect" })); setup();
expect(screen.getByRole("alert")).toHaveTextContent("fetch failed"); 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 () => { it("saves normalized url + trimmed token and calls onSaved", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse([])); const fetchMock = vi
const onSaved = vi.fn(); .spyOn(globalThis, "fetch")
render(<SettingsGate onSaved={onSaved} />); .mockResolvedValue(jsonResponse([]));
await userEvent.clear(screen.getByLabelText("Server URL")); const onSaved = vi.fn();
await userEvent.type(screen.getByLabelText("Server URL"), "http://daemon:8686///"); render(<SettingsGate onSaved={onSaved} />);
await userEvent.type(screen.getByLabelText("Bearer token"), " tok "); await userEvent.clear(screen.getByLabelText("Server URL"));
await userEvent.click(screen.getByRole("button", { name: "Connect" })); await userEvent.type(
expect(fetchMock).toHaveBeenCalledWith("http://daemon:8686/api/sessions", expect.anything()); screen.getByLabelText("Server URL"),
expect(getSettings()).toEqual({ serverUrl: "http://daemon:8686", token: "tok" }); "http://daemon:8686///",
expect(onSaved).toHaveBeenCalledTimes(1); );
}); 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 () => { it("non-Error rejections stringify via String(err)", async () => {
vi.spyOn(globalThis, "fetch").mockRejectedValue("plain-string" as never); vi.spyOn(globalThis, "fetch").mockRejectedValue("plain-string" as never);
setup(); setup();
await userEvent.type(screen.getByLabelText("Bearer token"), "tok"); await userEvent.type(screen.getByLabelText("Bearer token"), "tok");
await userEvent.click(screen.getByRole("button", { name: "Connect" })); await userEvent.click(screen.getByRole("button", { name: "Connect" }));
expect(screen.getByRole("alert")).toHaveTextContent("plain-string"); expect(screen.getByRole("alert")).toHaveTextContent("plain-string");
}); });
it("empty server url falls back to the current origin", async () => { it("empty server url falls back to the current origin", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse([])); const fetchMock = vi
setup(); .spyOn(globalThis, "fetch")
await userEvent.clear(screen.getByLabelText("Server URL")); .mockResolvedValue(jsonResponse([]));
await userEvent.type(screen.getByLabelText("Bearer token"), "tok"); setup();
await userEvent.click(screen.getByRole("button", { name: "Connect" })); await userEvent.clear(screen.getByLabelText("Server URL"));
expect(fetchMock).toHaveBeenCalledWith("http://localhost:3000/api/sessions", expect.anything()); await userEvent.type(screen.getByLabelText("Bearer token"), "tok");
expect(getSettings()?.serverUrl).toBe("http://localhost:3000"); 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");
});
}); });
+314 -278
View File
@@ -7,333 +7,369 @@ import type { SessionsStore } from "./store";
import { jsonResponse, mockFetchJson, seedSettings } from "./test/setup"; import { jsonResponse, mockFetchJson, seedSettings } from "./test/setup";
const repo = (path: string, branch = "main"): Repo => ({ const repo = (path: string, branch = "main"): Repo => ({
path, path,
name: path.split("/")[1] ?? path, name: path.split("/")[1] ?? path,
namespace: path.split("/")[0] ?? "g", namespace: path.split("/")[0] ?? "g",
lastActivityAt: "2024-05-01T00:00:00Z", lastActivityAt: "2024-05-01T00:00:00Z",
webUrl: `https://gl/${path}`, webUrl: `https://gl/${path}`,
defaultBranch: branch, defaultBranch: branch,
}); });
const PUSH_TOAST = vi.fn(); const PUSH_TOAST = vi.fn();
function makeStore(over: Partial<SessionsStore> = {}): SessionsStore { function makeStore(over: Partial<SessionsStore> = {}): SessionsStore {
return { return {
sessions: [], sessions: [],
state: "open", state: "open",
spawnJobs: [], spawnJobs: [],
refresh: async (): Promise<void> => undefined, refresh: async (): Promise<void> => undefined,
subscribe: (): (() => void) => () => undefined, subscribe: (): (() => void) => () => undefined,
...over, ...over,
}; };
} }
function tree(store: SessionsStore): React.ReactElement { function tree(store: SessionsStore): React.ReactElement {
return ( return (
<MemoryRouter initialEntries={["/new"]}> <MemoryRouter initialEntries={["/new"]}>
<Routes> <Routes>
<Route path="/new" element={<SpawnView store={store} pushToast={PUSH_TOAST} />} /> <Route
<Route path="/s/:id" element={<div data-testid="chat-route" />} /> path="/new"
</Routes> element={<SpawnView store={store} pushToast={PUSH_TOAST} />}
</MemoryRouter> />
); <Route path="/s/:id" element={<div data-testid="chat-route" />} />
</Routes>
</MemoryRouter>
);
} }
/** Flush pending microtasks + React effects (works under fake timers). */ /** Flush pending microtasks + React effects (works under fake timers). */
async function flush(ticks = 4): Promise<void> { async function flush(ticks = 4): Promise<void> {
for (let i = 0; i < ticks; i += 1) { for (let i = 0; i < ticks; i += 1) {
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
await act(async () => { await act(async () => {
await Promise.resolve(); await Promise.resolve();
}); });
} }
} }
beforeEach(() => { beforeEach(() => {
PUSH_TOAST.mockClear(); PUSH_TOAST.mockClear();
seedSettings(); seedSettings();
}); });
afterEach(() => { afterEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
describe("SpawnView status", () => { describe("SpawnView status", () => {
it("shows checking state, then error when gitlab status fails", async () => { it("shows checking state, then error when gitlab status fails", async () => {
mockFetchJson((url) => { mockFetchJson((url) => {
if (url.includes("/api/gitlab/status")) return jsonResponse({ error: "down" }, 500); if (url.includes("/api/gitlab/status"))
return []; return jsonResponse({ error: "down" }, 500);
}); return [];
render(tree(makeStore())); });
expect(screen.getByText("checking gitlab…")).toBeInTheDocument(); render(tree(makeStore()));
await flush(); expect(screen.getByText("checking gitlab…")).toBeInTheDocument();
expect(screen.getByText(/gitlab status failed: down/)).toBeInTheDocument(); await flush();
}); expect(screen.getByText(/gitlab status failed: down/)).toBeInTheDocument();
});
}); });
describe("SpawnView connect flow", () => { describe("SpawnView connect flow", () => {
it("requires a token", async () => { it("requires a token", async () => {
mockFetchJson((url) => { mockFetchJson((url) => {
if (url.endsWith("/api/gitlab/status")) return { connected: false, baseUrl: "https://gl" }; if (url.endsWith("/api/gitlab/status"))
return []; return { connected: false, baseUrl: "https://gl" };
}); return [];
render(tree(makeStore())); });
await flush(); render(tree(makeStore()));
fireEvent.click(screen.getByRole("button", { name: "Connect" })); await flush();
await flush(); fireEvent.click(screen.getByRole("button", { name: "Connect" }));
expect(screen.getByText("token required")).toBeInTheDocument(); await flush();
}); expect(screen.getByText("token required")).toBeInTheDocument();
});
it("connects, clears the PAT, loads repos and shows the picker", async () => { it("connects, clears the PAT, loads repos and shows the picker", async () => {
let connected = false; let connected = false;
mockFetchJson((url, init) => { mockFetchJson((url, init) => {
if (url.endsWith("/api/gitlab/status")) return { connected, baseUrl: "https://gl", username: connected ? "alice" : undefined }; if (url.endsWith("/api/gitlab/status"))
if (url.endsWith("/api/gitlab/connect") && init?.method === "POST") { return {
connected = true; connected,
return { username: "alice" }; baseUrl: "https://gl",
} username: connected ? "alice" : undefined,
if (url.endsWith("/api/gitlab/repos")) return [repo("g/one"), repo("g/two")]; };
return []; if (url.endsWith("/api/gitlab/connect") && init?.method === "POST") {
}); connected = true;
render(tree(makeStore())); return { username: "alice" };
await flush(); }
expect(screen.getByText("Connect GitLab")).toBeInTheDocument(); 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; const pat = screen.getByLabelText(
fireEvent.input(pat, { target: { value: "glpat-x" } }); "GitLab personal access token",
fireEvent.click(screen.getByRole("button", { name: "Connect" })); ) as HTMLInputElement;
await flush(); 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/one")).toBeInTheDocument();
expect(screen.getByText("g/two")).toBeInTheDocument(); expect(screen.getByText("g/two")).toBeInTheDocument();
expect(screen.queryByLabelText("GitLab personal access token")).toBeNull(); expect(screen.queryByLabelText("GitLab personal access token")).toBeNull();
expect(screen.getByText("Repository")).toBeInTheDocument(); expect(screen.getByText("Repository")).toBeInTheDocument();
}); });
it("connect failure shows the error and keeps the gate", async () => { it("connect failure shows the error and keeps the gate", async () => {
mockFetchJson((url, init) => { mockFetchJson((url, init) => {
if (url.endsWith("/api/gitlab/status")) return { connected: false, baseUrl: "https://gl" }; if (url.endsWith("/api/gitlab/status"))
if (url.endsWith("/api/gitlab/connect") && init?.method === "POST") return jsonResponse({ error: "bad pat" }, 401); return { connected: false, baseUrl: "https://gl" };
return []; if (url.endsWith("/api/gitlab/connect") && init?.method === "POST")
}); return jsonResponse({ error: "bad pat" }, 401);
render(tree(makeStore())); return [];
await flush(); });
fireEvent.input(screen.getByLabelText("GitLab personal access token"), { target: { value: "glpat-bad" } }); render(tree(makeStore()));
fireEvent.click(screen.getByRole("button", { name: "Connect" })); await flush();
await flush(); fireEvent.input(screen.getByLabelText("GitLab personal access token"), {
expect(screen.getByText("bad pat")).toBeInTheDocument(); target: { value: "glpat-bad" },
expect(screen.getByText("Connect GitLab")).toBeInTheDocument(); });
}); 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 () => { it("connected on load fetches repos immediately", async () => {
mockFetchJson((url) => { mockFetchJson((url) => {
if (url.endsWith("/api/gitlab/status")) return { connected: true, baseUrl: "https://gl", username: "alice" }; if (url.endsWith("/api/gitlab/status"))
if (url.endsWith("/api/gitlab/repos")) return [repo("g/quick")]; return { connected: true, baseUrl: "https://gl", username: "alice" };
return []; if (url.endsWith("/api/gitlab/repos")) return [repo("g/quick")];
}); return [];
render(tree(makeStore())); });
await flush(); render(tree(makeStore()));
expect(screen.getByText("g/quick")).toBeInTheDocument(); await flush();
}); expect(screen.getByText("g/quick")).toBeInTheDocument();
});
}); });
describe("SpawnView repo picker", () => { describe("SpawnView repo picker", () => {
function connectedMock(): void { function connectedMock(): void {
mockFetchJson((url) => { mockFetchJson((url) => {
if (url.endsWith("/api/gitlab/status")) return { connected: true, baseUrl: "https://gl", username: "alice" }; if (url.endsWith("/api/gitlab/status"))
if (url.endsWith("/api/gitlab/repos")) return [repo("g/alpha"), repo("g/beta", "dev")]; return { connected: true, baseUrl: "https://gl", username: "alice" };
return []; 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 () => { it("filters repos by query, keyboard selects, empty filter message", async () => {
connectedMock(); connectedMock();
render(tree(makeStore())); render(tree(makeStore()));
await flush(); await flush();
expect(screen.getByText("g/alpha")).toBeInTheDocument(); expect(screen.getByText("g/alpha")).toBeInTheDocument();
const filter = screen.getByLabelText("Filter repositories"); const filter = screen.getByLabelText("Filter repositories");
fireEvent.input(filter, { target: { value: "beta" } }); fireEvent.input(filter, { target: { value: "beta" } });
expect(screen.queryByText("g/alpha")).toBeNull(); expect(screen.queryByText("g/alpha")).toBeNull();
expect(screen.getByText("g/beta")).toBeInTheDocument(); expect(screen.getByText("g/beta")).toBeInTheDocument();
const item = screen.getByText("g/beta").closest(".repo-item") as HTMLElement; const item = screen
fireEvent.keyDown(item, { key: "Enter" }); .getByText("g/beta")
expect(screen.getByLabelText("Branch")).toHaveValue("dev"); .closest(".repo-item") as HTMLElement;
fireEvent.keyDown(item, { key: "Enter" });
expect(screen.getByLabelText("Branch")).toHaveValue("dev");
fireEvent.input(filter, { target: { value: "zzz" } }); fireEvent.input(filter, { target: { value: "zzz" } });
expect(screen.getByText("no matching repos")).toBeInTheDocument(); expect(screen.getByText("no matching repos")).toBeInTheDocument();
}); });
it("spawn without selection shows pick-a-repo error", async () => { it("spawn without selection shows pick-a-repo error", async () => {
connectedMock(); connectedMock();
render(tree(makeStore())); render(tree(makeStore()));
await flush(); await flush();
expect(screen.getByLabelText("Spawn container")).toBeDisabled(); expect(screen.getByLabelText("Spawn container")).toBeDisabled();
expect(screen.getByText("select a repo above")).toBeInTheDocument(); expect(screen.getByText("select a repo above")).toBeInTheDocument();
}); });
}); });
describe("SpawnView spawn+poll", () => { describe("SpawnView spawn+poll", () => {
beforeEach(() => { beforeEach(() => {
vi.useFakeTimers(); vi.useFakeTimers();
}); });
it("spawns, polls until online, then navigates to the chat", async () => { it("spawns, polls until online, then navigates to the chat", async () => {
const posts: Array<[string, RequestInit | undefined]> = []; const posts: Array<[string, RequestInit | undefined]> = [];
mockFetchJson((url, init) => { mockFetchJson((url, init) => {
if (init?.method === "POST" && url.endsWith("/api/spawn")) { if (init?.method === "POST" && url.endsWith("/api/spawn")) {
posts.push([url, init]); posts.push([url, init]);
return { sessionId: "new-1", containerId: "abc123def456" }; return { sessionId: "new-1", containerId: "abc123def456" };
} }
if (url.endsWith("/api/spawn/status")) return []; 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/status"))
if (url.endsWith("/api/gitlab/repos")) return [repo("g/proj")]; return { connected: true, baseUrl: "https://gl", username: "alice" };
return []; if (url.endsWith("/api/gitlab/repos")) return [repo("g/proj")];
}); return [];
const store = makeStore(); });
const { unmount } = render(tree(store)); const store = makeStore();
await flush(); const { unmount } = render(tree(store));
fireEvent.click(screen.getByText("g/proj")); await flush();
fireEvent.click(screen.getByLabelText("Spawn container")); fireEvent.click(screen.getByText("g/proj"));
await flush(); fireEvent.click(screen.getByLabelText("Spawn container"));
await flush();
expect(posts).toHaveLength(1); expect(posts).toHaveLength(1);
expect((posts[0]?.[1] as RequestInit).body).toBe(JSON.stringify({ repo: "g/proj", branch: "main" })); expect((posts[0]?.[1] as RequestInit).body).toBe(
expect(screen.getByText("Spawning…")).toBeInTheDocument(); JSON.stringify({ repo: "g/proj", branch: "main" }),
expect(screen.getByText(/container abc123def456/)).toBeInTheDocument(); );
expect(screen.getByText("waiting for session to come online…")).toBeInTheDocument(); 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 // first tick: session not online yet
await act(async () => { await act(async () => {
vi.advanceTimersByTime(1500); vi.advanceTimersByTime(1500);
}); });
await flush(); await flush();
expect(screen.queryByTestId("chat-route")).toBeNull(); expect(screen.queryByTestId("chat-route")).toBeNull();
// session comes online -> next tick navigates // session comes online -> next tick navigates
store.sessions = [ store.sessions = [
{ {
id: "new-1", id: "new-1",
name: "spawned", name: "spawned",
cwd: "/w", cwd: "/w",
model: "m", model: "m",
provider: "p", provider: "p",
agent: true, agent: true,
repo: "g/proj", repo: "g/proj",
startedAt: 1, startedAt: 1,
online: true, online: true,
lastEventAt: 1, lastEventAt: 1,
}, },
]; ];
await act(async () => { await act(async () => {
vi.advanceTimersByTime(1500); vi.advanceTimersByTime(1500);
}); });
await flush(); await flush();
expect(screen.getByTestId("chat-route")).toBeInTheDocument(); expect(screen.getByTestId("chat-route")).toBeInTheDocument();
unmount(); unmount();
}); });
it("branch left blank sends repo only; poll refresh failure toasts", async () => { it("branch left blank sends repo only; poll refresh failure toasts", async () => {
const bodies: string[] = []; const bodies: string[] = [];
mockFetchJson((url, init) => { mockFetchJson((url, init) => {
if (init?.method === "POST" && url.endsWith("/api/spawn")) { if (init?.method === "POST" && url.endsWith("/api/spawn")) {
bodies.push(String(init.body)); bodies.push(String(init.body));
return { sessionId: "new-2", containerId: "cccccccccccc" }; return { sessionId: "new-2", containerId: "cccccccccccc" };
} }
if (url.endsWith("/api/spawn/status")) return []; 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/status"))
if (url.endsWith("/api/gitlab/repos")) return [repo("g/blank")]; return { connected: true, baseUrl: "https://gl", username: "alice" };
return []; if (url.endsWith("/api/gitlab/repos")) return [repo("g/blank")];
}); return [];
const failingRefresh = makeStore({ });
refresh: async (): Promise<void> => { const failingRefresh = makeStore({
throw new Error("boom"); refresh: async (): Promise<void> => {
}, throw new Error("boom");
}); },
render(tree(failingRefresh)); });
await flush(); render(tree(failingRefresh));
fireEvent.click(screen.getByText("g/blank")); await flush();
fireEvent.change(screen.getByLabelText("Branch"), { target: { value: "" } }); fireEvent.click(screen.getByText("g/blank"));
fireEvent.click(screen.getByLabelText("Spawn container")); fireEvent.change(screen.getByLabelText("Branch"), {
await flush(); target: { value: "" },
expect(bodies).toEqual([JSON.stringify({ repo: "g/blank" })]); });
fireEvent.click(screen.getByLabelText("Spawn container"));
await flush();
expect(bodies).toEqual([JSON.stringify({ repo: "g/blank" })]);
await act(async () => { await act(async () => {
vi.advanceTimersByTime(1500); vi.advanceTimersByTime(1500);
}); });
await flush(); await flush();
expect(PUSH_TOAST).toHaveBeenCalledWith("boom"); expect(PUSH_TOAST).toHaveBeenCalledWith("boom");
}); });
it("spawn POST failure shows the error", async () => { it("spawn POST failure shows the error", async () => {
mockFetchJson((url, init) => { mockFetchJson((url, init) => {
if (init?.method === "POST" && url.endsWith("/api/spawn")) return jsonResponse({ error: "no docker" }, 500); if (init?.method === "POST" && url.endsWith("/api/spawn"))
if (url.endsWith("/api/gitlab/status")) return { connected: true, baseUrl: "https://gl", username: "alice" }; return jsonResponse({ error: "no docker" }, 500);
if (url.endsWith("/api/gitlab/repos")) return [repo("g/x")]; if (url.endsWith("/api/gitlab/status"))
return []; return { connected: true, baseUrl: "https://gl", username: "alice" };
}); if (url.endsWith("/api/gitlab/repos")) return [repo("g/x")];
render(tree(makeStore())); return [];
await flush(); });
fireEvent.click(screen.getByText("g/x")); render(tree(makeStore()));
fireEvent.click(screen.getByLabelText("Spawn container")); await flush();
await flush(); fireEvent.click(screen.getByText("g/x"));
expect(screen.getByText("no docker")).toBeInTheDocument(); 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 () => { it("polling gives up after the tick cap and stays on the page", async () => {
let statusCalls = 0; let statusCalls = 0;
mockFetchJson((url) => { mockFetchJson((url) => {
if (url.endsWith("/api/spawn/status")) { if (url.endsWith("/api/spawn/status")) {
statusCalls += 1; statusCalls += 1;
return []; return [];
} }
if (url.endsWith("/api/gitlab/status")) return { connected: true, baseUrl: "https://gl", username: "alice" }; if (url.endsWith("/api/gitlab/status"))
if (url.endsWith("/api/gitlab/repos")) return [repo("g/slow")]; return { connected: true, baseUrl: "https://gl", username: "alice" };
if (url.endsWith("/api/spawn")) return { sessionId: "slow-1", containerId: "d" }; if (url.endsWith("/api/gitlab/repos")) return [repo("g/slow")];
return []; if (url.endsWith("/api/spawn"))
}); return { sessionId: "slow-1", containerId: "d" };
const { unmount } = render(tree(makeStore())); return [];
await flush(); });
fireEvent.click(screen.getByText("g/slow")); const { unmount } = render(tree(makeStore()));
fireEvent.click(screen.getByLabelText("Spawn container")); await flush();
await flush(); fireEvent.click(screen.getByText("g/slow"));
expect(screen.getByText("Spawning…")).toBeInTheDocument(); fireEvent.click(screen.getByLabelText("Spawn container"));
await flush();
expect(screen.getByText("Spawning…")).toBeInTheDocument();
await act(async () => { await act(async () => {
vi.advanceTimersByTime(1500 * 402); vi.advanceTimersByTime(1500 * 402);
}); });
await flush(); await flush();
const afterCap = statusCalls; const afterCap = statusCalls;
await act(async () => { await act(async () => {
vi.advanceTimersByTime(1500 * 10); vi.advanceTimersByTime(1500 * 10);
}); });
await flush(); await flush();
expect(statusCalls).toBe(afterCap); expect(statusCalls).toBe(afterCap);
expect(screen.getByText("Spawning…")).toBeInTheDocument(); expect(screen.getByText("Spawning…")).toBeInTheDocument();
unmount(); unmount();
}); });
it("spawn job line renders from store.spawnJobs", async () => { it("spawn job line renders from store.spawnJobs", async () => {
mockFetchJson((url, init) => { mockFetchJson((url, init) => {
if (init?.method === "POST" && url.endsWith("/api/spawn")) return { sessionId: "sj-1", containerId: "cid" }; if (init?.method === "POST" && url.endsWith("/api/spawn"))
if (url.endsWith("/api/spawn/status")) return []; return { sessionId: "sj-1", containerId: "cid" };
if (url.endsWith("/api/gitlab/status")) return { connected: true, baseUrl: "https://gl", username: "alice" }; if (url.endsWith("/api/spawn/status")) return [];
if (url.endsWith("/api/gitlab/repos")) return [repo("g/p")]; if (url.endsWith("/api/gitlab/status"))
return []; return { connected: true, baseUrl: "https://gl", username: "alice" };
}); if (url.endsWith("/api/gitlab/repos")) return [repo("g/p")];
const store = makeStore(); return [];
const { rerender } = render(tree(store)); });
await flush(); const store = makeStore();
fireEvent.click(screen.getByText("g/p")); const { rerender } = render(tree(store));
fireEvent.click(screen.getByLabelText("Spawn container")); await flush();
await flush(); fireEvent.click(screen.getByText("g/p"));
expect(screen.getByText("Spawning…")).toBeInTheDocument(); fireEvent.click(screen.getByLabelText("Spawn container"));
await flush();
expect(screen.getByText("Spawning…")).toBeInTheDocument();
store.spawnJobs = [{ repo: "g/p", state: "cloning", sessionId: "sj-1" }]; store.spawnJobs = [{ repo: "g/p", state: "cloning", sessionId: "sj-1" }];
act(() => { act(() => {
rerender(tree(store)); rerender(tree(store));
}); });
expect(screen.getByText("g/p: cloning")).toBeInTheDocument(); expect(screen.getByText("g/p: cloning")).toBeInTheDocument();
}); });
}); });
+89 -58
View File
@@ -6,66 +6,97 @@ import TaskPanel from "./TaskPanel";
const base: TaskDerivation = { todos: [], subagents: [], workingTools: [] }; const base: TaskDerivation = { todos: [], subagents: [], workingTools: [] };
describe("TaskPanel", () => { describe("TaskPanel", () => {
it("empty state", () => { it("empty state", () => {
render(<TaskPanel tasks={base} />); render(<TaskPanel tasks={base} />);
expect(screen.getByText("No tasks yet.")).toBeInTheDocument(); expect(screen.getByText("No tasks yet.")).toBeInTheDocument();
}); });
it("todo rows render icons per status and deleted styling", () => { it("todo rows render icons per status and deleted styling", () => {
const tasks: TaskDerivation = { const tasks: TaskDerivation = {
...base, ...base,
todos: [ todos: [
{ content: "write tests", status: "pending", deleted: false }, { content: "write tests", status: "pending", deleted: false },
{ content: "run them", status: "in-progress", deleted: false }, { content: "run them", status: "in-progress", deleted: false },
{ content: "ship it", status: "completed", deleted: false }, { content: "ship it", status: "completed", deleted: false },
{ content: "old task", status: "pending", deleted: true }, { content: "old task", status: "pending", deleted: true },
], ],
}; };
const { container } = render(<TaskPanel tasks={tasks} />); const { container } = render(<TaskPanel tasks={tasks} />);
expect(container.querySelector(".todo-icon.pending")?.textContent).toBe("○"); 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(container.querySelector(".todo-icon.in-progress")?.textContent).toBe(
expect(deleted).not.toBeNull(); "◺",
expect(deleted.style.textDecoration).toContain("line-through"); );
}); 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", () => { it("subagent rows: running spinner, done check, failed cross", () => {
const tasks: TaskDerivation = { const tasks: TaskDerivation = {
...base, ...base,
subagents: [ subagents: [
{ key: "a", name: "scout", running: true, isError: false }, { key: "a", name: "scout", running: true, isError: false },
{ key: "b", name: "worker", running: false, isError: false }, { key: "b", name: "worker", running: false, isError: false },
{ key: "c", name: "reviewer", running: false, isError: true }, { key: "c", name: "reviewer", running: false, isError: true },
], ],
}; };
render(<TaskPanel tasks={tasks} />); render(<TaskPanel tasks={tasks} />);
expect(screen.getByLabelText("running")).toBeInTheDocument(); expect(screen.getByLabelText("running")).toBeInTheDocument();
expect(screen.getByText("scout")).toBeInTheDocument(); expect(screen.getByText("scout")).toBeInTheDocument();
expect(screen.getByText("running")).toBeInTheDocument(); expect(screen.getByText("running")).toBeInTheDocument();
expect(screen.getByText("✓")).toBeInTheDocument(); expect(screen.getByText("✓")).toBeInTheDocument();
expect(screen.getByText("✕")).toBeInTheDocument(); expect(screen.getByText("✕")).toBeInTheDocument();
expect(screen.getByText("done")).toBeInTheDocument(); expect(screen.getByText("done")).toBeInTheDocument();
expect(screen.getByText("failed")).toBeInTheDocument(); expect(screen.getByText("failed")).toBeInTheDocument();
}); });
it("working tools section", () => { it("working tools section", () => {
const tasks: TaskDerivation = { const tasks: TaskDerivation = {
...base, ...base,
workingTools: [{ id: "c1", name: "bash", args: "ls", running: true, isError: false, preview: "" }], workingTools: [
}; {
render(<TaskPanel tasks={tasks} />); id: "c1",
expect(screen.getByText("Working")).toBeInTheDocument(); name: "bash",
expect(screen.getByText("bash…")).toBeInTheDocument(); args: "ls",
}); running: true,
isError: false,
preview: "",
},
],
};
render(<TaskPanel tasks={tasks} />);
expect(screen.getByText("Working")).toBeInTheDocument();
expect(screen.getByText("bash…")).toBeInTheDocument();
});
it("sections appear only when populated", () => { it("sections appear only when populated", () => {
const { rerender, queryByText } = render(<TaskPanel tasks={{ ...base, todos: [{ content: "t", status: "pending", deleted: false }] }} />); const { rerender, queryByText } = render(
expect(queryByText("Tasks")).not.toBeNull(); <TaskPanel
expect(queryByText("Subagents")).toBeNull(); tasks={{
expect(queryByText("Working")).toBeNull(); ...base,
rerender(<TaskPanel tasks={{ ...base, subagents: [{ key: "a", name: "s", running: false, isError: false }] }} />); todos: [{ content: "t", status: "pending", deleted: false }],
expect(queryByText("Tasks")).toBeNull(); }}
expect(queryByText("Subagents")).not.toBeNull(); />,
}); );
expect(queryByText("Tasks")).not.toBeNull();
expect(queryByText("Subagents")).toBeNull();
expect(queryByText("Working")).toBeNull();
rerender(
<TaskPanel
tasks={{
...base,
subagents: [{ key: "a", name: "s", running: false, isError: false }],
}}
/>,
);
expect(queryByText("Tasks")).toBeNull();
expect(queryByText("Subagents")).not.toBeNull();
});
}); });
+85 -58
View File
@@ -4,70 +4,97 @@ import { saveSettings } from "./settings";
import { jsonResponse } from "./test/setup"; import { jsonResponse } from "./test/setup";
describe("api", () => { describe("api", () => {
beforeEach(() => { beforeEach(() => {
localStorage.clear(); localStorage.clear();
saveSettings({ serverUrl: "http://srv", token: "sekret" }); saveSettings({ serverUrl: "http://srv", token: "sekret" });
}); });
it("throws 401 when not configured", async () => { it("throws 401 when not configured", async () => {
localStorage.clear(); localStorage.clear();
await expect(fetchJson("/api/sessions")).rejects.toMatchObject({ status: 401 }); await expect(fetchJson("/api/sessions")).rejects.toMatchObject({
}); status: 401,
});
});
it("GET sends bearer header and parses JSON", async () => { it("GET sends bearer header and parses JSON", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse([{ id: "s1" }])); const fetchMock = vi
const out = await fetchJson<Array<{ id: string }>>("/api/sessions"); .spyOn(globalThis, "fetch")
expect(out).toEqual([{ id: "s1" }]); .mockResolvedValue(jsonResponse([{ id: "s1" }]));
const [input, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; const out = await fetchJson<Array<{ id: string }>>("/api/sessions");
expect(input).toBe("http://srv/api/sessions"); expect(out).toEqual([{ id: "s1" }]);
expect(init.headers).toEqual({ Authorization: "Bearer sekret" }); const [input, init] = fetchMock.mock.calls[0] as unknown as [
expect(init.body).toBeUndefined(); 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 () => { it("POST sends JSON content-type with body", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse({ ok: true })); const fetchMock = vi
await fetchJson("/api/sessions/s1/prompt", { method: "POST", body: JSON.stringify({ message: "hi" }) }); .spyOn(globalThis, "fetch")
const init = fetchMock.mock.calls[0]?.[1] as RequestInit; .mockResolvedValue(jsonResponse({ ok: true }));
expect(init.method).toBe("POST"); await fetchJson("/api/sessions/s1/prompt", {
expect(init.headers).toEqual({ Authorization: "Bearer sekret", "Content-Type": "application/json" }); 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 () => { it("throws ApiError with server error message", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse({ error: "boom" }, 500)); vi.spyOn(globalThis, "fetch").mockResolvedValue(
const err: unknown = await fetchJson("/api/sessions").catch((e: unknown) => e); jsonResponse({ error: "boom" }, 500),
expect(err).toBeInstanceOf(ApiError); );
expect((err as ApiError).message).toBe("boom"); const err: unknown = await fetchJson("/api/sessions").catch(
expect((err as ApiError).status).toBe(500); (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 () => { it("falls back to status text when body has no error string", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse({ other: 1 }, 404)); vi.spyOn(globalThis, "fetch").mockResolvedValue(
const err: unknown = await fetchJson("/api/sessions").catch((e: unknown) => e); jsonResponse({ other: 1 }, 404),
expect((err as ApiError).message).toBe("404 StatusText"); );
}); 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 () => { it("falls back to status text when body is not JSON", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue({ vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: false, ok: false,
status: 502, status: 502,
statusText: "Bad Gateway", statusText: "Bad Gateway",
json: async () => { json: async () => {
throw new SyntaxError("bad json"); throw new SyntaxError("bad json");
}, },
} as unknown as Response); } as unknown as Response);
const err: unknown = await fetchJson("/api/sessions").catch((e: unknown) => e); const err: unknown = await fetchJson("/api/sessions").catch(
expect((err as ApiError).message).toBe("502 Bad Gateway"); (e: unknown) => e,
}); );
expect((err as ApiError).message).toBe("502 Bad Gateway");
});
it("rejects null JSON bodies gracefully in error path", async () => { it("rejects null JSON bodies gracefully in error path", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse(null, 409)); vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse(null, 409));
const err: unknown = await fetchJson("/api/sessions").catch((e: unknown) => e); const err: unknown = await fetchJson("/api/sessions").catch(
expect((err as ApiError).message).toBe("409 StatusText"); (e: unknown) => e,
}); );
expect((err as ApiError).message).toBe("409 StatusText");
});
it("errMessage maps Error and non-Error values", () => { it("errMessage maps Error and non-Error values", () => {
expect(errMessage(new Error("oops"))).toBe("oops"); expect(errMessage(new Error("oops"))).toBe("oops");
expect(errMessage(42)).toBe("42"); expect(errMessage(42)).toBe("42");
expect(errMessage(null)).toBe("null"); expect(errMessage(null)).toBe("null");
}); });
}); });
+505 -346
View File
@@ -1,422 +1,581 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { EventFrame, Message } from "./protocol"; import type { EventFrame, Message } from "./protocol";
import { deriveChat, deriveTasks, lastPersistedSeq, mergeEvents } from "./derive"; import {
deriveChat,
deriveTasks,
lastPersistedSeq,
mergeEvents,
} from "./derive";
let seq: number = 0; let seq: number = 0;
function ev(partial: Partial<EventFrame> & { type: string }): EventFrame { function ev(partial: Partial<EventFrame> & { type: string }): EventFrame {
seq += 1; seq += 1;
return { v: 1, sessionId: "s1", seq: partial.seq ?? seq, ts: 0, ...partial } as EventFrame; return {
v: 1,
sessionId: "s1",
seq: partial.seq ?? seq,
ts: 0,
...partial,
} as EventFrame;
} }
function msg(m: Partial<Message>): Message { function msg(m: Partial<Message>): Message {
return { return {
role: "assistant", role: "assistant",
id: "m1", id: "m1",
text: "", text: "",
thinking: null, thinking: null,
toolCalls: [], toolCalls: [],
toolCallId: null, toolCallId: null,
...m, ...m,
}; };
} }
function toolStart(id: string, name: string, args?: string): EventFrame { 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 { 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 ---------- // ---------- mergeEvents / lastPersistedSeq ----------
describe("mergeEvents", () => { describe("mergeEvents", () => {
it("merges and sorts by seq, dedupes by seq", () => { it("merges and sorts by seq, dedupes by seq", () => {
const a = [ev({ type: "hello", seq: 5 }), ev({ type: "hello", seq: 1 })]; const a = [ev({ type: "hello", seq: 5 }), ev({ type: "hello", seq: 1 })];
seq = 0; seq = 0;
const b = [ev({ type: "hello", seq: 3 }), ev({ type: "hello", seq: 1 })]; const b = [ev({ type: "hello", seq: 3 }), ev({ type: "hello", seq: 1 })];
const merged = mergeEvents(a, b); const merged = mergeEvents(a, b);
expect(merged.map((e) => e.seq)).toEqual([1, 3, 5]); expect(merged.map((e) => e.seq)).toEqual([1, 3, 5]);
}); });
it("incoming wins on duplicate seq", () => { it("incoming wins on duplicate seq", () => {
const a = [ev({ type: "hello", seq: 1, reason: "a" })]; const a = [ev({ type: "hello", seq: 1, reason: "a" })];
const b: EventFrame[] = a.map((e) => ({ ...e, reason: "b" })); const b: EventFrame[] = a.map((e) => ({ ...e, reason: "b" }));
expect(mergeEvents(a, b)[0]?.reason).toBe("b"); expect(mergeEvents(a, b)[0]?.reason).toBe("b");
}); });
}); });
describe("lastPersistedSeq", () => { describe("lastPersistedSeq", () => {
it("ignores message_update deltas", () => { it("ignores message_update deltas", () => {
const events = [ const events = [
ev({ type: "message_end", seq: 7 }), ev({ type: "message_end", seq: 7 }),
ev({ type: "message_update", seq: 99 }), ev({ type: "message_update", seq: 99 }),
ev({ type: "agent_settled", seq: 8 }), ev({ type: "agent_settled", seq: 8 }),
]; ];
expect(lastPersistedSeq(events)).toBe(8); expect(lastPersistedSeq(events)).toBe(8);
}); });
it("returns 0 for empty or delta-only streams", () => { it("returns 0 for empty or delta-only streams", () => {
expect(lastPersistedSeq([])).toBe(0); expect(lastPersistedSeq([])).toBe(0);
expect(lastPersistedSeq([ev({ type: "message_update", delta: "x", seq: 5 })])).toBe(0); expect(
}); lastPersistedSeq([ev({ type: "message_update", delta: "x", seq: 5 })]),
).toBe(0);
});
}); });
// ---------- deriveChat ---------- // ---------- deriveChat ----------
describe("deriveChat", () => { describe("deriveChat", () => {
it("message_end renders user/assistant/system/toolResult messages", () => { it("message_end renders user/assistant/system/toolResult messages", () => {
const events = [ const events = [
ev({ type: "message_end", message: msg({ id: "u1", role: "user", text: "hi" }) }), ev({
ev({ type: "message_end", message: msg({ id: "a1", role: "assistant", text: "hello" }) }), type: "message_end",
ev({ type: "message_end", message: msg({ id: "t1", role: "toolResult", text: "out", toolCallId: "c1" }) }), message: msg({ id: "u1", role: "user", text: "hi" }),
ev({ type: "message_end", message: msg({ id: "s1", role: "system", text: "sys" }) }), }),
]; ev({
const { messages } = deriveChat(events); type: "message_end",
expect(messages.map((m) => m.role)).toEqual(["user", "assistant", "toolResult", "system"]); message: msg({ id: "a1", role: "assistant", text: "hello" }),
expect(messages[1]?.text).toBe("hello"); }),
expect(messages.every((m) => m.streaming === false)).toBe(true); 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", () => { it("message_update deltas append into a streaming bubble ended by matching message_end", () => {
const id = "a9"; const id = "a9";
const events = [ const events = [
ev({ type: "message_start", message: msg({ id, role: "assistant" }) }), ev({ type: "message_start", message: msg({ id, role: "assistant" }) }),
ev({ type: "message_update", delta: "Hel" }), ev({ type: "message_update", delta: "Hel" }),
ev({ type: "message_update", delta: "lo" }), ev({ type: "message_update", delta: "lo" }),
]; ];
let chat = deriveChat(events); let chat = deriveChat(events);
expect(chat.messages).toHaveLength(1); expect(chat.messages).toHaveLength(1);
expect(chat.messages[0]).toMatchObject({ role: "assistant", text: "Hello", streaming: true }); expect(chat.messages[0]).toMatchObject({
expect(chat.busy).toBe(true); role: "assistant",
text: "Hello",
streaming: true,
});
expect(chat.busy).toBe(true);
chat = deriveChat([ chat = deriveChat([
...events, ...events,
ev({ type: "message_end", message: msg({ id, role: "assistant", text: "Hello world" }) }), ev({
]); type: "message_end",
expect(chat.messages).toHaveLength(1); message: msg({ id, role: "assistant", text: "Hello world" }),
expect(chat.messages[0]).toMatchObject({ text: "Hello world", streaming: false }); }),
expect(chat.busy).toBe(false); ]);
}); 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", () => { it("non-assistant message_start does not open a stream", () => {
const chat = deriveChat([ev({ type: "message_start", message: msg({ id: "u1", role: "user" }) })]); const chat = deriveChat([
expect(chat.messages).toHaveLength(0); ev({ type: "message_start", message: msg({ id: "u1", role: "user" }) }),
expect(chat.busy).toBe(false); ]);
}); expect(chat.messages).toHaveLength(0);
expect(chat.busy).toBe(false);
});
it("missing delta does not append 'undefined'", () => { it("missing delta does not append 'undefined'", () => {
const chat = deriveChat([ const chat = deriveChat([
ev({ type: "message_start", message: msg({ id: "a", role: "assistant" }) }), ev({
ev({ type: "message_update" }), type: "message_start",
]); message: msg({ id: "a", role: "assistant" }),
expect(chat.messages[0]?.text).toBe(""); }),
}); ev({ type: "message_update" }),
]);
expect(chat.messages[0]?.text).toBe("");
});
it("agent_start/agent_settled drive busy", () => { it("agent_start/agent_settled drive busy", () => {
expect(deriveChat([ev({ type: "agent_start" })]).busy).toBe(true); expect(deriveChat([ev({ type: "agent_start" })]).busy).toBe(true);
expect(deriveChat([ev({ type: "agent_start" }), ev({ type: "agent_settled" })]).busy).toBe(false); 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", () => { it("message_start for a new assistant id drops the old stream", () => {
const chat = deriveChat([ const chat = deriveChat([
ev({ type: "message_start", message: msg({ id: "a1", role: "assistant" }) }), ev({
ev({ type: "message_update", delta: "x" }), type: "message_start",
ev({ type: "message_start", message: msg({ id: "a2", role: "assistant" }) }), message: msg({ id: "a1", role: "assistant" }),
ev({ type: "message_update", delta: "y" }), }),
]); ev({ type: "message_update", delta: "x" }),
expect(chat.messages).toHaveLength(1); ev({
expect(chat.messages[0]?.key).toBe("stream-a2"); type: "message_start",
expect(chat.messages[0]?.text).toBe("y"); 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", () => { it("message_end without message payload is ignored", () => {
const chat = deriveChat([ev({ type: "message_end" })]); const chat = deriveChat([ev({ type: "message_end" })]);
expect(chat.messages).toHaveLength(0); expect(chat.messages).toHaveLength(0);
}); });
it("tool lifecycle states", () => { it("tool lifecycle states", () => {
const chat = deriveChat([ const chat = deriveChat([
toolStart("c1", "bash", "ls"), toolStart("c1", "bash", "ls"),
toolStart("c2", "read"), toolStart("c2", "read"),
toolEnd("c1", true, "boom"), toolEnd("c1", true, "boom"),
toolEnd("c2", false, "ok"), toolEnd("c2", false, "ok"),
]); ]);
expect(chat.tools.get("c1")).toMatchObject({ name: "bash", args: "ls", running: false, isError: true, preview: "boom" }); expect(chat.tools.get("c1")).toMatchObject({
expect(chat.tools.get("c2")).toMatchObject({ running: false, isError: false, preview: "ok" }); name: "bash",
// defaults args: "ls",
const chat2 = deriveChat([ running: false,
ev({ type: "tool_execution_start", toolCallId: "c3", toolName: "t" }), isError: true,
ev({ type: "tool_execution_end", toolCallId: "c3" }), preview: "boom",
]); });
expect(chat2.tools.get("c3")).toMatchObject({ running: false, isError: false, preview: "" }); 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", () => { it("tool_execution_start without id and end for unknown id are ignored", () => {
const chat = deriveChat([ const chat = deriveChat([
ev({ type: "tool_execution_start", toolName: "x" }), ev({ type: "tool_execution_start", toolName: "x" }),
ev({ type: "tool_execution_end", toolCallId: "ghost" }), ev({ type: "tool_execution_end", toolCallId: "ghost" }),
]); ]);
expect(chat.tools.size).toBe(0); expect(chat.tools.size).toBe(0);
}); });
it("tool execution defaults missing names/args", () => { it("tool execution defaults missing names/args", () => {
const chat = deriveChat([ev({ type: "tool_execution_start", toolCallId: "c" })]); const chat = deriveChat([
expect(chat.tools.get("c")).toMatchObject({ name: "tool", args: "" }); ev({ type: "tool_execution_start", toolCallId: "c" }),
}); ]);
expect(chat.tools.get("c")).toMatchObject({ name: "tool", args: "" });
});
}); });
// ---------- deriveTasks: todo derivation truth table ---------- // ---------- deriveTasks: todo derivation truth table ----------
function todoSnap(items: unknown): string { function todoSnap(items: unknown): string {
return JSON.stringify(items); return JSON.stringify(items);
} }
describe("deriveTasks todos", () => { describe("deriveTasks todos", () => {
it("latest snapshot wins; statuses from latest; deletion marks earlier items", () => { it("latest snapshot wins; statuses from latest; deletion marks earlier items", () => {
const events = [ const events = [
toolStart("c1", "todo", todoSnap([{ content: "a" }, { content: "b" }])), toolStart("c1", "todo", todoSnap([{ content: "a" }, { content: "b" }])),
toolEnd("c1", false, todoSnap([{ content: "a", status: "in-progress" }, { content: "b" }])), toolEnd(
toolStart("c2", "todo", todoSnap([{ content: "b", status: "completed" }])), "c1",
]; false,
const { todos } = deriveTasks(events); todoSnap([{ content: "a", status: "in-progress" }, { content: "b" }]),
expect(todos).toEqual([ ),
{ content: "b", status: "completed", deleted: false }, toolStart(
{ content: "a", status: "pending", deleted: true }, "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", () => { it("status alias normalization table", () => {
const aliases: Array<[unknown, string]> = [ const aliases: Array<[unknown, string]> = [
["in_progress", "in-progress"], ["in_progress", "in-progress"],
["in-progress", "in-progress"], ["in-progress", "in-progress"],
["inprogress", "in-progress"], ["inprogress", "in-progress"],
["in progress", "in-progress"], ["in progress", "in-progress"],
["doing", "in-progress"], ["doing", "in-progress"],
["started", "in-progress"], ["started", "in-progress"],
["completed", "completed"], ["completed", "completed"],
["complete", "completed"], ["complete", "completed"],
["done", "completed"], ["done", "completed"],
["weird", "pending"], ["weird", "pending"],
[7, "pending"], [7, "pending"],
]; ];
for (const [raw, expected] of aliases) { for (const [raw, expected] of aliases) {
seq = 0; seq = 0;
const events = [toolStart("c1", "todo", todoSnap([{ content: "t", status: raw }]))]; const events = [
expect(deriveTasks(events).todos[0]?.status, `status ${String(raw)}`).toBe(expected); 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", () => { it("snapshot sources: args of start, preview of end, toolResult message text", () => {
const events = [ const events = [
toolStart("c1", "todo", todoSnap([{ content: "from-args" }])), toolStart("c1", "todo", todoSnap([{ content: "from-args" }])),
toolEnd("c1", false, todoSnap([{ content: "from-preview" }])), toolEnd("c1", false, todoSnap([{ content: "from-preview" }])),
ev({ ev({
type: "message_end", type: "message_end",
message: msg({ id: "t1", role: "toolResult", text: todoSnap([{ content: "from-msg" }]), toolCallId: "c2" }), message: msg({
}), id: "t1",
]; role: "toolResult",
// c2 has no tool_execution_start before its toolResult message; the second text: todoSnap([{ content: "from-msg" }]),
// pass only harvests toolResult text for known todo calls toolCallId: "c2",
const eventsKnown = [ }),
...events, }),
toolStart("c2", "todo"), ];
]; // c2 has no tool_execution_start before its toolResult message; the second
const { todos } = deriveTasks(eventsKnown); // pass only harvests toolResult text for known todo calls
// latest snapshot (highest seq) is the toolResult message; earlier-only const eventsKnown = [...events, toolStart("c2", "todo")];
// items survive as deleted markers const { todos } = deriveTasks(eventsKnown);
expect(todos.map((t) => t.content)).toEqual(["from-msg", "from-args", "from-preview"]); // latest snapshot (highest seq) is the toolResult message; earlier-only
expect(todos.map((t) => t.deleted)).toEqual([false, true, true]); // 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", () => { it("accepts wrapper objects and string arrays", () => {
const wrapped = deriveTasks([ const wrapped = deriveTasks([
toolStart("c1", "todo", JSON.stringify({ todos: [{ content: "w" }] })), toolStart("c1", "todo", JSON.stringify({ todos: [{ content: "w" }] })),
]); ]);
expect(wrapped.todos.map((t) => t.content)).toEqual(["w"]); expect(wrapped.todos.map((t) => t.content)).toEqual(["w"]);
const nestedItems = deriveTasks([toolStart("c1", "todo", JSON.stringify({ items: ["plain string"] }))]); const nestedItems = deriveTasks([
expect(nestedItems.todos).toEqual([{ content: "plain string", status: "pending", deleted: false }]); toolStart("c1", "todo", JSON.stringify({ items: ["plain string"] })),
const nestedTasks = deriveTasks([toolStart("c1", "todo", JSON.stringify({ tasks: [{ title: "tt" }] }))]); ]);
expect(nestedTasks.todos.map((t) => t.content)).toEqual(["tt"]); expect(nestedItems.todos).toEqual([
const nestedList = deriveTasks([toolStart("c1", "todo", JSON.stringify({ list: [{ text: "tx" }] }))]); { content: "plain string", status: "pending", deleted: false },
expect(nestedList.todos.map((t) => t.content)).toEqual(["tx"]); ]);
const subject = deriveTasks([toolStart("c1", "todo", JSON.stringify([{ subject: "sj" }]))]); const nestedTasks = deriveTasks([
expect(subject.todos.map((t) => t.content)).toEqual(["sj"]); toolStart("c1", "todo", JSON.stringify({ tasks: [{ title: "tt" }] })),
const summary = deriveTasks([toolStart("c1", "todo", JSON.stringify([{ summary: "sm" }]))]); ]);
expect(summary.todos.map((t) => t.content)).toEqual(["sm"]); 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", () => { it("invalid snapshots are skipped: bad JSON, non-array, empty entries, empty content", () => {
const cases: Array<string | undefined> = [ const cases: Array<string | undefined> = [
"{bad json", "{bad json",
JSON.stringify({ nope: 1 }), JSON.stringify({ nope: 1 }),
JSON.stringify([]), JSON.stringify([]),
JSON.stringify([{ content: "" }]), JSON.stringify([{ content: "" }]),
JSON.stringify([42]), JSON.stringify([42]),
"", "",
undefined, undefined,
]; ];
const events = cases.map((c, i) => toolStart(`c${i}`, "todo", c)); const events = cases.map((c, i) => toolStart(`c${i}`, "todo", c));
const { todos } = deriveTasks(events); const { todos } = deriveTasks(events);
expect(todos).toHaveLength(0); expect(todos).toHaveLength(0);
}); });
it("no todo tool calls yields no todos", () => { it("no todo tool calls yields no todos", () => {
expect(deriveTasks([]).todos).toHaveLength(0); expect(deriveTasks([]).todos).toHaveLength(0);
expect(deriveTasks([toolStart("c1", "bash")]).todos).toHaveLength(0); expect(deriveTasks([toolStart("c1", "bash")]).todos).toHaveLength(0);
}); });
}); });
// ---------- deriveTasks: subagents ---------- // ---------- deriveTasks: subagents ----------
describe("deriveTasks subagents", () => { describe("deriveTasks subagents", () => {
it("name fields fallback table", () => { it("name fields fallback table", () => {
const events = [ const events = [
toolStart("c1", "subagent", JSON.stringify({ agentName: "scout" })), toolStart("c1", "subagent", JSON.stringify({ agentName: "scout" })),
toolStart("c2", "subagent", JSON.stringify({ agent: "worker" })), toolStart("c2", "subagent", JSON.stringify({ agent: "worker" })),
toolStart("c3", "subagent", JSON.stringify({ name: "planner" })), toolStart("c3", "subagent", JSON.stringify({ name: "planner" })),
toolStart("c4", "subagent", JSON.stringify({ agentType: "reviewer" })), toolStart("c4", "subagent", JSON.stringify({ agentType: "reviewer" })),
toolStart("c5", "subagent", JSON.stringify({ role: "oracle" })), toolStart("c5", "subagent", JSON.stringify({ role: "oracle" })),
toolStart("c6", "subagent", JSON.stringify({ agentName: "" })), toolStart("c6", "subagent", JSON.stringify({ agentName: "" })),
toolStart("c7", "subagent", "not json"), toolStart("c7", "subagent", "not json"),
]; ];
const { subagents } = deriveTasks(events); const { subagents } = deriveTasks(events);
expect(subagents.map((s) => s.name)).toEqual(["scout", "worker", "planner", "reviewer", "oracle", "subagent", "subagent"]); expect(subagents.map((s) => s.name)).toEqual([
expect(subagents.every((s) => s.running && !s.isError)).toBe(true); "scout",
}); "worker",
"planner",
"reviewer",
"oracle",
"subagent",
"subagent",
]);
expect(subagents.every((s) => s.running && !s.isError)).toBe(true);
});
it("tool_execution_end marks done / failed", () => { it("tool_execution_end marks done / failed", () => {
const events = [ const events = [
toolStart("c1", "subagent", JSON.stringify({ agentName: "a" })), toolStart("c1", "subagent", JSON.stringify({ agentName: "a" })),
toolStart("c2", "subagent", JSON.stringify({ agentName: "b" })), toolStart("c2", "subagent", JSON.stringify({ agentName: "b" })),
toolEnd("c1", false), toolEnd("c1", false),
toolEnd("c2", true), toolEnd("c2", true),
]; ];
const { subagents } = deriveTasks(events); const { subagents } = deriveTasks(events);
expect(subagents.find((s) => s.key === "c1")).toMatchObject({ running: false, isError: false }); expect(subagents.find((s) => s.key === "c1")).toMatchObject({
expect(subagents.find((s) => s.key === "c2")).toMatchObject({ running: false, isError: true }); running: false,
}); isError: false,
});
expect(subagents.find((s) => s.key === "c2")).toMatchObject({
running: false,
isError: true,
});
});
it("end for unknown subagent id ignored", () => { it("end for unknown subagent id ignored", () => {
const { subagents } = deriveTasks([ const { subagents } = deriveTasks([
toolStart("c1", "subagent", JSON.stringify({ agentName: "a" })), toolStart("c1", "subagent", JSON.stringify({ agentName: "a" })),
toolEnd("ghost"), toolEnd("ghost"),
]); ]);
expect(subagents[0]?.running).toBe(true); expect(subagents[0]?.running).toBe(true);
}); });
}); });
// ---------- deriveTasks: working tools ---------- // ---------- deriveTasks: working tools ----------
describe("deriveTasks workingTools", () => { describe("deriveTasks workingTools", () => {
it("only running non-todo non-subagent tools are listed", () => { it("only running non-todo non-subagent tools are listed", () => {
const events = [ const events = [
toolStart("c1", "bash", "ls"), toolStart("c1", "bash", "ls"),
toolStart("c2", "read", "f"), toolStart("c2", "read", "f"),
toolEnd("c1", false, "out"), toolEnd("c1", false, "out"),
toolStart("c3", "todo", todoSnap([{ content: "x" }])), toolStart("c3", "todo", todoSnap([{ content: "x" }])),
toolStart("c4", "subagent", JSON.stringify({ agentName: "a" })), toolStart("c4", "subagent", JSON.stringify({ agentName: "a" })),
]; ];
const { workingTools } = deriveTasks(events); const { workingTools } = deriveTasks(events);
expect(workingTools.map((w) => w.id)).toEqual(["c2"]); expect(workingTools.map((w) => w.id)).toEqual(["c2"]);
expect(workingTools[0]).toMatchObject({ name: "read", args: "f", running: true }); expect(workingTools[0]).toMatchObject({
}); name: "read",
args: "f",
running: true,
});
});
it("tool_execution_update events do not affect derivation", () => { it("tool_execution_update events do not affect derivation", () => {
const { workingTools } = deriveTasks([ const { workingTools } = deriveTasks([
toolStart("c1", "bash"), toolStart("c1", "bash"),
ev({ type: "tool_execution_update", toolCallId: "c1", partial: "…" }), ev({ type: "tool_execution_update", toolCallId: "c1", partial: "…" }),
]); ]);
expect(workingTools).toHaveLength(1); expect(workingTools).toHaveLength(1);
}); });
}); });
describe("deriveChat/deriveTasks edge branches", () => { describe("deriveChat/deriveTasks edge branches", () => {
it("unhandled event types fall through the switch", () => { it("unhandled event types fall through the switch", () => {
const chat = deriveChat([ const chat = deriveChat([
ev({ type: "hello" }), ev({ type: "hello" }),
ev({ type: "bye", reason: "shutdown" }), ev({ type: "bye", reason: "shutdown" }),
ev({ type: "session_info" }), ev({ type: "session_info" }),
ev({ type: "agent_end", usage: {} }), ev({ type: "agent_end", usage: {} }),
ev({ type: "tool_execution_update", toolCallId: "c", partial: "x" }), ev({ type: "tool_execution_update", toolCallId: "c", partial: "x" }),
]); ]);
expect(chat.messages).toHaveLength(0); expect(chat.messages).toHaveLength(0);
expect(chat.busy).toBe(false); expect(chat.busy).toBe(false);
expect(chat.tools.size).toBe(0); expect(chat.tools.size).toBe(0);
}); });
it("message_end keeps toolCalls and ends only the matching stream", () => { it("message_end keeps toolCalls and ends only the matching stream", () => {
const withTools = deriveChat([ const withTools = deriveChat([
ev({ ev({
type: "message_end", type: "message_end",
message: msg({ id: "a1", toolCalls: [{ id: "c1", name: "bash", argsJson: "{}" }] }), message: msg({
}), id: "a1",
]); toolCalls: [{ id: "c1", name: "bash", argsJson: "{}" }],
expect(withTools.messages[0]?.toolCalls).toHaveLength(1); }),
}),
]);
expect(withTools.messages[0]?.toolCalls).toHaveLength(1);
// message_end for a different id does not close the open stream // message_end for a different id does not close the open stream
const mismatch = deriveChat([ const mismatch = deriveChat([
ev({ type: "message_start", message: msg({ id: "streaming" }) }), ev({ type: "message_start", message: msg({ id: "streaming" }) }),
ev({ type: "message_update", delta: "par" }), ev({ type: "message_update", delta: "par" }),
ev({ type: "message_end", message: msg({ id: "other", text: "done" }) }), ev({ type: "message_end", message: msg({ id: "other", text: "done" }) }),
]); ]);
expect(mismatch.messages[0]?.key).toMatch(/^msg-\d+$/); expect(mismatch.messages[0]?.key).toMatch(/^msg-\d+$/);
expect(mismatch.messages[1]?.text).toBe("par"); expect(mismatch.messages[1]?.text).toBe("par");
expect(mismatch.busy).toBe(true); expect(mismatch.busy).toBe(true);
}); });
it("tool_execution_start without toolName maps to empty name in deriveTasks", () => { it("tool_execution_start without toolName maps to empty name in deriveTasks", () => {
const { workingTools } = deriveTasks([ev({ type: "tool_execution_start", toolCallId: "c" })]); const { workingTools } = deriveTasks([
expect(workingTools).toHaveLength(1); ev({ type: "tool_execution_start", toolCallId: "c" }),
expect(workingTools[0]?.name).toBe(""); ]);
}); expect(workingTools).toHaveLength(1);
expect(workingTools[0]?.name).toBe("");
});
it("tool_execution_end without isError/resultPreview finishes the tool cleanly", () => { it("tool_execution_end without isError/resultPreview finishes the tool cleanly", () => {
const { workingTools } = deriveTasks([ const { workingTools } = deriveTasks([
toolStart("c1", "bash"), toolStart("c1", "bash"),
ev({ type: "tool_execution_end", toolCallId: "c1" }), ev({ type: "tool_execution_end", toolCallId: "c1" }),
]); ]);
expect(workingTools).toHaveLength(0); expect(workingTools).toHaveLength(0);
const chat = deriveChat([toolStart("c1", "bash"), ev({ type: "tool_execution_end", toolCallId: "c1" })]); const chat = deriveChat([
expect(chat.tools.get("c1")).toMatchObject({ running: false, isError: false, preview: "" }); 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", () => { it("snapshot from literal null args is ignored", () => {
const { todos } = deriveTasks([toolStart("c1", "todo", "null")]); const { todos } = deriveTasks([toolStart("c1", "todo", "null")]);
expect(todos).toHaveLength(0); expect(todos).toHaveLength(0);
}); });
it("content fallback: null content falls through to title", () => { it("content fallback: null content falls through to title", () => {
const { todos } = deriveTasks([ const { todos } = deriveTasks([
toolStart("c1", "todo", todoSnap([{ content: null, title: "t1" }])), toolStart("c1", "todo", todoSnap([{ content: null, title: "t1" }])),
]); ]);
expect(todos.map((t) => t.content)).toEqual(["t1"]); expect(todos.map((t) => t.content)).toEqual(["t1"]);
}); });
it("optional-field null sides", () => { it("optional-field null sides", () => {
// tool_execution_end without toolCallId: deriveChat ternary else // tool_execution_end without toolCallId: deriveChat ternary else
expect(() => deriveChat([ev({ type: "tool_execution_end" })])).not.toThrow(); expect(() =>
deriveChat([ev({ type: "tool_execution_end" })]),
).not.toThrow();
// message without toolCalls: ?? [] fallback // message without toolCalls: ?? [] fallback
const bare = deriveChat([ const bare = deriveChat([
ev({ ev({
type: "message_end", type: "message_end",
message: { role: "assistant", id: "a", text: "x", thinking: null, toolCallId: null } as Message, message: {
}), role: "assistant",
]); id: "a",
expect(bare.messages[0]?.toolCalls).toEqual([]); text: "x",
thinking: null,
toolCallId: null,
} as Message,
}),
]);
expect(bare.messages[0]?.toolCalls).toEqual([]);
// subagent end without isError: ?? false // subagent end without isError: ?? false
const sa = deriveTasks([ const sa = deriveTasks([
toolStart("c1", "subagent", JSON.stringify({ agentName: "a" })), toolStart("c1", "subagent", JSON.stringify({ agentName: "a" })),
ev({ type: "tool_execution_end", toolCallId: "c1" }), ev({ type: "tool_execution_end", toolCallId: "c1" }),
]); ]);
expect(sa.subagents[0]).toMatchObject({ running: false, isError: false }); expect(sa.subagents[0]).toMatchObject({ running: false, isError: false });
// toolResult message for a call id never seen: name lookup ?? "" // toolResult message for a call id never seen: name lookup ?? ""
const orphan = deriveTasks([ const orphan = deriveTasks([
ev({ ev({
type: "message_end", type: "message_end",
message: msg({ id: "t", role: "toolResult", text: todoSnap([{ content: "z" }]), toolCallId: "ghost" }), message: msg({
}), id: "t",
]); role: "toolResult",
expect(orphan.todos).toHaveLength(0); text: todoSnap([{ content: "z" }]),
}); toolCallId: "ghost",
}),
}),
]);
expect(orphan.todos).toHaveLength(0);
});
}); });
+25 -23
View File
@@ -2,40 +2,42 @@ import { describe, expect, it, vi } from "vitest";
import type { ReactElement } from "react"; import type { ReactElement } from "react";
const mocks = vi.hoisted(() => ({ const mocks = vi.hoisted(() => ({
render: vi.fn(), render: vi.fn(),
createRoot: vi.fn(() => ({ render: mocks.render })), createRoot: vi.fn(() => ({ render: mocks.render })),
})); }));
vi.mock("react-dom/client", () => ({ createRoot: mocks.createRoot })); vi.mock("react-dom/client", () => ({ createRoot: mocks.createRoot }));
vi.mock("./App", () => ({ default: (): null => null })); vi.mock("./App", () => ({ default: (): null => null }));
vi.mock("./index.css", () => ({})); vi.mock("./index.css", () => ({}));
async function freshImport(): Promise<void> { async function freshImport(): Promise<void> {
vi.resetModules(); vi.resetModules();
await import("./main"); await import("./main");
} }
function rootElement(): HTMLElement { function rootElement(): HTMLElement {
const el = document.createElement("div"); const el = document.createElement("div");
el.id = "root"; el.id = "root";
document.body.appendChild(el); document.body.appendChild(el);
return el; return el;
} }
describe("main", () => { describe("main", () => {
it("renders the app into #root", async () => { it("renders the app into #root", async () => {
const el = rootElement(); const el = rootElement();
await freshImport(); await freshImport();
expect(mocks.createRoot).toHaveBeenCalledWith(el); expect(mocks.createRoot).toHaveBeenCalledWith(el);
expect(mocks.render).toHaveBeenCalledTimes(1); expect(mocks.render).toHaveBeenCalledTimes(1);
const tree = mocks.render.mock.calls[0]?.[0] as { props: { children: ReactElement } }; const tree = mocks.render.mock.calls[0]?.[0] as {
expect(tree.props.children).not.toBeNull(); props: { children: ReactElement };
el.remove(); };
mocks.createRoot.mockClear(); expect(tree.props.children).not.toBeNull();
mocks.render.mockClear(); el.remove();
}); mocks.createRoot.mockClear();
mocks.render.mockClear();
});
it("throws when #root is missing", async () => { it("throws when #root is missing", async () => {
await expect(freshImport()).rejects.toThrow("#root missing in index.html"); await expect(freshImport()).rejects.toThrow("#root missing in index.html");
expect(mocks.createRoot).not.toHaveBeenCalled(); expect(mocks.createRoot).not.toHaveBeenCalled();
}); });
}); });
+171 -126
View File
@@ -2,167 +2,212 @@ import { act, renderHook, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getSettings, saveSettings } from "./settings"; import { getSettings, saveSettings } from "./settings";
import { classNames, relativeTime, useSessions, useToasts } from "./store"; 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"; import type { EventFrame } from "./protocol";
const NOW = new Date("2024-05-01T12:00:00Z").getTime(); const NOW = new Date("2024-05-01T12:00:00Z").getTime();
describe("relativeTime", () => { describe("relativeTime", () => {
beforeEach(() => { beforeEach(() => {
vi.useFakeTimers(); vi.useFakeTimers();
vi.setSystemTime(NOW); vi.setSystemTime(NOW);
}); });
afterEach(() => { afterEach(() => {
vi.useRealTimers(); vi.useRealTimers();
}); });
it("formats all buckets", () => { it("formats all buckets", () => {
expect(relativeTime(null)).toBe("never"); expect(relativeTime(null)).toBe("never");
expect(relativeTime(NOW - 10_000)).toBe("just now"); expect(relativeTime(NOW - 10_000)).toBe("just now");
expect(relativeTime(NOW + 30_000)).toBe("just now"); expect(relativeTime(NOW + 30_000)).toBe("just now");
expect(relativeTime(NOW - 5 * 60_000)).toBe("5m ago"); expect(relativeTime(NOW - 5 * 60_000)).toBe("5m ago");
expect(relativeTime(NOW - 3 * 3_600_000)).toBe("3h ago"); expect(relativeTime(NOW - 3 * 3_600_000)).toBe("3h ago");
expect(relativeTime(NOW - 2 * 86_400_000)).toBe("2d ago"); expect(relativeTime(NOW - 2 * 86_400_000)).toBe("2d ago");
}); });
}); });
describe("classNames", () => { describe("classNames", () => {
it("joins truthy parts", () => { it("joins truthy parts", () => {
expect(classNames("a", false, "b", null, undefined, "c")).toBe("a b c"); expect(classNames("a", false, "b", null, undefined, "c")).toBe("a b c");
expect(classNames()).toBe(""); expect(classNames()).toBe("");
}); });
}); });
describe("useToasts", () => { describe("useToasts", () => {
beforeEach(() => { beforeEach(() => {
vi.useFakeTimers(); vi.useFakeTimers();
}); });
afterEach(() => { afterEach(() => {
vi.useRealTimers(); vi.useRealTimers();
}); });
it("pushes a toast and removes it after TTL", () => { it("pushes a toast and removes it after TTL", () => {
const { result } = renderHook(() => useToasts()); const { result } = renderHook(() => useToasts());
act(() => result.current.push("hello")); act(() => result.current.push("hello"));
expect(result.current.toasts.map((t) => t.text)).toEqual(["hello"]); expect(result.current.toasts.map((t) => t.text)).toEqual(["hello"]);
act(() => result.current.push("second")); act(() => result.current.push("second"));
expect(result.current.toasts).toHaveLength(2); expect(result.current.toasts).toHaveLength(2);
act(() => { act(() => {
vi.advanceTimersByTime(4000); vi.advanceTimersByTime(4000);
}); });
expect(result.current.toasts).toHaveLength(0); expect(result.current.toasts).toHaveLength(0);
}); });
}); });
function listEvent(seq: number): EventFrame { 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", () => { describe("useSessions", () => {
it("returns null when unconfigured", () => { it("returns null when unconfigured", () => {
const push = vi.fn(); const push = vi.fn();
const { result } = renderHook(() => useSessions(push)); const { result } = renderHook(() => useSessions(push));
expect(result.current).toBeNull(); expect(result.current).toBeNull();
}); });
it("seeds via REST, updates from session_list and spawn_status frames, refresh works", async () => { it("seeds via REST, updates from session_list and spawn_status frames, refresh works", async () => {
seedSettings(); seedSettings();
const sessions = [{ id: "s1", name: "one", online: true }]; const sessions = [{ id: "s1", name: "one", online: true }];
const fetchMock = mockFetchJson((url) => { const fetchMock = mockFetchJson((url) => {
if (url.includes("/api/sessions")) return url.includes("/events") ? [] : sessions; if (url.includes("/api/sessions"))
return []; return url.includes("/events") ? [] : sessions;
}); return [];
const push = vi.fn(); });
const { result } = renderHook(() => useSessions(push)); const push = vi.fn();
const { result } = renderHook(() => useSessions(push));
expect(result.current?.state).toBe("connecting"); expect(result.current?.state).toBe("connecting");
await waitFor(() => expect(result.current?.sessions).toEqual(sessions)); await waitFor(() => expect(result.current?.sessions).toEqual(sessions));
const sock = FakeWebSocket.last(); const sock = FakeWebSocket.last();
act(() => sock.serverOpen()); act(() => sock.serverOpen());
expect(result.current?.state).toBe("open"); expect(result.current?.state).toBe("open");
act(() => sock.serverMessage({ type: "session_list", sessions: [{ id: "s2", name: "two", online: false }] })); act(() =>
expect(result.current?.sessions.map((s) => s.id)).toEqual(["s2"]); 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" }] })); act(() =>
expect(result.current?.spawnJobs).toEqual([{ repo: "g/p", state: "cloning" }]); 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 act(async () => {
await result.current?.refresh(); await result.current?.refresh();
}); });
expect(fetchMock).toHaveBeenCalled(); expect(fetchMock).toHaveBeenCalled();
}); });
it("refresh failure pushes a toast", async () => { it("refresh failure pushes a toast", async () => {
seedSettings(); seedSettings();
mockFetchJson(() => { mockFetchJson(() => {
throw new Error("network down"); throw new Error("network down");
}); });
const push = vi.fn(); const push = vi.fn();
const { result } = renderHook(() => useSessions(push)); const { result } = renderHook(() => useSessions(push));
await waitFor(() => expect(push).toHaveBeenCalledWith("sessions: network down")); await waitFor(() =>
expect(result.current).not.toBeNull(); expect(push).toHaveBeenCalledWith("sessions: network down"),
}); );
expect(result.current).not.toBeNull();
});
it("subscribe routes events frames for the session and detaches on unsubscribe", async () => { it("subscribe routes events frames for the session and detaches on unsubscribe", async () => {
seedSettings(); seedSettings();
mockFetchJson(() => []); mockFetchJson(() => []);
const push = vi.fn(); const push = vi.fn();
const { result } = renderHook(() => useSessions(push)); const { result } = renderHook(() => useSessions(push));
const sock = FakeWebSocket.last(); const sock = FakeWebSocket.last();
act(() => sock.serverOpen()); act(() => sock.serverOpen());
const seen: EventFrame[][] = []; const seen: EventFrame[][] = [];
let off: (() => void) | undefined; let off: (() => void) | undefined;
act(() => { act(() => {
off = result.current?.subscribe("s1", (events) => seen.push(events)); off = result.current?.subscribe("s1", (events) => seen.push(events));
}); });
expect(sock.sent).toContain(JSON.stringify({ type: "subscribe", sessionId: "s1" })); expect(sock.sent).toContain(
JSON.stringify({ type: "subscribe", sessionId: "s1" }),
);
act(() => sock.serverMessage({ type: "events", sessionId: "s1", after: 0, events: [listEvent(1)] })); act(() =>
act(() => sock.serverMessage({ type: "events", sessionId: "other", after: 0, events: [listEvent(2)] })); sock.serverMessage({
expect(seen).toEqual([[listEvent(1)]]); 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(() => off?.());
act(() => sock.serverMessage({ type: "events", sessionId: "s1", after: 1, events: [listEvent(3)] })); act(() =>
expect(seen).toHaveLength(1); sock.serverMessage({
expect(sock.sent).toContain(JSON.stringify({ type: "unsubscribe", sessionId: "s1" })); 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 () => { it("auth failure clears settings and reloads", async () => {
seedSettings(); seedSettings();
mockFetchJson(() => []); mockFetchJson(() => []);
const loc = stubReload(); const loc = stubReload();
const push = vi.fn(); const push = vi.fn();
const { result } = renderHook(() => useSessions(push)); const { result } = renderHook(() => useSessions(push));
const sock = FakeWebSocket.last(); const sock = FakeWebSocket.last();
act(() => sock.serverOpen()); act(() => sock.serverOpen());
act(() => sock.serverClose(1008)); act(() => sock.serverClose(1008));
expect(loc.reload).toHaveBeenCalled(); expect(loc.reload).toHaveBeenCalled();
expect(getSettings()).toBeNull(); expect(getSettings()).toBeNull();
await waitFor(() => expect(result.current).toBeNull()); await waitFor(() => expect(result.current).toBeNull());
loc.restore(); loc.restore();
}); });
it("unmount closes the manager", async () => { it("unmount closes the manager", async () => {
seedSettings(); seedSettings();
mockFetchJson(() => []); mockFetchJson(() => []);
const push = vi.fn(); const push = vi.fn();
const { unmount } = renderHook(() => useSessions(push)); const { unmount } = renderHook(() => useSessions(push));
await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1)); await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1));
const sock = FakeWebSocket.last(); const sock = FakeWebSocket.last();
unmount(); unmount();
expect(sock.closeCode).toBe(4900); expect(sock.closeCode).toBe(4900);
expect(sock.onclose).toBeNull(); expect(sock.onclose).toBeNull();
}); });
}); });
describe("settings persistence used by the store", () => { describe("settings persistence used by the store", () => {
it("saved settings are visible", () => { it("saved settings are visible", () => {
saveSettings({ serverUrl: "http://a", token: "t" }); saveSettings({ serverUrl: "http://a", token: "t" });
expect(getSettings()?.serverUrl).toBe("http://a"); expect(getSettings()?.serverUrl).toBe("http://a");
}); });
}); });
+102 -83
View File
@@ -1,5 +1,10 @@
import { useCallback, useEffect, useState } from "react"; 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 { Route } from "./protocol";
import { buildWsUrl, getSettings, clearSettings } from "./settings"; import { buildWsUrl, getSettings, clearSettings } from "./settings";
import { createWsManager, type WsManager, type WsState } from "./ws"; 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; const DAY_MS: number = 24 * HOUR_MS;
export function relativeTime(ts: number | null): string { export function relativeTime(ts: number | null): string {
if (ts === null) return "never"; if (ts === null) return "never";
const abs: number = Math.abs(Date.now() - ts); const abs: number = Math.abs(Date.now() - ts);
if (abs < MINUTE_MS) return "just now"; if (abs < MINUTE_MS) return "just now";
if (abs < HOUR_MS) return `${Math.floor(abs / MINUTE_MS)}m ago`; if (abs < HOUR_MS) return `${Math.floor(abs / MINUTE_MS)}m ago`;
if (abs < DAY_MS) return `${Math.floor(abs / HOUR_MS)}h ago`; if (abs < DAY_MS) return `${Math.floor(abs / HOUR_MS)}h ago`;
return `${Math.floor(abs / DAY_MS)}d ago`; return `${Math.floor(abs / DAY_MS)}d ago`;
} }
export function classNames(...parts: Array<string | false | null | undefined>): string { export function classNames(
return parts.filter(Boolean).join(" "); ...parts: Array<string | false | null | undefined>
): string {
return parts.filter(Boolean).join(" ");
} }
// ---------- toasts ---------- // ---------- toasts ----------
export interface Toast { export interface Toast {
id: number; id: number;
text: string; text: string;
} }
let toastSeq: number = 0; let toastSeq: number = 0;
const TOAST_TTL_MS: number = 4000; const TOAST_TTL_MS: number = 4000;
export function useToasts(): { toasts: Toast[]; push: (text: string) => void } { export function useToasts(): { toasts: Toast[]; push: (text: string) => void } {
const [toasts, setToasts] = useState<Toast[]>([]); const [toasts, setToasts] = useState<Toast[]>([]);
const push = useCallback((text: string): void => { const push = useCallback((text: string): void => {
const id: number = ++toastSeq; const id: number = ++toastSeq;
setToasts((prev) => [...prev, { id, text }]); setToasts((prev) => [...prev, { id, text }]);
window.setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), TOAST_TTL_MS); window.setTimeout(
}, []); () => setToasts((prev) => prev.filter((t) => t.id !== id)),
return { toasts, push }; TOAST_TTL_MS,
);
}, []);
return { toasts, push };
} }
// ---------- sessions store (REST seed + WS updates) ---------- // ---------- sessions store (REST seed + WS updates) ----------
export interface SessionsStore { export interface SessionsStore {
sessions: SessionListItem[]; sessions: SessionListItem[];
state: WsState; state: WsState;
spawnJobs: SpawnJob[]; spawnJobs: SpawnJob[];
refresh: () => Promise<void>; refresh: () => Promise<void>;
/** Subscribe to one session's event stream (protocol allows one at a time). */ /** Subscribe to one session's event stream (protocol allows one at a time). */
subscribe: (sessionId: string, onEvents: (events: EventFrame[]) => void) => () => void; subscribe: (
sessionId: string,
onEvents: (events: EventFrame[]) => void,
) => () => void;
} }
export function useSessions(pushToast: (text: string) => void): SessionsStore | null { export function useSessions(
const [sessions, setSessions] = useState<SessionListItem[]>([]); pushToast: (text: string) => void,
const [spawnJobs, setSpawnJobs] = useState<SpawnJob[]>([]); ): SessionsStore | null {
const [state, setState] = useState<WsState>("connecting"); const [sessions, setSessions] = useState<SessionListItem[]>([]);
const [manager, setManager] = useState<WsManager | null>(null); const [spawnJobs, setSpawnJobs] = useState<SpawnJob[]>([]);
const [state, setState] = useState<WsState>("connecting");
const [manager, setManager] = useState<WsManager | null>(null);
useEffect(() => { useEffect(() => {
const settings = getSettings(); const settings = getSettings();
if (settings === null) return; if (settings === null) return;
const m = createWsManager(buildWsUrl(settings)); const m = createWsManager(buildWsUrl(settings));
setManager(m); setManager(m);
const offState = m.onState(setState); const offState = m.onState(setState);
const offFrames = m.onFrame((frame: ServerFrame) => { const offFrames = m.onFrame((frame: ServerFrame) => {
if (frame.type === "session_list") setSessions(frame.sessions); if (frame.type === "session_list") setSessions(frame.sessions);
else if (frame.type === "spawn_status") setSpawnJobs(frame.jobs); else if (frame.type === "spawn_status") setSpawnJobs(frame.jobs);
}); });
m.onAuthError(() => { m.onAuthError(() => {
clearSettings(); clearSettings();
window.location.reload(); window.location.reload();
}); });
const refresh = async (): Promise<void> => { const refresh = async (): Promise<void> => {
try { try {
setSessions(await fetchJson<SessionListItem[]>(Route.Sessions)); setSessions(await fetchJson<SessionListItem[]>(Route.Sessions));
} catch (err) { } catch (err) {
pushToast(`sessions: ${errMessage(err)}`); pushToast(`sessions: ${errMessage(err)}`);
} }
}; };
void refresh(); void refresh();
return () => { return () => {
offState(); offState();
offFrames(); offFrames();
m.close(); m.close();
setManager(null); setManager(null);
}; };
}, [pushToast]); }, [pushToast]);
const refresh = useCallback(async (): Promise<void> => { const refresh = useCallback(async (): Promise<void> => {
try { try {
setSessions(await fetchJson<SessionListItem[]>(Route.Sessions)); setSessions(await fetchJson<SessionListItem[]>(Route.Sessions));
} catch (err) { } catch (err) {
pushToast(`sessions: ${errMessage(err)}`); pushToast(`sessions: ${errMessage(err)}`);
} }
}, [pushToast]); }, [pushToast]);
const subscribe = useCallback( const subscribe = useCallback(
(sessionId: string, onEvents: (events: EventFrame[]) => void): (() => void) => { (
if (manager === null) return () => undefined; sessionId: string,
const off = manager.onFrame((frame: ServerFrame) => { onEvents: (events: EventFrame[]) => void,
if (frame.type === "events" && frame.sessionId === sessionId) onEvents(frame.events); ): (() => void) => {
}); if (manager === null) return () => undefined;
manager.subscribe(sessionId); const off = manager.onFrame((frame: ServerFrame) => {
return () => { if (frame.type === "events" && frame.sessionId === sessionId)
manager.unsubscribe(sessionId); onEvents(frame.events);
off(); });
}; manager.subscribe(sessionId);
}, return () => {
[manager] manager.unsubscribe(sessionId);
); off();
};
},
[manager],
);
// hooks above must all run before any early return: clearing settings // 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 // mid-flight (ws auth failure) must not change the hook order on re-render
if (getSettings() === null) return null; if (getSettings() === null) return null;
return { sessions, state, spawnJobs, refresh, subscribe }; return { sessions, state, spawnJobs, refresh, subscribe };
} }
+119 -96
View File
@@ -7,59 +7,59 @@ import type { MockInstance } from "vitest";
export type WsHandler = ((ev: never) => void) | null; export type WsHandler = ((ev: never) => void) | null;
export class FakeWebSocket { export class FakeWebSocket {
static readonly CONNECTING = 0; static readonly CONNECTING = 0;
static readonly OPEN = 1; static readonly OPEN = 1;
static readonly CLOSING = 2; static readonly CLOSING = 2;
static readonly CLOSED = 3; static readonly CLOSED = 3;
static instances: FakeWebSocket[] = []; static instances: FakeWebSocket[] = [];
readonly url: string; readonly url: string;
readyState: number = FakeWebSocket.CONNECTING; readyState: number = FakeWebSocket.CONNECTING;
closeCode: number | null = null; closeCode: number | null = null;
sent: string[] = []; sent: string[] = [];
onopen: ((ev: Event) => void) | null = null; onopen: ((ev: Event) => void) | null = null;
onmessage: ((ev: MessageEvent) => void) | null = null; onmessage: ((ev: MessageEvent) => void) | null = null;
onclose: ((ev: CloseEvent) => void) | null = null; onclose: ((ev: CloseEvent) => void) | null = null;
onerror: ((ev: Event) => void) | null = null; onerror: ((ev: Event) => void) | null = null;
constructor(url: string) { constructor(url: string) {
this.url = url; this.url = url;
FakeWebSocket.instances.push(this); FakeWebSocket.instances.push(this);
} }
send(data: string): void { send(data: string): void {
this.sent.push(data); this.sent.push(data);
} }
close(code = 1000): void { close(code = 1000): void {
this.readyState = FakeWebSocket.CLOSED; this.readyState = FakeWebSocket.CLOSED;
this.closeCode = code; this.closeCode = code;
} }
// ---- test-side server simulation ---- // ---- test-side server simulation ----
serverOpen(): void { serverOpen(): void {
this.readyState = FakeWebSocket.OPEN; this.readyState = FakeWebSocket.OPEN;
this.onopen?.(new Event("open")); this.onopen?.(new Event("open"));
} }
serverMessage(data: unknown): void { serverMessage(data: unknown): void {
this.onmessage?.({ data: JSON.stringify(data) } as MessageEvent); this.onmessage?.({ data: JSON.stringify(data) } as MessageEvent);
} }
serverClose(code = 1006): void { serverClose(code = 1006): void {
this.readyState = FakeWebSocket.CLOSED; this.readyState = FakeWebSocket.CLOSED;
this.onclose?.({ code } as CloseEvent); this.onclose?.({ code } as CloseEvent);
} }
static reset(): void { static reset(): void {
FakeWebSocket.instances = []; FakeWebSocket.instances = [];
} }
static last(): FakeWebSocket { static last(): FakeWebSocket {
const inst = FakeWebSocket.instances[FakeWebSocket.instances.length - 1]; const inst = FakeWebSocket.instances[FakeWebSocket.instances.length - 1];
if (inst === undefined) throw new Error("no FakeWebSocket instance"); if (inst === undefined) throw new Error("no FakeWebSocket instance");
return inst; return inst;
} }
} }
globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket; globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket;
@@ -68,89 +68,112 @@ window.WebSocket = FakeWebSocket as unknown as typeof WebSocket;
// ---------- fetch mocking helpers ---------- // ---------- fetch mocking helpers ----------
export function jsonResponse(body: unknown, status = 200): Response { export function jsonResponse(body: unknown, status = 200): Response {
return { return {
ok: status >= 200 && status < 300, ok: status >= 200 && status < 300,
status, status,
statusText: "StatusText", statusText: "StatusText",
json: async () => body, json: async () => body,
} as unknown as Response; } as unknown as Response;
} }
type FetchHandler = (url: string, init?: RequestInit) => unknown; type FetchHandler = (url: string, init?: RequestInit) => unknown;
export function mockFetchJson(handler: FetchHandler): MockInstance { export function mockFetchJson(handler: FetchHandler): MockInstance {
return vi.spyOn(globalThis, "fetch").mockImplementation((async (input: RequestInfo | URL, init?: RequestInit) => { return vi.spyOn(globalThis, "fetch").mockImplementation((async (
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; input: RequestInfo | URL,
const out = handler(url, init); init?: RequestInit,
return isResponseLike(out) ? out : jsonResponse(out); ) => {
}) as typeof fetch); 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 { function isResponseLike(value: unknown): value is Response {
return ( return (
value !== null && value !== null &&
typeof value === "object" && typeof value === "object" &&
typeof (value as { json?: unknown }).json === "function" && typeof (value as { json?: unknown }).json === "function" &&
"ok" in (value as object) "ok" in (value as object)
); );
} }
// ---------- settings seed ---------- // ---------- settings seed ----------
export function seedSettings(serverUrl = "http://srv", token = "tok"): void { 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 ---------- // ---------- window.location.reload stub ----------
export function stubReload(): { reload: ReturnType<typeof vi.fn>; restore: () => void } { export function stubReload(): {
const original = window.location; reload: ReturnType<typeof vi.fn>;
const reload = vi.fn(); restore: () => void;
Object.defineProperty(window, "location", { value: { reload }, writable: true, configurable: true }); } {
return { const original = window.location;
reload, const reload = vi.fn();
restore: (): void => { Object.defineProperty(window, "location", {
Object.defineProperty(window, "location", { value: original, writable: true, configurable: true }); 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 // Node's experimental localStorage getter (returns undefined without
// --localstorage-file) shadows jsdom's under vitest; replace it with a plain // --localstorage-file) shadows jsdom's under vitest; replace it with a plain
// in-memory Storage so src modules see a working global. // in-memory Storage so src modules see a working global.
class MemoryStorage implements Storage { class MemoryStorage implements Storage {
private readonly map = new Map<string, string>(); private readonly map = new Map<string, string>();
get length(): number { get length(): number {
return this.map.size; return this.map.size;
} }
key(index: number): string | null { key(index: number): string | null {
return Array.from(this.map.keys())[index] ?? null; return Array.from(this.map.keys())[index] ?? null;
} }
getItem(key: string): string | null { getItem(key: string): string | null {
return this.map.get(key) ?? null; return this.map.get(key) ?? null;
} }
setItem(key: string, value: string): void { setItem(key: string, value: string): void {
this.map.set(String(key), String(value)); this.map.set(String(key), String(value));
} }
removeItem(key: string): void { removeItem(key: string): void {
this.map.delete(String(key)); this.map.delete(String(key));
} }
clear(): void { clear(): void {
this.map.clear(); this.map.clear();
} }
} }
const memoryStorage = new MemoryStorage(); 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 ---------- // ---------- global hygiene ----------
beforeEach(() => { beforeEach(() => {
FakeWebSocket.reset(); FakeWebSocket.reset();
memoryStorage.clear(); memoryStorage.clear();
}); });
afterEach(() => { afterEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
vi.useRealTimers(); vi.useRealTimers();
}); });