import { describe, test, expect, afterAll } from 'bun:test'; import type { ServerWebSocket, Subprocess } from 'bun'; // Integration test for the things about the pty sidecar that cannot be reasoned about from the code alone: // that it serves its own transport, that a shell outlives both its viewer and officer, and that re-attach // replays rather than re-spawns. // // The fake officer exists only to receive the announced port — officer is not in the data path any more, // so the browser side is spoken directly to the sidecar's own listener. Nothing here touches the running // officer: the sidecar dials API_URL, overridden per spawn. const SIDECAR = 'src/servers/sidecar/pty/index.mjs'; type Frame = Record; function collector() { const frames: Frame[] = []; const waiters: Array<{ match: (f: Frame) => boolean; resolve: (f: Frame) => void }> = []; const push = (frame: 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); } }; const await_ = (match: (f: Frame) => boolean, what: string, 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 ${what}`)), timeoutMs); waiters.push({ match, resolve: (f) => { clearTimeout(timer); resolve(f); }, }); }); return { frames, push, await_ }; } /** A fake officer: just enough registration socket to receive `pty:server`. */ function fakeOfficer(port?: number) { let socket: ServerWebSocket | null = null; const { push, await_ } = collector(); 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; ws.send(JSON.stringify({ type: 'registered', id: 'sc_test' })); }, message(_ws, raw) { push(JSON.parse(String(raw)) as Frame); }, close() { socket = null; }, }, }); return { port: server.port, await: (match: (f: Frame) => boolean, what: string) => await_(match, what), stop: () => server.stop(true), get connected() { return socket !== null; }, }; } /** A fake browser. The sidecar's socket contract IS the one the browser already speaks. */ function client(wsBase: string, query: string) { const ws = new WebSocket(`${wsBase}/ws?${query}`); const { frames, push, await_ } = collector(); ws.addEventListener('message', (ev) => push(JSON.parse(String(ev.data)) as Frame)); return { open: () => new Promise((resolve) => ws.readyState === WebSocket.OPEN ? resolve() : ws.addEventListener('open', () => resolve()), ), send: (msg: Frame) => ws.send(JSON.stringify(msg)), await: (match: (f: Frame) => boolean, what: string) => await_(match, what), outputSoFar: () => frames .filter((f) => f.type === 'output') .map((f) => String(f.data)) .join(''), close: () => ws.close(), }; } const sawOutput = (needle: string) => (f: Frame) => f.type === 'output' && String(f.data).includes(needle); let child: Subprocess | null = null; afterAll(() => child?.kill()); describe('pty sidecar', () => { test('serves its own transport; a shell outlives both its viewer and officer', async () => { const sessionId = 'test-transport'; let officer = fakeOfficer(); const officerPort = officer.port; child = Bun.spawn(['node', SIDECAR], { env: { ...process.env, API_URL: `ws://127.0.0.1:${officerPort}`, // The sidecar chooses the shell and the home itself, so pin both rather than spawning the owner's // interactive zsh, which would read their rc files and their history. SHELL: '/bin/sh', HOME_DIR: '/tmp', ENV: '/dev/null', }, stdout: 'ignore', stderr: 'ignore', }); // The only thing that crosses the registration socket now. const announced = await officer.await((f) => f.type === 'pty:server' && typeof f.port === 'number', 'pty:server'); const httpBase = `http://127.0.0.1:${announced.port}`; const wsBase = `ws://127.0.0.1:${announced.port}`; const a = client(wsBase, `sessionId=${sessionId}&cwd=~&cols=80&rows=24`); await a.open(); // `~` is resolved by the sidecar against its own HOME_DIR. a.send({ type: 'input', data: 'pwd\n' }); await a.await(sawOutput('/tmp'), 'pwd output'); a.send({ type: 'input', data: 'echo before-restart\n' }); await a.await(sawOutput('before-restart'), 'before-restart'); // ── officer restarts ── // It is not in the data path, so this is a non-event for the shell. Under the old translating bridge // every keystroke and every byte of output crossed the registration socket. officer.stop(); a.send({ type: 'input', data: 'echo during-outage\n' }); await a.await(sawOutput('during-outage'), 'output while officer is down'); officer = fakeOfficer(officerPort); await officer.await((f) => f.type === 'pty:server', 're-announced port'); // ── the viewer goes away, a new one attaches ── a.close(); const b = client(wsBase, `sessionId=${sessionId}&cols=80&rows=24`); await b.open(); // Re-attach replays scrollback as history rather than ordinary output, so the client rebuilds instead // of appending a second copy — and it spans everything, including what happened while officer was down. const replay = await b.await((f) => f.type === 'replay', 'replay'); expect(String(replay.data)).toContain('before-restart'); expect(String(replay.data)).toContain('during-outage'); expect(b.outputSoFar()).not.toContain('before-restart'); // Same shell, not a second one. b.send({ type: 'input', data: 'echo after-reattach\n' }); await b.await(sawOutput('after-reattach'), 'after-reattach'); // ── the sidecar's own HTTP surface ── const listed = (await fetch(`${httpBase}/_officer/sessions`).then((r) => r.json())) as { sessions: any[] }; const entry = listed.sessions.find((s) => s.sessionId === sessionId); expect(entry).toBeTruthy(); expect(entry.pid).toBeGreaterThan(0); expect(entry.clients).toBe(1); const killed = await fetch(`${httpBase}/_officer/sessions/${sessionId}`, { method: 'DELETE' }); expect(killed.status).toBe(200); await b.await((f) => f.type === 'exit', 'exit after kill'); const after = (await fetch(`${httpBase}/_officer/sessions`).then((r) => r.json())) as { sessions: any[] }; expect(after.sessions.find((s) => s.sessionId === sessionId)).toBeUndefined(); b.close(); officer.stop(); }, 30_000); });