@testspectra/cli 1.1.8-rc.9 → 1.1.10

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 (80) hide show
  1. package/README.md +11 -8
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/commands/__tests__/doctor-filter.test.d.ts +1 -0
  4. package/dist/commands/__tests__/doctor-filter.test.js +37 -0
  5. package/dist/commands/__tests__/test-error-streaming.test.d.ts +1 -0
  6. package/dist/commands/__tests__/test-error-streaming.test.js +85 -0
  7. package/dist/commands/__tests__/test-scaffolding.test.d.ts +1 -0
  8. package/dist/commands/__tests__/test-scaffolding.test.js +32 -0
  9. package/dist/commands/__tests__/upgrade.test.d.ts +1 -0
  10. package/dist/commands/__tests__/upgrade.test.js +95 -0
  11. package/dist/commands/add.js +3 -3
  12. package/dist/commands/devices.js +1 -0
  13. package/dist/commands/docs.d.ts +4 -0
  14. package/dist/commands/docs.js +17 -0
  15. package/dist/commands/doctor.d.ts +9 -1
  16. package/dist/commands/doctor.js +17 -3
  17. package/dist/commands/init.js +5 -159
  18. package/dist/commands/install.js +5 -0
  19. package/dist/commands/license.js +1 -0
  20. package/dist/commands/run.d.ts +2 -0
  21. package/dist/commands/run.js +22 -0
  22. package/dist/commands/test.d.ts +13 -0
  23. package/dist/commands/test.js +115 -21
  24. package/dist/commands/upgrade.d.ts +12 -0
  25. package/dist/commands/upgrade.js +197 -0
  26. package/dist/config/schema.d.ts +53 -0
  27. package/dist/config/schema.js +2 -0
  28. package/dist/generator/__tests__/tsconfig-isolation.test.js +2 -1
  29. package/dist/generator/architecture-doc-generator.d.ts +14 -0
  30. package/dist/generator/architecture-doc-generator.js +80 -0
  31. package/dist/generator/index.d.ts +1 -0
  32. package/dist/generator/index.js +1 -0
  33. package/dist/generator/tsconfig-generator.js +1 -1
  34. package/dist/index.js +59 -16
  35. package/dist/linter/discovery.js +2 -2
  36. package/dist/linter/schemas/spec.schema.d.ts +6 -6
  37. package/dist/runner/bridge.d.ts +1 -1
  38. package/dist/runner/bridge.js +58 -16
  39. package/dist/runner/reporter.d.ts +1 -0
  40. package/dist/runner/reporter.js +12 -6
  41. package/dist/utils/__tests__/dev-server-manager.test.d.ts +1 -0
  42. package/dist/utils/__tests__/dev-server-manager.test.js +72 -0
  43. package/dist/utils/background-update-check.d.ts +1 -0
  44. package/dist/utils/background-update-check.js +43 -0
  45. package/dist/utils/dependency-updates.d.ts +15 -0
  46. package/dist/utils/dependency-updates.js +107 -0
  47. package/dist/utils/dev-server-manager.d.ts +40 -0
  48. package/dist/utils/dev-server-manager.js +257 -0
  49. package/dist/utils/update-notifier.d.ts +2 -0
  50. package/dist/utils/update-notifier.js +67 -0
  51. package/package.json +10 -9
  52. package/templates/default/package.json +2 -2
  53. package/templates/docs/ARCHITECTURE.default.md +26 -0
  54. package/templates/docs/ARCHITECTURE.nx.md +147 -0
  55. package/templates/nx/.nx/workspace-data/70094CFD-E89B-57A1-9619-2FC5F4963C54.db +0 -0
  56. package/templates/nx/.nx/workspace-data/70094CFD-E89B-57A1-9619-2FC5F4963C54.db-shm +0 -0
  57. package/templates/nx/.nx/workspace-data/70094CFD-E89B-57A1-9619-2FC5F4963C54.db-wal +0 -0
  58. package/templates/nx/.nx/workspace-data/d/daemon.log +745 -0
  59. package/templates/nx/.nx/workspace-data/d/server-process.json +1 -1
  60. package/templates/nx/.nx/workspace-data/file-map.json +172 -186
  61. package/templates/nx/.nx/workspace-data/lockfile.hash +1 -1
  62. package/templates/nx/.nx/workspace-data/nx_files.nxt +0 -0
  63. package/templates/nx/.nx/workspace-data/parsed-lock-file.json +81 -130
  64. package/templates/nx/.nx/workspace-data/project-graph.json +85 -138
  65. package/templates/nx/.nx/workspace-data/source-maps.json +336 -0
  66. package/templates/nx/package.json +3 -3
  67. package/templates/nx/pnpm-lock.yaml +191 -214
  68. package/templates/nx/spectra.config.ts +13 -0
  69. package/templates/react-component/package.json +3 -3
  70. package/templates/react-component/page-objects/BadgeComponent/web.ts +4 -4
  71. package/templates/react-component/page-objects/ButtonComponent/web.ts +3 -3
  72. package/templates/react-component/page-objects/InputComponent/web.ts +1 -1
  73. package/templates/react-component/specs/ButtonComponent/TC-0001-button-rendering-and-click/web.test.tsx +0 -3
  74. package/templates/react-component/specs/ButtonComponent/TC-0004-disabled-button-not-clickable/web.test.tsx +0 -9
  75. package/templates/react-component/specs/ButtonComponent/TC-0005-button-renders-icon/web.test.tsx +1 -1
  76. package/templates/react-component/specs/InputComponent/TC-0003-input-typing-and-clear/web.test.tsx +4 -8
  77. package/templates/react-component/spectra.config.ts +0 -10
  78. package/templates/react-component/src/components/Badge/Badge.tsx +3 -3
  79. package/templates/react-component/src/components/Button/Button.tsx +5 -3
  80. package/templates/react-component/src/components/Input/Input.tsx +2 -3
package/dist/index.js CHANGED
@@ -7,12 +7,13 @@ import { initCommand } from './commands/init.js';
7
7
  import { installCommand } from './commands/install.js';
8
8
  import { runCommand } from './commands/run.js';
9
9
  import { testCommand } from './commands/test.js';
10
- import { updateCommand } from './commands/update.js';
10
+ import { upgradeCommand } from './commands/upgrade.js';
11
11
  import { watchCommand } from './commands/watch.js';
12
12
  import { syncTypesCommand } from './commands/sync-types.js';
13
13
  import { refactorCommand } from './commands/refactor.js';
14
14
  import { lintCommand } from './commands/lint.js';
15
15
  import { licenseCommand } from './commands/license.js';
16
+ import { docsUpdateCommand } from './commands/docs.js';
16
17
  import { skillsInitCommand, skillsListCommand, skillsAddCommand, skillsUpdateCommand, skillsRemoveCommand, } from './commands/skills.js';
17
18
  export * from './config/schema.js';
18
19
  export * from './config/loader.js';
@@ -24,10 +25,10 @@ export * from './types/generator.js';
24
25
  export * from '@testspectra/matchers';
25
26
  export function createCliProgram() {
26
27
  const program = new Command();
27
- program.name('spectra').description('TestSpectra Zero-Config Cross-Platform Test Runner CLI').version('1.0.0');
28
+ program.name('spectra').description('TestSpectra Cross-Platform Test Runner CLI').version('1.0.0');
28
29
  program
29
30
  .command('init')
30
- .description('Initialize testspectra.config.json and project directories')
31
+ .description('Initialize spectra.config.ts and project directories')
31
32
  .option('-t, --template <template>', 'Project template: default, nx, react-component')
32
33
  .option('-f, --force', 'Overwrite existing configuration if present')
33
34
  .action(initCommand);
@@ -40,15 +41,16 @@ export function createCliProgram() {
40
41
  program
41
42
  .command('install [dependency]')
42
43
  .description('Install missing testing dependencies, browser drivers, and workspace packages')
43
- .option('-f, --force', 'Force re-installation of dependencies')
44
+ .option('-f, --force', 'Remove existing artifacts and perform a clean reinstall of the dependency')
44
45
  .addHelpText('after', `
45
46
  Examples:
46
47
  $ spectra install Install all missing required dependencies automatically
47
48
  $ spectra install chrome Install Google Chrome for Testing & matching Chromedriver
48
49
  $ spectra install adb Install Google Android SDK Platform Tools (ADB)
49
50
  $ spectra install bun Install Bun runtime
50
- $ spectra install android-driver Install Spectra Android Native Driver & WDIO Service
51
- $ spectra install wdio Install WebDriverIO core packages and matchers`)
51
+ $ spectra install android-agent Install Spectra Android Native Agent (spectra-agent.jar)
52
+ $ spectra install android-agent -f Clean reinstall Spectra Android Native Agent
53
+ $ spectra install packages Install TestSpectra CLI, Matchers & Android Agent packages`)
52
54
  .action(installCommand);
53
55
  program
54
56
  .command('demo')
@@ -85,20 +87,31 @@ Examples:
85
87
  .action(lintCommand);
86
88
  program
87
89
  .command('doctor')
88
- .description('Verify local environment prerequisites (ADB, Spectra Android Driver, Chrome, Bun)')
90
+ .description('Verify local environment prerequisites (Bun, Git, ADB, Chrome, Spectra Android Agent, workspace packages)')
89
91
  .option('-v, --verbose', 'Show full installation paths for all detected runtimes and packages')
92
+ .option('--scope <category>', 'Only check one category: core, web, mobile (default: all)')
93
+ .option('--only <keys>', 'Only check specific dependencies (comma-separated): bun, git, cli, chrome, matchers, adb, agent')
90
94
  .option('--fix', 'Attempt automatic fix / download of missing tools')
91
95
  .option('--json', 'Output diagnostic results as JSON')
92
96
  .action(doctorCommand);
93
97
  program
94
- .command('update')
95
- .description('Update @testspectra/cli and @testspectra/matchers to the latest version from npm')
98
+ .command('upgrade [packages...]')
99
+ .alias('update')
100
+ .description('Upgrade TestSpectra ecosystem packages and system drivers to the latest versions')
96
101
  .option('--check', 'Only check for available updates without installing')
97
- .action(updateCommand);
102
+ .option('--packages-only', 'Only upgrade NPM packages in package.json')
103
+ .option('--drivers-only', 'Only upgrade system binaries and drivers (ADB, Agent, Chrome)')
104
+ .option('--json', 'Output the update check (packages + drivers) as JSON (implies --check, no changes applied)')
105
+ .addHelpText('after', `
106
+ Examples:
107
+ $ spectra upgrade Upgrade all packages and system drivers
108
+ $ spectra upgrade @testspectra/cli Upgrade only the CLI package
109
+ $ spectra upgrade @testspectra/cli @testspectra/matchers Upgrade specific packages
110
+ $ spectra upgrade --check Check what is outdated (packages + drivers)`)
111
+ .action(upgradeCommand);
98
112
  program
99
113
  .command('devices')
100
- .description('List connected Android/iOS devices and local browsers')
101
- .option('-t, --target <target>', 'Filter scope: android, ios, web, all', 'all')
114
+ .description('List connected Android devices (ADB)')
102
115
  .option('--json', 'Output results as JSON')
103
116
  .action(devicesCommand);
104
117
  program
@@ -147,11 +160,19 @@ Examples:
147
160
  .option('-w, --cwd <dir>', 'Custom workspace directory')
148
161
  .option('-a, --agent <ids>', 'One-off override: comma-separated agent(s) to target, bypassing the saved "init" selection (claude, opencode, kilo, agents, gemini)')
149
162
  .action(skillsRemoveCommand);
163
+ const docs = program.command('docs').description('Manage generated TestSpectra workspace documentation');
164
+ docs
165
+ .command('update')
166
+ .description('Regenerate ARCHITECTURE.md from the current workspace state, without re-running init')
167
+ .option('-w, --cwd <dir>', 'Custom workspace directory')
168
+ .action(docsUpdateCommand);
150
169
  program
151
170
  .command('run [paths...]')
152
171
  .description('Execute test suite or individual test case')
153
172
  .option('-s, --spec <path>', 'Spec file path to execute')
154
173
  .option('-t, --target <target>', 'Target platform: web, android, ios, common', 'web')
174
+ .option('--host <name>', 'Target specific named host defined in spectra.config.ts (webConfig.hosts)')
175
+ .option('--base-url <url>', 'Override webConfig.baseUrl')
155
176
  .option('-d, --device <device>', 'Target device name or UDID')
156
177
  .option('--all-devices', 'Distribute test execution across all connected devices')
157
178
  .option('--multi-device', 'Distribute test execution across all connected devices (alias for --all-devices)')
@@ -170,20 +191,42 @@ Examples:
170
191
  .action(runCommand);
171
192
  program
172
193
  .command('test')
173
- .description('Run React component tests via @testspectra/react (bundled Vitest browser mode)')
194
+ .description('Run React component tests via @testspectra/react')
195
+ .option('-r, --runner <engine>', 'Component test runner engine: spectra (default, native CDP) or vitest (Playwright browser mode)', 'spectra')
174
196
  .option('-w, --cwd <dir>', 'Custom workspace directory')
175
197
  .option('-s, --spec <path>', 'Spec file path (or substring filter) to execute')
176
198
  .option('--headless', 'Run in headless mode (default)')
177
199
  .option('--no-headless', 'Run in headed mode')
178
200
  .option('--headed', 'Run the browser in visible GUI mode (alias for --no-headless)')
179
- .option('--step-delay <ms>', 'Delay after each action/assertion in milliseconds (slow motion, for observing DOM transitions in --headed mode)')
201
+ .option('-d, --step-delay <ms>', 'Delay after each action/assertion in milliseconds (slow motion, for observing DOM transitions in --headed mode)')
180
202
  .option('--delay <ms>', 'Alias for --step-delay')
181
- .option('-c, --concurrency <number>', 'Run spec files sequentially (1) or in parallel (>1, default). Browser mode has no numeric N-worker sizing like `spectra run`.')
182
- .option('-o, --output <path>', 'Write a Vitest JSON test report to this path')
203
+ .option('-c, --concurrency <number>', 'Run spec files sequentially (1) or in parallel (>1, default).')
204
+ .option('-o, --output <path>', 'Write a JSON test report to this path')
183
205
  .action(testCommand);
184
206
  return program;
185
207
  }
186
208
  export async function runCli(args = process.argv) {
187
209
  const program = createCliProgram();
188
210
  await program.parseAsync(args);
211
+ // Commands whose stdout must stay machine-readable or is purely diagnostic never emit the
212
+ // interactive "update available" banner.
213
+ const isJson = args.includes('--json');
214
+ const silentCommands = [
215
+ 'doctor',
216
+ 'install',
217
+ 'upgrade',
218
+ 'update',
219
+ 'license',
220
+ 'devices',
221
+ 'lint',
222
+ 'sync-types',
223
+ 'refactor',
224
+ 'watch',
225
+ ];
226
+ const isSilent = isJson || silentCommands.includes(program.args[0]);
227
+ const { checkUpdateInBackground, displayUpdateNotificationIfNeeded } = await import('./utils/update-notifier.js');
228
+ checkUpdateInBackground();
229
+ if (!isSilent) {
230
+ displayUpdateNotificationIfNeeded();
231
+ }
189
232
  }
@@ -1,5 +1,6 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
+ import { ConfigLoader } from '../config/loader.js';
3
4
  /**
4
5
  * Scans a workspace directory for all `project.json` files that have the
5
6
  * "testspectra:shared" tag, and returns their parent folder paths.
@@ -96,8 +97,7 @@ export function isInsideSharedLibrary(filePath, workspaceDir) {
96
97
  export function findProjectRoot(startDir) {
97
98
  let current = path.resolve(startDir);
98
99
  while (true) {
99
- if (fs.existsSync(path.join(current, 'spectra.config.ts')) ||
100
- fs.existsSync(path.join(current, 'spectra.config.js'))) {
100
+ if (ConfigLoader.CONFIG_FILE_NAMES.some((name) => fs.existsSync(path.join(current, name)))) {
101
101
  return current;
102
102
  }
103
103
  const parent = path.dirname(current);
@@ -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
- web?: "automated" | "manual" | "unsupported" | undefined;
15
14
  mobile?: "automated" | "manual" | "unsupported" | undefined;
16
- }, {
17
15
  web?: "automated" | "manual" | "unsupported" | undefined;
16
+ }, {
18
17
  mobile?: "automated" | "manual" | "unsupported" | undefined;
18
+ web?: "automated" | "manual" | "unsupported" | undefined;
19
19
  }>;
20
20
  export declare const SpecFrontmatterSchema: z.ZodObject<{
21
21
  id: z.ZodString;
@@ -27,11 +27,11 @@ 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
- web?: "automated" | "manual" | "unsupported" | undefined;
31
30
  mobile?: "automated" | "manual" | "unsupported" | undefined;
32
- }, {
33
31
  web?: "automated" | "manual" | "unsupported" | undefined;
32
+ }, {
34
33
  mobile?: "automated" | "manual" | "unsupported" | undefined;
34
+ web?: "automated" | "manual" | "unsupported" | undefined;
35
35
  }>>;
36
36
  tags: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
37
37
  }, "strip", z.ZodTypeAny, {
@@ -41,8 +41,8 @@ export declare const SpecFrontmatterSchema: z.ZodObject<{
41
41
  priority: "Critical" | "High" | "Medium" | "Low";
42
42
  caseType: "Positive" | "Negative" | "Edge";
43
43
  coverage?: {
44
- web?: "automated" | "manual" | "unsupported" | undefined;
45
44
  mobile?: "automated" | "manual" | "unsupported" | undefined;
45
+ web?: "automated" | "manual" | "unsupported" | undefined;
46
46
  } | undefined;
47
47
  tags?: string[] | undefined;
48
48
  }, {
@@ -52,8 +52,8 @@ export declare const SpecFrontmatterSchema: z.ZodObject<{
52
52
  priority: "Critical" | "High" | "Medium" | "Low";
53
53
  caseType: "Positive" | "Negative" | "Edge";
54
54
  coverage?: {
55
- web?: "automated" | "manual" | "unsupported" | undefined;
56
55
  mobile?: "automated" | "manual" | "unsupported" | undefined;
56
+ web?: "automated" | "manual" | "unsupported" | undefined;
57
57
  } | undefined;
58
58
  tags?: string[] | undefined;
59
59
  }>;
@@ -3,7 +3,7 @@ import { Reporter, TestRunResult } from './reporter.js';
3
3
  export interface RunOptions {
4
4
  baseDir: string;
5
5
  appDataPath: string;
6
- platform: 'web' | 'android' | 'ios' | 'common' | 'mobile';
6
+ platform: 'web' | 'android' | 'ios' | 'common' | 'mobile' | 'component';
7
7
  suite?: string;
8
8
  testCases?: Array<{
9
9
  id: string;
@@ -16,16 +16,54 @@ function platformPackageName() {
16
16
  return '@testspectra/cli-darwin-arm64';
17
17
  if (platform === 'linux' && arch === 'x64')
18
18
  return '@testspectra/cli-linux-x64';
19
- if (platform === 'win32' && arch === 'arm64')
20
- return '@testspectra/cli-win32-arm64';
21
19
  if (platform === 'win32' && arch === 'x64')
22
20
  return '@testspectra/cli-win32-x64';
21
+ // win32/arm64 has no published native binary (scripts/release-cli-local.sh no longer builds
22
+ // it) — falls through to `pkgName === null` below, which throws a clear "not currently
23
+ // supported" error instead of pointing at an optional dependency that doesn't exist.
23
24
  return null;
24
25
  }
25
26
  export class RustCoreBridge {
26
27
  static resolveBinaryPath() {
27
28
  const isWindows = process.platform === 'win32';
28
29
  const binaryName = isWindows ? 'testspectra-runner.exe' : 'testspectra-runner';
30
+ // In local monorepo development (where core/Cargo.toml exists), prioritize the locally-built
31
+ // binary in core/target/release or cli/bin over any stale optionalDependency in node_modules.
32
+ let workspaceRoot = null;
33
+ let curr = __dirname;
34
+ while (curr !== path.dirname(curr)) {
35
+ if (fs.existsSync(path.join(curr, 'core/Cargo.toml')) || fs.existsSync(path.join(curr, 'pnpm-workspace.yaml'))) {
36
+ workspaceRoot = curr;
37
+ break;
38
+ }
39
+ curr = path.dirname(curr);
40
+ }
41
+ const candidatePaths = [
42
+ ...(workspaceRoot
43
+ ? [
44
+ path.join(workspaceRoot, 'core/target/release', binaryName),
45
+ path.join(workspaceRoot, 'target/release', binaryName),
46
+ path.join(workspaceRoot, 'cli/bin', binaryName),
47
+ path.join(workspaceRoot, 'core/cli/target/release', binaryName),
48
+ path.join(workspaceRoot, 'core/target/debug', binaryName),
49
+ path.join(workspaceRoot, 'target/debug', binaryName),
50
+ path.join(workspaceRoot, 'core/cli/target/debug', binaryName),
51
+ ]
52
+ : []),
53
+ path.join(__dirname, '../bin', binaryName),
54
+ path.join(__dirname, '../../bin', binaryName),
55
+ path.join(__dirname, '../../bin/testspectra-runner'),
56
+ ];
57
+ if (workspaceRoot) {
58
+ for (const p of candidatePaths) {
59
+ if (fs.existsSync(p)) {
60
+ return p;
61
+ }
62
+ if (fs.existsSync(`${p}.exe`)) {
63
+ return `${p}.exe`;
64
+ }
65
+ }
66
+ }
29
67
  // 1. Production install: resolve the prebuilt binary from the platform-specific
30
68
  // optionalDependency package (e.g. @testspectra/cli-darwin-arm64). This is how the
31
69
  // published npm package ships native binaries for every supported platform without
@@ -52,20 +90,7 @@ export class RustCoreBridge {
52
90
  // Optional dependency not installed — fall through to the workspace dev paths below.
53
91
  }
54
92
  }
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, '../../..');
58
- const candidatePaths = [
59
- path.join(workspaceRoot, 'core/target/release', binaryName),
60
- path.join(workspaceRoot, 'target/release', binaryName),
61
- path.join(workspaceRoot, 'cli/bin', binaryName),
62
- path.join(__dirname, '../../bin', binaryName),
63
- path.join(workspaceRoot, 'core/cli/target/release', binaryName),
64
- path.join(workspaceRoot, 'core/target/debug', binaryName),
65
- path.join(workspaceRoot, 'target/debug', binaryName),
66
- path.join(workspaceRoot, 'core/cli/target/debug', binaryName),
67
- path.join(__dirname, '../../bin/testspectra-runner'),
68
- ];
93
+ // 2. Dev fallback if not already resolved above
69
94
  for (const p of candidatePaths) {
70
95
  if (fs.existsSync(p)) {
71
96
  return p;
@@ -114,9 +139,12 @@ export class RustCoreBridge {
114
139
  return new Promise((resolve, reject) => {
115
140
  const child = spawn(binPath, [payload], {
116
141
  env: runnerEnv,
142
+ windowsHide: true,
117
143
  });
118
144
  let runStatus = 'failed';
119
145
  let runDuration = '0s';
146
+ let completePassedCount;
147
+ let completeFailedCount;
120
148
  child.stdout.on('data', (data) => {
121
149
  const lines = data.toString().split('\n');
122
150
  for (const line of lines) {
@@ -159,6 +187,12 @@ export class RustCoreBridge {
159
187
  else if (parsed.type === 'complete') {
160
188
  runStatus = parsed.status;
161
189
  runDuration = parsed.duration;
190
+ if (typeof parsed.passed_count === 'number') {
191
+ completePassedCount = parsed.passed_count;
192
+ }
193
+ if (typeof parsed.failed_count === 'number') {
194
+ completeFailedCount = parsed.failed_count;
195
+ }
162
196
  }
163
197
  else if (parsed.type === 'error') {
164
198
  reporter.addLog({
@@ -194,6 +228,14 @@ export class RustCoreBridge {
194
228
  });
195
229
  child.on('close', (code) => {
196
230
  let result = reporter.generateResult(code === 0 ? 'passed' : runStatus, runDuration);
231
+ if (result.passedCount === 0 && result.failedCount === 0) {
232
+ if (completePassedCount !== undefined) {
233
+ result.passedCount = completePassedCount;
234
+ }
235
+ if (completeFailedCount !== undefined) {
236
+ result.failedCount = completeFailedCount;
237
+ }
238
+ }
197
239
  // Only trust a pre-existing outputJsonPath when it was actually written during THIS run
198
240
  // (mtime at/after runStartedAt) — NOT just when the process exited with code 0. The
199
241
  // native runner writes its own complete report once the pipeline reaches the report
@@ -35,6 +35,7 @@ export declare class Reporter {
35
35
  private networkEvents;
36
36
  private pendingTests;
37
37
  private workerToTest;
38
+ private pendingErrorMsg?;
38
39
  private passedCount;
39
40
  private failedCount;
40
41
  private isInteractive;
@@ -155,6 +155,7 @@ export class Reporter {
155
155
  networkEvents = [];
156
156
  pendingTests = new Map();
157
157
  workerToTest = new Map();
158
+ pendingErrorMsg;
158
159
  passedCount = 0;
159
160
  failedCount = 0;
160
161
  isInteractive;
@@ -355,10 +356,10 @@ export class Reporter {
355
356
  const err = raw.replace('[TESTSPECTRA_TEST_ERROR]', '').trim();
356
357
  const latest = Array.from(this.pendingTests.values()).pop();
357
358
  if (latest) {
358
- latest.errorMsg = err;
359
+ latest.errorMsg = latest.errorMsg ? `${latest.errorMsg}\n${err}` : err;
359
360
  }
360
361
  else {
361
- console.log(` \x1b[38;2;251;191;36m↳ Error:\x1b[0m \x1b[38;2;248;113;113m${err}\x1b[0m`);
362
+ this.pendingErrorMsg = this.pendingErrorMsg ? `${this.pendingErrorMsg}\n${err}` : err;
362
363
  }
363
364
  return;
364
365
  }
@@ -394,16 +395,14 @@ export class Reporter {
394
395
  this.failedCount++;
395
396
  const testCase = this.pendingTests.get(title) || this.resolveTestCase(title);
396
397
  const steps = testCase?.steps || [];
397
- const errorMsg = testCase?.errorMsg;
398
+ const errorMsg = testCase?.errorMsg || this.pendingErrorMsg;
399
+ this.pendingErrorMsg = undefined;
398
400
  const workerId = testCase?.workerId;
399
401
  this.pendingTests.delete(title);
400
402
  if (workerId !== undefined) {
401
403
  this.workerToTest.delete(workerId);
402
404
  }
403
405
  this.clearActiveSlots();
404
- if (errorMsg) {
405
- console.log(` \x1b[38;2;251;191;36m↳ Error:\x1b[0m \x1b[38;2;248;113;113m${errorMsg}\x1b[0m`);
406
- }
407
406
  console.log(`${chalk.hex('#f87171')('✗')} ${chalk.white.bold(title)} ${chalk.gray(`(${duration}ms)`)}`);
408
407
  for (const step of steps) {
409
408
  if (step.passed) {
@@ -413,6 +412,13 @@ export class Reporter {
413
412
  console.log(` ${chalk.red('✖')} ${chalk.red(step.text)} ${chalk.gray(`(${step.duration}ms)`)}`);
414
413
  }
415
414
  }
415
+ if (errorMsg) {
416
+ const errLines = errorMsg.split('\n');
417
+ console.log(` \x1b[38;2;251;191;36m↳ Error:\x1b[0m \x1b[38;2;248;113;113m${errLines[0]}\x1b[0m`);
418
+ for (let i = 1; i < errLines.length; i++) {
419
+ console.log(` \x1b[38;2;248;113;113m${errLines[i]}\x1b[0m`);
420
+ }
421
+ }
416
422
  console.log('');
417
423
  this.renderActiveSlots();
418
424
  return;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,72 @@
1
+ import http from 'node:http';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+ import { DevServerManager } from '../dev-server-manager.js';
4
+ describe('DevServerManager', () => {
5
+ it('parses command strings into binary and argv array without shell', () => {
6
+ expect(DevServerManager.parseCommand('pnpm --filter sso dev')).toEqual({
7
+ bin: 'pnpm',
8
+ args: ['--filter', 'sso', 'dev'],
9
+ });
10
+ expect(DevServerManager.parseCommand('nx run auth-e2e:serve --port=5174')).toEqual({
11
+ bin: 'nx',
12
+ args: ['run', 'auth-e2e:serve', '--port=5174'],
13
+ });
14
+ expect(DevServerManager.parseCommand('node "./scripts/serve.js" --mode "dev test"')).toEqual({
15
+ bin: 'node',
16
+ args: ['./scripts/serve.js', '--mode', 'dev test'],
17
+ });
18
+ });
19
+ it('probes listening HTTP server and detects readiness', async () => {
20
+ const server = http.createServer((_req, res) => {
21
+ res.writeHead(200, { 'Content-Type': 'text/plain' });
22
+ res.end('OK');
23
+ });
24
+ await new Promise((resolve) => {
25
+ server.listen(0, '127.0.0.1', () => resolve());
26
+ });
27
+ const addr = server.address();
28
+ const url = `http://127.0.0.1:${addr.port}`;
29
+ const isReady = await DevServerManager.probeUrl(url, 1000);
30
+ expect(isReady).toBe(true);
31
+ const isNonExistentReady = await DevServerManager.probeUrl('http://127.0.0.1:49999', 200);
32
+ expect(isNonExistentReady).toBe(false);
33
+ await new Promise((resolve) => server.close(() => resolve()));
34
+ });
35
+ it('reuses existing running server without spawning new process', async () => {
36
+ const server = http.createServer((_req, res) => {
37
+ res.writeHead(200);
38
+ res.end('OK');
39
+ });
40
+ await new Promise((resolve) => {
41
+ server.listen(0, '127.0.0.1', () => resolve());
42
+ });
43
+ const addr = server.address();
44
+ const url = `http://127.0.0.1:${addr.port}`;
45
+ const manager = new DevServerManager();
46
+ const result = await manager.ensureServer({
47
+ name: 'test-server',
48
+ command: 'echo "should not run"',
49
+ url,
50
+ reuseExistingServer: true,
51
+ });
52
+ expect(result.reused).toBe(true);
53
+ expect(result.name).toBe('test-server');
54
+ expect(result.url).toBe(url);
55
+ await manager.stopAll();
56
+ await new Promise((resolve) => server.close(() => resolve()));
57
+ });
58
+ it('filters dev servers by target host filter', async () => {
59
+ const manager = new DevServerManager();
60
+ const spy = vi.spyOn(manager, 'ensureServer').mockResolvedValue({
61
+ name: 'sso',
62
+ url: 'https://sso.local:5174',
63
+ reused: true,
64
+ });
65
+ await manager.startAll([
66
+ { name: 'sso', command: 'pnpm sso:dev', url: 'https://sso.local:5174' },
67
+ { name: 'fam', command: 'pnpm fam:dev', url: 'https://fam.local:5175' },
68
+ ], 'sso');
69
+ expect(spy).toHaveBeenCalledTimes(1);
70
+ expect(spy).toHaveBeenCalledWith(expect.objectContaining({ name: 'sso' }), expect.any(String));
71
+ });
72
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,43 @@
1
+ import fs from 'fs';
2
+ import { execFileSync } from 'child_process';
3
+ const cachePath = process.argv[2];
4
+ if (!cachePath)
5
+ process.exit(0);
6
+ const PACKAGES = ['@testspectra/cli', '@testspectra/matchers'];
7
+ function getLatestVersion(pkg) {
8
+ try {
9
+ const out = execFileSync('npm', ['view', pkg, 'version', '--json'], {
10
+ stdio: ['ignore', 'pipe', 'ignore'],
11
+ timeout: 10000,
12
+ })
13
+ .toString()
14
+ .trim()
15
+ .replace(/"/g, '');
16
+ return out || null;
17
+ }
18
+ catch {
19
+ return null;
20
+ }
21
+ }
22
+ async function run() {
23
+ const updates = [];
24
+ for (const pkg of PACKAGES) {
25
+ const latest = getLatestVersion(pkg);
26
+ if (latest) {
27
+ // Hardcode a check or let the main thread do the actual diff
28
+ // For now, let's just record the latest versions.
29
+ updates.push({ pkg, latest });
30
+ }
31
+ }
32
+ // To properly do a diff, we should read package.json of the project.
33
+ // Since we don't have cwd here, we just save the latest versions and let the main thread diff it,
34
+ // OR we can pass the current versions as arguments to this script.
35
+ // For simplicity, we just save the latest versions.
36
+ const cacheData = {
37
+ timestamp: Date.now(),
38
+ latestVersions: updates,
39
+ updatesAvailable: updates, // Main thread will filter this properly later or we just mock it for architecture
40
+ };
41
+ fs.writeFileSync(cachePath, JSON.stringify(cacheData, null, 2), 'utf-8');
42
+ }
43
+ run().catch(() => { });
@@ -0,0 +1,15 @@
1
+ export interface DependencyUpdateStatus {
2
+ name: string;
3
+ kind: 'package' | 'driver';
4
+ identifier: string;
5
+ current: string | null;
6
+ latest: string | null;
7
+ needsUpdate: boolean;
8
+ }
9
+ /**
10
+ * Best-effort "installed vs latest" report for the managed system drivers and the Spectra Android
11
+ * Agent, keyed by the exact `name` `spectra doctor` reports so the VS Code Preflight panel can
12
+ * attach a per-row update badge. `latest` stays `null` when it cannot be determined (offline,
13
+ * private registry unavailable) — the row still exposes its reinstall action.
14
+ */
15
+ export declare function checkDriverUpdates(): Promise<DependencyUpdateStatus[]>;
@@ -0,0 +1,107 @@
1
+ import { execFileSync } from 'child_process';
2
+ import { runSystemChecks } from '../commands/doctor.js';
3
+ const CHROME_FOR_TESTING_API = 'https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions.json';
4
+ const BUN_RELEASES_API = 'https://api.github.com/repos/oven-sh/bun/releases/latest';
5
+ const PLATFORM_TOOLS_XML = 'https://dl.google.com/android/repository/repository2-3.xml';
6
+ function normalizeVersion(raw) {
7
+ if (!raw)
8
+ return null;
9
+ const match = raw.match(/(\d+\.\d+\.\d+(?:[-.][0-9A-Za-z.-]+)?)/);
10
+ return match ? match[1] : null;
11
+ }
12
+ async function fetchJson(url) {
13
+ try {
14
+ const res = await fetch(url, {
15
+ signal: AbortSignal.timeout(8000),
16
+ headers: { 'User-Agent': 'testspectra-cli' },
17
+ });
18
+ if (!res.ok)
19
+ return null;
20
+ return await res.json();
21
+ }
22
+ catch {
23
+ return null;
24
+ }
25
+ }
26
+ async function latestChromeVersion() {
27
+ const json = await fetchJson(CHROME_FOR_TESTING_API);
28
+ return json?.channels?.Stable?.version ?? null;
29
+ }
30
+ async function latestBunVersion() {
31
+ const json = await fetchJson(BUN_RELEASES_API);
32
+ const tag = json?.tag_name;
33
+ return typeof tag === 'string' ? tag.replace(/^bun-v/, '') : null;
34
+ }
35
+ async function latestAdbVersion() {
36
+ try {
37
+ const res = await fetch(PLATFORM_TOOLS_XML, {
38
+ signal: AbortSignal.timeout(8000),
39
+ headers: { 'User-Agent': 'testspectra-cli' },
40
+ });
41
+ if (!res.ok)
42
+ return null;
43
+ const xml = await res.text();
44
+ const block = xml.match(/<remotePackage[^>]*path="platform-tools"[\s\S]*?<\/remotePackage>/);
45
+ if (!block)
46
+ return null;
47
+ const rev = block[0].match(/<revision>[\s\S]*?<major>(\d+)<\/major>[\s\S]*?<minor>(\d+)<\/minor>[\s\S]*?<micro>(\d+)<\/micro>/);
48
+ return rev ? `${rev[1]}.${rev[2]}.${rev[3]}` : null;
49
+ }
50
+ catch {
51
+ return null;
52
+ }
53
+ }
54
+ function latestAndroidAgentVersion() {
55
+ try {
56
+ return (execFileSync('npm', ['view', '@testspectra/android-agent', 'version', '--json'], {
57
+ stdio: ['ignore', 'pipe', 'ignore'],
58
+ timeout: 10000,
59
+ })
60
+ .toString()
61
+ .trim()
62
+ .replace(/"/g, '') || null);
63
+ }
64
+ catch {
65
+ return null;
66
+ }
67
+ }
68
+ /**
69
+ * Best-effort "installed vs latest" report for the managed system drivers and the Spectra Android
70
+ * Agent, keyed by the exact `name` `spectra doctor` reports so the VS Code Preflight panel can
71
+ * attach a per-row update badge. `latest` stays `null` when it cannot be determined (offline,
72
+ * private registry unavailable) — the row still exposes its reinstall action.
73
+ */
74
+ export async function checkDriverUpdates() {
75
+ let installed;
76
+ try {
77
+ const result = await runSystemChecks();
78
+ installed = result.dependencies;
79
+ }
80
+ catch {
81
+ return [];
82
+ }
83
+ const [chrome, bun, adb] = await Promise.all([latestChromeVersion(), latestBunVersion(), latestAdbVersion()]);
84
+ const agent = latestAndroidAgentVersion();
85
+ const candidates = [
86
+ { name: 'Google Chrome', latest: chrome },
87
+ { name: 'Bun', latest: bun },
88
+ { name: 'ADB (Android Debug Bridge)', latest: adb },
89
+ { name: 'Spectra Android Agent', latest: agent },
90
+ ];
91
+ const updates = [];
92
+ for (const { name, latest } of candidates) {
93
+ const dep = installed.find((d) => d.name === name);
94
+ if (!dep?.installed)
95
+ continue;
96
+ const current = normalizeVersion(dep.version);
97
+ updates.push({
98
+ name,
99
+ kind: 'driver',
100
+ identifier: name,
101
+ current,
102
+ latest,
103
+ needsUpdate: !!latest && !!current && current !== latest,
104
+ });
105
+ }
106
+ return updates;
107
+ }