Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions tests/fixtures/parity/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# Parity fixtures (shared, language-neutral)

Round-2 of the parity drive. These fixtures are the **single source of truth**
both the node suite and the Rust port (pty-rust) assert against, so the two
implementations cannot silently drift.

**Ownership:** the node repo **owns** these files. pty-rust vendors a
**byte-identical mirror** and writes the equivalent Rust assertions. When a
fixture changes here, the mirror must be re-synced.

**Assertion rules (agreed with pty-rust-claude):**
- **Plain-screen bytes** are asserted **EXACTLY** (`peek --plain` output, with
the CLI's single trailing `\n` stripped).
- **ANSI** is asserted as a **mode-set** — *which* modes are present (e.g.
`?25l`, `?1000h`, `?1006h`), never the raw byte stream.

## `screens.json`

```
{
version: 2,
fixtures: [
{
id: string, // stable identifier
kind: "plain-screen" // assert the live plain screen
| "plain-screen-after-exit" // let it exit, then assert the preserved final screen + exit status
| "reaped-after-exit", // let it exit under REAP mode, then assert it is gone
description: string,
spawn: { command: string, args: string[], rows: number, cols: number },
env?: { [k: string]: string }, // per-fixture daemon env overlay; pins the
// exit-time mode (PTY_REAP_ON_EXIT=false = preserve)
settleMs: number, // wait after spawn (or after exit) before capturing
expect: {
plainScreen?: string, // EXACT bytes of `peek --plain` (trailing \n stripped)
plainScreenLength?: number, // optional guard against pad/trim regressions
status?: "exited", // for *-after-exit fixtures: registry status
exitCode?: number, // for *-after-exit fixtures: recorded exit code
reaped?: boolean // for reaped-after-exit: peek fails + ls omits it
}
}
]
}
```

Exit-time behavior is **configurable** (`PTY_REAP_ON_EXIT`; shipped default
`reap`). The per-fixture `env` overlay pins the mode a fixture needs:
`post-exit-final-screen` runs with `PTY_REAP_ON_EXIT=false` (preserve → final
screen survives); `post-exit-reaped` runs with the default (reap → the session
removes itself, `peek` fails, `ls` omits it).

The node harness (`tests/parity-fixtures.test.ts`) spawns the same daemon module
the CLI daemonizes (with the fixture's `env`), waits `settleMs`, then for
plain-screen kinds runs `peek --plain` and asserts `stdout.replace(/\n$/,"") ===
expect.plainScreen`; for `reaped-after-exit` it asserts `peek` exits non-zero and
`ls --json` has no entry. pty-rust does the equivalent against its own binary.

## `shapes.json`

JSON-SHAPE fixtures (companion to `screens.json`, harness
`tests/parity-shapes.test.ts`): run a scenario via the real `pty` CLI, then
assert the machine-readable output **field-by-field per policy** rather than by
raw bytes. Each field policy is one of `{exact:<v>}` (=== v, incl. `null`),
`{type:'number'|'string'}` (present, non-null, that type), or
`{omitWhenUnset:true}` (the key is absent). Seeds:

- `ls-json-shape` — `pty list --json` entry shape for a running vs an exited
session (preserve mode, so the exited entry stays listed). status enum
`running|exited|vanished`. **pid policy:** ls `pid` is the DAEMON pid
(`type:number` while running, `exact:null` once exited) — distinct from
`stats.process.pid` (the child pid).
- `client-count-during-peek` — after a transient `peek`, `stats --json`
`clients.attached === 0` (a peek/stats connection is not an attached
streaming client).

## Seeds landed

`screens.json`: `idle-prompt-plain`, `post-exit-final-screen` (preserve),
`post-exit-reaped` (reap). `shapes.json`: `ls-json-shape`,
`client-count-during-peek`.
40 changes: 40 additions & 0 deletions tests/fixtures/parity/screens.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
{
"version": 2,
"note": "CANONICAL, language-neutral parity fixtures. Node (this repo) OWNS this file; pty-rust vendors a synced mirror. BOTH suites load this exact JSON and assert their implementation reproduces each fixture. Plain-screen bytes are asserted EXACTLY; ANSI is asserted as a mode-set (never raw bytes). Exit-time behavior is CONFIGURABLE (PTY_REAP_ON_EXIT; shipped default=reap): the per-fixture `env` overlay pins the mode a fixture needs. Keep the two mirrors byte-identical.",
"fixtures": [
{
"id": "idle-prompt-plain",
"kind": "plain-screen",
"description": "A written shell-style prompt with a trailing space (the cell the cursor sits on). The plain screen preserves the explicitly-written trailing space and trims only never-written cells — it is NOT blanket right-trimmed and NOT padded to full width. printf is used (not a live shell prompt) so the bytes are shell-version independent. The session stays running (exec cat), so exit-time behavior is irrelevant here.",
"spawn": { "command": "sh", "args": ["-c", "printf 'READY> '; exec cat"], "rows": 24, "cols": 80 },
"settleMs": 700,
"expect": {
"plainScreen": "READY> ",
"plainScreenLength": 7
}
},
{
"id": "post-exit-final-screen",
"kind": "plain-screen-after-exit",
"description": "PRESERVE mode (PTY_REAP_ON_EXIT=false). Three lines, DONE with no trailing newline, then exit 7. The finished session is preserved, so the final rendered viewport survives verbatim and peeking is idempotent (does not consume/clear it). The registry records status=exited with the real exit code.",
"spawn": { "command": "sh", "args": ["-c", "printf 'LINE_A\\nLINE_B\\nDONE'; exit 7"], "rows": 24, "cols": 80 },
"env": { "PTY_REAP_ON_EXIT": "false" },
"settleMs": 1200,
"expect": {
"plainScreen": "LINE_A\nLINE_B\nDONE",
"status": "exited",
"exitCode": 7
}
},
{
"id": "post-exit-reaped",
"kind": "reaped-after-exit",
"description": "REAP mode (shipped default, no PTY_REAP_ON_EXIT). A finished non-permanent session removes its own registry entry as it exits, so there is nothing left to peek and ls no longer lists it.",
"spawn": { "command": "sh", "args": ["-c", "printf 'GONE'; exit 0"], "rows": 24, "cols": 80 },
"settleMs": 1200,
"expect": {
"reaped": true
}
}
]
}
59 changes: 59 additions & 0 deletions tests/fixtures/parity/shapes.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
{
"version": 1,
"note": "CANONICAL, language-neutral JSON-SHAPE parity fixtures (companion to screens.json). Node (this repo) OWNS this file; pty-rust vendors a byte-identical mirror. Both suites run the scenario, then assert the machine-readable output FIELD-BY-FIELD per policy — NOT raw bytes. Field policy is one of: {exact:<v>} (=== v, incl. null); {type:'number'|'string'} (present, non-null, that type); {omitWhenUnset:true} (the key is ABSENT). Sessions are created via the real `pty` CLI args in `run`, so the two suites drive identical commands.",
"fixtures": [
{
"id": "ls-json-shape",
"kind": "ls-json-shape",
"description": "`pty list --json` entry shape for a running vs an exited session. Runs in PRESERVE mode (PTY_REAP_ON_EXIT=false) so the exited entry stays listed. status enum is running|exited|vanished. Note the pid policy: ls --json pid is the DAEMON pid (a number while running, null once exited) — distinct from stats.process.pid, which is the CHILD pid.",
"env": { "PTY_REAP_ON_EXIT": "false" },
"settleMs": 1500,
"statusEnum": ["running", "exited", "vanished"],
"sessions": [
{
"id": "runsess",
"run": ["run", "-d", "--id", "runsess", "--no-display-name", "--", "cat"],
"expect": {
"name": { "exact": "runsess" },
"status": { "exact": "running" },
"pid": { "type": "number", "note": "daemon pid; != stats.process.pid (child)" },
"command": { "exact": "cat" },
"cwd": { "type": "string" },
"createdAt": { "type": "string" },
"exitCode": { "exact": null },
"exitedAt": { "exact": null },
"displayName": { "omitWhenUnset": true }
}
},
{
"id": "exsess",
"run": ["run", "-d", "--id", "exsess", "--name", "my-label", "--", "sh", "-c", "exit 3"],
"expect": {
"name": { "exact": "exsess" },
"status": { "exact": "exited" },
"pid": { "exact": null },
"command": { "exact": "sh -c exit 3" },
"cwd": { "type": "string" },
"createdAt": { "type": "string" },
"exitCode": { "exact": 3 },
"exitedAt": { "type": "string" },
"displayName": { "exact": "my-label" }
}
}
]
},
{
"id": "client-count-during-peek",
"kind": "stats-clients",
"description": "A transient `peek` (and the `stats` query itself) does NOT count as an attached streaming client. After peeking a running session, `stats --json` reports clients.attached === 0.",
"settleMs": 700,
"run": ["run", "-d", "--id", "peeksess", "--no-display-name", "--", "cat"],
"peekFirst": true,
"expect": {
"clients": {
"attached": { "exact": 0 }
}
}
}
]
}
192 changes: 192 additions & 0 deletions tests/parity-fixtures.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
// PARITY DRIVE — Round 2: shared, language-neutral fixtures.
//
// tests/fixtures/parity/screens.json is the SINGLE source of truth both this
// node suite and the Rust port (pty-rust) assert against, so the two
// implementations cannot silently drift. Node OWNS the file; pty-rust vendors a
// byte-identical mirror and writes the equivalent Rust assertions.
//
// This harness loads that JSON and, for each fixture, spawns the same daemon
// module the CLI daemonizes, waits settleMs, runs `peek --plain`, and asserts
// node reproduces the fixture's EXACT plain-screen bytes (+ exit status for the
// after-exit fixtures). Assertion rules (see the fixtures README): plain bytes
// EXACT; ANSI would be asserted as a mode-set, never raw bytes.

import { describe, it, expect, afterEach, afterAll } from "vitest";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import { spawn, spawnSync } from "node:child_process";

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const nodeBin = process.execPath;
const cliPath = path.join(__dirname, "..", "dist", "cli.js");
const serverModule = path.join(__dirname, "..", "dist", "server.js");

const fixturesPath = path.join(__dirname, "fixtures", "parity", "screens.json");
const fixtures = JSON.parse(fs.readFileSync(fixturesPath, "utf-8")) as {
version: number;
fixtures: Array<{
id: string;
kind: "plain-screen" | "plain-screen-after-exit" | "reaped-after-exit";
description: string;
spawn: { command: string; args: string[]; rows: number; cols: number };
// Per-fixture env overlay for the spawned daemon. Exit-time behavior is
// configurable (PTY_REAP_ON_EXIT); a fixture pins the mode it needs here.
env?: Record<string, string>;
settleMs: number;
expect: {
plainScreen?: string;
plainScreenLength?: number;
status?: string;
exitCode?: number;
reaped?: boolean;
};
}>;
};

const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pty-parityfx-"));
afterAll(() => {
fs.rmSync(testRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
});

let bgPids: number[] = [];
let sessionDirs: string[] = [];

function makeSessionDir(): string {
const dir = fs.mkdtempSync(path.join(testRoot, "d-"));
sessionDirs.push(dir);
return dir;
}

let nameCounter = 0;
function uniqueName(): string {
return `fx${++nameCounter}-${Math.random().toString(36).slice(2, 6)}`;
}

async function startDaemon(
sessionDir: string,
name: string,
command: string,
args: string[],
rows: number,
cols: number,
env: Record<string, string> = {},
): Promise<number> {
const config = JSON.stringify({
name, command, args, displayCommand: command,
cwd: os.tmpdir(), rows, cols,
});
const child = spawn(nodeBin, [serverModule], {
detached: true,
stdio: ["ignore", "ignore", "pipe"],
env: { ...process.env, PTY_SERVER_CONFIG: config, PTY_SESSION_DIR: sessionDir, ...env },
});
let stderr = "";
child.stderr?.on("data", (d: Buffer) => { stderr += d.toString(); });
let exitCode: number | null = null;
child.on("exit", (code) => { exitCode = code; });
(child.stderr as any)?.unref?.();
child.unref();

const socketPath = path.join(sessionDir, `${name}.sock`);
const start = Date.now();
while (Date.now() - start < 5000) {
if (exitCode !== null) throw new Error(`Daemon exited: ${stderr}`);
try {
fs.statSync(socketPath);
await new Promise((r) => setTimeout(r, 100));
bgPids.push(child.pid!);
return child.pid!;
} catch {}
await new Promise((r) => setTimeout(r, 50));
}
throw new Error(`Timeout waiting for daemon socket: ${socketPath}`);
}

function runCli(sessionDir: string, args: string[]) {
return spawnSync(nodeBin, [cliPath, ...args], {
env: { ...process.env, PTY_SESSION_DIR: sessionDir },
encoding: "utf-8",
timeout: 15000,
});
}

const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

afterEach(() => {
for (const pid of bgPids) { try { process.kill(pid, "SIGTERM"); } catch {} }
bgPids = [];
for (const dir of sessionDirs) {
try {
for (const e of fs.readdirSync(dir)) { try { fs.unlinkSync(path.join(dir, e)); } catch {} }
} catch {}
}
sessionDirs = [];
});

describe("parity R2: shared screen fixtures (node reproduces the canonical values)", () => {
it("the fixtures file is present and versioned", () => {
expect(fixtures.version).toBe(2);
expect(fixtures.fixtures.length).toBeGreaterThan(0);
});

for (const fx of fixtures.fixtures) {
if (fx.kind === "reaped-after-exit") {
it(`fixture "${fx.id}" (${fx.kind}): finished session reaps itself — peek gone, ls omits`, async () => {
const dir = makeSessionDir();
const name = uniqueName();
// Reap mode: the daemon removes itself as it exits, so there is no
// persistent socket to wait for — spawn raw, let it run + reap, then
// assert the session is gone.
const config = JSON.stringify({
name, command: fx.spawn.command, args: fx.spawn.args,
displayCommand: fx.spawn.command, cwd: os.tmpdir(),
rows: fx.spawn.rows, cols: fx.spawn.cols,
});
const child = spawn(nodeBin, [serverModule], {
detached: true,
stdio: ["ignore", "ignore", "ignore"],
env: { ...process.env, PTY_SERVER_CONFIG: config, PTY_SESSION_DIR: dir, ...(fx.env ?? {}) },
});
child.unref();
if (child.pid) bgPids.push(child.pid);
await sleep(fx.settleMs);

const peek = runCli(dir, ["peek", "--plain", name]);
expect(peek.status).not.toBe(0);
const list = JSON.parse(runCli(dir, ["list", "--json"]).stdout);
expect(list.find((s: any) => s.name === name)).toBeUndefined();
}, 20000);
continue;
}

it(`fixture "${fx.id}" (${fx.kind}) reproduces the exact plain screen`, async () => {
const dir = makeSessionDir();
const name = uniqueName();
await startDaemon(dir, name, fx.spawn.command, fx.spawn.args, fx.spawn.rows, fx.spawn.cols, fx.env ?? {});
await sleep(fx.settleMs);

const peek = runCli(dir, ["peek", "--plain", name]);
expect(peek.status).toBe(0);
const screen = peek.stdout.replace(/\n$/, "");
// Plain-screen bytes must match EXACTLY (the core R2 contract).
expect(screen).toBe(fx.expect.plainScreen);
if (typeof fx.expect.plainScreenLength === "number") {
expect(screen.length).toBe(fx.expect.plainScreenLength);
}

if (fx.kind === "plain-screen-after-exit") {
const list = JSON.parse(runCli(dir, ["list", "--json"]).stdout);
const found = list.find((s: any) => s.name === name);
expect(found).toBeDefined();
if (fx.expect.status) expect(found.status).toBe(fx.expect.status);
if (typeof fx.expect.exitCode === "number") expect(found.exitCode).toBe(fx.expect.exitCode);

// Idempotent: a second peek is byte-identical (peek does not consume).
const again = runCli(dir, ["peek", "--plain", name]);
expect(again.stdout).toBe(peek.stdout);
}
}, 20000);
}
});
Loading
Loading