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.
@@ -0,0 +1,67 @@
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
+ import type { ParsedArgs, ArgSpec } from './models';
22
+ export interface ParseHarnessOptions {
23
+ scriptName: string;
24
+ /** An existing runspec.toml (a fixture, or your package's real spec). */
25
+ configPath?: string;
26
+ /** An inline TOML string written to a throwaway temp file. */
27
+ toml?: string;
28
+ /** Pin the per-run summary off during a parse (default true). */
29
+ suppressSummary?: boolean;
30
+ }
31
+ export interface ExpectExitOptions {
32
+ /** Expected exit code; `null` accepts any non-zero. Default 1; use 0 for --help. */
33
+ code?: number | null;
34
+ /** Substring(s) the captured output must include. */
35
+ contains?: string | string[];
36
+ }
37
+ export interface SmokeOptions {
38
+ /** Arg-name → value map used to build valid invocations for each leaf. */
39
+ values?: Record<string, unknown>;
40
+ /** Assert `--help` exits 0 for the runnable and every subcommand (default true). */
41
+ checkHelp?: boolean;
42
+ }
43
+ export declare class ParseHarness {
44
+ readonly scriptName: string;
45
+ readonly configPath: string;
46
+ private readonly suppressSummary;
47
+ private tmpDir;
48
+ private readonly spec;
49
+ constructor(opts: ParseHarnessOptions);
50
+ close(): void;
51
+ parse(argv: string[]): ParsedArgs;
52
+ expectOk(argv: string[]): ParsedArgs;
53
+ expectExit(argv: string[], opts?: ExpectExitOptions): string;
54
+ commandPaths(): string[][];
55
+ leafCommandPaths(): string[][];
56
+ argsFor(p?: string[]): Record<string, ArgSpec>;
57
+ requiredArgs(p?: string[]): string[];
58
+ requiresCommand(p?: string[]): boolean;
59
+ smoke(opts?: SmokeOptions): string[];
60
+ private runParse;
61
+ private node;
62
+ private mergedArgs;
63
+ private buildValidArgv;
64
+ private withSummaryPinned;
65
+ }
66
+ export declare function harness(scriptName: string, opts?: Omit<ParseHarnessOptions, 'scriptName'>): ParseHarness;
67
+ //# sourceMappingURL=testing.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"testing.d.ts","sourceRoot":"","sources":["../src/testing.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AASH,OAAO,KAAK,EAAE,UAAU,EAAc,OAAO,EAAE,MAAM,UAAU,CAAC;AAEhE,MAAM,WAAW,mBAAmB;IAClC,UAAU,EAAE,MAAM,CAAC;IACnB,yEAAyE;IACzE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,8DAA8D;IAC9D,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,iEAAiE;IACjE,eAAe,CAAC,EAAE,OAAO,CAAC;CAC3B;AAED,MAAM,WAAW,iBAAiB;IAChC,oFAAoF;IACpF,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,qDAAqD;IACrD,QAAQ,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;CAC9B;AAED,MAAM,WAAW,YAAY;IAC3B,0EAA0E;IAC1E,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACjC,oFAAoF;IACpF,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AASD,qBAAa,YAAY;IACvB,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAU;IAC1C,OAAO,CAAC,MAAM,CAAuB;IACrC,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAa;gBAEtB,IAAI,EAAE,mBAAmB;IAkCrC,KAAK,IAAI,IAAI;IASb,KAAK,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,UAAU;IAQjC,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,UAAU;IAIpC,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,IAAI,GAAE,iBAAsB,GAAG,MAAM;IAsBhE,YAAY,IAAI,MAAM,EAAE,EAAE;IAa1B,gBAAgB,IAAI,MAAM,EAAE,EAAE;IAI9B,OAAO,CAAC,CAAC,GAAE,MAAM,EAAO,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IAIlD,YAAY,CAAC,CAAC,GAAE,MAAM,EAAO,GAAG,MAAM,EAAE;IAMxC,eAAe,CAAC,CAAC,GAAE,MAAM,EAAO,GAAG,OAAO;IAM1C,KAAK,CAAC,IAAI,GAAE,YAAiB,GAAG,MAAM,EAAE;IAgDxC,OAAO,CAAC,QAAQ;IAwDhB,OAAO,CAAC,IAAI;IAMZ,OAAO,CAAC,UAAU;IAUlB,OAAO,CAAC,cAAc;IAkBtB,OAAO,CAAC,iBAAiB;CAa1B;AAED,wBAAgB,OAAO,CAAC,UAAU,EAAE,MAAM,EAAE,IAAI,GAAE,IAAI,CAAC,mBAAmB,EAAE,YAAY,CAAM,GAAG,YAAY,CAE5G"}
@@ -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.1",
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,
@@ -490,16 +490,18 @@ function formatSummaryLine(state: SummaryState, durationMs: number, exitCode: nu
490
490
  const errors = (counts.ERROR ?? 0) + (counts.CRITICAL ?? 0);
491
491
  const secs = (durationMs / 1000).toFixed(2);
492
492
  const runnable = state.runnable;
493
- const wSuffix = warnings === 1 ? '' : 's';
494
- const eSuffix = errors === 1 ? '' : 's';
493
+ // Abbreviated level words ("warn"/"err", never "warning"/"error") so the line
494
+ // doesn't trip Rundeck's case-insensitive `error` log-highlight/level filters,
495
+ // which would otherwise paint a clean run red over the literal "0 errors".
496
+ const events = `${total} events (${warnings} warn, ${errors} err)`;
495
497
  const userPart = state.userTarget
496
498
  ? ` | user: ${state.user} → ${state.userTarget} (sudo)`
497
499
  : ` | user: ${state.user}`;
498
500
  if (state.exception || exitCode !== 0) {
499
501
  const excPart = state.exception ? `, ${state.exception.type}` : '';
500
- return `runspec: ${runnable} failed in ${secs}s — exit ${exitCode}${excPart} — ${total} events (${warnings} warning${wSuffix}, ${errors} error${eSuffix})${userPart}`;
502
+ return `runspec: ${runnable} failed in ${secs}s — exit ${exitCode}${excPart} — ${events}${userPart}`;
501
503
  }
502
- return `runspec: ${runnable} completed in ${secs}s — ${total} events (${warnings} warning${wSuffix}, ${errors} error${eSuffix})${userPart}`;
504
+ return `runspec: ${runnable} completed in ${secs}s — ${events}${userPart}`;
503
505
  }
504
506
 
505
507
  /**