Files
IdeA/frontend/src/features/terminals/TerminalView.portal.test.tsx
Blomios fdcf16c387 chore(wip): checkpoint P8/C avant chantier Codex inter-agents
Sauvegarde de l'arbre de travail en cours (persistance P8, conversations
C-series, write-portal frontend, médiation d'entrée) avant d'attaquer le
support de la délégation inter-agents pour les profils Codex.

Le round-trip inter-agent question/réponse est couvert sans tokens par
les tests loopback existants (state::mcp_e2e_loopback_tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 21:42:53 +02:00

175 lines
5.5 KiB
TypeScript

/**
* L3 — TerminalView agent cell is a **native terminal** wired to a
* {@link WritePortal} (ARCHITECTURE §20).
*
* Contracts exercised (xterm stubbed so `term.open` succeeds under jsdom and the
* keystroke wiring genuinely runs; the stub captures the `onData` handler):
* - In agent mode a keystroke is written to the PTY (native), AND reported to
* the portal for line counting.
* - The portal's `isSuspended()` gates the relay: while suspended, keystrokes
* are NOT forwarded to the PTY (but are still reported for counting).
* - The live handle is bound to the portal on adopt, unbound on unmount.
*/
import { beforeEach, describe, it, expect, vi } from "vitest";
import { render, waitFor } from "@testing-library/react";
const xtermState: { keyHandler: ((data: string) => void) | null } = {
keyHandler: null,
};
vi.mock("@xterm/xterm", () => ({
Terminal: class {
loadAddon() {}
open() {}
onData(cb: (data: string) => void) {
xtermState.keyHandler = cb;
return { dispose() {} };
}
onResize() {
return { dispose() {} };
}
write() {}
dispose() {}
get cols() {
return 80;
}
get rows() {
return 24;
}
},
}));
vi.mock("@xterm/addon-fit", () => ({
FitAddon: class {
fit() {}
},
}));
vi.mock("@xterm/xterm/css/xterm.css", () => ({}));
if (typeof globalThis.ResizeObserver === "undefined") {
globalThis.ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
} as unknown as typeof ResizeObserver;
}
import type { Gateways, TerminalHandle, WritePortal } from "@/ports";
import { DIProvider } from "@/app/di";
import { TerminalView } from "./TerminalView";
function makeHandle(write = vi.fn().mockResolvedValue(undefined)): TerminalHandle {
return {
sessionId: "s1",
write,
resize: vi.fn().mockResolvedValue(undefined),
detach: vi.fn(),
close: vi.fn().mockResolvedValue(undefined),
};
}
function makePortal(overrides: Partial<WritePortal> = {}): WritePortal {
return {
onHumanData: vi.fn(),
isSuspended: vi.fn(() => false),
bindHandle: vi.fn(),
unbindHandle: vi.fn(),
...overrides,
};
}
function decode(b: Uint8Array): string {
return new TextDecoder().decode(b);
}
beforeEach(() => {
xtermState.keyHandler = null;
});
describe("TerminalView native agent terminal + portal (§20)", () => {
it("writes keystrokes to the PTY in agent mode and reports them to the portal", async () => {
const write = vi.fn().mockResolvedValue(undefined);
const handle = makeHandle(write);
const open = vi.fn(async () => handle);
const portal = makePortal();
const gateways = { terminal: { reattach: vi.fn() } } as unknown as Gateways;
render(
<DIProvider gateways={gateways}>
<TerminalView cwd="/c" open={open} agentMode portal={portal} />
</DIProvider>,
);
await waitFor(() => expect(xtermState.keyHandler).not.toBeNull());
await waitFor(() => expect(portal.bindHandle).toHaveBeenCalledWith(handle));
xtermState.keyHandler!("a");
expect(portal.onHumanData).toHaveBeenCalledWith("a");
await waitFor(() => expect(write).toHaveBeenCalled());
expect(decode(write.mock.calls[0][0])).toBe("a");
});
it("does NOT forward keystrokes while the portal is suspended (but still counts them)", async () => {
const write = vi.fn().mockResolvedValue(undefined);
const handle = makeHandle(write);
const open = vi.fn(async () => handle);
const onHumanData = vi.fn();
const portal = makePortal({ isSuspended: () => true, onHumanData });
const gateways = { terminal: { reattach: vi.fn() } } as unknown as Gateways;
render(
<DIProvider gateways={gateways}>
<TerminalView cwd="/c" open={open} agentMode portal={portal} />
</DIProvider>,
);
await waitFor(() => expect(xtermState.keyHandler).not.toBeNull());
await waitFor(() => expect(portal.bindHandle).toHaveBeenCalled());
xtermState.keyHandler!("x");
// Reported for counting, but the relay is suspended ⇒ not written.
expect(onHumanData).toHaveBeenCalledWith("x");
expect(write).not.toHaveBeenCalled();
});
it("unbinds the handle from the portal on unmount", async () => {
const handle = makeHandle();
const open = vi.fn(async () => handle);
const portal = makePortal();
const gateways = { terminal: { reattach: vi.fn() } } as unknown as Gateways;
const { unmount } = render(
<DIProvider gateways={gateways}>
<TerminalView cwd="/c" open={open} agentMode portal={portal} />
</DIProvider>,
);
await waitFor(() => expect(portal.bindHandle).toHaveBeenCalled());
unmount();
expect(portal.unbindHandle).toHaveBeenCalled();
});
it("a PLAIN cell ignores the portal and writes keystrokes natively", async () => {
const write = vi.fn().mockResolvedValue(undefined);
const handle = makeHandle(write);
const open = vi.fn(async () => handle);
const portal = makePortal();
const gateways = { terminal: { reattach: vi.fn() } } as unknown as Gateways;
render(
<DIProvider gateways={gateways}>
{/* agentMode false: portal must never be touched */}
<TerminalView cwd="/c" open={open} portal={portal} />
</DIProvider>,
);
await waitFor(() => expect(xtermState.keyHandler).not.toBeNull());
xtermState.keyHandler!("l");
await waitFor(() => expect(write).toHaveBeenCalled());
expect(portal.onHumanData).not.toHaveBeenCalled();
expect(portal.bindHandle).not.toHaveBeenCalled();
});
});