/**
* pageCapture.js — Page-level artifact capture helpers
*
* Extracts DOM snapshot, screenshot, and bounding-box capture logic from
* executeTest so each concern is independently testable and the main
* execution function stays focused on orchestration.
*
* Exports:
* waitForStable(page, opts) — S3-02: MutationObserver DOM stability wait
* captureDomSnapshot(page)
* captureScreenshot(page, runId, stepIndex, { failed })
* captureBoundingBoxes(page)
* registerWebVitalsInitScript(context) — AUTO-017.1: install vitals observers before navigation
* captureWebVitals(page) — AUTO-017.1: read accumulated vitals at test end
*/
import path from "path";
import fs from "fs";
import { createRequire } from "module";
import { SHOTS_DIR } from "./config.js";
import { writeArtifactBuffer } from "../utils/objectStorage.js";
// AUTO-017: Resolve and cache the locally-installed `web-vitals` IIFE bundle so
// we can install it via `context.addInitScript({ content })` without hitting an
// external CDN at test time. Falls back to `null` if the package isn't installed
// (e.g. minimal Docker builds) — the init-script registration and capture both
// no-op in that case, returning the empty-metrics shape rather than crashing.
//
// NOTE: we can't `req.resolve("web-vitals/dist/web-vitals.iife.js")` directly
// because `web-vitals@4.x`'s `package.json` declares an `exports` field that
// only exposes `.` and `./attribution` — Node 20 strictly enforces this and
// throws ERR_PACKAGE_PATH_NOT_EXPORTED. Instead we resolve the package's
// `package.json` (which `exports` always exposes by convention) and derive the
// IIFE path from the package root. This layout is stable across web-vitals
// v3 / v4 / v5 — `dist/web-vitals.iife.js` is the canonical IIFE bundle.
// We resolve the package's *main entry* (which `exports` always exposes as `.`)
// and walk up to the package root, then derive the IIFE path. Resolving
// `web-vitals/package.json` directly throws ERR_PACKAGE_PATH_NOT_EXPORTED on
// Node 20 because `web-vitals@4.x`'s `exports` field only declares `.` and
// `./attribution`. The IIFE bundle layout (`dist/web-vitals.iife.js`) is
// stable across v3 / v4 / v5.
let WEB_VITALS_IIFE = null;
try {
const req = createRequire(import.meta.url);
const mainPath = req.resolve("web-vitals");
// The main entry lives at `<pkgRoot>/dist/web-vitals.js` (or similar inside
// dist/). Walk up until we find the package root (directory containing
// `package.json`), then join `dist/web-vitals.iife.js`.
let pkgRoot = path.dirname(mainPath);
while (pkgRoot !== path.dirname(pkgRoot) && !fs.existsSync(path.join(pkgRoot, "package.json"))) {
pkgRoot = path.dirname(pkgRoot);
}
const iifePath = path.join(pkgRoot, "dist", "web-vitals.iife.js");
WEB_VITALS_IIFE = fs.readFileSync(iifePath, "utf8");
} catch { /* package not installed or layout changed — web-vitals helpers will no-op */ }
// AUTO-017.1: Bootstrap that runs *after* the IIFE in the same init-script so
// `window.webVitals` is already defined. Registers observers on every new
// document (addInitScript fires on every frame navigation) so LCP / CLS / TTFB
// are captured during the real page lifecycle instead of being injected
// post-test (when buffered entries are unreliable and the cumulative CLS
// observer has missed earlier shifts). Results accumulate on
// `window.__sentriVitals` for `captureWebVitals()` to read at test end.
const WEB_VITALS_BOOTSTRAP = `
(function () {
try {
if (window.__sentriVitalsInstalled) return;
window.__sentriVitalsInstalled = true;
window.__sentriVitals = { lcp: null, cls: null, inp: null, ttfb: null };
if (!window.webVitals) return;
window.webVitals.onLCP(function (m) { window.__sentriVitals.lcp = Math.round(m.value); }, { reportAllChanges: true });
window.webVitals.onCLS(function (m) { window.__sentriVitals.cls = Number(m.value.toFixed(3)); }, { reportAllChanges: true });
window.webVitals.onINP(function (m) { window.__sentriVitals.inp = Math.round(m.value); }, { reportAllChanges: true });
window.webVitals.onTTFB(function (m) { window.__sentriVitals.ttfb = Math.round(m.value); }, { reportAllChanges: true });
} catch (e) { /* best-effort — never break the page */ }
})();
`;
/**
* registerWebVitalsInitScript(context) — AUTO-017.1
*
* Installs the web-vitals IIFE + observer bootstrap on the browser context
* via `addInitScript`, so observers are active from the first byte of every
* navigation. Must be called once per context immediately after creation and
* before the first `page.goto()`.
*
* No-ops when the web-vitals package isn't installed — callers should still
* invoke `captureWebVitals(page)`, which returns the empty-metrics shape in
* that case.
*/
export async function registerWebVitalsInitScript(context) {
if (!WEB_VITALS_IIFE) return;
try {
await context.addInitScript({ content: WEB_VITALS_IIFE + "\n" + WEB_VITALS_BOOTSTRAP });
} catch { /* context may be closing — capture will fall back to nulls */ }
}
/**
* waitForStable(page, opts) → Promise<void>
*
* S3-02 — DOM stability wait using MutationObserver.
*
* Modern SPAs (React, Vue, Angular, Next.js) and apps with streaming AI
* responses, skeleton screens, or async data fetches settle at variable
* times. Using a fixed `waitForTimeout` causes tests to assert on
* partially-rendered pages, producing false failures.
*
* This helper installs a MutationObserver on `document.body` that counts
* every DOM mutation. It polls until `stableSec` consecutive seconds pass
* with no new mutations (or `timeoutSec` is reached), then disconnects
* cleanly. The observer and mutation counter are stored on `window` so
* they survive across evaluate() calls and can be cleaned up reliably.
*
* Based on the Assrt `agent.ts` pattern referenced in NEXT_STEPS S3-02.
*
* @param {Object} page - Playwright Page instance
* @param {object} [opts]
* @param {number} [opts.timeoutSec=30] - Maximum wait in seconds
* @param {number} [opts.stableSec=2] - Quiet period required to declare stable
* @returns {Promise<void>}
*/
export async function waitForStable(page, { timeoutSec = 30, stableSec = 2 } = {}) {
// Install the MutationObserver in the page context. Stored on window so
// subsequent evaluate() calls can read the counter and clean up.
await page.evaluate(() => {
// Guard: if a previous waitForStable call was interrupted, disconnect it
// first so we don't accumulate multiple observers.
if (window.__sentri_observer) {
try { window.__sentri_observer.disconnect(); } catch {}
}
window.__sentri_mutations = 0;
window.__sentri_observer = new MutationObserver(mutations => {
window.__sentri_mutations += mutations.length;
});
window.__sentri_observer.observe(document.body, {
childList: true,
subtree: true,
characterData: true,
attributes: true,
});
}).catch(() => {
// If evaluate fails (page navigating, closed) — swallow and continue.
// The caller's own timeout / test runner will handle truly broken pages.
});
const start = Date.now();
let lastCount = -1;
let stableSince = Date.now();
while (Date.now() - start < timeoutSec * 1000) {
await new Promise(r => setTimeout(r, 500));
let count = lastCount;
try {
count = await page.evaluate(() => window.__sentri_mutations ?? -1);
} catch {
// Page closed or navigated mid-poll — treat as stable and exit
break;
}
if (count !== lastCount) {
// DOM is still mutating — reset the stability clock
lastCount = count;
stableSince = Date.now();
} else if (Date.now() - stableSince >= stableSec * 1000) {
// No mutations for stableSec seconds — DOM has settled
break;
}
}
// Always disconnect and clean up, even on timeout
await page.evaluate(() => {
try { window.__sentri_observer?.disconnect(); } catch {}
delete window.__sentri_observer;
delete window.__sentri_mutations;
}).catch(() => {});
}
/**
*
* Serialises a shallow representation of the current DOM (max depth 4)
* for debugging and AI context. Returns null on any failure.
*/
export async function captureDomSnapshot(page) {
return page.evaluate(() => {
function serialize(node, depth = 0) {
if (depth > 4 || !node) return null;
if (node.nodeType === Node.TEXT_NODE) {
const t = node.textContent?.trim();
return t ? { type: "text", text: t.slice(0, 80) } : null;
}
if (node.nodeType !== Node.ELEMENT_NODE) return null;
const el = node;
const tag = el.tagName.toLowerCase();
if (["script","style","noscript","svg","path"].includes(tag)) return null;
const attrs = {};
for (const a of el.attributes) {
if (["id","class","href","src","type","role","aria-label","name"].includes(a.name))
attrs[a.name] = a.value.slice(0, 60);
}
const children = [];
for (const child of el.childNodes) {
const c = serialize(child, depth + 1);
if (c) children.push(c);
if (children.length >= 6) break;
}
return { type: "element", tag, attrs, children };
}
return serialize(document.body);
}).catch(() => null);
}
/**
* captureScreenshot(page, runId, stepIndex, opts) → { base64, artifactPath }
*
* Takes a PNG screenshot, writes it to disk, and returns both the base64
* string (for SSE) and the artifact path (for the DB).
*
* @param {Object} page
* @param {string} runId
* @param {number} stepIndex — test index within the run
* @param {Object} [opts]
* @param {boolean} [opts.failed] — appends "-fail" to the filename
* @param {number} [opts.stepNumber] — per-step capture (DIF-016): appends "-s{N}" to the filename
*/
export async function captureScreenshot(page, runId, stepIndex, { failed = false, stepNumber } = {}) {
const suffix = failed ? "-fail" : stepNumber != null ? `-s${stepNumber}` : "";
const shotName = `${runId}-step${stepIndex}${suffix}.png`;
const shotPath = path.join(SHOTS_DIR, shotName);
const buf = await page.screenshot({ type: "png", fullPage: false });
await writeArtifactBuffer({
artifactPath: `/artifacts/screenshots/${shotName}`,
absolutePath: shotPath,
buffer: buf,
contentType: "image/png",
});
return {
base64: buf.toString("base64"),
artifactPath: `/artifacts/screenshots/${shotName}`,
};
}
/**
* captureBoundingBoxes(page) → Array<{ x, y, width, height }>
*
* Collects bounding boxes of the last interacted / focused elements so
* the frontend OverlayCanvas can draw highlights.
*/
export async function captureBoundingBoxes(page) {
try {
return await page.evaluate(() => {
const boxes = [];
// Prefer the currently-focused element
const focused = document.activeElement;
if (focused && focused !== document.body && focused !== document.documentElement) {
const r = focused.getBoundingClientRect();
if (r.width > 0 && r.height > 0) {
boxes.push({ x: r.x, y: r.y, width: r.width, height: r.height });
}
}
// Also collect any elements with aria-selected / data-testid that are visible
if (boxes.length === 0) {
const candidates = document.querySelectorAll(
"button:focus, input:focus, [aria-selected='true'], [data-focused='true']"
);
for (const el of candidates) {
const r = el.getBoundingClientRect();
if (r.width > 0 && r.height > 0) {
boxes.push({ x: r.x, y: r.y, width: r.width, height: r.height });
if (boxes.length >= 3) break;
}
}
}
return boxes;
}).catch(() => []);
} catch {
return [];
}
}
/**
* captureWebVitals(page) — AUTO-017.1
*
* Reads the metrics accumulated on `window.__sentriVitals` by the observers
* installed via `registerWebVitalsInitScript` at context creation. Because the
* observers have been running during the entire page lifecycle, LCP / CLS /
* TTFB reflect actual measurements rather than post-hoc buffered replays.
*
* Waits up to 800ms (early-exiting as soon as LCP + TTFB + CLS are populated)
* to let any final `reportAllChanges` callbacks flush. INP is reported only
* after a user interaction — it stays `null` for non-interactive tests, which
* the evaluator treats as "not measured" rather than a failure.
*
* Falls back to the empty-metrics shape if the init script was never
* registered (e.g. web-vitals not installed, or context is an older run
* started before AUTO-017.1 landed).
*/
export async function captureWebVitals(page) {
if (!WEB_VITALS_IIFE) return { lcp: null, cls: null, inp: null, ttfb: null };
try {
const metrics = await page.evaluate(async () => {
return await new Promise((resolve) => {
const read = () => window.__sentriVitals || { lcp: null, cls: null, inp: null, ttfb: null };
// If the init script never ran (pre-AUTO-017.1 context, or navigation
// blocked before onload), bail immediately rather than waiting 800ms
// for metrics that will never arrive.
if (!window.__sentriVitalsInstalled) return resolve(read());
const started = Date.now();
const tick = () => {
const m = read();
const allCore = m.lcp != null && m.ttfb != null && m.cls != null;
if (allCore || Date.now() - started >= 800) return resolve(m);
setTimeout(tick, 100);
};
tick();
});
});
return metrics || { lcp: null, cls: null, inp: null, ttfb: null };
} catch {
return { lcp: null, cls: null, inp: null, ttfb: null };
}
}