@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.
Files changed (50) hide show
  1. package/.claude-plugin/marketplace.json +4 -4
  2. package/.claude-plugin/plugin.json +2 -2
  3. package/LICENSE +1 -1
  4. package/README.md +491 -225
  5. package/agents/test-creator.md +4 -2
  6. package/agents/test-improver.md +7 -4
  7. package/bin/cli.js +93 -19
  8. package/package.json +4 -3
  9. package/skills/e2e-testing/SKILL.md +5 -3
  10. package/skills/e2e-testing/references/action-types.md +35 -18
  11. package/skills/e2e-testing/references/test-json-format.md +23 -0
  12. package/skills/e2e-testing/references/troubleshooting.md +2 -26
  13. package/src/actions.js +181 -15
  14. package/src/config.js +6 -0
  15. package/src/dashboard.js +185 -9
  16. package/src/db.js +26 -0
  17. package/src/mcp-tools.js +238 -69
  18. package/src/module-analysis.js +247 -0
  19. package/src/module-resolver.js +35 -2
  20. package/src/narrate.js +33 -1
  21. package/src/pool-manager.js +46 -1
  22. package/src/pool.js +177 -20
  23. package/src/runner.js +144 -19
  24. package/src/visual-diff.js +74 -4
  25. package/src/websocket.js +14 -3
  26. package/src/wizard.js +184 -0
  27. package/templates/build-dashboard.js +3 -0
  28. package/templates/dashboard/js/api.js +60 -3
  29. package/templates/dashboard/js/init.js +46 -0
  30. package/templates/dashboard/js/keyboard.js +8 -7
  31. package/templates/dashboard/js/quicksearch.js +277 -0
  32. package/templates/dashboard/js/state.js +61 -7
  33. package/templates/dashboard/js/toast.js +1 -1
  34. package/templates/dashboard/js/utils.js +23 -2
  35. package/templates/dashboard/js/view-live.js +235 -42
  36. package/templates/dashboard/js/view-runs.js +469 -42
  37. package/templates/dashboard/js/view-tests.js +157 -16
  38. package/templates/dashboard/js/view-tools.js +234 -0
  39. package/templates/dashboard/js/view-watch.js +2 -2
  40. package/templates/dashboard/js/websocket.js +33 -3
  41. package/templates/dashboard/styles/base.css +489 -53
  42. package/templates/dashboard/styles/components.css +736 -84
  43. package/templates/dashboard/styles/view-live.css +459 -78
  44. package/templates/dashboard/styles/view-runs.css +826 -177
  45. package/templates/dashboard/styles/view-tests.css +440 -77
  46. package/templates/dashboard/styles/view-tools.css +206 -0
  47. package/templates/dashboard/styles/view-watch.css +198 -41
  48. package/templates/dashboard/template.html +356 -58
  49. package/templates/dashboard.html +5354 -722
  50. 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
- const chosenPool = await selectPool(getPoolUrls(config), 2000, 60000, driverOpts);
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
- } catch {
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 safeName = test.name.replace(/[^a-zA-Z0-9_\-. ]/g, '_');
311
- const baselinePath = path.join(effectiveConfig.screenshotsDir, `baseline-${safeName}-${Date.now()}.png`);
312
- await page.screenshot({ path: baselinePath, fullPage: true });
313
- result.baselineScreenshot = baselinePath;
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 verifyPath = path.join(effectiveConfig.screenshotsDir, `verify-${safeName}-${Date.now()}.png`);
490
- await page.screenshot({ path: verifyPath, fullPage: true });
491
- result.verificationScreenshot = verifyPath;
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
- const safeName = test.name.replace(/[^a-zA-Z0-9_\-. ]/g, '_');
536
- const errorScreenshot = path.join(config.screenshotsDir, `error-${safeName}-${Date.now()}.png`);
537
- await page.screenshot({ path: errorScreenshot, fullPage: true });
538
- result.errorScreenshot = errorScreenshot;
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 {
@@ -32,11 +32,12 @@ function readChunks(buf) {
32
32
  return chunks;
33
33
  }
34
34
 
35
- function decodePNG(filePath) {
36
- const buf = fs.readFileSync(filePath);
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: ${filePath}`);
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
- if (origin && options.allowedOrigins) {
87
- if (!options.allowedOrigins.includes(origin)) {
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
  ];