@matware/e2e-runner 1.3.1 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/.claude-plugin/marketplace.json +4 -4
  2. package/.claude-plugin/plugin.json +2 -2
  3. package/README.md +110 -21
  4. package/agents/test-creator.md +4 -2
  5. package/agents/test-improver.md +5 -3
  6. package/bin/cli.js +80 -17
  7. package/package.json +3 -2
  8. package/skills/e2e-testing/SKILL.md +3 -2
  9. package/skills/e2e-testing/references/action-types.md +22 -4
  10. package/skills/e2e-testing/references/test-json-format.md +23 -0
  11. package/src/actions.js +170 -14
  12. package/src/config.js +6 -0
  13. package/src/dashboard.js +135 -4
  14. package/src/db.js +11 -0
  15. package/src/mcp-tools.js +8 -2
  16. package/src/module-analysis.js +247 -0
  17. package/src/module-resolver.js +35 -2
  18. package/src/narrate.js +14 -1
  19. package/src/pool-manager.js +46 -1
  20. package/src/pool.js +177 -20
  21. package/src/runner.js +77 -10
  22. package/src/visual-diff.js +69 -0
  23. package/src/websocket.js +14 -3
  24. package/src/wizard.js +184 -0
  25. package/templates/build-dashboard.js +3 -0
  26. package/templates/dashboard/js/api.js +60 -3
  27. package/templates/dashboard/js/init.js +46 -0
  28. package/templates/dashboard/js/keyboard.js +8 -7
  29. package/templates/dashboard/js/quicksearch.js +277 -0
  30. package/templates/dashboard/js/state.js +61 -7
  31. package/templates/dashboard/js/toast.js +1 -1
  32. package/templates/dashboard/js/view-live.js +235 -42
  33. package/templates/dashboard/js/view-runs.js +379 -37
  34. package/templates/dashboard/js/view-tests.js +157 -16
  35. package/templates/dashboard/js/view-tools.js +234 -0
  36. package/templates/dashboard/js/view-watch.js +2 -2
  37. package/templates/dashboard/js/websocket.js +33 -3
  38. package/templates/dashboard/styles/base.css +489 -53
  39. package/templates/dashboard/styles/components.css +719 -84
  40. package/templates/dashboard/styles/view-live.css +459 -78
  41. package/templates/dashboard/styles/view-runs.css +779 -177
  42. package/templates/dashboard/styles/view-tests.css +440 -77
  43. package/templates/dashboard/styles/view-tools.css +206 -0
  44. package/templates/dashboard/styles/view-watch.css +198 -41
  45. package/templates/dashboard/template.html +354 -56
  46. package/templates/dashboard.html +5173 -711
  47. package/templates/docker-compose-lightpanda.yml +7 -0
package/src/actions.js CHANGED
@@ -11,6 +11,48 @@ import path from 'path';
11
11
  import fs from 'fs';
12
12
  import { assertVisualMatch } from './visual-diff.js';
13
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
+ return buf.length < threshold;
54
+ }
55
+
14
56
  /** All recognized action types — single source of truth for validation. */
15
57
  export const KNOWN_ACTION_TYPES = new Set([
16
58
  'goto', 'click', 'type', 'fill', 'wait', 'screenshot',
@@ -20,7 +62,7 @@ export const KNOWN_ACTION_TYPES = new Set([
20
62
  'assert_no_network_errors', 'assert_storage',
21
63
  'get_text', 'select', 'clear', 'clear_cookies', 'press', 'scroll', 'hover',
22
64
  'navigate', 'evaluate',
23
- 'type_react', 'click_regex', 'click_option', 'focus_autocomplete', 'click_chip',
65
+ 'type_react', 'click_regex', 'click_option', 'select_combobox', 'focus_autocomplete', 'click_chip',
24
66
  'set_storage', 'click_icon', 'click_menu_item', 'click_in_context',
25
67
  'assert_text_in', 'assert_no_text',
26
68
  'gql', 'wait_network_idle',
@@ -50,16 +92,35 @@ export async function executeAction(page, action, config) {
50
92
  await page.click(selector);
51
93
  } else if (text) {
52
94
  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';
95
+ // Optional refinements (backward-compatible — defaults match old behavior):
96
+ // scope: "dialog" → only match inside an open [role=dialog]/MuiDialog
97
+ // visible: true → skip hidden/zero-size matches (implied by scope:dialog)
98
+ // last: true → click the LAST match instead of the first
99
+ const scopeSel = action.scope === 'dialog' ? '[role="dialog"], .MuiDialog-root' : null;
100
+ const wantVisible = action.visible === true || action.scope === 'dialog';
101
+ const wantLast = action.last === true;
53
102
  await page.waitForFunction(
54
- (t, sel) => [...document.querySelectorAll(sel)]
55
- .find(el => el.textContent.includes(t)),
103
+ (t, sel, scope, vis) => {
104
+ const roots = scope ? [...document.querySelectorAll(scope)] : [document];
105
+ 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'; };
106
+ for (const root of roots) {
107
+ if ([...root.querySelectorAll(sel)].some(el => el.textContent.includes(t) && isVis(el))) return true;
108
+ }
109
+ return false;
110
+ },
56
111
  { timeout },
57
- text, clickTextSelector
112
+ text, clickTextSelector, scopeSel, wantVisible
58
113
  );
59
- await page.$$eval(clickTextSelector, (els, t) => {
60
- const el = els.find(e => e.textContent.includes(t));
61
- if (el) el.click();
62
- }, text);
114
+ const clicked = await page.evaluate((t, sel, scope, vis, last) => {
115
+ const roots = scope ? [...document.querySelectorAll(scope)] : [document];
116
+ 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'; };
117
+ const matches = [];
118
+ for (const root of roots) matches.push(...[...root.querySelectorAll(sel)].filter(el => el.textContent.includes(t) && isVis(el)));
119
+ const el = last ? matches[matches.length - 1] : matches[0];
120
+ if (el) { el.click(); return true; }
121
+ return false;
122
+ }, text, clickTextSelector, scopeSel, wantVisible, wantLast);
123
+ if (!clicked) throw new Error(`click failed: no element containing "${text}"${scopeSel ? ' in an open dialog' : ''} found`);
63
124
  }
64
125
  break;
65
126
 
@@ -71,8 +132,34 @@ export async function executeAction(page, action, config) {
71
132
  await page.type(selector, value, { delay: 20 });
72
133
  break;
73
134
 
74
- case 'wait':
75
- if (selector) {
135
+ case 'wait': {
136
+ // Condition waits (preferred over fixed sleeps):
137
+ // { selector } → wait until it appears
138
+ // { text } → wait until text appears in the page
139
+ // { gone: "<css>" } → wait until that selector disappears/hides (e.g. spinner)
140
+ // { gone: true, selector }→ same, selector form
141
+ // { gone: true, text } → wait until text disappears
142
+ // { value: "<ms>" } → fixed sleep (last resort)
143
+ const goneSel = typeof action.gone === 'string' ? action.gone : (action.gone === true ? selector : null);
144
+ const goneTxt = action.gone === true && !selector ? text : null;
145
+ if (goneSel) {
146
+ try {
147
+ await page.waitForFunction((sel) => {
148
+ const el = document.querySelector(sel);
149
+ if (!el) return true;
150
+ const r = el.getBoundingClientRect(); const s = getComputedStyle(el);
151
+ return (r.width === 0 && r.height === 0) || s.display === 'none' || s.visibility === 'hidden' || s.opacity === '0';
152
+ }, { timeout }, goneSel);
153
+ } catch (e) {
154
+ throw new Error(`wait failed: "${goneSel}" still present/visible after ${timeout}ms`);
155
+ }
156
+ } else if (goneTxt) {
157
+ try {
158
+ await page.waitForFunction((t) => !document.body.innerText.includes(t), { timeout }, goneTxt);
159
+ } catch (e) {
160
+ throw new Error(`wait failed: text "${goneTxt}" still present after ${timeout}ms`);
161
+ }
162
+ } else if (selector) {
76
163
  try {
77
164
  await page.waitForSelector(selector, { timeout });
78
165
  } catch (e) {
@@ -92,6 +179,7 @@ export async function executeAction(page, action, config) {
92
179
  await sleep(parseInt(value));
93
180
  }
94
181
  break;
182
+ }
95
183
 
96
184
  case 'screenshot': {
97
185
  let filename = value || `screenshot-${Date.now()}.png`;
@@ -108,7 +196,20 @@ export async function executeAction(page, action, config) {
108
196
  filename = `${base}-${Date.now()}${ext}`;
109
197
  }
110
198
  const filepath = path.join(screenshotsDir, filename);
111
- await page.screenshot({ path: filepath, fullPage: action.fullPage || false });
199
+ // Skip capture when page is at about:blank or DOM is empty — these
200
+ // produce uniform-color PNGs that pollute screenshotsDir with no
201
+ // diagnostic value.
202
+ if (!(await pageHasRenderableContent(page))) {
203
+ return { screenshot: null, skipped: 'blank-page' };
204
+ }
205
+ // Capture to buffer first so we can post-filter near-uniform frames
206
+ // (e.g. browserless returning a 99%-gray render). Only persist if
207
+ // the encoded PNG carries enough entropy to be informative.
208
+ const ssBuf = await page.screenshot({ fullPage: action.fullPage || false });
209
+ if (looksLikeBlankCapture(ssBuf, 'png')) {
210
+ return { screenshot: null, skipped: 'blank-render', bytes: ssBuf.length };
211
+ }
212
+ fs.writeFileSync(filepath, ssBuf);
112
213
  return { screenshot: filepath };
113
214
  }
114
215
 
@@ -356,8 +457,11 @@ export async function executeAction(page, action, config) {
356
457
  case 'type_react': {
357
458
  // Types into React controlled inputs using the native value setter.
358
459
  // This bypasses React's synthetic event system which ignores programmatic .value changes.
460
+ // Optional: blur (commit on blur for fields that validate then),
461
+ // waitAfter (ms to wait after — e.g. for debounced autocomplete dropdowns).
359
462
  await page.waitForSelector(selector, { timeout });
360
- await page.evaluate((sel, val) => {
463
+ const trBlur = action.blur === true;
464
+ await page.evaluate((sel, val, doBlur) => {
361
465
  const input = document.querySelector(sel);
362
466
  if (!input) throw new Error(`type_react: element "${sel}" not found`);
363
467
  const proto = input instanceof HTMLTextAreaElement
@@ -367,11 +471,13 @@ export async function executeAction(page, action, config) {
367
471
  if (!descriptor || !descriptor.set) {
368
472
  throw new Error(`type_react: element "${sel}" has no writable value property`);
369
473
  }
474
+ input.focus();
370
475
  descriptor.set.call(input, val);
371
476
  input.dispatchEvent(new Event('input', { bubbles: true }));
372
477
  input.dispatchEvent(new Event('change', { bubbles: true }));
373
- input.focus();
374
- }, selector, value);
478
+ if (doBlur) input.blur();
479
+ }, selector, value, trBlur);
480
+ if (action.waitAfter) await sleep(parseInt(action.waitAfter));
375
481
  break;
376
482
  }
377
483
 
@@ -418,6 +524,56 @@ export async function executeAction(page, action, config) {
418
524
  break;
419
525
  }
420
526
 
527
+ case 'select_combobox': {
528
+ // Open a MUI Autocomplete / Select, optionally type to filter, then click the
529
+ // option matching `text` (case-insensitive substring). Falls back across
530
+ // [role=option], MuiAutocomplete-option and MuiMenuItem so it works for both
531
+ // Autocomplete listboxes and Select dropdowns.
532
+ // selector: combobox input (default input[role='combobox'])
533
+ // text: option to pick (required)
534
+ // filter: text typed into the input before picking (optional)
535
+ // openWait/filterWait: ms tuning for async/debounced option loaders
536
+ const cbInput = selector || "input[role='combobox']";
537
+ const cbOption = text || action.option;
538
+ if (!cbOption) throw new Error("select_combobox requires 'text' (option to pick)");
539
+ const cbFilter = action.filter || '';
540
+ const cbOpenWait = action.openWait ? parseInt(action.openWait) : 400;
541
+ const cbFilterWait = action.filterWait ? parseInt(action.filterWait) : 600;
542
+ await page.waitForSelector(cbInput, { timeout });
543
+ await page.evaluate((sel, flt) => {
544
+ const input = document.querySelector(sel);
545
+ if (!input) throw new Error(`select_combobox: input "${sel}" not found`);
546
+ input.focus();
547
+ if (typeof input.click === 'function') input.click();
548
+ if (flt) {
549
+ const proto = input instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
550
+ const setter = Object.getOwnPropertyDescriptor(proto, 'value').set;
551
+ setter.call(input, flt);
552
+ input.dispatchEvent(new Event('input', { bubbles: true }));
553
+ input.dispatchEvent(new Event('change', { bubbles: true }));
554
+ }
555
+ }, cbInput, cbFilter);
556
+ await sleep(cbFilter ? cbFilterWait : cbOpenWait);
557
+ const cbOptionSel = '[role="option"], .MuiAutocomplete-option, li.MuiMenuItem-root, .MuiList-root li';
558
+ try {
559
+ await page.waitForFunction(
560
+ (sels, t) => [...document.querySelectorAll(sels)].some(o => (o.textContent || '').toLowerCase().includes(t.toLowerCase())),
561
+ { timeout }, cbOptionSel, cbOption
562
+ );
563
+ } catch (e) {
564
+ throw new Error(`select_combobox: no option matching "${cbOption}" appeared (filter="${cbFilter}")`);
565
+ }
566
+ const cbPicked = await page.evaluate((sels, t) => {
567
+ const c = [...document.querySelectorAll(sels)];
568
+ const m = c.find(o => (o.textContent || '').toLowerCase().includes(t.toLowerCase()));
569
+ if (m) { m.click(); return (m.textContent || '').trim().slice(0, 80); }
570
+ return null;
571
+ }, cbOptionSel, cbOption);
572
+ if (cbPicked === null) throw new Error(`select_combobox: option "${cbOption}" vanished before click`);
573
+ if (action.waitAfter) await sleep(parseInt(action.waitAfter));
574
+ break;
575
+ }
576
+
421
577
  case 'focus_autocomplete': {
422
578
  // Focus an autocomplete/combobox input by its label text.
423
579
  // 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
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, 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
 
@@ -89,11 +90,16 @@ export async function startDashboard(config) {
89
90
  const url = new URL(req.url, `http://localhost:${port}`);
90
91
  const pathname = url.pathname;
91
92
 
92
- // CORS — restrict to same-origin (localhost on dashboard port)
93
+ // CORS — allow same-origin (Origin's host matches the Host header)
94
+ // and the explicit whitelist (localhost/127.0.0.1 on dashboard port).
93
95
  const allowedOrigins = [`http://localhost:${port}`, `http://127.0.0.1:${port}`];
94
96
  const origin = req.headers.origin;
95
- if (origin && allowedOrigins.includes(origin)) {
96
- res.setHeader('Access-Control-Allow-Origin', origin);
97
+ if (origin) {
98
+ let allowOrigin = allowedOrigins.includes(origin);
99
+ if (!allowOrigin && req.headers.host) {
100
+ try { allowOrigin = new URL(origin).host === req.headers.host; } catch { /* */ }
101
+ }
102
+ if (allowOrigin) res.setHeader('Access-Control-Allow-Origin', origin);
97
103
  }
98
104
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
99
105
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Accept, Mcp-Session-Id');
@@ -356,6 +362,28 @@ export async function startDashboard(config) {
356
362
  return;
357
363
  }
358
364
 
365
+ // API: DB — scan a project's screenshots for blank (uniform-color) images
366
+ const blankScanMatch = pathname.match(/^\/api\/db\/projects\/(\d+)\/screenshots\/blank-scan$/);
367
+ if (blankScanMatch) {
368
+ try {
369
+ const projectId = parseInt(blankScanMatch[1], 10);
370
+ const dir = dbGetProjectScreenshotsDir(projectId);
371
+ if (!dir || !fs.existsSync(dir)) { jsonResponse(res, { blanks: [], scanned: 0 }); return; }
372
+ // Only PNGs are decodable; other formats are skipped (never flagged).
373
+ const files = fs.readdirSync(dir).filter(f => /\.png$/i.test(f)).sort();
374
+ const blanks = [];
375
+ for (const f of files) {
376
+ const fp = path.join(dir, f);
377
+ const r = isBlankImage(fp);
378
+ if (r.blank) blanks.push({ name: f, path: fp, color: r.color, brightness: r.brightness });
379
+ }
380
+ jsonResponse(res, { blanks, scanned: files.length });
381
+ } catch (error) {
382
+ jsonResponse(res, { error: error.message }, 500);
383
+ }
384
+ return;
385
+ }
386
+
359
387
  // API: DB — project suites list
360
388
  const projectSuitesMatch = pathname.match(/^\/api\/db\/projects\/(\d+)\/suites$/);
361
389
  if (projectSuitesMatch) {
@@ -458,6 +486,68 @@ export async function startDashboard(config) {
458
486
  return;
459
487
  }
460
488
 
489
+ // API: Tools — proxy to MCP tool handlers
490
+ // Generic helper: resolve projectId from POST body → cwd, then call dispatchTool.
491
+ if (pathname.startsWith('/api/tool/') && req.method === 'POST') {
492
+ const tool = pathname.replace('/api/tool/', '');
493
+ const map = { capture: 'e2e_capture', analyze: 'e2e_analyze', 'issue-verify': 'e2e_issue' };
494
+ const mcpName = map[tool];
495
+ if (!mcpName) { jsonResponse(res, { error: 'Unknown tool: ' + tool }, 400); return; }
496
+ let body = '';
497
+ let oversize = false;
498
+ req.on('data', chunk => { body += chunk; if (body.length > MAX_BODY) { oversize = true; req.destroy(); } });
499
+ req.on('end', async () => {
500
+ if (oversize) { jsonResponse(res, { error: 'Payload too large' }, 413); return; }
501
+ try {
502
+ const args = body ? JSON.parse(body) : {};
503
+ if (args.projectId) {
504
+ const pcwd = dbGetProjectCwd(parseInt(args.projectId, 10));
505
+ if (pcwd) args.cwd = pcwd;
506
+ delete args.projectId;
507
+ }
508
+ if (tool === 'issue-verify') args.mode = 'verify';
509
+ const result = await dispatchTool(mcpName, args);
510
+ // dispatchTool returns MCP-style { content:[{type,text}], isError? }.
511
+ // Convert to a friendlier shape for the dashboard.
512
+ let payload = result;
513
+ if (result && Array.isArray(result.content)) {
514
+ const text = result.content.map(c => c.text || '').join('\n');
515
+ let parsed = null; try { parsed = JSON.parse(text); } catch { /* */ }
516
+ payload = parsed || { text };
517
+ if (result.isError) payload.error = payload.error || payload.text || 'Tool returned error';
518
+ }
519
+ jsonResponse(res, payload);
520
+ } catch (error) {
521
+ jsonResponse(res, { error: error.message }, 500);
522
+ }
523
+ });
524
+ return;
525
+ }
526
+
527
+ // API: Tools — module analysis
528
+ // Reads all tests + modules in a project, finds repeated 3-8-action
529
+ // subsequences that appear in 2+ tests (extraction candidates), and
530
+ // counts current module usage. Returns a report ready for the
531
+ // dashboard to display + a prompt the user can paste into Claude Code
532
+ // to ask the test-improver agent for deeper analysis.
533
+ const modAnalysisMatch = pathname.match(/^\/api\/tools\/module-analysis\/(\d+)$/);
534
+ if (modAnalysisMatch) {
535
+ try {
536
+ const projectId = parseInt(modAnalysisMatch[1], 10);
537
+ const cwd = dbGetProjectCwd(projectId);
538
+ const testsDir = dbGetProjectTestsDir(projectId);
539
+ if (!cwd || !testsDir || !fs.existsSync(testsDir)) {
540
+ jsonResponse(res, { error: 'Project tests directory not found' }, 404);
541
+ return;
542
+ }
543
+ const modulesDir = path.join(cwd, 'e2e', 'modules');
544
+ jsonResponse(res, runModuleAnalysis(testsDir, modulesDir, projectId));
545
+ } catch (error) {
546
+ jsonResponse(res, { error: error.message }, 500);
547
+ }
548
+ return;
549
+ }
550
+
461
551
  // API: DB — project variables (set/upsert)
462
552
  if (projectVarsMatch && req.method === 'PUT') {
463
553
  let body = '';
@@ -545,6 +635,47 @@ export async function startDashboard(config) {
545
635
  return;
546
636
  }
547
637
 
638
+ // API: delete screenshots — { paths: [...] }, each validated against known dirs
639
+ if (pathname === '/api/screenshots/delete' && req.method === 'POST') {
640
+ let body = '';
641
+ let oversize = false;
642
+ req.on('data', chunk => { body += chunk; if (body.length > MAX_BODY) { oversize = true; req.destroy(); } });
643
+ req.on('end', () => {
644
+ if (oversize) { jsonResponse(res, { error: 'Payload too large' }, 413); return; }
645
+ try {
646
+ const { paths } = body ? JSON.parse(body) : {};
647
+ if (!Array.isArray(paths) || !paths.length) { jsonResponse(res, { error: 'Missing paths array' }, 400); return; }
648
+ // Build the allow-list of directories deletions may touch.
649
+ const allowedDirs = [path.resolve(config.screenshotsDir)];
650
+ try {
651
+ for (const p of dbListProjects()) {
652
+ const dir = p.screenshots_dir || path.join(p.cwd, 'e2e', 'screenshots');
653
+ allowedDirs.push(path.resolve(dir));
654
+ }
655
+ } catch { /* db may be unavailable */ }
656
+ let deleted = 0;
657
+ const failed = [];
658
+ for (const raw of paths) {
659
+ try {
660
+ if (typeof raw !== 'string' || !path.isAbsolute(raw)) { failed.push({ path: raw, error: 'Invalid path' }); continue; }
661
+ const real = fs.realpathSync(raw);
662
+ const inAllowed = allowedDirs.some(dir => real.startsWith(dir + path.sep) || real === dir);
663
+ if (!inAllowed) { failed.push({ path: raw, error: 'Access denied' }); continue; }
664
+ if (!/\.(png|jpg|jpeg|gif|webp)$/i.test(real)) { failed.push({ path: raw, error: 'Not an image' }); continue; }
665
+ fs.unlinkSync(real);
666
+ deleted++;
667
+ } catch (e) {
668
+ failed.push({ path: raw, error: e.message });
669
+ }
670
+ }
671
+ jsonResponse(res, { deleted, failed });
672
+ } catch (error) {
673
+ jsonResponse(res, { error: error.message }, 500);
674
+ }
675
+ });
676
+ return;
677
+ }
678
+
548
679
  // API: visual diff — compare two screenshots on demand
549
680
  if (pathname === '/api/visual-diff') {
550
681
  try {
package/src/db.js CHANGED
@@ -429,6 +429,8 @@ export function saveRun(projectId, report, runId, suiteName, triggeredBy, poolDr
429
429
  narrative: a.narrative || undefined,
430
430
  error: a.error || undefined,
431
431
  actionRetries: a.actionRetries || undefined,
432
+ autoScreenshot: a.autoScreenshot || undefined,
433
+ screenshot: a.result?.screenshot || undefined,
432
434
  }));
433
435
 
434
436
  insertTest.run(
@@ -461,6 +463,15 @@ export function saveRun(projectId, report, runId, suiteName, triggeredBy, poolDr
461
463
  const actionIdx = r.actions.indexOf(a);
462
464
  insertHash.run(computeScreenshotHash(a.result.screenshot), a.result.screenshot, projectId, runDbId, r.name, actionIdx, null, 'action');
463
465
  }
466
+
467
+ // Auto-captured per-step thumbnails for the storyline view
468
+ (r.actions || []).forEach((a, idx) => {
469
+ if (a.autoScreenshot) {
470
+ try {
471
+ insertHash.run(computeScreenshotHash(a.autoScreenshot), a.autoScreenshot, projectId, runDbId, r.name, idx, null, 'step');
472
+ } catch { /* best effort */ }
473
+ }
474
+ });
464
475
  if (r.errorScreenshot) {
465
476
  insertHash.run(computeScreenshotHash(r.errorScreenshot), r.errorScreenshot, projectId, runDbId, r.name, null, null, 'error');
466
477
  }
package/src/mcp-tools.js CHANGED
@@ -137,6 +137,11 @@ export const TOOLS = [
137
137
  click_chip: { type: "click_chip", text: "Active" } — MUI Chip / tag elements
138
138
  click_icon: { type: "click_icon", value: "edit" } — SVG/icon by data-testid, aria-label, class
139
139
  click_in_context:{ type: "click_in_context", text: "Row text", selector: "button" } — child within container
140
+ click (in dialog):{ type: "click", text: "Confirm", scope: "dialog", last: true } — only [role=dialog]/MuiDialog; visible:true skips hidden; last:true picks last match
141
+
142
+ **Selecting from a MUI Autocomplete/Select** — DON'T write evaluate to open+filter+pick:
143
+ select_combobox: { type: "select_combobox", selector: "input[role='combobox']", filter: "cardio", text: "Cardiología" }
144
+ — opens the combobox, types optional filter, clicks the matching option (role=option / MuiAutocomplete-option / MuiMenuItem)
140
145
 
141
146
  **Asserting text presence/absence** — DON'T write evaluate with body.includes():
142
147
  assert_text: { type: "assert_text", text: "Welcome" } — text IS on page (case-sensitive). Uses: text
@@ -164,12 +169,13 @@ IMPORTANT field rules:
164
169
  navigate: { type: "navigate", value: "/settings" } — SPA-friendly (won't fail if no page load)
165
170
  wait: { type: "wait", text: "Loading complete" } — wait for text to appear in body
166
171
  wait: { type: "wait", selector: ".results" } — wait for element to appear
167
- wait: { type: "wait", value: "2000" } fixed delay (avoid when possible)
172
+ wait (gone): { type: "wait", gone: ".MuiBackdrop-root" } wait until a selector disappears/hides (spinner, closing dialog)
173
+ wait: { type: "wait", value: "2000" } — fixed delay (last resort — prefer gone/selector/text)
168
174
  wait_network_idle: { type: "wait_network_idle", value: "500" } — wait until no network for N ms
169
175
 
170
176
  **Form interaction** — DON'T write evaluate with native value setters (unless React):
171
177
  type: { type: "type", selector: "#email", value: "a@b.com" } — clears + types
172
- type_react: { type: "type_react", selector: "#email", value: "a@b.com" } — for React controlled inputs
178
+ type_react: { type: "type_react", selector: "#email", value: "a@b.com", waitAfter: "400" } — React controlled inputs; optional blur:true / waitAfter ms
173
179
  select: { type: "select", selector: "select#country", value: "US" }
174
180
  clear: { type: "clear", selector: "#search" }
175
181
  press: { type: "press", value: "Enter" }