runspec-node 0.27.0 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.d.ts +22 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +143 -0
- package/dist/cli.js.map +1 -1
- package/dist/runspec.toml +15 -0
- package/dist/serve.d.ts +1 -0
- package/dist/serve.d.ts.map +1 -1
- package/dist/serve.js +1 -0
- package/dist/serve.js.map +1 -1
- package/dist/testing.d.ts +67 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +324 -0
- package/dist/testing.js.map +1 -0
- package/package.json +11 -1
- package/src/cli.ts +160 -0
- package/src/runspec.toml +15 -0
- package/src/serve.ts +1 -1
- package/src/testing.ts +334 -0
- package/tests/test_cli_test.test.ts +134 -0
- package/tests/testing.test.ts +132 -0
package/src/testing.ts
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* runspec-node/testing — test helpers for runspec runnables.
|
|
3
|
+
*
|
|
4
|
+
* The Node port of Python's `runspec.testing`. Import it from the subpath
|
|
5
|
+
* export so it stays out of the main entry:
|
|
6
|
+
*
|
|
7
|
+
* import { ParseHarness } from 'runspec-node/testing';
|
|
8
|
+
*
|
|
9
|
+
* `parse()` already takes `argv` and `configPath`, so testing a runnable never
|
|
10
|
+
* requires monkeypatching `process.argv` or mocking the parser. `ParseHarness`
|
|
11
|
+
* removes the remaining boilerplate — pointing `parse()` at a spec, asserting on
|
|
12
|
+
* clean exits without the process actually exiting, and walking the subcommand
|
|
13
|
+
* tree to auto-cover every command.
|
|
14
|
+
*
|
|
15
|
+
* Node specifics this handles for you: `parse()` calls `process.exit(0)` on
|
|
16
|
+
* `--help` and *throws* `RunSpecError` on validation/require-command errors. The
|
|
17
|
+
* harness intercepts `process.exit` (so `--help` doesn't kill your test runner)
|
|
18
|
+
* and maps a thrown `RunSpecError` to a non-zero "exit", unifying both under one
|
|
19
|
+
* exit-code abstraction that mirrors Python's `SystemExit`-based helpers.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import * as fs from 'fs';
|
|
23
|
+
import * as os from 'os';
|
|
24
|
+
import * as path from 'path';
|
|
25
|
+
import { parse } from './parser';
|
|
26
|
+
import { loadRaw } from './loader';
|
|
27
|
+
import { inferScript } from './inference';
|
|
28
|
+
import { RunSpecError } from './errors';
|
|
29
|
+
import type { ParsedArgs, ScriptSpec, ArgSpec } from './models';
|
|
30
|
+
|
|
31
|
+
export interface ParseHarnessOptions {
|
|
32
|
+
scriptName: string;
|
|
33
|
+
/** An existing runspec.toml (a fixture, or your package's real spec). */
|
|
34
|
+
configPath?: string;
|
|
35
|
+
/** An inline TOML string written to a throwaway temp file. */
|
|
36
|
+
toml?: string;
|
|
37
|
+
/** Pin the per-run summary off during a parse (default true). */
|
|
38
|
+
suppressSummary?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ExpectExitOptions {
|
|
42
|
+
/** Expected exit code; `null` accepts any non-zero. Default 1; use 0 for --help. */
|
|
43
|
+
code?: number | null;
|
|
44
|
+
/** Substring(s) the captured output must include. */
|
|
45
|
+
contains?: string | string[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface SmokeOptions {
|
|
49
|
+
/** Arg-name → value map used to build valid invocations for each leaf. */
|
|
50
|
+
values?: Record<string, unknown>;
|
|
51
|
+
/** Assert `--help` exits 0 for the runnable and every subcommand (default true). */
|
|
52
|
+
checkHelp?: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Thrown in place of `process.exit()` so a parse that exits can be caught. */
|
|
56
|
+
class ExitSignal extends Error {
|
|
57
|
+
constructor(public exitCode: number) {
|
|
58
|
+
super(`process.exit(${exitCode})`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export class ParseHarness {
|
|
63
|
+
readonly scriptName: string;
|
|
64
|
+
readonly configPath: string;
|
|
65
|
+
private readonly suppressSummary: boolean;
|
|
66
|
+
private tmpDir: string | null = null;
|
|
67
|
+
private readonly spec: ScriptSpec;
|
|
68
|
+
|
|
69
|
+
constructor(opts: ParseHarnessOptions) {
|
|
70
|
+
const { scriptName, configPath, toml, suppressSummary = true } = opts;
|
|
71
|
+
if ((configPath == null) === (toml == null)) {
|
|
72
|
+
throw new Error('pass exactly one of configPath or toml');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
this.scriptName = scriptName;
|
|
76
|
+
this.suppressSummary = suppressSummary;
|
|
77
|
+
|
|
78
|
+
let cfgPath = configPath;
|
|
79
|
+
if (toml != null) {
|
|
80
|
+
this.tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'runspec-test-'));
|
|
81
|
+
cfgPath = path.join(this.tmpDir, 'runspec.toml');
|
|
82
|
+
fs.writeFileSync(cfgPath, toml, 'utf-8');
|
|
83
|
+
}
|
|
84
|
+
this.configPath = path.resolve(cfgPath as string);
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
const raw = loadRaw(this.configPath);
|
|
88
|
+
if (!(scriptName in raw.runnables)) {
|
|
89
|
+
const have = Object.keys(raw.runnables).sort().join(', ') || '(none)';
|
|
90
|
+
throw new Error(`runnable '${scriptName}' not in ${this.configPath} (have: ${have})`);
|
|
91
|
+
}
|
|
92
|
+
// Infer so `required` is resolved from defaults/types (raw leaves it unset).
|
|
93
|
+
this.spec = inferScript(raw.runnables[scriptName], raw.config.autonomyDefault);
|
|
94
|
+
} catch (e) {
|
|
95
|
+
// Tear down the temp dir synchronously on a failed construction.
|
|
96
|
+
this.close();
|
|
97
|
+
throw e;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ── lifecycle ─────────────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
close(): void {
|
|
104
|
+
if (this.tmpDir != null) {
|
|
105
|
+
fs.rmSync(this.tmpDir, { recursive: true, force: true });
|
|
106
|
+
this.tmpDir = null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── core parse entry points ───────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
parse(argv: string[]): ParsedArgs {
|
|
113
|
+
const { code, output, parsed } = this.runParse(argv);
|
|
114
|
+
if (parsed === null) {
|
|
115
|
+
throw new Error(`expected \`${this.scriptName} ${argv.join(' ')}\` to parse, but it exited ${code}:\n${output}`);
|
|
116
|
+
}
|
|
117
|
+
return parsed;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
expectOk(argv: string[]): ParsedArgs {
|
|
121
|
+
return this.parse(argv);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
expectExit(argv: string[], opts: ExpectExitOptions = {}): string {
|
|
125
|
+
const { code = 1, contains } = opts;
|
|
126
|
+
const res = this.runParse(argv);
|
|
127
|
+
if (res.code === null) {
|
|
128
|
+
throw new Error(`expected \`${this.scriptName} ${argv.join(' ')}\` to exit, but it parsed cleanly`);
|
|
129
|
+
}
|
|
130
|
+
if (code !== null && res.code !== code) {
|
|
131
|
+
throw new Error(`\`${this.scriptName} ${argv.join(' ')}\`: expected exit ${code}, got ${res.code}:\n${res.output}`);
|
|
132
|
+
}
|
|
133
|
+
if (contains != null) {
|
|
134
|
+
const needles = Array.isArray(contains) ? contains : [contains];
|
|
135
|
+
for (const needle of needles) {
|
|
136
|
+
if (!res.output.includes(needle)) {
|
|
137
|
+
throw new Error(`\`${this.scriptName} ${argv.join(' ')}\`: ${JSON.stringify(needle)} not found in output:\n${res.output}`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return res.output;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ── spec introspection ────────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
commandPaths(): string[][] {
|
|
147
|
+
const paths: string[][] = [];
|
|
148
|
+
const walk = (node: ScriptSpec, prefix: string[]): void => {
|
|
149
|
+
for (const [name, sub] of Object.entries(node.commands ?? {})) {
|
|
150
|
+
const here = [...prefix, name];
|
|
151
|
+
paths.push(here);
|
|
152
|
+
walk(sub, here);
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
walk(this.spec, []);
|
|
156
|
+
return paths;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
leafCommandPaths(): string[][] {
|
|
160
|
+
return this.commandPaths().filter((p) => Object.keys(this.node(p).commands ?? {}).length === 0);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
argsFor(p: string[] = []): Record<string, ArgSpec> {
|
|
164
|
+
return this.node(p).args ?? {};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
requiredArgs(p: string[] = []): string[] {
|
|
168
|
+
return Object.entries(this.argsFor(p))
|
|
169
|
+
.filter(([, a]) => a.required)
|
|
170
|
+
.map(([name]) => name);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
requiresCommand(p: string[] = []): boolean {
|
|
174
|
+
return Boolean(this.node(p).requireCommand);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ── auto coverage ─────────────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
smoke(opts: SmokeOptions = {}): string[] {
|
|
180
|
+
const { values = {}, checkHelp = true } = opts;
|
|
181
|
+
const done: string[] = [];
|
|
182
|
+
|
|
183
|
+
if (checkHelp) {
|
|
184
|
+
this.expectExit(['--help'], { code: 0 });
|
|
185
|
+
done.push('--help');
|
|
186
|
+
}
|
|
187
|
+
if (this.requiresCommand()) {
|
|
188
|
+
this.expectExit([]);
|
|
189
|
+
done.push('(no command) -> require-command');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
for (const p of this.commandPaths()) {
|
|
193
|
+
const label = p.join(' ');
|
|
194
|
+
const node = this.node(p);
|
|
195
|
+
|
|
196
|
+
if (checkHelp) {
|
|
197
|
+
this.expectExit([...p, '--help'], { code: 0 });
|
|
198
|
+
done.push(`${label} --help`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (node.requireCommand) {
|
|
202
|
+
this.expectExit(p);
|
|
203
|
+
done.push(`${label} -> require-command`);
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
if (Object.keys(node.commands ?? {}).length > 0) {
|
|
207
|
+
continue; // intermediate group without require-command
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (this.requiredArgs(p).length > 0) {
|
|
211
|
+
this.expectExit(p);
|
|
212
|
+
done.push(`${label} -> missing required`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const built = this.buildValidArgv(p, values);
|
|
216
|
+
if (built !== null) {
|
|
217
|
+
this.expectOk([...p, ...built]);
|
|
218
|
+
done.push(`${label} (valid)`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return done;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ── internals ─────────────────────────────────────────────────────────────
|
|
226
|
+
|
|
227
|
+
private runParse(argv: string[]): { code: number | null; output: string; parsed: ParsedArgs | null } {
|
|
228
|
+
// Capture the *original* (unbound) handles so restoration preserves identity.
|
|
229
|
+
const realExit = process.exit;
|
|
230
|
+
const realOut = process.stdout.write;
|
|
231
|
+
const realErr = process.stderr.write;
|
|
232
|
+
const realLog = console.log;
|
|
233
|
+
const realErrLog = console.error;
|
|
234
|
+
let captured = '';
|
|
235
|
+
|
|
236
|
+
(process as unknown as { exit: (c?: number) => never }).exit = (c?: number): never => {
|
|
237
|
+
throw new ExitSignal(c ?? 0);
|
|
238
|
+
};
|
|
239
|
+
process.stdout.write = ((chunk: unknown): boolean => {
|
|
240
|
+
captured += String(chunk);
|
|
241
|
+
return true;
|
|
242
|
+
}) as typeof process.stdout.write;
|
|
243
|
+
process.stderr.write = ((chunk: unknown): boolean => {
|
|
244
|
+
captured += String(chunk);
|
|
245
|
+
return true;
|
|
246
|
+
}) as typeof process.stderr.write;
|
|
247
|
+
console.log = (...a: unknown[]): void => {
|
|
248
|
+
captured += a.map(String).join(' ') + '\n';
|
|
249
|
+
};
|
|
250
|
+
console.error = (...a: unknown[]): void => {
|
|
251
|
+
captured += a.map(String).join(' ') + '\n';
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
return this.withSummaryPinned(() => {
|
|
255
|
+
try {
|
|
256
|
+
const parsed = parse({
|
|
257
|
+
scriptName: this.scriptName,
|
|
258
|
+
argv,
|
|
259
|
+
configPath: this.configPath,
|
|
260
|
+
_promptSecrets: false,
|
|
261
|
+
});
|
|
262
|
+
return { code: null, output: captured, parsed };
|
|
263
|
+
} catch (e) {
|
|
264
|
+
if (e instanceof ExitSignal) {
|
|
265
|
+
return { code: e.exitCode, output: captured, parsed: null };
|
|
266
|
+
}
|
|
267
|
+
if (e instanceof RunSpecError) {
|
|
268
|
+
// Validation / require-command throw rather than exit; fold the
|
|
269
|
+
// message into the captured output so `contains:` can match on it.
|
|
270
|
+
return { code: 1, output: `${captured}${e.message}\n`, parsed: null };
|
|
271
|
+
}
|
|
272
|
+
throw e;
|
|
273
|
+
} finally {
|
|
274
|
+
process.exit = realExit;
|
|
275
|
+
process.stdout.write = realOut;
|
|
276
|
+
process.stderr.write = realErr;
|
|
277
|
+
console.log = realLog;
|
|
278
|
+
console.error = realErrLog;
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
private node(p: string[]): ScriptSpec {
|
|
284
|
+
let node = this.spec;
|
|
285
|
+
for (const part of p) node = (node.commands ?? {})[part];
|
|
286
|
+
return node;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
private mergedArgs(p: string[]): Record<string, ArgSpec> {
|
|
290
|
+
const merged: Record<string, ArgSpec> = { ...this.argsFor([]) };
|
|
291
|
+
const acc: string[] = [];
|
|
292
|
+
for (const part of p) {
|
|
293
|
+
acc.push(part);
|
|
294
|
+
Object.assign(merged, this.argsFor(acc));
|
|
295
|
+
}
|
|
296
|
+
return merged;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
private buildValidArgv(p: string[], values: Record<string, unknown>): string[] | null {
|
|
300
|
+
const argv: string[] = [];
|
|
301
|
+
for (const [name, a] of Object.entries(this.mergedArgs(p))) {
|
|
302
|
+
const supplied = values[name];
|
|
303
|
+
if (supplied === undefined || supplied === null) {
|
|
304
|
+
if (a.required) return null;
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
const flag = `--${name}`;
|
|
308
|
+
if (a.type === 'flag') {
|
|
309
|
+
if (supplied) argv.push(flag);
|
|
310
|
+
} else {
|
|
311
|
+
argv.push(flag, String(supplied));
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return argv;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
private withSummaryPinned<T>(fn: () => T): T {
|
|
318
|
+
if (!this.suppressSummary) return fn();
|
|
319
|
+
const prefix = this.scriptName.toUpperCase().replace(/-/g, '_');
|
|
320
|
+
const key = `RUNSPEC_${prefix}_ARG_NO_SUMMARY`;
|
|
321
|
+
const prev = process.env[key];
|
|
322
|
+
process.env[key] = '1';
|
|
323
|
+
try {
|
|
324
|
+
return fn();
|
|
325
|
+
} finally {
|
|
326
|
+
if (prev === undefined) delete process.env[key];
|
|
327
|
+
else process.env[key] = prev;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export function harness(scriptName: string, opts: Omit<ParseHarnessOptions, 'scriptName'> = {}): ParseHarness {
|
|
333
|
+
return new ParseHarness({ scriptName, ...opts });
|
|
334
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as os from 'os';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { cmdTest, testOne } from '../src/cli';
|
|
5
|
+
|
|
6
|
+
const GOOD_TOML = `
|
|
7
|
+
[config]
|
|
8
|
+
autonomy-default = "autonomous"
|
|
9
|
+
|
|
10
|
+
[greet]
|
|
11
|
+
description = "Greet"
|
|
12
|
+
[greet.args]
|
|
13
|
+
name = {type = "str", required = false, default = "world"}
|
|
14
|
+
|
|
15
|
+
[farewell]
|
|
16
|
+
description = "Bye"
|
|
17
|
+
[farewell.args]
|
|
18
|
+
name = {type = "str", required = false, default = "world"}
|
|
19
|
+
`;
|
|
20
|
+
|
|
21
|
+
const cleanups: string[] = [];
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
while (cleanups.length) fs.rmSync(cleanups.pop()!, { recursive: true, force: true });
|
|
24
|
+
jest.restoreAllMocks();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
/** Build a venv-shaped folder: runspec.toml + node_modules/.bin/<name> shims
|
|
28
|
+
* exiting with the given code (a shim of `null` means: don't create it). */
|
|
29
|
+
function folder(toml: string, shims: Record<string, number | null>): string {
|
|
30
|
+
const dir = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'runspec-test-'));
|
|
31
|
+
cleanups.push(dir);
|
|
32
|
+
fs.writeFileSync(path.join(dir, 'runspec.toml'), toml);
|
|
33
|
+
const binDir = path.join(dir, 'node_modules', '.bin');
|
|
34
|
+
fs.mkdirSync(binDir, { recursive: true });
|
|
35
|
+
for (const [name, code] of Object.entries(shims)) {
|
|
36
|
+
if (code === null) continue;
|
|
37
|
+
const p = path.join(binDir, name);
|
|
38
|
+
fs.writeFileSync(p, `#!/bin/sh\nexit ${code}\n`);
|
|
39
|
+
fs.chmodSync(p, 0o755);
|
|
40
|
+
}
|
|
41
|
+
return dir;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function item(dir: string, runnable: string) {
|
|
45
|
+
return { source: path.join(dir, 'runspec.toml'), runnable, spec: {} as any };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── testOne (the per-runnable unit) ─────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
test('passes when spec smoke ok and entry point exits 0', () => {
|
|
51
|
+
const dir = folder(GOOD_TOML, { greet: 0 });
|
|
52
|
+
const r = testOne(item(dir, 'greet'));
|
|
53
|
+
expect(r.ok).toBe(true);
|
|
54
|
+
expect(r.checks.spec_smoke.ok).toBe(true);
|
|
55
|
+
expect(r.checks.entry_point.ok).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('fails when the entry-point binary exits non-zero', () => {
|
|
59
|
+
const dir = folder(GOOD_TOML, { greet: 1 });
|
|
60
|
+
const r = testOne(item(dir, 'greet'));
|
|
61
|
+
expect(r.ok).toBe(false);
|
|
62
|
+
expect(r.checks.spec_smoke.ok).toBe(true); // spec is fine
|
|
63
|
+
expect(r.checks.entry_point.ok).toBe(false);
|
|
64
|
+
expect(r.checks.entry_point.error).toContain('exit 1');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('fails when the entry point is missing', () => {
|
|
68
|
+
const dir = folder(GOOD_TOML, { greet: null });
|
|
69
|
+
const r = testOne(item(dir, 'greet'));
|
|
70
|
+
expect(r.ok).toBe(false);
|
|
71
|
+
expect(r.checks.entry_point.error).toContain('entry point not found');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('fails spec smoke when the runnable is not in the TOML', () => {
|
|
75
|
+
const dir = folder(GOOD_TOML, { ghost: 0 });
|
|
76
|
+
const r = testOne(item(dir, 'ghost'));
|
|
77
|
+
expect(r.ok).toBe(false);
|
|
78
|
+
expect(r.checks.spec_smoke.ok).toBe(false);
|
|
79
|
+
expect(r.checks.spec_smoke.error).toContain('ghost');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// ── cmdTest (orchestration: exit code, filter, json) ────────────────────────
|
|
83
|
+
|
|
84
|
+
function runCmd(dir: string, args: string[]): { exitCode: number | undefined; out: string } {
|
|
85
|
+
const cwd = process.cwd();
|
|
86
|
+
let exitCode: number | undefined;
|
|
87
|
+
const exitSpy = jest.spyOn(process, 'exit').mockImplementation(((c?: number) => {
|
|
88
|
+
exitCode = c;
|
|
89
|
+
return undefined as never;
|
|
90
|
+
}) as never);
|
|
91
|
+
let out = '';
|
|
92
|
+
const logSpy = jest.spyOn(console, 'log').mockImplementation((...a: unknown[]) => {
|
|
93
|
+
out += a.map(String).join(' ') + '\n';
|
|
94
|
+
});
|
|
95
|
+
try {
|
|
96
|
+
process.chdir(dir);
|
|
97
|
+
cmdTest(args);
|
|
98
|
+
} finally {
|
|
99
|
+
process.chdir(cwd);
|
|
100
|
+
logSpy.mockRestore();
|
|
101
|
+
exitSpy.mockRestore();
|
|
102
|
+
}
|
|
103
|
+
return { exitCode, out };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
test('all pass → no exit-1, summary reports passed', () => {
|
|
107
|
+
const dir = folder(GOOD_TOML, { greet: 0, farewell: 0 });
|
|
108
|
+
const { exitCode, out } = runCmd(dir, []);
|
|
109
|
+
expect(exitCode).toBeUndefined();
|
|
110
|
+
expect(out).toContain('2 passed, 0 failed');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('one broken runnable → exit 1', () => {
|
|
114
|
+
const dir = folder(GOOD_TOML, { greet: 1, farewell: 0 });
|
|
115
|
+
const { exitCode, out } = runCmd(dir, []);
|
|
116
|
+
expect(exitCode).toBe(1);
|
|
117
|
+
expect(out).toContain('1 passed, 1 failed');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('--runnable filters to a single runnable', () => {
|
|
121
|
+
const dir = folder(GOOD_TOML, { greet: 0, farewell: 0 });
|
|
122
|
+
const { out } = runCmd(dir, ['--runnable', 'greet']);
|
|
123
|
+
expect(out).toContain('1 passed');
|
|
124
|
+
expect(out).not.toContain('farewell');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('--format json emits results + summary', () => {
|
|
128
|
+
const dir = folder(GOOD_TOML, { greet: 0, farewell: 0 });
|
|
129
|
+
const { out } = runCmd(dir, ['--format', 'json']);
|
|
130
|
+
const payload = JSON.parse(out);
|
|
131
|
+
expect(payload.summary).toEqual({ total: 2, passed: 2, failed: 0 });
|
|
132
|
+
expect(payload.results).toHaveLength(2);
|
|
133
|
+
expect(Object.keys(payload.results[0].checks)).toEqual(['spec_smoke', 'entry_point']);
|
|
134
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { ParseHarness, harness } from '../src/testing';
|
|
2
|
+
|
|
3
|
+
// A spec with a required subcommand, a leaf with a required arg + a flag, and a
|
|
4
|
+
// leaf with only optional args — enough to exercise the whole smoke() walk.
|
|
5
|
+
const SPEC = `
|
|
6
|
+
[config]
|
|
7
|
+
autonomy-default = "autonomous"
|
|
8
|
+
|
|
9
|
+
[tool]
|
|
10
|
+
description = "A tool with subcommands"
|
|
11
|
+
require-command = true
|
|
12
|
+
|
|
13
|
+
[tool.commands.create]
|
|
14
|
+
description = "Create a thing"
|
|
15
|
+
[tool.commands.create.args]
|
|
16
|
+
name = {type = "str", required = true}
|
|
17
|
+
force = {type = "flag", default = false}
|
|
18
|
+
|
|
19
|
+
[tool.commands.show]
|
|
20
|
+
description = "Show a thing"
|
|
21
|
+
[tool.commands.show.args]
|
|
22
|
+
id = {type = "str", required = false, default = "x"}
|
|
23
|
+
`;
|
|
24
|
+
|
|
25
|
+
function h(): ParseHarness {
|
|
26
|
+
return new ParseHarness({ scriptName: 'tool', toml: SPEC });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ── core parse entry points ────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
test('expectExit(--help) returns 0 WITHOUT killing the process', () => {
|
|
32
|
+
const harn = h();
|
|
33
|
+
// If process.exit weren't intercepted, this would terminate the jest worker.
|
|
34
|
+
const out = harn.expectExit(['create', '--help'], { code: 0 });
|
|
35
|
+
expect(out).toContain('Create a thing');
|
|
36
|
+
harn.close();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('require-command: empty argv exits non-zero (RunSpecError mapped to code 1)', () => {
|
|
40
|
+
const harn = h();
|
|
41
|
+
harn.expectExit([]); // default code = 1
|
|
42
|
+
harn.close();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('missing required arg exits 1 and names the flag', () => {
|
|
46
|
+
const harn = h();
|
|
47
|
+
const out = harn.expectExit(['create'], { code: 1, contains: 'name' });
|
|
48
|
+
expect(out).toContain('name');
|
|
49
|
+
harn.close();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('expectOk returns the parsed args with coercion applied', () => {
|
|
53
|
+
const harn = h();
|
|
54
|
+
const parsed = harn.expectOk(['create', '--name', 'widget', '--force']);
|
|
55
|
+
expect(String(parsed.name)).toBe('widget');
|
|
56
|
+
expect(parsed.force).toBe(true);
|
|
57
|
+
harn.close();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// ── smoke() auto-coverage ───────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
test('smoke walks the whole tree and reports the checks performed', () => {
|
|
63
|
+
const harn = h();
|
|
64
|
+
const done = harn.smoke({ values: { name: 'widget' } });
|
|
65
|
+
expect(done).toContain('--help');
|
|
66
|
+
expect(done).toContain('(no command) -> require-command');
|
|
67
|
+
expect(done).toContain('create --help');
|
|
68
|
+
expect(done).toContain('create -> missing required');
|
|
69
|
+
expect(done).toContain('create (valid)');
|
|
70
|
+
expect(done).toContain('show (valid)');
|
|
71
|
+
harn.close();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('smoke without values still does the structural checks', () => {
|
|
75
|
+
const harn = h();
|
|
76
|
+
const done = harn.smoke();
|
|
77
|
+
expect(done).toContain('--help');
|
|
78
|
+
expect(done).toContain('create -> missing required');
|
|
79
|
+
// show has no required args, so it still gets a (valid) check with empty argv
|
|
80
|
+
expect(done).toContain('show (valid)');
|
|
81
|
+
harn.close();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// ── introspection ───────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
test('command/leaf paths and arg introspection', () => {
|
|
87
|
+
const harn = h();
|
|
88
|
+
expect(harn.commandPaths()).toEqual([['create'], ['show']]);
|
|
89
|
+
expect(harn.leafCommandPaths()).toEqual([['create'], ['show']]);
|
|
90
|
+
expect(harn.requiredArgs(['create'])).toEqual(['name']);
|
|
91
|
+
expect(harn.requiredArgs(['show'])).toEqual([]);
|
|
92
|
+
expect(harn.requiresCommand()).toBe(true);
|
|
93
|
+
expect(Object.keys(harn.argsFor(['create']))).toEqual(['name', 'force']);
|
|
94
|
+
harn.close();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// ── lifecycle / API guards ──────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
test('factory helper mirrors the constructor', () => {
|
|
100
|
+
const harn = harness('tool', { toml: SPEC });
|
|
101
|
+
expect(harn.scriptName).toBe('tool');
|
|
102
|
+
harn.close();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('rejects passing both config and toml', () => {
|
|
106
|
+
expect(() => new ParseHarness({ scriptName: 'tool', toml: SPEC, configPath: '/x/runspec.toml' })).toThrow(/exactly one/);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('rejects passing neither config nor toml', () => {
|
|
110
|
+
expect(() => new ParseHarness({ scriptName: 'tool' })).toThrow(/exactly one/);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('unknown runnable throws with the available names', () => {
|
|
114
|
+
expect(() => new ParseHarness({ scriptName: 'ghost', toml: SPEC })).toThrow(/ghost/);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// ── interception is fully restored ──────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
test('process.exit / stdout.write / console.log are restored after a run', () => {
|
|
120
|
+
const harn = h();
|
|
121
|
+
const exitBefore = process.exit;
|
|
122
|
+
const stdoutBefore = process.stdout.write;
|
|
123
|
+
const logBefore = console.log;
|
|
124
|
+
|
|
125
|
+
harn.expectExit(['--help'], { code: 0 });
|
|
126
|
+
harn.expectExit(['create']); // also exercise the throw path
|
|
127
|
+
|
|
128
|
+
expect(process.exit).toBe(exitBefore);
|
|
129
|
+
expect(process.stdout.write).toBe(stdoutBefore);
|
|
130
|
+
expect(console.log).toBe(logBefore);
|
|
131
|
+
harn.close();
|
|
132
|
+
});
|