@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/actions.js CHANGED
@@ -9,7 +9,59 @@
9
9
 
10
10
  import path from 'path';
11
11
  import fs from 'fs';
12
- import { assertVisualMatch } from './visual-diff.js';
12
+ import { assertVisualMatch, isBlankImage } from './visual-diff.js';
13
+
14
+ /**
15
+ * Returns false when the page has nothing useful to capture — used to
16
+ * skip screenshots that would otherwise be saved as pure-color PNGs
17
+ * (about:blank, fresh tab before navigation, DOM-only drivers that
18
+ * never paint, etc). Fails open: on any evaluation error we assume
19
+ * there *is* content so we don't lose legitimate captures.
20
+ */
21
+ export async function pageHasRenderableContent(page) {
22
+ try {
23
+ const url = page.url();
24
+ if (!url || url === 'about:blank' || url === 'about:srcdoc') return false;
25
+ return await page
26
+ .evaluate(() => {
27
+ if (!document.body) return false;
28
+ if (document.body.children.length > 0) return true;
29
+ return (document.body.innerText || '').trim().length > 0;
30
+ })
31
+ .catch(() => true);
32
+ } catch {
33
+ return true;
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Heuristic post-capture guard: PNGs compressed under this size at
39
+ * typical viewport resolutions are almost certainly near-uniform color
40
+ * (about:blank, default Chrome BG, broken render). Catches cases the
41
+ * pre-capture DOM check can't — e.g. browserless rendering example.com
42
+ * to a 99%-gray frame even though navigation succeeded.
43
+ *
44
+ * 20 KB sits cleanly between the observed blank cluster (5 KB – 18 KB)
45
+ * and the smallest real captures in this project (~23 KB+).
46
+ */
47
+ export const BLANK_PNG_BYTE_THRESHOLD = 20000;
48
+ export const BLANK_JPEG_BYTE_THRESHOLD = 8000;
49
+
50
+ export function looksLikeBlankCapture(buf, format = 'png') {
51
+ if (!Buffer.isBuffer(buf)) return false;
52
+ const threshold = format === 'jpeg' ? BLANK_JPEG_BYTE_THRESHOLD : BLANK_PNG_BYTE_THRESHOLD;
53
+ if (buf.length < threshold) return true;
54
+ // Byte size alone misses larger near-uniform frames (e.g. a white page
55
+ // whose PNG still compresses above 20 KB). For PNGs we can decode and
56
+ // check pixel uniformity directly — ≥98% of sampled pixels within
57
+ // tolerance of the mean color means there is nothing worth keeping.
58
+ // JPEGs (step captures) can't be decoded without deps, so they keep
59
+ // the byte heuristic only. Fails open on decode errors.
60
+ if (format === 'png') {
61
+ return isBlankImage(buf, { tolerance: 12, maxOutlierFraction: 0.02 }).blank;
62
+ }
63
+ return false;
64
+ }
13
65
 
14
66
  /** All recognized action types — single source of truth for validation. */
15
67
  export const KNOWN_ACTION_TYPES = new Set([
@@ -20,7 +72,7 @@ export const KNOWN_ACTION_TYPES = new Set([
20
72
  'assert_no_network_errors', 'assert_storage',
21
73
  'get_text', 'select', 'clear', 'clear_cookies', 'press', 'scroll', 'hover',
22
74
  'navigate', 'evaluate',
23
- 'type_react', 'click_regex', 'click_option', 'focus_autocomplete', 'click_chip',
75
+ 'type_react', 'click_regex', 'click_option', 'select_combobox', 'focus_autocomplete', 'click_chip',
24
76
  'set_storage', 'click_icon', 'click_menu_item', 'click_in_context',
25
77
  'assert_text_in', 'assert_no_text',
26
78
  'gql', 'wait_network_idle',
@@ -50,16 +102,35 @@ export async function executeAction(page, action, config) {
50
102
  await page.click(selector);
51
103
  } else if (text) {
52
104
  const clickTextSelector = 'button, a, [role="button"], [role="tab"], [role="menuitem"], [role="option"], [role="listitem"], div[class*="cursor"], span, li, td, th, label, p, h1, h2, h3, h4, h5, h6, dd, dt';
105
+ // Optional refinements (backward-compatible — defaults match old behavior):
106
+ // scope: "dialog" → only match inside an open [role=dialog]/MuiDialog
107
+ // visible: true → skip hidden/zero-size matches (implied by scope:dialog)
108
+ // last: true → click the LAST match instead of the first
109
+ const scopeSel = action.scope === 'dialog' ? '[role="dialog"], .MuiDialog-root' : null;
110
+ const wantVisible = action.visible === true || action.scope === 'dialog';
111
+ const wantLast = action.last === true;
53
112
  await page.waitForFunction(
54
- (t, sel) => [...document.querySelectorAll(sel)]
55
- .find(el => el.textContent.includes(t)),
113
+ (t, sel, scope, vis) => {
114
+ const roots = scope ? [...document.querySelectorAll(scope)] : [document];
115
+ const isVis = el => { if (!vis) return true; const r = el.getBoundingClientRect(); const s = getComputedStyle(el); return r.width > 0 && r.height > 0 && s.display !== 'none' && s.visibility !== 'hidden'; };
116
+ for (const root of roots) {
117
+ if ([...root.querySelectorAll(sel)].some(el => el.textContent.includes(t) && isVis(el))) return true;
118
+ }
119
+ return false;
120
+ },
56
121
  { timeout },
57
- text, clickTextSelector
122
+ text, clickTextSelector, scopeSel, wantVisible
58
123
  );
59
- await page.$$eval(clickTextSelector, (els, t) => {
60
- const el = els.find(e => e.textContent.includes(t));
61
- if (el) el.click();
62
- }, text);
124
+ const clicked = await page.evaluate((t, sel, scope, vis, last) => {
125
+ const roots = scope ? [...document.querySelectorAll(scope)] : [document];
126
+ const isVis = el => { if (!vis) return true; const r = el.getBoundingClientRect(); const s = getComputedStyle(el); return r.width > 0 && r.height > 0 && s.display !== 'none' && s.visibility !== 'hidden'; };
127
+ const matches = [];
128
+ for (const root of roots) matches.push(...[...root.querySelectorAll(sel)].filter(el => el.textContent.includes(t) && isVis(el)));
129
+ const el = last ? matches[matches.length - 1] : matches[0];
130
+ if (el) { el.click(); return true; }
131
+ return false;
132
+ }, text, clickTextSelector, scopeSel, wantVisible, wantLast);
133
+ if (!clicked) throw new Error(`click failed: no element containing "${text}"${scopeSel ? ' in an open dialog' : ''} found`);
63
134
  }
64
135
  break;
65
136
 
@@ -71,8 +142,34 @@ export async function executeAction(page, action, config) {
71
142
  await page.type(selector, value, { delay: 20 });
72
143
  break;
73
144
 
74
- case 'wait':
75
- if (selector) {
145
+ case 'wait': {
146
+ // Condition waits (preferred over fixed sleeps):
147
+ // { selector } → wait until it appears
148
+ // { text } → wait until text appears in the page
149
+ // { gone: "<css>" } → wait until that selector disappears/hides (e.g. spinner)
150
+ // { gone: true, selector }→ same, selector form
151
+ // { gone: true, text } → wait until text disappears
152
+ // { value: "<ms>" } → fixed sleep (last resort)
153
+ const goneSel = typeof action.gone === 'string' ? action.gone : (action.gone === true ? selector : null);
154
+ const goneTxt = action.gone === true && !selector ? text : null;
155
+ if (goneSel) {
156
+ try {
157
+ await page.waitForFunction((sel) => {
158
+ const el = document.querySelector(sel);
159
+ if (!el) return true;
160
+ const r = el.getBoundingClientRect(); const s = getComputedStyle(el);
161
+ return (r.width === 0 && r.height === 0) || s.display === 'none' || s.visibility === 'hidden' || s.opacity === '0';
162
+ }, { timeout }, goneSel);
163
+ } catch (e) {
164
+ throw new Error(`wait failed: "${goneSel}" still present/visible after ${timeout}ms`);
165
+ }
166
+ } else if (goneTxt) {
167
+ try {
168
+ await page.waitForFunction((t) => !document.body.innerText.includes(t), { timeout }, goneTxt);
169
+ } catch (e) {
170
+ throw new Error(`wait failed: text "${goneTxt}" still present after ${timeout}ms`);
171
+ }
172
+ } else if (selector) {
76
173
  try {
77
174
  await page.waitForSelector(selector, { timeout });
78
175
  } catch (e) {
@@ -92,6 +189,7 @@ export async function executeAction(page, action, config) {
92
189
  await sleep(parseInt(value));
93
190
  }
94
191
  break;
192
+ }
95
193
 
96
194
  case 'screenshot': {
97
195
  let filename = value || `screenshot-${Date.now()}.png`;
@@ -108,7 +206,20 @@ export async function executeAction(page, action, config) {
108
206
  filename = `${base}-${Date.now()}${ext}`;
109
207
  }
110
208
  const filepath = path.join(screenshotsDir, filename);
111
- await page.screenshot({ path: filepath, fullPage: action.fullPage || false });
209
+ // Skip capture when page is at about:blank or DOM is empty — these
210
+ // produce uniform-color PNGs that pollute screenshotsDir with no
211
+ // diagnostic value.
212
+ if (!(await pageHasRenderableContent(page))) {
213
+ return { screenshot: null, skipped: 'blank-page' };
214
+ }
215
+ // Capture to buffer first so we can post-filter near-uniform frames
216
+ // (e.g. browserless returning a 99%-gray render). Only persist if
217
+ // the encoded PNG carries enough entropy to be informative.
218
+ const ssBuf = await page.screenshot({ fullPage: action.fullPage || false });
219
+ if (looksLikeBlankCapture(ssBuf, 'png')) {
220
+ return { screenshot: null, skipped: 'blank-render', bytes: ssBuf.length };
221
+ }
222
+ fs.writeFileSync(filepath, ssBuf);
112
223
  return { screenshot: filepath };
113
224
  }
114
225
 
@@ -356,8 +467,11 @@ export async function executeAction(page, action, config) {
356
467
  case 'type_react': {
357
468
  // Types into React controlled inputs using the native value setter.
358
469
  // This bypasses React's synthetic event system which ignores programmatic .value changes.
470
+ // Optional: blur (commit on blur for fields that validate then),
471
+ // waitAfter (ms to wait after — e.g. for debounced autocomplete dropdowns).
359
472
  await page.waitForSelector(selector, { timeout });
360
- await page.evaluate((sel, val) => {
473
+ const trBlur = action.blur === true;
474
+ await page.evaluate((sel, val, doBlur) => {
361
475
  const input = document.querySelector(sel);
362
476
  if (!input) throw new Error(`type_react: element "${sel}" not found`);
363
477
  const proto = input instanceof HTMLTextAreaElement
@@ -367,11 +481,13 @@ export async function executeAction(page, action, config) {
367
481
  if (!descriptor || !descriptor.set) {
368
482
  throw new Error(`type_react: element "${sel}" has no writable value property`);
369
483
  }
484
+ input.focus();
370
485
  descriptor.set.call(input, val);
371
486
  input.dispatchEvent(new Event('input', { bubbles: true }));
372
487
  input.dispatchEvent(new Event('change', { bubbles: true }));
373
- input.focus();
374
- }, selector, value);
488
+ if (doBlur) input.blur();
489
+ }, selector, value, trBlur);
490
+ if (action.waitAfter) await sleep(parseInt(action.waitAfter));
375
491
  break;
376
492
  }
377
493
 
@@ -418,6 +534,56 @@ export async function executeAction(page, action, config) {
418
534
  break;
419
535
  }
420
536
 
537
+ case 'select_combobox': {
538
+ // Open a MUI Autocomplete / Select, optionally type to filter, then click the
539
+ // option matching `text` (case-insensitive substring). Falls back across
540
+ // [role=option], MuiAutocomplete-option and MuiMenuItem so it works for both
541
+ // Autocomplete listboxes and Select dropdowns.
542
+ // selector: combobox input (default input[role='combobox'])
543
+ // text: option to pick (required)
544
+ // filter: text typed into the input before picking (optional)
545
+ // openWait/filterWait: ms tuning for async/debounced option loaders
546
+ const cbInput = selector || "input[role='combobox']";
547
+ const cbOption = text || action.option;
548
+ if (!cbOption) throw new Error("select_combobox requires 'text' (option to pick)");
549
+ const cbFilter = action.filter || '';
550
+ const cbOpenWait = action.openWait ? parseInt(action.openWait) : 400;
551
+ const cbFilterWait = action.filterWait ? parseInt(action.filterWait) : 600;
552
+ await page.waitForSelector(cbInput, { timeout });
553
+ await page.evaluate((sel, flt) => {
554
+ const input = document.querySelector(sel);
555
+ if (!input) throw new Error(`select_combobox: input "${sel}" not found`);
556
+ input.focus();
557
+ if (typeof input.click === 'function') input.click();
558
+ if (flt) {
559
+ const proto = input instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
560
+ const setter = Object.getOwnPropertyDescriptor(proto, 'value').set;
561
+ setter.call(input, flt);
562
+ input.dispatchEvent(new Event('input', { bubbles: true }));
563
+ input.dispatchEvent(new Event('change', { bubbles: true }));
564
+ }
565
+ }, cbInput, cbFilter);
566
+ await sleep(cbFilter ? cbFilterWait : cbOpenWait);
567
+ const cbOptionSel = '[role="option"], .MuiAutocomplete-option, li.MuiMenuItem-root, .MuiList-root li';
568
+ try {
569
+ await page.waitForFunction(
570
+ (sels, t) => [...document.querySelectorAll(sels)].some(o => (o.textContent || '').toLowerCase().includes(t.toLowerCase())),
571
+ { timeout }, cbOptionSel, cbOption
572
+ );
573
+ } catch (e) {
574
+ throw new Error(`select_combobox: no option matching "${cbOption}" appeared (filter="${cbFilter}")`);
575
+ }
576
+ const cbPicked = await page.evaluate((sels, t) => {
577
+ const c = [...document.querySelectorAll(sels)];
578
+ const m = c.find(o => (o.textContent || '').toLowerCase().includes(t.toLowerCase()));
579
+ if (m) { m.click(); return (m.textContent || '').trim().slice(0, 80); }
580
+ return null;
581
+ }, cbOptionSel, cbOption);
582
+ if (cbPicked === null) throw new Error(`select_combobox: option "${cbOption}" vanished before click`);
583
+ if (action.waitAfter) await sleep(parseInt(action.waitAfter));
584
+ break;
585
+ }
586
+
421
587
  case 'focus_autocomplete': {
422
588
  // Focus an autocomplete/combobox input by its label text.
423
589
  // Supports MUI Autocomplete (.MuiAutocomplete-root) and generic [role="combobox"].
package/src/config.js CHANGED
@@ -60,6 +60,12 @@ const DEFAULTS = {
60
60
  screencastMaxWidth: 800,
61
61
  screencastMaxHeight: 600,
62
62
  screencastEveryNthFrame: 1,
63
+ // Auto-capture a thumbnail after each action so the storyline view is fully visual.
64
+ // Adds ~50-100ms per action; set false in CI if you only need final/error screenshots.
65
+ autoCaptureSteps: true,
66
+ autoCaptureWidth: 480,
67
+ autoCaptureHeight: 300,
68
+ autoCaptureQuality: 60,
63
69
  anthropicApiKey: null,
64
70
  anthropicModel: 'claude-sonnet-4-5-20250929',
65
71
  authToken: null,
package/src/dashboard.js CHANGED
@@ -16,12 +16,13 @@ import { createRequire } from 'module';
16
16
  import { createWebSocketServer } from './websocket.js';
17
17
  import { getPoolUrls, getAggregatedPoolStatus, waitForAnyPool } from './pool-manager.js';
18
18
  import { runTestsParallel, loadAllSuites, loadTestSuite, listSuites } from './runner.js';
19
+ import { runModuleAnalysis } from './module-analysis.js';
19
20
  import { generateReport, generateJUnitXML, saveReport, persistRun, loadHistory, loadHistoryRun } from './reporter.js';
20
- import { listProjects as dbListProjects, listProjectsWithSparklines as dbListProjectsWithSparklines, getProjectRuns as dbGetProjectRuns, getRunDetail as dbGetRunDetail, getAllRuns as dbGetAllRuns, getRunCount as dbGetRunCount, getProjectScreenshotsDir as dbGetProjectScreenshotsDir, getProjectTestsDir as dbGetProjectTestsDir, getProjectCwd as dbGetProjectCwd, lookupScreenshotHash as dbLookupScreenshotHash, ensureProject as dbEnsureProject, getNetworkLogs as dbGetNetworkLogs, listVariables as dbListVariables, setVariable as dbSetVariable, deleteVariable as dbDeleteVariable, closeDb } from './db.js';
21
+ import { listProjects as dbListProjects, listProjectsWithSparklines as dbListProjectsWithSparklines, getProjectRuns as dbGetProjectRuns, getRunDetail as dbGetRunDetail, getAllRuns as dbGetAllRuns, getRunCount as dbGetRunCount, getProjectScreenshotsDir as dbGetProjectScreenshotsDir, getProjectTestsDir as dbGetProjectTestsDir, getProjectCwd as dbGetProjectCwd, lookupScreenshotHash as dbLookupScreenshotHash, getScreenshotMetaByPaths as dbGetScreenshotMetaByPaths, ensureProject as dbEnsureProject, getNetworkLogs as dbGetNetworkLogs, listVariables as dbListVariables, setVariable as dbSetVariable, deleteVariable as dbDeleteVariable, closeDb } from './db.js';
21
22
  import { loadConfig } from './config.js';
22
23
  import { log, colors as C } from './logger.js';
23
24
  import { getLearningsSummary, getFlakySummary, getSelectorStability, getPageHealth, getApiHealth, getErrorPatterns, getTestTrends, getRunInsights, getHealthSnapshot, getActionHealthScores } from './learner-sqlite.js';
24
- import { compareImages } from './visual-diff.js';
25
+ import { compareImages, isBlankImage } from './visual-diff.js';
25
26
  import { handleSyncRoutes } from './sync/hub-routes.js';
26
27
  import { migrateSyncSchema } from './sync/schema.js';
27
28
 
@@ -36,6 +37,25 @@ const { version: VERSION } = _require('../package.json');
36
37
  const __filename = fileURLToPath(import.meta.url);
37
38
  const __dirname = path.dirname(__filename);
38
39
 
40
+ // Blank-PNG verdicts cached per path+size+mtime so the gallery listing
41
+ // doesn't re-decode every PNG on each request. Non-PNG files are never
42
+ // flagged (isBlankImage fails open on undecodable input).
43
+ const blankVerdictCache = new Map();
44
+ function isBlankScreenshotCached(filePath) {
45
+ if (!/\.png$/i.test(filePath)) return false;
46
+ try {
47
+ const st = fs.statSync(filePath);
48
+ const key = `${filePath}:${st.size}:${st.mtimeMs}`;
49
+ if (blankVerdictCache.has(key)) return blankVerdictCache.get(key);
50
+ const blank = isBlankImage(filePath).blank;
51
+ if (blankVerdictCache.size > 5000) blankVerdictCache.clear();
52
+ blankVerdictCache.set(key, blank);
53
+ return blank;
54
+ } catch {
55
+ return false;
56
+ }
57
+ }
58
+
39
59
  /** Starts the dashboard server */
40
60
  export async function startDashboard(config) {
41
61
  const port = config.dashboardPort || 8484;
@@ -89,11 +109,16 @@ export async function startDashboard(config) {
89
109
  const url = new URL(req.url, `http://localhost:${port}`);
90
110
  const pathname = url.pathname;
91
111
 
92
- // CORS — restrict to same-origin (localhost on dashboard port)
112
+ // CORS — allow same-origin (Origin's host matches the Host header)
113
+ // and the explicit whitelist (localhost/127.0.0.1 on dashboard port).
93
114
  const allowedOrigins = [`http://localhost:${port}`, `http://127.0.0.1:${port}`];
94
115
  const origin = req.headers.origin;
95
- if (origin && allowedOrigins.includes(origin)) {
96
- res.setHeader('Access-Control-Allow-Origin', origin);
116
+ if (origin) {
117
+ let allowOrigin = allowedOrigins.includes(origin);
118
+ if (!allowOrigin && req.headers.host) {
119
+ try { allowOrigin = new URL(origin).host === req.headers.host; } catch { /* */ }
120
+ }
121
+ if (allowOrigin) res.setHeader('Access-Control-Allow-Origin', origin);
97
122
  }
98
123
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
99
124
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Accept, Mcp-Session-Id');
@@ -338,7 +363,9 @@ export async function startDashboard(config) {
338
363
  return;
339
364
  }
340
365
 
341
- // API: DB — project screenshots list
366
+ // API: DB — project screenshots list (blank PNGs are hidden — they
367
+ // have no debug value and only waste gallery space; the blank-scan
368
+ // endpoint still finds them on disk for bulk deletion)
342
369
  const projectScreenshotsMatch = pathname.match(/^\/api\/db\/projects\/(\d+)\/screenshots$/);
343
370
  if (projectScreenshotsMatch) {
344
371
  try {
@@ -349,7 +376,52 @@ export async function startDashboard(config) {
349
376
  return;
350
377
  }
351
378
  const files = fs.readdirSync(dir).filter(f => /\.(png|jpg|jpeg|gif|webp)$/i.test(f)).sort();
352
- jsonResponse(res, files.map(f => ({ name: f, path: path.join(dir, f) })));
379
+ const visible = files.filter(f => !isBlankScreenshotCached(path.join(dir, f)));
380
+ const fullPaths = visible.map(f => path.join(dir, f));
381
+ const meta = dbGetScreenshotMetaByPaths(fullPaths);
382
+ // Filenames embed the SANITIZED test name (runner's safeName); build a map
383
+ // back to the real name from DB-known entries so legacy files (no DB row)
384
+ // land in the same group as their DB-registered siblings
385
+ const sanitize = (s) => String(s).replace(/[^a-zA-Z0-9_\-. ]/g, '_');
386
+ const sanitizedToReal = {};
387
+ for (const m of Object.values(meta)) {
388
+ if (m.testName) sanitizedToReal[sanitize(m.testName)] = m.testName;
389
+ }
390
+ jsonResponse(res, visible.map(f => {
391
+ const fp = path.join(dir, f);
392
+ const m = meta[fp];
393
+ // Fallback for files predating DB metadata: parse the test name out of
394
+ // runner-generated filenames (step-/error-/baseline-/verify-/current-/diff-<test>-<ts>.<ext>)
395
+ let testName = m?.testName || null;
396
+ let type = m?.type || null;
397
+ if (!testName) {
398
+ const fm = f.match(/^(step|error|baseline|verify|current|diff)-(.+?)(?:-\d{3})?-\d{10,}\.(?:png|jpe?g|gif|webp)$/i);
399
+ if (fm) { type = type || fm[1].toLowerCase(); testName = sanitizedToReal[fm[2]] || fm[2]; }
400
+ }
401
+ return { name: f, path: fp, testName, type };
402
+ }));
403
+ } catch (error) {
404
+ jsonResponse(res, { error: error.message }, 500);
405
+ }
406
+ return;
407
+ }
408
+
409
+ // API: DB — scan a project's screenshots for blank (uniform-color) images
410
+ const blankScanMatch = pathname.match(/^\/api\/db\/projects\/(\d+)\/screenshots\/blank-scan$/);
411
+ if (blankScanMatch) {
412
+ try {
413
+ const projectId = parseInt(blankScanMatch[1], 10);
414
+ const dir = dbGetProjectScreenshotsDir(projectId);
415
+ if (!dir || !fs.existsSync(dir)) { jsonResponse(res, { blanks: [], scanned: 0 }); return; }
416
+ // Only PNGs are decodable; other formats are skipped (never flagged).
417
+ const files = fs.readdirSync(dir).filter(f => /\.png$/i.test(f)).sort();
418
+ const blanks = [];
419
+ for (const f of files) {
420
+ const fp = path.join(dir, f);
421
+ const r = isBlankImage(fp);
422
+ if (r.blank) blanks.push({ name: f, path: fp, color: r.color, brightness: r.brightness });
423
+ }
424
+ jsonResponse(res, { blanks, scanned: files.length });
353
425
  } catch (error) {
354
426
  jsonResponse(res, { error: error.message }, 500);
355
427
  }
@@ -458,6 +530,68 @@ export async function startDashboard(config) {
458
530
  return;
459
531
  }
460
532
 
533
+ // API: Tools — proxy to MCP tool handlers
534
+ // Generic helper: resolve projectId from POST body → cwd, then call dispatchTool.
535
+ if (pathname.startsWith('/api/tool/') && req.method === 'POST') {
536
+ const tool = pathname.replace('/api/tool/', '');
537
+ const map = { capture: 'e2e_capture', analyze: 'e2e_analyze', 'issue-verify': 'e2e_issue' };
538
+ const mcpName = map[tool];
539
+ if (!mcpName) { jsonResponse(res, { error: 'Unknown tool: ' + tool }, 400); return; }
540
+ let body = '';
541
+ let oversize = false;
542
+ req.on('data', chunk => { body += chunk; if (body.length > MAX_BODY) { oversize = true; req.destroy(); } });
543
+ req.on('end', async () => {
544
+ if (oversize) { jsonResponse(res, { error: 'Payload too large' }, 413); return; }
545
+ try {
546
+ const args = body ? JSON.parse(body) : {};
547
+ if (args.projectId) {
548
+ const pcwd = dbGetProjectCwd(parseInt(args.projectId, 10));
549
+ if (pcwd) args.cwd = pcwd;
550
+ delete args.projectId;
551
+ }
552
+ if (tool === 'issue-verify') args.mode = 'verify';
553
+ const result = await dispatchTool(mcpName, args);
554
+ // dispatchTool returns MCP-style { content:[{type,text}], isError? }.
555
+ // Convert to a friendlier shape for the dashboard.
556
+ let payload = result;
557
+ if (result && Array.isArray(result.content)) {
558
+ const text = result.content.map(c => c.text || '').join('\n');
559
+ let parsed = null; try { parsed = JSON.parse(text); } catch { /* */ }
560
+ payload = parsed || { text };
561
+ if (result.isError) payload.error = payload.error || payload.text || 'Tool returned error';
562
+ }
563
+ jsonResponse(res, payload);
564
+ } catch (error) {
565
+ jsonResponse(res, { error: error.message }, 500);
566
+ }
567
+ });
568
+ return;
569
+ }
570
+
571
+ // API: Tools — module analysis
572
+ // Reads all tests + modules in a project, finds repeated 3-8-action
573
+ // subsequences that appear in 2+ tests (extraction candidates), and
574
+ // counts current module usage. Returns a report ready for the
575
+ // dashboard to display + a prompt the user can paste into Claude Code
576
+ // to ask the test-improver agent for deeper analysis.
577
+ const modAnalysisMatch = pathname.match(/^\/api\/tools\/module-analysis\/(\d+)$/);
578
+ if (modAnalysisMatch) {
579
+ try {
580
+ const projectId = parseInt(modAnalysisMatch[1], 10);
581
+ const cwd = dbGetProjectCwd(projectId);
582
+ const testsDir = dbGetProjectTestsDir(projectId);
583
+ if (!cwd || !testsDir || !fs.existsSync(testsDir)) {
584
+ jsonResponse(res, { error: 'Project tests directory not found' }, 404);
585
+ return;
586
+ }
587
+ const modulesDir = path.join(cwd, 'e2e', 'modules');
588
+ jsonResponse(res, runModuleAnalysis(testsDir, modulesDir, projectId));
589
+ } catch (error) {
590
+ jsonResponse(res, { error: error.message }, 500);
591
+ }
592
+ return;
593
+ }
594
+
461
595
  // API: DB — project variables (set/upsert)
462
596
  if (projectVarsMatch && req.method === 'PUT') {
463
597
  let body = '';
@@ -545,6 +679,47 @@ export async function startDashboard(config) {
545
679
  return;
546
680
  }
547
681
 
682
+ // API: delete screenshots — { paths: [...] }, each validated against known dirs
683
+ if (pathname === '/api/screenshots/delete' && req.method === 'POST') {
684
+ let body = '';
685
+ let oversize = false;
686
+ req.on('data', chunk => { body += chunk; if (body.length > MAX_BODY) { oversize = true; req.destroy(); } });
687
+ req.on('end', () => {
688
+ if (oversize) { jsonResponse(res, { error: 'Payload too large' }, 413); return; }
689
+ try {
690
+ const { paths } = body ? JSON.parse(body) : {};
691
+ if (!Array.isArray(paths) || !paths.length) { jsonResponse(res, { error: 'Missing paths array' }, 400); return; }
692
+ // Build the allow-list of directories deletions may touch.
693
+ const allowedDirs = [path.resolve(config.screenshotsDir)];
694
+ try {
695
+ for (const p of dbListProjects()) {
696
+ const dir = p.screenshots_dir || path.join(p.cwd, 'e2e', 'screenshots');
697
+ allowedDirs.push(path.resolve(dir));
698
+ }
699
+ } catch { /* db may be unavailable */ }
700
+ let deleted = 0;
701
+ const failed = [];
702
+ for (const raw of paths) {
703
+ try {
704
+ if (typeof raw !== 'string' || !path.isAbsolute(raw)) { failed.push({ path: raw, error: 'Invalid path' }); continue; }
705
+ const real = fs.realpathSync(raw);
706
+ const inAllowed = allowedDirs.some(dir => real.startsWith(dir + path.sep) || real === dir);
707
+ if (!inAllowed) { failed.push({ path: raw, error: 'Access denied' }); continue; }
708
+ if (!/\.(png|jpg|jpeg|gif|webp)$/i.test(real)) { failed.push({ path: raw, error: 'Not an image' }); continue; }
709
+ fs.unlinkSync(real);
710
+ deleted++;
711
+ } catch (e) {
712
+ failed.push({ path: raw, error: e.message });
713
+ }
714
+ }
715
+ jsonResponse(res, { deleted, failed });
716
+ } catch (error) {
717
+ jsonResponse(res, { error: error.message }, 500);
718
+ }
719
+ });
720
+ return;
721
+ }
722
+
548
723
  // API: visual diff — compare two screenshots on demand
549
724
  if (pathname === '/api/visual-diff') {
550
725
  try {
@@ -627,9 +802,10 @@ export async function startDashboard(config) {
627
802
  return;
628
803
  }
629
804
  const ext = path.extname(realPath).toLowerCase();
630
- const mimeTypes = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp' };
805
+ // .json covers step data captures (raw API responses saved instead of screenshots)
806
+ const mimeTypes = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.json': 'application/json' };
631
807
  if (!mimeTypes[ext]) {
632
- jsonResponse(res, { error: 'Not an image' }, 400);
808
+ jsonResponse(res, { error: 'Unsupported file type' }, 400);
633
809
  return;
634
810
  }
635
811
  res.writeHead(200, { 'Content-Type': mimeTypes[ext], 'Cache-Control': 'no-store' });
package/src/db.js CHANGED
@@ -375,6 +375,20 @@ export function getScreenshotHashes(filePaths) {
375
375
  return result;
376
376
  }
377
377
 
378
+ /** Batch lookup with metadata: given an array of file paths, returns
379
+ * { [path]: { hash, testName, type } } for paths registered in screenshot_hashes. */
380
+ export function getScreenshotMetaByPaths(filePaths) {
381
+ if (!filePaths || filePaths.length === 0) return {};
382
+ const d = getDb();
383
+ const stmt = d.prepare('SELECT hash, file_path, test_name, screenshot_type FROM screenshot_hashes WHERE file_path = ?');
384
+ const result = {};
385
+ for (const fp of filePaths) {
386
+ const row = stmt.get(fp);
387
+ if (row) result[fp] = { hash: row.hash, testName: row.test_name || null, type: row.screenshot_type || null };
388
+ }
389
+ return result;
390
+ }
391
+
378
392
  /** Save a run + its test results in a single transaction. Returns the run's DB id. */
379
393
  export function saveRun(projectId, report, runId, suiteName, triggeredBy, poolDriver) {
380
394
  const d = getDb();
@@ -429,6 +443,9 @@ export function saveRun(projectId, report, runId, suiteName, triggeredBy, poolDr
429
443
  narrative: a.narrative || undefined,
430
444
  error: a.error || undefined,
431
445
  actionRetries: a.actionRetries || undefined,
446
+ autoScreenshot: a.autoScreenshot || undefined,
447
+ dataCapture: a.dataCapture || undefined,
448
+ screenshot: a.result?.screenshot || undefined,
432
449
  }));
433
450
 
434
451
  insertTest.run(
@@ -461,6 +478,15 @@ export function saveRun(projectId, report, runId, suiteName, triggeredBy, poolDr
461
478
  const actionIdx = r.actions.indexOf(a);
462
479
  insertHash.run(computeScreenshotHash(a.result.screenshot), a.result.screenshot, projectId, runDbId, r.name, actionIdx, null, 'action');
463
480
  }
481
+
482
+ // Auto-captured per-step thumbnails for the storyline view
483
+ (r.actions || []).forEach((a, idx) => {
484
+ if (a.autoScreenshot) {
485
+ try {
486
+ insertHash.run(computeScreenshotHash(a.autoScreenshot), a.autoScreenshot, projectId, runDbId, r.name, idx, null, 'step');
487
+ } catch { /* best effort */ }
488
+ }
489
+ });
464
490
  if (r.errorScreenshot) {
465
491
  insertHash.run(computeScreenshotHash(r.errorScreenshot), r.errorScreenshot, projectId, runDbId, r.name, null, null, 'error');
466
492
  }