@matware/e2e-runner 1.3.1 → 1.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +4 -4
- package/.claude-plugin/plugin.json +2 -2
- package/LICENSE +1 -1
- package/README.md +491 -225
- package/agents/test-creator.md +4 -2
- package/agents/test-improver.md +7 -4
- package/bin/cli.js +93 -19
- package/package.json +4 -3
- package/skills/e2e-testing/SKILL.md +5 -3
- package/skills/e2e-testing/references/action-types.md +35 -18
- package/skills/e2e-testing/references/test-json-format.md +23 -0
- package/skills/e2e-testing/references/troubleshooting.md +2 -26
- package/src/actions.js +181 -15
- package/src/config.js +6 -0
- package/src/dashboard.js +185 -9
- package/src/db.js +26 -0
- package/src/mcp-tools.js +238 -69
- package/src/module-analysis.js +247 -0
- package/src/module-resolver.js +35 -2
- package/src/narrate.js +33 -1
- package/src/pool-manager.js +46 -1
- package/src/pool.js +177 -20
- package/src/runner.js +144 -19
- package/src/visual-diff.js +74 -4
- package/src/websocket.js +14 -3
- package/src/wizard.js +184 -0
- package/templates/build-dashboard.js +3 -0
- package/templates/dashboard/js/api.js +60 -3
- package/templates/dashboard/js/init.js +46 -0
- package/templates/dashboard/js/keyboard.js +8 -7
- package/templates/dashboard/js/quicksearch.js +277 -0
- package/templates/dashboard/js/state.js +61 -7
- package/templates/dashboard/js/toast.js +1 -1
- package/templates/dashboard/js/utils.js +23 -2
- package/templates/dashboard/js/view-live.js +235 -42
- package/templates/dashboard/js/view-runs.js +469 -42
- package/templates/dashboard/js/view-tests.js +157 -16
- package/templates/dashboard/js/view-tools.js +234 -0
- package/templates/dashboard/js/view-watch.js +2 -2
- package/templates/dashboard/js/websocket.js +33 -3
- package/templates/dashboard/styles/base.css +489 -53
- package/templates/dashboard/styles/components.css +736 -84
- package/templates/dashboard/styles/view-live.css +459 -78
- package/templates/dashboard/styles/view-runs.css +826 -177
- package/templates/dashboard/styles/view-tests.css +440 -77
- package/templates/dashboard/styles/view-tools.css +206 -0
- package/templates/dashboard/styles/view-watch.css +198 -41
- package/templates/dashboard/template.html +356 -58
- package/templates/dashboard.html +5354 -722
- package/templates/docker-compose-lightpanda.yml +7 -0
package/src/runner.js
CHANGED
|
@@ -9,10 +9,11 @@ import fs from 'fs';
|
|
|
9
9
|
import path from 'path';
|
|
10
10
|
import http from 'http';
|
|
11
11
|
import https from 'https';
|
|
12
|
+
import crypto from 'crypto';
|
|
12
13
|
import { connectToPool, getCachedDriver, disconnectFromPool } from './pool.js';
|
|
13
|
-
import { getPoolUrls, selectPool, releasePending } from './pool-manager.js';
|
|
14
|
+
import { getPoolUrls, selectPool, releasePending, resolvePoolsForTest } from './pool-manager.js';
|
|
14
15
|
import { forkAppInstance, destroyFork, isAppPoolEnabled } from './app-pool.js';
|
|
15
|
-
import { executeAction } from './actions.js';
|
|
16
|
+
import { executeAction, pageHasRenderableContent, looksLikeBlankCapture } from './actions.js';
|
|
16
17
|
import { narrateAction } from './narrate.js';
|
|
17
18
|
import { log, colors as C } from './logger.js';
|
|
18
19
|
import { resolveTestData, validateActionTypes } from './module-resolver.js';
|
|
@@ -23,6 +24,81 @@ function sleep(ms) {
|
|
|
23
24
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
24
25
|
}
|
|
25
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Best-effort step thumbnail for the storyline view.
|
|
29
|
+
* Captures once in memory, writes to disk AND returns base64 so callers
|
|
30
|
+
* can stream the same frame through the live preview WebSocket.
|
|
31
|
+
* Skips silently on any error so it never breaks a test run.
|
|
32
|
+
*
|
|
33
|
+
* Content dedup: when the captured frame is byte-identical to the previous
|
|
34
|
+
* step's frame (tracked per-test via dedupState), reuses the existing file
|
|
35
|
+
* instead of writing a duplicate, and skips re-streaming the live frame.
|
|
36
|
+
*
|
|
37
|
+
* Raw data responses (JSON/plain-text endpoints rendered by Chrome's viewer
|
|
38
|
+
* as a white page with a single <pre>) are NOT screenshotted — the body is
|
|
39
|
+
* saved as a minified .json sidecar instead and returned as { dataPath }.
|
|
40
|
+
*/
|
|
41
|
+
const NO_AUTO_CAPTURE_TYPES = new Set(['screenshot', 'close_tab']);
|
|
42
|
+
async function tryAutoCaptureStep(page, action, idx, testName, effectiveConfig, alreadyCaptured, dedupState) {
|
|
43
|
+
if (!effectiveConfig.autoCaptureSteps) return null;
|
|
44
|
+
if (NO_AUTO_CAPTURE_TYPES.has(action?.type)) return null;
|
|
45
|
+
if (alreadyCaptured) return null;
|
|
46
|
+
if (!page || (typeof page.isClosed === 'function' && page.isClosed())) return null;
|
|
47
|
+
// Skip auto-capture when the page can't produce a meaningful image —
|
|
48
|
+
// about:blank or fully empty DOM — to stop blank step-*.jpg flooding.
|
|
49
|
+
if (!(await pageHasRenderableContent(page))) return null;
|
|
50
|
+
try {
|
|
51
|
+
const safeName = String(testName).replace(/[^a-zA-Z0-9_\-. ]/g, '_');
|
|
52
|
+
// Raw JSON/text response? Save the body as data, not as a white JPEG.
|
|
53
|
+
const rawBody = await page.evaluate(() => {
|
|
54
|
+
const ct = document.contentType || '';
|
|
55
|
+
const b = document.body;
|
|
56
|
+
const lonePre = !!(b && b.children.length === 1 && b.children[0].tagName === 'PRE' && b.children[0].children.length === 0);
|
|
57
|
+
if ((ct && ct !== 'text/html') || lonePre) return (b && b.innerText) || '';
|
|
58
|
+
return null;
|
|
59
|
+
}).catch(() => null);
|
|
60
|
+
if (rawBody !== null) {
|
|
61
|
+
let text = rawBody.trim();
|
|
62
|
+
if (!text) return null;
|
|
63
|
+
try { text = JSON.stringify(JSON.parse(text)); } catch { /* not JSON — keep raw text */ }
|
|
64
|
+
const dataBuf = Buffer.from(text, 'utf8');
|
|
65
|
+
const dataHash = crypto.createHash('sha1').update(dataBuf).digest('hex');
|
|
66
|
+
if (dedupState && dedupState.hash === dataHash && dedupState.path) {
|
|
67
|
+
return { dataPath: dedupState.path, deduped: true };
|
|
68
|
+
}
|
|
69
|
+
const dataPath = path.join(effectiveConfig.screenshotsDir, `step-${safeName}-${String(idx).padStart(3, '0')}-${Date.now()}.json`);
|
|
70
|
+
fs.writeFileSync(dataPath, dataBuf);
|
|
71
|
+
if (dedupState) {
|
|
72
|
+
dedupState.hash = dataHash;
|
|
73
|
+
dedupState.path = dataPath;
|
|
74
|
+
}
|
|
75
|
+
return { dataPath };
|
|
76
|
+
}
|
|
77
|
+
const filename = `step-${safeName}-${String(idx).padStart(3, '0')}-${Date.now()}.jpg`;
|
|
78
|
+
const filepath = path.join(effectiveConfig.screenshotsDir, filename);
|
|
79
|
+
const buf = await page.screenshot({
|
|
80
|
+
type: 'jpeg',
|
|
81
|
+
quality: effectiveConfig.autoCaptureQuality ?? 60,
|
|
82
|
+
fullPage: false,
|
|
83
|
+
encoding: 'binary',
|
|
84
|
+
});
|
|
85
|
+
if (looksLikeBlankCapture(buf, 'jpeg')) return null;
|
|
86
|
+
const contentHash = crypto.createHash('sha1').update(buf).digest('hex');
|
|
87
|
+
if (dedupState && dedupState.hash === contentHash && dedupState.path) {
|
|
88
|
+
// Same frame as the previous step — reuse the file, don't re-stream
|
|
89
|
+
return { path: dedupState.path, base64: null, deduped: true };
|
|
90
|
+
}
|
|
91
|
+
fs.writeFileSync(filepath, buf);
|
|
92
|
+
if (dedupState) {
|
|
93
|
+
dedupState.hash = contentHash;
|
|
94
|
+
dedupState.path = filepath;
|
|
95
|
+
}
|
|
96
|
+
return { path: filepath, base64: buf.toString('base64') };
|
|
97
|
+
} catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
26
102
|
/** Simple glob matching with * wildcards for exclude patterns. */
|
|
27
103
|
function matchesExclude(filename, excludePatterns) {
|
|
28
104
|
if (!excludePatterns?.length) return false;
|
|
@@ -190,9 +266,24 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
|
|
|
190
266
|
}
|
|
191
267
|
|
|
192
268
|
const driverOpts = { poolDriver: config.poolDriver || 'auto', maxSessions: config.maxSessions || 10 };
|
|
193
|
-
|
|
269
|
+
|
|
270
|
+
// CLI override (--driver / --fallback-driver) wins over per-test fields.
|
|
271
|
+
const requestedDriver = config.cliDriverOverride || test.driver || null;
|
|
272
|
+
const requestedFallback = config.cliFallbackDriverOverride || test.fallbackDriver || null;
|
|
273
|
+
|
|
274
|
+
let candidatePoolUrls = getPoolUrls(config);
|
|
275
|
+
let driverChoice = null;
|
|
276
|
+
if (requestedDriver) {
|
|
277
|
+
const resolved = await resolvePoolsForTest(candidatePoolUrls, requestedDriver, requestedFallback, driverOpts);
|
|
278
|
+
candidatePoolUrls = resolved.urls;
|
|
279
|
+
driverChoice = { requested: requestedDriver, used: resolved.driver, usedFallback: resolved.usedFallback };
|
|
280
|
+
log('🎯', `${C.dim}${test.name}: driver=${resolved.driver}${resolved.usedFallback ? ' (fallback)' : ''}${C.reset}`);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const chosenPool = await selectPool(candidatePoolUrls, 2000, 60000, driverOpts);
|
|
194
284
|
result.poolUrl = chosenPool;
|
|
195
285
|
result.poolDriver = getCachedDriver(chosenPool);
|
|
286
|
+
if (driverChoice) result.driverChoice = driverChoice;
|
|
196
287
|
const poolLabel = chosenPool.replace('ws://', '').replace('wss://', '');
|
|
197
288
|
const isMultiPool = getPoolUrls(config).length > 1;
|
|
198
289
|
if (isMultiPool) {
|
|
@@ -236,9 +327,13 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
|
|
|
236
327
|
maxHeight: config.screencastMaxHeight || 600,
|
|
237
328
|
everyNthFrame: 1,
|
|
238
329
|
}), 5000);
|
|
239
|
-
|
|
330
|
+
log('📹', `${C.dim}screencast started for ${test.name} (driver=${poolDriver})${C.reset}`);
|
|
331
|
+
} catch (err) {
|
|
332
|
+
log('⚠️', `${C.amber}screencast failed for ${test.name}: ${err.message} (driver=${poolDriver})${C.reset}`);
|
|
240
333
|
cdpSession = null;
|
|
241
334
|
}
|
|
335
|
+
} else if (config.screencast && poolDriver === 'cdp') {
|
|
336
|
+
log('⚠️', `${C.amber}screencast disabled: pool driver is generic CDP (Lightpanda?), not supported${C.reset}`);
|
|
242
337
|
}
|
|
243
338
|
|
|
244
339
|
page.on('console', (msg) => {
|
|
@@ -304,16 +399,25 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
|
|
|
304
399
|
await executeHookActions(page, hooks.beforeEach, effectiveConfig);
|
|
305
400
|
}
|
|
306
401
|
|
|
307
|
-
// Auto-capture baseline screenshot if test has "expect" (BEFORE actions)
|
|
402
|
+
// Auto-capture baseline screenshot if test has "expect" (BEFORE actions).
|
|
403
|
+
// Blank frames (about:blank, white unrendered page) are not saved —
|
|
404
|
+
// they have no comparison value and pollute screenshotsDir.
|
|
308
405
|
if (test.expect && page) {
|
|
309
406
|
try {
|
|
310
|
-
const
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
407
|
+
const baseBuf = await page.screenshot({ fullPage: true });
|
|
408
|
+
if (!looksLikeBlankCapture(baseBuf, 'png')) {
|
|
409
|
+
const safeName = test.name.replace(/[^a-zA-Z0-9_\-. ]/g, '_');
|
|
410
|
+
const baselinePath = path.join(effectiveConfig.screenshotsDir, `baseline-${safeName}-${Date.now()}.png`);
|
|
411
|
+
fs.writeFileSync(baselinePath, baseBuf);
|
|
412
|
+
result.baselineScreenshot = baselinePath;
|
|
413
|
+
}
|
|
314
414
|
} catch { /* page may not be ready */ }
|
|
315
415
|
}
|
|
316
416
|
|
|
417
|
+
// Tracks the last auto-captured frame (content hash + path) so identical
|
|
418
|
+
// consecutive step screenshots reuse the same file instead of duplicating
|
|
419
|
+
const stepCaptureState = { hash: null, path: null };
|
|
420
|
+
|
|
317
421
|
for (let i = 0; i < test.actions.length; i++) {
|
|
318
422
|
const action = test.actions[i];
|
|
319
423
|
const maxActionRetries = action.retries ?? effectiveConfig.actionRetries ?? 0;
|
|
@@ -440,16 +544,21 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
|
|
|
440
544
|
actionResult = await executeAction(page, action, effectiveConfig);
|
|
441
545
|
}
|
|
442
546
|
const actionDuration = Date.now() - actionStart;
|
|
547
|
+
const autoShot = await tryAutoCaptureStep(page, action, i, test.name, effectiveConfig, !!actionResult?.screenshot, stepCaptureState);
|
|
443
548
|
const actionEntry = {
|
|
444
549
|
...action,
|
|
445
550
|
success: true,
|
|
446
551
|
duration: actionDuration,
|
|
447
552
|
result: actionResult,
|
|
448
553
|
};
|
|
554
|
+
if (autoShot?.path) actionEntry.autoScreenshot = autoShot.path;
|
|
555
|
+
if (autoShot?.dataPath) actionEntry.dataCapture = autoShot.dataPath;
|
|
449
556
|
if (attempt > 0) actionEntry.actionRetries = attempt;
|
|
450
557
|
actionEntry.narrative = narrateAction(action, actionEntry);
|
|
451
558
|
result.actions.push(actionEntry);
|
|
452
|
-
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 });
|
|
559
|
+
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 });
|
|
560
|
+
// Stream the auto-capture as a live frame so the storyline player has something to show even when CDP screencast is silent
|
|
561
|
+
if (autoShot?.base64) progressFn({ event: 'test:frame', name: test.name, data: autoShot.base64, source: 'step' });
|
|
453
562
|
lastError = null;
|
|
454
563
|
break;
|
|
455
564
|
} catch (error) {
|
|
@@ -460,16 +569,20 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
|
|
|
460
569
|
continue;
|
|
461
570
|
}
|
|
462
571
|
const actionDuration = Date.now() - actionStart;
|
|
572
|
+
const autoShot = await tryAutoCaptureStep(page, action, i, test.name, effectiveConfig, false, stepCaptureState);
|
|
463
573
|
const failedEntry = {
|
|
464
574
|
...action,
|
|
465
575
|
success: false,
|
|
466
576
|
duration: actionDuration,
|
|
467
577
|
error: error.message,
|
|
468
578
|
};
|
|
579
|
+
if (autoShot?.path) failedEntry.autoScreenshot = autoShot.path;
|
|
580
|
+
if (autoShot?.dataPath) failedEntry.dataCapture = autoShot.dataPath;
|
|
469
581
|
if (maxActionRetries > 0) failedEntry.actionRetries = attempt;
|
|
470
582
|
failedEntry.narrative = narrateAction(action, failedEntry);
|
|
471
583
|
result.actions.push(failedEntry);
|
|
472
|
-
progressFn({ event: 'test:action', name: test.name, action, actionIndex: i, totalActions: test.actions.length, success: false, duration: actionDuration, narrative: failedEntry.narrative, error: error.message });
|
|
584
|
+
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 });
|
|
585
|
+
if (autoShot?.base64) progressFn({ event: 'test:frame', name: test.name, data: autoShot.base64, source: 'step' });
|
|
473
586
|
throw error;
|
|
474
587
|
}
|
|
475
588
|
}
|
|
@@ -481,14 +594,18 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
|
|
|
481
594
|
throw new Error(`Network errors detected (failOnNetworkError=true): ${result.networkErrors.length} error(s): ${summary}`);
|
|
482
595
|
}
|
|
483
596
|
|
|
484
|
-
// Auto-capture verification screenshot if test has "expect"
|
|
597
|
+
// Auto-capture verification screenshot if test has "expect".
|
|
598
|
+
// Blank frames are skipped (not saved) — same guard as the baseline.
|
|
485
599
|
if (test.expect && page) {
|
|
486
600
|
result.expect = test.expect;
|
|
487
601
|
try {
|
|
488
602
|
const safeName = test.name.replace(/[^a-zA-Z0-9_\-. ]/g, '_');
|
|
489
|
-
const
|
|
490
|
-
|
|
491
|
-
|
|
603
|
+
const verifyBuf = await page.screenshot({ fullPage: true });
|
|
604
|
+
if (!looksLikeBlankCapture(verifyBuf, 'png')) {
|
|
605
|
+
const verifyPath = path.join(effectiveConfig.screenshotsDir, `verify-${safeName}-${Date.now()}.png`);
|
|
606
|
+
fs.writeFileSync(verifyPath, verifyBuf);
|
|
607
|
+
result.verificationScreenshot = verifyPath;
|
|
608
|
+
}
|
|
492
609
|
|
|
493
610
|
// Auto visual comparison: compare baseline vs verification screenshot
|
|
494
611
|
if (result.baselineScreenshot && result.verificationScreenshot) {
|
|
@@ -532,10 +649,18 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
|
|
|
532
649
|
|
|
533
650
|
if (page) {
|
|
534
651
|
try {
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
652
|
+
// Only capture when the page actually has something to show.
|
|
653
|
+
// about:blank / empty-DOM failures produced 5KB blank PNGs that
|
|
654
|
+
// accumulated in screenshotsDir with no debug value.
|
|
655
|
+
if (await pageHasRenderableContent(page)) {
|
|
656
|
+
const errBuf = await page.screenshot({ fullPage: true });
|
|
657
|
+
if (!looksLikeBlankCapture(errBuf, 'png')) {
|
|
658
|
+
const safeName = test.name.replace(/[^a-zA-Z0-9_\-. ]/g, '_');
|
|
659
|
+
const errorScreenshot = path.join(config.screenshotsDir, `error-${safeName}-${Date.now()}.png`);
|
|
660
|
+
fs.writeFileSync(errorScreenshot, errBuf);
|
|
661
|
+
result.errorScreenshot = errorScreenshot;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
539
664
|
} catch { /* page may be dead */ }
|
|
540
665
|
}
|
|
541
666
|
} finally {
|
package/src/visual-diff.js
CHANGED
|
@@ -32,11 +32,12 @@ function readChunks(buf) {
|
|
|
32
32
|
return chunks;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
function decodePNG(
|
|
36
|
-
|
|
35
|
+
function decodePNG(input) {
|
|
36
|
+
// Accepts a file path or an in-memory PNG Buffer (capture-time checks).
|
|
37
|
+
const buf = Buffer.isBuffer(input) ? input : fs.readFileSync(input);
|
|
37
38
|
|
|
38
|
-
if (buf.compare(PNG_SIGNATURE, 0, 8, 0, 8) !== 0) {
|
|
39
|
-
throw new Error(`Not a valid PNG file: ${
|
|
39
|
+
if (buf.length < 8 || buf.compare(PNG_SIGNATURE, 0, 8, 0, 8) !== 0) {
|
|
40
|
+
throw new Error(`Not a valid PNG file: ${Buffer.isBuffer(input) ? '<buffer>' : input}`);
|
|
40
41
|
}
|
|
41
42
|
|
|
42
43
|
const chunks = readChunks(buf);
|
|
@@ -444,3 +445,72 @@ function buildMaskLookup(regions, imgWidth, imgHeight) {
|
|
|
444
445
|
return (mask[bit >> 3] & (1 << (bit & 7))) !== 0;
|
|
445
446
|
};
|
|
446
447
|
}
|
|
448
|
+
|
|
449
|
+
// ── Blank screenshot detection ─────────────────────────────────────────────
|
|
450
|
+
/**
|
|
451
|
+
* Detects whether a PNG screenshot is "completely blank" — i.e. a single
|
|
452
|
+
* uniform fill color (a white/empty page, a solid error frame, etc.).
|
|
453
|
+
*
|
|
454
|
+
* Strategy: decode to RGBA, sample pixels evenly (capped for speed), compute
|
|
455
|
+
* the mean color, then count how many sampled pixels deviate from that mean by
|
|
456
|
+
* more than `tolerance` on any channel. An image is blank when the fraction of
|
|
457
|
+
* deviating pixels stays at/under `maxOutlierFraction` — this tolerates a few
|
|
458
|
+
* stray pixels (a cursor, a 1px border) while still requiring a near-uniform
|
|
459
|
+
* frame. Non-PNG or undecodable files are reported as not-blank so they are
|
|
460
|
+
* never deleted by mistake.
|
|
461
|
+
*
|
|
462
|
+
* @param {string|Buffer} input — PNG file path, or an in-memory PNG buffer
|
|
463
|
+
* @param {{tolerance?:number, maxOutlierFraction?:number, maxSamples?:number}} [opts]
|
|
464
|
+
* @returns {{blank:boolean, color?:{r:number,g:number,b:number}, brightness?:number,
|
|
465
|
+
* width?:number, height?:number, outlierFraction?:number, error?:string}}
|
|
466
|
+
*/
|
|
467
|
+
export function isBlankImage(input, opts = {}) {
|
|
468
|
+
const tolerance = opts.tolerance ?? 10;
|
|
469
|
+
const maxOutlierFraction = opts.maxOutlierFraction ?? 0.005; // ≤0.5% off-color pixels
|
|
470
|
+
const maxSamples = opts.maxSamples ?? 120000;
|
|
471
|
+
|
|
472
|
+
let img;
|
|
473
|
+
try {
|
|
474
|
+
img = decodePNG(input);
|
|
475
|
+
} catch (error) {
|
|
476
|
+
return { blank: false, error: error.message };
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const { width, height, data } = img;
|
|
480
|
+
const totalPixels = width * height;
|
|
481
|
+
if (totalPixels === 0) return { blank: false, width, height };
|
|
482
|
+
|
|
483
|
+
// Even sampling stride so huge captures stay fast without missing regions.
|
|
484
|
+
const step = Math.max(1, Math.floor(totalPixels / maxSamples));
|
|
485
|
+
|
|
486
|
+
let sumR = 0, sumG = 0, sumB = 0, n = 0;
|
|
487
|
+
for (let p = 0; p < totalPixels; p += step) {
|
|
488
|
+
const i = p * 4;
|
|
489
|
+
sumR += data[i]; sumG += data[i + 1]; sumB += data[i + 2];
|
|
490
|
+
n++;
|
|
491
|
+
}
|
|
492
|
+
const meanR = sumR / n, meanG = sumG / n, meanB = sumB / n;
|
|
493
|
+
|
|
494
|
+
let outliers = 0;
|
|
495
|
+
for (let p = 0; p < totalPixels; p += step) {
|
|
496
|
+
const i = p * 4;
|
|
497
|
+
if (Math.abs(data[i] - meanR) > tolerance ||
|
|
498
|
+
Math.abs(data[i + 1] - meanG) > tolerance ||
|
|
499
|
+
Math.abs(data[i + 2] - meanB) > tolerance) {
|
|
500
|
+
outliers++;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const outlierFraction = outliers / n;
|
|
505
|
+
const color = { r: Math.round(meanR), g: Math.round(meanG), b: Math.round(meanB) };
|
|
506
|
+
const brightness = Math.round((meanR + meanG + meanB) / 3);
|
|
507
|
+
|
|
508
|
+
return {
|
|
509
|
+
blank: outlierFraction <= maxOutlierFraction,
|
|
510
|
+
color,
|
|
511
|
+
brightness,
|
|
512
|
+
width,
|
|
513
|
+
height,
|
|
514
|
+
outlierFraction: Math.round(outlierFraction * 1e4) / 1e4,
|
|
515
|
+
};
|
|
516
|
+
}
|
package/src/websocket.js
CHANGED
|
@@ -81,10 +81,21 @@ export function createWebSocketServer(httpServer, options = {}) {
|
|
|
81
81
|
const clients = new Set();
|
|
82
82
|
|
|
83
83
|
httpServer.on('upgrade', (req, socket, head) => {
|
|
84
|
-
// Validate Origin to prevent cross-site WebSocket hijacking
|
|
84
|
+
// Validate Origin to prevent cross-site WebSocket hijacking.
|
|
85
|
+
// Allow if: no Origin (curl/scripts), explicit whitelist match, or same-origin
|
|
86
|
+
// (Origin's host == the Host header the client connected to).
|
|
85
87
|
const origin = req.headers.origin;
|
|
86
|
-
|
|
87
|
-
|
|
88
|
+
const host = req.headers.host;
|
|
89
|
+
if (origin) {
|
|
90
|
+
let allowed = false;
|
|
91
|
+
if (options.allowedOrigins && options.allowedOrigins.includes(origin)) allowed = true;
|
|
92
|
+
if (!allowed && host) {
|
|
93
|
+
try {
|
|
94
|
+
const u = new URL(origin);
|
|
95
|
+
if (u.host === host) allowed = true;
|
|
96
|
+
} catch { /* malformed origin */ }
|
|
97
|
+
}
|
|
98
|
+
if (!allowed) {
|
|
88
99
|
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
|
|
89
100
|
socket.destroy();
|
|
90
101
|
return;
|
package/src/wizard.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import readline from 'node:readline/promises';
|
|
2
|
+
import { stdin as input, stdout as output } from 'node:process';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { colors as C } from './logger.js';
|
|
5
|
+
|
|
6
|
+
const DRIVERS = ['browserless', 'cdp', 'steel', 'lightpanda'];
|
|
7
|
+
const OUTPUT_FORMATS = ['json', 'junit', 'both'];
|
|
8
|
+
|
|
9
|
+
function isInteractive() {
|
|
10
|
+
return Boolean(input.isTTY && output.isTTY);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function defaultAnswers(cwd) {
|
|
14
|
+
return {
|
|
15
|
+
projectName: path.basename(cwd),
|
|
16
|
+
baseUrl: 'http://host.docker.internal:3000',
|
|
17
|
+
driver: 'browserless',
|
|
18
|
+
poolPort: 3333,
|
|
19
|
+
concurrency: 3,
|
|
20
|
+
maxSessions: 5,
|
|
21
|
+
outputFormat: 'json',
|
|
22
|
+
includeSampleTest: true,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function getDefaultAnswers(cwd) {
|
|
27
|
+
return defaultAnswers(cwd);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function ask(rl, question, fallback, validate) {
|
|
31
|
+
const hint = fallback === '' ? '' : ` ${C.dim}(${fallback})${C.reset}`;
|
|
32
|
+
for (;;) {
|
|
33
|
+
const raw = (await rl.question(`${C.cyan}?${C.reset} ${question}${hint} `)).trim();
|
|
34
|
+
const value = raw === '' ? fallback : raw;
|
|
35
|
+
if (validate) {
|
|
36
|
+
const err = validate(value);
|
|
37
|
+
if (err) {
|
|
38
|
+
console.log(` ${C.red}${err}${C.reset}`);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function askChoice(rl, question, choices, fallback) {
|
|
47
|
+
const list = choices.map((c, i) => `${i + 1}) ${c}${c === fallback ? ' [default]' : ''}`).join(' ');
|
|
48
|
+
for (;;) {
|
|
49
|
+
const raw = (await rl.question(`${C.cyan}?${C.reset} ${question}\n ${C.dim}${list}${C.reset}\n `)).trim();
|
|
50
|
+
if (raw === '') return fallback;
|
|
51
|
+
const asNum = parseInt(raw, 10);
|
|
52
|
+
if (!Number.isNaN(asNum) && asNum >= 1 && asNum <= choices.length) return choices[asNum - 1];
|
|
53
|
+
if (choices.includes(raw)) return raw;
|
|
54
|
+
console.log(` ${C.red}Choose 1-${choices.length} or one of: ${choices.join(', ')}${C.reset}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function askYesNo(rl, question, fallback = true) {
|
|
59
|
+
const hint = fallback ? 'Y/n' : 'y/N';
|
|
60
|
+
const raw = (await rl.question(`${C.cyan}?${C.reset} ${question} ${C.dim}(${hint})${C.reset} `)).trim().toLowerCase();
|
|
61
|
+
if (raw === '') return fallback;
|
|
62
|
+
return raw === 'y' || raw === 'yes' || raw === 's' || raw === 'si';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function validateUrl(value) {
|
|
66
|
+
try {
|
|
67
|
+
const u = new URL(value);
|
|
68
|
+
if (!['http:', 'https:'].includes(u.protocol)) return 'Use http:// or https://';
|
|
69
|
+
return null;
|
|
70
|
+
} catch {
|
|
71
|
+
return 'Not a valid URL';
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function validatePositiveInt(value) {
|
|
76
|
+
const n = Number(value);
|
|
77
|
+
if (!Number.isInteger(n) || n <= 0) return 'Must be a positive integer';
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function runInitWizard(cwd, overrides = {}) {
|
|
82
|
+
const defaults = { ...defaultAnswers(cwd), ...overrides };
|
|
83
|
+
|
|
84
|
+
if (!isInteractive()) return defaults;
|
|
85
|
+
|
|
86
|
+
console.log(`\n${C.bold}${C.cyan}@matware/e2e-runner — init wizard${C.reset}`);
|
|
87
|
+
console.log(`${C.dim}Press Enter to accept the default in parentheses.${C.reset}\n`);
|
|
88
|
+
|
|
89
|
+
const rl = readline.createInterface({ input, output });
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
const projectName = await ask(rl, 'Project name', defaults.projectName);
|
|
93
|
+
const baseUrl = await ask(rl, 'App base URL', defaults.baseUrl, validateUrl);
|
|
94
|
+
const driver = await askChoice(rl, 'Browser driver', DRIVERS, defaults.driver);
|
|
95
|
+
const poolPort = parseInt(await ask(rl, 'Chrome pool port', String(defaults.poolPort), validatePositiveInt), 10);
|
|
96
|
+
const concurrency = parseInt(await ask(rl, 'Parallel test workers', String(defaults.concurrency), validatePositiveInt), 10);
|
|
97
|
+
const maxSessions = parseInt(await ask(rl, 'Max concurrent pool sessions', String(defaults.maxSessions), validatePositiveInt), 10);
|
|
98
|
+
const outputFormat = await askChoice(rl, 'Report output format', OUTPUT_FORMATS, defaults.outputFormat);
|
|
99
|
+
const includeSampleTest = await askYesNo(rl, 'Include a sample test?', defaults.includeSampleTest);
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
projectName,
|
|
103
|
+
baseUrl,
|
|
104
|
+
driver,
|
|
105
|
+
poolPort,
|
|
106
|
+
concurrency,
|
|
107
|
+
maxSessions,
|
|
108
|
+
outputFormat,
|
|
109
|
+
includeSampleTest,
|
|
110
|
+
};
|
|
111
|
+
} finally {
|
|
112
|
+
rl.close();
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function renderConfig(answers) {
|
|
117
|
+
const { projectName, baseUrl, driver, poolPort, concurrency, maxSessions, outputFormat } = answers;
|
|
118
|
+
const driverLine = driver === 'browserless'
|
|
119
|
+
? ''
|
|
120
|
+
: `\n // Browser driver: 'browserless' | 'cdp' | 'steel' | 'lightpanda'\n driver: '${driver}',\n`;
|
|
121
|
+
|
|
122
|
+
return `export default {
|
|
123
|
+
// Display name shown in the dashboard
|
|
124
|
+
projectName: '${projectName}',
|
|
125
|
+
|
|
126
|
+
// App URL (from inside Docker, use host.docker.internal to reach the host)
|
|
127
|
+
baseUrl: '${baseUrl}',
|
|
128
|
+
${driverLine}
|
|
129
|
+
// Chrome Pool WebSocket URL
|
|
130
|
+
poolUrl: 'ws://localhost:${poolPort}',
|
|
131
|
+
|
|
132
|
+
// Chrome Pool port (for pool start/stop)
|
|
133
|
+
poolPort: ${poolPort},
|
|
134
|
+
|
|
135
|
+
// Directory containing JSON test files
|
|
136
|
+
testsDir: 'e2e/tests',
|
|
137
|
+
|
|
138
|
+
// Directory for reusable modules (referenced via $use in tests)
|
|
139
|
+
// modulesDir: 'e2e/modules',
|
|
140
|
+
|
|
141
|
+
// Directory for screenshots and reports
|
|
142
|
+
screenshotsDir: 'e2e/screenshots',
|
|
143
|
+
|
|
144
|
+
// Parallel test workers
|
|
145
|
+
concurrency: ${concurrency},
|
|
146
|
+
|
|
147
|
+
// Max concurrent pool sessions
|
|
148
|
+
maxSessions: ${maxSessions},
|
|
149
|
+
|
|
150
|
+
// Browser viewport
|
|
151
|
+
viewport: { width: 1280, height: 720 },
|
|
152
|
+
|
|
153
|
+
// Timeout per action (ms)
|
|
154
|
+
defaultTimeout: 10000,
|
|
155
|
+
|
|
156
|
+
// Per-test timeout (ms) — kills the test if it exceeds this
|
|
157
|
+
testTimeout: 60000,
|
|
158
|
+
|
|
159
|
+
// Retry failed tests N times (0 = no retries)
|
|
160
|
+
retries: 0,
|
|
161
|
+
|
|
162
|
+
// Delay between retries (ms)
|
|
163
|
+
retryDelay: 1000,
|
|
164
|
+
|
|
165
|
+
// Report output format: 'json', 'junit', or 'both'
|
|
166
|
+
outputFormat: '${outputFormat}',
|
|
167
|
+
|
|
168
|
+
// Global hooks — run actions before/after all tests or each test
|
|
169
|
+
// hooks: {
|
|
170
|
+
// beforeAll: [{ type: 'goto', value: '/login' }],
|
|
171
|
+
// afterAll: [],
|
|
172
|
+
// beforeEach: [{ type: 'goto', value: '/' }],
|
|
173
|
+
// afterEach: [],
|
|
174
|
+
// },
|
|
175
|
+
|
|
176
|
+
// Environment profiles — override any config key per environment
|
|
177
|
+
// Use with --env <name> or E2E_ENV=<name>
|
|
178
|
+
// environments: {
|
|
179
|
+
// staging: { baseUrl: 'https://staging.example.com' },
|
|
180
|
+
// production: { baseUrl: 'https://example.com', concurrency: 5 },
|
|
181
|
+
// },
|
|
182
|
+
};
|
|
183
|
+
`;
|
|
184
|
+
}
|
|
@@ -24,6 +24,7 @@ const CSS_ORDER = [
|
|
|
24
24
|
'view-tests.css',
|
|
25
25
|
'view-runs.css',
|
|
26
26
|
'view-live.css',
|
|
27
|
+
'view-tools.css',
|
|
27
28
|
];
|
|
28
29
|
|
|
29
30
|
const JS_ORDER = [
|
|
@@ -36,6 +37,8 @@ const JS_ORDER = [
|
|
|
36
37
|
'view-tests.js',
|
|
37
38
|
'view-runs.js',
|
|
38
39
|
'view-live.js',
|
|
40
|
+
'view-tools.js',
|
|
41
|
+
'quicksearch.js',
|
|
39
42
|
'keyboard.js',
|
|
40
43
|
'init.js',
|
|
41
44
|
];
|