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
+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 { MemoryRouter, Route, Routes } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -10,288 +16,385 @@ import type { SessionListItem } from "./protocol";
let seq: number = 0;
function ev(type: string, extra: Partial<EventFrame> = {}): EventFrame {
seq += 1;
return { v: 1, sessionId: "s1", seq, ts: 0, type, ...extra } as EventFrame;
seq += 1;
return { v: 1, sessionId: "s1", seq, ts: 0, type, ...extra } as EventFrame;
}
const sessions: SessionListItem[] = [
{
id: "s1",
name: "worker",
cwd: "/w",
model: "glm-5.3",
provider: "zai-renaud",
agent: false,
repo: null,
startedAt: 0,
online: true,
lastEventAt: 1,
},
{
id: "s1",
name: "worker",
cwd: "/w",
model: "glm-5.3",
provider: "zai-renaud",
agent: false,
repo: null,
startedAt: 0,
online: true,
lastEventAt: 1,
},
];
function makeStore(over: Partial<SessionsStore> = {}): SessionsStore {
return {
sessions,
state: "open",
spawnJobs: [],
refresh: async () => undefined,
subscribe: (sessionId: string, onEvents: (events: EventFrame[]) => void): (() => void) => {
currentSub = { sessionId, onEvents };
return () => {
if (currentSub !== null && currentSub.sessionId === sessionId) currentSub = null;
};
},
...over,
};
return {
sessions,
state: "open",
spawnJobs: [],
refresh: async () => undefined,
subscribe: (
sessionId: string,
onEvents: (events: EventFrame[]) => void,
): (() => void) => {
currentSub = { sessionId, onEvents };
return () => {
if (currentSub !== null && currentSub.sessionId === sessionId)
currentSub = null;
};
},
...over,
};
}
interface ActiveSub {
sessionId: string;
onEvents: (events: EventFrame[]) => void;
sessionId: string;
onEvents: (events: EventFrame[]) => void;
}
let currentSub: ActiveSub | null = null;
function push(events: EventFrame[]): void {
act(() => currentSub?.onEvents(events));
act(() => currentSub?.onEvents(events));
}
function renderChat(store: SessionsStore, path = "/s/s1"): ReturnType<typeof render> {
return render(
<MemoryRouter initialEntries={[path]}>
<Routes>
<Route path="/s/:id" element={<ChatView store={store} pushToast={pushToast} />} />
<Route path="*" element={<div>OTHER</div>} />
</Routes>
</MemoryRouter>
);
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="*" element={<div>OTHER</div>} />
</Routes>
</MemoryRouter>,
);
}
const pushToast = vi.fn();
function historyEvents(): EventFrame[] {
seq = 0;
return [
ev("message_end", { message: { role: "user", id: "u1", text: "hello there", thinking: null, toolCalls: [], toolCallId: null } }),
ev("agent_start"),
ev("message_end", { message: { role: "assistant", id: "a1", text: "hi!", thinking: "hmm", toolCalls: [], toolCallId: null } }),
ev("agent_settled"),
];
seq = 0;
return [
ev("message_end", {
message: {
role: "user",
id: "u1",
text: "hello there",
thinking: null,
toolCalls: [],
toolCallId: null,
},
}),
ev("agent_start"),
ev("message_end", {
message: {
role: "assistant",
id: "a1",
text: "hi!",
thinking: "hmm",
toolCalls: [],
toolCallId: null,
},
}),
ev("agent_settled"),
];
}
beforeEach(() => {
seq = 0;
currentSub = null;
pushToast.mockClear();
seedSettings();
seq = 0;
currentSub = null;
pushToast.mockClear();
seedSettings();
});
afterEach(() => {
vi.restoreAllMocks();
vi.restoreAllMocks();
});
describe("ChatView", () => {
it("renders history from REST on mount and subscribes to the WS stream", async () => {
const fetchMock = mockFetchJson((url) => {
if (url.startsWith("http://srv/api/sessions/s1/events")) return historyEvents();
return [];
});
const store = makeStore();
const { rerender } = renderChat(store);
it("renders history from REST on mount and subscribes to the WS stream", async () => {
const fetchMock = mockFetchJson((url) => {
if (url.startsWith("http://srv/api/sessions/s1/events"))
return historyEvents();
return [];
});
const store = makeStore();
const { rerender } = renderChat(store);
await screen.findByText("hello there");
expect(screen.getByText("hi!")).toBeInTheDocument();
await screen.findByText("hello there");
expect(screen.getByText("hi!")).toBeInTheDocument();
await waitFor(() => expect(currentSub).not.toBeNull());
expect(currentSub?.sessionId).toBe("s1");
expect(fetchMock).toHaveBeenCalled();
await waitFor(() => expect(currentSub).not.toBeNull());
expect(currentSub?.sessionId).toBe("s1");
expect(fetchMock).toHaveBeenCalled();
// header shows session name + model + online dot
expect(screen.getByText("worker")).toBeInTheDocument();
expect(screen.getByText("glm-5.3")).toBeInTheDocument();
expect(screen.getByTitle("online")).toBeInTheDocument();
// header shows session name + model + online dot
expect(screen.getByText("worker")).toBeInTheDocument();
expect(screen.getByText("glm-5.3")).toBeInTheDocument();
expect(screen.getByTitle("online")).toBeInTheDocument();
rerender(
<MemoryRouter initialEntries={["/s/s1"]}>
<Routes>
<Route path="/s/:id" element={<ChatView store={store} pushToast={pushToast} />} />
<Route path="*" element={<div>OTHER</div>} />
</Routes>
</MemoryRouter>
);
});
rerender(
<MemoryRouter initialEntries={["/s/s1"]}>
<Routes>
<Route
path="/s/:id"
element={<ChatView store={store} pushToast={pushToast} />}
/>
<Route path="*" element={<div>OTHER</div>} />
</Routes>
</MemoryRouter>,
);
});
it("live delta streaming appends into a streaming bubble and ends it", async () => {
mockFetchJson(() => []);
renderChat(makeStore());
await waitFor(() => expect(currentSub).not.toBeNull());
it("live delta streaming appends into a streaming bubble and ends it", async () => {
mockFetchJson(() => []);
renderChat(makeStore());
await waitFor(() => expect(currentSub).not.toBeNull());
push([
ev("message_start", { message: { role: "assistant", id: "a9", text: "", thinking: null, toolCalls: [], toolCallId: null } }),
]);
push([ev("message_update", { delta: "Hel" })]);
push([ev("message_update", { delta: "lo" })]);
expect(screen.getByText("Hello")).toBeInTheDocument();
expect(screen.queryByLabelText("Send message")).toBeNull();
expect(screen.getByLabelText("Abort current run")).toBeInTheDocument();
push([
ev("message_start", {
message: {
role: "assistant",
id: "a9",
text: "",
thinking: null,
toolCalls: [],
toolCallId: null,
},
}),
]);
push([ev("message_update", { delta: "Hel" })]);
push([ev("message_update", { delta: "lo" })]);
expect(screen.getByText("Hello")).toBeInTheDocument();
expect(screen.queryByLabelText("Send message")).toBeNull();
expect(screen.getByLabelText("Abort current run")).toBeInTheDocument();
push([
ev("message_end", { message: { role: "assistant", id: "a9", text: "Hello world", thinking: null, toolCalls: [], toolCallId: null } }),
ev("agent_settled"),
]);
expect(screen.getByText("Hello world")).toBeInTheDocument();
expect(screen.getByLabelText("Send message")).toBeInTheDocument();
});
push([
ev("message_end", {
message: {
role: "assistant",
id: "a9",
text: "Hello world",
thinking: null,
toolCalls: [],
toolCallId: null,
},
}),
ev("agent_settled"),
]);
expect(screen.getByText("Hello world")).toBeInTheDocument();
expect(screen.getByLabelText("Send message")).toBeInTheDocument();
});
it("typing indicator shows while busy with no open stream", async () => {
mockFetchJson(() => []);
const { container } = renderChat(makeStore());
await waitFor(() => expect(currentSub).not.toBeNull());
it("typing indicator shows while busy with no open stream", async () => {
mockFetchJson(() => []);
const { container } = renderChat(makeStore());
await waitFor(() => expect(currentSub).not.toBeNull());
push([ev("agent_start")]);
expect(container.querySelector(".typing")).not.toBeNull();
push([ev("agent_start")]);
expect(container.querySelector(".typing")).not.toBeNull();
push([ev("agent_settled")]);
expect(container.querySelector(".typing")).toBeNull();
});
push([ev("agent_settled")]);
expect(container.querySelector(".typing")).toBeNull();
});
it("409 on send toasts 'session offline'", async () => {
let n = 0;
mockFetchJson((_url, init) => {
if (init?.method === "POST") {
n += 1;
return jsonResponse({ error: "session offline" }, n === 1 ? 409 : 200);
}
return [];
});
renderChat(makeStore());
const ta = screen.getByLabelText("Message");
await userEvent.type(ta, "go");
await userEvent.click(screen.getByLabelText("Send message"));
await vi.waitFor(() => expect(pushToast).toHaveBeenCalledWith("session offline"));
});
it("409 on send toasts 'session offline'", async () => {
let n = 0;
mockFetchJson((_url, init) => {
if (init?.method === "POST") {
n += 1;
return jsonResponse({ error: "session offline" }, n === 1 ? 409 : 200);
}
return [];
});
renderChat(makeStore());
const ta = screen.getByLabelText("Message");
await userEvent.type(ta, "go");
await userEvent.click(screen.getByLabelText("Send message"));
await vi.waitFor(() =>
expect(pushToast).toHaveBeenCalledWith("session offline"),
);
});
it("non-409 send failure toasts the error message", async () => {
mockFetchJson((_url, init) => {
if (init?.method === "POST") return jsonResponse({ error: "nope" }, 500);
return [];
});
renderChat(makeStore());
await userEvent.type(screen.getByLabelText("Message"), "go");
await userEvent.click(screen.getByLabelText("Send message"));
await vi.waitFor(() => expect(pushToast).toHaveBeenCalledWith("nope"));
});
it("non-409 send failure toasts the error message", async () => {
mockFetchJson((_url, init) => {
if (init?.method === "POST") return jsonResponse({ error: "nope" }, 500);
return [];
});
renderChat(makeStore());
await userEvent.type(screen.getByLabelText("Message"), "go");
await userEvent.click(screen.getByLabelText("Send message"));
await vi.waitFor(() => expect(pushToast).toHaveBeenCalledWith("nope"));
});
it("Enter sends, Shift+Enter adds a newline, send disabled while empty or sending", async () => {
const fetchMock = mockFetchJson((_url, init) => (init?.method === "POST" ? { ok: true } : []));
renderChat(makeStore());
const ta = screen.getByLabelText("Message") as HTMLTextAreaElement;
const send = screen.getByLabelText("Send message") as HTMLButtonElement;
expect(send).toBeDisabled();
it("Enter sends, Shift+Enter adds a newline, send disabled while empty or sending", async () => {
const fetchMock = mockFetchJson((_url, init) =>
init?.method === "POST" ? { ok: true } : [],
);
renderChat(makeStore());
const ta = screen.getByLabelText("Message") as HTMLTextAreaElement;
const send = screen.getByLabelText("Send message") as HTMLButtonElement;
expect(send).toBeDisabled();
await userEvent.type(ta, "hello");
expect(send).not.toBeDisabled();
await userEvent.type(ta, "hello");
expect(send).not.toBeDisabled();
fireEvent.keyDown(ta, { key: "Enter", shiftKey: true });
fireEvent.click(send);
await userEvent.clear(ta);
fireEvent.keyDown(ta, { key: "Enter" }); // empty draft: send() early-returns
await vi.waitFor(() => {
const post = fetchMock.mock.calls.find((c) => (c[1] as RequestInit | undefined)?.method === "POST");
expect(post).toBeDefined();
expect((post?.[1] as RequestInit).body).toBe(JSON.stringify({ message: "hello" }));
});
expect(ta.value).toBe("");
});
fireEvent.keyDown(ta, { key: "Enter", shiftKey: true });
fireEvent.click(send);
await userEvent.clear(ta);
fireEvent.keyDown(ta, { key: "Enter" }); // empty draft: send() early-returns
await vi.waitFor(() => {
const post = fetchMock.mock.calls.find(
(c) => (c[1] as RequestInit | undefined)?.method === "POST",
);
expect(post).toBeDefined();
expect((post?.[1] as RequestInit).body).toBe(
JSON.stringify({ message: "hello" }),
);
});
expect(ta.value).toBe("");
});
it("abort posts to the abort route", async () => {
const fetchMock = mockFetchJson((_url, init) => (init?.method === "POST" ? { ok: true } : []));
renderChat(makeStore());
push([ev("agent_start")]);
await userEvent.click(screen.getByLabelText("Abort current run"));
await vi.waitFor(() => {
const abortCall = fetchMock.mock.calls.find(
(c) => (c[1] as RequestInit | undefined)?.method === "POST" && String(c[0]).endsWith("/abort")
);
expect(abortCall).toBeDefined();
});
});
it("abort posts to the abort route", async () => {
const fetchMock = mockFetchJson((_url, init) =>
init?.method === "POST" ? { ok: true } : [],
);
renderChat(makeStore());
push([ev("agent_start")]);
await userEvent.click(screen.getByLabelText("Abort current run"));
await vi.waitFor(() => {
const abortCall = fetchMock.mock.calls.find(
(c) =>
(c[1] as RequestInit | undefined)?.method === "POST" &&
String(c[0]).endsWith("/abort"),
);
expect(abortCall).toBeDefined();
});
});
it("abort failure toasts", async () => {
mockFetchJson((url, init) => {
if (init?.method === "POST" && url.endsWith("/abort")) return jsonResponse({ error: "abort failed" }, 500);
return [];
});
renderChat(makeStore());
push([ev("agent_start")]);
await userEvent.click(screen.getByLabelText("Abort current run"));
await vi.waitFor(() => expect(pushToast).toHaveBeenCalledWith("abort failed"));
});
it("abort failure toasts", async () => {
mockFetchJson((url, init) => {
if (init?.method === "POST" && url.endsWith("/abort"))
return jsonResponse({ error: "abort failed" }, 500);
return [];
});
renderChat(makeStore());
push([ev("agent_start")]);
await userEvent.click(screen.getByLabelText("Abort current run"));
await vi.waitFor(() =>
expect(pushToast).toHaveBeenCalledWith("abort failed"),
);
});
it("history load failure shows the error page with a back link", async () => {
mockFetchJson((url) => {
if (url.includes("/events")) return jsonResponse({ error: "db gone" }, 500);
return [];
});
renderChat(makeStore());
expect(await screen.findByText(/Failed to load history: db gone/)).toBeInTheDocument();
expect(screen.getByText("Back to sessions")).toBeInTheDocument();
});
it("history load failure shows the error page with a back link", async () => {
mockFetchJson((url) => {
if (url.includes("/events"))
return jsonResponse({ error: "db gone" }, 500);
return [];
});
renderChat(makeStore());
expect(
await screen.findByText(/Failed to load history: db gone/),
).toBeInTheDocument();
expect(screen.getByText("Back to sessions")).toBeInTheDocument();
});
it("refetches missed persisted events when the ws (re)opens", async () => {
let after = "";
mockFetchJson((url) => {
const m = /[?&]after=(\d+)/.exec(url);
if (m !== null) after = m[1] ?? "";
if (url.includes("/events")) return [ev("message_end", { message: { role: "user", id: "u2", text: "caught up", thinking: null, toolCalls: [], toolCallId: null } })];
return [];
});
const store = makeStore({ state: "closed" });
const { rerender } = render(
<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");
it("refetches missed persisted events when the ws (re)opens", async () => {
let after = "";
mockFetchJson((url) => {
const m = /[?&]after=(\d+)/.exec(url);
if (m !== null) after = m[1] ?? "";
if (url.includes("/events"))
return [
ev("message_end", {
message: {
role: "user",
id: "u2",
text: "caught up",
thinking: null,
toolCalls: [],
toolCallId: null,
},
}),
];
return [];
});
const store = makeStore({ state: "closed" });
const { rerender } = render(
<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
act(() => {
rerender(
<MemoryRouter initialEntries={["/s/s1"]}>
<Routes>
<Route path="/s/:id" element={<ChatView store={makeStore({ state: "open" })} pushToast={pushToast} />} />
</Routes>
</MemoryRouter>
);
});
await vi.waitFor(() => expect(after).toBe("1"));
});
// reconnect: state closed -> open triggers the after=N refetch
act(() => {
rerender(
<MemoryRouter initialEntries={["/s/s1"]}>
<Routes>
<Route
path="/s/:id"
element={
<ChatView
store={makeStore({ state: "open" })}
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 () => {
mockFetchJson((url) => (url.includes("/events") ? historyEvents() : []));
const { container } = renderChat(makeStore());
it("task panel toggle and tool render in both the aside and mobile view", async () => {
mockFetchJson((url) => (url.includes("/events") ? historyEvents() : []));
const { container } = renderChat(makeStore());
// running tool start with no end shows in the Working section
await screen.findByText("hello there");
const toggle = screen.getByLabelText("Toggle task panel");
expect(toggle).toHaveAttribute("aria-expanded", "false");
await userEvent.click(toggle);
expect(screen.getByLabelText("Toggle task panel")).toHaveAttribute("aria-expanded", "true");
expect(container.querySelectorAll(".task-panel")).toHaveLength(2); // aside + mobile
expect(screen.getAllByText("No tasks yet.")).toHaveLength(2);
});
// running tool start with no end shows in the Working section
await screen.findByText("hello there");
const toggle = screen.getByLabelText("Toggle task panel");
expect(toggle).toHaveAttribute("aria-expanded", "false");
await userEvent.click(toggle);
expect(screen.getByLabelText("Toggle task panel")).toHaveAttribute(
"aria-expanded",
"true",
);
expect(container.querySelectorAll(".task-panel")).toHaveLength(2); // aside + mobile
expect(screen.getAllByText("No tasks yet.")).toHaveLength(2);
});
it("no session id param renders the empty page", () => {
render(
<MemoryRouter initialEntries={["/s/"]}>
<Routes>
<Route path="/s/" element={<ChatView store={makeStore()} pushToast={pushToast} />} />
<Route path="/s/:id" element={<div>CHAT</div>} />
</Routes>
</MemoryRouter>
);
expect(screen.getByText("No session selected.")).toBeInTheDocument();
});
it("no session id param renders the empty page", () => {
render(
<MemoryRouter initialEntries={["/s/"]}>
<Routes>
<Route
path="/s/"
element={<ChatView store={makeStore()} pushToast={pushToast} />}
/>
<Route path="/s/:id" element={<div>CHAT</div>} />
</Routes>
</MemoryRouter>,
);
expect(screen.getByText("No session selected.")).toBeInTheDocument();
});
});