@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/.claude-plugin/marketplace.json +37 -6
- package/.claude-plugin/plugin.json +17 -3
- package/LICENSE +190 -0
- package/README.md +61 -526
- package/bin/cli.js +5 -4
- package/commands/capture.md +45 -0
- package/package.json +1 -1
- package/src/actions.js +151 -0
- package/src/ai-generate.js +81 -0
- package/src/app-pool.js +339 -0
- package/src/config.js +125 -7
- package/src/dashboard.js +75 -8
- package/src/db.js +63 -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 +251 -32
- package/src/narrate.js +28 -0
- package/src/pool-manager.js +22 -16
- package/src/pool.js +301 -31
- package/src/reporter.js +4 -1
- package/src/runner.js +335 -55
- package/src/visual-diff.js +446 -0
- package/templates/dashboard/js/api.js +2 -0
- package/templates/dashboard/js/utils.js +20 -0
- package/templates/dashboard/js/view-live.js +40 -2
- package/templates/dashboard/js/view-runs.js +161 -57
- package/templates/dashboard/js/websocket.js +6 -0
- package/templates/dashboard/styles/components.css +7 -0
- package/templates/dashboard/styles/view-live.css +24 -1
- package/templates/dashboard/styles/view-runs.css +36 -0
- package/templates/dashboard/template.html +24 -9
- package/templates/dashboard.html +322 -310
package/src/pool.js
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
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 (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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
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
|
-
}
|
|
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
|
-
/**
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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
|
-
|
|
126
|
-
|
|
393
|
+
if (driver === 'steel') {
|
|
394
|
+
return getPoolStatusViaSteel(poolUrl, maxSessions);
|
|
395
|
+
}
|
|
127
396
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
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) {
|