pty
js/test/pty.ts
fino:test/pty — drive a terminal app under a real pty and assert on its emulated screen.
openPty() allocates a pseudo-terminal pair, spawns a child process whose
stdin/stdout/stderr are the slave side, and feeds every byte the child
writes into a VT terminal emulator (internal:tty/vt). On macOS, the parent
retains the slave until close drains pending output, so child exit cannot
discard bytes before the emulator reads them. Tests interact with
the child as a user would — keystrokes, mouse clicks, window resizes — and
assert on the emulated screen: visible text, styled spans, tracked DEC
modes, the alternate screen, and cursor state.
The child runs in a new session with its standard descriptors connected to
the slave. Raw mode, terminal geometry, and mouse reporting use that terminal;
resize() updates the geometry and explicitly signals the child with
SIGWINCH.
All I/O is asynchronous: the master fd is read through the event loop, and
child exit is observed with kernel notifications (kqueue EVFILT_PROC on
macOS, pidfd_open(2) on Linux) — never by blocking the main thread.
Useful references:
import { openPty } from 'fino:test/pty';
import { execPath } from 'fino:process';
const pty = await openPty(execPath, ['app.ts'], { cols: 100, rows: 30 });
await pty.waitFor((term) => term.text().some((l) => l.includes('ready')));
await pty.sendKey('enter');
const code = await pty.waitExit();
await pty.close();Interfaces
interface PtyOptions {
Options for {@link openPty}.
import type { PtyOptions } from 'fino:test/pty';
const opts: PtyOptions = { cols: 100, rows: 30, env: { TERM: 'xterm' } };Properties
cols?: number
Initial terminal width in columns. Defaults to 80.
rows?: number
Initial terminal height in rows. Defaults to 24.
cwd?: string
Working directory for the child process.
env?: Record<string, string>
Environment for the child. Replaces rather than merges with the inherited
snapshot, matching fino:process. TERM=xterm-256color is appended when
the effective environment has no TERM.
anchor?: 'cursor' | 'bottom'
How the emulator re-anchors content when {@link PtyHandle.resize} shrinks
the screen: 'cursor' (default) scrolls only far enough to keep the
cursor visible, 'bottom' always keeps the bottom rows.
interface PtyHandle {
A live pseudo-terminal session returned by {@link openPty}.
The handle owns the master fd, the child process, and the screen emulator.
Always close() it — the method is idempotent and safe after exit.
Readonly Properties
readonly term: Terminal
Emulated screen, updated as child output arrives.
readonly pid: number
Child process ID.
Methods
send(data: string | Uint8Array): Promise<void>
Write bytes to the master side, as if typed at the keyboard.
sendKey(key: string): Promise<void>
Send a named key: 'enter', 'tab', 'escape', 'backspace',
'up'/'down'/'right'/'left' (CSI A/B/C/D), or 'ctrl+<letter>'
for a control byte. Anything else is sent verbatim.
sendMouse(
action: 'press' | 'release' | 'wheel-up' | 'wheel-down',
x: number,
y: number,
): Promise<void>
Send an SGR-encoded mouse event at zero-based cell (x, y). The wire
encoding uses 1-based coordinates, matching what terminals emit when SGR
mouse reporting (mode 1006) is active.
resize(cols: number, rows: number): void
Change the pty window size: ioctl(TIOCSWINSZ) on the master, SIGWINCH
to the child, and a matching resize of the emulated screen.
waitFor(predicate: (term: Terminal) => boolean, options?: { timeout?: number }): Promise<void>
Resolve when predicate(term) becomes true. Polls the screen (~15ms) and
rejects after timeout ms (default 5000). The error includes child status,
received-byte count, read-pump state and the current screen text.
waitExit(options?: { timeout?: number }): Promise<number>
Resolve with the child's exit code once it terminates. A signal death
resolves to 128 + signo. Rejects after timeout ms (default 5000).
close(): Promise<void>
Terminate the session: SIGTERM, a ~500ms grace period, then SIGKILL
if needed; reap the child and close the master fd. Idempotent and safe
after the child has already exited.
Functions
async function openPty(
command: string,
args: string[] = [],
options: PtyOptions = {},
): Promise<PtyHandle>
Open a pseudo-terminal and spawn command on its slave side.
The returned {@link PtyHandle} exposes the child's screen as a live VT
emulator plus input, resize, exit, and teardown primitives. The child gets
a fresh session with its standard streams connected to the slave, sized to
colsxrows before it starts.
import { openPty } from 'fino:test/pty';
const pty = await openPty('/bin/sh', ['-i']);
await pty.send('echo hi\r');
await pty.waitFor((term) => term.text().some((l) => l.includes('hi')));
await pty.close();