This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

pty test

Nathan Herald (Mar 19, 2026, 4:28 PM +0100) 5b29d5d2 2e2a5072

+1034 -513
+17 -1
README.md
··· 19 19 npm install -g github:myobie/pty 20 20 ``` 21 21 22 - Requires Node.js. Works on macOS and Linux. 22 + The npm package name is `ptym`. Requires Node.js. Works on macOS and Linux. 23 23 24 24 ## Usage 25 25 ··· 71 71 ``` 72 72 73 73 Detach with `Ctrl+\`. (Press `Ctrl+\` twice to send it through to the process.) 74 + 75 + ## Testing Library 76 + 77 + ptym includes a terminal testing library — like Playwright, but for the terminal. 78 + Spawn any process in a real PTY, send input, capture screenshots, assert on screen state. 79 + 80 + ```typescript 81 + import { Session } from "ptym/testing"; 82 + 83 + const session = Session.spawn("echo", ["hello world"]); 84 + const ss = await session.waitForText("hello world"); 85 + console.log(ss.text); // "hello world" 86 + await session.close(); 87 + ``` 88 + 89 + See [docs/testing.md](docs/testing.md) for the full API and examples. 74 90 75 91 ## Tab Completion 76 92
+339
docs/testing.md
··· 1 + # ptym/testing 2 + 3 + A terminal testing library — like Playwright, but for the terminal. 4 + 5 + Spawn any process in a real PTY, send keystrokes, capture screenshots of the 6 + terminal buffer, and assert on screen state. Works with interactive TUI apps, 7 + shell sessions, and simple command output. 8 + 9 + ## Install 10 + 11 + ```sh 12 + npm install ptym 13 + ``` 14 + 15 + Import from `ptym/testing`: 16 + 17 + ```typescript 18 + import { Session } from "ptym/testing"; 19 + ``` 20 + 21 + ## Quick Start 22 + 23 + ```typescript test 24 + import { Session } from "ptym/testing"; 25 + 26 + const session = Session.spawn("echo", ["hello world"]); 27 + const ss = await session.waitForText("hello world"); 28 + expect(ss.text).toContain("hello world"); 29 + await session.close(); 30 + ``` 31 + 32 + ## Session.spawn() 33 + 34 + `Session.spawn()` creates a direct PTY process. Use this for testing CLI tools, 35 + shell commands, or any process where you control stdin/stdout directly. 36 + 37 + ```typescript 38 + const session = Session.spawn(command, args?, opts?) 39 + ``` 40 + 41 + **Options:** 42 + - `rows` — terminal rows (default: 24) 43 + - `cols` — terminal columns (default: 80) 44 + - `cwd` — working directory 45 + - `env` — extra environment variables (merged with `process.env`) 46 + 47 + ```typescript test 48 + import { Session } from "ptym/testing"; 49 + 50 + const session = Session.spawn("sh", ["-c", "echo 'spawned!'; sleep 1"]); 51 + const ss = await session.waitForText("spawned!"); 52 + expect(ss.text).toContain("spawned!"); 53 + await session.close(); 54 + ``` 55 + 56 + ## Session.server() 57 + 58 + `Session.server()` creates a persistent session backed by a `PtyServer`. Use 59 + this when you need detach/reattach, multiple clients, or resize support. 60 + 61 + ```typescript 62 + const session = await Session.server(command, args?, opts?) 63 + ``` 64 + 65 + **Options:** 66 + - `name` — session name (auto-generated if omitted) 67 + - `rows`, `cols`, `cwd` — same as spawn 68 + 69 + After creating, call `session.attach()` to start receiving output: 70 + 71 + ```typescript test 72 + import { Session } from "ptym/testing"; 73 + 74 + const session = await Session.server("sh", ["-c", "echo 'served!'; sleep 30"]); 75 + await session.attach(); 76 + const ss = await session.waitForText("served!"); 77 + expect(ss.text).toContain("served!"); 78 + await session.close(); 79 + ``` 80 + 81 + ## Sending Input 82 + 83 + ### sendKeys(keys) 84 + 85 + Send raw keystrokes: 86 + 87 + ```typescript test 88 + import { Session } from "ptym/testing"; 89 + 90 + const session = Session.spawn("cat"); 91 + session.sendKeys("hello\n"); 92 + const ss = await session.waitForText("hello"); 93 + expect(ss.text).toContain("hello"); 94 + await session.close(); 95 + ``` 96 + 97 + ### press(keyName) 98 + 99 + Send a named key. Supports modifiers with `+`: 100 + 101 + ```typescript test 102 + import { Session } from "ptym/testing"; 103 + 104 + const session = Session.spawn("cat"); 105 + session.sendKeys("test line\n"); 106 + await session.waitForText("test line"); 107 + session.press("ctrl+c"); 108 + await session.close(); 109 + ``` 110 + 111 + ### type(text) 112 + 113 + Alias for `sendKeys()` — sends text character by character: 114 + 115 + ```typescript test 116 + import { Session } from "ptym/testing"; 117 + 118 + const session = Session.spawn("cat"); 119 + session.type("typed text\n"); 120 + const ss = await session.waitForText("typed text"); 121 + expect(ss.text).toContain("typed text"); 122 + await session.close(); 123 + ``` 124 + 125 + ## Screenshots 126 + 127 + `session.screenshot()` captures the current terminal state: 128 + 129 + ```typescript 130 + const ss = session.screenshot(); 131 + ss.lines; // string[] — each line of the terminal, trailing whitespace trimmed 132 + ss.text; // string — all lines joined with "\n" 133 + ss.ansi; // string — ANSI-serialized terminal state (includes escape codes) 134 + ``` 135 + 136 + The `lines` array includes scrollback. Trailing empty lines are trimmed. 137 + 138 + ## Waiting 139 + 140 + ### waitForText(text, timeoutMs?) 141 + 142 + Poll until the terminal contains the given text. Returns the matching screenshot. 143 + Default timeout: 5000ms. 144 + 145 + ```typescript test 146 + import { Session } from "ptym/testing"; 147 + 148 + const session = Session.spawn("sh", ["-c", "sleep 0.1; echo 'delayed'"]); 149 + const ss = await session.waitForText("delayed", 3000); 150 + expect(ss.text).toContain("delayed"); 151 + await session.close(); 152 + ``` 153 + 154 + ### waitForAbsent(text, timeoutMs?) 155 + 156 + Poll until the terminal no longer contains the given text: 157 + 158 + ```typescript test 159 + import { Session } from "ptym/testing"; 160 + 161 + const session = Session.spawn("sh", ["-c", "echo 'gone'; sleep 0.1; printf '\\033[2J\\033[H'"]); 162 + await session.waitForText("gone"); 163 + // Screen clears after 100ms 164 + const ss = await session.waitForAbsent("gone", 3000); 165 + expect(ss.text).not.toContain("gone"); 166 + await session.close(); 167 + ``` 168 + 169 + ### waitFor(predicate, timeoutMs?, description?) 170 + 171 + Poll until a custom predicate returns true: 172 + 173 + ```typescript test 174 + import { Session } from "ptym/testing"; 175 + 176 + const session = Session.spawn("sh", ["-c", "echo 'line 1'; echo 'line 2'; sleep 1"]); 177 + const ss = await session.waitFor( 178 + (ss) => ss.lines.length >= 2, 179 + 3000, 180 + "at least 2 lines" 181 + ); 182 + expect(ss.lines.length).toBeGreaterThanOrEqual(2); 183 + await session.close(); 184 + ``` 185 + 186 + ## Key Names 187 + 188 + The `press()` method accepts these key names: 189 + 190 + | Key | Name(s) | 191 + |-----|---------| 192 + | Enter | `return`, `enter` | 193 + | Tab | `tab` | 194 + | Escape | `escape`, `esc` | 195 + | Space | `space` | 196 + | Backspace | `backspace` | 197 + | Delete | `delete` | 198 + | Arrow Up | `up` | 199 + | Arrow Down | `down` | 200 + | Arrow Left | `left` | 201 + | Arrow Right | `right` | 202 + | Home | `home` | 203 + | End | `end` | 204 + | Page Up | `pageup` | 205 + | Page Down | `pagedown` | 206 + 207 + Modifiers: `ctrl+`, `alt+`, `shift+` 208 + 209 + Examples: `ctrl+c`, `ctrl+z`, `alt+x`, `shift+a`, `ctrl+backspace` 210 + 211 + ## Patterns 212 + 213 + ### Testing a CLI tool 214 + 215 + ```typescript test 216 + import { Session } from "ptym/testing"; 217 + 218 + const session = Session.spawn("sh", ["-c", "echo 'Usage: mytool [options]'; sleep 1"]); 219 + const ss = await session.waitForText("Usage:"); 220 + expect(ss.text).toContain("mytool"); 221 + await session.close(); 222 + ``` 223 + 224 + ### Testing colored output 225 + 226 + Use the `ansi` field to verify ANSI escape codes: 227 + 228 + ```typescript test 229 + import { Session } from "ptym/testing"; 230 + 231 + const session = Session.spawn("sh", ["-c", "printf '\\033[31mERROR\\033[0m\\n'; sleep 1"]); 232 + const ss = await session.waitForText("ERROR"); 233 + expect(ss.text).toContain("ERROR"); 234 + expect(ss.ansi).toMatch(/\x1b\[31m/); 235 + await session.close(); 236 + ``` 237 + 238 + ### Testing an interactive TUI (vim) 239 + 240 + Full-screen TUI apps work out of the box — xterm-headless tracks the alternate 241 + screen buffer, cursor position, and all rendering. Here's a complete example 242 + that launches vim, enters insert mode, types text, and verifies the result: 243 + 244 + ```typescript test 245 + import { Session } from "ptym/testing"; 246 + 247 + const session = Session.spawn("vim", ["--clean"], { rows: 24, cols: 80 }); 248 + 249 + // Wait for vim to start — the welcome screen shows "VIM - Vi IMproved" 250 + await session.waitForText("VIM - Vi IMproved", 10000); 251 + 252 + // Enter insert mode and verify the mode indicator 253 + session.sendKeys("i"); 254 + await session.waitForText("INSERT"); 255 + 256 + // Type some text and verify it appears 257 + session.sendKeys("Hello from a test!"); 258 + const ss = await session.waitForText("Hello from a test!"); 259 + expect(ss.text).toContain("Hello from a test!"); 260 + expect(ss.text).toMatch(/INSERT/i); 261 + 262 + // Exit: Escape to normal mode, then :q! to quit without saving 263 + session.press("escape"); 264 + session.sendKeys(":q!\n"); 265 + await session.close(); 266 + ``` 267 + 268 + ### Testing an interactive shell session 269 + 270 + For programs that show a prompt and accept commands, use `waitFor()` with a 271 + regex to detect the prompt, then send commands and assert on their output: 272 + 273 + ```typescript test 274 + import { Session } from "ptym/testing"; 275 + 276 + const session = Session.spawn("bash", ["--norc", "--noprofile"]); 277 + 278 + // Wait for the shell prompt ($ or #) 279 + await session.waitFor((ss) => /[$#]/.test(ss.text), 5000, "shell prompt"); 280 + 281 + // Run a command and check the output 282 + session.sendKeys("echo hello-from-test\n"); 283 + const ss = await session.waitForText("hello-from-test"); 284 + expect(ss.text).toContain("hello-from-test"); 285 + 286 + // Ctrl+C interrupts a long-running command 287 + session.sendKeys("sleep 999\n"); 288 + await new Promise((r) => setTimeout(r, 200)); 289 + session.press("ctrl+c"); 290 + 291 + // Shell should give us a prompt back 292 + await session.waitFor((ss) => /[$#]\s*$/.test(ss.lines[ss.lines.length - 1] ?? ""), 3000, "prompt after ctrl+c"); 293 + 294 + session.sendKeys("exit\n"); 295 + await session.close(); 296 + ``` 297 + 298 + ## Running Tests 299 + 300 + Use the `pty test` command (a thin vitest wrapper): 301 + 302 + ```sh 303 + pty test # run all tests 304 + pty test watch # watch mode 305 + pty test -t "pattern" # run matching tests 306 + ``` 307 + 308 + Or use vitest directly: 309 + 310 + ```sh 311 + npx vitest run 312 + npx vitest 313 + ``` 314 + 315 + ## Tips 316 + 317 + - **Timeouts**: Increase timeout for slow-starting programs (vim, nano). Pass 318 + a longer `timeoutMs` to `waitForText()` and set a longer vitest timeout on 319 + the test itself. 320 + 321 + - **Debugging**: When a test fails, the error includes the current screen 322 + content. Use `console.log(session.screenshot().text)` to inspect the terminal 323 + at any point. 324 + 325 + - **Working directory isolation**: Create a temp directory per test to avoid 326 + test pollution: 327 + 328 + ```typescript 329 + import * as fs from "node:fs"; 330 + import * as os from "node:os"; 331 + import * as path from "node:path"; 332 + 333 + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "mytest-")); 334 + const session = Session.spawn("ls", [tmpDir]); 335 + ``` 336 + 337 + - **Server mode for detach/reattach**: Use `Session.server()` when testing 338 + reconnection behavior. Call `session.reconnect()` to simulate a detach + 339 + reattach cycle.
+6 -2
package.json
··· 1 1 { 2 - "name": "pty-manager", 2 + "name": "ptym", 3 3 "version": "0.1.0", 4 4 "description": "Persistent terminal sessions with detach/attach support", 5 5 "type": "module", 6 6 "bin": { 7 7 "pty": "./bin/pty" 8 8 }, 9 + "exports": { 10 + "./testing": "./src/testing/index.ts" 11 + }, 9 12 "scripts": { 10 13 "postinstall": "chmod +x node_modules/node-pty/prebuilds/darwin-*/spawn-helper 2>/dev/null || true", 11 14 "install-completions": "sh scripts/install-completions.sh", 12 15 "typecheck": "tsc", 13 16 "test": "vitest run", 14 - "test:watch": "vitest" 17 + "test:watch": "vitest", 18 + "verify-docs": "tsx scripts/verify-docs.ts" 15 19 }, 16 20 "dependencies": { 17 21 "@xterm/addon-serialize": "^0.14.0",
+78
scripts/verify-docs.ts
··· 1 + import * as fs from "node:fs"; 2 + import * as path from "node:path"; 3 + import * as os from "node:os"; 4 + import { spawnSync } from "node:child_process"; 5 + import { fileURLToPath } from "node:url"; 6 + 7 + const __dirname = path.dirname(fileURLToPath(import.meta.url)); 8 + const projectRoot = path.join(__dirname, ".."); 9 + const docsPath = path.join(projectRoot, "docs", "testing.md"); 10 + 11 + const content = fs.readFileSync(docsPath, "utf-8"); 12 + 13 + // Extract ```typescript test code blocks 14 + const codeBlockRegex = /```typescript test\n([\s\S]*?)```/g; 15 + const blocks: string[] = []; 16 + let match: RegExpExecArray | null; 17 + while ((match = codeBlockRegex.exec(content)) !== null) { 18 + blocks.push(match[1]); 19 + } 20 + 21 + if (blocks.length === 0) { 22 + console.log("No executable code blocks found in docs/testing.md"); 23 + process.exit(0); 24 + } 25 + 26 + console.log(`Found ${blocks.length} executable code blocks`); 27 + 28 + // Separate imports from body code in each block 29 + const importSet = new Set<string>(); 30 + const testCases = blocks 31 + .map((code, i) => { 32 + // Replace ptym/testing imports with relative path 33 + const adjusted = code.replace( 34 + /from ["']ptym\/testing["']/g, 35 + `from "${path.join(projectRoot, "src", "testing", "index.ts").replace(/\\/g, "/")}"` 36 + ); 37 + 38 + // Hoist import lines 39 + const lines = adjusted.split("\n"); 40 + const bodyLines: string[] = []; 41 + for (const line of lines) { 42 + if (/^\s*import\s/.test(line)) { 43 + importSet.add(line.trim()); 44 + } else { 45 + bodyLines.push(line); 46 + } 47 + } 48 + const body = bodyLines.join("\n").trim(); 49 + return ` it("doc example ${i + 1}", async () => {\n${body}\n }, 10000);`; 50 + }) 51 + .join("\n\n"); 52 + 53 + const imports = ['import { describe, it, expect } from "vitest";', ...importSet].join("\n"); 54 + const testFile = `${imports}\n\ndescribe("docs/testing.md examples", () => {\n${testCases}\n});\n`; 55 + 56 + // Write generated test file inside the project so vitest's include pattern matches 57 + const tmpFile = path.join(projectRoot, "tests", "_docs-verify.test.ts"); 58 + fs.writeFileSync(tmpFile, testFile); 59 + 60 + console.log(`Generated test file: ${tmpFile}`); 61 + console.log("Running vitest...\n"); 62 + 63 + const vitestBin = path.join(projectRoot, "node_modules", ".bin", "vitest"); 64 + const result = spawnSync(vitestBin, ["run", tmpFile], { 65 + stdio: "inherit", 66 + env: process.env, 67 + cwd: projectRoot, 68 + }); 69 + 70 + // Clean up 71 + try { fs.unlinkSync(tmpFile); } catch {} 72 + 73 + if (result.status !== 0) { 74 + console.error("\nDoc verification failed!"); 75 + process.exit(result.status ?? 1); 76 + } 77 + 78 + console.log("\nAll doc examples passed!");
+26
src/cli.ts
··· 1 1 import * as os from "node:os"; 2 + import * as path from "node:path"; 2 3 import * as readline from "node:readline/promises"; 4 + import { spawnSync } from "node:child_process"; 3 5 import { attach, peek, send } from "./client.ts"; 4 6 import { parseSeqValue } from "./keys.ts"; 5 7 import { ··· 34 36 pty list List active sessions 35 37 pty list --json List sessions as JSON 36 38 pty kill <name> Kill or remove a session 39 + pty test Run tests (vitest) 40 + pty test watch Watch mode 41 + pty test -t "pattern" Run matching tests 37 42 38 43 Detach from a session with Ctrl+\\ (press twice to send Ctrl+\\ to the process)`); 39 44 } ··· 243 248 process.exit(1); 244 249 } 245 250 await cmdKill(args[1]); 251 + break; 252 + } 253 + 254 + case "test": { 255 + await cmdTest(args.slice(1)); 246 256 break; 247 257 } 248 258 ··· 510 520 await spawnDaemon(name, meta.command, meta.args, meta.displayCommand, meta.cwd); 511 521 console.log(`Session "${name}" restarted.`); 512 522 doAttach(name); 523 + } 524 + 525 + async function cmdTest(args: string[]): Promise<void> { 526 + const vitestBin = path.join( 527 + import.meta.dirname ?? path.dirname(new URL(import.meta.url).pathname), 528 + "..", 529 + "node_modules", 530 + ".bin", 531 + "vitest" 532 + ); 533 + const vitestArgs = args.length === 0 ? ["run"] : args; 534 + const result = spawnSync(vitestBin, vitestArgs, { 535 + stdio: "inherit", 536 + env: process.env, 537 + }); 538 + process.exit(result.status ?? 1); 513 539 } 514 540 515 541 function ask(prompt: string): Promise<string> {
+5 -7
src/sessions.ts
··· 102 102 (await isSocketReachable(socketPath)); 103 103 104 104 if (alive) { 105 - sessions.push({ 106 - name, 107 - socketPath, 108 - pid, 109 - status: "running", 110 - metadata: readMetadata(name), 111 - }); 105 + const metadata = readMetadata(name); 106 + // The daemon writes exit metadata before its cleanup delay, so a 107 + // reachable socket can briefly coexist with exitedAt being set. 108 + const status = metadata?.exitedAt ? "exited" : "running"; 109 + sessions.push({ name, socketPath, pid, status, metadata }); 112 110 } else { 113 111 // Process died — clean up socket/pid but keep metadata 114 112 cleanupSocket(name);
+2
src/testing/index.ts
··· 1 + export { Session } from "./session.ts"; 2 + export type { Screenshot, SpawnOptions, ServerOptions } from "./types.ts";
+25
src/testing/screenshot.ts
··· 1 + import type { Terminal } from "@xterm/headless"; 2 + import type { SerializeAddon } from "@xterm/addon-serialize"; 3 + import type { Screenshot } from "./types.ts"; 4 + 5 + export function captureScreenshot( 6 + terminal: Terminal, 7 + serialize: SerializeAddon 8 + ): Screenshot { 9 + const buffer = terminal.buffer.active; 10 + const lines: string[] = []; 11 + for (let i = 0; i < buffer.length; i++) { 12 + const line = buffer.getLine(i); 13 + if (line) { 14 + lines.push(line.translateToString(true)); 15 + } 16 + } 17 + while (lines.length > 0 && lines[lines.length - 1].trim() === "") { 18 + lines.pop(); 19 + } 20 + return { 21 + lines, 22 + text: lines.join("\n"), 23 + ansi: serialize.serialize(), 24 + }; 25 + }
+368
src/testing/session.ts
··· 1 + import * as net from "node:net"; 2 + import type { Terminal } from "@xterm/headless"; 3 + import type { SerializeAddon } from "@xterm/addon-serialize"; 4 + import xterm from "@xterm/headless"; 5 + import xtermSerialize from "@xterm/addon-serialize"; 6 + import * as pty from "node-pty"; 7 + import { PtyServer, type ServerOptions as PtyServerOptions } from "../server.ts"; 8 + import { 9 + MessageType, 10 + PacketReader, 11 + encodeAttach, 12 + encodeData, 13 + encodeResize, 14 + } from "../protocol.ts"; 15 + import { getSocketPath } from "../sessions.ts"; 16 + import { resolveKey } from "../keys.ts"; 17 + import { captureScreenshot } from "./screenshot.ts"; 18 + import type { Screenshot, SpawnOptions, ServerOptions } from "./types.ts"; 19 + 20 + type Backend = 21 + | { kind: "spawn"; ptyProcess: pty.IPty } 22 + | { kind: "server"; server: PtyServer; ownsServer: boolean; socket: net.Socket; reader: PacketReader; screenCallbacks: Array<() => void>; exitCode: number | null; name: string }; 23 + 24 + let nameCounter = 0; 25 + function autoName(): string { 26 + return `test-${process.pid}-${Date.now()}-${++nameCounter}`; 27 + } 28 + 29 + export class Session { 30 + private terminal: Terminal; 31 + private serialize: SerializeAddon; 32 + private backend: Backend; 33 + private _rows: number; 34 + private _cols: number; 35 + 36 + private constructor( 37 + terminal: Terminal, 38 + serialize: SerializeAddon, 39 + backend: Backend, 40 + rows: number, 41 + cols: number 42 + ) { 43 + this.terminal = terminal; 44 + this.serialize = serialize; 45 + this.backend = backend; 46 + this._rows = rows; 47 + this._cols = cols; 48 + } 49 + 50 + // ── Factories ── 51 + 52 + static spawn( 53 + command: string, 54 + args: string[] = [], 55 + opts: SpawnOptions = {} 56 + ): Session { 57 + const rows = opts.rows ?? 24; 58 + const cols = opts.cols ?? 80; 59 + 60 + const terminal = new xterm.Terminal({ 61 + rows, 62 + cols, 63 + scrollback: 1000, 64 + allowProposedApi: true, 65 + }); 66 + const serialize = new xtermSerialize.SerializeAddon(); 67 + terminal.loadAddon(serialize); 68 + 69 + const env: Record<string, string> = { 70 + ...(process.env as Record<string, string>), 71 + ...opts.env, 72 + }; 73 + delete env.PTY_SERVER_CONFIG; 74 + 75 + const proc = pty.spawn(command, args, { 76 + name: "xterm-256color", 77 + cols, 78 + rows, 79 + cwd: opts.cwd ?? process.cwd(), 80 + env, 81 + }); 82 + 83 + proc.onData((data: string) => { 84 + terminal.write(data); 85 + }); 86 + 87 + const backend: Backend = { kind: "spawn", ptyProcess: proc }; 88 + return new Session(terminal, serialize, backend, rows, cols); 89 + } 90 + 91 + static async server( 92 + command: string, 93 + args: string[] = [], 94 + opts: ServerOptions = {} 95 + ): Promise<Session> { 96 + const rows = opts.rows ?? 24; 97 + const cols = opts.cols ?? 80; 98 + const name = opts.name ?? autoName(); 99 + 100 + const server = new PtyServer({ 101 + name, 102 + command, 103 + args, 104 + displayCommand: command, 105 + cwd: opts.cwd ?? process.cwd(), 106 + rows, 107 + cols, 108 + }); 109 + await server.ready; 110 + 111 + const terminal = new xterm.Terminal({ 112 + rows, 113 + cols, 114 + scrollback: 1000, 115 + allowProposedApi: true, 116 + }); 117 + const serialize = new xtermSerialize.SerializeAddon(); 118 + terminal.loadAddon(serialize); 119 + 120 + const backend: Backend = { 121 + kind: "server", 122 + server, 123 + ownsServer: true, 124 + socket: null!, 125 + reader: null!, 126 + screenCallbacks: [], 127 + exitCode: null, 128 + name, 129 + }; 130 + 131 + const session = new Session(terminal, serialize, backend, rows, cols); 132 + await session.connectSocket(); 133 + return session; 134 + } 135 + 136 + static async connectToExisting( 137 + existing: Session, 138 + opts: { rows?: number; cols?: number } = {} 139 + ): Promise<Session> { 140 + if (existing.backend.kind !== "server") { 141 + throw new Error("connectToExisting() requires a server-mode session"); 142 + } 143 + 144 + const rows = opts.rows ?? existing._rows; 145 + const cols = opts.cols ?? existing._cols; 146 + 147 + const terminal = new xterm.Terminal({ 148 + rows, 149 + cols, 150 + scrollback: 1000, 151 + allowProposedApi: true, 152 + }); 153 + const serialize = new xtermSerialize.SerializeAddon(); 154 + terminal.loadAddon(serialize); 155 + 156 + const backend: Backend = { 157 + kind: "server", 158 + server: existing.backend.server, 159 + ownsServer: false, 160 + socket: null!, 161 + reader: null!, 162 + screenCallbacks: [], 163 + exitCode: null, 164 + name: existing.backend.name, 165 + }; 166 + 167 + const session = new Session(terminal, serialize, backend, rows, cols); 168 + await session.connectSocket(); 169 + return session; 170 + } 171 + 172 + // ── Properties ── 173 + 174 + get rows(): number { 175 + return this._rows; 176 + } 177 + 178 + get cols(): number { 179 + return this._cols; 180 + } 181 + 182 + get hasExited(): boolean { 183 + if (this.backend.kind === "server") { 184 + return this.backend.exitCode !== null; 185 + } 186 + return false; 187 + } 188 + 189 + /** The PtyServer instance (server-mode only). */ 190 + get server(): PtyServer { 191 + if (this.backend.kind !== "server") { 192 + throw new Error("server is only available in server mode"); 193 + } 194 + return this.backend.server; 195 + } 196 + 197 + /** The session name (server-mode only). */ 198 + get name(): string { 199 + if (this.backend.kind !== "server") { 200 + throw new Error("name is only available in server mode"); 201 + } 202 + return this.backend.name; 203 + } 204 + 205 + // ── Input ── 206 + 207 + sendKeys(keys: string): void { 208 + if (this.backend.kind === "spawn") { 209 + this.backend.ptyProcess.write(keys); 210 + } else { 211 + this.backend.socket.write(encodeData(keys)); 212 + } 213 + } 214 + 215 + press(keyName: string): void { 216 + this.sendKeys(resolveKey(keyName)); 217 + } 218 + 219 + type(text: string): void { 220 + this.sendKeys(text); 221 + } 222 + 223 + // ── Screen ── 224 + 225 + screenshot(): Screenshot { 226 + return captureScreenshot(this.terminal, this.serialize); 227 + } 228 + 229 + // ── Waiting ── 230 + 231 + async waitForText(text: string, timeoutMs = 5000): Promise<Screenshot> { 232 + const start = Date.now(); 233 + while (Date.now() - start < timeoutMs) { 234 + await new Promise((r) => setTimeout(r, 50)); 235 + const ss = this.screenshot(); 236 + if (ss.text.includes(text)) return ss; 237 + } 238 + const ss = this.screenshot(); 239 + throw new Error( 240 + `Timed out after ${timeoutMs}ms waiting for "${text}".\nScreen:\n${ss.text}` 241 + ); 242 + } 243 + 244 + async waitForAbsent(text: string, timeoutMs = 5000): Promise<Screenshot> { 245 + const start = Date.now(); 246 + while (Date.now() - start < timeoutMs) { 247 + await new Promise((r) => setTimeout(r, 50)); 248 + const ss = this.screenshot(); 249 + if (!ss.text.includes(text)) return ss; 250 + } 251 + const ss = this.screenshot(); 252 + throw new Error( 253 + `Timed out after ${timeoutMs}ms waiting for "${text}" to disappear.\nScreen:\n${ss.text}` 254 + ); 255 + } 256 + 257 + async waitFor( 258 + predicate: (ss: Screenshot) => boolean, 259 + timeoutMs = 5000, 260 + description = "predicate" 261 + ): Promise<Screenshot> { 262 + const start = Date.now(); 263 + while (Date.now() - start < timeoutMs) { 264 + await new Promise((r) => setTimeout(r, 50)); 265 + const ss = this.screenshot(); 266 + if (predicate(ss)) return ss; 267 + } 268 + const ss = this.screenshot(); 269 + throw new Error( 270 + `Timed out after ${timeoutMs}ms waiting for ${description}.\nScreen:\n${ss.text}` 271 + ); 272 + } 273 + 274 + // ── Server-mode only ── 275 + 276 + async attach(): Promise<void> { 277 + if (this.backend.kind !== "server") { 278 + throw new Error("attach() is only available in server mode"); 279 + } 280 + const backend = this.backend; 281 + const screenPromise = new Promise<void>((resolve) => { 282 + const timer = setTimeout(resolve, 5000); 283 + backend.screenCallbacks.push(() => { 284 + clearTimeout(timer); 285 + resolve(); 286 + }); 287 + }); 288 + backend.socket.write(encodeAttach(this._rows, this._cols)); 289 + await screenPromise; 290 + } 291 + 292 + async reconnect(): Promise<void> { 293 + if (this.backend.kind !== "server") { 294 + throw new Error("reconnect() is only available in server mode"); 295 + } 296 + this.backend.socket.destroy(); 297 + await new Promise((r) => setTimeout(r, 100)); 298 + this.terminal.reset(); 299 + await this.connectSocket(); 300 + await this.attach(); 301 + } 302 + 303 + resize(rows: number, cols: number): void { 304 + if (this.backend.kind !== "server") { 305 + throw new Error("resize() is only available in server mode"); 306 + } 307 + this._rows = rows; 308 + this._cols = cols; 309 + this.backend.socket.write(encodeResize(rows, cols)); 310 + this.terminal.resize(cols, rows); 311 + } 312 + 313 + // ── Lifecycle ── 314 + 315 + async close(): Promise<void> { 316 + if (this.backend.kind === "spawn") { 317 + try { 318 + this.backend.ptyProcess.kill(); 319 + } catch {} 320 + this.terminal.dispose(); 321 + } else { 322 + this.backend.socket.destroy(); 323 + this.terminal.dispose(); 324 + if (this.backend.ownsServer) { 325 + await this.backend.server.close(); 326 + } 327 + } 328 + } 329 + 330 + // ── Private ── 331 + 332 + private async connectSocket(): Promise<void> { 333 + if (this.backend.kind !== "server") return; 334 + const backend = this.backend; 335 + 336 + backend.reader = new PacketReader(); 337 + backend.screenCallbacks = []; 338 + backend.exitCode = null; 339 + 340 + backend.socket = await new Promise<net.Socket>((resolve, reject) => { 341 + const s = net.createConnection(getSocketPath(backend.name)); 342 + s.on("connect", () => resolve(s)); 343 + s.on("error", reject); 344 + }); 345 + 346 + backend.socket.on("data", (data: Buffer) => { 347 + const packets = backend.reader.feed(data); 348 + for (const packet of packets) { 349 + switch (packet.type) { 350 + case MessageType.SCREEN: 351 + this.terminal.reset(); 352 + this.terminal.write(packet.payload.toString(), () => { 353 + const cbs = backend.screenCallbacks; 354 + backend.screenCallbacks = []; 355 + for (const cb of cbs) cb(); 356 + }); 357 + break; 358 + case MessageType.DATA: 359 + this.terminal.write(packet.payload.toString()); 360 + break; 361 + case MessageType.EXIT: 362 + backend.exitCode = packet.payload.readInt32BE(0); 363 + break; 364 + } 365 + } 366 + }); 367 + } 368 + }
+22
src/testing/types.ts
··· 1 + export interface Screenshot { 2 + /** Plain text lines (trailing whitespace trimmed per line) */ 3 + lines: string[]; 4 + /** All lines joined with newline */ 5 + text: string; 6 + /** ANSI-serialized terminal state (includes escape codes) */ 7 + ansi: string; 8 + } 9 + 10 + export interface SpawnOptions { 11 + rows?: number; 12 + cols?: number; 13 + cwd?: string; 14 + env?: Record<string, string>; 15 + } 16 + 17 + export interface ServerOptions { 18 + name?: string; 19 + rows?: number; 20 + cols?: number; 21 + cwd?: string; 22 + }
+6 -235
tests/screenshot.test.ts
··· 5 5 import * as path from "node:path"; 6 6 import { spawn } from "node:child_process"; 7 7 import { fileURLToPath } from "node:url"; 8 - import { Terminal } from "@xterm/headless"; 9 - import { SerializeAddon } from "@xterm/addon-serialize"; 10 - import { PtyServer, type ServerOptions } from "../src/server.ts"; 8 + import { Session, type Screenshot } from "../src/testing/index.ts"; 11 9 import { 12 10 MessageType, 13 11 PacketReader, ··· 27 25 fs.rmSync(testSessionDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); 28 26 }); 29 27 30 - // ─── Types ─── 31 - 32 - interface Screenshot { 33 - /** Plain text lines (trailing whitespace trimmed per line) */ 34 - lines: string[]; 35 - /** All lines joined with newline */ 36 - text: string; 37 - /** ANSI-serialized terminal state (includes escape codes) */ 38 - ansi: string; 39 - } 40 - 41 - // ─── TestSession ─── 42 - 43 - class TestSession { 44 - server: PtyServer; 45 - name: string; 46 - rows: number; 47 - cols: number; 48 - 49 - private ownsServer: boolean; 50 - private socket!: net.Socket; 51 - private reader!: PacketReader; 52 - private terminal: Terminal; 53 - private serialize: SerializeAddon; 54 - private screenCallbacks: Array<() => void> = []; 55 - private exitCode: number | null = null; 56 - 57 - private constructor( 58 - server: PtyServer, 59 - name: string, 60 - rows: number, 61 - cols: number, 62 - ownsServer: boolean 63 - ) { 64 - this.server = server; 65 - this.name = name; 66 - this.rows = rows; 67 - this.cols = cols; 68 - this.ownsServer = ownsServer; 69 - this.terminal = new Terminal({ 70 - rows, 71 - cols, 72 - scrollback: 1000, 73 - allowProposedApi: true, 74 - }); 75 - this.serialize = new SerializeAddon(); 76 - this.terminal.loadAddon(this.serialize); 77 - } 78 - 79 - static async create( 80 - name: string, 81 - command: string, 82 - args: string[] = [], 83 - opts: Partial<Pick<ServerOptions, "rows" | "cols">> & { cwd?: string } = {} 84 - ): Promise<TestSession> { 85 - const rows = opts.rows ?? 24; 86 - const cols = opts.cols ?? 80; 87 - const cwd = opts.cwd ?? testCwd; 88 - const server = new PtyServer({ 89 - name, 90 - command, 91 - args, 92 - displayCommand: command, 93 - cwd, 94 - rows, 95 - cols, 96 - }); 97 - await server.ready; 98 - const session = new TestSession(server, name, rows, cols, true); 99 - await session.connectSocket(); 100 - return session; 101 - } 102 - 103 - /** Connect to an existing server as a second client. */ 104 - static async connectToExisting( 105 - name: string, 106 - server: PtyServer, 107 - opts: { rows?: number; cols?: number } = {} 108 - ): Promise<TestSession> { 109 - const rows = opts.rows ?? 24; 110 - const cols = opts.cols ?? 80; 111 - const session = new TestSession(server, name, rows, cols, false); 112 - await session.connectSocket(); 113 - return session; 114 - } 115 - 116 - private async connectSocket(): Promise<void> { 117 - this.reader = new PacketReader(); 118 - this.screenCallbacks = []; 119 - this.exitCode = null; 120 - 121 - this.socket = await new Promise<net.Socket>((resolve, reject) => { 122 - const s = net.createConnection(getSocketPath(this.name)); 123 - s.on("connect", () => resolve(s)); 124 - s.on("error", reject); 125 - }); 126 - 127 - this.socket.on("data", (data: Buffer) => { 128 - const packets = this.reader.feed(data); 129 - for (const packet of packets) { 130 - switch (packet.type) { 131 - case MessageType.SCREEN: 132 - this.terminal.reset(); 133 - this.terminal.write(packet.payload.toString(), () => { 134 - const cbs = this.screenCallbacks; 135 - this.screenCallbacks = []; 136 - for (const cb of cbs) cb(); 137 - }); 138 - break; 139 - case MessageType.DATA: 140 - this.terminal.write(packet.payload.toString()); 141 - break; 142 - case MessageType.EXIT: 143 - this.exitCode = packet.payload.readInt32BE(0); 144 - break; 145 - } 146 - } 147 - }); 148 - } 149 - 150 - /** Send ATTACH and wait for the SCREEN response. */ 151 - async attach(): Promise<void> { 152 - const screenPromise = new Promise<void>((resolve) => { 153 - const timer = setTimeout(resolve, 5000); 154 - this.screenCallbacks.push(() => { 155 - clearTimeout(timer); 156 - resolve(); 157 - }); 158 - }); 159 - this.socket.write(encodeAttach(this.rows, this.cols)); 160 - await screenPromise; 161 - } 162 - 163 - /** Disconnect, create a new socket, and attach again. */ 164 - async reconnect(): Promise<void> { 165 - this.socket.destroy(); 166 - await new Promise((r) => setTimeout(r, 100)); 167 - this.terminal.reset(); 168 - await this.connectSocket(); 169 - await this.attach(); 170 - } 171 - 172 - /** Send keystrokes to the PTY process. */ 173 - sendKeys(keys: string): void { 174 - this.socket.write(encodeData(keys)); 175 - } 176 - 177 - /** Send a RESIZE message and update the local terminal dimensions. */ 178 - resize(rows: number, cols: number): void { 179 - this.rows = rows; 180 - this.cols = cols; 181 - this.socket.write(encodeResize(rows, cols)); 182 - this.terminal.resize(cols, rows); 183 - } 184 - 185 - /** Whether the PTY process has exited. */ 186 - get hasExited(): boolean { 187 - return this.exitCode !== null; 188 - } 189 - 190 - /** Capture the current terminal state. */ 191 - screenshot(): Screenshot { 192 - const buffer = this.terminal.buffer.active; 193 - const lines: string[] = []; 194 - for (let i = 0; i < buffer.length; i++) { 195 - const line = buffer.getLine(i); 196 - if (line) { 197 - lines.push(line.translateToString(true)); 198 - } 199 - } 200 - while (lines.length > 0 && lines[lines.length - 1].trim() === "") { 201 - lines.pop(); 202 - } 203 - return { 204 - lines, 205 - text: lines.join("\n"), 206 - ansi: this.serialize.serialize(), 207 - }; 208 - } 209 - 210 - /** Poll until the terminal contains the given text. */ 211 - async waitForText(text: string, timeoutMs = 5000): Promise<Screenshot> { 212 - const start = Date.now(); 213 - while (Date.now() - start < timeoutMs) { 214 - await new Promise((r) => setTimeout(r, 50)); 215 - const ss = this.screenshot(); 216 - if (ss.text.includes(text)) return ss; 217 - } 218 - const ss = this.screenshot(); 219 - throw new Error( 220 - `Timed out after ${timeoutMs}ms waiting for "${text}".\nScreen:\n${ss.text}` 221 - ); 222 - } 223 - 224 - /** Poll until a predicate on the screenshot returns true. */ 225 - async waitFor( 226 - predicate: (ss: Screenshot) => boolean, 227 - timeoutMs = 5000, 228 - description = "predicate" 229 - ): Promise<Screenshot> { 230 - const start = Date.now(); 231 - while (Date.now() - start < timeoutMs) { 232 - await new Promise((r) => setTimeout(r, 50)); 233 - const ss = this.screenshot(); 234 - if (predicate(ss)) return ss; 235 - } 236 - const ss = this.screenshot(); 237 - throw new Error( 238 - `Timed out after ${timeoutMs}ms waiting for ${description}.\nScreen:\n${ss.text}` 239 - ); 240 - } 241 - 242 - async close(): Promise<void> { 243 - this.socket.destroy(); 244 - this.terminal.dispose(); 245 - if (this.ownsServer) { 246 - await this.server.close(); 247 - } 248 - } 249 - } 250 - 251 28 // ─── Scaffolding ─── 252 29 253 - let sessions: TestSession[] = []; 30 + let sessions: Session[] = []; 254 31 let sessionNames: string[] = []; 255 32 let tmpDirs: string[] = []; 256 33 let daemonPids: number[] = []; ··· 271 48 command: string, 272 49 args: string[] = [], 273 50 opts: { rows?: number; cols?: number; cwd?: string } = {} 274 - ): Promise<TestSession> { 51 + ): Promise<Session> { 275 52 const name = uniqueName(); 276 - const session = await TestSession.create(name, command, args, opts); 53 + const session = await Session.server(command, args, { name, cwd: opts.cwd ?? testCwd, rows: opts.rows, cols: opts.cols }); 277 54 sessions.push(session); 278 55 await session.attach(); 279 56 return session; ··· 1017 794 1018 795 await session.waitForText("Shared colored output"); 1019 796 1020 - const peer = await TestSession.connectToExisting( 1021 - session.name, 1022 - session.server 1023 - ); 797 + const peer = await Session.connectToExisting(session); 1024 798 sessions.push(peer); 1025 799 await peer.attach(); 1026 800 ··· 1034 808 it("both clients receive live output from a new command", async () => { 1035 809 const session = await createSession("cat"); 1036 810 1037 - const peer = await TestSession.connectToExisting( 1038 - session.name, 1039 - session.server 1040 - ); 811 + const peer = await Session.connectToExisting(session); 1041 812 sessions.push(peer); 1042 813 await peer.attach(); 1043 814
-233
tests/tui-harness.ts
··· 1 - import * as fs from "node:fs"; 2 - import * as os from "node:os"; 3 - import * as path from "node:path"; 4 - import { fileURLToPath } from "node:url"; 5 - import { spawn, type ChildProcess } from "node:child_process"; 6 - import { Terminal } from "@xterm/headless"; 7 - import { SerializeAddon } from "@xterm/addon-serialize"; 8 - import * as pty from "node-pty"; 9 - import { resolveKey } from "../src/keys.ts"; 10 - 11 - const __dirname = path.dirname(fileURLToPath(import.meta.url)); 12 - 13 - export interface Screenshot { 14 - lines: string[]; 15 - text: string; 16 - ansi: string; 17 - } 18 - 19 - /** 20 - * Spawns the CLI as a pty process and provides helpers for 21 - * driving the interactive TUI in tests. 22 - */ 23 - export class TuiSession { 24 - private ptyProcess: pty.IPty; 25 - private terminal: Terminal; 26 - private serialize: SerializeAddon; 27 - rows: number; 28 - cols: number; 29 - 30 - private constructor( 31 - ptyProcess: pty.IPty, 32 - rows: number, 33 - cols: number 34 - ) { 35 - this.ptyProcess = ptyProcess; 36 - this.rows = rows; 37 - this.cols = cols; 38 - this.terminal = new Terminal({ 39 - rows, 40 - cols, 41 - scrollback: 1000, 42 - allowProposedApi: true, 43 - }); 44 - this.serialize = new SerializeAddon(); 45 - this.terminal.loadAddon(this.serialize); 46 - 47 - // Feed pty output into xterm-headless 48 - this.ptyProcess.onData((data: string) => { 49 - this.terminal.write(data); 50 - }); 51 - } 52 - 53 - /** 54 - * Create a TUI session that runs `tsx src/cli.ts` with a custom session dir. 55 - */ 56 - static create(opts: { 57 - sessionDir: string; 58 - rows?: number; 59 - cols?: number; 60 - cwd?: string; 61 - args?: string[]; 62 - }): TuiSession { 63 - const rows = opts.rows ?? 24; 64 - const cols = opts.cols ?? 80; 65 - const cwd = opts.cwd ?? process.cwd(); 66 - 67 - const tsxBin = path.join(__dirname, "..", "node_modules", ".bin", "tsx"); 68 - const cliModule = path.join(__dirname, "..", "src", "cli.ts"); 69 - const args = opts.args ?? []; 70 - 71 - const env = { 72 - ...process.env, 73 - PTY_SESSION_DIR: opts.sessionDir, 74 - TERM: "xterm-256color", 75 - }; 76 - delete (env as any).PTY_SERVER_CONFIG; 77 - 78 - const proc = pty.spawn(tsxBin, [cliModule, ...args], { 79 - name: "xterm-256color", 80 - cols, 81 - rows, 82 - cwd, 83 - env: env as Record<string, string>, 84 - }); 85 - 86 - return new TuiSession(proc, rows, cols); 87 - } 88 - 89 - screenshot(): Screenshot { 90 - const buffer = this.terminal.buffer.active; 91 - const lines: string[] = []; 92 - for (let i = 0; i < buffer.length; i++) { 93 - const line = buffer.getLine(i); 94 - if (line) { 95 - lines.push(line.translateToString(true)); 96 - } 97 - } 98 - while (lines.length > 0 && lines[lines.length - 1].trim() === "") { 99 - lines.pop(); 100 - } 101 - return { 102 - lines, 103 - text: lines.join("\n"), 104 - ansi: this.serialize.serialize(), 105 - }; 106 - } 107 - 108 - sendKeys(keys: string): void { 109 - this.ptyProcess.write(keys); 110 - } 111 - 112 - press(keyName: string): void { 113 - this.sendKeys(resolveKey(keyName)); 114 - } 115 - 116 - type(text: string): void { 117 - this.sendKeys(text); 118 - } 119 - 120 - async waitForText(text: string, timeoutMs = 5000): Promise<Screenshot> { 121 - const start = Date.now(); 122 - while (Date.now() - start < timeoutMs) { 123 - await new Promise((r) => setTimeout(r, 50)); 124 - const ss = this.screenshot(); 125 - if (ss.text.includes(text)) return ss; 126 - } 127 - const ss = this.screenshot(); 128 - throw new Error( 129 - `Timed out after ${timeoutMs}ms waiting for "${text}".\nScreen:\n${ss.text}` 130 - ); 131 - } 132 - 133 - async waitForAbsent(text: string, timeoutMs = 5000): Promise<Screenshot> { 134 - const start = Date.now(); 135 - while (Date.now() - start < timeoutMs) { 136 - await new Promise((r) => setTimeout(r, 50)); 137 - const ss = this.screenshot(); 138 - if (!ss.text.includes(text)) return ss; 139 - } 140 - const ss = this.screenshot(); 141 - throw new Error( 142 - `Timed out after ${timeoutMs}ms waiting for "${text}" to disappear.\nScreen:\n${ss.text}` 143 - ); 144 - } 145 - 146 - async waitFor( 147 - predicate: (ss: Screenshot) => boolean, 148 - timeoutMs = 5000, 149 - description = "predicate" 150 - ): Promise<Screenshot> { 151 - const start = Date.now(); 152 - while (Date.now() - start < timeoutMs) { 153 - await new Promise((r) => setTimeout(r, 50)); 154 - const ss = this.screenshot(); 155 - if (predicate(ss)) return ss; 156 - } 157 - const ss = this.screenshot(); 158 - throw new Error( 159 - `Timed out after ${timeoutMs}ms waiting for ${description}.\nScreen:\n${ss.text}` 160 - ); 161 - } 162 - 163 - close(): void { 164 - try { 165 - this.ptyProcess.kill(); 166 - } catch {} 167 - this.terminal.dispose(); 168 - } 169 - } 170 - 171 - /** 172 - * Spawn a background session as a separate daemon process with PTY_SESSION_DIR set. 173 - * Returns the child process (and its PID for cleanup). 174 - */ 175 - export async function createBackgroundSession( 176 - sessionDir: string, 177 - name: string, 178 - command: string, 179 - args: string[] = [], 180 - cwd?: string 181 - ): Promise<{ child: ChildProcess; pid: number }> { 182 - const tsxBin = path.join(__dirname, "..", "node_modules", ".bin", "tsx"); 183 - const serverModule = path.join(__dirname, "..", "src", "server.ts"); 184 - 185 - const config = JSON.stringify({ 186 - name, 187 - command, 188 - args, 189 - displayCommand: command, 190 - cwd: cwd ?? os.tmpdir(), 191 - rows: 24, 192 - cols: 80, 193 - }); 194 - 195 - const child = spawn(tsxBin, [serverModule], { 196 - detached: true, 197 - stdio: ["ignore", "ignore", "pipe"], 198 - env: { 199 - ...process.env, 200 - PTY_SERVER_CONFIG: config, 201 - PTY_SESSION_DIR: sessionDir, 202 - }, 203 - }); 204 - 205 - let stderr = ""; 206 - child.stderr?.on("data", (d: Buffer) => { 207 - stderr += d.toString(); 208 - }); 209 - 210 - let exitCode: number | null = null; 211 - child.on("exit", (code) => { 212 - exitCode = code; 213 - }); 214 - 215 - (child.stderr as any)?.unref?.(); 216 - child.unref(); 217 - 218 - // Wait for socket to appear 219 - const socketPath = path.join(sessionDir, `${name}.sock`); 220 - const start = Date.now(); 221 - while (Date.now() - start < 5000) { 222 - if (exitCode !== null) { 223 - throw new Error(`Background session daemon exited with code ${exitCode}. stderr:\n${stderr}`); 224 - } 225 - try { 226 - fs.statSync(socketPath); 227 - await new Promise((r) => setTimeout(r, 100)); 228 - return { child, pid: child.pid! }; 229 - } catch {} 230 - await new Promise((r) => setTimeout(r, 50)); 231 - } 232 - throw new Error(`Timeout waiting for background session "${name}" socket at ${socketPath}. stderr:\n${stderr}`); 233 - }
+140 -35
tests/tui.test.ts
··· 2 2 import * as fs from "node:fs"; 3 3 import * as os from "node:os"; 4 4 import * as path from "node:path"; 5 - import { TuiSession, createBackgroundSession } from "./tui-harness.ts"; 5 + import { fileURLToPath } from "node:url"; 6 + import { spawn, type ChildProcess } from "node:child_process"; 7 + import { Session } from "../src/testing/index.ts"; 8 + 9 + const __dirname = path.dirname(fileURLToPath(import.meta.url)); 10 + const tsxBin = path.join(__dirname, "..", "node_modules", ".bin", "tsx"); 11 + const cliPath = path.join(__dirname, "..", "src", "cli.ts"); 12 + const serverModule = path.join(__dirname, "..", "src", "server.ts"); 6 13 7 14 // Each test gets its own temp session dir 8 15 const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ptui-")); ··· 10 17 fs.rmSync(testRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); 11 18 }); 12 19 13 - let tuiSessions: TuiSession[] = []; 20 + let tuiSessions: Session[] = []; 14 21 let bgPids: number[] = []; 15 22 let sessionDirs: string[] = []; 16 23 ··· 22 29 23 30 afterEach(async () => { 24 31 for (const s of tuiSessions) { 25 - s.close(); 32 + await s.close(); 26 33 } 27 34 tuiSessions = []; 28 35 for (const pid of bgPids) { ··· 49 56 return `s${++nameCounter}-${Math.random().toString(36).slice(2, 6)}`; 50 57 } 51 58 59 + function createTuiSession(sessionDir: string, opts: { rows?: number; cols?: number } = {}): Session { 60 + const rows = opts.rows ?? 24; 61 + const cols = opts.cols ?? 80; 62 + const session = Session.spawn(tsxBin, [cliPath], { 63 + rows, 64 + cols, 65 + env: { 66 + PTY_SESSION_DIR: sessionDir, 67 + TERM: "xterm-256color", 68 + }, 69 + }); 70 + tuiSessions.push(session); 71 + return session; 72 + } 73 + 74 + /** 75 + * Spawn a background session as a separate daemon process with PTY_SESSION_DIR set. 76 + * Returns the child process PID for cleanup. 77 + */ 78 + async function createBackgroundSession( 79 + sessionDir: string, 80 + name: string, 81 + command: string, 82 + args: string[] = [], 83 + cwd?: string 84 + ): Promise<{ child: ChildProcess; pid: number }> { 85 + const config = JSON.stringify({ 86 + name, 87 + command, 88 + args, 89 + displayCommand: command, 90 + cwd: cwd ?? os.tmpdir(), 91 + rows: 24, 92 + cols: 80, 93 + }); 94 + 95 + const child = spawn(tsxBin, [serverModule], { 96 + detached: true, 97 + stdio: ["ignore", "ignore", "pipe"], 98 + env: { 99 + ...process.env, 100 + PTY_SERVER_CONFIG: config, 101 + PTY_SESSION_DIR: sessionDir, 102 + }, 103 + }); 104 + 105 + let stderr = ""; 106 + child.stderr?.on("data", (d: Buffer) => { 107 + stderr += d.toString(); 108 + }); 109 + 110 + let exitCode: number | null = null; 111 + child.on("exit", (code) => { 112 + exitCode = code; 113 + }); 114 + 115 + (child.stderr as any)?.unref?.(); 116 + child.unref(); 117 + 118 + // Wait for socket to appear 119 + const socketPath = path.join(sessionDir, `${name}.sock`); 120 + const start = Date.now(); 121 + while (Date.now() - start < 5000) { 122 + if (exitCode !== null) { 123 + throw new Error(`Background session daemon exited with code ${exitCode}. stderr:\n${stderr}`); 124 + } 125 + try { 126 + fs.statSync(socketPath); 127 + await new Promise((r) => setTimeout(r, 100)); 128 + return { child, pid: child.pid! }; 129 + } catch {} 130 + await new Promise((r) => setTimeout(r, 50)); 131 + } 132 + throw new Error(`Timeout waiting for background session "${name}" socket at ${socketPath}. stderr:\n${stderr}`); 133 + } 134 + 52 135 describe("interactive TUI layout", () => { 53 136 for (const cols of [80, 120, 200]) { 54 137 it( ··· 66 149 ); 67 150 bgPids.push(pid); 68 151 69 - const tui = TuiSession.create({ sessionDir, cols, rows: 24 }); 70 - tuiSessions.push(tui); 152 + const tui = createTuiSession(sessionDir, { cols, rows: 24 }); 71 153 72 154 const ss = await tui.waitForText(name, 10000); 73 155 ··· 111 193 ); 112 194 bgPids.push(pid); 113 195 114 - const tui = TuiSession.create({ sessionDir, cols: 120, rows: 24 }); 115 - tuiSessions.push(tui); 196 + const tui = createTuiSession(sessionDir, { cols: 120, rows: 24 }); 116 197 117 198 const ss = await tui.waitForText(name, 10000); 118 199 const sessionLine = ss.lines.find((l) => l.includes(name)); ··· 136 217 "session list renders correctly at narrow 60 columns", 137 218 async () => { 138 219 const sessionDir = makeSessionDir(); 139 - const tui = TuiSession.create({ sessionDir, cols: 60, rows: 24 }); 140 - tuiSessions.push(tui); 220 + const tui = createTuiSession(sessionDir, { cols: 60, rows: 24 }); 141 221 142 222 const ss = await tui.waitForText("Create new session...", 10000); 143 223 ··· 157 237 "renders session list with borders and 'Create new session...'", 158 238 async () => { 159 239 const sessionDir = makeSessionDir(); 160 - const tui = TuiSession.create({ sessionDir }); 161 - tuiSessions.push(tui); 240 + const tui = createTuiSession(sessionDir); 162 241 163 242 const ss = await tui.waitForText("Create new session...", 10000); 164 243 expect(ss.text).toContain("pty"); ··· 184 263 ); 185 264 bgPids.push(pid); 186 265 187 - const tui = TuiSession.create({ sessionDir }); 188 - tuiSessions.push(tui); 266 + const tui = createTuiSession(sessionDir); 189 267 190 268 const ss = await tui.waitForText(name, 10000); 191 269 expect(ss.text).toContain(name); ··· 209 287 ); 210 288 bgPids.push(pid); 211 289 212 - const tui = TuiSession.create({ sessionDir }); 213 - tuiSessions.push(tui); 290 + const tui = createTuiSession(sessionDir); 214 291 215 292 await tui.waitForText(name, 10000); 216 293 ··· 239 316 const bg2 = await createBackgroundSession(sessionDir, name2, "sh", ["-c", "sleep 300"], os.tmpdir()); 240 317 bgPids.push(bg2.pid); 241 318 242 - const tui = TuiSession.create({ sessionDir }); 243 - tuiSessions.push(tui); 319 + const tui = createTuiSession(sessionDir); 244 320 245 321 await tui.waitForText(name1, 10000); 246 322 await tui.waitForText(name2, 10000); ··· 282 358 ); 283 359 bgPids.push(pid); 284 360 285 - const tui = TuiSession.create({ sessionDir }); 286 - tuiSessions.push(tui); 361 + const tui = createTuiSession(sessionDir); 287 362 288 363 await tui.waitForText(name, 10000); 289 364 tui.press("return"); ··· 309 384 ); 310 385 bgPids.push(pid); 311 386 312 - const tui = TuiSession.create({ sessionDir }); 313 - tuiSessions.push(tui); 387 + const tui = createTuiSession(sessionDir); 314 388 315 389 await tui.waitForText(name, 10000); 316 390 tui.press("return"); ··· 329 403 "create wizard: shows directory picker", 330 404 async () => { 331 405 const sessionDir = makeSessionDir(); 332 - const tui = TuiSession.create({ sessionDir }); 333 - tuiSessions.push(tui); 406 + const tui = createTuiSession(sessionDir); 334 407 335 408 await tui.waitForText("Create new session...", 10000); 336 409 tui.press("return"); ··· 345 418 "create wizard: name auto-fills from directory", 346 419 async () => { 347 420 const sessionDir = makeSessionDir(); 348 - const tui = TuiSession.create({ sessionDir }); 349 - tuiSessions.push(tui); 421 + const tui = createTuiSession(sessionDir); 350 422 351 423 await tui.waitForText("Create new session...", 10000); 352 424 tui.press("return"); ··· 363 435 "empty state shows only 'Create new session...'", 364 436 async () => { 365 437 const sessionDir = makeSessionDir(); 366 - const tui = TuiSession.create({ sessionDir }); 367 - tuiSessions.push(tui); 438 + const tui = createTuiSession(sessionDir); 368 439 369 440 const ss = await tui.waitForText("Create new session...", 10000); 370 441 expect(ss.text).toContain("Create new session..."); ··· 377 448 "q quits the interactive TUI", 378 449 async () => { 379 450 const sessionDir = makeSessionDir(); 380 - const tui = TuiSession.create({ sessionDir }); 381 - tuiSessions.push(tui); 451 + const tui = createTuiSession(sessionDir); 382 452 383 453 await tui.waitForText("Create new session...", 10000); 384 454 tui.type("q"); ··· 394 464 "escape clears filter, then quits", 395 465 async () => { 396 466 const sessionDir = makeSessionDir(); 397 - const tui = TuiSession.create({ sessionDir }); 398 - tuiSessions.push(tui); 467 + const tui = createTuiSession(sessionDir); 399 468 400 469 await tui.waitForText("Create new session...", 10000); 401 470 ··· 437 506 ); 438 507 bgPids.push(pid); 439 508 440 - const tui = TuiSession.create({ sessionDir }); 441 - tuiSessions.push(tui); 509 + const tui = createTuiSession(sessionDir); 442 510 443 511 // Cycle 1: attach and detach 444 512 await tui.waitForText(name, 10000); ··· 477 545 ); 478 546 bgPids.push(pid); 479 547 480 - const tui = TuiSession.create({ sessionDir }); 481 - tuiSessions.push(tui); 548 + const tui = createTuiSession(sessionDir); 482 549 483 550 await tui.waitForText(name, 10000); 484 551 ··· 491 558 const ss = await tui.waitForText(name, 10000); 492 559 expect(ss.text).toContain(name); 493 560 expect(ss.text).toContain("Create new session..."); 561 + }, 562 + 20000 563 + ); 564 + 565 + it( 566 + "exited session shows as exited when returning to list during cleanup window", 567 + async () => { 568 + const sessionDir = makeSessionDir(); 569 + const name = uniqueName(); 570 + 571 + // Use a command that prints a marker then waits for input 572 + const { pid } = await createBackgroundSession( 573 + sessionDir, 574 + name, 575 + "sh", 576 + ["-c", "echo EXIT_RACE; exec cat"], 577 + os.tmpdir() 578 + ); 579 + bgPids.push(pid); 580 + 581 + const tui = createTuiSession(sessionDir); 582 + 583 + // Attach to the session 584 + await tui.waitForText(name, 10000); 585 + tui.press("return"); 586 + await tui.waitForText("EXIT_RACE", 10000); 587 + 588 + // Send EOF to make cat exit — triggers the race where the daemon 589 + // is still alive (500ms cleanup delay) but metadata has exitedAt set 590 + tui.sendKeys("\x04"); // Ctrl+D 591 + 592 + // Wait for TUI to return to the list 593 + await tui.waitForText("Create new session...", 10000); 594 + 595 + // The session must show as exited, not running 596 + const ss = tui.screenshot(); 597 + expect(ss.text).toContain("exited"); 598 + expect(ss.text).toContain(name); 494 599 }, 495 600 20000 496 601 );