/** * T12 tests — session/session.ts (PTY lifecycle + multi-client sharing). * * ARCHITECTURE §3.4 / §4.4 + the cross-validated fixes: * - M4: spawn failure THROWS, never swallowed. * - M5: every ws.send is guarded by `ws.readyState === WS_OPEN`. * - L1: detach-then-exit keeps the session (exit not delivered when no client attached). * - L4: writeInput/setClientDims are no-ops once the PTY has exited. * - v0.4: a session may hold MANY clients (multi-device mirror). Output/exit are * broadcast to all; the PTY size is the MIN cols/rows across clients. * * node-pty is mocked (`vi.mock`) so `spawn` returns a MockIPty — the sandbox blocks * real posix_spawn, and the mock lets us drive onData/onExit deterministically. */ import { describe, it, expect, beforeEach, vi } from 'vitest'; import type { Config, Dims, Session, ServerMessage, WebSocketLike } from '../src/types.js'; import { WS_OPEN } from '../src/types.js'; import { createMockPty, type MockIPty } from './helpers/mock-pty.js'; // ── node-pty mock ─────────────────────────────────────────────────────────── let nextPty: MockIPty; let spawnError: Error | null = null; const spawnCalls: Array<{ file: string; args: string[] | string; options: unknown }> = []; vi.mock('node-pty', () => ({ spawn: (file: string, args: string[] | string, options: unknown) => { spawnCalls.push({ file, args, options }); if (spawnError) throw spawnError; return nextPty; }, })); // Imported AFTER vi.mock so the module under test binds to the mocked spawn. const { createSession, attachWs, detachWs, writeInput, setClientDims, clearClientDims, kill } = await import('../src/session/session.js'); // ── helpers ───────────────────────────────────────────────────────────────── const CFG: Config = { port: 3000, bindHost: '0.0.0.0', shellPath: '/bin/zsh', homeDir: '/home/tester', idleTtlMs: 86_400_000, scrollbackBytes: 2 * 1024 * 1024, maxPayloadBytes: 1024 * 1024, wsPath: '/term', useTmux: false, allowedOrigins: [], }; const DIMS: Dims = { cols: 80, rows: 24 }; interface MockWs extends WebSocketLike { readyState: number; sent: string[]; closed: boolean; } function createMockWs(readyState: number = WS_OPEN): MockWs { const sent: string[] = []; return { readyState, sent, closed: false, send(data: string) { sent.push(data); }, close() { this.closed = true; }, }; } /** Decode the JSON frames a ws received back into ServerMessages. */ function received(ws: MockWs): ServerMessage[] { return ws.sent.map((s) => JSON.parse(s) as ServerMessage); } function newSession(onExit: (s: Session) => void = () => {}): Session { nextPty = createMockPty(); return createSession(CFG, DIMS, 1_000, onExit); } beforeEach(() => { spawnError = null; spawnCalls.length = 0; }); // ── createSession / spawn ───────────────────────────────────────────────────── describe('createSession', () => { it('spawns the PTY via node-pty with shellPath, cwd and dims', () => { const s = newSession(); expect(spawnCalls).toHaveLength(1); expect(spawnCalls[0]!.file).toBe(CFG.shellPath); const opts = spawnCalls[0]!.options as { cwd?: string; cols?: number; rows?: number }; expect(opts.cwd).toBe(CFG.homeDir); expect(opts.cols).toBe(DIMS.cols); expect(opts.rows).toBe(DIMS.rows); expect(s.pty).toBe(nextPty); expect(s.meta.shellPath).toBe(CFG.shellPath); expect(s.meta.createdAt).toBe(1_000); expect(s.clients.size).toBe(0); expect(s.exitedAt).toBeNull(); expect(s.exitCode).toBeNull(); }); it('THROWS (does not swallow) when spawn fails (M4)', () => { spawnError = new Error('spawn failed: /no/such/shell ENOENT'); nextPty = createMockPty(); expect(() => createSession(CFG, DIMS, 1_000, () => {})).toThrow(/spawn failed/); }); }); // ── onData → buffer + broadcast ──────────────────────────────────────────────── describe('onData', () => { it('appends output to the ring buffer and refreshes lastOutputAt', () => { const s = newSession(); const pty = s.pty as MockIPty; pty.emitData('hello '); pty.emitData('world'); expect(s.buffer.snapshot()).toBe('\x1b[0mhello world'); expect(s.lastOutputAt).toBeGreaterThanOrEqual(1_000); }); it('broadcasts output to the attached client as an `output` message', () => { const s = newSession(); const ws = createMockWs(); attachWs(s, ws); ws.sent.length = 0; // drop the replay frame from attach (s.pty as MockIPty).emitData('abc'); expect(received(ws)).toEqual([{ type: 'output', data: 'abc' }]); }); it('broadcasts output to EVERY client sharing the session (multi-device)', () => { const s = newSession(); const a = createMockWs(); const b = createMockWs(); attachWs(s, a); attachWs(s, b); a.sent.length = 0; b.sent.length = 0; (s.pty as MockIPty).emitData('shared'); expect(received(a)).toEqual([{ type: 'output', data: 'shared' }]); expect(received(b)).toEqual([{ type: 'output', data: 'shared' }]); }); it('still buffers output when no client is attached (does not throw)', () => { const s = newSession(); expect(() => (s.pty as MockIPty).emitData('orphan')).not.toThrow(); expect(s.buffer.snapshot()).toContain('orphan'); }); it('does NOT send to a client whose readyState is not OPEN (M5)', () => { const s = newSession(); const ws = createMockWs(WS_OPEN); attachWs(s, ws); ws.sent.length = 0; ws.readyState = 3; // CLOSED (s.pty as MockIPty).emitData('lost'); expect(ws.sent).toHaveLength(0); }); }); // ── attachWs (join, no kick) ────────────────────────────────────────────────── describe('attachWs', () => { it('replays the buffer snapshot to the newly attached client', () => { const s = newSession(); (s.pty as MockIPty).emitData('prior output'); const ws = createMockWs(); attachWs(s, ws); expect(s.clients.has(ws)).toBe(true); expect(received(ws)).toEqual([{ type: 'output', data: '\x1b[0mprior output' }]); }); it('a second attach JOINS (does not kick) — both clients stay connected', () => { const s = newSession(); const a = createMockWs(); attachWs(s, a); const b = createMockWs(); attachWs(s, b); // Both present; the first was NOT closed. expect(s.clients.has(a)).toBe(true); expect(s.clients.has(b)).toBe(true); expect(a.closed).toBe(false); // Only the joining client got the replay; live output reaches both. a.sent.length = 0; b.sent.length = 0; (s.pty as MockIPty).emitData('live'); expect(received(a)).toEqual([{ type: 'output', data: 'live' }]); expect(received(b)).toEqual([{ type: 'output', data: 'live' }]); }); it('attaching alone does NOT change the PTY size (the active device keeps it)', () => { const s = newSession(); // spawn dims 80x24 const wide = createMockWs(); setClientDims(s, wide, 200, 50); expect(s.pty.cols).toBe(200); const hidden = createMockWs(); attachWs(s, hidden); // joins but never reports dims (background mirror) // The join must not change the PTY — the active viewer keeps 200x50. expect(s.pty.cols).toBe(200); expect(s.pty.rows).toBe(50); }); it('latest-writer-wins: the most recent client dims drive the PTY size', () => { const s = newSession(); const desktop = createMockWs(); const ipad = createMockWs(); setClientDims(s, desktop, 200, 50); expect(s.pty.cols).toBe(200); expect(s.pty.rows).toBe(50); // The device used next (smaller iPad) takes over — it is now full-screen. setClientDims(s, ipad, 100, 40); expect(s.pty.cols).toBe(100); expect(s.pty.rows).toBe(40); // Switching back to the desktop (re-fit on focus) reclaims its size. setClientDims(s, desktop, 200, 50); expect(s.pty.cols).toBe(200); expect(s.pty.rows).toBe(50); }); it('clearClientDims (tab hidden) does NOT resize — the active device keeps its size', () => { const s = newSession(); const a = createMockWs(); const b = createMockWs(); setClientDims(s, a, 200, 50); setClientDims(s, b, 90, 30); // b is using it now → 90x30 clearClientDims(s, a); // a's tab hidden elsewhere → just forget a's dims // PTY unchanged: b is still the active viewer at 90x30. expect(s.pty.cols).toBe(90); expect(s.pty.rows).toBe(30); }); }); // ── detachWs (remove one client) ────────────────────────────────────────────── describe('detachWs', () => { it('removes the client and stamps detachedAt only when the LAST one leaves', () => { const s = newSession(); const a = createMockWs(); const b = createMockWs(); attachWs(s, a); attachWs(s, b); detachWs(s, a, 5_000); // one client remains → still "attached", no detach stamp expect(s.clients.has(a)).toBe(false); expect(s.clients.has(b)).toBe(true); expect(s.detachedAt).toBeNull(); detachWs(s, b, 6_000); // last client gone → detached expect(s.clients.size).toBe(0); expect(s.detachedAt).toBe(6_000); expect((s.pty as MockIPty).killed).toBe(false); }); it('does NOT resize when a client leaves (the remaining device keeps its size)', () => { const s = newSession(); const wide = createMockWs(); const narrow = createMockWs(); attachWs(s, wide); setClientDims(s, wide, 200, 50); attachWs(s, narrow); setClientDims(s, narrow, 90, 30); // narrow is the latest writer → 90x30 detachWs(s, narrow, 5_000); // detach does not resize; PTY stays at the last set size until a client re-fits. expect(s.pty.cols).toBe(90); expect(s.pty.rows).toBe(30); }); it('keeps the PTY alive after the last detach: further onData still fills the buffer', () => { const s = newSession(); const ws = createMockWs(); attachWs(s, ws); detachWs(s, ws, 5_000); (s.pty as MockIPty).emitData('background work'); expect((s.pty as MockIPty).killed).toBe(false); expect(s.buffer.snapshot()).toContain('background work'); }); }); // ── onExit ──────────────────────────────────────────────────────────────────── describe('onExit', () => { it('sets exitedAt/exitCode, broadcasts `exit` to all clients, and calls injected onExit', () => { const onExit = vi.fn(); nextPty = createMockPty(); const s = createSession(CFG, DIMS, 1_000, onExit); const a = createMockWs(); const b = createMockWs(); attachWs(s, a); attachWs(s, b); a.sent.length = 0; b.sent.length = 0; (s.pty as MockIPty).emitExit(0); expect(s.exitedAt).not.toBeNull(); expect(s.exitCode).toBe(0); expect(received(a)).toEqual([{ type: 'exit', code: 0 }]); expect(received(b)).toEqual([{ type: 'exit', code: 0 }]); expect(onExit).toHaveBeenCalledWith(s); }); it('after the last detach, exit is NOT delivered but the session is preserved (L1)', () => { const onExit = vi.fn(); nextPty = createMockPty(); const s = createSession(CFG, DIMS, 1_000, onExit); const ws = createMockWs(); attachWs(s, ws); detachWs(s, ws, 5_000); ws.sent.length = 0; (s.pty as MockIPty).emitExit(137); expect(s.exitedAt).not.toBeNull(); expect(s.exitCode).toBe(137); expect(ws.sent).toHaveLength(0); expect(onExit).toHaveBeenCalledWith(s); }); it('does not send exit to a client whose readyState is not OPEN (M5)', () => { nextPty = createMockPty(); const s = createSession(CFG, DIMS, 1_000, () => {}); const ws = createMockWs(WS_OPEN); attachWs(s, ws); ws.sent.length = 0; ws.readyState = 3; // CLOSED (s.pty as MockIPty).emitExit(1); expect(ws.sent).toHaveLength(0); }); }); // ── writeInput / setClientDims after exit (L4) + dims idempotence ────────────── describe('writeInput / setClientDims', () => { it('writeInput forwards to pty.write while alive', () => { const s = newSession(); writeInput(s, 'ls\r'); expect((s.pty as MockIPty).writes).toEqual(['ls\r']); }); it('setClientDims forwards to pty.resize while alive', () => { const s = newSession(); const ws = createMockWs(); attachWs(s, ws); setClientDims(s, ws, 100, 40); expect((s.pty as MockIPty).resizes).toContainEqual({ cols: 100, rows: 40 }); }); it('setClientDims is idempotent: same min cols/rows are skipped', () => { const s = newSession(); const ws = createMockWs(); attachWs(s, ws); // 80x24, equals spawn dims → no resize yet expect((s.pty as MockIPty).resizes).toHaveLength(0); setClientDims(s, ws, 90, 24); // changed → applied setClientDims(s, ws, 90, 24); // unchanged again → skipped expect((s.pty as MockIPty).resizes).toEqual([{ cols: 90, rows: 24 }]); }); it('ignores writeInput once the PTY has exited (L4)', () => { const s = newSession(); (s.pty as MockIPty).emitExit(0); writeInput(s, 'should be dropped'); expect((s.pty as MockIPty).writes).toHaveLength(0); }); it('ignores setClientDims once the PTY has exited (L4)', () => { const s = newSession(); const ws = createMockWs(); attachWs(s, ws); (s.pty as MockIPty).emitExit(0); setClientDims(s, ws, 200, 50); expect((s.pty as MockIPty).resizes).toHaveLength(0); }); }); // ── kill ────────────────────────────────────────────────────────────────────── describe('kill', () => { it('kills the underlying PTY', () => { const s = newSession(); kill(s); expect((s.pty as MockIPty).killed).toBe(true); }); });