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
+205 -125
View File
@@ -6,146 +6,226 @@ import SessionsView from "./SessionsView";
import { jsonResponse, mockFetchJson, seedSettings } from "./test/setup";
function session(p: Partial<SessionListItem>): SessionListItem {
return {
id: "s1",
name: null,
cwd: "/w",
model: "glm-5.3",
provider: "zai-renaud",
agent: false,
repo: null,
startedAt: 100,
online: false,
lastEventAt: null,
...p,
};
return {
id: "s1",
name: null,
cwd: "/w",
model: "glm-5.3",
provider: "zai-renaud",
agent: false,
repo: null,
startedAt: 100,
online: false,
lastEventAt: null,
...p,
};
}
function renderView(props: Partial<Parameters<typeof SessionsView>[0]> = {}): ReturnType<typeof render> {
return render(
<MemoryRouter>
<SessionsView sessions={[]} onChanged={() => undefined} pushToast={() => undefined} {...props} />
</MemoryRouter>
);
function renderView(
props: Partial<Parameters<typeof SessionsView>[0]> = {},
): ReturnType<typeof render> {
return render(
<MemoryRouter>
<SessionsView
sessions={[]}
onChanged={() => undefined}
pushToast={() => undefined}
{...props}
/>
</MemoryRouter>,
);
}
function Probe({ onVisit }: { onVisit: (path: string) => void }): null {
const { id } = useParams();
onVisit(`/s/${id ?? ""}`);
return null;
const { id } = useParams();
onVisit(`/s/${id ?? ""}`);
return null;
}
describe("SessionsView", () => {
it("empty state message", () => {
renderView();
expect(screen.getByText(/No sessions yet/i)).toBeInTheDocument();
});
it("empty state message", () => {
renderView();
expect(screen.getByText(/No sessions yet/i)).toBeInTheDocument();
});
it("renders cards sorted by last activity with fallbacks", () => {
renderView({
sessions: [
session({ id: "a", name: null, repo: "g/p", lastEventAt: 5, startedAt: 1 }),
session({ id: "b", name: "named", lastEventAt: null, startedAt: 10 }),
session({ id: "c", name: null, repo: null, cwd: "/fallback", startedAt: 100, online: true }),
],
});
const cards = screen.getAllByRole("button", { name: /^Open session/ });
expect(cards.map((c) => c.getAttribute("aria-label"))).toEqual([
"Open session c",
"Open session named",
"Open session a",
]);
expect(cards[0]?.textContent).toContain("/fallback");
expect(screen.getAllByTitle("online")).toHaveLength(1);
expect(screen.getAllByTitle("offline")).toHaveLength(2);
});
it("renders cards sorted by last activity with fallbacks", () => {
renderView({
sessions: [
session({
id: "a",
name: null,
repo: "g/p",
lastEventAt: 5,
startedAt: 1,
}),
session({ id: "b", name: "named", lastEventAt: null, startedAt: 10 }),
session({
id: "c",
name: null,
repo: null,
cwd: "/fallback",
startedAt: 100,
online: true,
}),
],
});
const cards = screen.getAllByRole("button", { name: /^Open session/ });
expect(cards.map((c) => c.getAttribute("aria-label"))).toEqual([
"Open session c",
"Open session named",
"Open session a",
]);
expect(cards[0]?.textContent).toContain("/fallback");
expect(screen.getAllByTitle("online")).toHaveLength(1);
expect(screen.getAllByTitle("offline")).toHaveLength(2);
});
it("shows repo, model, relative time and agent badge", () => {
renderView({ sessions: [session({ id: "s1", name: "named", repo: "g/p", lastEventAt: Date.now() - 5000, agent: true })] });
expect(screen.getAllByText("g/p")).toHaveLength(1);
expect(screen.getByText("glm-5.3")).toBeInTheDocument();
expect(screen.getByText("just now")).toBeInTheDocument();
expect(screen.getByText("agent")).toBeInTheDocument();
expect(screen.getByLabelText("Stop container for named")).toBeInTheDocument();
});
it("shows repo, model, relative time and agent badge", () => {
renderView({
sessions: [
session({
id: "s1",
name: "named",
repo: "g/p",
lastEventAt: Date.now() - 5000,
agent: true,
}),
],
});
expect(screen.getAllByText("g/p")).toHaveLength(1);
expect(screen.getByText("glm-5.3")).toBeInTheDocument();
expect(screen.getByText("just now")).toBeInTheDocument();
expect(screen.getByText("agent")).toBeInTheDocument();
expect(
screen.getByLabelText("Stop container for named"),
).toBeInTheDocument();
});
it("no badge/stop for non-agent sessions", () => {
renderView({ sessions: [session({ id: "s1", name: "local", repo: "g/p" })] });
expect(screen.queryByText("agent")).toBeNull();
expect(screen.queryByLabelText(/Stop container/)).toBeNull();
});
it("no badge/stop for non-agent sessions", () => {
renderView({
sessions: [session({ id: "s1", name: "local", repo: "g/p" })],
});
expect(screen.queryByText("agent")).toBeNull();
expect(screen.queryByLabelText(/Stop container/)).toBeNull();
});
it("keyboard Enter and Space open the session; other keys ignored", () => {
const probe = vi.fn();
render(
<MemoryRouter initialEntries={["/"]}>
<Routes>
<Route path="/" element={<SessionsView sessions={[session({ id: "s9", name: "kb" })]} onChanged={() => undefined} pushToast={() => undefined} />} />
<Route path="/s/:id" element={<Probe onVisit={probe} />} />
</Routes>
</MemoryRouter>
);
const card = screen.getByRole("button", { name: "Open session kb" });
fireEvent.keyDown(card, { key: "Tab" });
expect(probe).not.toHaveBeenCalled();
fireEvent.keyDown(card, { key: "Enter" });
expect(probe).toHaveBeenCalledWith("/s/s9");
});
it("keyboard Enter and Space open the session; other keys ignored", () => {
const probe = vi.fn();
render(
<MemoryRouter initialEntries={["/"]}>
<Routes>
<Route
path="/"
element={
<SessionsView
sessions={[session({ id: "s9", name: "kb" })]}
onChanged={() => undefined}
pushToast={() => undefined}
/>
}
/>
<Route path="/s/:id" element={<Probe onVisit={probe} />} />
</Routes>
</MemoryRouter>,
);
const card = screen.getByRole("button", { name: "Open session kb" });
fireEvent.keyDown(card, { key: "Tab" });
expect(probe).not.toHaveBeenCalled();
fireEvent.keyDown(card, { key: "Enter" });
expect(probe).toHaveBeenCalledWith("/s/s9");
});
it("Space key opens the session", () => {
const probe = vi.fn();
render(
<MemoryRouter initialEntries={["/"]}>
<Routes>
<Route path="/" element={<SessionsView sessions={[session({ id: "s8" })]} onChanged={() => undefined} pushToast={() => undefined} />} />
<Route path="/s/:id" element={<Probe onVisit={probe} />} />
</Routes>
</MemoryRouter>
);
fireEvent.keyDown(screen.getByRole("button", { name: "Open session s8" }), { key: " " });
expect(probe).toHaveBeenCalledWith("/s/s8");
});
it("Space key opens the session", () => {
const probe = vi.fn();
render(
<MemoryRouter initialEntries={["/"]}>
<Routes>
<Route
path="/"
element={
<SessionsView
sessions={[session({ id: "s8" })]}
onChanged={() => undefined}
pushToast={() => undefined}
/>
}
/>
<Route path="/s/:id" element={<Probe onVisit={probe} />} />
</Routes>
</MemoryRouter>,
);
fireEvent.keyDown(screen.getByRole("button", { name: "Open session s8" }), {
key: " ",
});
expect(probe).toHaveBeenCalledWith("/s/s8");
});
it("stop with unnamed session toasts the id", async () => {
seedSettings();
mockFetchJson(() => ({ ok: true }));
const pushToast = vi.fn();
renderView({ sessions: [session({ id: "s1", name: null, agent: true })], pushToast, onChanged: () => undefined });
fireEvent.click(screen.getByLabelText("Stop container for s1"));
await vi.waitFor(() => expect(pushToast).toHaveBeenCalledWith("stopped s1"));
});
it("stop with unnamed session toasts the id", async () => {
seedSettings();
mockFetchJson(() => ({ ok: true }));
const pushToast = vi.fn();
renderView({
sessions: [session({ id: "s1", name: null, agent: true })],
pushToast,
onChanged: () => undefined,
});
fireEvent.click(screen.getByLabelText("Stop container for s1"));
await vi.waitFor(() =>
expect(pushToast).toHaveBeenCalledWith("stopped s1"),
);
});
it("stop button deletes container, toasts and refreshes", async () => {
seedSettings();
const fetchMock = mockFetchJson(() => ({ ok: true }));
const onChanged = vi.fn();
const pushToast = vi.fn();
renderView({ sessions: [session({ id: "s1", name: "worker", agent: true })], onChanged, pushToast });
fireEvent.click(screen.getByLabelText("Stop container for worker"));
await vi.waitFor(() => {
const call = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
expect(call[0]).toBe("http://srv/api/sessions/s1/container");
expect(call[1].method).toBe("DELETE");
});
await vi.waitFor(() => expect(pushToast).toHaveBeenCalledWith("stopped worker"));
expect(onChanged).toHaveBeenCalled();
});
it("stop button deletes container, toasts and refreshes", async () => {
seedSettings();
const fetchMock = mockFetchJson(() => ({ ok: true }));
const onChanged = vi.fn();
const pushToast = vi.fn();
renderView({
sessions: [session({ id: "s1", name: "worker", agent: true })],
onChanged,
pushToast,
});
fireEvent.click(screen.getByLabelText("Stop container for worker"));
await vi.waitFor(() => {
const call = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
expect(call[0]).toBe("http://srv/api/sessions/s1/container");
expect(call[1].method).toBe("DELETE");
});
await vi.waitFor(() =>
expect(pushToast).toHaveBeenCalledWith("stopped worker"),
);
expect(onChanged).toHaveBeenCalled();
});
it("stop failure toasts the error", async () => {
seedSettings();
vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse({ error: "nope" }, 500));
const pushToast = vi.fn();
renderView({ sessions: [session({ id: "s1", name: "w", agent: true })], pushToast, onChanged: () => undefined });
fireEvent.click(screen.getByLabelText("Stop container for w"));
await vi.waitFor(() => expect(pushToast).toHaveBeenCalledWith("stop failed: nope"));
});
it("stop failure toasts the error", async () => {
seedSettings();
vi.spyOn(globalThis, "fetch").mockResolvedValue(
jsonResponse({ error: "nope" }, 500),
);
const pushToast = vi.fn();
renderView({
sessions: [session({ id: "s1", name: "w", agent: true })],
pushToast,
onChanged: () => undefined,
});
fireEvent.click(screen.getByLabelText("Stop container for w"));
await vi.waitFor(() =>
expect(pushToast).toHaveBeenCalledWith("stop failed: nope"),
);
});
it("non-error stop failure path stringifies non-Error throws", async () => {
seedSettings();
vi.spyOn(globalThis, "fetch").mockRejectedValue("plain-string");
const pushToast = vi.fn();
renderView({ sessions: [session({ id: "s1", name: "w", agent: true })], pushToast, onChanged: () => undefined });
fireEvent.click(screen.getByLabelText("Stop container for w"));
await vi.waitFor(() => expect(pushToast).toHaveBeenCalledWith("stop failed: plain-string"));
});
it("non-error stop failure path stringifies non-Error throws", async () => {
seedSettings();
vi.spyOn(globalThis, "fetch").mockRejectedValue("plain-string");
const pushToast = vi.fn();
renderView({
sessions: [session({ id: "s1", name: "w", agent: true })],
pushToast,
onChanged: () => undefined,
});
fireEvent.click(screen.getByLabelText("Stop container for w"));
await vi.waitFor(() =>
expect(pushToast).toHaveBeenCalledWith("stop failed: plain-string"),
);
});
});