@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
@@ -393,7 +393,7 @@ async function verifyZeroImport() {
393
393
  const featureLibDir = path.join(tmpDir, 'packages', 'features', 'auth');
394
394
  fs.mkdirSync(featureLibDir, { recursive: true });
395
395
  fs.writeFileSync(path.join(featureLibDir, 'project.json'), JSON.stringify({
396
- name: '@tagsamurai/feature-auth',
396
+ name: '@example/feature-auth',
397
397
  projectType: 'library',
398
398
  sourceRoot: 'packages/features/auth',
399
399
  targets: {},
@@ -421,4 +421,18 @@ async function verifyZeroImport() {
421
421
  // Verify packages/features/auth is NOT in scopes
422
422
  expect(scopes.some((s) => s.dir === path.resolve(featureLibDir))).toBe(false);
423
423
  });
424
+ it('generates ambient types for non-JSON fixtures including images and PDF files', () => {
425
+ const fixDir = path.join(tmpDir, 'fixtures');
426
+ fs.mkdirSync(fixDir, { recursive: true });
427
+ fs.writeFileSync(path.join(fixDir, 'sampleImage.png'), 'fake-png-bytes');
428
+ fs.writeFileSync(path.join(fixDir, 'sampleDocument.pdf'), 'fake-pdf-bytes');
429
+ fs.writeFileSync(path.join(fixDir, 'dataset.csv'), 'id,name\n1,Alice');
430
+ fs.writeFileSync(path.join(fixDir, 'users.json'), JSON.stringify({ count: 1 }));
431
+ TypeGenerator.generateAll(tmpDir);
432
+ const fixturesDts = fs.readFileSync(path.join(tmpDir, '.testspectra', 'types', 'fixtures.d.ts'), 'utf-8');
433
+ expect(fixturesDts).toContain('readonly sampleImage: string;');
434
+ expect(fixturesDts).toContain('readonly sampleDocument: string;');
435
+ expect(fixturesDts).toContain('readonly dataset: string;');
436
+ expect(fixturesDts).toContain('readonly users: typeof import(');
437
+ });
424
438
  });
@@ -260,6 +260,7 @@ export class TsConfigGenerator {
260
260
  target: 'ES2022',
261
261
  module: 'NodeNext',
262
262
  moduleResolution: 'NodeNext',
263
+ jsx: 'react-jsx',
263
264
  composite: true,
264
265
  noEmit: true,
265
266
  skipLibCheck: true,
@@ -579,6 +579,8 @@ export class TypeGenerator {
579
579
  scanFixtureDir(path.join(dir, entry.name));
580
580
  }
581
581
  else if (entry.isFile()) {
582
+ if (entry.name.endsWith('.d.ts'))
583
+ continue;
582
584
  const parsed = path.parse(entry.name);
583
585
  const varName = parsed.name.replace(/[^a-zA-Z0-9_$]/g, '_');
584
586
  if (declaredFixtures.has(varName))
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Command } from 'commander';
2
2
  import { addCommand } from './commands/add.js';
3
3
  import { demoCommand } from './commands/demo.js';
4
+ import { DEFAULT_DEMO_PORT } from './utils/demo-server.js';
4
5
  import { devicesCommand } from './commands/devices.js';
5
6
  import { doctorCommand } from './commands/doctor.js';
6
7
  import { initCommand } from './commands/init.js';
@@ -54,8 +55,8 @@ Examples:
54
55
  .action(installCommand);
55
56
  program
56
57
  .command('demo')
57
- .description('Launch the built-in TestSpectra Demo Web App server on localhost:5173')
58
- .option('-p, --port <port>', 'Port to bind server (default: 5173)')
58
+ .description(`Launch the built-in TestSpectra Demo Web App server on localhost:${DEFAULT_DEMO_PORT}`)
59
+ .option('-p, --port <port>', `Port to bind server (default: ${DEFAULT_DEMO_PORT})`)
59
60
  .action(demoCommand);
60
61
  program
61
62
  .command('watch')
@@ -182,17 +183,23 @@ Examples:
182
183
  .option('--headless', 'Run in headless mode')
183
184
  .option('--no-headless', 'Run in headed mode')
184
185
  .option('--headed', 'Run browser in visible GUI mode (alias for --no-headless)')
186
+ .option('--no-dev-server', 'Skip auto-starting local development servers')
187
+ .option('--no-dev-servers', 'Skip auto-starting local development servers (alias)')
188
+ .option('--skip-dev-servers', 'Skip auto-starting local development servers')
185
189
  .option('--step-delay <ms>', 'Delay between consecutive actions and commands in milliseconds (slow motion)')
186
190
  .option('--delay <ms>', 'Alias for --step-delay')
191
+ .option('--video', 'Enable failure video recording (default: true)')
192
+ .option('--no-video', 'Disable video recording completely')
187
193
  .option('--failed [targets]', 'Re-run failed tests from previous run (accepts comma-separated test IDs or spec paths)')
188
194
  .option('-c, --concurrency <number>', 'Maximum parallel worker concurrency (auto-detected if omitted)')
195
+ .option('--debug', 'Run in debug mode (headed browser, single worker, inspector attached for breakpoints)')
196
+ .option('--inspect-port <port>', 'Bun inspector port for debug mode (default: 9229)')
189
197
  .option('-w, --workdir <dir>', 'Custom app data / temp workdir')
190
198
  .option('-o, --output <path>', 'Custom test report JSON output path')
191
199
  .action(runCommand);
192
200
  program
193
201
  .command('test')
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')
202
+ .description('Run React component tests via the native CDP engine')
196
203
  .option('-w, --cwd <dir>', 'Custom workspace directory')
197
204
  .option('-s, --spec <path>', 'Spec file path (or substring filter) to execute')
198
205
  .option('--headless', 'Run in headless mode (default)')
@@ -0,0 +1,290 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
5
+ import { discoverGlobalHooks, discoverSuiteHooks } from '../hook-discovery.js';
6
+ import { LifecycleDispatcher, HookExecutionError } from '../hook-dispatcher.js';
7
+ import { calculateWorkerCount, groupSpecs } from '../parallel-grouping.js';
8
+ describe('Core Lifecycle Execution Engine (docs/v2/cli/lifecycle-execution-engine.md)', () => {
9
+ let tmpDir;
10
+ beforeEach(() => {
11
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'testspectra-lifecycle-test-'));
12
+ });
13
+ afterEach(() => {
14
+ try {
15
+ fs.rmSync(tmpDir, { recursive: true, force: true });
16
+ }
17
+ catch { }
18
+ });
19
+ describe('Hook Discovery', () => {
20
+ it('discovers global-hooks and filters by platform hierarchy', () => {
21
+ const gBefore = path.join(tmpDir, 'global-hooks', 'before');
22
+ const gAfter = path.join(tmpDir, 'global-hooks', 'after');
23
+ fs.mkdirSync(gBefore, { recursive: true });
24
+ fs.mkdirSync(gAfter, { recursive: true });
25
+ fs.writeFileSync(path.join(gBefore, 'web.hook.ts'), 'export default async () => {}');
26
+ fs.writeFileSync(path.join(gBefore, 'mobile.hook.ts'), 'export default async () => {}');
27
+ fs.writeFileSync(path.join(gBefore, 'common.hook.ts'), 'export default async () => {}');
28
+ fs.writeFileSync(path.join(gAfter, 'web.hook.ts'), 'export default async () => {}');
29
+ const webGlobal = discoverGlobalHooks(tmpDir, 'web');
30
+ expect(webGlobal.before.some((h) => h.endsWith('web.hook.ts'))).toBe(true);
31
+ expect(webGlobal.before.some((h) => h.endsWith('common.hook.ts'))).toBe(true);
32
+ expect(webGlobal.before.some((h) => h.endsWith('mobile.hook.ts'))).toBe(false);
33
+ const mobileGlobal = discoverGlobalHooks(tmpDir, 'mobile');
34
+ expect(mobileGlobal.before.some((h) => h.endsWith('mobile.hook.ts'))).toBe(true);
35
+ expect(mobileGlobal.before.some((h) => h.endsWith('common.hook.ts'))).toBe(true);
36
+ expect(mobileGlobal.before.some((h) => h.endsWith('web.hook.ts'))).toBe(false);
37
+ });
38
+ it('discovers suite hooks (before, beforeEach, afterEach, after)', () => {
39
+ const suiteDir = path.join(tmpDir, 'specs', 'Auth');
40
+ for (const hookType of ['before', 'beforeEach', 'afterEach', 'after']) {
41
+ const hDir = path.join(suiteDir, 'hooks', hookType);
42
+ fs.mkdirSync(hDir, { recursive: true });
43
+ fs.writeFileSync(path.join(hDir, 'web.hook.ts'), 'export default async () => {}');
44
+ }
45
+ const suiteHooks = discoverSuiteHooks(suiteDir, 'web');
46
+ expect(suiteHooks.before.length).toBe(1);
47
+ expect(suiteHooks.beforeEach.length).toBe(1);
48
+ expect(suiteHooks.afterEach.length).toBe(1);
49
+ expect(suiteHooks.after.length).toBe(1);
50
+ });
51
+ });
52
+ describe('Lifecycle Dispatcher & Execution Flow', () => {
53
+ it('single run executes suite hooks without triggering global hooks', async () => {
54
+ const executionOrder = [];
55
+ const globalBeforeFn = vi.fn(async () => {
56
+ executionOrder.push('globalBefore');
57
+ });
58
+ const globalAfterFn = vi.fn(async () => {
59
+ executionOrder.push('globalAfter');
60
+ });
61
+ const suiteHooks = {
62
+ before: [
63
+ async () => {
64
+ executionOrder.push('suiteBefore');
65
+ },
66
+ ],
67
+ beforeEach: [
68
+ async () => {
69
+ executionOrder.push('beforeEach');
70
+ },
71
+ ],
72
+ afterEach: [
73
+ async () => {
74
+ executionOrder.push('afterEach');
75
+ },
76
+ ],
77
+ after: [
78
+ async () => {
79
+ executionOrder.push('suiteAfter');
80
+ },
81
+ ],
82
+ };
83
+ const dispatcher = new LifecycleDispatcher('single', {
84
+ before: [globalBeforeFn],
85
+ after: [globalAfterFn],
86
+ });
87
+ // Single run bypasses global before & global after
88
+ await dispatcher.runGlobalBefore();
89
+ expect(globalBeforeFn).not.toHaveBeenCalled();
90
+ const result = await dispatcher.executeSuite('AuthSuite', suiteHooks, [
91
+ {
92
+ id: 'TC-01',
93
+ title: 'Valid Login',
94
+ fn: async () => {
95
+ executionOrder.push('testBody');
96
+ },
97
+ },
98
+ ]);
99
+ await dispatcher.runGlobalAfter();
100
+ expect(globalAfterFn).not.toHaveBeenCalled();
101
+ expect(result.status).toBe('PASSED');
102
+ expect(executionOrder).toEqual(['suiteBefore', 'beforeEach', 'testBody', 'afterEach', 'suiteAfter']);
103
+ });
104
+ it('global batch run executes global-hooks/before once at start and global-hooks/after once at finish', async () => {
105
+ const executionOrder = [];
106
+ const globalBeforeFn = vi.fn(async () => {
107
+ executionOrder.push('globalBefore');
108
+ });
109
+ const globalAfterFn = vi.fn(async () => {
110
+ executionOrder.push('globalAfter');
111
+ });
112
+ const dispatcher = new LifecycleDispatcher('global', {
113
+ before: [globalBeforeFn],
114
+ after: [globalAfterFn],
115
+ });
116
+ await dispatcher.runGlobalBefore();
117
+ expect(globalBeforeFn).toHaveBeenCalledTimes(1);
118
+ const suiteHooks = {
119
+ before: [
120
+ async () => {
121
+ executionOrder.push('suiteBefore');
122
+ },
123
+ ],
124
+ beforeEach: [
125
+ async () => {
126
+ executionOrder.push('beforeEach');
127
+ },
128
+ ],
129
+ afterEach: [
130
+ async () => {
131
+ executionOrder.push('afterEach');
132
+ },
133
+ ],
134
+ after: [
135
+ async () => {
136
+ executionOrder.push('suiteAfter');
137
+ },
138
+ ],
139
+ };
140
+ const result = await dispatcher.executeSuite('AuthSuite', suiteHooks, [
141
+ {
142
+ id: 'TC-01',
143
+ title: 'Test 1',
144
+ fn: async () => {
145
+ executionOrder.push('tc1');
146
+ },
147
+ },
148
+ {
149
+ id: 'TC-02',
150
+ title: 'Test 2',
151
+ fn: async () => {
152
+ executionOrder.push('tc2');
153
+ },
154
+ },
155
+ ]);
156
+ await dispatcher.runGlobalAfter();
157
+ expect(globalAfterFn).toHaveBeenCalledTimes(1);
158
+ expect(result.status).toBe('PASSED');
159
+ expect(executionOrder).toEqual([
160
+ 'globalBefore',
161
+ 'suiteBefore',
162
+ 'beforeEach',
163
+ 'tc1',
164
+ 'afterEach',
165
+ 'beforeEach',
166
+ 'tc2',
167
+ 'afterEach',
168
+ 'suiteAfter',
169
+ 'globalAfter',
170
+ ]);
171
+ });
172
+ it('when beforeEach fails, test body is skipped, afterEach is guaranteed to execute, and test is marked as ERROR', async () => {
173
+ const testBodyFn = vi.fn();
174
+ let afterEachRan = false;
175
+ const suiteHooks = {
176
+ before: [],
177
+ beforeEach: [
178
+ async () => {
179
+ throw new Error('Database connection failed in beforeEach');
180
+ },
181
+ ],
182
+ afterEach: [
183
+ async () => {
184
+ afterEachRan = true;
185
+ },
186
+ ],
187
+ after: [],
188
+ };
189
+ const dispatcher = new LifecycleDispatcher('single');
190
+ const result = await dispatcher.executeSuite('FailingBeforeEachSuite', suiteHooks, [
191
+ {
192
+ id: 'TC-FAIL-01',
193
+ title: 'Should be skipped',
194
+ fn: testBodyFn,
195
+ },
196
+ ]);
197
+ expect(testBodyFn).not.toHaveBeenCalled();
198
+ expect(afterEachRan).toBe(true);
199
+ expect(result.results[0].status).toBe('ERROR');
200
+ expect(result.results[0].error?.message).toContain('Database connection failed in beforeEach');
201
+ expect(result.status).toBe('ERROR');
202
+ });
203
+ it('when afterEach fails, test transitions to ERROR status', async () => {
204
+ const suiteHooks = {
205
+ before: [],
206
+ beforeEach: [],
207
+ afterEach: [
208
+ async () => {
209
+ throw new Error('Teardown resource leak in afterEach');
210
+ },
211
+ ],
212
+ after: [],
213
+ };
214
+ const dispatcher = new LifecycleDispatcher('single');
215
+ const result = await dispatcher.executeSuite('TeardownFailSuite', suiteHooks, [
216
+ {
217
+ id: 'TC-01',
218
+ title: 'Passing Body but failing afterEach',
219
+ fn: async () => {
220
+ // Passing body
221
+ },
222
+ },
223
+ ]);
224
+ expect(result.results[0].status).toBe('ERROR');
225
+ expect(result.results[0].error?.message).toContain('Teardown resource leak in afterEach');
226
+ expect(result.status).toBe('ERROR');
227
+ });
228
+ it('when global-hooks/before fails, throws HookExecutionError immediately', async () => {
229
+ const dispatcher = new LifecycleDispatcher('global', {
230
+ before: [
231
+ async () => {
232
+ throw new Error('Global health check failed');
233
+ },
234
+ ],
235
+ after: [],
236
+ });
237
+ await expect(dispatcher.runGlobalBefore()).rejects.toThrow(HookExecutionError);
238
+ await expect(dispatcher.runGlobalBefore()).rejects.toThrow('Global health check failed');
239
+ });
240
+ });
241
+ describe('Parallel Spec Grouping & Dynamic Worker Sizing', () => {
242
+ it('prioritizes explicit user concurrency flag over auto-calculation', () => {
243
+ const targets = Array.from({ length: 10 }, () => 'spec.ts');
244
+ expect(calculateWorkerCount(targets, { userConcurrency: 4 })).toBe(4);
245
+ // Clamps to item count if items < userConcurrency
246
+ expect(calculateWorkerCount(['only-one.ts'], { userConcurrency: 4 })).toBe(1);
247
+ });
248
+ it('enforces low-CPU safety ratio based on container/host CPU quota', () => {
249
+ const targets = Array.from({ length: 10 }, () => 'spec.ts');
250
+ const highRam = 16 * 1024 * 1024 * 1024; // 16 GB
251
+ // CPU <= 2 (e.g. 2-vCPU standard CI runner): clamped to 1 worker
252
+ expect(calculateWorkerCount(targets, { cpuQuotaOverride: 1, memoryBytesOverride: highRam })).toBe(1);
253
+ expect(calculateWorkerCount(targets, { cpuQuotaOverride: 2, memoryBytesOverride: highRam })).toBe(1);
254
+ // CPU 3 - 4: allocates 2 workers
255
+ expect(calculateWorkerCount(targets, { cpuQuotaOverride: 3, memoryBytesOverride: highRam })).toBe(2);
256
+ expect(calculateWorkerCount(targets, { cpuQuotaOverride: 4, memoryBytesOverride: highRam })).toBe(2);
257
+ // CPU > 4: allocates (cpu - 2) capped at maxWorkersCap (default 6)
258
+ expect(calculateWorkerCount(targets, { cpuQuotaOverride: 6, memoryBytesOverride: highRam })).toBe(4);
259
+ expect(calculateWorkerCount(targets, { cpuQuotaOverride: 8, memoryBytesOverride: highRam })).toBe(6);
260
+ expect(calculateWorkerCount(targets, { cpuQuotaOverride: 64, memoryBytesOverride: highRam })).toBe(6);
261
+ });
262
+ it('enforces memory-aware guard to prevent OOM kills during browser automation', () => {
263
+ const targets = Array.from({ length: 10 }, () => 'spec.ts');
264
+ const highCpu = 16;
265
+ // 500 MB RAM budget -> allows at most 1 worker (~600MB budget per browser)
266
+ expect(calculateWorkerCount(targets, { cpuQuotaOverride: highCpu, memoryBytesOverride: 500 * 1024 * 1024 })).toBe(1);
267
+ // 1.5 GB RAM budget -> allows 2 workers
268
+ expect(calculateWorkerCount(targets, { cpuQuotaOverride: highCpu, memoryBytesOverride: 1500 * 1024 * 1024 })).toBe(2);
269
+ // 2.5 GB RAM budget -> allows 4 workers
270
+ expect(calculateWorkerCount(targets, { cpuQuotaOverride: highCpu, memoryBytesOverride: 2500 * 1024 * 1024 })).toBe(4);
271
+ });
272
+ it('groups specs by suite directory in nested array format when mode is "suite"', () => {
273
+ const specs = [
274
+ 'specs/Auth/TC-001/web.test.ts',
275
+ 'specs/Auth/TC-002/web.test.ts',
276
+ 'specs/Cart/TC-010/web.test.ts',
277
+ 'specs/Cart/TC-011/web.test.ts',
278
+ ];
279
+ const grouped = groupSpecs(specs, 'suite');
280
+ expect(grouped.length).toBe(2);
281
+ expect(grouped[0]).toEqual(['specs/Auth/TC-001/web.test.ts', 'specs/Auth/TC-002/web.test.ts']);
282
+ expect(grouped[1]).toEqual(['specs/Cart/TC-010/web.test.ts', 'specs/Cart/TC-011/web.test.ts']);
283
+ });
284
+ it('flattens specs when mode is "testcase"', () => {
285
+ const specs = ['specs/Auth/TC-001/web.test.ts', 'specs/Auth/TC-002/web.test.ts', 'specs/Cart/TC-010/web.test.ts'];
286
+ const flat = groupSpecs(specs, 'testcase');
287
+ expect(flat).toEqual(specs);
288
+ });
289
+ });
290
+ });
@@ -0,0 +1,24 @@
1
+ import { PlatformTarget } from '../generator/type-generator.js';
2
+ export type HookEntry = string | (() => Promise<void> | void);
3
+ export interface DiscoveredSuiteHooks {
4
+ before: string[];
5
+ beforeEach: string[];
6
+ afterEach: string[];
7
+ after: string[];
8
+ }
9
+ export interface DiscoveredGlobalHooks {
10
+ before: string[];
11
+ after: string[];
12
+ }
13
+ export interface SuiteHooks {
14
+ before: HookEntry[];
15
+ beforeEach: HookEntry[];
16
+ afterEach: HookEntry[];
17
+ after: HookEntry[];
18
+ }
19
+ export interface GlobalHooks {
20
+ before: HookEntry[];
21
+ after: HookEntry[];
22
+ }
23
+ export declare function discoverGlobalHooks(cwd: string, platform?: PlatformTarget): DiscoveredGlobalHooks;
24
+ export declare function discoverSuiteHooks(suiteDir: string, platform?: PlatformTarget): DiscoveredSuiteHooks;
@@ -0,0 +1,60 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { PLATFORM_HIERARCHY } from '../generator/type-generator.js';
4
+ function resolveHookFilesForType(dir, platform = 'web') {
5
+ if (!fs.existsSync(dir))
6
+ return [];
7
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
8
+ const hierarchy = PLATFORM_HIERARCHY[platform] || ['common'];
9
+ const matched = [];
10
+ // Group files by base name if applicable or collect directly
11
+ for (const entry of entries) {
12
+ if (!entry.isFile() || !entry.name.endsWith('.hook.ts'))
13
+ continue;
14
+ const parts = entry.name.split('.');
15
+ // Formats:
16
+ // 1. web.hook.ts -> parts = ['web', 'hook', 'ts']
17
+ // 2. auth.web.hook.ts -> parts = ['auth', 'web', 'hook', 'ts']
18
+ // 3. auth.hook.ts -> parts = ['auth', 'hook', 'ts']
19
+ let filePlatform = 'common';
20
+ if (parts.length === 3) {
21
+ filePlatform = parts[0];
22
+ }
23
+ else if (parts.length >= 4) {
24
+ filePlatform = parts[parts.length - 3];
25
+ }
26
+ if (hierarchy.includes(filePlatform) || filePlatform === 'common') {
27
+ matched.push(path.join(dir, entry.name));
28
+ }
29
+ }
30
+ // Sort by hierarchy priority
31
+ matched.sort((a, b) => {
32
+ const getPriority = (filePath) => {
33
+ const base = path.basename(filePath);
34
+ for (let i = 0; i < hierarchy.length; i++) {
35
+ if (base.includes(`.${hierarchy[i]}.`) || base.startsWith(`${hierarchy[i]}.`)) {
36
+ return i;
37
+ }
38
+ }
39
+ return hierarchy.length;
40
+ };
41
+ return getPriority(a) - getPriority(b);
42
+ });
43
+ return matched;
44
+ }
45
+ export function discoverGlobalHooks(cwd, platform = 'web') {
46
+ const globalHooksDir = path.join(cwd, 'global-hooks');
47
+ return {
48
+ before: resolveHookFilesForType(path.join(globalHooksDir, 'before'), platform),
49
+ after: resolveHookFilesForType(path.join(globalHooksDir, 'after'), platform),
50
+ };
51
+ }
52
+ export function discoverSuiteHooks(suiteDir, platform = 'web') {
53
+ const hooksDir = path.join(suiteDir, 'hooks');
54
+ return {
55
+ before: resolveHookFilesForType(path.join(hooksDir, 'before'), platform),
56
+ beforeEach: resolveHookFilesForType(path.join(hooksDir, 'beforeEach'), platform),
57
+ afterEach: resolveHookFilesForType(path.join(hooksDir, 'afterEach'), platform),
58
+ after: resolveHookFilesForType(path.join(hooksDir, 'after'), platform),
59
+ };
60
+ }
@@ -0,0 +1,34 @@
1
+ import { SuiteHooks, GlobalHooks } from './hook-discovery.js';
2
+ export type TestCaseStatus = 'PASSED' | 'FAILED' | 'SKIPPED' | 'ERROR';
3
+ export interface TestCaseResult {
4
+ id: string;
5
+ title: string;
6
+ status: TestCaseStatus;
7
+ error?: Error;
8
+ durationMs: number;
9
+ }
10
+ export interface SuiteExecutionResult {
11
+ suiteName: string;
12
+ results: TestCaseResult[];
13
+ status: 'PASSED' | 'FAILED' | 'ERROR';
14
+ error?: Error;
15
+ }
16
+ export type HookFn = () => Promise<void> | void;
17
+ export declare class HookExecutionError extends Error {
18
+ phase: 'before' | 'beforeEach' | 'afterEach' | 'after' | 'globalBefore' | 'globalAfter';
19
+ originalError: any;
20
+ constructor(phase: 'before' | 'beforeEach' | 'afterEach' | 'after' | 'globalBefore' | 'globalAfter', originalError: any);
21
+ }
22
+ export declare function runHook(hookPathOrFn: string | HookFn, timeoutMs?: number): Promise<void>;
23
+ export declare class LifecycleDispatcher {
24
+ mode: 'single' | 'global';
25
+ globalHooks: GlobalHooks;
26
+ constructor(mode?: 'single' | 'global', globalHooks?: GlobalHooks);
27
+ runGlobalBefore(): Promise<void>;
28
+ runGlobalAfter(): Promise<void>;
29
+ executeSuite(suiteName: string, suiteHooks: SuiteHooks, testCases: Array<{
30
+ id: string;
31
+ title: string;
32
+ fn: () => Promise<void>;
33
+ }>): Promise<SuiteExecutionResult>;
34
+ }