mmt-testlight 0.4.4 → 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/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) ? JSON.parse(rawText) : yaml.load(rawText);
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 = path.resolve(outFile);
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 = path.resolve(reportFile);
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
- fs.writeFileSync(outPath, htmlOrMd, 'utf8');
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);
@@ -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
  });
@@ -0,0 +1,79 @@
1
+ import {
2
+ buildDownloadUrl,
3
+ compareVersions,
4
+ detectInstallChannel,
5
+ detectPlatform,
6
+ normalizeVersion,
7
+ planUpdate,
8
+ } from './selfUpdate';
9
+
10
+ describe('selfUpdate helpers', () => {
11
+ it('normalizes and compares versions', () => {
12
+ expect(normalizeVersion('v1.2.3')).toBe('1.2.3');
13
+ expect(compareVersions('1.2.3', '1.2.3')).toBe(0);
14
+ expect(compareVersions('1.2.3', '1.2.4')).toBe(-1);
15
+ expect(compareVersions('1.3.0', '1.2.9')).toBe(1);
16
+ expect(compareVersions('1.2.3-beta.1', '1.2.3')).toBe(-1);
17
+ });
18
+
19
+ it('detects platforms', () => {
20
+ expect(detectPlatform('darwin', 'arm64')).toBe('macos-arm64');
21
+ expect(detectPlatform('linux', 'x64')).toBe('linux-x64');
22
+ expect(detectPlatform('win32', 'arm64')).toBe('win-x64');
23
+ });
24
+
25
+ it('detects install channels', () => {
26
+ expect(detectInstallChannel('/usr/local/bin/testlight')).toBe('standalone');
27
+ expect(detectInstallChannel(
28
+ '/opt/homebrew/Cellar/mmt-testlight/0.4.4/bin/testlight'))
29
+ .toBe('homebrew');
30
+ expect(detectInstallChannel(
31
+ '/usr/local/bin/node',
32
+ '/usr/local/lib/node_modules/mmt-testlight/dist/cli.js'))
33
+ .toBe('npm');
34
+ });
35
+
36
+ it('builds github and portal download urls', () => {
37
+ expect(buildDownloadUrl({version: '1.2.3', platform: 'linux-x64'}))
38
+ .toBe(
39
+ 'https://github.com/mshobeyri/multimeter/releases/download/v1.2.3/testlight-linux-x64.tar.gz');
40
+ expect(buildDownloadUrl({
41
+ version: '1.2.3',
42
+ platform: 'win-x64',
43
+ releaseBaseUrl: 'https://portal.example/testlight',
44
+ })).toBe('https://portal.example/testlight/v1.2.3/testlight-win-x64.zip');
45
+ });
46
+
47
+ it('plans npm advice instead of binary replace', async () => {
48
+ const plan = await planUpdate({
49
+ currentVersion: '0.4.0',
50
+ version: '0.4.4',
51
+ execPath: '/usr/local/bin/node',
52
+ scriptPath: '/usr/local/lib/node_modules/mmt-testlight/dist/cli.js',
53
+ fetchJson: async () => ({tag_name: 'v0.4.4'}),
54
+ });
55
+ expect(plan.action).toBe('advise-npm');
56
+ expect(plan.advice).toContain('npm install -g mmt-testlight@0.4.4');
57
+ });
58
+
59
+ it('plans noop when already current', async () => {
60
+ const plan = await planUpdate({
61
+ currentVersion: '1.2.3',
62
+ version: '1.2.3',
63
+ execPath: '/usr/local/bin/testlight',
64
+ fetchJson: async () => ({tag_name: 'v1.2.3'}),
65
+ });
66
+ expect(plan.action).toBe('noop');
67
+ });
68
+
69
+ it('plans download for standalone when newer', async () => {
70
+ const plan = await planUpdate({
71
+ currentVersion: '1.0.0',
72
+ version: '1.2.3',
73
+ execPath: '/opt/portal/bin/testlight',
74
+ fetchJson: async () => ({tag_name: 'v1.2.3'}),
75
+ });
76
+ expect(plan.action).toBe('download');
77
+ expect(plan.downloadUrl).toContain('/v1.2.3/testlight-');
78
+ });
79
+ });