@matware/e2e-runner 1.3.0 → 1.5.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 (56) hide show
  1. package/.claude-plugin/marketplace.json +37 -6
  2. package/.claude-plugin/plugin.json +17 -3
  3. package/LICENSE +190 -0
  4. package/README.md +151 -527
  5. package/agents/test-creator.md +4 -2
  6. package/agents/test-improver.md +5 -3
  7. package/bin/cli.js +84 -20
  8. package/commands/capture.md +45 -0
  9. package/package.json +3 -2
  10. package/skills/e2e-testing/SKILL.md +3 -2
  11. package/skills/e2e-testing/references/action-types.md +22 -4
  12. package/skills/e2e-testing/references/test-json-format.md +23 -0
  13. package/src/actions.js +321 -14
  14. package/src/ai-generate.js +81 -0
  15. package/src/app-pool.js +339 -0
  16. package/src/config.js +131 -7
  17. package/src/dashboard.js +209 -11
  18. package/src/db.js +74 -7
  19. package/src/index.js +6 -4
  20. package/src/learner-sqlite.js +154 -0
  21. package/src/learner.js +70 -3
  22. package/src/mcp-tools.js +259 -34
  23. package/src/module-analysis.js +247 -0
  24. package/src/module-resolver.js +35 -2
  25. package/src/narrate.js +42 -1
  26. package/src/pool-manager.js +68 -17
  27. package/src/pool.js +464 -37
  28. package/src/reporter.js +4 -1
  29. package/src/runner.js +410 -63
  30. package/src/visual-diff.js +515 -0
  31. package/src/websocket.js +14 -3
  32. package/src/wizard.js +184 -0
  33. package/templates/build-dashboard.js +3 -0
  34. package/templates/dashboard/js/api.js +62 -3
  35. package/templates/dashboard/js/init.js +46 -0
  36. package/templates/dashboard/js/keyboard.js +8 -7
  37. package/templates/dashboard/js/quicksearch.js +277 -0
  38. package/templates/dashboard/js/state.js +61 -7
  39. package/templates/dashboard/js/toast.js +1 -1
  40. package/templates/dashboard/js/utils.js +20 -0
  41. package/templates/dashboard/js/view-live.js +240 -9
  42. package/templates/dashboard/js/view-runs.js +540 -94
  43. package/templates/dashboard/js/view-tests.js +157 -16
  44. package/templates/dashboard/js/view-tools.js +234 -0
  45. package/templates/dashboard/js/view-watch.js +2 -2
  46. package/templates/dashboard/js/websocket.js +36 -0
  47. package/templates/dashboard/styles/base.css +489 -53
  48. package/templates/dashboard/styles/components.css +719 -77
  49. package/templates/dashboard/styles/view-live.css +463 -59
  50. package/templates/dashboard/styles/view-runs.css +793 -155
  51. package/templates/dashboard/styles/view-tests.css +440 -77
  52. package/templates/dashboard/styles/view-tools.css +206 -0
  53. package/templates/dashboard/styles/view-watch.css +198 -41
  54. package/templates/dashboard/template.html +369 -56
  55. package/templates/dashboard.html +5375 -901
  56. package/templates/docker-compose-lightpanda.yml +7 -0
package/src/runner.js CHANGED
@@ -9,18 +9,53 @@ import fs from 'fs';
9
9
  import path from 'path';
10
10
  import http from 'http';
11
11
  import https from 'https';
12
- import { connectToPool } from './pool.js';
13
- import { getPoolUrls, selectPool, releasePending } from './pool-manager.js';
14
- import { executeAction } from './actions.js';
12
+ import { connectToPool, getCachedDriver, disconnectFromPool } from './pool.js';
13
+ import { getPoolUrls, selectPool, releasePending, resolvePoolsForTest } from './pool-manager.js';
14
+ import { forkAppInstance, destroyFork, isAppPoolEnabled } from './app-pool.js';
15
+ import { executeAction, pageHasRenderableContent, looksLikeBlankCapture } from './actions.js';
15
16
  import { narrateAction } from './narrate.js';
16
17
  import { log, colors as C } from './logger.js';
17
18
  import { resolveTestData, validateActionTypes } from './module-resolver.js';
19
+ import { compareImages } from './visual-diff.js';
18
20
  import { ensureProject, getVariables } from './db.js';
19
21
 
20
22
  function sleep(ms) {
21
23
  return new Promise(resolve => setTimeout(resolve, ms));
22
24
  }
23
25
 
26
+ /**
27
+ * Best-effort step thumbnail for the storyline view.
28
+ * Captures once in memory, writes to disk AND returns base64 so callers
29
+ * can stream the same frame through the live preview WebSocket.
30
+ * Skips silently on any error so it never breaks a test run.
31
+ */
32
+ const NO_AUTO_CAPTURE_TYPES = new Set(['screenshot', 'close_tab']);
33
+ async function tryAutoCaptureStep(page, action, idx, testName, effectiveConfig, alreadyCaptured) {
34
+ if (!effectiveConfig.autoCaptureSteps) return null;
35
+ if (NO_AUTO_CAPTURE_TYPES.has(action?.type)) return null;
36
+ if (alreadyCaptured) return null;
37
+ if (!page || (typeof page.isClosed === 'function' && page.isClosed())) return null;
38
+ // Skip auto-capture when the page can't produce a meaningful image —
39
+ // about:blank or fully empty DOM — to stop blank step-*.jpg flooding.
40
+ if (!(await pageHasRenderableContent(page))) return null;
41
+ try {
42
+ const safeName = String(testName).replace(/[^a-zA-Z0-9_\-. ]/g, '_');
43
+ const filename = `step-${safeName}-${String(idx).padStart(3, '0')}-${Date.now()}.jpg`;
44
+ const filepath = path.join(effectiveConfig.screenshotsDir, filename);
45
+ const buf = await page.screenshot({
46
+ type: 'jpeg',
47
+ quality: effectiveConfig.autoCaptureQuality ?? 60,
48
+ fullPage: false,
49
+ encoding: 'binary',
50
+ });
51
+ if (looksLikeBlankCapture(buf, 'jpeg')) return null;
52
+ fs.writeFileSync(filepath, buf);
53
+ return { path: filepath, base64: buf.toString('base64') };
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+
24
59
  /** Simple glob matching with * wildcards for exclude patterns. */
25
60
  function matchesExclude(filename, excludePatterns) {
26
61
  if (!excludePatterns?.length) return false;
@@ -115,7 +150,7 @@ function getByPath(obj, dotPath) {
115
150
  }
116
151
 
117
152
  /** Fetches an auth token by POSTing credentials to a login endpoint. */
118
- function fetchAuthToken(endpoint, credentials, tokenPath) {
153
+ export function fetchAuthToken(endpoint, credentials, tokenPath) {
119
154
  return new Promise((resolve, reject) => {
120
155
  const url = new URL(endpoint);
121
156
  const transport = url.protocol === 'https:' ? https : http;
@@ -123,7 +158,7 @@ function fetchAuthToken(endpoint, credentials, tokenPath) {
123
158
 
124
159
  const req = transport.request(url, {
125
160
  method: 'POST',
126
- headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
161
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body), 'Accept': '*/*', 'User-Agent': '@matware/e2e-runner' },
127
162
  timeout: 15000,
128
163
  }, (res) => {
129
164
  let data = '';
@@ -155,6 +190,14 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
155
190
  let browser = null;
156
191
  let context = null;
157
192
  let page = null;
193
+ let cdpSession = null;
194
+ let appFork = null;
195
+
196
+ // ── Multi-tab registry ────────────────────────────────────────────────────
197
+ // Maps label → page. The "default" label is the initial page.
198
+ // activePage tracks the current tab; page always points to it.
199
+ const tabRegistry = new Map();
200
+ let activeTabLabel = 'default';
158
201
 
159
202
  const result = {
160
203
  name: test.name,
@@ -169,8 +212,35 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
169
212
  const pendingBodies = [];
170
213
 
171
214
  try {
172
- const chosenPool = await selectPool(getPoolUrls(config));
215
+ // Fork an isolated app instance if app pool is enabled
216
+ let effectiveConfig = config;
217
+ if (isAppPoolEnabled(config)) {
218
+ appFork = await forkAppInstance(config, test.name);
219
+ // Override baseUrl to point to this test's isolated app instance
220
+ // Use dockerBaseUrl when Chrome runs inside Docker (default setup)
221
+ effectiveConfig = { ...config, baseUrl: appFork.dockerBaseUrl };
222
+ result.appFork = { forkId: appFork.forkId, baseUrl: appFork.baseUrl, port: appFork.port, forkTimeMs: appFork.forkTimeMs };
223
+ }
224
+
225
+ const driverOpts = { poolDriver: config.poolDriver || 'auto', maxSessions: config.maxSessions || 10 };
226
+
227
+ // CLI override (--driver / --fallback-driver) wins over per-test fields.
228
+ const requestedDriver = config.cliDriverOverride || test.driver || null;
229
+ const requestedFallback = config.cliFallbackDriverOverride || test.fallbackDriver || null;
230
+
231
+ let candidatePoolUrls = getPoolUrls(config);
232
+ let driverChoice = null;
233
+ if (requestedDriver) {
234
+ const resolved = await resolvePoolsForTest(candidatePoolUrls, requestedDriver, requestedFallback, driverOpts);
235
+ candidatePoolUrls = resolved.urls;
236
+ driverChoice = { requested: requestedDriver, used: resolved.driver, usedFallback: resolved.usedFallback };
237
+ log('🎯', `${C.dim}${test.name}: driver=${resolved.driver}${resolved.usedFallback ? ' (fallback)' : ''}${C.reset}`);
238
+ }
239
+
240
+ const chosenPool = await selectPool(candidatePoolUrls, 2000, 60000, driverOpts);
173
241
  result.poolUrl = chosenPool;
242
+ result.poolDriver = getCachedDriver(chosenPool);
243
+ if (driverChoice) result.driverChoice = driverChoice;
174
244
  const poolLabel = chosenPool.replace('ws://', '').replace('wss://', '');
175
245
  const isMultiPool = getPoolUrls(config).length > 1;
176
246
  if (isMultiPool) {
@@ -182,6 +252,46 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
182
252
  context = await browser.createBrowserContext();
183
253
  page = await context.newPage();
184
254
  await page.setViewport(config.viewport);
255
+ tabRegistry.set('default', page);
256
+
257
+ // CDP screencast — streams browser frames as JPEG to the dashboard
258
+ // Only attempt on browserless pools; generic CDP pools (Lightpanda) break on createCDPSession
259
+ const poolDriver = getCachedDriver(chosenPool);
260
+ if (config.screencast && poolDriver !== 'cdp') {
261
+ try {
262
+ const raceTimeout = (promise, ms) => Promise.race([
263
+ promise,
264
+ new Promise((_, reject) => { const t = setTimeout(() => reject(new Error('CDP timeout')), ms); t.unref(); }),
265
+ ]);
266
+ cdpSession = await raceTimeout(page.createCDPSession(), 5000);
267
+ let frameCount = 0;
268
+ const everyNth = config.screencastEveryNthFrame || 1;
269
+ cdpSession.on('Page.screencastFrame', (frame) => {
270
+ frameCount++;
271
+ cdpSession.send('Page.screencastFrameAck', { sessionId: frame.sessionId }).catch(() => {});
272
+ if (everyNth > 1 && frameCount % everyNth !== 0) return;
273
+ progressFn({
274
+ event: 'test:frame',
275
+ name: test.name,
276
+ data: frame.data,
277
+ metadata: frame.metadata,
278
+ });
279
+ });
280
+ await raceTimeout(cdpSession.send('Page.startScreencast', {
281
+ format: 'jpeg',
282
+ quality: config.screencastQuality || 60,
283
+ maxWidth: config.screencastMaxWidth || 800,
284
+ maxHeight: config.screencastMaxHeight || 600,
285
+ everyNthFrame: 1,
286
+ }), 5000);
287
+ log('📹', `${C.dim}screencast started for ${test.name} (driver=${poolDriver})${C.reset}`);
288
+ } catch (err) {
289
+ log('⚠️', `${C.amber}screencast failed for ${test.name}: ${err.message} (driver=${poolDriver})${C.reset}`);
290
+ cdpSession = null;
291
+ }
292
+ } else if (config.screencast && poolDriver === 'cdp') {
293
+ log('⚠️', `${C.amber}screencast disabled: pool driver is generic CDP (Lightpanda?), not supported${C.reset}`);
294
+ }
185
295
 
186
296
  page.on('console', (msg) => {
187
297
  result.consoleLogs.push({ type: msg.type(), text: msg.text() });
@@ -225,9 +335,9 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
225
335
  });
226
336
 
227
337
  // 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 });
338
+ if (effectiveConfig.authToken) {
339
+ const storageKey = effectiveConfig.authStorageKey || 'accessToken';
340
+ await page.goto(effectiveConfig.baseUrl, { waitUntil: 'domcontentloaded', timeout: 15000 });
231
341
  await page.evaluate((key, token) => {
232
342
  localStorage.setItem(key, token);
233
343
  }, storageKey, config.authToken);
@@ -243,14 +353,14 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
243
353
 
244
354
  // Run beforeEach hook
245
355
  if (hooks.beforeEach?.length) {
246
- await executeHookActions(page, hooks.beforeEach, config);
356
+ await executeHookActions(page, hooks.beforeEach, effectiveConfig);
247
357
  }
248
358
 
249
359
  // Auto-capture baseline screenshot if test has "expect" (BEFORE actions)
250
360
  if (test.expect && page) {
251
361
  try {
252
362
  const safeName = test.name.replace(/[^a-zA-Z0-9_\-. ]/g, '_');
253
- const baselinePath = path.join(config.screenshotsDir, `baseline-${safeName}-${Date.now()}.png`);
363
+ const baselinePath = path.join(effectiveConfig.screenshotsDir, `baseline-${safeName}-${Date.now()}.png`);
254
364
  await page.screenshot({ path: baselinePath, fullPage: true });
255
365
  result.baselineScreenshot = baselinePath;
256
366
  } catch { /* page may not be ready */ }
@@ -258,8 +368,8 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
258
368
 
259
369
  for (let i = 0; i < test.actions.length; i++) {
260
370
  const action = test.actions[i];
261
- const maxActionRetries = action.retries ?? config.actionRetries ?? 0;
262
- const actionRetryDelay = config.actionRetryDelay ?? 500;
371
+ const maxActionRetries = action.retries ?? effectiveConfig.actionRetries ?? 0;
372
+ const actionRetryDelay = effectiveConfig.actionRetryDelay ?? 500;
263
373
  let lastError = null;
264
374
 
265
375
  for (let attempt = 0; attempt <= maxActionRetries; attempt++) {
@@ -273,20 +383,129 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
273
383
  throw new Error(`assert_no_network_errors failed: ${result.networkErrors.length} error(s): ${summary}`);
274
384
  }
275
385
  actionResult = null;
386
+
387
+ // ── Multi-tab actions (intercepted here, not in actions.js) ──────
388
+ } else if (action.type === 'open_tab') {
389
+ const label = action.text || `tab-${tabRegistry.size}`;
390
+ const newPage = await context.newPage();
391
+ await newPage.setViewport(config.viewport);
392
+ tabRegistry.set(label, newPage);
393
+ activeTabLabel = label;
394
+ page = newPage;
395
+ // Navigate inside the new tab
396
+ actionResult = await executeAction(page, action, effectiveConfig);
397
+
398
+ } else if (action.type === 'switch_tab') {
399
+ // value: label, title regex, URL substring, or numeric index
400
+ const target = action.value;
401
+ let found = false;
402
+
403
+ // 1. By label (exact match)
404
+ if (tabRegistry.has(target)) {
405
+ page = tabRegistry.get(target);
406
+ activeTabLabel = target;
407
+ found = true;
408
+ }
409
+
410
+ // 2. By numeric index
411
+ if (!found && /^\d+$/.test(target)) {
412
+ const idx = parseInt(target);
413
+ const labels = [...tabRegistry.keys()];
414
+ if (idx >= 0 && idx < labels.length) {
415
+ activeTabLabel = labels[idx];
416
+ page = tabRegistry.get(activeTabLabel);
417
+ found = true;
418
+ }
419
+ }
420
+
421
+ // 3. By title or URL match (substring or regex)
422
+ if (!found) {
423
+ for (const [label, p] of tabRegistry) {
424
+ try {
425
+ const title = await p.title();
426
+ const url = p.url();
427
+ const regex = new RegExp(target, 'i');
428
+ if (regex.test(title) || regex.test(url) || url.includes(target)) {
429
+ page = p;
430
+ activeTabLabel = label;
431
+ found = true;
432
+ break;
433
+ }
434
+ } catch { /* page may be closed */ }
435
+ }
436
+ }
437
+
438
+ if (!found) {
439
+ throw new Error(`switch_tab failed: no tab matching "${target}" (labels: ${[...tabRegistry.keys()].join(', ')})`);
440
+ }
441
+ // Bring tab to front
442
+ await page.bringToFront();
443
+ actionResult = null;
444
+
445
+ } else if (action.type === 'close_tab') {
446
+ const targetLabel = action.value || activeTabLabel;
447
+ if (targetLabel === 'default' && tabRegistry.size > 1) {
448
+ throw new Error('close_tab: cannot close the default tab while other tabs are open');
449
+ }
450
+ const targetPage = tabRegistry.get(targetLabel);
451
+ if (!targetPage) {
452
+ throw new Error(`close_tab failed: no tab with label "${targetLabel}"`);
453
+ }
454
+ tabRegistry.delete(targetLabel);
455
+ if (!targetPage.isClosed()) {
456
+ await targetPage.close();
457
+ }
458
+ // Switch to the last remaining tab
459
+ if (activeTabLabel === targetLabel) {
460
+ const remaining = [...tabRegistry.keys()];
461
+ activeTabLabel = remaining[remaining.length - 1] || 'default';
462
+ page = tabRegistry.get(activeTabLabel);
463
+ if (page) await page.bringToFront();
464
+ }
465
+ actionResult = null;
466
+
467
+ } else if (action.type === 'assert_tab_count') {
468
+ action.__tabCount = tabRegistry.size;
469
+ actionResult = await executeAction(page, action, effectiveConfig);
470
+
471
+ } else if (action.type === 'wait_for_tab') {
472
+ // Wait for a new tab/popup to be opened (e.g. by window.open, target=_blank)
473
+ const label = action.text || `tab-${tabRegistry.size}`;
474
+ const waitTimeout = action.timeout || config.defaultTimeout || 10000;
475
+ const newTarget = await new Promise((resolve, reject) => {
476
+ const timer = setTimeout(() => reject(new Error(`wait_for_tab: no new tab appeared after ${waitTimeout}ms`)), waitTimeout);
477
+ context.once('targetcreated', (target) => {
478
+ clearTimeout(timer);
479
+ resolve(target);
480
+ });
481
+ });
482
+ const newPage = await newTarget.page();
483
+ if (newPage) {
484
+ await newPage.setViewport(config.viewport);
485
+ tabRegistry.set(label, newPage);
486
+ activeTabLabel = label;
487
+ page = newPage;
488
+ }
489
+ actionResult = null;
490
+
276
491
  } else {
277
- actionResult = await executeAction(page, action, config);
492
+ actionResult = await executeAction(page, action, effectiveConfig);
278
493
  }
279
494
  const actionDuration = Date.now() - actionStart;
495
+ const autoShot = await tryAutoCaptureStep(page, action, i, test.name, effectiveConfig, !!actionResult?.screenshot);
280
496
  const actionEntry = {
281
497
  ...action,
282
498
  success: true,
283
499
  duration: actionDuration,
284
500
  result: actionResult,
285
501
  };
502
+ if (autoShot) actionEntry.autoScreenshot = autoShot.path;
286
503
  if (attempt > 0) actionEntry.actionRetries = attempt;
287
504
  actionEntry.narrative = narrateAction(action, actionEntry);
288
505
  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 });
506
+ 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, autoScreenshot: autoShot?.path || null });
507
+ // Stream the auto-capture as a live frame so the storyline player has something to show even when CDP screencast is silent
508
+ if (autoShot?.base64) progressFn({ event: 'test:frame', name: test.name, data: autoShot.base64, source: 'step' });
290
509
  lastError = null;
291
510
  break;
292
511
  } catch (error) {
@@ -297,16 +516,19 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
297
516
  continue;
298
517
  }
299
518
  const actionDuration = Date.now() - actionStart;
519
+ const autoShot = await tryAutoCaptureStep(page, action, i, test.name, effectiveConfig, false);
300
520
  const failedEntry = {
301
521
  ...action,
302
522
  success: false,
303
523
  duration: actionDuration,
304
524
  error: error.message,
305
525
  };
526
+ if (autoShot) failedEntry.autoScreenshot = autoShot.path;
306
527
  if (maxActionRetries > 0) failedEntry.actionRetries = attempt;
307
528
  failedEntry.narrative = narrateAction(action, failedEntry);
308
529
  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 });
530
+ progressFn({ event: 'test:action', name: test.name, action, actionIndex: i, totalActions: test.actions.length, success: false, duration: actionDuration, narrative: failedEntry.narrative, error: error.message, autoScreenshot: autoShot?.path || null });
531
+ if (autoShot?.base64) progressFn({ event: 'test:frame', name: test.name, data: autoShot.base64, source: 'step' });
310
532
  throw error;
311
533
  }
312
534
  }
@@ -323,15 +545,40 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
323
545
  result.expect = test.expect;
324
546
  try {
325
547
  const safeName = test.name.replace(/[^a-zA-Z0-9_\-. ]/g, '_');
326
- const verifyPath = path.join(config.screenshotsDir, `verify-${safeName}-${Date.now()}.png`);
548
+ const verifyPath = path.join(effectiveConfig.screenshotsDir, `verify-${safeName}-${Date.now()}.png`);
327
549
  await page.screenshot({ path: verifyPath, fullPage: true });
328
550
  result.verificationScreenshot = verifyPath;
551
+
552
+ // Auto visual comparison: compare baseline vs verification screenshot
553
+ if (result.baselineScreenshot && result.verificationScreenshot) {
554
+ try {
555
+ const diffPath = path.join(effectiveConfig.screenshotsDir, `diff-${safeName}-${Date.now()}.png`);
556
+ const threshold = effectiveConfig.verificationThreshold ?? 0.02;
557
+ const visualResult = compareImages(result.baselineScreenshot, result.verificationScreenshot, {
558
+ threshold: 0.1,
559
+ diffOutputPath: diffPath,
560
+ maskRegions: test.expect?.maskRegions || [],
561
+ });
562
+ result.visualDiff = {
563
+ diffPercentage: visualResult.diffPercentage,
564
+ differentPixels: visualResult.differentPixels,
565
+ totalPixels: visualResult.totalPixels,
566
+ matchPercentage: visualResult.matchPercentage,
567
+ diffImagePath: visualResult.diffImagePath,
568
+ threshold,
569
+ passed: visualResult.diffPercentage <= threshold,
570
+ };
571
+ if (result.visualDiff.diffImagePath) {
572
+ result.diffScreenshot = result.visualDiff.diffImagePath;
573
+ }
574
+ } catch { /* visual diff is best-effort, never blocks the test */ }
575
+ }
329
576
  } catch { /* page may be dead */ }
330
577
  }
331
578
 
332
579
  // Run afterEach hook (success path)
333
580
  if (hooks.afterEach?.length) {
334
- await executeHookActions(page, hooks.afterEach, config);
581
+ await executeHookActions(page, hooks.afterEach, effectiveConfig);
335
582
  }
336
583
  } catch (error) {
337
584
  result.success = false;
@@ -339,18 +586,31 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
339
586
 
340
587
  // Run afterEach hook (failure path)
341
588
  if (page && hooks.afterEach?.length) {
342
- try { await executeHookActions(page, hooks.afterEach, config); } catch { /* */ }
589
+ try { await executeHookActions(page, hooks.afterEach, effectiveConfig); } catch { /* */ }
343
590
  }
344
591
 
345
592
  if (page) {
346
593
  try {
347
- const safeName = test.name.replace(/[^a-zA-Z0-9_\-. ]/g, '_');
348
- const errorScreenshot = path.join(config.screenshotsDir, `error-${safeName}-${Date.now()}.png`);
349
- await page.screenshot({ path: errorScreenshot, fullPage: true });
350
- result.errorScreenshot = errorScreenshot;
594
+ // Only capture when the page actually has something to show.
595
+ // about:blank / empty-DOM failures produced 5KB blank PNGs that
596
+ // accumulated in screenshotsDir with no debug value.
597
+ if (await pageHasRenderableContent(page)) {
598
+ const errBuf = await page.screenshot({ fullPage: true });
599
+ if (!looksLikeBlankCapture(errBuf, 'png')) {
600
+ const safeName = test.name.replace(/[^a-zA-Z0-9_\-. ]/g, '_');
601
+ const errorScreenshot = path.join(config.screenshotsDir, `error-${safeName}-${Date.now()}.png`);
602
+ fs.writeFileSync(errorScreenshot, errBuf);
603
+ result.errorScreenshot = errorScreenshot;
604
+ }
605
+ }
351
606
  } catch { /* page may be dead */ }
352
607
  }
353
608
  } finally {
609
+ // Stop screencast before disconnecting
610
+ if (cdpSession) {
611
+ try { await cdpSession.send('Page.stopScreencast'); } catch { /* */ }
612
+ try { await cdpSession.detach(); } catch { /* */ }
613
+ }
354
614
  // Flush pending response body reads before disconnecting
355
615
  if (pendingBodies.length > 0) {
356
616
  try { await Promise.allSettled(pendingBodies); } catch { /* */ }
@@ -363,12 +623,68 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
363
623
  try { await context.close(); } catch { /* */ }
364
624
  }
365
625
  if (browser) {
366
- try { browser.disconnect(); } catch { /* */ }
626
+ try { await disconnectFromPool(browser, result.poolUrl); } catch { /* */ }
367
627
  }
368
628
  // Release local pending counter so selectPool() knows this slot is free
369
629
  if (result.poolUrl) {
370
630
  releasePending(result.poolUrl);
371
631
  }
632
+ // Destroy the app fork after the test completes
633
+ if (appFork) {
634
+ try { await destroyFork(appFork.forkId); } catch { /* best effort */ }
635
+ }
636
+ }
637
+
638
+ return result;
639
+ }
640
+
641
+ /**
642
+ * Majority voting — runs a test N times in parallel and uses majority vote for pass/fail.
643
+ * If majority passes but not unanimously, marks as flaky.
644
+ */
645
+ async function runTestWithVoting(test, config, hooks, votingCount, testTimeout, progressFn) {
646
+ const votes = [];
647
+ const promises = [];
648
+
649
+ for (let v = 0; v < votingCount; v++) {
650
+ const timeoutPromise = new Promise((_, reject) => {
651
+ const timer = setTimeout(() => reject(new Error(`Test timed out after ${testTimeout}ms`)), testTimeout);
652
+ timer.unref();
653
+ });
654
+ promises.push(
655
+ Promise.race([runTest(test, config, hooks, progressFn), timeoutPromise])
656
+ .catch(error => ({
657
+ name: test.name,
658
+ startTime: new Date().toISOString(),
659
+ endTime: new Date().toISOString(),
660
+ actions: [],
661
+ success: false,
662
+ error: error.message,
663
+ consoleLogs: [],
664
+ networkErrors: [],
665
+ networkLogs: [],
666
+ }))
667
+ );
668
+ }
669
+
670
+ const results = await Promise.all(promises);
671
+ const passCount = results.filter(r => r.success).length;
672
+ const majorityPassed = passCount > votingCount / 2;
673
+
674
+ // Pick the representative result: a passing one if majority passed, failing one otherwise
675
+ const representative = majorityPassed
676
+ ? results.find(r => r.success) || results[0]
677
+ : results.find(r => !r.success) || results[0];
678
+
679
+ const result = { ...representative };
680
+ result.success = majorityPassed;
681
+ result.voting = { total: votingCount, passed: passCount, failed: votingCount - passCount };
682
+ result.attempt = 1;
683
+ result.maxAttempts = 1;
684
+
685
+ // Non-unanimous pass = flaky
686
+ if (majorityPassed && passCount < votingCount) {
687
+ result.flaky = true;
372
688
  }
373
689
 
374
690
  return result;
@@ -377,6 +693,7 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
377
693
  /** Runs tests in parallel with limited concurrency, retries, timeouts, and hooks */
378
694
  export async function runTestsParallel(tests, config, suiteHooks = {}) {
379
695
  const hooks = mergeHooks(config.hooks, suiteHooks);
696
+ const driverOpts = { poolDriver: config.poolDriver || 'auto', maxSessions: config.maxSessions || 10 };
380
697
 
381
698
  // Run beforeAll hook
382
699
  if (hooks.beforeAll?.length) {
@@ -389,7 +706,7 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
389
706
  log('🪝', `${C.dim}Running beforeAll hook...${C.reset}`);
390
707
  let browser = null;
391
708
  try {
392
- const hookPool = await selectPool(getPoolUrls(config));
709
+ const hookPool = await selectPool(getPoolUrls(config), 2000, 60000, driverOpts);
393
710
  browser = await connectToPool(hookPool, config.connectRetries, config.connectRetryDelay);
394
711
  const page = await browser.newPage();
395
712
  await page.setViewport(config.viewport);
@@ -399,7 +716,7 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
399
716
  log('❌', `${C.red}beforeAll hook failed: ${error.message}${C.reset}`);
400
717
  throw error;
401
718
  } finally {
402
- if (browser) try { browser.disconnect(); } catch { /* */ }
719
+ if (browser) try { await disconnectFromPool(browser, hookPool); } catch { /* */ }
403
720
  }
404
721
  }
405
722
 
@@ -414,8 +731,27 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
414
731
  );
415
732
  log('✅', `${C.dim}Auth token acquired (${config.authToken.length} chars)${C.reset}`);
416
733
  } catch (error) {
417
- log('❌', `${C.red}Auth auto-login failed: ${error.message}${C.reset}`);
418
- throw error;
734
+ // Docker-internal hostname (nginx, api, etc.) retry with localhost from host machine
735
+ if (error.message && error.message.includes('ENOTFOUND')) {
736
+ const url = new URL(config.authLoginEndpoint);
737
+ if (!url.hostname.includes('.')) {
738
+ const localhostUrl = `http://localhost${url.port && url.port !== '80' ? ':' + url.port : ''}${url.pathname}${url.search}`;
739
+ log('🔄', `${C.dim}Docker hostname "${url.hostname}" not reachable from host, retrying with ${localhostUrl}...${C.reset}`);
740
+ try {
741
+ config.authToken = await fetchAuthToken(localhostUrl, config.authCredentials, config.authTokenPath || 'token');
742
+ log('✅', `${C.dim}Auth token acquired via localhost fallback (${config.authToken.length} chars)${C.reset}`);
743
+ } catch (retryErr) {
744
+ log('❌', `${C.red}Auth auto-login failed (localhost fallback): ${retryErr.message}${C.reset}`);
745
+ throw retryErr;
746
+ }
747
+ } else {
748
+ log('❌', `${C.red}Auth auto-login failed: ${error.message}${C.reset}`);
749
+ throw error;
750
+ }
751
+ } else {
752
+ log('❌', `${C.red}Auth auto-login failed: ${error.message}${C.reset}`);
753
+ throw error;
754
+ }
419
755
  }
420
756
  }
421
757
 
@@ -446,40 +782,49 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
446
782
  log('▶▶▶', `${C.cyan}${test.name}${C.reset} ${C.dim}(${activeCount} active)${C.reset}`);
447
783
  _progress({ event: 'test:start', name: test.name, serial: test.serial || false, activeCount, queueRemaining: queue.length });
448
784
 
449
- const maxAttempts = (test.retries ?? config.retries ?? 0) + 1;
450
785
  const testTimeout = test.timeout ?? config.testTimeout ?? 60000;
786
+ const votingCount = test.voting ?? config.voting ?? 0;
787
+ const testHooks = test._suiteHooks ? mergeHooks(config.hooks, test._suiteHooks) : hooks;
451
788
  let result;
452
789
 
453
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
454
- const timeoutPromise = new Promise((_, reject) => {
455
- const timer = setTimeout(() => reject(new Error(`Test timed out after ${testTimeout}ms`)), testTimeout);
456
- timer.unref();
457
- });
458
-
459
- try {
460
- const testHooks = test._suiteHooks ? mergeHooks(config.hooks, test._suiteHooks) : hooks;
461
- result = await Promise.race([runTest(test, config, testHooks, _progress), timeoutPromise]);
462
- } catch (error) {
463
- result = {
464
- name: test.name,
465
- startTime: new Date().toISOString(),
466
- endTime: new Date().toISOString(),
467
- actions: [],
468
- success: false,
469
- error: error.message,
470
- consoleLogs: [],
471
- networkErrors: [],
472
- networkLogs: [],
473
- };
474
- }
790
+ if (votingCount > 1) {
791
+ // Majority voting: run N times in parallel, majority wins
792
+ log('🗳️', `${C.dim}${test.name}: voting ${votingCount}x in parallel${C.reset}`);
793
+ result = await runTestWithVoting(test, config, testHooks, votingCount, testTimeout, _progress);
794
+ } else {
795
+ // Standard sequential retry
796
+ const maxAttempts = (test.retries ?? config.retries ?? 0) + 1;
797
+
798
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
799
+ const timeoutPromise = new Promise((_, reject) => {
800
+ const timer = setTimeout(() => reject(new Error(`Test timed out after ${testTimeout}ms`)), testTimeout);
801
+ timer.unref();
802
+ });
803
+
804
+ try {
805
+ result = await Promise.race([runTest(test, config, testHooks, _progress), timeoutPromise]);
806
+ } catch (error) {
807
+ result = {
808
+ name: test.name,
809
+ startTime: new Date().toISOString(),
810
+ endTime: new Date().toISOString(),
811
+ actions: [],
812
+ success: false,
813
+ error: error.message,
814
+ consoleLogs: [],
815
+ networkErrors: [],
816
+ networkLogs: [],
817
+ };
818
+ }
475
819
 
476
- result.attempt = attempt;
477
- result.maxAttempts = maxAttempts;
820
+ result.attempt = attempt;
821
+ result.maxAttempts = maxAttempts;
478
822
 
479
- if (result.success || attempt === maxAttempts) break;
480
- log('🔄', `${C.yellow}${test.name}${C.reset} failed, retrying (${attempt}/${maxAttempts})...`);
481
- _progress({ event: 'test:retry', name: test.name, attempt, maxAttempts });
482
- await sleep(config.retryDelay || 1000);
823
+ if (result.success || attempt === maxAttempts) break;
824
+ log('🔄', `${C.yellow}${test.name}${C.reset} failed, retrying (${attempt}/${maxAttempts})...`);
825
+ _progress({ event: 'test:retry', name: test.name, attempt, maxAttempts });
826
+ await sleep(config.retryDelay || 1000);
827
+ }
483
828
  }
484
829
 
485
830
  results.push(result);
@@ -489,11 +834,13 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
489
834
  _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 });
490
835
 
491
836
  if (result.success) {
492
- const flaky = result.attempt > 1 ? ` ${C.yellow}(flaky, passed on attempt ${result.attempt}/${result.maxAttempts})${C.reset}` : '';
493
- log('✅', `${C.green}${test.name}${C.reset} ${C.dim}(${timeDiff(result.startTime, result.endTime)})${C.reset}${flaky}`);
837
+ const votingInfo = result.voting ? ` ${C.yellow}(voting: ${result.voting.passed}/${result.voting.total} passed${result.flaky ? ', flaky' : ''})${C.reset}` : '';
838
+ const retryInfo = !result.voting && result.attempt > 1 ? ` ${C.yellow}(flaky, passed on attempt ${result.attempt}/${result.maxAttempts})${C.reset}` : '';
839
+ log('✅', `${C.green}${test.name}${C.reset} ${C.dim}(${timeDiff(result.startTime, result.endTime)})${C.reset}${votingInfo}${retryInfo}`);
494
840
  } else {
495
- const attempts = result.maxAttempts > 1 ? ` (${result.maxAttempts} attempts)` : '';
496
- log('❌', `${C.red}${test.name}${C.reset}: ${result.error}${attempts}`);
841
+ const votingInfo = result.voting ? ` (voting: ${result.voting.passed}/${result.voting.total} passed)` : '';
842
+ const attempts = !result.voting && result.maxAttempts > 1 ? ` (${result.maxAttempts} attempts)` : '';
843
+ log('❌', `${C.red}${test.name}${C.reset}: ${result.error}${votingInfo}${attempts}`);
497
844
  }
498
845
 
499
846
  const consoleIssues = result.consoleLogs?.filter(l => l.type === 'error' || l.type === 'warning').length || 0;
@@ -524,7 +871,7 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
524
871
  log('🪝', `${C.dim}Running afterAll hook...${C.reset}`);
525
872
  let browser = null;
526
873
  try {
527
- const hookPool = await selectPool(getPoolUrls(config));
874
+ const hookPool = await selectPool(getPoolUrls(config), 2000, 60000, driverOpts);
528
875
  browser = await connectToPool(hookPool, config.connectRetries, config.connectRetryDelay);
529
876
  const page = await browser.newPage();
530
877
  await page.setViewport(config.viewport);
@@ -533,7 +880,7 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
533
880
  } catch (error) {
534
881
  log('⚠️', `${C.yellow}afterAll hook failed: ${error.message}${C.reset}`);
535
882
  } finally {
536
- if (browser) try { browser.disconnect(); } catch { /* */ }
883
+ if (browser) try { await disconnectFromPool(browser, hookPool); } catch { /* */ }
537
884
  }
538
885
  }
539
886