@matware/e2e-runner 1.3.1 → 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 (47) hide show
  1. package/.claude-plugin/marketplace.json +4 -4
  2. package/.claude-plugin/plugin.json +2 -2
  3. package/README.md +110 -21
  4. package/agents/test-creator.md +4 -2
  5. package/agents/test-improver.md +5 -3
  6. package/bin/cli.js +80 -17
  7. package/package.json +3 -2
  8. package/skills/e2e-testing/SKILL.md +3 -2
  9. package/skills/e2e-testing/references/action-types.md +22 -4
  10. package/skills/e2e-testing/references/test-json-format.md +23 -0
  11. package/src/actions.js +170 -14
  12. package/src/config.js +6 -0
  13. package/src/dashboard.js +135 -4
  14. package/src/db.js +11 -0
  15. package/src/mcp-tools.js +8 -2
  16. package/src/module-analysis.js +247 -0
  17. package/src/module-resolver.js +35 -2
  18. package/src/narrate.js +14 -1
  19. package/src/pool-manager.js +46 -1
  20. package/src/pool.js +177 -20
  21. package/src/runner.js +77 -10
  22. package/src/visual-diff.js +69 -0
  23. package/src/websocket.js +14 -3
  24. package/src/wizard.js +184 -0
  25. package/templates/build-dashboard.js +3 -0
  26. package/templates/dashboard/js/api.js +60 -3
  27. package/templates/dashboard/js/init.js +46 -0
  28. package/templates/dashboard/js/keyboard.js +8 -7
  29. package/templates/dashboard/js/quicksearch.js +277 -0
  30. package/templates/dashboard/js/state.js +61 -7
  31. package/templates/dashboard/js/toast.js +1 -1
  32. package/templates/dashboard/js/view-live.js +235 -42
  33. package/templates/dashboard/js/view-runs.js +379 -37
  34. package/templates/dashboard/js/view-tests.js +157 -16
  35. package/templates/dashboard/js/view-tools.js +234 -0
  36. package/templates/dashboard/js/view-watch.js +2 -2
  37. package/templates/dashboard/js/websocket.js +33 -3
  38. package/templates/dashboard/styles/base.css +489 -53
  39. package/templates/dashboard/styles/components.css +719 -84
  40. package/templates/dashboard/styles/view-live.css +459 -78
  41. package/templates/dashboard/styles/view-runs.css +779 -177
  42. package/templates/dashboard/styles/view-tests.css +440 -77
  43. package/templates/dashboard/styles/view-tools.css +206 -0
  44. package/templates/dashboard/styles/view-watch.css +198 -41
  45. package/templates/dashboard/template.html +354 -56
  46. package/templates/dashboard.html +5173 -711
  47. package/templates/docker-compose-lightpanda.yml +7 -0
package/src/pool.js CHANGED
@@ -4,9 +4,13 @@
4
4
  * Connectivity to browser pools and Docker Compose lifecycle.
5
5
  * Supports multiple pool drivers:
6
6
  * - "browserless" — browserless/chrome with /pressure and /sessions HTTP API
7
- * - "cdp" — generic CDP pool (Lightpanda, raw Chrome, etc.) using /json/version health check
7
+ * - "cdp" — generic CDP pool (raw Chrome, etc.) using /json/version health check
8
+ * - "lightpanda" — Lightpanda browser (Zig-based, 9x faster, ~16x less memory) via CDP on port 9222
9
+ * - "obscura" — Obscura headless browser (Rust+V8, ~30 MB, anti-detect) via CDP on port 9222
8
10
  * - "steel" — Steel Browser with /v1/sessions REST API and session lifecycle
9
- * - "auto" — detect driver by probing endpoints: /pressure → browserless, /v1/sessions → steel, fallback → cdp
11
+ * - "auto" — detect driver by probing endpoints: /pressure → browserless, /v1/sessions → steel,
12
+ * /json/version with Browser=lightpanda → lightpanda, Browser=obscura → obscura,
13
+ * fallback → cdp
10
14
  */
11
15
 
12
16
  import puppeteer from 'puppeteer-core';
@@ -25,12 +29,25 @@ function sleep(ms) {
25
29
 
26
30
  // ── Driver detection cache ────────────────────────────────────────────────────
27
31
 
32
+ /** Set of driver identifiers accepted by config, test JSON, and CLI overrides. */
33
+ export const KNOWN_DRIVERS = new Set(['auto', 'browserless', 'cdp', 'lightpanda', 'obscura', 'steel']);
34
+
28
35
  /** Caches detected driver per pool URL to avoid re-probing on every status call. */
29
36
  const driverCache = new Map();
30
37
 
38
+ /**
39
+ * Caches the canonical Puppeteer WS endpoint per pool URL, as advertised
40
+ * by /json/version → webSocketDebuggerUrl. Used by connectToPool so users
41
+ * can configure either http://host:port or ws://host:port for obscura,
42
+ * lightpanda, and generic cdp pools without needing to know the
43
+ * /devtools/browser suffix Obscura requires.
44
+ */
45
+ const wsEndpointCache = new Map();
46
+
31
47
  /** Clears the driver cache (useful for tests or pool restarts). */
32
48
  export function clearDriverCache() {
33
49
  driverCache.clear();
50
+ wsEndpointCache.clear();
34
51
  }
35
52
 
36
53
  /** Returns the cached driver for a pool URL, or null if not yet detected. */
@@ -38,9 +55,16 @@ export function getCachedDriver(poolUrl) {
38
55
  return driverCache.get(poolUrl) || null;
39
56
  }
40
57
 
58
+ /** Returns the cached webSocketDebuggerUrl for a pool URL, or null. */
59
+ export function getCachedWsEndpoint(poolUrl) {
60
+ return wsEndpointCache.get(poolUrl) || null;
61
+ }
62
+
41
63
  /**
42
64
  * Detects the pool driver by probing HTTP endpoints.
43
- * Probe order: /pressure (browserless) → /v1/sessions (steel) → fallback (cdp).
65
+ * Probe order: /pressure (browserless) → /v1/sessions (steel) →
66
+ * /json/version with Browser=lightpanda → lightpanda, Browser=obscura → obscura,
67
+ * fallback → cdp.
44
68
  */
45
69
  async function detectPoolDriver(poolUrl) {
46
70
  if (driverCache.has(poolUrl)) return driverCache.get(poolUrl);
@@ -71,18 +95,89 @@ async function detectPoolDriver(poolUrl) {
71
95
  }
72
96
  } catch { /* not steel */ }
73
97
 
74
- // Fallback: generic CDP
98
+ // Probe Lightpanda / Obscura / generic CDP: /json/version
99
+ // Capture webSocketDebuggerUrl so connectToPool can use the canonical
100
+ // ws:// endpoint regardless of how the user spelled poolUrl.
101
+ try {
102
+ const res = await fetch(`${httpUrl}/json/version`, { signal: AbortSignal.timeout(3000) });
103
+ if (res.ok) {
104
+ const data = await res.json();
105
+ if (typeof data.webSocketDebuggerUrl === 'string' && data.webSocketDebuggerUrl) {
106
+ wsEndpointCache.set(poolUrl, rewriteWsHost(data.webSocketDebuggerUrl, poolUrl));
107
+ }
108
+ const browser = typeof data.Browser === 'string' ? data.Browser.toLowerCase() : '';
109
+ if (browser.includes('lightpanda')) {
110
+ driverCache.set(poolUrl, 'lightpanda');
111
+ return 'lightpanda';
112
+ }
113
+ if (browser.includes('obscura')) {
114
+ driverCache.set(poolUrl, 'obscura');
115
+ return 'obscura';
116
+ }
117
+ // /json/version answered with a Browser field but it isn't one we
118
+ // specifically recognize — treat as generic CDP.
119
+ driverCache.set(poolUrl, 'cdp');
120
+ return 'cdp';
121
+ }
122
+ } catch { /* not CDP-family or network error */ }
123
+
124
+ // Fallback: generic CDP (assume ws:// endpoint as-is)
75
125
  driverCache.set(poolUrl, 'cdp');
76
126
  return 'cdp';
77
127
  }
78
128
 
129
+ /**
130
+ * Some CDP servers (notably Obscura when bound to 0.0.0.0) advertise an
131
+ * internal host in webSocketDebuggerUrl that does not match the URL the
132
+ * client used. Rewrite host:port to match the original poolUrl so the
133
+ * resulting ws:// is reachable from this machine.
134
+ */
135
+ function rewriteWsHost(wsUrl, poolUrl) {
136
+ try {
137
+ const ws = new URL(wsUrl);
138
+ const ref = new URL(poolUrl.replace(/^ws/, 'http'));
139
+ ws.hostname = ref.hostname;
140
+ if (ref.port) ws.port = ref.port;
141
+ return ws.toString();
142
+ } catch {
143
+ return wsUrl;
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Returns a Puppeteer-ready ws:// endpoint for a CDP-family pool URL.
149
+ * Uses the cache when available; otherwise probes /json/version on demand.
150
+ * Falls back to coercing the input http://→ws:// if discovery fails.
151
+ */
152
+ async function resolveCdpWsEndpoint(poolUrl) {
153
+ const cached = wsEndpointCache.get(poolUrl);
154
+ if (cached) return cached;
155
+
156
+ const httpUrl = poolUrl.replace(/^ws:/, 'http:').replace(/^wss:/, 'https:');
157
+ try {
158
+ const res = await fetch(`${httpUrl}/json/version`, { signal: AbortSignal.timeout(3000) });
159
+ if (res.ok) {
160
+ const data = await res.json();
161
+ if (typeof data.webSocketDebuggerUrl === 'string' && data.webSocketDebuggerUrl) {
162
+ const ws = rewriteWsHost(data.webSocketDebuggerUrl, poolUrl);
163
+ wsEndpointCache.set(poolUrl, ws);
164
+ return ws;
165
+ }
166
+ }
167
+ } catch { /* fall through to coercion */ }
168
+
169
+ // No discovery — assume the input is already a usable ws endpoint.
170
+ return poolUrl.replace(/^http:/, 'ws:').replace(/^https:/, 'wss:');
171
+ }
172
+
79
173
  /**
80
174
  * Resolves the effective driver string.
81
- * Maps config values: 'auto' → detect, 'lightpanda''cdp', explicit pass through.
175
+ * Maps config values: 'auto' → detect, explicitcache and pass through.
82
176
  */
83
177
  async function resolveDriver(poolUrl, poolDriver) {
84
178
  if (!poolDriver || poolDriver === 'auto') return detectPoolDriver(poolUrl);
85
- if (poolDriver === 'lightpanda') return 'cdp';
179
+ // Cache explicit driver so status calls and connect calls share the same value
180
+ driverCache.set(poolUrl, poolDriver);
86
181
  return poolDriver;
87
182
  }
88
183
 
@@ -111,7 +206,7 @@ function getCdpSessionCount(poolUrl) {
111
206
  return cdpSessions.get(poolUrl)?.size || 0;
112
207
  }
113
208
 
114
- async function getPoolStatusViaCDP(poolUrl, maxSessions) {
209
+ async function getPoolStatusViaCDP(poolUrl, maxSessions, driverName = 'cdp') {
115
210
  const httpUrl = poolUrl.replace('ws://', 'http://').replace('wss://', 'https://');
116
211
  try {
117
212
  const res = await fetch(`${httpUrl}/json/version`, { signal: AbortSignal.timeout(3000) });
@@ -124,7 +219,7 @@ async function getPoolStatusViaCDP(poolUrl, maxSessions) {
124
219
  maxConcurrent: maxSessions,
125
220
  queued: 0,
126
221
  sessions: [],
127
- driver: 'cdp',
222
+ driver: driverName,
128
223
  };
129
224
  } catch (error) {
130
225
  return {
@@ -134,7 +229,7 @@ async function getPoolStatusViaCDP(poolUrl, maxSessions) {
134
229
  maxConcurrent: maxSessions,
135
230
  queued: 0,
136
231
  sessions: [],
137
- driver: 'cdp',
232
+ driver: driverName,
138
233
  };
139
234
  }
140
235
  }
@@ -143,9 +238,13 @@ async function getPoolStatusViaCDP(poolUrl, maxSessions) {
143
238
 
144
239
  async function getPoolStatusViaBrowserless(poolUrl) {
145
240
  const httpUrl = poolUrl.replace('ws://', 'http://').replace('wss://', 'https://');
241
+ // FIX: add timeout. Without it, a hung browserless HTTP response (TCP
242
+ // open but no body) makes `fetch` wait indefinitely, which freezes
243
+ // `selectPool()` and looks downstream like a 0ms test timeout. Other
244
+ // drivers (CDP, Steel) already use AbortSignal.timeout(3000); match it.
146
245
  const [pressureRes, sessionsRes] = await Promise.all([
147
- fetch(`${httpUrl}/pressure`),
148
- fetch(`${httpUrl}/sessions`),
246
+ fetch(`${httpUrl}/pressure`, { signal: AbortSignal.timeout(3000) }),
247
+ fetch(`${httpUrl}/sessions`, { signal: AbortSignal.timeout(3000) }),
149
248
  ]);
150
249
 
151
250
  const pressure = pressureRes.ok ? await pressureRes.json() : null;
@@ -299,10 +398,19 @@ export async function connectToPool(poolUrl, retries = 3, delay = 2000) {
299
398
  return connectToSteelPool(poolUrl, retries, delay);
300
399
  }
301
400
 
401
+ // For CDP-family drivers, resolve the canonical webSocketDebuggerUrl from
402
+ // /json/version so users can configure either http://host:port or
403
+ // ws://host:port without knowing the driver-specific path
404
+ // (Obscura requires /devtools/browser; browserless does not).
405
+ let wsEndpoint = poolUrl;
406
+ if (driver === 'obscura' || driver === 'lightpanda' || driver === 'cdp') {
407
+ wsEndpoint = await resolveCdpWsEndpoint(poolUrl);
408
+ }
409
+
302
410
  for (let attempt = 1; attempt <= retries; attempt++) {
303
411
  try {
304
412
  return await puppeteer.connect({
305
- browserWSEndpoint: poolUrl,
413
+ browserWSEndpoint: wsEndpoint,
306
414
  timeout: 30000,
307
415
  });
308
416
  } catch (error) {
@@ -331,14 +439,40 @@ export async function disconnectFromPool(browser, poolUrl) {
331
439
  /** Generates docker-compose.yml and starts the pool */
332
440
  export function startPool(config, cwd = null) {
333
441
  cwd = cwd || process.cwd();
442
+ const driver = config.poolDriver || 'auto';
443
+
444
+ // Obscura is a single Rust binary — no official image, no compose. Print install/run guidance.
445
+ if (driver === 'obscura') {
446
+ const port = config.poolPort || 9222;
447
+ log('ℹ️', 'Obscura is a local binary, not a Docker pool. Install it once:');
448
+ log(' ', ' # Linux x86_64');
449
+ log(' ', ' curl -LO https://github.com/h4ckf0r0day/obscura/releases/latest/download/obscura-x86_64-linux.tar.gz');
450
+ log(' ', ' tar xzf obscura-x86_64-linux.tar.gz');
451
+ log(' ', ' # macOS Apple Silicon');
452
+ log(' ', ' curl -LO https://github.com/h4ckf0r0day/obscura/releases/latest/download/obscura-aarch64-macos.tar.gz');
453
+ log(' ', ' # macOS Intel');
454
+ log(' ', ' curl -LO https://github.com/h4ckf0r0day/obscura/releases/latest/download/obscura-x86_64-macos.tar.gz');
455
+ log(' ', ' # Arch Linux (AUR)');
456
+ log(' ', ' yay -S obscura-browser');
457
+ log('ℹ️', `Then run it (in another shell): obscura serve --port ${port} --stealth`);
458
+ log('ℹ️', `Set poolUrls in e2e.config.js (any of these works):`);
459
+ log(' ', ` ['http://127.0.0.1:${port}'] # auto-discovers ws endpoint`);
460
+ log(' ', ` ['ws://127.0.0.1:${port}/devtools/browser'] # explicit ws endpoint`);
461
+ log(' ', `Or export CHROME_POOL_URL=http://127.0.0.1:${port}`);
462
+ return;
463
+ }
464
+
334
465
  const poolDir = path.join(cwd, '.e2e-pool');
335
466
 
336
467
  if (!fs.existsSync(poolDir)) {
337
468
  fs.mkdirSync(poolDir, { recursive: true });
338
469
  }
339
470
 
340
- // Read template and interpolate variables
341
- const templatePath = path.join(__dirname, '..', 'templates', 'docker-compose.yml');
471
+ // Select template based on poolDriver
472
+ const templateFile = driver === 'lightpanda'
473
+ ? 'docker-compose-lightpanda.yml'
474
+ : 'docker-compose.yml';
475
+ const templatePath = path.join(__dirname, '..', 'templates', templateFile);
342
476
  let template = fs.readFileSync(templatePath, 'utf-8');
343
477
  template = template.replace(/\$\{PORT\}/g, String(config.poolPort || 3333));
344
478
  template = template.replace(/\$\{MAX_SESSIONS\}/g, String(config.maxSessions || 5));
@@ -355,14 +489,37 @@ export function startPool(config, cwd = null) {
355
489
  }
356
490
  }
357
491
 
358
- log('🐳', 'Starting Chrome Pool...');
359
- execFileSync('docker', ['compose', '-f', composePath, 'up', '-d'], { stdio: 'inherit' });
360
- log('✅', `Chrome Pool started on port ${config.poolPort || 3333}`);
492
+ const label = driver === 'lightpanda' ? 'Lightpanda Pool' : 'Chrome Pool';
493
+ log('🐳', `Starting ${label}...`);
494
+ const containerMatch = template.match(/container_name:\s*(\S+)/);
495
+ const containerName = containerMatch ? containerMatch[1].trim() : null;
496
+ try {
497
+ execFileSync('docker', ['compose', '-f', composePath, 'up', '-d'], { stdio: 'inherit' });
498
+ } catch (err) {
499
+ // Most common failure: a stopped container with the same name lingers from a
500
+ // previous run, so `up -d` errors with "container name is already in use".
501
+ // Recover by removing the stale container and recreating cleanly.
502
+ if (containerName) {
503
+ log('🔁', `Recovering: removing stale container ${containerName} and retrying...`);
504
+ try { execFileSync('docker', ['rm', '-f', containerName], { stdio: 'ignore' }); } catch { /* may not exist */ }
505
+ execFileSync('docker', ['compose', '-f', composePath, 'up', '-d'], { stdio: 'inherit' });
506
+ } else {
507
+ throw err;
508
+ }
509
+ }
510
+ log('✅', `${label} started on port ${config.poolPort || 3333}`);
361
511
  }
362
512
 
363
513
  /** Stops the pool */
364
514
  export function stopPool(config, cwd = null) {
365
515
  cwd = cwd || process.cwd();
516
+ const driver = config.poolDriver || 'auto';
517
+
518
+ if (driver === 'obscura') {
519
+ log('ℹ️', 'Obscura runs as a local process — stop it with Ctrl-C in its own shell.');
520
+ return;
521
+ }
522
+
366
523
  const composePath = path.join(cwd, '.e2e-pool', 'docker-compose.yml');
367
524
  if (!fs.existsSync(composePath)) {
368
525
  log('⚠️', '.e2e-pool/docker-compose.yml not found');
@@ -383,7 +540,7 @@ export function restartPool(config, cwd = null) {
383
540
  /**
384
541
  * Gets pool status using the appropriate driver.
385
542
  * @param {string} poolUrl - WebSocket URL of the pool
386
- * @param {object} [options] - { poolDriver: 'auto'|'browserless'|'lightpanda'|'cdp'|'steel', maxSessions: number }
543
+ * @param {object} [options] - { poolDriver: 'auto'|'browserless'|'cdp'|'lightpanda'|'obscura'|'steel', maxSessions: number }
387
544
  */
388
545
  export async function getPoolStatus(poolUrl, options = {}) {
389
546
  const { poolDriver = 'auto', maxSessions = 10 } = options;
@@ -394,8 +551,8 @@ export async function getPoolStatus(poolUrl, options = {}) {
394
551
  return getPoolStatusViaSteel(poolUrl, maxSessions);
395
552
  }
396
553
 
397
- if (driver === 'cdp') {
398
- return getPoolStatusViaCDP(poolUrl, maxSessions);
554
+ if (driver === 'lightpanda' || driver === 'obscura' || driver === 'cdp') {
555
+ return getPoolStatusViaCDP(poolUrl, maxSessions, driver);
399
556
  }
400
557
 
401
558
  // Browserless driver
package/src/runner.js CHANGED
@@ -10,9 +10,9 @@ import path from 'path';
10
10
  import http from 'http';
11
11
  import https from 'https';
12
12
  import { connectToPool, getCachedDriver, disconnectFromPool } from './pool.js';
13
- import { getPoolUrls, selectPool, releasePending } from './pool-manager.js';
13
+ import { getPoolUrls, selectPool, releasePending, resolvePoolsForTest } from './pool-manager.js';
14
14
  import { forkAppInstance, destroyFork, isAppPoolEnabled } from './app-pool.js';
15
- import { executeAction } from './actions.js';
15
+ import { executeAction, pageHasRenderableContent, looksLikeBlankCapture } from './actions.js';
16
16
  import { narrateAction } from './narrate.js';
17
17
  import { log, colors as C } from './logger.js';
18
18
  import { resolveTestData, validateActionTypes } from './module-resolver.js';
@@ -23,6 +23,39 @@ function sleep(ms) {
23
23
  return new Promise(resolve => setTimeout(resolve, ms));
24
24
  }
25
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
+
26
59
  /** Simple glob matching with * wildcards for exclude patterns. */
27
60
  function matchesExclude(filename, excludePatterns) {
28
61
  if (!excludePatterns?.length) return false;
@@ -190,9 +223,24 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
190
223
  }
191
224
 
192
225
  const driverOpts = { poolDriver: config.poolDriver || 'auto', maxSessions: config.maxSessions || 10 };
193
- const chosenPool = await selectPool(getPoolUrls(config), 2000, 60000, driverOpts);
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);
194
241
  result.poolUrl = chosenPool;
195
242
  result.poolDriver = getCachedDriver(chosenPool);
243
+ if (driverChoice) result.driverChoice = driverChoice;
196
244
  const poolLabel = chosenPool.replace('ws://', '').replace('wss://', '');
197
245
  const isMultiPool = getPoolUrls(config).length > 1;
198
246
  if (isMultiPool) {
@@ -236,9 +284,13 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
236
284
  maxHeight: config.screencastMaxHeight || 600,
237
285
  everyNthFrame: 1,
238
286
  }), 5000);
239
- } catch {
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}`);
240
290
  cdpSession = null;
241
291
  }
292
+ } else if (config.screencast && poolDriver === 'cdp') {
293
+ log('⚠️', `${C.amber}screencast disabled: pool driver is generic CDP (Lightpanda?), not supported${C.reset}`);
242
294
  }
243
295
 
244
296
  page.on('console', (msg) => {
@@ -440,16 +492,20 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
440
492
  actionResult = await executeAction(page, action, effectiveConfig);
441
493
  }
442
494
  const actionDuration = Date.now() - actionStart;
495
+ const autoShot = await tryAutoCaptureStep(page, action, i, test.name, effectiveConfig, !!actionResult?.screenshot);
443
496
  const actionEntry = {
444
497
  ...action,
445
498
  success: true,
446
499
  duration: actionDuration,
447
500
  result: actionResult,
448
501
  };
502
+ if (autoShot) actionEntry.autoScreenshot = autoShot.path;
449
503
  if (attempt > 0) actionEntry.actionRetries = attempt;
450
504
  actionEntry.narrative = narrateAction(action, actionEntry);
451
505
  result.actions.push(actionEntry);
452
- 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' });
453
509
  lastError = null;
454
510
  break;
455
511
  } catch (error) {
@@ -460,16 +516,19 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
460
516
  continue;
461
517
  }
462
518
  const actionDuration = Date.now() - actionStart;
519
+ const autoShot = await tryAutoCaptureStep(page, action, i, test.name, effectiveConfig, false);
463
520
  const failedEntry = {
464
521
  ...action,
465
522
  success: false,
466
523
  duration: actionDuration,
467
524
  error: error.message,
468
525
  };
526
+ if (autoShot) failedEntry.autoScreenshot = autoShot.path;
469
527
  if (maxActionRetries > 0) failedEntry.actionRetries = attempt;
470
528
  failedEntry.narrative = narrateAction(action, failedEntry);
471
529
  result.actions.push(failedEntry);
472
- 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' });
473
532
  throw error;
474
533
  }
475
534
  }
@@ -532,10 +591,18 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
532
591
 
533
592
  if (page) {
534
593
  try {
535
- const safeName = test.name.replace(/[^a-zA-Z0-9_\-. ]/g, '_');
536
- const errorScreenshot = path.join(config.screenshotsDir, `error-${safeName}-${Date.now()}.png`);
537
- await page.screenshot({ path: errorScreenshot, fullPage: true });
538
- 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
+ }
539
606
  } catch { /* page may be dead */ }
540
607
  }
541
608
  } finally {
@@ -444,3 +444,72 @@ function buildMaskLookup(regions, imgWidth, imgHeight) {
444
444
  return (mask[bit >> 3] & (1 << (bit & 7))) !== 0;
445
445
  };
446
446
  }
447
+
448
+ // ── Blank screenshot detection ─────────────────────────────────────────────
449
+ /**
450
+ * Detects whether a PNG screenshot is "completely blank" — i.e. a single
451
+ * uniform fill color (a white/empty page, a solid error frame, etc.).
452
+ *
453
+ * Strategy: decode to RGBA, sample pixels evenly (capped for speed), compute
454
+ * the mean color, then count how many sampled pixels deviate from that mean by
455
+ * more than `tolerance` on any channel. An image is blank when the fraction of
456
+ * deviating pixels stays at/under `maxOutlierFraction` — this tolerates a few
457
+ * stray pixels (a cursor, a 1px border) while still requiring a near-uniform
458
+ * frame. Non-PNG or undecodable files are reported as not-blank so they are
459
+ * never deleted by mistake.
460
+ *
461
+ * @param {string} filePath
462
+ * @param {{tolerance?:number, maxOutlierFraction?:number, maxSamples?:number}} [opts]
463
+ * @returns {{blank:boolean, color?:{r:number,g:number,b:number}, brightness?:number,
464
+ * width?:number, height?:number, outlierFraction?:number, error?:string}}
465
+ */
466
+ export function isBlankImage(filePath, opts = {}) {
467
+ const tolerance = opts.tolerance ?? 10;
468
+ const maxOutlierFraction = opts.maxOutlierFraction ?? 0.005; // ≤0.5% off-color pixels
469
+ const maxSamples = opts.maxSamples ?? 120000;
470
+
471
+ let img;
472
+ try {
473
+ img = decodePNG(filePath);
474
+ } catch (error) {
475
+ return { blank: false, error: error.message };
476
+ }
477
+
478
+ const { width, height, data } = img;
479
+ const totalPixels = width * height;
480
+ if (totalPixels === 0) return { blank: false, width, height };
481
+
482
+ // Even sampling stride so huge captures stay fast without missing regions.
483
+ const step = Math.max(1, Math.floor(totalPixels / maxSamples));
484
+
485
+ let sumR = 0, sumG = 0, sumB = 0, n = 0;
486
+ for (let p = 0; p < totalPixels; p += step) {
487
+ const i = p * 4;
488
+ sumR += data[i]; sumG += data[i + 1]; sumB += data[i + 2];
489
+ n++;
490
+ }
491
+ const meanR = sumR / n, meanG = sumG / n, meanB = sumB / n;
492
+
493
+ let outliers = 0;
494
+ for (let p = 0; p < totalPixels; p += step) {
495
+ const i = p * 4;
496
+ if (Math.abs(data[i] - meanR) > tolerance ||
497
+ Math.abs(data[i + 1] - meanG) > tolerance ||
498
+ Math.abs(data[i + 2] - meanB) > tolerance) {
499
+ outliers++;
500
+ }
501
+ }
502
+
503
+ const outlierFraction = outliers / n;
504
+ const color = { r: Math.round(meanR), g: Math.round(meanG), b: Math.round(meanB) };
505
+ const brightness = Math.round((meanR + meanG + meanB) / 3);
506
+
507
+ return {
508
+ blank: outlierFraction <= maxOutlierFraction,
509
+ color,
510
+ brightness,
511
+ width,
512
+ height,
513
+ outlierFraction: Math.round(outlierFraction * 1e4) / 1e4,
514
+ };
515
+ }
package/src/websocket.js CHANGED
@@ -81,10 +81,21 @@ export function createWebSocketServer(httpServer, options = {}) {
81
81
  const clients = new Set();
82
82
 
83
83
  httpServer.on('upgrade', (req, socket, head) => {
84
- // Validate Origin to prevent cross-site WebSocket hijacking
84
+ // Validate Origin to prevent cross-site WebSocket hijacking.
85
+ // Allow if: no Origin (curl/scripts), explicit whitelist match, or same-origin
86
+ // (Origin's host == the Host header the client connected to).
85
87
  const origin = req.headers.origin;
86
- if (origin && options.allowedOrigins) {
87
- if (!options.allowedOrigins.includes(origin)) {
88
+ const host = req.headers.host;
89
+ if (origin) {
90
+ let allowed = false;
91
+ if (options.allowedOrigins && options.allowedOrigins.includes(origin)) allowed = true;
92
+ if (!allowed && host) {
93
+ try {
94
+ const u = new URL(origin);
95
+ if (u.host === host) allowed = true;
96
+ } catch { /* malformed origin */ }
97
+ }
98
+ if (!allowed) {
88
99
  socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
89
100
  socket.destroy();
90
101
  return;