@testspectra/cli 1.1.8-rc.1 → 1.1.8-rc.11

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.
@@ -289,6 +289,7 @@ export async function addCommand(moduleNameArg, options = {}) {
289
289
  copyRecursive(sampleE2eSrc, targetE2eDir, {
290
290
  'playground-e2e': e2eProjectName,
291
291
  'packages/playground/e2e': path.relative(rootDir, targetE2eDir).replace(/\\/g, '/'),
292
+ 'testspectra:scope:playground': `testspectra:scope:${featureName}`,
292
293
  'scope:playground': `scope:${featureName}`,
293
294
  });
294
295
  // Adjust project.json
@@ -300,7 +301,7 @@ export async function addCommand(moduleNameArg, options = {}) {
300
301
  pObj.sourceRoot = path.relative(rootDir, targetE2eDir).replace(/\\/g, '/');
301
302
  pObj.targets.e2e.options.cwd = path.relative(rootDir, targetE2eDir).replace(/\\/g, '/');
302
303
  pObj.targets['type-check'].options.cwd = '.';
303
- pObj.tags = [`scope:${featureName}`, 'type:e2e'];
304
+ pObj.tags = ['testspectra:e2e', `testspectra:scope:${featureName}`];
304
305
  fs.writeFileSync(projJsonPath, JSON.stringify(pObj, null, 2), 'utf-8');
305
306
  }
306
307
  catch { }
@@ -508,6 +508,7 @@ export async function initCommand(options = {}) {
508
508
  copyRecursive(sampleE2eSrc, targetE2eDir, {
509
509
  'playground-e2e': e2eProjectName,
510
510
  'packages/playground/e2e': path.relative(cwd, targetE2eDir).replace(/\\/g, '/'),
511
+ 'testspectra:scope:playground': `testspectra:scope:${featureName}`,
511
512
  'scope:playground': `scope:${featureName}`,
512
513
  });
513
514
  // Adjust project.json
@@ -519,7 +520,7 @@ export async function initCommand(options = {}) {
519
520
  pObj.sourceRoot = path.relative(cwd, targetE2eDir).replace(/\\/g, '/');
520
521
  pObj.targets.e2e.options.cwd = path.relative(cwd, targetE2eDir).replace(/\\/g, '/');
521
522
  pObj.targets['type-check'].options.cwd = '.';
522
- pObj.tags = [`scope:${featureName}`, 'type:e2e'];
523
+ pObj.tags = ['testspectra:e2e', `testspectra:scope:${featureName}`];
523
524
  fs.writeFileSync(projJsonPath, JSON.stringify(pObj, null, 2), 'utf-8');
524
525
  }
525
526
  catch { }
@@ -1,6 +1,8 @@
1
1
  import path from 'node:path';
2
2
  import chalk from 'chalk';
3
3
  import * as p from '@clack/prompts';
4
+ import { renderSummaryView } from '../reporter/summary-view.js';
5
+ import { Reporter } from '../runner/reporter.js';
4
6
  import { runSystemChecks } from './doctor.js';
5
7
  /**
6
8
  * Finds the Chrome-for-Testing binary that `spectra doctor` already installs/manages for the
@@ -63,14 +65,40 @@ export async function testCommand(options = {}) {
63
65
  headless = options.headless;
64
66
  }
65
67
  const concurrency = options.concurrency !== undefined ? parseInt(String(options.concurrency), 10) : undefined;
68
+ const outputFile = options.output ? path.resolve(cwd, options.output) : undefined;
69
+ // Reuses the exact same semantic terminal reporter `spectra run` drives (see
70
+ // docs/v2/cli/matchers-semantic-reporter.md) — @testspectra/react emits the identical
71
+ // [TESTSPECTRA_*] protocol lines the E2E Rust orchestrator does (see tools/react/src/prelude.ts
72
+ // / setup.ts), so the same Reporter class parses them with zero changes on either side.
73
+ const reporter = new Reporter();
74
+ const runStartedAt = Date.now();
66
75
  const ok = await reactPkg.runComponentTests({
67
76
  cwd,
68
77
  headless,
69
78
  stepDelayMs,
70
79
  spec: options.spec,
71
- outputFile: options.output ? path.resolve(cwd, options.output) : undefined,
80
+ outputFile,
72
81
  concurrency: concurrency !== undefined && !isNaN(concurrency) ? concurrency : undefined,
73
82
  browserExecutablePath,
83
+ onLine: (line) => {
84
+ if (!line.startsWith('[TESTSPECTRA_'))
85
+ return;
86
+ reporter.addLog({ timestamp: new Date().toLocaleTimeString(), level: 'INFO', message: line });
87
+ if (!reporter.isInteractiveMode()) {
88
+ console.log(line);
89
+ }
90
+ },
91
+ });
92
+ const durationMs = Date.now() - runStartedAt;
93
+ const result = reporter.generateResult(ok ? 'passed' : 'failed', `${(durationMs / 1000).toFixed(1)}s`);
94
+ const totalTests = result.passedCount + result.failedCount;
95
+ renderSummaryView({
96
+ totalSuites: totalTests,
97
+ totalTests,
98
+ passedCount: result.passedCount,
99
+ failedCount: result.failedCount,
100
+ durationMs,
101
+ reportLocation: outputFile ? path.relative(cwd, outputFile) : '(use -o/--output to save a report)',
74
102
  });
75
103
  process.exit(ok ? 0 : 1);
76
104
  }
@@ -383,4 +383,42 @@ async function verifyZeroImport() {
383
383
  expect(fs.existsSync(path.join(tmpDir, '.testspectra', 'types', 'web.d.ts'))).toBe(false);
384
384
  expect(fs.existsSync(path.join(tmpDir, '.testspectra', 'types', 'common.d.ts'))).toBe(false);
385
385
  });
386
+ it('nx monorepo: does NOT treat feature library as shared library and discovers nested e2e subproject', () => {
387
+ // 1. Shared library with testspectra:shared
388
+ const sharedDir = path.join(tmpDir, 'shared', 'testing');
389
+ fs.mkdirSync(path.join(sharedDir, 'page-objects', 'SharedNav'), { recursive: true });
390
+ fs.writeFileSync(path.join(sharedDir, 'project.json'), JSON.stringify({ name: 'shared-testing', projectType: 'library', tags: ['testspectra:shared'] }));
391
+ fs.writeFileSync(path.join(sharedDir, 'page-objects', 'SharedNav', 'common.ts'), 'export default class SharedNav {}');
392
+ // 2. Nx Feature library (projectType: "library", standard Nx tags, NO testspectra tags)
393
+ const featureLibDir = path.join(tmpDir, 'packages', 'features', 'auth');
394
+ fs.mkdirSync(featureLibDir, { recursive: true });
395
+ fs.writeFileSync(path.join(featureLibDir, 'project.json'), JSON.stringify({
396
+ name: '@tagsamurai/feature-auth',
397
+ projectType: 'library',
398
+ sourceRoot: 'packages/features/auth',
399
+ targets: {},
400
+ tags: ['type:feature', 'scope:feature-auth'],
401
+ }));
402
+ // 3. Nested E2E subproject inside feature library
403
+ const featureE2eDir = path.join(featureLibDir, 'e2e');
404
+ fs.mkdirSync(path.join(featureE2eDir, 'specs', 'AuthSuite', 'TC-001'), { recursive: true });
405
+ fs.writeFileSync(path.join(featureE2eDir, 'project.json'), JSON.stringify({
406
+ name: 'feature-auth-e2e',
407
+ projectType: 'application',
408
+ tags: ['testspectra:e2e', 'testspectra:scope:feature-auth'],
409
+ }));
410
+ fs.writeFileSync(path.join(featureE2eDir, 'specs', 'AuthSuite', 'TC-001', 'web.test.ts'), 'it("auth test", async () => {});');
411
+ const scopes = TypeGenerator.getEntityScopes(tmpDir);
412
+ expect(scopes).toHaveLength(2);
413
+ const sharedScope = scopes.find((s) => s.isShared);
414
+ expect(sharedScope).toBeDefined();
415
+ expect(sharedScope?.name).toBe('shared');
416
+ expect(sharedScope?.dir).toBe(path.resolve(sharedDir));
417
+ const featureScope = scopes.find((s) => !s.isShared);
418
+ expect(featureScope).toBeDefined();
419
+ expect(featureScope?.name).toBe('feature-auth');
420
+ expect(featureScope?.dir).toBe(path.resolve(featureE2eDir));
421
+ // Verify packages/features/auth is NOT in scopes
422
+ expect(scopes.some((s) => s.dir === path.resolve(featureLibDir))).toBe(false);
423
+ });
386
424
  });
@@ -9,8 +9,11 @@ export interface EntityScope {
9
9
  * Discovers all feature directories and shared library directories.
10
10
  * Standard discovery hierarchy:
11
11
  * 1. Nx / Monorepo Project Descriptors (`project.json`):
12
- * - projectType: "library" OR tags: ["type:shared", "type:library", "scope:shared"] => Shared Library Scope (isShared: true)
13
- * - projectType: "application" OR tags: ["type:e2e", "type:app"] => Feature Scope (isShared: false)
12
+ * - tags: ["testspectra:shared"] => Shared Library Scope (isShared: true)
13
+ * - tags: ["testspectra:e2e"] or ["testspectra:scope:<name>"] => Feature Scope (isShared: false)
14
+ * - Generic Nx libraries/apps without testspectra tags are not TestSpectra scopes and recursion
15
+ * continues into their subdirectories so nested e2e projects (e.g. `packages/features/auth/e2e`)
16
+ * are discovered.
14
17
  * 2. Explicit User Configuration (`tsconfig.spectra.shared.json`):
15
18
  * - Custom include path for shared testing utilities
16
19
  * 3. Dynamic Workspace Filesystem Discovery (Fallback for non-Nx / custom layouts):
@@ -13,8 +13,11 @@ export const PLATFORM_HIERARCHY = {
13
13
  * Discovers all feature directories and shared library directories.
14
14
  * Standard discovery hierarchy:
15
15
  * 1. Nx / Monorepo Project Descriptors (`project.json`):
16
- * - projectType: "library" OR tags: ["type:shared", "type:library", "scope:shared"] => Shared Library Scope (isShared: true)
17
- * - projectType: "application" OR tags: ["type:e2e", "type:app"] => Feature Scope (isShared: false)
16
+ * - tags: ["testspectra:shared"] => Shared Library Scope (isShared: true)
17
+ * - tags: ["testspectra:e2e"] or ["testspectra:scope:<name>"] => Feature Scope (isShared: false)
18
+ * - Generic Nx libraries/apps without testspectra tags are not TestSpectra scopes and recursion
19
+ * continues into their subdirectories so nested e2e projects (e.g. `packages/features/auth/e2e`)
20
+ * are discovered.
18
21
  * 2. Explicit User Configuration (`tsconfig.spectra.shared.json`):
19
22
  * - Custom include path for shared testing utilities
20
23
  * 3. Dynamic Workspace Filesystem Discovery (Fallback for non-Nx / custom layouts):
@@ -48,16 +51,9 @@ export function discoverEntityScopes(cwd) {
48
51
  if (fs.existsSync(projectJsonPath)) {
49
52
  try {
50
53
  const pObj = JSON.parse(fs.readFileSync(projectJsonPath, 'utf-8'));
51
- const projectType = pObj.projectType;
52
54
  const tags = Array.isArray(pObj.tags) ? pObj.tags : [];
53
- const isSharedLib = projectType === 'library' ||
54
- tags.includes('type:shared') ||
55
- tags.includes('type:library') ||
56
- tags.includes('scope:shared');
57
- const isE2eApp = projectType === 'application' ||
58
- tags.includes('type:e2e') ||
59
- tags.includes('type:app') ||
60
- tags.some((t) => t.startsWith('scope:') && t !== 'scope:shared');
55
+ const isSharedLib = tags.includes('testspectra:shared');
56
+ const isE2eApp = tags.includes('testspectra:e2e') || tags.some((t) => t.startsWith('testspectra:scope:'));
61
57
  if (isSharedLib) {
62
58
  if (!scopes.some((s) => s.dir === dir)) {
63
59
  scopes.push({ name: 'shared', dir, isShared: true });
@@ -65,9 +61,9 @@ export function discoverEntityScopes(cwd) {
65
61
  return; // Do not recurse into subdirectories of an identified project
66
62
  }
67
63
  else if (isE2eApp) {
68
- // Extract scope name from tags (e.g. "scope:matchers" => "matchers") or project name
69
- const scopeTag = tags.find((t) => t.startsWith('scope:') && t !== 'scope:shared');
70
- let featureName = scopeTag ? scopeTag.replace('scope:', '') : '';
64
+ // Extract scope name from tags (e.g. "testspectra:scope:matchers" => "matchers") or project name
65
+ const scopeTag = tags.find((t) => t.startsWith('testspectra:scope:'));
66
+ let featureName = scopeTag ? scopeTag.replace('testspectra:scope:', '') : '';
71
67
  if (!featureName && pObj.name) {
72
68
  featureName = pObj.name.replace(/-e2e$/, '').replace(/^e2e-/, '');
73
69
  }
@@ -329,6 +325,9 @@ export class TypeGenerator {
329
325
  const hierarchy = PLATFORM_HIERARCHY[platform];
330
326
  let content = `// Auto-generated ambient declarations for TestSpectra [${platform.toUpperCase()}]\n`;
331
327
  content += `// Managed automatically by TestSpectra Language Service Plugin.\n\n`;
328
+ content += `/// <reference types="@testspectra/matchers" />\n`;
329
+ content += `/// <reference path="./fixtures.d.ts" />\n`;
330
+ content += `/// <reference path="${envRefPath}" />\n\n`;
332
331
  // TestSpectra's Rust-native orchestrator worker (core/orchestrator/src/worker/bdd_runner.ts)
333
332
  // registers its own `it`/`test`/lifecycle-hook globals directly — there is no WDIO or Mocha
334
333
  // runtime involved at all (not just for component testing; this is true across every
@@ -346,9 +345,7 @@ export class TypeGenerator {
346
345
  // — the Rust orchestrator's BDD runtime has no `expect` global at all.
347
346
  content += `declare const expect: import('@testspectra/react').ExpectStatic;\n`;
348
347
  }
349
- content += `/// <reference types="@testspectra/matchers" />\n`;
350
- content += `/// <reference path="./fixtures.d.ts" />\n`;
351
- content += `/// <reference path="${envRefPath}" />\n\n`;
348
+ content += `\n`;
352
349
  // 1. Page Objects Global Declarations
353
350
  const declaredPOMs = new Set();
354
351
  for (const poDir of poDirs) {
@@ -17,16 +17,16 @@ describe('Shared Library Boundary (testspectra/no-specs-in-shared-lib)', () => {
17
17
  }
18
18
  });
19
19
  describe('findSharedLibraryRoots()', () => {
20
- it('discovers parent folders of project.json files containing both scope:shared and type:shared', () => {
20
+ it('discovers parent folders of project.json files containing testspectra:shared', () => {
21
21
  const sharedDir1 = path.join(tempDir, 'shared', 'testing');
22
22
  const sharedDir2 = path.join(tempDir, 'libs', 'common-helpers');
23
23
  const nonSharedDir = path.join(tempDir, 'packages', 'playground', 'e2e');
24
24
  fs.mkdirSync(sharedDir1, { recursive: true });
25
25
  fs.mkdirSync(sharedDir2, { recursive: true });
26
26
  fs.mkdirSync(nonSharedDir, { recursive: true });
27
- fs.writeFileSync(path.join(sharedDir1, 'project.json'), JSON.stringify({ name: 'shared-testing', tags: ['scope:shared', 'type:shared'] }));
28
- fs.writeFileSync(path.join(sharedDir2, 'project.json'), JSON.stringify({ name: 'common-helpers', tags: ['scope:shared', 'type:shared'] }));
29
- fs.writeFileSync(path.join(nonSharedDir, 'project.json'), JSON.stringify({ name: 'playground-e2e', tags: ['scope:playground', 'type:e2e'] }));
27
+ fs.writeFileSync(path.join(sharedDir1, 'project.json'), JSON.stringify({ name: 'shared-testing', tags: ['testspectra:shared'] }));
28
+ fs.writeFileSync(path.join(sharedDir2, 'project.json'), JSON.stringify({ name: 'common-helpers', tags: ['testspectra:shared'] }));
29
+ fs.writeFileSync(path.join(nonSharedDir, 'project.json'), JSON.stringify({ name: 'playground-e2e', tags: ['testspectra:e2e', 'testspectra:scope:playground'] }));
30
30
  const roots = findSharedLibraryRoots(tempDir);
31
31
  expect(roots).toHaveLength(2);
32
32
  expect(roots).toContain(sharedDir1);
@@ -35,12 +35,12 @@ describe('Shared Library Boundary (testspectra/no-specs-in-shared-lib)', () => {
35
35
  });
36
36
  });
37
37
  describe('isInsideSharedLibrary()', () => {
38
- it('returns true for files inside a project tagged with scope:shared or type:shared in project.json', () => {
38
+ it('returns true for files inside a project tagged with testspectra:shared in project.json', () => {
39
39
  const sharedDir = path.join(tempDir, 'shared', 'testing');
40
40
  fs.mkdirSync(sharedDir, { recursive: true });
41
41
  fs.writeFileSync(path.join(sharedDir, 'project.json'), JSON.stringify({
42
42
  name: 'shared-testing',
43
- tags: ['scope:shared', 'type:shared'],
43
+ tags: ['testspectra:shared'],
44
44
  }));
45
45
  const stepFile = path.join(sharedDir, 'support', 'steps', 'web.step.ts');
46
46
  const specFile = path.join(sharedDir, 'specs', 'TC-001', 'spec.md');
@@ -55,7 +55,7 @@ describe('Shared Library Boundary (testspectra/no-specs-in-shared-lib)', () => {
55
55
  fs.mkdirSync(customLibDir, { recursive: true });
56
56
  fs.writeFileSync(path.join(customLibDir, 'project.json'), JSON.stringify({
57
57
  name: 'custom-common-lib',
58
- tags: ['scope:shared', 'type:shared'],
58
+ tags: ['testspectra:shared'],
59
59
  }));
60
60
  const actionFile = path.join(customLibDir, 'support', 'actions', 'click.action.ts');
61
61
  expect(isInsideSharedLibrary(actionFile)).toBe(true);
@@ -65,7 +65,7 @@ describe('Shared Library Boundary (testspectra/no-specs-in-shared-lib)', () => {
65
65
  fs.mkdirSync(sharedAppDir, { recursive: true });
66
66
  fs.writeFileSync(path.join(sharedAppDir, 'project.json'), JSON.stringify({
67
67
  name: 'shared-app',
68
- tags: ['scope:shared-app', 'type:e2e'],
68
+ tags: ['testspectra:e2e', 'testspectra:scope:shared-app'],
69
69
  }));
70
70
  const specFile = path.join(sharedAppDir, 'specs', 'TC-001', 'spec.md');
71
71
  expect(isInsideSharedLibrary(specFile)).toBe(false);
@@ -75,7 +75,7 @@ describe('Shared Library Boundary (testspectra/no-specs-in-shared-lib)', () => {
75
75
  fs.mkdirSync(featureDir, { recursive: true });
76
76
  fs.writeFileSync(path.join(featureDir, 'project.json'), JSON.stringify({
77
77
  name: 'playground-e2e',
78
- tags: ['scope:playground', 'type:e2e'],
78
+ tags: ['testspectra:e2e', 'testspectra:scope:playground'],
79
79
  }));
80
80
  const specFile = path.join(featureDir, 'specs', 'TC-001', 'spec.md');
81
81
  expect(isInsideSharedLibrary(specFile)).toBe(false);
@@ -91,7 +91,7 @@ describe('Shared Library Boundary (testspectra/no-specs-in-shared-lib)', () => {
91
91
  fs.mkdirSync(sharedLibDir, { recursive: true });
92
92
  fs.writeFileSync(path.join(sharedLibDir, 'project.json'), JSON.stringify({
93
93
  name: 'shared-testing',
94
- tags: ['scope:shared', 'type:shared'],
94
+ tags: ['testspectra:shared'],
95
95
  }));
96
96
  const sharedSpecDir = path.join(sharedLibDir, 'specs', 'TC-error');
97
97
  fs.mkdirSync(sharedSpecDir, { recursive: true });
@@ -119,7 +119,7 @@ status: draft
119
119
  fs.mkdirSync(sharedLibDir, { recursive: true });
120
120
  fs.writeFileSync(path.join(sharedLibDir, 'project.json'), JSON.stringify({
121
121
  name: 'shared-testing',
122
- tags: ['scope:shared', 'type:shared'],
122
+ tags: ['testspectra:shared'],
123
123
  }));
124
124
  const sharedSuiteDir = path.join(sharedLibDir, 'support');
125
125
  fs.mkdirSync(sharedSuiteDir, { recursive: true });
@@ -141,7 +141,7 @@ status: active
141
141
  fs.mkdirSync(customLibDir, { recursive: true });
142
142
  fs.writeFileSync(path.join(customLibDir, 'project.json'), JSON.stringify({
143
143
  name: 'my-testing-utils',
144
- tags: ['scope:shared', 'type:shared'],
144
+ tags: ['testspectra:shared'],
145
145
  }));
146
146
  const specDir = path.join(customLibDir, 'specs', 'TC-custom');
147
147
  fs.mkdirSync(specDir, { recursive: true });
@@ -169,7 +169,7 @@ status: draft
169
169
  fs.mkdirSync(featureDir, { recursive: true });
170
170
  fs.writeFileSync(path.join(featureDir, 'project.json'), JSON.stringify({
171
171
  name: 'playground-e2e',
172
- tags: ['scope:playground', 'type:e2e'],
172
+ tags: ['testspectra:e2e', 'testspectra:scope:playground'],
173
173
  }));
174
174
  const featureSpecDir = path.join(featureDir, 'specs', 'TC-001');
175
175
  fs.mkdirSync(featureSpecDir, { recursive: true });
@@ -195,7 +195,7 @@ status: draft
195
195
  fs.mkdirSync(sharedE2eDir, { recursive: true });
196
196
  fs.writeFileSync(path.join(sharedE2eDir, 'project.json'), JSON.stringify({
197
197
  name: 'shared-feature-e2e',
198
- tags: ['scope:shared-feature', 'type:e2e'],
198
+ tags: ['testspectra:e2e', 'testspectra:scope:shared-feature'],
199
199
  }));
200
200
  const specDir = path.join(sharedE2eDir, 'specs', 'TC-001');
201
201
  fs.mkdirSync(specDir, { recursive: true });
@@ -241,7 +241,7 @@ status: draft
241
241
  fs.mkdirSync(sharedLibDir, { recursive: true });
242
242
  fs.writeFileSync(path.join(sharedLibDir, 'project.json'), JSON.stringify({
243
243
  name: 'shared-testing',
244
- tags: ['scope:shared', 'type:shared'],
244
+ tags: ['testspectra:shared'],
245
245
  }));
246
246
  const sharedDir1 = path.join(sharedLibDir, 'specs', 'TC-001');
247
247
  const sharedDir2 = path.join(sharedLibDir, 'specs', 'TC-002');
@@ -4,14 +4,14 @@ export interface WorkspaceAssets {
4
4
  fixtures: Set<string>;
5
5
  }
6
6
  /**
7
- * Scans a workspace directory for all `project.json` files that have both
8
- * "scope:shared" and "type:shared" tags, and returns their parent folder paths.
7
+ * Scans a workspace directory for all `project.json` files that have the
8
+ * "testspectra:shared" tag, and returns their parent folder paths.
9
9
  */
10
10
  export declare function findSharedLibraryRoots(workspaceDir: string): string[];
11
11
  /**
12
12
  * Resolves the root directory of a shared testing library if the file path is inside one.
13
13
  * Determines shared library status based on `project.json` containing
14
- * both "scope:shared" and "type:shared" tags in ancestor directories,
14
+ * the "testspectra:shared" tag in ancestor directories,
15
15
  * taking the parent folder of that `project.json` as the shared library root.
16
16
  */
17
17
  export declare function getSharedLibraryRoot(filePath: string, workspaceDir?: string): string | null;
@@ -1,8 +1,8 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
3
  /**
4
- * Scans a workspace directory for all `project.json` files that have both
5
- * "scope:shared" and "type:shared" tags, and returns their parent folder paths.
4
+ * Scans a workspace directory for all `project.json` files that have the
5
+ * "testspectra:shared" tag, and returns their parent folder paths.
6
6
  */
7
7
  export function findSharedLibraryRoots(workspaceDir) {
8
8
  const sharedRoots = [];
@@ -23,7 +23,7 @@ export function findSharedLibraryRoots(workspaceDir) {
23
23
  const raw = fs.readFileSync(path.join(dir, entry.name), 'utf-8');
24
24
  const parsed = JSON.parse(raw);
25
25
  const tags = Array.isArray(parsed.tags) ? parsed.tags : [];
26
- if (tags.includes('scope:shared') && tags.includes('type:shared')) {
26
+ if (tags.includes('testspectra:shared')) {
27
27
  sharedRoots.push(dir);
28
28
  }
29
29
  }
@@ -39,7 +39,7 @@ export function findSharedLibraryRoots(workspaceDir) {
39
39
  /**
40
40
  * Resolves the root directory of a shared testing library if the file path is inside one.
41
41
  * Determines shared library status based on `project.json` containing
42
- * both "scope:shared" and "type:shared" tags in ancestor directories,
42
+ * the "testspectra:shared" tag in ancestor directories,
43
43
  * taking the parent folder of that `project.json` as the shared library root.
44
44
  */
45
45
  export function getSharedLibraryRoot(filePath, workspaceDir) {
@@ -67,7 +67,7 @@ export function getSharedLibraryRoot(filePath, workspaceDir) {
67
67
  const raw = fs.readFileSync(projectJsonPath, 'utf-8');
68
68
  const projectConfig = JSON.parse(raw);
69
69
  const tags = Array.isArray(projectConfig.tags) ? projectConfig.tags : [];
70
- if (tags.includes('scope:shared') && tags.includes('type:shared')) {
70
+ if (tags.includes('testspectra:shared')) {
71
71
  return current;
72
72
  }
73
73
  return null;
@@ -11,11 +11,11 @@ export declare const CoverageSchema: z.ZodObject<{
11
11
  web: z.ZodOptional<z.ZodEnum<["automated", "manual", "unsupported"]>>;
12
12
  mobile: z.ZodOptional<z.ZodEnum<["automated", "manual", "unsupported"]>>;
13
13
  }, "strip", z.ZodTypeAny, {
14
- mobile?: "automated" | "manual" | "unsupported" | undefined;
15
14
  web?: "automated" | "manual" | "unsupported" | undefined;
16
- }, {
17
15
  mobile?: "automated" | "manual" | "unsupported" | undefined;
16
+ }, {
18
17
  web?: "automated" | "manual" | "unsupported" | undefined;
18
+ mobile?: "automated" | "manual" | "unsupported" | undefined;
19
19
  }>;
20
20
  export declare const SpecFrontmatterSchema: z.ZodObject<{
21
21
  id: z.ZodString;
@@ -27,33 +27,33 @@ export declare const SpecFrontmatterSchema: z.ZodObject<{
27
27
  web: z.ZodOptional<z.ZodEnum<["automated", "manual", "unsupported"]>>;
28
28
  mobile: z.ZodOptional<z.ZodEnum<["automated", "manual", "unsupported"]>>;
29
29
  }, "strip", z.ZodTypeAny, {
30
- mobile?: "automated" | "manual" | "unsupported" | undefined;
31
30
  web?: "automated" | "manual" | "unsupported" | undefined;
32
- }, {
33
31
  mobile?: "automated" | "manual" | "unsupported" | undefined;
32
+ }, {
34
33
  web?: "automated" | "manual" | "unsupported" | undefined;
34
+ mobile?: "automated" | "manual" | "unsupported" | undefined;
35
35
  }>>;
36
36
  tags: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
37
37
  }, "strip", z.ZodTypeAny, {
38
- status: "draft" | "ready-for-automation" | "automated" | "deprecated";
39
- title: string;
40
38
  id: string;
39
+ title: string;
40
+ status: "draft" | "ready-for-automation" | "automated" | "deprecated";
41
41
  priority: "Critical" | "High" | "Medium" | "Low";
42
42
  caseType: "Positive" | "Negative" | "Edge";
43
43
  coverage?: {
44
- mobile?: "automated" | "manual" | "unsupported" | undefined;
45
44
  web?: "automated" | "manual" | "unsupported" | undefined;
45
+ mobile?: "automated" | "manual" | "unsupported" | undefined;
46
46
  } | undefined;
47
47
  tags?: string[] | undefined;
48
48
  }, {
49
- status: "draft" | "ready-for-automation" | "automated" | "deprecated";
50
- title: string;
51
49
  id: string;
50
+ title: string;
51
+ status: "draft" | "ready-for-automation" | "automated" | "deprecated";
52
52
  priority: "Critical" | "High" | "Medium" | "Low";
53
53
  caseType: "Positive" | "Negative" | "Edge";
54
54
  coverage?: {
55
- mobile?: "automated" | "manual" | "unsupported" | undefined;
56
55
  web?: "automated" | "manual" | "unsupported" | undefined;
56
+ mobile?: "automated" | "manual" | "unsupported" | undefined;
57
57
  } | undefined;
58
58
  tags?: string[] | undefined;
59
59
  }>;
@@ -14,36 +14,36 @@ export declare const SuiteFrontmatterSchema: z.ZodEffects<z.ZodObject<{
14
14
  tags: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
15
15
  }, "strip", z.ZodTypeAny, {
16
16
  id: string;
17
- status?: "active" | "draft" | "deprecated" | undefined;
18
- title?: string | undefined;
19
17
  name?: string | undefined;
18
+ title?: string | undefined;
19
+ status?: "draft" | "deprecated" | "active" | undefined;
20
20
  tags?: string[] | undefined;
21
21
  description?: string | undefined;
22
22
  executionOrder?: number | undefined;
23
23
  parallel?: boolean | undefined;
24
24
  }, {
25
25
  id: string;
26
- status?: "active" | "draft" | "deprecated" | undefined;
27
- title?: string | undefined;
28
26
  name?: string | undefined;
27
+ title?: string | undefined;
28
+ status?: "draft" | "deprecated" | "active" | undefined;
29
29
  tags?: string[] | undefined;
30
30
  description?: string | undefined;
31
31
  executionOrder?: number | undefined;
32
32
  parallel?: boolean | undefined;
33
33
  }>, {
34
34
  id: string;
35
- status?: "active" | "draft" | "deprecated" | undefined;
36
- title?: string | undefined;
37
35
  name?: string | undefined;
36
+ title?: string | undefined;
37
+ status?: "draft" | "deprecated" | "active" | undefined;
38
38
  tags?: string[] | undefined;
39
39
  description?: string | undefined;
40
40
  executionOrder?: number | undefined;
41
41
  parallel?: boolean | undefined;
42
42
  }, {
43
43
  id: string;
44
- status?: "active" | "draft" | "deprecated" | undefined;
45
- title?: string | undefined;
46
44
  name?: string | undefined;
45
+ title?: string | undefined;
46
+ status?: "draft" | "deprecated" | "active" | undefined;
47
47
  tags?: string[] | undefined;
48
48
  description?: string | undefined;
49
49
  executionOrder?: number | undefined;
@@ -50,6 +50,11 @@ function formatCollectionTarget(target) {
50
50
  export function formatAction(actionKey, target, args = {}) {
51
51
  const el = formatElementTarget(target);
52
52
  switch (actionKey) {
53
+ // Component testing only (@testspectra/react's Spectra.mount()) — target is a component name,
54
+ // not a DOM selector, so it's rendered plainly rather than through formatElementTarget's
55
+ // `element "..."` wrapping.
56
+ case 'mount':
57
+ return `Mount component "${extractTargetString(target) || '{component}'}"`;
53
58
  case 'click':
54
59
  return `Click on ${el}`;
55
60
  case 'doubleClick':
@@ -1,15 +1,60 @@
1
1
  import { spawn } from 'child_process';
2
2
  import fs from 'fs';
3
3
  import path from 'path';
4
+ import { createRequire } from 'module';
4
5
  import { fileURLToPath } from 'url';
5
6
  const __filename = fileURLToPath(import.meta.url);
6
7
  const __dirname = path.dirname(__filename);
8
+ const require = createRequire(import.meta.url);
9
+ // Maps process.platform/process.arch to the optionalDependency package that carries the
10
+ // prebuilt native binary for that platform (see cli/npm/*). Keep in sync with the
11
+ // optionalDependencies list in cli/package.json and the packages built by
12
+ // scripts/release-cli-local.sh.
13
+ function platformPackageName() {
14
+ const { platform, arch } = process;
15
+ if (platform === 'darwin' && arch === 'arm64')
16
+ return '@testspectra/cli-darwin-arm64';
17
+ if (platform === 'linux' && arch === 'x64')
18
+ return '@testspectra/cli-linux-x64';
19
+ if (platform === 'win32' && arch === 'arm64')
20
+ return '@testspectra/cli-win32-arm64';
21
+ if (platform === 'win32' && arch === 'x64')
22
+ return '@testspectra/cli-win32-x64';
23
+ return null;
24
+ }
7
25
  export class RustCoreBridge {
8
26
  static resolveBinaryPath() {
9
- // 1. Look for precompiled release/debug binary in workspace target
10
- const workspaceRoot = path.resolve(__dirname, '../../..');
11
27
  const isWindows = process.platform === 'win32';
12
28
  const binaryName = isWindows ? 'testspectra-runner.exe' : 'testspectra-runner';
29
+ // 1. Production install: resolve the prebuilt binary from the platform-specific
30
+ // optionalDependency package (e.g. @testspectra/cli-darwin-arm64). This is how the
31
+ // published npm package ships native binaries for every supported platform without
32
+ // bundling all of them into @testspectra/cli itself.
33
+ const pkgName = platformPackageName();
34
+ if (pkgName) {
35
+ try {
36
+ const pkgJsonPath = require.resolve(`${pkgName}/package.json`);
37
+ const pkgBinPath = path.join(path.dirname(pkgJsonPath), 'bin', binaryName);
38
+ if (fs.existsSync(pkgBinPath)) {
39
+ if (process.platform !== 'win32') {
40
+ try {
41
+ const stat = fs.statSync(pkgBinPath);
42
+ if ((stat.mode & 0o111) === 0) {
43
+ fs.chmodSync(pkgBinPath, 0o755);
44
+ }
45
+ }
46
+ catch { }
47
+ }
48
+ return pkgBinPath;
49
+ }
50
+ }
51
+ catch {
52
+ // Optional dependency not installed — fall through to the workspace dev paths below.
53
+ }
54
+ }
55
+ // 2. Monorepo dev fallback: look for a precompiled release/debug binary in the workspace
56
+ // target, as produced by scripts/ensure-runner-binary.js during local development.
57
+ const workspaceRoot = path.resolve(__dirname, '../../..');
13
58
  const candidatePaths = [
14
59
  path.join(workspaceRoot, 'core/target/release', binaryName),
15
60
  path.join(workspaceRoot, 'target/release', binaryName),
@@ -29,8 +74,11 @@ export class RustCoreBridge {
29
74
  return `${p}.exe`;
30
75
  }
31
76
  }
32
- throw new Error("[TestSpectra] Native core runner binary 'testspectra-runner' not found.\n" +
33
- "Please build the project first using 'pnpm run build' or 'pnpm run build:core'.");
77
+ throw new Error(pkgName
78
+ ? `[TestSpectra] Native core runner binary not found for ${process.platform}/${process.arch}.\n` +
79
+ `Expected the optional dependency '${pkgName}' to provide it. Try reinstalling ` +
80
+ "('npm install' / 'pnpm install') without --no-optional, or build locally with 'pnpm run build:core'."
81
+ : `[TestSpectra] ${process.platform}/${process.arch} is not currently supported by @testspectra/cli.`);
34
82
  }
35
83
  static async run(options, reporter) {
36
84
  const binPath = this.resolveBinaryPath();