diff --git a/tests/fixtures/parity/README.md b/tests/fixtures/parity/README.md new file mode 100644 index 0000000..728dfd6 --- /dev/null +++ b/tests/fixtures/parity/README.md @@ -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, 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`. diff --git a/tests/fixtures/parity/screens.json b/tests/fixtures/parity/screens.json new file mode 100644 index 0000000..b74b028 --- /dev/null +++ b/tests/fixtures/parity/screens.json @@ -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 + } + } + ] +} diff --git a/tests/fixtures/parity/shapes.json b/tests/fixtures/parity/shapes.json new file mode 100644 index 0000000..45a913d --- /dev/null +++ b/tests/fixtures/parity/shapes.json @@ -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, 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 } + } + } + } + ] +} diff --git a/tests/parity-fixtures.test.ts b/tests/parity-fixtures.test.ts new file mode 100644 index 0000000..f5dd32e --- /dev/null +++ b/tests/parity-fixtures.test.ts @@ -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; + 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 = {}, +): Promise { + 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); + } +}); diff --git a/tests/parity-shapes.test.ts b/tests/parity-shapes.test.ts new file mode 100644 index 0000000..df746ea --- /dev/null +++ b/tests/parity-shapes.test.ts @@ -0,0 +1,124 @@ +// PARITY DRIVE — Round 2: shared JSON-SHAPE fixtures (companion to +// parity-fixtures.test.ts / screens.json). +// +// tests/fixtures/parity/shapes.json is the canonical source both suites assert. +// Node OWNS it; pty-rust mirrors byte-identical. Unlike the plain-screen +// fixtures (exact bytes), these assert machine-readable output FIELD-BY-FIELD +// per policy: {exact:v} | {type:'number'|'string'} | {omitWhenUnset:true}. + +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 { 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 shapesPath = path.join(__dirname, "fixtures", "parity", "shapes.json"); +const shapes = JSON.parse(fs.readFileSync(shapesPath, "utf-8")) as { + version: number; + fixtures: any[]; +}; + +const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pty-shapes-")); +afterAll(() => { + fs.rmSync(testRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); +}); + +let sessionDirs: string[] = []; +function makeSessionDir(): string { + const dir = fs.mkdtempSync(path.join(testRoot, "d-")); + sessionDirs.push(dir); + return dir; +} + +function runCli(dir: string, args: string[], env: Record = {}) { + return spawnSync(nodeBin, [cliPath, ...args], { + env: { ...process.env, PTY_SESSION_DIR: dir, ...env }, + encoding: "utf-8", + timeout: 15000, + }); +} + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +afterEach(() => { + for (const dir of sessionDirs) { + try { + for (const e of fs.readdirSync(dir)) { + if (e.endsWith(".pid")) { + try { process.kill(Number(fs.readFileSync(path.join(dir, e), "utf8").trim()), "SIGTERM"); } catch {} + } + } + } catch {} + } + sessionDirs = []; +}); + +// Assert one field of `entry` against a policy: exact | type | omit-when-unset. +function assertField(entry: any, key: string, policy: any, label: string): void { + if (policy.omitWhenUnset) { + expect(entry[key], `${label}.${key} should be omitted when unset`).toBeUndefined(); + } else if ("exact" in policy) { + expect(entry[key], `${label}.${key} exact`).toStrictEqual(policy.exact); + } else if (policy.type) { + expect(entry[key], `${label}.${key} present`).not.toBeNull(); + expect(typeof entry[key], `${label}.${key} type`).toBe(policy.type); + } else { + throw new Error(`no assertable policy for ${label}.${key}`); + } +} + +describe("parity R2: shared JSON-shape fixtures (node reproduces the canonical shapes)", () => { + it("the shapes file is present and versioned", () => { + expect(shapes.version).toBe(1); + expect(shapes.fixtures.length).toBeGreaterThan(0); + }); + + for (const fx of shapes.fixtures) { + if (fx.kind === "ls-json-shape") { + it(`shape "${fx.id}": ls --json entry shape (running + exited)`, async () => { + const dir = makeSessionDir(); + for (const sess of fx.sessions) { + const r = runCli(dir, sess.run, fx.env ?? {}); + expect(r.status, `run ${sess.id} failed: ${r.stderr}`).toBe(0); + } + await sleep(fx.settleMs); + + const list = JSON.parse(runCli(dir, ["list", "--json"]).stdout); + for (const sess of fx.sessions) { + const entry = list.find((e: any) => e.name === sess.expect.name.exact); + expect(entry, `entry ${sess.id} present in ls --json`).toBeDefined(); + if (fx.statusEnum) expect(fx.statusEnum).toContain(entry.status); + for (const [key, policy] of Object.entries(sess.expect)) { + assertField(entry, key, policy, sess.id); + } + } + }, 25000); + continue; + } + + if (fx.kind === "stats-clients") { + it(`shape "${fx.id}": transient peek is not an attached client`, async () => { + const dir = makeSessionDir(); + const r = runCli(dir, fx.run, fx.env ?? {}); + expect(r.status, `run failed: ${r.stderr}`).toBe(0); + await sleep(fx.settleMs); + + const name = fx.run[fx.run.indexOf("--id") + 1]; + if (fx.peekFirst) { + const peek = runCli(dir, ["peek", "--plain", name]); + expect(peek.status).toBe(0); + } + const stats = JSON.parse(runCli(dir, ["stats", "--json", name]).stdout); + for (const [key, policy] of Object.entries(fx.expect.clients)) { + assertField(stats.clients, key, policy, `${fx.id}.clients`); + } + }, 25000); + continue; + } + } +});