A terminal testing library — like Playwright, but for the terminal.
Spawn any process in a real PTY, send keystrokes, capture screenshots of the terminal buffer, and assert on screen state. Works with interactive TUI apps, shell sessions, and simple command output.
npm install @compoundingtech/ptyImport from @compoundingtech/pty/testing:
import { Session } from "@compoundingtech/pty/testing";import { Session } from "@compoundingtech/pty/testing";
const session = Session.spawn("echo", ["hello world"]);
const ss = await session.waitForText("hello world");
expect(ss.text).toContain("hello world");
await session.close();Session.spawn() creates a direct PTY process. Use this for testing CLI tools,
shell commands, or any process where you control stdin/stdout directly.
const session = Session.spawn(command, args?, opts?)Options:
rows— terminal rows (default: 24)cols— terminal columns (default: 80)cwd— working directoryenv— extra environment variables (merged withprocess.env)
import { Session } from "@compoundingtech/pty/testing";
const session = Session.spawn("sh", ["-c", "echo 'spawned!'; sleep 1"]);
const ss = await session.waitForText("spawned!");
expect(ss.text).toContain("spawned!");
await session.close();Session.server() creates a persistent session backed by a PtyServer. Use
this when you need detach/reattach, multiple clients, or resize support.
const session = await Session.server(command, args?, opts?)Options:
name— session name (auto-generated if omitted)rows,cols,cwd— same as spawn
After creating, call session.attach() to start receiving output:
import { Session } from "@compoundingtech/pty/testing";
const session = await Session.server("sh", ["-c", "echo 'served!'; sleep 30"]);
await session.attach();
const ss = await session.waitForText("served!");
expect(ss.text).toContain("served!");
await session.close();Send raw keystrokes:
import { Session } from "@compoundingtech/pty/testing";
const session = Session.spawn("cat");
session.sendKeys("hello\n");
const ss = await session.waitForText("hello");
expect(ss.text).toContain("hello");
await session.close();Send a named key. Names are case-insensitive; modifier chords accept +, -,
or _, and compact C- means Control:
import { Session } from "@compoundingtech/pty/testing";
const session = Session.spawn("cat");
session.sendKeys("test line\n");
await session.waitForText("test line");
session.press("ctrl+c");
await session.close();Alias for sendKeys() — sends text character by character:
import { Session } from "@compoundingtech/pty/testing";
const session = Session.spawn("cat");
session.type("typed text\n");
const ss = await session.waitForText("typed text");
expect(ss.text).toContain("typed text");
await session.close();session.screenshot() captures the current terminal state:
const ss = session.screenshot();
ss.lines; // string[] — each line of the terminal, trailing whitespace trimmed
ss.text; // string — all lines joined with "\n"
ss.ansi; // string — ANSI-serialized terminal state (includes escape codes)The lines array includes scrollback. Trailing empty lines are trimmed.
Poll until the terminal contains the given text. Returns the matching screenshot. Default timeout: 5000ms.
import { Session } from "@compoundingtech/pty/testing";
const session = Session.spawn("sh", ["-c", "sleep 0.1; echo 'delayed'"]);
const ss = await session.waitForText("delayed", 3000);
expect(ss.text).toContain("delayed");
await session.close();Poll until the terminal no longer contains the given text:
import { Session } from "@compoundingtech/pty/testing";
const session = Session.spawn("sh", ["-c", "echo 'gone'; sleep 0.1; printf '\\033[2J\\033[H'"]);
await session.waitForText("gone");
// Screen clears after 100ms
const ss = await session.waitForAbsent("gone", 3000);
expect(ss.text).not.toContain("gone");
await session.close();Poll until a custom predicate returns true:
import { Session } from "@compoundingtech/pty/testing";
const session = Session.spawn("sh", ["-c", "echo 'line 1'; echo 'line 2'; sleep 1"]);
const ss = await session.waitFor(
(ss) => ss.lines.length >= 2,
3000,
"at least 2 lines"
);
expect(ss.lines.length).toBeGreaterThanOrEqual(2);
await session.close();The press() method accepts these key names:
| Key | Name(s) |
|---|---|
| Enter | return, enter |
| Tab | tab |
| Escape | escape, esc |
| Space | space |
| Backspace | backspace |
| Delete | delete |
| Arrow Up | up |
| Arrow Down | down |
| Arrow Left | left |
| Arrow Right | right |
| Home | home |
| End | end |
| Page Up | pageup |
| Page Down | pagedown |
Modifiers: ctrl, alt, shift; use +, -, or _ as the separator.
Examples: ctrl+c, ctrl-c, ctrl_c, C-c, alt+x, shift+a, ctrl+backspace
import { Session } from "@compoundingtech/pty/testing";
const session = Session.spawn("sh", ["-c", "echo 'Usage: mytool [options]'; sleep 1"]);
const ss = await session.waitForText("Usage:");
expect(ss.text).toContain("mytool");
await session.close();Use the ansi field to verify ANSI escape codes:
import { Session } from "@compoundingtech/pty/testing";
const session = Session.spawn("sh", ["-c", "printf '\\033[31mERROR\\033[0m\\n'; sleep 1"]);
const ss = await session.waitForText("ERROR");
expect(ss.text).toContain("ERROR");
expect(ss.ansi).toMatch(/\x1b\[31m/);
await session.close();Full-screen TUI apps work out of the box — xterm-headless tracks the alternate screen buffer, cursor position, and all rendering. Here's a complete example that launches vim, enters insert mode, types text, and verifies the result:
import { Session } from "@compoundingtech/pty/testing";
const session = Session.spawn("vim", ["--clean"], { rows: 24, cols: 80 });
// Wait for vim to start — the welcome screen shows "VIM - Vi IMproved"
await session.waitForText("VIM - Vi IMproved", 10000);
// Enter insert mode and verify the mode indicator
session.sendKeys("i");
await session.waitForText("INSERT");
// Type some text and verify it appears
session.sendKeys("Hello from a test!");
const ss = await session.waitForText("Hello from a test!");
expect(ss.text).toContain("Hello from a test!");
expect(ss.text).toMatch(/INSERT/i);
// Exit: Escape to normal mode, then :q! to quit without saving
session.press("escape");
session.sendKeys(":q!\n");
await session.close();For programs that show a prompt and accept commands, use waitFor() with a
regex to detect the prompt, then send commands and assert on their output:
import { Session } from "@compoundingtech/pty/testing";
const session = Session.spawn("bash", ["--norc", "--noprofile"]);
// Wait for the shell prompt ($ or #)
await session.waitFor((ss) => /[$#]/.test(ss.text), 5000, "shell prompt");
// Run a command and check the output
session.sendKeys("echo hello-from-test\n");
const ss = await session.waitForText("hello-from-test");
expect(ss.text).toContain("hello-from-test");
// Ctrl+C interrupts a long-running command
session.sendKeys("sleep 999\n");
await new Promise((r) => setTimeout(r, 200));
session.press("ctrl+c");
// Shell should give us a prompt back
await session.waitFor((ss) => /[$#]\s*$/.test(ss.lines[ss.lines.length - 1] ?? ""), 3000, "prompt after ctrl+c");
session.sendKeys("exit\n");
await session.close();These methods are only available on sessions created with Session.server().
Start receiving output from the server. Required after Session.server():
const session = await Session.server("bash", ["--norc"]);
await session.attach();
await session.waitForText("$");Simulate a detach + reattach cycle. Destroys the connection, resets the terminal, and reconnects:
const session = await Session.server("bash", ["--norc"]);
await session.attach();
await session.waitForText("$");
session.sendKeys("echo hello\n");
await session.waitForText("hello");
// Simulate disconnect and reconnect
await session.reconnect();
// Screen state is replayed — "hello" should still be visible
await session.waitForText("hello");Resize the terminal dimensions:
session.resize(40, 120);In server mode this requests a size from the daemon. session.rows and
session.cols update when the daemon reports the effective min-wins geometry;
another smaller writable client can keep the effective grid below the requested
size. Geometry is applied before affected screen/output bytes are parsed.
Create a second client attached to the same server process:
const session1 = await Session.server("bash", ["--norc"]);
await session1.attach();
const session2 = await Session.connectToExisting(session1);
await session2.attach();
// Both clients see the same terminal output
session1.sendKeys("echo shared\n");
await session2.waitForText("shared");session.hasExited— whether the process has exited (alwaysfalsefor spawn-mode)session.name— the session name (server-mode only)session.server— the underlyingPtyServerinstance (server-mode only)session.rows/session.cols— current effective terminal dimensions
Use the pty test command (a thin vitest wrapper):
pty test # run all tests
pty test watch # watch mode
pty test -t "pattern" # run matching testsOr use vitest directly:
npx vitest run
npx vitest-
Timeouts: Increase timeout for slow-starting programs (vim, nano). Pass a longer
timeoutMstowaitForText()and set a longer vitest timeout on the test itself. -
Debugging: When a test fails, the error includes the current screen content. Use
console.log(session.screenshot().text)to inspect the terminal at any point. -
Working directory isolation: Create a temp directory per test to avoid test pollution:
import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "mytest-")); const session = Session.spawn("ls", [tmpDir]);
-
Server mode for detach/reattach: Use
Session.server()when testing reconnection behavior. Callsession.reconnect()to simulate a detach + reattach cycle.