mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
e372ff336a
Vitest integration CI shards by test file, so the 340-test App Router integration file becomes one oversized scheduling unit. That makes the slowest shard depend on one monolithic file even after adding native shards. Split the App Router integration coverage into focused files and increase the integration matrix to four native Vitest shards. This keeps fileParallelism disabled for shared-fixture safety while giving CI smaller scheduling units to distribute. Validation: vp check tests/app-router-*.test.ts vite.config.ts .github/workflows/ci.yml; vp test run --project integration tests/app-router-client-preloading.test.ts tests/app-router-dev-server.test.ts tests/app-router-external-rewrite.test.ts tests/app-router-font-google-prod.test.ts tests/app-router-isr-codegen.test.ts tests/app-router-malformed-url.test.ts tests/app-router-metadata-routes.test.ts tests/app-router-middleware-next-request.test.ts tests/app-router-next-config-codegen.test.ts tests/app-router-next-config-dev.test.ts tests/app-router-origin-check.test.ts tests/app-router-production-build.test.ts tests/app-router-production-server.test.ts tests/app-router-rsc-flight-hint.test.ts tests/app-router-rsc-plugin.test.ts tests/app-router-static-export.test.ts tests/app-router-worker-entry.test.ts
207 lines
10 KiB
TypeScript
207 lines
10 KiB
TypeScript
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { createBuilder } from "vite";
|
|
import { beforeAll, afterAll, describe, expect, it } from "vitest";
|
|
import vinext from "../packages/vinext/src/index.js";
|
|
|
|
describe("App Router Production server self-hosted next/font/google headers", () => {
|
|
// Regression for a bug where vinext's `next/font/google` self-hosting
|
|
// pipeline emitted the dev-machine absolute filesystem path into the
|
|
// HTTP `Link:` response header, the HTML body's `<link rel="preload">`
|
|
// tags, and the `<style data-vinext-fonts>` `@font-face src: url(...)`
|
|
// block. `fetchAndCacheFont` in `packages/vinext/src/plugins/fonts.ts`
|
|
// downloaded Google Fonts `.woff2` files into `<root>/.vinext/fonts/`
|
|
// and wrote `path.join(fontDir, filename)` — an absolute filesystem
|
|
// path — into the cached `@font-face` CSS's `src: url(...)`. The CSS
|
|
// was then embedded verbatim as `selfHostedCSS` in the server bundle
|
|
// and every downstream consumer (the body preload tags, the Link
|
|
// response header, and the injected style block) read the same
|
|
// leaked filesystem path. In production this produced high-priority
|
|
// 404s (`<origin>/home/user/project/.vinext/fonts/...`) on every
|
|
// request and fell back to the real font only via the browser's
|
|
// unrelated runtime retry of the stylesheet CDN.
|
|
//
|
|
// The fix uses a separate fixture (`tests/fixtures/font-google-multiple`)
|
|
// rather than `app-basic` because `app-basic` is shared by many other
|
|
// tests — adding `next/font/google` to its root layout would force a
|
|
// real Google Fonts network fetch into every test run in this file.
|
|
// The mocked fetch below stands in for the Google Fonts CDN so the
|
|
// build is hermetic.
|
|
const FONT_FIXTURE_DIR = path.resolve(import.meta.dirname, "./fixtures/font-google-multiple");
|
|
const fontOutDir = path.resolve(FONT_FIXTURE_DIR, "dist");
|
|
const fontCacheDir = path.resolve(FONT_FIXTURE_DIR, ".vinext");
|
|
const nodeModulesLink = path.join(FONT_FIXTURE_DIR, "node_modules");
|
|
let fontServer: import("node:http").Server | undefined;
|
|
let fontBaseUrl: string;
|
|
|
|
beforeAll(async () => {
|
|
// Start from a clean slate so the test deterministically exercises
|
|
// `fetchAndCacheFont`'s fresh-fetch path and the writeBundle copy.
|
|
fs.rmSync(fontOutDir, { recursive: true, force: true });
|
|
fs.rmSync(fontCacheDir, { recursive: true, force: true });
|
|
|
|
// The font fixture has no installed node_modules of its own — mirror
|
|
// `font-google-build.test.ts` and symlink the repo-level node_modules
|
|
// so `vinext` resolves as a workspace package during the in-process
|
|
// build below.
|
|
const projectNodeModules = path.resolve(import.meta.dirname, "../node_modules");
|
|
fs.rmSync(nodeModulesLink, { recursive: true, force: true });
|
|
fs.symlinkSync(projectNodeModules, nodeModulesLink);
|
|
|
|
// Mock the Google Fonts CDN so the build is hermetic and
|
|
// `fetchAndCacheFont` exercises its real URL-rewrite code path
|
|
// (which used to bake the filesystem path into the cached CSS).
|
|
// The mocked CSS MUST contain `https://fonts.gstatic.com/...` URLs
|
|
// so `fetchAndCacheFont`'s regex extracts them and triggers the
|
|
// `css.split(fontUrl).join(filePath)` rewrite that was the source
|
|
// of the bug. Returning CSS with already-relative URLs would sidestep
|
|
// the failure mode.
|
|
const originalFetch = globalThis.fetch;
|
|
// Normalize every `fetch()` input shape to a plain URL string so the
|
|
// mock can match by substring. The build plugin currently always
|
|
// passes string URLs, but `globalThis.fetch` accepts `RequestInfo |
|
|
// URL` and a future change (or test helper) passing a `Request` or
|
|
// `URL` instance would otherwise be coerced to `[object Request]`
|
|
// by `String()` and silently skip the mock branches, falling through
|
|
// to a real network request for Google Fonts.
|
|
const resolveFetchUrl = (input: unknown): string => {
|
|
if (typeof input === "string") return input;
|
|
if (input instanceof URL) return input.toString();
|
|
if (typeof Request !== "undefined" && input instanceof Request) return input.url;
|
|
return String(input);
|
|
};
|
|
// Preserve `globalThis.fetch`'s full `(input, init)` signature so the
|
|
// fallback path forwards request options verbatim to the real fetch.
|
|
// The build plugin only issues plain GETs for Google Fonts today, so
|
|
// the `init` argument is never populated for the mock branches — but
|
|
// dropping it from the fallback signature would silently strip
|
|
// headers/method/body from any unrelated request that happens to run
|
|
// during the test and fall through.
|
|
globalThis.fetch = async (input: unknown, init?: RequestInit) => {
|
|
const url = resolveFetchUrl(input);
|
|
if (url.includes("fonts.googleapis.com")) {
|
|
const isMono = url.includes("Geist+Mono") || url.includes("Geist%20Mono");
|
|
const family = isMono ? "Geist Mono" : "Geist";
|
|
const gstaticUrl = `https://fonts.gstatic.com/s/${isMono ? "geistmono" : "geist"}/v1/${isMono ? "geistmono" : "geist"}-latin.woff2`;
|
|
const css = [
|
|
"@font-face {",
|
|
` font-family: '${family}';`,
|
|
" font-style: normal;",
|
|
" font-weight: 400;",
|
|
" font-display: swap;",
|
|
` src: url(${gstaticUrl}) format('woff2');`,
|
|
" unicode-range: U+0000-00FF;",
|
|
"}",
|
|
].join("\n");
|
|
return new Response(css, {
|
|
status: 200,
|
|
headers: { "content-type": "text/css" },
|
|
});
|
|
}
|
|
if (url.includes("fonts.gstatic.com")) {
|
|
// 16 bytes is plenty — the plugin writes whatever it gets to disk
|
|
// under `.vinext/fonts/<family>/<hash>.woff2`. The test never reads
|
|
// the contents back, it only asserts the file exists and serves
|
|
// with the right content-type.
|
|
return new Response(
|
|
new Uint8Array([0x77, 0x4f, 0x46, 0x32, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]),
|
|
{ status: 200, headers: { "content-type": "font/woff2" } },
|
|
);
|
|
}
|
|
return originalFetch(input as RequestInfo, init);
|
|
};
|
|
|
|
try {
|
|
const builder = await createBuilder({
|
|
root: FONT_FIXTURE_DIR,
|
|
configFile: false,
|
|
plugins: [vinext({ appDir: FONT_FIXTURE_DIR })],
|
|
logLevel: "silent",
|
|
});
|
|
await builder.buildApp();
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
|
|
const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js");
|
|
({ server: fontServer } = await startProdServer({
|
|
port: 0,
|
|
outDir: fontOutDir,
|
|
noCompression: true,
|
|
}));
|
|
const addr = fontServer!.address();
|
|
const port = typeof addr === "object" && addr ? addr.port : 4212;
|
|
fontBaseUrl = `http://localhost:${port}`;
|
|
}, 60000);
|
|
|
|
afterAll(() => {
|
|
fontServer?.close();
|
|
fs.rmSync(fontOutDir, { recursive: true, force: true });
|
|
fs.rmSync(fontCacheDir, { recursive: true, force: true });
|
|
fs.rmSync(nodeModulesLink, { recursive: true, force: true });
|
|
});
|
|
|
|
it("emits served URLs in the HTTP Link response header (not filesystem paths)", async () => {
|
|
const res = await fetch(`${fontBaseUrl}/`);
|
|
expect(res.status).toBe(200);
|
|
const link = res.headers.get("link");
|
|
expect(link).toBeTruthy();
|
|
// Every preload in the Link header must reference the served URL
|
|
// namespace created by the fix. Before the fix, the header value was
|
|
// `</home/user/project/.vinext/fonts/geist-<hash>/geist-<hash>.woff2>`.
|
|
expect(link).toContain("/_next/static/_vinext_fonts/");
|
|
expect(link).toMatch(/rel=preload/);
|
|
expect(link).toMatch(/as=font/);
|
|
expect(link).toMatch(/type=font\/woff2/);
|
|
// Both the absolute dev-machine prefix and the relative cache dir
|
|
// name must be absent — the leaked path always contained both.
|
|
expect(link).not.toContain(FONT_FIXTURE_DIR);
|
|
expect(link).not.toContain(".vinext/fonts");
|
|
});
|
|
|
|
it("emits served URLs in the body <link rel=preload> tags", async () => {
|
|
const res = await fetch(`${fontBaseUrl}/`);
|
|
const html = await res.text();
|
|
expect(html).toMatch(
|
|
/<link rel="preload"[^>]*href="\/_next\/static\/_vinext_fonts\/[^"]+\.woff2"[^>]*as="font"/,
|
|
);
|
|
expect(html).not.toContain(FONT_FIXTURE_DIR);
|
|
expect(html).not.toContain(".vinext/fonts");
|
|
});
|
|
|
|
it("emits served URLs in the injected <style data-vinext-fonts> block", async () => {
|
|
// The injected @font-face CSS is the upstream source of truth the body
|
|
// `<link>` tags and HTTP `Link:` header are both derived from — a
|
|
// regression here would reproduce the bug across all three emission
|
|
// paths at once.
|
|
const res = await fetch(`${fontBaseUrl}/`);
|
|
const html = await res.text();
|
|
const styleMatch = html.match(/<style data-vinext-fonts[^>]*>([\s\S]*?)<\/style>/);
|
|
expect(styleMatch).not.toBeNull();
|
|
const styleContent = styleMatch![1];
|
|
expect(styleContent).toMatch(/url\(\/_next\/static\/_vinext_fonts\/[^)]+\.woff2\)/);
|
|
expect(styleContent).not.toContain(FONT_FIXTURE_DIR);
|
|
expect(styleContent).not.toContain(".vinext/fonts");
|
|
});
|
|
|
|
it("serves the cached font files copied into the client output", async () => {
|
|
// Regression guard for the writeBundle copy hook: without it, the
|
|
// rewritten URLs would be syntactically correct but 404 at request
|
|
// time because the font files never leave `<root>/.vinext/fonts/`.
|
|
const res = await fetch(`${fontBaseUrl}/`);
|
|
const html = await res.text();
|
|
const match = html.match(/\/_next\/static\/_vinext_fonts\/[^"]+\.woff2/);
|
|
expect(match).not.toBeNull();
|
|
const fontPath = match![0];
|
|
const fontRes = await fetch(`${fontBaseUrl}${fontPath}`);
|
|
expect(fontRes.status).toBe(200);
|
|
expect(fontRes.headers.get("content-type")).toBe("font/woff2");
|
|
expect(fontRes.headers.get("cache-control")).toContain("immutable");
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Malformed percent-encoded URL regression tests — App Router dev server
|
|
// (covers entries/app-rsc-entry.ts generated RSC handler decodeURIComponent)
|
|
// ---------------------------------------------------------------------------
|