Terminal snapshots now carry soft-wrap row metadata only to clients that advertise support, so restored output can resize like live output without breaking older clients.
323 lines
11 KiB
TypeScript
323 lines
11 KiB
TypeScript
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<T>(): {
|
|
promise: Promise<T>;
|
|
resolve: (value: T) => void;
|
|
reject: (error: unknown) => void;
|
|
} {
|
|
let resolve!: (value: T) => void;
|
|
let reject!: (error: unknown) => void;
|
|
const promise = new Promise<T>((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<TerminalStateSnapshot | null>();
|
|
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<void> {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
}
|
|
|
|
describe("terminal-session-controller wrap-flag gating", () => {
|
|
function setup(clientSupportsWrapReflow?: () => boolean): {
|
|
controller: TerminalSessionController;
|
|
getTerminalState: ReturnType<typeof vi.fn>;
|
|
} {
|
|
const terminal: TerminalSession = {
|
|
id: "term-1",
|
|
name: "Terminal",
|
|
cwd: "/tmp",
|
|
send: vi.fn(),
|
|
subscribe: (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("hello"),
|
|
getStateSnapshot: () => ({ state: terminalState("hello"), revision: 1 }),
|
|
getReplayPreamble: () => "",
|
|
getTitle: () => undefined,
|
|
setTitle: vi.fn(),
|
|
getExitInfo: () => null,
|
|
kill: vi.fn(),
|
|
killAndWait: vi.fn(),
|
|
};
|
|
const getTerminalState = vi.fn(() =>
|
|
Promise.resolve<TerminalStateSnapshot>({ state: terminalState("hello"), revision: 1 }),
|
|
);
|
|
const terminalManager = {
|
|
getTerminals: vi.fn(),
|
|
createTerminal: vi.fn(),
|
|
registerCwdEnv: vi.fn(),
|
|
getTerminal: vi.fn(() => terminal),
|
|
getTerminalState,
|
|
setTerminalTitle: vi.fn(),
|
|
killTerminal: vi.fn(),
|
|
killTerminalAndWait: vi.fn(),
|
|
captureTerminal: vi.fn(),
|
|
listDirectories: vi.fn(() => []),
|
|
killAll: vi.fn(),
|
|
subscribeTerminalsChanged: vi.fn(() => vi.fn()),
|
|
} as unknown as TerminalManager;
|
|
const controller = new TerminalSessionController({
|
|
terminalManager,
|
|
emit: vi.fn(),
|
|
emitBinary: vi.fn(),
|
|
hasBinaryChannel: () => true,
|
|
isPathWithinRoot: () => false,
|
|
sessionLogger: createLogger(),
|
|
...(clientSupportsWrapReflow ? { clientSupportsWrapReflow } : {}),
|
|
});
|
|
return { controller, getTerminalState };
|
|
}
|
|
|
|
async function subscribe(controller: TerminalSessionController): Promise<void> {
|
|
await controller.dispatch({
|
|
type: "subscribe_terminal_request",
|
|
terminalId: "term-1",
|
|
requestId: "req-1",
|
|
restore: { mode: "visible-snapshot", scrollbackLines: 200 },
|
|
});
|
|
await flushMicrotasks();
|
|
}
|
|
|
|
test("requests wrap flags when the client supports reflowable snapshots", async () => {
|
|
const { controller, getTerminalState } = setup(() => true);
|
|
await subscribe(controller);
|
|
expect(getTerminalState).toHaveBeenCalledWith(
|
|
"term-1",
|
|
expect.objectContaining({ includeWrapFlags: true }),
|
|
);
|
|
});
|
|
|
|
test("omits wrap flags when the client does not advertise support", async () => {
|
|
const { controller, getTerminalState } = setup();
|
|
await subscribe(controller);
|
|
expect(getTerminalState).toHaveBeenCalledWith(
|
|
"term-1",
|
|
expect.objectContaining({ includeWrapFlags: false }),
|
|
);
|
|
});
|
|
});
|
|
|
|
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" },
|
|
],
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
});
|