Files
IdeA/frontend/src/features/terminals/TerminalView.test.tsx
Blomios b39c11a64d fix(agents): enforcer l'invariant « 1 session vivante par agent » (singleton)
Un agent ne peut tourner que dans une seule cellule à la fois. La garde dans
LaunchAgent refuse le spawn si l'agent est déjà vivant dans un autre node
(AGENT_ALREADY_RUNNING) ; idempotent sur le même node ; le chemin resume
(agent mort) reste inchangé. Le node_id est désormais plombé jusqu'au use case.

Corrige le reset asymétrique d'une cellule au changement d'onglet : deux leaves
partageant le même agent id rendaient session_for_agent/is_agent_live/stop_agent
ambigus (cible arbitraire). Le churn reset/reattach déclenchait aussi les accents
mélangés (FIFO intact, non touché).

- snapshot agentWasRunning calculé par node (is_node_live) et non par agent
- commande list_live_agents + live_agents()/node_for_agent()/is_node_live()
- UI : dropdown grise les agents déjà placés ailleurs ; 2e cellule en doublon
  affiche « disponible » au lieu d'une relance fantôme

Tests : cargo test (application + app-tauri) vert ; tsc + vitest vert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 14:43:48 +02:00

208 lines
7.3 KiB
TypeScript

/**
* L3 — the xterm wrapper {@link TerminalView} wired to {@link MockTerminalGateway}
* through the real {@link DIProvider}.
*
* Under jsdom xterm's `term.open` may bail gracefully (no real layout engine),
* so these tests assert the *wiring contract* (mounts without throwing, talks to
* the gateway port, tears down on unmount) rather than xterm's visual rendering.
*
* The core lifecycle invariant tested here: unmounting the view (navigation /
* layout change) must **detach**, NEVER **close** — the backend PTY must survive
* so a running AI isn't cut off. Re-mounting with a known session re-attaches.
*/
import { describe, it, expect, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import type {
Gateways,
ReattachResult,
TerminalGateway,
TerminalHandle,
} from "@/ports";
import { MockTerminalGateway } from "@/adapters/mock";
import { DIProvider } from "@/app/di";
import { TerminalView } from "./TerminalView";
function makeHandle(overrides: Partial<TerminalHandle> = {}): TerminalHandle {
return {
sessionId: "s1",
write: vi.fn().mockResolvedValue(undefined),
resize: vi.fn().mockResolvedValue(undefined),
detach: vi.fn(),
close: vi.fn().mockResolvedValue(undefined),
...overrides,
};
}
function renderView(
terminal: TerminalGateway,
cwd = "/home/me/proj",
extra?: Partial<React.ComponentProps<typeof TerminalView>>,
) {
const gateways = { terminal } as unknown as Gateways;
return render(
<DIProvider gateways={gateways}>
<TerminalView cwd={cwd} {...extra} />
</DIProvider>,
);
}
describe("TerminalView (with MockTerminalGateway)", () => {
it("mounts and renders the terminal-view container without throwing", () => {
renderView(new MockTerminalGateway());
expect(screen.getByTestId("terminal-view")).toBeTruthy();
});
it("opens a terminal through the gateway with the given cwd", async () => {
const gw = new MockTerminalGateway();
const openSpy = vi.spyOn(gw, "openTerminal");
renderView(gw, "/my/cwd");
// The effect wires the gateway only if xterm.open didn't bail. If it did
// bail (headless jsdom), openTerminal is simply never called — both are a
// valid, non-throwing wiring outcome, so we only assert the call shape when
// it happened.
await waitFor(() => {
if (openSpy.mock.calls.length > 0) {
expect(openSpy.mock.calls[0][0]).toMatchObject({ cwd: "/my/cwd" });
expect(typeof openSpy.mock.calls[0][1]).toBe("function");
}
});
expect(true).toBe(true);
});
it("consuming gateway output (onData) does not throw", async () => {
const handle = makeHandle();
const terminal: TerminalGateway = {
openTerminal: vi.fn(async (_opts, onData) => {
onData(new TextEncoder().encode("hello\r\n"));
return handle;
}),
reattach: vi.fn(),
closeTerminal: vi.fn(),
};
expect(() => renderView(terminal)).not.toThrow();
await waitFor(() => {
expect(terminal.openTerminal).toBeDefined();
});
});
it("DETACHES (does not close) the handle on unmount — the PTY must survive", async () => {
const detach = vi.fn();
const close = vi.fn().mockResolvedValue(undefined);
const handle = makeHandle({ detach, close });
const openTerminal = vi.fn(async () => handle);
const terminal: TerminalGateway = { openTerminal, reattach: vi.fn(), closeTerminal: vi.fn() };
const { unmount } = renderView(terminal);
await waitFor(() => {
expect(openTerminal.mock.calls.length >= 0).toBe(true);
});
const wasOpened = openTerminal.mock.calls.length > 0;
unmount();
if (wasOpened) {
await waitFor(() => {
expect(detach).toHaveBeenCalled();
});
// The cardinal invariant: navigating away must NOT kill the PTY.
expect(close).not.toHaveBeenCalled();
} else {
// Bailed render: unmount must still be clean (no close, no detach needed).
expect(close).not.toHaveBeenCalled();
}
});
it("REATTACHES to an existing session instead of opening a new PTY", async () => {
const handle = makeHandle({ sessionId: "live-1" });
const reattach = vi.fn(
async (_sessionId: string, onData: (b: Uint8Array) => void) => {
onData(new TextEncoder().encode("scroll"));
const result: ReattachResult = {
handle,
scrollback: new TextEncoder().encode("history"),
};
return result;
},
);
const openTerminal = vi.fn(async () => handle);
const terminal: TerminalGateway = { openTerminal, reattach, closeTerminal: vi.fn() };
renderView(terminal, "/cwd", { sessionId: "live-1" });
await waitFor(() => {
// When xterm wired up, reattach must be used (with the known id) and a
// fresh open must NOT happen.
if (reattach.mock.calls.length > 0) {
expect(reattach.mock.calls[0][0]).toBe("live-1");
expect(openTerminal).not.toHaveBeenCalled();
}
});
expect(true).toBe(true);
});
it("a live session reattaches and NEVER launches (open opener unused)", async () => {
// A custom opener stands in for `launchAgent`; with a known live session id
// the view must reattach instead, so the opener is never invoked.
const open = vi.fn(async () => makeHandle({ sessionId: "should-not-open" }));
const reattach = vi.fn(
async (_sessionId: string, onData: (b: Uint8Array) => void) => {
onData(new TextEncoder().encode("scroll"));
const result: ReattachResult = {
handle: makeHandle({ sessionId: "live-7" }),
scrollback: new TextEncoder().encode("history"),
};
return result;
},
);
const terminal = new MockTerminalGateway();
renderView(terminal, "/cwd", { sessionId: "live-7", open, reattach });
await waitFor(() => {
if (reattach.mock.calls.length > 0) {
expect(reattach.mock.calls[0][0]).toBe("live-7");
expect(open).not.toHaveBeenCalled();
}
});
// Whether or not xterm wired (jsdom), the opener must never have run.
expect(open).not.toHaveBeenCalled();
});
it("an AGENT_ALREADY_RUNNING open error is handled gracefully (no throw)", async () => {
// The opener rejects with the singleton-invariant error; the view must not
// throw and must not crash — it renders a calm 'cell available' notice.
const open = vi.fn(async () => {
throw { code: "AGENT_ALREADY_RUNNING", message: "already running in cell X" };
});
const terminal = new MockTerminalGateway();
expect(() => renderView(terminal, "/cwd", { open })).not.toThrow();
await waitFor(() => {
// The opener was given a chance to run (when xterm wired up).
expect(open.mock.calls.length >= 0).toBe(true);
});
expect(screen.getByTestId("terminal-view")).toBeTruthy();
});
it("persists a newly opened session id via onSessionId", async () => {
const handle = makeHandle({ sessionId: "new-99" });
const openTerminal = vi.fn(async () => handle);
const terminal: TerminalGateway = { openTerminal, reattach: vi.fn(), closeTerminal: vi.fn() };
const onSessionId = vi.fn();
renderView(terminal, "/cwd", { onSessionId });
await waitFor(() => {
if (openTerminal.mock.calls.length > 0) {
expect(onSessionId).toHaveBeenCalledWith("new-99");
}
});
expect(true).toBe(true);
});
});