/** * L3 — behavioural tests for {@link MockTerminalGateway}: it greets on open, * echoes writes back through `onData` (translating CR→CRLF like a cooked * terminal), behaves on resize/close, and mints distinct session ids. */ import { describe, it, expect, vi } from "vitest"; import { MockTerminalGateway } from "./index"; /** Flush queued microtasks (the greeting is delivered via `queueMicrotask`). */ async function flushMicrotasks() { await Promise.resolve(); await Promise.resolve(); } function decode(chunks: Uint8Array[]): string { const dec = new TextDecoder(); return chunks.map((c) => dec.decode(c)).join(""); } describe("MockTerminalGateway", () => { it("greets on open with the cwd via onData", async () => { const gw = new MockTerminalGateway(); const received: Uint8Array[] = []; await gw.openTerminal({ cwd: "/work/dir", rows: 24, cols: 80 }, (b) => received.push(b), ); await flushMicrotasks(); expect(decode(received)).toContain("/work/dir"); }); it("returns a handle with a sessionId and write/resize/close methods", async () => { const gw = new MockTerminalGateway(); const handle = await gw.openTerminal( { cwd: "/c", rows: 24, cols: 80 }, () => {}, ); expect(typeof handle.sessionId).toBe("string"); expect(typeof handle.write).toBe("function"); expect(typeof handle.resize).toBe("function"); expect(typeof handle.close).toBe("function"); }); it("echoes written bytes back through onData", async () => { const gw = new MockTerminalGateway(); const received: Uint8Array[] = []; const handle = await gw.openTerminal( { cwd: "/c", rows: 24, cols: 80 }, (b) => received.push(b), ); await flushMicrotasks(); received.length = 0; // drop the greeting await handle.write(new TextEncoder().encode("hi")); expect(decode(received)).toBe("hi"); }); it("translates a CR keystroke to CRLF on echo", async () => { const gw = new MockTerminalGateway(); const received: Uint8Array[] = []; const handle = await gw.openTerminal( { cwd: "/c", rows: 24, cols: 80 }, (b) => received.push(b), ); await flushMicrotasks(); received.length = 0; // 0x0d == CR await handle.write(Uint8Array.from([0x61, 0x0d])); // 'a' + CR const out = received.flatMap((c) => Array.from(c)); expect(out).toEqual([0x61, 0x0d, 0x0a]); // 'a', CR, LF }); it("stops echoing once closed", async () => { const gw = new MockTerminalGateway(); const received: Uint8Array[] = []; const handle = await gw.openTerminal( { cwd: "/c", rows: 24, cols: 80 }, (b) => received.push(b), ); await flushMicrotasks(); received.length = 0; await handle.close(); await handle.write(new TextEncoder().encode("ignored")); expect(received).toHaveLength(0); }); it("resize resolves without throwing", async () => { const gw = new MockTerminalGateway(); const handle = await gw.openTerminal( { cwd: "/c", rows: 24, cols: 80 }, () => {}, ); await expect(handle.resize(40, 120)).resolves.toBeUndefined(); }); it("mints distinct session ids for two opens", async () => { const gw = new MockTerminalGateway(); const a = await gw.openTerminal({ cwd: "/c", rows: 24, cols: 80 }, () => {}); const b = await gw.openTerminal({ cwd: "/c", rows: 24, cols: 80 }, () => {}); expect(a.sessionId).not.toEqual(b.sessionId); }); it("does not echo before any write (only the greeting)", async () => { const gw = new MockTerminalGateway(); const onData = vi.fn(); await gw.openTerminal({ cwd: "/c", rows: 24, cols: 80 }, onData); await flushMicrotasks(); // Exactly one delivery so far: the greeting. expect(onData).toHaveBeenCalledTimes(1); }); it("detach stops delivery to the old view but keeps the session alive", async () => { const gw = new MockTerminalGateway(); const first: Uint8Array[] = []; const handle = await gw.openTerminal( { cwd: "/c", rows: 24, cols: 80 }, (b) => first.push(b), ); await flushMicrotasks(); first.length = 0; handle.detach(); // Output produced after detach must NOT reach the detached view. await handle.write(new TextEncoder().encode("after-detach")); expect(first).toHaveLength(0); // But the session is still alive: reattach succeeds. await expect( gw.reattach(handle.sessionId, () => {}), ).resolves.toBeDefined(); }); it("reattach replays scrollback and resumes live output", async () => { const gw = new MockTerminalGateway(); const handle = await gw.openTerminal( { cwd: "/work", rows: 24, cols: 80 }, () => {}, ); await flushMicrotasks(); await handle.write(new TextEncoder().encode("typed")); handle.detach(); const fresh: Uint8Array[] = []; const { handle: h2, scrollback } = await gw.reattach( handle.sessionId, (b) => fresh.push(b), ); // Scrollback carries the prior greeting + echoed input. const sb = decode([scrollback]); expect(sb).toContain("/work"); expect(sb).toContain("typed"); // New output now flows to the re-attached view. await h2.write(new TextEncoder().encode("more")); expect(decode(fresh)).toBe("more"); }); it("reattach to a closed session rejects (PTY is gone)", async () => { const gw = new MockTerminalGateway(); const handle = await gw.openTerminal( { cwd: "/c", rows: 24, cols: 80 }, () => {}, ); await handle.close(); await expect(gw.reattach(handle.sessionId, () => {})).rejects.toMatchObject({ code: "NOT_FOUND", }); }); it("closeTerminal(sessionId) kills the PTY so a later reattach fails (Bug #3)", async () => { const gw = new MockTerminalGateway(); const handle = await gw.openTerminal( { cwd: "/c", rows: 24, cols: 80 }, () => {}, ); await gw.closeTerminal(handle.sessionId); await expect(gw.reattach(handle.sessionId, () => {})).rejects.toMatchObject({ code: "NOT_FOUND", }); }); it("closeTerminal on an unknown session is a no-op (best-effort)", async () => { const gw = new MockTerminalGateway(); await expect(gw.closeTerminal("does-not-exist")).resolves.toBeUndefined(); }); });