- #70: implémentation suppression modèles locaux téléchargés - #100: correction scroll OpenCode - #102: correction fit TUI après switch/layout - memory note scoping UX
480 lines
18 KiB
TypeScript
480 lines
18 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("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();
|
|
});
|
|
});
|
|
});
|