@matware/e2e-runner 1.3.0 → 1.3.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/src/pool.js CHANGED
@@ -1,7 +1,12 @@
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 (Lightpanda, raw Chrome, etc.) using /json/version health check
8
+ * - "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
5
10
  */
6
11
 
7
12
  import puppeteer from 'puppeteer-core';
@@ -18,22 +23,267 @@ function sleep(ms) {
18
23
  return new Promise(resolve => setTimeout(resolve, ms));
19
24
  }
20
25
 
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`;
26
+ // ── Driver detection cache ────────────────────────────────────────────────────
27
+
28
+ /** Caches detected driver per pool URL to avoid re-probing on every status call. */
29
+ const driverCache = new Map();
30
+
31
+ /** Clears the driver cache (useful for tests or pool restarts). */
32
+ export function clearDriverCache() {
33
+ driverCache.clear();
34
+ }
35
+
36
+ /** Returns the cached driver for a pool URL, or null if not yet detected. */
37
+ export function getCachedDriver(poolUrl) {
38
+ return driverCache.get(poolUrl) || null;
39
+ }
40
+
41
+ /**
42
+ * Detects the pool driver by probing HTTP endpoints.
43
+ * Probe order: /pressure (browserless) → /v1/sessions (steel) → fallback (cdp).
44
+ */
45
+ async function detectPoolDriver(poolUrl) {
46
+ if (driverCache.has(poolUrl)) return driverCache.get(poolUrl);
47
+
48
+ const httpUrl = poolUrl.replace('ws://', 'http://').replace('wss://', 'https://');
49
+
50
+ // Probe browserless
51
+ try {
52
+ const res = await fetch(`${httpUrl}/pressure`, { signal: AbortSignal.timeout(3000) });
53
+ if (res.ok) {
54
+ const data = await res.json();
55
+ if (data.pressure !== undefined) {
56
+ driverCache.set(poolUrl, 'browserless');
57
+ return 'browserless';
58
+ }
59
+ }
60
+ } catch { /* not browserless */ }
61
+
62
+ // Probe Steel
63
+ try {
64
+ const res = await fetch(`${httpUrl}/v1/sessions`, { signal: AbortSignal.timeout(3000) });
65
+ if (res.ok) {
66
+ const data = await res.json();
67
+ if (data.sessions !== undefined) {
68
+ driverCache.set(poolUrl, 'steel');
69
+ return 'steel';
70
+ }
71
+ }
72
+ } catch { /* not steel */ }
73
+
74
+ // Fallback: generic CDP
75
+ driverCache.set(poolUrl, 'cdp');
76
+ return 'cdp';
77
+ }
78
+
79
+ /**
80
+ * Resolves the effective driver string.
81
+ * Maps config values: 'auto' → detect, 'lightpanda' → 'cdp', explicit → pass through.
82
+ */
83
+ async function resolveDriver(poolUrl, poolDriver) {
84
+ if (!poolDriver || poolDriver === 'auto') return detectPoolDriver(poolUrl);
85
+ if (poolDriver === 'lightpanda') return 'cdp';
86
+ return poolDriver;
87
+ }
88
+
89
+ // ── CDP driver: status via /json/version health check ─────────────────────────
90
+
91
+ /**
92
+ * Local session tracker for CDP pools (no remote management API).
93
+ * Maps poolUrl → Set of session IDs currently in use.
94
+ */
95
+ const cdpSessions = new Map();
96
+
97
+ export function trackCdpSession(poolUrl, sessionId) {
98
+ if (!cdpSessions.has(poolUrl)) cdpSessions.set(poolUrl, new Set());
99
+ cdpSessions.get(poolUrl).add(sessionId);
100
+ }
101
+
102
+ export function releaseCdpSession(poolUrl, sessionId) {
103
+ const sessions = cdpSessions.get(poolUrl);
104
+ if (sessions) {
105
+ sessions.delete(sessionId);
106
+ if (sessions.size === 0) cdpSessions.delete(poolUrl);
107
+ }
108
+ }
109
+
110
+ function getCdpSessionCount(poolUrl) {
111
+ return cdpSessions.get(poolUrl)?.size || 0;
112
+ }
113
+
114
+ async function getPoolStatusViaCDP(poolUrl, maxSessions) {
115
+ const httpUrl = poolUrl.replace('ws://', 'http://').replace('wss://', 'https://');
116
+ try {
117
+ const res = await fetch(`${httpUrl}/json/version`, { signal: AbortSignal.timeout(3000) });
118
+ if (!res.ok) throw new Error(`/json/version returned ${res.status}`);
119
+
120
+ const running = getCdpSessionCount(poolUrl);
121
+ return {
122
+ available: running < maxSessions,
123
+ running,
124
+ maxConcurrent: maxSessions,
125
+ queued: 0,
126
+ sessions: [],
127
+ driver: 'cdp',
128
+ };
129
+ } catch (error) {
130
+ return {
131
+ available: false,
132
+ error: error.message,
133
+ running: 0,
134
+ maxConcurrent: maxSessions,
135
+ queued: 0,
136
+ sessions: [],
137
+ driver: 'cdp',
138
+ };
139
+ }
140
+ }
141
+
142
+ // ── Browserless driver: status via /pressure + /sessions ──────────────────────
143
+
144
+ async function getPoolStatusViaBrowserless(poolUrl) {
145
+ const httpUrl = poolUrl.replace('ws://', 'http://').replace('wss://', 'https://');
146
+ const [pressureRes, sessionsRes] = await Promise.all([
147
+ fetch(`${httpUrl}/pressure`),
148
+ fetch(`${httpUrl}/sessions`),
149
+ ]);
150
+
151
+ const pressure = pressureRes.ok ? await pressureRes.json() : null;
152
+ const sessions = sessionsRes.ok ? await sessionsRes.json() : null;
153
+
154
+ return {
155
+ available: pressure?.pressure?.isAvailable ?? false,
156
+ running: pressure?.pressure?.running ?? 0,
157
+ maxConcurrent: pressure?.pressure?.maxConcurrent ?? 0,
158
+ queued: pressure?.pressure?.queued ?? 0,
159
+ sessions: sessions || [],
160
+ driver: 'browserless',
161
+ };
162
+ }
163
+
164
+ // ── Steel driver: status via /v1/sessions REST API ────────────────────────────
165
+
166
+ /**
167
+ * Tracks Steel session IDs created by this process so we can release them.
168
+ * Maps poolUrl → Map<browserId, steelSessionId>.
169
+ */
170
+ const steelSessionMap = new Map();
171
+
172
+ function trackSteelSession(poolUrl, browserId, steelSessionId) {
173
+ if (!steelSessionMap.has(poolUrl)) steelSessionMap.set(poolUrl, new Map());
174
+ steelSessionMap.get(poolUrl).set(browserId, steelSessionId);
175
+ }
176
+
177
+ function getSteelSessionId(poolUrl, browserId) {
178
+ return steelSessionMap.get(poolUrl)?.get(browserId) || null;
179
+ }
180
+
181
+ function removeSteelSession(poolUrl, browserId) {
182
+ const sessions = steelSessionMap.get(poolUrl);
183
+ if (sessions) {
184
+ sessions.delete(browserId);
185
+ if (sessions.size === 0) steelSessionMap.delete(poolUrl);
186
+ }
187
+ }
188
+
189
+ async function getPoolStatusViaSteel(poolUrl, maxSessions) {
190
+ const httpUrl = poolUrl.replace('ws://', 'http://').replace('wss://', 'https://');
191
+ try {
192
+ const res = await fetch(`${httpUrl}/v1/sessions`, { signal: AbortSignal.timeout(3000) });
193
+ if (!res.ok) throw new Error(`/v1/sessions returned ${res.status}`);
194
+ const data = await res.json();
195
+ const activeSessions = (data.sessions || []).filter(s => s.status === 'live' || s.status === 'idle');
196
+ return {
197
+ available: activeSessions.length < maxSessions,
198
+ running: activeSessions.length,
199
+ maxConcurrent: maxSessions,
200
+ queued: 0,
201
+ sessions: activeSessions.map(s => ({ id: s.id, status: s.status, duration: s.duration })),
202
+ driver: 'steel',
203
+ };
204
+ } catch (error) {
205
+ return {
206
+ available: false,
207
+ error: error.message,
208
+ running: 0,
209
+ maxConcurrent: maxSessions,
210
+ queued: 0,
211
+ sessions: [],
212
+ driver: 'steel',
213
+ };
214
+ }
215
+ }
216
+
217
+ /** Creates a Steel session and connects Puppeteer to it. */
218
+ async function connectToSteelPool(poolUrl, retries = 3, delay = 2000) {
219
+ const httpUrl = poolUrl.replace('ws://', 'http://').replace('wss://', 'https://');
220
+
221
+ for (let attempt = 1; attempt <= retries; attempt++) {
222
+ try {
223
+ // Create a new Steel session
224
+ const sessionRes = await fetch(`${httpUrl}/v1/sessions`, {
225
+ method: 'POST',
226
+ headers: { 'Content-Type': 'application/json' },
227
+ body: JSON.stringify({}),
228
+ signal: AbortSignal.timeout(15000),
229
+ });
230
+ if (!sessionRes.ok) throw new Error(`Steel session creation failed: ${sessionRes.status}`);
231
+ const session = await sessionRes.json();
232
+
233
+ // Rewrite the internal WS URL (0.0.0.0:3000) to match our host:port
234
+ const wsUrl = poolUrl.endsWith('/') ? poolUrl : poolUrl + '/';
235
+
236
+ const browser = await puppeteer.connect({
237
+ browserWSEndpoint: wsUrl,
238
+ timeout: 30000,
239
+ });
240
+
241
+ // Track session for cleanup
242
+ const browserId = browser.wsEndpoint();
243
+ trackSteelSession(poolUrl, browserId, session.id);
244
+
245
+ return browser;
246
+ } catch (error) {
247
+ if (attempt === retries) {
248
+ throw new Error(`Failed to connect to Steel pool: ${error.message}`);
249
+ }
250
+ log('🔄', `Attempt ${attempt}/${retries} failed, retrying...`);
251
+ await sleep(delay);
252
+ }
253
+ }
254
+ }
255
+
256
+ /**
257
+ * Releases a Steel session after browser disconnect.
258
+ * Call this in the finally block of test execution.
259
+ */
260
+ export async function releaseSteelSession(poolUrl, browser) {
261
+ if (!browser) return;
262
+ const browserId = browser.wsEndpoint();
263
+ const sessionId = getSteelSessionId(poolUrl, browserId);
264
+ if (!sessionId) return;
265
+
266
+ const httpUrl = poolUrl.replace('ws://', 'http://').replace('wss://', 'https://');
267
+ try {
268
+ await fetch(`${httpUrl}/v1/sessions/${sessionId}/release`, {
269
+ method: 'POST',
270
+ signal: AbortSignal.timeout(5000),
271
+ });
272
+ } catch { /* best effort */ }
273
+ removeSteelSession(poolUrl, browserId);
274
+ }
275
+
276
+ // ── Public API ────────────────────────────────────────────────────────────────
277
+
278
+ /** Waits for the pool to become available */
279
+ export async function waitForPool(poolUrl, maxWaitMs = 30000, options = {}) {
25
280
  const start = Date.now();
26
281
 
27
282
  while (Date.now() - start < maxWaitMs) {
28
283
  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
- }
284
+ const status = await getPoolStatus(poolUrl, options);
285
+ if (status.available) return status;
286
+ log('⏳', `Pool busy (${status.running}/${status.maxConcurrent}), waiting...`);
37
287
  } catch {
38
288
  // Pool not ready
39
289
  }
@@ -42,8 +292,13 @@ export async function waitForPool(poolUrl, maxWaitMs = 30000) {
42
292
  throw new Error(`Chrome Pool unavailable after ${maxWaitMs / 1000}s. Verify the container is running.`);
43
293
  }
44
294
 
45
- /** Connects to the pool with retries */
295
+ /** Connects to the pool with retries. For Steel pools, creates a session first. */
46
296
  export async function connectToPool(poolUrl, retries = 3, delay = 2000) {
297
+ const driver = getCachedDriver(poolUrl);
298
+ if (driver === 'steel') {
299
+ return connectToSteelPool(poolUrl, retries, delay);
300
+ }
301
+
47
302
  for (let attempt = 1; attempt <= retries; attempt++) {
48
303
  try {
49
304
  return await puppeteer.connect({
@@ -60,6 +315,19 @@ export async function connectToPool(poolUrl, retries = 3, delay = 2000) {
60
315
  }
61
316
  }
62
317
 
318
+ /**
319
+ * Disconnects from a pool, releasing any driver-specific resources.
320
+ * For Steel pools, releases the session via REST API.
321
+ */
322
+ export async function disconnectFromPool(browser, poolUrl) {
323
+ if (!browser) return;
324
+ const driver = getCachedDriver(poolUrl);
325
+ if (driver === 'steel') {
326
+ await releaseSteelSession(poolUrl, browser);
327
+ }
328
+ try { await browser.disconnect(); } catch { /* */ }
329
+ }
330
+
63
331
  /** Generates docker-compose.yml and starts the pool */
64
332
  export function startPool(config, cwd = null) {
65
333
  cwd = cwd || process.cwd();
@@ -112,26 +380,27 @@ export function restartPool(config, cwd = null) {
112
380
  startPool(config, cwd);
113
381
  }
114
382
 
115
- /** Gets pool status */
116
- export async function getPoolStatus(poolUrl) {
117
- const poolHttpUrl = poolUrl.replace('ws://', 'http://').replace('wss://', 'https://');
383
+ /**
384
+ * Gets pool status using the appropriate driver.
385
+ * @param {string} poolUrl - WebSocket URL of the pool
386
+ * @param {object} [options] - { poolDriver: 'auto'|'browserless'|'lightpanda'|'cdp'|'steel', maxSessions: number }
387
+ */
388
+ export async function getPoolStatus(poolUrl, options = {}) {
389
+ const { poolDriver = 'auto', maxSessions = 10 } = options;
118
390
 
119
- try {
120
- const [pressureRes, sessionsRes] = await Promise.all([
121
- fetch(`${poolHttpUrl}/pressure`),
122
- fetch(`${poolHttpUrl}/sessions`),
123
- ]);
391
+ const driver = await resolveDriver(poolUrl, poolDriver);
124
392
 
125
- const pressure = pressureRes.ok ? await pressureRes.json() : null;
126
- const sessions = sessionsRes.ok ? await sessionsRes.json() : null;
393
+ if (driver === 'steel') {
394
+ return getPoolStatusViaSteel(poolUrl, maxSessions);
395
+ }
127
396
 
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
- };
397
+ if (driver === 'cdp') {
398
+ return getPoolStatusViaCDP(poolUrl, maxSessions);
399
+ }
400
+
401
+ // Browserless driver
402
+ try {
403
+ return await getPoolStatusViaBrowserless(poolUrl);
135
404
  } catch (error) {
136
405
  return {
137
406
  available: false,
@@ -140,6 +409,7 @@ export async function getPoolStatus(poolUrl) {
140
409
  maxConcurrent: 0,
141
410
  queued: 0,
142
411
  sessions: [],
412
+ driver: 'browserless',
143
413
  };
144
414
  }
145
415
  }
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) {