···1919npm install -g github:myobie/pty
2020```
21212222-Requires Node.js. Works on macOS and Linux.
2222+The npm package name is `ptym`. Requires Node.js. Works on macOS and Linux.
23232424## Usage
2525···7171```
72727373Detach with `Ctrl+\`. (Press `Ctrl+\` twice to send it through to the process.)
7474+7575+## Testing Library
7676+7777+ptym includes a terminal testing library — like Playwright, but for the terminal.
7878+Spawn any process in a real PTY, send input, capture screenshots, assert on screen state.
7979+8080+```typescript
8181+import { Session } from "ptym/testing";
8282+8383+const session = Session.spawn("echo", ["hello world"]);
8484+const ss = await session.waitForText("hello world");
8585+console.log(ss.text); // "hello world"
8686+await session.close();
8787+```
8888+8989+See [docs/testing.md](docs/testing.md) for the full API and examples.
74907591## Tab Completion
7692
+339
docs/testing.md
···11+# ptym/testing
22+33+A terminal testing library — like Playwright, but for the terminal.
44+55+Spawn any process in a real PTY, send keystrokes, capture screenshots of the
66+terminal buffer, and assert on screen state. Works with interactive TUI apps,
77+shell sessions, and simple command output.
88+99+## Install
1010+1111+```sh
1212+npm install ptym
1313+```
1414+1515+Import from `ptym/testing`:
1616+1717+```typescript
1818+import { Session } from "ptym/testing";
1919+```
2020+2121+## Quick Start
2222+2323+```typescript test
2424+import { Session } from "ptym/testing";
2525+2626+const session = Session.spawn("echo", ["hello world"]);
2727+const ss = await session.waitForText("hello world");
2828+expect(ss.text).toContain("hello world");
2929+await session.close();
3030+```
3131+3232+## Session.spawn()
3333+3434+`Session.spawn()` creates a direct PTY process. Use this for testing CLI tools,
3535+shell commands, or any process where you control stdin/stdout directly.
3636+3737+```typescript
3838+const session = Session.spawn(command, args?, opts?)
3939+```
4040+4141+**Options:**
4242+- `rows` — terminal rows (default: 24)
4343+- `cols` — terminal columns (default: 80)
4444+- `cwd` — working directory
4545+- `env` — extra environment variables (merged with `process.env`)
4646+4747+```typescript test
4848+import { Session } from "ptym/testing";
4949+5050+const session = Session.spawn("sh", ["-c", "echo 'spawned!'; sleep 1"]);
5151+const ss = await session.waitForText("spawned!");
5252+expect(ss.text).toContain("spawned!");
5353+await session.close();
5454+```
5555+5656+## Session.server()
5757+5858+`Session.server()` creates a persistent session backed by a `PtyServer`. Use
5959+this when you need detach/reattach, multiple clients, or resize support.
6060+6161+```typescript
6262+const session = await Session.server(command, args?, opts?)
6363+```
6464+6565+**Options:**
6666+- `name` — session name (auto-generated if omitted)
6767+- `rows`, `cols`, `cwd` — same as spawn
6868+6969+After creating, call `session.attach()` to start receiving output:
7070+7171+```typescript test
7272+import { Session } from "ptym/testing";
7373+7474+const session = await Session.server("sh", ["-c", "echo 'served!'; sleep 30"]);
7575+await session.attach();
7676+const ss = await session.waitForText("served!");
7777+expect(ss.text).toContain("served!");
7878+await session.close();
7979+```
8080+8181+## Sending Input
8282+8383+### sendKeys(keys)
8484+8585+Send raw keystrokes:
8686+8787+```typescript test
8888+import { Session } from "ptym/testing";
8989+9090+const session = Session.spawn("cat");
9191+session.sendKeys("hello\n");
9292+const ss = await session.waitForText("hello");
9393+expect(ss.text).toContain("hello");
9494+await session.close();
9595+```
9696+9797+### press(keyName)
9898+9999+Send a named key. Supports modifiers with `+`:
100100+101101+```typescript test
102102+import { Session } from "ptym/testing";
103103+104104+const session = Session.spawn("cat");
105105+session.sendKeys("test line\n");
106106+await session.waitForText("test line");
107107+session.press("ctrl+c");
108108+await session.close();
109109+```
110110+111111+### type(text)
112112+113113+Alias for `sendKeys()` — sends text character by character:
114114+115115+```typescript test
116116+import { Session } from "ptym/testing";
117117+118118+const session = Session.spawn("cat");
119119+session.type("typed text\n");
120120+const ss = await session.waitForText("typed text");
121121+expect(ss.text).toContain("typed text");
122122+await session.close();
123123+```
124124+125125+## Screenshots
126126+127127+`session.screenshot()` captures the current terminal state:
128128+129129+```typescript
130130+const ss = session.screenshot();
131131+ss.lines; // string[] — each line of the terminal, trailing whitespace trimmed
132132+ss.text; // string — all lines joined with "\n"
133133+ss.ansi; // string — ANSI-serialized terminal state (includes escape codes)
134134+```
135135+136136+The `lines` array includes scrollback. Trailing empty lines are trimmed.
137137+138138+## Waiting
139139+140140+### waitForText(text, timeoutMs?)
141141+142142+Poll until the terminal contains the given text. Returns the matching screenshot.
143143+Default timeout: 5000ms.
144144+145145+```typescript test
146146+import { Session } from "ptym/testing";
147147+148148+const session = Session.spawn("sh", ["-c", "sleep 0.1; echo 'delayed'"]);
149149+const ss = await session.waitForText("delayed", 3000);
150150+expect(ss.text).toContain("delayed");
151151+await session.close();
152152+```
153153+154154+### waitForAbsent(text, timeoutMs?)
155155+156156+Poll until the terminal no longer contains the given text:
157157+158158+```typescript test
159159+import { Session } from "ptym/testing";
160160+161161+const session = Session.spawn("sh", ["-c", "echo 'gone'; sleep 0.1; printf '\\033[2J\\033[H'"]);
162162+await session.waitForText("gone");
163163+// Screen clears after 100ms
164164+const ss = await session.waitForAbsent("gone", 3000);
165165+expect(ss.text).not.toContain("gone");
166166+await session.close();
167167+```
168168+169169+### waitFor(predicate, timeoutMs?, description?)
170170+171171+Poll until a custom predicate returns true:
172172+173173+```typescript test
174174+import { Session } from "ptym/testing";
175175+176176+const session = Session.spawn("sh", ["-c", "echo 'line 1'; echo 'line 2'; sleep 1"]);
177177+const ss = await session.waitFor(
178178+ (ss) => ss.lines.length >= 2,
179179+ 3000,
180180+ "at least 2 lines"
181181+);
182182+expect(ss.lines.length).toBeGreaterThanOrEqual(2);
183183+await session.close();
184184+```
185185+186186+## Key Names
187187+188188+The `press()` method accepts these key names:
189189+190190+| Key | Name(s) |
191191+|-----|---------|
192192+| Enter | `return`, `enter` |
193193+| Tab | `tab` |
194194+| Escape | `escape`, `esc` |
195195+| Space | `space` |
196196+| Backspace | `backspace` |
197197+| Delete | `delete` |
198198+| Arrow Up | `up` |
199199+| Arrow Down | `down` |
200200+| Arrow Left | `left` |
201201+| Arrow Right | `right` |
202202+| Home | `home` |
203203+| End | `end` |
204204+| Page Up | `pageup` |
205205+| Page Down | `pagedown` |
206206+207207+Modifiers: `ctrl+`, `alt+`, `shift+`
208208+209209+Examples: `ctrl+c`, `ctrl+z`, `alt+x`, `shift+a`, `ctrl+backspace`
210210+211211+## Patterns
212212+213213+### Testing a CLI tool
214214+215215+```typescript test
216216+import { Session } from "ptym/testing";
217217+218218+const session = Session.spawn("sh", ["-c", "echo 'Usage: mytool [options]'; sleep 1"]);
219219+const ss = await session.waitForText("Usage:");
220220+expect(ss.text).toContain("mytool");
221221+await session.close();
222222+```
223223+224224+### Testing colored output
225225+226226+Use the `ansi` field to verify ANSI escape codes:
227227+228228+```typescript test
229229+import { Session } from "ptym/testing";
230230+231231+const session = Session.spawn("sh", ["-c", "printf '\\033[31mERROR\\033[0m\\n'; sleep 1"]);
232232+const ss = await session.waitForText("ERROR");
233233+expect(ss.text).toContain("ERROR");
234234+expect(ss.ansi).toMatch(/\x1b\[31m/);
235235+await session.close();
236236+```
237237+238238+### Testing an interactive TUI (vim)
239239+240240+Full-screen TUI apps work out of the box — xterm-headless tracks the alternate
241241+screen buffer, cursor position, and all rendering. Here's a complete example
242242+that launches vim, enters insert mode, types text, and verifies the result:
243243+244244+```typescript test
245245+import { Session } from "ptym/testing";
246246+247247+const session = Session.spawn("vim", ["--clean"], { rows: 24, cols: 80 });
248248+249249+// Wait for vim to start — the welcome screen shows "VIM - Vi IMproved"
250250+await session.waitForText("VIM - Vi IMproved", 10000);
251251+252252+// Enter insert mode and verify the mode indicator
253253+session.sendKeys("i");
254254+await session.waitForText("INSERT");
255255+256256+// Type some text and verify it appears
257257+session.sendKeys("Hello from a test!");
258258+const ss = await session.waitForText("Hello from a test!");
259259+expect(ss.text).toContain("Hello from a test!");
260260+expect(ss.text).toMatch(/INSERT/i);
261261+262262+// Exit: Escape to normal mode, then :q! to quit without saving
263263+session.press("escape");
264264+session.sendKeys(":q!\n");
265265+await session.close();
266266+```
267267+268268+### Testing an interactive shell session
269269+270270+For programs that show a prompt and accept commands, use `waitFor()` with a
271271+regex to detect the prompt, then send commands and assert on their output:
272272+273273+```typescript test
274274+import { Session } from "ptym/testing";
275275+276276+const session = Session.spawn("bash", ["--norc", "--noprofile"]);
277277+278278+// Wait for the shell prompt ($ or #)
279279+await session.waitFor((ss) => /[$#]/.test(ss.text), 5000, "shell prompt");
280280+281281+// Run a command and check the output
282282+session.sendKeys("echo hello-from-test\n");
283283+const ss = await session.waitForText("hello-from-test");
284284+expect(ss.text).toContain("hello-from-test");
285285+286286+// Ctrl+C interrupts a long-running command
287287+session.sendKeys("sleep 999\n");
288288+await new Promise((r) => setTimeout(r, 200));
289289+session.press("ctrl+c");
290290+291291+// Shell should give us a prompt back
292292+await session.waitFor((ss) => /[$#]\s*$/.test(ss.lines[ss.lines.length - 1] ?? ""), 3000, "prompt after ctrl+c");
293293+294294+session.sendKeys("exit\n");
295295+await session.close();
296296+```
297297+298298+## Running Tests
299299+300300+Use the `pty test` command (a thin vitest wrapper):
301301+302302+```sh
303303+pty test # run all tests
304304+pty test watch # watch mode
305305+pty test -t "pattern" # run matching tests
306306+```
307307+308308+Or use vitest directly:
309309+310310+```sh
311311+npx vitest run
312312+npx vitest
313313+```
314314+315315+## Tips
316316+317317+- **Timeouts**: Increase timeout for slow-starting programs (vim, nano). Pass
318318+ a longer `timeoutMs` to `waitForText()` and set a longer vitest timeout on
319319+ the test itself.
320320+321321+- **Debugging**: When a test fails, the error includes the current screen
322322+ content. Use `console.log(session.screenshot().text)` to inspect the terminal
323323+ at any point.
324324+325325+- **Working directory isolation**: Create a temp directory per test to avoid
326326+ test pollution:
327327+328328+ ```typescript
329329+ import * as fs from "node:fs";
330330+ import * as os from "node:os";
331331+ import * as path from "node:path";
332332+333333+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "mytest-"));
334334+ const session = Session.spawn("ls", [tmpDir]);
335335+ ```
336336+337337+- **Server mode for detach/reattach**: Use `Session.server()` when testing
338338+ reconnection behavior. Call `session.reconnect()` to simulate a detach +
339339+ reattach cycle.