/** * 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, beforeAll, afterAll } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import { FitAddon } from "@xterm/addon-fit"; 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 { 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>, ) { const gateways = { terminal } as unknown as Gateways; return render( , ); } describe("TerminalView (with MockTerminalGateway)", () => { it("mounts and renders the terminal-view container without throwing", () => { renderView(new MockTerminalGateway()); expect(screen.getByTestId("terminal-view")).toBeTruthy(); }); it("shows a non-blocking network banner when system permissions are locked", () => { renderView(new MockTerminalGateway(), "/home/me/proj", { systemPermissions: { wanted: "allow", effective: "deny", runtimeLock: { state: "locked", reason: "Runtime network locked." }, control: { mode: "editable" }, runtimeControl: { mode: "readOnly", reason: "Runtime network locked." }, }, }); expect(screen.getByTestId("terminal-network-banner").textContent).toContain( "Réseau verrouillé par le runtime.", ); 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); }); }); // The launch-error surface (ticket #14 F3) can only be observed when xterm // actually mounts — otherwise the effect bails before ever calling the opener. // jsdom lacks `matchMedia`/`ResizeObserver`, which is exactly what makes // `term.open` throw. This block installs minimal polyfills so xterm mounts and // the opener genuinely runs (and rejects), letting us assert the real rendered // error. Scoped + torn down so the rest of the file keeps its headless // bail-graceful contract untouched. describe("TerminalView — visible launch-failure surface (ticket #14 F3)", () => { const w = window as unknown as { matchMedia?: (q: string) => MediaQueryList; }; const savedMatchMedia = w.matchMedia; const savedResizeObserver = globalThis.ResizeObserver; beforeAll(() => { w.matchMedia = (query: string) => ({ matches: false, media: query, onchange: null, addEventListener: () => {}, removeEventListener: () => {}, addListener: () => {}, removeListener: () => {}, dispatchEvent: () => false, }) as unknown as MediaQueryList; globalThis.ResizeObserver = class { observe() {} unobserve() {} disconnect() {} } as unknown as typeof ResizeObserver; }); afterAll(() => { w.matchMedia = savedMatchMedia; globalThis.ResizeObserver = savedResizeObserver; }); function setTerminalBoxSize(width: number, height: number) { const container = screen.getByTestId("terminal-xterm-container"); Object.defineProperty(container, "clientWidth", { value: width, configurable: true }); Object.defineProperty(container, "clientHeight", { value: height, configurable: true }); return container; } it("sanity: with the polyfills xterm mounts and the opener runs", async () => { // Guards the premise of the tests below: if this fails, the opener never // fired and the error assertions would be vacuous. const open = vi.fn(async () => makeHandle({ sessionId: "up-1" })); renderView(new MockTerminalGateway(), "/cwd", { open }); await waitFor(() => expect(open).toHaveBeenCalled()); }); it("renders an 'endpoint unavailable' launch failure as a visible alert without blocking the UI", async () => { // An OpenAI-compatible agent whose endpoint is down rejects the launch with a // Start error. The cell must show a visible, accessible error message — never // throw, never crash — so IdeA stays usable and other cells keep working. const open = vi.fn(async () => { throw { code: "AGENT_SESSION_START", message: "endpoint indisponible: http://localhost:11434/v1", }; }); expect(() => renderView(new MockTerminalGateway(), "/cwd", { open }), ).not.toThrow(); // The rejection is actually exercised (the opener was invoked and threw). await waitFor(() => expect(open).toHaveBeenCalled()); // A real, accessible error message is rendered to the user (role="alert"), // carrying the endpoint diagnostic — not just painted into the xterm buffer. const alert = await screen.findByRole("alert"); expect(alert.textContent).toMatch(/Échec du lancement de l'agent/); expect(alert.textContent).toMatch(/endpoint indisponible/); // The cell stays mounted and interactive (not a blank/blocked screen). expect(screen.getByTestId("terminal-view")).toBeTruthy(); }); it("shows NO failure banner on a successful launch", async () => { // Guard: the alert is strictly a failure surface — a healthy cell has none. const open = vi.fn(async () => makeHandle({ sessionId: "ok-1" })); renderView(new MockTerminalGateway(), "/cwd", { open }); await waitFor(() => expect(open).toHaveBeenCalled()); expect(screen.queryByRole("alert")).toBeNull(); expect(screen.queryByTestId("terminal-error")).toBeNull(); }); it("keeps a boot placeholder while the terminal box has no usable size", async () => { const handle = makeHandle({ sessionId: "boot-zero-1" }); const open = vi.fn(async () => handle); renderView(new MockTerminalGateway(), "/cwd", { open, refitSignal: 1 }); await waitFor(() => expect(open).toHaveBeenCalledTimes(1)); await new Promise((resolve) => requestAnimationFrame(resolve)); expect(screen.getByTestId("terminal-boot-placeholder")).toBeTruthy(); expect(screen.getByTestId("terminal-xterm-container").style.visibility).toBe( "hidden", ); expect(handle.resize).not.toHaveBeenCalled(); }); it("reveals xterm and resizes the handle after the first useful fit", async () => { const handle = makeHandle({ sessionId: "boot-ready-1" }); const open = vi.fn(async () => handle); const { rerender } = renderView(new MockTerminalGateway(), "/cwd", { open, refitSignal: 1, }); await waitFor(() => expect(open).toHaveBeenCalledTimes(1)); expect(screen.getByTestId("terminal-boot-placeholder")).toBeTruthy(); setTerminalBoxSize(480, 240); rerender( , ); await waitFor(() => expect(screen.queryByTestId("terminal-boot-placeholder")).toBeNull(), ); expect(screen.getByTestId("terminal-xterm-container").style.visibility).toBe( "visible", ); await waitFor(() => expect(handle.resize).toHaveBeenCalled()); }); describe("refitSignal (ticket #61 — refit after split/merge)", () => { it("refits WITHOUT reopening the terminal when refitSignal changes", async () => { // Simulates LayoutGrid bumping `useLayout`'s layout version after a // split/merge: a survivor cell must refit its xterm grid, but must NOT // re-run open/reattach — that would tear down and relaunch its PTY. const fitSpy = vi.spyOn(FitAddon.prototype, "fit"); const open = vi.fn(async () => makeHandle({ sessionId: "survivor-1" })); const { rerender } = renderView(new MockTerminalGateway(), "/cwd", { open, refitSignal: 1, }); await waitFor(() => expect(open).toHaveBeenCalledTimes(1)); fitSpy.mockClear(); // jsdom reports a zero-size layout box, which the coalesced refit // deliberately skips (the same guard that protects the resize-observer // path from fitting to a transient zero size). Stub a real size on the // inner xterm container so the refit triggered below actually reaches // `fit.fit()` instead of bailing on the zero-size guard. setTerminalBoxSize(400, 200); rerender( , ); await waitFor(() => expect(fitSpy).toHaveBeenCalled()); // The structural-mutation refit must never reopen the PTY. expect(open).toHaveBeenCalledTimes(1); fitSpy.mockRestore(); }); it("recovers from a transient zero-size container instead of giving up (web regression)", async () => { // On mobile there is no window-resize equivalent that "repairs" a refit // that landed on a not-yet-settled 0x0 container — so a zero size at the // scheduled frame must reschedule on a following frame rather than // abandon the refit permanently. const fitSpy = vi.spyOn(FitAddon.prototype, "fit"); const open = vi.fn(async () => makeHandle({ sessionId: "zero-size-1" })); const { rerender } = renderView(new MockTerminalGateway(), "/cwd", { open, refitSignal: 1, }); await waitFor(() => expect(open).toHaveBeenCalledTimes(1)); fitSpy.mockClear(); const container = screen.getByTestId("terminal-xterm-container"); // jsdom's default layout box is 0x0 — exactly the transient-zero case: // left as-is, the container "hasn't settled" yet. rerender( , ); // First scheduled frame: still zero size — must skip fit() and // reschedule, not give up. await new Promise((resolve) => requestAnimationFrame(resolve)); expect(fitSpy).not.toHaveBeenCalled(); // The container settles to a real size before the rescheduled retry runs. Object.defineProperty(container, "clientWidth", { value: 400, configurable: true }); Object.defineProperty(container, "clientHeight", { value: 200, configurable: true }); await waitFor(() => expect(fitSpy).toHaveBeenCalled()); // Still never reopened the PTY across the retries. expect(open).toHaveBeenCalledTimes(1); fitSpy.mockRestore(); }); it("refits after window restore/focus without a new refitSignal", async () => { const fitSpy = vi.spyOn(FitAddon.prototype, "fit"); const open = vi.fn(async () => makeHandle({ sessionId: "restore-1" })); renderView(new MockTerminalGateway(), "/cwd", { open, refitSignal: 1, }); await waitFor(() => expect(open).toHaveBeenCalledTimes(1)); setTerminalBoxSize(400, 200); await waitFor(() => expect(fitSpy).toHaveBeenCalled()); fitSpy.mockClear(); window.dispatchEvent(new Event("focus")); await waitFor(() => expect(fitSpy).toHaveBeenCalled()); expect(open).toHaveBeenCalledTimes(1); fitSpy.mockRestore(); }); it("keeps fitting across a keyed project remount until the new cell is actually stable", async () => { // ProjectsView keys LayoutGrid by project. A project switch therefore // unmounts/remounts TerminalView, so the mount path itself must fit until // the new cell's real geometry has stabilized, not for a guessed count of // frames. const fitSpy = vi.spyOn(FitAddon.prototype, "fit"); const firstHandle = makeHandle({ sessionId: "project-1" }); const secondHandle = makeHandle({ sessionId: "project-2" }); const open = vi .fn() .mockResolvedValueOnce(firstHandle) .mockResolvedValueOnce(secondHandle); function ProjectTerminal({ projectId }: { projectId: string }) { return ( ); } const { rerender } = render(); await waitFor(() => expect(open).toHaveBeenCalledTimes(1)); setTerminalBoxSize(400, 200); await waitFor(() => expect(firstHandle.resize).toHaveBeenCalled()); fitSpy.mockClear(); rerender(); await waitFor(() => expect(open).toHaveBeenCalledTimes(2)); setTerminalBoxSize(520, 260); for (let i = 0; i < 3; i += 1) { await new Promise((resolve) => requestAnimationFrame(resolve)); } expect(fitSpy.mock.calls.length).toBeGreaterThanOrEqual(2); await waitFor(() => expect(secondHandle.resize).toHaveBeenCalled()); expect(firstHandle.close).not.toHaveBeenCalled(); expect(secondHandle.close).not.toHaveBeenCalled(); fitSpy.mockRestore(); }); it("refits a late remount after scrollback while the cell was absent", async () => { // Regression for project/layout switch: the agent can keep writing while // its view is unmounted. On reattach, scrollback is repainted immediately, // but the new container may stay 0x0 longer than the old fixed remount // frame tail. The view must keep retrying until a real size appears. const fitSpy = vi.spyOn(FitAddon.prototype, "fit"); const handle = makeHandle({ sessionId: "late-remount-1" }); const reattach = vi.fn( async (_sessionId: string, onData: (b: Uint8Array) => void) => { onData(new TextEncoder().encode("agent wrote while absent\r\n")); return { handle, scrollback: new TextEncoder().encode("retained scrollback\r\n"), } satisfies ReattachResult; }, ); const open = vi.fn(async () => makeHandle({ sessionId: "should-not-open" })); renderView(new MockTerminalGateway(), "/cwd", { sessionId: "late-remount-1", open, reattach, }); await waitFor(() => expect(reattach).toHaveBeenCalledTimes(1)); expect(open).not.toHaveBeenCalled(); fitSpy.mockClear(); // Stay zero-sized past the previous fixed remount tail. for (let i = 0; i < 16; i += 1) { await new Promise((resolve) => requestAnimationFrame(resolve)); } expect(fitSpy).not.toHaveBeenCalled(); expect(handle.resize).not.toHaveBeenCalled(); setTerminalBoxSize(560, 280); await waitFor(() => expect(fitSpy).toHaveBeenCalled()); await waitFor(() => expect(handle.resize).toHaveBeenCalled()); expect(handle.close).not.toHaveBeenCalled(); fitSpy.mockRestore(); }); it("continues refitting while remount geometry keeps changing, then stops after stability", async () => { const fitSpy = vi.spyOn(FitAddon.prototype, "fit"); const handle = makeHandle({ sessionId: "moving-remount-1" }); const open = vi.fn(async () => handle); renderView(new MockTerminalGateway(), "/cwd", { open }); await waitFor(() => expect(open).toHaveBeenCalledTimes(1)); for (let i = 0; i < 16; i += 1) { setTerminalBoxSize(420 + i, 220); await new Promise((resolve) => requestAnimationFrame(resolve)); } expect(fitSpy.mock.calls.length).toBeGreaterThan(12); setTerminalBoxSize(520, 260); await waitFor(() => expect(handle.resize).toHaveBeenCalled()); fitSpy.mockRestore(); }); it("does not refit when refitSignal is left undefined (no-op for callers that don't pass it)", async () => { const fitSpy = vi.spyOn(FitAddon.prototype, "fit"); const open = vi.fn(async () => makeHandle({ sessionId: "no-signal-1" })); const { rerender } = renderView(new MockTerminalGateway(), "/cwd", { open }); await waitFor(() => expect(open).toHaveBeenCalledTimes(1)); fitSpy.mockClear(); rerender( , ); // Give any stray rAF a chance to fire, then assert nothing extra happened. await new Promise((resolve) => requestAnimationFrame(resolve)); expect(fitSpy).not.toHaveBeenCalled(); fitSpy.mockRestore(); }); }); });