/** * Ticket #54 F1 — model-server download overlay on agent cells. * * When a pinned agent's OpenCode profile binds a local model server and that * server emits `downloading` / `starting` / `probing` (via the existing * `modelServerStatusChanged` stream), a full-cell overlay covers the cell — so * the boot no longer looks like a hung/timed-out empty terminal. The overlay * retracts at `ready`/`failed`. * * Correlation is `agentId → profileId → opencode.localModelServerId → serverId`: * two cells whose agents share the same bound server are BOTH veiled by a single * event on that server. xterm is stubbed so the terminal mounts under jsdom. */ import { beforeEach, describe, it, expect, vi } from "vitest"; import { render, screen, waitFor, configure } from "@testing-library/react"; // Delivery of a `modelServerStatusChanged` event is made deterministic by // `trackSubscriptions` (no lost event). What remains is *propagation* latency: // the overlay only shows once the hook's async profile + agent loads resolve, so // the assertion is a guaranteed-eventual state. Under the full-suite parallel // run (22 files) the worker's event loop can be starved past the 1000 ms default // async-util budget — a scheduling delay, not a logic race. A generous timeout // removes that flake without weakening any assertion (the state is guaranteed to // arrive; we only wait long enough for it under load). configure({ asyncUtilTimeout: 5000 }); vi.mock("@xterm/xterm", () => ({ Terminal: class { loadAddon() {} open() {} onData() { return { dispose() {} }; } onResize() { return { dispose() {} }; } write() {} dispose() {} get cols() { return 80; } get rows() { return 24; } }, })); vi.mock("@xterm/addon-fit", () => ({ FitAddon: class { fit() {} }, })); vi.mock("@xterm/xterm/css/xterm.css", () => ({})); if (typeof globalThis.ResizeObserver === "undefined") { globalThis.ResizeObserver = class { observe() {} unobserve() {} disconnect() {} } as unknown as typeof ResizeObserver; } import type { AgentProfile, ModelServerStatus } from "@/domain"; import type { Gateways } from "@/ports"; import { MockAgentGateway, MockInputGateway, MockLayoutGateway, MockProfileGateway, MockSystemGateway, MockTerminalGateway, } from "@/adapters/mock"; import { DIProvider } from "@/app/di"; import { leaves } from "./layout"; import { LayoutGrid } from "./LayoutGrid"; const SERVER_ID = "srv-local-1"; /** A profile that binds the given local model server through OpenCode. */ function localModelProfile(id: string): AgentProfile { return { id, name: "OpenCode + llama.cpp", command: "opencode", args: [], contextInjection: { strategy: "conventionFile", target: "AGENTS.md" }, detect: null, cwdTemplate: "{projectRoot}", structuredAdapter: "openCode", opencode: { baseURL: "http://localhost:8080/v1", apiKey: "sk-no-key", model: "qwen3-coder-30b", localModelServerId: SERVER_ID, }, }; } function makeGateways( agentGateway: MockAgentGateway, profileGateway: MockProfileGateway, systemGateway: MockSystemGateway, ): Gateways { return { layout: new MockLayoutGateway(), agent: agentGateway, profile: profileGateway, terminal: new MockTerminalGateway(), system: systemGateway, input: new MockInputGateway(), } as unknown as Gateways; } function renderGrid(gateways: Gateways) { return render( , ); } /** * Emit `status` and KEEP re-emitting it until `check` passes (the emit runs * inside `waitFor`, whose async wrapper already provides `act`). This kills the * emit-vs-subscribe race at the root — with no need to identify *which* * `onDomainEvent` subscriber is the overlay hook's. * * Why the previous `waitSubscribed(count > 0)` gate was insufficient: several * components subscribe to the event stream, and the ones outside `LeafView` * mount *before* the async layout load brings `LeafView` (and its overlay hook) * into the tree — so the global subscriber count crosses 0 while the overlay * hook's own handler is not yet registered, and a single emit gated on it is * still dropped. Re-emitting is safe because the reducer stores an idempotent * per-server value: whichever attempt lands *after* the overlay hook subscribed * and its profile/agent loads resolved is the one that makes `check` pass; the * earlier (possibly dropped) attempts are no-ops. Used for BOTH appearance and * retraction assertions so no case can regress into the race. */ async function emitUntil( systemGateway: MockSystemGateway, status: ModelServerStatus, check: () => void, ): Promise { await waitFor(() => { systemGateway.emit({ type: "modelServerStatusChanged", serverId: SERVER_ID, status, }); check(); }); } beforeEach(() => { vi.clearAllMocks(); }); describe("LayoutGrid — model-server launch overlay (ticket #54)", () => { it("veils the cell while downloading, then retracts at ready", async () => { const profileGateway = new MockProfileGateway(); await profileGateway.saveProfile(localModelProfile("opencode-local")); const agentGateway = new MockAgentGateway(); const agent = await agentGateway.createAgent("p1", { name: "Worker", profileId: "opencode-local", }); const systemGateway = new MockSystemGateway(); const gateways = makeGateways(agentGateway, profileGateway, systemGateway); // Pin the agent onto the single default cell. const layout = gateways.layout as MockLayoutGateway; const leafId = leaves(await layout.loadLayout("p1"))[0].id; await layout.mutateLayout("p1", { type: "setCellAgent", target: leafId, agent: agent.id, }); renderGrid(gateways); // The cell exists but no status has been emitted yet → no overlay. await screen.findByTestId("layout-leaf"); expect(screen.queryByTestId("model-server-overlay")).toBeNull(); // Downloading → overlay with the download wording. await emitUntil( systemGateway, { state: "downloading", downloadedBytes: null, totalBytes: null, percent: null, source: null, }, () => expect( screen.getByTestId("model-server-overlay").textContent, ).toContain("Téléchargement du modèle"), ); // Ready → overlay gone. await emitUntil(systemGateway, { state: "ready", reused: false }, () => expect(screen.queryByTestId("model-server-overlay")).toBeNull(), ); }); it("shows 'Chargement du serveur…' for starting/probing and hides at failed", async () => { const profileGateway = new MockProfileGateway(); await profileGateway.saveProfile(localModelProfile("opencode-local")); const agentGateway = new MockAgentGateway(); const agent = await agentGateway.createAgent("p1", { name: "Worker", profileId: "opencode-local", }); const systemGateway = new MockSystemGateway(); const gateways = makeGateways(agentGateway, profileGateway, systemGateway); const layout = gateways.layout as MockLayoutGateway; const leafId = leaves(await layout.loadLayout("p1"))[0].id; await layout.mutateLayout("p1", { type: "setCellAgent", target: leafId, agent: agent.id, }); renderGrid(gateways); await emitUntil(systemGateway, { state: "probing" }, () => expect( screen.getByTestId("model-server-overlay").textContent, ).toContain("Chargement du serveur"), ); await emitUntil(systemGateway, { state: "starting" }, () => expect( screen.getByTestId("model-server-overlay").textContent, ).toContain("Chargement du serveur"), ); // No progressbar for a non-download preparing state. expect(screen.queryByTestId("model-server-progress")).toBeNull(); await emitUntil( systemGateway, { state: "failed", code: "spawn", message: "boom" }, () => expect(screen.queryByTestId("model-server-overlay")).toBeNull(), ); }); it("veils EVERY cell whose agent references the downloading server", async () => { const profileGateway = new MockProfileGateway(); await profileGateway.saveProfile(localModelProfile("opencode-local")); const agentGateway = new MockAgentGateway(); const agentA = await agentGateway.createAgent("p1", { name: "A", profileId: "opencode-local", }); const agentB = await agentGateway.createAgent("p1", { name: "B", profileId: "opencode-local", }); const systemGateway = new MockSystemGateway(); const gateways = makeGateways(agentGateway, profileGateway, systemGateway); // Split the root into two cells, pin one agent onto each. const layout = gateways.layout as MockLayoutGateway; const rootLeaf = leaves(await layout.loadLayout("p1"))[0].id; await layout.mutateLayout("p1", { type: "split", target: rootLeaf, direction: "row", newLeaf: "cell-b", container: "split-c", }); await layout.mutateLayout("p1", { type: "setCellAgent", target: rootLeaf, agent: agentA.id, }); await layout.mutateLayout("p1", { type: "setCellAgent", target: "cell-b", agent: agentB.id, }); renderGrid(gateways); // Both cells carry the overlay from a single server event (re-emitted until // both cells' overlay hooks have subscribed and correlated the server). await emitUntil( systemGateway, { state: "downloading", downloadedBytes: null, totalBytes: null, percent: null, source: null, }, () => expect(screen.getAllByTestId("model-server-overlay").length).toBe(2), ); await emitUntil(systemGateway, { state: "ready", reused: true }, () => expect(screen.queryAllByTestId("model-server-overlay").length).toBe(0), ); }); }); /** Render a single pinned-agent cell and return its system gateway. */ async function renderSinglePinnedCell(): Promise { const profileGateway = new MockProfileGateway(); await profileGateway.saveProfile(localModelProfile("opencode-local")); const agentGateway = new MockAgentGateway(); const agent = await agentGateway.createAgent("p1", { name: "Worker", profileId: "opencode-local", }); const systemGateway = new MockSystemGateway(); const gateways = makeGateways(agentGateway, profileGateway, systemGateway); const layout = gateways.layout as MockLayoutGateway; const leafId = leaves(await layout.loadLayout("p1"))[0].id; await layout.mutateLayout("p1", { type: "setCellAgent", target: leafId, agent: agent.id, }); renderGrid(gateways); // No subscription gate needed: every emission goes through `emitUntil`, which // re-emits until the overlay hook has subscribed and correlated the server. return systemGateway; } describe("LayoutGrid — download progress (ticket #54 F2)", () => { it("known percent → determinate progressbar + % + formatted bytes + source", async () => { const systemGateway = await renderSinglePinnedCell(); await emitUntil( systemGateway, { state: "downloading", downloadedBytes: 1_500_000, totalBytes: 3_000_000, percent: 50, source: "unsloth/Qwen3-Coder-30B", }, () => expect( screen.getByTestId("model-server-progress").getAttribute("aria-valuenow"), ).toBe("50"), ); const bar = screen.getByTestId("model-server-progress"); expect(bar.getAttribute("role")).toBe("progressbar"); expect(bar.getAttribute("aria-valuemin")).toBe("0"); expect(bar.getAttribute("aria-valuemax")).toBe("100"); const label = screen.getByTestId("model-server-progress-label"); expect(label.textContent).toContain("50 %"); expect(label.textContent).toContain("1.5 Mo / 3 Mo"); expect(screen.getByTestId("model-server-source").textContent).toBe( "unsloth/Qwen3-Coder-30B", ); }); it("unknown total → indeterminate bar, NO fake %, shows downloaded bytes", async () => { const systemGateway = await renderSinglePinnedCell(); await emitUntil( systemGateway, { state: "downloading", downloadedBytes: 800_000, totalBytes: null, percent: null, source: null, }, () => expect(screen.getByTestId("model-server-progress")).toBeTruthy(), ); const bar = screen.getByTestId("model-server-progress"); // Indeterminate: no aria-valuenow set. expect(bar.hasAttribute("aria-valuenow")).toBe(false); const label = screen.getByTestId("model-server-progress-label"); expect(label.textContent).not.toContain("%"); expect(label.textContent).toContain("800 Ko"); // No source line when the backend sends none. expect(screen.queryByTestId("model-server-source")).toBeNull(); }); it("starting/probing show the title but NO progressbar", async () => { const systemGateway = await renderSinglePinnedCell(); await emitUntil(systemGateway, { state: "starting" }, () => expect( screen.getByTestId("model-server-overlay").textContent, ).toContain("Chargement du serveur"), ); expect(screen.queryByTestId("model-server-progress")).toBeNull(); }); it("later progress events update the bar in place", async () => { const systemGateway = await renderSinglePinnedCell(); await emitUntil( systemGateway, { state: "downloading", downloadedBytes: 1_000_000, totalBytes: 4_000_000, percent: 25, source: null, }, () => expect( screen.getByTestId("model-server-progress").getAttribute("aria-valuenow"), ).toBe("25"), ); await emitUntil( systemGateway, { state: "downloading", downloadedBytes: 3_000_000, totalBytes: 4_000_000, percent: 75, source: null, }, () => expect( screen.getByTestId("model-server-progress").getAttribute("aria-valuenow"), ).toBe("75"), ); expect( screen.getByTestId("model-server-progress-label").textContent, ).toContain("3 Mo / 4 Mo"); }); });