@matware/e2e-runner 1.1.1 β†’ 1.3.0

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/.claude-plugin/marketplace.json +21 -0
  2. package/.claude-plugin/plugin.json +9 -0
  3. package/.mcp.json +9 -0
  4. package/.opencode/commands/create-test.md +63 -0
  5. package/.opencode/commands/run.md +50 -0
  6. package/.opencode/commands/verify-issue.md +62 -0
  7. package/.opencode/skills/e2e-testing/SKILL.md +181 -0
  8. package/.opencode/skills/e2e-testing/references/action-types.md +143 -0
  9. package/.opencode/skills/e2e-testing/references/auth-strategies.md +91 -0
  10. package/.opencode/skills/e2e-testing/references/graphql.md +59 -0
  11. package/.opencode/skills/e2e-testing/references/issue-verification.md +59 -0
  12. package/.opencode/skills/e2e-testing/references/multi-pool.md +60 -0
  13. package/.opencode/skills/e2e-testing/references/network-debugging.md +62 -0
  14. package/.opencode/skills/e2e-testing/references/test-json-format.md +163 -0
  15. package/.opencode/skills/e2e-testing/references/troubleshooting.md +224 -0
  16. package/.opencode/skills/e2e-testing/references/variables.md +41 -0
  17. package/.opencode/skills/e2e-testing/references/visual-verification.md +89 -0
  18. package/OPENCODE.md +166 -0
  19. package/README.md +990 -296
  20. package/agents/test-analyzer.md +81 -0
  21. package/agents/test-creator.md +155 -0
  22. package/agents/test-improver.md +177 -0
  23. package/bin/cli.js +602 -22
  24. package/commands/create-test.md +65 -0
  25. package/commands/run.md +49 -0
  26. package/commands/verify-issue.md +63 -0
  27. package/opencode.json +11 -0
  28. package/package.json +15 -2
  29. package/scripts/setup-opencode.sh +113 -0
  30. package/skills/e2e-testing/SKILL.md +173 -0
  31. package/skills/e2e-testing/references/action-types.md +143 -0
  32. package/skills/e2e-testing/references/auth-strategies.md +91 -0
  33. package/skills/e2e-testing/references/graphql.md +59 -0
  34. package/skills/e2e-testing/references/issue-verification.md +59 -0
  35. package/skills/e2e-testing/references/multi-pool.md +60 -0
  36. package/skills/e2e-testing/references/network-debugging.md +62 -0
  37. package/skills/e2e-testing/references/test-json-format.md +163 -0
  38. package/skills/e2e-testing/references/troubleshooting.md +224 -0
  39. package/skills/e2e-testing/references/variables.md +41 -0
  40. package/skills/e2e-testing/references/visual-verification.md +89 -0
  41. package/src/actions.js +597 -20
  42. package/src/ai-generate.js +142 -12
  43. package/src/config.js +171 -0
  44. package/src/dashboard.js +299 -17
  45. package/src/db.js +335 -13
  46. package/src/index.js +15 -8
  47. package/src/learner-markdown.js +177 -0
  48. package/src/learner-neo4j.js +255 -0
  49. package/src/learner-sqlite.js +658 -0
  50. package/src/learner.js +418 -0
  51. package/src/mcp-tools.js +1558 -50
  52. package/src/module-resolver.js +310 -0
  53. package/src/narrate.js +262 -0
  54. package/src/neo4j-pool.js +124 -0
  55. package/src/pool-manager.js +223 -0
  56. package/src/reporter.js +117 -3
  57. package/src/runner.js +274 -71
  58. package/src/sync/auth.js +354 -0
  59. package/src/sync/client.js +572 -0
  60. package/src/sync/hub-routes.js +816 -0
  61. package/src/sync/index.js +68 -0
  62. package/src/sync/middleware.js +347 -0
  63. package/src/sync/queue.js +209 -0
  64. package/src/sync/schema.js +540 -0
  65. package/src/verify.js +14 -9
  66. package/src/watch.js +384 -0
  67. package/templates/build-dashboard.js +69 -0
  68. package/templates/dashboard/js/api.js +60 -0
  69. package/templates/dashboard/js/init.js +13 -0
  70. package/templates/dashboard/js/keyboard.js +46 -0
  71. package/templates/dashboard/js/state.js +40 -0
  72. package/templates/dashboard/js/toast.js +41 -0
  73. package/templates/dashboard/js/utils.js +196 -0
  74. package/templates/dashboard/js/view-live.js +143 -0
  75. package/templates/dashboard/js/view-runs.js +572 -0
  76. package/templates/dashboard/js/view-tests.js +294 -0
  77. package/templates/dashboard/js/view-watch.js +242 -0
  78. package/templates/dashboard/js/websocket.js +110 -0
  79. package/templates/dashboard/styles/base.css +69 -0
  80. package/templates/dashboard/styles/components.css +110 -0
  81. package/templates/dashboard/styles/view-live.css +74 -0
  82. package/templates/dashboard/styles/view-runs.css +207 -0
  83. package/templates/dashboard/styles/view-tests.css +96 -0
  84. package/templates/dashboard/styles/view-watch.css +53 -0
  85. package/templates/dashboard/template.html +267 -0
  86. package/templates/dashboard.html +2171 -530
  87. package/templates/docker-compose-neo4j.yml +19 -0
  88. package/templates/e2e.config.js +3 -0
  89. package/templates/sample-test.json +0 -8
package/src/runner.js CHANGED
@@ -7,14 +7,30 @@
7
7
 
8
8
  import fs from 'fs';
9
9
  import path from 'path';
10
- import { connectToPool, waitForPool, getPoolStatus } from './pool.js';
10
+ import http from 'http';
11
+ import https from 'https';
12
+ import { connectToPool } from './pool.js';
13
+ import { getPoolUrls, selectPool, releasePending } from './pool-manager.js';
11
14
  import { executeAction } from './actions.js';
15
+ import { narrateAction } from './narrate.js';
12
16
  import { log, colors as C } from './logger.js';
17
+ import { resolveTestData, validateActionTypes } from './module-resolver.js';
18
+ import { ensureProject, getVariables } from './db.js';
13
19
 
14
20
  function sleep(ms) {
15
21
  return new Promise(resolve => setTimeout(resolve, ms));
16
22
  }
17
23
 
24
+ /** Simple glob matching with * wildcards for exclude patterns. */
25
+ function matchesExclude(filename, excludePatterns) {
26
+ if (!excludePatterns?.length) return false;
27
+ const name = filename.replace('.json', '');
28
+ return excludePatterns.some(pattern => {
29
+ const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$');
30
+ return regex.test(name) || regex.test(filename);
31
+ });
32
+ }
33
+
18
34
  function timeDiff(start, end) {
19
35
  const ms = new Date(end) - new Date(start);
20
36
  return ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`;
@@ -32,6 +48,46 @@ function mergeHooks(configHooks, suiteHooks) {
32
48
  };
33
49
  }
34
50
 
51
+ /** Replaces {{var.KEY}} and {{env.KEY}} in all string fields of an action object.
52
+ * Skips {{param}} patterns (no dot) β€” those are module params handled by module-resolver. */
53
+ function resolveVarsInAction(action, vars) {
54
+ const resolved = { ...action };
55
+ for (const key of Object.keys(resolved)) {
56
+ if (typeof resolved[key] !== 'string') continue;
57
+ resolved[key] = resolved[key].replace(/\{\{(var|env)\.([^}]+)\}\}/g, (match, ns, name) => {
58
+ if (ns === 'env') {
59
+ if (process.env[name] !== undefined) return process.env[name];
60
+ throw new Error(`Unresolved variable: {{env.${name}}} β€” environment variable not set`);
61
+ }
62
+ // ns === 'var'
63
+ if (vars[name] !== undefined) return vars[name];
64
+ throw new Error(`Unresolved variable: {{var.${name}}} β€” not found in project or suite variables`);
65
+ });
66
+ }
67
+ return resolved;
68
+ }
69
+
70
+ /** Loads merged variables for a test (project scope + suite scope overlay). */
71
+ function loadVarsForTest(config, suiteName) {
72
+ try {
73
+ const cwd = config._cwd || process.cwd();
74
+ const projectName = config.projectName || cwd.split('/').pop() || 'default';
75
+ const projectId = ensureProject(cwd, projectName, config.screenshotsDir, config.testsDir);
76
+ const projectVars = getVariables(projectId, 'project');
77
+ if (!suiteName) return projectVars;
78
+ const suiteVars = getVariables(projectId, suiteName);
79
+ return { ...projectVars, ...suiteVars };
80
+ } catch {
81
+ return {};
82
+ }
83
+ }
84
+
85
+ /** Resolves variables in an array of actions. */
86
+ function resolveVarsInActions(actions, vars) {
87
+ if (!actions || !actions.length) return actions;
88
+ return actions.map(a => resolveVarsInAction(a, vars));
89
+ }
90
+
35
91
  /** Executes an array of hook actions on a page */
36
92
  async function executeHookActions(page, actions, config) {
37
93
  for (const action of actions) {
@@ -44,32 +100,60 @@ function normalizeTestData(data) {
44
100
  if (Array.isArray(data)) {
45
101
  return { tests: data, hooks: {} };
46
102
  }
47
- return { tests: data.tests || [], hooks: data.hooks || {} };
103
+ // Support hooks nested under "hooks" key or directly at root level
104
+ const hooks = data.hooks || {};
105
+ if (!hooks.beforeAll && data.beforeAll) hooks.beforeAll = data.beforeAll;
106
+ if (!hooks.afterAll && data.afterAll) hooks.afterAll = data.afterAll;
107
+ if (!hooks.beforeEach && data.beforeEach) hooks.beforeEach = data.beforeEach;
108
+ if (!hooks.afterEach && data.afterEach) hooks.afterEach = data.afterEach;
109
+ return { tests: data.tests || [], hooks };
48
110
  }
49
111
 
50
- /** Waits until the pool has capacity before connecting */
51
- async function waitForSlot(poolUrl, pollIntervalMs = 2000, maxWaitMs = 60000) {
52
- const start = Date.now();
53
- while (Date.now() - start < maxWaitMs) {
54
- try {
55
- const status = await getPoolStatus(poolUrl);
56
- if (status.available && status.running < status.maxConcurrent) {
57
- return;
58
- }
59
- log('⏳', `${C.dim}Pool at capacity (${status.running}/${status.maxConcurrent}, ${status.queued} queued), waiting for slot...${C.reset}`);
60
- } catch {
61
- // Pool unreachable, let connectToPool handle the error
62
- return;
63
- }
64
- await sleep(pollIntervalMs);
65
- }
66
- // Timeout β€” proceed anyway and let connectToPool deal with it
67
- log('⚠️', `${C.yellow}Waited ${maxWaitMs / 1000}s for pool slot, proceeding anyway${C.reset}`);
112
+ /** Extracts a value from an object using a dot-path (e.g. "data.token"). */
113
+ function getByPath(obj, dotPath) {
114
+ return dotPath.split('.').reduce((o, key) => o?.[key], obj);
115
+ }
116
+
117
+ /** Fetches an auth token by POSTing credentials to a login endpoint. */
118
+ function fetchAuthToken(endpoint, credentials, tokenPath) {
119
+ return new Promise((resolve, reject) => {
120
+ const url = new URL(endpoint);
121
+ const transport = url.protocol === 'https:' ? https : http;
122
+ const body = JSON.stringify(credentials);
123
+
124
+ const req = transport.request(url, {
125
+ method: 'POST',
126
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
127
+ timeout: 15000,
128
+ }, (res) => {
129
+ let data = '';
130
+ res.on('data', (chunk) => { data += chunk; });
131
+ res.on('end', () => {
132
+ if (res.statusCode < 200 || res.statusCode >= 300) {
133
+ return reject(new Error(`Auth login failed: HTTP ${res.statusCode} from ${endpoint}`));
134
+ }
135
+ try {
136
+ const json = JSON.parse(data);
137
+ const token = getByPath(json, tokenPath);
138
+ if (!token) {
139
+ return reject(new Error(`Auth login: token not found at path "${tokenPath}" in response`));
140
+ }
141
+ resolve(token);
142
+ } catch (e) {
143
+ reject(new Error(`Auth login: failed to parse response from ${endpoint}: ${e.message}`));
144
+ }
145
+ });
146
+ });
147
+ req.on('error', (e) => reject(new Error(`Auth login request failed: ${e.message}`)));
148
+ req.on('timeout', () => { req.destroy(); reject(new Error(`Auth login request timed out: ${endpoint}`)); });
149
+ req.end(body);
150
+ });
68
151
  }
69
152
 
70
153
  /** Runs a single test end-to-end */
71
154
  export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
72
155
  let browser = null;
156
+ let context = null;
73
157
  let page = null;
74
158
 
75
159
  const result = {
@@ -85,16 +169,28 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
85
169
  const pendingBodies = [];
86
170
 
87
171
  try {
88
- await waitForSlot(config.poolUrl);
89
- browser = await connectToPool(config.poolUrl, config.connectRetries, config.connectRetryDelay);
90
- page = await browser.newPage();
172
+ const chosenPool = await selectPool(getPoolUrls(config));
173
+ result.poolUrl = chosenPool;
174
+ const poolLabel = chosenPool.replace('ws://', '').replace('wss://', '');
175
+ const isMultiPool = getPoolUrls(config).length > 1;
176
+ if (isMultiPool) {
177
+ log('πŸ”—', `${C.cyan}${test.name}${C.reset} ${C.dim}β†’ ${poolLabel}${C.reset}`);
178
+ }
179
+ progressFn({ event: 'test:pool', name: test.name, poolUrl: chosenPool });
180
+ browser = await connectToPool(chosenPool, config.connectRetries, config.connectRetryDelay);
181
+ // Use incognito context for cookie isolation between concurrent tests
182
+ context = await browser.createBrowserContext();
183
+ page = await context.newPage();
91
184
  await page.setViewport(config.viewport);
92
185
 
93
186
  page.on('console', (msg) => {
94
187
  result.consoleLogs.push({ type: msg.type(), text: msg.text() });
95
188
  });
96
189
  page.on('requestfailed', (req) => {
97
- result.networkErrors.push({ url: req.url(), error: req.failure()?.errorText });
190
+ const url = req.url();
191
+ const ignoreDomains = config.networkIgnoreDomains || [];
192
+ if (ignoreDomains.length > 0 && ignoreDomains.some(d => url.includes(d))) return;
193
+ result.networkErrors.push({ url, error: req.failure()?.errorText });
98
194
  });
99
195
 
100
196
  const requestTimings = new Map();
@@ -128,44 +224,91 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
128
224
  }
129
225
  });
130
226
 
227
+ // Auto-inject auth token into localStorage (runs BEFORE beforeEach hooks)
228
+ if (config.authToken) {
229
+ const storageKey = config.authStorageKey || 'accessToken';
230
+ await page.goto(config.baseUrl, { waitUntil: 'domcontentloaded', timeout: 15000 });
231
+ await page.evaluate((key, token) => {
232
+ localStorage.setItem(key, token);
233
+ }, storageKey, config.authToken);
234
+ }
235
+
236
+ // Resolve {{var.X}} and {{env.X}} in test actions and hooks
237
+ const vars = loadVarsForTest(config, config._suiteName);
238
+ if (Object.keys(vars).length > 0 || /\{\{(var|env)\./.test(JSON.stringify(test.actions))) {
239
+ test = { ...test, actions: resolveVarsInActions(test.actions, vars) };
240
+ if (hooks.beforeEach?.length) hooks = { ...hooks, beforeEach: resolveVarsInActions(hooks.beforeEach, vars) };
241
+ if (hooks.afterEach?.length) hooks = { ...hooks, afterEach: resolveVarsInActions(hooks.afterEach, vars) };
242
+ }
243
+
131
244
  // Run beforeEach hook
132
245
  if (hooks.beforeEach?.length) {
133
246
  await executeHookActions(page, hooks.beforeEach, config);
134
247
  }
135
248
 
249
+ // Auto-capture baseline screenshot if test has "expect" (BEFORE actions)
250
+ if (test.expect && page) {
251
+ try {
252
+ const safeName = test.name.replace(/[^a-zA-Z0-9_\-. ]/g, '_');
253
+ const baselinePath = path.join(config.screenshotsDir, `baseline-${safeName}-${Date.now()}.png`);
254
+ await page.screenshot({ path: baselinePath, fullPage: true });
255
+ result.baselineScreenshot = baselinePath;
256
+ } catch { /* page may not be ready */ }
257
+ }
258
+
136
259
  for (let i = 0; i < test.actions.length; i++) {
137
260
  const action = test.actions[i];
138
- const actionStart = Date.now();
139
- try {
140
- let actionResult;
141
- if (action.type === 'assert_no_network_errors') {
142
- // Handled inline β€” needs access to result.networkErrors
143
- if (result.networkErrors.length > 0) {
144
- const summary = result.networkErrors.map(e => `${e.url} (${e.error})`).join(', ');
145
- throw new Error(`assert_no_network_errors failed: ${result.networkErrors.length} error(s): ${summary}`);
261
+ const maxActionRetries = action.retries ?? config.actionRetries ?? 0;
262
+ const actionRetryDelay = config.actionRetryDelay ?? 500;
263
+ let lastError = null;
264
+
265
+ for (let attempt = 0; attempt <= maxActionRetries; attempt++) {
266
+ const actionStart = Date.now();
267
+ try {
268
+ let actionResult;
269
+ if (action.type === 'assert_no_network_errors') {
270
+ // Handled inline β€” needs access to result.networkErrors
271
+ if (result.networkErrors.length > 0) {
272
+ const summary = result.networkErrors.map(e => `${e.url} (${e.error})`).join(', ');
273
+ throw new Error(`assert_no_network_errors failed: ${result.networkErrors.length} error(s): ${summary}`);
274
+ }
275
+ actionResult = null;
276
+ } else {
277
+ actionResult = await executeAction(page, action, config);
278
+ }
279
+ const actionDuration = Date.now() - actionStart;
280
+ const actionEntry = {
281
+ ...action,
282
+ success: true,
283
+ duration: actionDuration,
284
+ result: actionResult,
285
+ };
286
+ if (attempt > 0) actionEntry.actionRetries = attempt;
287
+ actionEntry.narrative = narrateAction(action, actionEntry);
288
+ result.actions.push(actionEntry);
289
+ progressFn({ event: 'test:action', name: test.name, action, actionIndex: i, totalActions: test.actions.length, success: true, duration: actionDuration, narrative: actionEntry.narrative, screenshotPath: actionResult?.screenshot || null });
290
+ lastError = null;
291
+ break;
292
+ } catch (error) {
293
+ lastError = error;
294
+ if (attempt < maxActionRetries) {
295
+ log('πŸ”„', `${C.dim}Action ${action.type} retry ${attempt + 1}/${maxActionRetries} (${error.message})${C.reset}`);
296
+ await sleep(actionRetryDelay);
297
+ continue;
146
298
  }
147
- actionResult = null;
148
- } else {
149
- actionResult = await executeAction(page, action, config);
299
+ const actionDuration = Date.now() - actionStart;
300
+ const failedEntry = {
301
+ ...action,
302
+ success: false,
303
+ duration: actionDuration,
304
+ error: error.message,
305
+ };
306
+ if (maxActionRetries > 0) failedEntry.actionRetries = attempt;
307
+ failedEntry.narrative = narrateAction(action, failedEntry);
308
+ result.actions.push(failedEntry);
309
+ progressFn({ event: 'test:action', name: test.name, action, actionIndex: i, totalActions: test.actions.length, success: false, duration: actionDuration, narrative: failedEntry.narrative, error: error.message });
310
+ throw error;
150
311
  }
151
- const actionDuration = Date.now() - actionStart;
152
- result.actions.push({
153
- ...action,
154
- success: true,
155
- duration: actionDuration,
156
- result: actionResult,
157
- });
158
- progressFn({ event: 'test:action', name: test.name, action, actionIndex: i, totalActions: test.actions.length, success: true, duration: actionDuration, screenshotPath: actionResult?.screenshot || null });
159
- } catch (error) {
160
- const actionDuration = Date.now() - actionStart;
161
- result.actions.push({
162
- ...action,
163
- success: false,
164
- duration: actionDuration,
165
- error: error.message,
166
- });
167
- progressFn({ event: 'test:action', name: test.name, action, actionIndex: i, totalActions: test.actions.length, success: false, duration: actionDuration, error: error.message });
168
- throw error;
169
312
  }
170
313
  }
171
314
 
@@ -216,9 +359,16 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
216
359
  if (page) {
217
360
  try { result.finalUrl = page.url(); } catch { /* */ }
218
361
  }
362
+ if (context) {
363
+ try { await context.close(); } catch { /* */ }
364
+ }
219
365
  if (browser) {
220
366
  try { browser.disconnect(); } catch { /* */ }
221
367
  }
368
+ // Release local pending counter so selectPool() knows this slot is free
369
+ if (result.poolUrl) {
370
+ releasePending(result.poolUrl);
371
+ }
222
372
  }
223
373
 
224
374
  return result;
@@ -230,10 +380,17 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
230
380
 
231
381
  // Run beforeAll hook
232
382
  if (hooks.beforeAll?.length) {
383
+ const stateActions = hooks.beforeAll.filter(a =>
384
+ ['evaluate', 'goto', 'navigate', 'clear_cookies', 'type', 'click', 'select'].includes(a.type)
385
+ );
386
+ if (stateActions.length > 0) {
387
+ log('⚠️', `${C.yellow}beforeAll runs on a separate browser β€” state from ${stateActions.map(a => a.type).join(', ')} will NOT carry over to tests. Use beforeEach instead.${C.reset}`);
388
+ }
233
389
  log('πŸͺ', `${C.dim}Running beforeAll hook...${C.reset}`);
234
390
  let browser = null;
235
391
  try {
236
- browser = await connectToPool(config.poolUrl, config.connectRetries, config.connectRetryDelay);
392
+ const hookPool = await selectPool(getPoolUrls(config));
393
+ browser = await connectToPool(hookPool, config.connectRetries, config.connectRetryDelay);
237
394
  const page = await browser.newPage();
238
395
  await page.setViewport(config.viewport);
239
396
  await executeHookActions(page, hooks.beforeAll, config);
@@ -246,16 +403,40 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
246
403
  }
247
404
  }
248
405
 
406
+ // Auto-login: fetch auth token via API if configured and not already provided
407
+ if (config.authLoginEndpoint && !config.authToken && config.authCredentials) {
408
+ log('πŸ”‘', `${C.dim}Fetching auth token from ${config.authLoginEndpoint}...${C.reset}`);
409
+ try {
410
+ config.authToken = await fetchAuthToken(
411
+ config.authLoginEndpoint,
412
+ config.authCredentials,
413
+ config.authTokenPath || 'token'
414
+ );
415
+ log('βœ…', `${C.dim}Auth token acquired (${config.authToken.length} chars)${C.reset}`);
416
+ } catch (error) {
417
+ log('❌', `${C.red}Auth auto-login failed: ${error.message}${C.reset}`);
418
+ throw error;
419
+ }
420
+ }
421
+
249
422
  const concurrency = config.concurrency || 3;
250
423
  const _runId = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
251
424
  const _proj = config.projectName || null;
252
425
  const _cwd = config._cwd || null;
253
426
  const _triggeredBy = config.triggeredBy || 'unknown';
254
427
  const _progress = (data) => config.onProgress && config.onProgress({ ...data, runId: _runId, project: _proj, cwd: _cwd, triggeredBy: _triggeredBy });
428
+
429
+ // Split serial and parallel tests
430
+ const parallelTests = tests.filter(t => !t.serial);
431
+ const serialTests = tests.filter(t => t.serial);
432
+ if (serialTests.length > 0) {
433
+ log('πŸ”’', `${C.dim}${serialTests.length} serial test(s) will run after parallel batch${C.reset}`);
434
+ }
435
+
255
436
  _progress({ event: 'run:start', total: tests.length, concurrency, timestamp: new Date().toISOString() });
256
437
 
257
438
  const results = [];
258
- const queue = [...tests];
439
+ const queue = [...parallelTests];
259
440
  let activeCount = 0;
260
441
 
261
442
  const worker = async () => {
@@ -263,7 +444,7 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
263
444
  const test = queue.shift();
264
445
  activeCount++;
265
446
  log('β–Άβ–Άβ–Ά', `${C.cyan}${test.name}${C.reset} ${C.dim}(${activeCount} active)${C.reset}`);
266
- _progress({ event: 'test:start', name: test.name, activeCount, queueRemaining: queue.length });
447
+ _progress({ event: 'test:start', name: test.name, serial: test.serial || false, activeCount, queueRemaining: queue.length });
267
448
 
268
449
  const maxAttempts = (test.retries ?? config.retries ?? 0) + 1;
269
450
  const testTimeout = test.timeout ?? config.testTimeout ?? 60000;
@@ -276,7 +457,8 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
276
457
  });
277
458
 
278
459
  try {
279
- result = await Promise.race([runTest(test, config, hooks, _progress), timeoutPromise]);
460
+ const testHooks = test._suiteHooks ? mergeHooks(config.hooks, test._suiteHooks) : hooks;
461
+ result = await Promise.race([runTest(test, config, testHooks, _progress), timeoutPromise]);
280
462
  } catch (error) {
281
463
  result = {
282
464
  name: test.name,
@@ -304,7 +486,7 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
304
486
  activeCount--;
305
487
 
306
488
  const screenshots = result.actions.filter(a => a.result?.screenshot).map(a => a.result.screenshot);
307
- _progress({ event: 'test:complete', name: test.name, success: result.success, duration: timeDiff(result.startTime, result.endTime), error: result.error, consoleLogs: result.consoleLogs, networkErrors: result.networkErrors, networkLogs: result.networkLogs, errorScreenshot: result.errorScreenshot, screenshots });
489
+ _progress({ event: 'test:complete', name: test.name, success: result.success, duration: timeDiff(result.startTime, result.endTime), error: result.error, consoleLogs: result.consoleLogs, networkErrors: result.networkErrors, networkLogs: result.networkLogs, errorScreenshot: result.errorScreenshot, screenshots, poolUrl: result.poolUrl || null });
308
490
 
309
491
  if (result.success) {
310
492
  const flaky = result.attempt > 1 ? ` ${C.yellow}(flaky, passed on attempt ${result.attempt}/${result.maxAttempts})${C.reset}` : '';
@@ -325,17 +507,25 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
325
507
  };
326
508
 
327
509
  const workers = [];
328
- for (let i = 0; i < Math.min(concurrency, tests.length); i++) {
510
+ for (let i = 0; i < Math.min(concurrency, parallelTests.length); i++) {
329
511
  workers.push(worker());
330
512
  }
331
513
  await Promise.all(workers);
332
514
 
515
+ // Run serial tests one at a time
516
+ if (serialTests.length > 0) {
517
+ log('πŸ”’', `${C.dim}Running ${serialTests.length} serial test(s)...${C.reset}`);
518
+ queue.push(...serialTests);
519
+ await worker();
520
+ }
521
+
333
522
  // Run afterAll hook
334
523
  if (hooks.afterAll?.length) {
335
524
  log('πŸͺ', `${C.dim}Running afterAll hook...${C.reset}`);
336
525
  let browser = null;
337
526
  try {
338
- browser = await connectToPool(config.poolUrl, config.connectRetries, config.connectRetryDelay);
527
+ const hookPool = await selectPool(getPoolUrls(config));
528
+ browser = await connectToPool(hookPool, config.connectRetries, config.connectRetryDelay);
339
529
  const page = await browser.newPage();
340
530
  await page.setViewport(config.viewport);
341
531
  await executeHookActions(page, hooks.afterAll, config);
@@ -357,16 +547,19 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
357
547
  }
358
548
 
359
549
  /** Loads tests from a JSON file β€” returns { tests, hooks } */
360
- export function loadTestFile(filePath) {
550
+ export function loadTestFile(filePath, modulesDir) {
361
551
  if (!fs.existsSync(filePath)) {
362
552
  throw new Error(`File not found: ${filePath}`);
363
553
  }
364
554
  const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
365
- return normalizeTestData(data);
555
+ const normalized = normalizeTestData(data);
556
+ const resolved = modulesDir ? resolveTestData(normalized, modulesDir) : normalized;
557
+ validateActionTypes(resolved, path.basename(filePath));
558
+ return resolved;
366
559
  }
367
560
 
368
561
  /** Loads a test suite by name β€” returns { tests, hooks } */
369
- export function loadTestSuite(suiteName, testsDir) {
562
+ export function loadTestSuite(suiteName, testsDir, modulesDir) {
370
563
  // Match with or without numeric prefix (e.g. "agents" matches "03-agents.json")
371
564
  const files = fs.readdirSync(testsDir).filter(f => f.endsWith('.json'));
372
565
  const exact = files.find(f => f === `${suiteName}.json`);
@@ -378,29 +571,39 @@ export function loadTestSuite(suiteName, testsDir) {
378
571
  }
379
572
 
380
573
  const data = JSON.parse(fs.readFileSync(path.join(testsDir, match), 'utf-8'));
381
- return normalizeTestData(data);
574
+ const normalized = normalizeTestData(data);
575
+ const resolved = modulesDir ? resolveTestData(normalized, modulesDir) : normalized;
576
+ validateActionTypes(resolved, match);
577
+ return resolved;
382
578
  }
383
579
 
384
580
  /** Loads all test suites from the tests directory β€” returns { tests, hooks } */
385
- export function loadAllSuites(testsDir) {
581
+ export function loadAllSuites(testsDir, modulesDir, exclude = []) {
386
582
  if (!fs.existsSync(testsDir)) {
387
583
  throw new Error(`Tests directory not found: ${testsDir}`);
388
584
  }
389
585
 
390
- const files = fs.readdirSync(testsDir).filter(f => f.endsWith('.json')).sort();
586
+ const files = fs.readdirSync(testsDir).filter(f => f.endsWith('.json')).sort()
587
+ .filter(f => !matchesExclude(f, exclude));
391
588
  let allTests = [];
392
- let mergedHooks = {};
393
589
 
394
590
  for (const file of files) {
395
591
  const data = JSON.parse(fs.readFileSync(path.join(testsDir, file), 'utf-8'));
396
- const { tests, hooks } = normalizeTestData(data);
592
+ let { tests, hooks } = normalizeTestData(data);
593
+ // Resolve modules per-suite before concatenating
594
+ if (modulesDir) {
595
+ ({ tests, hooks } = resolveTestData({ tests, hooks }, modulesDir));
596
+ }
597
+ validateActionTypes({ tests, hooks }, file);
598
+ // Tag each test with its own suite's hooks so they're preserved
599
+ for (const t of tests) {
600
+ t._suiteHooks = hooks;
601
+ }
397
602
  allTests = allTests.concat(tests);
398
- // Last suite's hooks win for each non-empty key
399
- mergedHooks = mergeHooks(mergedHooks, hooks);
400
603
  log('πŸ“‹', `${C.cyan}${file}${C.reset} (${tests.length} tests)`);
401
604
  }
402
605
 
403
- return { tests: allTests, hooks: mergedHooks };
606
+ return { tests: allTests, hooks: {} };
404
607
  }
405
608
 
406
609
  /** Lists all available test suites */