mmt-testlight 0.4.3 → 0.4.5
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/README.md +1 -1
- package/dist/cli.js +26899 -9530
- package/dist/guides/agent-workflow.md +75 -0
- package/dist/guides/general.md +64 -0
- package/dist/guides/generate-api.md +221 -0
- package/dist/guides/generate-doc.md +103 -0
- package/dist/guides/generate-env.md +147 -0
- package/dist/guides/generate-loadtest.md +55 -0
- package/dist/guides/generate-suite.md +167 -0
- package/dist/guides/generate-test-skill.md +60 -0
- package/dist/guides/generate-test.md +335 -0
- package/dist/guides/generate.md +60 -0
- package/dist/guides/golden-smoke.md +52 -0
- package/dist/guides/min/api.md +29 -0
- package/dist/guides/min/constraints.md +9 -0
- package/dist/guides/min/doc.md +17 -0
- package/dist/guides/min/env.md +26 -0
- package/dist/guides/min/loadtest.md +17 -0
- package/dist/guides/min/overview.md +25 -0
- package/dist/guides/min/suite.md +18 -0
- package/dist/guides/min/test.md +42 -0
- package/dist/guides/min/workflow.md +16 -0
- package/dist/guides/offline-agent.md +46 -0
- package/esbuild.mjs +25 -0
- package/package.json +2 -1
- package/src/aiDocs.ts +88 -0
- package/src/cli.ts +315 -24
- package/src/pathNormalize.cjs +16 -0
- package/src/pathNormalize.test.ts +8 -0
- package/src/pkg-entry.cjs +75 -40
- package/src/runArgs.ts +4 -4
- package/src/selfUpdate.test.ts +79 -0
- package/src/selfUpdate.ts +471 -0
- package/src/validateMmt.ts +45 -0
package/src/cli.ts
CHANGED
|
@@ -10,13 +10,17 @@ import path from 'path';
|
|
|
10
10
|
import {createRequire} from 'module';
|
|
11
11
|
|
|
12
12
|
const requireFromCli = createRequire(__filename);
|
|
13
|
-
const {resolveUserPath} = requireFromCli('../src/pathNormalize.cjs') as {
|
|
13
|
+
const {resolveUserPath, writeTextFile} = requireFromCli('../src/pathNormalize.cjs') as {
|
|
14
14
|
resolveUserPath: (input: string, baseDir?: string, pathMod?: typeof path) => string;
|
|
15
|
+
writeTextFile: (filePath: string, content: string) => string;
|
|
15
16
|
};
|
|
16
17
|
|
|
17
18
|
import {summarize} from './loadTest.js';
|
|
18
19
|
import {startMockServerFromPath, stopAllServers} from './mockRunner.js';
|
|
19
20
|
import {buildCliRunArgs} from './runArgs.js';
|
|
21
|
+
import {formatCliDocs, listCliDocTopics} from './aiDocs.js';
|
|
22
|
+
import {resolveValidatePath, validateMmtFile} from './validateMmt.js';
|
|
23
|
+
import {runUpdate} from './selfUpdate.js';
|
|
20
24
|
|
|
21
25
|
// Defer importing runTest until needed to avoid pulling axios for to-js
|
|
22
26
|
|
|
@@ -35,6 +39,17 @@ function resolveCliVersion(): string {
|
|
|
35
39
|
}
|
|
36
40
|
const CLI_VERSION = resolveCliVersion();
|
|
37
41
|
|
|
42
|
+
function collectPreset(value: string, previous: string[]): string[] {
|
|
43
|
+
const names: string[] = [];
|
|
44
|
+
for (const part of String(value).split(',')) {
|
|
45
|
+
const name = part.trim();
|
|
46
|
+
if (name) {
|
|
47
|
+
names.push(name);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return previous.concat(names.length ? names : [value]);
|
|
51
|
+
}
|
|
52
|
+
|
|
38
53
|
type JsRunnerModule = typeof import('mmt-core/jsRunner');
|
|
39
54
|
let jsRunnerModulePromise: Promise<JsRunnerModule>|undefined;
|
|
40
55
|
|
|
@@ -247,23 +262,30 @@ program.name('testlight')
|
|
|
247
262
|
' -o, --out <file> Write result JSON to file',
|
|
248
263
|
' -i, --input <k=v...> Input variables (repeatable)',
|
|
249
264
|
' -e, --env <k=v...> Environment variables (repeatable)',
|
|
250
|
-
' --env-file <path>
|
|
251
|
-
' --preset <name>
|
|
252
|
-
' --example <name|#n>
|
|
265
|
+
' -F, --env-file <path> Environment file (.mmt/.yaml)',
|
|
266
|
+
' -P, --preset <name> Preset from env file (repeatable)',
|
|
267
|
+
' -x, --example <name|#n> Named example or index (#1 is first)',
|
|
253
268
|
' -p, --print-js Print generated JS before executing',
|
|
254
|
-
' --report <format>
|
|
255
|
-
' --report-file <path>
|
|
269
|
+
' -r, --report <format> junit | mmt | html | md | md-detailed',
|
|
270
|
+
' -R, --report-file <path> Report output path',
|
|
256
271
|
'',
|
|
257
272
|
'Examples:',
|
|
258
273
|
' testlight run path/to/test.mmt',
|
|
259
|
-
' testlight run path/to/test.mmt -
|
|
274
|
+
' testlight run path/to/test.mmt -F env.mmt -P runner.dev -P custom.prod',
|
|
260
275
|
' testlight run path/to/suite.mmt --report html',
|
|
276
|
+
' testlight scaffold test --from path/to/api.mmt',
|
|
277
|
+
' testlight scaffold test --from path/to/api.mmt -o tests/api-smoke.mmt',
|
|
278
|
+
' testlight docs test',
|
|
279
|
+
' testlight validate path/to/test.mmt',
|
|
280
|
+
' testlight suggest asserts --from path/to/api.mmt',
|
|
281
|
+
' testlight update',
|
|
282
|
+
' testlight update --check',
|
|
261
283
|
'',
|
|
262
284
|
'Run `testlight <command> --help` for command-specific options.',
|
|
263
285
|
].join('\n'));
|
|
264
286
|
|
|
265
287
|
program.option(
|
|
266
|
-
'--log-level <level>',
|
|
288
|
+
'-L, --log-level <level>',
|
|
267
289
|
'Set log level (error|warn|info|debug|trace)',
|
|
268
290
|
'info');
|
|
269
291
|
|
|
@@ -279,20 +301,22 @@ program.command('run')
|
|
|
279
301
|
'-e, --env <values...>',
|
|
280
302
|
'Environment variables as key value pairs or key=val (repeatable)')
|
|
281
303
|
.option(
|
|
282
|
-
'--env-file <path>',
|
|
304
|
+
'-F, --env-file <path>',
|
|
283
305
|
'Environment file (.mmt/.yaml) to read variables from')
|
|
284
306
|
.option(
|
|
285
|
-
'--preset <name>',
|
|
286
|
-
'Preset
|
|
307
|
+
'-P, --preset <name>',
|
|
308
|
+
'Preset from env file (repeatable; e.g. runner.dev or group.name)',
|
|
309
|
+
collectPreset,
|
|
310
|
+
[])
|
|
287
311
|
.option(
|
|
288
|
-
'--example <name|#n>',
|
|
312
|
+
'-x, --example <name|#n>',
|
|
289
313
|
'Run a named example (matches name) or numeric index (#1 = first)')
|
|
290
314
|
.option('-p, --print-js', 'Print generated JS before executing', false)
|
|
291
315
|
.option(
|
|
292
|
-
'--report <format>',
|
|
316
|
+
'-r, --report <format>',
|
|
293
317
|
'Generate test report: junit, mmt, html, md, or md-detailed')
|
|
294
318
|
.option(
|
|
295
|
-
'--report-file <path>',
|
|
319
|
+
'-R, --report-file <path>',
|
|
296
320
|
'Output path for the report file (default depends on format)')
|
|
297
321
|
.option(
|
|
298
322
|
'--no-real-threads',
|
|
@@ -302,8 +326,12 @@ program.command('run')
|
|
|
302
326
|
const {runJSCode, setRunnerNetworkConfig} = await loadJsRunnerModule();
|
|
303
327
|
const full = resolveUserPath(file, process.cwd(), path);
|
|
304
328
|
const rawText = fs.readFileSync(full, 'utf8');
|
|
329
|
+
// Quote unsafe expect/check operators (!=, >60%, …) before js-yaml
|
|
330
|
+
// so summarize does not throw on valid .mmt that the runner accepts.
|
|
305
331
|
const raw =
|
|
306
|
-
/\.json$/i.test(full) ?
|
|
332
|
+
/\.json$/i.test(full) ?
|
|
333
|
+
JSON.parse(rawText) :
|
|
334
|
+
yaml.load(mmtcore.testParsePack.quoteExpectOperators(rawText));
|
|
307
335
|
const summary = summarize(raw);
|
|
308
336
|
if (!opts.quiet) {
|
|
309
337
|
console.log(`Loaded: ${full} (${summary})`);
|
|
@@ -367,8 +395,7 @@ program.command('run')
|
|
|
367
395
|
}
|
|
368
396
|
}
|
|
369
397
|
if (outFile) {
|
|
370
|
-
const outPath =
|
|
371
|
-
fs.writeFileSync(outPath, JSON.stringify(result, null, 2), 'utf8');
|
|
398
|
+
const outPath = writeTextFile(outFile, JSON.stringify(result, null, 2));
|
|
372
399
|
if (!opts.quiet) {
|
|
373
400
|
console.log(`Result written: ${outPath}`);
|
|
374
401
|
}
|
|
@@ -389,8 +416,7 @@ program.command('run')
|
|
|
389
416
|
reportContent = serializer(collectedResults);
|
|
390
417
|
}
|
|
391
418
|
if (reportContent) {
|
|
392
|
-
const reportPath =
|
|
393
|
-
fs.writeFileSync(reportPath, reportContent, 'utf8');
|
|
419
|
+
const reportPath = writeTextFile(reportFile, reportContent);
|
|
394
420
|
if (!opts.quiet) {
|
|
395
421
|
console.log(`Report written: ${reportPath}`);
|
|
396
422
|
}
|
|
@@ -498,13 +524,15 @@ program.command('print-js')
|
|
|
498
524
|
'-e, --env <values...>',
|
|
499
525
|
'Environment variables as key value pairs or key=val (repeatable)')
|
|
500
526
|
.option(
|
|
501
|
-
'--env-file <path>',
|
|
527
|
+
'-F, --env-file <path>',
|
|
502
528
|
'Environment file (.mmt/.yaml) to read variables from')
|
|
503
529
|
.option(
|
|
504
|
-
'--preset <name>',
|
|
505
|
-
'Preset
|
|
530
|
+
'-P, --preset <name>',
|
|
531
|
+
'Preset from env file (repeatable; e.g. runner.dev or group.name)',
|
|
532
|
+
collectPreset,
|
|
533
|
+
[])
|
|
506
534
|
.option(
|
|
507
|
-
'--example <name|#n>',
|
|
535
|
+
'-x, --example <name|#n>',
|
|
508
536
|
'Select a named example (matches name) or numeric index (#1 = first)')
|
|
509
537
|
.action(async (file: string, opts: {stages?: boolean}) => {
|
|
510
538
|
try {
|
|
@@ -539,6 +567,269 @@ program.command('version-info')
|
|
|
539
567
|
console.log('Node:', process.version);
|
|
540
568
|
});
|
|
541
569
|
|
|
570
|
+
program.command('update')
|
|
571
|
+
.description(
|
|
572
|
+
'Update standalone/portal testlight binary from GitHub releases (or a mirror)')
|
|
573
|
+
.option('--check', 'Only check whether an update is available', false)
|
|
574
|
+
.option(
|
|
575
|
+
'--to <version>',
|
|
576
|
+
'Install a specific version (e.g. 1.38.1). Avoids GitHub latest lookup.')
|
|
577
|
+
.option(
|
|
578
|
+
'--channel <name>',
|
|
579
|
+
'Pick latest matching channel from GitHub (beta, rc, prerelease)')
|
|
580
|
+
.option(
|
|
581
|
+
'--force',
|
|
582
|
+
'Reinstall even when the current version is already newest',
|
|
583
|
+
false)
|
|
584
|
+
.option(
|
|
585
|
+
'--repo <owner/name>',
|
|
586
|
+
'GitHub repo for releases (default: mshobeyri/multimeter or TESTLIGHT_REPO)')
|
|
587
|
+
.option(
|
|
588
|
+
'--base-url <url>',
|
|
589
|
+
'Portal/mirror base URL: <url>/v<version>/testlight-<platform>.tar.gz|zip (or TESTLIGHT_RELEASE_BASE_URL)')
|
|
590
|
+
.action(async (opts: {
|
|
591
|
+
check?: boolean;
|
|
592
|
+
to?: string;
|
|
593
|
+
channel?: string;
|
|
594
|
+
force?: boolean;
|
|
595
|
+
repo?: string;
|
|
596
|
+
baseUrl?: string;
|
|
597
|
+
}) => {
|
|
598
|
+
try {
|
|
599
|
+
const result = await runUpdate({
|
|
600
|
+
currentVersion: CLI_VERSION,
|
|
601
|
+
checkOnly: !!opts.check,
|
|
602
|
+
version: opts.to,
|
|
603
|
+
channel: opts.channel,
|
|
604
|
+
force: !!opts.force,
|
|
605
|
+
repo: opts.repo,
|
|
606
|
+
releaseBaseUrl: opts.baseUrl,
|
|
607
|
+
});
|
|
608
|
+
console.log(result.message);
|
|
609
|
+
if (!result.ok) {
|
|
610
|
+
process.exit(2);
|
|
611
|
+
}
|
|
612
|
+
} catch (e: any) {
|
|
613
|
+
console.error('Error updating testlight:', e?.message || e);
|
|
614
|
+
process.exit(2);
|
|
615
|
+
}
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
{
|
|
619
|
+
const scaffold = program.command('scaffold').description(
|
|
620
|
+
'Scaffold Multimeter .mmt files (AI/offline-friendly)');
|
|
621
|
+
scaffold.command('test')
|
|
622
|
+
.description('Scaffold a smoke test from an API .mmt')
|
|
623
|
+
.requiredOption('--from <file>', 'Source API .mmt file')
|
|
624
|
+
.option(
|
|
625
|
+
'-s, --strategy <name>', 'smoke (default) or example', 'smoke')
|
|
626
|
+
.option('-a, --alias <name>', 'Import alias override')
|
|
627
|
+
.option(
|
|
628
|
+
'-o, --out <file>',
|
|
629
|
+
'Write test YAML to file (default: print to stdout)')
|
|
630
|
+
.action(async (
|
|
631
|
+
opts: {from: string; strategy?: string; alias?: string; out?: string}) => {
|
|
632
|
+
try {
|
|
633
|
+
const {scaffoldTestFromApi, buildApiDetailsSummary, suggestTestPath} =
|
|
634
|
+
await import('mmt-core/testScaffold');
|
|
635
|
+
const apiFull = resolveUserPath(opts.from, process.cwd(), path);
|
|
636
|
+
if (!fs.existsSync(apiFull)) {
|
|
637
|
+
console.error(`API file not found: ${apiFull}`);
|
|
638
|
+
process.exit(2);
|
|
639
|
+
}
|
|
640
|
+
const apiText = fs.readFileSync(apiFull, 'utf8');
|
|
641
|
+
if (mmtcore.JSer.fileType(apiFull, apiText) !== 'api') {
|
|
642
|
+
console.error(`Expected type: api: ${apiFull}`);
|
|
643
|
+
process.exit(2);
|
|
644
|
+
}
|
|
645
|
+
const api = apiParsePack.yamlToAPIStrict(apiText);
|
|
646
|
+
const cwd = process.cwd();
|
|
647
|
+
const apiRel = path.relative(cwd, apiFull).replace(/\\/g, '/') ||
|
|
648
|
+
path.basename(apiFull);
|
|
649
|
+
const strategyRaw = String(opts.strategy || 'smoke').toLowerCase();
|
|
650
|
+
if (strategyRaw !== 'smoke' && strategyRaw !== 'example') {
|
|
651
|
+
console.error(`Invalid --strategy (use smoke or example): ${opts.strategy}`);
|
|
652
|
+
process.exit(2);
|
|
653
|
+
}
|
|
654
|
+
const outRel = opts.out ?
|
|
655
|
+
path.relative(cwd, resolveUserPath(opts.out, cwd, path))
|
|
656
|
+
.replace(/\\/g, '/') :
|
|
657
|
+
suggestTestPath(apiRel);
|
|
658
|
+
const summary = buildApiDetailsSummary(apiRel, api, outRel);
|
|
659
|
+
const alias = opts.alias || summary.suggestedAlias;
|
|
660
|
+
const test = scaffoldTestFromApi(api, {
|
|
661
|
+
alias,
|
|
662
|
+
importPath: summary.suggestedImportPath,
|
|
663
|
+
strategy: strategyRaw as 'smoke' | 'example',
|
|
664
|
+
});
|
|
665
|
+
const yamlOut = mmtcore.testParsePack.testToYaml(test);
|
|
666
|
+
mmtcore.testParsePack.yamlToTestStrict(yamlOut);
|
|
667
|
+
if (opts.out) {
|
|
668
|
+
const outFull = resolveUserPath(opts.out, cwd, path);
|
|
669
|
+
const outDir = path.dirname(outFull);
|
|
670
|
+
if (!fs.existsSync(outDir)) {
|
|
671
|
+
fs.mkdirSync(outDir, {recursive: true});
|
|
672
|
+
}
|
|
673
|
+
writeTextFile(outFull, yamlOut.endsWith('\n') ? yamlOut : `${yamlOut}\n`);
|
|
674
|
+
console.error(`Scaffolded: ${outFull}`);
|
|
675
|
+
} else {
|
|
676
|
+
process.stdout.write(yamlOut.endsWith('\n') ? yamlOut : `${yamlOut}\n`);
|
|
677
|
+
}
|
|
678
|
+
} catch (e: any) {
|
|
679
|
+
console.error('Error scaffolding test:', e?.message || e);
|
|
680
|
+
process.exit(2);
|
|
681
|
+
}
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
program.command('docs')
|
|
686
|
+
.description('Print bundled Multimeter AI docs (offline-friendly)')
|
|
687
|
+
.argument(
|
|
688
|
+
'[topic]',
|
|
689
|
+
`Topic: ${listCliDocTopics().join('|')}`,
|
|
690
|
+
'overview')
|
|
691
|
+
.option(
|
|
692
|
+
'-p, --pack <name>',
|
|
693
|
+
'min (default, low token) or full',
|
|
694
|
+
'min')
|
|
695
|
+
.action((topic: string, opts: {pack?: string}) => {
|
|
696
|
+
try {
|
|
697
|
+
const packRaw = String(opts.pack || 'min').toLowerCase();
|
|
698
|
+
if (packRaw !== 'min' && packRaw !== 'full') {
|
|
699
|
+
console.error(`Invalid --pack (use min or full): ${opts.pack}`);
|
|
700
|
+
process.exit(2);
|
|
701
|
+
}
|
|
702
|
+
const allowed = new Set(listCliDocTopics());
|
|
703
|
+
if (!allowed.has(topic)) {
|
|
704
|
+
console.error(`Unknown topic "${topic}". Use: ${listCliDocTopics().join(', ')}`);
|
|
705
|
+
process.exit(2);
|
|
706
|
+
}
|
|
707
|
+
const text = formatCliDocs(topic as any, packRaw as 'min'|'full');
|
|
708
|
+
process.stdout.write(text.endsWith('\n') ? text : `${text}\n`);
|
|
709
|
+
} catch (e: any) {
|
|
710
|
+
console.error('Error reading docs:', e?.message || e);
|
|
711
|
+
process.exit(2);
|
|
712
|
+
}
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
program.command('validate')
|
|
716
|
+
.description('Validate a .mmt API or test file')
|
|
717
|
+
.argument('<file>', 'Path to .mmt file')
|
|
718
|
+
.option(
|
|
719
|
+
'-t, --type <name>',
|
|
720
|
+
'Expected type: api|test')
|
|
721
|
+
.action((file: string, opts: {type?: string}) => {
|
|
722
|
+
try {
|
|
723
|
+
const full = resolveValidatePath(file);
|
|
724
|
+
if (!fs.existsSync(full)) {
|
|
725
|
+
console.error(`File not found: ${full}`);
|
|
726
|
+
process.exit(2);
|
|
727
|
+
}
|
|
728
|
+
const expected = opts.type ? String(opts.type).toLowerCase() : undefined;
|
|
729
|
+
if (expected && expected !== 'api' && expected !== 'test') {
|
|
730
|
+
console.error(`Unsupported --type (use api or test): ${opts.type}`);
|
|
731
|
+
process.exit(2);
|
|
732
|
+
}
|
|
733
|
+
const result = validateMmtFile(full, expected);
|
|
734
|
+
if (result.valid) {
|
|
735
|
+
console.log(JSON.stringify({
|
|
736
|
+
file: full,
|
|
737
|
+
valid: true,
|
|
738
|
+
detectedType: result.detectedType,
|
|
739
|
+
}, null, 2));
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
console.error(JSON.stringify({
|
|
743
|
+
file: full,
|
|
744
|
+
valid: false,
|
|
745
|
+
detectedType: result.detectedType,
|
|
746
|
+
errors: result.errors,
|
|
747
|
+
}, null, 2));
|
|
748
|
+
process.exit(1);
|
|
749
|
+
} catch (e: any) {
|
|
750
|
+
console.error('Error validating:', e?.message || e);
|
|
751
|
+
process.exit(2);
|
|
752
|
+
}
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
{
|
|
756
|
+
const suggest = program.command('suggest').description(
|
|
757
|
+
'Suggest low-token patches for .mmt files (AI/offline-friendly)');
|
|
758
|
+
suggest.command('asserts')
|
|
759
|
+
.description('Suggest expect/assert patches from API outputs or JSON body')
|
|
760
|
+
.option('--from <file>', 'API .mmt file (reads outputs)')
|
|
761
|
+
.option('--body-file <file>', 'JSON response body file')
|
|
762
|
+
.option('--body <json>', 'JSON response body string')
|
|
763
|
+
.option('--status <n>', 'HTTP status to expect', (v) => Number(v))
|
|
764
|
+
.option('--step-id <id>', 'Call step id for ${id.field} asserts')
|
|
765
|
+
.option(
|
|
766
|
+
'--style <name>', 'expect | assert | both (default both)', 'both')
|
|
767
|
+
.option('--max-fields <n>', 'Max body fields', (v) => Number(v))
|
|
768
|
+
.action(async (opts: {
|
|
769
|
+
from?: string;
|
|
770
|
+
bodyFile?: string;
|
|
771
|
+
body?: string;
|
|
772
|
+
status?: number;
|
|
773
|
+
stepId?: string;
|
|
774
|
+
style?: string;
|
|
775
|
+
maxFields?: number;
|
|
776
|
+
}) => {
|
|
777
|
+
try {
|
|
778
|
+
const {suggestAssertions} = await import('mmt-core/suggestAssertions');
|
|
779
|
+
const {safeStepIdFromAlias, suggestAliasFromPath} =
|
|
780
|
+
await import('mmt-core/testScaffold');
|
|
781
|
+
let outputs: Record<string, string>|undefined;
|
|
782
|
+
let stepId = opts.stepId;
|
|
783
|
+
if (opts.from) {
|
|
784
|
+
const apiFull = resolveUserPath(opts.from, process.cwd(), path);
|
|
785
|
+
const apiText = fs.readFileSync(apiFull, 'utf8');
|
|
786
|
+
if (mmtcore.JSer.fileType(apiFull, apiText) !== 'api') {
|
|
787
|
+
console.error(`Expected type: api: ${apiFull}`);
|
|
788
|
+
process.exit(2);
|
|
789
|
+
}
|
|
790
|
+
const api = apiParsePack.yamlToAPIStrict(apiText);
|
|
791
|
+
outputs = (api.outputs || {}) as Record<string, string>;
|
|
792
|
+
if (!stepId) {
|
|
793
|
+
const apiRel =
|
|
794
|
+
path.relative(process.cwd(), apiFull).replace(/\\/g, '/') ||
|
|
795
|
+
path.basename(apiFull);
|
|
796
|
+
stepId = safeStepIdFromAlias(suggestAliasFromPath(apiRel));
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
let body: unknown;
|
|
800
|
+
if (opts.bodyFile) {
|
|
801
|
+
const full = resolveUserPath(opts.bodyFile, process.cwd(), path);
|
|
802
|
+
body = JSON.parse(fs.readFileSync(full, 'utf8'));
|
|
803
|
+
} else if (opts.body) {
|
|
804
|
+
body = JSON.parse(opts.body);
|
|
805
|
+
}
|
|
806
|
+
if (!outputs && body === undefined && opts.status === undefined) {
|
|
807
|
+
console.error('Provide --from, --body/--body-file, and/or --status');
|
|
808
|
+
process.exit(2);
|
|
809
|
+
}
|
|
810
|
+
const styleRaw = String(opts.style || 'both').toLowerCase();
|
|
811
|
+
if (styleRaw !== 'expect' && styleRaw !== 'assert' && styleRaw !== 'both') {
|
|
812
|
+
console.error(`Invalid --style: ${opts.style}`);
|
|
813
|
+
process.exit(2);
|
|
814
|
+
}
|
|
815
|
+
const result = suggestAssertions({
|
|
816
|
+
stepId,
|
|
817
|
+
status: opts.status,
|
|
818
|
+
outputs,
|
|
819
|
+
body: body as any,
|
|
820
|
+
style: styleRaw as any,
|
|
821
|
+
maxFields: opts.maxFields,
|
|
822
|
+
});
|
|
823
|
+
process.stdout.write(
|
|
824
|
+
(result.patchHint.endsWith('\n') ? result.patchHint :
|
|
825
|
+
`${result.patchHint}\n`));
|
|
826
|
+
} catch (e: any) {
|
|
827
|
+
console.error('Error suggesting asserts:', e?.message || e);
|
|
828
|
+
process.exit(2);
|
|
829
|
+
}
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
|
|
542
833
|
program.command('doc')
|
|
543
834
|
.argument('<file>', 'Doc file (.mmt/.yaml/.yml)')
|
|
544
835
|
.description('Generate documentation from a doc .mmt')
|
|
@@ -639,7 +930,7 @@ program.command('doc')
|
|
|
639
930
|
path.resolve(
|
|
640
931
|
process.cwd(),
|
|
641
932
|
`${path.basename(full, path.extname(full))}${defExt}`);
|
|
642
|
-
|
|
933
|
+
writeTextFile(outPath, htmlOrMd);
|
|
643
934
|
console.log(`Doc generated: ${outPath}`);
|
|
644
935
|
} catch (e: any) {
|
|
645
936
|
console.error('Error generating doc:', e?.message || e);
|
package/src/pathNormalize.cjs
CHANGED
|
@@ -153,8 +153,24 @@ function resolveUserPathPreferExisting(input, baseDirs, pathMod, existsFn) {
|
|
|
153
153
|
return first;
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
+
/**
|
|
157
|
+
* Write a UTF-8 file, creating missing parent directories.
|
|
158
|
+
* @param {string} filePath
|
|
159
|
+
* @param {string} content
|
|
160
|
+
* @returns {string} resolved path
|
|
161
|
+
*/
|
|
162
|
+
function writeTextFile(filePath, content) {
|
|
163
|
+
const api = pathApi();
|
|
164
|
+
const fs = require('fs');
|
|
165
|
+
const resolved = api.resolve(String(filePath ?? ''));
|
|
166
|
+
fs.mkdirSync(api.dirname(resolved), {recursive: true});
|
|
167
|
+
fs.writeFileSync(resolved, content, 'utf8');
|
|
168
|
+
return resolved;
|
|
169
|
+
}
|
|
170
|
+
|
|
156
171
|
module.exports = {
|
|
157
172
|
normalizeUserPath,
|
|
158
173
|
resolveUserPath,
|
|
159
174
|
resolveUserPathPreferExisting,
|
|
175
|
+
writeTextFile,
|
|
160
176
|
};
|
|
@@ -7,6 +7,7 @@ const {
|
|
|
7
7
|
normalizeUserPath,
|
|
8
8
|
resolveUserPath,
|
|
9
9
|
resolveUserPathPreferExisting,
|
|
10
|
+
writeTextFile,
|
|
10
11
|
} = require('./pathNormalize.cjs');
|
|
11
12
|
|
|
12
13
|
describe('pathNormalize (Windows-shaped)', () => {
|
|
@@ -152,4 +153,11 @@ describe('pathNormalize FS layout (../../ file + env)', () => {
|
|
|
152
153
|
expect(resolvedFile).toBe(testFile);
|
|
153
154
|
expect(resolvedEnv).toBe(localEnv);
|
|
154
155
|
});
|
|
156
|
+
|
|
157
|
+
it('creates missing parent directories when writing a report file', () => {
|
|
158
|
+
const reportPath = path.join(root, 'results', 'nested', 'junit.xml');
|
|
159
|
+
const written = writeTextFile(reportPath, '<testsuites/>');
|
|
160
|
+
expect(written).toBe(reportPath);
|
|
161
|
+
expect(fs.readFileSync(reportPath, 'utf8')).toBe('<testsuites/>');
|
|
162
|
+
});
|
|
155
163
|
});
|