/** * F2 — the web client routing + pairing flow: * - not paired ⇒ pairing screen; wrong code ⇒ clear error, stays on pairing; * - successful pair ⇒ read-only workspace (project list + open + snapshot); * - a 401 (session.notifyUnauthorized) ⇒ back to pairing. */ import { describe, it, expect } from "vitest"; import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import type { Gateways } from "@/ports"; import { DIProvider } from "@/app/di"; import { createMockGateways, MockWorkStateGateway } from "@/adapters/mock"; import { WebSession } from "@/adapters/http"; import type { FetchLike } from "@/adapters/http/httpInvoker"; import type { FlagStore } from "@/adapters/http/webSession"; import { WebApp } from "./WebApp"; function memStore(): FlagStore { const map = new Map(); return { getItem: (k) => map.get(k) ?? null, setItem: (k, v) => void map.set(k, v), removeItem: (k) => void map.delete(k), }; } const okFetch: FetchLike = async () => ({ ok: true, status: 200, json: async () => ({ ok: true }), text: async () => "{}", }); const rejectFetch: FetchLike = async () => ({ ok: false, status: 401, json: async () => ({ code: "INVALID", message: "bad" }), text: async () => "{}", }); /** Mock gateways seeded with one project + a work-state snapshot. */ async function seededGateways(): Promise { const gateways = createMockGateways(); const project = await gateways.project.createProject("Demo", "/srv/demo"); (gateways.workState as MockWorkStateGateway)._setProjectWorkState(project.id, { agents: [ { agentId: "a1", name: "Archi", profileId: "p1", busy: { state: "idle" }, tickets: [] }, ], conversations: [], }); return gateways; } function renderWebApp(session: WebSession, gateways: Gateways) { return render( , ); } describe("WebApp pairing routing", () => { it("shows the pairing screen when not paired", async () => { const session = new WebSession({ baseUrl: "https://h", fetchImpl: okFetch, store: memStore() }); renderWebApp(session, await seededGateways()); expect(screen.getByText("Appairer cet appareil")).toBeTruthy(); expect(screen.queryByText("Projets")).toBeNull(); }); it("advances to the read-only workspace after a successful pair", async () => { const session = new WebSession({ baseUrl: "https://h", fetchImpl: okFetch, store: memStore() }); renderWebApp(session, await seededGateways()); fireEvent.change(screen.getByLabelText("Code d'appairage"), { target: { value: "4821-93" } }); fireEvent.click(screen.getByRole("button", { name: "Appairer" })); expect(await screen.findByText("Projets")).toBeTruthy(); expect(await screen.findByText("Demo")).toBeTruthy(); expect(session.isPaired()).toBe(true); }); it("shows a clear error on a wrong code and stays on pairing", async () => { const session = new WebSession({ baseUrl: "https://h", fetchImpl: rejectFetch, store: memStore() }); renderWebApp(session, await seededGateways()); fireEvent.change(screen.getByLabelText("Code d'appairage"), { target: { value: "nope" } }); fireEvent.click(screen.getByRole("button", { name: "Appairer" })); expect(await screen.findByTestId("pairing-error")).toBeTruthy(); expect(screen.queryByText("Projets")).toBeNull(); expect(session.isPaired()).toBe(false); }); it("opens a project read-only and renders the work-state snapshot", async () => { const session = new WebSession({ baseUrl: "https://h", fetchImpl: okFetch, store: memStore() }); session.markPaired(); renderWebApp(session, await seededGateways()); // Workspace is shown directly (already paired); open the seeded project. fireEvent.click(await screen.findByText("Demo")); const snapshot = await screen.findByTestId("web-workstate"); expect(snapshot.textContent).toContain("Archi"); expect(snapshot.textContent).toContain("Idle"); }); it("opens a live agent cell for a work-state agent (F4 affordance)", async () => { const session = new WebSession({ baseUrl: "https://h", fetchImpl: okFetch, store: memStore() }); session.markPaired(); renderWebApp(session, await seededGateways()); fireEvent.click(await screen.findByText("Demo")); await screen.findByTestId("web-workstate"); // The per-agent "Ouvrir" affordance mounts the reusable TerminalView cell, // wired to the DI agent gateway (the CLI runs server-side). expect(screen.queryByTestId("web-agent-cell")).toBeNull(); fireEvent.click(screen.getByRole("button", { name: "Ouvrir" })); expect(await screen.findByTestId("web-agent-cell")).toBeTruthy(); }); it("returns to pairing when the session reports unauthorized (401)", async () => { const session = new WebSession({ baseUrl: "https://h", fetchImpl: okFetch, store: memStore() }); session.markPaired(); renderWebApp(session, await seededGateways()); expect(await screen.findByText("Projets")).toBeTruthy(); act(() => session.notifyUnauthorized()); await waitFor(() => expect(screen.getByText("Appairer cet appareil")).toBeTruthy()); expect(screen.queryByText("Projets")).toBeNull(); }); });