mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
c17d6941be
* 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
621 lines
24 KiB
TypeScript
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();
|
|
});
|
|
});
|