runspec-node 0.27.0 → 0.28.1

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/src/runspec.toml CHANGED
@@ -53,6 +53,21 @@ examples = [
53
53
  format = {type = "choice", description = "Output format", options = ["text", "json", "mcp", "openai", "anthropic"], short = "-f", default = "text"}
54
54
  runnable = {type = "str", description = "Filter output to a single runnable by name", short = "-r", required = false}
55
55
 
56
+ [runspec.commands.test]
57
+ description = "Smoke-test runnables: spec smoke + entry-point --help"
58
+ autonomy = "autonomous"
59
+ output = "json"
60
+
61
+ examples = [
62
+ {cmd = "runspec test", description = "Test every runnable in this folder (CI gate)"},
63
+ {cmd = "runspec test --format json", description = "Machine-readable results for CI"},
64
+ {cmd = "runspec test --runnable deploy", description = "Test a single runnable"},
65
+ ]
66
+
67
+ [runspec.commands.test.args]
68
+ format = {type = "choice", description = "Output format", options = ["text", "json"], short = "-f", default = "text"}
69
+ runnable = {type = "str", description = "Filter to a single runnable by name", short = "-r", required = false}
70
+
56
71
  [runspec.commands.serve]
57
72
  description = "Start an MCP stdio server exposing all installed runnables as tools"
58
73
 
package/src/serve.ts CHANGED
@@ -258,7 +258,7 @@ function argsToRunspecEnv(args: Record<string, unknown>, argSpecs: Record<string
258
258
  return env;
259
259
  }
260
260
 
261
- function findScript(name: string, binDir: string): string | null {
261
+ export function findScript(name: string, binDir: string): string | null {
262
262
  // 1. node_modules/.bin (Node entry points; also .exe on Windows)
263
263
  for (const ext of [...SHELL_EXTS, '.exe']) {
264
264
  const candidate = path.join(binDir, name + ext);
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
+ });
@@ -184,7 +184,9 @@ test('RUNSPEC_MYSCRIPT_ARG_NO_SUMMARY=1 disables summary', () => {
184
184
 
185
185
  // ── stderr line shape ────────────────────────────────────────────────────────
186
186
 
187
- test('success line uses singular "warning" for 1 and plural "errors" for 0', () => {
187
+ test('success line uses abbreviated "warn"/"err" (no "warning"/"error")', () => {
188
+ // Abbreviated levels keep the line from tripping Rundeck's case-insensitive
189
+ // `error` log-highlight filter, which would paint a clean run red.
188
190
  const dir = tmpDir();
189
191
  configureLogging(makeCfg(dir));
190
192
  getLogger('t').warning('one');
@@ -192,11 +194,12 @@ test('success line uses singular "warning" for 1 and plural "errors" for 0', ()
192
194
  emitRunSummary();
193
195
  cap.restore();
194
196
  const joined = cap.lines.join('');
195
- expect(joined).toContain('1 warning,');
196
- expect(joined).toContain('0 errors)');
197
+ expect(joined).toContain('(1 warn, 0 err)');
198
+ expect(joined).not.toContain('warning');
199
+ expect(joined).not.toContain('error');
197
200
  });
198
201
 
199
- test('success line uses plural for 2 warnings', () => {
202
+ test('counts render without pluralising the level words', () => {
200
203
  const dir = tmpDir();
201
204
  configureLogging(makeCfg(dir));
202
205
  getLogger('t').warning('a');
@@ -204,7 +207,7 @@ test('success line uses plural for 2 warnings', () => {
204
207
  const cap = captureStderr();
205
208
  emitRunSummary();
206
209
  cap.restore();
207
- expect(cap.lines.join('')).toContain('2 warnings,');
210
+ expect(cap.lines.join('')).toContain('2 warn,');
208
211
  });
209
212
 
210
213
  // ── invoker capture ──────────────────────────────────────────────────────────
@@ -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
+ });