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.
@@ -0,0 +1,324 @@
1
+ "use strict";
2
+ /**
3
+ * runspec-node/testing — test helpers for runspec runnables.
4
+ *
5
+ * The Node port of Python's `runspec.testing`. Import it from the subpath
6
+ * export so it stays out of the main entry:
7
+ *
8
+ * import { ParseHarness } from 'runspec-node/testing';
9
+ *
10
+ * `parse()` already takes `argv` and `configPath`, so testing a runnable never
11
+ * requires monkeypatching `process.argv` or mocking the parser. `ParseHarness`
12
+ * removes the remaining boilerplate — pointing `parse()` at a spec, asserting on
13
+ * clean exits without the process actually exiting, and walking the subcommand
14
+ * tree to auto-cover every command.
15
+ *
16
+ * Node specifics this handles for you: `parse()` calls `process.exit(0)` on
17
+ * `--help` and *throws* `RunSpecError` on validation/require-command errors. The
18
+ * harness intercepts `process.exit` (so `--help` doesn't kill your test runner)
19
+ * and maps a thrown `RunSpecError` to a non-zero "exit", unifying both under one
20
+ * exit-code abstraction that mirrors Python's `SystemExit`-based helpers.
21
+ */
22
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
23
+ if (k2 === undefined) k2 = k;
24
+ var desc = Object.getOwnPropertyDescriptor(m, k);
25
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
26
+ desc = { enumerable: true, get: function() { return m[k]; } };
27
+ }
28
+ Object.defineProperty(o, k2, desc);
29
+ }) : (function(o, m, k, k2) {
30
+ if (k2 === undefined) k2 = k;
31
+ o[k2] = m[k];
32
+ }));
33
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
34
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
35
+ }) : function(o, v) {
36
+ o["default"] = v;
37
+ });
38
+ var __importStar = (this && this.__importStar) || (function () {
39
+ var ownKeys = function(o) {
40
+ ownKeys = Object.getOwnPropertyNames || function (o) {
41
+ var ar = [];
42
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
43
+ return ar;
44
+ };
45
+ return ownKeys(o);
46
+ };
47
+ return function (mod) {
48
+ if (mod && mod.__esModule) return mod;
49
+ var result = {};
50
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
51
+ __setModuleDefault(result, mod);
52
+ return result;
53
+ };
54
+ })();
55
+ Object.defineProperty(exports, "__esModule", { value: true });
56
+ exports.ParseHarness = void 0;
57
+ exports.harness = harness;
58
+ const fs = __importStar(require("fs"));
59
+ const os = __importStar(require("os"));
60
+ const path = __importStar(require("path"));
61
+ const parser_1 = require("./parser");
62
+ const loader_1 = require("./loader");
63
+ const inference_1 = require("./inference");
64
+ const errors_1 = require("./errors");
65
+ /** Thrown in place of `process.exit()` so a parse that exits can be caught. */
66
+ class ExitSignal extends Error {
67
+ exitCode;
68
+ constructor(exitCode) {
69
+ super(`process.exit(${exitCode})`);
70
+ this.exitCode = exitCode;
71
+ }
72
+ }
73
+ class ParseHarness {
74
+ scriptName;
75
+ configPath;
76
+ suppressSummary;
77
+ tmpDir = null;
78
+ spec;
79
+ constructor(opts) {
80
+ const { scriptName, configPath, toml, suppressSummary = true } = opts;
81
+ if ((configPath == null) === (toml == null)) {
82
+ throw new Error('pass exactly one of configPath or toml');
83
+ }
84
+ this.scriptName = scriptName;
85
+ this.suppressSummary = suppressSummary;
86
+ let cfgPath = configPath;
87
+ if (toml != null) {
88
+ this.tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'runspec-test-'));
89
+ cfgPath = path.join(this.tmpDir, 'runspec.toml');
90
+ fs.writeFileSync(cfgPath, toml, 'utf-8');
91
+ }
92
+ this.configPath = path.resolve(cfgPath);
93
+ try {
94
+ const raw = (0, loader_1.loadRaw)(this.configPath);
95
+ if (!(scriptName in raw.runnables)) {
96
+ const have = Object.keys(raw.runnables).sort().join(', ') || '(none)';
97
+ throw new Error(`runnable '${scriptName}' not in ${this.configPath} (have: ${have})`);
98
+ }
99
+ // Infer so `required` is resolved from defaults/types (raw leaves it unset).
100
+ this.spec = (0, inference_1.inferScript)(raw.runnables[scriptName], raw.config.autonomyDefault);
101
+ }
102
+ catch (e) {
103
+ // Tear down the temp dir synchronously on a failed construction.
104
+ this.close();
105
+ throw e;
106
+ }
107
+ }
108
+ // ── lifecycle ─────────────────────────────────────────────────────────────
109
+ close() {
110
+ if (this.tmpDir != null) {
111
+ fs.rmSync(this.tmpDir, { recursive: true, force: true });
112
+ this.tmpDir = null;
113
+ }
114
+ }
115
+ // ── core parse entry points ───────────────────────────────────────────────
116
+ parse(argv) {
117
+ const { code, output, parsed } = this.runParse(argv);
118
+ if (parsed === null) {
119
+ throw new Error(`expected \`${this.scriptName} ${argv.join(' ')}\` to parse, but it exited ${code}:\n${output}`);
120
+ }
121
+ return parsed;
122
+ }
123
+ expectOk(argv) {
124
+ return this.parse(argv);
125
+ }
126
+ expectExit(argv, opts = {}) {
127
+ const { code = 1, contains } = opts;
128
+ const res = this.runParse(argv);
129
+ if (res.code === null) {
130
+ throw new Error(`expected \`${this.scriptName} ${argv.join(' ')}\` to exit, but it parsed cleanly`);
131
+ }
132
+ if (code !== null && res.code !== code) {
133
+ throw new Error(`\`${this.scriptName} ${argv.join(' ')}\`: expected exit ${code}, got ${res.code}:\n${res.output}`);
134
+ }
135
+ if (contains != null) {
136
+ const needles = Array.isArray(contains) ? contains : [contains];
137
+ for (const needle of needles) {
138
+ if (!res.output.includes(needle)) {
139
+ throw new Error(`\`${this.scriptName} ${argv.join(' ')}\`: ${JSON.stringify(needle)} not found in output:\n${res.output}`);
140
+ }
141
+ }
142
+ }
143
+ return res.output;
144
+ }
145
+ // ── spec introspection ────────────────────────────────────────────────────
146
+ commandPaths() {
147
+ const paths = [];
148
+ const walk = (node, prefix) => {
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
+ leafCommandPaths() {
159
+ return this.commandPaths().filter((p) => Object.keys(this.node(p).commands ?? {}).length === 0);
160
+ }
161
+ argsFor(p = []) {
162
+ return this.node(p).args ?? {};
163
+ }
164
+ requiredArgs(p = []) {
165
+ return Object.entries(this.argsFor(p))
166
+ .filter(([, a]) => a.required)
167
+ .map(([name]) => name);
168
+ }
169
+ requiresCommand(p = []) {
170
+ return Boolean(this.node(p).requireCommand);
171
+ }
172
+ // ── auto coverage ─────────────────────────────────────────────────────────
173
+ smoke(opts = {}) {
174
+ const { values = {}, checkHelp = true } = opts;
175
+ const done = [];
176
+ if (checkHelp) {
177
+ this.expectExit(['--help'], { code: 0 });
178
+ done.push('--help');
179
+ }
180
+ if (this.requiresCommand()) {
181
+ this.expectExit([]);
182
+ done.push('(no command) -> require-command');
183
+ }
184
+ for (const p of this.commandPaths()) {
185
+ const label = p.join(' ');
186
+ const node = this.node(p);
187
+ if (checkHelp) {
188
+ this.expectExit([...p, '--help'], { code: 0 });
189
+ done.push(`${label} --help`);
190
+ }
191
+ if (node.requireCommand) {
192
+ this.expectExit(p);
193
+ done.push(`${label} -> require-command`);
194
+ continue;
195
+ }
196
+ if (Object.keys(node.commands ?? {}).length > 0) {
197
+ continue; // intermediate group without require-command
198
+ }
199
+ if (this.requiredArgs(p).length > 0) {
200
+ this.expectExit(p);
201
+ done.push(`${label} -> missing required`);
202
+ }
203
+ const built = this.buildValidArgv(p, values);
204
+ if (built !== null) {
205
+ this.expectOk([...p, ...built]);
206
+ done.push(`${label} (valid)`);
207
+ }
208
+ }
209
+ return done;
210
+ }
211
+ // ── internals ─────────────────────────────────────────────────────────────
212
+ runParse(argv) {
213
+ // Capture the *original* (unbound) handles so restoration preserves identity.
214
+ const realExit = process.exit;
215
+ const realOut = process.stdout.write;
216
+ const realErr = process.stderr.write;
217
+ const realLog = console.log;
218
+ const realErrLog = console.error;
219
+ let captured = '';
220
+ process.exit = (c) => {
221
+ throw new ExitSignal(c ?? 0);
222
+ };
223
+ process.stdout.write = ((chunk) => {
224
+ captured += String(chunk);
225
+ return true;
226
+ });
227
+ process.stderr.write = ((chunk) => {
228
+ captured += String(chunk);
229
+ return true;
230
+ });
231
+ console.log = (...a) => {
232
+ captured += a.map(String).join(' ') + '\n';
233
+ };
234
+ console.error = (...a) => {
235
+ captured += a.map(String).join(' ') + '\n';
236
+ };
237
+ return this.withSummaryPinned(() => {
238
+ try {
239
+ const parsed = (0, parser_1.parse)({
240
+ scriptName: this.scriptName,
241
+ argv,
242
+ configPath: this.configPath,
243
+ _promptSecrets: false,
244
+ });
245
+ return { code: null, output: captured, parsed };
246
+ }
247
+ catch (e) {
248
+ if (e instanceof ExitSignal) {
249
+ return { code: e.exitCode, output: captured, parsed: null };
250
+ }
251
+ if (e instanceof errors_1.RunSpecError) {
252
+ // Validation / require-command throw rather than exit; fold the
253
+ // message into the captured output so `contains:` can match on it.
254
+ return { code: 1, output: `${captured}${e.message}\n`, parsed: null };
255
+ }
256
+ throw e;
257
+ }
258
+ finally {
259
+ process.exit = realExit;
260
+ process.stdout.write = realOut;
261
+ process.stderr.write = realErr;
262
+ console.log = realLog;
263
+ console.error = realErrLog;
264
+ }
265
+ });
266
+ }
267
+ node(p) {
268
+ let node = this.spec;
269
+ for (const part of p)
270
+ node = (node.commands ?? {})[part];
271
+ return node;
272
+ }
273
+ mergedArgs(p) {
274
+ const merged = { ...this.argsFor([]) };
275
+ const acc = [];
276
+ for (const part of p) {
277
+ acc.push(part);
278
+ Object.assign(merged, this.argsFor(acc));
279
+ }
280
+ return merged;
281
+ }
282
+ buildValidArgv(p, values) {
283
+ const argv = [];
284
+ for (const [name, a] of Object.entries(this.mergedArgs(p))) {
285
+ const supplied = values[name];
286
+ if (supplied === undefined || supplied === null) {
287
+ if (a.required)
288
+ return null;
289
+ continue;
290
+ }
291
+ const flag = `--${name}`;
292
+ if (a.type === 'flag') {
293
+ if (supplied)
294
+ argv.push(flag);
295
+ }
296
+ else {
297
+ argv.push(flag, String(supplied));
298
+ }
299
+ }
300
+ return argv;
301
+ }
302
+ withSummaryPinned(fn) {
303
+ if (!this.suppressSummary)
304
+ return fn();
305
+ const prefix = this.scriptName.toUpperCase().replace(/-/g, '_');
306
+ const key = `RUNSPEC_${prefix}_ARG_NO_SUMMARY`;
307
+ const prev = process.env[key];
308
+ process.env[key] = '1';
309
+ try {
310
+ return fn();
311
+ }
312
+ finally {
313
+ if (prev === undefined)
314
+ delete process.env[key];
315
+ else
316
+ process.env[key] = prev;
317
+ }
318
+ }
319
+ }
320
+ exports.ParseHarness = ParseHarness;
321
+ function harness(scriptName, opts = {}) {
322
+ return new ParseHarness({ scriptName, ...opts });
323
+ }
324
+ //# sourceMappingURL=testing.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"testing.js","sourceRoot":"","sources":["../src/testing.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;;;;;GAmBG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAwTH,0BAEC;AAxTD,uCAAyB;AACzB,uCAAyB;AACzB,2CAA6B;AAC7B,qCAAiC;AACjC,qCAAmC;AACnC,2CAA0C;AAC1C,qCAAwC;AA2BxC,+EAA+E;AAC/E,MAAM,UAAW,SAAQ,KAAK;IACT;IAAnB,YAAmB,QAAgB;QACjC,KAAK,CAAC,gBAAgB,QAAQ,GAAG,CAAC,CAAC;QADlB,aAAQ,GAAR,QAAQ,CAAQ;IAEnC,CAAC;CACF;AAED,MAAa,YAAY;IACd,UAAU,CAAS;IACnB,UAAU,CAAS;IACX,eAAe,CAAU;IAClC,MAAM,GAAkB,IAAI,CAAC;IACpB,IAAI,CAAa;IAElC,YAAY,IAAyB;QACnC,MAAM,EAAE,UAAU,EAAE,UAAU,EAAE,IAAI,EAAE,eAAe,GAAG,IAAI,EAAE,GAAG,IAAI,CAAC;QACtE,IAAI,CAAC,UAAU,IAAI,IAAI,CAAC,KAAK,CAAC,IAAI,IAAI,IAAI,CAAC,EAAE,CAAC;YAC5C,MAAM,IAAI,KAAK,CAAC,wCAAwC,CAAC,CAAC;QAC5D,CAAC;QAED,IAAI,CAAC,UAAU,GAAG,UAAU,CAAC;QAC7B,IAAI,CAAC,eAAe,GAAG,eAAe,CAAC;QAEvC,IAAI,OAAO,GAAG,UAAU,CAAC;QACzB,IAAI,IAAI,IAAI,IAAI,EAAE,CAAC;YACjB,IAAI,CAAC,MAAM,GAAG,EAAE,CAAC,WAAW,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,MAAM,EAAE,EAAE,eAAe,CAAC,CAAC,CAAC;YACtE,OAAO,GAAG,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC;YACjD,EAAE,CAAC,aAAa,CAAC,OAAO,EAAE,IAAI,EAAE,OAAO,CAAC,CAAC;QAC3C,CAAC;QACD,IAAI,CAAC,UAAU,GAAG,IAAI,CAAC,OAAO,CAAC,OAAiB,CAAC,CAAC;QAElD,IAAI,CAAC;YACH,MAAM,GAAG,GAAG,IAAA,gBAAO,EAAC,IAAI,CAAC,UAAU,CAAC,CAAC;YACrC,IAAI,CAAC,CAAC,UAAU,IAAI,GAAG,CAAC,SAAS,CAAC,EAAE,CAAC;gBACnC,MAAM,IAAI,GAAG,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC,IAAI,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,QAAQ,CAAC;gBACtE,MAAM,IAAI,KAAK,CAAC,aAAa,UAAU,YAAY,IAAI,CAAC,UAAU,WAAW,IAAI,GAAG,CAAC,CAAC;YACxF,CAAC;YACD,6EAA6E;YAC7E,IAAI,CAAC,IAAI,GAAG,IAAA,uBAAW,EAAC,GAAG,CAAC,SAAS,CAAC,UAAU,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,eAAe,CAAC,CAAC;QACjF,CAAC;QAAC,OAAO,CAAC,EAAE,CAAC;YACX,iEAAiE;YACjE,IAAI,CAAC,KAAK,EAAE,CAAC;YACb,MAAM,CAAC,CAAC;QACV,CAAC;IACH,CAAC;IAED,6EAA6E;IAE7E,KAAK;QACH,IAAI,IAAI,CAAC,MAAM,IAAI,IAAI,EAAE,CAAC;YACxB,EAAE,CAAC,MAAM,CAAC,IAAI,CAAC,MAAM,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC;YACzD,IAAI,CAAC,MAAM,GAAG,IAAI,CAAC;QACrB,CAAC;IACH,CAAC;IAED,6EAA6E;IAE7E,KAAK,CAAC,IAAc;QAClB,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,GAAG,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC;QACrD,IAAI,MAAM,KAAK,IAAI,EAAE,CAAC;YACpB,MAAM,IAAI,KAAK,CAAC,cAAc,IAAI,CAAC,UAAU,IAAI,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,8BAA8B,IAAI,MAAM,MAAM,EAAE,CAAC,CAAC;QACnH,CAAC;QACD,OAAO,MAAM,CAAC;IAChB,CAAC;IAED,QAAQ,CAAC,IAAc;QACrB,OAAO,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;IAC1B,CAAC;IAED,UAAU,CAAC,IAAc,EAAE,OAA0B,EAAE;QACrD,MAAM,EAAE,IAAI,GAAG,CAAC,EAAE,QAAQ,EAAE,GAAG,IAAI,CAAC;QACpC,MAAM,GAAG,GAAG,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC;QAChC,IAAI,GAAG,CAAC,IAAI,KAAK,IAAI,EAAE,CAAC;YACtB,MAAM,IAAI,KAAK,CAAC,cAAc,IAAI,CAAC,UAAU,IAAI,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,mCAAmC,CAAC,CAAC;QACtG,CAAC;QACD,IAAI,IAAI,KAAK,IAAI,IAAI,GAAG,CAAC,IAAI,KAAK,IAAI,EAAE,CAAC;YACvC,MAAM,IAAI,KAAK,CAAC,KAAK,IAAI,CAAC,UAAU,IAAI,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,qBAAqB,IAAI,SAAS,GAAG,CAAC,IAAI,MAAM,GAAG,CAAC,MAAM,EAAE,CAAC,CAAC;QACtH,CAAC;QACD,IAAI,QAAQ,IAAI,IAAI,EAAE,CAAC;YACrB,MAAM,OAAO,GAAG,KAAK,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC;YAChE,KAAK,MAAM,MAAM,IAAI,OAAO,EAAE,CAAC;gBAC7B,IAAI,CAAC,GAAG,CAAC,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC;oBACjC,MAAM,IAAI,KAAK,CAAC,KAAK,IAAI,CAAC,UAAU,IAAI,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,OAAO,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,0BAA0B,GAAG,CAAC,MAAM,EAAE,CAAC,CAAC;gBAC7H,CAAC;YACH,CAAC;QACH,CAAC;QACD,OAAO,GAAG,CAAC,MAAM,CAAC;IACpB,CAAC;IAED,6EAA6E;IAE7E,YAAY;QACV,MAAM,KAAK,GAAe,EAAE,CAAC;QAC7B,MAAM,IAAI,GAAG,CAAC,IAAgB,EAAE,MAAgB,EAAQ,EAAE;YACxD,KAAK,MAAM,CAAC,IAAI,EAAE,GAAG,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,QAAQ,IAAI,EAAE,CAAC,EAAE,CAAC;gBAC9D,MAAM,IAAI,GAAG,CAAC,GAAG,MAAM,EAAE,IAAI,CAAC,CAAC;gBAC/B,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;gBACjB,IAAI,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC;YAClB,CAAC;QACH,CAAC,CAAC;QACF,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC;QACpB,OAAO,KAAK,CAAC;IACf,CAAC;IAED,gBAAgB;QACd,OAAO,IAAI,CAAC,YAAY,EAAE,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,QAAQ,IAAI,EAAE,CAAC,CAAC,MAAM,KAAK,CAAC,CAAC,CAAC;IAClG,CAAC;IAED,OAAO,CAAC,IAAc,EAAE;QACtB,OAAO,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,IAAI,EAAE,CAAC;IACjC,CAAC;IAED,YAAY,CAAC,IAAc,EAAE;QAC3B,OAAO,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC;aACnC,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,QAAQ,CAAC;aAC7B,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,EAAE,EAAE,CAAC,IAAI,CAAC,CAAC;IAC3B,CAAC;IAED,eAAe,CAAC,IAAc,EAAE;QAC9B,OAAO,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,cAAc,CAAC,CAAC;IAC9C,CAAC;IAED,6EAA6E;IAE7E,KAAK,CAAC,OAAqB,EAAE;QAC3B,MAAM,EAAE,MAAM,GAAG,EAAE,EAAE,SAAS,GAAG,IAAI,EAAE,GAAG,IAAI,CAAC;QAC/C,MAAM,IAAI,GAAa,EAAE,CAAC;QAE1B,IAAI,SAAS,EAAE,CAAC;YACd,IAAI,CAAC,UAAU,CAAC,CAAC,QAAQ,CAAC,EAAE,EAAE,IAAI,EAAE,CAAC,EAAE,CAAC,CAAC;YACzC,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;QACtB,CAAC;QACD,IAAI,IAAI,CAAC,eAAe,EAAE,EAAE,CAAC;YAC3B,IAAI,CAAC,UAAU,CAAC,EAAE,CAAC,CAAC;YACpB,IAAI,CAAC,IAAI,CAAC,iCAAiC,CAAC,CAAC;QAC/C,CAAC;QAED,KAAK,MAAM,CAAC,IAAI,IAAI,CAAC,YAAY,EAAE,EAAE,CAAC;YACpC,MAAM,KAAK,GAAG,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;YAC1B,MAAM,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;YAE1B,IAAI,SAAS,EAAE,CAAC;gBACd,IAAI,CAAC,UAAU,CAAC,CAAC,GAAG,CAAC,EAAE,QAAQ,CAAC,EAAE,EAAE,IAAI,EAAE,CAAC,EAAE,CAAC,CAAC;gBAC/C,IAAI,CAAC,IAAI,CAAC,GAAG,KAAK,SAAS,CAAC,CAAC;YAC/B,CAAC;YAED,IAAI,IAAI,CAAC,cAAc,EAAE,CAAC;gBACxB,IAAI,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC;gBACnB,IAAI,CAAC,IAAI,CAAC,GAAG,KAAK,qBAAqB,CAAC,CAAC;gBACzC,SAAS;YACX,CAAC;YACD,IAAI,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,QAAQ,IAAI,EAAE,CAAC,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;gBAChD,SAAS,CAAC,6CAA6C;YACzD,CAAC;YAED,IAAI,IAAI,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;gBACpC,IAAI,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC;gBACnB,IAAI,CAAC,IAAI,CAAC,GAAG,KAAK,sBAAsB,CAAC,CAAC;YAC5C,CAAC;YAED,MAAM,KAAK,GAAG,IAAI,CAAC,cAAc,CAAC,CAAC,EAAE,MAAM,CAAC,CAAC;YAC7C,IAAI,KAAK,KAAK,IAAI,EAAE,CAAC;gBACnB,IAAI,CAAC,QAAQ,CAAC,CAAC,GAAG,CAAC,EAAE,GAAG,KAAK,CAAC,CAAC,CAAC;gBAChC,IAAI,CAAC,IAAI,CAAC,GAAG,KAAK,UAAU,CAAC,CAAC;YAChC,CAAC;QACH,CAAC;QAED,OAAO,IAAI,CAAC;IACd,CAAC;IAED,6EAA6E;IAErE,QAAQ,CAAC,IAAc;QAC7B,8EAA8E;QAC9E,MAAM,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC;QAC9B,MAAM,OAAO,GAAG,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC;QACrC,MAAM,OAAO,GAAG,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC;QACrC,MAAM,OAAO,GAAG,OAAO,CAAC,GAAG,CAAC;QAC5B,MAAM,UAAU,GAAG,OAAO,CAAC,KAAK,CAAC;QACjC,IAAI,QAAQ,GAAG,EAAE,CAAC;QAEjB,OAAsD,CAAC,IAAI,GAAG,CAAC,CAAU,EAAS,EAAE;YACnF,MAAM,IAAI,UAAU,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC;QAC/B,CAAC,CAAC;QACF,OAAO,CAAC,MAAM,CAAC,KAAK,GAAG,CAAC,CAAC,KAAc,EAAW,EAAE;YAClD,QAAQ,IAAI,MAAM,CAAC,KAAK,CAAC,CAAC;YAC1B,OAAO,IAAI,CAAC;QACd,CAAC,CAAgC,CAAC;QAClC,OAAO,CAAC,MAAM,CAAC,KAAK,GAAG,CAAC,CAAC,KAAc,EAAW,EAAE;YAClD,QAAQ,IAAI,MAAM,CAAC,KAAK,CAAC,CAAC;YAC1B,OAAO,IAAI,CAAC;QACd,CAAC,CAAgC,CAAC;QAClC,OAAO,CAAC,GAAG,GAAG,CAAC,GAAG,CAAY,EAAQ,EAAE;YACtC,QAAQ,IAAI,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,GAAG,IAAI,CAAC;QAC7C,CAAC,CAAC;QACF,OAAO,CAAC,KAAK,GAAG,CAAC,GAAG,CAAY,EAAQ,EAAE;YACxC,QAAQ,IAAI,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,GAAG,IAAI,CAAC;QAC7C,CAAC,CAAC;QAEF,OAAO,IAAI,CAAC,iBAAiB,CAAC,GAAG,EAAE;YACjC,IAAI,CAAC;gBACH,MAAM,MAAM,GAAG,IAAA,cAAK,EAAC;oBACnB,UAAU,EAAE,IAAI,CAAC,UAAU;oBAC3B,IAAI;oBACJ,UAAU,EAAE,IAAI,CAAC,UAAU;oBAC3B,cAAc,EAAE,KAAK;iBACtB,CAAC,CAAC;gBACH,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,CAAC;YAClD,CAAC;YAAC,OAAO,CAAC,EAAE,CAAC;gBACX,IAAI,CAAC,YAAY,UAAU,EAAE,CAAC;oBAC5B,OAAO,EAAE,IAAI,EAAE,CAAC,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC;gBAC9D,CAAC;gBACD,IAAI,CAAC,YAAY,qBAAY,EAAE,CAAC;oBAC9B,gEAAgE;oBAChE,mEAAmE;oBACnE,OAAO,EAAE,IAAI,EAAE,CAAC,EAAE,MAAM,EAAE,GAAG,QAAQ,GAAG,CAAC,CAAC,OAAO,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC;gBACxE,CAAC;gBACD,MAAM,CAAC,CAAC;YACV,CAAC;oBAAS,CAAC;gBACT,OAAO,CAAC,IAAI,GAAG,QAAQ,CAAC;gBACxB,OAAO,CAAC,MAAM,CAAC,KAAK,GAAG,OAAO,CAAC;gBAC/B,OAAO,CAAC,MAAM,CAAC,KAAK,GAAG,OAAO,CAAC;gBAC/B,OAAO,CAAC,GAAG,GAAG,OAAO,CAAC;gBACtB,OAAO,CAAC,KAAK,GAAG,UAAU,CAAC;YAC7B,CAAC;QACH,CAAC,CAAC,CAAC;IACL,CAAC;IAEO,IAAI,CAAC,CAAW;QACtB,IAAI,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC;QACrB,KAAK,MAAM,IAAI,IAAI,CAAC;YAAE,IAAI,GAAG,CAAC,IAAI,CAAC,QAAQ,IAAI,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC;QACzD,OAAO,IAAI,CAAC;IACd,CAAC;IAEO,UAAU,CAAC,CAAW;QAC5B,MAAM,MAAM,GAA4B,EAAE,GAAG,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC,EAAE,CAAC;QAChE,MAAM,GAAG,GAAa,EAAE,CAAC;QACzB,KAAK,MAAM,IAAI,IAAI,CAAC,EAAE,CAAC;YACrB,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;YACf,MAAM,CAAC,MAAM,CAAC,MAAM,EAAE,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC;QAC3C,CAAC;QACD,OAAO,MAAM,CAAC;IAChB,CAAC;IAEO,cAAc,CAAC,CAAW,EAAE,MAA+B;QACjE,MAAM,IAAI,GAAa,EAAE,CAAC;QAC1B,KAAK,MAAM,CAAC,IAAI,EAAE,CAAC,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;YAC3D,MAAM,QAAQ,GAAG,MAAM,CAAC,IAAI,CAAC,CAAC;YAC9B,IAAI,QAAQ,KAAK,SAAS,IAAI,QAAQ,KAAK,IAAI,EAAE,CAAC;gBAChD,IAAI,CAAC,CAAC,QAAQ;oBAAE,OAAO,IAAI,CAAC;gBAC5B,SAAS;YACX,CAAC;YACD,MAAM,IAAI,GAAG,KAAK,IAAI,EAAE,CAAC;YACzB,IAAI,CAAC,CAAC,IAAI,KAAK,MAAM,EAAE,CAAC;gBACtB,IAAI,QAAQ;oBAAE,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;YAChC,CAAC;iBAAM,CAAC;gBACN,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC;YACpC,CAAC;QACH,CAAC;QACD,OAAO,IAAI,CAAC;IACd,CAAC;IAEO,iBAAiB,CAAI,EAAW;QACtC,IAAI,CAAC,IAAI,CAAC,eAAe;YAAE,OAAO,EAAE,EAAE,CAAC;QACvC,MAAM,MAAM,GAAG,IAAI,CAAC,UAAU,CAAC,WAAW,EAAE,CAAC,OAAO,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC;QAChE,MAAM,GAAG,GAAG,WAAW,MAAM,iBAAiB,CAAC;QAC/C,MAAM,IAAI,GAAG,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;QAC9B,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,GAAG,GAAG,CAAC;QACvB,IAAI,CAAC;YACH,OAAO,EAAE,EAAE,CAAC;QACd,CAAC;gBAAS,CAAC;YACT,IAAI,IAAI,KAAK,SAAS;gBAAE,OAAO,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;;gBAC3C,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,GAAG,IAAI,CAAC;QAC/B,CAAC;IACH,CAAC;CACF;AA5QD,oCA4QC;AAED,SAAgB,OAAO,CAAC,UAAkB,EAAE,OAAgD,EAAE;IAC5F,OAAO,IAAI,YAAY,CAAC,EAAE,UAAU,EAAE,GAAG,IAAI,EAAE,CAAC,CAAC;AACnD,CAAC"}
package/package.json CHANGED
@@ -1,9 +1,19 @@
1
1
  {
2
2
  "name": "runspec-node",
3
- "version": "0.27.0",
3
+ "version": "0.28.0",
4
4
  "description": "Node/TypeScript language pack for runspec",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "default": "./dist/index.js"
11
+ },
12
+ "./testing": {
13
+ "types": "./dist/testing.d.ts",
14
+ "default": "./dist/testing.js"
15
+ }
16
+ },
7
17
  "bin": {
8
18
  "runspec": "./bin/runspec.js"
9
19
  },
package/src/cli.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import * as fs from 'fs';
2
2
  import * as path from 'path';
3
3
  import * as readline from 'readline';
4
+ import { spawnSync } from 'child_process';
4
5
  import { findConfig } from './finder';
5
6
  import * as logs from './logs';
6
7
  import { loadRaw } from './loader';
@@ -33,6 +34,7 @@ export function main(): void {
33
34
  const commands: Record<string, (args: string[]) => void | Promise<void>> = {
34
35
  init: cmdInit,
35
36
  local: cmdLocal,
37
+ test: cmdTest,
36
38
  bin: cmdBin,
37
39
  logs: cmdLogs,
38
40
  jump: cmdJump,
@@ -359,6 +361,145 @@ function cmdServe(_args: string[]): void {
359
361
  serve();
360
362
  }
361
363
 
364
+ // Hard cap on the entry-point `--help` subprocess so a runnable that hangs on
365
+ // import can't wedge a CI run. A timeout is treated as a failure.
366
+ const TEST_SUBPROCESS_TIMEOUT_MS = 30000;
367
+
368
+ interface TestCheck {
369
+ ok: boolean;
370
+ checks?: string[];
371
+ error?: string;
372
+ stderr?: string;
373
+ }
374
+
375
+ interface TestResult {
376
+ runnable: string;
377
+ source: string;
378
+ ok: boolean;
379
+ checks: { spec_smoke: TestCheck; entry_point: TestCheck };
380
+ }
381
+
382
+ export function cmdTest(args: string[]): void {
383
+ const fmt = getFlag(args, '--format') ?? 'text';
384
+ const runnableFilter = getFlag(args, '--runnable');
385
+
386
+ let discovered = discoverLocal();
387
+ if (runnableFilter) discovered = discovered.filter((d) => d.runnable === runnableFilter);
388
+
389
+ if (!discovered.length) {
390
+ console.log('No runspec-aware runnables found in this environment.');
391
+ console.log("Run from a folder with a runspec.toml, or 'runspec init' to create one.");
392
+ return;
393
+ }
394
+
395
+ const results = discovered.map((d) => testOne(d));
396
+ const failed = results.filter((r) => !r.ok).length;
397
+
398
+ if (fmt === 'json') {
399
+ const summary = { total: results.length, passed: results.length - failed, failed };
400
+ console.log(JSON.stringify({ results, summary }, null, 2));
401
+ } else {
402
+ printTestText(results);
403
+ }
404
+
405
+ if (failed) process.exit(1);
406
+ }
407
+
408
+ // Run both checks against one discovered runnable; never throws. Each phase is
409
+ // isolated so a single broken runnable cannot abort the run.
410
+ export function testOne(item: { source: string; runnable: string; spec: ScriptSpec }): TestResult {
411
+ const { runnable: name, source } = item;
412
+
413
+ // ── phase 1: spec smoke (in-process) ──────────────────────────────────────
414
+ let specSmoke: TestCheck;
415
+ try {
416
+ const { ParseHarness } = require('./testing') as typeof import('./testing');
417
+ const h = new ParseHarness({ scriptName: name, configPath: source, suppressSummary: true });
418
+ try {
419
+ specSmoke = { ok: true, checks: h.smoke({ checkHelp: true }) };
420
+ } finally {
421
+ h.close();
422
+ }
423
+ } catch (e) {
424
+ specSmoke = { ok: false, error: e instanceof Error ? e.message : String(e) };
425
+ }
426
+
427
+ // ── phase 2: entry-point --help (subprocess) ──────────────────────────────
428
+ const { findScript } = require('./serve') as typeof import('./serve');
429
+ const binDir = path.join(path.dirname(source), 'node_modules', '.bin');
430
+ const cmd = findScript(name, binDir);
431
+
432
+ let entryPoint: TestCheck;
433
+ if (cmd === null) {
434
+ entryPoint = { ok: false, error: 'entry point not found — run `runspec bin` or check package.json "bin"' };
435
+ } else {
436
+ const res = spawnSync(cmd, ['--help'], {
437
+ encoding: 'utf-8',
438
+ timeout: TEST_SUBPROCESS_TIMEOUT_MS,
439
+ env: { ...process.env, RUNSPEC_AGENT: '1' },
440
+ });
441
+ if (res.error) {
442
+ const code = (res.error as NodeJS.ErrnoException).code;
443
+ const msg = code === 'ETIMEDOUT' ? `timed out after ${TEST_SUBPROCESS_TIMEOUT_MS / 1000}s` : res.error.message;
444
+ entryPoint = { ok: false, error: msg };
445
+ } else if (res.status !== 0) {
446
+ entryPoint = {
447
+ ok: false,
448
+ error: `exit ${res.status}`,
449
+ stderr: (res.stderr || res.stdout || '').trim().slice(0, 2000),
450
+ };
451
+ } else {
452
+ entryPoint = { ok: true };
453
+ }
454
+ }
455
+
456
+ return {
457
+ runnable: name,
458
+ source,
459
+ ok: specSmoke.ok && entryPoint.ok,
460
+ checks: { spec_smoke: specSmoke, entry_point: entryPoint },
461
+ };
462
+ }
463
+
464
+ // Pick the most informative single line from captured stderr: the last line
465
+ // mentioning an error/exception (the "Error: ..." line for a Node crash, the
466
+ // exception for a Python traceback), falling back to the last non-empty line.
467
+ function summariseStderr(stderr: string): string {
468
+ const lines = stderr
469
+ .split('\n')
470
+ .map((l) => l.trim())
471
+ .filter((l) => l);
472
+ if (!lines.length) return '';
473
+ const errLines = lines.filter((l) => /error|exception/i.test(l));
474
+ return (errLines.length ? errLines : lines)[(errLines.length ? errLines : lines).length - 1];
475
+ }
476
+
477
+ function printTestText(results: TestResult[]): void {
478
+ const total = results.length;
479
+ const passed = results.filter((r) => r.ok).length;
480
+ const failed = total - passed;
481
+
482
+ console.log(`Tested ${total} runnable(s):\n`);
483
+ for (const r of results) {
484
+ const spec = r.checks.spec_smoke;
485
+ const ep = r.checks.entry_point;
486
+ const mark = r.ok ? '✓' : '✗';
487
+ const specNote = spec.ok ? 'spec ok' : 'spec failed';
488
+ const epNote = ep.ok ? '--help exit 0' : '--help failed';
489
+ console.log(` ${mark} ${r.runnable.padEnd(24)} ${specNote}, ${epNote}`);
490
+ if (!spec.ok) console.log(` spec: ${spec.error}`);
491
+ if (!ep.ok) {
492
+ console.log(` entry point: ${ep.error}`);
493
+ if (ep.stderr) {
494
+ const summary = summariseStderr(ep.stderr);
495
+ if (summary) console.log(` stderr: ${summary}`);
496
+ }
497
+ }
498
+ }
499
+ console.log();
500
+ console.log(`Summary: ${passed} passed, ${failed} failed (${total} total)`);
501
+ }
502
+
362
503
  // ── Schema builder ────────────────────────────────────────────────────────────
363
504
 
364
505
  export function buildSchema(name: string, script: ScriptSpec, fmt: string): Record<string, unknown> {
@@ -830,6 +971,7 @@ Usage:
830
971
  Commands:
831
972
  init Create runspec.toml and a code stub
832
973
  local List runnables and emit tool schemas
974
+ test Smoke-test every runnable: spec smoke + entry-point --help (CI gate)
833
975
  bin Generate a venv-shaped bin/ so a controller can run this folder
834
976
  logs View, status, prune, or compact per-invocation audit logs
835
977
  jump Execute a runnable on a remote host via SSH
@@ -842,6 +984,7 @@ Examples:
842
984
  runspec init --example
843
985
  runspec local
844
986
  runspec local --format mcp
987
+ runspec test
845
988
  runspec bin
846
989
  runspec logs deploy
847
990
  runspec serve`);
@@ -874,6 +1017,23 @@ Examples:
874
1017
  runspec local --format mcp --script deploy
875
1018
  runspec local --format json`,
876
1019
 
1020
+ test: `runspec test — Smoke-test every runnable in this folder (CI gate)
1021
+
1022
+ For each runnable in runspec.toml runs two checks: an in-process spec smoke
1023
+ (--help works for every command, require-command is enforced, required args
1024
+ are validated) and a subprocess that executes the entry point with --help
1025
+ (proves the runnable's own code imports and is wired to the right spec).
1026
+ Exits 1 if any runnable fails either check.
1027
+
1028
+ Options:
1029
+ --format Output format: text (default) or json
1030
+ --runnable Filter to a single runnable by name
1031
+
1032
+ Examples:
1033
+ runspec test
1034
+ runspec test --format json
1035
+ runspec test --runnable deploy`,
1036
+
877
1037
  bin: `runspec bin — Generate a venv-shaped bin/ for this folder's runnables
878
1038
 
879
1039
  Writes bin/runspec plus one bin/<runnable> shim per runnable in runspec.toml,
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);