@matware/e2e-runner 1.5.0 → 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 +3 -3
- package/.claude-plugin/plugin.json +1 -1
- package/LICENSE +1 -1
- package/README.md +451 -274
- package/agents/test-improver.md +2 -1
- package/bin/cli.js +13 -2
- package/package.json +2 -2
- package/skills/e2e-testing/SKILL.md +2 -1
- package/skills/e2e-testing/references/action-types.md +17 -18
- package/skills/e2e-testing/references/troubleshooting.md +2 -26
- package/src/actions.js +12 -2
- package/src/dashboard.js +50 -5
- package/src/db.js +15 -0
- package/src/mcp-tools.js +238 -75
- package/src/narrate.js +19 -0
- package/src/runner.js +72 -14
- package/src/visual-diff.js +8 -7
- package/templates/dashboard/js/utils.js +23 -2
- package/templates/dashboard/js/view-runs.js +94 -9
- package/templates/dashboard/styles/components.css +17 -0
- package/templates/dashboard/styles/view-runs.css +51 -4
- package/templates/dashboard/template.html +2 -2
- package/templates/dashboard.html +187 -17
package/agents/test-improver.md
CHANGED
|
@@ -24,7 +24,7 @@ You are a specialist in refactoring and optimizing existing E2E tests without ch
|
|
|
24
24
|
|
|
25
25
|
- **Evaluate replacement**: Replace verbose `evaluate` actions with equivalent built-in actions (`type_react`, `click_option`, `assert_element_text`, etc.)
|
|
26
26
|
- **Duplication extraction**: Identify repeated action sequences across tests and extract them into reusable modules (`$use`)
|
|
27
|
-
- **Selector hardening**: Replace brittle selectors (nth-child, deep nesting, generated classes) with stable alternatives (`data-testid`, `id`, text-based)
|
|
27
|
+
- **Selector hardening**: Replace brittle selectors (nth-child, deep nesting, generated classes) with stable alternatives (`data-testid`, `id`, text-based). Applies to *interaction* selectors only — assertion selectors are treated as stable contracts (see Rules)
|
|
28
28
|
- **Flaky test stabilization**: Add `wait` actions, `retries`, and `serial: true` based on historical failure data from the learning system
|
|
29
29
|
- **Fixed delay elimination**: Replace hardcoded `wait` with ms values with condition waits — `wait` on a `selector`/`text` to appear, or `wait` with `gone` to wait for a spinner/backdrop/dialog to disappear (e.g. `{ "type": "wait", "gone": ".MuiBackdrop-root" }`)
|
|
30
30
|
- **Visual verification**: Add `expect` fields to tests that lack visual verification
|
|
@@ -167,6 +167,7 @@ When extracting to a module, use `{{param}}` placeholders for values that vary b
|
|
|
167
167
|
4. **Preserve test ordering** — don't reorder tests within a suite. Numeric prefix ordering is intentional.
|
|
168
168
|
5. **Keep evaluates when no built-in exists** — if the evaluate does something that no built-in action covers (e.g., complex DOM manipulation, localStorage checks), leave it as-is.
|
|
169
169
|
6. **Prefer selector waits over fixed delays** — replace `{ "type": "wait", "value": "3000" }` with `{ "type": "wait", "selector": ".expected-element" }` when possible. Only keep fixed delays when there's genuinely no element to wait for.
|
|
170
|
+
7. **Assertion selectors are the contract** — interaction selectors may heal, but never retarget the `selector` of an `assert_*` action while hardening: that silently changes *what* the test verifies. If an assertion selector is genuinely broken, pin it to a stable `data-testid` and call it out in the summary instead of swapping it for whatever makes the test green.
|
|
170
171
|
|
|
171
172
|
## Output
|
|
172
173
|
|
package/bin/cli.js
CHANGED
|
@@ -35,6 +35,7 @@ import { loadConfig } from '../src/config.js';
|
|
|
35
35
|
import { startPool, stopPool, restartPool, connectToPool } from '../src/pool.js';
|
|
36
36
|
import { getPoolUrls, getAggregatedPoolStatus, waitForAnyPool, selectPool } from '../src/pool-manager.js';
|
|
37
37
|
import { runTestsParallel, loadTestFile, loadTestSuite, loadAllSuites, listSuites } from '../src/runner.js';
|
|
38
|
+
import { looksLikeBlankCapture } from '../src/actions.js';
|
|
38
39
|
import { generateReport, saveReport, printReport, persistRun, printInsights } from '../src/reporter.js';
|
|
39
40
|
import { startDashboard } from '../src/dashboard.js';
|
|
40
41
|
import { startWatch } from '../src/watch.js';
|
|
@@ -152,6 +153,7 @@ ${C.bold}Usage:${C.reset}
|
|
|
152
153
|
e2e-runner capture <url> --selector <sel> Wait for selector before capture
|
|
153
154
|
e2e-runner capture <url> --delay <ms> Wait before capturing
|
|
154
155
|
e2e-runner capture <url> --filename <name> Custom filename
|
|
156
|
+
e2e-runner capture <url> --force Save even if the frame is blank
|
|
155
157
|
|
|
156
158
|
e2e-runner issue <url> Fetch issue and show details
|
|
157
159
|
e2e-runner issue <url> --generate Generate test file via Claude API
|
|
@@ -546,7 +548,7 @@ async function cmdDashboard() {
|
|
|
546
548
|
async function cmdCapture() {
|
|
547
549
|
const url = args[1];
|
|
548
550
|
if (!url || url.startsWith('--')) {
|
|
549
|
-
console.error(`${C.red}Usage: e2e-runner capture <url> [--filename <name>] [--full-page] [--selector <sel>] [--delay <ms>]${C.reset}`);
|
|
551
|
+
console.error(`${C.red}Usage: e2e-runner capture <url> [--filename <name>] [--full-page] [--selector <sel>] [--delay <ms>] [--force]${C.reset}`);
|
|
550
552
|
process.exit(1);
|
|
551
553
|
}
|
|
552
554
|
|
|
@@ -592,7 +594,16 @@ async function cmdCapture() {
|
|
|
592
594
|
|
|
593
595
|
const screenshotPath = path.join(config.screenshotsDir, filename);
|
|
594
596
|
const fullPage = hasFlag('--full-page');
|
|
595
|
-
await page.screenshot({
|
|
597
|
+
const captureBuf = await page.screenshot({ fullPage });
|
|
598
|
+
|
|
599
|
+
// Blank frame (uniform color — page never rendered): skip the save
|
|
600
|
+
// unless the user explicitly forces it.
|
|
601
|
+
if (!hasFlag('--force') && looksLikeBlankCapture(captureBuf, 'png')) {
|
|
602
|
+
log('⚠️', `${C.yellow}Capture skipped:${C.reset} page rendered a blank (uniform-color) frame — nothing saved. Use ${C.dim}--force${C.reset} to save anyway.`);
|
|
603
|
+
console.log('');
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
fs.writeFileSync(screenshotPath, captureBuf);
|
|
596
607
|
|
|
597
608
|
// Register hash in SQLite
|
|
598
609
|
const cwd = process.cwd();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@matware/e2e-runner",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.1",
|
|
4
4
|
"mcpName": "io.github.fastslack/e2e-runner",
|
|
5
5
|
"description": "E2E test runner using Chrome Pool (browserless/chrome) with parallel execution",
|
|
6
6
|
"type": "module",
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"github-issues",
|
|
40
40
|
"ai-testing"
|
|
41
41
|
],
|
|
42
|
-
"author": "Matware",
|
|
42
|
+
"author": "Matias Aguirre (Matware)",
|
|
43
43
|
"license": "Apache-2.0",
|
|
44
44
|
"repository": {
|
|
45
45
|
"type": "git",
|
|
@@ -9,7 +9,7 @@ description: Create, run, and debug JSON-driven E2E browser tests with Chrome po
|
|
|
9
9
|
|
|
10
10
|
`@matware/e2e-runner` is a JSON-driven E2E test runner. Tests are defined as JSON files with sequential browser actions — no JavaScript test code. Tests run in parallel against a Chrome pool (browserless/chrome via Docker) using Puppeteer.
|
|
11
11
|
|
|
12
|
-
**Key capabilities:**
|
|
12
|
+
**Key capabilities:** 17 MCP tools for running tests, creating test files, capturing screenshots, analyzing network traffic, verifying GitHub/GitLab issues, and querying a learning system for stability insights.
|
|
13
13
|
|
|
14
14
|
## Prerequisites
|
|
15
15
|
|
|
@@ -159,6 +159,7 @@ Start/stop the web dashboard with `e2e_dashboard_start` / `e2e_dashboard_stop` f
|
|
|
159
159
|
4. **`evaluate` is strict** — Returns starting with `FAIL:`/`ERROR:` or returning `false` will fail the test. Prefer granular assertion actions over `evaluate` with inline JS.
|
|
160
160
|
5. **Serial tests** — Mark tests with `"serial": true` if they share mutable state. They run after all parallel tests.
|
|
161
161
|
6. **Action retries** — Use `"retries": N` on individual actions for flaky selectors, or globally via config.
|
|
162
|
+
7. **Assertion selectors are the contract** — when fixing flaky tests, heal *interaction* selectors freely, but never retarget an `assert_*` selector to make a test pass: pin assertion selectors to stable `data-testid`s.
|
|
162
163
|
|
|
163
164
|
## References
|
|
164
165
|
|
|
@@ -47,11 +47,25 @@ Complete catalog of all action types supported by @matware/e2e-runner.
|
|
|
47
47
|
| `click_menu_item` | `text` (menu item text), `selector` (scope, optional) | Click a menu item by text. Searches `[role="menuitem"]`, `[role="menuitemradio"]`, `[role="menuitemcheckbox"]`, `.dropdown-item`, `.menu-item`, `[class*="MenuItem"]`, `[role="menu"] > li`. Waits for element to appear. |
|
|
48
48
|
| `click_in_context` | `text` (container text), `selector` (child to click) | Find the smallest container whose text includes `text`, then click the `selector` child within it. Containers: `section`, `article`, `[class*="card"]`, `li`, `tr`, `div[class]`, etc. Both fields required. |
|
|
49
49
|
|
|
50
|
+
## Multi-Tab
|
|
51
|
+
|
|
52
|
+
All subsequent actions run in the active tab. The runner manages a tab registry keyed by label.
|
|
53
|
+
|
|
54
|
+
| Action | Fields | Description |
|
|
55
|
+
|--------|--------|-------------|
|
|
56
|
+
| `open_tab` | `value` (URL), `text` (label, optional) | Open a new tab and navigate (relative to `baseUrl` or absolute). Label defaults to `tab-<n>`. The new tab becomes active. |
|
|
57
|
+
| `switch_tab` | `value` | Switch active tab by label (exact), numeric index, or title/URL match (regex or substring). `"default"` returns to the original tab. |
|
|
58
|
+
| `wait_for_tab` | `text` (label, optional), `timeout` | Wait for a tab/popup opened by the app (`window.open`, `target="_blank"`) and make it active. Use right after the action that triggers the popup. |
|
|
59
|
+
| `assert_tab_count` | `value` | Assert number of open tabs: exact (`"2"`) or operators (`">=2"`). |
|
|
60
|
+
| `close_tab` | `value` (label, optional) | Close the current (or named) tab and switch back to the last remaining one. Cannot close `default` while other tabs are open. |
|
|
61
|
+
|
|
50
62
|
## Assertions
|
|
51
63
|
|
|
52
64
|
| Action | Fields | Description |
|
|
53
65
|
|--------|--------|-------------|
|
|
54
66
|
| `assert_text` | `text` | Check entire page body contains text (substring match). |
|
|
67
|
+
| `assert_no_text` | `text` | Check text does NOT appear anywhere in the page body. Opposite of `assert_text`. |
|
|
68
|
+
| `assert_text_in` | `selector`, `text`, `value` (`"exact"` optional) | Check text inside a scoped container. Joins `textContent` from all matching elements. Default: case-insensitive regex; with `value: "exact"`: case-sensitive substring. |
|
|
55
69
|
| `assert_element_text` | `selector`, `text`, `value` (`"exact"` optional) | Check specific element's `textContent`. Default: substring match. With `value: "exact"`: strict `trim() ===` comparison. |
|
|
56
70
|
| `assert_url` | `value` | Check current URL. Path-only (`/dashboard`) compares pathname. Full URL does substring match. |
|
|
57
71
|
| `assert_visible` | `selector` | Element exists and is visible (`display`, `visibility`, `opacity` checks). |
|
|
@@ -62,11 +76,14 @@ Complete catalog of all action types supported by @matware/e2e-runner.
|
|
|
62
76
|
| `assert_input_value` | `selector`, `value` | Checks `element.value.includes(value)` on input/select/textarea. |
|
|
63
77
|
| `assert_matches` | `selector`, `value` (regex) | Tests element's `textContent` against `new RegExp(value)`. |
|
|
64
78
|
| `assert_no_network_errors` | — | Checks accumulated `requestfailed` events during the test. Fails with error details if any exist. |
|
|
79
|
+
| `assert_visual` | `value` (golden image filename), `selector` (optional element scope), `text` (max diff fraction, e.g. `"0.02"`), plus `fullPage`, `maskRegions`, `threshold` | Visual regression against a golden reference image (`goldenDir`, default `{screenshotsDir}/golden`). First run saves the golden and passes; later runs fail if more pixels differ than the max diff (default 2%) and write a diff image. `maskRegions: [{x,y,width,height}]` ignores dynamic areas (timestamps, avatars). |
|
|
65
80
|
|
|
66
81
|
### Assertion Disambiguation
|
|
67
82
|
|
|
68
83
|
- **`assert_text`** → searches the **entire page body** (substring)
|
|
84
|
+
- **`assert_no_text`** → asserts text is **absent** from the page body (do NOT use `assert_not_visible` with `text` — it requires `selector`)
|
|
69
85
|
- **`assert_element_text`** → checks a **specific element** (substring, or exact with `value: "exact"`)
|
|
86
|
+
- **`assert_text_in`** → checks text inside a **scoped container** (regex by default)
|
|
70
87
|
- **`assert_matches`** → checks a specific element against a **regex** pattern
|
|
71
88
|
- **`assert_input_value`** → reads the `.value` property (for form fields)
|
|
72
89
|
|
|
@@ -134,24 +151,6 @@ Delay between retries: `actionRetryDelay` config (default 500ms).
|
|
|
134
151
|
{ "type": "click_regex", "text": "add to cart", "selector": "button", "value": "last" }
|
|
135
152
|
```
|
|
136
153
|
|
|
137
|
-
### Form validation assertions
|
|
138
|
-
```json
|
|
139
|
-
{ "type": "assert_attribute", "selector": "input#email", "value": "type=email" },
|
|
140
|
-
{ "type": "assert_attribute", "selector": "button.submit", "value": "disabled" },
|
|
141
|
-
{ "type": "assert_class", "selector": ".nav-item:first-child", "value": "active" },
|
|
142
|
-
{ "type": "assert_input_value", "selector": "#email", "value": "user@example.com" },
|
|
143
|
-
{ "type": "assert_matches", "selector": ".phone", "value": "\\d{3}-\\d{3}-\\d{4}" },
|
|
144
|
-
{ "type": "assert_count", "selector": ".table-row", "value": ">3" }
|
|
145
|
-
```
|
|
146
|
-
|
|
147
|
-
### Storage operations
|
|
148
|
-
```json
|
|
149
|
-
{ "type": "set_storage", "value": "authToken=eyJhbGciOiJIUzI1NiJ9..." },
|
|
150
|
-
{ "type": "assert_storage", "value": "authToken" },
|
|
151
|
-
{ "type": "set_storage", "value": "theme=dark", "selector": "session" },
|
|
152
|
-
{ "type": "assert_storage", "value": "theme=dark", "selector": "session" }
|
|
153
|
-
```
|
|
154
|
-
|
|
155
154
|
### Icon, menu, and contextual clicks
|
|
156
155
|
```json
|
|
157
156
|
{ "type": "click_icon", "value": "edit" },
|
|
@@ -193,32 +193,8 @@ The `KNOWN_ACTION_TYPES` Set in `src/actions.js` is the single source of truth.
|
|
|
193
193
|
|
|
194
194
|
## Screenshot Hashes
|
|
195
195
|
|
|
196
|
-
Every screenshot
|
|
197
|
-
|
|
198
|
-
**Flow**: screenshot saved on disk → `saveRun()` registers hash in SQLite `screenshot_hashes` table → dashboard shows `[ss:XXXXXXXX]` badge (click to copy) → user pastes hash in Claude Code → `e2e_screenshot` MCP tool looks up hash, reads file, returns the image.
|
|
199
|
-
|
|
200
|
-
- Hashes are registered inside the `saveRun()` transaction (covers action, error, verification, and baseline screenshots)
|
|
201
|
-
- The `ss:` prefix is optional when calling `e2e_screenshot` — stripped during lookup
|
|
202
|
-
- Dashboard computes hashes client-side (Web Crypto) for the Live view (before `persistRun()` writes to DB)
|
|
203
|
-
- Run detail API (`/api/db/runs/:id`) includes `screenshotHashes` map per test result
|
|
204
|
-
- Dashboard endpoint `/api/screenshot-hash/:hash` serves the image by hash
|
|
205
|
-
- Dashboard Screenshots view has a **search bar** — type a hash to find and display the screenshot
|
|
196
|
+
Every screenshot gets a short hash like `ss:a3f2b1c9` (deterministic, from its file path). The dashboard shows it as a click-to-copy badge; paste it (the `ss:` prefix is optional) and `e2e_screenshot` returns the image. The dashboard Screenshots view also has a hash search bar.
|
|
206
197
|
|
|
207
198
|
## Web Dashboard
|
|
208
199
|
|
|
209
|
-
|
|
210
|
-
**`templates/dashboard.html`** — SPA, dark theme, vanilla JS, safe DOM (textContent + createEl helper).
|
|
211
|
-
|
|
212
|
-
**Features:**
|
|
213
|
-
- Live test execution with WebSocket updates
|
|
214
|
-
- Run history with inline detail expansion
|
|
215
|
-
- Screenshots gallery with hash badges and hash search
|
|
216
|
-
- Network request logs with clickable expandable rows (full request/response detail)
|
|
217
|
-
- Pool status monitoring
|
|
218
|
-
- Multi-project support via project selector
|
|
219
|
-
- Variables tab with masked values, inline edit, add, and delete
|
|
220
|
-
|
|
221
|
-
**CLI:** `e2e-runner dashboard [--port 8484]`
|
|
222
|
-
**MCP tools:** `e2e_dashboard_start`, `e2e_dashboard_stop`
|
|
223
|
-
|
|
224
|
-
Config defaults: `dashboardPort: 8484`, `maxHistoryRuns: 100`
|
|
200
|
+
Start with `e2e_dashboard_start` (or `e2e-runner dashboard [--port 8484]`), stop with `e2e_dashboard_stop`. Provides live execution (WebSocket), run history, screenshot gallery + hash search, expandable network logs, pool status, multi-project selector, and a variables tab.
|
package/src/actions.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
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
13
|
|
|
14
14
|
/**
|
|
15
15
|
* Returns false when the page has nothing useful to capture — used to
|
|
@@ -50,7 +50,17 @@ export const BLANK_JPEG_BYTE_THRESHOLD = 8000;
|
|
|
50
50
|
export function looksLikeBlankCapture(buf, format = 'png') {
|
|
51
51
|
if (!Buffer.isBuffer(buf)) return false;
|
|
52
52
|
const threshold = format === 'jpeg' ? BLANK_JPEG_BYTE_THRESHOLD : BLANK_PNG_BYTE_THRESHOLD;
|
|
53
|
-
|
|
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;
|
|
54
64
|
}
|
|
55
65
|
|
|
56
66
|
/** All recognized action types — single source of truth for validation. */
|
package/src/dashboard.js
CHANGED
|
@@ -18,7 +18,7 @@ import { getPoolUrls, getAggregatedPoolStatus, waitForAnyPool } from './pool-man
|
|
|
18
18
|
import { runTestsParallel, loadAllSuites, loadTestSuite, listSuites } from './runner.js';
|
|
19
19
|
import { runModuleAnalysis } from './module-analysis.js';
|
|
20
20
|
import { generateReport, generateJUnitXML, saveReport, persistRun, loadHistory, loadHistoryRun } from './reporter.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, 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';
|
|
22
22
|
import { loadConfig } from './config.js';
|
|
23
23
|
import { log, colors as C } from './logger.js';
|
|
24
24
|
import { getLearningsSummary, getFlakySummary, getSelectorStability, getPageHealth, getApiHealth, getErrorPatterns, getTestTrends, getRunInsights, getHealthSnapshot, getActionHealthScores } from './learner-sqlite.js';
|
|
@@ -37,6 +37,25 @@ const { version: VERSION } = _require('../package.json');
|
|
|
37
37
|
const __filename = fileURLToPath(import.meta.url);
|
|
38
38
|
const __dirname = path.dirname(__filename);
|
|
39
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
|
+
|
|
40
59
|
/** Starts the dashboard server */
|
|
41
60
|
export async function startDashboard(config) {
|
|
42
61
|
const port = config.dashboardPort || 8484;
|
|
@@ -344,7 +363,9 @@ export async function startDashboard(config) {
|
|
|
344
363
|
return;
|
|
345
364
|
}
|
|
346
365
|
|
|
347
|
-
// 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)
|
|
348
369
|
const projectScreenshotsMatch = pathname.match(/^\/api\/db\/projects\/(\d+)\/screenshots$/);
|
|
349
370
|
if (projectScreenshotsMatch) {
|
|
350
371
|
try {
|
|
@@ -355,7 +376,30 @@ export async function startDashboard(config) {
|
|
|
355
376
|
return;
|
|
356
377
|
}
|
|
357
378
|
const files = fs.readdirSync(dir).filter(f => /\.(png|jpg|jpeg|gif|webp)$/i.test(f)).sort();
|
|
358
|
-
|
|
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
|
+
}));
|
|
359
403
|
} catch (error) {
|
|
360
404
|
jsonResponse(res, { error: error.message }, 500);
|
|
361
405
|
}
|
|
@@ -758,9 +802,10 @@ export async function startDashboard(config) {
|
|
|
758
802
|
return;
|
|
759
803
|
}
|
|
760
804
|
const ext = path.extname(realPath).toLowerCase();
|
|
761
|
-
|
|
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' };
|
|
762
807
|
if (!mimeTypes[ext]) {
|
|
763
|
-
jsonResponse(res, { error: '
|
|
808
|
+
jsonResponse(res, { error: 'Unsupported file type' }, 400);
|
|
764
809
|
return;
|
|
765
810
|
}
|
|
766
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();
|
|
@@ -430,6 +444,7 @@ export function saveRun(projectId, report, runId, suiteName, triggeredBy, poolDr
|
|
|
430
444
|
error: a.error || undefined,
|
|
431
445
|
actionRetries: a.actionRetries || undefined,
|
|
432
446
|
autoScreenshot: a.autoScreenshot || undefined,
|
|
447
|
+
dataCapture: a.dataCapture || undefined,
|
|
433
448
|
screenshot: a.result?.screenshot || undefined,
|
|
434
449
|
}));
|
|
435
450
|
|