diff --git a/packages/runtime-playground/src/browser-dom-snapshot.ts b/packages/runtime-playground/src/browser-dom-snapshot.ts index 503517ca..b3b80e36 100644 --- a/packages/runtime-playground/src/browser-dom-snapshot.ts +++ b/packages/runtime-playground/src/browser-dom-snapshot.ts @@ -21,12 +21,27 @@ export interface BrowserDomSelectorSnapshot { error?: string } +export interface BrowserDomSvgImagePayloadSnapshot { + imagePath: string + sha256: string + byteCount: number + textNodeCount: number + fontFamilyAttributes: string[] + fontSizeAttributes: string[] + fontStyleAttributes: string[] + fontWeightAttributes: string[] + letterSpacingAttributes: string[] + fontFaceRuleCount: number + styleText: { sha256: string; byteCount: number } +} + export interface BrowserDomSnapshot { url: string title: string elementCount: number capturedElements: BrowserDomElementSnapshot[] selectors?: BrowserDomSelectorSnapshot[] + svgImagePayloads?: BrowserDomSvgImagePayloadSnapshot[] truncated: boolean } @@ -44,7 +59,7 @@ export interface BrowserDomSnapshotArtifact { } export async function captureBrowserDomSnapshot(page: Page, maxElements: number, selectors: string[] = []): Promise { - return page.evaluate(({ maxElements: maxElementsInput, styleProperties, attributeNames, selectors: selectorInputs }) => { + return page.evaluate(async ({ maxElements: maxElementsInput, styleProperties, attributeNames, selectors: selectorInputs }) => { const maxElements = Math.max(1, Number(maxElementsInput) || 1) const elements = Array.from(document.body?.querySelectorAll("*") ?? []) const visibleElements = elements @@ -52,6 +67,7 @@ export async function captureBrowserDomSnapshot(page: Page, maxElements: number, .filter((element): element is BrowserDomElementSnapshot => Boolean(element)) const capturedByPath = new Map(visibleElements.slice(0, maxElements).map((element) => [element.path, element])) const selectorSnapshots = selectorInputs.map((selector) => selectorSnapshot(selector, capturedByPath, styleProperties, attributeNames)) + const svgImagePayloads = await Promise.all(Array.from(document.images).map((image) => svgImagePayloadSnapshot(image))) return { url: window.location.href, @@ -59,6 +75,7 @@ export async function captureBrowserDomSnapshot(page: Page, maxElements: number, elementCount: visibleElements.length, capturedElements: [...capturedByPath.values()], ...(selectorSnapshots.length > 0 ? { selectors: selectorSnapshots } : {}), + ...(svgImagePayloads.some((payload): payload is BrowserDomSvgImagePayloadSnapshot => Boolean(payload)) ? { svgImagePayloads: svgImagePayloads.filter((payload): payload is BrowserDomSvgImagePayloadSnapshot => Boolean(payload)) } : {}), truncated: visibleElements.length > maxElements, } @@ -87,7 +104,7 @@ export async function captureBrowserDomSnapshot(page: Page, maxElements: number, text: compactText(element.textContent || "", 180), attributes: Object.fromEntries(attributes.flatMap((name) => { const value = element.getAttribute(name) - return value === null ? [] : [[name, compactText(value, 180)]] + return value === null ? [] : [[name, compactText(redactPayloadUrl(value), 180)]] })), boundingBox: { x: roundNumber(rect.x), @@ -132,6 +149,102 @@ export async function captureBrowserDomSnapshot(page: Page, maxElements: number, return compact.length > maxLength ? `${compact.slice(0, maxLength - 1)}…` : compact } + function redactPayloadUrl(value: string): string { + return /^\s*(?:blob:|data:)/i.test(value) ? "[payload URL redacted]" : value + } + + async function svgImagePayloadSnapshot(image: HTMLImageElement): Promise { + // Match the readiness normalizer: responsive sources are left untouched, while + // same-document blobs and same-origin standalone SVGs are safe to inspect. + if (!crypto.subtle || image.srcset || image.parentElement?.tagName === "PICTURE") { + return undefined + } + const source = image.currentSrc || image.src + let parsed: URL + try { + parsed = new URL(source, document.baseURI) + } catch { + return undefined + } + if (parsed.protocol !== "blob:" && (parsed.origin !== location.origin || !/\.svg$/i.test(parsed.pathname))) { + return undefined + } + const maxBytes = 2 * 1024 * 1024 + try { + const response = await fetch(parsed.href) + const contentLength = Number(response.headers.get("content-length")) + if (!response.ok || (Number.isFinite(contentLength) && contentLength > maxBytes)) { + return undefined + } + const reader = response.body?.getReader() + if (!reader) { + return undefined + } + const chunks: Uint8Array[] = [] + let byteCount = 0 + while (true) { + const next = await reader.read() + if (next.done) { + break + } + byteCount += next.value.byteLength + if (byteCount > maxBytes) { + await reader.cancel() + return undefined + } + chunks.push(next.value) + } + const bytes = new Uint8Array(byteCount) + let offset = 0 + for (const chunk of chunks) { + bytes.set(chunk, offset) + offset += chunk.byteLength + } + const sourceText = new TextDecoder().decode(bytes) + if (!/\bimage\/svg\+xml\b/i.test(response.headers.get("content-type") ?? "") && !/^\s*(?:<\?xml[^>]*>\s*)? style.textContent ?? "").join("\n") + const styleBytes = new TextEncoder().encode(styleText) + function attributeValues(name: string): string[] { + return [...new Set(Array.from(svg.querySelectorAll("*")).flatMap((element) => { + const value = element.getAttribute(name)?.trim() + return value ? [value] : [] + }))].sort() + } + async function digest(value: Uint8Array): Promise { + const result = await crypto.subtle.digest("SHA-256", value as Uint8Array) + return Array.from(new Uint8Array(result)).map((byte) => byte.toString(16).padStart(2, "0")).join("") + } + const walker = svg.createTreeWalker(svg, NodeFilter.SHOW_TEXT) + let textNodeCount = 0 + while (walker.nextNode()) { + textNodeCount += 1 + } + const [sha256, styleSha256] = await Promise.all([digest(bytes), digest(styleBytes)]) + return { + imagePath: elementPath(image), + sha256, + byteCount, + textNodeCount, + fontFamilyAttributes: attributeValues("font-family"), + fontSizeAttributes: attributeValues("font-size"), + fontStyleAttributes: attributeValues("font-style"), + fontWeightAttributes: attributeValues("font-weight"), + letterSpacingAttributes: attributeValues("letter-spacing"), + fontFaceRuleCount: (styleText.match(/@font-face\b/gi) ?? []).length, + styleText: { sha256: styleSha256, byteCount: styleBytes.byteLength }, + } + } catch { + // Snapshot evidence is diagnostic only; inaccessible payloads must not block capture. + return undefined + } + } + function roundNumber(value: number): number { return Math.round(value * 100) / 100 } diff --git a/packages/runtime-playground/src/browser-visual-compare.ts b/packages/runtime-playground/src/browser-visual-compare.ts index ec4886f4..4ea5ed26 100644 --- a/packages/runtime-playground/src/browser-visual-compare.ts +++ b/packages/runtime-playground/src/browser-visual-compare.ts @@ -1848,12 +1848,16 @@ async function settleVisualComparePageForCapture(page: Page): Promise { }) } -async function waitForVisualComparePaintReady(page: Page, timeoutMs: number): Promise { +export async function waitForVisualComparePaintReady(page: Page, timeoutMs: number): Promise { const readinessTimeoutMs = Math.max(1_000, Math.min(10_000, timeoutMs)) await page.waitForLoadState("load", { timeout: readinessTimeoutMs }).catch(() => undefined) await page.evaluate(async (timeout) => { - const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)) const until = Date.now() + timeout + const normalizedSvgBlobUrlsKey = "__wpCodeboxVisualCompareSvgBlobUrls" + const blobUrlTarget = window as unknown as Window & { [key: string]: string[] | undefined } + const normalizedSvgBlobUrls = blobUrlTarget[normalizedSvgBlobUrlsKey] ?? [] + blobUrlTarget[normalizedSvgBlobUrlsKey] = normalizedSvgBlobUrls + const maxSvgBytes = 2 * 1024 * 1024 const stylesheetLinks = Array.from(document.querySelectorAll('link[rel~="stylesheet"]')) .filter((link) => !link.disabled && Boolean(link.href)) @@ -1864,17 +1868,68 @@ async function waitForVisualComparePaintReady(page: Page, timeoutMs: number): Pr link.addEventListener("load", () => resolve(), { once: true }) link.addEventListener("error", () => resolve(), { once: true }) }), - sleep(100), + new Promise((resolve) => setTimeout(resolve, 100)), ]) } })) await (document as Document & { fonts?: { ready?: Promise } }).fonts?.ready?.catch(() => undefined) - const images = Array.from(document.images).filter((image) => !image.complete) + const images = Array.from(document.images) await Promise.all(images.map(async (image) => { + // Keep responsive image selection and all non-SVG image paths unchanged. + if (!image.srcset && image.parentElement?.tagName !== "PICTURE") { + try { + const source = new URL(image.currentSrc || image.src, document.baseURI) + if (source.origin === location.origin && /\.svg$/i.test(source.pathname)) { + const controller = new AbortController() + const abortTimer = setTimeout(() => controller.abort(), Math.max(0, until - Date.now())) + try { + const response = await fetch(source.href, { signal: controller.signal }) + const contentLength = Number(response.headers.get("content-length")) + if (response.ok && (!Number.isFinite(contentLength) || contentLength <= maxSvgBytes) && /\bsvg\b/i.test(response.headers.get("content-type") ?? "")) { + const reader = response.body?.getReader() + if (reader) { + const chunks: ArrayBuffer[] = [] + let size = 0 + while (Date.now() < until) { + const next = await reader.read() + if (next.done) { + break + } + size += next.value.byteLength + if (size > maxSvgBytes) { + await reader.cancel() + break + } + chunks.push(Uint8Array.from(next.value).buffer) + } + if (size <= maxSvgBytes && Date.now() < until) { + const blobUrl = URL.createObjectURL(new Blob(chunks, { type: "image/svg+xml" })) + image.src = blobUrl + try { + await image.decode() + normalizedSvgBlobUrls.push(blobUrl) + } catch { + image.src = source.href + URL.revokeObjectURL(blobUrl) + } + } + } + } + } finally { + clearTimeout(abortTimer) + } + } + } catch { + // Keep the original image when a bounded same-origin normalization fails. + } + } if (typeof image.decode === "function") { - await image.decode().catch(() => undefined) + await Promise.race([ + image.decode().catch(() => undefined), + new Promise((resolve) => setTimeout(resolve, Math.max(0, until - Date.now()))), + ]) return } if (image.complete) { @@ -1885,7 +1940,7 @@ async function waitForVisualComparePaintReady(page: Page, timeoutMs: number): Pr image.addEventListener("load", () => resolve(), { once: true }) image.addEventListener("error", () => resolve(), { once: true }) }), - sleep(Math.max(0, until - Date.now())), + new Promise((resolve) => setTimeout(resolve, Math.max(0, until - Date.now()))), ]) })) @@ -1893,6 +1948,17 @@ async function waitForVisualComparePaintReady(page: Page, timeoutMs: number): Pr }, readinessTimeoutMs).catch(() => undefined) } +async function releaseVisualCompareSvgBlobUrls(page: Page): Promise { + await page.evaluate(() => { + const normalizedSvgBlobUrlsKey = "__wpCodeboxVisualCompareSvgBlobUrls" + const target = window as unknown as Window & { [key: string]: string[] | undefined } + for (const blobUrl of target[normalizedSvgBlobUrlsKey] ?? []) { + URL.revokeObjectURL(blobUrl) + } + delete target[normalizedSvgBlobUrlsKey] + }).catch(() => undefined) +} + export interface VisualCompareNavigationPolicy { attempts: number navigationBudgetMs: number @@ -2201,7 +2267,11 @@ async function captureVisualCompareUrl(page: Page, targetUrl: string, outputPath // `animations: "disabled"` fast-forwards finite CSS/Web animations and transitions // to their final state and freezes infinite ones to a deterministic frame, so the // capture does not depend on transition timing. Applied to both sides equally. - await captureVisualComparePageScreenshot(page, outputPath, { fullPage, timeoutMs, maxFullPageHeightPx: maxFullPageHeight }) + try { + await captureVisualComparePageScreenshot(page, outputPath, { fullPage, timeoutMs, maxFullPageHeightPx: maxFullPageHeight }) + } finally { + await releaseVisualCompareSvgBlobUrls(page) + } return { finalUrl: page.url(), domSnapshot, captureDiagnostics } } diff --git a/tests/browser-visual-compare-capture-reliability.test.ts b/tests/browser-visual-compare-capture-reliability.test.ts index 23807de0..a9fb9073 100644 --- a/tests/browser-visual-compare-capture-reliability.test.ts +++ b/tests/browser-visual-compare-capture-reliability.test.ts @@ -1,11 +1,16 @@ import assert from "node:assert/strict" +import { createHash } from "node:crypto" import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises" +import { createServer } from "node:http" import { tmpdir } from "node:os" import { join } from "node:path" import { PNG } from "pngjs" -import { comparePngFiles, visualCompareCaptureReadiness, visualCompareCompactCaptureDiagnostics, visualCompareErrorDetail, visualCompareNavigationPolicy, visualCompareOfflineRequestAllowed, visualCompareRegionElementOverlaps, visualCompareSelectorDeltas, type VisualCompareCaptureDiagnostics } from "../packages/runtime-playground/src/browser-visual-compare.js" +import { chromium } from "playwright" + +import { comparePngFiles, visualCompareCaptureReadiness, visualCompareCompactCaptureDiagnostics, visualCompareErrorDetail, visualCompareNavigationPolicy, visualCompareOfflineRequestAllowed, visualCompareRegionElementOverlaps, visualCompareSelectorDeltas, waitForVisualComparePaintReady, type VisualCompareCaptureDiagnostics } from "../packages/runtime-playground/src/browser-visual-compare.js" +import { captureBrowserDomSnapshot } from "../packages/runtime-playground/src/browser-dom-snapshot.js" // Build an opaque solid-color PNG. `fill` is [r,g,b]. function solidPng(width: number, height: number, fill: [number, number, number]): PNG { @@ -79,6 +84,121 @@ function paintRect(png: PNG, x0: number, y0: number, x1: number, y1: number, fil assert.equal(visualCompareOfflineRequestAllowed("https://fonts.example.invalid/font.woff2", "http://127.0.0.1:42573"), false) } +// 4. A complete same-origin external SVG is rehydrated through a Blob before decode. +// Embedded-font metrics are browser-specific, so this asserts the resource path itself. +{ + const server = createServer((request, response) => { + if (request.url === "/image.svg") { + response.writeHead(200, { "content-type": "image/svg+xml" }) + response.end('settled SVG') + return + } + response.writeHead(200, { "content-type": "text/html" }) + response.end('') + }) + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)) + const address = server.address() + assert.ok(address && typeof address !== "string") + const browser = await chromium.launch({ headless: true }) + try { + const page = await browser.newPage() + await page.goto(`http://127.0.0.1:${address.port}`) + await page.waitForFunction(() => document.images[0]?.complete === true) + await page.evaluate(` + const image = document.querySelector("#svg"); + const decode = image.decode.bind(image); + let calls = 0; + Object.defineProperty(image, "decode", { + configurable: true, + value() { + image.dataset.decodeCalls = String(++calls); + return decode(); + }, + }); + image.dataset.decodeCalls = String(calls); + `) + await waitForVisualComparePaintReady(page, 1_000) + assert.equal(await page.locator("#svg").evaluate((image) => image.dataset.decodeCalls), "2") + assert.match(await page.locator("#svg").evaluate((image) => (image as HTMLImageElement).src), /^blob:/) + } finally { + await browser.close() + await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())) + } +} + +// 5. SVG image payload evidence is bounded and metadata-only. Identical payloads have +// identical digests, style changes are isolated through styleText, and untrusted bodies +// never enter a DOM snapshot. +{ + const styleA = "@font-face { font-family: Embedded; src: url(data:font/woff2;base64,secret); }" + const styleB = "@font-face { font-family: Embedded; font-style: italic; src: url(data:font/woff2;base64,secret); }" + const svgA = `Evidence` + const svgB = `Evidence` + const server = createServer((request, response) => { + if (request.url === "/same.svg") { + response.writeHead(200, { "content-type": "image/svg+xml" }) + response.end(svgA) + return + } + if (request.url === "/style-change.svg") { + response.writeHead(200, { "content-type": "image/svg+xml" }) + response.end(svgB) + return + } + if (request.url === "/too-large.svg") { + response.writeHead(200, { "content-type": "image/svg+xml" }) + response.end(" ".repeat(2 * 1024 * 1024 + 1)) + return + } + if (request.url === "/not-svg.svg") { + response.writeHead(200, { "content-type": "text/plain" }) + response.end("not SVG") + return + } + response.writeHead(200, { "content-type": "text/html" }) + response.end(`\n`) + }) + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)) + const address = server.address() + assert.ok(address && typeof address !== "string") + const browser = await chromium.launch({ headless: true }) + try { + const page = await browser.newPage() + await page.goto(`http://127.0.0.1:${address.port}`) + // tsx injects this helper into nested async functions serialized by page.evaluate. + await page.evaluate("globalThis.__name = (value) => value") + const snapshot = await captureBrowserDomSnapshot(page, 100) + assert.equal(snapshot.svgImagePayloads?.length, 4, "two matching URLs, one style variant, and one blob payload are retained") + const [first, second, changed, blob] = snapshot.svgImagePayloads ?? [] + const sha256 = (value: string) => createHash("sha256").update(value).digest("hex") + assert.equal(first?.sha256, sha256(svgA)) + assert.equal(second?.sha256, first?.sha256, "matching payloads have matching hashes") + assert.equal(first?.byteCount, Buffer.byteLength(svgA)) + assert.equal(first?.textNodeCount, 2) + assert.deepEqual(first?.fontFamilyAttributes, ["Embedded"]) + assert.deepEqual(first?.fontSizeAttributes, ["16"]) + assert.deepEqual(first?.fontStyleAttributes, ["normal"]) + assert.deepEqual(first?.fontWeightAttributes, ["700"]) + assert.deepEqual(first?.letterSpacingAttributes, [".1em"]) + assert.equal(first?.fontFaceRuleCount, 1) + assert.equal(first?.styleText.sha256, sha256(styleA)) + assert.equal(first?.styleText.byteCount, Buffer.byteLength(styleA)) + assert.notEqual(changed?.styleText.sha256, first?.styleText.sha256, "style changes have different style hashes") + assert.equal(blob?.sha256, first?.sha256, "blob SVG payloads use the same metadata-only path") + const serialized = JSON.stringify(snapshot) + assert.equal(serialized.includes(svgA), false, "raw SVG is not retained") + assert.equal(serialized.includes(styleA), false, "raw SVG CSS is not retained") + assert.equal(serialized.includes("data:font/woff2;base64,secret"), false, "embedded font data is not retained") + assert.equal(serialized.includes("blob:"), false, "full blob URLs are not retained") + await page.evaluate("Object.defineProperty(globalThis.crypto, 'subtle', { configurable: true, value: undefined })") + const withoutWebCrypto = await captureBrowserDomSnapshot(page, 100) + assert.equal(withoutWebCrypto.svgImagePayloads, undefined, "WebCrypto absence omits optional payload evidence without failing the snapshot") + } finally { + await browser.close() + await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())) + } +} + // Count pixels in a diff PNG whose RGB is nonzero — the exact predicate the region // detector uses (`visualCompareDiffPixel`). pixelmatch renders even unchanged pixels as // a dimmed grayscale of the original, so for a real (or solid) page this is typically the @@ -95,7 +215,7 @@ function countDiffPixels(png: PNG): number { return count } -// 4. Mismatch-region attribution ranks DOM elements by how much of the hotspot they +// 6. Mismatch-region attribution ranks DOM elements by how much of the hotspot they // cover and carries the element path/styles needed for actionable visual repairs. { const overlaps = visualCompareRegionElementOverlaps({ x: 10, y: 20, width: 100, height: 50, pixels: 5000 }, [ @@ -127,8 +247,7 @@ function countDiffPixels(png: PNG): number { assert.equal(overlaps[1]?.styles["background-color"], "rgb(200, 0, 0)") } -// 5. The bounded flood-fill region detection runs the real comparePngFiles aggregation -// 5. Requested single-match selectors produce paired deltas even when source and +// 7. Requested single-match selectors produce paired deltas even when source and // candidate DOM paths differ after a structural move. { const selectorDeltas = visualCompareSelectorDeltas( @@ -151,7 +270,7 @@ function countDiffPixels(png: PNG): number { assert.equal(selectorDeltas[0]?.styles.find((style) => style.property === "color")?.category, "paint") } -// 6. Capture diagnostics are normalized into compact readiness/noise signals without +// 8. Capture diagnostics are normalized into compact readiness/noise signals without // changing diff policy. Asset problems and dynamic content lower confidence; clean // settled pages stay high-confidence. { @@ -208,7 +327,7 @@ function countDiffPixels(png: PNG): number { assert.equal("title" in (compact.source?.environment ?? {}), false) } -// 7. The bounded flood-fill region detection runs the real comparePngFiles aggregation +// 9. The bounded flood-fill region detection runs the real comparePngFiles aggregation // over a large, tall, high-mismatch canvas — the exact shape that previously OOM'd // old-space by pushing ~4× the diff-pixel count as [x,y] tuple arrays and marking // visited only at pop time. The rewrite (flat numeric index stack, mark-at-push) must