fix(cli): localize remote fonts in snapshot/check capture to match render

This commit is contained in:
Vance Ingalls
2026-07-11 16:14:36 -07:00
parent 35c231e35f
commit ad0d2393fe
5 changed files with 80 additions and 6 deletions
+4 -2
View File
@@ -193,11 +193,13 @@ async function captureSnapshots(
zoomScale?: number;
},
): Promise<string[]> {
const { bundleToSingleHtml } = await import("@hyperframes/core/compiler");
const { bundleWithLocalizedFonts } = await import("../utils/bundleWithLocalizedFonts.js");
const numFrames = opts.frames ?? 5;
const html = await bundleToSingleHtml(projectDir);
// Localize fonts (embed remote @font-face as data URIs, matching the render
// path) so snapshots render the real font instead of a fallback sans.
const html = await bundleWithLocalizedFonts(projectDir);
const server = await serveStaticProjectHtml(projectDir, html);
const savedPaths: string[] = [];
@@ -0,0 +1,32 @@
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("@hyperframes/core/compiler", () => ({
bundleToSingleHtml: vi.fn(async () => "<html><body>bundled</body></html>"),
}));
const injectMock = vi.fn(async (html: string) => html.replace("bundled", "bundled+fonts"));
vi.mock("@hyperframes/producer", () => ({
injectDeterministicFontFaces: (html: string) => injectMock(html),
}));
import { bundleWithLocalizedFonts } from "./bundleWithLocalizedFonts.js";
afterEach(() => {
vi.clearAllMocks();
});
describe("bundleWithLocalizedFonts", () => {
it("localizes fonts on top of the plain bundle", async () => {
const html = await bundleWithLocalizedFonts("/project");
expect(injectMock).toHaveBeenCalledOnce();
expect(html).toBe("<html><body>bundled+fonts</body></html>");
});
it("falls open to the plain bundle when font localization throws", async () => {
injectMock.mockRejectedValueOnce(new Error("offline / fetch layer unavailable"));
const html = await bundleWithLocalizedFonts("/project");
// Never worse than a plain bundleToSingleHtml — the remote <link> still
// loads at capture time as before.
expect(html).toBe("<html><body>bundled</body></html>");
});
});
@@ -0,0 +1,31 @@
/**
* Bundle a project to a single HTML string AND localize its fonts — fetch and
* embed `@font-face` rules for every requested family (including families
* declared only via a remote `<link>`, e.g. Google Fonts) as data URIs.
*
* Why the audit/snapshot paths need this: core's `bundleToSingleHtml` inlines
* only LOCAL stylesheets and leaves remote font `<link>`s as-is, so a snapshot
* depends on loading the remote font at capture time. The render pipeline
* instead localizes fonts in its compile stage, which is why a render embeds
* (say) League Gothic correctly while a snapshot of the same composition can
* fall back to an un-styled system sans when the remote font loses the race
* against the capture. Running the SAME localization the render path uses makes
* snapshot/check captures font-faithful and deterministic — no network race.
*
* Fail-open: if a family can't be fetched (offline, unknown font), the
* underlying injector leaves the HTML unchanged, so this never makes a bundle
* worse than plain `bundleToSingleHtml`.
*/
export async function bundleWithLocalizedFonts(projectDir: string): Promise<string> {
const { bundleToSingleHtml } = await import("@hyperframes/core/compiler");
const html = await bundleToSingleHtml(projectDir);
try {
const { injectDeterministicFontFaces } = await import("@hyperframes/producer");
return await injectDeterministicFontFaces(html);
} catch {
// Producer/font localization unavailable (or a fetch layer threw) — fall
// back to the plain bundle. Fonts declared via remote <link> still load at
// capture time as before; we just lose the deterministic embed.
return html;
}
}
+4 -4
View File
@@ -88,8 +88,8 @@ export async function runBrowserCheck(
motion: MotionSpecResolution,
runGrid: RunAuditGrid,
): Promise<CheckBrowserResult> {
const { bundleToSingleHtml } = await import("@hyperframes/core/compiler");
const html = await bundleToSingleHtml(project.dir);
const { bundleWithLocalizedFonts } = await import("./bundleWithLocalizedFonts.js");
const html = await bundleWithLocalizedFonts(project.dir);
const server = await serveStaticProjectHtml(project.dir, html, "Failed to bind check server");
const drafts: RuntimeDraft[] = [];
let currentTime = 0;
@@ -145,8 +145,8 @@ export async function captureFindingCrops(
requests: CheckFindingCropRequest[],
): Promise<string[]> {
if (requests.length === 0) return [];
const { bundleToSingleHtml } = await import("@hyperframes/core/compiler");
const html = await bundleToSingleHtml(project.dir);
const { bundleWithLocalizedFonts } = await import("./bundleWithLocalizedFonts.js");
const html = await bundleWithLocalizedFonts(project.dir);
const server = await serveStaticProjectHtml(project.dir, html, "Failed to bind check server");
let chromeBrowser: import("puppeteer-core").Browser | undefined;
const written: string[] = [];
+9
View File
@@ -86,6 +86,15 @@ export {
// ── Utilities ───────────────────────────────────────────────────────────────
export { normalizeErrorMessage } from "./utils/errorMessage.js";
// Font localization: fetch + embed @font-face rules for requested families
// (including those declared only via a remote <link>) so a bundled composition
// renders with the real font instead of a fallback, regardless of network
// timing. The render pipeline runs this in its compile stage; the CLI audit
// paths (snapshot/check) reuse it so their captures match the render.
export {
injectDeterministicFontFaces,
type InjectDeterministicFontFacesOptions,
} from "./services/deterministicFonts.js";
export { quantizeTimeToFrame } from "./utils/parityContract.js";
export { resolveRenderPaths, type RenderPaths } from "./utils/paths.js";