import { describe, test, expect, afterAll } from 'bun:test'; import type { ServerWebSocket, Subprocess } from 'bun'; // Integration test for the one thing about the pty sidecar that cannot be reasoned about from the code // alone: what happens to a live shell when officer goes away and comes back. It stands up a fake // registration socket, runs the real sidecar against it, then kills the socket and rebinds the same port // the way `pm2 restart officer` does. // // Nothing here touches the running officer — the sidecar dials API_URL, which is overridden per spawn. const SIDECAR = 'src/servers/api/terminal/pty-sidecar.mjs'; type Frame = Record; // One fake officer. `stop()` drops the socket; a new instance on the same port is the restart. function fakeOfficer(port?: number) { let socket: ServerWebSocket | null = null; const frames: Frame[] = []; const waiters: Array<{ match: (f: Frame) => boolean; resolve: (f: Frame) => void }> = []; const server = Bun.serve({ port: port ?? 0, fetch(req, srv) { if (new URL(req.url).pathname === '/api/sidecar/register' && srv.upgrade(req)) return undefined; return new Response('no', { status: 404 }); }, websocket: { open(ws) { socket = ws; }, message(_ws, raw) { const frame = JSON.parse(String(raw)) as Frame; frames.push(frame); for (let i = waiters.length - 1; i >= 0; i--) { if (waiters[i]!.match(frame)) waiters.splice(i, 1)[0]!.resolve(frame); } }, close() { socket = null; }, }, }); return { port: server.port, send: (msg: Frame) => socket?.send(JSON.stringify(msg)), /** Resolve on the first frame matching `match`, including ones already received. */ await: (match: (f: Frame) => boolean, timeoutMs = 15_000) => new Promise((resolve, reject) => { const seen = frames.find(match); if (seen) return resolve(seen); const timer = setTimeout(() => reject(new Error(`timed out waiting for a frame`)), timeoutMs); waiters.push({ match, resolve: (f) => { clearTimeout(timer); resolve(f); }, }); }), /** Output frames for one session, concatenated — the terminal's visible text. */ outputFor: (sessionId: string) => frames .filter((f) => f.type === 'pty:output' && f.sessionId === sessionId) .map((f) => f.data as string) .join(''), stop: () => server.stop(true), }; } const isOutput = (sessionId: string, needle: string) => (f: Frame) => f.type === 'pty:output' && f.sessionId === sessionId && String(f.data).includes(needle); let child: Subprocess | null = null; afterAll(() => child?.kill()); describe('pty sidecar', () => { test('a shell keeps streaming output after officer restarts under it', async () => { const sessionId = 'test-restart'; let officer = fakeOfficer(); const port = officer.port; child = Bun.spawn(['node', SIDECAR], { env: { ...process.env, API_URL: `ws://127.0.0.1:${port}` }, stdout: 'ignore', stderr: 'ignore', }); await officer.await((f) => f.type === 'register' && f.capabilities?.includes('terminal')); // A plain non-interactive shell: no rc files, no prompt, so the assertions below are about output // the test itself caused. officer.send({ type: 'pty:init', id: 'i1', sessionId, config: { sessionId, host: true, shell: { command: '/bin/sh', args: [] }, cwd: '/tmp', homeDir: '/tmp', cols: 80, rows: 24, }, }); await officer.await((f) => f.type === 'pty:ready' && f.sessionId === sessionId); officer.send({ type: 'pty:input', id: 'in1', sessionId, data: 'echo before-restart\n' }); await officer.await(isOutput(sessionId, 'before-restart')); // ── the restart ── officer.stop(); officer = fakeOfficer(port); await officer.await((f) => f.type === 'register'); // The shell is the same process; only officer changed. Before the sendJson fix this input was // accepted and executed, but its output went to the socket captured at init time and vanished. officer.send({ type: 'pty:input', id: 'in2', sessionId, data: 'echo after-restart\n' }); await officer.await(isOutput(sessionId, 'after-restart')); // Re-attaching replays the scrollback, marked as history so the client can rebuild rather than // append — and it contains what happened on both sides of the restart. officer.send({ type: 'pty:init', id: 'i2', sessionId, config: { sessionId, host: true, cols: 80, rows: 24 } }); const replay = await officer.await((f) => f.type === 'pty:replay' && f.sessionId === sessionId); expect(String(replay.data)).toContain('before-restart'); expect(String(replay.data)).toContain('after-restart'); // Re-attach must not spawn a second shell, and must not replay as ordinary output. await officer.await((f) => f.type === 'pty:ready' && f.id === 'i2'); expect(officer.outputFor(sessionId)).not.toContain('before-restart'); officer.send({ type: 'pty:close', id: 'c1', sessionId }); await officer.await((f) => f.type === 'pty:exit' && f.sessionId === sessionId); officer.stop(); }, 30_000); });