/** * L4 — {@link LayoutGrid} wired through the real {@link DIProvider} with the * in-memory {@link MockLayoutGateway} (+ {@link MockTerminalGateway} for the * leaves' embedded {@link TerminalView}). * * Under jsdom xterm's `term.open` may bail gracefully (no real layout engine), * so these tests assert the *structure* the grid renders (cells, no throw) * rather than xterm's visual output. They stay robust whether or not xterm * bailed. */ import { describe, it, expect, vi } from "vitest"; import { render, screen, waitFor, fireEvent } from "@testing-library/react"; import type { Gateways } from "@/ports"; import { MockAgentGateway, MockLayoutGateway, MockTerminalGateway, MockWorkStateGateway, } from "@/adapters/mock"; import { DIProvider } from "@/app/di"; import { leaves } from "./layout"; import { LayoutGrid } from "./LayoutGrid"; function renderGrid(layout: MockLayoutGateway, projectId = "p1") { const gateways = { layout, terminal: new MockTerminalGateway(), } as unknown as Gateways; return render( , ); } describe("LayoutGrid (with MockLayoutGateway)", () => { it("falls back to an empty workspace with a user-facing message when layout restore fails", async () => { const layout = new MockLayoutGateway(); vi.spyOn(layout, "loadLayout").mockRejectedValueOnce( new Error("unknown command: load_layout"), ); const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); renderGrid(layout); const alert = await screen.findByRole("alert"); expect(alert.textContent).toContain( "Impossible de restaurer l’espace de travail de ce projet.", ); expect(alert.textContent).not.toContain("unknown command"); await waitFor(() => { expect(screen.getAllByTestId("layout-leaf")).toHaveLength(1); }); expect(warn).toHaveBeenCalledWith( "Layout restore failed", expect.objectContaining({ message: "unknown command: load_layout" }), ); warn.mockRestore(); }); it("renders a single-leaf layout without throwing", async () => { const layout = new MockLayoutGateway(); expect(() => renderGrid(layout)).not.toThrow(); // The grid container always renders. expect(screen.getByTestId("layout-grid")).toBeTruthy(); // Once loaded, the single leaf cell is shown. await waitFor(() => { expect(screen.getAllByTestId("layout-leaf")).toHaveLength(1); }); }); it("renders N cells for a pre-split tree", async () => { const layout = new MockLayoutGateway(); // Pre-split the project's tree into two leaves before rendering. const initial = await layout.loadLayout("p1"); await layout.mutateLayout("p1", { type: "split", target: leaves(initial)[0].id, direction: "row", newLeaf: "b", container: "c", }); renderGrid(layout); await waitFor(() => { expect(screen.getAllByTestId("layout-leaf")).toHaveLength(2); }); // The split container is present with its direction recorded. const split = screen.getByTestId("layout-split"); expect(split.getAttribute("data-direction")).toBe("row"); }); it("stays graceful (no throw) even though xterm bails under jsdom", async () => { const layout = new MockLayoutGateway(); // Render a 3-leaf tree: nested splits, each leaf hosts a TerminalView. const initial = await layout.loadLayout("p1"); const a = leaves(initial)[0].id; await layout.mutateLayout("p1", { type: "split", target: a, direction: "row", newLeaf: "b", container: "c", }); await layout.mutateLayout("p1", { type: "split", target: "b", direction: "column", newLeaf: "d", container: "e", }); expect(() => renderGrid(layout)).not.toThrow(); await waitFor(() => { expect(screen.getAllByTestId("layout-leaf")).toHaveLength(3); }); }); it("closing a cell removes THAT cell and keeps its sibling without killing its session", async () => { const layout = new MockLayoutGateway(); const terminal = new MockTerminalGateway(); const closeSpy = vi.spyOn(terminal, "closeTerminal"); const initial = await layout.loadLayout("p1"); const a = leaves(initial)[0].id; // Split A → container c with children [A (index 0), b (index 1)]. await layout.mutateLayout("p1", { type: "split", target: a, direction: "row", newLeaf: "b", container: "c", }); await layout.mutateLayout("p1", { type: "setSession", target: "b", session: "running-session-b", }); const gateways = { layout, terminal } as unknown as Gateways; render( , ); await waitFor(() => expect(screen.getAllByTestId("layout-leaf")).toHaveLength(2), ); // Click the close button on cell `b` — `b` must disappear, `a` must remain. fireEvent.click(screen.getByLabelText("close b")); await waitFor(() => expect(screen.getAllByTestId("layout-leaf")).toHaveLength(1), ); expect( screen.getByTestId("layout-leaf").getAttribute("data-node-id"), ).toBe(a); expect(closeSpy).not.toHaveBeenCalled(); }); it("closing the other cell keeps the opposite sibling", async () => { const layout = new MockLayoutGateway(); const initial = await layout.loadLayout("p1"); const a = leaves(initial)[0].id; await layout.mutateLayout("p1", { type: "split", target: a, direction: "row", newLeaf: "b", container: "c", }); renderGrid(layout); await waitFor(() => expect(screen.getAllByTestId("layout-leaf")).toHaveLength(2), ); // Closing `a` (index 0) must keep `b` (index 1). fireEvent.click(screen.getByLabelText(`close ${a}`)); await waitFor(() => expect(screen.getAllByTestId("layout-leaf")).toHaveLength(1), ); expect( screen.getByTestId("layout-leaf").getAttribute("data-node-id"), ).toBe("b"); }); it("the sole root cell has no close button (cannot be closed)", async () => { const layout = new MockLayoutGateway(); renderGrid(layout); await waitFor(() => expect(screen.getAllByTestId("layout-leaf")).toHaveLength(1), ); expect(screen.queryByLabelText(/^close /)).toBeNull(); }); it("renders the loading/empty container when the layout gateway is absent", () => { // A DI subset without the layout gateway → useLayout is defensive (null). const gateways = { terminal: new MockTerminalGateway() } as unknown as Gateways; expect(() => render( , ), ).not.toThrow(); expect(screen.getByTestId("layout-grid")).toBeTruthy(); }); it("renders an explicit stopped-agent cell without showing it as idle", async () => { const layout = new MockLayoutGateway(); const agent = new MockAgentGateway(); const workState = new MockWorkStateGateway(); const created = await agent.createAgent("p1", { name: "StoppedAgent", profileId: "prof-1", }); const tree = await layout.loadLayout("p1"); const leafId = leaves(tree)[0].id; await layout.mutateLayout("p1", { type: "setCellAgent", target: leafId, agent: created.id, }); await layout.mutateLayout("p1", { type: "setSession", target: leafId, session: "dead-session", }); workState._setProjectWorkState("p1", { agents: [ { agentId: created.id, name: "StoppedAgent", profileId: "prof-1", runtimeState: "stopped", busy: { state: "idle" }, }, ], }); render( , ); expect(await screen.findByLabelText("StoppedAgent arrêté")).toBeTruthy(); expect(screen.getByText("Arrêté")).toBeTruthy(); expect(screen.getByText("Cette session est terminée.")).toBeTruthy(); expect(screen.getByText("Ouvre une nouvelle session pour cet agent.")).toBeTruthy(); expect(screen.getByRole("button", { name: "Relancer" })).toBeTruthy(); expect(screen.getByRole("button", { name: "Actualiser" })).toBeTruthy(); expect(screen.queryByText("Idle")).toBeNull(); }); it("relaunches a stopped agent from scratch in the same cell", async () => { const layout = new MockLayoutGateway(); const agent = new MockAgentGateway(); const workState = new MockWorkStateGateway(); const launchSpy = vi.spyOn(agent, "launchAgent"); const created = await agent.createAgent("p1", { name: "StoppedAgent", profileId: "prof-1", }); const tree = await layout.loadLayout("p1"); const leafId = leaves(tree)[0].id; await layout.mutateLayout("p1", { type: "setCellAgent", target: leafId, agent: created.id, }); await layout.mutateLayout("p1", { type: "setSession", target: leafId, session: "dead-session", }); await layout.mutateLayout("p1", { type: "setCellConversation", target: leafId, conversationId: "old-conversation", }); workState._setProjectWorkState("p1", { agents: [ { agentId: created.id, name: "StoppedAgent", profileId: "prof-1", runtimeState: "stopped", busy: { state: "idle" }, }, ], }); render( , ); fireEvent.click(await screen.findByRole("button", { name: "Relancer" })); await waitFor(() => expect(launchSpy).toHaveBeenCalledTimes(1)); expect(launchSpy.mock.calls[0]?.[0]).toBe("p1"); expect(launchSpy.mock.calls[0]?.[1]).toBe(created.id); expect(launchSpy.mock.calls[0]?.[2]).toMatchObject({ cwd: "/home/me/proj", nodeId: leafId, freshStart: true, }); expect(launchSpy.mock.calls[0]?.[2].conversationId).toBeUndefined(); await waitFor(async () => { const fresh = await layout.loadLayout("p1"); const leaf = leaves(fresh)[0]; expect(leaf.agent).toBe(created.id); expect(leaf.session).toMatch(/^mock-agent-session-/); expect(leaf.conversationId).toMatch(/^mock-conversation-/); }); }); it("refreshes a stopped agent state without relaunching", async () => { const layout = new MockLayoutGateway(); const agent = new MockAgentGateway(); const workState = new MockWorkStateGateway(); const refreshSpy = vi.spyOn(workState, "getProjectWorkState"); const launchSpy = vi.spyOn(agent, "launchAgent"); const created = await agent.createAgent("p1", { name: "StoppedAgent", profileId: "prof-1", }); const tree = await layout.loadLayout("p1"); const leafId = leaves(tree)[0].id; await layout.mutateLayout("p1", { type: "setCellAgent", target: leafId, agent: created.id, }); await layout.mutateLayout("p1", { type: "setSession", target: leafId, session: "dead-session", }); workState._setProjectWorkState("p1", { agents: [ { agentId: created.id, name: "StoppedAgent", profileId: "prof-1", runtimeState: "stopped", busy: { state: "idle" }, }, ], }); render( , ); fireEvent.click(await screen.findByRole("button", { name: "Actualiser" })); await waitFor(() => expect(refreshSpy).toHaveBeenCalledTimes(2)); expect(launchSpy).not.toHaveBeenCalled(); }); it("shows the transient restart label while relaunching", async () => { const layout = new MockLayoutGateway(); const agent = new MockAgentGateway(); const workState = new MockWorkStateGateway(); let resolveLaunch!: (value: Awaited>) => void; const launchPromise = new Promise>>( (resolve) => { resolveLaunch = resolve; }, ); vi.spyOn(agent, "launchAgent").mockReturnValue(launchPromise); const created = await agent.createAgent("p1", { name: "StoppedAgent", profileId: "prof-1", }); const tree = await layout.loadLayout("p1"); const leafId = leaves(tree)[0].id; await layout.mutateLayout("p1", { type: "setCellAgent", target: leafId, agent: created.id, }); workState._setProjectWorkState("p1", { agents: [ { agentId: created.id, name: "StoppedAgent", profileId: "prof-1", runtimeState: "stopped", busy: { state: "idle" }, }, ], }); render( , ); fireEvent.click(await screen.findByRole("button", { name: "Relancer" })); await waitFor(() => expect(screen.getAllByText("Relance…")).toHaveLength(2)); expect( (screen.getByRole("button", { name: "Relance…" }) as HTMLButtonElement) .disabled, ).toBe(true); resolveLaunch({ sessionId: "mock-agent-session-deferred", assignedConversationId: "mock-conversation-deferred", write: async () => {}, resize: async () => {}, detach: () => {}, close: async () => {}, }); }); it("shows the stopped-cell restart error copy when relaunch fails", async () => { const layout = new MockLayoutGateway(); const agent = new MockAgentGateway(); const workState = new MockWorkStateGateway(); const created = await agent.createAgent("p1", { name: "StoppedAgent", profileId: "prof-1", }); vi.spyOn(agent, "launchAgent").mockRejectedValue( new Error("launch failed"), ); const tree = await layout.loadLayout("p1"); const leafId = leaves(tree)[0].id; await layout.mutateLayout("p1", { type: "setCellAgent", target: leafId, agent: created.id, }); workState._setProjectWorkState("p1", { agents: [ { agentId: created.id, name: "StoppedAgent", profileId: "prof-1", runtimeState: "stopped", busy: { state: "idle" }, }, ], }); render( , ); fireEvent.click(await screen.findByRole("button", { name: "Relancer" })); expect(await screen.findByText("Erreur de relance")).toBeTruthy(); expect( screen.getByText("Impossible de relancer l’agent. Réessayez."), ).toBeTruthy(); }); });