@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.
- package/.claude-plugin/marketplace.json +37 -6
- package/.claude-plugin/plugin.json +17 -3
- package/LICENSE +190 -0
- package/README.md +151 -527
- package/agents/test-creator.md +4 -2
- package/agents/test-improver.md +5 -3
- package/bin/cli.js +84 -20
- package/commands/capture.md +45 -0
- 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 +321 -14
- package/src/ai-generate.js +81 -0
- package/src/app-pool.js +339 -0
- package/src/config.js +131 -7
- package/src/dashboard.js +209 -11
- package/src/db.js +74 -7
- package/src/index.js +6 -4
- package/src/learner-sqlite.js +154 -0
- package/src/learner.js +70 -3
- package/src/mcp-tools.js +259 -34
- package/src/module-analysis.js +247 -0
- package/src/module-resolver.js +35 -2
- package/src/narrate.js +42 -1
- package/src/pool-manager.js +68 -17
- package/src/pool.js +464 -37
- package/src/reporter.js +4 -1
- package/src/runner.js +410 -63
- package/src/visual-diff.js +515 -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 +62 -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 +20 -0
- package/templates/dashboard/js/view-live.js +240 -9
- package/templates/dashboard/js/view-runs.js +540 -94
- 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 +36 -0
- package/templates/dashboard/styles/base.css +489 -53
- package/templates/dashboard/styles/components.css +719 -77
- package/templates/dashboard/styles/view-live.css +463 -59
- package/templates/dashboard/styles/view-runs.css +793 -155
- 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 +369 -56
- package/templates/dashboard.html +5375 -901
- 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
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
30
|
-
if (
|
|
31
|
-
|
|
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:
|
|
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
|
-
//
|
|
73
|
-
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);
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
/**
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
548
|
+
const driver = await resolveDriver(poolUrl, poolDriver);
|
|
549
|
+
|
|
550
|
+
if (driver === 'steel') {
|
|
551
|
+
return getPoolStatusViaSteel(poolUrl, maxSessions);
|
|
552
|
+
}
|
|
124
553
|
|
|
125
|
-
|
|
126
|
-
|
|
554
|
+
if (driver === 'lightpanda' || driver === 'obscura' || driver === 'cdp') {
|
|
555
|
+
return getPoolStatusViaCDP(poolUrl, maxSessions, driver);
|
|
556
|
+
}
|
|
127
557
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
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) {
|