Skip to content

Commit cc5cc0b

Browse files
authored
Merge pull request #79 from constructive-io/feat/inquirerer-test-subprocess
feat(inquirerer-test): add runCli subprocess helper + Testing docs
2 parents b1d40ce + fc5b836 commit cc5cc0b

5 files changed

Lines changed: 539 additions & 0 deletions

File tree

‎packages/inquirerer-test/README.md‎

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,85 @@ The `createTestEnvironment()` function returns a `TestEnvironment` object with:
114114
| `getOutput` | `() => string` | Get all captured output |
115115
| `clearOutput` | `() => void` | Clear captured output |
116116

117+
## Subprocess Testing (CLI E2E)
118+
119+
Some CLI tests need to exercise the actual built executable rather than the
120+
in-process `Inquirerer` class — for example, you want to verify exit codes,
121+
shebang resolution, or that the binary works against a real HTTP server. For
122+
those, use `runCli`.
123+
124+
```typescript
125+
import { runCli, parseArgString } from '@inquirerer/test';
126+
127+
const CLI_ENTRY = require.resolve('../src/index.ts');
128+
129+
it('search returns results', async () => {
130+
const { stdout, exitCode } = await runCli('node', [CLI_ENTRY, 'search', 'hello']);
131+
expect(exitCode).toBe(0);
132+
expect(stdout).toContain('1 result');
133+
});
134+
```
135+
136+
### Shell-string args
137+
138+
If you'd prefer to write args as a single string (handy for table-driven
139+
tests), use `parseArgString` — it splits on whitespace while respecting
140+
single- and double-quoted segments. It does **not** interpret shell
141+
features (no globbing, no env expansion, no escapes).
142+
143+
```typescript
144+
const { stdout } = await runCli(
145+
'node',
146+
parseArgString(`${CLI_ENTRY} search "hello world" --json`)
147+
);
148+
```
149+
150+
### Inspecting failures
151+
152+
By default `runCli` rejects with a `RunCliError` on non-zero exit, with the
153+
captured `stdout` / `stderr` / `exitCode` attached. Pass `reject: false` to
154+
resolve regardless of exit status:
155+
156+
```typescript
157+
const result = await runCli(BIN, ['bad-command'], { reject: false });
158+
expect(result.exitCode).toBe(1);
159+
expect(result.stderr).toContain('Unknown command');
160+
```
161+
162+
### Custom environment (e.g. `tsx` inside Jest)
163+
164+
When launching `tsx` / `ts-node` from a Jest worker, clear `NODE_OPTIONS`
165+
to avoid Jest's instrumentation leaking into the child:
166+
167+
```typescript
168+
await runCli(TSX_BIN, [CLI_ENTRY, 'init'], {
169+
cwd: REPO_ROOT,
170+
env: {
171+
...process.env,
172+
HOME: testHome,
173+
NODE_OPTIONS: '',
174+
},
175+
timeout: 120_000,
176+
});
177+
```
178+
179+
### `runCli` API
180+
181+
```typescript
182+
runCli(bin: string, args: string[], options?: RunCliOptions): Promise<RunCliResult>
183+
```
184+
185+
| Option | Type | Default | Description |
186+
|---|---|---|---|
187+
| `cwd` | `string` | parent | Working directory for the child |
188+
| `env` | `NodeJS.ProcessEnv` | parent | Environment vars (replaces parent env when set — spread `process.env` to extend) |
189+
| `timeout` | `number` | `30_000` | Max ms before the child is killed |
190+
| `killSignal` | `NodeJS.Signals` | `'SIGKILL'` | Signal used on timeout |
191+
| `stdin` | `string` | — | Optional string written to the child's stdin |
192+
| `reject` | `boolean` | `true` | Reject on non-zero exit; set `false` to inspect failures |
193+
194+
`RunCliResult` exposes `stdout`, `stderr`, `exitCode`, `signal`, `timedOut`, and `command`. `RunCliError` (thrown on non-zero exit or timeout) carries the same fields.
195+
117196
## License
118197

119198
MIT
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import { parseArgString, runCli, RunCliError } from '../src/subprocess';
2+
3+
describe('parseArgString', () => {
4+
it('splits unquoted args on whitespace', () => {
5+
expect(parseArgString('search hello world')).toEqual([
6+
'search',
7+
'hello',
8+
'world',
9+
]);
10+
});
11+
12+
it('respects double-quoted segments', () => {
13+
expect(parseArgString('search "hello world" --json')).toEqual([
14+
'search',
15+
'hello world',
16+
'--json',
17+
]);
18+
});
19+
20+
it('respects single-quoted segments', () => {
21+
expect(parseArgString("ask 'what is going on' --verbose")).toEqual([
22+
'ask',
23+
'what is going on',
24+
'--verbose',
25+
]);
26+
});
27+
28+
it('collapses runs of whitespace', () => {
29+
expect(parseArgString(' a b\tc ')).toEqual(['a', 'b', 'c']);
30+
});
31+
32+
it('returns an empty array for an empty / whitespace-only string', () => {
33+
expect(parseArgString('')).toEqual([]);
34+
expect(parseArgString(' \t ')).toEqual([]);
35+
});
36+
});
37+
38+
describe('runCli', () => {
39+
const node = process.execPath;
40+
41+
it('captures stdout and stderr from a successful exit', async () => {
42+
const { stdout, stderr, exitCode, signal, timedOut } = await runCli(node, [
43+
'-e',
44+
'process.stdout.write("hello\\n"); process.stderr.write("warn\\n");',
45+
]);
46+
47+
expect(stdout).toBe('hello\n');
48+
expect(stderr).toBe('warn\n');
49+
expect(exitCode).toBe(0);
50+
expect(signal).toBeNull();
51+
expect(timedOut).toBe(false);
52+
});
53+
54+
it('rejects with RunCliError on non-zero exit by default', async () => {
55+
await expect(
56+
runCli(node, ['-e', 'process.stderr.write("boom"); process.exit(2);'])
57+
).rejects.toMatchObject({
58+
name: 'RunCliError',
59+
exitCode: 2,
60+
stderr: 'boom',
61+
});
62+
});
63+
64+
it('attaches captured streams to the rejection error', async () => {
65+
let caught: RunCliError | undefined;
66+
try {
67+
await runCli(node, [
68+
'-e',
69+
'process.stdout.write("partial output\\n"); process.exit(1);',
70+
]);
71+
} catch (err) {
72+
caught = err as RunCliError;
73+
}
74+
expect(caught).toBeInstanceOf(RunCliError);
75+
expect(caught!.stdout).toBe('partial output\n');
76+
expect(caught!.exitCode).toBe(1);
77+
});
78+
79+
it('with reject:false, resolves on non-zero exit', async () => {
80+
const result = await runCli(
81+
node,
82+
['-e', 'process.stderr.write("bad"); process.exit(7);'],
83+
{ reject: false }
84+
);
85+
86+
expect(result.exitCode).toBe(7);
87+
expect(result.stderr).toBe('bad');
88+
expect(result.timedOut).toBe(false);
89+
});
90+
91+
it('writes options.stdin to the child', async () => {
92+
const { stdout } = await runCli(
93+
node,
94+
[
95+
'-e',
96+
'let d=""; process.stdin.on("data",c=>d+=c); process.stdin.on("end",()=>process.stdout.write(d));',
97+
],
98+
{ stdin: 'piped input' }
99+
);
100+
expect(stdout).toBe('piped input');
101+
});
102+
103+
it('honours options.cwd', async () => {
104+
const { stdout } = await runCli(node, [
105+
'-e',
106+
'process.stdout.write(process.cwd());',
107+
], { cwd: '/' });
108+
expect(stdout).toBe('/');
109+
});
110+
111+
it('honours options.env (replaces parent env)', async () => {
112+
const { stdout } = await runCli(
113+
node,
114+
['-e', 'process.stdout.write(process.env.RUN_CLI_TEST_VAR ?? "missing");'],
115+
{ env: { ...process.env, RUN_CLI_TEST_VAR: 'visible' } }
116+
);
117+
expect(stdout).toBe('visible');
118+
});
119+
120+
it('rejects with timedOut=true when the subprocess exceeds the timeout', async () => {
121+
let caught: RunCliError | undefined;
122+
try {
123+
await runCli(node, ['-e', 'setTimeout(() => {}, 60000);'], {
124+
timeout: 100,
125+
});
126+
} catch (err) {
127+
caught = err as RunCliError;
128+
}
129+
expect(caught).toBeInstanceOf(RunCliError);
130+
expect(caught!.timedOut).toBe(true);
131+
expect(caught!.message).toMatch(/timed out after 100ms/);
132+
});
133+
134+
it('rejects on spawn error (binary not found)', async () => {
135+
await expect(
136+
runCli('/this/binary/does/not/exist', ['arg'])
137+
).rejects.toThrow();
138+
});
139+
});

‎packages/inquirerer-test/src/index.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,9 @@ export type { TestFixture, TestFixtureOptions, RunCmdResult } from './fixture';
1414
export { normalizePackageJsonForSnapshot } from './snapshot';
1515
export type { NormalizeOptions } from './snapshot';
1616

17+
// Subprocess testing for CLI E2E tests
18+
export { runCli, parseArgString, RunCliError } from './subprocess';
19+
export type { RunCliOptions, RunCliResult } from './subprocess';
20+
1721
// ANSI utilities (re-exported from clean-ansi for convenience)
1822
export { cleanAnsi } from 'clean-ansi';

0 commit comments

Comments
 (0)