/** * F5 — the live web surfaces: a `event.domain` event refreshes the displayed * work-state; background tasks render with status + cancel/retry wired to the * gateway; a WS reconnect re-synchronises the read-model. */ import { describe, it, expect, vi, afterEach, beforeAll, afterAll } from "vitest"; import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { FitAddon } from "@xterm/addon-fit"; import type { Gateways } from "@/ports"; import type { BackgroundCompletion, ProjectWorkState } from "@/domain"; import { DIProvider } from "@/app/di"; import { createMockGateways, MockSystemGateway, MockWorkStateGateway } from "@/adapters/mock"; import { WsLiveClient, setWebLiveClient, WebSession, } from "@/adapters/http"; import { WebApp } from "./WebApp"; afterEach(() => setWebLiveClient(null)); const okFetch = async () => ({ ok: true, status: 200, json: async () => ({ ok: true }), text: async () => "{}", }); function memStore() { const map = new Map(); return { getItem: (k: string) => map.get(k) ?? null, setItem: (k: string, v: string) => void map.set(k, v), removeItem: (k: string) => void map.delete(k), }; } function state(agents: ProjectWorkState["agents"]): ProjectWorkState { return { agents, conversations: [] }; } async function seeded(initial: ProjectWorkState): Promise<{ gateways: Gateways; projectId: string }> { const gateways = createMockGateways(); const project = await gateways.project.createProject("Demo", "/srv/demo"); (gateways.workState as MockWorkStateGateway)._setProjectWorkState(project.id, initial); return { gateways, projectId: project.id }; } function renderPaired(gateways: Gateways) { const session = new WebSession({ baseUrl: "https://h", fetchImpl: okFetch, store: memStore() }); session.markPaired(); return render( , ); } const AGENT_IDLE = { agentId: "a1", name: "Archi", profileId: "p1", busy: { state: "idle" as const }, tickets: [] }; const AGENT_FRONT = { agentId: "a2", name: "Front", profileId: "p2", busy: { state: "idle" as const }, tickets: [] }; function backgroundTask( taskId: string, ownerAgentId: string, updatedAtMs = 1, ): BackgroundCompletion { return { taskId, ownerAgentId, projectId: "p", kind: "shell", status: "running", exitCode: null, summary: null, stdoutTail: null, stderrTail: null, updatedAtMs, }; } async function openLivePanel(gateways: Gateways) { renderPaired(gateways); fireEvent.click(await screen.findByText("Demo")); return screen.findByTestId("web-workstate"); } describe("WebWorkspace live surfaces (F5)", () => { it("refreshes the work-state on a relevant event.domain event", async () => { const { gateways, projectId } = await seeded(state([AGENT_IDLE])); renderPaired(gateways); fireEvent.click(await screen.findByText("Demo")); const panel = await screen.findByTestId("web-workstate"); expect(panel.textContent).toContain("Idle"); // The agent goes busy server-side; the read-model now reflects it… (gateways.workState as MockWorkStateGateway)._setProjectWorkState( projectId, state([{ ...AGENT_IDLE, busy: { state: "busy", ticket: "t-1", sinceMs: 1 } }]), ); // …and an `agentBusyChanged` domain event drives a live refresh. act(() => { (gateways.system as MockSystemGateway).emit({ type: "agentBusyChanged", agentId: "a1", busy: true }); }); await waitFor(() => expect(screen.getByTestId("web-workstate").textContent).toContain("Busy"), ); }); it("renders background tasks and wires cancel through the gateway", async () => { const task = backgroundTask("bt-1", "a1"); const { gateways } = await seeded(state([{ ...AGENT_IDLE, backgroundTasks: [task] }])); const cancelSpy = vi.spyOn(gateways.workState, "cancelBackgroundTask"); await openLivePanel(gateways); fireEvent.click( await screen.findByRole("button", { name: "Afficher les background tasks de Archi" }), ); const tasks = await screen.findByLabelText("Archi background tasks"); expect(tasks.textContent).toContain("Running"); expect(tasks.textContent).toContain("shell"); fireEvent.click(screen.getByRole("button", { name: "Cancel" })); await waitFor(() => expect(cancelSpy).toHaveBeenCalledWith("bt-1")); }); it("keeps background task groups collapsed by default and toggles them per agent", async () => { const { gateways } = await seeded(state([ { ...AGENT_IDLE, backgroundTasks: [backgroundTask("bt-a", "a1")] }, { ...AGENT_FRONT, backgroundTasks: [backgroundTask("bt-f", "a2")] }, ])); await openLivePanel(gateways); const archiToggle = await screen.findByRole("button", { name: "Afficher les background tasks de Archi", }); const frontToggle = await screen.findByRole("button", { name: "Afficher les background tasks de Front", }); expect(archiToggle.getAttribute("aria-expanded")).toBe("false"); expect(frontToggle.getAttribute("aria-expanded")).toBe("false"); expect(screen.queryByLabelText("Archi background tasks")).toBeNull(); expect(screen.queryByLabelText("Front background tasks")).toBeNull(); fireEvent.click(archiToggle); expect( screen .getByRole("button", { name: "Masquer les background tasks de Archi" }) .getAttribute("aria-expanded"), ).toBe("true"); expect( screen .getByRole("button", { name: "Afficher les background tasks de Front" }) .getAttribute("aria-expanded"), ).toBe("false"); expect(await screen.findByLabelText("Archi background tasks")).toBeTruthy(); expect(screen.queryByLabelText("Front background tasks")).toBeNull(); fireEvent.click(screen.getByRole("button", { name: "Afficher les background tasks de Front" })); expect(screen.getByLabelText("Archi background tasks")).toBeTruthy(); expect(screen.getByLabelText("Front background tasks")).toBeTruthy(); }); it("updates task counters without changing the current expanded state", async () => { const { gateways, projectId } = await seeded(state([ { ...AGENT_IDLE, backgroundTasks: [backgroundTask("bt-1", "a1")] }, ])); await openLivePanel(gateways); const collapsedToggle = await screen.findByRole("button", { name: "Afficher les background tasks de Archi", }); expect(collapsedToggle.getAttribute("aria-expanded")).toBe("false"); expect(collapsedToggle.textContent).toContain("1"); expect(screen.queryByLabelText("Archi background tasks")).toBeNull(); (gateways.workState as MockWorkStateGateway)._setProjectWorkState( projectId, state([ { ...AGENT_IDLE, backgroundTasks: [backgroundTask("bt-1", "a1", 1), backgroundTask("bt-2", "a1", 2)], }, ]), ); act(() => { (gateways.system as MockSystemGateway).emit({ type: "backgroundTaskChanged", projectId, taskId: "bt-2", agentId: "a1", state: "running", }); }); await waitFor(() => expect( screen.getByRole("button", { name: "Afficher les background tasks de Archi" }).textContent, ).toContain("2"), ); expect( screen .getByRole("button", { name: "Afficher les background tasks de Archi" }) .getAttribute("aria-expanded"), ).toBe("false"); expect(screen.queryByLabelText("Archi background tasks")).toBeNull(); fireEvent.click(screen.getByRole("button", { name: "Afficher les background tasks de Archi" })); expect(await screen.findByLabelText("Archi background tasks")).toBeTruthy(); (gateways.workState as MockWorkStateGateway)._setProjectWorkState( projectId, state([{ ...AGENT_IDLE, backgroundTasks: [backgroundTask("bt-1", "a1", 3)] }]), ); act(() => { (gateways.system as MockSystemGateway).emit({ type: "backgroundTaskChanged", projectId, taskId: "bt-2", agentId: "a1", state: "completed", }); }); await waitFor(() => expect( screen.getByRole("button", { name: "Masquer les background tasks de Archi" }).textContent, ).toContain("1"), ); expect( screen .getByRole("button", { name: "Masquer les background tasks de Archi" }) .getAttribute("aria-expanded"), ).toBe("true"); expect(screen.getByLabelText("Archi background tasks")).toBeTruthy(); }); it("hides and resets an open background task group when its task count reaches zero", async () => { const { gateways, projectId } = await seeded(state([ { ...AGENT_IDLE, backgroundTasks: [backgroundTask("bt-1", "a1")] }, ])); await openLivePanel(gateways); fireEvent.click(await screen.findByRole("button", { name: "Afficher les background tasks de Archi", })); expect(await screen.findByLabelText("Archi background tasks")).toBeTruthy(); (gateways.workState as MockWorkStateGateway)._setProjectWorkState( projectId, state([{ ...AGENT_IDLE, backgroundTasks: [] }]), ); act(() => { (gateways.system as MockSystemGateway).emit({ type: "backgroundTaskChanged", projectId, taskId: "bt-1", agentId: "a1", state: "completed", }); }); await waitFor(() => expect(screen.queryByRole("button", { name: /background tasks de Archi/ })).toBeNull(), ); expect(screen.queryByLabelText("Archi background tasks")).toBeNull(); (gateways.workState as MockWorkStateGateway)._setProjectWorkState( projectId, state([{ ...AGENT_IDLE, backgroundTasks: [backgroundTask("bt-2", "a1", 2)] }]), ); act(() => { (gateways.system as MockSystemGateway).emit({ type: "backgroundTaskChanged", projectId, taskId: "bt-2", agentId: "a1", state: "running", }); }); await waitFor(() => expect( screen .getByRole("button", { name: "Afficher les background tasks de Archi" }) .getAttribute("aria-expanded"), ).toBe("false"), ); expect(screen.queryByLabelText("Archi background tasks")).toBeNull(); }); it("re-synchronises the read-model when the WS reconnects", async () => { const { gateways, projectId } = await seeded(state([AGENT_IDLE])); // Register a live client whose connection state we can drive. let notify: ((s: "connecting" | "connected" | "reconnecting" | "closed") => void) | null = null; const fakeClient = { getConnectionState: () => "connected" as const, onConnectionStateChange: (l: (s: "connecting" | "connected" | "reconnecting" | "closed") => void) => { notify = l; return () => { notify = null; }; }, }; setWebLiveClient(fakeClient as unknown as WsLiveClient); const refreshSpy = vi.spyOn(gateways.workState, "getProjectWorkState"); renderPaired(gateways); fireEvent.click(await screen.findByText("Demo")); await screen.findByTestId("web-workstate"); const callsAfterOpen = refreshSpy.mock.calls.length; expect(projectId).toBeTruthy(); // Simulate a reconnect: reconnecting → connected must re-fetch the read-model. act(() => { notify?.("reconnecting"); notify?.("connected"); }); await waitFor(() => expect(refreshSpy.mock.calls.length).toBeGreaterThan(callsAfterOpen)); }); }); /** * Ticket #61 (web regression) — `LiveProjectPanel`'s `cellLayoutVersion`. * * The web shell has no `useLayout`/`LayoutGrid`, so nothing ever bumped * `refitSignal` on the web agent cell. `LiveProjectPanel` now keeps a local * counter, bumped on every cell open/close, forwarded as `refitSignal` to the * currently-visible `WebAgentCell`. The panel currently shows at most one cell * at a time (opening a different agent replaces, rather than adds to, the * visible cell) — so "the cell already displayed keeps a stale scaling" is * exercised here as: opening a *second*, different agent's cell must still * carry a bumped, defined `refitSignal` through to the freshly-mounted * `TerminalView`, producing an explicit `fit()` call beyond the one guaranteed * at mount — not just whatever the (here stubbed-inert) `ResizeObserver` would * have produced on its own. That is the exact mechanism a real multi-cell * surface would rely on for a survivor cell (item 4 of the plan). * * Needs the same `matchMedia`/`ResizeObserver` polyfills as * `TerminalView.test.tsx` for xterm to actually mount under jsdom — scoped to * this describe block only. */ describe("LiveProjectPanel — cellLayoutVersion (ticket #61 web regression)", () => { const w = window as unknown as { matchMedia?: (q: string) => MediaQueryList }; const savedMatchMedia = w.matchMedia; const savedResizeObserver = globalThis.ResizeObserver; beforeAll(() => { w.matchMedia = (query: string) => ({ matches: false, media: query, onchange: null, addEventListener: () => {}, removeEventListener: () => {}, addListener: () => {}, removeListener: () => {}, dispatchEvent: () => false, }) as unknown as MediaQueryList; // A no-op observer: real resize events must not be what drives the extra // fit() below — only the explicit `refitSignal` bump should. globalThis.ResizeObserver = class { observe() {} unobserve() {} disconnect() {} } as unknown as typeof ResizeObserver; }); afterAll(() => { w.matchMedia = savedMatchMedia; globalThis.ResizeObserver = savedResizeObserver; }); it("opening a second cell carries a bumped refitSignal, producing an extra fit()", async () => { const { gateways } = await seeded(state([AGENT_IDLE, AGENT_FRONT])); const fitSpy = vi.spyOn(FitAddon.prototype, "fit"); renderPaired(gateways); fireEvent.click(await screen.findByText("Demo")); await screen.findByTestId("web-workstate"); const archiRow = screen.getByText("Archi").closest("li")!; fireEvent.click(within(archiRow).getByRole("button", { name: "Ouvrir" })); await screen.findByTestId("web-agent-cell"); // First cell mounted: at least the one guaranteed fit-on-mount happened. expect(fitSpy.mock.calls.length).toBeGreaterThan(0); // Switch to a different agent's cell — the panel shows one cell at a time, // so this replaces (unmounts A, mounts B) rather than adding a second. const frontRow = screen.getByText("Front").closest("li")!; fireEvent.click(within(frontRow).getByRole("button", { name: "Ouvrir" })); const cellB = await screen.findByTestId("web-agent-cell"); const fitCallsAtBMount = fitSpy.mock.calls.length; // jsdom reports a zero-size layout box, which the refit deliberately skips // — give the freshly-mounted cell's inner xterm container a real size so // the refitSignal-driven refit actually reaches `fit.fit()`. const containerB = cellB.querySelector('[data-testid="terminal-view"]')! .firstElementChild as HTMLElement; Object.defineProperty(containerB, "clientWidth", { value: 400, configurable: true }); Object.defineProperty(containerB, "clientHeight", { value: 200, configurable: true }); // The bumped `cellLayoutVersion` reaching `refitSignal` schedules this // extra refit — with the no-op ResizeObserver stubbed above, nothing else // could have produced it. await waitFor(() => expect(fitSpy.mock.calls.length).toBeGreaterThan(fitCallsAtBMount), ); fitSpy.mockRestore(); }); });