mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
Merge pull request #2264 from heygen-com/07-11-fix-cli-localize-remote-fonts-in-audit-capture
fix(cli): localize remote fonts in snapshot/check capture to match render
This commit is contained in:
@@ -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,68 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@hyperframes/core/compiler", () => ({
|
||||
bundleToSingleHtml: vi.fn(async () => "<html><body>bundled</body></html>"),
|
||||
}));
|
||||
|
||||
import {
|
||||
__resetFontLocalizationWarningsForTests,
|
||||
bundleWithLocalizedFonts,
|
||||
localizeWithProducer,
|
||||
} from "./bundleWithLocalizedFonts.js";
|
||||
|
||||
afterEach(() => {
|
||||
__resetFontLocalizationWarningsForTests();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("bundleWithLocalizedFonts (call-site integration)", () => {
|
||||
it("runs the injected font localizer over the plain bundle", async () => {
|
||||
const localize = vi.fn(async (html: string) => html.replace("bundled", "bundled+fonts"));
|
||||
const html = await bundleWithLocalizedFonts("/project", localize);
|
||||
expect(localize).toHaveBeenCalledOnce();
|
||||
expect(localize).toHaveBeenCalledWith("<html><body>bundled</body></html>");
|
||||
expect(html).toBe("<html><body>bundled+fonts</body></html>");
|
||||
});
|
||||
|
||||
it("returns the localizer output verbatim (localization is the last step)", async () => {
|
||||
const html = await bundleWithLocalizedFonts("/project", async () => "<html>embedded</html>");
|
||||
expect(html).toBe("<html>embedded</html>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("localizeWithProducer", () => {
|
||||
it("embeds fonts when the injector is available", async () => {
|
||||
const inject = vi.fn(async (html: string) => `${html}<!--fonts-->`);
|
||||
const warn = vi.fn();
|
||||
const out = await localizeWithProducer("<html></html>", async () => inject, warn);
|
||||
expect(out).toBe("<html></html><!--fonts-->");
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails open silently when producer is unavailable (module absent → null)", async () => {
|
||||
const warn = vi.fn();
|
||||
const out = await localizeWithProducer("<html>plain</html>", async () => null, warn);
|
||||
// Never worse than a plain bundle; benign absence is not a warning.
|
||||
expect(out).toBe("<html>plain</html>");
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails open WITH a diagnostic when the injector itself throws", async () => {
|
||||
const warn = vi.fn();
|
||||
const boom: () => Promise<never> = () => Promise.reject(new Error("fetch layer down"));
|
||||
const out = await localizeWithProducer("<html>plain</html>", async () => boom, warn);
|
||||
expect(out).toBe("<html>plain</html>");
|
||||
expect(warn).toHaveBeenCalledOnce();
|
||||
expect(warn.mock.calls[0]?.[0]).toContain("fetch layer down");
|
||||
});
|
||||
|
||||
it("dedups repeated identical injector failures across re-bundles", async () => {
|
||||
const warn = vi.fn();
|
||||
const boom: () => Promise<never> = () => Promise.reject(new Error("same failure"));
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await localizeWithProducer("<html></html>", async () => boom, warn);
|
||||
}
|
||||
// snapshot/check re-bundle per grid point; the warning must fire once.
|
||||
expect(warn).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { normalizeErrorMessage } from "./errorMessage.js";
|
||||
import { c } from "../ui/colors.js";
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export async function bundleWithLocalizedFonts(
|
||||
projectDir: string,
|
||||
// Injectable for tests. Production callers omit it and get the producer
|
||||
// font-localization pass (see localizeWithProducer).
|
||||
localizeFonts: (html: string) => Promise<string> = localizeWithProducer,
|
||||
): Promise<string> {
|
||||
const { bundleToSingleHtml } = await import("@hyperframes/core/compiler");
|
||||
const html = await bundleToSingleHtml(projectDir);
|
||||
return localizeFonts(html);
|
||||
}
|
||||
|
||||
type FontInjector = (html: string) => Promise<string>;
|
||||
|
||||
/**
|
||||
* Load the render pipeline's `injectDeterministicFontFaces`, resolving
|
||||
* `@hyperframes/producer` at RUNTIME only. The specifier is kept out of the
|
||||
* bundler's/test-runner's static module graph (`@vite-ignore` + a variable
|
||||
* specifier) on purpose: the CLI test job builds with `--filter
|
||||
* '!@hyperframes/producer'`, so a static `import("@hyperframes/producer")`
|
||||
* would fail Vitest's transform-time resolution. At runtime — the built CLI or
|
||||
* an installed package — producer is a real dependency and resolves via
|
||||
* node_modules.
|
||||
*
|
||||
* Returns `null` (not a throw) when the module simply isn't available in this
|
||||
* environment, so the caller can treat "producer absent" — a benign, expected
|
||||
* condition — differently from "the injector itself failed".
|
||||
*/
|
||||
async function loadFontInjector(): Promise<FontInjector | null> {
|
||||
try {
|
||||
const producerSpecifier = "@hyperframes/producer";
|
||||
const mod = (await import(/* @vite-ignore */ producerSpecifier)) as {
|
||||
injectDeterministicFontFaces?: FontInjector;
|
||||
};
|
||||
return mod.injectDeterministicFontFaces ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const warnedFontLocalizationFailures = new Set<string>();
|
||||
|
||||
/** Reset the dedup latch — tests only. */
|
||||
export function __resetFontLocalizationWarningsForTests(): void {
|
||||
warnedFontLocalizationFailures.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Localize fonts via the producer injector, distinguishing two failure modes:
|
||||
*
|
||||
* - **Module unavailable** (`loadInjector` yields `null`): benign — producer
|
||||
* isn't in this environment. Return the HTML unchanged, silently; fonts
|
||||
* declared via a remote `<link>` still load at capture time as before.
|
||||
* - **Injector threw** (a fetch layer failed, a family errored past the
|
||||
* injector's own per-family handling): unexpected — surface it ONCE per
|
||||
* distinct message (snapshot/check re-bundle per grid point, so an
|
||||
* un-deduped warning would spam), then fail open to the plain bundle.
|
||||
*
|
||||
* Per-family resolution failures inside a successful pass are already reported
|
||||
* by the injector itself (producer's `warnUnresolvedFonts`); this layer only
|
||||
* owns the module-vs-execution distinction and the dedup.
|
||||
*/
|
||||
export async function localizeWithProducer(
|
||||
html: string,
|
||||
loadInjector: () => Promise<FontInjector | null> = loadFontInjector,
|
||||
warn: (message: string) => void = (m) => console.warn(` ${c.warn("⚠")} ${m}`),
|
||||
): Promise<string> {
|
||||
const inject = await loadInjector();
|
||||
if (!inject) return html;
|
||||
try {
|
||||
return await inject(html);
|
||||
} catch (err) {
|
||||
const message = `Font localization failed; capturing with remote/fallback fonts instead: ${normalizeErrorMessage(err)}`;
|
||||
if (!warnedFontLocalizationFailures.has(message)) {
|
||||
warnedFontLocalizationFailures.add(message);
|
||||
warn(message);
|
||||
}
|
||||
return html;
|
||||
}
|
||||
}
|
||||
@@ -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[] = [];
|
||||
|
||||
Reference in New Issue
Block a user