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