mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +00:00
fix(producer): resolve residual font and probe failures (#2738)
This commit is contained in:
@@ -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...");
|
||||
|
||||
Reference in New Issue
Block a user