/** * T12 tests — session/session.ts (single-session lifecycle). * * 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 attachedWs is null). * - L4: writeInput/resize are no-ops once the PTY has exited. * * 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 and * assert lifecycle without a real shell. */ 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 ─────────────────────────────────────────────────────────── // spawn returns whatever `nextPty` points at, and records its call args so we // can assert how createSession invoked it (and force it to throw for M4). 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, resize, 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', 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.attachedWs).toBeNull(); 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 + forward ────────────────────────────────────────────────── 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'); // buffer snapshot prepends the M2 soft-reset, then the verbatim output. expect(s.buffer.snapshot()).toBe('\x1b[0mhello world'); expect(s.lastOutputAt).toBeGreaterThanOrEqual(1_000); }); it('forwards output to the attached ws 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('still buffers output when no ws 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 ws 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 ────────────────────────────────────────────────────────────────── describe('attachWs', () => { it('replays the buffer snapshot to the newly attached ws', () => { const s = newSession(); (s.pty as MockIPty).emitData('prior output'); const ws = createMockWs(); const kicked = attachWs(s, ws); expect(kicked).toBeNull(); expect(s.attachedWs).toBe(ws); expect(received(ws)).toEqual([{ type: 'output', data: '\x1b[0mprior output' }]); }); it('points attachedWs at the new ws BEFORE returning the kicked old ws (later wins)', () => { const s = newSession(); const oldWs = createMockWs(); attachWs(s, oldWs); const newWs = createMockWs(); const kicked = attachWs(s, newWs); // pointer is swapped first; caller is responsible for closing the kicked ws. expect(s.attachedWs).toBe(newWs); expect(kicked).toBe(oldWs); expect(oldWs.closed).toBe(false); // attachWs must NOT close it itself // forwarding now only ever reaches the new ws. newWs.sent.length = 0; (s.pty as MockIPty).emitData('after kick'); expect(received(newWs)).toEqual([{ type: 'output', data: 'after kick' }]); expect(oldWs.sent.some((f) => f.includes('after kick'))).toBe(false); }); }); // ── detachWs ────────────────────────────────────────────────────────────────── describe('detachWs', () => { it('clears attachedWs and stamps detachedAt but NEVER kills the PTY', () => { const s = newSession(); const ws = createMockWs(); attachWs(s, ws); detachWs(s, 5_000); expect(s.attachedWs).toBeNull(); expect(s.detachedAt).toBe(5_000); expect((s.pty as MockIPty).killed).toBe(false); }); it('keeps the PTY alive after detach: further onData still fills the buffer', () => { const s = newSession(); const ws = createMockWs(); attachWs(s, ws); detachWs(s, 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, sends `exit` to the attached ws, and calls injected onExit', () => { const onExit = vi.fn(); nextPty = createMockPty(); const s = createSession(CFG, DIMS, 1_000, onExit); const ws = createMockWs(); attachWs(s, ws); ws.sent.length = 0; (s.pty as MockIPty).emitExit(0); expect(s.exitedAt).not.toBeNull(); expect(s.exitCode).toBe(0); expect(received(ws)).toEqual([{ type: 'exit', code: 0 }]); expect(onExit).toHaveBeenCalledWith(s); }); it('after 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, 5_000); ws.sent.length = 0; (s.pty as MockIPty).emitExit(137); // exit happened: state recorded, but no ws to notify → nothing delivered. expect(s.exitedAt).not.toBeNull(); expect(s.exitCode).toBe(137); expect(ws.sent).toHaveLength(0); // session preserved: buffer intact (caller/manager decides removal via onExit). expect(onExit).toHaveBeenCalledWith(s); }); it('does not send exit to a ws 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 / resize after exit (L4) + resize idempotence ────────────────── describe('writeInput / resize', () => { 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('resize forwards to pty.resize while alive', () => { const s = newSession(); resize(s, 100, 40); expect((s.pty as MockIPty).resizes).toEqual([{ cols: 100, rows: 40 }]); }); it('resize is idempotent: same cols/rows are skipped', () => { const s = newSession(); resize(s, 80, 24); // equal to the spawn dims → no-op expect((s.pty as MockIPty).resizes).toHaveLength(0); resize(s, 90, 24); // changed → applied resize(s, 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 resize once the PTY has exited (L4)', () => { const s = newSession(); (s.pty as MockIPty).emitExit(0); resize(s, 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); }); });