import { act, fireEvent, render, screen } from "@testing-library/react"; import { MemoryRouter, Route, Routes, useParams } from "react-router-dom"; import { describe, expect, it, vi } from "vitest"; import type { SessionListItem } from "./protocol"; import SessionsView from "./SessionsView"; import { jsonResponse, mockFetchJson, seedSettings } from "./test/setup"; function session(p: Partial): SessionListItem { return { id: "s1", name: null, cwd: "/w", model: "glm-5.3", provider: "zai-renaud", agent: false, repo: null, startedAt: 100, online: true, lastEventAt: null, ...p, }; } function renderView( props: Partial[0]> = {}, ): ReturnType { return render( undefined} pushToast={() => undefined} {...props} /> , ); } function Probe({ onVisit }: { onVisit: (path: string) => void }): null { const { id } = useParams(); onVisit(`/s/${id ?? ""}`); return null; } describe("SessionsView", () => { it("offline sessions appear under the Archive section, not in active", () => { renderView({ sessions: [ session({ id: "on", name: "live" }), session({ id: "off", name: "dead", online: false }), ], }); const cards = screen.getAllByRole("button", { name: /^Open session/ }); expect(cards.map((c) => c.getAttribute("aria-label"))).toEqual([ "Open session live", "Open session dead", ]); expect(screen.getByText("Archive")).toBeInTheDocument(); }); it("empty state message (after load window)", () => { vi.useFakeTimers(); renderView({ sessions: [] }); act(() => { vi.advanceTimersByTime(700); }); expect(screen.getByText(/No active sessions/i)).toBeInTheDocument(); vi.useRealTimers(); }); it("skeleton while first load pending", () => { renderView({ sessions: [] }); expect( screen.getByRole("heading", { name: "Active sessions" }), ).toBeInTheDocument(); const skel = document.querySelector(".skeleton"); expect(skel).not.toBeNull(); }); 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(3); expect(screen.queryAllByTitle("offline")).toHaveLength(0); }); 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("keyboard Enter and Space open the session; other keys ignored", () => { const probe = vi.fn(); render( undefined} pushToast={() => undefined} /> } /> } /> , ); const card = screen.getByRole("button", { name: "Open session kb" }); fireEvent.keyDown(card, { key: "Tab" }); expect(probe).not.toHaveBeenCalled(); fireEvent.keyDown(card, { key: "Enter" }); expect(probe).toHaveBeenCalledWith("/s/s9"); }); it("Space key opens the session", () => { const probe = vi.fn(); render( undefined} pushToast={() => undefined} /> } /> } /> , ); const ev = fireEvent.keyDown( screen.getByRole("button", { name: "Open session s8" }), { key: " ", }, ); expect(ev).toBe(false); // preventDefault consumed the default action expect(probe).toHaveBeenCalledWith("/s/s8"); }); it("Space and Enter keydown are default-prevented so the page does not scroll (S8)", () => { const probe = vi.fn(); const renderKb = (): HTMLElement => { render( undefined} pushToast={() => undefined} /> } /> } /> , ); return screen.getByRole("button", { name: "Open session kb2" }); }; // document-level bubble listener observes the event AFTER React's // delegated handler, so defaultPrevented reflects the component's call const seen: boolean[] = []; const rec = (e: Event): void => { seen.push(e.defaultPrevented); }; document.addEventListener("keydown", rec); // Space: navigation unmounts the card, so each key gets a fresh render fireEvent.keyDown(renderKb(), { key: " " }); fireEvent.keyDown(renderKb(), { key: "Enter" }); fireEvent.keyDown(renderKb(), { key: "Tab" }); document.removeEventListener("keydown", rec); expect(seen).toEqual([true, true, false]); expect(probe).toHaveBeenCalledWith("/s/kb2"); }); 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 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"), ); }); });