mmt-testlight 0.4.4 → 1.40.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/README.md +1 -1
- package/dist/cli.js +56348 -56511
- 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 +27 -0
- package/package.json +2 -1
- package/src/aiDocs.ts +88 -0
- package/src/cli.ts +283 -7
- package/src/mockRunner.ts +25 -116
- package/src/pathNormalize.cjs +16 -0
- package/src/pathNormalize.test.ts +8 -0
- package/src/pkg-entry.cjs +36 -6
- 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
|
|
|
@@ -269,6 +273,13 @@ program.name('testlight')
|
|
|
269
273
|
' testlight run path/to/test.mmt',
|
|
270
274
|
' testlight run path/to/test.mmt -F env.mmt -P runner.dev -P custom.prod',
|
|
271
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',
|
|
272
283
|
'',
|
|
273
284
|
'Run `testlight <command> --help` for command-specific options.',
|
|
274
285
|
].join('\n'));
|
|
@@ -315,8 +326,12 @@ program.command('run')
|
|
|
315
326
|
const {runJSCode, setRunnerNetworkConfig} = await loadJsRunnerModule();
|
|
316
327
|
const full = resolveUserPath(file, process.cwd(), path);
|
|
317
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.
|
|
318
331
|
const raw =
|
|
319
|
-
/\.json$/i.test(full) ?
|
|
332
|
+
/\.json$/i.test(full) ?
|
|
333
|
+
JSON.parse(rawText) :
|
|
334
|
+
yaml.load(mmtcore.testParsePack.quoteExpectOperators(rawText));
|
|
320
335
|
const summary = summarize(raw);
|
|
321
336
|
if (!opts.quiet) {
|
|
322
337
|
console.log(`Loaded: ${full} (${summary})`);
|
|
@@ -380,8 +395,7 @@ program.command('run')
|
|
|
380
395
|
}
|
|
381
396
|
}
|
|
382
397
|
if (outFile) {
|
|
383
|
-
const outPath =
|
|
384
|
-
fs.writeFileSync(outPath, JSON.stringify(result, null, 2), 'utf8');
|
|
398
|
+
const outPath = writeTextFile(outFile, JSON.stringify(result, null, 2));
|
|
385
399
|
if (!opts.quiet) {
|
|
386
400
|
console.log(`Result written: ${outPath}`);
|
|
387
401
|
}
|
|
@@ -402,8 +416,7 @@ program.command('run')
|
|
|
402
416
|
reportContent = serializer(collectedResults);
|
|
403
417
|
}
|
|
404
418
|
if (reportContent) {
|
|
405
|
-
const reportPath =
|
|
406
|
-
fs.writeFileSync(reportPath, reportContent, 'utf8');
|
|
419
|
+
const reportPath = writeTextFile(reportFile, reportContent);
|
|
407
420
|
if (!opts.quiet) {
|
|
408
421
|
console.log(`Report written: ${reportPath}`);
|
|
409
422
|
}
|
|
@@ -554,6 +567,269 @@ program.command('version-info')
|
|
|
554
567
|
console.log('Node:', process.version);
|
|
555
568
|
});
|
|
556
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
|
+
|
|
557
833
|
program.command('doc')
|
|
558
834
|
.argument('<file>', 'Doc file (.mmt/.yaml/.yml)')
|
|
559
835
|
.description('Generate documentation from a doc .mmt')
|
|
@@ -654,7 +930,7 @@ program.command('doc')
|
|
|
654
930
|
path.resolve(
|
|
655
931
|
process.cwd(),
|
|
656
932
|
`${path.basename(full, path.extname(full))}${defExt}`);
|
|
657
|
-
|
|
933
|
+
writeTextFile(outPath, htmlOrMd);
|
|
658
934
|
console.log(`Doc generated: ${outPath}`);
|
|
659
935
|
} catch (e: any) {
|
|
660
936
|
console.error('Error generating doc:', e?.message || e);
|
package/src/mockRunner.ts
CHANGED
|
@@ -6,65 +6,15 @@ import fs from 'fs';
|
|
|
6
6
|
import http from 'http';
|
|
7
7
|
import https from 'https';
|
|
8
8
|
import path from 'path';
|
|
9
|
-
import yaml from 'js-yaml';
|
|
10
9
|
import * as mmtcore from 'mmt-core';
|
|
11
10
|
import {findProjectRootSync, resolveCertFilePath} from 'mmt-core/fileHelper';
|
|
11
|
+
import {dispatchMockHttpRequest} from 'mmt-core/mockDispatch';
|
|
12
|
+
import {buildMockHttpsOptions} from 'mmt-core/mockTlsMaterial';
|
|
12
13
|
|
|
13
14
|
const {mockParsePack, mockServer, variableReplacer} = mmtcore;
|
|
14
15
|
|
|
15
|
-
type GeneratedTlsMaterial = {
|
|
16
|
-
cert: string;
|
|
17
|
-
key: string;
|
|
18
|
-
};
|
|
19
|
-
|
|
20
16
|
/** Track active servers so we can clean them all up at exit. */
|
|
21
17
|
const activeServers = new Map<string, {server: http.Server | https.Server; port: number; dispose: () => void}>();
|
|
22
|
-
let generatedDefaultTlsMaterial: GeneratedTlsMaterial | undefined;
|
|
23
|
-
|
|
24
|
-
function getDefaultMockTlsMaterial(): GeneratedTlsMaterial {
|
|
25
|
-
if (generatedDefaultTlsMaterial) {
|
|
26
|
-
return generatedDefaultTlsMaterial;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
// Generate a localhost-only self-signed cert at runtime so the CLI
|
|
30
|
-
// does not embed or distribute a static private key.
|
|
31
|
-
const forge = require('node-forge');
|
|
32
|
-
const keys = forge.pki.rsa.generateKeyPair(2048);
|
|
33
|
-
const certificate = forge.pki.createCertificate();
|
|
34
|
-
const now = new Date();
|
|
35
|
-
const expiresAt = new Date(now);
|
|
36
|
-
expiresAt.setFullYear(expiresAt.getFullYear() + 10);
|
|
37
|
-
|
|
38
|
-
certificate.publicKey = keys.publicKey;
|
|
39
|
-
certificate.serialNumber = Math.max(Date.now(), 1).toString(16);
|
|
40
|
-
certificate.validity.notBefore = now;
|
|
41
|
-
certificate.validity.notAfter = expiresAt;
|
|
42
|
-
|
|
43
|
-
const subject = [{name: 'commonName', value: 'localhost'}];
|
|
44
|
-
certificate.setSubject(subject);
|
|
45
|
-
certificate.setIssuer(subject);
|
|
46
|
-
certificate.setExtensions([
|
|
47
|
-
{name: 'basicConstraints', cA: false},
|
|
48
|
-
{name: 'keyUsage', digitalSignature: true, keyEncipherment: true},
|
|
49
|
-
{name: 'extKeyUsage', serverAuth: true},
|
|
50
|
-
{
|
|
51
|
-
name: 'subjectAltName',
|
|
52
|
-
altNames: [
|
|
53
|
-
{type: 2, value: 'localhost'},
|
|
54
|
-
{type: 7, ip: '127.0.0.1'},
|
|
55
|
-
{type: 7, ip: '::1'},
|
|
56
|
-
],
|
|
57
|
-
},
|
|
58
|
-
]);
|
|
59
|
-
certificate.sign(keys.privateKey, forge.md.sha256.create());
|
|
60
|
-
|
|
61
|
-
generatedDefaultTlsMaterial = {
|
|
62
|
-
cert: forge.pki.certificateToPem(certificate),
|
|
63
|
-
key: forge.pki.privateKeyToPem(keys.privateKey),
|
|
64
|
-
};
|
|
65
|
-
|
|
66
|
-
return generatedDefaultTlsMaterial;
|
|
67
|
-
}
|
|
68
18
|
|
|
69
19
|
function resolveFilePath(relative: string, basePath: string): string {
|
|
70
20
|
return resolveCertFilePath(relative, {baseFilePath: basePath});
|
|
@@ -78,26 +28,11 @@ function createHttpsMockServer(
|
|
|
78
28
|
data: any,
|
|
79
29
|
filePath: string,
|
|
80
30
|
requestHandler: http.RequestListener): https.Server {
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
const defaultTlsMaterial = hasCustomCert ? undefined : getDefaultMockTlsMaterial();
|
|
87
|
-
const tlsOptions: https.ServerOptions = {
|
|
88
|
-
cert: connection.cert ? fs.readFileSync(resolveFilePath(connection.cert, filePath)) : defaultTlsMaterial!.cert,
|
|
89
|
-
key: connection.key ? fs.readFileSync(resolveFilePath(connection.key, filePath)) : defaultTlsMaterial!.key,
|
|
90
|
-
};
|
|
91
|
-
if (connection.client_ca) {
|
|
92
|
-
tlsOptions.ca = fs.readFileSync(resolveFilePath(connection.client_ca, filePath));
|
|
93
|
-
}
|
|
94
|
-
if (connection.mode === 'mtls') {
|
|
95
|
-
if (!connection.client_ca) {
|
|
96
|
-
throw new Error('connection.client_ca is required when connection.mode is mtls');
|
|
97
|
-
}
|
|
98
|
-
tlsOptions.requestCert = true;
|
|
99
|
-
tlsOptions.rejectUnauthorized = true;
|
|
100
|
-
}
|
|
31
|
+
const tlsOptions = buildMockHttpsOptions(
|
|
32
|
+
data.connection,
|
|
33
|
+
(abs) => fs.readFileSync(abs),
|
|
34
|
+
(rel) => resolveFilePath(rel, filePath),
|
|
35
|
+
);
|
|
101
36
|
return https.createServer(tlsOptions, requestHandler);
|
|
102
37
|
}
|
|
103
38
|
|
|
@@ -116,10 +51,10 @@ export async function startMockServerFromPath(
|
|
|
116
51
|
}
|
|
117
52
|
|
|
118
53
|
const rawContent = fs.readFileSync(filePath, 'utf-8');
|
|
119
|
-
let
|
|
54
|
+
let processedContent = rawContent;
|
|
120
55
|
try {
|
|
121
56
|
const processor = (mmtcore as any).dataImportProcessor;
|
|
122
|
-
|
|
57
|
+
processedContent = processor?.processDataImportsInYaml ?
|
|
123
58
|
await processor.processDataImportsInYaml({
|
|
124
59
|
rawText: rawContent,
|
|
125
60
|
filePath,
|
|
@@ -127,14 +62,13 @@ export async function startMockServerFromPath(
|
|
|
127
62
|
fileLoader: async (p: string) => fs.readFileSync(p, 'utf-8'),
|
|
128
63
|
}) :
|
|
129
64
|
rawContent;
|
|
130
|
-
parsed = yaml.load(processedContent);
|
|
131
65
|
} catch (err: any) {
|
|
132
66
|
throw new Error(`Mock server: YAML parse error in ${path.basename(filePath)}: ${err.message}`);
|
|
133
67
|
}
|
|
134
68
|
|
|
135
|
-
const {data, errors} = mockParsePack.
|
|
69
|
+
const {data, errors} = mockParsePack.loadMockFromYaml(processedContent);
|
|
136
70
|
if (errors.length > 0 || !data) {
|
|
137
|
-
const msg = errors.map((e: any) => e.message).join('; ');
|
|
71
|
+
const msg = errors.map((e: any) => e.message).join('; ') || 'Invalid mock server file';
|
|
138
72
|
throw new Error(`Mock server validation errors in ${path.basename(filePath)}: ${msg}`);
|
|
139
73
|
}
|
|
140
74
|
|
|
@@ -189,55 +123,30 @@ export async function startMockServerFromPath(
|
|
|
189
123
|
let body = '';
|
|
190
124
|
req.on('data', (chunk: Buffer) => { body += chunk; });
|
|
191
125
|
req.on('end', async () => {
|
|
192
|
-
let
|
|
193
|
-
const queryObj: Record<string, string> = {};
|
|
194
|
-
const qIdx = urlStr.indexOf('?');
|
|
195
|
-
if (qIdx >= 0) {
|
|
196
|
-
pathname = urlStr.slice(0, qIdx);
|
|
197
|
-
const searchParams = new URLSearchParams(urlStr.slice(qIdx + 1));
|
|
198
|
-
searchParams.forEach((v, k) => { queryObj[k] = v; });
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
const parsedBody = mockServer.parseRequestBody(body, (req.headers || {}) as Record<string, string>);
|
|
202
|
-
|
|
203
|
-
const mockReq = {
|
|
204
|
-
method,
|
|
205
|
-
path: pathname,
|
|
206
|
-
headers: (req.headers || {}) as Record<string, string>,
|
|
207
|
-
query: queryObj,
|
|
208
|
-
body: parsedBody,
|
|
209
|
-
};
|
|
210
|
-
|
|
211
|
-
let mockRes: ReturnType<typeof router>;
|
|
126
|
+
let result: ReturnType<typeof dispatchMockHttpRequest>;
|
|
212
127
|
try {
|
|
213
|
-
|
|
128
|
+
result = dispatchMockHttpRequest(router, {
|
|
129
|
+
method,
|
|
130
|
+
url: urlStr,
|
|
131
|
+
headers: (req.headers || {}) as Record<string, string>,
|
|
132
|
+
rawBody: body,
|
|
133
|
+
resolveHeaderToken: (v) => String(variableReplacer.resolveEmbeddedTokens(v, envVars)),
|
|
134
|
+
});
|
|
214
135
|
} catch (err: any) {
|
|
215
136
|
res.statusCode = 500;
|
|
216
137
|
res.end(JSON.stringify({error: 'Mock router error', message: err.message}));
|
|
217
138
|
return;
|
|
218
139
|
}
|
|
219
140
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
await new Promise<void>(resolve => setTimeout(resolve, mockRes.delay));
|
|
141
|
+
if (result.delay > 0) {
|
|
142
|
+
await new Promise<void>(resolve => setTimeout(resolve, result.delay));
|
|
223
143
|
}
|
|
224
144
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
if (typeof v === 'string') {
|
|
229
|
-
res.setHeader(k, String(variableReplacer.resolveEmbeddedTokens(v, envVars)));
|
|
230
|
-
} else {
|
|
231
|
-
res.setHeader(k, v);
|
|
232
|
-
}
|
|
233
|
-
}
|
|
145
|
+
res.statusCode = result.status;
|
|
146
|
+
for (const [k, v] of Object.entries(result.headers)) {
|
|
147
|
+
res.setHeader(k, v);
|
|
234
148
|
}
|
|
235
|
-
|
|
236
|
-
res.statusCode = mockRes.status;
|
|
237
|
-
const responseBody = mockRes.body !== undefined ? (
|
|
238
|
-
typeof mockRes.body === 'string' ? mockRes.body : JSON.stringify(mockRes.body)
|
|
239
|
-
) : '';
|
|
240
|
-
res.end(responseBody);
|
|
149
|
+
res.end(result.body);
|
|
241
150
|
});
|
|
242
151
|
};
|
|
243
152
|
|
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
|
});
|
package/src/pkg-entry.cjs
CHANGED
|
@@ -183,17 +183,47 @@ function createPkgJsRunner() {
|
|
|
183
183
|
);
|
|
184
184
|
|
|
185
185
|
// Wrap send_ with trace-level logging when requested
|
|
186
|
-
|
|
186
|
+
let formatHttpTraceRequest = (req) => {
|
|
187
187
|
const reqSummary = req ? `${(req.method || 'GET').toUpperCase()} ${req.url || ''}` : 'unknown';
|
|
188
|
-
|
|
188
|
+
return `Request: ${reqSummary}`;
|
|
189
|
+
};
|
|
190
|
+
let formatHttpTraceResponse = (args) => {
|
|
191
|
+
if (args.error) {
|
|
192
|
+
return `Response: error - ${args.error}`;
|
|
193
|
+
}
|
|
194
|
+
const status = args.status ?? '?';
|
|
195
|
+
const duration = args.durationMs != null ? ` (${args.durationMs}ms)` : '';
|
|
196
|
+
return `Response: ${status}${duration}`;
|
|
197
|
+
};
|
|
198
|
+
try {
|
|
199
|
+
// eslint-disable-next-line global-require
|
|
200
|
+
const httpTraceLog = require('../../core/dist/httpTraceLog.js');
|
|
201
|
+
if (httpTraceLog && httpTraceLog.formatHttpTraceRequest) {
|
|
202
|
+
formatHttpTraceRequest = httpTraceLog.formatHttpTraceRequest;
|
|
203
|
+
formatHttpTraceResponse = httpTraceLog.formatHttpTraceResponse;
|
|
204
|
+
}
|
|
205
|
+
} catch { /* optional */ }
|
|
206
|
+
const sendFn = traceSend ? async (req) => {
|
|
207
|
+
lg('trace', formatHttpTraceRequest({
|
|
208
|
+
method: req && req.method,
|
|
209
|
+
url: req && req.url,
|
|
210
|
+
headers: req && req.headers,
|
|
211
|
+
query: req && req.query,
|
|
212
|
+
body: req && req.body,
|
|
213
|
+
}));
|
|
189
214
|
try {
|
|
190
215
|
const res = await networkCore.send(req);
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
216
|
+
lg('trace', formatHttpTraceResponse({
|
|
217
|
+
status: res && typeof res.status === 'number' ? res.status : '?',
|
|
218
|
+
durationMs: res && typeof res.duration === 'number' ? res.duration : undefined,
|
|
219
|
+
headers: res && res.headers,
|
|
220
|
+
body: res && res.body,
|
|
221
|
+
}));
|
|
194
222
|
return res;
|
|
195
223
|
} catch (err) {
|
|
196
|
-
lg('trace',
|
|
224
|
+
lg('trace', formatHttpTraceResponse({
|
|
225
|
+
error: err && err.message ? err.message : String(err),
|
|
226
|
+
}));
|
|
197
227
|
throw err;
|
|
198
228
|
}
|
|
199
229
|
} : networkCore.send;
|