@matware/e2e-runner 1.2.1 → 1.3.1

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 (88) hide show
  1. package/.claude-plugin/marketplace.json +52 -0
  2. package/.claude-plugin/plugin.json +17 -3
  3. package/.mcp.json +2 -2
  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/LICENSE +190 -0
  19. package/OPENCODE.md +166 -0
  20. package/README.md +165 -104
  21. package/agents/test-creator.md +54 -1
  22. package/agents/test-improver.md +37 -0
  23. package/bin/cli.js +409 -16
  24. package/commands/capture.md +45 -0
  25. package/commands/create-test.md +16 -1
  26. package/opencode.json +11 -0
  27. package/package.json +7 -2
  28. package/scripts/setup-opencode.sh +113 -0
  29. package/skills/e2e-testing/SKILL.md +10 -3
  30. package/skills/e2e-testing/references/action-types.md +48 -5
  31. package/skills/e2e-testing/references/auth-strategies.md +91 -0
  32. package/skills/e2e-testing/references/graphql.md +59 -0
  33. package/skills/e2e-testing/references/issue-verification.md +59 -0
  34. package/skills/e2e-testing/references/multi-pool.md +60 -0
  35. package/skills/e2e-testing/references/network-debugging.md +62 -0
  36. package/skills/e2e-testing/references/test-json-format.md +4 -0
  37. package/skills/e2e-testing/references/troubleshooting.md +44 -2
  38. package/skills/e2e-testing/references/variables.md +41 -0
  39. package/skills/e2e-testing/references/visual-verification.md +89 -0
  40. package/src/actions.js +475 -2
  41. package/src/ai-generate.js +139 -8
  42. package/src/app-pool.js +339 -0
  43. package/src/config.js +266 -5
  44. package/src/dashboard.js +216 -17
  45. package/src/db.js +191 -7
  46. package/src/index.js +12 -9
  47. package/src/learner-sqlite.js +458 -0
  48. package/src/learner.js +78 -6
  49. package/src/mcp-tools.js +1348 -51
  50. package/src/module-resolver.js +37 -0
  51. package/src/narrate.js +65 -0
  52. package/src/pool-manager.js +229 -0
  53. package/src/pool.js +301 -31
  54. package/src/reporter.js +86 -2
  55. package/src/runner.js +480 -71
  56. package/src/sync/auth.js +354 -0
  57. package/src/sync/client.js +572 -0
  58. package/src/sync/hub-routes.js +816 -0
  59. package/src/sync/index.js +68 -0
  60. package/src/sync/middleware.js +347 -0
  61. package/src/sync/queue.js +209 -0
  62. package/src/sync/schema.js +540 -0
  63. package/src/verify.js +10 -7
  64. package/src/visual-diff.js +446 -0
  65. package/src/watch.js +384 -0
  66. package/templates/build-dashboard.js +47 -6
  67. package/templates/dashboard/js/api.js +62 -0
  68. package/templates/dashboard/js/init.js +13 -0
  69. package/templates/dashboard/js/keyboard.js +46 -0
  70. package/templates/dashboard/js/state.js +40 -0
  71. package/templates/dashboard/js/toast.js +41 -0
  72. package/templates/dashboard/js/utils.js +216 -0
  73. package/templates/dashboard/js/view-live.js +181 -0
  74. package/templates/dashboard/js/view-runs.js +676 -0
  75. package/templates/dashboard/js/view-tests.js +294 -0
  76. package/templates/dashboard/js/view-watch.js +242 -0
  77. package/templates/dashboard/js/websocket.js +116 -0
  78. package/templates/dashboard/styles/base.css +69 -0
  79. package/templates/dashboard/styles/components.css +117 -0
  80. package/templates/dashboard/styles/view-live.css +97 -0
  81. package/templates/dashboard/styles/view-runs.css +243 -0
  82. package/templates/dashboard/styles/view-tests.css +96 -0
  83. package/templates/dashboard/styles/view-watch.css +53 -0
  84. package/templates/dashboard/template.html +181 -100
  85. package/templates/dashboard.html +1614 -547
  86. package/templates/sample-test.json +0 -8
  87. package/templates/dashboard/app.js +0 -1152
  88. package/templates/dashboard/styles.css +0 -413
package/src/runner.js CHANGED
@@ -7,11 +7,17 @@
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, getCachedDriver, disconnectFromPool } from './pool.js';
13
+ import { getPoolUrls, selectPool, releasePending } from './pool-manager.js';
14
+ import { forkAppInstance, destroyFork, isAppPoolEnabled } from './app-pool.js';
11
15
  import { executeAction } from './actions.js';
12
16
  import { narrateAction } from './narrate.js';
13
17
  import { log, colors as C } from './logger.js';
14
- import { resolveTestData } from './module-resolver.js';
18
+ import { resolveTestData, validateActionTypes } from './module-resolver.js';
19
+ import { compareImages } from './visual-diff.js';
20
+ import { ensureProject, getVariables } from './db.js';
15
21
 
16
22
  function sleep(ms) {
17
23
  return new Promise(resolve => setTimeout(resolve, ms));
@@ -44,6 +50,46 @@ function mergeHooks(configHooks, suiteHooks) {
44
50
  };
45
51
  }
46
52
 
53
+ /** Replaces {{var.KEY}} and {{env.KEY}} in all string fields of an action object.
54
+ * Skips {{param}} patterns (no dot) — those are module params handled by module-resolver. */
55
+ function resolveVarsInAction(action, vars) {
56
+ const resolved = { ...action };
57
+ for (const key of Object.keys(resolved)) {
58
+ if (typeof resolved[key] !== 'string') continue;
59
+ resolved[key] = resolved[key].replace(/\{\{(var|env)\.([^}]+)\}\}/g, (match, ns, name) => {
60
+ if (ns === 'env') {
61
+ if (process.env[name] !== undefined) return process.env[name];
62
+ throw new Error(`Unresolved variable: {{env.${name}}} — environment variable not set`);
63
+ }
64
+ // ns === 'var'
65
+ if (vars[name] !== undefined) return vars[name];
66
+ throw new Error(`Unresolved variable: {{var.${name}}} — not found in project or suite variables`);
67
+ });
68
+ }
69
+ return resolved;
70
+ }
71
+
72
+ /** Loads merged variables for a test (project scope + suite scope overlay). */
73
+ function loadVarsForTest(config, suiteName) {
74
+ try {
75
+ const cwd = config._cwd || process.cwd();
76
+ const projectName = config.projectName || cwd.split('/').pop() || 'default';
77
+ const projectId = ensureProject(cwd, projectName, config.screenshotsDir, config.testsDir);
78
+ const projectVars = getVariables(projectId, 'project');
79
+ if (!suiteName) return projectVars;
80
+ const suiteVars = getVariables(projectId, suiteName);
81
+ return { ...projectVars, ...suiteVars };
82
+ } catch {
83
+ return {};
84
+ }
85
+ }
86
+
87
+ /** Resolves variables in an array of actions. */
88
+ function resolveVarsInActions(actions, vars) {
89
+ if (!actions || !actions.length) return actions;
90
+ return actions.map(a => resolveVarsInAction(a, vars));
91
+ }
92
+
47
93
  /** Executes an array of hook actions on a page */
48
94
  async function executeHookActions(page, actions, config) {
49
95
  for (const action of actions) {
@@ -65,24 +111,45 @@ function normalizeTestData(data) {
65
111
  return { tests: data.tests || [], hooks };
66
112
  }
67
113
 
68
- /** Waits until the pool has capacity before connecting */
69
- async function waitForSlot(poolUrl, pollIntervalMs = 2000, maxWaitMs = 60000) {
70
- const start = Date.now();
71
- while (Date.now() - start < maxWaitMs) {
72
- try {
73
- const status = await getPoolStatus(poolUrl);
74
- if (status.available && status.running < status.maxConcurrent) {
75
- return;
76
- }
77
- log('⏳', `${C.dim}Pool at capacity (${status.running}/${status.maxConcurrent}, ${status.queued} queued), waiting for slot...${C.reset}`);
78
- } catch {
79
- // Pool unreachable, let connectToPool handle the error
80
- return;
81
- }
82
- await sleep(pollIntervalMs);
83
- }
84
- // Timeout proceed anyway and let connectToPool deal with it
85
- log('⚠️', `${C.yellow}Waited ${maxWaitMs / 1000}s for pool slot, proceeding anyway${C.reset}`);
114
+ /** Extracts a value from an object using a dot-path (e.g. "data.token"). */
115
+ function getByPath(obj, dotPath) {
116
+ return dotPath.split('.').reduce((o, key) => o?.[key], obj);
117
+ }
118
+
119
+ /** Fetches an auth token by POSTing credentials to a login endpoint. */
120
+ export function fetchAuthToken(endpoint, credentials, tokenPath) {
121
+ return new Promise((resolve, reject) => {
122
+ const url = new URL(endpoint);
123
+ const transport = url.protocol === 'https:' ? https : http;
124
+ const body = JSON.stringify(credentials);
125
+
126
+ const req = transport.request(url, {
127
+ method: 'POST',
128
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body), 'Accept': '*/*', 'User-Agent': '@matware/e2e-runner' },
129
+ timeout: 15000,
130
+ }, (res) => {
131
+ let data = '';
132
+ res.on('data', (chunk) => { data += chunk; });
133
+ res.on('end', () => {
134
+ if (res.statusCode < 200 || res.statusCode >= 300) {
135
+ return reject(new Error(`Auth login failed: HTTP ${res.statusCode} from ${endpoint}`));
136
+ }
137
+ try {
138
+ const json = JSON.parse(data);
139
+ const token = getByPath(json, tokenPath);
140
+ if (!token) {
141
+ return reject(new Error(`Auth login: token not found at path "${tokenPath}" in response`));
142
+ }
143
+ resolve(token);
144
+ } catch (e) {
145
+ reject(new Error(`Auth login: failed to parse response from ${endpoint}: ${e.message}`));
146
+ }
147
+ });
148
+ });
149
+ req.on('error', (e) => reject(new Error(`Auth login request failed: ${e.message}`)));
150
+ req.on('timeout', () => { req.destroy(); reject(new Error(`Auth login request timed out: ${endpoint}`)); });
151
+ req.end(body);
152
+ });
86
153
  }
87
154
 
88
155
  /** Runs a single test end-to-end */
@@ -90,6 +157,14 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
90
157
  let browser = null;
91
158
  let context = null;
92
159
  let page = null;
160
+ let cdpSession = null;
161
+ let appFork = null;
162
+
163
+ // ── Multi-tab registry ────────────────────────────────────────────────────
164
+ // Maps label → page. The "default" label is the initial page.
165
+ // activePage tracks the current tab; page always points to it.
166
+ const tabRegistry = new Map();
167
+ let activeTabLabel = 'default';
93
168
 
94
169
  const result = {
95
170
  name: test.name,
@@ -104,18 +179,76 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
104
179
  const pendingBodies = [];
105
180
 
106
181
  try {
107
- await waitForSlot(config.poolUrl);
108
- browser = await connectToPool(config.poolUrl, config.connectRetries, config.connectRetryDelay);
182
+ // Fork an isolated app instance if app pool is enabled
183
+ let effectiveConfig = config;
184
+ if (isAppPoolEnabled(config)) {
185
+ appFork = await forkAppInstance(config, test.name);
186
+ // Override baseUrl to point to this test's isolated app instance
187
+ // Use dockerBaseUrl when Chrome runs inside Docker (default setup)
188
+ effectiveConfig = { ...config, baseUrl: appFork.dockerBaseUrl };
189
+ result.appFork = { forkId: appFork.forkId, baseUrl: appFork.baseUrl, port: appFork.port, forkTimeMs: appFork.forkTimeMs };
190
+ }
191
+
192
+ const driverOpts = { poolDriver: config.poolDriver || 'auto', maxSessions: config.maxSessions || 10 };
193
+ const chosenPool = await selectPool(getPoolUrls(config), 2000, 60000, driverOpts);
194
+ result.poolUrl = chosenPool;
195
+ result.poolDriver = getCachedDriver(chosenPool);
196
+ const poolLabel = chosenPool.replace('ws://', '').replace('wss://', '');
197
+ const isMultiPool = getPoolUrls(config).length > 1;
198
+ if (isMultiPool) {
199
+ log('🔗', `${C.cyan}${test.name}${C.reset} ${C.dim}→ ${poolLabel}${C.reset}`);
200
+ }
201
+ progressFn({ event: 'test:pool', name: test.name, poolUrl: chosenPool });
202
+ browser = await connectToPool(chosenPool, config.connectRetries, config.connectRetryDelay);
109
203
  // Use incognito context for cookie isolation between concurrent tests
110
204
  context = await browser.createBrowserContext();
111
205
  page = await context.newPage();
112
206
  await page.setViewport(config.viewport);
207
+ tabRegistry.set('default', page);
208
+
209
+ // CDP screencast — streams browser frames as JPEG to the dashboard
210
+ // Only attempt on browserless pools; generic CDP pools (Lightpanda) break on createCDPSession
211
+ const poolDriver = getCachedDriver(chosenPool);
212
+ if (config.screencast && poolDriver !== 'cdp') {
213
+ try {
214
+ const raceTimeout = (promise, ms) => Promise.race([
215
+ promise,
216
+ new Promise((_, reject) => { const t = setTimeout(() => reject(new Error('CDP timeout')), ms); t.unref(); }),
217
+ ]);
218
+ cdpSession = await raceTimeout(page.createCDPSession(), 5000);
219
+ let frameCount = 0;
220
+ const everyNth = config.screencastEveryNthFrame || 1;
221
+ cdpSession.on('Page.screencastFrame', (frame) => {
222
+ frameCount++;
223
+ cdpSession.send('Page.screencastFrameAck', { sessionId: frame.sessionId }).catch(() => {});
224
+ if (everyNth > 1 && frameCount % everyNth !== 0) return;
225
+ progressFn({
226
+ event: 'test:frame',
227
+ name: test.name,
228
+ data: frame.data,
229
+ metadata: frame.metadata,
230
+ });
231
+ });
232
+ await raceTimeout(cdpSession.send('Page.startScreencast', {
233
+ format: 'jpeg',
234
+ quality: config.screencastQuality || 60,
235
+ maxWidth: config.screencastMaxWidth || 800,
236
+ maxHeight: config.screencastMaxHeight || 600,
237
+ everyNthFrame: 1,
238
+ }), 5000);
239
+ } catch {
240
+ cdpSession = null;
241
+ }
242
+ }
113
243
 
114
244
  page.on('console', (msg) => {
115
245
  result.consoleLogs.push({ type: msg.type(), text: msg.text() });
116
246
  });
117
247
  page.on('requestfailed', (req) => {
118
- result.networkErrors.push({ url: req.url(), error: req.failure()?.errorText });
248
+ const url = req.url();
249
+ const ignoreDomains = config.networkIgnoreDomains || [];
250
+ if (ignoreDomains.length > 0 && ignoreDomains.some(d => url.includes(d))) return;
251
+ result.networkErrors.push({ url, error: req.failure()?.errorText });
119
252
  });
120
253
 
121
254
  const requestTimings = new Map();
@@ -149,15 +282,42 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
149
282
  }
150
283
  });
151
284
 
285
+ // Auto-inject auth token into localStorage (runs BEFORE beforeEach hooks)
286
+ if (effectiveConfig.authToken) {
287
+ const storageKey = effectiveConfig.authStorageKey || 'accessToken';
288
+ await page.goto(effectiveConfig.baseUrl, { waitUntil: 'domcontentloaded', timeout: 15000 });
289
+ await page.evaluate((key, token) => {
290
+ localStorage.setItem(key, token);
291
+ }, storageKey, config.authToken);
292
+ }
293
+
294
+ // Resolve {{var.X}} and {{env.X}} in test actions and hooks
295
+ const vars = loadVarsForTest(config, config._suiteName);
296
+ if (Object.keys(vars).length > 0 || /\{\{(var|env)\./.test(JSON.stringify(test.actions))) {
297
+ test = { ...test, actions: resolveVarsInActions(test.actions, vars) };
298
+ if (hooks.beforeEach?.length) hooks = { ...hooks, beforeEach: resolveVarsInActions(hooks.beforeEach, vars) };
299
+ if (hooks.afterEach?.length) hooks = { ...hooks, afterEach: resolveVarsInActions(hooks.afterEach, vars) };
300
+ }
301
+
152
302
  // Run beforeEach hook
153
303
  if (hooks.beforeEach?.length) {
154
- await executeHookActions(page, hooks.beforeEach, config);
304
+ await executeHookActions(page, hooks.beforeEach, effectiveConfig);
305
+ }
306
+
307
+ // Auto-capture baseline screenshot if test has "expect" (BEFORE actions)
308
+ if (test.expect && page) {
309
+ try {
310
+ const safeName = test.name.replace(/[^a-zA-Z0-9_\-. ]/g, '_');
311
+ const baselinePath = path.join(effectiveConfig.screenshotsDir, `baseline-${safeName}-${Date.now()}.png`);
312
+ await page.screenshot({ path: baselinePath, fullPage: true });
313
+ result.baselineScreenshot = baselinePath;
314
+ } catch { /* page may not be ready */ }
155
315
  }
156
316
 
157
317
  for (let i = 0; i < test.actions.length; i++) {
158
318
  const action = test.actions[i];
159
- const maxActionRetries = action.retries ?? config.actionRetries ?? 0;
160
- const actionRetryDelay = config.actionRetryDelay ?? 500;
319
+ const maxActionRetries = action.retries ?? effectiveConfig.actionRetries ?? 0;
320
+ const actionRetryDelay = effectiveConfig.actionRetryDelay ?? 500;
161
321
  let lastError = null;
162
322
 
163
323
  for (let attempt = 0; attempt <= maxActionRetries; attempt++) {
@@ -171,8 +331,113 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
171
331
  throw new Error(`assert_no_network_errors failed: ${result.networkErrors.length} error(s): ${summary}`);
172
332
  }
173
333
  actionResult = null;
334
+
335
+ // ── Multi-tab actions (intercepted here, not in actions.js) ──────
336
+ } else if (action.type === 'open_tab') {
337
+ const label = action.text || `tab-${tabRegistry.size}`;
338
+ const newPage = await context.newPage();
339
+ await newPage.setViewport(config.viewport);
340
+ tabRegistry.set(label, newPage);
341
+ activeTabLabel = label;
342
+ page = newPage;
343
+ // Navigate inside the new tab
344
+ actionResult = await executeAction(page, action, effectiveConfig);
345
+
346
+ } else if (action.type === 'switch_tab') {
347
+ // value: label, title regex, URL substring, or numeric index
348
+ const target = action.value;
349
+ let found = false;
350
+
351
+ // 1. By label (exact match)
352
+ if (tabRegistry.has(target)) {
353
+ page = tabRegistry.get(target);
354
+ activeTabLabel = target;
355
+ found = true;
356
+ }
357
+
358
+ // 2. By numeric index
359
+ if (!found && /^\d+$/.test(target)) {
360
+ const idx = parseInt(target);
361
+ const labels = [...tabRegistry.keys()];
362
+ if (idx >= 0 && idx < labels.length) {
363
+ activeTabLabel = labels[idx];
364
+ page = tabRegistry.get(activeTabLabel);
365
+ found = true;
366
+ }
367
+ }
368
+
369
+ // 3. By title or URL match (substring or regex)
370
+ if (!found) {
371
+ for (const [label, p] of tabRegistry) {
372
+ try {
373
+ const title = await p.title();
374
+ const url = p.url();
375
+ const regex = new RegExp(target, 'i');
376
+ if (regex.test(title) || regex.test(url) || url.includes(target)) {
377
+ page = p;
378
+ activeTabLabel = label;
379
+ found = true;
380
+ break;
381
+ }
382
+ } catch { /* page may be closed */ }
383
+ }
384
+ }
385
+
386
+ if (!found) {
387
+ throw new Error(`switch_tab failed: no tab matching "${target}" (labels: ${[...tabRegistry.keys()].join(', ')})`);
388
+ }
389
+ // Bring tab to front
390
+ await page.bringToFront();
391
+ actionResult = null;
392
+
393
+ } else if (action.type === 'close_tab') {
394
+ const targetLabel = action.value || activeTabLabel;
395
+ if (targetLabel === 'default' && tabRegistry.size > 1) {
396
+ throw new Error('close_tab: cannot close the default tab while other tabs are open');
397
+ }
398
+ const targetPage = tabRegistry.get(targetLabel);
399
+ if (!targetPage) {
400
+ throw new Error(`close_tab failed: no tab with label "${targetLabel}"`);
401
+ }
402
+ tabRegistry.delete(targetLabel);
403
+ if (!targetPage.isClosed()) {
404
+ await targetPage.close();
405
+ }
406
+ // Switch to the last remaining tab
407
+ if (activeTabLabel === targetLabel) {
408
+ const remaining = [...tabRegistry.keys()];
409
+ activeTabLabel = remaining[remaining.length - 1] || 'default';
410
+ page = tabRegistry.get(activeTabLabel);
411
+ if (page) await page.bringToFront();
412
+ }
413
+ actionResult = null;
414
+
415
+ } else if (action.type === 'assert_tab_count') {
416
+ action.__tabCount = tabRegistry.size;
417
+ actionResult = await executeAction(page, action, effectiveConfig);
418
+
419
+ } else if (action.type === 'wait_for_tab') {
420
+ // Wait for a new tab/popup to be opened (e.g. by window.open, target=_blank)
421
+ const label = action.text || `tab-${tabRegistry.size}`;
422
+ const waitTimeout = action.timeout || config.defaultTimeout || 10000;
423
+ const newTarget = await new Promise((resolve, reject) => {
424
+ const timer = setTimeout(() => reject(new Error(`wait_for_tab: no new tab appeared after ${waitTimeout}ms`)), waitTimeout);
425
+ context.once('targetcreated', (target) => {
426
+ clearTimeout(timer);
427
+ resolve(target);
428
+ });
429
+ });
430
+ const newPage = await newTarget.page();
431
+ if (newPage) {
432
+ await newPage.setViewport(config.viewport);
433
+ tabRegistry.set(label, newPage);
434
+ activeTabLabel = label;
435
+ page = newPage;
436
+ }
437
+ actionResult = null;
438
+
174
439
  } else {
175
- actionResult = await executeAction(page, action, config);
440
+ actionResult = await executeAction(page, action, effectiveConfig);
176
441
  }
177
442
  const actionDuration = Date.now() - actionStart;
178
443
  const actionEntry = {
@@ -221,15 +486,40 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
221
486
  result.expect = test.expect;
222
487
  try {
223
488
  const safeName = test.name.replace(/[^a-zA-Z0-9_\-. ]/g, '_');
224
- const verifyPath = path.join(config.screenshotsDir, `verify-${safeName}-${Date.now()}.png`);
489
+ const verifyPath = path.join(effectiveConfig.screenshotsDir, `verify-${safeName}-${Date.now()}.png`);
225
490
  await page.screenshot({ path: verifyPath, fullPage: true });
226
491
  result.verificationScreenshot = verifyPath;
492
+
493
+ // Auto visual comparison: compare baseline vs verification screenshot
494
+ if (result.baselineScreenshot && result.verificationScreenshot) {
495
+ try {
496
+ const diffPath = path.join(effectiveConfig.screenshotsDir, `diff-${safeName}-${Date.now()}.png`);
497
+ const threshold = effectiveConfig.verificationThreshold ?? 0.02;
498
+ const visualResult = compareImages(result.baselineScreenshot, result.verificationScreenshot, {
499
+ threshold: 0.1,
500
+ diffOutputPath: diffPath,
501
+ maskRegions: test.expect?.maskRegions || [],
502
+ });
503
+ result.visualDiff = {
504
+ diffPercentage: visualResult.diffPercentage,
505
+ differentPixels: visualResult.differentPixels,
506
+ totalPixels: visualResult.totalPixels,
507
+ matchPercentage: visualResult.matchPercentage,
508
+ diffImagePath: visualResult.diffImagePath,
509
+ threshold,
510
+ passed: visualResult.diffPercentage <= threshold,
511
+ };
512
+ if (result.visualDiff.diffImagePath) {
513
+ result.diffScreenshot = result.visualDiff.diffImagePath;
514
+ }
515
+ } catch { /* visual diff is best-effort, never blocks the test */ }
516
+ }
227
517
  } catch { /* page may be dead */ }
228
518
  }
229
519
 
230
520
  // Run afterEach hook (success path)
231
521
  if (hooks.afterEach?.length) {
232
- await executeHookActions(page, hooks.afterEach, config);
522
+ await executeHookActions(page, hooks.afterEach, effectiveConfig);
233
523
  }
234
524
  } catch (error) {
235
525
  result.success = false;
@@ -237,7 +527,7 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
237
527
 
238
528
  // Run afterEach hook (failure path)
239
529
  if (page && hooks.afterEach?.length) {
240
- try { await executeHookActions(page, hooks.afterEach, config); } catch { /* */ }
530
+ try { await executeHookActions(page, hooks.afterEach, effectiveConfig); } catch { /* */ }
241
531
  }
242
532
 
243
533
  if (page) {
@@ -249,6 +539,11 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
249
539
  } catch { /* page may be dead */ }
250
540
  }
251
541
  } finally {
542
+ // Stop screencast before disconnecting
543
+ if (cdpSession) {
544
+ try { await cdpSession.send('Page.stopScreencast'); } catch { /* */ }
545
+ try { await cdpSession.detach(); } catch { /* */ }
546
+ }
252
547
  // Flush pending response body reads before disconnecting
253
548
  if (pendingBodies.length > 0) {
254
549
  try { await Promise.allSettled(pendingBodies); } catch { /* */ }
@@ -261,16 +556,77 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
261
556
  try { await context.close(); } catch { /* */ }
262
557
  }
263
558
  if (browser) {
264
- try { browser.disconnect(); } catch { /* */ }
559
+ try { await disconnectFromPool(browser, result.poolUrl); } catch { /* */ }
560
+ }
561
+ // Release local pending counter so selectPool() knows this slot is free
562
+ if (result.poolUrl) {
563
+ releasePending(result.poolUrl);
564
+ }
565
+ // Destroy the app fork after the test completes
566
+ if (appFork) {
567
+ try { await destroyFork(appFork.forkId); } catch { /* best effort */ }
265
568
  }
266
569
  }
267
570
 
268
571
  return result;
269
572
  }
270
573
 
574
+ /**
575
+ * Majority voting — runs a test N times in parallel and uses majority vote for pass/fail.
576
+ * If majority passes but not unanimously, marks as flaky.
577
+ */
578
+ async function runTestWithVoting(test, config, hooks, votingCount, testTimeout, progressFn) {
579
+ const votes = [];
580
+ const promises = [];
581
+
582
+ for (let v = 0; v < votingCount; v++) {
583
+ const timeoutPromise = new Promise((_, reject) => {
584
+ const timer = setTimeout(() => reject(new Error(`Test timed out after ${testTimeout}ms`)), testTimeout);
585
+ timer.unref();
586
+ });
587
+ promises.push(
588
+ Promise.race([runTest(test, config, hooks, progressFn), timeoutPromise])
589
+ .catch(error => ({
590
+ name: test.name,
591
+ startTime: new Date().toISOString(),
592
+ endTime: new Date().toISOString(),
593
+ actions: [],
594
+ success: false,
595
+ error: error.message,
596
+ consoleLogs: [],
597
+ networkErrors: [],
598
+ networkLogs: [],
599
+ }))
600
+ );
601
+ }
602
+
603
+ const results = await Promise.all(promises);
604
+ const passCount = results.filter(r => r.success).length;
605
+ const majorityPassed = passCount > votingCount / 2;
606
+
607
+ // Pick the representative result: a passing one if majority passed, failing one otherwise
608
+ const representative = majorityPassed
609
+ ? results.find(r => r.success) || results[0]
610
+ : results.find(r => !r.success) || results[0];
611
+
612
+ const result = { ...representative };
613
+ result.success = majorityPassed;
614
+ result.voting = { total: votingCount, passed: passCount, failed: votingCount - passCount };
615
+ result.attempt = 1;
616
+ result.maxAttempts = 1;
617
+
618
+ // Non-unanimous pass = flaky
619
+ if (majorityPassed && passCount < votingCount) {
620
+ result.flaky = true;
621
+ }
622
+
623
+ return result;
624
+ }
625
+
271
626
  /** Runs tests in parallel with limited concurrency, retries, timeouts, and hooks */
272
627
  export async function runTestsParallel(tests, config, suiteHooks = {}) {
273
628
  const hooks = mergeHooks(config.hooks, suiteHooks);
629
+ const driverOpts = { poolDriver: config.poolDriver || 'auto', maxSessions: config.maxSessions || 10 };
274
630
 
275
631
  // Run beforeAll hook
276
632
  if (hooks.beforeAll?.length) {
@@ -283,7 +639,8 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
283
639
  log('🪝', `${C.dim}Running beforeAll hook...${C.reset}`);
284
640
  let browser = null;
285
641
  try {
286
- browser = await connectToPool(config.poolUrl, config.connectRetries, config.connectRetryDelay);
642
+ const hookPool = await selectPool(getPoolUrls(config), 2000, 60000, driverOpts);
643
+ browser = await connectToPool(hookPool, config.connectRetries, config.connectRetryDelay);
287
644
  const page = await browser.newPage();
288
645
  await page.setViewport(config.viewport);
289
646
  await executeHookActions(page, hooks.beforeAll, config);
@@ -292,7 +649,42 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
292
649
  log('❌', `${C.red}beforeAll hook failed: ${error.message}${C.reset}`);
293
650
  throw error;
294
651
  } finally {
295
- if (browser) try { browser.disconnect(); } catch { /* */ }
652
+ if (browser) try { await disconnectFromPool(browser, hookPool); } catch { /* */ }
653
+ }
654
+ }
655
+
656
+ // Auto-login: fetch auth token via API if configured and not already provided
657
+ if (config.authLoginEndpoint && !config.authToken && config.authCredentials) {
658
+ log('🔑', `${C.dim}Fetching auth token from ${config.authLoginEndpoint}...${C.reset}`);
659
+ try {
660
+ config.authToken = await fetchAuthToken(
661
+ config.authLoginEndpoint,
662
+ config.authCredentials,
663
+ config.authTokenPath || 'token'
664
+ );
665
+ log('✅', `${C.dim}Auth token acquired (${config.authToken.length} chars)${C.reset}`);
666
+ } catch (error) {
667
+ // Docker-internal hostname (nginx, api, etc.) → retry with localhost from host machine
668
+ if (error.message && error.message.includes('ENOTFOUND')) {
669
+ const url = new URL(config.authLoginEndpoint);
670
+ if (!url.hostname.includes('.')) {
671
+ const localhostUrl = `http://localhost${url.port && url.port !== '80' ? ':' + url.port : ''}${url.pathname}${url.search}`;
672
+ log('🔄', `${C.dim}Docker hostname "${url.hostname}" not reachable from host, retrying with ${localhostUrl}...${C.reset}`);
673
+ try {
674
+ config.authToken = await fetchAuthToken(localhostUrl, config.authCredentials, config.authTokenPath || 'token');
675
+ log('✅', `${C.dim}Auth token acquired via localhost fallback (${config.authToken.length} chars)${C.reset}`);
676
+ } catch (retryErr) {
677
+ log('❌', `${C.red}Auth auto-login failed (localhost fallback): ${retryErr.message}${C.reset}`);
678
+ throw retryErr;
679
+ }
680
+ } else {
681
+ log('❌', `${C.red}Auth auto-login failed: ${error.message}${C.reset}`);
682
+ throw error;
683
+ }
684
+ } else {
685
+ log('❌', `${C.red}Auth auto-login failed: ${error.message}${C.reset}`);
686
+ throw error;
687
+ }
296
688
  }
297
689
  }
298
690
 
@@ -323,54 +715,65 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
323
715
  log('▶▶▶', `${C.cyan}${test.name}${C.reset} ${C.dim}(${activeCount} active)${C.reset}`);
324
716
  _progress({ event: 'test:start', name: test.name, serial: test.serial || false, activeCount, queueRemaining: queue.length });
325
717
 
326
- const maxAttempts = (test.retries ?? config.retries ?? 0) + 1;
327
718
  const testTimeout = test.timeout ?? config.testTimeout ?? 60000;
719
+ const votingCount = test.voting ?? config.voting ?? 0;
720
+ const testHooks = test._suiteHooks ? mergeHooks(config.hooks, test._suiteHooks) : hooks;
328
721
  let result;
329
722
 
330
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
331
- const timeoutPromise = new Promise((_, reject) => {
332
- const timer = setTimeout(() => reject(new Error(`Test timed out after ${testTimeout}ms`)), testTimeout);
333
- timer.unref();
334
- });
335
-
336
- try {
337
- const testHooks = test._suiteHooks ? mergeHooks(config.hooks, test._suiteHooks) : hooks;
338
- result = await Promise.race([runTest(test, config, testHooks, _progress), timeoutPromise]);
339
- } catch (error) {
340
- result = {
341
- name: test.name,
342
- startTime: new Date().toISOString(),
343
- endTime: new Date().toISOString(),
344
- actions: [],
345
- success: false,
346
- error: error.message,
347
- consoleLogs: [],
348
- networkErrors: [],
349
- networkLogs: [],
350
- };
351
- }
723
+ if (votingCount > 1) {
724
+ // Majority voting: run N times in parallel, majority wins
725
+ log('🗳️', `${C.dim}${test.name}: voting ${votingCount}x in parallel${C.reset}`);
726
+ result = await runTestWithVoting(test, config, testHooks, votingCount, testTimeout, _progress);
727
+ } else {
728
+ // Standard sequential retry
729
+ const maxAttempts = (test.retries ?? config.retries ?? 0) + 1;
730
+
731
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
732
+ const timeoutPromise = new Promise((_, reject) => {
733
+ const timer = setTimeout(() => reject(new Error(`Test timed out after ${testTimeout}ms`)), testTimeout);
734
+ timer.unref();
735
+ });
736
+
737
+ try {
738
+ result = await Promise.race([runTest(test, config, testHooks, _progress), timeoutPromise]);
739
+ } catch (error) {
740
+ result = {
741
+ name: test.name,
742
+ startTime: new Date().toISOString(),
743
+ endTime: new Date().toISOString(),
744
+ actions: [],
745
+ success: false,
746
+ error: error.message,
747
+ consoleLogs: [],
748
+ networkErrors: [],
749
+ networkLogs: [],
750
+ };
751
+ }
352
752
 
353
- result.attempt = attempt;
354
- result.maxAttempts = maxAttempts;
753
+ result.attempt = attempt;
754
+ result.maxAttempts = maxAttempts;
355
755
 
356
- if (result.success || attempt === maxAttempts) break;
357
- log('🔄', `${C.yellow}${test.name}${C.reset} failed, retrying (${attempt}/${maxAttempts})...`);
358
- _progress({ event: 'test:retry', name: test.name, attempt, maxAttempts });
359
- await sleep(config.retryDelay || 1000);
756
+ if (result.success || attempt === maxAttempts) break;
757
+ log('🔄', `${C.yellow}${test.name}${C.reset} failed, retrying (${attempt}/${maxAttempts})...`);
758
+ _progress({ event: 'test:retry', name: test.name, attempt, maxAttempts });
759
+ await sleep(config.retryDelay || 1000);
760
+ }
360
761
  }
361
762
 
362
763
  results.push(result);
363
764
  activeCount--;
364
765
 
365
766
  const screenshots = result.actions.filter(a => a.result?.screenshot).map(a => a.result.screenshot);
366
- _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 });
767
+ _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 });
367
768
 
368
769
  if (result.success) {
369
- const flaky = result.attempt > 1 ? ` ${C.yellow}(flaky, passed on attempt ${result.attempt}/${result.maxAttempts})${C.reset}` : '';
370
- log('✅', `${C.green}${test.name}${C.reset} ${C.dim}(${timeDiff(result.startTime, result.endTime)})${C.reset}${flaky}`);
770
+ const votingInfo = result.voting ? ` ${C.yellow}(voting: ${result.voting.passed}/${result.voting.total} passed${result.flaky ? ', flaky' : ''})${C.reset}` : '';
771
+ const retryInfo = !result.voting && result.attempt > 1 ? ` ${C.yellow}(flaky, passed on attempt ${result.attempt}/${result.maxAttempts})${C.reset}` : '';
772
+ log('✅', `${C.green}${test.name}${C.reset} ${C.dim}(${timeDiff(result.startTime, result.endTime)})${C.reset}${votingInfo}${retryInfo}`);
371
773
  } else {
372
- const attempts = result.maxAttempts > 1 ? ` (${result.maxAttempts} attempts)` : '';
373
- log('❌', `${C.red}${test.name}${C.reset}: ${result.error}${attempts}`);
774
+ const votingInfo = result.voting ? ` (voting: ${result.voting.passed}/${result.voting.total} passed)` : '';
775
+ const attempts = !result.voting && result.maxAttempts > 1 ? ` (${result.maxAttempts} attempts)` : '';
776
+ log('❌', `${C.red}${test.name}${C.reset}: ${result.error}${votingInfo}${attempts}`);
374
777
  }
375
778
 
376
779
  const consoleIssues = result.consoleLogs?.filter(l => l.type === 'error' || l.type === 'warning').length || 0;
@@ -401,7 +804,8 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
401
804
  log('🪝', `${C.dim}Running afterAll hook...${C.reset}`);
402
805
  let browser = null;
403
806
  try {
404
- browser = await connectToPool(config.poolUrl, config.connectRetries, config.connectRetryDelay);
807
+ const hookPool = await selectPool(getPoolUrls(config), 2000, 60000, driverOpts);
808
+ browser = await connectToPool(hookPool, config.connectRetries, config.connectRetryDelay);
405
809
  const page = await browser.newPage();
406
810
  await page.setViewport(config.viewport);
407
811
  await executeHookActions(page, hooks.afterAll, config);
@@ -409,7 +813,7 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
409
813
  } catch (error) {
410
814
  log('⚠️', `${C.yellow}afterAll hook failed: ${error.message}${C.reset}`);
411
815
  } finally {
412
- if (browser) try { browser.disconnect(); } catch { /* */ }
816
+ if (browser) try { await disconnectFromPool(browser, hookPool); } catch { /* */ }
413
817
  }
414
818
  }
415
819
 
@@ -429,7 +833,9 @@ export function loadTestFile(filePath, modulesDir) {
429
833
  }
430
834
  const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
431
835
  const normalized = normalizeTestData(data);
432
- return modulesDir ? resolveTestData(normalized, modulesDir) : normalized;
836
+ const resolved = modulesDir ? resolveTestData(normalized, modulesDir) : normalized;
837
+ validateActionTypes(resolved, path.basename(filePath));
838
+ return resolved;
433
839
  }
434
840
 
435
841
  /** Loads a test suite by name — returns { tests, hooks } */
@@ -446,7 +852,9 @@ export function loadTestSuite(suiteName, testsDir, modulesDir) {
446
852
 
447
853
  const data = JSON.parse(fs.readFileSync(path.join(testsDir, match), 'utf-8'));
448
854
  const normalized = normalizeTestData(data);
449
- return modulesDir ? resolveTestData(normalized, modulesDir) : normalized;
855
+ const resolved = modulesDir ? resolveTestData(normalized, modulesDir) : normalized;
856
+ validateActionTypes(resolved, match);
857
+ return resolved;
450
858
  }
451
859
 
452
860
  /** Loads all test suites from the tests directory — returns { tests, hooks } */
@@ -466,6 +874,7 @@ export function loadAllSuites(testsDir, modulesDir, exclude = []) {
466
874
  if (modulesDir) {
467
875
  ({ tests, hooks } = resolveTestData({ tests, hooks }, modulesDir));
468
876
  }
877
+ validateActionTypes({ tests, hooks }, file);
469
878
  // Tag each test with its own suite's hooks so they're preserved
470
879
  for (const t of tests) {
471
880
  t._suiteHooks = hooks;