feat: T12 session/session.ts — PTY lifecycle (M4/M5/L1/L4)
createSession/attachWs/detachWs/writeInput/resize/kill; node-pty spawn (throws on failure/M4), onData->buffer+forward, onExit->exitedAt+exit+injected onExit; sendIfOpen guards readyState (M5); detach never kills PTY; write/resize no-op after exit (L4); detach-then-exit keeps session (L1). Tests vi.mock node-pty -> mock IPty (no real spawn): 19 tests. Full suite green.
This commit is contained in:
145
src/session/session.ts
Normal file
145
src/session/session.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
/**
|
||||||
|
* src/session/session.ts (T12) — single PTY session: lifecycle + ws plumbing.
|
||||||
|
*
|
||||||
|
* Owns one node-pty process and its scrollback. Wires:
|
||||||
|
* - pty.onData → ring buffer + (forward to attachedWs as `output`),
|
||||||
|
* - pty.onExit → stamp exitedAt/exitCode → (notify attachedWs with `exit`)
|
||||||
|
* → call the injected onExit so the manager can drop it (L2).
|
||||||
|
*
|
||||||
|
* Cross-validated fixes embedded here (ARCHITECTURE §3.4 / §4.4):
|
||||||
|
* - M4: spawn failure THROWS — createSession does not swallow it; server.ts
|
||||||
|
* catches and serializes a single `exit(-1, reason)` for that connection.
|
||||||
|
* - M5: every ws.send is guarded by `ws.readyState === WS_OPEN`.
|
||||||
|
* - L1: detach-then-exit keeps the session; with no attachedWs the `exit` is
|
||||||
|
* not delivered now — it is re-sent on the next attach (manager's job).
|
||||||
|
* - L4: writeInput/resize are silently ignored once the PTY has exited
|
||||||
|
* (calling write/resize on a dead PTY throws).
|
||||||
|
*
|
||||||
|
* The session never holds a reference to the manager; the exit event bubbles
|
||||||
|
* across that layer purely through the injected `onExit` callback.
|
||||||
|
*
|
||||||
|
* Imports src/types.ts (frozen, read-only) and createRingBuffer (T6).
|
||||||
|
* node-pty's `spawn` is imported here and mocked in tests so no real shell runs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { spawn } from 'node-pty';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
Config,
|
||||||
|
Dims,
|
||||||
|
IPty,
|
||||||
|
Session,
|
||||||
|
SessionMeta,
|
||||||
|
ServerMessage,
|
||||||
|
WebSocketLike,
|
||||||
|
} from '../types.js';
|
||||||
|
import { WS_OPEN } from '../types.js';
|
||||||
|
import { serialize } from '../protocol.js';
|
||||||
|
import { createRingBuffer } from './ring-buffer.js';
|
||||||
|
|
||||||
|
/** Send a server message to `ws` only if it is OPEN (M5). Never throws on a
|
||||||
|
* closed socket; forwarding to a dead ws is simply a no-op. */
|
||||||
|
function sendIfOpen(ws: WebSocketLike | null, msg: ServerMessage): void {
|
||||||
|
if (ws !== null && ws.readyState === WS_OPEN) {
|
||||||
|
ws.send(serialize(msg));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spawn a PTY and wire its data/exit streams. Throws on spawn failure (M4).
|
||||||
|
*
|
||||||
|
* @param onExit injected by the manager — invoked after exitedAt is set so the
|
||||||
|
* session can be removed from the table (L2).
|
||||||
|
*/
|
||||||
|
export function createSession(
|
||||||
|
cfg: Config,
|
||||||
|
dims: Dims,
|
||||||
|
now: number,
|
||||||
|
onExit: (session: Session) => void,
|
||||||
|
): Session {
|
||||||
|
// M4: let a spawn failure (e.g. missing shell) propagate synchronously.
|
||||||
|
const pty: IPty = spawn(cfg.shellPath, [], {
|
||||||
|
name: 'xterm-256color',
|
||||||
|
cols: dims.cols,
|
||||||
|
rows: dims.rows,
|
||||||
|
cwd: cfg.homeDir,
|
||||||
|
env: process.env,
|
||||||
|
});
|
||||||
|
|
||||||
|
const meta: SessionMeta = {
|
||||||
|
id: randomUUID(),
|
||||||
|
createdAt: now,
|
||||||
|
shellPath: cfg.shellPath,
|
||||||
|
};
|
||||||
|
|
||||||
|
const session: Session = {
|
||||||
|
meta,
|
||||||
|
buffer: createRingBuffer(cfg.scrollbackBytes),
|
||||||
|
attachedWs: null,
|
||||||
|
detachedAt: null,
|
||||||
|
lastOutputAt: now,
|
||||||
|
exitedAt: null,
|
||||||
|
exitCode: null,
|
||||||
|
pty,
|
||||||
|
};
|
||||||
|
|
||||||
|
// onData: persist to scrollback, refresh liveness, forward to the live ws.
|
||||||
|
pty.onData((chunk) => {
|
||||||
|
session.buffer.append(chunk);
|
||||||
|
session.lastOutputAt = Date.now();
|
||||||
|
sendIfOpen(session.attachedWs, { type: 'output', data: chunk });
|
||||||
|
});
|
||||||
|
|
||||||
|
// onExit: record exit, notify the attached ws (if any, L1), then bubble up.
|
||||||
|
pty.onExit(({ exitCode }) => {
|
||||||
|
session.exitedAt = Date.now();
|
||||||
|
session.exitCode = exitCode;
|
||||||
|
sendIfOpen(session.attachedWs, { type: 'exit', code: exitCode });
|
||||||
|
onExit(session);
|
||||||
|
});
|
||||||
|
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bind `ws` to the session: swap the pointer to the new ws FIRST (so live
|
||||||
|
* forwarding never targets a kicked socket), replay the scrollback, then let
|
||||||
|
* the real-time stream continue. Returns the kicked old ws (caller closes it).
|
||||||
|
*/
|
||||||
|
export function attachWs(session: Session, ws: WebSocketLike): WebSocketLike | null {
|
||||||
|
const previous = session.attachedWs;
|
||||||
|
|
||||||
|
// Pointer first: later attach wins; forwarding henceforth only reaches `ws`.
|
||||||
|
session.attachedWs = ws;
|
||||||
|
session.detachedAt = null;
|
||||||
|
|
||||||
|
// Replay the buffered scrollback so the reconnecting client sees the last screen.
|
||||||
|
sendIfOpen(ws, { type: 'output', data: session.buffer.snapshot() });
|
||||||
|
|
||||||
|
return previous;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Detach the ws and stamp detachedAt. NEVER kills the PTY (vibe-coding core). */
|
||||||
|
export function detachWs(session: Session, now: number): void {
|
||||||
|
session.attachedWs = null;
|
||||||
|
session.detachedAt = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Forward input to the PTY. No-op once the PTY has exited (L4). */
|
||||||
|
export function writeInput(session: Session, data: string): void {
|
||||||
|
if (session.exitedAt !== null) return;
|
||||||
|
session.pty.write(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resize the PTY. No-op after exit (L4); idempotent when dims are unchanged. */
|
||||||
|
export function resize(session: Session, cols: number, rows: number): void {
|
||||||
|
if (session.exitedAt !== null) return;
|
||||||
|
if (session.pty.cols === cols && session.pty.rows === rows) return;
|
||||||
|
session.pty.resize(cols, rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kill the underlying PTY (process exit / idle reclaim). */
|
||||||
|
export function kill(session: Session): void {
|
||||||
|
session.pty.kill();
|
||||||
|
}
|
||||||
327
test/session.test.ts
Normal file
327
test/session.test.ts
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user