import { describe, expect, test, vi } from "vitest"; import type pino from "pino"; import type { SessionOutboundMessage } from "../server/messages.js"; import { TerminalStreamOpcode, decodeTerminalStreamFrame, type TerminalStreamFrame, } from "@getpaseo/protocol/binary-frames/index"; import type { TerminalCell, TerminalState } from "@getpaseo/protocol/messages"; import type { ServerMessage, TerminalSession, TerminalStateSnapshot } from "./terminal.js"; import { TerminalSessionController } from "./terminal-session-controller.js"; import type { TerminalManager, TerminalsChangedEvent } from "./terminal-manager.js"; import { isSameOrDescendantPath } from "../server/path-utils.js"; function deferred(): { promise: Promise; resolve: (value: T) => void; reject: (error: unknown) => void; } { let resolve!: (value: T) => void; let reject!: (error: unknown) => void; const promise = new Promise((promiseResolve, promiseReject) => { resolve = promiseResolve; reject = promiseReject; }); return { promise, resolve, reject }; } function terminalRow(text: string, cols = 80): TerminalCell[] { return Array.from({ length: cols }, (_, index) => ({ char: text[index] ?? " ", })); } function terminalState(text: string): TerminalState { return { rows: 1, cols: 80, grid: [terminalRow(text)], scrollback: [], cursor: { row: 0, col: text.length }, }; } function createLogger(): pino.Logger { return { warn: vi.fn(), error: vi.fn(), } as unknown as pino.Logger; } describe("terminal-session-controller restore", () => { test("delivers output produced while restore is in flight after the restore frame", async () => { let terminalListener: ((message: ServerMessage) => void) | null = null; const snapshot = deferred(); const binaryFrames: TerminalStreamFrame[] = []; const outboundMessages: SessionOutboundMessage[] = []; const terminal: TerminalSession = { id: "term-1", name: "Terminal", cwd: "/tmp", send: vi.fn(), subscribe: (listener) => { terminalListener = listener; queueMicrotask(() => listener({ type: "snapshotReady", revision: 1 })); return vi.fn(); }, onExit: () => vi.fn(), onCommandFinished: () => vi.fn(), onTitleChange: () => vi.fn(), getSize: () => ({ rows: 1, cols: 80 }), getState: () => terminalState("restore-before"), getStateSnapshot: () => ({ state: terminalState("restore-before"), revision: 1 }), getReplayPreamble: () => "", getTitle: () => undefined, setTitle: vi.fn(), getExitInfo: () => null, kill: vi.fn(), killAndWait: vi.fn(), }; const terminalManager: TerminalManager = { getTerminals: vi.fn(), createTerminal: vi.fn(), registerCwdEnv: vi.fn(), getTerminal: vi.fn(() => terminal), getTerminalState: vi.fn(() => snapshot.promise), setTerminalTitle: vi.fn(), killTerminal: vi.fn(), killTerminalAndWait: vi.fn(), captureTerminal: vi.fn(), listDirectories: vi.fn(() => []), killAll: vi.fn(), subscribeTerminalsChanged: vi.fn(() => vi.fn()), }; const controller = new TerminalSessionController({ terminalManager, emit: (message) => outboundMessages.push(message), emitBinary: (bytes) => { const frame = decodeTerminalStreamFrame(bytes); if (frame) { binaryFrames.push(frame); } }, hasBinaryChannel: () => true, isPathWithinRoot: () => false, sessionLogger: createLogger(), }); await controller.dispatch({ type: "subscribe_terminal_request", terminalId: "term-1", requestId: "req-1", restore: { mode: "visible-snapshot", scrollbackLines: 200, }, }); await Promise.resolve(); expect(terminalManager.getTerminalState).toHaveBeenCalledTimes(1); terminalListener?.({ type: "output", data: "restore-after\n", revision: 2 }); snapshot.resolve({ state: terminalState("restore-before"), revision: 1 }); await snapshot.promise; await new Promise((resolve) => setTimeout(resolve, 20)); expect(outboundMessages).toContainEqual({ type: "subscribe_terminal_response", payload: { terminalId: "term-1", slot: 0, error: null, requestId: "req-1", }, }); expect(binaryFrames.map((frame) => frame.opcode)).toEqual([ TerminalStreamOpcode.Restore, TerminalStreamOpcode.Output, ]); expect(new TextDecoder().decode(binaryFrames[0]?.payload)).toContain("restore-before"); expect(new TextDecoder().decode(binaryFrames[1]?.payload)).toBe("restore-after\n"); }); }); function listSession(input: { id: string; name: string; cwd: string }): TerminalSession { return { id: input.id, name: input.name, cwd: input.cwd, send: vi.fn(), subscribe: () => vi.fn(), onExit: () => vi.fn(), onCommandFinished: () => vi.fn(), onTitleChange: () => vi.fn(), getSize: () => ({ rows: 1, cols: 80 }), getState: () => terminalState(""), getStateSnapshot: () => ({ state: terminalState(""), revision: 0 }), getReplayPreamble: () => "", getTitle: () => undefined, setTitle: vi.fn(), getExitInfo: () => null, kill: vi.fn(), killAndWait: vi.fn(), }; } async function flushMicrotasks(): Promise { await new Promise((resolve) => setTimeout(resolve, 0)); } describe("terminal-session-controller subdirectory aggregation", () => { test("delivers a subdirectory change to a root subscriber as an aggregated, root-keyed snapshot", async () => { const rootCwd = "/work/repo"; const subdirCwd = "/work/repo/apps/mobile"; // Aggregating subdirectory buckets into the root query is the manager's // contract, covered by terminal-manager.test.ts. Here we only assert the // controller re-fetches by root and keys the snapshot by root, so the fake // returns a fixed aggregated list for the root and nothing otherwise. const aggregatedRootTerminals = [ listSession({ id: "root-term", name: "Terminal 1", cwd: rootCwd }), listSession({ id: "subdir-term", name: "Mobile", cwd: subdirCwd }), ]; let changedListener: ((event: TerminalsChangedEvent) => void) | null = null; const terminalManager: TerminalManager = { getTerminals: vi.fn(async (cwd: string) => (cwd === rootCwd ? aggregatedRootTerminals : [])), createTerminal: vi.fn(), registerCwdEnv: vi.fn(), getTerminal: vi.fn(), getTerminalState: vi.fn(), setTerminalTitle: vi.fn(), killTerminal: vi.fn(), killTerminalAndWait: vi.fn(), captureTerminal: vi.fn(), listDirectories: vi.fn(() => [rootCwd, subdirCwd]), killAll: vi.fn(), subscribeTerminalsChanged: vi.fn((listener) => { changedListener = listener; return vi.fn(); }), }; const outboundMessages: SessionOutboundMessage[] = []; const controller = new TerminalSessionController({ terminalManager, emit: (message) => outboundMessages.push(message), emitBinary: vi.fn(), hasBinaryChannel: () => true, isPathWithinRoot: isSameOrDescendantPath, sessionLogger: createLogger(), }); controller.start(); controller.dispatch({ type: "subscribe_terminals_request", cwd: rootCwd }); await flushMicrotasks(); outboundMessages.length = 0; changedListener?.({ cwd: subdirCwd, terminals: [{ id: "subdir-term", name: "Mobile", cwd: subdirCwd }], }); await flushMicrotasks(); expect(outboundMessages).toEqual([ { type: "terminals_changed", payload: { cwd: rootCwd, terminals: [ { id: "root-term", name: "Terminal 1" }, { id: "subdir-term", name: "Mobile" }, ], }, }, ]); }); });