@testspectra/cli 1.1.11-rc.3 → 1.1.12

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.
Files changed (90) hide show
  1. package/demo-app/assets/index-DDYS5fLp.css +1 -0
  2. package/demo-app/assets/index-euCENPMy.js +154 -0
  3. package/demo-app/index.html +2 -2
  4. package/dist/.tsbuildinfo +1 -0
  5. package/dist/commands/__tests__/run-e2e.test.js +24 -6
  6. package/dist/commands/__tests__/run.test.js +27 -0
  7. package/dist/commands/demo.d.ts +1 -0
  8. package/dist/commands/demo.js +26 -4
  9. package/dist/commands/doctor.js +23 -0
  10. package/dist/commands/run.d.ts +6 -0
  11. package/dist/commands/run.js +51 -5
  12. package/dist/commands/test.d.ts +9 -9
  13. package/dist/commands/test.js +40 -75
  14. package/dist/commands/update.d.ts +3 -0
  15. package/dist/commands/update.js +109 -0
  16. package/dist/config/loader.js +31 -4
  17. package/dist/config/schema.d.ts +60 -2
  18. package/dist/config/schema.js +4 -1
  19. package/dist/generator/__tests__/tsconfig-isolation.test.js +21 -0
  20. package/dist/generator/__tests__/type-generator.test.js +15 -1
  21. package/dist/generator/tsconfig-generator.js +1 -0
  22. package/dist/generator/type-generator.js +2 -0
  23. package/dist/index.js +11 -4
  24. package/dist/lifecycle/__tests__/lifecycle-engine.test.js +290 -0
  25. package/dist/lifecycle/hook-discovery.d.ts +24 -0
  26. package/dist/lifecycle/hook-discovery.js +60 -0
  27. package/dist/lifecycle/hook-dispatcher.d.ts +34 -0
  28. package/dist/lifecycle/hook-dispatcher.js +190 -0
  29. package/dist/lifecycle/index.d.ts +3 -0
  30. package/dist/lifecycle/index.js +3 -0
  31. package/dist/lifecycle/parallel-grouping.d.ts +27 -0
  32. package/dist/lifecycle/parallel-grouping.js +151 -0
  33. package/dist/reporter/semantic-formatter.js +2 -0
  34. package/dist/reporter/types.d.ts +1 -1
  35. package/dist/runner/bridge.d.ts +2 -0
  36. package/dist/runner/bridge.js +4 -1
  37. package/dist/runner/reporter.d.ts +5 -43
  38. package/dist/runner/reporter.js +46 -179
  39. package/dist/utils/__tests__/demo-state.test.d.ts +1 -0
  40. package/dist/utils/__tests__/demo-state.test.js +96 -0
  41. package/dist/utils/api-server.d.ts +1 -1
  42. package/dist/utils/api-server.js +7 -3
  43. package/dist/utils/demo-server.d.ts +2 -0
  44. package/dist/utils/demo-server.js +18 -5
  45. package/dist/utils/demo-state.d.ts +40 -0
  46. package/dist/utils/demo-state.js +157 -0
  47. package/package.json +9 -8
  48. package/templates/default/fixtures/sampleDocument.pdf +14 -0
  49. package/templates/default/fixtures/sampleImage.png +0 -0
  50. package/templates/default/package.json +2 -2
  51. package/templates/default/page-objects/MatchersPage/common.ts +41 -0
  52. package/templates/default/page-objects/MatchersPage/mobile.ts +8 -3
  53. package/templates/default/page-objects/PlaygroundPage/mobile.ts +6 -0
  54. package/templates/default/page-objects/PlaygroundPage/web.ts +2 -0
  55. package/templates/default/specs/Authentication/TC-0021-seed-authenticated-session/mobile.test.ts +3 -0
  56. package/templates/default/specs/BrowserContext/TC-0007-title-and-navigation/mobile.test.ts +3 -0
  57. package/templates/default/specs/BrowserContext/TC-0007-title-and-navigation/web.test.ts +1 -1
  58. package/templates/default/specs/Playground/TC-0003-file-upload/web.test.ts +11 -0
  59. package/templates/default/spectra.config.ts +5 -1
  60. package/templates/nx/.nx/workspace-data/70094CFD-E89B-57A1-9619-2FC5F4963C54.db +0 -0
  61. package/templates/nx/.nx/workspace-data/d/daemon.log +7063 -1012
  62. package/templates/nx/.nx/workspace-data/file-map.json +181 -169
  63. package/templates/nx/.nx/workspace-data/lockfile.hash +1 -1
  64. package/templates/nx/.nx/workspace-data/nx_files.nxt +0 -0
  65. package/templates/nx/.nx/workspace-data/parsed-lock-file.json +81 -130
  66. package/templates/nx/.nx/workspace-data/project-graph.json +83 -138
  67. package/templates/nx/package.json +2 -2
  68. package/templates/nx/packages/matchers/e2e/specs/BrowserContext/TC-0007-title-and-navigation/web.test.ts +1 -1
  69. package/templates/nx/packages/playground/e2e/specs/Playground/TC-0003-file-upload/web.test.ts +11 -0
  70. package/templates/nx/shared/testing/fixtures/sampleDocument.pdf +14 -0
  71. package/templates/nx/shared/testing/fixtures/sampleImage.png +0 -0
  72. package/templates/nx/shared/testing/page-objects/MatchersPage/mobile.ts +68 -26
  73. package/templates/nx/shared/testing/page-objects/PlaygroundPage/mobile.ts +39 -17
  74. package/templates/nx/shared/testing/page-objects/PlaygroundPage/web.ts +2 -0
  75. package/templates/nx/spectra.config.ts +3 -3
  76. package/templates/react-component/fixtures/sampleDocument.pdf +14 -0
  77. package/templates/react-component/fixtures/sampleImage.png +0 -0
  78. package/templates/react-component/package.json +3 -3
  79. package/templates/react-component/page-objects/FileUploadComponent/web.ts +15 -0
  80. package/templates/react-component/specs/FileUploadComponent/TC-0001-upload-fixtures/web.test.tsx +18 -0
  81. package/templates/react-component/specs/UserListComponent/TC-0001-intercepted-user-list/spec.md +20 -0
  82. package/templates/react-component/specs/UserListComponent/TC-0001-intercepted-user-list/web.test.tsx +15 -0
  83. package/templates/react-component/specs/UserListComponent/suite.md +12 -0
  84. package/templates/react-component/src/components/FileUpload/FileUpload.tsx +44 -0
  85. package/templates/react-component/src/components/UserList/UserList.tsx +48 -0
  86. package/bin/.testspectra-runner.hash +0 -1
  87. package/demo-app/assets/index-BhlW-eXG.css +0 -1
  88. package/demo-app/assets/index-DOtRypCa.js +0 -154
  89. package/dist/commands/__tests__/doctor-scope.test.js +0 -24
  90. /package/dist/{commands/__tests__/doctor-scope.test.d.ts → lifecycle/__tests__/lifecycle-engine.test.d.ts} +0 -0
@@ -4,9 +4,10 @@ import { ConfigLoader } from '../config/loader.js';
4
4
  import { RustCoreBridge } from '../runner/bridge.js';
5
5
  import { Reporter } from '../runner/reporter.js';
6
6
  import { TypeGenerator } from '../types/generator.js';
7
- import { DemoServer } from '../utils/demo-server.js';
7
+ import { DemoServer, DEFAULT_DEMO_PORT } from '../utils/demo-server.js';
8
8
  import { DemoApiServer } from '../utils/api-server.js';
9
9
  import { DevServerManager } from '../utils/dev-server-manager.js';
10
+ import { DemoStateManager } from '../utils/demo-state.js';
10
11
  function renderSummaryCard(options) {
11
12
  const width = 64;
12
13
  const borderColor = options.isPassed ? '\x1b[38;2;52;211;153m' : '\x1b[38;2;248;113;113m';
@@ -69,6 +70,15 @@ function renderSummaryCard(options) {
69
70
  if (options.networkReportPath) {
70
71
  console.log(row(`${dim}Network${reset}`, `${dim}${options.networkReportPath}${reset}`));
71
72
  }
73
+ if (options.videoPaths && options.videoPaths.length > 0) {
74
+ const reportsDir = path.dirname(options.reportPath);
75
+ const videoDir = (reportsDir !== '.'
76
+ ? path.join(reportsDir, 'videos')
77
+ : path.dirname(options.videoPaths[0]) !== '.'
78
+ ? path.dirname(options.videoPaths[0])
79
+ : 'videos').replace(/\\/g, '/');
80
+ console.log(row(`${dim}Videos${reset}`, `${yellow}${options.videoPaths.length} failed recorded${reset} ${dim}(${videoDir})${reset}`));
81
+ }
72
82
  console.log(`${bot}\n`);
73
83
  }
74
84
  const TIMING_PHASE_LABELS = {
@@ -170,6 +180,13 @@ export async function runCommand(specPaths, options = {}) {
170
180
  console.warn(`\x1b[33m[TestSpectra] Warning: Target host "${options.host}" is not defined in spectra.config.ts (webConfig.hosts). Falling back to ${config.webConfig.baseUrl}\x1b[0m`);
171
181
  }
172
182
  }
183
+ else if (!options.demo) {
184
+ const activeDemo = await DemoStateManager.getActiveDemoServer(cwd);
185
+ if (activeDemo) {
186
+ config.webConfig.baseUrl = activeDemo.url;
187
+ console.log(`\x1b[36m[TestSpectra]\x1b[0m 🌐 Connected to active demo server at: \x1b[33m${activeDemo.url}\x1b[0m`);
188
+ }
189
+ }
173
190
  let demoServer = null;
174
191
  let apiServer = null;
175
192
  const devServerManager = new DevServerManager();
@@ -178,11 +195,21 @@ export async function runCommand(specPaths, options = {}) {
178
195
  ...(config.webConfig.devServers || []),
179
196
  ...(config.webConfig.devServer ? [config.webConfig.devServer] : []),
180
197
  ];
181
- if (configuredDevServers.length > 0 && !options.demo) {
198
+ const shouldStartDevServers = options.devServer !== false &&
199
+ options.devServers !== false &&
200
+ !options.skipDevServers &&
201
+ process.env.SPECTRA_SKIP_DEV_SERVERS !== '1' &&
202
+ process.env.SPECTRA_SKIP_DEV_SERVERS !== 'true' &&
203
+ process.env.SPECTRA_SKIP_DEV_SERVER !== '1' &&
204
+ process.env.SPECTRA_SKIP_DEV_SERVER !== 'true' &&
205
+ process.env.TESTSPECTRA_SKIP_DEV_SERVERS !== '1' &&
206
+ process.env.TESTSPECTRA_SKIP_DEV_SERVERS !== 'true';
207
+ if (shouldStartDevServers && configuredDevServers.length > 0 && !options.demo) {
182
208
  await devServerManager.startAll(configuredDevServers, options.host, cwd);
183
209
  }
184
210
  if (options.demo) {
185
- demoServer = new DemoServer(5173);
211
+ // Try preferred static port first (DEFAULT_DEMO_PORT); if occupied, DemoServer automatically falls back to an ephemeral port
212
+ demoServer = new DemoServer(DEFAULT_DEMO_PORT);
186
213
  try {
187
214
  const { url } = await demoServer.start();
188
215
  config.webConfig.baseUrl = url;
@@ -200,7 +227,7 @@ export async function runCommand(specPaths, options = {}) {
200
227
  console.warn(`\x1b[33m[TestSpectra] Warning: Could not start built-in demo API server: ${err.message}\x1b[0m`);
201
228
  }
202
229
  }
203
- if (options.headed) {
230
+ if (options.debug || options.headed) {
204
231
  config.webConfig.headless = false;
205
232
  }
206
233
  else if (options.headless !== undefined) {
@@ -215,6 +242,13 @@ export async function runCommand(specPaths, options = {}) {
215
242
  config.iosConfig.stepDelay = delayNum;
216
243
  }
217
244
  }
245
+ if (options.video !== undefined) {
246
+ config.webConfig.video = options.video;
247
+ if (config.androidConfig)
248
+ config.androidConfig.video = options.video;
249
+ if (config.iosConfig)
250
+ config.iosConfig.video = options.video;
251
+ }
218
252
  const platform = options.target || 'web';
219
253
  const userConcurrency = options.concurrency ? parseInt(String(options.concurrency), 10) : undefined;
220
254
  console.log(`\x1b[36m[TestSpectra]\x1b[0m Starting execution on target \x1b[1m${platform}\x1b[0m...`);
@@ -258,9 +292,11 @@ export async function runCommand(specPaths, options = {}) {
258
292
  licenseKey: options.licenseKey,
259
293
  outputJsonPath,
260
294
  specs: effectiveSpecPaths,
261
- concurrency: userConcurrency,
295
+ concurrency: options.debug ? 1 : userConcurrency,
262
296
  failedOnly: isFailedMode,
263
297
  failedTarget,
298
+ debug: options.debug,
299
+ inspectPort: options.inspectPort ? Number(options.inspectPort) : undefined,
264
300
  }, reporter);
265
301
  const isPassed = result.status.toLowerCase() === 'passed';
266
302
  const relReport = path.relative(cwd, outputJsonPath);
@@ -296,6 +332,7 @@ export async function runCommand(specPaths, options = {}) {
296
332
  finalDuration = totalMs < 1000 ? `${Math.round(totalMs)}ms` : `${(totalMs / 1000).toFixed(1)}s`;
297
333
  }
298
334
  const overallPassed = isPassed && totalFailed === 0;
335
+ const videoPaths = [];
299
336
  if (fs.existsSync(outputJsonPath)) {
300
337
  try {
301
338
  const fullReport = JSON.parse(fs.readFileSync(outputJsonPath, 'utf8'));
@@ -307,6 +344,13 @@ export async function runCommand(specPaths, options = {}) {
307
344
  s.status === 'failed' ||
308
345
  s.tests?.some((t) => t.passed === false || t.status === 'failed')).length;
309
346
  passedSuites = fullReport.suites.length - failedSuites;
347
+ for (const s of fullReport.suites) {
348
+ for (const t of s.tests || []) {
349
+ if (t.videoPath) {
350
+ videoPaths.push(t.videoPath);
351
+ }
352
+ }
353
+ }
310
354
  }
311
355
  if (fullReport.performance) {
312
356
  renderSummaryCard({
@@ -323,6 +367,7 @@ export async function runCommand(specPaths, options = {}) {
323
367
  carriedPassed,
324
368
  reportPath: relReport,
325
369
  networkReportPath: relNetworkReport,
370
+ videoPaths,
326
371
  });
327
372
  renderTimingBreakdown({
328
373
  ...fullReport.performance,
@@ -354,6 +399,7 @@ export async function runCommand(specPaths, options = {}) {
354
399
  carriedPassed,
355
400
  reportPath: relReport,
356
401
  networkReportPath: relNetworkReport,
402
+ videoPaths,
357
403
  });
358
404
  if (!overallPassed || totalFailed > 0) {
359
405
  process.exitCode = 1;
@@ -1,6 +1,5 @@
1
1
  export interface TestCommandOptions {
2
2
  cwd?: string;
3
- runner?: 'spectra' | 'vitest';
4
3
  headed?: boolean;
5
4
  headless?: boolean;
6
5
  stepDelay?: string | number;
@@ -10,20 +9,21 @@ export interface TestCommandOptions {
10
9
  concurrency?: string | number;
11
10
  }
12
11
  /**
13
- * Guarantees the `.testspectra` solution-tsconfig scaffolding exists before Vitest starts.
12
+ * Guarantees the `.testspectra` solution-tsconfig scaffolding exists before the component
13
+ * runner starts.
14
14
  *
15
- * The bundled Vitest browser runner transpiles raw TypeScript specs/page-objects Just-In-Time.
16
- * When it does, `vite:esbuild` walks the nearest `tsconfig.json` and tries to read every entry in
15
+ * The bundled component runner transpiles raw TypeScript specs/page-objects Just-In-Time.
16
+ * When it does, esbuild walks the nearest `tsconfig.json` and tries to read every entry in
17
17
  * its `references` — including `./.testspectra/tsconfig.json`. That directory is gitignored, so on
18
18
  * a clean clone / fresh CI checkout the reference points at a file that does not exist, esbuild
19
- * throws `ENOENT`, Vite answers HTTP 500, and every suite dies with "Failed to fetch dynamically
20
- * imported module" before a single assertion runs. Regenerating the scaffolding up front (same
21
- * output as `spectra sync-types`) keeps the reference resolvable.
19
+ * throws `ENOENT`, and every suite dies before a single assertion runs. Regenerating the
20
+ * scaffolding up front (same output as `spectra sync-types`) keeps the reference resolvable.
22
21
  */
23
22
  export declare function ensureTestSpectraScaffolding(cwd: string): void;
24
23
  /**
25
- * `spectra test` — delegates to `@testspectra/react`'s bundled Vitest browser-mode runner for
26
- * component testing. Deliberately separate from `spectra run` (E2E via core/orchestrator): see
24
+ * `spectra test` — runs React component tests via the native CDP engine
25
+ * (`@testspectra/react` bundle server + Rust `core/orchestrator::component_runner`).
26
+ * Deliberately separate from `spectra run` (E2E via core/orchestrator): see
27
27
  * docs/v2/features/component-testing/react/README.md §2 for why these are two independent
28
28
  * runners rather than one extended pipeline.
29
29
  */
@@ -9,15 +9,15 @@ import { RustCoreBridge } from '../runner/bridge.js';
9
9
  import { TypeGenerator } from '../generator/type-generator.js';
10
10
  import { runSystemChecks } from './doctor.js';
11
11
  /**
12
- * Guarantees the `.testspectra` solution-tsconfig scaffolding exists before Vitest starts.
12
+ * Guarantees the `.testspectra` solution-tsconfig scaffolding exists before the component
13
+ * runner starts.
13
14
  *
14
- * The bundled Vitest browser runner transpiles raw TypeScript specs/page-objects Just-In-Time.
15
- * When it does, `vite:esbuild` walks the nearest `tsconfig.json` and tries to read every entry in
15
+ * The bundled component runner transpiles raw TypeScript specs/page-objects Just-In-Time.
16
+ * When it does, esbuild walks the nearest `tsconfig.json` and tries to read every entry in
16
17
  * its `references` — including `./.testspectra/tsconfig.json`. That directory is gitignored, so on
17
18
  * a clean clone / fresh CI checkout the reference points at a file that does not exist, esbuild
18
- * throws `ENOENT`, Vite answers HTTP 500, and every suite dies with "Failed to fetch dynamically
19
- * imported module" before a single assertion runs. Regenerating the scaffolding up front (same
20
- * output as `spectra sync-types`) keeps the reference resolvable.
19
+ * throws `ENOENT`, and every suite dies before a single assertion runs. Regenerating the
20
+ * scaffolding up front (same output as `spectra sync-types`) keeps the reference resolvable.
21
21
  */
22
22
  export function ensureTestSpectraScaffolding(cwd) {
23
23
  const masterConfigPath = path.join(cwd, '.testspectra', 'tsconfig.json');
@@ -28,17 +28,16 @@ export function ensureTestSpectraScaffolding(cwd) {
28
28
  /**
29
29
  * Finds the Chrome-for-Testing binary that `spectra doctor` already installs/manages for the
30
30
  * E2E CDP driver (`core/driver-cdp`) — same install, same binary. `@testspectra/react` must not
31
- * contain any browser-discovery logic of its own (see the package-level comment in
32
- * tools/react/vitest.config.ts): this is the one place that resolves the path, passed down to
33
- * the bundled Vitest/Playwright runner as a plain argument (env var), so component testing never
34
- * requires a separate `playwright install` download alongside TestSpectra's own browser.
31
+ * contain any browser-discovery logic of its own: this is the one place that resolves the path,
32
+ * passed down to the native CDP component runner as a plain argument, so component testing never
33
+ * requires a separate browser download alongside TestSpectra's own browser.
35
34
  */
36
35
  async function resolveBrowserExecutable(cwd) {
37
36
  try {
38
37
  // `--only chrome` runs exactly one check — no adb/bun/git spawns, no Matchers lookup — since
39
38
  // this call exists solely to locate the managed browser binary.
40
39
  const { dependencies } = await runSystemChecks(cwd, { only: 'chrome' });
41
- const chrome = dependencies.find((d) => d.category === 'web' && d.name.includes('Chrome'));
40
+ const chrome = dependencies.find((d) => d.category === 'web' && (d.name.includes('Chrome') || d.name.includes('Chromium')));
42
41
  return chrome?.installed && chrome.location ? chrome.location : null;
43
42
  }
44
43
  catch {
@@ -46,8 +45,9 @@ async function resolveBrowserExecutable(cwd) {
46
45
  }
47
46
  }
48
47
  /**
49
- * `spectra test` — delegates to `@testspectra/react`'s bundled Vitest browser-mode runner for
50
- * component testing. Deliberately separate from `spectra run` (E2E via core/orchestrator): see
48
+ * `spectra test` — runs React component tests via the native CDP engine
49
+ * (`@testspectra/react` bundle server + Rust `core/orchestrator::component_runner`).
50
+ * Deliberately separate from `spectra run` (E2E via core/orchestrator): see
51
51
  * docs/v2/features/component-testing/react/README.md §2 for why these are two independent
52
52
  * runners rather than one extended pipeline.
53
53
  */
@@ -116,71 +116,36 @@ export async function testCommand(options = {}) {
116
116
  // / setup.ts), so the same Reporter class parses them with zero changes on either side.
117
117
  const reporter = new Reporter();
118
118
  const runStartedAt = Date.now();
119
+ logPhase('Starting component test runner (Spectra Native CDP Engine)…');
120
+ const bundleServer = await reactPkg.startBundleServer({
121
+ testRoot: cwd,
122
+ stepDelayMs: stepDelayMs ?? 0,
123
+ });
119
124
  let ok = false;
120
- if (options.runner === 'vitest') {
121
- logPhase('Starting component test runner (Vitest Browser Mode)…');
122
- ok = await reactPkg.runComponentTests({
123
- runner: 'vitest',
124
- cwd,
125
- headless,
126
- stepDelayMs,
127
- spec: options.spec,
128
- outputFile,
129
- concurrency: concurrency !== undefined && !isNaN(concurrency) ? concurrency : undefined,
130
- browserExecutablePath,
131
- onLine: (line) => {
132
- const cleanLine = line.replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, '').trim();
133
- if (!cleanLine)
134
- return;
135
- if (cleanLine.startsWith('[TESTSPECTRA_')) {
136
- reporter.addLog({ timestamp: new Date().toLocaleTimeString(), level: 'INFO', message: cleanLine });
137
- if (!reporter.isInteractiveMode()) {
138
- console.log(cleanLine);
139
- }
140
- return;
141
- }
142
- if (reactPkg.isVitestNoise?.(cleanLine)) {
143
- return;
144
- }
145
- const sanitized = reactPkg.sanitizeErrorMessage?.(cleanLine) ?? cleanLine;
146
- reporter.addLog({ timestamp: new Date().toLocaleTimeString(), level: 'ERROR', message: sanitized });
147
- if (!reporter.isInteractiveMode()) {
148
- console.error(chalk.red(sanitized));
149
- }
125
+ try {
126
+ const home = process.env.HOME || process.env.USERPROFILE || '';
127
+ const globalAppDataPath = path.join(home, '.testspectra');
128
+ const appDataPath = globalAppDataPath;
129
+ const runResult = await RustCoreBridge.run({
130
+ baseDir: cwd,
131
+ appDataPath,
132
+ platform: 'component',
133
+ config: {
134
+ bundleServerUrl: bundleServer.url,
135
+ webConfig: {
136
+ headless,
137
+ stepDelay: stepDelayMs,
138
+ browserExecutablePath,
139
+ },
150
140
  },
151
- });
141
+ specs: options.spec ? [options.spec] : undefined,
142
+ concurrency: concurrency !== undefined && !isNaN(concurrency) ? concurrency : undefined,
143
+ outputJsonPath: outputFile,
144
+ }, reporter);
145
+ ok = runResult.status.toLowerCase() === 'passed';
152
146
  }
153
- else {
154
- logPhase('Starting component test runner (Spectra Native CDP Engine)…');
155
- const bundleServer = await reactPkg.startBundleServer({
156
- testRoot: cwd,
157
- stepDelayMs: stepDelayMs ?? 0,
158
- });
159
- try {
160
- const home = process.env.HOME || process.env.USERPROFILE || '';
161
- const globalAppDataPath = path.join(home, '.testspectra');
162
- const appDataPath = globalAppDataPath;
163
- const runResult = await RustCoreBridge.run({
164
- baseDir: cwd,
165
- appDataPath,
166
- platform: 'component',
167
- config: {
168
- bundleServerUrl: bundleServer.url,
169
- webConfig: {
170
- headless,
171
- stepDelay: stepDelayMs,
172
- browserExecutablePath,
173
- },
174
- },
175
- specs: options.spec ? [options.spec] : undefined,
176
- concurrency: concurrency !== undefined && !isNaN(concurrency) ? concurrency : undefined,
177
- outputJsonPath: outputFile,
178
- }, reporter);
179
- ok = runResult.status.toLowerCase() === 'passed';
180
- }
181
- finally {
182
- bundleServer.stop();
183
- }
147
+ finally {
148
+ bundleServer.stop();
184
149
  }
185
150
  const durationMs = Date.now() - runStartedAt;
186
151
  const result = reporter.generateResult(ok ? 'passed' : 'failed', `${(durationMs / 1000).toFixed(1)}s`);
@@ -0,0 +1,3 @@
1
+ export declare function updateCommand(options?: {
2
+ check?: boolean;
3
+ }): Promise<void>;
@@ -0,0 +1,109 @@
1
+ import { execSync, execFileSync } from 'child_process';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import { fileURLToPath } from 'url';
5
+ const __filename = fileURLToPath(import.meta.url);
6
+ const __dirname = path.dirname(__filename);
7
+ const PACKAGES = ['@testspectra/cli', '@testspectra/matchers'];
8
+ function getLatestVersion(pkg) {
9
+ try {
10
+ const out = execFileSync('npm', ['view', pkg, 'version', '--json'], {
11
+ stdio: ['ignore', 'pipe', 'ignore'],
12
+ timeout: 10000,
13
+ })
14
+ .toString()
15
+ .trim()
16
+ .replace(/"/g, '');
17
+ return out || null;
18
+ }
19
+ catch {
20
+ return null;
21
+ }
22
+ }
23
+ function getCurrentVersion(pkg, cwd) {
24
+ // Walk up to find the installed version
25
+ let cur = path.resolve(cwd);
26
+ while (cur && cur !== path.dirname(cur)) {
27
+ const pkgJson = path.join(cur, 'node_modules', pkg, 'package.json');
28
+ if (fs.existsSync(pkgJson)) {
29
+ try {
30
+ const p = JSON.parse(fs.readFileSync(pkgJson, 'utf-8'));
31
+ return p.version ?? null;
32
+ }
33
+ catch { }
34
+ }
35
+ cur = path.dirname(cur);
36
+ }
37
+ return null;
38
+ }
39
+ function detectPackageManager(cwd) {
40
+ if (fs.existsSync(path.join(cwd, 'pnpm-lock.yaml')))
41
+ return 'pnpm';
42
+ if (fs.existsSync(path.join(cwd, 'bun.lockb')) || fs.existsSync(path.join(cwd, 'bun.lock')))
43
+ return 'bun';
44
+ return 'npm';
45
+ }
46
+ function buildInstallCmd(pm, pkgSpecs) {
47
+ const list = pkgSpecs.join(' ');
48
+ switch (pm) {
49
+ case 'pnpm':
50
+ return `pnpm add -D ${list}`;
51
+ case 'bun':
52
+ return `bun add -d ${list}`;
53
+ case 'npm':
54
+ default:
55
+ return `npm install -D ${list}`;
56
+ }
57
+ }
58
+ export async function updateCommand(options = {}) {
59
+ const cwd = process.cwd();
60
+ const pm = detectPackageManager(cwd);
61
+ console.log('\x1b[36m┌ 🔄 TestSpectra Self-Update\x1b[0m');
62
+ console.log('\x1b[36m│\x1b[0m');
63
+ console.log(`\x1b[36m│\x1b[0m Package Manager: \x1b[33m${pm}\x1b[0m`);
64
+ console.log('\x1b[36m│\x1b[0m');
65
+ console.log('\x1b[36m│\x1b[0m Fetching latest versions from npm registry...');
66
+ const updates = [];
67
+ for (const pkg of PACKAGES) {
68
+ const current = getCurrentVersion(pkg, cwd);
69
+ const latest = getLatestVersion(pkg);
70
+ const needsUpdate = !!latest && latest !== current;
71
+ updates.push({ pkg, current, latest, needsUpdate });
72
+ const currentStr = current ? `\x1b[33mv${current}\x1b[0m` : '\x1b[90m(not installed)\x1b[0m';
73
+ const latestStr = latest ? `\x1b[32mv${latest}\x1b[0m` : '\x1b[31m(unavailable)\x1b[0m';
74
+ const arrow = needsUpdate ? ` → ${latestStr}` : '';
75
+ const status = !latest ? '\x1b[31m✗\x1b[0m' : needsUpdate ? '\x1b[33m↑\x1b[0m' : '\x1b[32m✓\x1b[0m';
76
+ console.log(`\x1b[36m│\x1b[0m ${status} ${pkg.padEnd(28)} ${currentStr}${arrow}`);
77
+ }
78
+ const toUpdate = updates.filter((u) => u.needsUpdate && u.latest);
79
+ console.log('\x1b[36m│\x1b[0m');
80
+ if (toUpdate.length === 0) {
81
+ console.log('\x1b[36m└──────────────────────────────────────────────────────────\x1b[0m');
82
+ console.log('\x1b[32m✓ All TestSpectra packages are already up-to-date!\x1b[0m\n');
83
+ return;
84
+ }
85
+ if (options.check) {
86
+ console.log(`\x1b[36m│\x1b[0m \x1b[33m${toUpdate.length} update(s) available. Run \x1b[36mspectra update\x1b[33m to install.\x1b[0m`);
87
+ console.log('\x1b[36m└──────────────────────────────────────────────────────────\x1b[0m\n');
88
+ return;
89
+ }
90
+ const pkgSpecs = toUpdate.map((u) => `${u.pkg}@${u.latest}`);
91
+ const installCmd = buildInstallCmd(pm, pkgSpecs);
92
+ console.log(`\x1b[36m│\x1b[0m Running: \x1b[90m${installCmd}\x1b[0m`);
93
+ console.log('\x1b[36m│\x1b[0m');
94
+ try {
95
+ execSync(installCmd, { cwd, stdio: 'inherit' });
96
+ console.log('\x1b[36m│\x1b[0m');
97
+ console.log('\x1b[36m└──────────────────────────────────────────────────────────\x1b[0m');
98
+ console.log('\x1b[32m🎉 TestSpectra updated successfully!\x1b[0m');
99
+ for (const u of toUpdate) {
100
+ console.log(` \x1b[32m✓\x1b[0m ${u.pkg}: v${u.current ?? '?'} → v${u.latest}`);
101
+ }
102
+ console.log('');
103
+ }
104
+ catch (err) {
105
+ console.log('\x1b[36m└──────────────────────────────────────────────────────────\x1b[0m');
106
+ console.error(`\x1b[31m✗ Update failed: ${err.message}\x1b[0m\n`);
107
+ process.exit(1);
108
+ }
109
+ }
@@ -41,12 +41,39 @@ export class ConfigLoader {
41
41
  }
42
42
  else if (configPath.endsWith('.ts') || configPath.endsWith('.js')) {
43
43
  try {
44
- const fileUrl = new URL(`file://${path.resolve(configPath)}`).href;
45
- const mod = await import(fileUrl);
46
- loaded = mod.default || mod.config || mod;
44
+ const raw = fs.readFileSync(configPath, 'utf-8');
45
+ const ts = (await import('typescript')).default || (await import('typescript'));
46
+ const transpiled = ts.transpileModule(raw, {
47
+ compilerOptions: {
48
+ module: ts.ModuleKind.CommonJS,
49
+ target: ts.ScriptTarget.ES2022,
50
+ },
51
+ });
52
+ const mod = { exports: {} };
53
+ const mockRequire = (id) => {
54
+ if (id === '@testspectra/cli' || id.endsWith('defineConfig')) {
55
+ return { defineConfig: (c) => c };
56
+ }
57
+ try {
58
+ return require(id);
59
+ }
60
+ catch {
61
+ return {};
62
+ }
63
+ };
64
+ const fn = new Function('exports', 'require', 'module', '__filename', '__dirname', transpiled.outputText);
65
+ fn(mod.exports, mockRequire, mod, configPath, path.dirname(configPath));
66
+ loaded = mod.exports.default || mod.exports.config || mod.exports;
47
67
  }
48
68
  catch (e) {
49
- console.warn(`[TestSpectra] Warning: Failed to import ${configPath}:`, e);
69
+ try {
70
+ const fileUrl = new URL(`file://${path.resolve(configPath)}`).href;
71
+ const mod = await import(fileUrl);
72
+ loaded = mod.default || mod.config || mod;
73
+ }
74
+ catch {
75
+ console.warn(`[TestSpectra] Warning: Failed to import ${configPath}:`, e);
76
+ }
50
77
  }
51
78
  }
52
79
  }
@@ -11,7 +11,7 @@ export interface DevServerConfig {
11
11
  */
12
12
  command: string;
13
13
  /**
14
- * Target URL or healthcheck endpoint (e.g. "https://sso.tagsamurai.local:5174").
14
+ * Target URL or healthcheck endpoint (e.g. "https://sso.example.local:5174").
15
15
  */
16
16
  url: string;
17
17
  /**
@@ -37,6 +37,46 @@ export interface DevServerConfig {
37
37
  */
38
38
  env?: Record<string, string>;
39
39
  }
40
+ /**
41
+ * Configuration options for failure-only video recording.
42
+ */
43
+ export interface VideoConfig {
44
+ /**
45
+ * Video container format ('webm' | 'mp4').
46
+ * @default "webm"
47
+ */
48
+ format?: 'webm' | 'mp4';
49
+ /**
50
+ * Target frame rate in frames per second (15 | 30 | 60).
51
+ * @default 30
52
+ */
53
+ fps?: 15 | 30 | 60 | number;
54
+ /**
55
+ * Compression quality (1 - 100).
56
+ * @default 80
57
+ */
58
+ quality?: number;
59
+ /**
60
+ * Maximum video width in pixels.
61
+ * @default 1280
62
+ */
63
+ maxWidth?: number;
64
+ /**
65
+ * Maximum video height in pixels.
66
+ * @default 720
67
+ */
68
+ maxHeight?: number;
69
+ /**
70
+ * Bitrate in Mbps (Android screen recording).
71
+ * @default 4
72
+ */
73
+ bitRateMbps?: number;
74
+ /**
75
+ * Maximum recording time limit per test case in seconds (Android screen recording).
76
+ * @default 180
77
+ */
78
+ timeLimitSeconds?: number;
79
+ }
40
80
  /**
41
81
  * Configuration for web automation and browser test runners.
42
82
  */
@@ -48,7 +88,7 @@ export interface WebConfig {
48
88
  baseUrl: string;
49
89
  /**
50
90
  * Multi-host / multi-app mapping dictionary for micro-frontends and multi-entrypoint monorepos.
51
- * Maps named aliases to full base URLs (e.g. `{ sso: "https://sso.tagsamurai.local:5174", fam: "https://fam.tagsamurai.local:5175" }`).
91
+ * Maps named aliases to full base URLs (e.g. `{ sso: "https://sso.example.local:5174", fam: "https://fam.example.local:5175" }`).
52
92
  * Allows tests to navigate using `Spectra.navigate('sso:/login')` or CLI `--host sso`.
53
93
  */
54
94
  hosts?: Record<string, string>;
@@ -90,6 +130,14 @@ export interface WebConfig {
90
130
  * @default "testcase"
91
131
  */
92
132
  parallelizationMode: 'testcase' | 'suite';
133
+ /**
134
+ * Failure-only video recording configuration.
135
+ * - `true`: Records screencast and persists video only when a test case fails (default).
136
+ * - `false`: Disables video recording completely for maximum execution speed.
137
+ * - `VideoConfig`: Detailed video format and quality settings.
138
+ * @default true
139
+ */
140
+ video?: boolean | VideoConfig;
93
141
  }
94
142
  /**
95
143
  * Target Android device specification for multi-device parallel execution.
@@ -203,6 +251,11 @@ export interface AndroidConfig {
203
251
  * @default 0
204
252
  */
205
253
  stepDelay?: number;
254
+ /**
255
+ * Failure-only video recording configuration.
256
+ * @default true
257
+ */
258
+ video?: boolean | VideoConfig;
206
259
  }
207
260
  /**
208
261
  * Configuration for iOS mobile test automation with Appium & XCUITest.
@@ -273,6 +326,11 @@ export interface IosConfig {
273
326
  * @default "suite"
274
327
  */
275
328
  parallelizationMode: 'testcase' | 'suite';
329
+ /**
330
+ * Failure-only video recording configuration.
331
+ * @default true
332
+ */
333
+ video?: boolean | VideoConfig;
276
334
  }
277
335
  /**
278
336
  * Specific domain filter for real-time network interception and recording.
@@ -28,6 +28,7 @@ export const DEFAULT_CONFIG_DATA = {
28
28
  headless: true,
29
29
  implicitWait: '5000',
30
30
  parallelizationMode: 'testcase',
31
+ video: true,
31
32
  },
32
33
  androidConfig: {
33
34
  driverServer: 'http://127.0.0.1:4800',
@@ -36,6 +37,7 @@ export const DEFAULT_CONFIG_DATA = {
36
37
  noReset: false,
37
38
  implicitWait: 10000,
38
39
  parallelizationMode: 'suite',
40
+ video: true,
39
41
  devices: [
40
42
  {
41
43
  id: 'emulator-5554',
@@ -53,6 +55,7 @@ export const DEFAULT_CONFIG_DATA = {
53
55
  noReset: false,
54
56
  implicitWait: 10000,
55
57
  parallelizationMode: 'suite',
58
+ video: true,
56
59
  devices: [
57
60
  {
58
61
  id: 'iphone-15-pro',
@@ -68,6 +71,6 @@ export const DEFAULT_CONFIG_DATA = {
68
71
  fastResponseTime: '200',
69
72
  normalResponseTime: '1000',
70
73
  monitoredDomains: [],
71
- environmentVariables: {},
74
+ environmentVariables: { DEMO_API_MODE: 'sandbox' },
72
75
  },
73
76
  };
@@ -262,6 +262,27 @@ describe('Platform TSConfigs & Type Isolation (docs/v2/cli/tsconfig-platform-iso
262
262
  expect(threw).toBe(true);
263
263
  expect(errorMessage).toContain("Cannot find name 'AuthLoginPage'");
264
264
  });
265
+ it('feature-scoped configs enable JSX so .tsx component tests type-check (regression: getScopedConfig omitted jsx)', () => {
266
+ const featureDir = path.join(tmpDir, 'packages', 'catalog', 'e2e');
267
+ fs.mkdirSync(path.join(featureDir, 'page-objects', 'RowComponent'), { recursive: true });
268
+ fs.writeFileSync(path.join(featureDir, 'page-objects', 'RowComponent', 'web.ts'), 'export default class RowComponent { static title = "row"; }');
269
+ fs.mkdirSync(path.join(featureDir, 'specs'), { recursive: true });
270
+ TypeGenerator.generateAll(tmpDir);
271
+ const scopedWebConfig = JSON.parse(fs.readFileSync(path.join(tmpDir, '.testspectra', 'tsconfig', 'features', 'catalog', 'web.json'), 'utf-8'));
272
+ expect(scopedWebConfig.compilerOptions.jsx).toBe('react-jsx');
273
+ });
274
+ it('E2E: compiles a .tsx component test inside a feature scope via tsc -b with 0 errors', () => {
275
+ linkNodeModules(tmpDir);
276
+ const featureDir = path.join(tmpDir, 'packages', 'catalog', 'e2e');
277
+ fs.mkdirSync(path.join(featureDir, 'page-objects', 'RowComponent'), { recursive: true });
278
+ fs.writeFileSync(path.join(featureDir, 'page-objects', 'RowComponent', 'web.ts'), 'export default class RowComponent { static title = "row"; }');
279
+ fs.mkdirSync(path.join(featureDir, 'specs', 'Row', 'TC-0001'), { recursive: true });
280
+ fs.writeFileSync(path.join(featureDir, 'specs', 'Row', 'TC-0001', 'web.test.tsx'), 'const el = <div>{RowComponent.title}</div>;\n');
281
+ TypeGenerator.generateAll(tmpDir);
282
+ expect(() => {
283
+ execSync(`npx tsc -b ${tmpDir}`, { stdio: 'pipe' });
284
+ }).not.toThrow();
285
+ });
265
286
  it('E2E: compiles the full Nx monorepo template via tsc -b with 0 errors', async () => {
266
287
  let root = process.cwd();
267
288
  while (root && root !== path.dirname(root)) {