@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.
@@ -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({ path: screenshotPath, fullPage });
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.0",
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:** 13 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.
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 captured during a run is assigned a short hash (`ss:a3f2b1c9`) the first 8 hex chars of the SHA-256 of its file path. Hashes are deterministic and computed identically on the server (Node `crypto`) and in the browser (Web Crypto API).
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
- **`src/dashboard.js`** HTTP server, REST API, WebSocket broadcast, pool polling.
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
- return buf.length < 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;
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
- jsonResponse(res, files.map(f => ({ name: f, path: path.join(dir, f) })));
379
+ const visible = files.filter(f => !isBlankScreenshotCached(path.join(dir, f)));
380
+ const fullPaths = visible.map(f => path.join(dir, f));
381
+ const meta = dbGetScreenshotMetaByPaths(fullPaths);
382
+ // Filenames embed the SANITIZED test name (runner's safeName); build a map
383
+ // back to the real name from DB-known entries so legacy files (no DB row)
384
+ // land in the same group as their DB-registered siblings
385
+ const sanitize = (s) => String(s).replace(/[^a-zA-Z0-9_\-. ]/g, '_');
386
+ const sanitizedToReal = {};
387
+ for (const m of Object.values(meta)) {
388
+ if (m.testName) sanitizedToReal[sanitize(m.testName)] = m.testName;
389
+ }
390
+ jsonResponse(res, visible.map(f => {
391
+ const fp = path.join(dir, f);
392
+ const m = meta[fp];
393
+ // Fallback for files predating DB metadata: parse the test name out of
394
+ // runner-generated filenames (step-/error-/baseline-/verify-/current-/diff-<test>-<ts>.<ext>)
395
+ let testName = m?.testName || null;
396
+ let type = m?.type || null;
397
+ if (!testName) {
398
+ const fm = f.match(/^(step|error|baseline|verify|current|diff)-(.+?)(?:-\d{3})?-\d{10,}\.(?:png|jpe?g|gif|webp)$/i);
399
+ if (fm) { type = type || fm[1].toLowerCase(); testName = sanitizedToReal[fm[2]] || fm[2]; }
400
+ }
401
+ return { name: f, path: fp, testName, type };
402
+ }));
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
- const mimeTypes = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp' };
805
+ // .json covers step data captures (raw API responses saved instead of screenshots)
806
+ const mimeTypes = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.json': 'application/json' };
762
807
  if (!mimeTypes[ext]) {
763
- jsonResponse(res, { error: 'Not an image' }, 400);
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