diff --git a/packages/engine/src/services/frameCapture-cssBackgroundReady.test.ts b/packages/engine/src/services/frameCapture-cssBackgroundReady.test.ts index 34e60a4595..d12a89d430 100644 --- a/packages/engine/src/services/frameCapture-cssBackgroundReady.test.ts +++ b/packages/engine/src/services/frameCapture-cssBackgroundReady.test.ts @@ -1,8 +1,8 @@ import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { Page } from "puppeteer-core"; -import { decodeDynamicCssBackgroundImages } from "./frameCapture.js"; +import { BACKGROUND_DECODE_TIMEOUT_MS, decodeDynamicCssBackgroundImages } from "./frameCapture.js"; function makeMockPage( getBackgroundImage: () => string, @@ -10,7 +10,7 @@ function makeMockPage( decodeImage?: (src: string) => Promise, ): Page { return { - evaluate: async (fn: () => unknown) => { + evaluate: async (fn: (arg?: unknown) => unknown, arg?: unknown) => { const previousDocument = globalThis.document; const previousImage = globalThis.Image; const previousWindow = globalThis.window; @@ -41,7 +41,7 @@ function makeMockPage( }); try { - return await fn(); + return await fn(arg); } finally { Object.assign(globalThis, { document: previousDocument, @@ -56,9 +56,12 @@ function makeMockPage( afterEach(() => { const root = globalThis as { __hf_css_background_decoded?: Set; + __hf_css_background_timed_out?: Set; __hfDecodeDynamicCssBackgroundImages?: () => Promise; }; delete root.__hf_css_background_decoded; + delete root.__hf_css_background_timed_out; + vi.useRealTimers(); delete root.__hfDecodeDynamicCssBackgroundImages; }); @@ -119,6 +122,23 @@ describe("decodeDynamicCssBackgroundImages", () => { expect(decoded).toEqual(["/assets/late.jpg", "/assets/late.jpg"]); }); + it("stops waiting for a url whose decode never settles and names it", async () => { + vi.useFakeTimers(); + const decoded: string[] = []; + const page = makeMockPage( + () => 'url("/assets/stuck.jpg")', + decoded, + () => new Promise(() => {}), + ); + + const first = decodeDynamicCssBackgroundImages(page); + await vi.advanceTimersByTimeAsync(BACKGROUND_DECODE_TIMEOUT_MS); + expect(await first).toEqual(["/assets/stuck.jpg"]); + + expect(await decodeDynamicCssBackgroundImages(page)).toEqual([]); + expect(decoded).toEqual(["/assets/stuck.jpg"]); + }); + it("installs a page-local decoder that can run after an in-page seek", async () => { let backgroundImage = 'url("/assets/row-0.jpg")'; const decoded: string[] = []; diff --git a/packages/engine/src/services/frameCapture.ts b/packages/engine/src/services/frameCapture.ts index 744f9a9733..e173a64d3a 100644 --- a/packages/engine/src/services/frameCapture.ts +++ b/packages/engine/src/services/frameCapture.ts @@ -452,15 +452,25 @@ export function getDrawElementVerificationDetails( return undefined; } -/** Wait for inline CSS background images introduced by the latest seek. */ -export async function decodeDynamicCssBackgroundImages(page: Page): Promise { - await page.evaluate(async () => { +/** Longest the capture waits for one inline background image to decode; it is then left out. */ +export const BACKGROUND_DECODE_TIMEOUT_MS = 15_000; + +/** + * Wait for inline CSS background images introduced by the latest seek. + * Resolves with the urls that did not settle in time: they are captured as they stand and not waited on again. + */ +export async function decodeDynamicCssBackgroundImages(page: Page): Promise { + return page.evaluate(async (timeoutMs: number) => { const root = globalThis as typeof globalThis & { __hf_css_background_decoded?: Set; - __hfDecodeDynamicCssBackgroundImages?: () => Promise; + __hf_css_background_timed_out?: Set; + __hf_css_background_timeout_ms?: number; + __hfDecodeDynamicCssBackgroundImages?: () => Promise; }; const decode = (root.__hfDecodeDynamicCssBackgroundImages ??= async () => { + const limitMs = root.__hf_css_background_timeout_ms ?? 0; const decoded = (root.__hf_css_background_decoded ??= new Set()); + const timedOut = (root.__hf_css_background_timed_out ??= new Set()); const urls: string[] = []; const parseBackgroundUrls = (value: string): string[] => { const found: string[] = []; @@ -514,26 +524,42 @@ export async function decodeDynamicCssBackgroundImages(page: Page): Promise { const image = new Image(); image.src = url; + let timer: ReturnType | undefined; + const expired = new Promise<"late">((resolve) => { + timer = setTimeout(() => resolve("late"), limitMs); + }); try { - await image.decode(); - decoded.add(url); + const outcome = await Promise.race([ + image.decode().then(() => "ready" as const), + expired, + ]); + if (outcome === "ready") decoded.add(url); + else { + timedOut.add(url); + late.push(url); + } } catch { // Keep existing capture behavior for missing assets; request diagnostics report them. + } finally { + clearTimeout(timer); } }), ); + return late; }); - await decode(); - }); + root.__hf_css_background_timeout_ms = timeoutMs; + return decode(); + }, BACKGROUND_DECODE_TIMEOUT_MS); } // Circular buffer for browser console messages dumped on render failure diagnostics. @@ -3291,7 +3317,12 @@ async function prepareFrameForCapture( const hasPendingComposite = await seekPageTimeline(page, quantizedTime, seekOptions); session.captureStep = "decoding CSS background images"; - await decodeDynamicCssBackgroundImages(page); + for (const url of await decodeDynamicCssBackgroundImages(page)) { + appendBrowserDiagnostic( + session, + `[FrameCapture] background image still decoding after ${BACKGROUND_DECODE_TIMEOUT_MS}ms, captured without it: ${url}`, + ); + } const seekMs = Date.now() - seekStart;