@testspectra/cli 1.1.11-rc.5 → 1.1.13

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 (92) 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 +8 -7
  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/70094CFD-E89B-57A1-9619-2FC5F4963C54.db-shm +0 -0
  61. package/templates/nx/.nx/workspace-data/70094CFD-E89B-57A1-9619-2FC5F4963C54.db-wal +0 -0
  62. package/templates/nx/.nx/workspace-data/d/daemon.log +7104 -1012
  63. package/templates/nx/.nx/workspace-data/d/server-process.json +3 -0
  64. package/templates/nx/.nx/workspace-data/file-map.json +116 -104
  65. package/templates/nx/.nx/workspace-data/lockfile.hash +1 -1
  66. package/templates/nx/.nx/workspace-data/nx_files.nxt +0 -0
  67. package/templates/nx/.nx/workspace-data/parsed-lock-file.json +81 -130
  68. package/templates/nx/.nx/workspace-data/project-graph.json +83 -138
  69. package/templates/nx/package.json +2 -2
  70. package/templates/nx/packages/matchers/e2e/specs/BrowserContext/TC-0007-title-and-navigation/web.test.ts +1 -1
  71. package/templates/nx/packages/playground/e2e/specs/Playground/TC-0003-file-upload/web.test.ts +11 -0
  72. package/templates/nx/shared/testing/fixtures/sampleDocument.pdf +14 -0
  73. package/templates/nx/shared/testing/fixtures/sampleImage.png +0 -0
  74. package/templates/nx/shared/testing/page-objects/MatchersPage/mobile.ts +68 -26
  75. package/templates/nx/shared/testing/page-objects/PlaygroundPage/mobile.ts +39 -17
  76. package/templates/nx/shared/testing/page-objects/PlaygroundPage/web.ts +2 -0
  77. package/templates/nx/spectra.config.ts +3 -3
  78. package/templates/react-component/fixtures/sampleDocument.pdf +14 -0
  79. package/templates/react-component/fixtures/sampleImage.png +0 -0
  80. package/templates/react-component/package.json +3 -3
  81. package/templates/react-component/page-objects/FileUploadComponent/web.ts +15 -0
  82. package/templates/react-component/specs/FileUploadComponent/TC-0001-upload-fixtures/web.test.tsx +18 -0
  83. package/templates/react-component/specs/UserListComponent/TC-0001-intercepted-user-list/spec.md +20 -0
  84. package/templates/react-component/specs/UserListComponent/TC-0001-intercepted-user-list/web.test.tsx +15 -0
  85. package/templates/react-component/specs/UserListComponent/suite.md +12 -0
  86. package/templates/react-component/src/components/FileUpload/FileUpload.tsx +44 -0
  87. package/templates/react-component/src/components/UserList/UserList.tsx +48 -0
  88. package/bin/.testspectra-runner.hash +0 -1
  89. package/demo-app/assets/index-BhlW-eXG.css +0 -1
  90. package/demo-app/assets/index-DOtRypCa.js +0 -154
  91. package/dist/commands/__tests__/doctor-scope.test.js +0 -24
  92. /package/dist/{commands/__tests__/doctor-scope.test.d.ts → lifecycle/__tests__/lifecycle-engine.test.d.ts} +0 -0
@@ -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
+ }
@@ -0,0 +1,190 @@
1
+ import { pathToFileURL } from 'url';
2
+ export class HookExecutionError extends Error {
3
+ phase;
4
+ originalError;
5
+ constructor(phase, originalError) {
6
+ super(`Hook failure during [${phase}]: ${originalError?.message || String(originalError)}`);
7
+ this.phase = phase;
8
+ this.originalError = originalError;
9
+ this.name = 'HookExecutionError';
10
+ }
11
+ }
12
+ export async function runHook(hookPathOrFn, timeoutMs = 10000) {
13
+ let fn;
14
+ if (typeof hookPathOrFn === 'function') {
15
+ fn = hookPathOrFn;
16
+ }
17
+ else {
18
+ try {
19
+ const fileUrl = pathToFileURL(hookPathOrFn).href;
20
+ const mod = await import(fileUrl);
21
+ fn = mod.default || mod;
22
+ if (typeof fn !== 'function') {
23
+ throw new Error(`Hook file ${hookPathOrFn} does not export a default function`);
24
+ }
25
+ }
26
+ catch (err) {
27
+ throw new Error(`Failed to load hook from ${hookPathOrFn}: ${err.message}`);
28
+ }
29
+ }
30
+ let timeoutId = null;
31
+ const timeoutPromise = new Promise((_, reject) => {
32
+ timeoutId = setTimeout(() => {
33
+ reject(new Error(`Hook execution timed out after ${timeoutMs}ms`));
34
+ }, timeoutMs);
35
+ });
36
+ try {
37
+ await Promise.race([Promise.resolve(fn()), timeoutPromise]);
38
+ }
39
+ finally {
40
+ if (timeoutId)
41
+ clearTimeout(timeoutId);
42
+ }
43
+ }
44
+ export class LifecycleDispatcher {
45
+ mode;
46
+ globalHooks;
47
+ constructor(mode = 'single', globalHooks = { before: [], after: [] }) {
48
+ this.mode = mode;
49
+ this.globalHooks = globalHooks;
50
+ }
51
+ async runGlobalBefore() {
52
+ if (this.mode !== 'global')
53
+ return;
54
+ for (const hook of this.globalHooks.before) {
55
+ try {
56
+ await runHook(hook);
57
+ }
58
+ catch (err) {
59
+ throw new HookExecutionError('globalBefore', err);
60
+ }
61
+ }
62
+ }
63
+ async runGlobalAfter() {
64
+ if (this.mode !== 'global')
65
+ return;
66
+ let firstError = null;
67
+ for (const hook of this.globalHooks.after) {
68
+ try {
69
+ await runHook(hook);
70
+ }
71
+ catch (err) {
72
+ if (!firstError)
73
+ firstError = err;
74
+ }
75
+ }
76
+ if (firstError) {
77
+ throw new HookExecutionError('globalAfter', firstError);
78
+ }
79
+ }
80
+ async executeSuite(suiteName, suiteHooks, testCases) {
81
+ const results = [];
82
+ let suiteLevelError;
83
+ // 1. Run suite before hook
84
+ let beforeSuiteFailed = false;
85
+ for (const hook of suiteHooks.before) {
86
+ try {
87
+ await runHook(hook);
88
+ }
89
+ catch (err) {
90
+ beforeSuiteFailed = true;
91
+ suiteLevelError = new HookExecutionError('before', err);
92
+ break;
93
+ }
94
+ }
95
+ if (beforeSuiteFailed) {
96
+ // All test cases in suite are skipped / marked ERROR
97
+ for (const tc of testCases) {
98
+ results.push({
99
+ id: tc.id,
100
+ title: tc.title,
101
+ status: 'ERROR',
102
+ error: suiteLevelError,
103
+ durationMs: 0,
104
+ });
105
+ }
106
+ // Always execute suite after hook even on before failure
107
+ for (const hook of suiteHooks.after) {
108
+ try {
109
+ await runHook(hook);
110
+ }
111
+ catch { }
112
+ }
113
+ return {
114
+ suiteName,
115
+ results,
116
+ status: 'ERROR',
117
+ error: suiteLevelError,
118
+ };
119
+ }
120
+ // 2. Run each test case with beforeEach and afterEach
121
+ for (const tc of testCases) {
122
+ const startTime = Date.now();
123
+ let tcStatus = 'PASSED';
124
+ let tcError;
125
+ let beforeEachFailed = false;
126
+ // Run beforeEach hooks
127
+ for (const hook of suiteHooks.beforeEach) {
128
+ try {
129
+ await runHook(hook);
130
+ }
131
+ catch (err) {
132
+ beforeEachFailed = true;
133
+ tcStatus = 'ERROR';
134
+ tcError = new HookExecutionError('beforeEach', err);
135
+ break;
136
+ }
137
+ }
138
+ // Run test body if beforeEach passed
139
+ if (!beforeEachFailed) {
140
+ try {
141
+ await tc.fn();
142
+ tcStatus = 'PASSED';
143
+ }
144
+ catch (err) {
145
+ tcStatus = 'FAILED';
146
+ tcError = err instanceof Error ? err : new Error(String(err));
147
+ }
148
+ }
149
+ // afterEach is GUARANTEED to execute even if beforeEach or test body failed
150
+ for (const hook of suiteHooks.afterEach) {
151
+ try {
152
+ await runHook(hook);
153
+ }
154
+ catch (err) {
155
+ // If test passed or failed, an afterEach failure transitions it to ERROR (Teardown Failure)
156
+ tcStatus = 'ERROR';
157
+ if (!tcError) {
158
+ tcError = new HookExecutionError('afterEach', err);
159
+ }
160
+ }
161
+ }
162
+ results.push({
163
+ id: tc.id,
164
+ title: tc.title,
165
+ status: tcStatus,
166
+ error: tcError,
167
+ durationMs: Date.now() - startTime,
168
+ });
169
+ }
170
+ // 3. Run suite after hook
171
+ for (const hook of suiteHooks.after) {
172
+ try {
173
+ await runHook(hook);
174
+ }
175
+ catch (err) {
176
+ if (!suiteLevelError) {
177
+ suiteLevelError = new HookExecutionError('after', err);
178
+ }
179
+ }
180
+ }
181
+ const hasErrors = results.some((r) => r.status === 'ERROR') || !!suiteLevelError;
182
+ const hasFailures = results.some((r) => r.status === 'FAILED');
183
+ return {
184
+ suiteName,
185
+ results,
186
+ status: hasErrors ? 'ERROR' : hasFailures ? 'FAILED' : 'PASSED',
187
+ error: suiteLevelError,
188
+ };
189
+ }
190
+ }
@@ -0,0 +1,3 @@
1
+ export * from './hook-discovery.js';
2
+ export * from './hook-dispatcher.js';
3
+ export * from './parallel-grouping.js';
@@ -0,0 +1,3 @@
1
+ export * from './hook-discovery.js';
2
+ export * from './hook-dispatcher.js';
3
+ export * from './parallel-grouping.js';
@@ -0,0 +1,27 @@
1
+ export type ParallelizationMode = 'suite' | 'testcase';
2
+ export interface WorkerSizingOptions {
3
+ maxWorkersCap?: number;
4
+ userConcurrency?: number;
5
+ cpuQuotaOverride?: number;
6
+ memoryBytesOverride?: number;
7
+ }
8
+ /**
9
+ * Detects actual CPU quota available to the process, accounting for
10
+ * Docker and Kubernetes cgroup v1 and cgroup v2 container limits.
11
+ */
12
+ export declare function detectContainerCpuQuota(): number;
13
+ /**
14
+ * Detects available memory in bytes, respecting container cgroup memory limits
15
+ * to prevent Linux OOM (Out of Memory) kills.
16
+ */
17
+ export declare function detectAvailableMemoryBytes(): number;
18
+ /**
19
+ * Dynamically computes optimal worker concurrency respecting:
20
+ * 1. Explicit user concurrency flag (--concurrency <N> or config)
21
+ * 2. Real container cgroups CPU quota & host CPU count
22
+ * 3. Low-CPU safety ratio (clamping <=2 CPUs to 1 worker, 3-4 CPUs to 2 workers)
23
+ * 4. Memory-aware guard (~600MB RAM budget per Chromium instance to prevent OOM kills)
24
+ * 5. Target items count & maximum worker cap
25
+ */
26
+ export declare function calculateWorkerCount(targetItems: any[], options?: WorkerSizingOptions | number): number;
27
+ export declare function groupSpecs(specPaths: string[], mode?: ParallelizationMode): (string | string[])[];