@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.
- package/.claude-plugin/marketplace.json +4 -4
- package/.claude-plugin/plugin.json +2 -2
- package/README.md +110 -21
- package/agents/test-creator.md +4 -2
- package/agents/test-improver.md +5 -3
- package/bin/cli.js +80 -17
- package/package.json +3 -2
- package/skills/e2e-testing/SKILL.md +3 -2
- package/skills/e2e-testing/references/action-types.md +22 -4
- package/skills/e2e-testing/references/test-json-format.md +23 -0
- package/src/actions.js +170 -14
- package/src/config.js +6 -0
- package/src/dashboard.js +135 -4
- package/src/db.js +11 -0
- package/src/mcp-tools.js +8 -2
- package/src/module-analysis.js +247 -0
- package/src/module-resolver.js +35 -2
- package/src/narrate.js +14 -1
- package/src/pool-manager.js +46 -1
- package/src/pool.js +177 -20
- package/src/runner.js +77 -10
- package/src/visual-diff.js +69 -0
- package/src/websocket.js +14 -3
- package/src/wizard.js +184 -0
- package/templates/build-dashboard.js +3 -0
- package/templates/dashboard/js/api.js +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/view-live.js +235 -42
- package/templates/dashboard/js/view-runs.js +379 -37
- 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 +719 -84
- package/templates/dashboard/styles/view-live.css +459 -78
- package/templates/dashboard/styles/view-runs.css +779 -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 +354 -56
- package/templates/dashboard.html +5173 -711
- 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) =>
|
|
55
|
-
|
|
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
|
|
60
|
-
const
|
|
61
|
-
if (
|
|
62
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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 —
|
|
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
|
|
96
|
-
|
|
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:
|
|
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" } —
|
|
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" }
|