/** * 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 { 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( , ); 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( , ); 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( , ); 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( {/* agentMode false: portal must never be touched */} , ); 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(); }); });