/** * "One live session per agent" invariant (UI side). * * - The mock agent gateway mirrors the backend guard: launching an agent already * live in another cell is refused with `AGENT_ALREADY_RUNNING`, and the same * node is idempotent; `listLiveAgents` reports who runs where (T5). * - The per-cell dropdown blocks an agent already visible in another cell, but * can rebind a background live session when the backend exposes its session id. */ import { describe, it, expect, vi } from "vitest"; import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import type { Gateways, LiveAgent } from "@/ports"; import { MockLayoutGateway, MockAgentGateway, type GatewayError, } from "@/adapters/mock"; import { DIProvider } from "@/app/di"; import { LayoutGrid } from "./LayoutGrid"; import { leaves } from "./layout"; const noop = () => {}; describe("singleton agent — mock gateway guard (T5)", () => { it("refuses launching an agent already running in another cell", async () => { const agent = new MockAgentGateway(); const a = await agent.createAgent("p1", { name: "Solo", profileId: "p" }); // Launch in cell A. await agent.launchAgent( "p1", a.id, { cwd: "/x", rows: 24, cols: 80, nodeId: "node-A" }, noop, ); // Launching the SAME agent in cell B is refused. let caught: GatewayError | null = null; await agent .launchAgent( "p1", a.id, { cwd: "/x", rows: 24, cols: 80, nodeId: "node-B" }, noop, ) .catch((e) => { caught = e as GatewayError; }); expect(caught).not.toBeNull(); expect(caught!.code).toBe("AGENT_ALREADY_RUNNING"); }); it("listLiveAgents reports the cell hosting each live agent", async () => { const agent = new MockAgentGateway(); const a = await agent.createAgent("p1", { name: "Solo", profileId: "p" }); expect(await agent.listLiveAgents("p1")).toEqual([]); await agent.launchAgent( "p1", a.id, { cwd: "/x", rows: 24, cols: 80, nodeId: "node-A" }, noop, ); const live: LiveAgent[] = await agent.listLiveAgents("p1"); expect(live).toEqual([ { agentId: a.id, nodeId: "node-A", sessionId: "mock-agent-session-1", kind: "pty", }, ]); }); it("relaunching in the SAME node is allowed (idempotent), not refused", async () => { const agent = new MockAgentGateway(); const a = await agent.createAgent("p1", { name: "Solo", profileId: "p" }); await agent.launchAgent( "p1", a.id, { cwd: "/x", rows: 24, cols: 80, nodeId: "node-A" }, noop, ); // Same node again must NOT throw. await expect( agent.launchAgent( "p1", a.id, { cwd: "/x", rows: 24, cols: 80, nodeId: "node-A" }, noop, ), ).resolves.toBeDefined(); }); }); describe("singleton agent — dropdown guard (T6)", () => { function renderGrid(layout: MockLayoutGateway, agent: MockAgentGateway) { // A system gateway stub so LeafView's event subscription is a no-op. const system = { onDomainEvent: vi.fn().mockResolvedValue(() => {}), }; const gateways = { layout, agent, system } as unknown as Gateways; return render( , ); } it("opens an agent already running elsewhere by rebinding its live session", async () => { const layout = new MockLayoutGateway(); const agent = new MockAgentGateway(); const a = await agent.createAgent("p1", { name: "Busy", profileId: "p" }); await agent.launchAgent( "p1", a.id, { cwd: "/x", rows: 24, cols: 80, nodeId: "old-node" }, noop, ); const tree = await layout.loadLayout("p1"); const leafId = leaves(tree)[0].id; renderGrid(layout, agent); // The option is selectable: choosing it rebinds the running session here. const option = await screen.findByRole("option", { name: /Busy/, }); expect((option as HTMLOptionElement).disabled).toBe(false); const select = screen.getByRole("combobox") as HTMLSelectElement; fireEvent.change(select, { target: { value: a.id } }); await waitFor(async () => { const updated = await layout.loadLayout("p1"); const leaf = leaves(updated).find((l) => l.id === leafId)!; expect(leaf.agent).toBe(a.id); expect(leaf.session).toBe("mock-agent-session-1"); }); expect(await agent.listLiveAgents("p1")).toEqual([ { agentId: a.id, nodeId: leafId, sessionId: "mock-agent-session-1", kind: "pty", }, ]); }); it("explains the missing backend contract when a live agent has no attachable session", async () => { const layout = new MockLayoutGateway(); const agent = new MockAgentGateway(); const a = await agent.createAgent("p1", { name: "Busy", profileId: "p" }); vi.spyOn(agent, "listLiveAgents").mockResolvedValue([ { agentId: a.id, nodeId: "some-other-node" } as LiveAgent, ]); renderGrid(layout, agent); const option = await screen.findByRole("option", { name: /Busy/, }); expect((option as HTMLOptionElement).disabled).toBe(false); const select = screen.getByRole("combobox") as HTMLSelectElement; fireEvent.change(select, { target: { value: a.id } }); expect((await screen.findByRole("status")).textContent).toMatch( /Session active introuvable/, ); }); it("the agent pinned on THIS cell stays selectable even while live (same node)", async () => { const layout = new MockLayoutGateway(); const agent = new MockAgentGateway(); const a = await agent.createAgent("p1", { name: "Mine", profileId: "p" }); // Pin the agent on the single leaf. const tree = await layout.loadLayout("p1"); const leafId = leaves(tree)[0].id; await layout.mutateLayout("p1", { type: "setCellAgent", target: leafId, agent: a.id, }); // The agent is live in THIS very cell (same node id as the leaf). vi.spyOn(agent, "listLiveAgents").mockResolvedValue([ { agentId: a.id, nodeId: leafId, sessionId: "s-own", kind: "pty" }, ]); renderGrid(layout, agent); const option = await screen.findByRole("option", { name: "Mine" }); // Live on its own cell ⇒ NOT disabled (it can keep running here). expect((option as HTMLOptionElement).disabled).toBe(false); }); }); describe("R0d — dropdown disables agents live elsewhere + go-to-cell", () => { function renderGrid(layout: MockLayoutGateway, agent: MockAgentGateway) { const system = { onDomainEvent: vi.fn().mockResolvedValue(() => {}), }; const gateways = { layout, agent, system } as unknown as Gateways; return render( , ); } /** Splits the project's single leaf into two visible leaves; returns both ids. */ async function splitInTwo( layout: MockLayoutGateway, ): Promise<{ a: string; b: string }> { 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", }); const after = leaves(await layout.loadLayout("p1")); return { a, b: after.find((l) => l.id !== a)!.id }; } it("an agent live in another VISIBLE cell ⇒ option disabled + 'aller à la cellule'", async () => { const layout = new MockLayoutGateway(); const agent = new MockAgentGateway(); const ag = await agent.createAgent("p1", { name: "Busy", profileId: "p" }); const { a, b } = await splitInTwo(layout); // The agent is live in cell B (a currently-visible leaf). vi.spyOn(agent, "listLiveAgents").mockResolvedValue([ { agentId: ag.id, nodeId: b, sessionId: "s-1", kind: "pty" }, ]); renderGrid(layout, agent); // In cell A's dropdown the option is DISABLED (cannot run in two cells). const selectA = (await screen.findByLabelText( `agent selector ${a}`, )) as HTMLSelectElement; await waitFor(() => { const opt = Array.from(selectA.options).find((o) => o.value === ag.id)!; expect(opt.disabled).toBe(true); expect(opt.textContent).toMatch(/visible ailleurs/); }); // Selecting it surfaces a clear notice + an "aller à la cellule" action. fireEvent.change(selectA, { target: { value: ag.id } }); expect((await screen.findByRole("status")).textContent).toMatch( /déjà actif dans une autre cellule/, ); expect( screen.getByRole("button", { name: "aller à la cellule" }), ).toBeTruthy(); }); it("an agent NOT live anywhere stays selectable (no regression)", async () => { const layout = new MockLayoutGateway(); const agent = new MockAgentGateway(); const ag = await agent.createAgent("p1", { name: "Free", profileId: "p" }); // No live agents at all. vi.spyOn(agent, "listLiveAgents").mockResolvedValue([]); renderGrid(layout, agent); const option = await screen.findByRole("option", { name: "Free" }); expect((option as HTMLOptionElement).disabled).toBe(false); // And selecting it pins the agent on the cell (no notice, no block). const leafId = leaves(await layout.loadLayout("p1"))[0].id; const select = screen.getByRole("combobox") as HTMLSelectElement; fireEvent.change(select, { target: { value: ag.id } }); await waitFor(async () => { const leaf = leaves(await layout.loadLayout("p1")).find( (l) => l.id === leafId, )!; expect(leaf.agent).toBe(ag.id); }); expect(screen.queryByRole("status")).toBeNull(); }); });