Files
cloudflare__vinext/tests/font-google.test.ts
T
Stephen Zhou c17d6941be chore: migrate to vite plus (#535)
* chore: migrate to vite plus

* Disable typeAware and typeCheck

* Update CI

* Fix CI

* Fix test

* Clean

* Run test with vp

* Try revert

* react: false In test

* Fix test

* Revert "Try revert"

This reverts commit 009da10473.

* Update

* Update

* Try revert ci changes

* revert

* Run vp migrate

* Disable typeAware and typeCheck for now

* Better resolve for test

* Use vp dev instead of vite

* Update expect

* Fix NormalizeManifestModuleId

* Try increase timeout

* Update to use vp

* Try new check

* Bring back npx vp

* Migrate CI

* Make next-intl resolvable

* Update

* Update

* Update
2026-03-15 10:50:13 +00:00

621 lines
24 KiB
TypeScript

import { describe, it, expect, afterEach } from "vite-plus/test";
import path from "node:path";
import fs from "node:fs";
import vinext, {
_parseStaticObjectLiteral as parseStaticObjectLiteral,
} from "../packages/vinext/src/index.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;
}
/** Extract the vinext:google-fonts plugin from the plugin array */
function getGoogleFontsPlugin(): Plugin & {
_isBuild: boolean;
_fontCache: Map<string, string>;
_cacheDir: string;
} {
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 as any;
}
// ── 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("named export Inter returns className, style, variable", async () => {
const { Inter } = await import("../packages/vinext/src/shims/font-google.js");
const result = Inter({ weight: ["400", "700"], subsets: ["latin"] });
expect(result.className).toMatch(/^__font_inter_\d+$/);
expect(result.style.fontFamily).toContain("Inter");
// variable returns a class name that sets the CSS variable, not the variable name itself
expect(result.variable).toMatch(/^__variable_inter_\d+$/);
});
it("supports custom variable name", async () => {
const { Inter } = await import("../packages/vinext/src/shims/font-google.js");
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_\d+$/);
});
it("supports custom fallback fonts", async () => {
const { Inter } = await import("../packages/vinext/src/shims/font-google.js");
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 { Inter } = await import("../packages/vinext/src/shims/font-google.js");
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_\d+$/);
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 _selfHostedCSS option for self-hosted mode", async () => {
const { Inter } = await import("../packages/vinext/src/shims/font-google.js");
const fakeCSS = "@font-face { font-family: 'Inter'; src: url(/fonts/inter.woff2); }";
const result = Inter({ weight: ["400"], _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("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 common font families as named exports", async () => {
const mod = await import("../packages/vinext/src/shims/font-google.js");
const names = [
"Inter",
"Roboto",
"Roboto_Mono",
"Open_Sans",
"Lato",
"Poppins",
"Montserrat",
"Geist",
"Geist_Mono",
"JetBrains_Mono",
"Fira_Code",
];
for (const name of names) {
expect(typeof (mod as any)[name]).toBe("function");
}
});
it("exports all Google Fonts as named exports", async () => {
const mod = await import("../packages/vinext/src/shims/font-google.js");
const fixturePath = path.join(process.cwd(), "tests/fixtures/google-fonts.json");
const fixture = JSON.parse(fs.readFileSync(fixturePath, "utf-8")) as {
families: string[];
};
const toExportName = (family: string): string =>
family
.replace(/[^0-9A-Za-z]+/g, "_")
.replace(/^_+|_+$/g, "")
.replace(/_+/g, "_");
const expected = fixture.families.map(toExportName).sort();
const nonFontExports = new Set([
"default",
"buildGoogleFontsUrl",
"getSSRFontLinks",
"getSSRFontStyles",
"getSSRFontPreloads",
]);
const actual = Object.keys(mod)
.filter((name) => !nonFontExports.has(name))
.sort();
expect(actual).toEqual(expected);
for (const name of actual) {
expect(typeof (mod as any)[name]).toBe("function");
}
});
// ── 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("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;
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;
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 { Inter } = mod;
const beforeStyles = mod.getSSRFontStyles().length;
const result = Inter({
weight: ["400"],
variable: "--font-inter",
});
expect(result.className).toBeDefined();
// Should use the provided variable name in the CSS
const styles = mod.getSSRFontStyles();
const newStyles = styles.slice(beforeStyles);
const hasVar = newStyles.some((s: string) => s.includes("--font-inter"));
expect(hasVar).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("is a no-op in dev mode (isBuild = false)", async () => {
const plugin = getGoogleFontsPlugin();
plugin._isBuild = false;
const transform = unwrapHook(plugin.transform);
const code = `import { Inter } from 'next/font/google';\nconst inter = Inter({ weight: ['400'] });`;
const result = await transform.call(plugin, code, "/app/layout.tsx");
expect(result).toBeNull();
});
it("returns null for files without next/font/google imports", async () => {
const plugin = getGoogleFontsPlugin();
plugin._isBuild = true;
plugin._cacheDir = path.join(import.meta.dirname, ".test-font-cache");
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("returns null for node_modules files", async () => {
const plugin = getGoogleFontsPlugin();
plugin._isBuild = true;
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).toBeNull();
});
it("returns null for virtual modules", async () => {
const plugin = getGoogleFontsPlugin();
plugin._isBuild = true;
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("returns null for non-script files", async () => {
const plugin = getGoogleFontsPlugin();
plugin._isBuild = true;
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("returns null when import exists but no font constructor call", async () => {
const plugin = getGoogleFontsPlugin();
plugin._isBuild = true;
plugin._cacheDir = path.join(import.meta.dirname, ".test-font-cache");
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).toBeNull();
});
it("transforms font call to include _selfHostedCSS during build", async () => {
const plugin = getGoogleFontsPlugin();
plugin._isBuild = true;
const cacheDir = path.join(import.meta.dirname, ".test-font-cache");
plugin._cacheDir = cacheDir;
plugin._fontCache.clear();
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("_selfHostedCSS");
expect(result.code).toContain("@font-face");
expect(result.code).toContain("Inter");
expect(result.map).toBeDefined();
// Verify cache dir was created with font files
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(cacheDir, { recursive: true, force: true });
}, 15000); // Network timeout
it("uses cached fonts on second call", async () => {
const plugin = getGoogleFontsPlugin();
plugin._isBuild = true;
const cacheDir = path.join(import.meta.dirname, ".test-font-cache-2");
plugin._cacheDir = cacheDir;
// Pre-populate cache
const fakeCSS = "@font-face { font-family: 'Inter'; src: url(/fake.woff2); }";
plugin._fontCache.set(
"https://fonts.googleapis.com/css2?family=Inter%3Awght%40400&display=swap",
fakeCSS,
);
const transform = unwrapHook(plugin.transform);
const code = [
`import { Inter } from 'next/font/google';`,
`const inter = Inter({ weight: '400' });`,
].join("\n");
const result = await transform.call(plugin, code, "/app/layout.tsx");
expect(result).not.toBeNull();
expect(result.code).toContain("_selfHostedCSS");
// lgtm[js/incomplete-sanitization] — escaping quotes for test assertion, not sanitization
expect(result.code).toContain(fakeCSS.replace(/"/g, '\\"'));
plugin._fontCache.clear();
});
it("handles multiple font imports in one file", async () => {
const plugin = getGoogleFontsPlugin();
plugin._isBuild = true;
const cacheDir = path.join(import.meta.dirname, ".test-font-cache-3");
plugin._cacheDir = cacheDir;
plugin._fontCache.clear();
// Pre-populate cache for both fonts
plugin._fontCache.set(
"https://fonts.googleapis.com/css2?family=Inter%3Awght%40400&display=swap",
"@font-face { font-family: 'Inter'; src: url(/inter.woff2); }",
);
plugin._fontCache.set(
"https://fonts.googleapis.com/css2?family=Roboto%3Awght%40400&display=swap",
"@font-face { font-family: 'Roboto'; src: url(/roboto.woff2); }",
);
const transform = unwrapHook(plugin.transform);
const code = [
`import { Inter, Roboto } from 'next/font/google';`,
`const inter = Inter({ weight: '400' });`,
`const roboto = Roboto({ weight: '400' });`,
].join("\n");
const result = await transform.call(plugin, code, "/app/layout.tsx");
expect(result).not.toBeNull();
// Both font calls should be transformed
const matches = result.code.match(/_selfHostedCSS/g);
expect(matches?.length).toBe(2);
plugin._fontCache.clear();
});
it("skips font calls not from the import", async () => {
const plugin = getGoogleFontsPlugin();
plugin._isBuild = true;
plugin._cacheDir = path.join(import.meta.dirname, ".test-font-cache-4");
plugin._fontCache.clear();
const transform = unwrapHook(plugin.transform);
const code = [
`import { Inter } from 'next/font/google';`,
`const inter = Inter({ weight: '400' });`,
`const Roboto = (opts) => opts; // Not from import`,
`const roboto = Roboto({ weight: '400' });`,
].join("\n");
// Pre-populate Inter cache only
plugin._fontCache.set(
"https://fonts.googleapis.com/css2?family=Inter%3Awght%40400&display=swap",
"@font-face { font-family: 'Inter'; }",
);
const result = await transform.call(plugin, code, "/app/layout.tsx");
expect(result).not.toBeNull();
// Only Inter should be transformed (1 match)
const matches = result.code.match(/_selfHostedCSS/g);
expect(matches?.length).toBe(1);
plugin._fontCache.clear();
});
});
// ── fetchAndCacheFont integration ─────────────────────────────
describe("fetchAndCacheFont", () => {
const cacheDir = path.join(import.meta.dirname, ".test-fetch-cache");
afterEach(() => {
fs.rmSync(cacheDir, { 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();
plugin._isBuild = true;
plugin._cacheDir = cacheDir;
plugin._fontCache.clear();
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 CSS references local file paths, not googleapis.com
const selfHostedCSS = plugin._fontCache.values().next().value;
expect(selfHostedCSS).toBeDefined();
expect(selfHostedCSS).toContain("@font-face");
expect(selfHostedCSS).toContain("Inter");
expect(selfHostedCSS).not.toContain("fonts.gstatic.com");
// Should reference local absolute paths to cached woff2 files
expect(selfHostedCSS).toContain(".woff2");
}, 15000);
it("reuses cached CSS on filesystem", async () => {
// Create a fake cached font dir
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);
});
});
// ── 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();
});
});