mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
c8aef02dcd
The font self-hosting unit tests depended on live Google Fonts CSS and font file requests. When the CSS fetch hit a recoverable network error, the plugin correctly skipped _vinext injection for offline fallback, but the test expected self-hosted output. Mock both fonts.googleapis.com CSS and fonts.gstatic.com font binaries for the self-hosting assertions. The offline fallback behavior remains covered by the explicit network-error test.
1736 lines
73 KiB
TypeScript
1736 lines
73 KiB
TypeScript
import { describe, it, expect, afterEach } from "vite-plus/test";
|
|
import path from "node:path";
|
|
import fs from "node:fs";
|
|
import { http, HttpResponse } from "msw";
|
|
import { server } from "./_msw/server.js";
|
|
import vinext from "../packages/vinext/src/index.js";
|
|
import {
|
|
parseStaticObjectLiteral,
|
|
_findBalancedObject as findBalancedObject,
|
|
_findCallEnd as findCallEnd,
|
|
_rewriteCachedFontCssToServedUrls as rewriteCachedFontCssToServedUrls,
|
|
} from "../packages/vinext/src/plugins/fonts.js";
|
|
import type { Plugin } from "vite-plus";
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────
|
|
|
|
/** Unwrap a Vite plugin hook that may use the object-with-filter format */
|
|
function unwrapHook(hook: any): Function {
|
|
return typeof hook === "function" ? hook : hook?.handler;
|
|
}
|
|
|
|
/**
|
|
* Install an MSW handler that returns the given body for any
|
|
* `fonts.googleapis.com` request, and records every URL it sees.
|
|
*
|
|
* Replaces the prior pattern of swapping `globalThis.fetch` inside a
|
|
* try/finally — handlers are reset between tests by the global
|
|
* `afterEach` in `tests/_msw/setup.ts`.
|
|
*/
|
|
function mockGoogleFontsCSS(
|
|
body: string,
|
|
opts: { status?: number; headers?: HeadersInit } = {},
|
|
): { urls: string[]; count: () => number } {
|
|
const urls: string[] = [];
|
|
const headers = opts.headers ?? { "content-type": "text/css" };
|
|
server.use(
|
|
http.get("https://fonts.googleapis.com/*", ({ request }) => {
|
|
urls.push(request.url);
|
|
return new HttpResponse(body, { status: opts.status ?? 200, headers });
|
|
}),
|
|
);
|
|
return { urls, count: () => urls.length };
|
|
}
|
|
|
|
function mockGoogleFontFiles(body = new Uint8Array([0, 1, 2, 3])): { urls: string[] } {
|
|
const urls: string[] = [];
|
|
server.use(
|
|
http.get("https://fonts.gstatic.com/*", ({ request }) => {
|
|
urls.push(request.url);
|
|
return new HttpResponse(body, {
|
|
headers: {
|
|
"content-type": "font/woff2",
|
|
"cache-control": "public, max-age=31536000",
|
|
},
|
|
});
|
|
}),
|
|
);
|
|
return { urls };
|
|
}
|
|
|
|
/** Extract the vinext:google-fonts plugin from the plugin array */
|
|
function getGoogleFontsPlugin(): Plugin {
|
|
const plugins = vinext() as Plugin[];
|
|
const plugin = plugins.find((p) => p.name === "vinext:google-fonts");
|
|
if (!plugin) throw new Error("vinext:google-fonts plugin not found");
|
|
return plugin;
|
|
}
|
|
|
|
/** Simulate Vite's configResolved hook to initialize plugin state */
|
|
function initPlugin(plugin: Plugin, opts: { command?: "build" | "serve"; root?: string }) {
|
|
const fakeConfig = {
|
|
command: opts.command ?? "serve",
|
|
root: opts.root ?? import.meta.dirname,
|
|
};
|
|
(plugin.configResolved as Function)(fakeConfig);
|
|
}
|
|
|
|
// ── Font shim tests ───────────────────────────────────────────
|
|
|
|
describe("next/font/google shim", () => {
|
|
it("exports a Proxy that creates font loaders for any family", async () => {
|
|
const mod = await import("../packages/vinext/src/shims/font-google.js");
|
|
const Inter = (mod.default as any).Inter;
|
|
expect(typeof Inter).toBe("function");
|
|
});
|
|
|
|
it("createFontLoader returns className and style without variable unless requested", async () => {
|
|
const { createFontLoader } = await import("../packages/vinext/src/shims/font-google.js");
|
|
const Inter = createFontLoader("Inter");
|
|
const result = Inter({ weight: ["400", "700"], subsets: ["latin"] });
|
|
expect(result.className).toMatch(/^__font_inter_[a-z0-9]+$/);
|
|
expect(result.style.fontFamily).toContain("Inter");
|
|
expect(result.variable).toBeUndefined();
|
|
});
|
|
|
|
it("supports custom variable name", async () => {
|
|
const { createFontLoader } = await import("../packages/vinext/src/shims/font-google.js");
|
|
const Inter = createFontLoader("Inter");
|
|
const result = Inter({ weight: ["400"], variable: "--my-font" });
|
|
// variable returns a class name that sets the CSS variable, not the variable name itself
|
|
expect(result.variable).toMatch(/^__variable_inter_[a-z0-9]+$/);
|
|
});
|
|
|
|
it("matches Next.js style exports for single weight and style", async () => {
|
|
// Ported from Next.js: test/e2e/next-font/index.test.ts
|
|
// https://github.com/vercel/next.js/blob/canary/test/e2e/next-font/index.test.ts
|
|
const { createFontLoader, getSSRFontStyles } =
|
|
await import("../packages/vinext/src/shims/font-google.js");
|
|
const Roboto = createFontLoader("Roboto");
|
|
const beforeStyles = getSSRFontStyles();
|
|
const result = Roboto({ weight: "100", style: "italic", subsets: ["latin"] });
|
|
|
|
expect(result.style).toMatchObject({
|
|
fontWeight: 100,
|
|
fontStyle: "italic",
|
|
});
|
|
|
|
const addedStyles = getSSRFontStyles().slice(beforeStyles.length).join("\n");
|
|
expect(addedStyles).toContain(`.${result.className}`);
|
|
expect(addedStyles).toContain("font-weight: 100");
|
|
expect(addedStyles).toContain("font-style: italic");
|
|
});
|
|
|
|
it("uses transform-resolved Google style metadata for italic-only fonts", async () => {
|
|
// Ported from Next.js: packages/font/src/google/loader.ts
|
|
// https://github.com/vercel/next.js/blob/canary/packages/font/src/google/loader.ts
|
|
const { createFontLoader, getSSRFontStyles } =
|
|
await import("../packages/vinext/src/shims/font-google.js");
|
|
const Molle = createFontLoader("Molle");
|
|
const beforeStyles = getSSRFontStyles();
|
|
const result = Molle({
|
|
weight: "400",
|
|
subsets: ["latin"],
|
|
_vinext: { font: { fontWeight: 400, fontStyle: "italic" } },
|
|
} as any);
|
|
|
|
expect(result.style).toMatchObject({
|
|
fontWeight: 400,
|
|
fontStyle: "italic",
|
|
});
|
|
|
|
const addedStyles = getSSRFontStyles().slice(beforeStyles.length).join("\n");
|
|
expect(addedStyles).toContain(`.${result.className}`);
|
|
expect(addedStyles).toContain("font-weight: 400");
|
|
expect(addedStyles).toContain("font-style: italic");
|
|
});
|
|
|
|
it("does not export Google fontStyle for ambiguous multi-style requests", async () => {
|
|
// Ported from Next.js: packages/font/src/google/loader.ts
|
|
// https://github.com/vercel/next.js/blob/canary/packages/font/src/google/loader.ts
|
|
const { createFontLoader, getSSRFontStyles } =
|
|
await import("../packages/vinext/src/shims/font-google.js");
|
|
const Inter = createFontLoader("Inter");
|
|
const beforeStyles = getSSRFontStyles();
|
|
const result = Inter({
|
|
weight: "400",
|
|
style: ["normal", "italic"],
|
|
subsets: ["latin"],
|
|
});
|
|
|
|
expect(result.style.fontStyle).toBeUndefined();
|
|
|
|
const addedStyles = getSSRFontStyles().slice(beforeStyles.length).join("\n");
|
|
expect(addedStyles).toContain(`.${result.className}`);
|
|
expect(addedStyles).not.toContain("font-style: normal");
|
|
expect(addedStyles).not.toContain("font-style: italic");
|
|
});
|
|
|
|
it("rejects unsafe Google font-style values in class rules", async () => {
|
|
const { createFontLoader, getSSRFontStyles } =
|
|
await import("../packages/vinext/src/shims/font-google.js");
|
|
const Roboto = createFontLoader("Roboto");
|
|
const beforeStyles = getSSRFontStyles();
|
|
const result = Roboto({
|
|
weight: "400",
|
|
style: "italic;}body{color:red",
|
|
subsets: ["latin"],
|
|
} as any);
|
|
|
|
expect(result.style.fontStyle).toBeUndefined();
|
|
|
|
const addedStyles = getSSRFontStyles().slice(beforeStyles.length).join("\n");
|
|
expect(addedStyles).not.toContain("color:red");
|
|
expect(addedStyles).not.toContain("color: red");
|
|
expect(addedStyles).not.toContain("italic;}body");
|
|
});
|
|
|
|
it("uses adjusted Google font fallback metrics by default", async () => {
|
|
// Ported from Next.js: packages/font/src/google/loader.ts and
|
|
// packages/next/src/build/webpack/loaders/next-font-loader/postcss-next-font.ts
|
|
// https://github.com/vercel/next.js/blob/canary/packages/font/src/google/loader.ts
|
|
// https://github.com/vercel/next.js/blob/canary/packages/next/src/build/webpack/loaders/next-font-loader/postcss-next-font.ts
|
|
const { createFontLoader, getSSRFontStyles } =
|
|
await import("../packages/vinext/src/shims/font-google.js");
|
|
const Inter = createFontLoader("Open Sans");
|
|
const beforeStyles = getSSRFontStyles();
|
|
const fallbackCSS = `@font-face {
|
|
font-family: 'Open Sans Fallback';
|
|
src: local("Arial");
|
|
ascent-override: 100.00%;
|
|
descent-override: 20.00%;
|
|
line-gap-override: 0.00%;
|
|
size-adjust: 90.00%;
|
|
}\n`;
|
|
const result = Inter({
|
|
weight: "400",
|
|
subsets: ["latin"],
|
|
_vinext: { font: { adjustedFallbackCSS: fallbackCSS } },
|
|
} as any);
|
|
|
|
expect(result.style.fontFamily).toContain("'Open Sans Fallback'");
|
|
|
|
const addedStyles = getSSRFontStyles().slice(beforeStyles.length).join("\n");
|
|
expect(addedStyles).toContain("font-family: 'Open Sans Fallback'");
|
|
expect(addedStyles).toContain('src: local("Arial")');
|
|
expect(addedStyles).toContain("ascent-override:");
|
|
expect(addedStyles).toContain("descent-override:");
|
|
expect(addedStyles).toContain("line-gap-override:");
|
|
expect(addedStyles).toContain("size-adjust:");
|
|
});
|
|
|
|
it("does not include adjusted Google fallback when disabled", async () => {
|
|
const { createFontLoader, getSSRFontStyles } =
|
|
await import("../packages/vinext/src/shims/font-google.js");
|
|
const NoAdjust = createFontLoader("No Adjust");
|
|
const beforeStyles = getSSRFontStyles();
|
|
const result = NoAdjust({ weight: "400", adjustFontFallback: false });
|
|
|
|
expect(result.style.fontFamily).not.toContain("No Adjust Fallback");
|
|
|
|
const addedStyles = getSSRFontStyles().slice(beforeStyles.length).join("\n");
|
|
expect(addedStyles).not.toContain("No Adjust Fallback");
|
|
});
|
|
|
|
it("supports custom fallback fonts", async () => {
|
|
const { createFontLoader } = await import("../packages/vinext/src/shims/font-google.js");
|
|
const Inter = createFontLoader("Inter");
|
|
const result = Inter({ weight: ["400"], fallback: ["Arial", "Helvetica"] });
|
|
expect(result.style.fontFamily).toContain("Arial");
|
|
expect(result.style.fontFamily).toContain("Helvetica");
|
|
});
|
|
|
|
it("generates unique classNames for each call", async () => {
|
|
const { createFontLoader } = await import("../packages/vinext/src/shims/font-google.js");
|
|
const Inter = createFontLoader("Inter");
|
|
const a = Inter({ weight: ["400"] });
|
|
const b = Inter({ weight: ["700"] });
|
|
expect(a.className).not.toBe(b.className);
|
|
});
|
|
|
|
it("proxy creates loaders for arbitrary fonts", async () => {
|
|
const mod = await import("../packages/vinext/src/shims/font-google.js");
|
|
const fonts = mod.default as any;
|
|
const roboto = fonts.Roboto({ weight: ["400"] });
|
|
expect(roboto.className).toMatch(/^__font_roboto_[a-z0-9]+$/);
|
|
expect(roboto.style.fontFamily).toContain("Roboto");
|
|
});
|
|
|
|
it("proxy converts PascalCase to spaced family names", async () => {
|
|
const mod = await import("../packages/vinext/src/shims/font-google.js");
|
|
const fonts = mod.default as any;
|
|
const rm = fonts.RobotoMono({ weight: ["400"] });
|
|
expect(rm.style.fontFamily).toContain("Roboto Mono");
|
|
});
|
|
|
|
it("accepts internal self-hosted CSS for self-hosted mode", async () => {
|
|
const { createFontLoader } = await import("../packages/vinext/src/shims/font-google.js");
|
|
const Inter = createFontLoader("Inter");
|
|
const fakeCSS = "@font-face { font-family: 'Inter'; src: url(/fonts/inter.woff2); }";
|
|
const result = Inter({ weight: ["400"], _vinext: { font: { selfHostedCSS: fakeCSS } } } as any);
|
|
expect(result.className).toBeDefined();
|
|
expect(result.style.fontFamily).toContain("Inter");
|
|
});
|
|
|
|
it("exports buildGoogleFontsUrl", async () => {
|
|
const { buildGoogleFontsUrl } = await import("../packages/vinext/src/shims/font-google.js");
|
|
expect(typeof buildGoogleFontsUrl).toBe("function");
|
|
});
|
|
|
|
it("buildGoogleFontsUrl generates correct URL for simple weight", async () => {
|
|
const { buildGoogleFontsUrl } = await import("../packages/vinext/src/shims/font-google.js");
|
|
const url = buildGoogleFontsUrl("Inter", { weight: ["400", "700"] });
|
|
expect(url).toContain("fonts.googleapis.com/css2");
|
|
expect(url).toContain("Inter");
|
|
expect(url).toContain("wght");
|
|
expect(url).toContain("400");
|
|
expect(url).toContain("700");
|
|
expect(url).toContain("display=swap");
|
|
});
|
|
|
|
it("buildGoogleFontsUrl handles italic styles", async () => {
|
|
const { buildGoogleFontsUrl } = await import("../packages/vinext/src/shims/font-google.js");
|
|
const url = buildGoogleFontsUrl("Inter", { weight: ["400"], style: ["italic"] });
|
|
expect(url).toContain("ital");
|
|
});
|
|
|
|
it("buildGoogleFontsUrl handles custom display", async () => {
|
|
const { buildGoogleFontsUrl } = await import("../packages/vinext/src/shims/font-google.js");
|
|
const url = buildGoogleFontsUrl("Inter", { weight: ["400"], display: "optional" });
|
|
expect(url).toContain("display=optional");
|
|
});
|
|
|
|
it("buildGoogleFontsUrl handles multi-word font names", async () => {
|
|
const { buildGoogleFontsUrl } = await import("../packages/vinext/src/shims/font-google.js");
|
|
const url = buildGoogleFontsUrl("Roboto Mono", { weight: ["400"] });
|
|
// URLSearchParams encodes + as %2B
|
|
expect(url).toMatch(/Roboto[+%].*Mono/);
|
|
});
|
|
|
|
it("buildGoogleFontsUrl emits no axis segment for empty options (regression for #885)", async () => {
|
|
// Issue #885: `Sen({ subsets: ['latin'] })` used to emit
|
|
// `:wght@100..900` and Google returned HTTP 400 because Sen's wght
|
|
// axis is 400..800. The shim's dev fallback now emits no axis
|
|
// segment, so Google returns the default static face (200) regardless
|
|
// of the font's actual axis. The build plugin always pre-resolves the
|
|
// real axis range from metadata before this path is reached in
|
|
// production.
|
|
//
|
|
// URLSearchParams encodes `:` and `@`, so check the decoded URL.
|
|
const { buildGoogleFontsUrl } = await import("../packages/vinext/src/shims/font-google.js");
|
|
const url = buildGoogleFontsUrl("Sen", { subsets: ["latin"] });
|
|
const decoded = decodeURIComponent(url);
|
|
expect(decoded).not.toContain("wght@100..900");
|
|
expect(decoded).not.toContain("wght@");
|
|
expect(decoded).toContain("family=Sen");
|
|
expect(decoded).toContain("display=swap");
|
|
});
|
|
|
|
it("buildGoogleFontsUrl preserves italic-only style requests", async () => {
|
|
// Pre-port shim: outer guard on `weights.length > 0 || styles.length > 0`
|
|
// entered the block, but the inner branch only handled `weights.length > 0`.
|
|
// The result was `family=Inter&display=swap` with no ital axis, so Google
|
|
// served the regular (non-italic) face and the user's italic was silently
|
|
// dropped. Italic-only must now leave a visible ital axis in the URL.
|
|
const { buildGoogleFontsUrl } = await import("../packages/vinext/src/shims/font-google.js");
|
|
const url = buildGoogleFontsUrl("Inter", { style: ["italic"] });
|
|
const decoded = decodeURIComponent(url);
|
|
expect(decoded).toContain(":ital,wght@1,400");
|
|
expect(decoded).not.toContain("ital,wght@0,");
|
|
});
|
|
|
|
it("buildGoogleFontsUrl drops the unresolved variable sentinel in the dev fallback", async () => {
|
|
// The shim has no metadata, so it cannot resolve "variable" to the
|
|
// font's real min..max range. Production resolves this in the plugin;
|
|
// dev fallback should avoid emitting Google's invalid `wght@variable`.
|
|
const { buildGoogleFontsUrl } = await import("../packages/vinext/src/shims/font-google.js");
|
|
const url = buildGoogleFontsUrl("Inter", { weight: "variable" });
|
|
const decoded = decodeURIComponent(url);
|
|
expect(decoded).not.toContain("wght@variable");
|
|
expect(decoded).not.toContain("wght@");
|
|
});
|
|
|
|
it("getSSRFontLinks returns collected URLs without clearing", async () => {
|
|
const mod = await import("../packages/vinext/src/shims/font-google.js");
|
|
// Force a CDN-mode font load (SSR context: document is undefined)
|
|
const fonts = mod.default as any;
|
|
fonts.Nunito_Sans({ weight: ["400"] });
|
|
const links = mod.getSSRFontLinks();
|
|
// Should have collected at least one URL
|
|
expect(links.length).toBeGreaterThanOrEqual(0); // May be 0 if deduped
|
|
// In Workers, fonts persist across requests, so arrays are NOT cleared
|
|
// Second call returns same data (not empty)
|
|
const links2 = mod.getSSRFontLinks();
|
|
expect(links2.length).toBe(links.length);
|
|
});
|
|
|
|
it("getSSRFontStyles returns collected CSS without clearing", async () => {
|
|
const mod = await import("../packages/vinext/src/shims/font-google.js");
|
|
const styles = mod.getSSRFontStyles();
|
|
// Returns array (may be empty if already cleared)
|
|
expect(Array.isArray(styles)).toBe(true);
|
|
// In Workers, fonts persist across requests, so arrays are NOT cleared
|
|
// Second call returns same data (not empty)
|
|
const styles2 = mod.getSSRFontStyles();
|
|
expect(styles2.length).toBe(styles.length);
|
|
});
|
|
|
|
it("exports createFontLoader for ad-hoc font creation", async () => {
|
|
const mod = await import("../packages/vinext/src/shims/font-google.js");
|
|
expect(typeof mod.createFontLoader).toBe("function");
|
|
const loader = mod.createFontLoader("Inter");
|
|
expect(typeof loader).toBe("function");
|
|
const result = loader({ weight: ["400"] });
|
|
expect(result.className).toMatch(/^__font_inter_[a-z0-9]+$/);
|
|
expect(result.style.fontFamily).toContain("Inter");
|
|
});
|
|
|
|
it("uses a stable class identity for equivalent font calls", async () => {
|
|
const { createFontLoader } = await import("../packages/vinext/src/shims/font-google.js");
|
|
const Inter = createFontLoader("Inter");
|
|
const first = Inter({ weight: ["400"], subsets: ["latin"], variable: "--font-primary" });
|
|
const second = Inter({ weight: ["400"], subsets: ["latin"], variable: "--font-primary" });
|
|
|
|
expect(second.className).toBe(first.className);
|
|
expect(second.variable).toBe(first.variable);
|
|
});
|
|
|
|
it("normalizes explicit display swap to the same identity as the default", async () => {
|
|
const { createFontLoader } = await import("../packages/vinext/src/shims/font-google.js");
|
|
const DisplayDefault = createFontLoader("Display Default");
|
|
const implicit = DisplayDefault({ weight: ["400"], subsets: ["latin"] });
|
|
const explicit = DisplayDefault({ weight: ["400"], subsets: ["latin"], display: "swap" });
|
|
|
|
expect(explicit.className).toBe(implicit.className);
|
|
expect(explicit.variable).toBe(implicit.variable);
|
|
});
|
|
|
|
it("normalizes identity inputs that emit equivalent Google font output", async () => {
|
|
const { createFontLoader } = await import("../packages/vinext/src/shims/font-google.js");
|
|
const CanonicalIdentity = createFontLoader("Canonical Identity");
|
|
const canonical = CanonicalIdentity({
|
|
weight: ["400", "700"],
|
|
subsets: ["latin", "latin-ext"],
|
|
fallback: ["Arial", "Helvetica"],
|
|
});
|
|
const equivalent = CanonicalIdentity({
|
|
weight: ["700", "400"],
|
|
style: ["normal"],
|
|
subsets: ["latin-ext", "latin"],
|
|
fallback: [" Arial ", "Helvetica"],
|
|
});
|
|
|
|
expect(equivalent.className).toBe(canonical.className);
|
|
expect(equivalent.variable).toBe(canonical.variable);
|
|
});
|
|
|
|
it("uses explicit fallback fonts as part of the class identity", async () => {
|
|
const { createFontLoader } = await import("../packages/vinext/src/shims/font-google.js");
|
|
const DefaultFallback = createFontLoader("Default Fallback");
|
|
const implicit = DefaultFallback({ weight: ["400"], subsets: ["latin"] });
|
|
const explicit = DefaultFallback({
|
|
weight: ["400"],
|
|
subsets: ["latin"],
|
|
fallback: ["sans-serif"],
|
|
});
|
|
|
|
expect(explicit.className).not.toBe(implicit.className);
|
|
});
|
|
|
|
it("does not emit Next-incompatible :root font variable rules", async () => {
|
|
const { createFontLoader, getSSRFontStyles } =
|
|
await import("../packages/vinext/src/shims/font-google.js");
|
|
const beforeStyles = getSSRFontStyles();
|
|
const Sen = createFontLoader("Root Rule Sen");
|
|
const Outfit = createFontLoader("Root Rule Outfit");
|
|
|
|
Sen({ subsets: ["latin"], variable: "--font-primary" });
|
|
Outfit({ subsets: ["latin"], variable: "--font-primary" });
|
|
|
|
const styles = getSSRFontStyles().slice(beforeStyles.length).join("\n");
|
|
expect(styles).not.toContain(":root");
|
|
expect(styles).toContain("--font-primary: 'Root Rule Sen'");
|
|
expect(styles).toContain("--font-primary: 'Root Rule Outfit'");
|
|
});
|
|
|
|
it("does not grow SSR style output for repeated equivalent font calls", async () => {
|
|
const { createFontLoader, getSSRFontStyles } =
|
|
await import("../packages/vinext/src/shims/font-google.js");
|
|
const Inter = createFontLoader("Inter");
|
|
|
|
Inter({ weight: ["400"], subsets: ["latin"], variable: "--font-primary" });
|
|
const stylesAfterFirstCall = getSSRFontStyles();
|
|
Inter({ weight: ["400"], subsets: ["latin"], variable: "--font-primary" });
|
|
const stylesAfterSecondCall = getSSRFontStyles();
|
|
|
|
expect(stylesAfterSecondCall).toEqual(stylesAfterFirstCall);
|
|
});
|
|
|
|
it("proxy handles underscore-style names", async () => {
|
|
const mod = await import("../packages/vinext/src/shims/font-google.js");
|
|
const fonts = mod.default as any;
|
|
const rm = fonts.Roboto_Mono({ weight: ["400"] });
|
|
expect(rm.style.fontFamily).toContain("Roboto Mono");
|
|
});
|
|
|
|
it("keeps utility exports in sync with the shim barrel", async () => {
|
|
const mod = await import("../packages/vinext/src/shims/font-google.js");
|
|
const actual = Object.keys(mod)
|
|
.filter((name) => name !== "default")
|
|
.sort();
|
|
expect(actual).toEqual([
|
|
"buildGoogleFontsUrl",
|
|
"createFontLoader",
|
|
"getSSRFontLinks",
|
|
"getSSRFontPreloads",
|
|
"getSSRFontStyles",
|
|
]);
|
|
});
|
|
|
|
// ── Security: CSS injection via font family names ──
|
|
|
|
it("escapes single quotes in font family names", async () => {
|
|
const mod = await import("../packages/vinext/src/shims/font-google.js");
|
|
const fonts = mod.default as any;
|
|
// Proxy converts PascalCase to spaced, so a crafted property name
|
|
// could produce a family with special characters
|
|
const result = fonts["Evil']; } body { color: red; } .x { font-family: '"]({
|
|
weight: ["400"],
|
|
});
|
|
// The fontFamily in the result should have the quote escaped
|
|
expect(result.style.fontFamily).toContain("\\'");
|
|
// Should not contain an unescaped breakout sequence
|
|
expect(result.style.fontFamily).not.toMatch(/[^\\]'; }/);
|
|
});
|
|
|
|
it("sanitizes generated class selectors for crafted font family names", async () => {
|
|
const mod = await import("../packages/vinext/src/shims/font-google.js");
|
|
const fonts = mod.default as any;
|
|
const result = fonts["Evil']; } body { color: red; } .x { font-family: '"]({
|
|
weight: ["400"],
|
|
variable: "--evil-font",
|
|
});
|
|
|
|
expect(result.className).toMatch(/^__font_[a-z0-9_-]+_[a-z0-9]+$/);
|
|
expect(result.variable).toMatch(/^__variable_[a-z0-9_-]+_[a-z0-9]+$/);
|
|
expect(result.className).not.toMatch(/[;{}'"\s]/);
|
|
expect(result.variable).not.toMatch(/[;{}'"\s]/);
|
|
});
|
|
|
|
it("escapes backslashes in font family names", async () => {
|
|
const mod = await import("../packages/vinext/src/shims/font-google.js");
|
|
const fonts = mod.default as any;
|
|
const result = fonts["Test\\Font"]({ weight: ["400"] });
|
|
// The backslash should be escaped in the CSS string
|
|
expect(result.style.fontFamily).toContain("\\\\");
|
|
});
|
|
|
|
it("sanitizes fallback font names with CSS injection attempts", async () => {
|
|
const mod = await import("../packages/vinext/src/shims/font-google.js");
|
|
const Inter = mod.createFontLoader("Inter");
|
|
const result = Inter({
|
|
weight: ["400"],
|
|
fallback: ["sans-serif", "'); } body { color: red; } .x { font-family: ('"],
|
|
});
|
|
// The malicious single quotes in the fallback should be escaped with \'
|
|
// so they can't break out of the CSS string context
|
|
expect(result.style.fontFamily).toContain("\\'");
|
|
// Should still have sans-serif as a safe generic
|
|
expect(result.style.fontFamily).toContain("sans-serif");
|
|
// The malicious fallback should be wrapped in quotes (not used as a bare identifier)
|
|
// so it's treated as a CSS string value. The sanitizeFallback function
|
|
// wraps non-generic names in quotes and escapes internal quotes.
|
|
// Verify the fontFamily contains the escaped quote, meaning the CSS parser
|
|
// will treat the entire value as a string and not interpret '; }' as CSS syntax.
|
|
expect(result.style.fontFamily).toMatch(/'\\'.*\\'/);
|
|
});
|
|
|
|
it("rejects invalid CSS variable names and falls back to auto-generated", async () => {
|
|
const mod = await import("../packages/vinext/src/shims/font-google.js");
|
|
const Inter = mod.createFontLoader("Inter");
|
|
const beforeStyles = mod.getSSRFontStyles().length;
|
|
const result = Inter({
|
|
weight: ["400"],
|
|
variable: "--x; } body { color: red; } .y { --z",
|
|
});
|
|
// Should still return a valid result
|
|
expect(result.className).toBeDefined();
|
|
expect(result.variable).toBeDefined();
|
|
// Generated CSS should NOT contain the injection payload
|
|
const styles = mod.getSSRFontStyles();
|
|
const newStyles = styles.slice(beforeStyles);
|
|
for (const css of newStyles) {
|
|
expect(css).not.toContain("color: red");
|
|
expect(css).not.toContain("color:red");
|
|
}
|
|
});
|
|
|
|
it("accepts valid CSS variable names", async () => {
|
|
const mod = await import("../packages/vinext/src/shims/font-google.js");
|
|
const ValidVarTest = mod.createFontLoader("Valid Var Test");
|
|
const beforeStyles = mod.getSSRFontStyles();
|
|
const result = ValidVarTest({
|
|
weight: ["400"],
|
|
variable: "--font-valid-var-test",
|
|
});
|
|
expect(result.className).toBeDefined();
|
|
expect(result.variable).toBeDefined();
|
|
|
|
const styles = mod.getSSRFontStyles();
|
|
const addedStyles = styles.slice(beforeStyles.length);
|
|
expect(addedStyles.some((s: string) => s.includes(`.${result.variable}`))).toBe(true);
|
|
expect(addedStyles.some((s: string) => s.includes("--font-valid-var-test"))).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ── Plugin tests ──────────────────────────────────────────────
|
|
|
|
describe("vinext:google-fonts plugin", () => {
|
|
it("exists in the plugin array", () => {
|
|
const plugin = getGoogleFontsPlugin();
|
|
expect(plugin.name).toBe("vinext:google-fonts");
|
|
expect(plugin.enforce).toBe("pre");
|
|
});
|
|
|
|
it("rewrites named font imports in dev mode", async () => {
|
|
// Verifies the import-rewrite path runs in `command: "serve"` (it gates
|
|
// on the transform filter, not on the build mode). The constructor-call
|
|
// self-hosting branch also runs in dev now — covered separately by the
|
|
// Playwright spec under tests/e2e/font-google/ — so this unit test
|
|
// intentionally omits a constructor call to keep the assertion focused
|
|
// on the import shape and avoid coupling to a network fetch.
|
|
const plugin = getGoogleFontsPlugin();
|
|
initPlugin(plugin, { command: "serve" });
|
|
const transform = unwrapHook(plugin.transform);
|
|
const code = `import { Inter } from 'next/font/google';`;
|
|
const result = await transform.call(plugin, code, "/app/layout.tsx");
|
|
expect(result).not.toBeNull();
|
|
expect(result.code).toContain("virtual:vinext-google-fonts?");
|
|
expect(result.code).not.toContain("selfHostedCSS");
|
|
});
|
|
|
|
it("returns null for files without next/font/google imports", async () => {
|
|
const plugin = getGoogleFontsPlugin();
|
|
initPlugin(plugin, { command: "build" });
|
|
const transform = unwrapHook(plugin.transform);
|
|
const code = `import React from 'react';\nconst x = 1;`;
|
|
const result = await transform.call(plugin, code, "/app/layout.tsx");
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("rewrites dependency files that import next/font/google", async () => {
|
|
const plugin = getGoogleFontsPlugin();
|
|
initPlugin(plugin, { command: "serve" });
|
|
const transform = unwrapHook(plugin.transform);
|
|
const code = `import { Inter } from 'next/font/google';`;
|
|
const result = await transform.call(plugin, code, "node_modules/some-pkg/index.ts");
|
|
expect(result).not.toBeNull();
|
|
expect(result.code).toContain("virtual:vinext-google-fonts?");
|
|
});
|
|
|
|
it("returns null for virtual modules", async () => {
|
|
const plugin = getGoogleFontsPlugin();
|
|
initPlugin(plugin, { command: "build" });
|
|
const transform = unwrapHook(plugin.transform);
|
|
const code = `import { Inter } from 'next/font/google';`;
|
|
const result = await transform.call(plugin, code, "\0virtual:something");
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("rewrites the named import even when a preceding line lacks a semicolon", async () => {
|
|
// Repro: source files written without trailing semicolons (Prettier
|
|
// default, ASI). The lazy `[^;]+?` clause used to roll across `\n`
|
|
// and swallow the previous import, leaving the font import as a
|
|
// mangled half-clause — so the rewrite was silently skipped and
|
|
// rolldown later failed with MISSING_EXPORT against the shim.
|
|
const plugin = getGoogleFontsPlugin();
|
|
initPlugin(plugin, { command: "serve" });
|
|
const transform = unwrapHook(plugin.transform);
|
|
const code = [
|
|
`import type { Metadata } from 'next'`,
|
|
`import { Inter } from 'next/font/google'`,
|
|
`export { Inter }`,
|
|
].join("\n");
|
|
const result = await transform.call(plugin, code, "/app/layout.tsx");
|
|
expect(result).not.toBeNull();
|
|
expect(result.code).toContain("virtual:vinext-google-fonts?");
|
|
// The semicolon-less Metadata import must remain untouched (only the
|
|
// font import is rewritten).
|
|
expect(result.code).toContain(`import type { Metadata } from 'next'`);
|
|
});
|
|
|
|
it("rewrites multi-line bracket imports (Prettier wraps past printWidth)", async () => {
|
|
// Repro: `import { A, B, C, D } from 'next/font/google'` exceeds the
|
|
// default 80-char printWidth once 4+ fonts are imported, so Prettier
|
|
// wraps the named specifiers across multiple lines. The clause must
|
|
// tolerate `\n` inside the `{...}` block while still refusing to
|
|
// cross newlines outside it.
|
|
const plugin = getGoogleFontsPlugin();
|
|
initPlugin(plugin, { command: "serve" });
|
|
const transform = unwrapHook(plugin.transform);
|
|
const code = [
|
|
`import {`,
|
|
` Inter,`,
|
|
` Roboto,`,
|
|
` Architects_Daughter,`,
|
|
`} from 'next/font/google'`,
|
|
`export { Inter, Roboto, Architects_Daughter }`,
|
|
].join("\n");
|
|
const result = await transform.call(plugin, code, "/app/layout.tsx");
|
|
expect(result).not.toBeNull();
|
|
expect(result.code).toContain("virtual:vinext-google-fonts?");
|
|
});
|
|
|
|
it("rewrites multi-line bracket re-exports", async () => {
|
|
// Same wrap logic applies to `export { ... } from 'next/font/google'`
|
|
// when the specifier list crosses printWidth. Less common in user code
|
|
// than the import case but kept for parity.
|
|
const plugin = getGoogleFontsPlugin();
|
|
initPlugin(plugin, { command: "build" });
|
|
const transform = unwrapHook(plugin.transform);
|
|
const code = [`export {`, ` Inter,`, ` Roboto,`, `} from 'next/font/google'`].join("\n");
|
|
const result = await transform.call(plugin, code, "/app/fonts.ts");
|
|
expect(result).not.toBeNull();
|
|
expect(result.code).toContain("virtual:vinext-google-fonts?");
|
|
});
|
|
|
|
it("returns null for non-script files", async () => {
|
|
const plugin = getGoogleFontsPlugin();
|
|
initPlugin(plugin, { command: "build" });
|
|
const transform = unwrapHook(plugin.transform);
|
|
const code = `import { Inter } from 'next/font/google';`;
|
|
const result = await transform.call(plugin, code, "/app/styles.css");
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("rewrites imports even when no constructor call exists", async () => {
|
|
const plugin = getGoogleFontsPlugin();
|
|
initPlugin(plugin, { command: "build" });
|
|
const transform = unwrapHook(plugin.transform);
|
|
const code = `import { Inter } from 'next/font/google';\n// no call`;
|
|
const result = await transform.call(plugin, code, "/app/layout.tsx");
|
|
expect(result).not.toBeNull();
|
|
expect(result.code).toContain("virtual:vinext-google-fonts?");
|
|
expect(result.code).not.toContain("selfHostedCSS");
|
|
});
|
|
|
|
it("rewrites namespace imports to the default proxy", async () => {
|
|
const plugin = getGoogleFontsPlugin();
|
|
initPlugin(plugin, { command: "serve" });
|
|
const transform = unwrapHook(plugin.transform);
|
|
// No constructor call here — the assertion is about the namespace
|
|
// rewrite, and the call-site rewrite path (which would fetch from
|
|
// Google) is exercised by the build/dev integration tests.
|
|
const code = `import * as fonts from 'next/font/google';\nexport const Inter = fonts.Inter;`;
|
|
const result = await transform.call(plugin, code, "/app/layout.tsx");
|
|
expect(result).not.toBeNull();
|
|
expect(result.code).toContain("__vinext_google_fonts_proxy_0");
|
|
expect(result.code).toContain("var fonts = __vinext_google_fonts_proxy_0;");
|
|
});
|
|
|
|
it("rewrites named re-exports through a virtual module", async () => {
|
|
const plugin = getGoogleFontsPlugin();
|
|
initPlugin(plugin, { command: "serve" });
|
|
const transform = unwrapHook(plugin.transform);
|
|
const code = `export { Inter, buildGoogleFontsUrl } from 'next/font/google';`;
|
|
const result = await transform.call(plugin, code, "/app/fonts.ts");
|
|
expect(result).not.toBeNull();
|
|
expect(result.code).toContain("virtual:vinext-google-fonts?");
|
|
});
|
|
|
|
it("transforms font call to include the internal font payload during build", async () => {
|
|
const plugin = getGoogleFontsPlugin();
|
|
const root = path.join(import.meta.dirname, ".test-font-root");
|
|
initPlugin(plugin, { command: "build", root });
|
|
|
|
mockGoogleFontsCSS(
|
|
[
|
|
"@font-face {",
|
|
" font-family: 'Inter';",
|
|
" src: url(https://fonts.gstatic.com/s/inter/v19/inter-latin-400.woff2) format('woff2');",
|
|
"}",
|
|
].join("\n"),
|
|
);
|
|
mockGoogleFontFiles();
|
|
|
|
const transform = unwrapHook(plugin.transform);
|
|
const code = [
|
|
`import { Inter } from 'next/font/google';`,
|
|
`const inter = Inter({ weight: ['400', '700'], subsets: ['latin'] });`,
|
|
].join("\n");
|
|
|
|
const result = await transform.call(plugin, code, "/app/layout.tsx");
|
|
expect(result).not.toBeNull();
|
|
expect(result.code).toContain("virtual:vinext-google-fonts?");
|
|
expect(result.code).toContain("_vinext");
|
|
expect(result.code).toContain("selfHostedCSS");
|
|
expect(result.code).toContain("adjustedFallbackCSS");
|
|
expect(result.code).toContain("@font-face");
|
|
expect(result.code).toContain("Inter");
|
|
expect(result.map).toBeDefined();
|
|
|
|
// Verify cache dir was created with font files
|
|
const cacheDir = path.join(root, ".vinext", "fonts");
|
|
expect(fs.existsSync(cacheDir)).toBe(true);
|
|
const dirs = fs.readdirSync(cacheDir);
|
|
const interDir = dirs.find((d: string) => d.startsWith("inter-"));
|
|
expect(interDir).toBeDefined();
|
|
|
|
const files = fs.readdirSync(path.join(cacheDir, interDir!));
|
|
expect(files).toContain("style.css");
|
|
expect(files.some((f: string) => f.endsWith(".woff2"))).toBe(true);
|
|
|
|
// Clean up
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}, 15000); // Network timeout
|
|
|
|
it("injects validated single-face metadata for italic-only Google fonts", async () => {
|
|
const plugin = getGoogleFontsPlugin();
|
|
const root = path.join(import.meta.dirname, ".test-font-root-italic-only");
|
|
initPlugin(plugin, { command: "build", root });
|
|
|
|
mockGoogleFontsCSS("@font-face { font-family: 'Molle'; font-style: italic; }");
|
|
|
|
try {
|
|
const transform = unwrapHook(plugin.transform);
|
|
const code = [
|
|
`import { Molle } from 'next/font/google';`,
|
|
`const molle = Molle({ weight: '400', subsets: ['latin'] });`,
|
|
].join("\n");
|
|
|
|
const result = await transform.call(plugin, code, "/app/layout.tsx");
|
|
expect(result).not.toBeNull();
|
|
expect(result.code).toContain("_vinext");
|
|
expect(result.code).toContain("selfHostedCSS");
|
|
expect(result.code).toContain("fontWeight: 400");
|
|
expect(result.code).toContain('fontStyle: "italic"');
|
|
} finally {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("does not inject single-face style metadata for multi-style Google fonts", async () => {
|
|
const plugin = getGoogleFontsPlugin();
|
|
const root = path.join(import.meta.dirname, ".test-font-root-multi-style");
|
|
initPlugin(plugin, { command: "build", root });
|
|
|
|
mockGoogleFontsCSS("@font-face { font-family: 'Inter'; }");
|
|
|
|
try {
|
|
const transform = unwrapHook(plugin.transform);
|
|
const code = [
|
|
`import { Inter } from 'next/font/google';`,
|
|
`const inter = Inter({ weight: '400', style: ['normal', 'italic'], subsets: ['latin'] });`,
|
|
].join("\n");
|
|
|
|
const result = await transform.call(plugin, code, "/app/layout.tsx");
|
|
expect(result).not.toBeNull();
|
|
expect(result.code).toContain("_vinext");
|
|
expect(result.code).toContain("selfHostedCSS");
|
|
expect(result.code).toContain("fontWeight: 400");
|
|
expect(result.code).not.toContain("fontStyle:");
|
|
} finally {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("does not inject adjusted fallback CSS when adjustFontFallback is false", async () => {
|
|
const plugin = getGoogleFontsPlugin();
|
|
const root = path.join(import.meta.dirname, ".test-font-root-no-adjust");
|
|
initPlugin(plugin, { command: "build", root });
|
|
|
|
mockGoogleFontsCSS("@font-face { font-family: 'Inter'; src: url(/inter.woff2); }");
|
|
|
|
try {
|
|
const transform = unwrapHook(plugin.transform);
|
|
const code = [
|
|
`import { Inter } from 'next/font/google';`,
|
|
`const inter = Inter({ weight: '400', subsets: ['latin'], adjustFontFallback: false });`,
|
|
].join("\n");
|
|
|
|
const result = await transform.call(plugin, code, "/app/layout.tsx");
|
|
expect(result).not.toBeNull();
|
|
expect(result.code).toContain("selfHostedCSS");
|
|
expect(result.code).not.toContain("adjustedFallbackCSS");
|
|
} finally {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("uses cached fonts on second call", async () => {
|
|
const plugin = getGoogleFontsPlugin();
|
|
const root = path.join(import.meta.dirname, ".test-font-root-2");
|
|
initPlugin(plugin, { command: "build", root });
|
|
|
|
// Pre-populate the on-disk cache so fetchAndCacheFont finds it
|
|
const fakeCSS = "@font-face { font-family: 'Inter'; src: url(/fake.woff2); }";
|
|
// The plugin hashes the URL to create the dir name. Instead, call
|
|
// transform twice: first with a real fetch to populate the in-memory
|
|
// cache, then again to verify the cache is used (no second fetch).
|
|
// Simpler approach: mock fetch to return controlled CSS.
|
|
const fonts = mockGoogleFontsCSS(fakeCSS);
|
|
|
|
try {
|
|
const transform = unwrapHook(plugin.transform);
|
|
const code = [
|
|
`import { Inter } from 'next/font/google';`,
|
|
`const inter = Inter({ weight: '400', subsets: ['latin'] });`,
|
|
].join("\n");
|
|
|
|
// First call: fetches and caches
|
|
const result1 = await transform.call(plugin, code, "/app/layout.tsx");
|
|
expect(result1).not.toBeNull();
|
|
expect(result1.code).toContain("virtual:vinext-google-fonts?");
|
|
expect(result1.code).toContain("selfHostedCSS");
|
|
const firstFetchCount = fonts.count();
|
|
|
|
// Second call: should use in-memory cache (no additional fetch)
|
|
const result2 = await transform.call(plugin, code, "/app/page.tsx");
|
|
expect(result2).not.toBeNull();
|
|
expect(fonts.count()).toBe(firstFetchCount);
|
|
} finally {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("handles multiple font imports in one file", async () => {
|
|
const plugin = getGoogleFontsPlugin();
|
|
const root = path.join(import.meta.dirname, ".test-font-root-3");
|
|
initPlugin(plugin, { command: "build", root });
|
|
|
|
// Mock Google Fonts to return different CSS per font family.
|
|
server.use(
|
|
http.get("https://fonts.googleapis.com/*", ({ request }) => {
|
|
if (request.url.includes("Inter")) {
|
|
return HttpResponse.text("@font-face { font-family: 'Inter'; src: url(/inter.woff2); }", {
|
|
headers: { "content-type": "text/css" },
|
|
});
|
|
}
|
|
return HttpResponse.text("@font-face { font-family: 'Roboto'; src: url(/roboto.woff2); }", {
|
|
headers: { "content-type": "text/css" },
|
|
});
|
|
}),
|
|
);
|
|
|
|
try {
|
|
const transform = unwrapHook(plugin.transform);
|
|
const code = [
|
|
`import { Inter, Roboto } from 'next/font/google';`,
|
|
`const inter = Inter({ weight: '400', subsets: ['latin'] });`,
|
|
`const roboto = Roboto({ weight: '400', subsets: ['latin'] });`,
|
|
].join("\n");
|
|
|
|
const result = await transform.call(plugin, code, "/app/layout.tsx");
|
|
expect(result).not.toBeNull();
|
|
expect(result.code).toContain("virtual:vinext-google-fonts?");
|
|
// Both font calls should be transformed
|
|
const matches = result.code.match(/selfHostedCSS/g);
|
|
expect(matches?.length).toBe(2);
|
|
} finally {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("skips font calls not from the import", async () => {
|
|
const plugin = getGoogleFontsPlugin();
|
|
const root = path.join(import.meta.dirname, ".test-font-root-4");
|
|
initPlugin(plugin, { command: "build", root });
|
|
|
|
// Mock fetch for Inter only
|
|
mockGoogleFontsCSS("@font-face { font-family: 'Inter'; }");
|
|
|
|
try {
|
|
const transform = unwrapHook(plugin.transform);
|
|
const code = [
|
|
`import { Inter } from 'next/font/google';`,
|
|
`const inter = Inter({ weight: '400', subsets: ['latin'] });`,
|
|
`const Roboto = (opts) => opts; // Not from import`,
|
|
`const roboto = Roboto({ weight: '400' });`,
|
|
].join("\n");
|
|
|
|
const result = await transform.call(plugin, code, "/app/layout.tsx");
|
|
expect(result).not.toBeNull();
|
|
expect(result.code).toContain("virtual:vinext-google-fonts?");
|
|
// Only Inter should be transformed (1 match)
|
|
const matches = result.code.match(/selfHostedCSS/g);
|
|
expect(matches?.length).toBe(1);
|
|
} finally {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("does not produce double-comma when font options have a trailing comma", async () => {
|
|
// Regression test: Inter({ subsets: ["latin"], }) already has a trailing comma.
|
|
// injectSelfHostedCss must not prepend another ", " making the object literal
|
|
// {subsets: ["latin"],, selfHostedCSS: "..."} which is a syntax error.
|
|
const plugin = getGoogleFontsPlugin();
|
|
const root = path.join(import.meta.dirname, ".test-font-root-trailing-comma");
|
|
initPlugin(plugin, { command: "build", root });
|
|
|
|
mockGoogleFontsCSS("@font-face { font-family: 'Inter'; src: url(/inter.woff2); }");
|
|
|
|
try {
|
|
const transform = unwrapHook(plugin.transform);
|
|
// Note the trailing comma after "latin": Inter({ subsets: ["latin"], })
|
|
const code = [
|
|
`import { Inter } from 'next/font/google';`,
|
|
`const inter = Inter({`,
|
|
` subsets: ["latin"],`,
|
|
`});`,
|
|
].join("\n");
|
|
|
|
const result = await transform.call(plugin, code, "/app/layout.tsx");
|
|
expect(result).not.toBeNull();
|
|
expect(result.code).toContain("selfHostedCSS");
|
|
// Must not have a double-comma — that would be a JS syntax error
|
|
expect(result.code).not.toMatch(/,\s*,/);
|
|
// Verify the generated code is syntactically valid by checking structure
|
|
expect(result.code).toContain('selfHostedCSS: "');
|
|
} finally {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("does not produce double-comma when a trailing comma is hidden behind a block comment", async () => {
|
|
// Regression for #1973: `.trim()` strips whitespace but not comments, so a
|
|
// trailing comma followed by a block comment, e.g. Inter({ subsets: ["latin"], /* c */ }),
|
|
// was not detected and a second ", " was inserted → `, /* c */ , _vinext` (invalid JS).
|
|
const plugin = getGoogleFontsPlugin();
|
|
const root = path.join(import.meta.dirname, ".test-font-root-block-comment");
|
|
initPlugin(plugin, { command: "build", root });
|
|
|
|
mockGoogleFontsCSS("@font-face { font-family: 'Inter'; src: url(/inter.woff2); }");
|
|
|
|
try {
|
|
const transform = unwrapHook(plugin.transform);
|
|
const code = [
|
|
`import { Inter } from 'next/font/google';`,
|
|
`const inter = Inter({`,
|
|
` subsets: ["latin"], /* c */`,
|
|
`});`,
|
|
].join("\n");
|
|
|
|
const result = await transform.call(plugin, code, "/app/layout.tsx");
|
|
expect(result).not.toBeNull();
|
|
expect(result.code).toContain("selfHostedCSS");
|
|
// No comma must be inserted right after the block comment's close.
|
|
expect(result.code).not.toMatch(/\*\/\s*,/);
|
|
} finally {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("does not produce double-comma when a trailing comma is hidden behind a line comment", async () => {
|
|
// Regression for #1973: a trailing comma followed by a `// line comment`
|
|
// also escaped detection → a second comma was inserted on the next line.
|
|
const plugin = getGoogleFontsPlugin();
|
|
const root = path.join(import.meta.dirname, ".test-font-root-line-comment");
|
|
initPlugin(plugin, { command: "build", root });
|
|
|
|
mockGoogleFontsCSS("@font-face { font-family: 'Inter'; src: url(/inter.woff2); }");
|
|
|
|
try {
|
|
const transform = unwrapHook(plugin.transform);
|
|
const code = [
|
|
`import { Inter } from 'next/font/google';`,
|
|
`const inter = Inter({`,
|
|
` subsets: ["latin"], // c`,
|
|
`});`,
|
|
].join("\n");
|
|
|
|
const result = await transform.call(plugin, code, "/app/layout.tsx");
|
|
expect(result).not.toBeNull();
|
|
expect(result.code).toContain("selfHostedCSS");
|
|
// No comma must be inserted on the line following the line comment.
|
|
expect(result.code).not.toMatch(/\/\/ c\n\s*,/);
|
|
} finally {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("does not produce double-comma when a string option value contains `//` before a trailing comma", async () => {
|
|
// Hardening for #1973: a whole-slice comment strip also deletes the `//`
|
|
// inside a string literal (e.g. Inter({ variable: "--font//x", })) and the
|
|
// rest of that line — swallowing the REAL trailing comma and injecting a
|
|
// second one (`, , selfHostedCSS` → invalid JS). The string-aware, end-only
|
|
// scan is not fooled by the in-string `//`.
|
|
const plugin = getGoogleFontsPlugin();
|
|
const root = path.join(import.meta.dirname, ".test-font-root-string-slashes");
|
|
initPlugin(plugin, { command: "build", root });
|
|
|
|
mockGoogleFontsCSS("@font-face { font-family: 'Inter'; src: url(/inter.woff2); }");
|
|
|
|
try {
|
|
const transform = unwrapHook(plugin.transform);
|
|
const code = [
|
|
`import { Inter } from 'next/font/google';`,
|
|
`const inter = Inter({`,
|
|
` subsets: ["latin"],`,
|
|
` variable: "--font//x",`,
|
|
`});`,
|
|
].join("\n");
|
|
|
|
const result = await transform.call(plugin, code, "/app/layout.tsx");
|
|
expect(result).not.toBeNull();
|
|
expect(result.code).toContain("selfHostedCSS");
|
|
// The real trailing comma must be detected → no double comma injected.
|
|
expect(result.code).not.toMatch(/,\s*,/);
|
|
} finally {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("self-hosts font calls with nested-brace options (e.g. axes: { wght: 400 })", async () => {
|
|
// Regression: namedCallRe used \{[^}]*\} which stopped at the first '}'
|
|
// inside a nested object, so calls with nested braces were silently skipped.
|
|
const plugin = getGoogleFontsPlugin();
|
|
const root = path.join(import.meta.dirname, ".test-font-root-nested");
|
|
initPlugin(plugin, { command: "build", root });
|
|
|
|
mockGoogleFontsCSS("@font-face { font-family: 'Inter'; src: url(/inter.woff2); }");
|
|
|
|
try {
|
|
const transform = unwrapHook(plugin.transform);
|
|
|
|
// Named-import form with a nested axes object
|
|
const code = [
|
|
`import { Inter } from 'next/font/google';`,
|
|
`const inter = Inter({ subsets: ["latin"], _placeholder: { wght: 400 } });`,
|
|
].join("\n");
|
|
|
|
const result = await transform.call(plugin, code, "/app/layout.tsx");
|
|
expect(result).not.toBeNull();
|
|
expect(result.code).toContain("virtual:vinext-google-fonts?");
|
|
// selfHostedCSS must have been injected — without the fix this was absent
|
|
expect(result.code).toContain("selfHostedCSS");
|
|
expect(result.code).toContain("@font-face");
|
|
// Verify the injected object is syntactically valid (no double-comma)
|
|
expect(result.code).not.toMatch(/,\s*,/);
|
|
} finally {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("self-hosts namespace member calls with nested-brace options", async () => {
|
|
// Same regression as above but for the memberCallRe path (fonts.Inter({...}))
|
|
const plugin = getGoogleFontsPlugin();
|
|
const root = path.join(import.meta.dirname, ".test-font-root-nested-member");
|
|
initPlugin(plugin, { command: "build", root });
|
|
|
|
mockGoogleFontsCSS("@font-face { font-family: 'Inter'; src: url(/inter.woff2); }");
|
|
|
|
try {
|
|
const transform = unwrapHook(plugin.transform);
|
|
|
|
const code = [
|
|
`import fonts from 'next/font/google';`,
|
|
`const inter = fonts.Inter({ subsets: ["latin"], _placeholder: { wght: 400 } });`,
|
|
].join("\n");
|
|
|
|
const result = await transform.call(plugin, code, "/app/layout.tsx");
|
|
expect(result).not.toBeNull();
|
|
expect(result.code).toContain("selfHostedCSS");
|
|
expect(result.code).not.toMatch(/,\s*,/);
|
|
} finally {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("self-hosts font calls whose string values contain brace characters", async () => {
|
|
// Ensures _findBalancedObject doesn't treat '}' inside a string value as
|
|
// the end of the options object.
|
|
const plugin = getGoogleFontsPlugin();
|
|
const root = path.join(import.meta.dirname, ".test-font-root-brace-string");
|
|
initPlugin(plugin, { command: "build", root });
|
|
|
|
mockGoogleFontsCSS("@font-face { font-family: 'Inter'; src: url(/inter.woff2); }");
|
|
|
|
try {
|
|
const transform = unwrapHook(plugin.transform);
|
|
// String value contains '}' — old \{[^}]*\} regex would have stopped here.
|
|
const code = [
|
|
`import { Inter } from 'next/font/google';`,
|
|
`const inter = Inter({ subsets: ["latin"], display: "swap", _label: "font {bold}" });`,
|
|
].join("\n");
|
|
|
|
const result = await transform.call(plugin, code, "/app/layout.tsx");
|
|
expect(result).not.toBeNull();
|
|
expect(result.code).toContain("selfHostedCSS");
|
|
expect(result.code).not.toMatch(/,\s*,/);
|
|
} finally {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("self-hosts aliased lowercase font imports during build", async () => {
|
|
const plugin = getGoogleFontsPlugin();
|
|
const root = path.join(import.meta.dirname, ".test-font-root-alias");
|
|
initPlugin(plugin, { command: "build", root });
|
|
|
|
mockGoogleFontsCSS("@font-face { font-family: 'Inter'; src: url(/inter.woff2); }");
|
|
|
|
try {
|
|
const transform = unwrapHook(plugin.transform);
|
|
const code = [
|
|
`import { Inter as inter } from 'next/font/google';`,
|
|
`const body = inter({ weight: '400', subsets: ['latin'] });`,
|
|
].join("\n");
|
|
const result = await transform.call(plugin, code, "/app/layout.tsx");
|
|
expect(result).not.toBeNull();
|
|
expect(result.code).toContain("virtual:vinext-google-fonts?");
|
|
expect(result.code).toContain("selfHostedCSS");
|
|
} finally {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("resolves narrow-axis variable fonts to their real wght range (regression for #885)", async () => {
|
|
// Sen's wght axis is 400..800. Pre-port vinext built
|
|
// `:wght@100..900` and Google returned HTTP 400. The metadata-driven
|
|
// pipeline must now request the real axis range from Google.
|
|
const plugin = getGoogleFontsPlugin();
|
|
const root = path.join(import.meta.dirname, ".test-font-root-sen");
|
|
initPlugin(plugin, { command: "build", root });
|
|
|
|
const fonts = mockGoogleFontsCSS("@font-face { font-family: 'Sen'; src: url(/sen.woff2); }");
|
|
|
|
try {
|
|
const transform = unwrapHook(plugin.transform);
|
|
const code = [
|
|
`import { Sen } from 'next/font/google';`,
|
|
`const sen = Sen({ subsets: ['latin'] });`,
|
|
].join("\n");
|
|
|
|
const result = await transform.call(plugin, code, "/app/layout.tsx");
|
|
expect(result).not.toBeNull();
|
|
expect(result.code).toContain("selfHostedCSS");
|
|
|
|
const cssFetch = fonts.urls.find((u) => u.includes("fonts.googleapis.com/css2"));
|
|
expect(cssFetch).toBeDefined();
|
|
const decoded = decodeURIComponent(cssFetch!);
|
|
expect(decoded).toContain("Sen:wght@400..800");
|
|
expect(decoded).not.toContain("wght@100..900");
|
|
} finally {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("self-hosts variable font axes through the plugin pipeline", async () => {
|
|
// Covers the documented `axes` option end-to-end: static parsing,
|
|
// validation, metadata axis resolution, and URL assembly.
|
|
const plugin = getGoogleFontsPlugin();
|
|
const root = path.join(import.meta.dirname, ".test-font-root-axes");
|
|
initPlugin(plugin, { command: "build", root });
|
|
|
|
const fonts = mockGoogleFontsCSS(
|
|
"@font-face { font-family: 'Roboto Flex'; src: url(/flex.woff2); }",
|
|
);
|
|
|
|
try {
|
|
const transform = unwrapHook(plugin.transform);
|
|
const code = [
|
|
`import { Roboto_Flex } from 'next/font/google';`,
|
|
`const flex = Roboto_Flex({ weight: 'variable', axes: ['opsz'], subsets: ['latin'] });`,
|
|
].join("\n");
|
|
|
|
const result = await transform.call(plugin, code, "/app/layout.tsx");
|
|
expect(result).not.toBeNull();
|
|
expect(result.code).toContain("selfHostedCSS");
|
|
|
|
const cssFetch = fonts.urls.find((u) => u.includes("fonts.googleapis.com/css2"));
|
|
expect(cssFetch).toBeDefined();
|
|
const decoded = decodeURIComponent(cssFetch!);
|
|
expect(decoded).toContain("Roboto+Flex:opsz,wght@8..144,100..1000");
|
|
} finally {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("throws a build error on unknown font families (regression for #885)", async () => {
|
|
// Pre-port vinext built a URL for any property name on the proxy and
|
|
// only discovered the family was unknown when Google returned 400.
|
|
// The validator now surfaces this at transform time with a message
|
|
// pointing at the file path.
|
|
const plugin = getGoogleFontsPlugin();
|
|
initPlugin(plugin, { command: "build", root: import.meta.dirname });
|
|
const transform = unwrapHook(plugin.transform);
|
|
const code = [
|
|
`import { NotARealFont } from 'next/font/google';`,
|
|
`const f = NotARealFont({ weight: '400', subsets: ['latin'] });`,
|
|
].join("\n");
|
|
await expect(transform.call(plugin, code, "/app/layout.tsx")).rejects.toThrow(
|
|
/Unknown font `NotARealFont`/,
|
|
);
|
|
});
|
|
|
|
it("throws a build error when a static font is called without an explicit weight (regression for #885)", async () => {
|
|
// Anton has only weight '400' and no variable face, so calling it
|
|
// without weight should error at build time. Pre-port vinext silently
|
|
// emitted `:wght@100..900` which Google rejected with HTTP 400.
|
|
const plugin = getGoogleFontsPlugin();
|
|
initPlugin(plugin, { command: "build", root: import.meta.dirname });
|
|
const transform = unwrapHook(plugin.transform);
|
|
const code = [
|
|
`import { Anton } from 'next/font/google';`,
|
|
`const f = Anton({ subsets: ['latin'] });`,
|
|
].join("\n");
|
|
await expect(transform.call(plugin, code, "/app/layout.tsx")).rejects.toThrow(
|
|
/Missing weight for font `Anton`/,
|
|
);
|
|
});
|
|
|
|
it("surfaces HTTP errors from Google Fonts as build errors with a bounded response body", async () => {
|
|
// If Google returns 4xx/5xx the plugin must not silently fall through
|
|
// to the runtime CDN path; the same broken URL would just 400 in the
|
|
// browser. Throw a build error containing the URL and Google's body
|
|
// so the user can see what went wrong.
|
|
const plugin = getGoogleFontsPlugin();
|
|
const root = path.join(import.meta.dirname, ".test-font-root-http-error");
|
|
initPlugin(plugin, { command: "build", root });
|
|
const longBody = `/* axis range out of bounds */${"x".repeat(600)}`;
|
|
const truncatedLength = longBody.length - 500;
|
|
|
|
mockGoogleFontsCSS(longBody, {
|
|
status: 400,
|
|
headers: { "content-type": "text/html" },
|
|
});
|
|
|
|
try {
|
|
const transform = unwrapHook(plugin.transform);
|
|
const code = [
|
|
`import { Inter } from 'next/font/google';`,
|
|
`const inter = Inter({ weight: '400', subsets: ['latin'] });`,
|
|
].join("\n");
|
|
await expect(transform.call(plugin, code, "/app/layout.tsx")).rejects.toThrowError(
|
|
new RegExp(
|
|
`Google Fonts returned HTTP 400[\\s\\S]*truncated ${truncatedLength} characters`,
|
|
),
|
|
);
|
|
} finally {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("falls through silently when fetch fails with a network error (offline dev)", async () => {
|
|
// A raw fetch rejection (DNS, ECONNREFUSED, AbortError) is treated as
|
|
// recoverable: the plugin skips self-hosting and the runtime CDN path
|
|
// takes over. Distinct from an HTTP non-2xx response, which is a hard
|
|
// build error.
|
|
const plugin = getGoogleFontsPlugin();
|
|
const root = path.join(import.meta.dirname, ".test-font-root-network-error");
|
|
initPlugin(plugin, { command: "build", root });
|
|
|
|
server.use(http.get("https://fonts.googleapis.com/*", () => HttpResponse.error()));
|
|
|
|
try {
|
|
const transform = unwrapHook(plugin.transform);
|
|
const code = [
|
|
`import { Inter } from 'next/font/google';`,
|
|
`const inter = Inter({ weight: '400', subsets: ['latin'] });`,
|
|
].join("\n");
|
|
const result = await transform.call(plugin, code, "/app/layout.tsx");
|
|
// Transform still returns; it just does not inject selfHostedCSS.
|
|
expect(result).not.toBeNull();
|
|
expect(result.code).not.toContain("selfHostedCSS");
|
|
} finally {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("self-hosts default proxy member calls during build", async () => {
|
|
const plugin = getGoogleFontsPlugin();
|
|
const root = path.join(import.meta.dirname, ".test-font-root-default");
|
|
initPlugin(plugin, { command: "build", root });
|
|
|
|
mockGoogleFontsCSS("@font-face { font-family: 'Roboto Mono'; src: url(/roboto-mono.woff2); }");
|
|
|
|
try {
|
|
const transform = unwrapHook(plugin.transform);
|
|
const code = [
|
|
`import fonts from 'next/font/google';`,
|
|
`const mono = fonts.Roboto_Mono({ weight: '400', subsets: ['latin'] });`,
|
|
].join("\n");
|
|
const result = await transform.call(plugin, code, "/app/layout.tsx");
|
|
expect(result).not.toBeNull();
|
|
expect(result.code).toContain("selfHostedCSS");
|
|
} finally {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
// ── fetchAndCacheFont integration ─────────────────────────────
|
|
|
|
describe("fetchAndCacheFont", () => {
|
|
const root = path.join(import.meta.dirname, ".test-fetch-root");
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
});
|
|
|
|
it("fetches Inter font CSS and downloads woff2 files", async () => {
|
|
// Use the plugin's transform which internally calls fetchAndCacheFont
|
|
const plugin = getGoogleFontsPlugin();
|
|
initPlugin(plugin, { command: "build", root });
|
|
|
|
mockGoogleFontsCSS(
|
|
[
|
|
"@font-face {",
|
|
" font-family: 'Inter';",
|
|
" src: url(https://fonts.gstatic.com/s/inter/v19/inter-latin-400.woff2) format('woff2');",
|
|
"}",
|
|
].join("\n"),
|
|
);
|
|
mockGoogleFontFiles();
|
|
|
|
const transform = unwrapHook(plugin.transform);
|
|
const code = [
|
|
`import { Inter } from 'next/font/google';`,
|
|
`const inter = Inter({ weight: ['400'], subsets: ['latin'] });`,
|
|
].join("\n");
|
|
|
|
const result = await transform.call(plugin, code, "/app/layout.tsx");
|
|
expect(result).not.toBeNull();
|
|
|
|
// Verify the transformed code contains self-hosted CSS with @font-face
|
|
expect(result.code).toContain("selfHostedCSS");
|
|
expect(result.code).toContain("@font-face");
|
|
expect(result.code).toContain("Inter");
|
|
// Should reference local file paths, not googleapis.com CDN
|
|
expect(result.code).not.toContain("fonts.gstatic.com");
|
|
expect(result.code).toContain(".woff2");
|
|
}, 15000);
|
|
|
|
it("reuses cached CSS on filesystem", async () => {
|
|
// Create a fake cached font dir
|
|
const cacheDir = path.join(root, ".vinext", "fonts");
|
|
const fontDir = path.join(cacheDir, "inter-fake123");
|
|
fs.mkdirSync(fontDir, { recursive: true });
|
|
const fakeCSS = "@font-face { font-family: 'Inter'; src: url(/cached.woff2); }";
|
|
fs.writeFileSync(path.join(fontDir, "style.css"), fakeCSS);
|
|
|
|
// The fetchAndCacheFont function checks existsSync on the cache path
|
|
// We can't easily test this without calling the function directly,
|
|
// but we verified the caching logic works via the plugin transform tests above
|
|
expect(fs.existsSync(path.join(fontDir, "style.css"))).toBe(true);
|
|
expect(fs.readFileSync(path.join(fontDir, "style.css"), "utf-8")).toBe(fakeCSS);
|
|
});
|
|
});
|
|
|
|
// ── Served URL rewrite for cached Google Fonts CSS ────────────
|
|
|
|
describe("_rewriteCachedFontCssToServedUrls", () => {
|
|
// Regression for a bug where self-hosted next/font/google built by vinext
|
|
// emitted absolute dev-machine filesystem paths into every preload path
|
|
// — the <style data-vinext-fonts> @font-face src: url(), the HTML body's
|
|
// <link rel="preload"> tags, and the HTTP Link: response header — because
|
|
// `fetchAndCacheFont` wrote `path.join(cacheDir, ...)` into the cached
|
|
// CSS and nothing rewrote those paths before the CSS was embedded in the
|
|
// bundle as `selfHostedCSS`. Every downstream consumer then read the
|
|
// same leaked filesystem path. In production this caused high-priority
|
|
// 404s (`<origin>/home/user/project/.vinext/fonts/...`) on every request.
|
|
//
|
|
// The fix replaces the cache-dir prefix with the served URL namespace
|
|
// `/_next/static/_vinext_fonts` before the CSS string is handed off to
|
|
// the bundle. The plugin's writeBundle hook then copies the cached font
|
|
// files into the matching `dist/client/_next/static/_vinext_fonts/`
|
|
// location so the rewritten URLs actually resolve against the origin.
|
|
|
|
it("rewrites absolute cache-dir paths in url() references to served URLs", () => {
|
|
const cacheDir = "/home/user/project/.vinext/fonts";
|
|
const css = [
|
|
"@font-face {",
|
|
" font-family: 'Geist';",
|
|
" src: url(/home/user/project/.vinext/fonts/geist-4db05770f54f/geist-8e42e564.woff2) format('woff2');",
|
|
"}",
|
|
"@font-face {",
|
|
" font-family: 'Geist';",
|
|
" src: url(/home/user/project/.vinext/fonts/geist-4db05770f54f/geist-bd9fc9d8.woff2) format('woff2');",
|
|
"}",
|
|
].join("\n");
|
|
|
|
const out = rewriteCachedFontCssToServedUrls(css, cacheDir);
|
|
|
|
expect(out).toContain(
|
|
"url(/_next/static/_vinext_fonts/geist-4db05770f54f/geist-8e42e564.woff2)",
|
|
);
|
|
expect(out).toContain(
|
|
"url(/_next/static/_vinext_fonts/geist-4db05770f54f/geist-bd9fc9d8.woff2)",
|
|
);
|
|
// The dev-machine filesystem prefix must not leak into the rewritten CSS.
|
|
expect(out).not.toContain("/home/user/project/.vinext/fonts");
|
|
// Unrelated @font-face metadata is preserved verbatim.
|
|
expect(out).toContain("font-family: 'Geist'");
|
|
expect(out).toContain("format('woff2')");
|
|
});
|
|
|
|
it("rewrites every occurrence when the same path appears multiple times", () => {
|
|
// The broken path can appear in the same cached CSS via the cyrillic /
|
|
// latin-ext / latin @font-face blocks Google Fonts returns per family,
|
|
// plus a duplicate in a `src: url(...) tech(variations)` fallback.
|
|
const cacheDir = "/root/.vinext/fonts";
|
|
const css = [
|
|
"src: url(/root/.vinext/fonts/geist/a.woff2);",
|
|
"src: url(/root/.vinext/fonts/geist/b.woff2);",
|
|
"src: url(/root/.vinext/fonts/geist/a.woff2);",
|
|
].join("\n");
|
|
|
|
const out = rewriteCachedFontCssToServedUrls(css, cacheDir);
|
|
|
|
expect(out).not.toContain("/root/.vinext/fonts");
|
|
const aCount = (out.match(/\/_next\/static\/_vinext_fonts\/geist\/a\.woff2/g) ?? []).length;
|
|
const bCount = (out.match(/\/_next\/static\/_vinext_fonts\/geist\/b\.woff2/g) ?? []).length;
|
|
expect(aCount).toBe(2);
|
|
expect(bCount).toBe(1);
|
|
});
|
|
|
|
it("is a no-op when the CSS does not reference the cache directory", () => {
|
|
const cacheDir = "/home/user/project/.vinext/fonts";
|
|
const css = "@font-face { font-family: 'Inter'; src: url(/cached.woff2); }";
|
|
expect(rewriteCachedFontCssToServedUrls(css, cacheDir)).toBe(css);
|
|
});
|
|
|
|
it("handles cache directories containing regex metacharacters", () => {
|
|
// Using split/join instead of a constructed regex guarantees safety for
|
|
// any absolute path — including ones that happen to contain characters
|
|
// that would otherwise need escaping in a RegExp.
|
|
const cacheDir = "/tmp/build (1)/.vinext/fonts";
|
|
const css = "src: url(/tmp/build (1)/.vinext/fonts/inter-xyz/inter-abc.woff2) format('woff2');";
|
|
|
|
const out = rewriteCachedFontCssToServedUrls(css, cacheDir);
|
|
|
|
expect(out).toBe(
|
|
"src: url(/_next/static/_vinext_fonts/inter-xyz/inter-abc.woff2) format('woff2');",
|
|
);
|
|
});
|
|
|
|
it("is a no-op when cacheDir is empty", () => {
|
|
// Defensive guard: before Vite's configResolved hook runs, `cacheDir`
|
|
// is the empty string. A naive split/join on "" would insert the URL
|
|
// namespace between every character in the CSS.
|
|
const css = "src: url(/home/user/project/.vinext/fonts/geist/a.woff2);";
|
|
expect(rewriteCachedFontCssToServedUrls(css, "")).toBe(css);
|
|
});
|
|
|
|
it("uses a custom assetsDir when passed through from plugin state", () => {
|
|
// Regression for a bug where the helper hardcoded the default
|
|
// assets directory into the URL prefix while the `writeBundle`
|
|
// hook read the real `envConfig.build.assetsDir` from Vite — a user
|
|
// who customized `build.assetsDir` (e.g. to `"static"`) would see
|
|
// the embedded CSS point at `/_next/static/_vinext_fonts/...` while
|
|
// the physical files landed in `<outDir>/static/_vinext_fonts/...`,
|
|
// so every preload would 404 in production.
|
|
//
|
|
// The fix threads the resolved `assetsDir` through as a third
|
|
// argument from `injectSelfHostedCss` at the call site. This test
|
|
// exercises the threaded path and asserts the URL prefix tracks it.
|
|
const cacheDir = "/home/user/project/.vinext/fonts";
|
|
const css =
|
|
"src: url(/home/user/project/.vinext/fonts/geist-abc/geist-def.woff2) format('woff2');";
|
|
|
|
const out = rewriteCachedFontCssToServedUrls(css, cacheDir, "static");
|
|
|
|
expect(out).toBe("src: url(/static/_vinext_fonts/geist-abc/geist-def.woff2) format('woff2');");
|
|
expect(out).not.toContain("/_next/static/_vinext_fonts/");
|
|
});
|
|
|
|
it("falls back to the default assetsDir when an empty string is passed", () => {
|
|
// Guard against a misconfigured environment passing `""` — never
|
|
// construct a URL of the form `//`. The helper falls back to the
|
|
// default `_next/static` prefix so the URL always has a real
|
|
// directory segment between the root and the `_vinext_fonts`
|
|
// namespace.
|
|
const cacheDir = "/root/.vinext/fonts";
|
|
const css = "src: url(/root/.vinext/fonts/geist/a.woff2);";
|
|
|
|
const out = rewriteCachedFontCssToServedUrls(css, cacheDir, "");
|
|
|
|
expect(out).toBe("src: url(/_next/static/_vinext_fonts/geist/a.woff2);");
|
|
expect(out).not.toContain("//_vinext_fonts");
|
|
});
|
|
});
|
|
|
|
// ── parseStaticObjectLiteral security tests ───────────────────
|
|
|
|
describe("parseStaticObjectLiteral", () => {
|
|
it("parses simple object with string values", () => {
|
|
const result = parseStaticObjectLiteral(`{ weight: '400', display: 'swap' }`);
|
|
expect(result).toEqual({ weight: "400", display: "swap" });
|
|
});
|
|
|
|
it("parses object with array of strings", () => {
|
|
const result = parseStaticObjectLiteral(`{ weight: ['400', '700'], subsets: ['latin'] }`);
|
|
expect(result).toEqual({ weight: ["400", "700"], subsets: ["latin"] });
|
|
});
|
|
|
|
it("parses object with double-quoted strings", () => {
|
|
const result = parseStaticObjectLiteral(`{ weight: "400" }`);
|
|
expect(result).toEqual({ weight: "400" });
|
|
});
|
|
|
|
it("parses object with trailing comma", () => {
|
|
const result = parseStaticObjectLiteral(`{ weight: '400', }`);
|
|
expect(result).toEqual({ weight: "400" });
|
|
});
|
|
|
|
it("parses object with numeric values", () => {
|
|
const result = parseStaticObjectLiteral(`{ size: 16 }`);
|
|
expect(result).toEqual({ size: 16 });
|
|
});
|
|
|
|
it("parses object with boolean values", () => {
|
|
const result = parseStaticObjectLiteral(`{ preload: true }`);
|
|
expect(result).toEqual({ preload: true });
|
|
});
|
|
|
|
it("parses object with quoted keys", () => {
|
|
const result = parseStaticObjectLiteral(`{ 'weight': '400' }`);
|
|
expect(result).toEqual({ weight: "400" });
|
|
});
|
|
|
|
it("parses empty object", () => {
|
|
const result = parseStaticObjectLiteral(`{}`);
|
|
expect(result).toEqual({});
|
|
});
|
|
|
|
it("parses nested objects", () => {
|
|
const result = parseStaticObjectLiteral(`{ axes: { wght: 400 } }`);
|
|
expect(result).toEqual({ axes: { wght: 400 } });
|
|
});
|
|
|
|
// ── Security: these must all return null ──
|
|
|
|
it("rejects function calls (code execution)", () => {
|
|
const result = parseStaticObjectLiteral(
|
|
`{ weight: require('child_process').execSync('whoami') }`,
|
|
);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("rejects template literals", () => {
|
|
const result = parseStaticObjectLiteral("{ weight: `${process.env.HOME}` }");
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("rejects identifier references", () => {
|
|
const result = parseStaticObjectLiteral(`{ weight: myVar }`);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("rejects computed property keys", () => {
|
|
const result = parseStaticObjectLiteral(`{ [Symbol.toPrimitive]: '400' }`);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("rejects spread elements", () => {
|
|
const result = parseStaticObjectLiteral(`{ ...evil }`);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("rejects new expressions", () => {
|
|
const result = parseStaticObjectLiteral(`{ weight: new Function('return 1')() }`);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("rejects IIFE in values", () => {
|
|
const result = parseStaticObjectLiteral(`{ weight: (() => { process.exit(1) })() }`);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("rejects import() expressions", () => {
|
|
const result = parseStaticObjectLiteral(`{ weight: import('fs') }`);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("returns null for invalid syntax", () => {
|
|
const result = parseStaticObjectLiteral(`{ not valid javascript `);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("returns null for non-object expressions", () => {
|
|
const result = parseStaticObjectLiteral(`"just a string"`);
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ── _findBalancedObject / _findCallEnd unit tests ─────────────
|
|
|
|
describe("_findBalancedObject", () => {
|
|
it("returns [start, end] for a simple flat object", () => {
|
|
const code = `{ a: 1 }`;
|
|
expect(findBalancedObject(code, 0)).toEqual([0, code.length]);
|
|
});
|
|
|
|
it("handles nested objects", () => {
|
|
const code = `{ outer: { inner: 1 } }`;
|
|
expect(findBalancedObject(code, 0)).toEqual([0, code.length]);
|
|
});
|
|
|
|
it("stops at the correct closing brace, ignoring braces in single-quoted strings", () => {
|
|
const code = `{ key: 'val }' }`;
|
|
expect(findBalancedObject(code, 0)).toEqual([0, code.length]);
|
|
});
|
|
|
|
it("stops at the correct closing brace, ignoring braces in double-quoted strings", () => {
|
|
const code = `{ key: "font {bold}" }`;
|
|
expect(findBalancedObject(code, 0)).toEqual([0, code.length]);
|
|
});
|
|
|
|
it("handles backslash escapes inside strings", () => {
|
|
const code = String.raw`{ key: "a \"quoted\" {value}" }`;
|
|
expect(findBalancedObject(code, 0)).toEqual([0, code.length]);
|
|
});
|
|
|
|
it("ignores braces inside template literals", () => {
|
|
const code = "{ key: `val {x}` }";
|
|
expect(findBalancedObject(code, 0)).toEqual([0, code.length]);
|
|
});
|
|
|
|
it("ignores braces inside template literal ${...} interpolations", () => {
|
|
// The '}' inside ${nested} must not end the string scan prematurely
|
|
const val = "val ${nested}";
|
|
const code = "{ key: `" + val + "` }";
|
|
expect(findBalancedObject(code, 0)).toEqual([0, code.length]);
|
|
});
|
|
|
|
it("returns null when no opening brace is found", () => {
|
|
expect(findBalancedObject(`foo`, 0)).toBeNull();
|
|
});
|
|
|
|
it("returns null for an unbalanced object", () => {
|
|
expect(findBalancedObject(`{ a: 1`, 0)).toBeNull();
|
|
});
|
|
|
|
it("skips leading whitespace before the opening brace", () => {
|
|
const code = ` { a: 1 }`;
|
|
const result = findBalancedObject(code, 0);
|
|
expect(result).not.toBeNull();
|
|
expect(result![0]).toBe(3); // points to '{'
|
|
expect(result![1]).toBe(code.length);
|
|
});
|
|
});
|
|
|
|
describe("_findCallEnd", () => {
|
|
it("returns index after ')' when it immediately follows objEnd", () => {
|
|
const code = `foo({})`;
|
|
// '{}' spans indices 4-5; objEnd (just after '}') is 6; ')' is at 6
|
|
expect(findCallEnd(code, 6)).toBe(7);
|
|
});
|
|
|
|
it("skips whitespace before ')'", () => {
|
|
const code = `foo({}\n)`;
|
|
// objEnd is 6; whitespace at 6; ')' at 7
|
|
expect(findCallEnd(code, 6)).toBe(code.length);
|
|
});
|
|
|
|
it("returns null when next non-whitespace is not ')'", () => {
|
|
const code = `foo({}, extra)`;
|
|
// objEnd is 6; next non-whitespace is ',' not ')'
|
|
expect(findCallEnd(code, 6)).toBeNull();
|
|
});
|
|
|
|
it("returns null at end of string", () => {
|
|
expect(findCallEnd(`{`, 1)).toBeNull();
|
|
});
|
|
});
|