@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.
- package/.claude-plugin/marketplace.json +4 -4
- package/.claude-plugin/plugin.json +2 -2
- package/LICENSE +1 -1
- package/README.md +491 -225
- package/agents/test-creator.md +4 -2
- package/agents/test-improver.md +7 -4
- package/bin/cli.js +93 -19
- package/package.json +4 -3
- package/skills/e2e-testing/SKILL.md +5 -3
- package/skills/e2e-testing/references/action-types.md +35 -18
- package/skills/e2e-testing/references/test-json-format.md +23 -0
- package/skills/e2e-testing/references/troubleshooting.md +2 -26
- package/src/actions.js +181 -15
- package/src/config.js +6 -0
- package/src/dashboard.js +185 -9
- package/src/db.js +26 -0
- package/src/mcp-tools.js +238 -69
- package/src/module-analysis.js +247 -0
- package/src/module-resolver.js +35 -2
- package/src/narrate.js +33 -1
- package/src/pool-manager.js +46 -1
- package/src/pool.js +177 -20
- package/src/runner.js +144 -19
- package/src/visual-diff.js +74 -4
- 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/utils.js +23 -2
- package/templates/dashboard/js/view-live.js +235 -42
- package/templates/dashboard/js/view-runs.js +469 -42
- 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 +736 -84
- package/templates/dashboard/styles/view-live.css +459 -78
- package/templates/dashboard/styles/view-runs.css +826 -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 +356 -58
- package/templates/dashboard.html +5354 -722
- package/templates/docker-compose-lightpanda.yml +7 -0
package/src/module-resolver.js
CHANGED
|
@@ -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 —
|
|
174
|
-
|
|
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}"` : ''}`;
|
package/src/pool-manager.js
CHANGED
|
@@ -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 (
|
|
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
|