@testspectra/cli 1.1.11-rc.5 → 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 (89) 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/demo.d.ts +1 -0
  7. package/dist/commands/demo.js +26 -4
  8. package/dist/commands/doctor.js +23 -0
  9. package/dist/commands/run.d.ts +3 -0
  10. package/dist/commands/run.js +41 -4
  11. package/dist/commands/test.d.ts +9 -9
  12. package/dist/commands/test.js +40 -75
  13. package/dist/commands/update.d.ts +3 -0
  14. package/dist/commands/update.js +109 -0
  15. package/dist/config/loader.js +31 -4
  16. package/dist/config/schema.d.ts +58 -0
  17. package/dist/config/schema.js +4 -1
  18. package/dist/generator/__tests__/tsconfig-isolation.test.js +21 -0
  19. package/dist/generator/__tests__/type-generator.test.js +14 -0
  20. package/dist/generator/tsconfig-generator.js +1 -0
  21. package/dist/generator/type-generator.js +2 -0
  22. package/dist/index.js +8 -4
  23. package/dist/lifecycle/__tests__/lifecycle-engine.test.js +290 -0
  24. package/dist/lifecycle/hook-discovery.d.ts +24 -0
  25. package/dist/lifecycle/hook-discovery.js +60 -0
  26. package/dist/lifecycle/hook-dispatcher.d.ts +34 -0
  27. package/dist/lifecycle/hook-dispatcher.js +190 -0
  28. package/dist/lifecycle/index.d.ts +3 -0
  29. package/dist/lifecycle/index.js +3 -0
  30. package/dist/lifecycle/parallel-grouping.d.ts +27 -0
  31. package/dist/lifecycle/parallel-grouping.js +151 -0
  32. package/dist/reporter/semantic-formatter.js +2 -0
  33. package/dist/reporter/types.d.ts +1 -1
  34. package/dist/runner/bridge.d.ts +2 -0
  35. package/dist/runner/bridge.js +4 -1
  36. package/dist/runner/reporter.d.ts +5 -43
  37. package/dist/runner/reporter.js +46 -179
  38. package/dist/utils/__tests__/demo-state.test.d.ts +1 -0
  39. package/dist/utils/__tests__/demo-state.test.js +96 -0
  40. package/dist/utils/api-server.d.ts +1 -1
  41. package/dist/utils/api-server.js +7 -3
  42. package/dist/utils/demo-server.d.ts +2 -0
  43. package/dist/utils/demo-server.js +18 -5
  44. package/dist/utils/demo-state.d.ts +40 -0
  45. package/dist/utils/demo-state.js +157 -0
  46. package/package.json +9 -8
  47. package/templates/default/fixtures/sampleDocument.pdf +14 -0
  48. package/templates/default/fixtures/sampleImage.png +0 -0
  49. package/templates/default/package.json +2 -2
  50. package/templates/default/page-objects/MatchersPage/common.ts +41 -0
  51. package/templates/default/page-objects/MatchersPage/mobile.ts +8 -3
  52. package/templates/default/page-objects/PlaygroundPage/mobile.ts +6 -0
  53. package/templates/default/page-objects/PlaygroundPage/web.ts +2 -0
  54. package/templates/default/specs/Authentication/TC-0021-seed-authenticated-session/mobile.test.ts +3 -0
  55. package/templates/default/specs/BrowserContext/TC-0007-title-and-navigation/mobile.test.ts +3 -0
  56. package/templates/default/specs/BrowserContext/TC-0007-title-and-navigation/web.test.ts +1 -1
  57. package/templates/default/specs/Playground/TC-0003-file-upload/web.test.ts +11 -0
  58. package/templates/default/spectra.config.ts +5 -1
  59. package/templates/nx/.nx/workspace-data/70094CFD-E89B-57A1-9619-2FC5F4963C54.db +0 -0
  60. package/templates/nx/.nx/workspace-data/d/daemon.log +7063 -1012
  61. package/templates/nx/.nx/workspace-data/file-map.json +181 -169
  62. package/templates/nx/.nx/workspace-data/lockfile.hash +1 -1
  63. package/templates/nx/.nx/workspace-data/nx_files.nxt +0 -0
  64. package/templates/nx/.nx/workspace-data/parsed-lock-file.json +81 -130
  65. package/templates/nx/.nx/workspace-data/project-graph.json +83 -138
  66. package/templates/nx/package.json +2 -2
  67. package/templates/nx/packages/matchers/e2e/specs/BrowserContext/TC-0007-title-and-navigation/web.test.ts +1 -1
  68. package/templates/nx/packages/playground/e2e/specs/Playground/TC-0003-file-upload/web.test.ts +11 -0
  69. package/templates/nx/shared/testing/fixtures/sampleDocument.pdf +14 -0
  70. package/templates/nx/shared/testing/fixtures/sampleImage.png +0 -0
  71. package/templates/nx/shared/testing/page-objects/MatchersPage/mobile.ts +68 -26
  72. package/templates/nx/shared/testing/page-objects/PlaygroundPage/mobile.ts +39 -17
  73. package/templates/nx/shared/testing/page-objects/PlaygroundPage/web.ts +2 -0
  74. package/templates/nx/spectra.config.ts +3 -3
  75. package/templates/react-component/fixtures/sampleDocument.pdf +14 -0
  76. package/templates/react-component/fixtures/sampleImage.png +0 -0
  77. package/templates/react-component/package.json +3 -3
  78. package/templates/react-component/page-objects/FileUploadComponent/web.ts +15 -0
  79. package/templates/react-component/specs/FileUploadComponent/TC-0001-upload-fixtures/web.test.tsx +18 -0
  80. package/templates/react-component/specs/UserListComponent/TC-0001-intercepted-user-list/spec.md +20 -0
  81. package/templates/react-component/specs/UserListComponent/TC-0001-intercepted-user-list/web.test.tsx +15 -0
  82. package/templates/react-component/specs/UserListComponent/suite.md +12 -0
  83. package/templates/react-component/src/components/FileUpload/FileUpload.tsx +44 -0
  84. package/templates/react-component/src/components/UserList/UserList.tsx +48 -0
  85. package/bin/.testspectra-runner.hash +0 -1
  86. package/demo-app/assets/index-BhlW-eXG.css +0 -1
  87. package/demo-app/assets/index-DOtRypCa.js +0 -154
  88. package/dist/commands/__tests__/doctor-scope.test.js +0 -24
  89. /package/dist/{commands/__tests__/doctor-scope.test.d.ts → lifecycle/__tests__/lifecycle-engine.test.d.ts} +0 -0
@@ -0,0 +1,151 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ /**
5
+ * Detects actual CPU quota available to the process, accounting for
6
+ * Docker and Kubernetes cgroup v1 and cgroup v2 container limits.
7
+ */
8
+ export function detectContainerCpuQuota() {
9
+ try {
10
+ // 1. Check cgroup v2 (Unified Hierarchy): /sys/fs/cgroup/cpu.max
11
+ // Format: "<quota> <period>" e.g. "200000 100000" or "max 100000"
12
+ if (fs.existsSync('/sys/fs/cgroup/cpu.max')) {
13
+ const content = fs.readFileSync('/sys/fs/cgroup/cpu.max', 'utf-8').trim();
14
+ const [quotaStr, periodStr] = content.split(/\s+/);
15
+ if (quotaStr && quotaStr !== 'max' && periodStr) {
16
+ const quota = parseFloat(quotaStr);
17
+ const period = parseFloat(periodStr);
18
+ if (quota > 0 && period > 0) {
19
+ return Math.max(0.5, quota / period);
20
+ }
21
+ }
22
+ }
23
+ // 2. Check cgroup v1: /sys/fs/cgroup/cpu/cpu.cfs_quota_us and cpu.cfs_period_us
24
+ if (fs.existsSync('/sys/fs/cgroup/cpu/cpu.cfs_quota_us') && fs.existsSync('/sys/fs/cgroup/cpu/cpu.cfs_period_us')) {
25
+ const quota = parseFloat(fs.readFileSync('/sys/fs/cgroup/cpu/cpu.cfs_quota_us', 'utf-8').trim());
26
+ const period = parseFloat(fs.readFileSync('/sys/fs/cgroup/cpu/cpu.cfs_period_us', 'utf-8').trim());
27
+ if (quota > 0 && period > 0) {
28
+ return Math.max(0.5, quota / period);
29
+ }
30
+ }
31
+ }
32
+ catch { }
33
+ // 3. Fallback to host CPU count
34
+ if (typeof os.availableParallelism === 'function') {
35
+ try {
36
+ return os.availableParallelism();
37
+ }
38
+ catch { }
39
+ }
40
+ return os.cpus()?.length || 2;
41
+ }
42
+ /**
43
+ * Detects available memory in bytes, respecting container cgroup memory limits
44
+ * to prevent Linux OOM (Out of Memory) kills.
45
+ */
46
+ export function detectAvailableMemoryBytes() {
47
+ try {
48
+ // 1. Check cgroup v2: /sys/fs/cgroup/memory.max and /sys/fs/cgroup/memory.current
49
+ if (fs.existsSync('/sys/fs/cgroup/memory.max')) {
50
+ const maxStr = fs.readFileSync('/sys/fs/cgroup/memory.max', 'utf-8').trim();
51
+ if (maxStr !== 'max') {
52
+ const limit = parseFloat(maxStr);
53
+ let usage = 0;
54
+ if (fs.existsSync('/sys/fs/cgroup/memory.current')) {
55
+ usage = parseFloat(fs.readFileSync('/sys/fs/cgroup/memory.current', 'utf-8').trim()) || 0;
56
+ }
57
+ if (limit > 0) {
58
+ return Math.max(0, limit - usage);
59
+ }
60
+ }
61
+ }
62
+ // 2. Check cgroup v1: /sys/fs/cgroup/memory/memory.limit_in_bytes
63
+ if (fs.existsSync('/sys/fs/cgroup/memory/memory.limit_in_bytes')) {
64
+ const limit = parseFloat(fs.readFileSync('/sys/fs/cgroup/memory/memory.limit_in_bytes', 'utf-8').trim());
65
+ // cgroup v1 sets a huge number (e.g. 9223372036854771712) when no limit is enforced
66
+ if (limit > 0 && limit < 1e15) {
67
+ let usage = 0;
68
+ if (fs.existsSync('/sys/fs/cgroup/memory/memory.usage_in_bytes')) {
69
+ usage = parseFloat(fs.readFileSync('/sys/fs/cgroup/memory/memory.usage_in_bytes', 'utf-8').trim()) || 0;
70
+ }
71
+ return Math.max(0, limit - usage);
72
+ }
73
+ }
74
+ }
75
+ catch { }
76
+ // 3. Fallback to host available memory (estimating reclaimable cache/buffer on modern OS)
77
+ try {
78
+ const free = os.freemem();
79
+ const total = os.totalmem();
80
+ return Math.max(free, total * 0.4);
81
+ }
82
+ catch {
83
+ return 1024 * 1024 * 1024; // 1 GB fallback
84
+ }
85
+ }
86
+ /**
87
+ * Dynamically computes optimal worker concurrency respecting:
88
+ * 1. Explicit user concurrency flag (--concurrency <N> or config)
89
+ * 2. Real container cgroups CPU quota & host CPU count
90
+ * 3. Low-CPU safety ratio (clamping <=2 CPUs to 1 worker, 3-4 CPUs to 2 workers)
91
+ * 4. Memory-aware guard (~600MB RAM budget per Chromium instance to prevent OOM kills)
92
+ * 5. Target items count & maximum worker cap
93
+ */
94
+ export function calculateWorkerCount(targetItems, options) {
95
+ const opts = typeof options === 'number' ? { maxWorkersCap: options } : options || {};
96
+ const itemCount = targetItems.length;
97
+ if (itemCount === 0)
98
+ return 1;
99
+ // 1. Explicit User Concurrency has absolute priority
100
+ if (opts.userConcurrency && opts.userConcurrency > 0) {
101
+ return Math.min(opts.userConcurrency, itemCount);
102
+ }
103
+ // 2. Determine CPU Quota (cgroups aware)
104
+ const cpuCount = opts.cpuQuotaOverride !== undefined ? opts.cpuQuotaOverride : detectContainerCpuQuota();
105
+ // 3. Low-CPU Safety Ratio:
106
+ // - CPU <= 2: Max 1 worker (prevents severe thrashing on 2-vCPU CI runners)
107
+ // - CPU 3-4: Max 2 workers
108
+ // - CPU > 4: cpuCount - 2 (leaves 2 cores for OS/IDE/DevTools)
109
+ let cpuBasedWorkers = 1;
110
+ if (cpuCount <= 2) {
111
+ cpuBasedWorkers = 1;
112
+ }
113
+ else if (cpuCount <= 4) {
114
+ cpuBasedWorkers = 2;
115
+ }
116
+ else {
117
+ cpuBasedWorkers = Math.max(1, Math.floor(cpuCount - 2));
118
+ }
119
+ // 4. Memory-Aware Guard (Anti-OOM):
120
+ // Allocate ~600MB per Chromium browser instance
121
+ const MEMORY_PER_WORKER_BYTES = 600 * 1024 * 1024;
122
+ const availableMemBytes = opts.memoryBytesOverride !== undefined ? opts.memoryBytesOverride : detectAvailableMemoryBytes();
123
+ const memBasedWorkers = Math.max(1, Math.floor(availableMemBytes / MEMORY_PER_WORKER_BYTES));
124
+ // 5. Apply Maximum Cap and Item Count Clamping
125
+ const maxCap = opts.maxWorkersCap || 6;
126
+ return Math.max(1, Math.min(cpuBasedWorkers, memBasedWorkers, maxCap, itemCount));
127
+ }
128
+ export function groupSpecs(specPaths, mode = 'suite') {
129
+ if (mode === 'testcase') {
130
+ return [...specPaths];
131
+ }
132
+ // Group by suite directory
133
+ const suiteGroups = new Map();
134
+ for (const specPath of specPaths) {
135
+ const normalized = specPath.replace(/\\/g, '/');
136
+ const parts = normalized.split('/');
137
+ let suiteKey = 'default';
138
+ const specsIdx = parts.findIndex((p) => p === 'specs' || p === 'suites');
139
+ if (specsIdx !== -1 && specsIdx + 1 < parts.length) {
140
+ suiteKey = parts.slice(0, specsIdx + 2).join('/');
141
+ }
142
+ else {
143
+ suiteKey = path.dirname(normalized);
144
+ }
145
+ if (!suiteGroups.has(suiteKey)) {
146
+ suiteGroups.set(suiteKey, []);
147
+ }
148
+ suiteGroups.get(suiteKey).push(specPath);
149
+ }
150
+ return Array.from(suiteGroups.values());
151
+ }
@@ -97,6 +97,8 @@ export function formatAction(actionKey, target, args = {}) {
97
97
  return `Scroll ${args.direction ?? '{direction}'} by ${args.pixels ?? '{pixels}'}px`;
98
98
  case 'swipe':
99
99
  return `Swipe ${args.direction ?? '{direction}'} by ${args.distance ?? '{distance}'}px`;
100
+ case 'upload':
101
+ return `Upload file "${args.files ?? args.value ?? args.file ?? '{file}'}" to ${el}`;
100
102
  case 'intercept':
101
103
  return `Intercept ${args.method ?? '{method}'} "${args.url ?? '{url}'}" mock rule registered`;
102
104
  case 'clearCookies':
@@ -1,2 +1,2 @@
1
- export type ActionKey = 'click' | 'doubleClick' | 'rightClick' | 'type' | 'clear' | 'select' | 'hover' | 'dragDrop' | 'scrollIntoView' | 'longPress' | 'waitForElement' | 'navigate' | 'back' | 'forward' | 'refresh' | 'setViewport' | 'wait' | 'pressKey' | 'scroll' | 'swipe' | 'intercept' | 'clearCookies' | 'setCookies' | 'clearLocalStorage';
1
+ export type ActionKey = 'click' | 'doubleClick' | 'rightClick' | 'type' | 'clear' | 'select' | 'hover' | 'dragDrop' | 'scrollIntoView' | 'longPress' | 'waitForElement' | 'navigate' | 'back' | 'forward' | 'refresh' | 'setViewport' | 'wait' | 'pressKey' | 'scroll' | 'swipe' | 'upload' | 'intercept' | 'clearCookies' | 'setCookies' | 'clearLocalStorage';
2
2
  export type AssertionKey = 'be.visible' | 'not.be.visible' | 'exist' | 'not.exist' | 'be.clickable' | 'not.be.clickable' | 'be.enabled' | 'be.disabled' | 'be.checked' | 'not.be.checked' | 'be.selected' | 'not.be.selected' | 'be.focused' | 'not.be.focused' | 'have.text' | 'not.have.text' | 'contain.text' | 'not.contain.text' | 'have.value' | 'not.have.value' | 'contain.value' | 'not.contain.value' | 'have.attr' | 'not.have.attr' | 'have.class' | 'not.have.class' | 'have.css' | 'not.have.css' | 'have.length' | 'not.have.length' | 'have.length.greaterThan' | 'have.length.lessThan' | 'be.empty' | 'not.be.empty' | 'shouldHaveUrl' | 'shouldContainUrl' | 'shouldHaveTitle' | 'shouldContainTitle' | 'shouldBeLoaded' | 'shouldBePageLoaded' | 'shouldHaveNoConsoleErrors' | 'have.url' | 'contain.url' | 'have.title' | 'contain.title' | 'be.loaded' | 'be.pageLoaded' | 'have.noConsoleErrors';
@@ -18,6 +18,8 @@ export interface RunOptions {
18
18
  concurrency?: number;
19
19
  failedOnly?: boolean;
20
20
  failedTarget?: string;
21
+ debug?: boolean;
22
+ inspectPort?: number;
21
23
  }
22
24
  export declare class RustCoreBridge {
23
25
  static resolveBinaryPath(): string;
@@ -121,6 +121,8 @@ export class RustCoreBridge {
121
121
  concurrency: options.concurrency,
122
122
  failedOnly: options.failedOnly,
123
123
  failedTarget: options.failedTarget,
124
+ debug: options.debug,
125
+ inspectPort: options.inspectPort,
124
126
  });
125
127
  const home = process.env.HOME || process.env.USERPROFILE || '';
126
128
  const globalDriversDir = path.join(home, '.testspectra', 'drivers');
@@ -162,7 +164,8 @@ export class RustCoreBridge {
162
164
  // a real TTY (CI, or an external tool like the VS Code extension spawning this process
163
165
  // via a pipe) — in that case this raw line is the *only* live signal a wrapping process
164
166
  // can see, so it must still reach stdout verbatim.
165
- if (!reporter.isInteractiveMode()) {
167
+ // Always forward inspector websocket URL so IDE tools can attach directly.
168
+ if (line.startsWith('[TESTSPECTRA_DEBUG_INSPECTOR]') || !reporter.isInteractiveMode()) {
166
169
  console.log(line);
167
170
  }
168
171
  continue;
@@ -1,55 +1,17 @@
1
- export interface TestLog {
2
- timestamp: string;
3
- level: 'INFO' | 'SUCCESS' | 'WARNING' | 'ERROR' | 'DEBUG' | string;
4
- message: string;
5
- }
6
- export interface NetworkResource {
7
- requestId: string;
8
- url: string;
9
- method: string;
10
- status: number;
11
- }
12
- export interface TestRunResult {
13
- status: 'passed' | 'failed' | 'error' | string;
14
- duration: string;
15
- passedCount: number;
16
- failedCount: number;
17
- logs: TestLog[];
18
- networkResources?: NetworkResource[];
19
- }
20
- export interface PendingStep {
21
- text: string;
22
- duration: number;
23
- passed: boolean;
24
- }
25
- export interface PendingTestCase {
26
- title: string;
27
- workerId?: number;
28
- steps: PendingStep[];
29
- currentStep?: string;
30
- errorMsg?: string;
31
- startTime: number;
32
- }
33
- export declare class Reporter {
34
- private logs;
35
- private networkEvents;
36
- private pendingTests;
37
- private workerToTest;
38
- private pendingErrorMsg?;
39
- private passedCount;
40
- private failedCount;
1
+ import { type ActionKey, type AssertionKey, type ExecutionSummary, formatAction, formatAssertion, type NetworkResource, parseAndFormat, type PendingStep, type PendingTestCase, type ReporterOptions, SemanticReporter, stripAnsi, type TestLog, type TestRunResult } from '@testspectra/reporter';
2
+ export { type ActionKey, type AssertionKey, type ExecutionSummary, formatAction, formatAssertion, type NetworkResource, parseAndFormat, type PendingStep, type PendingTestCase, type ReporterOptions, SemanticReporter, stripAnsi, type TestLog, type TestRunResult, };
3
+ export declare class Reporter extends SemanticReporter {
41
4
  private isInteractive;
42
5
  private lastRenderedLines;
43
6
  private spinnerFrames;
44
7
  private spinnerIndex;
45
8
  private spinnerTimer;
46
- constructor();
9
+ constructor(options?: ReporterOptions);
47
10
  /** Whether this reporter is drawing the live-updating interactive terminal UI (real TTY, not CI/VS Code/test). */
48
11
  isInteractiveMode(): boolean;
49
12
  private clearActiveSlots;
50
13
  private renderActiveSlots;
51
- private resolveTestCase;
52
- addLog(log: TestLog): void;
14
+ addLog(input: string | TestLog): void;
53
15
  addNetwork(res: NetworkResource): void;
54
16
  getLogs(): TestLog[];
55
17
  getNetworkEvents(): NetworkResource[];
@@ -1,169 +1,14 @@
1
+ import { formatAction, formatAssertion, parseAndFormat, SemanticReporter, stripAnsi, } from '@testspectra/reporter';
1
2
  import chalk from 'chalk';
2
- import { formatAction, formatAssertion } from '../reporter/semantic-formatter.js';
3
- function stripAnsi(str) {
4
- return str.replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, '');
5
- }
6
- function mapActionArgs(key, arr) {
7
- if (!Array.isArray(arr)) {
8
- return typeof arr === 'object' && arr !== null ? arr : {};
9
- }
10
- switch (key) {
11
- case 'type':
12
- return { text: arr[0] };
13
- case 'select':
14
- case 'selectOption':
15
- return { option: arr[0] };
16
- case 'dragDrop':
17
- return { dest: arr[0] };
18
- case 'longPress':
19
- return { durationMs: arr[0] };
20
- case 'navigate':
21
- return { url: arr[0] };
22
- case 'setCookies':
23
- return { url: arr[0] };
24
- case 'setViewport':
25
- return { width: arr[0], height: arr[1] };
26
- case 'wait':
27
- return { ms: arr[0] };
28
- case 'pressKey':
29
- return { key: arr[0] };
30
- case 'scroll':
31
- return { direction: arr[0], pixels: arr[1] };
32
- case 'swipe':
33
- return { direction: arr[0], distance: arr[1] };
34
- case 'intercept':
35
- return { method: arr[0], url: arr[1] };
36
- default:
37
- return {};
38
- }
39
- }
40
- function mapAssertionArgs(key, arr) {
41
- if (!Array.isArray(arr)) {
42
- return typeof arr === 'object' && arr !== null ? arr : { expected: arr };
43
- }
44
- if (key === 'have.attr' || key === 'not.have.attr') {
45
- return arr.length >= 2 ? { name: arr[0], value: arr[1], expected: arr[0] } : { name: arr[0], expected: arr[0] };
46
- }
47
- if (key === 'have.css' || key === 'not.have.css') {
48
- return { name: arr[0], property: arr[0], value: arr[1], expected: arr[0] };
49
- }
50
- if (arr.length >= 2) {
51
- return { name: arr[0], value: arr[1], expected: arr[0] };
52
- }
53
- return {
54
- expected: arr[0],
55
- name: arr[0],
56
- text: arr[0],
57
- value: arr[0],
58
- title: arr[0],
59
- url: arr[0],
60
- length: arr[0],
61
- min: arr[0],
62
- max: arr[0],
63
- };
64
- }
65
- function parseAndFormat(cmdText) {
66
- try {
67
- const payload = JSON.parse(cmdText);
68
- // Passed through as-is (not pre-flattened to a bare string here) — `formatAction`/
69
- // `formatAssertion` already do the same `.selector`/`.name`/`.target` fallback lookup
70
- // themselves, and only the raw object retains `.parent` for a scoped (`element.get()`/
71
- // `.getAll()`-chained) target, which they need to render the "within" phrasing.
72
- const target = payload.target;
73
- if (payload.type === 'action') {
74
- return formatAction(payload.key, target, mapActionArgs(payload.key, payload.args || []));
75
- }
76
- else if (payload.type === 'assertion' || payload.type === 'browser') {
77
- return formatAssertion(payload.key, target, mapAssertionArgs(payload.key, payload.args || []));
78
- }
79
- // Native Orchestrator payload format ({ test, action, selector, target, ... })
80
- if (payload.action) {
81
- const action = payload.action;
82
- const t = payload.selector || payload.target;
83
- switch (action) {
84
- case 'navigate':
85
- return formatAction('navigate', undefined, {
86
- url: t || payload.url || payload.value || 'http://localhost:5173',
87
- });
88
- case 'click':
89
- return formatAction('click', t);
90
- case 'double_click':
91
- case 'doubleClick':
92
- return formatAction('doubleClick', t);
93
- case 'right_click':
94
- case 'rightClick':
95
- return formatAction('rightClick', t);
96
- case 'type':
97
- case 'type_text':
98
- case 'set_value':
99
- return formatAction('type', t, { text: payload.text || payload.value });
100
- case 'clear':
101
- return formatAction('clear', t);
102
- case 'hover':
103
- return formatAction('hover', t);
104
- case 'scroll_into_view':
105
- case 'scrollIntoView':
106
- return formatAction('scrollIntoView', t);
107
- case 'should_be_visible':
108
- return formatAssertion('be.visible', t);
109
- case 'should_not_be_visible':
110
- return formatAssertion('not.be.visible', t);
111
- case 'should_exist':
112
- return formatAssertion('exist', t);
113
- case 'should_not_exist':
114
- return formatAssertion('not.exist', t);
115
- case 'should_be_enabled':
116
- return formatAssertion('be.enabled', t);
117
- case 'should_be_disabled':
118
- return formatAssertion('be.disabled', t);
119
- case 'should_be_clickable':
120
- return formatAssertion('be.clickable', t);
121
- case 'should_have_text':
122
- return formatAssertion('have.text', t, { expected: payload.expected || payload.text || payload.value });
123
- case 'should_contain_text':
124
- return formatAssertion('contain.text', t, {
125
- expected: payload.expected || payload.text || payload.value,
126
- });
127
- case 'should_have_value':
128
- return formatAssertion('have.value', t, { expected: payload.expected || payload.value });
129
- case 'should_contain_value':
130
- return formatAssertion('contain.value', t, { expected: payload.expected || payload.value });
131
- case 'should_have_length':
132
- return formatAssertion('have.length', t, {
133
- expected: payload.expected || payload.length || payload.value,
134
- });
135
- case 'should_have_title':
136
- return formatAssertion('have.title', undefined, {
137
- expected: payload.expected || payload.value || t || 'TestSpectra',
138
- });
139
- case 'should_be_empty':
140
- return formatAssertion('be.empty', t);
141
- case 'should_not_be_empty':
142
- return formatAssertion('not.be.empty', t);
143
- default:
144
- return `Execute step "${action}" on ${t || 'target'}`;
145
- }
146
- }
147
- return cmdText;
148
- }
149
- catch {
150
- return cmdText;
151
- }
152
- }
153
- export class Reporter {
154
- logs = [];
155
- networkEvents = [];
156
- pendingTests = new Map();
157
- workerToTest = new Map();
158
- pendingErrorMsg;
159
- passedCount = 0;
160
- failedCount = 0;
3
+ export { formatAction, formatAssertion, parseAndFormat, SemanticReporter, stripAnsi, };
4
+ export class Reporter extends SemanticReporter {
161
5
  isInteractive;
162
6
  lastRenderedLines = 0;
163
7
  spinnerFrames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
164
8
  spinnerIndex = 0;
165
9
  spinnerTimer = null;
166
- constructor() {
10
+ constructor(options = {}) {
11
+ super(options);
167
12
  this.isInteractive = Boolean(process.stdout.isTTY && !process.env.CI && process.env.NODE_ENV !== 'test' && !process.env.VITEST);
168
13
  if (this.isInteractive) {
169
14
  this.spinnerTimer = setInterval(() => {
@@ -211,23 +56,16 @@ export class Reporter {
211
56
  }
212
57
  this.lastRenderedLines = linesCount;
213
58
  }
214
- resolveTestCase(testTitle, workerId) {
215
- if (testTitle && this.pendingTests.has(testTitle)) {
216
- return this.pendingTests.get(testTitle);
217
- }
218
- if (workerId !== undefined && this.workerToTest.has(workerId)) {
219
- const title = this.workerToTest.get(workerId);
220
- if (title && this.pendingTests.has(title)) {
221
- return this.pendingTests.get(title);
59
+ addLog(input) {
60
+ const log = typeof input === 'string'
61
+ ? {
62
+ timestamp: new Date().toISOString(),
63
+ level: 'INFO',
64
+ message: input,
222
65
  }
223
- }
224
- if (this.pendingTests.size === 1) {
225
- return this.pendingTests.values().next().value;
226
- }
227
- return Array.from(this.pendingTests.values()).pop();
228
- }
229
- addLog(log) {
66
+ : input;
230
67
  this.logs.push(log);
68
+ this.options.onLog?.(log);
231
69
  const raw = stripAnsi(log.message || '').trim();
232
70
  if (!raw)
233
71
  return;
@@ -284,6 +122,7 @@ export class Reporter {
284
122
  this.workerToTest.set(workerId, title);
285
123
  }
286
124
  this.renderActiveSlots();
125
+ this.options.onTestStart?.(pending);
287
126
  return;
288
127
  }
289
128
  // 2. Step in-progress / start
@@ -303,6 +142,7 @@ export class Reporter {
303
142
  testCase.currentStep = formatted;
304
143
  this.renderActiveSlots();
305
144
  }
145
+ this.options.onStepStart?.(testTitle || testCase?.title || '', formatted, workerId ?? testCase?.workerId);
306
146
  return;
307
147
  }
308
148
  // 3. Step passed -> record step to matching test case
@@ -321,11 +161,13 @@ export class Reporter {
321
161
  }
322
162
  catch { }
323
163
  const testCase = this.resolveTestCase(testTitle, workerId);
164
+ const step = { text: formatted, duration, passed: true };
324
165
  if (testCase) {
325
- testCase.steps.push({ text: formatted, duration, passed: true });
166
+ testCase.steps.push(step);
326
167
  testCase.currentStep = formatted;
327
168
  this.renderActiveSlots();
328
169
  }
170
+ this.options.onStepPass?.(testTitle || testCase?.title || '', step, workerId ?? testCase?.workerId);
329
171
  return;
330
172
  }
331
173
  // 4. Step failed -> record failed step to matching test case
@@ -344,11 +186,28 @@ export class Reporter {
344
186
  }
345
187
  catch { }
346
188
  const testCase = this.resolveTestCase(testTitle, workerId);
189
+ const step = { text: formatted, duration, passed: false };
347
190
  if (testCase) {
348
- testCase.steps.push({ text: formatted, duration, passed: false });
191
+ testCase.steps.push(step);
349
192
  testCase.currentStep = formatted;
350
193
  this.renderActiveSlots();
351
194
  }
195
+ this.options.onStepFail?.(testTitle || testCase?.title || '', step, workerId ?? testCase?.workerId);
196
+ return;
197
+ }
198
+ // 4.5 Video capture notification
199
+ if (raw.startsWith('[TESTSPECTRA_VIDEO]')) {
200
+ const content = raw.replace('[TESTSPECTRA_VIDEO]', '').trim();
201
+ try {
202
+ const payload = JSON.parse(content);
203
+ if (payload.videoPath) {
204
+ const testCase = this.resolveTestCase(payload.id);
205
+ if (testCase) {
206
+ testCase.videoPath = payload.videoPath;
207
+ }
208
+ }
209
+ }
210
+ catch { }
352
211
  return;
353
212
  }
354
213
  // 5. Test error detail
@@ -361,6 +220,7 @@ export class Reporter {
361
220
  else {
362
221
  this.pendingErrorMsg = this.pendingErrorMsg ? `${this.pendingErrorMsg}\n${err}` : err;
363
222
  }
223
+ this.options.onTestError?.(err, latest?.title, latest?.workerId);
364
224
  return;
365
225
  }
366
226
  // 6. Test passed completely -> flush atomic test block to permanent history
@@ -384,6 +244,7 @@ export class Reporter {
384
244
  }
385
245
  console.log('');
386
246
  this.renderActiveSlots();
247
+ this.options.onTestPass?.({ title, duration, steps, workerId });
387
248
  return;
388
249
  }
389
250
  // 7. Test failed completely -> flush atomic failed test block to permanent history
@@ -397,6 +258,7 @@ export class Reporter {
397
258
  const steps = testCase?.steps || [];
398
259
  const errorMsg = testCase?.errorMsg || this.pendingErrorMsg;
399
260
  this.pendingErrorMsg = undefined;
261
+ const videoPath = testCase?.videoPath;
400
262
  const workerId = testCase?.workerId;
401
263
  this.pendingTests.delete(title);
402
264
  if (workerId !== undefined) {
@@ -419,17 +281,21 @@ export class Reporter {
419
281
  console.log(` \x1b[38;2;248;113;113m${errLines[i]}\x1b[0m`);
420
282
  }
421
283
  }
284
+ if (videoPath) {
285
+ console.log(` \x1b[38;2;251;191;36m↳ Video:\x1b[0m \x1b[38;2;251;191;36m${videoPath}\x1b[0m`);
286
+ }
422
287
  console.log('');
423
288
  this.renderActiveSlots();
289
+ this.options.onTestFail?.({ title, duration, steps, errorMsg, workerId });
424
290
  return;
425
291
  }
426
292
  // Generic errors & warnings
427
- if (log.level === 'ERROR' || log.level === 'FAILED') {
293
+ if (log.level === 'ERROR') {
428
294
  this.clearActiveSlots();
429
295
  console.log(` \x1b[38;2;248;113;113m${log.message}\x1b[0m`);
430
296
  this.renderActiveSlots();
431
297
  }
432
- else if (log.level === 'WARN' || log.level === 'WARNING') {
298
+ else if (log.level === 'WARNING') {
433
299
  this.clearActiveSlots();
434
300
  console.log(` \x1b[38;2;251;191;36m${log.message}\x1b[0m`);
435
301
  this.renderActiveSlots();
@@ -437,6 +303,7 @@ export class Reporter {
437
303
  }
438
304
  addNetwork(res) {
439
305
  this.networkEvents.push(res);
306
+ this.options.onNetwork?.(res);
440
307
  }
441
308
  getLogs() {
442
309
  return this.logs;
@@ -0,0 +1 @@
1
+ export {};