Files
IdeA/frontend/src/features/terminals/TerminalView.test.tsx
Blomios 6165eaf8d9 fix(terminals): détecte la stabilité réelle du fit terminal au remount
L'ancien correctif (traîne de refits à nombre de frames fixe,
MOUNT_SETTLE_REFIT_FRAMES) restait insuffisant : une cellule CLI pouvait
rester mal dimensionnée après un changement de projet/layout si l'agent
écrivait en arrière-plan pendant la fenêtre de stabilisation, nécessitant
un resize manuel pour corriger l'affichage. TerminalView détecte
désormais la stabilité réelle du fit (dimensions inchangées sur des
mesures successives) au lieu de s'appuyer sur un nombre de frames fixe
avant de rendre la main au ResizeObserver.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 09:36:05 +02:00

591 lines
23 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, 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> = {}): 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("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(
<DIProvider gateways={{ terminal: new MockTerminalGateway() } as unknown as Gateways}>
<TerminalView cwd="/cwd" open={open} refitSignal={2} />
</DIProvider>,
);
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(
<DIProvider gateways={{ terminal: new MockTerminalGateway() } as unknown as Gateways}>
<TerminalView cwd="/cwd" open={open} refitSignal={2} />
</DIProvider>,
);
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(
<DIProvider gateways={{ terminal: new MockTerminalGateway() } as unknown as Gateways}>
<TerminalView cwd="/cwd" open={open} refitSignal={2} />
</DIProvider>,
);
// 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 (
<DIProvider gateways={{ terminal: new MockTerminalGateway() } as unknown as Gateways}>
<TerminalView
key={projectId}
cwd={`/cwd/${projectId}`}
open={open}
/>
</DIProvider>
);
}
const { rerender } = render(<ProjectTerminal projectId="p1" />);
await waitFor(() => expect(open).toHaveBeenCalledTimes(1));
setTerminalBoxSize(400, 200);
await waitFor(() => expect(firstHandle.resize).toHaveBeenCalled());
fitSpy.mockClear();
rerender(<ProjectTerminal projectId="p2" />);
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(
<DIProvider gateways={{ terminal: new MockTerminalGateway() } as unknown as Gateways}>
<TerminalView cwd="/cwd" open={open} />
</DIProvider>,
);
// 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();
});
});
});