From 948264d6b205cadd191e04f36af332ed4a6bb233 Mon Sep 17 00:00:00 2001 From: James Russo Date: Wed, 22 Jul 2026 21:17:52 -0400 Subject: [PATCH] fix(producer): resolve residual font and probe failures (#2738) --- .../deterministicFonts-failClosed.test.ts | 46 ++++++++++ .../src/services/deterministicFonts.ts | 8 +- .../services/render/stages/probeStage.test.ts | 84 ++++++++++++++++--- .../src/services/render/stages/probeStage.ts | 13 ++- 4 files changed, 134 insertions(+), 17 deletions(-) diff --git a/packages/producer/src/services/deterministicFonts-failClosed.test.ts b/packages/producer/src/services/deterministicFonts-failClosed.test.ts index d3860ed6f..f2c7cb4c9 100644 --- a/packages/producer/src/services/deterministicFonts-failClosed.test.ts +++ b/packages/producer/src/services/deterministicFonts-failClosed.test.ts @@ -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 = `

hello

`; + + 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 { diff --git a/packages/producer/src/services/deterministicFonts.ts b/packages/producer/src/services/deterministicFonts.ts index 24dee3db0..df4664d4a 100644 --- a/packages/producer/src/services/deterministicFonts.ts +++ b/packages/producer/src/services/deterministicFonts.ts @@ -743,7 +743,13 @@ async function fetchGoogleFont( fontText?: string, ): Promise { 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}`; diff --git a/packages/producer/src/services/render/stages/probeStage.test.ts b/packages/producer/src/services/render/stages/probeStage.test.ts index a1b78c190..c39dc2e29 100644 --- a/packages/producer/src/services/render/stages/probeStage.test.ts +++ b/packages/producer/src/services/render/stages/probeStage.test.ts @@ -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; + }>; + }; + 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; diff --git a/packages/producer/src/services/render/stages/probeStage.ts b/packages/producer/src/services/render/stages/probeStage.ts index 3e9f3b158..58142a30f 100644 --- a/packages/producer/src/services/render/stages/probeStage.ts +++ b/packages/producer/src/services/render/stages/probeStage.ts @@ -321,9 +321,10 @@ export async function runProbeStage(input: ProbeStageInput): Promise