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 packages/inquirerer-test/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<RunCliResult>
```

| 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
139 changes: 139 additions & 0 deletions packages/inquirerer-test/__tests__/subprocess.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
4 changes: 4 additions & 0 deletions packages/inquirerer-test/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Loading
Loading