PTY-backed TUI testing for Node.js, powered by Ghostty's libghostty-vt.
ptywright is a small native Node.js library for testing terminal UIs and CLI flows without guessing at ANSI escape sequences by hand.
It gives you two complementary views of a terminal program:
- the full PTY transcript
- the rendered screen a human would actually see after VT parsing
It can run in two modes:
createTerminal(...)for pure in-memory VT parsingspawnSession(...)for a real child process running in a PTY
Terminal UIs are easy to regress and annoying to test.
Asserting on raw terminal output is brittle because:
- escape sequences are noisy
- line wrapping changes the transcript in non-obvious ways
- screen state matters more than byte-for-byte output
- some programs query the terminal and expect a reply
Screenshot testing helps in some cases, but it is heavier than necessary for most CLI and TUI regression tests.
ptywright takes a different approach:
- run a real program in a PTY when you need end-to-end behavior
- feed the output through Ghostty's VT engine
- assert on the rendered screen, terminal metadata, and transcript
- automatically write terminal reply bytes back to the child process
That makes tests much closer to "what a user would see" while still staying lightweight and programmable.
A PTY is a pseudo-terminal. It is what shells, REPLs, and TUIs talk to when they think they are running in a real terminal window.
Terminal programs do not just print text. They emit control sequences for cursor movement, colors, clearing the screen, setting the window title, reporting the working directory, querying terminal modes, and more.
ptywright uses libghostty-vt to parse those sequences and reconstruct terminal state.
These are different, and both are useful in tests:
transcript: the full PTY output stream captured so farvisible: the rendered terminal screen after VT parsing
When a test fails, the transcript helps debug "what happened", while the visible screen helps answer "what the user would have seen".
- Real PTY sessions via
node-pty - In-memory VT parsing via Ghostty
- Snapshot metadata including cursor position, title, working directory, and scrollback counts
- Automatic handling of synchronous terminal query replies
- Promise-based wait helpers for visible text, transcript text, stability, and process exit
- Artifact writing for failed-test debugging
- No dependency on a specific test runner
This package currently lives inside this monorepo.
From the repo root:
npm run build --workspace @onkernel/ptywright
npm test --workspace @onkernel/ptywrightBecause ptywright includes a native addon, the first native build also:
- reads the pinned Ghostty revision from
packages/ptywright/GHOSTTY_UPSTREAM - downloads that exact source archive
- verifies its
sha256 - unpacks it into
packages/ptywright/.cache/ghostty/<commit> - builds
libghostty-vtlocally
- Node.js
- Python and a working
node-gyptoolchain - Zig
0.15.2
You can provide Zig explicitly with:
PTYWRIGHT_ZIG=/path/to/zig npm run build --workspace @onkernel/ptywrightIf PTYWRIGHT_ZIG is not set, the build tries:
- a cached Zig binary under
.dev/tools zigon yourPATH
Use this when you already have terminal bytes and only want to reconstruct screen state.
import { createTerminal } from "@onkernel/ptywright";
const terminal = createTerminal({ cols: 80, rows: 24 });
terminal.feed("hello\r\nworld");
terminal.feed("\x1b]2;demo title\x1b\\");
terminal.feed("\x1b]7;file://localhost/tmp/demo\x1b\\");
const snapshot = terminal.snapshot();
console.log(snapshot.visible);
console.log(snapshot.lines);
console.log(snapshot.title);
console.log(snapshot.pwd);
terminal.dispose();Use this when you want to drive a real shell, REPL, or TUI.
import { KeyCtrlD, spawnSession } from "@onkernel/ptywright";
const session = spawnSession({
command: "/bin/sh",
args: ["-lc", "printf 'ready\\n'; cat"],
cols: 80,
rows: 12,
});
await session.waitForVisible("ready", { timeoutMs: 5_000 });
session.line("echo hello");
await session.waitForTranscript("hello", { timeoutMs: 5_000 });
const snapshot = session.snapshot();
console.log(snapshot.visible);
console.log(snapshot.transcript);
session.press(KeyCtrlD);
await session.waitForExit({ timeoutMs: 5_000 });
session.close();Choose this when you want:
- deterministic tests of VT behavior
- no child process management
- to feed bytes directly and inspect the rendered result
Typical use cases:
- parser and formatter tests
- replaying recorded terminal output
- testing title, pwd, or reply-byte behavior directly
Choose this when you want:
- an actual child process
- real keyboard input and terminal resizing
- end-to-end CLI or TUI regression tests
Typical use cases:
- shell flows
- prompts and full-screen apps
- testing terminal query replies
- black-box regression tests for a TUI
Creates an in-memory terminal surface.
const terminal = createTerminal({
cols: 80,
rows: 24,
scrollback: 1000,
});| Option | Type | Required | Description |
|---|---|---|---|
cols |
number |
yes | Terminal width in cells |
rows |
number |
yes | Terminal height in cells |
scrollback |
number |
no | Maximum terminal scrollback tracked by Ghostty |
Feeds terminal data into the VT parser.
const { replyBytes } = terminal.feed("\x1b[?7$p");- Accepts
stringorUint8Array - Returns
{ replyBytes?: Uint8Array } replyBytescontains terminal-generated responses to queries such as mode reports
Most consumers should not need to use replyBytes directly. PtySession handles them automatically.
Updates the terminal dimensions.
Returns a normalized view of terminal state:
const snapshot = terminal.snapshot({
trim: true,
unwrap: false,
});| Field | Type | Description |
|---|---|---|
visible |
string |
Rendered visible screen |
lines |
string[] |
visible, split into lines for convenience |
width |
number |
Terminal width in cells |
height |
number |
Terminal height in cells |
cursor |
{ x, y, visible } |
Cursor position and visibility |
title |
string | undefined |
Terminal title if set |
pwd |
string | undefined |
Working directory URI if reported |
totalRows |
number |
Total active screen rows including scrollback |
scrollbackRows |
number |
Number of scrollback rows |
trim and unwrap are passed through to Ghostty's formatter to make snapshots easier to assert on.
Releases native resources. Always call this when you create a TerminalSurface directly.
Spawns a child process in a PTY and wires it to an internal TerminalSurface.
const session = spawnSession({
command: "python3",
args: ["-i"],
cwd: process.cwd(),
env: { PYTHONUNBUFFERED: "1" },
cols: 100,
rows: 30,
scrollback: 5000,
name: "xterm-256color",
});| Option | Type | Required | Default | Description |
|---|---|---|---|---|
command |
string |
yes | - | Executable to spawn |
args |
string[] |
no | [] |
Process arguments |
cwd |
string |
no | inherited | Working directory |
env |
NodeJS.ProcessEnv |
no | inherited | Environment overrides |
cols |
number |
no | 120 |
PTY width |
rows |
number |
no | 40 |
PTY height |
scrollback |
number |
no | 0 |
Ghostty scrollback capacity |
name |
string |
no | "xterm-256color" |
PTY/TERM name |
spawnSession always sets TERM to name unless you override it in env.
Writes raw text to the PTY.
Writes text followed by Enter.
Writes a key. A string is sent as raw bytes (Key* constants stay pass-through). A SpecialKey is encoded from the live terminal modes (for example DECCKM application cursor keys).
import { KeyEnter, SpecialArrowUp } from "@onkernel/ptywright";
session.press(KeyEnter);
session.press(SpecialArrowUp);Resizes both the child PTY and the terminal surface.
Returns a SessionSnapshot, which is a TerminalSnapshot plus:
| Field | Type | Description |
|---|---|---|
transcript |
string |
Complete PTY output captured so far |
Returns process status:
{
pid: 12345,
running: true,
exitCode: undefined,
signal: undefined,
startedAt: "...",
exitedAt: undefined
}All wait helpers accept:
{
timeoutMs?: number;
signal?: AbortSignal;
}Resolves when the rendered screen contains text.
Resolves when the PTY transcript contains text.
General-purpose wait primitive:
await session.waitFor(
"cursor to reach row 10",
(snapshot) => snapshot.cursor.y === 10,
{ timeoutMs: 5_000 },
);Resolves once the visible screen has stopped changing for the given duration.
Useful when a TUI renders in bursts and you want to wait for it to settle.
Resolves when the child process exits.
Writes a debugging bundle to disk:
transcript.txtvisible.txtmetadata.json
metadata.json includes:
- command and args
- width and height
- title and pwd
- scrollback metadata
- cursor position
- process status
This is useful for preserving test failures in CI.
Best-effort teardown:
- kills the child PTY
- disposes the underlying terminal
- stops future processing
Call this in finally blocks or test cleanup hooks.
The package exports common terminal key sequences as strings:
KeyEnterKeyCtrlCKeyCtrlDKeyTabKeyBacktabKeyEscapeKeyBackspaceKeyInsertKeyDeleteKeyHomeKeyEndKeyPageUpKeyPageDownKeyArrowUpKeyArrowDownKeyArrowLeftKeyArrowRight
Those Key* values are raw bytes. Pass SpecialArrowUp (and the other Special* keys) to press() when the sequence should follow live terminal modes.
You can also pass any raw sequence directly to session.send(...).
import { spawnSession } from "@onkernel/ptywright";
const session = spawnSession({
command: "/bin/sh",
args: ["-lc", "printf '\\x1b[2J\\x1b[Hhello\\n'"],
});
const snapshot = await session.waitForVisible("hello", { timeoutMs: 5_000 });
console.log(snapshot.visible);
session.close();import { createTerminal } from "@onkernel/ptywright";
const terminal = createTerminal({ cols: 80, rows: 24 });
const result = terminal.feed("\x1b[?7$p");
if (result.replyBytes) {
console.log(Buffer.from(result.replyBytes).toString("latin1"));
}
terminal.dispose();import { spawnSession } from "@onkernel/ptywright";
const session = spawnSession({
command: "my-tui-app",
cols: 120,
rows: 40,
});
await session.waitForStable(250, { timeoutMs: 10_000 });
const snapshot = session.snapshot();
console.log(snapshot.lines);
session.close();import { spawnSession } from "@onkernel/ptywright";
const session = spawnSession({
command: "my-cli",
args: ["--interactive"],
});
try {
await session.waitForVisible("Ready", { timeoutMs: 5_000 });
} catch (error) {
await session.writeArtifacts("artifacts/ptywright-failure");
throw error;
} finally {
session.close();
}PtySessionalready handles Ghostty reply bytes for you. If the child process sends a terminal query,ptywrightwrites the reply back to the PTY automatically.scrollbackaffects Ghostty's terminal history, not transcript retention. The transcript is accumulated separately.- Wait failures are designed to be debuggable. Error messages include process status, cursor position, title, pwd, the last visible screen, and a tail of the transcript.
titleandpwdcome from terminal escape sequences. If the program never emits them, those fields stayundefined.
This package is designed first for automated testing, not for building a production terminal emulator UI.
The current shape is intentionally small:
- screen snapshots
- transcript capture
- terminal replies
- metadata
- PTY control and wait helpers
If future regressions need lower-level inspection, cell-grid APIs can be added later without changing the testing model.