fix(producer): resolve residual font and probe failures (#2738)

This commit is contained in:
James Russo
2026-07-22 21:17:52 -04:00
committed by GitHub
parent c39f3cf924
commit 948264d6b2
4 changed files with 134 additions and 17 deletions
@@ -55,6 +55,26 @@ function makeHttp503Fetch(): typeof fetch {
})) as unknown as typeof fetch;
}
function makeGoogleFontFetch(cssRequests: string[]): typeof fetch {
return (async (input: string | URL | Request) => {
const requestUrl = input instanceof Request ? input.url : String(input);
if (requestUrl.startsWith("https://fonts.googleapis.com/")) {
cssRequests.push(requestUrl);
const family = new URL(requestUrl).searchParams.get("family")?.split(":", 1)[0] ?? "test";
const fontUrl = `https://fonts.gstatic.com/s/test/v1/${family.toLowerCase().replace(/\s+/g, "-")}.woff2`;
return new Response(
`@font-face {
font-style: normal;
font-weight: 400;
src: url(${fontUrl}) format('woff2');
}`,
{ status: 200 },
);
}
return new Response(new Uint8Array([0, 1, 2, 3]), { status: 200 });
}) as unknown as typeof fetch;
}
describe("injectDeterministicFontFaces — failClosedFontFetch: false (default)", () => {
it("swallows a network failure and returns the original HTML (no throw)", async () => {
const result = await injectDeterministicFontFaces(HTML_REQUESTING_UNRESOLVED_FONT, {
@@ -95,6 +115,32 @@ describe("injectDeterministicFontFaces — failClosedFontFetch: false (default)"
});
describe("injectDeterministicFontFaces — failClosedFontFetch: true", () => {
for (const [authoredFamily, googleFamily] of [
["DM+Mono", "DM Mono"],
["IBM+Plex+Mono", "IBM Plex Mono"],
["Spline+Sans+Mono", "Spline Sans Mono"],
] as const) {
it(`resolves URL-style family ${authoredFamily} through Google Fonts`, async () => {
const cssRequests: string[] = [];
const html = `<!doctype html><html><head><style>
body { font-family: "${authoredFamily}", monospace; }
</style></head><body><p>hello</p></body></html>`;
const result = await injectDeterministicFontFaces(html, {
failClosedFontFetch: true,
allowSystemFontCapture: false,
fetchImpl: makeGoogleFontFetch(cssRequests),
});
expect(cssRequests).toHaveLength(1);
const familyParam = new URL(cssRequests[0]!).searchParams.get("family");
expect(familyParam?.startsWith(`${googleFamily}:`)).toBe(true);
expect(familyParam?.startsWith(`${authoredFamily}:`)).toBe(false);
expect(result).toContain(`font-family: "${authoredFamily}"`);
expect(result).toContain("data-hyperframes-deterministic-fonts");
});
}
it("throws FontFetchError on a network failure", async () => {
let caught: unknown;
try {
@@ -743,7 +743,13 @@ async function fetchGoogleFont(
fontText?: string,
): Promise<GoogleFontFace[]> {
const slug = fontSlug(familyName);
const encodedFamily = encodeURIComponent(familyName);
// Agents sometimes copy the `family=` value from a Google Fonts URL into
// CSS, where `+` remains a literal character instead of being decoded as a
// space. Resolve that URL-style spelling through the canonical Google family
// while preserving `familyName` for the emitted @font-face alias so the
// authored CSS still matches it.
const googleFamilyName = familyName.replace(/\+/g, " ");
const encodedFamily = encodeURIComponent(googleFamilyName);
const textParam = fontText ? `&text=${encodeURIComponent(fontText)}` : "";
const url = `https://fonts.googleapis.com/css2?family=${encodedFamily}:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,400;1,700${textParam}`;
@@ -12,14 +12,23 @@ import {
const capturedCfgs: unknown[] = [];
const capturedOptions: unknown[] = [];
const mockPage = {
evaluate: async () => ({
timelineKeys: [],
hfDuration: 5,
gsapLoaded: false,
totalDurationMs: 5000,
__hf: {},
}),
type MockSession = {
id: number;
isInitialized: boolean;
browserConsoleBuffer: string[];
page: {
sessionId: number;
evaluate: () => Promise<{
timelineKeys: never[];
hfDuration: number;
gsapLoaded: boolean;
totalDurationMs: number;
__hf: Record<string, never>;
}>;
};
launchCaptureMode: "beginframe" | "screenshot";
beginFrameTimeTicks: number;
beginFrameIntervalMs: number;
};
let initializeSessionCallCount = 0;
@@ -29,6 +38,10 @@ let createSessionCallCount = 0;
let createSessionFailUntilAttempt = 0;
let createSessionError: Error | null = null;
let closeCaptureSessionCallCount = 0;
let probeBeginFrameAlive = true;
const createdSessions: MockSession[] = [];
const closedSessions: MockSession[] = [];
const durationProbeSessions: MockSession[] = [];
function resetRetryMocks() {
initializeSessionCallCount = 0;
@@ -38,6 +51,10 @@ function resetRetryMocks() {
createSessionFailUntilAttempt = 0;
createSessionError = null;
closeCaptureSessionCallCount = 0;
probeBeginFrameAlive = true;
createdSessions.length = 0;
closedSessions.length = 0;
durationProbeSessions.length = 0;
}
mock.module("@hyperframes/engine", () => ({
@@ -54,11 +71,29 @@ mock.module("@hyperframes/engine", () => ({
if (createSessionError && createSessionCallCount <= createSessionFailUntilAttempt) {
throw createSessionError;
}
return {
const sessionId = createSessionCallCount;
const session: MockSession = {
id: sessionId,
isInitialized: false,
browserConsoleBuffer: [],
page: mockPage,
page: {
sessionId,
evaluate: async () => ({
timelineKeys: [],
hfDuration: 5,
gsapLoaded: false,
totalDurationMs: 5000,
__hf: {},
}),
},
launchCaptureMode: (cfg as { forceScreenshot?: boolean }).forceScreenshot
? "screenshot"
: "beginframe",
beginFrameTimeTicks: 100,
beginFrameIntervalMs: 1,
};
createdSessions.push(session);
return session;
},
initializeSession: async (session: { isInitialized: boolean }) => {
initializeSessionCallCount++;
@@ -67,10 +102,15 @@ mock.module("@hyperframes/engine", () => ({
}
session.isInitialized = true;
},
getCompositionDuration: async () => 5,
closeCaptureSession: async () => {
closeCaptureSessionCallCount++;
getCompositionDuration: async (session: MockSession) => {
durationProbeSessions.push(session);
return 5;
},
closeCaptureSession: async (session: MockSession) => {
closeCaptureSessionCallCount++;
closedSessions.push(session);
},
probeBeginFrameLiveness: async () => probeBeginFrameAlive,
// Mirror of the real engine classifier. Canonical tests + pattern list
// live in frameCapture-transientErrors.test.ts — update both if patterns change.
isTransientBrowserError: (error: unknown) => {
@@ -440,6 +480,24 @@ describe("runProbeStage — decimal duration frame count", () => {
});
describe("runProbeStage — transient browser error retry (#1687)", () => {
it("uses the replacement session after a BeginFrame liveness fallback", async () => {
resetRetryMocks();
capturedCfgs.length = 0;
probeBeginFrameAlive = false;
const { runProbeStage } = await import("./probeStage.js");
const input = makeProbeInput({ cfgForceScreenshot: false, stageForceScreenshot: false });
const result = await runProbeStage(input);
expect(createSessionCallCount).toBe(2);
expect(closedSessions).toEqual([createdSessions[0]]);
expect(capturedCfgs[1]).toMatchObject({ forceScreenshot: true });
expect(durationProbeSessions).toEqual([createdSessions[1]]);
expect(result.probeSession).toBe(createdSessions[1]);
expect(result.beginFrameStalled).toBe(true);
});
it("retries once on a transient 'Navigating frame was detached' error and succeeds", async () => {
resetRetryMocks();
capturedCfgs.length = 0;
@@ -321,9 +321,10 @@ export async function runProbeStage(input: ProbeStageInput): Promise<ProbeStageR
assertNotAborted();
// After the retry loop, probeSession is guaranteed non-null (the loop
// either breaks with a valid session or throws on the last attempt).
const session = probeSession!;
probeSession = session;
lastBrowserConsole = session.browserConsoleBuffer;
if (!probeSession) {
throw new Error("Browser probe completed without a capture session");
}
lastBrowserConsole = probeSession.browserConsoleBuffer;
// BeginFrame liveness probe. On SwiftShader, heavy-layer compositions
// (multi-group nested opacity caption animations — style-N prod comps)
@@ -384,6 +385,12 @@ export async function runProbeStage(input: ProbeStageInput): Promise<ProbeStageR
}
}
// Bind the session only after the BeginFrame fallback, which may close the
// original browser and replace it with a screenshot-mode session. Every
// downstream probe must use the live replacement rather than the closed
// session captured before the fallback.
const session = probeSession;
// Discover root composition duration
if (composition.duration <= 0) {
log.info("Discovering composition duration...");