@matware/e2e-runner 1.3.1 → 1.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +4 -4
- package/.claude-plugin/plugin.json +2 -2
- package/LICENSE +1 -1
- package/README.md +491 -225
- package/agents/test-creator.md +4 -2
- package/agents/test-improver.md +7 -4
- package/bin/cli.js +93 -19
- package/package.json +4 -3
- package/skills/e2e-testing/SKILL.md +5 -3
- package/skills/e2e-testing/references/action-types.md +35 -18
- package/skills/e2e-testing/references/test-json-format.md +23 -0
- package/skills/e2e-testing/references/troubleshooting.md +2 -26
- package/src/actions.js +181 -15
- package/src/config.js +6 -0
- package/src/dashboard.js +185 -9
- package/src/db.js +26 -0
- package/src/mcp-tools.js +238 -69
- package/src/module-analysis.js +247 -0
- package/src/module-resolver.js +35 -2
- package/src/narrate.js +33 -1
- package/src/pool-manager.js +46 -1
- package/src/pool.js +177 -20
- package/src/runner.js +144 -19
- package/src/visual-diff.js +74 -4
- package/src/websocket.js +14 -3
- package/src/wizard.js +184 -0
- package/templates/build-dashboard.js +3 -0
- package/templates/dashboard/js/api.js +60 -3
- package/templates/dashboard/js/init.js +46 -0
- package/templates/dashboard/js/keyboard.js +8 -7
- package/templates/dashboard/js/quicksearch.js +277 -0
- package/templates/dashboard/js/state.js +61 -7
- package/templates/dashboard/js/toast.js +1 -1
- package/templates/dashboard/js/utils.js +23 -2
- package/templates/dashboard/js/view-live.js +235 -42
- package/templates/dashboard/js/view-runs.js +469 -42
- package/templates/dashboard/js/view-tests.js +157 -16
- package/templates/dashboard/js/view-tools.js +234 -0
- package/templates/dashboard/js/view-watch.js +2 -2
- package/templates/dashboard/js/websocket.js +33 -3
- package/templates/dashboard/styles/base.css +489 -53
- package/templates/dashboard/styles/components.css +736 -84
- package/templates/dashboard/styles/view-live.css +459 -78
- package/templates/dashboard/styles/view-runs.css +826 -177
- package/templates/dashboard/styles/view-tests.css +440 -77
- package/templates/dashboard/styles/view-tools.css +206 -0
- package/templates/dashboard/styles/view-watch.css +198 -41
- package/templates/dashboard/template.html +356 -58
- package/templates/dashboard.html +5354 -722
- package/templates/docker-compose-lightpanda.yml +7 -0
package/src/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) =>
|
|
55
|
-
|
|
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
|
|
60
|
-
const
|
|
61
|
-
if (
|
|
62
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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 —
|
|
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
|
|
96
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: '
|
|
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
|
}
|