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