216 lines
8.2 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|