/** * F3 — the WS terminal gateway + handle round-trip against the B5 frame contract: * open→attached (empty scrollback), attach→attached (scrollback returned for the * view to repaint, not double-pushed), input/resize emit conforming frames, * detach ≠ close, and `terminal.output` bytes are base64-decoded into the sink. */ import { describe, it, expect, vi } from "vitest"; import { HttpTerminalGateway } from "./streamGateways"; import { WsLiveClient, type WebSocketLike } from "./wsLiveClient"; import { bytesToBase64 } from "./frames"; class FakeSocket implements WebSocketLike { sent: string[] = []; onopen: (() => void) | null = null; onmessage: ((event: { data: string }) => void) | null = null; onerror: ((event: unknown) => void) | null = null; onclose: (() => void) | null = null; send(data: string): void { this.sent.push(data); } close(): void { this.onclose?.(); } receive(frame: unknown): void { this.onmessage?.({ data: JSON.stringify(frame) }); } } /** Client whose socket auto-opens on the next microtask. */ function client(): { ws: WsLiveClient; sockets: FakeSocket[] } { const sockets: FakeSocket[] = []; const ws = new WsLiveClient({ wsUrl: "wss://host", socketFactory: () => { const s = new FakeSocket(); sockets.push(s); queueMicrotask(() => s.onopen?.()); return s; }, }); return { ws, sockets }; } /** Awaits the frame the client just sent, then replies with `reply(id)`. */ async function replyToLast( socket: FakeSocket, index: number, reply: (id: string) => unknown, ): Promise> { await vi.waitFor(() => expect(socket.sent.length).toBeGreaterThan(index)); const sent = JSON.parse(socket.sent[index]); socket.receive(reply(sent.id)); return sent; } function attachedAck(id: string, sessionId: string, scrollbackBytes?: Uint8Array) { return { kind: "terminal.attached", replyTo: id, payload: { session: { sessionId, rows: 30, cols: 120 }, scrollback: scrollbackBytes ? [{ seq: 0, bytesBase64: bytesToBase64(scrollbackBytes) }] : [], nextSeq: scrollbackBytes ? 1 : 0, status: "running", gap: false, }, }; } describe("HttpTerminalGateway round-trip (B5 frames)", () => { it("open → attached with empty scrollback; output is decoded into the sink", async () => { const { ws, sockets } = client(); const gw = new HttpTerminalGateway(ws); const chunks: Uint8Array[] = []; const handlePromise = gw.openTerminal({ cwd: "/srv/app", rows: 30, cols: 120 }, (b) => chunks.push(b), ); const sent = await replyToLast(sockets[0], 0, (id) => attachedAck(id, "s1")); const handle = await handlePromise; // Conforming open frame (no projectId; cwd is server-validated). expect(sent.kind).toBe("terminal.open"); expect(sent.payload).toMatchObject({ cwd: "/srv/app", rows: 30, cols: 120 }); expect(handle.sessionId).toBe("s1"); // No scrollback pushed on a fresh open. expect(chunks).toHaveLength(0); // A terminal.output frame is base64-decoded and written to the sink. sockets[0].receive({ kind: "terminal.output", payload: { sessionId: "s1", seq: 1, bytesBase64: bytesToBase64(new Uint8Array([104, 105])) }, }); expect(chunks).toEqual([new Uint8Array([104, 105])]); }); it("attach → attached returns the scrollback for the view (not double-pushed)", async () => { const { ws, sockets } = client(); const gw = new HttpTerminalGateway(ws); const chunks: Uint8Array[] = []; const scroll = new Uint8Array([65, 66, 67]); const resPromise = gw.reattach("s7", (b) => chunks.push(b)); const sent = await replyToLast(sockets[0], 0, (id) => attachedAck(id, "s7", scroll)); const res = await resPromise; expect(sent.kind).toBe("terminal.attach"); expect(sent.payload).toMatchObject({ sessionId: "s7" }); // Scrollback is returned for TerminalView to repaint… expect(res.scrollback).toEqual(scroll); // …and NOT also pushed through onData (no double paint on the initial attach). expect(chunks).toHaveLength(0); }); it("input and resize emit conforming frames; detach ≠ close", async () => { const { ws, sockets } = client(); const gw = new HttpTerminalGateway(ws); const handlePromise = gw.openTerminal({ cwd: "/srv", rows: 24, cols: 80 }, () => {}); await replyToLast(sockets[0], 0, (id) => attachedAck(id, "s1")); const handle = await handlePromise; const base = sockets[0].sent.length; await handle.write(new Uint8Array([13])); await handle.resize(40, 140); const input = JSON.parse(sockets[0].sent[base]); expect(input.kind).toBe("terminal.input"); expect(input.payload).toEqual({ sessionId: "s1", bytesBase64: bytesToBase64(new Uint8Array([13])) }); const resize = JSON.parse(sockets[0].sent[base + 1]); expect(resize.kind).toBe("terminal.resize"); expect(resize.payload).toEqual({ sessionId: "s1", rows: 40, cols: 140 }); // detach keeps the PTY alive (terminal.detach); close kills it (terminal.close). handle.detach(); await vi.waitFor(() => expect(JSON.parse(sockets[0].sent[base + 2]).kind).toBe("terminal.detach"), ); }); });