Files
web-terminal/test/session.test.ts
Yaojia Wang 9099f73534 feat(v0.3): H1 — tmux keepalive (sessions survive a server restart)
- src/session/tmux.ts: sync tmux CLI wrappers (available/has/kill), best-effort
- config: useTmux from USE_TMUX env (1/0/auto→detect tmux on PATH)
- session: when useTmux, spawn 'tmux new-session -A -s web_<id> <shell>' (the
  node-pty proc is a tmux CLIENT); createSession takes an optional id for
  re-attach; Session.tmuxName; kill() runs tmux kill-session
- manager: handleAttach re-attaches to a surviving 'web_<id>' tmux session after
  a restart (Case 3.5); shutdown kills only the client pty for tmux sessions so
  the shell keeps running
- tests: USE_TMUX:0 in shared integration cfg (no tmux leakage); unit CFGs
  useTmux:false; integration 'H1' (real tmux): set var → restart server →
  re-attach → var survived. 208 tests green.
2026-06-17 19:48:39 +02:00

329 lines
11 KiB
TypeScript

/**
* 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',
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.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);
});
});