From fc5b836cbf120a4e436facc8b70e965a7f92bd6f Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Sun, 26 Apr 2026 21:45:32 +0000 Subject: [PATCH] feat(inquirerer-test): add runCli subprocess helper + Testing docs in inquirerer Adds a runCli/parseArgString helper to @inquirerer/test for end-to-end CLI subprocess tests, alongside the existing in-process Inquirerer harness. Defaults match common Jest expectations: rejects on non-zero exit (with captured stdout/stderr/exitCode attached to RunCliError), inherits the parent env unless overridden, 30s default timeout, configurable kill signal, and an opt-out (`reject: false`) for inspecting failure cases. Also adds a Testing section to inquirerer's main README pointing at @inquirerer/test so the helper is discoverable from the consumer side. --- packages/inquirerer-test/README.md | 79 +++++ .../__tests__/subprocess.test.ts | 139 +++++++++ packages/inquirerer-test/src/index.ts | 4 + packages/inquirerer-test/src/subprocess.ts | 271 ++++++++++++++++++ packages/inquirerer/README.md | 46 +++ 5 files changed, 539 insertions(+) create mode 100644 packages/inquirerer-test/__tests__/subprocess.test.ts create mode 100644 packages/inquirerer-test/src/subprocess.ts diff --git a/packages/inquirerer-test/README.md b/packages/inquirerer-test/README.md index 9419d84..101363d 100644 --- a/packages/inquirerer-test/README.md +++ b/packages/inquirerer-test/README.md @@ -114,6 +114,85 @@ The `createTestEnvironment()` function returns a `TestEnvironment` object with: | `getOutput` | `() => string` | Get all captured output | | `clearOutput` | `() => void` | Clear captured output | +## Subprocess Testing (CLI E2E) + +Some CLI tests need to exercise the actual built executable rather than the +in-process `Inquirerer` class — for example, you want to verify exit codes, +shebang resolution, or that the binary works against a real HTTP server. For +those, use `runCli`. + +```typescript +import { runCli, parseArgString } from '@inquirerer/test'; + +const CLI_ENTRY = require.resolve('../src/index.ts'); + +it('search returns results', async () => { + const { stdout, exitCode } = await runCli('node', [CLI_ENTRY, 'search', 'hello']); + expect(exitCode).toBe(0); + expect(stdout).toContain('1 result'); +}); +``` + +### Shell-string args + +If you'd prefer to write args as a single string (handy for table-driven +tests), use `parseArgString` — it splits on whitespace while respecting +single- and double-quoted segments. It does **not** interpret shell +features (no globbing, no env expansion, no escapes). + +```typescript +const { stdout } = await runCli( + 'node', + parseArgString(`${CLI_ENTRY} search "hello world" --json`) +); +``` + +### Inspecting failures + +By default `runCli` rejects with a `RunCliError` on non-zero exit, with the +captured `stdout` / `stderr` / `exitCode` attached. Pass `reject: false` to +resolve regardless of exit status: + +```typescript +const result = await runCli(BIN, ['bad-command'], { reject: false }); +expect(result.exitCode).toBe(1); +expect(result.stderr).toContain('Unknown command'); +``` + +### Custom environment (e.g. `tsx` inside Jest) + +When launching `tsx` / `ts-node` from a Jest worker, clear `NODE_OPTIONS` +to avoid Jest's instrumentation leaking into the child: + +```typescript +await runCli(TSX_BIN, [CLI_ENTRY, 'init'], { + cwd: REPO_ROOT, + env: { + ...process.env, + HOME: testHome, + NODE_OPTIONS: '', + }, + timeout: 120_000, +}); +``` + +### `runCli` API + +```typescript +runCli(bin: string, args: string[], options?: RunCliOptions): Promise +``` + +| Option | Type | Default | Description | +|---|---|---|---| +| `cwd` | `string` | parent | Working directory for the child | +| `env` | `NodeJS.ProcessEnv` | parent | Environment vars (replaces parent env when set — spread `process.env` to extend) | +| `timeout` | `number` | `30_000` | Max ms before the child is killed | +| `killSignal` | `NodeJS.Signals` | `'SIGKILL'` | Signal used on timeout | +| `stdin` | `string` | — | Optional string written to the child's stdin | +| `reject` | `boolean` | `true` | Reject on non-zero exit; set `false` to inspect failures | + +`RunCliResult` exposes `stdout`, `stderr`, `exitCode`, `signal`, `timedOut`, and `command`. `RunCliError` (thrown on non-zero exit or timeout) carries the same fields. + ## License MIT diff --git a/packages/inquirerer-test/__tests__/subprocess.test.ts b/packages/inquirerer-test/__tests__/subprocess.test.ts new file mode 100644 index 0000000..8c28008 --- /dev/null +++ b/packages/inquirerer-test/__tests__/subprocess.test.ts @@ -0,0 +1,139 @@ +import { parseArgString, runCli, RunCliError } from '../src/subprocess'; + +describe('parseArgString', () => { + it('splits unquoted args on whitespace', () => { + expect(parseArgString('search hello world')).toEqual([ + 'search', + 'hello', + 'world', + ]); + }); + + it('respects double-quoted segments', () => { + expect(parseArgString('search "hello world" --json')).toEqual([ + 'search', + 'hello world', + '--json', + ]); + }); + + it('respects single-quoted segments', () => { + expect(parseArgString("ask 'what is going on' --verbose")).toEqual([ + 'ask', + 'what is going on', + '--verbose', + ]); + }); + + it('collapses runs of whitespace', () => { + expect(parseArgString(' a b\tc ')).toEqual(['a', 'b', 'c']); + }); + + it('returns an empty array for an empty / whitespace-only string', () => { + expect(parseArgString('')).toEqual([]); + expect(parseArgString(' \t ')).toEqual([]); + }); +}); + +describe('runCli', () => { + const node = process.execPath; + + it('captures stdout and stderr from a successful exit', async () => { + const { stdout, stderr, exitCode, signal, timedOut } = await runCli(node, [ + '-e', + 'process.stdout.write("hello\\n"); process.stderr.write("warn\\n");', + ]); + + expect(stdout).toBe('hello\n'); + expect(stderr).toBe('warn\n'); + expect(exitCode).toBe(0); + expect(signal).toBeNull(); + expect(timedOut).toBe(false); + }); + + it('rejects with RunCliError on non-zero exit by default', async () => { + await expect( + runCli(node, ['-e', 'process.stderr.write("boom"); process.exit(2);']) + ).rejects.toMatchObject({ + name: 'RunCliError', + exitCode: 2, + stderr: 'boom', + }); + }); + + it('attaches captured streams to the rejection error', async () => { + let caught: RunCliError | undefined; + try { + await runCli(node, [ + '-e', + 'process.stdout.write("partial output\\n"); process.exit(1);', + ]); + } catch (err) { + caught = err as RunCliError; + } + expect(caught).toBeInstanceOf(RunCliError); + expect(caught!.stdout).toBe('partial output\n'); + expect(caught!.exitCode).toBe(1); + }); + + it('with reject:false, resolves on non-zero exit', async () => { + const result = await runCli( + node, + ['-e', 'process.stderr.write("bad"); process.exit(7);'], + { reject: false } + ); + + expect(result.exitCode).toBe(7); + expect(result.stderr).toBe('bad'); + expect(result.timedOut).toBe(false); + }); + + it('writes options.stdin to the child', async () => { + const { stdout } = await runCli( + node, + [ + '-e', + 'let d=""; process.stdin.on("data",c=>d+=c); process.stdin.on("end",()=>process.stdout.write(d));', + ], + { stdin: 'piped input' } + ); + expect(stdout).toBe('piped input'); + }); + + it('honours options.cwd', async () => { + const { stdout } = await runCli(node, [ + '-e', + 'process.stdout.write(process.cwd());', + ], { cwd: '/' }); + expect(stdout).toBe('/'); + }); + + it('honours options.env (replaces parent env)', async () => { + const { stdout } = await runCli( + node, + ['-e', 'process.stdout.write(process.env.RUN_CLI_TEST_VAR ?? "missing");'], + { env: { ...process.env, RUN_CLI_TEST_VAR: 'visible' } } + ); + expect(stdout).toBe('visible'); + }); + + it('rejects with timedOut=true when the subprocess exceeds the timeout', async () => { + let caught: RunCliError | undefined; + try { + await runCli(node, ['-e', 'setTimeout(() => {}, 60000);'], { + timeout: 100, + }); + } catch (err) { + caught = err as RunCliError; + } + expect(caught).toBeInstanceOf(RunCliError); + expect(caught!.timedOut).toBe(true); + expect(caught!.message).toMatch(/timed out after 100ms/); + }); + + it('rejects on spawn error (binary not found)', async () => { + await expect( + runCli('/this/binary/does/not/exist', ['arg']) + ).rejects.toThrow(); + }); +}); diff --git a/packages/inquirerer-test/src/index.ts b/packages/inquirerer-test/src/index.ts index fcc7b84..91b269b 100644 --- a/packages/inquirerer-test/src/index.ts +++ b/packages/inquirerer-test/src/index.ts @@ -14,5 +14,9 @@ export type { TestFixture, TestFixtureOptions, RunCmdResult } from './fixture'; export { normalizePackageJsonForSnapshot } from './snapshot'; export type { NormalizeOptions } from './snapshot'; +// Subprocess testing for CLI E2E tests +export { runCli, parseArgString, RunCliError } from './subprocess'; +export type { RunCliOptions, RunCliResult } from './subprocess'; + // ANSI utilities (re-exported from clean-ansi for convenience) export { cleanAnsi } from 'clean-ansi'; diff --git a/packages/inquirerer-test/src/subprocess.ts b/packages/inquirerer-test/src/subprocess.ts new file mode 100644 index 0000000..5c99106 --- /dev/null +++ b/packages/inquirerer-test/src/subprocess.ts @@ -0,0 +1,271 @@ +import { ChildProcess, spawn, SpawnOptions } from 'child_process'; + +/** + * Result of running a CLI subprocess. + */ +export interface RunCliResult { + /** Captured stdout (utf-8 decoded). */ + stdout: string; + /** Captured stderr (utf-8 decoded). */ + stderr: string; + /** Exit code, or `null` if the process was killed by a signal. */ + exitCode: number | null; + /** Signal that terminated the process, if any. */ + signal: NodeJS.Signals | null; + /** True iff the process was killed because it exceeded `options.timeout`. */ + timedOut: boolean; + /** The command that was executed (binary + args). */ + command: string; +} + +/** + * An error thrown when a CLI subprocess exits with a non-zero status, times out, + * or fails to spawn. The error retains the full {@link RunCliResult} so test + * assertions can inspect captured output. + */ +export class RunCliError extends Error { + readonly stdout: string; + readonly stderr: string; + readonly exitCode: number | null; + readonly signal: NodeJS.Signals | null; + readonly timedOut: boolean; + readonly command: string; + + constructor(message: string, result: RunCliResult) { + super(message); + this.name = 'RunCliError'; + this.stdout = result.stdout; + this.stderr = result.stderr; + this.exitCode = result.exitCode; + this.signal = result.signal; + this.timedOut = result.timedOut; + this.command = result.command; + } +} + +/** + * Options for {@link runCli}. + */ +export interface RunCliOptions { + /** Working directory for the child process. */ + cwd?: string; + /** + * Environment variables for the child. If omitted, inherits from the parent. + * If provided, replaces the inherited environment entirely. To extend the + * parent's env, spread `process.env` yourself (and remember to clear + * Jest-specific vars like `NODE_OPTIONS` if launching `tsx` / `ts-node`). + */ + env?: NodeJS.ProcessEnv; + /** Maximum time (ms) the subprocess may run before being killed. Default: 30_000. */ + timeout?: number; + /** Signal used to kill the child on timeout. Default: `'SIGKILL'`. */ + killSignal?: NodeJS.Signals; + /** Optional string to write to the child's stdin before closing it. */ + stdin?: string; + /** + * If `true` (default), reject with {@link RunCliError} on non-zero exit, + * timeout, or spawn error. If `false`, resolve with the {@link RunCliResult} + * regardless of exit status (only spawn errors and timeouts still reject — + * but see `reject: 'spawn'` to opt out of that too). + */ + reject?: boolean; +} + +/** + * Parse a shell-like argument string into an argv array, respecting single + * and double quoted segments. No shell features (globbing, variable + * expansion, escapes) — just whitespace splitting with quote awareness. + * + * @example + * parseArgString(`search "hello world" --json`) + * // → ['search', 'hello world', '--json'] + */ +export function parseArgString(args: string): string[] { + const result: string[] = []; + let current = ''; + let inQuote = false; + let quoteChar = ''; + for (const ch of args) { + if (inQuote) { + if (ch === quoteChar) { + inQuote = false; + } else { + current += ch; + } + } else if (ch === '"' || ch === "'") { + inQuote = true; + quoteChar = ch; + } else if (/\s/.test(ch)) { + if (current) { + result.push(current); + current = ''; + } + } else { + current += ch; + } + } + if (current) result.push(current); + return result; +} + +const DEFAULT_TIMEOUT_MS = 30_000; + +/** + * Run a CLI binary as a child process and capture its stdout/stderr. Designed + * for end-to-end CLI tests where you want to exercise the actual built + * executable rather than the in-process `Inquirerer` class. + * + * Defaults match common Jest expectations: rejects on non-zero exit or + * timeout (with the captured streams attached to the error), inherits the + * parent environment unless `options.env` is provided, and uses a 30s + * timeout. Set `reject: false` to inspect failure cases without `try/catch`. + * + * @example In-line args + * ```ts + * import { runCli } from '@inquirerer/test'; + * + * const { stdout } = await runCli('node', [CLI_ENTRY, 'search', 'hello world']); + * expect(stdout).toContain('1 result'); + * ``` + * + * @example Shell-string args via {@link parseArgString} + * ```ts + * const { stdout } = await runCli('node', parseArgString(`${CLI_ENTRY} search "hello world" --json`)); + * ``` + * + * @example Inspecting failures + * ```ts + * const result = await runCli(BIN, ['bad-command'], { reject: false }); + * expect(result.exitCode).toBe(1); + * expect(result.stderr).toContain('Unknown command'); + * ``` + * + * @example Custom environment (e.g. running `tsx` inside Jest) + * ```ts + * await runCli(TSX_BIN, [CLI_ENTRY, 'init'], { + * cwd: REPO_ROOT, + * env: { + * ...process.env, + * HOME: testHome, + * // Clear Jest-inherited NODE_OPTIONS that may conflict with tsx + * NODE_OPTIONS: '', + * }, + * timeout: 120_000, + * }); + * ``` + */ +export function runCli( + bin: string, + args: string[], + options: RunCliOptions = {} +): Promise { + const { + cwd, + env, + timeout = DEFAULT_TIMEOUT_MS, + killSignal = 'SIGKILL', + stdin, + reject: shouldReject = true, + } = options; + + const command = [bin, ...args].join(' '); + + return new Promise((resolve, reject) => { + const spawnOptions: SpawnOptions = { + stdio: ['pipe', 'pipe', 'pipe'], + }; + if (cwd !== undefined) spawnOptions.cwd = cwd; + if (env !== undefined) spawnOptions.env = env; + + let child: ChildProcess; + try { + child = spawn(bin, args, spawnOptions); + } catch (err) { + reject(err); + return; + } + + let stdout = ''; + let stderr = ''; + let timedOut = false; + let settled = false; + + child.stdout?.setEncoding('utf8'); + child.stderr?.setEncoding('utf8'); + child.stdout?.on('data', (chunk: string) => { stdout += chunk; }); + child.stderr?.on('data', (chunk: string) => { stderr += chunk; }); + + const timer = setTimeout(() => { + timedOut = true; + child.kill(killSignal); + }, timeout); + // Don't keep the event loop alive just for this timer. + timer.unref?.(); + + if (stdin !== undefined && child.stdin) { + child.stdin.end(stdin); + } else if (child.stdin) { + child.stdin.end(); + } + + const finish = (result: RunCliResult, error?: Error) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (error) { + reject(error); + } else { + resolve(result); + } + }; + + child.on('error', (err) => { + finish( + { + stdout, + stderr, + exitCode: null, + signal: null, + timedOut, + command, + }, + err + ); + }); + + child.on('close', (code, signal) => { + const result: RunCliResult = { + stdout, + stderr, + exitCode: code, + signal, + timedOut, + command, + }; + + if (timedOut) { + finish( + result, + new RunCliError( + `Command "${command}" timed out after ${timeout}ms`, + result + ) + ); + return; + } + + if (shouldReject && code !== 0) { + finish( + result, + new RunCliError( + `Command "${command}" exited with code ${code}${signal ? ` (signal ${signal})` : ''}\nstdout: ${stdout}\nstderr: ${stderr}`, + result + ) + ); + return; + } + + finish(result); + }); + }); +} diff --git a/packages/inquirerer/README.md b/packages/inquirerer/README.md index 3c76d98..0679bf2 100644 --- a/packages/inquirerer/README.md +++ b/packages/inquirerer/README.md @@ -73,6 +73,7 @@ npm install inquirerer - [Progress Bar](#progress-bar) - [Streaming Text](#streaming-text) - [Custom UI with UIEngine](#custom-ui-with-uiengine) +- [Testing](#testing) - [Developing](#developing) ## Quick Start @@ -1642,6 +1643,51 @@ await engine.run({ }); ``` +## Testing + +For testing CLI applications built with `inquirerer`, use the companion +package [`@inquirerer/test`](https://www.npmjs.com/package/@inquirerer/test) +(also in this repo). It provides everything you need to mock stdin/stdout, +queue keypresses and readline responses, capture and snapshot output, and +even drive the built executable as a subprocess for end-to-end tests — +without you having to hand-roll any of that scaffolding. + +```bash +npm install --save-dev @inquirerer/test +``` + +**In-process testing** (mock streams): + +```typescript +import { createTestEnvironment, KEY_SEQUENCES } from '@inquirerer/test'; +import { Inquirerer } from 'inquirerer'; + +const env = createTestEnvironment(); +env.sendKey(KEY_SEQUENCES.ENTER); + +const prompter = new Inquirerer(env.options); +const result = await prompter.prompt({}, [ + { name: 'confirm', type: 'confirm', message: 'Continue?' } +]); + +expect(result.confirm).toBe(true); +expect(env.getOutput()).toContain('Continue?'); +``` + +**Subprocess (E2E) testing** — drive the actual built CLI: + +```typescript +import { runCli } from '@inquirerer/test'; + +const { stdout, exitCode } = await runCli('node', [CLI_ENTRY, 'search', 'hello']); +expect(exitCode).toBe(0); +expect(stdout).toContain('1 result'); +``` + +See the [@inquirerer/test README](https://github.com/constructive-io/dev-utils/tree/main/packages/inquirerer-test#readme) for the full API, including `createTestFixture` (temp-dir + commands runner), key-sequence constants, and snapshot normalisers. + +## Developing + **Run the demos:** ```bash