import { describe, it, expect, vi } from 'vitest'; import { EventEmitter } from 'node:events'; import { ConsoleSession } from './console-session.js'; class StubChannel extends EventEmitter { written: Buffer[] = []; windowChanges: Array<{ rows: number; cols: number }> = []; ended = false; write(buf: Buffer): boolean { this.written.push(buf); return true; } end(): void { this.ended = true; this.emit('close'); } setWindow(rows: number, cols: number, _h?: number, _w?: number): void { this.windowChanges.push({ rows, cols }); } } class StubClient extends EventEmitter { ended = false; end(): void { this.ended = true; this.emit('close'); } } function mkAudit() { return { beginAndComplete: vi.fn(), begin: vi.fn().mockReturnValue(1), complete: vi.fn(), }; } function mkSession(channel: StubChannel, client: StubClient = new StubClient()) { const audit = mkAudit(); const session = new ConsoleSession({ localTaskId: 't1', connectionId: 'c1', ownerId: 'u1', startedByUserId: 'u1', cols: 80, rows: 24, scrollbackCap: 1024, channel: channel as any, client: client as any, auditRepo: audit as any, }); return { session, audit, client }; } describe('ConsoleSession', () => { it('initialises with cols/rows + ssh2 channel hooks', () => { const ch = new StubChannel(); const { session } = mkSession(ch); expect(session.cols).toBe(80); expect(session.rows).toBe(24); expect(session.totalOutputBytes).toBe(0); }); it('routes server output into scrollback and headless terminal', () => { const ch = new StubChannel(); const { session } = mkSession(ch); ch.emit('data', Buffer.from('hello')); expect(session.totalOutputBytes).toBe(5); const screen = session.snapshotScreen(); expect(screen.text).toContain('hello'); }); it('write() forwards to channel and updates lastActivityAt (AI input: LF→CR)', async () => { const ch = new StubChannel(); const { session } = mkSession(ch); const before = session.lastActivityAt; await new Promise((r) => setTimeout(r, 5)); session.write(Buffer.from('ls\n'), 'ai'); // AI input has its LF terminator rewritten to CR so the remote PTY // (cooked mode, ICRNL) treats it as Enter — matches xterm.js human input. expect(ch.written[0]!.toString()).toBe('ls\r'); expect(session.lastActivityAt).toBeGreaterThan(before); expect(session.totalInputBytes).toBe(3); }); it('write() preserves human CR input unchanged', () => { const ch = new StubChannel(); const { session } = mkSession(ch); session.write(Buffer.from('uptime\r'), 'human'); expect(ch.written[0]!.toString()).toBe('uptime\r'); }); it('write() human partial input is forwarded immediately (no line buffer)', () => { const ch = new StubChannel(); const { session } = mkSession(ch); // Typing single chars one at a time — must reach the channel without // waiting for Enter, otherwise the shell cannot echo and the user // sees nothing in the terminal. session.write(Buffer.from('l'), 'human'); session.write(Buffer.from('s'), 'human'); session.write(Buffer.from(' -la'), 'human'); expect(ch.written.map((b) => b.toString())).toEqual(['l', 's', ' -la']); expect(session.totalInputBytes).toBe(6); }); it('write() AI partial input is also forwarded immediately (mirrors human)', () => { const ch = new StubChannel(); const { session } = mkSession(ch); // AI calling SshConsoleSend with no newline used to buffer the // bytes server-side, which made the shell go silent and looked like // a freeze. Forward immediately so the PTY echoes the characters // the same way it does when a human types. session.write(Buffer.from('ls'), 'ai'); expect(ch.written.length).toBe(1); expect(ch.written[0]!.toString()).toBe('ls'); session.write(Buffer.from(' -la\n'), 'ai'); // LF → CR via normalize expect(ch.written.length).toBe(2); expect(ch.written[1]!.toString()).toBe(' -la\r'); }); it('write() preserves control bytes (Ctrl-C) for both sources', () => { const ch = new StubChannel(); const { session } = mkSession(ch); session.write(Buffer.from([0x03]), 'ai'); session.write(Buffer.from([0x03]), 'human'); expect(ch.written.map((b) => b[0])).toEqual([0x03, 0x03]); }); it('write() converts every LF in a multi-line AI input', () => { const ch = new StubChannel(); const { session } = mkSession(ch); session.write(Buffer.from('line1\nline2\nline3\n'), 'ai'); // Each '\n' (0x0a) becomes '\r' (0x0d) so the remote shell treats each // line as Enter. const all = Buffer.concat(ch.written).toString(); expect(all).toBe('line1\rline2\rline3\r'); expect(all.indexOf('\n')).toBe(-1); }); it('resize() calls channel.setWindow + headless.resize', () => { const ch = new StubChannel(); const { session } = mkSession(ch); session.resize(100, 40); expect(session.cols).toBe(100); expect(session.rows).toBe(40); expect(ch.windowChanges).toEqual([{ rows: 40, cols: 100 }]); }); it('close() is idempotent, ends the client, and records audit', async () => { const ch = new StubChannel(); const { session, audit, client } = mkSession(ch); await session.close('idle_timeout'); await session.close('idle_timeout'); expect(ch.ended).toBe(true); expect(client.ended).toBe(true); expect(audit.beginAndComplete).toHaveBeenCalledTimes(1); const call = audit.beginAndComplete.mock.calls[0]![0]; expect(call.action).toBe('ssh.console.close'); expect(call.detail.reason).toBe('idle_timeout'); }); it('client "error" (ECONNRESET) tears the session down without throwing', async () => { const ch = new StubChannel(); const { session, audit, client } = mkSession(ch); // Regression for #407: an unhandled ssh2 Client 'error' event crashes // the whole Node process. ConsoleSession must own a listener so a // dropped transport closes only this session. expect(() => client.emit('error', new Error('read ECONNRESET'))).not.toThrow(); // close() runs on the next tick via the catch-less promise chain. await Promise.resolve(); expect(session.isClosed).toBe(true); expect(audit.beginAndComplete).toHaveBeenCalledTimes(1); expect(audit.beginAndComplete.mock.calls[0]![0].detail.reason).toBe('host_disconnect'); }); it('client "close" tears the session down once', async () => { const ch = new StubChannel(); const { session, audit, client } = mkSession(ch); client.emit('close'); await Promise.resolve(); expect(session.isClosed).toBe(true); expect(audit.beginAndComplete).toHaveBeenCalledTimes(1); expect(audit.beginAndComplete.mock.calls[0]![0].detail.reason).toBe('host_disconnect'); }); it('scrollback caps at scrollbackCap', () => { const ch = new StubChannel(); const { session } = mkSession(ch); ch.emit('data', Buffer.alloc(2048, 0x61)); const scroll = session.snapshotScrollback({ maxBytes: 5000 }); expect(scroll.text.length).toBeLessThanOrEqual(1024); }); describe('viewers', () => { it('addViewer / listViewers registers and lists handles', () => { const ch = new StubChannel(); const { session } = mkSession(ch); const closeA = vi.fn(); const closeB = vi.fn(); session.addViewer({ userId: 'u1', close: closeA }); session.addViewer({ userId: 'u2', close: closeB }); expect(session.listViewers().map((v) => v.userId).sort()).toEqual(['u1', 'u2']); }); it('addViewer returns an unsubscribe that removes the handle', () => { const ch = new StubChannel(); const { session } = mkSession(ch); const close = vi.fn(); const unsub = session.addViewer({ userId: 'u1', close }); expect(session.listViewers()).toHaveLength(1); unsub(); expect(session.listViewers()).toHaveLength(0); }); it('close() clears all viewers', async () => { const ch = new StubChannel(); const { session } = mkSession(ch); session.addViewer({ userId: 'u1', close: vi.fn() }); session.addViewer({ userId: 'u2', close: vi.fn() }); await session.close('idle_timeout'); expect(session.listViewers()).toHaveLength(0); }); }); });