/** * L4 — the write-portal hook {@link useWritePortal} (ARCHITECTURE §20). * * Driven with fake timers + a fake handle recording writes, and the real * {@link DIProvider} wiring the in-memory {@link MockSystemGateway} (to emit * `delegationReady`) and {@link MockInputGateway} (to record the ack). * * Cases (cadrage §20.6): * - injects NOTHING while the human line is non-empty (counter > 0); * - at an empty line → writes `text` WITHOUT `\n`, then `\r` after the delay; * - K=2 race → exactly two `\x7f` before the text; * - 2 s floor → overlay/suspension held until 2000 ms after step (b); * - the relay is suspended during the overlay; * - `delegationDelivered` is acked exactly once after (d); * - default submitSequence/delay applied when the profile omits them. */ import { afterEach, beforeEach, describe, it, expect, vi } from "vitest"; import { act, renderHook } from "@testing-library/react"; import type { Gateways, TerminalHandle } from "@/ports"; import { MockInputGateway, MockSystemGateway, } from "@/adapters/mock"; import { DIProvider } from "@/app/di"; import { useWritePortal } from "./useWritePortal"; function makeHandle(): { handle: TerminalHandle; writes: string[] } { const writes: string[] = []; const handle: TerminalHandle = { sessionId: "s1", write: vi.fn(async (b: Uint8Array) => { writes.push(new TextDecoder().decode(b)); }), resize: vi.fn().mockResolvedValue(undefined), detach: vi.fn(), close: vi.fn().mockResolvedValue(undefined), }; return { handle, writes }; } function setup(agentId: string | null = "ag1") { const system = new MockSystemGateway(); const input = new MockInputGateway(); const gateways = { system, input } as unknown as Gateways; const wrapper = ({ children }: { children: React.ReactNode }) => ( {children} ); const view = renderHook(() => useWritePortal("p1", agentId), { wrapper }); return { system, input, view }; } const PROFILELESS = (ticket: string, text: string) => ({ type: "delegationReady" as const, agentId: "ag1", ticket, text, }); beforeEach(() => { vi.useFakeTimers(); }); afterEach(() => { vi.useRealTimers(); }); describe("useWritePortal (§20)", () => { it("injects NOTHING while the human line is non-empty, releases at empty line", async () => { const { system, view } = setup(); const { handle, writes } = makeHandle(); // Bind a live handle and type a printable char (line non-empty). act(() => view.result.current.portal.bindHandle(handle)); act(() => view.result.current.portal.onHumanData("a")); // K = 1 // A delegation arrives while the line is non-empty. await act(async () => { system.emit(PROFILELESS("t1", "hello")); }); // Nothing written: the delegation waits. expect(writes.length).toBe(0); // The human presses Enter → line empty → injection fires. await act(async () => { view.result.current.portal.onHumanData("\r"); await vi.runAllTimersAsync(); }); // text written without trailing newline, then the submit sequence. expect(writes).toContain("hello"); expect(writes[writes.length - 1]).toBe("\r"); expect(writes.join("")).not.toContain("\n"); }); it("at an empty line writes text then \\r after the delay, acking once", async () => { const { system, input, view } = setup(); const { handle, writes } = makeHandle(); act(() => view.result.current.portal.bindHandle(handle)); await act(async () => { system.emit(PROFILELESS("t1", "do it")); // Flush the microtask that defers step (c)/(d) but NOT the submit delay. await Promise.resolve(); await Promise.resolve(); }); // Line is empty (counter 0) ⇒ handshake started; overlay raised. expect(view.result.current.overlay).toBe(true); // Before the delay elapses, only the text has been written (no submit yet). expect(writes).toEqual(["do it"]); await act(async () => { await vi.runAllTimersAsync(); }); expect(writes).toEqual(["do it", "\r"]); // Acked exactly once with the right shape. expect(input.delivered).toEqual([ { projectId: "p1", agentId: "ag1", ticket: "t1" }, ]); }); it("chunks long delegated text before submit and acks after the submit", async () => { const { system, input, view } = setup(); const { handle, writes } = makeHandle(); act(() => view.result.current.portal.bindHandle(handle)); const text = `début ${"x".repeat(1200)} fin`; await act(async () => { system.emit(PROFILELESS("t1", text)); await vi.runAllTimersAsync(); }); expect(writes.length).toBeGreaterThan(2); expect(writes[writes.length - 1]).toBe("\r"); expect(writes.slice(0, -1).join("")).toBe(text); expect(input.delivered).toEqual([ { projectId: "p1", agentId: "ag1", ticket: "t1" }, ]); }); it("K=2 race → exactly two \\x7f backspaces before the text (never Ctrl-U)", async () => { const { system, view } = setup(); const { handle, writes } = makeHandle(); act(() => view.result.current.portal.bindHandle(handle)); // The delegation arrives at an EMPTY line, so the handshake starts and // raises suspension. The §20.6 race: in the micro-window between "empty // observed" (b) and step (c) re-checking the counter, the human types K=2 // printables. TerminalView still reports them while suspended, so the // counter is 2 when step (c) reads it ⇒ exactly two backspaces. await act(async () => { system.emit(PROFILELESS("t1", "TASK")); // Race two keystrokes in before the timers (delay) flush step (c)/(d). view.result.current.portal.onHumanData("x"); view.result.current.portal.onHumanData("y"); await vi.runAllTimersAsync(); }); const idxText = writes.indexOf("TASK"); expect(idxText).toBeGreaterThan(0); expect(writes[idxText - 1]).toBe("\x7f\x7f"); expect(writes.join("")).not.toContain("\x15"); // never Ctrl-U }); it("holds the overlay until the 2 s floor after step (b)", async () => { const { system, view } = setup(); const { handle } = makeHandle(); act(() => view.result.current.portal.bindHandle(handle)); await act(async () => { system.emit(PROFILELESS("t1", "hi")); }); expect(view.result.current.overlay).toBe(true); // Finish the write phase (default 60 ms delay) — still well under 2 s. await act(async () => { await vi.advanceTimersByTimeAsync(100); }); // The relay is still suspended and the overlay still up (2 s floor). expect(view.result.current.portal.isSuspended()).toBe(true); expect(view.result.current.overlay).toBe(true); // Cross the 2 s floor. await act(async () => { await vi.advanceTimersByTimeAsync(2000); }); expect(view.result.current.overlay).toBe(false); expect(view.result.current.portal.isSuspended()).toBe(false); }); it("suspends the keystroke relay during the overlay", async () => { const { system, view } = setup(); const { handle } = makeHandle(); act(() => view.result.current.portal.bindHandle(handle)); expect(view.result.current.portal.isSuspended()).toBe(false); await act(async () => { system.emit(PROFILELESS("t1", "hi")); }); // During injection the relay is suspended. expect(view.result.current.portal.isSuspended()).toBe(true); await act(async () => { await vi.runAllTimersAsync(); }); expect(view.result.current.portal.isSuspended()).toBe(false); }); it("applies the profile submitSequence/submitDelayMs when provided", async () => { const { system, view } = setup(); const { handle, writes } = makeHandle(); act(() => view.result.current.portal.bindHandle(handle)); await act(async () => { system.emit({ type: "delegationReady", agentId: "ag1", ticket: "t1", text: "msg", submitSequence: "\n", submitDelayMs: 200, }); }); // After 100 ms (< 200) the submit sequence has not been written yet. await act(async () => { await vi.advanceTimersByTimeAsync(100); }); expect(writes).toEqual(["msg"]); await act(async () => { await vi.runAllTimersAsync(); }); expect(writes).toEqual(["msg", "\n"]); }); it("ignores delegationReady for a different agent", async () => { const { system, view } = setup(); const { handle, writes } = makeHandle(); act(() => view.result.current.portal.bindHandle(handle)); await act(async () => { system.emit({ type: "delegationReady", agentId: "OTHER", ticket: "t1", text: "nope", }); await vi.runAllTimersAsync(); }); expect(writes.length).toBe(0); expect(view.result.current.overlay).toBe(false); }); it("waits for a live handle before injecting", async () => { const { system, input, view } = setup(); const { handle, writes } = makeHandle(); // No handle yet: an arriving delegation must NOT write or ack. await act(async () => { system.emit(PROFILELESS("t1", "later")); await vi.runAllTimersAsync(); }); expect(writes.length).toBe(0); expect(input.delivered.length).toBe(0); // Handle becomes live (PTY opened) → injection proceeds. await act(async () => { view.result.current.portal.bindHandle(handle); await vi.runAllTimersAsync(); }); expect(writes).toEqual(["later", "\r"]); expect(input.delivered.length).toBe(1); }); it("reports front attachment only after the delegation subscription is ready", async () => { const { input, view } = setup(); const { handle } = makeHandle(); act(() => view.result.current.portal.bindHandle(handle)); expect(input.frontAttached).toEqual([]); await act(async () => { await Promise.resolve(); }); expect(input.frontAttached).toEqual([{ agentId: "ag1", attached: true }]); act(() => view.result.current.portal.unbindHandle()); expect(input.frontAttached).toEqual([ { agentId: "ag1", attached: true }, { agentId: "ag1", attached: false }, ]); }); it("is inert for a plain (agent-less) cell", async () => { const { system, view } = setup(null); const { handle, writes } = makeHandle(); act(() => view.result.current.portal.bindHandle(handle)); await act(async () => { system.emit(PROFILELESS("t1", "x")); await vi.runAllTimersAsync(); }); expect(writes.length).toBe(0); expect(view.result.current.overlay).toBe(false); }); });