maestro/src/ssh/console-session.test.ts
oss-sync 02c7dfdd83
Some checks failed
CI / build-and-test (push) Has been cancelled
sync: update from private repo (7d64ee2)
2026-06-05 05:42:11 +00:00

216 lines
8.2 KiB
TypeScript

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);
});
});
});