@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.
- package/.claude-plugin/marketplace.json +4 -4
- package/.claude-plugin/plugin.json +2 -2
- package/README.md +110 -21
- package/agents/test-creator.md +4 -2
- package/agents/test-improver.md +5 -3
- package/bin/cli.js +80 -17
- package/package.json +3 -2
- package/skills/e2e-testing/SKILL.md +3 -2
- package/skills/e2e-testing/references/action-types.md +22 -4
- package/skills/e2e-testing/references/test-json-format.md +23 -0
- package/src/actions.js +170 -14
- package/src/config.js +6 -0
- package/src/dashboard.js +135 -4
- package/src/db.js +11 -0
- package/src/mcp-tools.js +8 -2
- package/src/module-analysis.js +247 -0
- package/src/module-resolver.js +35 -2
- package/src/narrate.js +14 -1
- package/src/pool-manager.js +46 -1
- package/src/pool.js +177 -20
- package/src/runner.js +77 -10
- package/src/visual-diff.js +69 -0
- package/src/websocket.js +14 -3
- package/src/wizard.js +184 -0
- package/templates/build-dashboard.js +3 -0
- package/templates/dashboard/js/api.js +60 -3
- package/templates/dashboard/js/init.js +46 -0
- package/templates/dashboard/js/keyboard.js +8 -7
- package/templates/dashboard/js/quicksearch.js +277 -0
- package/templates/dashboard/js/state.js +61 -7
- package/templates/dashboard/js/toast.js +1 -1
- package/templates/dashboard/js/view-live.js +235 -42
- package/templates/dashboard/js/view-runs.js +379 -37
- package/templates/dashboard/js/view-tests.js +157 -16
- package/templates/dashboard/js/view-tools.js +234 -0
- package/templates/dashboard/js/view-watch.js +2 -2
- package/templates/dashboard/js/websocket.js +33 -3
- package/templates/dashboard/styles/base.css +489 -53
- package/templates/dashboard/styles/components.css +719 -84
- package/templates/dashboard/styles/view-live.css +459 -78
- package/templates/dashboard/styles/view-runs.css +779 -177
- package/templates/dashboard/styles/view-tests.css +440 -77
- package/templates/dashboard/styles/view-tools.css +206 -0
- package/templates/dashboard/styles/view-watch.css +198 -41
- package/templates/dashboard/template.html +354 -56
- package/templates/dashboard.html +5173 -711
- 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 (
|
|
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,
|
|
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) →
|
|
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
|
-
//
|
|
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,
|
|
175
|
+
* Maps config values: 'auto' → detect, explicit → cache and pass through.
|
|
82
176
|
*/
|
|
83
177
|
async function resolveDriver(poolUrl, poolDriver) {
|
|
84
178
|
if (!poolDriver || poolDriver === 'auto') return detectPoolDriver(poolUrl);
|
|
85
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
//
|
|
341
|
-
const
|
|
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
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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'|'
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
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 {
|
package/src/visual-diff.js
CHANGED
|
@@ -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
|
-
|
|
87
|
-
|
|
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;
|