@matware/e2e-runner 1.3.1 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/.claude-plugin/marketplace.json +4 -4
  2. package/.claude-plugin/plugin.json +2 -2
  3. package/LICENSE +1 -1
  4. package/README.md +491 -225
  5. package/agents/test-creator.md +4 -2
  6. package/agents/test-improver.md +7 -4
  7. package/bin/cli.js +93 -19
  8. package/package.json +4 -3
  9. package/skills/e2e-testing/SKILL.md +5 -3
  10. package/skills/e2e-testing/references/action-types.md +35 -18
  11. package/skills/e2e-testing/references/test-json-format.md +23 -0
  12. package/skills/e2e-testing/references/troubleshooting.md +2 -26
  13. package/src/actions.js +181 -15
  14. package/src/config.js +6 -0
  15. package/src/dashboard.js +185 -9
  16. package/src/db.js +26 -0
  17. package/src/mcp-tools.js +238 -69
  18. package/src/module-analysis.js +247 -0
  19. package/src/module-resolver.js +35 -2
  20. package/src/narrate.js +33 -1
  21. package/src/pool-manager.js +46 -1
  22. package/src/pool.js +177 -20
  23. package/src/runner.js +144 -19
  24. package/src/visual-diff.js +74 -4
  25. package/src/websocket.js +14 -3
  26. package/src/wizard.js +184 -0
  27. package/templates/build-dashboard.js +3 -0
  28. package/templates/dashboard/js/api.js +60 -3
  29. package/templates/dashboard/js/init.js +46 -0
  30. package/templates/dashboard/js/keyboard.js +8 -7
  31. package/templates/dashboard/js/quicksearch.js +277 -0
  32. package/templates/dashboard/js/state.js +61 -7
  33. package/templates/dashboard/js/toast.js +1 -1
  34. package/templates/dashboard/js/utils.js +23 -2
  35. package/templates/dashboard/js/view-live.js +235 -42
  36. package/templates/dashboard/js/view-runs.js +469 -42
  37. package/templates/dashboard/js/view-tests.js +157 -16
  38. package/templates/dashboard/js/view-tools.js +234 -0
  39. package/templates/dashboard/js/view-watch.js +2 -2
  40. package/templates/dashboard/js/websocket.js +33 -3
  41. package/templates/dashboard/styles/base.css +489 -53
  42. package/templates/dashboard/styles/components.css +736 -84
  43. package/templates/dashboard/styles/view-live.css +459 -78
  44. package/templates/dashboard/styles/view-runs.css +826 -177
  45. package/templates/dashboard/styles/view-tests.css +440 -77
  46. package/templates/dashboard/styles/view-tools.css +206 -0
  47. package/templates/dashboard/styles/view-watch.css +198 -41
  48. package/templates/dashboard/template.html +356 -58
  49. package/templates/dashboard.html +5354 -722
  50. package/templates/docker-compose-lightpanda.yml +7 -0
@@ -14,6 +14,7 @@
14
14
  import fs from 'fs';
15
15
  import path from 'path';
16
16
  import { KNOWN_ACTION_TYPES } from './actions.js';
17
+ import { KNOWN_DRIVERS } from './pool.js';
17
18
 
18
19
  /**
19
20
  * Loads all module definitions from a directory.
@@ -170,8 +171,17 @@ function resolveActions(actions, registry, contextParams = {}, visited = new Set
170
171
  const moduleActions = moduleDef.actions || [];
171
172
  const substituted = moduleActions.map(a => {
172
173
  if (a.$use) {
173
- // Nested $use — pass through for recursive resolution
174
- return { ...a, params: { ...mergedParams, ...a.params } };
174
+ // Nested $use — resolve {{param}} placeholders in the nested call's
175
+ // params against THIS module's scope (its merged params + defaults)
176
+ // so a module can forward its own params/defaults to a module it
177
+ // $uses. Then merge over the inherited context params as fallback.
178
+ const nestedParams = {};
179
+ for (const [k, v] of Object.entries(a.params || {})) {
180
+ nestedParams[k] = typeof v === 'string'
181
+ ? substituteParams(v, mergedParams, moduleDef.params)
182
+ : v;
183
+ }
184
+ return { ...a, params: { ...mergedParams, ...nestedParams } };
175
185
  }
176
186
  return substituteActionParams(a, mergedParams, moduleDef.params, moduleName);
177
187
  });
@@ -307,4 +317,27 @@ export function validateActionTypes(data, context) {
307
317
  const details = unknown.map(u => `"${u.type}" in ${u.location}`).join(', ');
308
318
  throw new Error(`Unknown action type(s) in ${context}: ${details}`);
309
319
  }
320
+
321
+ // Validate per-test driver / fallbackDriver fields
322
+ const knownExceptAuto = [...KNOWN_DRIVERS].filter(d => d !== 'auto');
323
+ for (const test of data.tests || []) {
324
+ if (test.driver !== undefined && !knownExceptAuto.includes(test.driver)) {
325
+ throw new Error(
326
+ `Invalid driver "${test.driver}" in test "${test.name}" (${context}). ` +
327
+ `Allowed: ${knownExceptAuto.join(', ')}.`
328
+ );
329
+ }
330
+ if (test.fallbackDriver !== undefined && !knownExceptAuto.includes(test.fallbackDriver)) {
331
+ throw new Error(
332
+ `Invalid fallbackDriver "${test.fallbackDriver}" in test "${test.name}" (${context}). ` +
333
+ `Allowed: ${knownExceptAuto.join(', ')}.`
334
+ );
335
+ }
336
+ if (test.fallbackDriver !== undefined && test.driver === undefined) {
337
+ throw new Error(
338
+ `Test "${test.name}" (${context}) declares fallbackDriver without driver. ` +
339
+ `fallbackDriver only applies when driver is set.`
340
+ );
341
+ }
342
+ }
310
343
  }
package/src/narrate.js CHANGED
@@ -36,20 +36,35 @@ export function narrateAction(action, result) {
36
36
  return `Typed "${masked}" into "${selector}"${time}`;
37
37
  }
38
38
 
39
- case 'wait':
39
+ case 'wait': {
40
+ const goneTarget = typeof action.gone === 'string' ? action.gone : (action.gone === true ? (selector || (text ? `text "${text}"` : null)) : null);
41
+ if (goneTarget) return `Waited for ${goneTarget.startsWith('text ') ? goneTarget : `"${goneTarget}"`} to disappear${time}`;
40
42
  if (selector) return `Waited for "${selector}" to appear${time}`;
41
43
  if (text) return `Waited for text "${text}" to appear${time}`;
42
44
  return `Waited ${value}ms`;
45
+ }
43
46
 
44
47
  case 'screenshot':
45
48
  if (result.result?.screenshot) {
46
49
  return `Captured screenshot: ${result.result.screenshot}`;
47
50
  }
51
+ if (result.result?.skipped) {
52
+ const reason = result.result.skipped === 'blank-page'
53
+ ? 'page was blank'
54
+ : 'render looked blank';
55
+ return `Skipped screenshot (${reason})`;
56
+ }
48
57
  return `Captured screenshot${value ? `: ${value}` : ''}`;
49
58
 
50
59
  case 'assert_text':
51
60
  return `Verified text "${text}" is present on page${time}`;
52
61
 
62
+ case 'assert_no_text':
63
+ return `Verified text "${text}" is NOT present on page${time}`;
64
+
65
+ case 'assert_text_in':
66
+ return `Verified "${selector}" contains text "${text}"${value === 'exact' ? ' (exact)' : ''}${time}`;
67
+
53
68
  case 'assert_url':
54
69
  return `Verified URL contains "${value}"${time}`;
55
70
 
@@ -123,6 +138,9 @@ export function narrateAction(action, result) {
123
138
  case 'click_option':
124
139
  return `Clicked dropdown option "${text}"${time}`;
125
140
 
141
+ case 'select_combobox':
142
+ return `Selected "${text || action.option}" from combobox${action.filter ? ` (filtered "${action.filter}")` : ''}${time}`;
143
+
126
144
  case 'focus_autocomplete':
127
145
  return `Focused autocomplete labeled "${text}"${time}`;
128
146
 
@@ -174,6 +192,15 @@ export function narrateAction(action, result) {
174
192
  return `Visual comparison against "${value}": ${pct}${time}`;
175
193
  }
176
194
 
195
+ case 'gql': {
196
+ const query = (value || '').replace(/\s+/g, ' ').trim();
197
+ const snippet = query.length > 60 ? query.slice(0, 57) + '...' : query;
198
+ return `Executed GraphQL: ${snippet}${selector ? ' (asserted response)' : ''}${time}`;
199
+ }
200
+
201
+ case 'wait_network_idle':
202
+ return `Waited for network idle (${value || 500}ms)${time}`;
203
+
177
204
  case 'open_tab':
178
205
  return `Opened new tab${text ? ` "${text}"` : ''} → ${value}${time}`;
179
206
 
@@ -211,6 +238,8 @@ function describeIntent(action) {
211
238
  return `Wait ${value}ms`;
212
239
  case 'screenshot': return 'Capture screenshot';
213
240
  case 'assert_text': return `Assert text "${text}" present`;
241
+ case 'assert_no_text': return `Assert text "${text}" NOT present`;
242
+ case 'assert_text_in': return `Assert "${selector}" contains "${text}"`;
214
243
  case 'assert_url': return `Assert URL contains "${value}"`;
215
244
  case 'assert_visible': return `Assert "${selector}" visible`;
216
245
  case 'assert_count': return `Assert "${selector}" count = ${value}`;
@@ -232,6 +261,7 @@ function describeIntent(action) {
232
261
  case 'type_react': return `Type into React input "${selector}"`;
233
262
  case 'click_regex': return `Click element matching /${text}/i`;
234
263
  case 'click_option': return `Click option "${text}"`;
264
+ case 'select_combobox': return `Select "${text || action.option}" from combobox`;
235
265
  case 'focus_autocomplete': return `Focus autocomplete "${text}"`;
236
266
  case 'click_chip': return `Click chip "${text}"`;
237
267
  case 'set_storage': return `Set ${selector === 'session' ? 'sessionStorage' : 'localStorage'} key "${value?.split('=')[0] || value}"`;
@@ -246,6 +276,8 @@ function describeIntent(action) {
246
276
  case 'click_in_context': return `Click "${selector}" in context of "${text}"`;
247
277
  case 'evaluate': return 'Execute JS';
248
278
  case 'assert_visual': return `Visual compare against "${value}"`;
279
+ case 'gql': return 'Execute GraphQL query';
280
+ case 'wait_network_idle': return 'Wait for network idle';
249
281
  case 'open_tab': return `Open new tab → ${value}`;
250
282
  case 'switch_tab': return `Switch to tab "${value}"`;
251
283
  case 'close_tab': return `Close tab${value ? ` "${value}"` : ''}`;
@@ -11,7 +11,7 @@
11
11
  * subsequent calls factor in connections that are in-flight.
12
12
  */
13
13
 
14
- import { getPoolStatus, connectToPool } from './pool.js';
14
+ import { getPoolStatus, connectToPool, getCachedDriver } from './pool.js';
15
15
  import { log, colors as C } from './logger.js';
16
16
 
17
17
  function sleep(ms) {
@@ -201,6 +201,51 @@ export async function selectPool(poolUrls, pollIntervalMs = 2000, maxWaitMs = 60
201
201
  return poolUrls[0];
202
202
  }
203
203
 
204
+ /**
205
+ * Filters pool URLs to those whose detected driver matches `driver`,
206
+ * with explicit opt-in fallback to `fallbackDriver`.
207
+ *
208
+ * Probes all pools once via getAllPoolStatuses() to warm the per-URL driver cache,
209
+ * then filters by getCachedDriver(url). Pools that are unreachable have a null
210
+ * detected driver and are excluded.
211
+ *
212
+ * Throws if no pool matches the requested driver and no usable fallback exists.
213
+ * Pool busyness is NOT a fallback trigger — selectPool() handles capacity waits
214
+ * inside the filtered set.
215
+ *
216
+ * @param {string[]} poolUrls
217
+ * @param {string} driver - Required driver name (e.g. 'obscura')
218
+ * @param {string|null} fallbackDriver - Explicit fallback driver, or null/undefined for hard error
219
+ * @param {object} options - { poolDriver, maxSessions } passed to getPoolStatus
220
+ * @returns {Promise<{urls: string[], driver: string, usedFallback: boolean}>}
221
+ */
222
+ export async function resolvePoolsForTest(poolUrls, driver, fallbackDriver, options = {}) {
223
+ // Warm driver cache for all reachable pools
224
+ await getAllPoolStatuses(poolUrls, options);
225
+
226
+ const matching = poolUrls.filter(url => getCachedDriver(url) === driver);
227
+ if (matching.length > 0) {
228
+ return { urls: matching, driver, usedFallback: false };
229
+ }
230
+
231
+ if (fallbackDriver) {
232
+ const fallbackMatching = poolUrls.filter(url => getCachedDriver(url) === fallbackDriver);
233
+ if (fallbackMatching.length > 0) {
234
+ log('⚠️', `${C.yellow}No pool with driver=${driver}, falling back to ${fallbackDriver}${C.reset}`);
235
+ return { urls: fallbackMatching, driver: fallbackDriver, usedFallback: true };
236
+ }
237
+ throw new Error(
238
+ `No pool available for driver "${driver}" and fallback driver "${fallbackDriver}" also unavailable. ` +
239
+ `Reachable pools: ${poolUrls.map(u => `${u}=${getCachedDriver(u) || 'unreachable'}`).join(', ')}`
240
+ );
241
+ }
242
+
243
+ throw new Error(
244
+ `No pool available for driver "${driver}" and no fallbackDriver specified. ` +
245
+ `Reachable pools: ${poolUrls.map(u => `${u}=${getCachedDriver(u) || 'unreachable'}`).join(', ')}`
246
+ );
247
+ }
248
+
204
249
  /** Convenience: selectPool + connectToPool in one call. */
205
250
  export async function selectAndConnect(config) {
206
251
  const poolUrls = getPoolUrls(config);
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