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
+2 -1
View File
@@ -22,7 +22,8 @@ export function startFakeGitLab() {
};
if (req.url.startsWith("/api/v1/user/repos")) {
seen.repoAuths.push(auth);
if (auth !== `token ${GOOD_PAT}`) return send(HTTP_UNAUTHORIZED, { message: "401 Unauthorized" });
if (auth !== `token ${GOOD_PAT}`)
return send(HTTP_UNAUTHORIZED, { message: "401 Unauthorized" });
const base = `http://127.0.0.1:${server.address().port}`;
return send(HTTP_OK, [
{
+33 -9
View File
@@ -18,43 +18,67 @@ export async function run(ctx) {
);
r.check(
"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 &&
gitlab.seen.userAuths[0] === `token ${gitlab.pat}`,
JSON.stringify(gitlab.seen.userAuths),
);
const status = await rest(base, token, "/api/gitlab/status");
r.check(
"status shows connected with username + baseUrl",
status.json?.connected === true && status.json?.username === "e2e-user" && status.json?.baseUrl === gitlab.url,
status.json?.connected === true &&
status.json?.username === "e2e-user" &&
status.json?.baseUrl === gitlab.url,
JSON.stringify(status.json),
);
const repos = await rest(base, token, "/api/gitlab/repos");
r.check(
"repos mapped to protocol shape",
Array.isArray(repos.json) && repos.json.length === 2 &&
repos.json.every((p) => typeof p.path === "string" && typeof p.name === "string" && typeof p.namespace === "string" && typeof p.lastActivityAt === "string" && typeof p.webUrl === "string" && typeof p.defaultBranch === "string"),
Array.isArray(repos.json) &&
repos.json.length === 2 &&
repos.json.every(
(p) =>
typeof p.path === "string" &&
typeof p.name === "string" &&
typeof p.namespace === "string" &&
typeof p.lastActivityAt === "string" &&
typeof p.webUrl === "string" &&
typeof p.defaultBranch === "string",
),
JSON.stringify(repos.json ?? null),
);
r.check(
"repos sorted by lastActivityAt desc (daemon-side sort)",
repos.json?.[0]?.path === "lvmh/alpha" && repos.json?.[1]?.path === "lvmh/beta" &&
repos.json?.[0]?.path === "lvmh/alpha" &&
repos.json?.[1]?.path === "lvmh/beta" &&
repos.json?.[0]?.defaultBranch === "trunk",
JSON.stringify((repos.json ?? []).map((p) => p.path)),
);
const leaky = [connect.text, status.text, repos.text].filter((t) => t.includes(gitlab.pat));
r.check("PAT never appears in any daemon response", leaky.length === 0, `leaked in ${leaky.length} response(s)`);
const leaky = [connect.text, status.text, repos.text].filter((t) =>
t.includes(gitlab.pat),
);
r.check(
"PAT never appears in any daemon response",
leaky.length === 0,
`leaked in ${leaky.length} response(s)`,
);
const badConnect = await rest(base, token, "/api/gitlab/connect", {
method: "POST",
body: { token: "glpat-wrong" },
});
r.check("connect with bad PAT → error status", badConnect.status >= 400, `got ${badConnect.status}`);
r.check(
"connect with bad PAT → error status",
badConnect.status >= 400,
`got ${badConnect.status}`,
);
const statusAfter = await rest(base, token, "/api/gitlab/status");
r.check(
"failed connect does not clobber the stored PAT",
statusAfter.json?.connected === true && statusAfter.json?.username === "e2e-user",
statusAfter.json?.connected === true &&
statusAfter.json?.username === "e2e-user",
JSON.stringify(statusAfter.json),
);
}
+88 -18
View File
@@ -1,22 +1,69 @@
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import {
act,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { afterEach, describe, expect, it } from "vitest";
import App from "./App";
import { clearSettings } from "./settings";
import { FakeWebSocket, jsonResponse, mockFetchJson, seedSettings, stubReload } from "./test/setup";
import {
FakeWebSocket,
jsonResponse,
mockFetchJson,
seedSettings,
stubReload,
} from "./test/setup";
import type { SessionListItem } from "./protocol";
const sessions: SessionListItem[] = [
{ id: "s1", name: "alpha", cwd: "/w", model: "glm-5.3", provider: "zai-renaud", agent: false, repo: "g/a", startedAt: 10, online: true, lastEventAt: null },
{ id: "s2", name: null, cwd: "/w", model: "glm-5.3", provider: "zai-renaud", agent: true, repo: "g/b", startedAt: 20, online: false, lastEventAt: null },
{
id: "s1",
name: "alpha",
cwd: "/w",
model: "glm-5.3",
provider: "zai-renaud",
agent: false,
repo: "g/a",
startedAt: 10,
online: true,
lastEventAt: null,
},
{
id: "s2",
name: null,
cwd: "/w",
model: "glm-5.3",
provider: "zai-renaud",
agent: true,
repo: "g/b",
startedAt: 20,
online: false,
lastEventAt: null,
},
// bare session: exercises null-name/null-repo fallbacks
{ id: "s3", name: null, cwd: "/w", model: "glm-5.3", provider: "zai-renaud", agent: false, repo: null, startedAt: 30, online: false, lastEventAt: null },
{
id: "s3",
name: null,
cwd: "/w",
model: "glm-5.3",
provider: "zai-renaud",
agent: false,
repo: null,
startedAt: 30,
online: false,
lastEventAt: null,
},
];
function seedApi(): void {
mockFetchJson((url) => {
if (url.includes("/api/sessions")) return url.includes("/events") ? [] : sessions;
if (url.includes("/api/gitlab/status")) return { connected: false, baseUrl: "https://gl" };
if (url.includes("/api/sessions"))
return url.includes("/events") ? [] : sessions;
if (url.includes("/api/gitlab/status"))
return { connected: false, baseUrl: "https://gl" };
return [];
});
}
@@ -25,7 +72,7 @@ function renderApp(path = "/"): ReturnType<typeof render> {
return render(
<MemoryRouter initialEntries={[path]}>
<App />
</MemoryRouter>
</MemoryRouter>,
);
}
@@ -36,15 +83,23 @@ afterEach(() => {
describe("App gate", () => {
it("shows the settings gate when unconfigured", () => {
renderApp();
expect(screen.getByText("Connect to your lvmh daemon.")).toBeInTheDocument();
expect(
screen.getByText("Connect to your lvmh daemon."),
).toBeInTheDocument();
expect(screen.queryByLabelText("Sessions")).toBeNull();
});
it("after saving settings the shell renders", async () => {
seedApi();
renderApp();
fireEvent.input(screen.getByLabelText("Bearer token"), { target: { value: "tok" } });
fireEvent.submit(screen.getByRole("button", { name: "Connect" }).closest("form") as HTMLFormElement);
fireEvent.input(screen.getByLabelText("Bearer token"), {
target: { value: "tok" },
});
fireEvent.submit(
screen
.getByRole("button", { name: "Connect" })
.closest("form") as HTMLFormElement,
);
await screen.findByLabelText("Sessions");
});
});
@@ -58,15 +113,21 @@ describe("App shell", () => {
await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1));
const sock = FakeWebSocket.last();
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 links = Array.from(sidebar.querySelectorAll("a.session-link")).map((a) => a.getAttribute("href"));
const links = Array.from(sidebar.querySelectorAll("a.session-link")).map(
(a) => a.getAttribute("href"),
);
expect(links).toEqual(["/s/s3", "/s/s2", "/s/s1"]); // all null lastEventAt -> startedAt desc
expect(screen.getByTitle("ws open")).toBeInTheDocument();
// root route lists sessions
expect(screen.getByRole("heading", { name: "Sessions" })).toBeInTheDocument();
expect(
screen.getByRole("heading", { name: "Sessions" }),
).toBeInTheDocument();
expect(screen.getByLabelText("Open session alpha")).toBeInTheDocument();
// spawn link navigates
@@ -75,7 +136,9 @@ describe("App shell", () => {
// unknown route redirects to sessions
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 () => {
@@ -106,7 +169,9 @@ describe("App shell", () => {
fireEvent.click(menu);
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 () => {
@@ -135,13 +200,18 @@ describe("App shell", () => {
act(() => FakeWebSocket.last().serverClose(1008));
expect(loc.reload).toHaveBeenCalled();
await waitFor(() => expect(screen.getByText("Connect to your lvmh daemon.")).toBeInTheDocument());
await waitFor(() =>
expect(
screen.getByText("Connect to your lvmh daemon."),
).toBeInTheDocument(),
);
loc.restore();
});
it("REST seed failure surfaces a toast", async () => {
mockFetchJson((url) => {
if (url.includes("/api/sessions") && !url.includes("/events")) return jsonResponse({ error: "seed fail" }, 500);
if (url.includes("/api/sessions") && !url.includes("/events"))
return jsonResponse({ error: "seed fail" }, 500);
return [];
});
seedSettings();
+151 -28
View File
@@ -30,21 +30,33 @@ const tool = (p: Partial<ToolState>): ToolState => ({
describe("Bubble", () => {
it("renders plain text per role class", () => {
const { container } = render(
<Bubble msg={msg({ role: "user", text: "hi there" })} tools={new Map()} />
<Bubble
msg={msg({ role: "user", text: "hi there" })}
tools={new Map()}
/>,
);
expect(container.querySelector(".bubble-row.user")).not.toBeNull();
expect(container.textContent).toContain("hi there");
});
it("renders empty assistant text as nothing but shows nothing when empty", () => {
const { container } = render(<Bubble msg={msg({ role: "assistant", text: "" })} tools={new Map()} />);
const { container } = render(
<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 () => {
const long = `${"x".repeat(200)}`;
render(<Bubble msg={msg({ role: "toolResult", text: long })} tools={new Map()} />);
const details = screen.getByText("result").closest("details") as HTMLDetailsElement;
render(
<Bubble
msg={msg({ role: "toolResult", text: long })}
tools={new Map()}
/>,
);
const details = screen
.getByText("result")
.closest("details") as HTMLDetailsElement;
expect(details.open).toBe(false);
expect(details.textContent).toContain("…");
await userEvent.click(screen.getByText("result"));
@@ -53,28 +65,59 @@ describe("Bubble", () => {
});
it("toolResult with short text keeps full one-line preview", () => {
render(<Bubble msg={msg({ role: "toolResult", text: "short out" })} tools={new Map()} />);
const details = screen.getByText("result").closest("details") as HTMLDetailsElement;
render(
<Bubble
msg={msg({ role: "toolResult", text: "short out" })}
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", () => {
render(<Bubble msg={msg({ role: "toolResult", text: "a\n\n b c" })} tools={new Map()} />);
render(
<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 () => {
const tools = new Map<string, ToolState>([
["c1", tool({ id: "c1", name: "bash", args: "ls -la", running: false, isError: false, preview: "file" })],
[
"c1",
tool({
id: "c1",
name: "bash",
args: "ls -la",
running: false,
isError: false,
preview: "file",
}),
],
]);
render(
<Bubble msg={msg({ role: "assistant", text: "finished", toolCalls: [{ id: "c1", name: "bash", argsJson: "{}" }] })} tools={tools} />
<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
.getByText("🛠 bash")
.closest("summary") as HTMLElement;
const card = summary.closest("details") as HTMLDetailsElement;
expect(card.open).toBe(false);
await userEvent.click(summary);
@@ -93,10 +136,14 @@ describe("Bubble", () => {
<Bubble
msg={msg({
role: "assistant",
toolCalls: ["c1", "c2", "c3"].map((id) => ({ id, name: `t-${id}`, argsJson: "{}" })),
toolCalls: ["c1", "c2", "c3"].map((id) => ({
id,
name: `t-${id}`,
argsJson: "{}",
})),
})}
tools={tools}
/>
/>,
);
expect(screen.getByText("working…")).toBeInTheDocument();
expect(screen.getByText("error")).toBeInTheDocument();
@@ -105,24 +152,36 @@ describe("Bubble", () => {
it("tool call with no matching state renders no card", () => {
const { container } = render(
<Bubble msg={msg({ role: "assistant", toolCalls: [{ id: "ghost", name: "x", argsJson: "{}" }] })} tools={new Map()} />
<Bubble
msg={msg({
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 () => {
render(<Bubble msg={msg({ thinking: "because" })} tools={new Map()} />);
const details = screen.getByText("thinking").closest("details") as HTMLDetailsElement;
const details = screen
.getByText("thinking")
.closest("details") as HTMLDetailsElement;
await userEvent.click(screen.getByText("thinking"));
expect(details.open).toBe(true);
expect(details.textContent).toContain("because");
const { container } = render(<Bubble msg={msg({ thinking: null })} tools={new Map()} />);
const { container } = render(
<Bubble msg={msg({ thinking: null })} tools={new Map()} />,
);
expect(container.querySelector(".thinking")).toBeNull();
});
it("streaming bubble shows the caret", () => {
const { container } = render(<Bubble msg={msg({ text: "par", streaming: true })} tools={new Map()} />);
const { container } = render(
<Bubble msg={msg({ text: "par", streaming: true })} tools={new Map()} />,
);
expect(container.querySelector(".stream-caret")).not.toBeNull();
});
});
@@ -138,42 +197,106 @@ describe("TypingIndicator", () => {
describe("ChatStream", () => {
it("renders messages and typing indicator while busy with no open stream", () => {
const { container, rerender } = render(
<ChatStream messages={[msg({ key: "a", text: "one" })]} tools={new Map()} busy />
<ChatStream
messages={[msg({ key: "a", text: "one" })]}
tools={new Map()}
busy
/>,
);
expect(container.querySelector(".typing")).not.toBeNull();
rerender(
<ChatStream messages={[msg({ key: "a", text: "one" }), msg({ key: "b", streaming: true })]} tools={new Map()} busy />
<ChatStream
messages={[
msg({ key: "a", text: "one" }),
msg({ key: "b", streaming: true }),
]}
tools={new Map()}
busy
/>,
);
expect(container.querySelector(".typing")).toBeNull();
rerender(<ChatStream messages={[msg({ key: "a", text: "one" })]} tools={new Map()} busy={false} />);
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", () => {
const { container, rerender } = render(<ChatStream messages={[msg({ key: "a" })]} tools={new Map()} busy={false} />);
const { container, rerender } = render(
<ChatStream
messages={[msg({ key: "a" })]}
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 });
Object.defineProperty(scroller, "scrollHeight", {
configurable: true,
value: 1000,
});
Object.defineProperty(scroller, "clientHeight", {
configurable: true,
value: 300,
});
scroller.dispatchEvent(new Event("scroll"));
// pinned: scrollTop at bottom
scroller.scrollTop = 700;
Object.defineProperty(scroller, "scrollTop", { configurable: true, writable: true, value: 700 });
Object.defineProperty(scroller, "scrollTop", {
configurable: true,
writable: true,
value: 700,
});
scroller.dispatchEvent(new Event("scroll"));
rerender(<ChatStream messages={[msg({ key: "a" }), msg({ key: "b" })]} tools={new Map()} busy={false} />);
rerender(
<ChatStream
messages={[msg({ key: "a" }), msg({ key: "b" })]}
tools={new Map()}
busy={false}
/>,
);
expect(scroller.scrollTop).toBe(1000);
// scroll far up -> unpin
Object.defineProperty(scroller, "scrollTop", { configurable: true, writable: true, value: 0 });
Object.defineProperty(scroller, "scrollTop", {
configurable: true,
writable: true,
value: 0,
});
scroller.dispatchEvent(new Event("scroll"));
rerender(<ChatStream messages={[msg({ key: "a" }), msg({ key: "b" }), msg({ key: "c" })]} tools={new Map()} busy={false} />);
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
Object.defineProperty(scroller, "scrollTop", { configurable: true, writable: true, value: 940 });
Object.defineProperty(scroller, "scrollTop", {
configurable: true,
writable: true,
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} />);
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);
});
});
+134 -31
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 { MemoryRouter, Route, Routes } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -35,10 +41,14 @@ function makeStore(over: Partial<SessionsStore> = {}): SessionsStore {
state: "open",
spawnJobs: [],
refresh: async () => undefined,
subscribe: (sessionId: string, onEvents: (events: EventFrame[]) => void): (() => void) => {
subscribe: (
sessionId: string,
onEvents: (events: EventFrame[]) => void,
): (() => void) => {
currentSub = { sessionId, onEvents };
return () => {
if (currentSub !== null && currentSub.sessionId === sessionId) currentSub = null;
if (currentSub !== null && currentSub.sessionId === sessionId)
currentSub = null;
};
},
...over,
@@ -55,14 +65,20 @@ function push(events: EventFrame[]): void {
act(() => currentSub?.onEvents(events));
}
function renderChat(store: SessionsStore, path = "/s/s1"): ReturnType<typeof render> {
function renderChat(
store: SessionsStore,
path = "/s/s1",
): ReturnType<typeof render> {
return render(
<MemoryRouter initialEntries={[path]}>
<Routes>
<Route path="/s/:id" element={<ChatView store={store} pushToast={pushToast} />} />
<Route
path="/s/:id"
element={<ChatView store={store} pushToast={pushToast} />}
/>
<Route path="*" element={<div>OTHER</div>} />
</Routes>
</MemoryRouter>
</MemoryRouter>,
);
}
@@ -71,9 +87,27 @@ const pushToast = vi.fn();
function historyEvents(): EventFrame[] {
seq = 0;
return [
ev("message_end", { message: { role: "user", id: "u1", text: "hello there", thinking: null, toolCalls: [], toolCallId: null } }),
ev("message_end", {
message: {
role: "user",
id: "u1",
text: "hello there",
thinking: null,
toolCalls: [],
toolCallId: null,
},
}),
ev("agent_start"),
ev("message_end", { message: { role: "assistant", id: "a1", text: "hi!", thinking: "hmm", toolCalls: [], toolCallId: null } }),
ev("message_end", {
message: {
role: "assistant",
id: "a1",
text: "hi!",
thinking: "hmm",
toolCalls: [],
toolCallId: null,
},
}),
ev("agent_settled"),
];
}
@@ -92,7 +126,8 @@ afterEach(() => {
describe("ChatView", () => {
it("renders history from REST on mount and subscribes to the WS stream", async () => {
const fetchMock = mockFetchJson((url) => {
if (url.startsWith("http://srv/api/sessions/s1/events")) return historyEvents();
if (url.startsWith("http://srv/api/sessions/s1/events"))
return historyEvents();
return [];
});
const store = makeStore();
@@ -113,10 +148,13 @@ describe("ChatView", () => {
rerender(
<MemoryRouter initialEntries={["/s/s1"]}>
<Routes>
<Route path="/s/:id" element={<ChatView store={store} pushToast={pushToast} />} />
<Route
path="/s/:id"
element={<ChatView store={store} pushToast={pushToast} />}
/>
<Route path="*" element={<div>OTHER</div>} />
</Routes>
</MemoryRouter>
</MemoryRouter>,
);
});
@@ -126,7 +164,16 @@ describe("ChatView", () => {
await waitFor(() => expect(currentSub).not.toBeNull());
push([
ev("message_start", { message: { role: "assistant", id: "a9", text: "", thinking: null, toolCalls: [], toolCallId: null } }),
ev("message_start", {
message: {
role: "assistant",
id: "a9",
text: "",
thinking: null,
toolCalls: [],
toolCallId: null,
},
}),
]);
push([ev("message_update", { delta: "Hel" })]);
push([ev("message_update", { delta: "lo" })]);
@@ -135,7 +182,16 @@ describe("ChatView", () => {
expect(screen.getByLabelText("Abort current run")).toBeInTheDocument();
push([
ev("message_end", { message: { role: "assistant", id: "a9", text: "Hello world", thinking: null, toolCalls: [], toolCallId: null } }),
ev("message_end", {
message: {
role: "assistant",
id: "a9",
text: "Hello world",
thinking: null,
toolCalls: [],
toolCallId: null,
},
}),
ev("agent_settled"),
]);
expect(screen.getByText("Hello world")).toBeInTheDocument();
@@ -167,7 +223,9 @@ describe("ChatView", () => {
const ta = screen.getByLabelText("Message");
await userEvent.type(ta, "go");
await userEvent.click(screen.getByLabelText("Send message"));
await vi.waitFor(() => expect(pushToast).toHaveBeenCalledWith("session offline"));
await vi.waitFor(() =>
expect(pushToast).toHaveBeenCalledWith("session offline"),
);
});
it("non-409 send failure toasts the error message", async () => {
@@ -182,7 +240,9 @@ describe("ChatView", () => {
});
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) =>
init?.method === "POST" ? { ok: true } : [],
);
renderChat(makeStore());
const ta = screen.getByLabelText("Message") as HTMLTextAreaElement;
const send = screen.getByLabelText("Send message") as HTMLButtonElement;
@@ -196,21 +256,29 @@ describe("ChatView", () => {
await userEvent.clear(ta);
fireEvent.keyDown(ta, { key: "Enter" }); // empty draft: send() early-returns
await vi.waitFor(() => {
const post = fetchMock.mock.calls.find((c) => (c[1] as RequestInit | undefined)?.method === "POST");
const post = fetchMock.mock.calls.find(
(c) => (c[1] as RequestInit | undefined)?.method === "POST",
);
expect(post).toBeDefined();
expect((post?.[1] as RequestInit).body).toBe(JSON.stringify({ message: "hello" }));
expect((post?.[1] as RequestInit).body).toBe(
JSON.stringify({ message: "hello" }),
);
});
expect(ta.value).toBe("");
});
it("abort posts to the abort route", async () => {
const fetchMock = mockFetchJson((_url, init) => (init?.method === "POST" ? { ok: true } : []));
const fetchMock = mockFetchJson((_url, init) =>
init?.method === "POST" ? { ok: true } : [],
);
renderChat(makeStore());
push([ev("agent_start")]);
await userEvent.click(screen.getByLabelText("Abort current run"));
await vi.waitFor(() => {
const abortCall = fetchMock.mock.calls.find(
(c) => (c[1] as RequestInit | undefined)?.method === "POST" && String(c[0]).endsWith("/abort")
(c) =>
(c[1] as RequestInit | undefined)?.method === "POST" &&
String(c[0]).endsWith("/abort"),
);
expect(abortCall).toBeDefined();
});
@@ -218,22 +286,28 @@ describe("ChatView", () => {
it("abort failure toasts", async () => {
mockFetchJson((url, init) => {
if (init?.method === "POST" && url.endsWith("/abort")) return jsonResponse({ error: "abort failed" }, 500);
if (init?.method === "POST" && url.endsWith("/abort"))
return jsonResponse({ error: "abort failed" }, 500);
return [];
});
renderChat(makeStore());
push([ev("agent_start")]);
await userEvent.click(screen.getByLabelText("Abort current run"));
await vi.waitFor(() => expect(pushToast).toHaveBeenCalledWith("abort failed"));
await vi.waitFor(() =>
expect(pushToast).toHaveBeenCalledWith("abort failed"),
);
});
it("history load failure shows the error page with a back link", async () => {
mockFetchJson((url) => {
if (url.includes("/events")) return jsonResponse({ error: "db gone" }, 500);
if (url.includes("/events"))
return jsonResponse({ error: "db gone" }, 500);
return [];
});
renderChat(makeStore());
expect(await screen.findByText(/Failed to load history: db gone/)).toBeInTheDocument();
expect(
await screen.findByText(/Failed to load history: db gone/),
).toBeInTheDocument();
expect(screen.getByText("Back to sessions")).toBeInTheDocument();
});
@@ -242,16 +316,31 @@ describe("ChatView", () => {
mockFetchJson((url) => {
const m = /[?&]after=(\d+)/.exec(url);
if (m !== null) after = m[1] ?? "";
if (url.includes("/events")) return [ev("message_end", { message: { role: "user", id: "u2", text: "caught up", thinking: null, toolCalls: [], toolCallId: null } })];
if (url.includes("/events"))
return [
ev("message_end", {
message: {
role: "user",
id: "u2",
text: "caught up",
thinking: null,
toolCalls: [],
toolCallId: null,
},
}),
];
return [];
});
const store = makeStore({ state: "closed" });
const { rerender } = render(
<MemoryRouter initialEntries={["/s/s1"]}>
<Routes>
<Route path="/s/:id" element={<ChatView store={store} pushToast={pushToast} />} />
<Route
path="/s/:id"
element={<ChatView store={store} pushToast={pushToast} />}
/>
</Routes>
</MemoryRouter>
</MemoryRouter>,
);
await screen.findByText("caught up");
expect(after).toBe("0");
@@ -261,9 +350,17 @@ describe("ChatView", () => {
rerender(
<MemoryRouter initialEntries={["/s/s1"]}>
<Routes>
<Route path="/s/:id" element={<ChatView store={makeStore({ state: "open" })} pushToast={pushToast} />} />
<Route
path="/s/:id"
element={
<ChatView
store={makeStore({ state: "open" })}
pushToast={pushToast}
/>
}
/>
</Routes>
</MemoryRouter>
</MemoryRouter>,
);
});
await vi.waitFor(() => expect(after).toBe("1"));
@@ -278,7 +375,10 @@ describe("ChatView", () => {
const toggle = screen.getByLabelText("Toggle task panel");
expect(toggle).toHaveAttribute("aria-expanded", "false");
await userEvent.click(toggle);
expect(screen.getByLabelText("Toggle task panel")).toHaveAttribute("aria-expanded", "true");
expect(screen.getByLabelText("Toggle task panel")).toHaveAttribute(
"aria-expanded",
"true",
);
expect(container.querySelectorAll(".task-panel")).toHaveLength(2); // aside + mobile
expect(screen.getAllByText("No tasks yet.")).toHaveLength(2);
});
@@ -287,10 +387,13 @@ describe("ChatView", () => {
render(
<MemoryRouter initialEntries={["/s/"]}>
<Routes>
<Route path="/s/" element={<ChatView store={makeStore()} pushToast={pushToast} />} />
<Route
path="/s/"
element={<ChatView store={makeStore()} pushToast={pushToast} />}
/>
<Route path="/s/:id" element={<div>CHAT</div>} />
</Routes>
</MemoryRouter>
</MemoryRouter>,
);
expect(screen.getByText("No session selected.")).toBeInTheDocument();
});
+102 -22
View File
@@ -21,11 +21,18 @@ function session(p: Partial<SessionListItem>): SessionListItem {
};
}
function renderView(props: Partial<Parameters<typeof SessionsView>[0]> = {}): ReturnType<typeof render> {
function renderView(
props: Partial<Parameters<typeof SessionsView>[0]> = {},
): ReturnType<typeof render> {
return render(
<MemoryRouter>
<SessionsView sessions={[]} onChanged={() => undefined} pushToast={() => undefined} {...props} />
</MemoryRouter>
<SessionsView
sessions={[]}
onChanged={() => undefined}
pushToast={() => undefined}
{...props}
/>
</MemoryRouter>,
);
}
@@ -44,9 +51,22 @@ describe("SessionsView", () => {
it("renders cards sorted by last activity with fallbacks", () => {
renderView({
sessions: [
session({ id: "a", name: null, repo: "g/p", lastEventAt: 5, startedAt: 1 }),
session({
id: "a",
name: null,
repo: "g/p",
lastEventAt: 5,
startedAt: 1,
}),
session({ id: "b", name: "named", lastEventAt: null, startedAt: 10 }),
session({ id: "c", name: null, repo: null, cwd: "/fallback", startedAt: 100, online: true }),
session({
id: "c",
name: null,
repo: null,
cwd: "/fallback",
startedAt: 100,
online: true,
}),
],
});
const cards = screen.getAllByRole("button", { name: /^Open session/ });
@@ -61,16 +81,30 @@ describe("SessionsView", () => {
});
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({
sessions: [
session({
id: "s1",
name: "named",
repo: "g/p",
lastEventAt: Date.now() - 5000,
agent: true,
}),
],
});
expect(screen.getAllByText("g/p")).toHaveLength(1);
expect(screen.getByText("glm-5.3")).toBeInTheDocument();
expect(screen.getByText("just now")).toBeInTheDocument();
expect(screen.getByText("agent")).toBeInTheDocument();
expect(screen.getByLabelText("Stop container for named")).toBeInTheDocument();
expect(
screen.getByLabelText("Stop container for named"),
).toBeInTheDocument();
});
it("no badge/stop for non-agent sessions", () => {
renderView({ sessions: [session({ id: "s1", name: "local", repo: "g/p" })] });
renderView({
sessions: [session({ id: "s1", name: "local", repo: "g/p" })],
});
expect(screen.queryByText("agent")).toBeNull();
expect(screen.queryByLabelText(/Stop container/)).toBeNull();
});
@@ -80,10 +114,19 @@ describe("SessionsView", () => {
render(
<MemoryRouter initialEntries={["/"]}>
<Routes>
<Route path="/" element={<SessionsView sessions={[session({ id: "s9", name: "kb" })]} onChanged={() => undefined} pushToast={() => undefined} />} />
<Route
path="/"
element={
<SessionsView
sessions={[session({ id: "s9", name: "kb" })]}
onChanged={() => undefined}
pushToast={() => undefined}
/>
}
/>
<Route path="/s/:id" element={<Probe onVisit={probe} />} />
</Routes>
</MemoryRouter>
</MemoryRouter>,
);
const card = screen.getByRole("button", { name: "Open session kb" });
fireEvent.keyDown(card, { key: "Tab" });
@@ -97,12 +140,23 @@ describe("SessionsView", () => {
render(
<MemoryRouter initialEntries={["/"]}>
<Routes>
<Route path="/" element={<SessionsView sessions={[session({ id: "s8" })]} onChanged={() => undefined} pushToast={() => undefined} />} />
<Route
path="/"
element={
<SessionsView
sessions={[session({ id: "s8" })]}
onChanged={() => undefined}
pushToast={() => undefined}
/>
}
/>
<Route path="/s/:id" element={<Probe onVisit={probe} />} />
</Routes>
</MemoryRouter>
</MemoryRouter>,
);
fireEvent.keyDown(screen.getByRole("button", { name: "Open session s8" }), { key: " " });
fireEvent.keyDown(screen.getByRole("button", { name: "Open session s8" }), {
key: " ",
});
expect(probe).toHaveBeenCalledWith("/s/s8");
});
@@ -110,9 +164,15 @@ describe("SessionsView", () => {
seedSettings();
mockFetchJson(() => ({ ok: true }));
const pushToast = vi.fn();
renderView({ sessions: [session({ id: "s1", name: null, agent: true })], pushToast, onChanged: () => undefined });
renderView({
sessions: [session({ id: "s1", name: null, agent: true })],
pushToast,
onChanged: () => undefined,
});
fireEvent.click(screen.getByLabelText("Stop container for s1"));
await vi.waitFor(() => expect(pushToast).toHaveBeenCalledWith("stopped s1"));
await vi.waitFor(() =>
expect(pushToast).toHaveBeenCalledWith("stopped s1"),
);
});
it("stop button deletes container, toasts and refreshes", async () => {
@@ -120,32 +180,52 @@ describe("SessionsView", () => {
const fetchMock = mockFetchJson(() => ({ ok: true }));
const onChanged = vi.fn();
const pushToast = vi.fn();
renderView({ sessions: [session({ id: "s1", name: "worker", agent: true })], onChanged, pushToast });
renderView({
sessions: [session({ id: "s1", name: "worker", agent: true })],
onChanged,
pushToast,
});
fireEvent.click(screen.getByLabelText("Stop container for worker"));
await vi.waitFor(() => {
const call = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
expect(call[0]).toBe("http://srv/api/sessions/s1/container");
expect(call[1].method).toBe("DELETE");
});
await vi.waitFor(() => expect(pushToast).toHaveBeenCalledWith("stopped worker"));
await vi.waitFor(() =>
expect(pushToast).toHaveBeenCalledWith("stopped worker"),
);
expect(onChanged).toHaveBeenCalled();
});
it("stop failure toasts the error", async () => {
seedSettings();
vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse({ error: "nope" }, 500));
vi.spyOn(globalThis, "fetch").mockResolvedValue(
jsonResponse({ error: "nope" }, 500),
);
const pushToast = vi.fn();
renderView({ sessions: [session({ id: "s1", name: "w", agent: true })], pushToast, onChanged: () => undefined });
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"));
await vi.waitFor(() =>
expect(pushToast).toHaveBeenCalledWith("stop failed: nope"),
);
});
it("non-error stop failure path stringifies non-Error throws", async () => {
seedSettings();
vi.spyOn(globalThis, "fetch").mockRejectedValue("plain-string");
const pushToast = vi.fn();
renderView({ sessions: [session({ id: "s1", name: "w", agent: true })], pushToast, onChanged: () => undefined });
renderView({
sessions: [session({ id: "s1", name: "w", agent: true })],
pushToast,
onChanged: () => undefined,
});
fireEvent.click(screen.getByLabelText("Stop container for w"));
await vi.waitFor(() => expect(pushToast).toHaveBeenCalledWith("stop failed: plain-string"));
await vi.waitFor(() =>
expect(pushToast).toHaveBeenCalledWith("stop failed: plain-string"),
);
});
});
+32 -10
View File
@@ -34,20 +34,26 @@ describe("SettingsGate", () => {
});
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
.spyOn(globalThis, "fetch")
.mockResolvedValue(jsonResponse({ error: "unauthorized" }, 401));
setup();
await userEvent.type(screen.getByLabelText("Bearer token"), "bad");
await userEvent.click(screen.getByRole("button", { name: "Connect" }));
expect(screen.getByRole("alert")).toHaveTextContent("connection failed (401)");
expect(screen.getByRole("alert")).toHaveTextContent(
"connection failed (401)",
);
expect(getSettings()).toBeNull();
expect(fetchMock).toHaveBeenCalledWith(
"http://localhost:3000/api/sessions",
expect.objectContaining({ headers: { Authorization: "Bearer bad" } })
expect.objectContaining({ headers: { Authorization: "Bearer bad" } }),
);
});
it("network rejection surfaces the thrown message", async () => {
vi.spyOn(globalThis, "fetch").mockRejectedValue(new TypeError("fetch failed"));
vi.spyOn(globalThis, "fetch").mockRejectedValue(
new TypeError("fetch failed"),
);
setup();
await userEvent.type(screen.getByLabelText("Bearer token"), "tok");
await userEvent.click(screen.getByRole("button", { name: "Connect" }));
@@ -55,15 +61,26 @@ describe("SettingsGate", () => {
});
it("saves normalized url + trimmed token and calls onSaved", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse([]));
const fetchMock = vi
.spyOn(globalThis, "fetch")
.mockResolvedValue(jsonResponse([]));
const onSaved = vi.fn();
render(<SettingsGate onSaved={onSaved} />);
await userEvent.clear(screen.getByLabelText("Server URL"));
await userEvent.type(screen.getByLabelText("Server URL"), "http://daemon:8686///");
await userEvent.type(
screen.getByLabelText("Server URL"),
"http://daemon:8686///",
);
await userEvent.type(screen.getByLabelText("Bearer token"), " tok ");
await userEvent.click(screen.getByRole("button", { name: "Connect" }));
expect(fetchMock).toHaveBeenCalledWith("http://daemon:8686/api/sessions", expect.anything());
expect(getSettings()).toEqual({ serverUrl: "http://daemon:8686", token: "tok" });
expect(fetchMock).toHaveBeenCalledWith(
"http://daemon:8686/api/sessions",
expect.anything(),
);
expect(getSettings()).toEqual({
serverUrl: "http://daemon:8686",
token: "tok",
});
expect(onSaved).toHaveBeenCalledTimes(1);
});
@@ -76,12 +93,17 @@ describe("SettingsGate", () => {
});
it("empty server url falls back to the current origin", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse([]));
const fetchMock = vi
.spyOn(globalThis, "fetch")
.mockResolvedValue(jsonResponse([]));
setup();
await userEvent.clear(screen.getByLabelText("Server URL"));
await userEvent.type(screen.getByLabelText("Bearer token"), "tok");
await userEvent.click(screen.getByRole("button", { name: "Connect" }));
expect(fetchMock).toHaveBeenCalledWith("http://localhost:3000/api/sessions", expect.anything());
expect(fetchMock).toHaveBeenCalledWith(
"http://localhost:3000/api/sessions",
expect.anything(),
);
expect(getSettings()?.serverUrl).toBe("http://localhost:3000");
});
});
+60 -24
View File
@@ -32,7 +32,10 @@ function tree(store: SessionsStore): React.ReactElement {
return (
<MemoryRouter initialEntries={["/new"]}>
<Routes>
<Route path="/new" element={<SpawnView store={store} pushToast={PUSH_TOAST} />} />
<Route
path="/new"
element={<SpawnView store={store} pushToast={PUSH_TOAST} />}
/>
<Route path="/s/:id" element={<div data-testid="chat-route" />} />
</Routes>
</MemoryRouter>
@@ -61,7 +64,8 @@ afterEach(() => {
describe("SpawnView status", () => {
it("shows checking state, then error when gitlab status fails", async () => {
mockFetchJson((url) => {
if (url.includes("/api/gitlab/status")) return jsonResponse({ error: "down" }, 500);
if (url.includes("/api/gitlab/status"))
return jsonResponse({ error: "down" }, 500);
return [];
});
render(tree(makeStore()));
@@ -74,7 +78,8 @@ describe("SpawnView status", () => {
describe("SpawnView connect flow", () => {
it("requires a token", async () => {
mockFetchJson((url) => {
if (url.endsWith("/api/gitlab/status")) return { connected: false, baseUrl: "https://gl" };
if (url.endsWith("/api/gitlab/status"))
return { connected: false, baseUrl: "https://gl" };
return [];
});
render(tree(makeStore()));
@@ -87,19 +92,27 @@ describe("SpawnView connect flow", () => {
it("connects, clears the PAT, loads repos and shows the picker", async () => {
let connected = false;
mockFetchJson((url, init) => {
if (url.endsWith("/api/gitlab/status")) return { connected, baseUrl: "https://gl", username: connected ? "alice" : undefined };
if (url.endsWith("/api/gitlab/status"))
return {
connected,
baseUrl: "https://gl",
username: connected ? "alice" : undefined,
};
if (url.endsWith("/api/gitlab/connect") && init?.method === "POST") {
connected = true;
return { username: "alice" };
}
if (url.endsWith("/api/gitlab/repos")) return [repo("g/one"), repo("g/two")];
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(
"GitLab personal access token",
) as HTMLInputElement;
fireEvent.input(pat, { target: { value: "glpat-x" } });
fireEvent.click(screen.getByRole("button", { name: "Connect" }));
await flush();
@@ -112,13 +125,17 @@ describe("SpawnView connect flow", () => {
it("connect failure shows the error and keeps the gate", async () => {
mockFetchJson((url, init) => {
if (url.endsWith("/api/gitlab/status")) return { connected: false, baseUrl: "https://gl" };
if (url.endsWith("/api/gitlab/connect") && init?.method === "POST") return jsonResponse({ error: "bad pat" }, 401);
if (url.endsWith("/api/gitlab/status"))
return { connected: false, baseUrl: "https://gl" };
if (url.endsWith("/api/gitlab/connect") && init?.method === "POST")
return jsonResponse({ error: "bad pat" }, 401);
return [];
});
render(tree(makeStore()));
await flush();
fireEvent.input(screen.getByLabelText("GitLab personal access token"), { target: { value: "glpat-bad" } });
fireEvent.input(screen.getByLabelText("GitLab personal access token"), {
target: { value: "glpat-bad" },
});
fireEvent.click(screen.getByRole("button", { name: "Connect" }));
await flush();
expect(screen.getByText("bad pat")).toBeInTheDocument();
@@ -127,7 +144,8 @@ describe("SpawnView connect flow", () => {
it("connected on load fetches repos immediately", async () => {
mockFetchJson((url) => {
if (url.endsWith("/api/gitlab/status")) return { connected: true, baseUrl: "https://gl", username: "alice" };
if (url.endsWith("/api/gitlab/status"))
return { connected: true, baseUrl: "https://gl", username: "alice" };
if (url.endsWith("/api/gitlab/repos")) return [repo("g/quick")];
return [];
});
@@ -140,8 +158,10 @@ describe("SpawnView connect flow", () => {
describe("SpawnView repo picker", () => {
function connectedMock(): void {
mockFetchJson((url) => {
if (url.endsWith("/api/gitlab/status")) return { connected: true, baseUrl: "https://gl", username: "alice" };
if (url.endsWith("/api/gitlab/repos")) return [repo("g/alpha"), repo("g/beta", "dev")];
if (url.endsWith("/api/gitlab/status"))
return { connected: true, baseUrl: "https://gl", username: "alice" };
if (url.endsWith("/api/gitlab/repos"))
return [repo("g/alpha"), repo("g/beta", "dev")];
return [];
});
}
@@ -157,7 +177,9 @@ describe("SpawnView repo picker", () => {
expect(screen.queryByText("g/alpha")).toBeNull();
expect(screen.getByText("g/beta")).toBeInTheDocument();
const item = screen.getByText("g/beta").closest(".repo-item") as HTMLElement;
const item = screen
.getByText("g/beta")
.closest(".repo-item") as HTMLElement;
fireEvent.keyDown(item, { key: "Enter" });
expect(screen.getByLabelText("Branch")).toHaveValue("dev");
@@ -187,7 +209,8 @@ describe("SpawnView spawn+poll", () => {
return { sessionId: "new-1", containerId: "abc123def456" };
}
if (url.endsWith("/api/spawn/status")) return [];
if (url.endsWith("/api/gitlab/status")) return { connected: true, baseUrl: "https://gl", username: "alice" };
if (url.endsWith("/api/gitlab/status"))
return { connected: true, baseUrl: "https://gl", username: "alice" };
if (url.endsWith("/api/gitlab/repos")) return [repo("g/proj")];
return [];
});
@@ -199,10 +222,14 @@ describe("SpawnView spawn+poll", () => {
await flush();
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(
JSON.stringify({ repo: "g/proj", branch: "main" }),
);
expect(screen.getByText("Spawning…")).toBeInTheDocument();
expect(screen.getByText(/container abc123def456/)).toBeInTheDocument();
expect(screen.getByText("waiting for session to come online…")).toBeInTheDocument();
expect(
screen.getByText("waiting for session to come online…"),
).toBeInTheDocument();
// first tick: session not online yet
await act(async () => {
@@ -242,7 +269,8 @@ describe("SpawnView spawn+poll", () => {
return { sessionId: "new-2", containerId: "cccccccccccc" };
}
if (url.endsWith("/api/spawn/status")) return [];
if (url.endsWith("/api/gitlab/status")) return { connected: true, baseUrl: "https://gl", username: "alice" };
if (url.endsWith("/api/gitlab/status"))
return { connected: true, baseUrl: "https://gl", username: "alice" };
if (url.endsWith("/api/gitlab/repos")) return [repo("g/blank")];
return [];
});
@@ -254,7 +282,9 @@ describe("SpawnView spawn+poll", () => {
render(tree(failingRefresh));
await flush();
fireEvent.click(screen.getByText("g/blank"));
fireEvent.change(screen.getByLabelText("Branch"), { target: { value: "" } });
fireEvent.change(screen.getByLabelText("Branch"), {
target: { value: "" },
});
fireEvent.click(screen.getByLabelText("Spawn container"));
await flush();
expect(bodies).toEqual([JSON.stringify({ repo: "g/blank" })]);
@@ -268,8 +298,10 @@ describe("SpawnView spawn+poll", () => {
it("spawn POST failure shows the error", async () => {
mockFetchJson((url, init) => {
if (init?.method === "POST" && url.endsWith("/api/spawn")) return jsonResponse({ error: "no docker" }, 500);
if (url.endsWith("/api/gitlab/status")) return { connected: true, baseUrl: "https://gl", username: "alice" };
if (init?.method === "POST" && url.endsWith("/api/spawn"))
return jsonResponse({ error: "no docker" }, 500);
if (url.endsWith("/api/gitlab/status"))
return { connected: true, baseUrl: "https://gl", username: "alice" };
if (url.endsWith("/api/gitlab/repos")) return [repo("g/x")];
return [];
});
@@ -288,9 +320,11 @@ describe("SpawnView spawn+poll", () => {
statusCalls += 1;
return [];
}
if (url.endsWith("/api/gitlab/status")) return { connected: true, baseUrl: "https://gl", username: "alice" };
if (url.endsWith("/api/gitlab/status"))
return { connected: true, baseUrl: "https://gl", username: "alice" };
if (url.endsWith("/api/gitlab/repos")) return [repo("g/slow")];
if (url.endsWith("/api/spawn")) return { sessionId: "slow-1", containerId: "d" };
if (url.endsWith("/api/spawn"))
return { sessionId: "slow-1", containerId: "d" };
return [];
});
const { unmount } = render(tree(makeStore()));
@@ -316,9 +350,11 @@ describe("SpawnView spawn+poll", () => {
it("spawn job line renders from store.spawnJobs", async () => {
mockFetchJson((url, init) => {
if (init?.method === "POST" && url.endsWith("/api/spawn")) return { sessionId: "sj-1", containerId: "cid" };
if (init?.method === "POST" && url.endsWith("/api/spawn"))
return { sessionId: "sj-1", containerId: "cid" };
if (url.endsWith("/api/spawn/status")) return [];
if (url.endsWith("/api/gitlab/status")) return { connected: true, baseUrl: "https://gl", username: "alice" };
if (url.endsWith("/api/gitlab/status"))
return { connected: true, baseUrl: "https://gl", username: "alice" };
if (url.endsWith("/api/gitlab/repos")) return [repo("g/p")];
return [];
});
+38 -7
View File
@@ -22,10 +22,18 @@ describe("TaskPanel", () => {
],
};
const { container } = render(<TaskPanel tasks={tasks} />);
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.pending")?.textContent).toBe(
"",
);
expect(container.querySelector(".todo-icon.in-progress")?.textContent).toBe(
"◺",
);
expect(container.querySelector(".todo-icon.completed")?.textContent).toBe(
"●",
);
const deleted = container.querySelector(
".todo-item.deleted .todo-text",
) as HTMLElement;
expect(deleted).not.toBeNull();
expect(deleted.style.textDecoration).toContain("line-through");
});
@@ -52,7 +60,16 @@ describe("TaskPanel", () => {
it("working tools section", () => {
const tasks: TaskDerivation = {
...base,
workingTools: [{ id: "c1", name: "bash", args: "ls", running: true, isError: false, preview: "" }],
workingTools: [
{
id: "c1",
name: "bash",
args: "ls",
running: true,
isError: false,
preview: "",
},
],
};
render(<TaskPanel tasks={tasks} />);
expect(screen.getByText("Working")).toBeInTheDocument();
@@ -60,11 +77,25 @@ describe("TaskPanel", () => {
});
it("sections appear only when populated", () => {
const { rerender, queryByText } = render(<TaskPanel tasks={{ ...base, todos: [{ content: "t", status: "pending", deleted: false }] }} />);
const { rerender, queryByText } = render(
<TaskPanel
tasks={{
...base,
todos: [{ content: "t", status: "pending", deleted: false }],
}}
/>,
);
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 }] }} />);
rerender(
<TaskPanel
tasks={{
...base,
subagents: [{ key: "a", name: "s", running: false, isError: false }],
}}
/>,
);
expect(queryByText("Tasks")).toBeNull();
expect(queryByText("Subagents")).not.toBeNull();
});
+39 -12
View File
@@ -11,38 +11,61 @@ describe("api", () => {
it("throws 401 when not configured", async () => {
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 () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse([{ id: "s1" }]));
const fetchMock = vi
.spyOn(globalThis, "fetch")
.mockResolvedValue(jsonResponse([{ id: "s1" }]));
const out = await fetchJson<Array<{ id: string }>>("/api/sessions");
expect(out).toEqual([{ id: "s1" }]);
const [input, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
const [input, init] = fetchMock.mock.calls[0] as unknown as [
string,
RequestInit,
];
expect(input).toBe("http://srv/api/sessions");
expect(init.headers).toEqual({ Authorization: "Bearer sekret" });
expect(init.body).toBeUndefined();
});
it("POST sends JSON content-type with body", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse({ ok: true }));
await fetchJson("/api/sessions/s1/prompt", { method: "POST", body: JSON.stringify({ message: "hi" }) });
const fetchMock = vi
.spyOn(globalThis, "fetch")
.mockResolvedValue(jsonResponse({ ok: true }));
await fetchJson("/api/sessions/s1/prompt", {
method: "POST",
body: JSON.stringify({ message: "hi" }),
});
const init = fetchMock.mock.calls[0]?.[1] as RequestInit;
expect(init.method).toBe("POST");
expect(init.headers).toEqual({ Authorization: "Bearer sekret", "Content-Type": "application/json" });
expect(init.headers).toEqual({
Authorization: "Bearer sekret",
"Content-Type": "application/json",
});
});
it("throws ApiError with server error message", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse({ error: "boom" }, 500));
const err: unknown = await fetchJson("/api/sessions").catch((e: unknown) => e);
vi.spyOn(globalThis, "fetch").mockResolvedValue(
jsonResponse({ error: "boom" }, 500),
);
const err: unknown = await fetchJson("/api/sessions").catch(
(e: unknown) => e,
);
expect(err).toBeInstanceOf(ApiError);
expect((err as ApiError).message).toBe("boom");
expect((err as ApiError).status).toBe(500);
});
it("falls back to status text when body has no error string", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse({ other: 1 }, 404));
const err: unknown = await fetchJson("/api/sessions").catch((e: unknown) => e);
vi.spyOn(globalThis, "fetch").mockResolvedValue(
jsonResponse({ other: 1 }, 404),
);
const err: unknown = await fetchJson("/api/sessions").catch(
(e: unknown) => e,
);
expect((err as ApiError).message).toBe("404 StatusText");
});
@@ -55,13 +78,17 @@ describe("api", () => {
throw new SyntaxError("bad json");
},
} as unknown as Response);
const err: unknown = await fetchJson("/api/sessions").catch((e: unknown) => e);
const err: unknown = await fetchJson("/api/sessions").catch(
(e: unknown) => e,
);
expect((err as ApiError).message).toBe("502 Bad Gateway");
});
it("rejects null JSON bodies gracefully in error path", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse(null, 409));
const err: unknown = await fetchJson("/api/sessions").catch((e: unknown) => e);
const err: unknown = await fetchJson("/api/sessions").catch(
(e: unknown) => e,
);
expect((err as ApiError).message).toBe("409 StatusText");
});
+208 -49
View File
@@ -1,11 +1,22 @@
import { describe, expect, it } from "vitest";
import type { EventFrame, Message } from "./protocol";
import { deriveChat, deriveTasks, lastPersistedSeq, mergeEvents } from "./derive";
import {
deriveChat,
deriveTasks,
lastPersistedSeq,
mergeEvents,
} from "./derive";
let seq: number = 0;
function ev(partial: Partial<EventFrame> & { type: string }): EventFrame {
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 {
return {
@@ -19,10 +30,20 @@ function msg(m: Partial<Message>): Message {
};
}
function toolStart(id: string, name: string, args?: string): EventFrame {
return ev({ type: "tool_execution_start", toolCallId: id, toolName: name, args: args ?? "" });
return ev({
type: "tool_execution_start",
toolCallId: id,
toolName: name,
args: args ?? "",
});
}
function toolEnd(id: string, isError?: boolean, preview?: string): EventFrame {
return ev({ type: "tool_execution_end", toolCallId: id, isError: isError ?? false, resultPreview: preview ?? "" });
return ev({
type: "tool_execution_end",
toolCallId: id,
isError: isError ?? false,
resultPreview: preview ?? "",
});
}
// ---------- mergeEvents / lastPersistedSeq ----------
@@ -55,7 +76,9 @@ describe("lastPersistedSeq", () => {
it("returns 0 for empty or delta-only streams", () => {
expect(lastPersistedSeq([])).toBe(0);
expect(lastPersistedSeq([ev({ type: "message_update", delta: "x", seq: 5 })])).toBe(0);
expect(
lastPersistedSeq([ev({ type: "message_update", delta: "x", seq: 5 })]),
).toBe(0);
});
});
@@ -64,13 +87,35 @@ describe("lastPersistedSeq", () => {
describe("deriveChat", () => {
it("message_end renders user/assistant/system/toolResult messages", () => {
const events = [
ev({ type: "message_end", message: msg({ id: "u1", role: "user", text: "hi" }) }),
ev({ type: "message_end", message: msg({ id: "a1", role: "assistant", text: "hello" }) }),
ev({ type: "message_end", message: msg({ id: "t1", role: "toolResult", text: "out", toolCallId: "c1" }) }),
ev({ type: "message_end", message: msg({ id: "s1", role: "system", text: "sys" }) }),
ev({
type: "message_end",
message: msg({ id: "u1", role: "user", text: "hi" }),
}),
ev({
type: "message_end",
message: msg({ id: "a1", role: "assistant", text: "hello" }),
}),
ev({
type: "message_end",
message: msg({
id: "t1",
role: "toolResult",
text: "out",
toolCallId: "c1",
}),
}),
ev({
type: "message_end",
message: msg({ id: "s1", role: "system", text: "sys" }),
}),
];
const { messages } = deriveChat(events);
expect(messages.map((m) => m.role)).toEqual(["user", "assistant", "toolResult", "system"]);
expect(messages.map((m) => m.role)).toEqual([
"user",
"assistant",
"toolResult",
"system",
]);
expect(messages[1]?.text).toBe("hello");
expect(messages.every((m) => m.streaming === false)).toBe(true);
});
@@ -84,27 +129,42 @@ describe("deriveChat", () => {
];
let chat = deriveChat(events);
expect(chat.messages).toHaveLength(1);
expect(chat.messages[0]).toMatchObject({ role: "assistant", text: "Hello", streaming: true });
expect(chat.messages[0]).toMatchObject({
role: "assistant",
text: "Hello",
streaming: true,
});
expect(chat.busy).toBe(true);
chat = deriveChat([
...events,
ev({ type: "message_end", message: msg({ id, role: "assistant", text: "Hello world" }) }),
ev({
type: "message_end",
message: msg({ id, role: "assistant", text: "Hello world" }),
}),
]);
expect(chat.messages).toHaveLength(1);
expect(chat.messages[0]).toMatchObject({ text: "Hello world", streaming: false });
expect(chat.messages[0]).toMatchObject({
text: "Hello world",
streaming: false,
});
expect(chat.busy).toBe(false);
});
it("non-assistant message_start does not open a stream", () => {
const chat = deriveChat([ev({ type: "message_start", message: msg({ id: "u1", role: "user" }) })]);
const chat = deriveChat([
ev({ type: "message_start", message: msg({ id: "u1", role: "user" }) }),
]);
expect(chat.messages).toHaveLength(0);
expect(chat.busy).toBe(false);
});
it("missing delta does not append 'undefined'", () => {
const chat = deriveChat([
ev({ type: "message_start", message: msg({ id: "a", role: "assistant" }) }),
ev({
type: "message_start",
message: msg({ id: "a", role: "assistant" }),
}),
ev({ type: "message_update" }),
]);
expect(chat.messages[0]?.text).toBe("");
@@ -112,14 +172,23 @@ describe("deriveChat", () => {
it("agent_start/agent_settled drive busy", () => {
expect(deriveChat([ev({ type: "agent_start" })]).busy).toBe(true);
expect(deriveChat([ev({ type: "agent_start" }), ev({ type: "agent_settled" })]).busy).toBe(false);
expect(
deriveChat([ev({ type: "agent_start" }), ev({ type: "agent_settled" })])
.busy,
).toBe(false);
});
it("message_start for a new assistant id drops the old stream", () => {
const chat = deriveChat([
ev({ type: "message_start", message: msg({ id: "a1", role: "assistant" }) }),
ev({
type: "message_start",
message: msg({ id: "a1", role: "assistant" }),
}),
ev({ type: "message_update", delta: "x" }),
ev({ type: "message_start", message: msg({ id: "a2", role: "assistant" }) }),
ev({
type: "message_start",
message: msg({ id: "a2", role: "assistant" }),
}),
ev({ type: "message_update", delta: "y" }),
]);
expect(chat.messages).toHaveLength(1);
@@ -139,14 +208,28 @@ describe("deriveChat", () => {
toolEnd("c1", true, "boom"),
toolEnd("c2", false, "ok"),
]);
expect(chat.tools.get("c1")).toMatchObject({ name: "bash", args: "ls", running: false, isError: true, preview: "boom" });
expect(chat.tools.get("c2")).toMatchObject({ running: false, isError: false, preview: "ok" });
expect(chat.tools.get("c1")).toMatchObject({
name: "bash",
args: "ls",
running: false,
isError: true,
preview: "boom",
});
expect(chat.tools.get("c2")).toMatchObject({
running: false,
isError: false,
preview: "ok",
});
// defaults
const chat2 = deriveChat([
ev({ type: "tool_execution_start", toolCallId: "c3", toolName: "t" }),
ev({ type: "tool_execution_end", toolCallId: "c3" }),
]);
expect(chat2.tools.get("c3")).toMatchObject({ running: false, isError: false, preview: "" });
expect(chat2.tools.get("c3")).toMatchObject({
running: false,
isError: false,
preview: "",
});
});
it("tool_execution_start without id and end for unknown id are ignored", () => {
@@ -158,7 +241,9 @@ describe("deriveChat", () => {
});
it("tool execution defaults missing names/args", () => {
const chat = deriveChat([ev({ type: "tool_execution_start", toolCallId: "c" })]);
const chat = deriveChat([
ev({ type: "tool_execution_start", toolCallId: "c" }),
]);
expect(chat.tools.get("c")).toMatchObject({ name: "tool", args: "" });
});
});
@@ -173,8 +258,16 @@ describe("deriveTasks todos", () => {
it("latest snapshot wins; statuses from latest; deletion marks earlier items", () => {
const events = [
toolStart("c1", "todo", todoSnap([{ content: "a" }, { content: "b" }])),
toolEnd("c1", false, todoSnap([{ content: "a", status: "in-progress" }, { content: "b" }])),
toolStart("c2", "todo", todoSnap([{ content: "b", status: "completed" }])),
toolEnd(
"c1",
false,
todoSnap([{ content: "a", status: "in-progress" }, { content: "b" }]),
),
toolStart(
"c2",
"todo",
todoSnap([{ content: "b", status: "completed" }]),
),
];
const { todos } = deriveTasks(events);
expect(todos).toEqual([
@@ -199,8 +292,13 @@ describe("deriveTasks todos", () => {
];
for (const [raw, expected] of aliases) {
seq = 0;
const events = [toolStart("c1", "todo", todoSnap([{ content: "t", status: raw }]))];
expect(deriveTasks(events).todos[0]?.status, `status ${String(raw)}`).toBe(expected);
const events = [
toolStart("c1", "todo", todoSnap([{ content: "t", status: raw }])),
];
expect(
deriveTasks(events).todos[0]?.status,
`status ${String(raw)}`,
).toBe(expected);
}
});
@@ -210,19 +308,25 @@ describe("deriveTasks todos", () => {
toolEnd("c1", false, todoSnap([{ content: "from-preview" }])),
ev({
type: "message_end",
message: msg({ id: "t1", role: "toolResult", text: todoSnap([{ content: "from-msg" }]), toolCallId: "c2" }),
message: msg({
id: "t1",
role: "toolResult",
text: todoSnap([{ content: "from-msg" }]),
toolCallId: "c2",
}),
}),
];
// c2 has no tool_execution_start before its toolResult message; the second
// pass only harvests toolResult text for known todo calls
const eventsKnown = [
...events,
toolStart("c2", "todo"),
];
const eventsKnown = [...events, toolStart("c2", "todo")];
const { todos } = deriveTasks(eventsKnown);
// latest snapshot (highest seq) is the toolResult message; earlier-only
// items survive as deleted markers
expect(todos.map((t) => t.content)).toEqual(["from-msg", "from-args", "from-preview"]);
expect(todos.map((t) => t.content)).toEqual([
"from-msg",
"from-args",
"from-preview",
]);
expect(todos.map((t) => t.deleted)).toEqual([false, true, true]);
});
@@ -231,15 +335,27 @@ describe("deriveTasks todos", () => {
toolStart("c1", "todo", JSON.stringify({ todos: [{ content: "w" }] })),
]);
expect(wrapped.todos.map((t) => t.content)).toEqual(["w"]);
const nestedItems = deriveTasks([toolStart("c1", "todo", JSON.stringify({ items: ["plain string"] }))]);
expect(nestedItems.todos).toEqual([{ content: "plain string", status: "pending", deleted: false }]);
const nestedTasks = deriveTasks([toolStart("c1", "todo", JSON.stringify({ tasks: [{ title: "tt" }] }))]);
const nestedItems = deriveTasks([
toolStart("c1", "todo", JSON.stringify({ items: ["plain string"] })),
]);
expect(nestedItems.todos).toEqual([
{ content: "plain string", status: "pending", deleted: false },
]);
const nestedTasks = deriveTasks([
toolStart("c1", "todo", JSON.stringify({ tasks: [{ title: "tt" }] })),
]);
expect(nestedTasks.todos.map((t) => t.content)).toEqual(["tt"]);
const nestedList = deriveTasks([toolStart("c1", "todo", JSON.stringify({ list: [{ text: "tx" }] }))]);
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" }]))]);
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" }]))]);
const summary = deriveTasks([
toolStart("c1", "todo", JSON.stringify([{ summary: "sm" }])),
]);
expect(summary.todos.map((t) => t.content)).toEqual(["sm"]);
});
@@ -278,7 +394,15 @@ describe("deriveTasks subagents", () => {
toolStart("c7", "subagent", "not json"),
];
const { subagents } = deriveTasks(events);
expect(subagents.map((s) => s.name)).toEqual(["scout", "worker", "planner", "reviewer", "oracle", "subagent", "subagent"]);
expect(subagents.map((s) => s.name)).toEqual([
"scout",
"worker",
"planner",
"reviewer",
"oracle",
"subagent",
"subagent",
]);
expect(subagents.every((s) => s.running && !s.isError)).toBe(true);
});
@@ -290,8 +414,14 @@ describe("deriveTasks subagents", () => {
toolEnd("c2", true),
];
const { subagents } = deriveTasks(events);
expect(subagents.find((s) => s.key === "c1")).toMatchObject({ running: false, isError: false });
expect(subagents.find((s) => s.key === "c2")).toMatchObject({ running: false, isError: true });
expect(subagents.find((s) => s.key === "c1")).toMatchObject({
running: false,
isError: false,
});
expect(subagents.find((s) => s.key === "c2")).toMatchObject({
running: false,
isError: true,
});
});
it("end for unknown subagent id ignored", () => {
@@ -316,7 +446,11 @@ describe("deriveTasks workingTools", () => {
];
const { workingTools } = deriveTasks(events);
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", () => {
@@ -346,7 +480,10 @@ describe("deriveChat/deriveTasks edge branches", () => {
const withTools = deriveChat([
ev({
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);
@@ -363,7 +500,9 @@ describe("deriveChat/deriveTasks edge branches", () => {
});
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([
ev({ type: "tool_execution_start", toolCallId: "c" }),
]);
expect(workingTools).toHaveLength(1);
expect(workingTools[0]?.name).toBe("");
});
@@ -374,8 +513,15 @@ describe("deriveChat/deriveTasks edge branches", () => {
ev({ type: "tool_execution_end", toolCallId: "c1" }),
]);
expect(workingTools).toHaveLength(0);
const chat = deriveChat([toolStart("c1", "bash"), ev({ type: "tool_execution_end", toolCallId: "c1" })]);
expect(chat.tools.get("c1")).toMatchObject({ running: false, isError: false, preview: "" });
const chat = deriveChat([
toolStart("c1", "bash"),
ev({ type: "tool_execution_end", toolCallId: "c1" }),
]);
expect(chat.tools.get("c1")).toMatchObject({
running: false,
isError: false,
preview: "",
});
});
it("snapshot from literal null args is ignored", () => {
@@ -392,13 +538,21 @@ describe("deriveChat/deriveTasks edge branches", () => {
it("optional-field null sides", () => {
// 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
const bare = deriveChat([
ev({
type: "message_end",
message: { role: "assistant", id: "a", text: "x", thinking: null, toolCallId: null } as Message,
message: {
role: "assistant",
id: "a",
text: "x",
thinking: null,
toolCallId: null,
} as Message,
}),
]);
expect(bare.messages[0]?.toolCalls).toEqual([]);
@@ -414,7 +568,12 @@ describe("deriveChat/deriveTasks edge branches", () => {
const orphan = deriveTasks([
ev({
type: "message_end",
message: msg({ id: "t", role: "toolResult", text: todoSnap([{ content: "z" }]), toolCallId: "ghost" }),
message: msg({
id: "t",
role: "toolResult",
text: todoSnap([{ content: "z" }]),
toolCallId: "ghost",
}),
}),
]);
expect(orphan.todos).toHaveLength(0);
+3 -1
View File
@@ -27,7 +27,9 @@ describe("main", () => {
await freshImport();
expect(mocks.createRoot).toHaveBeenCalledWith(el);
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 {
props: { children: ReactElement };
};
expect(tree.props.children).not.toBeNull();
el.remove();
mocks.createRoot.mockClear();
+56 -11
View File
@@ -2,7 +2,12 @@ import { act, renderHook, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getSettings, saveSettings } from "./settings";
import { classNames, relativeTime, useSessions, useToasts } from "./store";
import { FakeWebSocket, mockFetchJson, seedSettings, stubReload } from "./test/setup";
import {
FakeWebSocket,
mockFetchJson,
seedSettings,
stubReload,
} from "./test/setup";
import type { EventFrame } from "./protocol";
const NOW = new Date("2024-05-01T12:00:00Z").getTime();
@@ -71,7 +76,8 @@ describe("useSessions", () => {
seedSettings();
const sessions = [{ id: "s1", name: "one", online: true }];
const fetchMock = mockFetchJson((url) => {
if (url.includes("/api/sessions")) return url.includes("/events") ? [] : sessions;
if (url.includes("/api/sessions"))
return url.includes("/events") ? [] : sessions;
return [];
});
const push = vi.fn();
@@ -84,11 +90,23 @@ describe("useSessions", () => {
act(() => sock.serverOpen());
expect(result.current?.state).toBe("open");
act(() => sock.serverMessage({ type: "session_list", sessions: [{ id: "s2", name: "two", online: false }] }));
act(() =>
sock.serverMessage({
type: "session_list",
sessions: [{ id: "s2", name: "two", online: false }],
}),
);
expect(result.current?.sessions.map((s) => s.id)).toEqual(["s2"]);
act(() => sock.serverMessage({ type: "spawn_status", jobs: [{ repo: "g/p", state: "cloning" }] }));
expect(result.current?.spawnJobs).toEqual([{ repo: "g/p", state: "cloning" }]);
act(() =>
sock.serverMessage({
type: "spawn_status",
jobs: [{ repo: "g/p", state: "cloning" }],
}),
);
expect(result.current?.spawnJobs).toEqual([
{ repo: "g/p", state: "cloning" },
]);
await act(async () => {
await result.current?.refresh();
@@ -103,7 +121,9 @@ describe("useSessions", () => {
});
const push = vi.fn();
const { result } = renderHook(() => useSessions(push));
await waitFor(() => expect(push).toHaveBeenCalledWith("sessions: network down"));
await waitFor(() =>
expect(push).toHaveBeenCalledWith("sessions: network down"),
);
expect(result.current).not.toBeNull();
});
@@ -120,16 +140,41 @@ describe("useSessions", () => {
act(() => {
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(() => sock.serverMessage({ type: "events", sessionId: "other", after: 0, events: [listEvent(2)] }));
act(() =>
sock.serverMessage({
type: "events",
sessionId: "s1",
after: 0,
events: [listEvent(1)],
}),
);
act(() =>
sock.serverMessage({
type: "events",
sessionId: "other",
after: 0,
events: [listEvent(2)],
}),
);
expect(seen).toEqual([[listEvent(1)]]);
act(() => off?.());
act(() => sock.serverMessage({ type: "events", sessionId: "s1", after: 1, events: [listEvent(3)] }));
act(() =>
sock.serverMessage({
type: "events",
sessionId: "s1",
after: 1,
events: [listEvent(3)],
}),
);
expect(seen).toHaveLength(1);
expect(sock.sent).toContain(JSON.stringify({ type: "unsubscribe", sessionId: "s1" }));
expect(sock.sent).toContain(
JSON.stringify({ type: "unsubscribe", sessionId: "s1" }),
);
});
it("auth failure clears settings and reloads", async () => {
+27 -8
View File
@@ -1,5 +1,10 @@
import { useCallback, useEffect, useState } from "react";
import type { EventFrame, ServerFrame, SessionListItem, SpawnJob } from "./protocol";
import type {
EventFrame,
ServerFrame,
SessionListItem,
SpawnJob,
} from "./protocol";
import { Route } from "./protocol";
import { buildWsUrl, getSettings, clearSettings } from "./settings";
import { createWsManager, type WsManager, type WsState } from "./ws";
@@ -20,7 +25,9 @@ export function relativeTime(ts: number | null): string {
return `${Math.floor(abs / DAY_MS)}d ago`;
}
export function classNames(...parts: Array<string | false | null | undefined>): string {
export function classNames(
...parts: Array<string | false | null | undefined>
): string {
return parts.filter(Boolean).join(" ");
}
@@ -39,7 +46,10 @@ export function useToasts(): { toasts: Toast[]; push: (text: string) => void } {
const push = useCallback((text: string): void => {
const id: number = ++toastSeq;
setToasts((prev) => [...prev, { id, text }]);
window.setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), TOAST_TTL_MS);
window.setTimeout(
() => setToasts((prev) => prev.filter((t) => t.id !== id)),
TOAST_TTL_MS,
);
}, []);
return { toasts, push };
}
@@ -52,10 +62,15 @@ export interface SessionsStore {
spawnJobs: SpawnJob[];
refresh: () => Promise<void>;
/** 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(
pushToast: (text: string) => void,
): SessionsStore | null {
const [sessions, setSessions] = useState<SessionListItem[]>([]);
const [spawnJobs, setSpawnJobs] = useState<SpawnJob[]>([]);
const [state, setState] = useState<WsState>("connecting");
@@ -104,10 +119,14 @@ export function useSessions(pushToast: (text: string) => void): SessionsStore |
}, [pushToast]);
const subscribe = useCallback(
(sessionId: string, onEvents: (events: EventFrame[]) => void): (() => void) => {
(
sessionId: string,
onEvents: (events: EventFrame[]) => void,
): (() => void) => {
if (manager === null) return () => undefined;
const off = manager.onFrame((frame: ServerFrame) => {
if (frame.type === "events" && frame.sessionId === sessionId) onEvents(frame.events);
if (frame.type === "events" && frame.sessionId === sessionId)
onEvents(frame.events);
});
manager.subscribe(sessionId);
return () => {
@@ -115,7 +134,7 @@ export function useSessions(pushToast: (text: string) => void): SessionsStore |
off();
};
},
[manager]
[manager],
);
// hooks above must all run before any early return: clearing settings
+29 -6
View File
@@ -79,8 +79,16 @@ export function jsonResponse(body: unknown, status = 200): Response {
type FetchHandler = (url: string, init?: RequestInit) => unknown;
export function mockFetchJson(handler: FetchHandler): MockInstance {
return vi.spyOn(globalThis, "fetch").mockImplementation((async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
return vi.spyOn(globalThis, "fetch").mockImplementation((async (
input: RequestInfo | URL,
init?: RequestInit,
) => {
const url =
typeof input === "string"
? input
: input instanceof URL
? input.href
: input.url;
const out = handler(url, init);
return isResponseLike(out) ? out : jsonResponse(out);
}) as typeof fetch);
@@ -103,14 +111,25 @@ export function seedSettings(serverUrl = "http://srv", token = "tok"): void {
// ---------- window.location.reload stub ----------
export function stubReload(): { reload: ReturnType<typeof vi.fn>; restore: () => void } {
export function stubReload(): {
reload: ReturnType<typeof vi.fn>;
restore: () => void;
} {
const original = window.location;
const reload = vi.fn();
Object.defineProperty(window, "location", { value: { reload }, writable: true, configurable: true });
Object.defineProperty(window, "location", {
value: { reload },
writable: true,
configurable: true,
});
return {
reload,
restore: (): void => {
Object.defineProperty(window, "location", { value: original, writable: true, configurable: true });
Object.defineProperty(window, "location", {
value: original,
writable: true,
configurable: true,
});
},
};
}
@@ -141,7 +160,11 @@ class MemoryStorage implements Storage {
}
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 ----------