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
20 changes: 16 additions & 4 deletions packages/engine/src/services/drawElementService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1002,7 +1002,13 @@ export async function produceDrawElementFrameBatch(
width: number,
height: number,
quality = 80,
): Promise<{ encodeResults: Array<Promise<Buffer>>; failedAt: number | null; error?: string }> {
): Promise<{
encodeResults: Array<Promise<Buffer>>;
failedAt: number | null;
error?: string;
/** Background urls whose decode timed out during this batch. */
lateBackgrounds: string[];
}> {
const state = workerEncodeStates.get(page);
if (!state) {
throw new Error(
Expand Down Expand Up @@ -1070,7 +1076,8 @@ export async function produceDrawElementFrameBatch(
__hf3d?: { update: () => void };
__hf?: { seek?: (t: number) => void };
__hfWaitForSeekCompletion?: () => Promise<void>;
__hfDecodeDynamicCssBackgroundImages?: () => Promise<void>;
__hfDecodeDynamicCssBackgroundImages?: () => Promise<string[] | void>;
__hf_css_background_late?: string[];
__hfDeInvalidate?: () => boolean;
__HF_ROOT_PROPS__?: boolean;
__HF_ROOT_BASE_OPACITY__?: number;
Expand Down Expand Up @@ -1106,7 +1113,8 @@ export async function produceDrawElementFrameBatch(
try {
if (aw.__hf && typeof aw.__hf.seek === "function") aw.__hf.seek(t);
await aw.__hfWaitForSeekCompletion?.();
await aw.__hfDecodeDynamicCssBackgroundImages?.();
const late = await aw.__hfDecodeDynamicCssBackgroundImages?.();
if (late?.length) (aw.__hf_css_background_late ??= []).push(...late);
aw.__hf3d?.update();
const accel = (aw.__hf_accel_canvases ?? []).filter((c) => root.contains(c));
for (const c of accel) {
Expand Down Expand Up @@ -1233,5 +1241,9 @@ export async function produceDrawElementFrameBatch(
}
}

return { encodeResults, failedAt: outcome.failedAt, error: outcome.error };
const lateBackgrounds = await page.evaluate(() => {
const w = window as Window & { __hf_css_background_late?: string[] };
return w.__hf_css_background_late?.splice(0) ?? [];
});
return { encodeResults, failedAt: outcome.failedAt, error: outcome.error, lateBackgrounds };
}
Original file line number Diff line number Diff line change
@@ -1,16 +1,21 @@
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,
recordBackgroundDecodeTimeouts,
type CaptureSession,
} from "./frameCapture.js";

function makeMockPage(
getBackgroundImage: () => string,
decoded: string[],
decodeImage?: (src: string) => Promise<void>,
): 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;
Expand Down Expand Up @@ -41,7 +46,7 @@ function makeMockPage(
});

try {
return await fn();
return await fn(arg);
} finally {
Object.assign(globalThis, {
document: previousDocument,
Expand All @@ -56,9 +61,12 @@ function makeMockPage(
afterEach(() => {
const root = globalThis as {
__hf_css_background_decoded?: Set<string>;
__hf_css_background_timed_out?: Set<string>;
__hfDecodeDynamicCssBackgroundImages?: () => Promise<void>;
};
delete root.__hf_css_background_decoded;
delete root.__hf_css_background_timed_out;
vi.useRealTimers();
delete root.__hfDecodeDynamicCssBackgroundImages;
});

Expand Down Expand Up @@ -119,6 +127,59 @@ 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<void>(() => {}),
);

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("reports a timed-out url without its credentials or query, in the text and the warning", () => {
vi.spyOn(console, "warn").mockImplementation(() => {});
const session = { warnings: [], browserConsoleBuffer: [] } as unknown as CaptureSession;
const secret = "https://user:pw@cdn.test/bg.jpg?token=SECRET#frag";

recordBackgroundDecodeTimeouts(session, [secret]);
recordBackgroundDecodeTimeouts(session, [secret]);

expect(session.browserConsoleBuffer.join("\n")).toContain("https://cdn.test/bg.jpg");
expect(JSON.stringify([session.browserConsoleBuffer, session.warnings])).not.toMatch(
/SECRET|user|pw@|frag/,
);
expect(session.warnings).toEqual([
expect.objectContaining({
code: "media_readiness_timeout",
details: {
mediaType: "image",
sources: ["https://cdn.test/bg.jpg"],
timeoutMs: BACKGROUND_DECODE_TIMEOUT_MS,
},
}),
]);
});

it("reports a timed-out relative url without its query or fragment", () => {
vi.spyOn(console, "warn").mockImplementation(() => {});
const session = { warnings: [], browserConsoleBuffer: [] } as unknown as CaptureSession;

recordBackgroundDecodeTimeouts(session, ["./assets/bg.webp?example=REDACT_ME#fragment"]);

expect(JSON.stringify([session.browserConsoleBuffer, session.warnings])).not.toMatch(
/REDACT_ME|fragment/,
);
expect(session.warnings[0]?.details).toMatchObject({ sources: ["/assets/bg.webp"] });
});

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[] = [];
Expand Down Expand Up @@ -148,7 +209,7 @@ describe("decodeDynamicCssBackgroundImages", () => {
);

expect(batchSource).toMatch(
/aw\.__hf\.seek\(t\);\s*await aw\.__hfWaitForSeekCompletion\?\.\(\);\s*await aw\.__hfDecodeDynamicCssBackgroundImages\?\.\(\);/,
/aw\.__hf\.seek\(t\);\s*await aw\.__hfWaitForSeekCompletion\?\.\(\);\s*const late = await aw\.__hfDecodeDynamicCssBackgroundImages\?\.\(\);\s*if \(late\?\.length\) \(aw\.__hf_css_background_late \?\?= \[\]\)\.push\(\.\.\.late\);/,
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ describe("fresh screenshot fallback", () => {
vi.mocked(produceDrawElementFrameBatch).mockImplementationOnce(async () => ({
encodeResults: [Promise.resolve(Buffer.from("ok")), Promise.reject(timedOut(8))],
failedAt: null,
lateBackgrounds: [],
}));
const results = await captureFramesBatchPipelined(await session(), [7, 8], [7 / 30, 8 / 30]);
expect((await results[0]!.encodeResult).toString()).toBe("ok");
Expand Down Expand Up @@ -255,6 +256,7 @@ describe("fresh screenshot fallback", () => {
vi.mocked(produceDrawElementFrameBatch).mockResolvedValueOnce({
encodeResults: [prefix],
failedAt: 1,
lateBackgrounds: [],
error,
});
await expect(
Expand Down
8 changes: 8 additions & 0 deletions packages/engine/src/services/frameCapture.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,14 @@ describe("navigation diagnostics", () => {
);
});

it("keeps only the path of bare and dot-relative URLs", () => {
expect(sanitizeDiagnosticUrl("./assets/bg.webp?example=REDACT_ME#fragment")).toBe(
"/assets/bg.webp",
);
expect(sanitizeDiagnosticUrl("assets/bg.webp?example=REDACT_ME")).toBe("/assets/bg.webp");
expect(sanitizeDiagnosticUrl("//user:pw@cdn.test/bg.jpg?t=1")).toBe("/bg.jpg");
});

it("formats page.goto failures with mode, timeout, elapsed time, and sanitized URL", () => {
const diagnostic = formatNavigationFailureDiagnostic({
captureMode: "screenshot",
Expand Down
90 changes: 69 additions & 21 deletions packages/engine/src/services/frameCapture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -457,15 +457,25 @@ export function getDrawElementVerificationDetails(
return undefined;
}

/** Wait for inline CSS background images introduced by the latest seek. */
export async function decodeDynamicCssBackgroundImages(page: Page): Promise<void> {
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<string[]> {
return page.evaluate(async (timeoutMs: number) => {
const root = globalThis as typeof globalThis & {
__hf_css_background_decoded?: Set<string>;
__hfDecodeDynamicCssBackgroundImages?: () => Promise<void>;
__hf_css_background_timed_out?: Set<string>;
__hf_css_background_timeout_ms?: number;
__hfDecodeDynamicCssBackgroundImages?: () => Promise<string[]>;
};
const decode = (root.__hfDecodeDynamicCssBackgroundImages ??= async () => {
const limitMs = root.__hf_css_background_timeout_ms ?? 0;
const decoded = (root.__hf_css_background_decoded ??= new Set<string>());
const timedOut = (root.__hf_css_background_timed_out ??= new Set<string>());
const urls: string[] = [];
const parseBackgroundUrls = (value: string): string[] => {
const found: string[] = [];
Expand Down Expand Up @@ -519,26 +529,42 @@ export async function decodeDynamicCssBackgroundImages(page: Page): Promise<void
if (!backgroundImage || backgroundImage === "none") continue;

for (const url of parseBackgroundUrls(backgroundImage)) {
if (!decoded.has(url)) urls.push(url);
if (!decoded.has(url) && !timedOut.has(url)) urls.push(url);
}
}

const late: string[] = [];
await Promise.all(
[...new Set(urls)].map(async (url) => {
const image = new Image();
image.src = url;
let timer: ReturnType<typeof setTimeout> | 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.
Expand Down Expand Up @@ -635,14 +661,6 @@ export function sanitizeDiagnosticUrl(input: string): string {
if (!input) return "(empty)";
if (input.startsWith("data:")) return "data:<redacted>";
if (input.startsWith("blob:")) return "blob:<redacted>";
if (input.startsWith("/")) {
try {
const url = new URL(input, "http://hyperframes.local");
return url.pathname;
} catch {
return input;
}
}

try {
const url = new URL(input);
Expand All @@ -652,7 +670,12 @@ export function sanitizeDiagnosticUrl(input: string): string {
url.hash = "";
return url.toString();
} catch {
return input;
// A relative reference keeps its path only, and raw input never comes back.
try {
return new URL(input, "http://hyperframes.local").pathname;
} catch {
return input.split(/[?#]/, 1)[0] ?? "";
}
}
}

Expand Down Expand Up @@ -1359,7 +1382,7 @@ async function finalizeDrawElementInit(
const { transparent, forceDE } = opts;
// Install the page-local decoder before batch drawElement capture begins;
// the batch loop re-runs it after every in-page seek.
await decodeDynamicCssBackgroundImages(page);
recordBackgroundDecodeTimeouts(session, await decodeDynamicCssBackgroundImages(page));
// Self-verification ground truth: must run pre-injection — after the canvas
// wraps the root, a page screenshot shows the canvas's last-drawn bitmap,
// not the live DOM (see the Lim 6 boundary-screenshot note).
Expand Down Expand Up @@ -2469,6 +2492,29 @@ function recordCaptureWarnings(session: CaptureSession, warnings: readonly Captu
}
}

/** Report background urls that timed out decoding; the shown url drops credentials and query. */
export function recordBackgroundDecodeTimeouts(
session: CaptureSession,
urls: readonly string[] | undefined,
): void {
// Mocked pages answer evaluate() with undefined, so tolerate a missing list.
if (!urls?.length) return;
const sources = [...new Set(urls.map(sanitizeDiagnosticUrl))].sort();
for (const source of sources) {
appendBrowserDiagnostic(
session,
`[FrameCapture] background image still decoding after ${BACKGROUND_DECODE_TIMEOUT_MS}ms, captured without it: ${source}`,
);
}
recordCaptureWarnings(session, [
{
code: "media_readiness_timeout",
message: `image media did not finish decoding within ${BACKGROUND_DECODE_TIMEOUT_MS}ms`,
details: { mediaType: "image", sources, timeoutMs: BACKGROUND_DECODE_TIMEOUT_MS },
},
]);
}

export function recordSubTimelineWarning(session: CaptureSession, timeoutMs: number): void {
const outcome = session.subTimelineWaitOutcome;
if (outcome === "ready" || !outcome) return;
Expand Down Expand Up @@ -3355,7 +3401,8 @@ async function prepareFrameForCapture(
const hasPendingComposite = await seekPageTimeline(page, quantizedTime, seekOptions);

session.captureStep = "decoding CSS background images";
await decodeDynamicCssBackgroundImages(page);
// Mocked pages answer evaluate() with undefined, so tolerate a missing list.
recordBackgroundDecodeTimeouts(session, await decodeDynamicCssBackgroundImages(page));

const seekMs = Date.now() - seekStart;

Expand Down Expand Up @@ -5211,13 +5258,14 @@ export async function captureFramesBatchPipelined(
const quantized = times.map((t) => quantizeTimeToFrame(t, fps));

session.captureStep = "capturing the frame";
const { encodeResults, failedAt, error } = await produceDrawElementFrameBatch(
const { encodeResults, failedAt, error, lateBackgrounds } = await produceDrawElementFrameBatch(
page,
quantized,
options.width,
options.height,
options.quality ?? 80,
);
recordBackgroundDecodeTimeouts(session, lateBackgrounds);

// A later batch failure abandons this prefix. Its asynchronous encodes can
// still reject while session cleanup shuts the worker down.
Expand Down
Loading