Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 115 additions & 2 deletions packages/runtime-playground/src/browser-dom-snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -44,21 +59,23 @@ export interface BrowserDomSnapshotArtifact {
}

export async function captureBrowserDomSnapshot(page: Page, maxElements: number, selectors: string[] = []): Promise<BrowserDomSnapshot> {
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
.map((element) => elementSnapshot(element, styleProperties, attributeNames))
.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,
title: document.title || "",
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,
}

Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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<BrowserDomSvgImagePayloadSnapshot | undefined> {
// 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*)?<svg\b/i.test(sourceText)) {
return undefined
}
const svg = new DOMParser().parseFromString(sourceText, "image/svg+xml")
if (svg.querySelector("parsererror")) {
return undefined
}
const styleText = Array.from(svg.querySelectorAll("style")).map((style) => 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<string> {
const result = await crypto.subtle.digest("SHA-256", value as Uint8Array<ArrayBuffer>)
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
}
Expand Down
84 changes: 77 additions & 7 deletions packages/runtime-playground/src/browser-visual-compare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1848,12 +1848,16 @@ async function settleVisualComparePageForCapture(page: Page): Promise<void> {
})
}

async function waitForVisualComparePaintReady(page: Page, timeoutMs: number): Promise<void> {
export async function waitForVisualComparePaintReady(page: Page, timeoutMs: number): Promise<void> {
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<void> => 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<HTMLLinkElement>('link[rel~="stylesheet"]'))
.filter((link) => !link.disabled && Boolean(link.href))
Expand All @@ -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<void>((resolve) => setTimeout(resolve, 100)),
])
}
}))

await (document as Document & { fonts?: { ready?: Promise<unknown> } }).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<void>((resolve) => setTimeout(resolve, Math.max(0, until - Date.now()))),
])
return
}
if (image.complete) {
Expand All @@ -1885,14 +1940,25 @@ 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<void>((resolve) => setTimeout(resolve, Math.max(0, until - Date.now()))),
])
}))

await new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))
}, readinessTimeoutMs).catch(() => undefined)
}

async function releaseVisualCompareSvgBlobUrls(page: Page): Promise<void> {
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
Expand Down Expand Up @@ -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 }
}

Expand Down
Loading