runspec-node 0.17.1 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +6 -0
- package/dist/cli.js.map +1 -1
- package/dist/errors.d.ts +5 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +44 -0
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/inference.d.ts +6 -0
- package/dist/inference.d.ts.map +1 -1
- package/dist/inference.js +25 -0
- package/dist/inference.js.map +1 -1
- package/dist/loader.js +4 -0
- package/dist/loader.js.map +1 -1
- package/dist/logging_setup.d.ts +29 -1
- package/dist/logging_setup.d.ts.map +1 -1
- package/dist/logging_setup.js +120 -30
- package/dist/logging_setup.js.map +1 -1
- package/dist/models.d.ts +4 -0
- package/dist/models.d.ts.map +1 -1
- package/dist/parser.d.ts +2 -0
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +13 -3
- package/dist/parser.js.map +1 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +44 -2
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
- package/src/cli.ts +3 -0
- package/src/errors.ts +48 -0
- package/src/index.ts +1 -0
- package/src/inference.ts +25 -0
- package/src/loader.ts +4 -0
- package/src/logging_setup.ts +137 -30
- package/src/models.ts +4 -0
- package/src/parser.ts +21 -4
- package/src/types.ts +51 -3
- package/tests/test_emit_schema.test.ts +48 -0
- package/tests/test_inference.test.ts +29 -1
- package/tests/test_integration.test.ts +20 -0
- package/tests/test_loader.test.ts +33 -0
- package/tests/test_parser.test.ts +83 -1
- package/tests/test_run_summary.test.ts +99 -0
- package/tests/test_types.test.ts +73 -0
|
@@ -106,6 +106,39 @@ workers = {default = 4, range = [1, 32]}
|
|
|
106
106
|
expect(raw.runnables['greet'].args['workers'].range).toEqual([1, 32]);
|
|
107
107
|
});
|
|
108
108
|
|
|
109
|
+
test('normalises pattern / min-length / max-length fields', () => {
|
|
110
|
+
const dir = tmpDir();
|
|
111
|
+
const file = path.join(dir, 'runspec.toml');
|
|
112
|
+
fs.writeFileSync(file, `
|
|
113
|
+
[greet]
|
|
114
|
+
description = "hi"
|
|
115
|
+
|
|
116
|
+
[greet.args]
|
|
117
|
+
slug = {type = "str", pattern = "[a-z]+-[0-9]+", min-length = 3, max-length = 10}
|
|
118
|
+
`);
|
|
119
|
+
const raw = loadRaw(file);
|
|
120
|
+
const slug = raw.runnables['greet'].args['slug'];
|
|
121
|
+
expect(slug.pattern).toBe('[a-z]+-[0-9]+');
|
|
122
|
+
expect(slug.minLength).toBe(3);
|
|
123
|
+
expect(slug.maxLength).toBe(10);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('normalises require-command on a runnable', () => {
|
|
127
|
+
const dir = tmpDir();
|
|
128
|
+
const file = path.join(dir, 'runspec.toml');
|
|
129
|
+
fs.writeFileSync(file, `
|
|
130
|
+
[db]
|
|
131
|
+
require-command = true
|
|
132
|
+
|
|
133
|
+
[db.commands.migrate]
|
|
134
|
+
description = "run migrations"
|
|
135
|
+
`);
|
|
136
|
+
const raw = loadRaw(file);
|
|
137
|
+
expect(raw.runnables['db'].requireCommand).toBe(true);
|
|
138
|
+
// Defaults to false when absent.
|
|
139
|
+
expect(raw.runnables['db'].commands['migrate'].requireCommand).toBe(false);
|
|
140
|
+
});
|
|
141
|
+
|
|
109
142
|
test('normalises group fields', () => {
|
|
110
143
|
const dir = tmpDir();
|
|
111
144
|
const file = path.join(dir, 'runspec.toml');
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as fs from 'fs';
|
|
2
2
|
import * as os from 'os';
|
|
3
3
|
import * as path from 'path';
|
|
4
|
-
import { parse } from '../src/parser';
|
|
4
|
+
import { parse, loadSpec } from '../src/parser';
|
|
5
5
|
|
|
6
6
|
function makeTmpConfig(toml: string): string {
|
|
7
7
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'runspec-parser-test-'));
|
|
@@ -157,3 +157,85 @@ description = "Run it"
|
|
|
157
157
|
expect(usage.indexOf('<command>')).toBeLessThan(usage.indexOf('-- <extra>'));
|
|
158
158
|
});
|
|
159
159
|
});
|
|
160
|
+
|
|
161
|
+
// ── require-command ─────────────────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
describe('require-command', () => {
|
|
164
|
+
const DB_TOML = `
|
|
165
|
+
[db]
|
|
166
|
+
require-command = true
|
|
167
|
+
[db.commands.migrate]
|
|
168
|
+
[db.commands.seed]
|
|
169
|
+
`;
|
|
170
|
+
|
|
171
|
+
test('no command errors and lists commands', () => {
|
|
172
|
+
const configPath = makeTmpConfig(DB_TOML);
|
|
173
|
+
let msg = '';
|
|
174
|
+
try {
|
|
175
|
+
parse({ scriptName: 'db', argv: [], configPath });
|
|
176
|
+
throw new Error('expected parse to throw');
|
|
177
|
+
} catch (e) {
|
|
178
|
+
msg = (e as Error).message;
|
|
179
|
+
}
|
|
180
|
+
expect(msg).toContain('requires a command');
|
|
181
|
+
expect(msg).toContain('migrate');
|
|
182
|
+
expect(msg).toContain('seed');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('valid command passes', () => {
|
|
186
|
+
const configPath = makeTmpConfig(DB_TOML);
|
|
187
|
+
const args = parse({ scriptName: 'db', argv: ['migrate'], configPath });
|
|
188
|
+
expect(args.runspec_command_path).toEqual(['migrate']);
|
|
189
|
+
expect(args.runspec_command).toBe('migrate');
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('nested requirement enforced at each depth', () => {
|
|
193
|
+
const toml = `
|
|
194
|
+
[app]
|
|
195
|
+
require-command = true
|
|
196
|
+
[app.commands.db]
|
|
197
|
+
require-command = true
|
|
198
|
+
[app.commands.db.commands.migrate]
|
|
199
|
+
`;
|
|
200
|
+
const configPath = makeTmpConfig(toml);
|
|
201
|
+
// Choosing the intermediate command isn't enough — db itself requires one.
|
|
202
|
+
expect(() => parse({ scriptName: 'app', argv: ['db'], configPath })).toThrow(/requires a command/);
|
|
203
|
+
// Going all the way down satisfies it.
|
|
204
|
+
const args = parse({ scriptName: 'app', argv: ['db', 'migrate'], configPath });
|
|
205
|
+
expect(args.runspec_command_path).toEqual(['db', 'migrate']);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test('backward compatible: commands without require-command run at root', () => {
|
|
209
|
+
const toml = `
|
|
210
|
+
[tool]
|
|
211
|
+
[tool.commands.run]
|
|
212
|
+
`;
|
|
213
|
+
const configPath = makeTmpConfig(toml);
|
|
214
|
+
const args = parse({ scriptName: 'tool', argv: [], configPath });
|
|
215
|
+
expect(args.runspec_command_path).toEqual([]);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('loadSpec bypasses enforcement', () => {
|
|
219
|
+
const configPath = makeTmpConfig(DB_TOML);
|
|
220
|
+
const spec = loadSpec({ scriptName: 'db', configPath });
|
|
221
|
+
expect(spec.runspec_command_path).toEqual([]);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test('--help still works at a required level', () => {
|
|
225
|
+
const configPath = makeTmpConfig(DB_TOML);
|
|
226
|
+
const output: string[] = [];
|
|
227
|
+
const logSpy = jest.spyOn(console, 'log').mockImplementation((...a) => { output.push(a.join(' ')); });
|
|
228
|
+
const exitSpy = jest.spyOn(process, 'exit').mockImplementation((() => { throw new Error('__exit__'); }) as never);
|
|
229
|
+
try {
|
|
230
|
+
parse({ scriptName: 'db', argv: ['--help'], configPath });
|
|
231
|
+
} catch (e) {
|
|
232
|
+
if ((e as Error).message !== '__exit__') throw e;
|
|
233
|
+
} finally {
|
|
234
|
+
logSpy.mockRestore();
|
|
235
|
+
exitSpy.mockRestore();
|
|
236
|
+
}
|
|
237
|
+
const out = output.join('\n');
|
|
238
|
+
expect(out).toContain('Commands (required):');
|
|
239
|
+
expect(out).toContain('migrate');
|
|
240
|
+
});
|
|
241
|
+
});
|
|
@@ -6,7 +6,11 @@ import {
|
|
|
6
6
|
getLogger,
|
|
7
7
|
emitRunSummary,
|
|
8
8
|
_resetForTest,
|
|
9
|
+
_handleUncaught,
|
|
10
|
+
buildExcStructured,
|
|
11
|
+
formatCompactTrace,
|
|
9
12
|
RUN_SUMMARY_LOGGER,
|
|
13
|
+
EXCEPTION_LOGGER,
|
|
10
14
|
} from '../src/logging_setup';
|
|
11
15
|
|
|
12
16
|
function tmpDir(): string {
|
|
@@ -260,3 +264,98 @@ test('sudo user_target written to audit record', () => {
|
|
|
260
264
|
expect(summary.extra.user).toBe('alice');
|
|
261
265
|
expect(summary.extra.user_target).toBe('root');
|
|
262
266
|
});
|
|
267
|
+
|
|
268
|
+
// ── uncaught exceptions ────────────────────────────────────────────────────────
|
|
269
|
+
|
|
270
|
+
function readExcRecord(dir: string): Record<string, any> | undefined {
|
|
271
|
+
const content = fs.readFileSync(path.join(dir, 'logs', 'myscript.log'), 'utf-8');
|
|
272
|
+
return content.trim().split('\n').map(l => JSON.parse(l)).find(o => o.logger === EXCEPTION_LOGGER);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
test('uncaught exception writes a structured record even with summary off', () => {
|
|
276
|
+
const dir = tmpDir();
|
|
277
|
+
configureLogging(makeCfg(dir, { summary: false }));
|
|
278
|
+
const cap = captureStderr();
|
|
279
|
+
_handleUncaught(new TypeError('invalid quality 200'));
|
|
280
|
+
cap.restore();
|
|
281
|
+
const rec = readExcRecord(dir);
|
|
282
|
+
expect(rec).toBeDefined();
|
|
283
|
+
expect(rec!.level).toBe('CRITICAL');
|
|
284
|
+
expect(rec!.exc_structured.type).toBe('TypeError');
|
|
285
|
+
expect(rec!.exc_structured.message).toBe('invalid quality 200');
|
|
286
|
+
expect(Array.isArray(rec!.exc_structured.frames)).toBe(true);
|
|
287
|
+
expect(rec!.exc).toBeDefined(); // full stack string also present
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test('without --debug the console shows a one-liner, not a traceback', () => {
|
|
291
|
+
const dir = tmpDir();
|
|
292
|
+
configureLogging(makeCfg(dir, { debug: false }));
|
|
293
|
+
const cap = captureStderr();
|
|
294
|
+
_handleUncaught(new Error('boom'));
|
|
295
|
+
cap.restore();
|
|
296
|
+
const joined = cap.lines.join('');
|
|
297
|
+
expect(joined).toContain('ERROR: Error: boom');
|
|
298
|
+
expect(joined).toContain('run with --debug');
|
|
299
|
+
expect(joined).not.toContain('\n at '); // no raw V8 stack dump
|
|
300
|
+
expect(readExcRecord(dir)).toBeDefined();
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test('with --debug the console shows a compact aligned trace', () => {
|
|
304
|
+
const dir = tmpDir();
|
|
305
|
+
configureLogging(makeCfg(dir, { debug: true }));
|
|
306
|
+
const cap = captureStderr();
|
|
307
|
+
_handleUncaught(new Error('boom'));
|
|
308
|
+
cap.restore();
|
|
309
|
+
const joined = cap.lines.join('');
|
|
310
|
+
expect(joined).toContain('Error: boom');
|
|
311
|
+
expect(joined).not.toContain('run with --debug'); // hint only in quiet mode
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test('the exception record is not echoed to the console handlers', () => {
|
|
315
|
+
const dir = tmpDir();
|
|
316
|
+
const stdoutLines: string[] = [];
|
|
317
|
+
const stderrLines: string[] = [];
|
|
318
|
+
const o = jest.spyOn(process.stdout, 'write').mockImplementation((c) => { stdoutLines.push(String(c)); return true; });
|
|
319
|
+
const e = jest.spyOn(process.stderr, 'write').mockImplementation((c) => { stderrLines.push(String(c)); return true; });
|
|
320
|
+
configureLogging(makeCfg(dir, { debug: false }));
|
|
321
|
+
_handleUncaught(new Error('boom'));
|
|
322
|
+
o.mockRestore();
|
|
323
|
+
e.mockRestore();
|
|
324
|
+
// The structured JSON record is file-only; only our one-liner hits stderr.
|
|
325
|
+
expect(stdoutLines.join('')).not.toContain('uncaught exception');
|
|
326
|
+
expect(stderrLines.join('')).not.toContain('"logger":"runspec.exception"');
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test('buildExcStructured parses frames innermost-last with module', () => {
|
|
330
|
+
const err = new Error('x');
|
|
331
|
+
err.stack = [
|
|
332
|
+
'Error: x',
|
|
333
|
+
' at inner (/app/deep.js:5:10)',
|
|
334
|
+
' at outer (/app/main.js:20:3)',
|
|
335
|
+
].join('\n');
|
|
336
|
+
const es = buildExcStructured(err);
|
|
337
|
+
expect(es.type).toBe('Error');
|
|
338
|
+
expect(es.frames.map(f => f.func)).toEqual(['outer', 'inner']); // innermost last
|
|
339
|
+
expect(es.module).toBe('deep');
|
|
340
|
+
expect(es.frames[0]).toEqual({ func: 'outer', file: '/app/main.js', line: 20, code: null });
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test('formatCompactTrace drops internal runspec frames but keeps user frames', () => {
|
|
344
|
+
const err = new Error('x');
|
|
345
|
+
// RUNSPEC_PKG_DIR is the dir of the logging_setup module (the package source).
|
|
346
|
+
const pkgDir = path.resolve(__dirname, '..', 'src');
|
|
347
|
+
const frames = [
|
|
348
|
+
{ func: 'parse', file: path.join(pkgDir, 'parser.ts'), line: 10, code: null },
|
|
349
|
+
{ func: 'main', file: '/app/deploy.js', line: 42, code: null },
|
|
350
|
+
];
|
|
351
|
+
const out = formatCompactTrace(err, frames);
|
|
352
|
+
expect(out).toContain('deploy.js:42');
|
|
353
|
+
expect(out).not.toContain('parser.ts:10');
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test('formatCompactTrace falls back to full list when every frame is internal', () => {
|
|
357
|
+
const err = new Error('x');
|
|
358
|
+
const pkgDir = path.resolve(__dirname, '..', 'src');
|
|
359
|
+
const frames = [{ func: 'parse', file: path.join(pkgDir, 'parser.ts'), line: 10, code: null }];
|
|
360
|
+
expect(formatCompactTrace(err, frames)).toContain('parser.ts:10');
|
|
361
|
+
});
|
package/tests/test_types.test.ts
CHANGED
|
@@ -15,6 +15,38 @@ test('coerces number to string', () => {
|
|
|
15
15
|
expect(coerce(42, spec({ type: 'str' }))).toBe('42');
|
|
16
16
|
});
|
|
17
17
|
|
|
18
|
+
// ── str validation: pattern / min-length / max-length (str only) ───────────────
|
|
19
|
+
|
|
20
|
+
test('str matches pattern', () => {
|
|
21
|
+
expect(coerce('PROJ-123', spec({ type: 'str', pattern: '[A-Z]+-[0-9]+' }))).toBe('PROJ-123');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('str violates pattern', () => {
|
|
25
|
+
expect(() => coerce('proj-123', spec({ type: 'str', pattern: '[A-Z]+-[0-9]+' }))).toThrow();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('pattern is anchored (fullmatch, not substring)', () => {
|
|
29
|
+
expect(() => coerce('PROJ-123-extra', spec({ type: 'str', pattern: '[A-Z]+-[0-9]+' }))).toThrow();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('anchored pattern wraps top-level alternation correctly', () => {
|
|
33
|
+
// ^(?:a|b)$ accepts only "a" or "b" exactly, not a string ending in "b".
|
|
34
|
+
expect(coerce('a', spec({ type: 'str', pattern: 'a|b' }))).toBe('a');
|
|
35
|
+
expect(() => coerce('xb', spec({ type: 'str', pattern: 'a|b' }))).toThrow();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('str respects minLength', () => {
|
|
39
|
+
expect(() => coerce('ab', spec({ type: 'str', minLength: 3 }))).toThrow();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('str respects maxLength', () => {
|
|
43
|
+
expect(() => coerce('abcdef', spec({ type: 'str', maxLength: 5 }))).toThrow();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('str within length bounds passes', () => {
|
|
47
|
+
expect(coerce('hello', spec({ type: 'str', minLength: 3, maxLength: 10 }))).toBe('hello');
|
|
48
|
+
});
|
|
49
|
+
|
|
18
50
|
// ── int ───────────────────────────────────────────────────────────────────────
|
|
19
51
|
|
|
20
52
|
test('coerces integer string', () => {
|
|
@@ -97,6 +129,47 @@ test('rejects invalid choice', () => {
|
|
|
97
129
|
expect(() => coerce('xml', spec({ type: 'choice', options: ['json', 'csv'] }))).toThrow();
|
|
98
130
|
});
|
|
99
131
|
|
|
132
|
+
// ── multiple (per-item coercion) ───────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
test('multiple returns a list of coerced items', () => {
|
|
135
|
+
expect(coerce(['a', 'b', 'c'], spec({ type: 'str', multiple: true }))).toEqual(['a', 'b', 'c']);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('multiple int coerces each item', () => {
|
|
139
|
+
expect(coerce(['1', '2', '3'], spec({ type: 'int', multiple: true }))).toEqual([1, 2, 3]);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('multiple applies pattern per item', () => {
|
|
143
|
+
const s = spec({ type: 'str', multiple: true, pattern: '[a-z]+' });
|
|
144
|
+
expect(coerce(['ab', 'cd'], s)).toEqual(['ab', 'cd']);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('multiple wraps a scalar value into a one-item list', () => {
|
|
148
|
+
expect(coerce('solo', spec({ type: 'str', multiple: true }))).toEqual(['solo']);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('multiple collects all failing items with index and value', () => {
|
|
152
|
+
const s = spec({ name: 'tag', type: 'str', multiple: true, pattern: '[a-z]+' });
|
|
153
|
+
let msg = '';
|
|
154
|
+
try {
|
|
155
|
+
coerce(['ab', 'XY', 'z9', 'de'], s);
|
|
156
|
+
} catch (e) {
|
|
157
|
+
msg = (e as Error).message;
|
|
158
|
+
}
|
|
159
|
+
expect(msg).toContain('2 of 4 item(s) failed');
|
|
160
|
+
expect(msg).toContain('item 2 ("XY")');
|
|
161
|
+
expect(msg).toContain('item 3 ("z9")');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('multiple range applies per item', () => {
|
|
165
|
+
const s = spec({ name: 'n', type: 'int', multiple: true, range: [1, 10] });
|
|
166
|
+
expect(() => coerce(['5', '99'], s)).toThrow(/item 2 \("99"\)/);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('rest type is not treated as per-item', () => {
|
|
170
|
+
expect(coerce(['--flag', 'value'], spec({ type: 'rest' }))).toEqual(['--flag', 'value']);
|
|
171
|
+
});
|
|
172
|
+
|
|
100
173
|
// ── custom types ──────────────────────────────────────────────────────────────
|
|
101
174
|
|
|
102
175
|
test('registerType adds custom coercer', () => {
|