@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/pool.js CHANGED
@@ -1,7 +1,16 @@
1
1
  /**
2
2
  * Pool Management
3
3
  *
4
- * Connectivity to the Chrome Pool (browserless/chrome) and Docker Compose lifecycle.
4
+ * Connectivity to browser pools and Docker Compose lifecycle.
5
+ * Supports multiple pool drivers:
6
+ * - "browserless" — browserless/chrome with /pressure and /sessions HTTP API
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
10
+ * - "steel" — Steel Browser with /v1/sessions REST API and session lifecycle
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
5
14
  */
6
15
 
7
16
  import puppeteer from 'puppeteer-core';
@@ -18,22 +27,362 @@ function sleep(ms) {
18
27
  return new Promise(resolve => setTimeout(resolve, ms));
19
28
  }
20
29
 
21
- /** Waits for the Chrome Pool to become available */
22
- export async function waitForPool(poolUrl, maxWaitMs = 30000) {
23
- const poolHttpUrl = poolUrl.replace('ws://', 'http://').replace('wss://', 'https://');
24
- const pressureUrl = `${poolHttpUrl}/pressure`;
30
+ // ── Driver detection cache ────────────────────────────────────────────────────
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
+
35
+ /** Caches detected driver per pool URL to avoid re-probing on every status call. */
36
+ const driverCache = new Map();
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
+
47
+ /** Clears the driver cache (useful for tests or pool restarts). */
48
+ export function clearDriverCache() {
49
+ driverCache.clear();
50
+ wsEndpointCache.clear();
51
+ }
52
+
53
+ /** Returns the cached driver for a pool URL, or null if not yet detected. */
54
+ export function getCachedDriver(poolUrl) {
55
+ return driverCache.get(poolUrl) || null;
56
+ }
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
+
63
+ /**
64
+ * Detects the pool driver by probing HTTP endpoints.
65
+ * Probe order: /pressure (browserless) → /v1/sessions (steel) →
66
+ * /json/version with Browser=lightpanda → lightpanda, Browser=obscura → obscura,
67
+ * fallback → cdp.
68
+ */
69
+ async function detectPoolDriver(poolUrl) {
70
+ if (driverCache.has(poolUrl)) return driverCache.get(poolUrl);
71
+
72
+ const httpUrl = poolUrl.replace('ws://', 'http://').replace('wss://', 'https://');
73
+
74
+ // Probe browserless
75
+ try {
76
+ const res = await fetch(`${httpUrl}/pressure`, { signal: AbortSignal.timeout(3000) });
77
+ if (res.ok) {
78
+ const data = await res.json();
79
+ if (data.pressure !== undefined) {
80
+ driverCache.set(poolUrl, 'browserless');
81
+ return 'browserless';
82
+ }
83
+ }
84
+ } catch { /* not browserless */ }
85
+
86
+ // Probe Steel
87
+ try {
88
+ const res = await fetch(`${httpUrl}/v1/sessions`, { signal: AbortSignal.timeout(3000) });
89
+ if (res.ok) {
90
+ const data = await res.json();
91
+ if (data.sessions !== undefined) {
92
+ driverCache.set(poolUrl, 'steel');
93
+ return 'steel';
94
+ }
95
+ }
96
+ } catch { /* not steel */ }
97
+
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)
125
+ driverCache.set(poolUrl, 'cdp');
126
+ return 'cdp';
127
+ }
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
+
173
+ /**
174
+ * Resolves the effective driver string.
175
+ * Maps config values: 'auto' → detect, explicit → cache and pass through.
176
+ */
177
+ async function resolveDriver(poolUrl, poolDriver) {
178
+ if (!poolDriver || poolDriver === 'auto') return detectPoolDriver(poolUrl);
179
+ // Cache explicit driver so status calls and connect calls share the same value
180
+ driverCache.set(poolUrl, poolDriver);
181
+ return poolDriver;
182
+ }
183
+
184
+ // ── CDP driver: status via /json/version health check ─────────────────────────
185
+
186
+ /**
187
+ * Local session tracker for CDP pools (no remote management API).
188
+ * Maps poolUrl → Set of session IDs currently in use.
189
+ */
190
+ const cdpSessions = new Map();
191
+
192
+ export function trackCdpSession(poolUrl, sessionId) {
193
+ if (!cdpSessions.has(poolUrl)) cdpSessions.set(poolUrl, new Set());
194
+ cdpSessions.get(poolUrl).add(sessionId);
195
+ }
196
+
197
+ export function releaseCdpSession(poolUrl, sessionId) {
198
+ const sessions = cdpSessions.get(poolUrl);
199
+ if (sessions) {
200
+ sessions.delete(sessionId);
201
+ if (sessions.size === 0) cdpSessions.delete(poolUrl);
202
+ }
203
+ }
204
+
205
+ function getCdpSessionCount(poolUrl) {
206
+ return cdpSessions.get(poolUrl)?.size || 0;
207
+ }
208
+
209
+ async function getPoolStatusViaCDP(poolUrl, maxSessions, driverName = 'cdp') {
210
+ const httpUrl = poolUrl.replace('ws://', 'http://').replace('wss://', 'https://');
211
+ try {
212
+ const res = await fetch(`${httpUrl}/json/version`, { signal: AbortSignal.timeout(3000) });
213
+ if (!res.ok) throw new Error(`/json/version returned ${res.status}`);
214
+
215
+ const running = getCdpSessionCount(poolUrl);
216
+ return {
217
+ available: running < maxSessions,
218
+ running,
219
+ maxConcurrent: maxSessions,
220
+ queued: 0,
221
+ sessions: [],
222
+ driver: driverName,
223
+ };
224
+ } catch (error) {
225
+ return {
226
+ available: false,
227
+ error: error.message,
228
+ running: 0,
229
+ maxConcurrent: maxSessions,
230
+ queued: 0,
231
+ sessions: [],
232
+ driver: driverName,
233
+ };
234
+ }
235
+ }
236
+
237
+ // ── Browserless driver: status via /pressure + /sessions ──────────────────────
238
+
239
+ async function getPoolStatusViaBrowserless(poolUrl) {
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.
245
+ const [pressureRes, sessionsRes] = await Promise.all([
246
+ fetch(`${httpUrl}/pressure`, { signal: AbortSignal.timeout(3000) }),
247
+ fetch(`${httpUrl}/sessions`, { signal: AbortSignal.timeout(3000) }),
248
+ ]);
249
+
250
+ const pressure = pressureRes.ok ? await pressureRes.json() : null;
251
+ const sessions = sessionsRes.ok ? await sessionsRes.json() : null;
252
+
253
+ return {
254
+ available: pressure?.pressure?.isAvailable ?? false,
255
+ running: pressure?.pressure?.running ?? 0,
256
+ maxConcurrent: pressure?.pressure?.maxConcurrent ?? 0,
257
+ queued: pressure?.pressure?.queued ?? 0,
258
+ sessions: sessions || [],
259
+ driver: 'browserless',
260
+ };
261
+ }
262
+
263
+ // ── Steel driver: status via /v1/sessions REST API ────────────────────────────
264
+
265
+ /**
266
+ * Tracks Steel session IDs created by this process so we can release them.
267
+ * Maps poolUrl → Map<browserId, steelSessionId>.
268
+ */
269
+ const steelSessionMap = new Map();
270
+
271
+ function trackSteelSession(poolUrl, browserId, steelSessionId) {
272
+ if (!steelSessionMap.has(poolUrl)) steelSessionMap.set(poolUrl, new Map());
273
+ steelSessionMap.get(poolUrl).set(browserId, steelSessionId);
274
+ }
275
+
276
+ function getSteelSessionId(poolUrl, browserId) {
277
+ return steelSessionMap.get(poolUrl)?.get(browserId) || null;
278
+ }
279
+
280
+ function removeSteelSession(poolUrl, browserId) {
281
+ const sessions = steelSessionMap.get(poolUrl);
282
+ if (sessions) {
283
+ sessions.delete(browserId);
284
+ if (sessions.size === 0) steelSessionMap.delete(poolUrl);
285
+ }
286
+ }
287
+
288
+ async function getPoolStatusViaSteel(poolUrl, maxSessions) {
289
+ const httpUrl = poolUrl.replace('ws://', 'http://').replace('wss://', 'https://');
290
+ try {
291
+ const res = await fetch(`${httpUrl}/v1/sessions`, { signal: AbortSignal.timeout(3000) });
292
+ if (!res.ok) throw new Error(`/v1/sessions returned ${res.status}`);
293
+ const data = await res.json();
294
+ const activeSessions = (data.sessions || []).filter(s => s.status === 'live' || s.status === 'idle');
295
+ return {
296
+ available: activeSessions.length < maxSessions,
297
+ running: activeSessions.length,
298
+ maxConcurrent: maxSessions,
299
+ queued: 0,
300
+ sessions: activeSessions.map(s => ({ id: s.id, status: s.status, duration: s.duration })),
301
+ driver: 'steel',
302
+ };
303
+ } catch (error) {
304
+ return {
305
+ available: false,
306
+ error: error.message,
307
+ running: 0,
308
+ maxConcurrent: maxSessions,
309
+ queued: 0,
310
+ sessions: [],
311
+ driver: 'steel',
312
+ };
313
+ }
314
+ }
315
+
316
+ /** Creates a Steel session and connects Puppeteer to it. */
317
+ async function connectToSteelPool(poolUrl, retries = 3, delay = 2000) {
318
+ const httpUrl = poolUrl.replace('ws://', 'http://').replace('wss://', 'https://');
319
+
320
+ for (let attempt = 1; attempt <= retries; attempt++) {
321
+ try {
322
+ // Create a new Steel session
323
+ const sessionRes = await fetch(`${httpUrl}/v1/sessions`, {
324
+ method: 'POST',
325
+ headers: { 'Content-Type': 'application/json' },
326
+ body: JSON.stringify({}),
327
+ signal: AbortSignal.timeout(15000),
328
+ });
329
+ if (!sessionRes.ok) throw new Error(`Steel session creation failed: ${sessionRes.status}`);
330
+ const session = await sessionRes.json();
331
+
332
+ // Rewrite the internal WS URL (0.0.0.0:3000) to match our host:port
333
+ const wsUrl = poolUrl.endsWith('/') ? poolUrl : poolUrl + '/';
334
+
335
+ const browser = await puppeteer.connect({
336
+ browserWSEndpoint: wsUrl,
337
+ timeout: 30000,
338
+ });
339
+
340
+ // Track session for cleanup
341
+ const browserId = browser.wsEndpoint();
342
+ trackSteelSession(poolUrl, browserId, session.id);
343
+
344
+ return browser;
345
+ } catch (error) {
346
+ if (attempt === retries) {
347
+ throw new Error(`Failed to connect to Steel pool: ${error.message}`);
348
+ }
349
+ log('🔄', `Attempt ${attempt}/${retries} failed, retrying...`);
350
+ await sleep(delay);
351
+ }
352
+ }
353
+ }
354
+
355
+ /**
356
+ * Releases a Steel session after browser disconnect.
357
+ * Call this in the finally block of test execution.
358
+ */
359
+ export async function releaseSteelSession(poolUrl, browser) {
360
+ if (!browser) return;
361
+ const browserId = browser.wsEndpoint();
362
+ const sessionId = getSteelSessionId(poolUrl, browserId);
363
+ if (!sessionId) return;
364
+
365
+ const httpUrl = poolUrl.replace('ws://', 'http://').replace('wss://', 'https://');
366
+ try {
367
+ await fetch(`${httpUrl}/v1/sessions/${sessionId}/release`, {
368
+ method: 'POST',
369
+ signal: AbortSignal.timeout(5000),
370
+ });
371
+ } catch { /* best effort */ }
372
+ removeSteelSession(poolUrl, browserId);
373
+ }
374
+
375
+ // ── Public API ────────────────────────────────────────────────────────────────
376
+
377
+ /** Waits for the pool to become available */
378
+ export async function waitForPool(poolUrl, maxWaitMs = 30000, options = {}) {
25
379
  const start = Date.now();
26
380
 
27
381
  while (Date.now() - start < maxWaitMs) {
28
382
  try {
29
- const res = await fetch(pressureUrl);
30
- if (res.ok) {
31
- const data = await res.json();
32
- if (data.pressure?.isAvailable) {
33
- return data.pressure;
34
- }
35
- log('⏳', `Pool busy (${data.pressure.running}/${data.pressure.maxConcurrent}), waiting...`);
36
- }
383
+ const status = await getPoolStatus(poolUrl, options);
384
+ if (status.available) return status;
385
+ log('⏳', `Pool busy (${status.running}/${status.maxConcurrent}), waiting...`);
37
386
  } catch {
38
387
  // Pool not ready
39
388
  }
@@ -42,12 +391,26 @@ export async function waitForPool(poolUrl, maxWaitMs = 30000) {
42
391
  throw new Error(`Chrome Pool unavailable after ${maxWaitMs / 1000}s. Verify the container is running.`);
43
392
  }
44
393
 
45
- /** Connects to the pool with retries */
394
+ /** Connects to the pool with retries. For Steel pools, creates a session first. */
46
395
  export async function connectToPool(poolUrl, retries = 3, delay = 2000) {
396
+ const driver = getCachedDriver(poolUrl);
397
+ if (driver === 'steel') {
398
+ return connectToSteelPool(poolUrl, retries, delay);
399
+ }
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
+
47
410
  for (let attempt = 1; attempt <= retries; attempt++) {
48
411
  try {
49
412
  return await puppeteer.connect({
50
- browserWSEndpoint: poolUrl,
413
+ browserWSEndpoint: wsEndpoint,
51
414
  timeout: 30000,
52
415
  });
53
416
  } catch (error) {
@@ -60,17 +423,56 @@ export async function connectToPool(poolUrl, retries = 3, delay = 2000) {
60
423
  }
61
424
  }
62
425
 
426
+ /**
427
+ * Disconnects from a pool, releasing any driver-specific resources.
428
+ * For Steel pools, releases the session via REST API.
429
+ */
430
+ export async function disconnectFromPool(browser, poolUrl) {
431
+ if (!browser) return;
432
+ const driver = getCachedDriver(poolUrl);
433
+ if (driver === 'steel') {
434
+ await releaseSteelSession(poolUrl, browser);
435
+ }
436
+ try { await browser.disconnect(); } catch { /* */ }
437
+ }
438
+
63
439
  /** Generates docker-compose.yml and starts the pool */
64
440
  export function startPool(config, cwd = null) {
65
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
+
66
465
  const poolDir = path.join(cwd, '.e2e-pool');
67
466
 
68
467
  if (!fs.existsSync(poolDir)) {
69
468
  fs.mkdirSync(poolDir, { recursive: true });
70
469
  }
71
470
 
72
- // Read template and interpolate variables
73
- 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);
74
476
  let template = fs.readFileSync(templatePath, 'utf-8');
75
477
  template = template.replace(/\$\{PORT\}/g, String(config.poolPort || 3333));
76
478
  template = template.replace(/\$\{MAX_SESSIONS\}/g, String(config.maxSessions || 5));
@@ -87,14 +489,37 @@ export function startPool(config, cwd = null) {
87
489
  }
88
490
  }
89
491
 
90
- log('🐳', 'Starting Chrome Pool...');
91
- execFileSync('docker', ['compose', '-f', composePath, 'up', '-d'], { stdio: 'inherit' });
92
- 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}`);
93
511
  }
94
512
 
95
513
  /** Stops the pool */
96
514
  export function stopPool(config, cwd = null) {
97
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
+
98
523
  const composePath = path.join(cwd, '.e2e-pool', 'docker-compose.yml');
99
524
  if (!fs.existsSync(composePath)) {
100
525
  log('⚠️', '.e2e-pool/docker-compose.yml not found');
@@ -112,26 +537,27 @@ export function restartPool(config, cwd = null) {
112
537
  startPool(config, cwd);
113
538
  }
114
539
 
115
- /** Gets pool status */
116
- export async function getPoolStatus(poolUrl) {
117
- const poolHttpUrl = poolUrl.replace('ws://', 'http://').replace('wss://', 'https://');
540
+ /**
541
+ * Gets pool status using the appropriate driver.
542
+ * @param {string} poolUrl - WebSocket URL of the pool
543
+ * @param {object} [options] - { poolDriver: 'auto'|'browserless'|'cdp'|'lightpanda'|'obscura'|'steel', maxSessions: number }
544
+ */
545
+ export async function getPoolStatus(poolUrl, options = {}) {
546
+ const { poolDriver = 'auto', maxSessions = 10 } = options;
118
547
 
119
- try {
120
- const [pressureRes, sessionsRes] = await Promise.all([
121
- fetch(`${poolHttpUrl}/pressure`),
122
- fetch(`${poolHttpUrl}/sessions`),
123
- ]);
548
+ const driver = await resolveDriver(poolUrl, poolDriver);
549
+
550
+ if (driver === 'steel') {
551
+ return getPoolStatusViaSteel(poolUrl, maxSessions);
552
+ }
124
553
 
125
- const pressure = pressureRes.ok ? await pressureRes.json() : null;
126
- const sessions = sessionsRes.ok ? await sessionsRes.json() : null;
554
+ if (driver === 'lightpanda' || driver === 'obscura' || driver === 'cdp') {
555
+ return getPoolStatusViaCDP(poolUrl, maxSessions, driver);
556
+ }
127
557
 
128
- return {
129
- available: pressure?.pressure?.isAvailable ?? false,
130
- running: pressure?.pressure?.running ?? 0,
131
- maxConcurrent: pressure?.pressure?.maxConcurrent ?? 0,
132
- queued: pressure?.pressure?.queued ?? 0,
133
- sessions: sessions || [],
134
- };
558
+ // Browserless driver
559
+ try {
560
+ return await getPoolStatusViaBrowserless(poolUrl);
135
561
  } catch (error) {
136
562
  return {
137
563
  available: false,
@@ -140,6 +566,7 @@ export async function getPoolStatus(poolUrl) {
140
566
  maxConcurrent: 0,
141
567
  queued: 0,
142
568
  sessions: [],
569
+ driver: 'browserless',
143
570
  };
144
571
  }
145
572
  }
package/src/reporter.js CHANGED
@@ -158,7 +158,10 @@ export async function persistRun(report, config, suiteName) {
158
158
 
159
159
  try {
160
160
  const projectId = ensureProject(config._cwd, config.projectName, config.screenshotsDir, config.testsDir);
161
- runDbId = saveRunToDb(projectId, report, runId, suiteName || null, config.triggeredBy || null);
161
+ // Derive actual pool driver from test results (resolves 'auto' to real driver)
162
+ const drivers = [...new Set((report.results || []).map(r => r.poolDriver).filter(Boolean))];
163
+ const resolvedDriver = drivers.length === 1 ? drivers[0] : drivers.length > 1 ? drivers.join(',') : config.poolDriver || null;
164
+ runDbId = saveRunToDb(projectId, report, runId, suiteName || null, config.triggeredBy || null, resolvedDriver);
162
165
 
163
166
  // Fire-and-forget: learn from this run (never blocks or crashes the runner)
164
167
  if (config.learningsEnabled !== false) {