@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/runner.js
CHANGED
|
@@ -9,18 +9,53 @@ import fs from 'fs';
|
|
|
9
9
|
import path from 'path';
|
|
10
10
|
import http from 'http';
|
|
11
11
|
import https from 'https';
|
|
12
|
-
import { connectToPool } from './pool.js';
|
|
13
|
-
import { getPoolUrls, selectPool, releasePending } from './pool-manager.js';
|
|
14
|
-
import {
|
|
12
|
+
import { connectToPool, getCachedDriver, disconnectFromPool } from './pool.js';
|
|
13
|
+
import { getPoolUrls, selectPool, releasePending, resolvePoolsForTest } from './pool-manager.js';
|
|
14
|
+
import { forkAppInstance, destroyFork, isAppPoolEnabled } from './app-pool.js';
|
|
15
|
+
import { executeAction, pageHasRenderableContent, looksLikeBlankCapture } from './actions.js';
|
|
15
16
|
import { narrateAction } from './narrate.js';
|
|
16
17
|
import { log, colors as C } from './logger.js';
|
|
17
18
|
import { resolveTestData, validateActionTypes } from './module-resolver.js';
|
|
19
|
+
import { compareImages } from './visual-diff.js';
|
|
18
20
|
import { ensureProject, getVariables } from './db.js';
|
|
19
21
|
|
|
20
22
|
function sleep(ms) {
|
|
21
23
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
22
24
|
}
|
|
23
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Best-effort step thumbnail for the storyline view.
|
|
28
|
+
* Captures once in memory, writes to disk AND returns base64 so callers
|
|
29
|
+
* can stream the same frame through the live preview WebSocket.
|
|
30
|
+
* Skips silently on any error so it never breaks a test run.
|
|
31
|
+
*/
|
|
32
|
+
const NO_AUTO_CAPTURE_TYPES = new Set(['screenshot', 'close_tab']);
|
|
33
|
+
async function tryAutoCaptureStep(page, action, idx, testName, effectiveConfig, alreadyCaptured) {
|
|
34
|
+
if (!effectiveConfig.autoCaptureSteps) return null;
|
|
35
|
+
if (NO_AUTO_CAPTURE_TYPES.has(action?.type)) return null;
|
|
36
|
+
if (alreadyCaptured) return null;
|
|
37
|
+
if (!page || (typeof page.isClosed === 'function' && page.isClosed())) return null;
|
|
38
|
+
// Skip auto-capture when the page can't produce a meaningful image —
|
|
39
|
+
// about:blank or fully empty DOM — to stop blank step-*.jpg flooding.
|
|
40
|
+
if (!(await pageHasRenderableContent(page))) return null;
|
|
41
|
+
try {
|
|
42
|
+
const safeName = String(testName).replace(/[^a-zA-Z0-9_\-. ]/g, '_');
|
|
43
|
+
const filename = `step-${safeName}-${String(idx).padStart(3, '0')}-${Date.now()}.jpg`;
|
|
44
|
+
const filepath = path.join(effectiveConfig.screenshotsDir, filename);
|
|
45
|
+
const buf = await page.screenshot({
|
|
46
|
+
type: 'jpeg',
|
|
47
|
+
quality: effectiveConfig.autoCaptureQuality ?? 60,
|
|
48
|
+
fullPage: false,
|
|
49
|
+
encoding: 'binary',
|
|
50
|
+
});
|
|
51
|
+
if (looksLikeBlankCapture(buf, 'jpeg')) return null;
|
|
52
|
+
fs.writeFileSync(filepath, buf);
|
|
53
|
+
return { path: filepath, base64: buf.toString('base64') };
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
24
59
|
/** Simple glob matching with * wildcards for exclude patterns. */
|
|
25
60
|
function matchesExclude(filename, excludePatterns) {
|
|
26
61
|
if (!excludePatterns?.length) return false;
|
|
@@ -115,7 +150,7 @@ function getByPath(obj, dotPath) {
|
|
|
115
150
|
}
|
|
116
151
|
|
|
117
152
|
/** Fetches an auth token by POSTing credentials to a login endpoint. */
|
|
118
|
-
function fetchAuthToken(endpoint, credentials, tokenPath) {
|
|
153
|
+
export function fetchAuthToken(endpoint, credentials, tokenPath) {
|
|
119
154
|
return new Promise((resolve, reject) => {
|
|
120
155
|
const url = new URL(endpoint);
|
|
121
156
|
const transport = url.protocol === 'https:' ? https : http;
|
|
@@ -123,7 +158,7 @@ function fetchAuthToken(endpoint, credentials, tokenPath) {
|
|
|
123
158
|
|
|
124
159
|
const req = transport.request(url, {
|
|
125
160
|
method: 'POST',
|
|
126
|
-
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
|
|
161
|
+
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body), 'Accept': '*/*', 'User-Agent': '@matware/e2e-runner' },
|
|
127
162
|
timeout: 15000,
|
|
128
163
|
}, (res) => {
|
|
129
164
|
let data = '';
|
|
@@ -155,6 +190,14 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
|
|
|
155
190
|
let browser = null;
|
|
156
191
|
let context = null;
|
|
157
192
|
let page = null;
|
|
193
|
+
let cdpSession = null;
|
|
194
|
+
let appFork = null;
|
|
195
|
+
|
|
196
|
+
// ── Multi-tab registry ────────────────────────────────────────────────────
|
|
197
|
+
// Maps label → page. The "default" label is the initial page.
|
|
198
|
+
// activePage tracks the current tab; page always points to it.
|
|
199
|
+
const tabRegistry = new Map();
|
|
200
|
+
let activeTabLabel = 'default';
|
|
158
201
|
|
|
159
202
|
const result = {
|
|
160
203
|
name: test.name,
|
|
@@ -169,8 +212,35 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
|
|
|
169
212
|
const pendingBodies = [];
|
|
170
213
|
|
|
171
214
|
try {
|
|
172
|
-
|
|
215
|
+
// Fork an isolated app instance if app pool is enabled
|
|
216
|
+
let effectiveConfig = config;
|
|
217
|
+
if (isAppPoolEnabled(config)) {
|
|
218
|
+
appFork = await forkAppInstance(config, test.name);
|
|
219
|
+
// Override baseUrl to point to this test's isolated app instance
|
|
220
|
+
// Use dockerBaseUrl when Chrome runs inside Docker (default setup)
|
|
221
|
+
effectiveConfig = { ...config, baseUrl: appFork.dockerBaseUrl };
|
|
222
|
+
result.appFork = { forkId: appFork.forkId, baseUrl: appFork.baseUrl, port: appFork.port, forkTimeMs: appFork.forkTimeMs };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const driverOpts = { poolDriver: config.poolDriver || 'auto', maxSessions: config.maxSessions || 10 };
|
|
226
|
+
|
|
227
|
+
// CLI override (--driver / --fallback-driver) wins over per-test fields.
|
|
228
|
+
const requestedDriver = config.cliDriverOverride || test.driver || null;
|
|
229
|
+
const requestedFallback = config.cliFallbackDriverOverride || test.fallbackDriver || null;
|
|
230
|
+
|
|
231
|
+
let candidatePoolUrls = getPoolUrls(config);
|
|
232
|
+
let driverChoice = null;
|
|
233
|
+
if (requestedDriver) {
|
|
234
|
+
const resolved = await resolvePoolsForTest(candidatePoolUrls, requestedDriver, requestedFallback, driverOpts);
|
|
235
|
+
candidatePoolUrls = resolved.urls;
|
|
236
|
+
driverChoice = { requested: requestedDriver, used: resolved.driver, usedFallback: resolved.usedFallback };
|
|
237
|
+
log('🎯', `${C.dim}${test.name}: driver=${resolved.driver}${resolved.usedFallback ? ' (fallback)' : ''}${C.reset}`);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const chosenPool = await selectPool(candidatePoolUrls, 2000, 60000, driverOpts);
|
|
173
241
|
result.poolUrl = chosenPool;
|
|
242
|
+
result.poolDriver = getCachedDriver(chosenPool);
|
|
243
|
+
if (driverChoice) result.driverChoice = driverChoice;
|
|
174
244
|
const poolLabel = chosenPool.replace('ws://', '').replace('wss://', '');
|
|
175
245
|
const isMultiPool = getPoolUrls(config).length > 1;
|
|
176
246
|
if (isMultiPool) {
|
|
@@ -182,6 +252,46 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
|
|
|
182
252
|
context = await browser.createBrowserContext();
|
|
183
253
|
page = await context.newPage();
|
|
184
254
|
await page.setViewport(config.viewport);
|
|
255
|
+
tabRegistry.set('default', page);
|
|
256
|
+
|
|
257
|
+
// CDP screencast — streams browser frames as JPEG to the dashboard
|
|
258
|
+
// Only attempt on browserless pools; generic CDP pools (Lightpanda) break on createCDPSession
|
|
259
|
+
const poolDriver = getCachedDriver(chosenPool);
|
|
260
|
+
if (config.screencast && poolDriver !== 'cdp') {
|
|
261
|
+
try {
|
|
262
|
+
const raceTimeout = (promise, ms) => Promise.race([
|
|
263
|
+
promise,
|
|
264
|
+
new Promise((_, reject) => { const t = setTimeout(() => reject(new Error('CDP timeout')), ms); t.unref(); }),
|
|
265
|
+
]);
|
|
266
|
+
cdpSession = await raceTimeout(page.createCDPSession(), 5000);
|
|
267
|
+
let frameCount = 0;
|
|
268
|
+
const everyNth = config.screencastEveryNthFrame || 1;
|
|
269
|
+
cdpSession.on('Page.screencastFrame', (frame) => {
|
|
270
|
+
frameCount++;
|
|
271
|
+
cdpSession.send('Page.screencastFrameAck', { sessionId: frame.sessionId }).catch(() => {});
|
|
272
|
+
if (everyNth > 1 && frameCount % everyNth !== 0) return;
|
|
273
|
+
progressFn({
|
|
274
|
+
event: 'test:frame',
|
|
275
|
+
name: test.name,
|
|
276
|
+
data: frame.data,
|
|
277
|
+
metadata: frame.metadata,
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
await raceTimeout(cdpSession.send('Page.startScreencast', {
|
|
281
|
+
format: 'jpeg',
|
|
282
|
+
quality: config.screencastQuality || 60,
|
|
283
|
+
maxWidth: config.screencastMaxWidth || 800,
|
|
284
|
+
maxHeight: config.screencastMaxHeight || 600,
|
|
285
|
+
everyNthFrame: 1,
|
|
286
|
+
}), 5000);
|
|
287
|
+
log('📹', `${C.dim}screencast started for ${test.name} (driver=${poolDriver})${C.reset}`);
|
|
288
|
+
} catch (err) {
|
|
289
|
+
log('⚠️', `${C.amber}screencast failed for ${test.name}: ${err.message} (driver=${poolDriver})${C.reset}`);
|
|
290
|
+
cdpSession = null;
|
|
291
|
+
}
|
|
292
|
+
} else if (config.screencast && poolDriver === 'cdp') {
|
|
293
|
+
log('⚠️', `${C.amber}screencast disabled: pool driver is generic CDP (Lightpanda?), not supported${C.reset}`);
|
|
294
|
+
}
|
|
185
295
|
|
|
186
296
|
page.on('console', (msg) => {
|
|
187
297
|
result.consoleLogs.push({ type: msg.type(), text: msg.text() });
|
|
@@ -225,9 +335,9 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
|
|
|
225
335
|
});
|
|
226
336
|
|
|
227
337
|
// Auto-inject auth token into localStorage (runs BEFORE beforeEach hooks)
|
|
228
|
-
if (
|
|
229
|
-
const storageKey =
|
|
230
|
-
await page.goto(
|
|
338
|
+
if (effectiveConfig.authToken) {
|
|
339
|
+
const storageKey = effectiveConfig.authStorageKey || 'accessToken';
|
|
340
|
+
await page.goto(effectiveConfig.baseUrl, { waitUntil: 'domcontentloaded', timeout: 15000 });
|
|
231
341
|
await page.evaluate((key, token) => {
|
|
232
342
|
localStorage.setItem(key, token);
|
|
233
343
|
}, storageKey, config.authToken);
|
|
@@ -243,14 +353,14 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
|
|
|
243
353
|
|
|
244
354
|
// Run beforeEach hook
|
|
245
355
|
if (hooks.beforeEach?.length) {
|
|
246
|
-
await executeHookActions(page, hooks.beforeEach,
|
|
356
|
+
await executeHookActions(page, hooks.beforeEach, effectiveConfig);
|
|
247
357
|
}
|
|
248
358
|
|
|
249
359
|
// Auto-capture baseline screenshot if test has "expect" (BEFORE actions)
|
|
250
360
|
if (test.expect && page) {
|
|
251
361
|
try {
|
|
252
362
|
const safeName = test.name.replace(/[^a-zA-Z0-9_\-. ]/g, '_');
|
|
253
|
-
const baselinePath = path.join(
|
|
363
|
+
const baselinePath = path.join(effectiveConfig.screenshotsDir, `baseline-${safeName}-${Date.now()}.png`);
|
|
254
364
|
await page.screenshot({ path: baselinePath, fullPage: true });
|
|
255
365
|
result.baselineScreenshot = baselinePath;
|
|
256
366
|
} catch { /* page may not be ready */ }
|
|
@@ -258,8 +368,8 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
|
|
|
258
368
|
|
|
259
369
|
for (let i = 0; i < test.actions.length; i++) {
|
|
260
370
|
const action = test.actions[i];
|
|
261
|
-
const maxActionRetries = action.retries ??
|
|
262
|
-
const actionRetryDelay =
|
|
371
|
+
const maxActionRetries = action.retries ?? effectiveConfig.actionRetries ?? 0;
|
|
372
|
+
const actionRetryDelay = effectiveConfig.actionRetryDelay ?? 500;
|
|
263
373
|
let lastError = null;
|
|
264
374
|
|
|
265
375
|
for (let attempt = 0; attempt <= maxActionRetries; attempt++) {
|
|
@@ -273,20 +383,129 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
|
|
|
273
383
|
throw new Error(`assert_no_network_errors failed: ${result.networkErrors.length} error(s): ${summary}`);
|
|
274
384
|
}
|
|
275
385
|
actionResult = null;
|
|
386
|
+
|
|
387
|
+
// ── Multi-tab actions (intercepted here, not in actions.js) ──────
|
|
388
|
+
} else if (action.type === 'open_tab') {
|
|
389
|
+
const label = action.text || `tab-${tabRegistry.size}`;
|
|
390
|
+
const newPage = await context.newPage();
|
|
391
|
+
await newPage.setViewport(config.viewport);
|
|
392
|
+
tabRegistry.set(label, newPage);
|
|
393
|
+
activeTabLabel = label;
|
|
394
|
+
page = newPage;
|
|
395
|
+
// Navigate inside the new tab
|
|
396
|
+
actionResult = await executeAction(page, action, effectiveConfig);
|
|
397
|
+
|
|
398
|
+
} else if (action.type === 'switch_tab') {
|
|
399
|
+
// value: label, title regex, URL substring, or numeric index
|
|
400
|
+
const target = action.value;
|
|
401
|
+
let found = false;
|
|
402
|
+
|
|
403
|
+
// 1. By label (exact match)
|
|
404
|
+
if (tabRegistry.has(target)) {
|
|
405
|
+
page = tabRegistry.get(target);
|
|
406
|
+
activeTabLabel = target;
|
|
407
|
+
found = true;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// 2. By numeric index
|
|
411
|
+
if (!found && /^\d+$/.test(target)) {
|
|
412
|
+
const idx = parseInt(target);
|
|
413
|
+
const labels = [...tabRegistry.keys()];
|
|
414
|
+
if (idx >= 0 && idx < labels.length) {
|
|
415
|
+
activeTabLabel = labels[idx];
|
|
416
|
+
page = tabRegistry.get(activeTabLabel);
|
|
417
|
+
found = true;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// 3. By title or URL match (substring or regex)
|
|
422
|
+
if (!found) {
|
|
423
|
+
for (const [label, p] of tabRegistry) {
|
|
424
|
+
try {
|
|
425
|
+
const title = await p.title();
|
|
426
|
+
const url = p.url();
|
|
427
|
+
const regex = new RegExp(target, 'i');
|
|
428
|
+
if (regex.test(title) || regex.test(url) || url.includes(target)) {
|
|
429
|
+
page = p;
|
|
430
|
+
activeTabLabel = label;
|
|
431
|
+
found = true;
|
|
432
|
+
break;
|
|
433
|
+
}
|
|
434
|
+
} catch { /* page may be closed */ }
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (!found) {
|
|
439
|
+
throw new Error(`switch_tab failed: no tab matching "${target}" (labels: ${[...tabRegistry.keys()].join(', ')})`);
|
|
440
|
+
}
|
|
441
|
+
// Bring tab to front
|
|
442
|
+
await page.bringToFront();
|
|
443
|
+
actionResult = null;
|
|
444
|
+
|
|
445
|
+
} else if (action.type === 'close_tab') {
|
|
446
|
+
const targetLabel = action.value || activeTabLabel;
|
|
447
|
+
if (targetLabel === 'default' && tabRegistry.size > 1) {
|
|
448
|
+
throw new Error('close_tab: cannot close the default tab while other tabs are open');
|
|
449
|
+
}
|
|
450
|
+
const targetPage = tabRegistry.get(targetLabel);
|
|
451
|
+
if (!targetPage) {
|
|
452
|
+
throw new Error(`close_tab failed: no tab with label "${targetLabel}"`);
|
|
453
|
+
}
|
|
454
|
+
tabRegistry.delete(targetLabel);
|
|
455
|
+
if (!targetPage.isClosed()) {
|
|
456
|
+
await targetPage.close();
|
|
457
|
+
}
|
|
458
|
+
// Switch to the last remaining tab
|
|
459
|
+
if (activeTabLabel === targetLabel) {
|
|
460
|
+
const remaining = [...tabRegistry.keys()];
|
|
461
|
+
activeTabLabel = remaining[remaining.length - 1] || 'default';
|
|
462
|
+
page = tabRegistry.get(activeTabLabel);
|
|
463
|
+
if (page) await page.bringToFront();
|
|
464
|
+
}
|
|
465
|
+
actionResult = null;
|
|
466
|
+
|
|
467
|
+
} else if (action.type === 'assert_tab_count') {
|
|
468
|
+
action.__tabCount = tabRegistry.size;
|
|
469
|
+
actionResult = await executeAction(page, action, effectiveConfig);
|
|
470
|
+
|
|
471
|
+
} else if (action.type === 'wait_for_tab') {
|
|
472
|
+
// Wait for a new tab/popup to be opened (e.g. by window.open, target=_blank)
|
|
473
|
+
const label = action.text || `tab-${tabRegistry.size}`;
|
|
474
|
+
const waitTimeout = action.timeout || config.defaultTimeout || 10000;
|
|
475
|
+
const newTarget = await new Promise((resolve, reject) => {
|
|
476
|
+
const timer = setTimeout(() => reject(new Error(`wait_for_tab: no new tab appeared after ${waitTimeout}ms`)), waitTimeout);
|
|
477
|
+
context.once('targetcreated', (target) => {
|
|
478
|
+
clearTimeout(timer);
|
|
479
|
+
resolve(target);
|
|
480
|
+
});
|
|
481
|
+
});
|
|
482
|
+
const newPage = await newTarget.page();
|
|
483
|
+
if (newPage) {
|
|
484
|
+
await newPage.setViewport(config.viewport);
|
|
485
|
+
tabRegistry.set(label, newPage);
|
|
486
|
+
activeTabLabel = label;
|
|
487
|
+
page = newPage;
|
|
488
|
+
}
|
|
489
|
+
actionResult = null;
|
|
490
|
+
|
|
276
491
|
} else {
|
|
277
|
-
actionResult = await executeAction(page, action,
|
|
492
|
+
actionResult = await executeAction(page, action, effectiveConfig);
|
|
278
493
|
}
|
|
279
494
|
const actionDuration = Date.now() - actionStart;
|
|
495
|
+
const autoShot = await tryAutoCaptureStep(page, action, i, test.name, effectiveConfig, !!actionResult?.screenshot);
|
|
280
496
|
const actionEntry = {
|
|
281
497
|
...action,
|
|
282
498
|
success: true,
|
|
283
499
|
duration: actionDuration,
|
|
284
500
|
result: actionResult,
|
|
285
501
|
};
|
|
502
|
+
if (autoShot) actionEntry.autoScreenshot = autoShot.path;
|
|
286
503
|
if (attempt > 0) actionEntry.actionRetries = attempt;
|
|
287
504
|
actionEntry.narrative = narrateAction(action, actionEntry);
|
|
288
505
|
result.actions.push(actionEntry);
|
|
289
|
-
progressFn({ event: 'test:action', name: test.name, action, actionIndex: i, totalActions: test.actions.length, success: true, duration: actionDuration, narrative: actionEntry.narrative, screenshotPath: actionResult?.screenshot || null });
|
|
506
|
+
progressFn({ event: 'test:action', name: test.name, action, actionIndex: i, totalActions: test.actions.length, success: true, duration: actionDuration, narrative: actionEntry.narrative, screenshotPath: actionResult?.screenshot || null, autoScreenshot: autoShot?.path || null });
|
|
507
|
+
// Stream the auto-capture as a live frame so the storyline player has something to show even when CDP screencast is silent
|
|
508
|
+
if (autoShot?.base64) progressFn({ event: 'test:frame', name: test.name, data: autoShot.base64, source: 'step' });
|
|
290
509
|
lastError = null;
|
|
291
510
|
break;
|
|
292
511
|
} catch (error) {
|
|
@@ -297,16 +516,19 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
|
|
|
297
516
|
continue;
|
|
298
517
|
}
|
|
299
518
|
const actionDuration = Date.now() - actionStart;
|
|
519
|
+
const autoShot = await tryAutoCaptureStep(page, action, i, test.name, effectiveConfig, false);
|
|
300
520
|
const failedEntry = {
|
|
301
521
|
...action,
|
|
302
522
|
success: false,
|
|
303
523
|
duration: actionDuration,
|
|
304
524
|
error: error.message,
|
|
305
525
|
};
|
|
526
|
+
if (autoShot) failedEntry.autoScreenshot = autoShot.path;
|
|
306
527
|
if (maxActionRetries > 0) failedEntry.actionRetries = attempt;
|
|
307
528
|
failedEntry.narrative = narrateAction(action, failedEntry);
|
|
308
529
|
result.actions.push(failedEntry);
|
|
309
|
-
progressFn({ event: 'test:action', name: test.name, action, actionIndex: i, totalActions: test.actions.length, success: false, duration: actionDuration, narrative: failedEntry.narrative, error: error.message });
|
|
530
|
+
progressFn({ event: 'test:action', name: test.name, action, actionIndex: i, totalActions: test.actions.length, success: false, duration: actionDuration, narrative: failedEntry.narrative, error: error.message, autoScreenshot: autoShot?.path || null });
|
|
531
|
+
if (autoShot?.base64) progressFn({ event: 'test:frame', name: test.name, data: autoShot.base64, source: 'step' });
|
|
310
532
|
throw error;
|
|
311
533
|
}
|
|
312
534
|
}
|
|
@@ -323,15 +545,40 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
|
|
|
323
545
|
result.expect = test.expect;
|
|
324
546
|
try {
|
|
325
547
|
const safeName = test.name.replace(/[^a-zA-Z0-9_\-. ]/g, '_');
|
|
326
|
-
const verifyPath = path.join(
|
|
548
|
+
const verifyPath = path.join(effectiveConfig.screenshotsDir, `verify-${safeName}-${Date.now()}.png`);
|
|
327
549
|
await page.screenshot({ path: verifyPath, fullPage: true });
|
|
328
550
|
result.verificationScreenshot = verifyPath;
|
|
551
|
+
|
|
552
|
+
// Auto visual comparison: compare baseline vs verification screenshot
|
|
553
|
+
if (result.baselineScreenshot && result.verificationScreenshot) {
|
|
554
|
+
try {
|
|
555
|
+
const diffPath = path.join(effectiveConfig.screenshotsDir, `diff-${safeName}-${Date.now()}.png`);
|
|
556
|
+
const threshold = effectiveConfig.verificationThreshold ?? 0.02;
|
|
557
|
+
const visualResult = compareImages(result.baselineScreenshot, result.verificationScreenshot, {
|
|
558
|
+
threshold: 0.1,
|
|
559
|
+
diffOutputPath: diffPath,
|
|
560
|
+
maskRegions: test.expect?.maskRegions || [],
|
|
561
|
+
});
|
|
562
|
+
result.visualDiff = {
|
|
563
|
+
diffPercentage: visualResult.diffPercentage,
|
|
564
|
+
differentPixels: visualResult.differentPixels,
|
|
565
|
+
totalPixels: visualResult.totalPixels,
|
|
566
|
+
matchPercentage: visualResult.matchPercentage,
|
|
567
|
+
diffImagePath: visualResult.diffImagePath,
|
|
568
|
+
threshold,
|
|
569
|
+
passed: visualResult.diffPercentage <= threshold,
|
|
570
|
+
};
|
|
571
|
+
if (result.visualDiff.diffImagePath) {
|
|
572
|
+
result.diffScreenshot = result.visualDiff.diffImagePath;
|
|
573
|
+
}
|
|
574
|
+
} catch { /* visual diff is best-effort, never blocks the test */ }
|
|
575
|
+
}
|
|
329
576
|
} catch { /* page may be dead */ }
|
|
330
577
|
}
|
|
331
578
|
|
|
332
579
|
// Run afterEach hook (success path)
|
|
333
580
|
if (hooks.afterEach?.length) {
|
|
334
|
-
await executeHookActions(page, hooks.afterEach,
|
|
581
|
+
await executeHookActions(page, hooks.afterEach, effectiveConfig);
|
|
335
582
|
}
|
|
336
583
|
} catch (error) {
|
|
337
584
|
result.success = false;
|
|
@@ -339,18 +586,31 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
|
|
|
339
586
|
|
|
340
587
|
// Run afterEach hook (failure path)
|
|
341
588
|
if (page && hooks.afterEach?.length) {
|
|
342
|
-
try { await executeHookActions(page, hooks.afterEach,
|
|
589
|
+
try { await executeHookActions(page, hooks.afterEach, effectiveConfig); } catch { /* */ }
|
|
343
590
|
}
|
|
344
591
|
|
|
345
592
|
if (page) {
|
|
346
593
|
try {
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
594
|
+
// Only capture when the page actually has something to show.
|
|
595
|
+
// about:blank / empty-DOM failures produced 5KB blank PNGs that
|
|
596
|
+
// accumulated in screenshotsDir with no debug value.
|
|
597
|
+
if (await pageHasRenderableContent(page)) {
|
|
598
|
+
const errBuf = await page.screenshot({ fullPage: true });
|
|
599
|
+
if (!looksLikeBlankCapture(errBuf, 'png')) {
|
|
600
|
+
const safeName = test.name.replace(/[^a-zA-Z0-9_\-. ]/g, '_');
|
|
601
|
+
const errorScreenshot = path.join(config.screenshotsDir, `error-${safeName}-${Date.now()}.png`);
|
|
602
|
+
fs.writeFileSync(errorScreenshot, errBuf);
|
|
603
|
+
result.errorScreenshot = errorScreenshot;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
351
606
|
} catch { /* page may be dead */ }
|
|
352
607
|
}
|
|
353
608
|
} finally {
|
|
609
|
+
// Stop screencast before disconnecting
|
|
610
|
+
if (cdpSession) {
|
|
611
|
+
try { await cdpSession.send('Page.stopScreencast'); } catch { /* */ }
|
|
612
|
+
try { await cdpSession.detach(); } catch { /* */ }
|
|
613
|
+
}
|
|
354
614
|
// Flush pending response body reads before disconnecting
|
|
355
615
|
if (pendingBodies.length > 0) {
|
|
356
616
|
try { await Promise.allSettled(pendingBodies); } catch { /* */ }
|
|
@@ -363,12 +623,68 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
|
|
|
363
623
|
try { await context.close(); } catch { /* */ }
|
|
364
624
|
}
|
|
365
625
|
if (browser) {
|
|
366
|
-
try { browser.
|
|
626
|
+
try { await disconnectFromPool(browser, result.poolUrl); } catch { /* */ }
|
|
367
627
|
}
|
|
368
628
|
// Release local pending counter so selectPool() knows this slot is free
|
|
369
629
|
if (result.poolUrl) {
|
|
370
630
|
releasePending(result.poolUrl);
|
|
371
631
|
}
|
|
632
|
+
// Destroy the app fork after the test completes
|
|
633
|
+
if (appFork) {
|
|
634
|
+
try { await destroyFork(appFork.forkId); } catch { /* best effort */ }
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
return result;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Majority voting — runs a test N times in parallel and uses majority vote for pass/fail.
|
|
643
|
+
* If majority passes but not unanimously, marks as flaky.
|
|
644
|
+
*/
|
|
645
|
+
async function runTestWithVoting(test, config, hooks, votingCount, testTimeout, progressFn) {
|
|
646
|
+
const votes = [];
|
|
647
|
+
const promises = [];
|
|
648
|
+
|
|
649
|
+
for (let v = 0; v < votingCount; v++) {
|
|
650
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
651
|
+
const timer = setTimeout(() => reject(new Error(`Test timed out after ${testTimeout}ms`)), testTimeout);
|
|
652
|
+
timer.unref();
|
|
653
|
+
});
|
|
654
|
+
promises.push(
|
|
655
|
+
Promise.race([runTest(test, config, hooks, progressFn), timeoutPromise])
|
|
656
|
+
.catch(error => ({
|
|
657
|
+
name: test.name,
|
|
658
|
+
startTime: new Date().toISOString(),
|
|
659
|
+
endTime: new Date().toISOString(),
|
|
660
|
+
actions: [],
|
|
661
|
+
success: false,
|
|
662
|
+
error: error.message,
|
|
663
|
+
consoleLogs: [],
|
|
664
|
+
networkErrors: [],
|
|
665
|
+
networkLogs: [],
|
|
666
|
+
}))
|
|
667
|
+
);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
const results = await Promise.all(promises);
|
|
671
|
+
const passCount = results.filter(r => r.success).length;
|
|
672
|
+
const majorityPassed = passCount > votingCount / 2;
|
|
673
|
+
|
|
674
|
+
// Pick the representative result: a passing one if majority passed, failing one otherwise
|
|
675
|
+
const representative = majorityPassed
|
|
676
|
+
? results.find(r => r.success) || results[0]
|
|
677
|
+
: results.find(r => !r.success) || results[0];
|
|
678
|
+
|
|
679
|
+
const result = { ...representative };
|
|
680
|
+
result.success = majorityPassed;
|
|
681
|
+
result.voting = { total: votingCount, passed: passCount, failed: votingCount - passCount };
|
|
682
|
+
result.attempt = 1;
|
|
683
|
+
result.maxAttempts = 1;
|
|
684
|
+
|
|
685
|
+
// Non-unanimous pass = flaky
|
|
686
|
+
if (majorityPassed && passCount < votingCount) {
|
|
687
|
+
result.flaky = true;
|
|
372
688
|
}
|
|
373
689
|
|
|
374
690
|
return result;
|
|
@@ -377,6 +693,7 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
|
|
|
377
693
|
/** Runs tests in parallel with limited concurrency, retries, timeouts, and hooks */
|
|
378
694
|
export async function runTestsParallel(tests, config, suiteHooks = {}) {
|
|
379
695
|
const hooks = mergeHooks(config.hooks, suiteHooks);
|
|
696
|
+
const driverOpts = { poolDriver: config.poolDriver || 'auto', maxSessions: config.maxSessions || 10 };
|
|
380
697
|
|
|
381
698
|
// Run beforeAll hook
|
|
382
699
|
if (hooks.beforeAll?.length) {
|
|
@@ -389,7 +706,7 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
|
|
|
389
706
|
log('🪝', `${C.dim}Running beforeAll hook...${C.reset}`);
|
|
390
707
|
let browser = null;
|
|
391
708
|
try {
|
|
392
|
-
const hookPool = await selectPool(getPoolUrls(config));
|
|
709
|
+
const hookPool = await selectPool(getPoolUrls(config), 2000, 60000, driverOpts);
|
|
393
710
|
browser = await connectToPool(hookPool, config.connectRetries, config.connectRetryDelay);
|
|
394
711
|
const page = await browser.newPage();
|
|
395
712
|
await page.setViewport(config.viewport);
|
|
@@ -399,7 +716,7 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
|
|
|
399
716
|
log('❌', `${C.red}beforeAll hook failed: ${error.message}${C.reset}`);
|
|
400
717
|
throw error;
|
|
401
718
|
} finally {
|
|
402
|
-
if (browser) try { browser
|
|
719
|
+
if (browser) try { await disconnectFromPool(browser, hookPool); } catch { /* */ }
|
|
403
720
|
}
|
|
404
721
|
}
|
|
405
722
|
|
|
@@ -414,8 +731,27 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
|
|
|
414
731
|
);
|
|
415
732
|
log('✅', `${C.dim}Auth token acquired (${config.authToken.length} chars)${C.reset}`);
|
|
416
733
|
} catch (error) {
|
|
417
|
-
|
|
418
|
-
|
|
734
|
+
// Docker-internal hostname (nginx, api, etc.) → retry with localhost from host machine
|
|
735
|
+
if (error.message && error.message.includes('ENOTFOUND')) {
|
|
736
|
+
const url = new URL(config.authLoginEndpoint);
|
|
737
|
+
if (!url.hostname.includes('.')) {
|
|
738
|
+
const localhostUrl = `http://localhost${url.port && url.port !== '80' ? ':' + url.port : ''}${url.pathname}${url.search}`;
|
|
739
|
+
log('🔄', `${C.dim}Docker hostname "${url.hostname}" not reachable from host, retrying with ${localhostUrl}...${C.reset}`);
|
|
740
|
+
try {
|
|
741
|
+
config.authToken = await fetchAuthToken(localhostUrl, config.authCredentials, config.authTokenPath || 'token');
|
|
742
|
+
log('✅', `${C.dim}Auth token acquired via localhost fallback (${config.authToken.length} chars)${C.reset}`);
|
|
743
|
+
} catch (retryErr) {
|
|
744
|
+
log('❌', `${C.red}Auth auto-login failed (localhost fallback): ${retryErr.message}${C.reset}`);
|
|
745
|
+
throw retryErr;
|
|
746
|
+
}
|
|
747
|
+
} else {
|
|
748
|
+
log('❌', `${C.red}Auth auto-login failed: ${error.message}${C.reset}`);
|
|
749
|
+
throw error;
|
|
750
|
+
}
|
|
751
|
+
} else {
|
|
752
|
+
log('❌', `${C.red}Auth auto-login failed: ${error.message}${C.reset}`);
|
|
753
|
+
throw error;
|
|
754
|
+
}
|
|
419
755
|
}
|
|
420
756
|
}
|
|
421
757
|
|
|
@@ -446,40 +782,49 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
|
|
|
446
782
|
log('▶▶▶', `${C.cyan}${test.name}${C.reset} ${C.dim}(${activeCount} active)${C.reset}`);
|
|
447
783
|
_progress({ event: 'test:start', name: test.name, serial: test.serial || false, activeCount, queueRemaining: queue.length });
|
|
448
784
|
|
|
449
|
-
const maxAttempts = (test.retries ?? config.retries ?? 0) + 1;
|
|
450
785
|
const testTimeout = test.timeout ?? config.testTimeout ?? 60000;
|
|
786
|
+
const votingCount = test.voting ?? config.voting ?? 0;
|
|
787
|
+
const testHooks = test._suiteHooks ? mergeHooks(config.hooks, test._suiteHooks) : hooks;
|
|
451
788
|
let result;
|
|
452
789
|
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
790
|
+
if (votingCount > 1) {
|
|
791
|
+
// Majority voting: run N times in parallel, majority wins
|
|
792
|
+
log('🗳️', `${C.dim}${test.name}: voting ${votingCount}x in parallel${C.reset}`);
|
|
793
|
+
result = await runTestWithVoting(test, config, testHooks, votingCount, testTimeout, _progress);
|
|
794
|
+
} else {
|
|
795
|
+
// Standard sequential retry
|
|
796
|
+
const maxAttempts = (test.retries ?? config.retries ?? 0) + 1;
|
|
797
|
+
|
|
798
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
799
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
800
|
+
const timer = setTimeout(() => reject(new Error(`Test timed out after ${testTimeout}ms`)), testTimeout);
|
|
801
|
+
timer.unref();
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
try {
|
|
805
|
+
result = await Promise.race([runTest(test, config, testHooks, _progress), timeoutPromise]);
|
|
806
|
+
} catch (error) {
|
|
807
|
+
result = {
|
|
808
|
+
name: test.name,
|
|
809
|
+
startTime: new Date().toISOString(),
|
|
810
|
+
endTime: new Date().toISOString(),
|
|
811
|
+
actions: [],
|
|
812
|
+
success: false,
|
|
813
|
+
error: error.message,
|
|
814
|
+
consoleLogs: [],
|
|
815
|
+
networkErrors: [],
|
|
816
|
+
networkLogs: [],
|
|
817
|
+
};
|
|
818
|
+
}
|
|
475
819
|
|
|
476
|
-
|
|
477
|
-
|
|
820
|
+
result.attempt = attempt;
|
|
821
|
+
result.maxAttempts = maxAttempts;
|
|
478
822
|
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
823
|
+
if (result.success || attempt === maxAttempts) break;
|
|
824
|
+
log('🔄', `${C.yellow}${test.name}${C.reset} failed, retrying (${attempt}/${maxAttempts})...`);
|
|
825
|
+
_progress({ event: 'test:retry', name: test.name, attempt, maxAttempts });
|
|
826
|
+
await sleep(config.retryDelay || 1000);
|
|
827
|
+
}
|
|
483
828
|
}
|
|
484
829
|
|
|
485
830
|
results.push(result);
|
|
@@ -489,11 +834,13 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
|
|
|
489
834
|
_progress({ event: 'test:complete', name: test.name, success: result.success, duration: timeDiff(result.startTime, result.endTime), error: result.error, consoleLogs: result.consoleLogs, networkErrors: result.networkErrors, networkLogs: result.networkLogs, errorScreenshot: result.errorScreenshot, screenshots, poolUrl: result.poolUrl || null });
|
|
490
835
|
|
|
491
836
|
if (result.success) {
|
|
492
|
-
const
|
|
493
|
-
|
|
837
|
+
const votingInfo = result.voting ? ` ${C.yellow}(voting: ${result.voting.passed}/${result.voting.total} passed${result.flaky ? ', flaky' : ''})${C.reset}` : '';
|
|
838
|
+
const retryInfo = !result.voting && result.attempt > 1 ? ` ${C.yellow}(flaky, passed on attempt ${result.attempt}/${result.maxAttempts})${C.reset}` : '';
|
|
839
|
+
log('✅', `${C.green}${test.name}${C.reset} ${C.dim}(${timeDiff(result.startTime, result.endTime)})${C.reset}${votingInfo}${retryInfo}`);
|
|
494
840
|
} else {
|
|
495
|
-
const
|
|
496
|
-
|
|
841
|
+
const votingInfo = result.voting ? ` (voting: ${result.voting.passed}/${result.voting.total} passed)` : '';
|
|
842
|
+
const attempts = !result.voting && result.maxAttempts > 1 ? ` (${result.maxAttempts} attempts)` : '';
|
|
843
|
+
log('❌', `${C.red}${test.name}${C.reset}: ${result.error}${votingInfo}${attempts}`);
|
|
497
844
|
}
|
|
498
845
|
|
|
499
846
|
const consoleIssues = result.consoleLogs?.filter(l => l.type === 'error' || l.type === 'warning').length || 0;
|
|
@@ -524,7 +871,7 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
|
|
|
524
871
|
log('🪝', `${C.dim}Running afterAll hook...${C.reset}`);
|
|
525
872
|
let browser = null;
|
|
526
873
|
try {
|
|
527
|
-
const hookPool = await selectPool(getPoolUrls(config));
|
|
874
|
+
const hookPool = await selectPool(getPoolUrls(config), 2000, 60000, driverOpts);
|
|
528
875
|
browser = await connectToPool(hookPool, config.connectRetries, config.connectRetryDelay);
|
|
529
876
|
const page = await browser.newPage();
|
|
530
877
|
await page.setViewport(config.viewport);
|
|
@@ -533,7 +880,7 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
|
|
|
533
880
|
} catch (error) {
|
|
534
881
|
log('⚠️', `${C.yellow}afterAll hook failed: ${error.message}${C.reset}`);
|
|
535
882
|
} finally {
|
|
536
|
-
if (browser) try { browser
|
|
883
|
+
if (browser) try { await disconnectFromPool(browser, hookPool); } catch { /* */ }
|
|
537
884
|
}
|
|
538
885
|
}
|
|
539
886
|
|