Files
cloudflare__vinext/tests/features.test.ts

6351 lines
220 KiB
TypeScript

import {
describe,
it,
expect,
beforeAll,
afterAll,
beforeEach,
afterEach,
vi,
} from "vite-plus/test";
import { createServer, type ViteDevServer } from "vite-plus";
import path from "node:path";
import { PassThrough, Writable } from "node:stream";
import { finished } from "node:stream/promises";
import zlib from "node:zlib";
import vinext from "../packages/vinext/src/index.js";
import {
APP_FIXTURE_DIR,
PAGES_FIXTURE_DIR,
PAGES_I18N_DOMAINS_BASEPATH_FIXTURE_DIR,
PAGES_I18N_DOMAINS_FIXTURE_DIR,
createIsolatedFixture,
requestNodeServerWithHost,
startFixtureServer,
} from "./helpers.js";
import { withEnvVar } from "./env-test-helpers.js";
import { createValidFileMatcher } from "../packages/vinext/src/routing/file-matcher.js";
import { toSlash } from "pathslash";
const FIXTURE_DIR = PAGES_FIXTURE_DIR;
class CapturingNodeResponse extends PassThrough {
statusCode = 0;
headers: Record<string, string | string[]> = {};
writeHead(
statusCode: number,
headersOrStatusText: string | Record<string, string | string[]>,
maybeHeaders?: Record<string, string | string[]>,
): this {
this.statusCode = statusCode;
this.headers =
typeof headersOrStatusText === "string" ? (maybeHeaders ?? {}) : headersOrStatusText;
return this;
}
}
class DisconnectingNodeResponse extends Writable {
statusCode = 0;
headers: Record<string, string | string[]> = {};
writeCount = 0;
writeHead(
statusCode: number,
headersOrStatusText: string | Record<string, string | string[]>,
maybeHeaders?: Record<string, string | string[]>,
): this {
this.statusCode = statusCode;
this.headers =
typeof headersOrStatusText === "string" ? (maybeHeaders ?? {}) : headersOrStatusText;
return this;
}
override _write(_chunk: Buffer, _encoding: BufferEncoding, callback: (error?: Error) => void) {
this.writeCount += 1;
const error = new Error("client disconnected");
callback(error);
this.destroy(error);
}
}
function createStreamingBody(
chunks: Array<{ text: string; delayMs?: number }>,
onCancel?: () => void,
): ReadableStream<Uint8Array> {
const encoder = new TextEncoder();
let canceled = false;
return new ReadableStream<Uint8Array>({
async start(controller) {
for (const { text, delayMs } of chunks) {
if (delayMs) {
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
if (canceled) return;
controller.enqueue(encoder.encode(text));
}
controller.close();
},
cancel() {
canceled = true;
onCancel?.();
},
});
}
async function captureCompressedWebResponse(encoding: "gzip" | "br"): Promise<{
body: string;
rawBody: Buffer;
headers: Record<string, string | string[]>;
statusCode: number;
firstDecodedMs: number;
endMs: number;
}> {
const { sendWebResponse } = await import("../packages/vinext/src/server/prod-server.js");
// ReadableStream.start() begins running during construction, so start timing
// before creating the Response to include the full delayed chunk interval.
const startedAt = performance.now();
const response = new Response(
createStreamingBody([
{ text: "<h1>Streaming SSR Test</h1><p>Loading delayed chunk...</p>" },
{ text: "<p>Delayed stream content loaded</p>", delayMs: 250 },
]),
{
status: 200,
headers: {
"content-type": "text/html; charset=utf-8",
},
},
);
const req = {
method: "GET",
headers: { "accept-encoding": encoding },
};
const res = new CapturingNodeResponse();
const decoder = encoding === "br" ? zlib.createBrotliDecompress() : zlib.createGunzip();
const rawChunks: Buffer[] = [];
const decodedChunks: Buffer[] = [];
let firstDecodedMs = -1;
res.on("data", (chunk: Buffer) => {
rawChunks.push(Buffer.from(chunk));
});
res.on("error", () => {
/* ignore closed test stream noise */
});
decoder.on("data", (chunk: Buffer) => {
decodedChunks.push(Buffer.from(chunk));
if (firstDecodedMs === -1) {
firstDecodedMs = performance.now() - startedAt;
}
});
decoder.on("error", () => {
/* surfaced through finished(decoder) */
});
res.pipe(decoder);
await sendWebResponse(response, req as any, res as any, true);
await Promise.all([finished(res), finished(decoder)]);
return {
body: Buffer.concat(decodedChunks).toString("utf8"),
rawBody: Buffer.concat(rawChunks),
headers: res.headers,
statusCode: res.statusCode,
firstDecodedMs,
endMs: performance.now() - startedAt,
};
}
describe("parameterized redirects and rewrites", () => {
let prServer: ViteDevServer;
let prBaseUrl: string;
let tmpDir: string;
beforeAll(async () => {
const os = await import("node:os");
const fsp = await import("node:fs/promises");
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-rr-"));
// Symlink node_modules
const rootNodeModules = path.resolve(import.meta.dirname, "../node_modules");
await fsp.symlink(rootNodeModules, path.join(tmpDir, "node_modules"), "junction");
// next.config.mjs with parameterized redirects and rewrites
await fsp.writeFile(
path.join(tmpDir, "next.config.mjs"),
`export default {
async redirects() {
return [
{ source: "/old-blog/:slug", destination: "/blog/:slug", permanent: false },
{ source: "/legacy/:year/:month", destination: "/archive/:year-:month", permanent: true },
];
},
async rewrites() {
return [
{ source: "/posts/:id", destination: "/blog/:id" },
{ source: "/docs/:path*", destination: "/help/:path*" },
];
},
async headers() {
return [
{
source: "/api/:path*",
headers: [{ key: "X-Api-Version", value: "2" }],
},
{
source: "/blog/:slug",
headers: [{ key: "X-Content-Type", value: "blog" }],
},
];
},
};`,
);
await fsp.mkdir(path.join(tmpDir, "pages"), { recursive: true });
await fsp.mkdir(path.join(tmpDir, "pages", "blog"), { recursive: true });
await fsp.mkdir(path.join(tmpDir, "pages", "help"), { recursive: true });
await fsp.mkdir(path.join(tmpDir, "pages", "api"), { recursive: true });
await fsp.writeFile(
path.join(tmpDir, "pages", "index.tsx"),
`export default function Home() { return <h1>Home</h1>; }`,
);
await fsp.writeFile(
path.join(tmpDir, "pages", "blog", "[slug].tsx"),
`export function getServerSideProps({ params }) {
return { props: { slug: params.slug } };
}
export default function BlogPost({ slug }) {
return <h1>Blog: {slug}</h1>;
}`,
);
await fsp.writeFile(
path.join(tmpDir, "pages", "help", "[[...path]].tsx"),
`export function getServerSideProps({ params }) {
return { props: { path: params.path || [] } };
}
export default function Help({ path }) {
return <h1>Help: {path.join("/")}</h1>;
}`,
);
await fsp.writeFile(
path.join(tmpDir, "pages", "api", "test.ts"),
`export default function handler(req, res) {
res.json({ ok: true });
}`,
);
const plugins: any[] = [vinext()];
prServer = await createServer({
root: tmpDir,
configFile: false,
plugins,
server: { port: 0 },
logLevel: "silent",
});
await prServer.listen();
const addr = prServer.httpServer?.address();
if (addr && typeof addr === "object") {
prBaseUrl = `http://localhost:${addr.port}`;
}
}, 30000);
afterAll(async () => {
try {
(prServer?.httpServer as any)?.closeAllConnections?.();
await Promise.race([prServer?.close(), new Promise((resolve) => setTimeout(resolve, 5000))]);
} catch {
/* ignore */
}
const fsp = await import("node:fs/promises");
await fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
}, 15000);
it("parameterized redirect: /old-blog/:slug -> /blog/:slug", async () => {
const res = await fetch(`${prBaseUrl}/old-blog/my-post`, { redirect: "manual" });
expect(res.status).toBe(307);
expect(res.headers.get("location")).toBe("/blog/my-post");
});
it("permanent redirect with multiple params", async () => {
const res = await fetch(`${prBaseUrl}/legacy/2024/06`, { redirect: "manual" });
expect(res.status).toBe(308);
expect(res.headers.get("location")).toBe("/archive/2024-06");
});
it("parameterized rewrite: /posts/:id -> /blog/:id", async () => {
const res = await fetch(`${prBaseUrl}/posts/hello-world`);
expect(res.status).toBe(200);
const html = await res.text();
// React SSR may insert comment nodes (<!-- -->) between text segments
expect(html).toMatch(/Blog:.*hello-world/);
});
it("custom headers with :path* pattern", async () => {
const res = await fetch(`${prBaseUrl}/api/test`);
expect(res.headers.get("x-api-version")).toBe("2");
});
it("custom headers with :slug pattern", async () => {
const res = await fetch(`${prBaseUrl}/blog/my-post`);
expect(res.headers.get("x-content-type")).toBe("blog");
});
});
// ---------------------------------------------------------------------------
// External URL rewrites (proxy to third-party hosts)
// ---------------------------------------------------------------------------
describe("external URL rewrites", () => {
let extServer: ViteDevServer;
let extBaseUrl: string;
let extTmpDir: string;
// Local HTTP server to act as the external upstream
let upstreamServer: import("node:http").Server;
let upstreamPort: number;
beforeAll(async () => {
const os = await import("node:os");
const fsp = await import("node:fs/promises");
const http = await import("node:http");
// Start a local HTTP server to act as the "external" upstream
upstreamServer = http.createServer((req, res) => {
const url = new URL(req.url ?? "/", `http://localhost`);
if (url.pathname === "/api/data") {
res.writeHead(200, { "Content-Type": "application/json", "X-Upstream": "true" });
res.end(JSON.stringify({ source: "upstream", path: url.pathname }));
} else if (url.pathname.startsWith("/static/")) {
res.writeHead(200, { "Content-Type": "text/plain", "X-Upstream": "true" });
res.end("static:" + url.pathname.slice("/static/".length));
} else {
res.writeHead(200, { "Content-Type": "text/plain", "X-Upstream": "true" });
res.end("upstream:" + url.pathname);
}
});
await new Promise<void>((resolve) => {
upstreamServer.listen(0, () => {
const addr = upstreamServer.address();
upstreamPort = typeof addr === "object" && addr ? addr.port : 0;
resolve();
});
});
extTmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-ext-rewrite-"));
// Symlink node_modules
const rootNodeModules = path.resolve(import.meta.dirname, "../node_modules");
await fsp.symlink(rootNodeModules, path.join(extTmpDir, "node_modules"), "junction");
// next.config.mjs with external rewrites pointing to our local upstream
await fsp.writeFile(
path.join(extTmpDir, "next.config.mjs"),
`export default {
async rewrites() {
return [
{ source: "/proxy/api/data", destination: "http://localhost:${upstreamPort}/api/data" },
{ source: "/proxy/static/:path*", destination: "http://localhost:${upstreamPort}/static/:path*" },
{ source: "/proxy/catch/:path*", destination: "http://localhost:${upstreamPort}/:path*" },
];
},
};`,
);
await fsp.mkdir(path.join(extTmpDir, "pages"), { recursive: true });
await fsp.writeFile(
path.join(extTmpDir, "pages", "index.tsx"),
`export default function Home() { return <h1>Home</h1>; }`,
);
const plugins: any[] = [vinext()];
extServer = await createServer({
root: extTmpDir,
configFile: false,
plugins,
server: { port: 0 },
logLevel: "silent",
});
await extServer.listen();
const addr = extServer.httpServer?.address();
if (addr && typeof addr === "object") {
extBaseUrl = `http://localhost:${addr.port}`;
}
}, 30000);
afterAll(async () => {
try {
(extServer?.httpServer as any)?.closeAllConnections?.();
await Promise.race([extServer?.close(), new Promise((resolve) => setTimeout(resolve, 5000))]);
} catch {
/* ignore */
}
upstreamServer?.close();
const fsp = await import("node:fs/promises");
await fsp.rm(extTmpDir, { recursive: true, force: true }).catch(() => {});
}, 15000);
it("proxies exact path external rewrite to upstream", async () => {
const res = await fetch(`${extBaseUrl}/proxy/api/data`);
expect(res.status).toBe(200);
expect(res.headers.get("x-upstream")).toBe("true");
const data = await res.json();
expect(data.source).toBe("upstream");
expect(data.path).toBe("/api/data");
});
it("proxies catch-all external rewrite with path substitution", async () => {
const res = await fetch(`${extBaseUrl}/proxy/static/script`);
expect(res.status).toBe(200);
expect(res.headers.get("x-upstream")).toBe("true");
const body = await res.text();
expect(body).toBe("static:script");
});
it("proxies nested catch-all paths", async () => {
const res = await fetch(`${extBaseUrl}/proxy/catch/deeply/nested/path`);
expect(res.status).toBe(200);
const body = await res.text();
expect(body).toBe("upstream:/deeply/nested/path");
});
it("does not proxy internal rewrites (non-external URLs still work)", async () => {
const res = await fetch(`${extBaseUrl}/`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("Home");
});
});
// ---------------------------------------------------------------------------
// CSS Modules support
// ---------------------------------------------------------------------------
describe("CSS Modules support (Pages Router)", () => {
let cssServer: ViteDevServer;
let cssBaseUrl: string;
let cssTmpDir: string;
beforeAll(async () => {
const os = await import("node:os");
const fsp = await import("node:fs/promises");
cssTmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-css-"));
// Symlink node_modules
const rootNodeModules = path.resolve(import.meta.dirname, "../node_modules");
await fsp.symlink(rootNodeModules, path.join(cssTmpDir, "node_modules"), "junction");
// next.config.mjs
await fsp.writeFile(path.join(cssTmpDir, "next.config.mjs"), `export default {};`);
// Create directories
await fsp.mkdir(path.join(cssTmpDir, "pages"), { recursive: true });
await fsp.mkdir(path.join(cssTmpDir, "styles"), { recursive: true });
// CSS module file
await fsp.writeFile(
path.join(cssTmpDir, "styles", "card.module.css"),
`.card { border: 1px solid #ddd; border-radius: 8px; padding: 16px; }
.cardTitle { font-size: 1.5rem; font-weight: bold; }
.cardBody { color: #666; line-height: 1.6; }`,
);
// Page using CSS modules
await fsp.writeFile(
path.join(cssTmpDir, "pages", "index.tsx"),
`import styles from "../styles/card.module.css";
export default function CSSModulesTest() {
return (
<div>
<h1>CSS Modules Test</h1>
<div className={styles.card}>
<h2 className={styles.cardTitle}>Card Title</h2>
<p className={styles.cardBody}>Card body content</p>
</div>
<div id="class-names" data-card={styles.card} data-title={styles.cardTitle} data-body={styles.cardBody}>
debug
</div>
</div>
);
}`,
);
const vinext = (await import("../packages/vinext/src/index.js")).default;
const plugins: any[] = [vinext()];
cssServer = await createServer({
root: cssTmpDir,
configFile: false,
plugins,
server: { port: 0 },
logLevel: "silent",
});
await cssServer.listen();
const addr = cssServer.httpServer?.address();
if (addr && typeof addr === "object") {
cssBaseUrl = `http://localhost:${addr.port}`;
}
}, 30000);
afterAll(async () => {
try {
(cssServer?.httpServer as any)?.closeAllConnections?.();
await Promise.race([cssServer?.close(), new Promise((resolve) => setTimeout(resolve, 5000))]);
} catch {
/* ignore */
}
const fsp = await import("node:fs/promises");
await fsp.rm(cssTmpDir, { recursive: true, force: true }).catch(() => {});
}, 15000);
it("renders page with CSS module class names in SSR", async () => {
const res = await fetch(`${cssBaseUrl}/`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("CSS Modules Test");
expect(html).toContain("Card Title");
expect(html).toContain("Card body content");
});
it("CSS module class names are scoped (hashed) in SSR output", async () => {
const res = await fetch(`${cssBaseUrl}/`);
const html = await res.text();
// Vite CSS modules produce hashed class names like "_card_xxxxx_1"
// The debug div has data attributes with the class names
const dataMatch = html.match(/data-card="([^"]+)"/);
expect(dataMatch).not.toBeNull();
const cardClass = dataMatch![1];
// The class should NOT be just "card" — it should be hashed/scoped
expect(cardClass).not.toBe("card");
// Vite CSS module format: usually contains the original name as a substring
expect(cardClass.length).toBeGreaterThan(3);
});
it("different CSS module classes have different hashed names", async () => {
const res = await fetch(`${cssBaseUrl}/`);
const html = await res.text();
const cardMatch = html.match(/data-card="([^"]+)"/);
const titleMatch = html.match(/data-title="([^"]+)"/);
const bodyMatch = html.match(/data-body="([^"]+)"/);
expect(cardMatch).not.toBeNull();
expect(titleMatch).not.toBeNull();
expect(bodyMatch).not.toBeNull();
// All three should be different
const classes = [cardMatch![1], titleMatch![1], bodyMatch![1]];
const unique = new Set(classes);
expect(unique.size).toBe(3);
});
it("CSS module class names are applied as className attribute", async () => {
const res = await fetch(`${cssBaseUrl}/`);
const html = await res.text();
// Extract the card class name from data attribute
const cardMatch = html.match(/data-card="([^"]+)"/);
expect(cardMatch).not.toBeNull();
const cardClass = cardMatch![1];
// The same class should appear as a className on a div
expect(html).toContain(`class="${cardClass}"`);
});
it("CSS module class names are consistent across SSR requests", async () => {
const res1 = await fetch(`${cssBaseUrl}/`);
const html1 = await res1.text();
const res2 = await fetch(`${cssBaseUrl}/`);
const html2 = await res2.text();
const card1 = html1.match(/data-card="([^"]+)"/)?.[1];
const card2 = html2.match(/data-card="([^"]+)"/)?.[1];
expect(card1).toBe(card2);
});
});
// ---------------------------------------------------------------------------
// next/form shim
// ---------------------------------------------------------------------------
describe("ISR (Pages Router)", () => {
let server: ViteDevServer;
let baseUrl: string;
function readGeneration(html: string): number {
return Number(html.match(/data-testid="generation">(\d+)</)?.[1]);
}
function expectDevCacheHeaders(response: Response): void {
expect(response.headers.get("x-nextjs-cache")).toBe("HIT");
expect(response.headers.get("x-vinext-cache")).toBeNull();
expect(response.headers.get("cache-control")).toBe("no-cache, must-revalidate");
}
beforeAll(async () => {
({ server, baseUrl } = await startFixtureServer(FIXTURE_DIR));
});
afterAll(async () => {
await server?.close();
});
it("renders a GSP page with the Next.js development cache boundary", async () => {
const res = await fetch(`${baseUrl}/isr-test`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("ISR Page");
expect(html).toContain("Hello from ISR");
expect(readGeneration(html)).toBeGreaterThan(0);
expectDevCacheHeaders(res);
});
it("reruns getStaticProps on immediate repeated requests", async () => {
const res1 = await fetch(`${baseUrl}/isr-test`);
expect(res1.status).toBe(200);
const html1 = await res1.text();
const generation1 = readGeneration(html1);
expect(generation1).toBeGreaterThan(0);
const res2 = await fetch(`${baseUrl}/isr-test`);
expect(res2.status).toBe(200);
const html2 = await res2.text();
expect(readGeneration(html2)).toBeGreaterThan(generation1);
expectDevCacheHeaders(res1);
expectDevCacheHeaders(res2);
});
it("continues rerunning after the configured revalidate interval", async () => {
const res1 = await fetch(`${baseUrl}/isr-test`);
const html1 = await res1.text();
const generation1 = readGeneration(html1);
expect(generation1).toBeGreaterThan(0);
// The delay crosses the configured one-second interval, but dev still has
// no response-cache boundary: this is another independent GSP render.
await new Promise((r) => setTimeout(r, 1200));
const res2 = await fetch(`${baseUrl}/isr-test`);
expect(res2.status).toBe(200);
const html2 = await res2.text();
expect(readGeneration(html2)).toBeGreaterThan(generation1);
expectDevCacheHeaders(res1);
expectDevCacheHeaders(res2);
});
it("keeps each fresh GSP HTML render and hydration data in sync", async () => {
const res = await fetch(`${baseUrl}/isr-test`);
expectDevCacheHeaders(res);
const html = await res.text();
const htmlGeneration = readGeneration(html);
const htmlTimestamp = Number(html.match(/data-testid="timestamp">(\d+)</)?.[1]);
expect(htmlGeneration).toBeGreaterThan(0);
expect(htmlTimestamp).toBeGreaterThan(0);
// __NEXT_DATA__ must contain the same props as the HTML, proving both the
// server-rendered HTML and the hydration data are in sync.
const nextDataMatch = html.match(
/<script id="__NEXT_DATA__" type="application\/json">([\s\S]*?)<\/script>/,
);
expect(nextDataMatch).toBeTruthy();
const nextData = JSON.parse(nextDataMatch![1]);
expect(nextData.props.pageProps.generation).toBe(htmlGeneration);
expect(nextData.props.pageProps.timestamp).toBe(htmlTimestamp);
});
it("uses the exact development Cache-Control header even when revalidate is configured", async () => {
const res = await fetch(`${baseUrl}/isr-test`);
expectDevCacheHeaders(res);
});
it("does not set ISR headers for non-ISR pages", async () => {
const res = await fetch(`${baseUrl}/about`);
expect(res.status).toBe(200);
expect(res.headers.get("x-vinext-cache")).toBeNull();
expect(res.headers.get("cache-control")).toBeNull();
});
});
// ---------------------------------------------------------------------------
// ISR (App Router)
// ---------------------------------------------------------------------------
describe("ISR (App Router)", () => {
let server: ViteDevServer;
let baseUrl: string;
beforeAll(async () => {
({ server, baseUrl } = await startFixtureServer(APP_FIXTURE_DIR, { appRouter: true }));
}, 30000);
afterAll(async () => {
await server?.close();
});
// ── Dev mode ──────────────────────────────────────────────────────────────
// NOTE: The Vite plugin statically replaces `process.env.NODE_ENV` at
// transform time using `define`, so it cannot be mutated at runtime in
// integration tests. Production ISR cache behavior (MISS/HIT/STALE/regen) is
// covered by Playwright E2E tests. These integration tests verify dev-mode
// behavior: correct headers emitted, no ISR cache reads/writes.
it("dev: renders ISR page and emits Cache-Control header", async () => {
const res = await fetch(`${baseUrl}/isr-test`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("App Router ISR Test");
expect(html).toContain("Hello from ISR");
expect(res.headers.get("cache-control")).toContain("s-maxage=1");
expect(res.headers.get("cache-control")).toContain("stale-while-revalidate");
});
it("dev: does NOT write to or read from ISR cache (no X-Vinext-Cache header)", async () => {
// In dev mode the production guard (statically compiled) prevents cache
// reads and writes, so X-Vinext-Cache is absent on every request.
const res1 = await fetch(`${baseUrl}/isr-test`);
expect(res1.headers.get("x-vinext-cache")).toBeNull();
const res2 = await fetch(`${baseUrl}/isr-test`);
expect(res2.headers.get("x-vinext-cache")).toBeNull();
});
it("dev: RSC requests (.rsc suffix) return RSC stream with Cache-Control but no X-Vinext-Cache", async () => {
const res = await fetch(`${baseUrl}/isr-test.rsc`, {
headers: { Accept: "text/x-component" },
});
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toContain("text/x-component");
// ISR cache reads/writes are prod-only, so no X-Vinext-Cache in dev
expect(res.headers.get("x-vinext-cache")).toBeNull();
// Cache-Control IS still emitted for RSC responses on ISR pages
expect(res.headers.get("cache-control")).toContain("s-maxage=1");
expect(res.headers.get("cache-control")).toContain("stale-while-revalidate");
});
it("dev: RSC prefetch requests (Next-Router-Prefetch header) return RSC stream with Cache-Control", async () => {
const res = await fetch(`${baseUrl}/isr-test.rsc`, {
headers: { Accept: "text/x-component", "Next-Router-Prefetch": "1" },
});
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toContain("text/x-component");
// Prefetch RSC requests should also get Cache-Control (no X-Vinext-Cache in dev)
expect(res.headers.get("cache-control")).toContain("s-maxage=1");
expect(res.headers.get("x-vinext-cache")).toBeNull();
});
it("dev: pages without revalidate export emit no Cache-Control or X-Vinext-Cache headers", async () => {
// The home page does not export `revalidate`, so it is treated as dynamic
const res = await fetch(`${baseUrl}/`);
expect(res.status).toBe(200);
expect(res.headers.get("x-vinext-cache")).toBeNull();
// No ISR Cache-Control on dynamic pages
expect(res.headers.get("cache-control") ?? "").not.toContain("s-maxage");
});
});
// ---------------------------------------------------------------------------
// ISR cache internals
// ---------------------------------------------------------------------------
describe("ISR cache internals", () => {
it("MemoryCacheHandler returns stale entries instead of null", async () => {
const { MemoryCacheHandler } = await import("../packages/vinext/src/shims/cache.js");
const handler = new MemoryCacheHandler();
// Set an entry with a very short TTL
await handler.set(
"test-stale",
{ kind: "FETCH", data: { headers: {}, body: "test", url: "" }, tags: [], revalidate: 0.001 },
{ revalidate: 0.001 },
);
// Wait for it to expire
await new Promise((r) => setTimeout(r, 10));
// Should return the entry with cacheState: "stale"
const result = await handler.get("test-stale");
expect(result).not.toBeNull();
expect(result!.cacheState).toBe("stale");
expect(result!.value).not.toBeNull();
});
it("MemoryCacheHandler returns fresh entries without cacheState", async () => {
const { MemoryCacheHandler } = await import("../packages/vinext/src/shims/cache.js");
const handler = new MemoryCacheHandler();
await handler.set(
"test-fresh",
{ kind: "FETCH", data: { headers: {}, body: "test", url: "" }, tags: [], revalidate: 60 },
{ revalidate: 60 },
);
const result = await handler.get("test-fresh");
expect(result).not.toBeNull();
expect(result!.cacheState).toBeUndefined();
});
it("MemoryCacheHandler still returns null for tag-invalidated entries", async () => {
const { MemoryCacheHandler } = await import("../packages/vinext/src/shims/cache.js");
const handler = new MemoryCacheHandler();
await handler.set(
"test-tag",
{
kind: "FETCH",
data: { headers: {}, body: "test", url: "" },
tags: ["mytag"],
revalidate: 60,
},
{ revalidate: 60, tags: ["mytag"] },
);
// Invalidate the tag
await handler.revalidateTag("mytag");
// Should return null (hard invalidation, not stale)
const result = await handler.get("test-tag");
expect(result).toBeNull();
});
it("MemoryCacheHandler skips storage when data.revalidate is 0", async () => {
const { MemoryCacheHandler } = await import("../packages/vinext/src/shims/cache.js");
const handler = new MemoryCacheHandler();
// revalidate: 0 means "don't cache" — entry should not be stored at all
await handler.set(
"revalidate-zero-data",
{ kind: "FETCH", data: { headers: {}, body: "test", url: "" }, tags: [], revalidate: 0 },
{ tags: [] },
);
const result = await handler.get("revalidate-zero-data");
expect(result).toBeNull();
});
it("MemoryCacheHandler skips storage when ctx.revalidate is 0", async () => {
const { MemoryCacheHandler } = await import("../packages/vinext/src/shims/cache.js");
const handler = new MemoryCacheHandler();
// revalidate: 0 via ctx should also skip storage
await handler.set(
"revalidate-zero-ctx",
{ kind: "FETCH", data: { headers: {}, body: "test", url: "" }, tags: [], revalidate: false },
{ revalidate: 0 },
);
const result = await handler.get("revalidate-zero-ctx");
expect(result).toBeNull();
});
it("MemoryCacheHandler stores entry when ctx.revalidate is 0 but data.revalidate is positive", async () => {
const { MemoryCacheHandler } = await import("../packages/vinext/src/shims/cache.js");
const handler = new MemoryCacheHandler();
// data.revalidate overrides ctx — positive value should store
await handler.set(
"ctx-zero-data-positive",
{ kind: "FETCH", data: { headers: {}, body: "test", url: "" }, tags: [], revalidate: 60 },
{ revalidate: 0 },
);
const result = await handler.get("ctx-zero-data-positive");
expect(result).not.toBeNull();
expect(result!.cacheState).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// next/dynamic shim unit tests
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// i18n config parsing
// ---------------------------------------------------------------------------
describe("i18n config parsing", () => {
it("parses i18n config from next.config.js", async () => {
const { resolveNextConfig } = await import("../packages/vinext/src/config/next-config.js");
const config = await resolveNextConfig({
i18n: {
locales: ["en", "fr", "de"],
defaultLocale: "en",
localeDetection: true,
},
});
expect(config.i18n).not.toBeNull();
expect(config.i18n!.locales).toEqual(["en", "fr", "de"]);
expect(config.i18n!.defaultLocale).toBe("en");
expect(config.i18n!.localeDetection).toBe(true);
});
it("places the default locale first without mutating the input", async () => {
// Ported from Next.js: test/e2e/i18n-support-catchall/i18n-support-catchall.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/i18n-support-catchall/i18n-support-catchall.test.ts
const { resolveNextConfig } = await import("../packages/vinext/src/config/next-config.js");
const locales = ["nl-NL", "nl-BE", "nl", "fr-BE", "fr", "en-US", "en"];
const config = await resolveNextConfig({
i18n: {
locales,
defaultLocale: "en-US",
},
});
expect(config.i18n!.locales).toEqual(["en-US", "nl-NL", "nl-BE", "nl", "fr-BE", "fr", "en"]);
expect(locales).toEqual(["nl-NL", "nl-BE", "nl", "fr-BE", "fr", "en-US", "en"]);
});
it("rejects a default locale that is not included in locales", async () => {
// Ported from Next.js: packages/next/src/server/config.ts
// https://github.com/vercel/next.js/blob/v16.3.0-canary.80/packages/next/src/server/config.ts
const { resolveNextConfig } = await import("../packages/vinext/src/config/next-config.js");
await expect(
resolveNextConfig({
i18n: {
locales: ["fr"],
defaultLocale: "en",
},
}),
).rejects.toThrow(
"Specified i18n.defaultLocale should be included in i18n.locales.\nSee more info here: https://nextjs.org/docs/messages/invalid-i18n-config",
);
});
it("validates i18n before invoking config callbacks", async () => {
const { resolveNextConfig } = await import("../packages/vinext/src/config/next-config.js");
const redirects = vi.fn(() => []);
await expect(
resolveNextConfig({
i18n: {
locales: ["fr"],
defaultLocale: "en",
},
redirects,
}),
).rejects.toThrow(
"Specified i18n.defaultLocale should be included in i18n.locales.\nSee more info here: https://nextjs.org/docs/messages/invalid-i18n-config",
);
expect(redirects).not.toHaveBeenCalled();
});
it("reports invalid locales before checking default locale membership", async () => {
const { resolveNextConfig } = await import("../packages/vinext/src/config/next-config.js");
await expect(
resolveNextConfig({
i18n: {
locales: [42],
defaultLocale: "en",
},
} as never),
).rejects.toThrow(
'Specified i18n.locales contains invalid values (42), locales must be valid locale tags provided as strings e.g. "en-US".',
);
});
it("rejects overlapping domain locales exposed by a malformed includes value", async () => {
const { resolveNextConfig } = await import("../packages/vinext/src/config/next-config.js");
await expect(
resolveNextConfig({
i18n: {
locales: ["en", "fr"],
defaultLocale: "en",
domains: [
{ domain: "example.com", defaultLocale: "en", locales: ["fr"] },
{ domain: "example.fr", defaultLocale: "fr", locales: "fr" },
],
},
} as never),
).rejects.toThrow("Invalid i18n.domains values:");
});
it("rejects duplicate domain default locales", async () => {
const { resolveNextConfig } = await import("../packages/vinext/src/config/next-config.js");
await expect(
resolveNextConfig({
i18n: {
locales: ["en", "fr"],
defaultLocale: "en",
domains: [
{ domain: "example.fr", defaultLocale: "fr" },
{ domain: "french.example.com", defaultLocale: "fr" },
],
},
}),
).rejects.toThrow("Invalid i18n.domains values:");
});
it("returns null i18n when not configured", async () => {
const { resolveNextConfig } = await import("../packages/vinext/src/config/next-config.js");
const config = await resolveNextConfig({});
expect(config.i18n).toBeNull();
});
it("defaults localeDetection to true", async () => {
const { resolveNextConfig } = await import("../packages/vinext/src/config/next-config.js");
const config = await resolveNextConfig({
i18n: {
locales: ["en", "fr"],
defaultLocale: "en",
},
});
expect(config.i18n!.localeDetection).toBe(true);
});
it("respects localeDetection: false", async () => {
const { resolveNextConfig } = await import("../packages/vinext/src/config/next-config.js");
const config = await resolveNextConfig({
i18n: {
locales: ["en", "fr"],
defaultLocale: "en",
localeDetection: false,
},
});
expect(config.i18n!.localeDetection).toBe(false);
});
});
// ---------------------------------------------------------------------------
// i18n utility functions (unit tests)
// ---------------------------------------------------------------------------
describe("extractLocaleFromUrl", () => {
let extractLocaleFromUrl: typeof import("../packages/vinext/src/server/dev-server.js").extractLocaleFromUrl;
beforeAll(async () => {
const mod = await import("../packages/vinext/src/server/dev-server.js");
extractLocaleFromUrl = mod.extractLocaleFromUrl;
});
const i18nConfig = {
locales: ["en", "fr", "de"],
defaultLocale: "en",
localeDetection: true,
};
it("extracts locale prefix from URL", () => {
const result = extractLocaleFromUrl("/fr/about", i18nConfig);
expect(result).toEqual({ locale: "fr", url: "/about", hadPrefix: true });
});
it("extracts locale from root URL", () => {
const result = extractLocaleFromUrl("/de/", i18nConfig);
expect(result).toEqual({ locale: "de", url: "/", hadPrefix: true });
});
it("returns default locale when no prefix", () => {
const result = extractLocaleFromUrl("/about", i18nConfig);
expect(result).toEqual({ locale: "en", url: "/about", hadPrefix: false });
});
it("preserves query string", () => {
const result = extractLocaleFromUrl("/fr/page?foo=bar", i18nConfig);
expect(result.locale).toBe("fr");
expect(result.url).toBe("/page?foo=bar");
expect(result.hadPrefix).toBe(true);
});
it("does not match unknown locale prefixes", () => {
const result = extractLocaleFromUrl("/es/about", i18nConfig);
expect(result).toEqual({ locale: "en", url: "/es/about", hadPrefix: false });
});
it("handles root path without prefix", () => {
const result = extractLocaleFromUrl("/", i18nConfig);
expect(result).toEqual({ locale: "en", url: "/", hadPrefix: false });
});
it("handles multi-segment paths", () => {
const result = extractLocaleFromUrl("/fr/docs/api/routes", i18nConfig);
expect(result.locale).toBe("fr");
expect(result.url).toBe("/docs/api/routes");
});
});
// ---------------------------------------------------------------------------
// i18n routing integration (Pages Router)
// ---------------------------------------------------------------------------
describe("i18n routing (Pages Router)", () => {
let i18nServer: ViteDevServer;
let i18nBaseUrl: string;
let i18nTmpDir: string;
beforeAll(async () => {
const os = await import("node:os");
const fsp = await import("node:fs/promises");
i18nTmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-i18n-"));
// Symlink node_modules from root
const rootNodeModules = path.resolve(import.meta.dirname, "../node_modules");
await fsp.symlink(rootNodeModules, path.join(i18nTmpDir, "node_modules"), "junction");
// next.config.mjs with i18n
await fsp.writeFile(
path.join(i18nTmpDir, "next.config.mjs"),
`export default {
i18n: {
locales: ["en", "fr", "de"],
defaultLocale: "en",
},
};`,
);
await fsp.mkdir(path.join(i18nTmpDir, "pages"), { recursive: true });
// Home page
await fsp.writeFile(
path.join(i18nTmpDir, "pages", "index.tsx"),
`export default function Home() { return <h1>Home</h1>; }`,
);
// About page — uses getServerSideProps to expose locale
await fsp.writeFile(
path.join(i18nTmpDir, "pages", "about.tsx"),
`import { useRouter } from "next/router";
export function getServerSideProps({ locale, locales, defaultLocale }) {
return { props: { locale: locale || null, locales: locales || [], defaultLocale: defaultLocale || null } };
}
export default function About({ locale, locales, defaultLocale }) {
const router = useRouter();
return (
<div>
<h1>About</h1>
<p id="locale">{locale}</p>
<p id="locales">{locales.join(",")}</p>
<p id="defaultLocale">{defaultLocale}</p>
<p id="asPath">{router.asPath}</p>
</div>
);
}`,
);
const vinext = (await import("../packages/vinext/src/index.js")).default;
const plugins: any[] = [vinext()];
i18nServer = await createServer({
root: i18nTmpDir,
configFile: false,
plugins,
server: { port: 0 },
logLevel: "silent",
});
await i18nServer.listen();
const addr = i18nServer.httpServer?.address();
if (addr && typeof addr === "object") {
i18nBaseUrl = `http://localhost:${addr.port}`;
}
}, 30000);
afterAll(async () => {
try {
(i18nServer?.httpServer as any)?.closeAllConnections?.();
await Promise.race([
i18nServer?.close(),
new Promise((resolve) => setTimeout(resolve, 5000)),
]);
} catch {
/* ignore */
}
const fsp = await import("node:fs/promises");
await fsp.rm(i18nTmpDir, { recursive: true, force: true }).catch(() => {});
}, 15000);
// --- Basic routing ---
it("renders the home page without locale prefix (default locale)", async () => {
const res = await fetch(`${i18nBaseUrl}/`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("Home");
});
it("renders the about page without locale prefix", async () => {
const res = await fetch(`${i18nBaseUrl}/about`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("About");
});
it("renders the about page with default locale prefix (/en/about)", async () => {
const res = await fetch(`${i18nBaseUrl}/en/about`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("About");
});
it("renders the about page with non-default locale prefix (/fr/about)", async () => {
const res = await fetch(`${i18nBaseUrl}/fr/about`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("About");
});
it("renders the home page with non-default locale prefix (/de/)", async () => {
const res = await fetch(`${i18nBaseUrl}/de/`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("Home");
});
// --- Locale context in getServerSideProps ---
it("passes default locale to getServerSideProps when no prefix", async () => {
const res = await fetch(`${i18nBaseUrl}/about`);
expect(res.status).toBe(200);
const html = await res.text();
// locale should be "en" (default)
expect(html).toMatch(/<p id="locale">.*en.*<\/p>/);
// locales array
expect(html).toMatch(/<p id="locales">.*en,fr,de.*<\/p>/);
// defaultLocale
expect(html).toMatch(/<p id="defaultLocale">.*en.*<\/p>/);
});
it("passes correct locale to getServerSideProps for /fr/about", async () => {
const res = await fetch(`${i18nBaseUrl}/fr/about`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toMatch(/<p id="locale">.*fr.*<\/p>/);
});
it("strips the locale prefix from router.asPath in dev SSR", async () => {
// Ported from Next.js:
// test/e2e/i18n-support-fallback-rewrite/i18n-support-fallback-rewrite.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/i18n-support-fallback-rewrite/i18n-support-fallback-rewrite.test.ts
const res = await fetch(`${i18nBaseUrl}/fr/about?hello=world`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toMatch(/<p id="asPath">.*\/about\?hello=world.*<\/p>/);
});
it("passes correct locale to getServerSideProps for /de/about", async () => {
const res = await fetch(`${i18nBaseUrl}/de/about`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toMatch(/<p id="locale">.*de.*<\/p>/);
});
// --- __NEXT_DATA__ locale info ---
it("includes locale info in __NEXT_DATA__ script", async () => {
const res = await fetch(`${i18nBaseUrl}/fr/about`);
const html = await res.text();
// Extract the JSON object from __NEXT_DATA__ (handles nested braces)
const dataMatch = html.match(
/<script id="__NEXT_DATA__" type="application\/json">([\s\S]*?)<\/script>/,
);
expect(dataMatch).not.toBeNull();
const data = JSON.parse(dataMatch![1]);
expect(data.locale).toBe("fr");
expect(data.locales).toEqual(["en", "fr", "de"]);
expect(data.defaultLocale).toBe("en");
});
it("includes locale info in __NEXT_DATA__ for default locale", async () => {
const res = await fetch(`${i18nBaseUrl}/about`);
const html = await res.text();
const dataMatch = html.match(
/<script id="__NEXT_DATA__" type="application\/json">([\s\S]*?)<\/script>/,
);
expect(dataMatch).not.toBeNull();
const data = JSON.parse(dataMatch![1]);
expect(data.locale).toBe("en");
expect(data.defaultLocale).toBe("en");
});
// --- Accept-Language detection + redirect ---
it("redirects to detected locale based on Accept-Language header", async () => {
const res = await fetch(`${i18nBaseUrl}/about`, {
redirect: "manual",
headers: { "Accept-Language": "fr-FR,fr;q=0.9,en;q=0.8" },
});
// Next.js only auto-detects locale on the application root.
expect(res.status).toBe(200);
expect(res.headers.get("location")).toBeNull();
});
it("redirects to /de for Accept-Language: de on root", async () => {
const res = await fetch(`${i18nBaseUrl}/`, {
redirect: "manual",
headers: { "Accept-Language": "de" },
});
expect(res.status).toBe(307);
// Redirect to /{locale}{url} — for root ("/"), produces "/de/"
// Implementation uses `/${detectedLocale}${url}` where url is "/"
const loc = res.headers.get("location");
// Accept either /de or /de/ depending on implementation
expect(loc).toMatch(/^\/de\/?$/);
});
it("does not redirect when Accept-Language matches default locale", async () => {
const res = await fetch(`${i18nBaseUrl}/about`, {
redirect: "manual",
headers: { "Accept-Language": "en-US,en;q=0.9" },
});
// Should NOT redirect — default locale matches
expect(res.status).toBe(200);
});
it("does not redirect when URL already has locale prefix", async () => {
const res = await fetch(`${i18nBaseUrl}/fr/about`, {
redirect: "manual",
headers: { "Accept-Language": "de" },
});
// Already has /fr prefix — should serve directly, no redirect
expect(res.status).toBe(200);
});
it("does not redirect for unknown Accept-Language", async () => {
const res = await fetch(`${i18nBaseUrl}/about`, {
redirect: "manual",
headers: { "Accept-Language": "ja" },
});
// Japanese not in locales — falls back to default, no redirect
expect(res.status).toBe(200);
});
// --- 404 for unknown locale prefix ---
it("returns 404 for unknown locale prefix", async () => {
const res = await fetch(`${i18nBaseUrl}/es/about`);
// "es" is not in locales — should not match as a locale
// The URL /es/about won't match any page
expect(res.status).toBe(404);
});
});
async function startDomainFixtureServer(
fixtureDir: string,
prefix: string,
): Promise<{
port: number;
server: ViteDevServer;
tmpDir: string;
}> {
const tmpDir = await createIsolatedFixture(fixtureDir, prefix);
const { server } = await startFixtureServer(tmpDir, {
server: {
host: "127.0.0.1",
allowedHosts: ["example.com", "example.fr"],
},
});
const addr = server.httpServer?.address();
if (!addr || typeof addr === "string") {
throw new Error(`Failed to start dev server for fixture ${fixtureDir}`);
}
return { port: addr.port, server, tmpDir };
}
describe("i18n domain routing (Pages Router)", () => {
let domainServer: ViteDevServer;
let domainTmpDir: string;
let domainPort: number;
beforeAll(async () => {
({
server: domainServer,
tmpDir: domainTmpDir,
port: domainPort,
} = await startDomainFixtureServer(PAGES_I18N_DOMAINS_FIXTURE_DIR, "vinext-i18n-domain-"));
}, 30000);
afterAll(async () => {
try {
(domainServer?.httpServer as any)?.closeAllConnections?.();
await Promise.race([
domainServer?.close(),
new Promise((resolve) => setTimeout(resolve, 5000)),
]);
} catch {
/* ignore */
}
const fsp = await import("node:fs/promises");
await fsp.rm(domainTmpDir, { recursive: true, force: true }).catch(() => {});
}, 15000);
it("redirects the root path to the preferred locale domain", async () => {
const res = await requestNodeServerWithHost(domainPort, "/", "example.com", {
"Accept-Language": "fr-FR,fr;q=0.9,en;q=0.8",
});
expect(res.status).toBe(307);
expect(res.headers.location).toBe("http://example.fr/");
});
it("uses Accept-Language rather than NEXT_LOCALE to pick the preferred domain", async () => {
const res = await requestNodeServerWithHost(domainPort, "/", "example.com", {
"Accept-Language": "fr-FR,fr;q=0.9,en;q=0.8",
Cookie: "NEXT_LOCALE=en",
});
expect(res.status).toBe(307);
expect(res.headers.location).toBe("http://example.fr/");
});
it("preserves the search string on root locale redirects", async () => {
const res = await requestNodeServerWithHost(
domainPort,
"/?utm=campaign&next=%2Fcheckout",
"example.com",
{
"Accept-Language": "fr-FR,fr;q=0.9,en;q=0.8",
},
);
expect(res.status).toBe(307);
expect(res.headers.location).toBe("http://example.fr/?utm=campaign&next=%2Fcheckout");
});
it("does not redirect unprefixed non-root paths for locale detection", async () => {
const res = await requestNodeServerWithHost(domainPort, "/about", "example.com", {
"Accept-Language": "fr-FR,fr;q=0.9,en;q=0.8",
});
expect(res.status).toBe(200);
expect(res.headers.location).toBeUndefined();
});
it("renders locale-switcher links with the target locale domain during SSR", async () => {
const res = await requestNodeServerWithHost(domainPort, "/about", "example.com");
expect(res.status).toBe(200);
expect(res.body).toContain('href="http://example.fr/about" id="switch-locale"');
});
it("uses the matched domain default locale in request context and __NEXT_DATA__", async () => {
const res = await requestNodeServerWithHost(domainPort, "/about", "example.fr");
expect(res.status).toBe(200);
expect(res.body).toContain('<p id="locale">fr</p>');
expect(res.body).toContain('<p id="defaultLocale">fr</p>');
expect(res.body).toContain('href="/about" id="switch-locale"');
expect(res.body).toContain('"defaultLocale":"fr"');
expect(res.body).toContain(
'"domainLocales":[{"domain":"example.com","defaultLocale":"en"},{"domain":"example.fr","defaultLocale":"fr","http":true}]',
);
});
it("revalidates with the request domain locale without persisting a dev response", async () => {
const before = await requestNodeServerWithHost(domainPort, "/isr-about", "example.fr");
const beforeGeneration = Number(before.body.match(/<p id="generation">(\d+)<\/p>/)?.[1]);
expect(beforeGeneration).toBeGreaterThan(0);
expect(before.body).toContain('<p id="reason">stale</p>');
const revalidate = await requestNodeServerWithHost(
domainPort,
"/api/revalidate?path=%2Fisr-about&includeState=1",
"example.fr",
);
expect(revalidate.status).toBe(200);
const revalidateResult = JSON.parse(revalidate.body);
expect(revalidateResult).toEqual({
revalidated: true,
state: {
defaultLocale: "fr",
generation: expect.any(Number),
locale: "fr",
reason: "on-demand",
},
});
expect(revalidateResult.state.generation).toBeGreaterThan(beforeGeneration);
const after = await requestNodeServerWithHost(domainPort, "/isr-about", "example.fr");
expect(after.body).toContain('<p id="locale">fr</p>');
expect(after.body).toContain('<p id="defaultLocale">fr</p>');
expect(after.body).toContain('<p id="reason">stale</p>');
expect(Number(after.body.match(/<p id="generation">(\d+)<\/p>/)?.[1])).toBeGreaterThan(
revalidateResult.state.generation,
);
});
it("does not trust or expose a forged logical revalidation hostname", async () => {
const response = await requestNodeServerWithHost(
domainPort,
"/api/revalidation-headers",
"example.com",
{
"x-prerender-revalidate": "not-the-secret",
"x-vinext-revalidate-host": "example.fr",
},
);
expect(JSON.parse(response.body)).toEqual({ host: "example.com", logicalHost: null });
});
it("uses the dynamic route pattern for locale-prefixed data responses in dev", async () => {
const res = await requestNodeServerWithHost(
domainPort,
"/_next/data/test-build-id/fr/posts/first.json",
"example.com",
);
expect(res.status).toBe(200);
expect(res.headers["x-nextjs-matched-path"]).toBe("/fr/posts/[id]");
expect(JSON.parse(res.body)).toEqual({ pageProps: { id: "first" }, __N_SSP: true });
});
it("preserves the locale-prefixed matched path for data misses in dev", async () => {
// Ported from Next.js: test/e2e/middleware-general/test/index.test.ts
// https://github.com/vercel/next.js/blob/v16.2.6/test/e2e/middleware-general/test/index.test.ts
const res = await requestNodeServerWithHost(
domainPort,
"/_next/data/test-build-id/fr/missing.json",
"example.com",
);
expect(res.status).toBe(200);
expect(res.headers["x-nextjs-matched-path"]).toBe("/fr/missing");
expect(JSON.parse(res.body)).toEqual({});
});
it("prefixes the default locale on unprefixed data misses in dev", async () => {
// Ported from Next.js: test/e2e/middleware-general/test/index.test.ts
// https://github.com/vercel/next.js/blob/v16.2.6/test/e2e/middleware-general/test/index.test.ts
const res = await requestNodeServerWithHost(
domainPort,
"/_next/data/test-build-id/missing.json",
"example.com",
);
expect(res.status).toBe(200);
expect(res.headers["x-nextjs-matched-path"]).toBe("/en/missing");
expect(JSON.parse(res.body)).toEqual({});
});
// Issue #1336 item 3: locale prefix must be stripped before API route matching.
//
// Ported from Next.js: test/e2e/middleware-redirects/test/index.test.ts
// (the "should redirect to api route with locale" case, which exercises
// /fr/api/ok hitting pages/api/ok.js)
// https://github.com/vercel/next.js/blob/canary/test/e2e/middleware-redirects/test/index.test.ts
it("matches /api/ok without a locale prefix (dev)", async () => {
const res = await requestNodeServerWithHost(domainPort, "/api/ok", "example.com");
expect(res.status).toBe(200);
expect(res.body).toBe("ok");
});
it("matches /fr/api/ok by stripping the locale prefix in dev (issue #1336)", async () => {
const res = await requestNodeServerWithHost(domainPort, "/fr/api/ok", "example.com");
expect(res.status).toBe(200);
expect(res.body).toBe("ok");
});
it("preserves query parameters when stripping the locale prefix from an API path (dev)", async () => {
const res = await requestNodeServerWithHost(
domainPort,
"/fr/api/ok?foo=bar&baz=qux",
"example.com",
);
expect(res.status).toBe(200);
expect(res.body).toBe("ok");
});
});
describe("i18n domain routing with basePath (Pages Router)", () => {
let domainServer: ViteDevServer;
let domainTmpDir: string;
let domainPort: number;
beforeAll(async () => {
({
server: domainServer,
tmpDir: domainTmpDir,
port: domainPort,
} = await startDomainFixtureServer(
PAGES_I18N_DOMAINS_BASEPATH_FIXTURE_DIR,
"vinext-i18n-domain-basepath-",
));
}, 30000);
afterAll(async () => {
try {
(domainServer?.httpServer as any)?.closeAllConnections?.();
await Promise.race([
domainServer?.close(),
new Promise((resolve) => setTimeout(resolve, 5000)),
]);
} catch {
/* ignore */
}
const fsp = await import("node:fs/promises");
await fsp.rm(domainTmpDir, { recursive: true, force: true }).catch(() => {});
}, 15000);
it("preserves basePath and trailingSlash in root locale redirects", async () => {
const res = await requestNodeServerWithHost(domainPort, "/app/?utm=campaign", "example.com", {
"Accept-Language": "fr-FR,fr;q=0.9,en;q=0.8",
});
expect(res.status).toBe(307);
expect(res.headers.location).toBe("http://example.fr/app/?utm=campaign");
});
it("renders locale-switcher links with basePath on cross-domain hrefs", async () => {
const res = await requestNodeServerWithHost(domainPort, "/app/about/", "example.com");
expect(res.status).toBe(200);
expect(res.body).toContain('href="http://example.fr/app/about" id="switch-locale"');
});
});
// ---------------------------------------------------------------------------
// Link locale prop
// ---------------------------------------------------------------------------
describe("Link locale prop", () => {
let linkLocaleServer: ViteDevServer;
let linkLocaleBaseUrl: string;
let linkLocaleTmpDir: string;
beforeAll(async () => {
const os = await import("node:os");
const fsp = await import("node:fs/promises");
linkLocaleTmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-link-locale-"));
const rootNodeModules = path.resolve(import.meta.dirname, "../node_modules");
await fsp.symlink(rootNodeModules, path.join(linkLocaleTmpDir, "node_modules"), "junction");
await fsp.writeFile(
path.join(linkLocaleTmpDir, "next.config.mjs"),
`export default {
i18n: {
locales: ["en", "fr", "de"],
defaultLocale: "en",
},
};`,
);
await fsp.mkdir(path.join(linkLocaleTmpDir, "pages"), { recursive: true });
// Page with Link components using locale prop
await fsp.writeFile(
path.join(linkLocaleTmpDir, "pages", "index.tsx"),
`import Link from "next/link";
export default function Home() {
return (
<div>
<h1>Link Locale Test</h1>
<Link href="/about" locale="fr" id="link-fr">French About</Link>
<Link href="/about" locale="de" id="link-de">German About</Link>
<Link href="/about" locale="en" id="link-en">English About</Link>
<Link href="/about" id="link-default">Default About</Link>
<Link href="/about" locale={false} id="link-no-locale">No Locale</Link>
</div>
);
}`,
);
await fsp.writeFile(
path.join(linkLocaleTmpDir, "pages", "about.tsx"),
`export default function About() { return <h1>About</h1>; }`,
);
const vinext = (await import("../packages/vinext/src/index.js")).default;
const plugins: any[] = [vinext()];
linkLocaleServer = await createServer({
root: linkLocaleTmpDir,
configFile: false,
plugins,
server: { port: 0 },
logLevel: "silent",
});
await linkLocaleServer.listen();
const addr = linkLocaleServer.httpServer?.address();
if (addr && typeof addr === "object") {
linkLocaleBaseUrl = `http://localhost:${addr.port}`;
}
}, 30000);
afterAll(async () => {
try {
(linkLocaleServer?.httpServer as any)?.closeAllConnections?.();
await Promise.race([
linkLocaleServer?.close(),
new Promise((resolve) => setTimeout(resolve, 5000)),
]);
} catch {
/* ignore */
}
const fsp = await import("node:fs/promises");
await fsp.rm(linkLocaleTmpDir, { recursive: true, force: true }).catch(() => {});
}, 15000);
it("Link with locale='fr' renders href with /fr prefix", async () => {
const res = await fetch(`${linkLocaleBaseUrl}/`);
const html = await res.text();
// React may render attributes in any order — check both
expect(html).toMatch(/href="\/fr\/about"[^>]*id="link-fr"|id="link-fr"[^>]*href="\/fr\/about"/);
});
it("Link with locale='de' renders href with /de prefix", async () => {
const res = await fetch(`${linkLocaleBaseUrl}/`);
const html = await res.text();
expect(html).toMatch(/href="\/de\/about"[^>]*id="link-de"|id="link-de"[^>]*href="\/de\/about"/);
});
it("Link with locale='en' (default) renders href without locale prefix", async () => {
const res = await fetch(`${linkLocaleBaseUrl}/`);
const html = await res.text();
// Default locale should NOT have a prefix in the URL
// The <a> with id="link-en" should have href="/about" (not /en/about)
const linkMatch = html.match(/href="([^"]*)"[^>]*id="link-en"|id="link-en"[^>]*href="([^"]*)"/);
expect(linkMatch).not.toBeNull();
const href = linkMatch![1] || linkMatch![2];
expect(href).toBe("/about");
});
it("Link without locale prop renders href without locale prefix", async () => {
const res = await fetch(`${linkLocaleBaseUrl}/`);
const html = await res.text();
const linkMatch = html.match(
/href="([^"]*)"[^>]*id="link-default"|id="link-default"[^>]*href="([^"]*)"/,
);
expect(linkMatch).not.toBeNull();
const href = linkMatch![1] || linkMatch![2];
expect(href).toBe("/about");
});
it("Link with locale={false} renders href without locale prefix", async () => {
const res = await fetch(`${linkLocaleBaseUrl}/`);
const html = await res.text();
const linkMatch = html.match(
/href="([^"]*)"[^>]*id="link-no-locale"|id="link-no-locale"[^>]*href="([^"]*)"/,
);
expect(linkMatch).not.toBeNull();
const href = linkMatch![1] || linkMatch![2];
expect(href).toBe("/about");
});
});
// ---------------------------------------------------------------------------
// i18n localeDetection: false
// ---------------------------------------------------------------------------
describe("i18n localeDetection: false", () => {
let noDetectServer: ViteDevServer;
let noDetectBaseUrl: string;
let noDetectTmpDir: string;
beforeAll(async () => {
const os = await import("node:os");
const fsp = await import("node:fs/promises");
noDetectTmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-i18n-nodetect-"));
const rootNodeModules = path.resolve(import.meta.dirname, "../node_modules");
await fsp.symlink(rootNodeModules, path.join(noDetectTmpDir, "node_modules"), "junction");
await fsp.writeFile(
path.join(noDetectTmpDir, "next.config.mjs"),
`export default {
i18n: {
locales: ["en", "fr"],
defaultLocale: "en",
localeDetection: false,
},
};`,
);
await fsp.mkdir(path.join(noDetectTmpDir, "pages"), { recursive: true });
await fsp.writeFile(
path.join(noDetectTmpDir, "pages", "index.tsx"),
`export default function Home() { return <h1>Home</h1>; }`,
);
const vinext = (await import("../packages/vinext/src/index.js")).default;
const plugins: any[] = [vinext()];
noDetectServer = await createServer({
root: noDetectTmpDir,
configFile: false,
plugins,
server: { port: 0 },
logLevel: "silent",
});
await noDetectServer.listen();
const addr = noDetectServer.httpServer?.address();
if (addr && typeof addr === "object") {
noDetectBaseUrl = `http://localhost:${addr.port}`;
}
}, 30000);
afterAll(async () => {
try {
(noDetectServer?.httpServer as any)?.closeAllConnections?.();
await Promise.race([
noDetectServer?.close(),
new Promise((resolve) => setTimeout(resolve, 5000)),
]);
} catch {
/* ignore */
}
const fsp = await import("node:fs/promises");
await fsp.rm(noDetectTmpDir, { recursive: true, force: true }).catch(() => {});
}, 15000);
it("does NOT redirect based on Accept-Language when localeDetection is false", async () => {
const res = await fetch(`${noDetectBaseUrl}/`, {
redirect: "manual",
headers: { "Accept-Language": "fr" },
});
// localeDetection: false means no auto-redirect — serve with default locale
expect(res.status).toBe(200);
});
it("still serves locale-prefixed URLs when localeDetection is false", async () => {
const res = await fetch(`${noDetectBaseUrl}/fr/`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("Home");
});
});
describe("basePath support (Pages Router)", () => {
it("resolveNextConfig correctly resolves basePath", async () => {
const { resolveNextConfig } = await import("../packages/vinext/src/config/next-config.js");
// Default: empty basePath
const defaultConfig = await resolveNextConfig({});
expect(defaultConfig.basePath).toBe("");
// With basePath configured
const withBasePath = await resolveNextConfig({ basePath: "/app" });
expect(withBasePath.basePath).toBe("/app");
// basePath must start with / (Next.js requirement)
const withSlash = await resolveNextConfig({ basePath: "/docs" });
expect(withSlash.basePath).toBe("/docs");
});
it("resolveNextConfig correctly resolves trailingSlash", async () => {
const { resolveNextConfig } = await import("../packages/vinext/src/config/next-config.js");
// Default: trailingSlash is false
const defaultConfig = await resolveNextConfig({});
expect(defaultConfig.trailingSlash).toBe(false);
// With trailingSlash: true
const withTrailing = await resolveNextConfig({ trailingSlash: true });
expect(withTrailing.trailingSlash).toBe(true);
});
});
// ---------------------------------------------------------------------------
// build-time defines exposed to client code
describe("build-time defines (Pages Router)", () => {
let server: ViteDevServer;
beforeAll(async () => {
const plugins: any[] = [vinext()];
server = await createServer({
root: PAGES_FIXTURE_DIR,
configFile: false,
plugins,
server: { port: 0 },
logLevel: "silent",
});
await server.listen();
});
afterAll(async () => {
await server?.close();
});
it("basePath define is injected into client code", async () => {
// Default fixture has no basePath, so it should be ""
const defineKey = "process.env.__NEXT_ROUTER_BASEPATH";
expect(server.config.define?.[defineKey]).toBe(JSON.stringify(""));
});
it("App Shells define is always false", async () => {
// Plumbing-only upstream; vinext always reports false so client gating
// code never trips. See issue #1405.
const defineKey = "process.env.__NEXT_APP_SHELLS";
expect(server.config.define?.[defineKey]).toBe(JSON.stringify(false));
});
it("App navigation failure handling defaults to false", () => {
expect(server.config.define?.["process.env.__NEXT_APP_NAV_FAIL_HANDLING"]).toBe(
JSON.stringify(false),
);
});
it("Next.js test mode defaults to false", () => {
expect(server.config.define?.["process.env.__NEXT_TEST_MODE"]).toBe(JSON.stringify(false));
});
});
// ---------------------------------------------------------------------------
// basePath deep testing — HTTP routing integration
describe("basePath HTTP routing (Pages Router)", () => {
let bpServer: ViteDevServer;
let bpBaseUrl: string;
let tmpDir: string;
beforeAll(async () => {
const os = await import("node:os");
const fsp = await import("node:fs/promises");
// Create a temporary fixture directory with basePath configured
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-bp-"));
// Symlink node_modules from project root so React etc. are resolvable
const rootNodeModules = path.resolve(import.meta.dirname, "../node_modules");
await fsp.symlink(rootNodeModules, path.join(tmpDir, "node_modules"), "junction");
// next.config.mjs with basePath
await fsp.writeFile(
path.join(tmpDir, "next.config.mjs"),
`/** @type {import('next').NextConfig} */
export default {
basePath: "/app",
async redirects() {
return [
{
source: "/redir",
destination: "/application/about",
permanent: false,
},
{
source: "/redir-external",
destination: "https://example.com/page",
permanent: false,
},
];
},
};
`,
);
// pages directory
await fsp.mkdir(path.join(tmpDir, "pages"), { recursive: true });
// index page
await fsp.writeFile(
path.join(tmpDir, "pages", "index.tsx"),
`import Link from "next/link";
export default function Home() {
return (
<div>
<h1>BasePath Home</h1>
<Link href="/about">Go to About</Link>
</div>
);
}
`,
);
// about page
await fsp.writeFile(
path.join(tmpDir, "pages", "about.tsx"),
`export default function About() {
return <h1>BasePath About</h1>;
}
`,
);
// Collision route used to verify that shared string prefixes outside the
// basePath do not get stripped and misrouted into a valid page.
await fsp.mkdir(path.join(tmpDir, "pages", "lication"), { recursive: true });
await fsp.writeFile(
path.join(tmpDir, "pages", "lication", "about.tsx"),
`export default function Collision() {
return <h1>Collision Route</h1>;
}
`,
);
// API route
await fsp.mkdir(path.join(tmpDir, "pages", "api"), { recursive: true });
await fsp.writeFile(
path.join(tmpDir, "pages", "api", "hello.ts"),
`export default function handler(req, res) {
res.json({ message: "hello from basePath" });
}
`,
);
// Start server
const plugins: any[] = [vinext()];
bpServer = await createServer({
root: tmpDir,
configFile: false,
plugins,
server: { port: 0 },
logLevel: "silent",
});
await bpServer.listen();
const addr = bpServer.httpServer?.address();
if (addr && typeof addr === "object") {
bpBaseUrl = `http://localhost:${addr.port}`;
}
}, 30000);
afterAll(async () => {
try {
(bpServer?.httpServer as any)?.closeAllConnections?.();
await Promise.race([bpServer?.close(), new Promise((resolve) => setTimeout(resolve, 5000))]);
} catch {
/* ignore close errors */
}
const fsp = await import("node:fs/promises");
await fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
}, 15000);
it("Vite base is set to basePath + /", () => {
expect(bpServer.config.base).toBe("/app/");
});
it("process.env.__NEXT_ROUTER_BASEPATH define is /app", () => {
const define = bpServer.config.define?.["process.env.__NEXT_ROUTER_BASEPATH"];
expect(define).toBe(JSON.stringify("/app"));
});
it("GET /app/ serves the index page", async () => {
const res = await fetch(`${bpBaseUrl}/app/`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("BasePath Home");
});
it("GET /app (without trailing slash) returns 404 — Vite base requires trailing slash", async () => {
// With Vite's base set to /app/, a bare /app request doesn't match the
// base and Vite returns 404. This matches how Vite handles base paths.
// Users would typically configure a reverse proxy or Vite's server.origin
// to redirect /app → /app/ in production.
const res = await fetch(`${bpBaseUrl}/app`, { redirect: "manual" });
expect(res.status).toBe(404);
});
it("GET /app/about serves the about page", async () => {
const res = await fetch(`${bpBaseUrl}/app/about`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("BasePath About");
});
it("GET /about (without basePath) does NOT serve pages", async () => {
const res = await fetch(`${bpBaseUrl}/about`);
// Without basePath prefix, the Pages Router middleware should not match.
// The response should be a 404 or Vite's default fallback.
expect(res.status).not.toBe(200);
});
it("does not strip shared string prefixes outside basePath", async () => {
const valid = await fetch(`${bpBaseUrl}/app/lication/about`);
expect(valid.status).toBe(200);
expect(await valid.text()).toContain("Collision Route");
const outside = await fetch(`${bpBaseUrl}/application/about`);
expect(outside.status).not.toBe(200);
expect(await outside.text()).not.toContain("Collision Route");
});
it("GET /app/api/hello serves the API route", async () => {
const res = await fetch(`${bpBaseUrl}/app/api/hello`);
expect(res.status).toBe(200);
const data = await res.json();
expect(data.message).toBe("hello from basePath");
});
it("redirects prefix destinations under the real basePath boundary", async () => {
const res = await fetch(`${bpBaseUrl}/app/redir`, { redirect: "manual" });
expect(res.status).toBe(307);
expect(res.headers.get("location")).toBe("/app/application/about");
});
it("does not prepend basePath to external redirect destinations", async () => {
const res = await fetch(`${bpBaseUrl}/app/redir-external`, {
redirect: "manual",
});
expect(res.status).toBe(307);
expect(res.headers.get("location")).toBe("https://example.com/page");
});
it("GET /api/hello (without basePath) does NOT serve API routes", async () => {
const res = await fetch(`${bpBaseUrl}/api/hello`);
expect(res.status).not.toBe(200);
});
it("Link component renders href with basePath prefix in SSR", async () => {
const res = await fetch(`${bpBaseUrl}/app/`);
const html = await res.text();
// The Link component should render <a href="/app/about">
expect(html).toContain('href="/app/about"');
});
});
// ---------------------------------------------------------------------------
// basePath with nested path (e.g. /docs/v2)
describe("basePath with nested path (/docs/v2)", () => {
let nestedServer: ViteDevServer;
let nestedBaseUrl: string;
let tmpDir: string;
beforeAll(async () => {
const os = await import("node:os");
const fsp = await import("node:fs/promises");
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-bp-nested-"));
// Symlink node_modules from project root
const rootNodeModules = path.resolve(import.meta.dirname, "../node_modules");
await fsp.symlink(rootNodeModules, path.join(tmpDir, "node_modules"), "junction");
await fsp.writeFile(
path.join(tmpDir, "next.config.mjs"),
`export default { basePath: "/docs/v2" };`,
);
await fsp.mkdir(path.join(tmpDir, "pages"), { recursive: true });
await fsp.writeFile(
path.join(tmpDir, "pages", "index.tsx"),
`export default function Home() {
return <h1>Nested BasePath Home</h1>;
}`,
);
await fsp.writeFile(
path.join(tmpDir, "pages", "guide.tsx"),
`export default function Guide() {
return <h1>Nested BasePath Guide</h1>;
}`,
);
const plugins: any[] = [vinext()];
nestedServer = await createServer({
root: tmpDir,
configFile: false,
plugins,
server: { port: 0 },
logLevel: "silent",
});
await nestedServer.listen();
const addr = nestedServer.httpServer?.address();
if (addr && typeof addr === "object") {
nestedBaseUrl = `http://localhost:${addr.port}`;
}
}, 30000);
afterAll(async () => {
// Force close the server (httpServer.close can hang if connections are still open)
try {
(nestedServer?.httpServer as any)?.closeAllConnections?.();
await Promise.race([
nestedServer?.close(),
new Promise((resolve) => setTimeout(resolve, 5000)),
]);
} catch {
/* ignore close errors */
}
const fsp = await import("node:fs/promises");
await fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
}, 15000);
it("Vite base is set to /docs/v2/", () => {
expect(nestedServer.config.base).toBe("/docs/v2/");
});
it("GET /docs/v2/ serves the index page", async () => {
const res = await fetch(`${nestedBaseUrl}/docs/v2/`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("Nested BasePath Home");
});
it("GET /docs/v2/guide serves the guide page", async () => {
const res = await fetch(`${nestedBaseUrl}/docs/v2/guide`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("Nested BasePath Guide");
});
it("GET /docs/ (partial basePath) does NOT serve pages", async () => {
const res = await fetch(`${nestedBaseUrl}/docs/`);
expect(res.status).not.toBe(200);
});
it("GET /guide (without basePath) does NOT serve pages", async () => {
const res = await fetch(`${nestedBaseUrl}/guide`);
expect(res.status).not.toBe(200);
});
});
// ---------------------------------------------------------------------------
// basePath config validation
describe("basePath + trailingSlash interaction", () => {
let tsServer: ViteDevServer;
let tsBaseUrl: string;
let tmpDir: string;
beforeAll(async () => {
const os = await import("node:os");
const fsp = await import("node:fs/promises");
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-bp-ts-"));
// Symlink node_modules from project root
const rootNodeModules = path.resolve(import.meta.dirname, "../node_modules");
await fsp.symlink(rootNodeModules, path.join(tmpDir, "node_modules"), "junction");
await fsp.writeFile(
path.join(tmpDir, "next.config.mjs"),
`export default { basePath: "/app", trailingSlash: true };`,
);
await fsp.mkdir(path.join(tmpDir, "pages"), { recursive: true });
await fsp.writeFile(
path.join(tmpDir, "pages", "index.tsx"),
`export default function Home() {
return <h1>TrailingSlash Home</h1>;
}`,
);
await fsp.writeFile(
path.join(tmpDir, "pages", "about.tsx"),
`export default function About() {
return <h1>TrailingSlash About</h1>;
}`,
);
await fsp.mkdir(path.join(tmpDir, "pages", "catch-all"), { recursive: true });
await fsp.writeFile(
path.join(tmpDir, "pages", "catch-all", "[...slug].tsx"),
`export default function CatchAll() {
return <h1>TrailingSlash CatchAll</h1>;
}`,
);
const plugins: any[] = [vinext()];
tsServer = await createServer({
root: tmpDir,
configFile: false,
plugins,
server: { port: 0 },
logLevel: "silent",
});
await tsServer.listen();
const addr = tsServer.httpServer?.address();
if (addr && typeof addr === "object") {
tsBaseUrl = `http://localhost:${addr.port}`;
}
}, 30000);
afterAll(async () => {
try {
(tsServer?.httpServer as any)?.closeAllConnections?.();
await Promise.race([tsServer?.close(), new Promise((resolve) => setTimeout(resolve, 5000))]);
} catch {
/* ignore close errors */
}
const fsp = await import("node:fs/promises");
await fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
}, 15000);
it("GET /app/about redirects to /app/about/ with trailingSlash:true", async () => {
const res = await fetch(`${tsBaseUrl}/app/about`, { redirect: "manual" });
expect(res.status).toBe(308);
const location = res.headers.get("location");
expect(location).toBe("/app/about/");
});
it("GET /app/about/ serves the about page with trailingSlash:true", async () => {
const res = await fetch(`${tsBaseUrl}/app/about/`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("TrailingSlash About");
});
it("GET /app/catch-all/hello.world/ redirects to the file-looking canonical path", async () => {
const res = await fetch(`${tsBaseUrl}/app/catch-all/hello.world/`, {
redirect: "manual",
});
expect(res.status).toBe(308);
expect(res.headers.get("location")).toBe("/app/catch-all/hello.world");
});
});
describe("metadata title templates", () => {
let mergeMetadata: typeof import("../packages/vinext/src/shims/metadata.js").mergeMetadata;
let mergeMetadataEntries: typeof import("../packages/vinext/src/shims/metadata.js").mergeMetadataEntries;
beforeAll(async () => {
const mod = await import("../packages/vinext/src/shims/metadata.js");
mergeMetadata = mod.mergeMetadata;
mergeMetadataEntries = mod.mergeMetadataEntries;
});
it("applies layout template to child page string title", () => {
const result = mergeMetadata([
{ title: { template: "%s | My Site", default: "My Site" } },
{ title: "About" },
]);
expect(result.title).toBe("About | My Site");
});
it("uses layout default title when page has no title", () => {
const result = mergeMetadata([
{ title: { template: "%s | My Site", default: "My Site" } },
{ description: "No title here" },
]);
expect(result.title).toBe("My Site");
});
it("applies ancestor title template to child layout default title", () => {
// Next.js resolveTitle() applies the stashed ancestor template to title.default:
// https://github.com/vercel/next.js/blob/canary/packages/next/src/lib/metadata/resolvers/resolve-title.ts
const result = mergeMetadataEntries([
{
metadata: { title: { template: "%s | Site", default: "Site" } },
},
{
metadata: { title: { default: "Blog" } },
},
]);
expect(result.title).toBe("Blog | Site");
});
it("title.absolute skips all templates", () => {
const result = mergeMetadata([
{ title: { template: "%s | My Site", default: "My Site" } },
{ title: { absolute: "Custom Title" } },
]);
expect(result.title).toBe("Custom Title");
});
it("nearest layout template wins over root", () => {
const result = mergeMetadata([
{ title: { template: "%s | Root", default: "Root" } },
{ title: { template: "%s - Blog", default: "Blog" } },
{ title: "Hello World" },
]);
expect(result.title).toBe("Hello World - Blog");
});
it("applies ancestor template to page default while ignoring page template", () => {
const result = mergeMetadata([
{ title: { template: "%s | Site", default: "Site" } },
{ title: { template: "%s - Page Template", default: "Page Default" } },
]);
expect(result.title).toBe("Page Default | Site");
});
it("does not apply a page template to the page's own default title", () => {
const result = mergeMetadata([{ title: { template: "%s | Page", default: "Page" } }]);
expect(result.title).toBe("Page");
});
it("preserves non-title metadata during merge", () => {
const result = mergeMetadata([
{ title: { template: "%s | Site", default: "Site" }, description: "Root desc" },
{ title: "About", keywords: ["about"] },
]);
expect(result.title).toBe("About | Site");
expect(result.description).toBe("Root desc");
expect(result.keywords).toEqual(["about"]);
});
it("later entries override earlier for non-title fields", () => {
const result = mergeMetadata([
{ description: "From layout", openGraph: { title: "OG Layout" } },
{ description: "From page" },
]);
expect(result.description).toBe("From page");
// openGraph from layout should be inherited if page doesn't override it.
// Next.js postProcessMetadata also fills openGraph.description from
// metadata.description when absent.
expect(result.openGraph).toEqual({ title: "OG Layout", description: "From page" });
});
it("simple string title without template passes through", () => {
const result = mergeMetadata([{ title: "My Page" }]);
expect(result.title).toBe("My Page");
});
it("handles empty metadata list", () => {
const result = mergeMetadata([]);
expect(result).toEqual({});
});
it("uses explicit page markers instead of assuming the last entry is the page", () => {
const result = mergeMetadataEntries([
{
metadata: { title: { default: "Root", template: "%s | Root" } },
},
{
isPage: true,
metadata: { description: "Page", title: { default: "Page", template: "%s | Page" } },
},
{
contributesTitle: false,
metadata: {
openGraph: { title: "Slot OG title" },
title: { default: "Slot", template: "%s | Slot" },
},
},
]);
// mergeMetadataEntries does raw segment merging only; post-processing is
// applied separately by postProcessMetadata (called by mergeMetadata and by
// resolveAppPageHead after file-based metadata is applied).
expect(result).toEqual({
description: "Page",
openGraph: { title: "Slot OG title" },
title: "Page | Root",
});
});
});
// ---------------------------------------------------------------------------
// Metadata segment merge tests
// Ported from Next.js behavior:
// - docs: https://nextjs.org/docs/app/api-reference/functions/generate-metadata#merging
// - source: packages/next/src/lib/metadata/resolve-metadata.ts
// https://github.com/vercel/next.js/blob/canary/packages/next/src/lib/metadata/resolve-metadata.ts
describe("metadata segment merge", () => {
let mergeMetadata: typeof import("../packages/vinext/src/shims/metadata.js").mergeMetadata;
beforeAll(async () => {
const mod = await import("../packages/vinext/src/shims/metadata.js");
mergeMetadata = mod.mergeMetadata;
});
it("replaces openGraph instead of deep-merging subkeys", () => {
const result = mergeMetadata([
{ openGraph: { siteName: "My Site", images: ["/og-root.png"], locale: "en-US" } },
{ openGraph: { title: "Child Page" } },
]);
expect(result.openGraph).toEqual({
title: "Child Page",
});
expect(result.twitter?.images).toBeUndefined();
});
it("replaces twitter instead of deep-merging subkeys", () => {
const result = mergeMetadata([
{ twitter: { card: "summary", site: "@site" } },
{ twitter: { title: "Tweet Title" } },
]);
expect(result.twitter).toEqual({
title: "Tweet Title",
card: "summary",
});
});
it("replaces alternates instead of deep-merging subkeys", () => {
const result = mergeMetadata([
{ alternates: { canonical: "https://example.com", languages: { "en-US": "/en" } } },
{ alternates: { media: { print: "/print" } } },
]);
expect(result.alternates).toEqual({
media: { print: "/print" },
});
});
it("replaces robots instead of deep-merging subkeys", () => {
const result = mergeMetadata([
{ robots: { index: true, follow: true, googleBot: { index: true } } },
{ robots: { follow: false } },
]);
expect(result.robots).toEqual({
follow: false,
});
});
it("replaces robots string with object outright", () => {
const result = mergeMetadata([{ robots: "index, follow" }, { robots: { index: false } }]);
expect(result.robots).toEqual({ index: false });
});
it("replaces icons instead of deep-merging map objects", () => {
const result = mergeMetadata([
{ icons: { icon: "/favicon.ico", apple: "/apple.png" } },
{ icons: { shortcut: "/shortcut.png" } },
]);
expect(result.icons).toEqual({
shortcut: "/shortcut.png",
});
});
it("replaces shorthand icon string with map object", () => {
const result = mergeMetadata([{ icons: "/favicon.ico" }, { icons: { apple: "/apple.png" } }]);
expect(result.icons).toEqual({ apple: "/apple.png" });
});
it("merges other custom meta tags", () => {
const result = mergeMetadata([
{ other: { foo: "bar", baz: "qux" } },
{ other: { baz: "override", new: "value" } },
]);
expect(result.other).toEqual({
foo: "bar",
baz: "override",
new: "value",
});
});
it("preserves root openGraph when page does not override it", () => {
const result = mergeMetadata([
{ openGraph: { title: "OG Layout", siteName: "Site" } },
{ keywords: ["page"] },
]);
expect(result.openGraph).toEqual({ title: "OG Layout", siteName: "Site" });
});
it("inherits openGraph.description from metadata.description when missing", () => {
const result = mergeMetadata([
{ openGraph: { title: "OG Layout" } },
{ description: "Page desc" },
]);
expect(result.openGraph).toEqual({
title: "OG Layout",
description: "Page desc",
});
});
it("child openGraph replaces the whole parent openGraph object", () => {
const result = mergeMetadata([
{ openGraph: { title: "Root", images: ["/og.png"] } },
{ openGraph: { images: undefined } },
]);
expect(result.openGraph?.title).toBeUndefined();
expect(result.openGraph?.images).toBeUndefined();
expect(result.twitter?.images).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// Metadata OG/Twitter inheritance tests
// Ported from Next.js behavior: packages/next/src/lib/metadata/resolve-metadata.ts
// https://github.com/vercel/next.js/blob/canary/packages/next/src/lib/metadata/resolve-metadata.ts
describe("metadata OG/Twitter inheritance", () => {
let mergeMetadata: typeof import("../packages/vinext/src/shims/metadata.js").mergeMetadata;
let MetadataHead: typeof import("../packages/vinext/src/shims/metadata.js").MetadataHead;
let React: typeof import("react");
let renderToStaticMarkup: typeof import("react-dom/server").renderToStaticMarkup;
beforeAll(async () => {
const mod = await import("../packages/vinext/src/shims/metadata.js");
mergeMetadata = mod.mergeMetadata;
MetadataHead = mod.MetadataHead;
React = await import("react");
renderToStaticMarkup = (await import("react-dom/server")).renderToStaticMarkup;
});
it("auto-fills twitter:title from openGraph:title", () => {
const result = mergeMetadata([{ openGraph: { title: "OG Title" } }]);
expect(result.twitter?.title).toBe("OG Title");
});
it("auto-fills twitter:description from openGraph:description", () => {
const result = mergeMetadata([{ openGraph: { description: "OG Desc" } }]);
expect(result.twitter?.description).toBe("OG Desc");
});
it("auto-fills twitter:images from openGraph:images", () => {
const result = mergeMetadata([{ openGraph: { images: ["/og.png"] } }]);
expect(result.twitter?.images).toEqual(["/og.png"]);
});
it("auto-fills twitter:title from metadata.title when openGraph.title is absent", () => {
const result = mergeMetadata([{ title: "Page Title", openGraph: {} }]);
expect(result.twitter?.title).toBe("Page Title");
});
it("auto-fills twitter:description from metadata.description when openGraph.description is absent", () => {
const result = mergeMetadata([{ description: "Page Desc", openGraph: {} }]);
expect(result.twitter?.description).toBe("Page Desc");
});
it("does not create twitter fields from metadata when openGraph is absent entirely", () => {
const result = mergeMetadata([{ title: "Page Title", description: "Page Desc" }]);
expect(result.twitter).toBeUndefined();
});
it("fills existing twitter title and description from metadata when openGraph is absent", () => {
const result = mergeMetadata([{ title: "Page Title", description: "Page Desc", twitter: {} }]);
expect(result.twitter?.title).toBe("Page Title");
expect(result.twitter?.description).toBe("Page Desc");
expect(result.twitter?.card).toBe("summary");
});
it("does not overwrite explicitly set twitter fields", () => {
const result = mergeMetadata([
{ openGraph: { title: "OG Title", description: "OG Desc", images: ["/og.png"] } },
{ twitter: { title: "Custom Twitter Title", description: "Custom Twitter Desc" } },
]);
expect(result.twitter?.title).toBe("Custom Twitter Title");
expect(result.twitter?.description).toBe("Custom Twitter Desc");
});
it("does not auto-fill twitter:images when twitter.images is explicitly set to empty array", () => {
const result = mergeMetadata([
{ openGraph: { images: ["/og.png"] } },
{ twitter: { images: [] } },
]);
expect(result.twitter?.images).toEqual([]);
});
it("auto-fills openGraph:title from metadata.title", () => {
const result = mergeMetadata([{ title: "Page Title", openGraph: {} }]);
expect(result.openGraph?.title).toBe("Page Title");
});
it("auto-fills openGraph:description from metadata.description", () => {
const result = mergeMetadata([{ description: "Page Desc", openGraph: {} }]);
expect(result.openGraph?.description).toBe("Page Desc");
});
it("renders twitter card tags when only openGraph is configured", () => {
const metadata = mergeMetadata([
{
openGraph: {
title: "My custom title",
description: "My custom description",
url: "https://example.com",
siteName: "My custom site name",
images: [{ url: "https://example.com/image.png", width: 800, height: 600 }],
locale: "en-US",
type: "website",
},
},
]);
const html = renderToStaticMarkup(React.createElement(MetadataHead, { metadata }));
expect(html).toContain('name="twitter:card"');
expect(html).toContain('content="summary_large_image"');
expect(html).toContain('name="twitter:title"');
expect(html).toContain('content="My custom title"');
expect(html).toContain('name="twitter:description"');
expect(html).toContain('content="My custom description"');
expect(html).toContain('name="twitter:image"');
expect(html).toContain('content="https://example.com/image.png"');
});
it("renders twitter:card summary when no images are present", () => {
const metadata = mergeMetadata([{ openGraph: { title: "No Images" } }]);
const html = renderToStaticMarkup(React.createElement(MetadataHead, { metadata }));
expect(html).toContain('name="twitter:card"');
expect(html).toContain('content="summary"');
});
});
// ---------------------------------------------------------------------------
// MetadataHead rendering tests
describe("MetadataHead rendering", () => {
let MetadataHead: typeof import("../packages/vinext/src/shims/metadata.js").MetadataHead;
let renderMetadataToHtml: typeof import("../packages/vinext/src/shims/metadata.js").renderMetadataToHtml;
let React: typeof import("react");
let renderToStaticMarkup: typeof import("react-dom/server").renderToStaticMarkup;
beforeAll(async () => {
const mod = await import("../packages/vinext/src/shims/metadata.js");
MetadataHead = mod.MetadataHead;
renderMetadataToHtml = mod.renderMetadataToHtml;
React = await import("react");
renderToStaticMarkup = (await import("react-dom/server")).renderToStaticMarkup;
});
function metadataRouteImage(url: string): { url: string } {
const image = { url };
Object.defineProperty(image, "metadataRoute", { value: true });
return image;
}
function renderStaticSocialImagesMetadata(metadataBase: URL | null): string {
return renderToStaticMarkup(
React.createElement(MetadataHead, {
metadata: {
metadataBase,
openGraph: {
images: [metadataRouteImage("/metadata-base/unset/opengraph-image2/100")],
},
twitter: {
images: metadataRouteImage("/metadata-base/unset/twitter-image.png"),
},
},
}),
);
}
it("renders generator meta tag", () => {
const html = renderToStaticMarkup(
React.createElement(MetadataHead, { metadata: { generator: "Next.js" } }),
);
expect(html).toContain('name="generator"');
expect(html).toContain('content="Next.js"');
});
it("renders application-name meta tag", () => {
const html = renderToStaticMarkup(
React.createElement(MetadataHead, { metadata: { applicationName: "My App" } }),
);
expect(html).toContain('name="application-name"');
expect(html).toContain('content="My App"');
});
it("renders author meta and link tags", () => {
const html = renderToStaticMarkup(
React.createElement(MetadataHead, {
metadata: {
authors: [{ name: "Seb" }, { name: "Josh", url: "https://josh.dev" }],
},
}),
);
expect(html).toContain('name="author"');
expect(html).toContain('content="Seb"');
expect(html).toContain('rel="author"');
expect(html).toContain('href="https://josh.dev"');
});
it("renders format-detection meta tag", () => {
const html = renderToStaticMarkup(
React.createElement(MetadataHead, {
metadata: { formatDetection: { telephone: false, email: false } },
}),
);
expect(html).toContain('name="format-detection"');
expect(html).toContain("telephone=no");
expect(html).toContain("email=no");
});
it("renders googlebot meta tag separately from robots", () => {
const html = renderToStaticMarkup(
React.createElement(MetadataHead, {
metadata: {
robots: {
index: true,
follow: true,
googleBot: { index: true, follow: true, "max-snippet": -1 },
},
},
}),
);
expect(html).toContain('name="robots"');
expect(html).toContain('name="googlebot"');
expect(html).toContain("max-snippet:-1");
});
it("renders verification meta tags", () => {
const html = renderToStaticMarkup(
React.createElement(MetadataHead, {
metadata: {
verification: { google: "abc123", yandex: "xyz789" },
},
}),
);
expect(html).toContain('name="google-site-verification"');
expect(html).toContain('content="abc123"');
expect(html).toContain('name="yandex-verification"');
expect(html).toContain('content="xyz789"');
});
it("renders icon link tags from icons metadata", () => {
const html = renderToStaticMarkup(
React.createElement(MetadataHead, {
metadata: {
icons: {
icon: "/favicon.ico",
apple: [{ url: "/apple-icon.png", sizes: "180x180" }],
shortcut: "/shortcut.png",
},
},
}),
);
expect(html).toContain('rel="icon"');
expect(html).toContain('href="/favicon.ico"');
expect(html).toContain('rel="apple-touch-icon"');
expect(html).toContain('sizes="180x180"');
expect(html).toContain('rel="shortcut icon"');
});
// Regression for #1492: Next.js accepts icons.other as a single descriptor
// or an array; vinext previously iterated it directly and crashed when
// given a non-array. Matches resolveAsArrayOrUndefined in
// .nextjs-ref/packages/next/src/lib/metadata/resolvers/resolve-icons.ts.
it("renders icons.other as a single descriptor (not just an array)", () => {
const html = renderToStaticMarkup(
React.createElement(MetadataHead, {
metadata: {
icons: {
other: {
rel: "apple-touch-icon-precomposed",
url: "/apple-touch-icon-precomposed.png",
},
},
},
}),
);
expect(html).toContain('rel="apple-touch-icon-precomposed"');
expect(html).toContain('href="/apple-touch-icon-precomposed.png"');
});
it("renders icons.other as an array of descriptors", () => {
const html = renderToStaticMarkup(
React.createElement(MetadataHead, {
metadata: {
icons: {
other: [
{ rel: "mask-icon", url: "/mask.svg", type: "image/svg+xml" },
{ rel: "apple-touch-icon-precomposed", url: "/apple.png" },
],
},
},
}),
);
expect(html).toContain('rel="mask-icon"');
expect(html).toContain('href="/mask.svg"');
expect(html).toContain('type="image/svg+xml"');
expect(html).toContain('rel="apple-touch-icon-precomposed"');
expect(html).toContain('href="/apple.png"');
});
it("serializes rich metadata for the streaming body outlet", () => {
const html = renderMetadataToHtml(
{
metadataBase: new URL("https://example.com"),
title: "Generated <Title>",
description: 'Description with "quotes"',
alternates: {
canonical: "/products",
languages: { "en-US": "/products/en" },
},
openGraph: {
images: [{ url: "/og.png", width: 1200, height: 630, type: "image/png" }],
},
icons: {
icon: [
{
url: "/icon.png",
sizes: "32x32",
type: "image/png",
media: "(prefers-color-scheme: dark)",
},
],
},
},
"/products",
);
expect(html).toContain("<title>Generated &lt;Title&gt;</title>");
expect(html).toContain('name="description"');
expect(html).toContain('content="Description with &quot;quotes&quot;"');
expect(html).toContain('rel="canonical"');
expect(html).toContain('href="https://example.com/products"');
expect(html).toContain('property="og:image"');
expect(html).toContain('content="https://example.com/og.png"');
expect(html).toContain('property="og:image:width"');
expect(html).toContain('content="1200"');
expect(html).toContain('property="og:image:height"');
expect(html).toContain('content="630"');
expect(html).toContain('property="og:image:type"');
expect(html).toContain('content="image/png"');
expect(html).toContain('rel="alternate"');
expect(html).toContain('hreflang="en-US"');
expect(html).toContain('href="https://example.com/products/en"');
expect(html).toContain('rel="icon"');
expect(html).toContain('href="/icon.png"');
expect(html).toContain('sizes="32x32"');
expect(html).toContain('type="image/png"');
expect(html).toContain('media="(prefers-color-scheme: dark)"');
});
it("does not serialize event handlers from icon descriptors", () => {
const html = renderMetadataToHtml(
{
icons: {
icon: {
url: "/icon.png",
onLoad: "alert('unsafe')",
} as never,
},
},
"/products",
{ streamedIconKey: "metadata-icons" },
);
expect(html).toBe(
'<link data-vinext-streamed-icon="metadata-icons:0" rel="icon" href="/icon.png">',
);
expect(html).not.toContain("onLoad");
expect(html).not.toContain("onload");
});
it("renders single descriptor icon objects", () => {
const html = renderToStaticMarkup(
React.createElement(MetadataHead, {
metadata: {
icons: {
apple: { url: "/apple-icon.png", sizes: "180x180", type: "image/png" },
icon: { url: "/icon.png", sizes: "96x96", type: "image/png" },
},
},
}),
);
expect(html).toContain('rel="icon"');
expect(html).toContain('href="/icon.png"');
expect(html).toContain('sizes="96x96"');
expect(html).toContain('rel="apple-touch-icon"');
expect(html).toContain('href="/apple-icon.png"');
expect(html).toContain('sizes="180x180"');
});
it("renders top-level icon shorthand metadata", () => {
const html = renderToStaticMarkup(
React.createElement(MetadataHead, {
metadata: { icons: "/manual-icon.png" },
}),
);
expect(html).toContain('rel="icon"');
expect(html).toContain('href="/manual-icon.png"');
});
it("keeps icon metadata hrefs relative when metadataBase is configured", () => {
// Ported from Next.js: test/e2e/app-dir/metadata-dynamic-routes/index.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/metadata-dynamic-routes/index.test.ts
const html = renderToStaticMarkup(
React.createElement(MetadataHead, {
metadata: {
metadataBase: new URL("https://mydomain.com"),
icons: {
icon: [{ url: "/dynamic/big/icon-ahg52g/small", sizes: "48x48", type: "image/png" }],
apple: [{ url: "/dynamic/big/apple-icon-ahg52g/0", sizes: "48x48", type: "image/png" }],
},
},
}),
);
expect(html).toContain('href="/dynamic/big/icon-ahg52g/small"');
expect(html).toContain('href="/dynamic/big/apple-icon-ahg52g/0"');
expect(html).not.toContain("https://mydomain.com/dynamic/big");
});
it("renders alternate hreflang links", () => {
const html = renderToStaticMarkup(
React.createElement(MetadataHead, {
metadata: {
alternates: {
canonical: "https://example.com",
languages: { "en-US": "https://example.com/en", "de-DE": "https://example.com/de" },
},
},
}),
);
expect(html).toContain('rel="canonical"');
expect(html).toContain('href="https://example.com"');
// React renders hrefLang in camelCase (hrefLang or hreflang depending on version)
expect(html).toMatch(/hreflang="en-US"|hrefLang="en-US"/i);
expect(html).toMatch(/hreflang="de-DE"|hrefLang="de-DE"/i);
});
it("renders alternate RSS feed link", () => {
const html = renderToStaticMarkup(
React.createElement(MetadataHead, {
metadata: {
alternates: {
types: { "application/rss+xml": "https://example.com/rss" },
},
},
}),
);
expect(html).toContain('type="application/rss+xml"');
expect(html).toContain('href="https://example.com/rss"');
});
it("renders twitter:site and twitter:creator:id", () => {
const html = renderToStaticMarkup(
React.createElement(MetadataHead, {
metadata: {
twitter: {
card: "summary",
site: "@nextjs",
siteId: "12345",
creatorId: "67890",
},
},
}),
);
expect(html).toContain('name="twitter:site"');
expect(html).toContain('content="@nextjs"');
expect(html).toContain('name="twitter:site:id"');
expect(html).toContain('content="12345"');
expect(html).toContain('name="twitter:creator:id"');
});
it("resolves relative URLs with metadataBase", () => {
const html = renderToStaticMarkup(
React.createElement(MetadataHead, {
metadata: {
metadataBase: new URL("https://acme.com"),
alternates: { canonical: "/about" },
openGraph: { images: "/og.png" },
},
}),
);
expect(html).toContain('href="https://acme.com/about"');
expect(html).toContain('content="https://acme.com/og.png"');
});
it("normalizes root canonical metadataBase URLs without a trailing slash", () => {
// Ported from Next.js: test/e2e/app-dir/metadata-dynamic-routes/index.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/metadata-dynamic-routes/index.test.ts
const html = renderToStaticMarkup(
React.createElement(MetadataHead, {
metadata: {
metadataBase: new URL("https://mydomain.com"),
alternates: { canonical: "./" },
},
pathname: "/",
}),
);
expect(html).toContain('rel="canonical"');
expect(html).toContain('href="https://mydomain.com"');
});
it("keeps manifest metadata routes relative when metadataBase is configured", () => {
const html = renderToStaticMarkup(
React.createElement(MetadataHead, {
metadata: {
metadataBase: new URL("https://mydomain.com"),
manifest: "/manifest.webmanifest",
},
}),
);
expect(html).toContain('rel="manifest"');
expect(html).toContain('href="/manifest.webmanifest"');
});
it("uses social image fallback metadataBase for static metadata route images", () => {
withEnvVar("NODE_ENV", "production", () => {
withEnvVar("PORT", "4567", () => {
withEnvVar("VERCEL_PROJECT_PRODUCTION_URL", undefined, () => {
const html = renderStaticSocialImagesMetadata(null);
expect(html).toContain(
'content="http://localhost:4567/metadata-base/unset/opengraph-image2/100"',
);
expect(html).toContain(
'content="http://localhost:4567/metadata-base/unset/twitter-image.png"',
);
});
});
});
});
it("lets configured metadataBase win over non-preview deployment urls for static metadata route images", () => {
withEnvVar("NODE_ENV", "production", () => {
withEnvVar("VERCEL_URL", "my-deployment.vercel.app", () => {
withEnvVar("VERCEL_ENV", "production", () => {
const html = renderStaticSocialImagesMetadata(new URL("https://mydomain.com"));
expect(html).toContain(
'content="https://mydomain.com/metadata-base/unset/opengraph-image2/100"',
);
expect(html).not.toContain("my-deployment.vercel.app");
});
});
});
});
it("lets configured metadataBase win over deployment urls when VERCEL_ENV is unset", () => {
withEnvVar("NODE_ENV", "production", () => {
withEnvVar("VERCEL_URL", "my-deployment.vercel.app", () => {
withEnvVar("VERCEL_ENV", undefined, () => {
const html = renderStaticSocialImagesMetadata(new URL("https://mydomain.com"));
expect(html).toContain(
'content="https://mydomain.com/metadata-base/unset/opengraph-image2/100"',
);
expect(html).not.toContain("my-deployment.vercel.app");
});
});
});
});
it("uses preview deployment urls for static metadata route images in Vercel preview", () => {
withEnvVar("NODE_ENV", "production", () => {
withEnvVar("VERCEL_BRANCH_URL", "branch-preview.vercel.app", () => {
withEnvVar("VERCEL_ENV", "preview", () => {
const html = renderStaticSocialImagesMetadata(new URL("https://mydomain.com"));
expect(html).toContain(
'content="https://branch-preview.vercel.app/metadata-base/unset/opengraph-image2/100"',
);
expect(html).not.toContain("https://mydomain.com/metadata-base/unset");
});
});
});
});
it("uses production deployment urls as fallback when metadataBase is unset", () => {
withEnvVar("NODE_ENV", "production", () => {
withEnvVar("VERCEL_PROJECT_PRODUCTION_URL", "project-production.vercel.app", () => {
withEnvVar("VERCEL_URL", "my-deployment.vercel.app", () => {
withEnvVar("VERCEL_ENV", "production", () => {
const html = renderStaticSocialImagesMetadata(null);
expect(html).toContain(
'content="https://project-production.vercel.app/metadata-base/unset/opengraph-image2/100"',
);
expect(html).not.toContain("my-deployment.vercel.app");
});
});
});
});
});
it("accepts URL objects for canonical and openGraph.url", () => {
// Next.js allows string | URL for URL fields; passing a URL object must not throw
const html = renderToStaticMarkup(
React.createElement(MetadataHead, {
metadata: {
alternates: { canonical: new URL("https://example.com/page") },
openGraph: { url: new URL("https://example.com/og") },
},
}),
);
expect(html).toContain('href="https://example.com/page"');
expect(html).toContain('content="https://example.com/og"');
});
it("renders OG video and audio tags", () => {
const html = renderToStaticMarkup(
React.createElement(MetadataHead, {
metadata: {
openGraph: {
videos: [{ url: "https://example.com/video.mp4", width: 800, height: 600 }],
audio: [{ url: "https://example.com/audio.mp3" }],
},
},
}),
);
expect(html).toContain('property="og:video"');
expect(html).toContain('content="https://example.com/video.mp4"');
expect(html).toContain('property="og:video:width"');
expect(html).toContain('property="og:audio"');
});
it("renders manifest link", () => {
const html = renderToStaticMarkup(
React.createElement(MetadataHead, {
metadata: { manifest: "/manifest.json" },
}),
);
expect(html).toContain('rel="manifest"');
expect(html).toContain('href="/manifest.json"');
});
it("renders other meta with array values", () => {
const html = renderToStaticMarkup(
React.createElement(MetadataHead, {
metadata: { other: { custom: ["val1", "val2"] } },
}),
);
expect(html).toContain('content="val1"');
expect(html).toContain('content="val2"');
});
// trailingSlash canonical URL tests
// Ported from Next.js app-dir trailing-slash e2e: "should contain trailing slash to canonical url"
// see https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/trailingslash/trailingslash.test.ts#L31
it("trailingSlash:true appends slash to root canonical '/'", () => {
// metadataBase='http://trailingslash.com', canonical='/', pathname='/'
// → href='http://trailingslash.com/'
const html = renderMetadataToHtml(
{
metadataBase: new URL("http://trailingslash.com"),
alternates: { canonical: "/" },
},
"/",
{ trailingSlash: true },
);
expect(html).toContain('href="http://trailingslash.com/"');
});
it("trailingSlash:true appends slash to relative canonical './' at pathname '/a'", () => {
// metadataBase='http://trailingslash.com', canonical='./', pathname='/a'
// → href='http://trailingslash.com/a/'
const html = renderMetadataToHtml(
{
metadataBase: new URL("http://trailingslash.com"),
alternates: { canonical: "./" },
},
"/a",
{ trailingSlash: true },
);
expect(html).toContain('href="http://trailingslash.com/a/"');
});
it("trailingSlash:true does not append slash before query string", () => {
// canonical='/q?x=1' → href='http://trailingslash.com/q?x=1' (no slash before '?')
const html = renderMetadataToHtml(
{
metadataBase: new URL("http://trailingslash.com"),
alternates: { canonical: "/q?x=1" },
},
"/q",
{ trailingSlash: true },
);
expect(html).toContain('href="http://trailingslash.com/q?x=1"');
expect(html).not.toContain('href="http://trailingslash.com/q/?x=1"');
});
it("trailingSlash:true leaves external canonical unchanged", () => {
// canonical='http://other.com/a' → href='http://other.com/a' (external host)
const html = renderMetadataToHtml(
{
metadataBase: new URL("http://trailingslash.com"),
alternates: { canonical: "http://other.com/a" },
},
"/a",
{ trailingSlash: true },
);
expect(html).toContain('href="http://other.com/a"');
expect(html).not.toContain('href="http://other.com/a/"');
});
it("trailingSlash:false preserves a user-provided trailing slash on canonical", () => {
// Match Next.js: trailingSlash:false (the default) does NOT strip a
// user-provided slash; canonical='/a/' renders verbatim.
const html = renderMetadataToHtml(
{
metadataBase: new URL("http://trailingslash.com"),
alternates: { canonical: "/a/" },
},
"/a",
{ trailingSlash: false },
);
expect(html).toContain('href="http://trailingslash.com/a/"');
});
it("trailingSlash:true is idempotent when canonical already ends with slash", () => {
// canonical='http://trailingslash.com/a/' → href='http://trailingslash.com/a/' (unchanged)
const html = renderMetadataToHtml(
{
metadataBase: new URL("http://trailingslash.com"),
alternates: { canonical: "http://trailingslash.com/a/" },
},
"/a",
{ trailingSlash: true },
);
expect(html).toContain('href="http://trailingslash.com/a/"');
});
it("trailingSlash:true appends slash to same-origin absolute canonical urls", () => {
const html = renderMetadataToHtml(
{
metadataBase: new URL("http://trailingslash.com"),
alternates: { canonical: "http://trailingslash.com/a" },
},
"/a",
{ trailingSlash: true },
);
expect(html).toContain('href="http://trailingslash.com/a/"');
});
it("trailingSlash:true does not append slash to file-like canonical urls", () => {
// canonical='/sitemap.xml' → href='http://trailingslash.com/sitemap.xml'
const html = renderMetadataToHtml(
{
metadataBase: new URL("http://trailingslash.com"),
alternates: { canonical: "/sitemap.xml" },
},
"/",
{ trailingSlash: true },
);
expect(html).toContain('href="http://trailingslash.com/sitemap.xml"');
expect(html).not.toContain("sitemap.xml/");
});
it("trailingSlash:true appends slash to .well-known canonical urls", () => {
// Ported from Next.js: packages/next/src/lib/metadata/resolvers/resolve-url.test.ts
// https://github.com/vercel/next.js/blob/canary/packages/next/src/lib/metadata/resolvers/resolve-url.test.ts
// .well-known is excluded from the file regex, so it is treated as a normal path.
const html = renderMetadataToHtml(
{
metadataBase: new URL("http://trailingslash.com"),
alternates: { canonical: "/.well-known/apple-app-site-association" },
},
"/",
{ trailingSlash: true },
);
expect(html).toContain(
'href="http://trailingslash.com/.well-known/apple-app-site-association/"',
);
expect(html).not.toContain('"http://trailingslash.com/.well-known/apple-app-site-association"');
});
it("trailingSlash:true appends slash to openGraph.url and alternate urls", () => {
// Ported from the resolver call sites in Next.js:
// packages/next/src/lib/metadata/resolvers/resolve-opengraph.ts and resolve-basics.ts
const html = renderMetadataToHtml(
{
metadataBase: new URL("http://trailingslash.com"),
openGraph: { url: "/og" },
alternates: {
languages: { en: "/en" },
media: { print: "/print" },
types: { "application/rss+xml": "/feed" },
},
},
"/",
{ trailingSlash: true },
);
expect(html).toContain('property="og:url" content="http://trailingslash.com/og/"');
expect(html).toContain('href="http://trailingslash.com/en/" hreflang="en"');
expect(html).toContain('href="http://trailingslash.com/print/" media="print"');
expect(html).toContain('href="http://trailingslash.com/feed/" type="application/rss+xml"');
});
it("resolves URL-object alternates as bases for the current pathname", () => {
// Ported from Next.js: packages/next/src/lib/metadata/resolvers/resolve-basics.ts
// URL-object alternates are bases for pathname resolution and retain their query params.
const html = renderMetadataToHtml(
{
metadataBase: new URL("http://trailingslash.com"),
alternates: {
languages: { en: new URL("http://trailingslash.com/base") },
media: { print: new URL("http://trailingslash.com/base?source=print") },
},
},
"/docs",
{ trailingSlash: true },
);
expect(html).toContain('href="http://trailingslash.com/docs/" hreflang="en"');
expect(html).toContain('href="http://trailingslash.com/docs?source=print" media="print"');
});
});
describe("createAppPageRouteBodyMetadata (body-placement canonical)", () => {
let createAppPageRouteBodyMetadata: typeof import("../packages/vinext/src/server/app-page-route-wiring.js").createAppPageRouteBodyMetadata;
let _React: typeof import("react");
let renderToStaticMarkup: typeof import("react-dom/server").renderToStaticMarkup;
beforeAll(async () => {
({ createAppPageRouteBodyMetadata } =
await import("../packages/vinext/src/server/app-page-route-wiring.js"));
_React = await import("react");
({ renderToStaticMarkup } = await import("react-dom/server"));
});
it("body placement: applies trailingSlash to canonical href in the streamed body branch", () => {
const node = createAppPageRouteBodyMetadata(
{
metadataBase: new URL("http://trailingslash.com"),
alternates: { canonical: "/a" },
},
"/a",
"body",
true, // trailingSlash
);
const html = renderToStaticMarkup(node as React.ReactElement);
// Body branch wraps the metadata HTML in a hidden div (htmlLimitedBots
// / streamed-body path used when metadata can't be flushed into <head>
// before the first shell chunk).
expect(html).toContain("<div hidden");
expect(html).toContain('href="http://trailingslash.com/a/"');
});
it("body placement: no slash inserted before query string", () => {
const node = createAppPageRouteBodyMetadata(
{
metadataBase: new URL("http://trailingslash.com"),
alternates: { canonical: "/q?x=1" },
},
"/q",
"body",
true,
);
const html = renderToStaticMarkup(node as React.ReactElement);
expect(html).toContain('href="http://trailingslash.com/q?x=1"');
expect(html).not.toContain("/q/?");
});
it("body placement: returns null when placement is 'head' (no double-render)", () => {
const node = createAppPageRouteBodyMetadata(
{ metadataBase: new URL("http://trailingslash.com"), alternates: { canonical: "/a" } },
"/a",
"head",
true,
);
expect(node).toBeNull();
});
it("body placement: returns null when metadata is null", () => {
expect(createAppPageRouteBodyMetadata(null, "/a", "body", true)).toBeNull();
});
it("body placement: re-inserts streamed icons into the document head", () => {
const node = createAppPageRouteBodyMetadata(
{
icons: {
icon: "/favicon.ico",
apple: "/apple-touch-icon.png",
shortcut: "/shortcut.png",
other: [
{ rel: "apple-touch-icon-precomposed", url: "/precomposed.png" },
{ rel: "mask-icon", url: "/mask.svg" },
],
},
},
"/icons",
"body",
);
const html = renderToStaticMarkup(node as React.ReactElement);
expect(html).toMatch(
/<link data-vinext-streamed-icon="[^"]+" rel="icon" href="\/favicon\.ico">/,
);
expect(html).toMatch(
/<link data-vinext-streamed-icon="[^"]+" rel="apple-touch-icon" href="\/apple-touch-icon\.png">/,
);
expect(html).toMatch(
/<link data-vinext-streamed-icon="[^"]+" rel="shortcut icon" href="\/shortcut\.png">/,
);
expect(html).toMatch(
/<link data-vinext-streamed-icon="[^"]+" rel="apple-touch-icon-precomposed" href="\/precomposed\.png">/,
);
expect(html).toMatch(
/<link data-vinext-streamed-icon="[^"]+" rel="mask-icon" href="\/mask\.svg">/,
);
expect(html).toContain(
`document.querySelectorAll('body link[rel="icon"], body link[rel="apple-touch-icon"]').forEach(el => document.head.appendChild(el))`,
);
expect(html).toContain(`const a='data-vinext-streamed-icon'`);
expect(html).toContain(`.sort((l,r)=>o(l)-o(r)).forEach(el=>document.head.appendChild(el))`);
expect(html).toMatch(/<script>document\.querySelectorAll[\s\S]*<\/script><\/div>$/);
});
it("body placement: serializes icon-bearing metadata once", () => {
let titleReads = 0;
const title = 'data-vinext-streamed-icon="vinext-pending-streamed-icon-key:0';
const node = createAppPageRouteBodyMetadata(
{
get title() {
titleReads += 1;
return title;
},
icons: { icon: "/favicon.ico" },
},
'/icons" data-injected="true',
"body",
);
const html = renderToStaticMarkup(node as React.ReactElement);
// MetadataHead reads a string title twice per serialization: once for the
// type check and once for the value. A second serialization would read it
// four times.
expect(titleReads).toBe(2);
expect(html).toContain(`<title>${title}</title>`);
expect(html).not.toContain(' data-injected="true"');
expect(html).toContain("/icons&quot; data-injected=&quot;true:");
});
it("body placement: includes icon cleanup reconciliation without streamed icons", () => {
const node = createAppPageRouteBodyMetadata({ title: "Streamed title" }, "/metadata", "body");
const html = renderToStaticMarkup(node as React.ReactElement);
expect(html).toContain("document.querySelectorAll('body link");
});
it("body placement: applies the request nonce to the parser-time icon script", () => {
const node = createAppPageRouteBodyMetadata(
{ icons: { icon: "/favicon.ico" } },
"/icons",
"body",
false,
"test-nonce",
);
const html = renderToStaticMarkup(node as React.ReactElement);
expect(html).toMatch(/<script nonce="test-nonce">[\s\S]*<\/script><\/div>$/);
});
});
// ---------------------------------------------------------------------------
// ViewportHead rendering tests
describe("ViewportHead rendering", () => {
let ViewportHead: typeof import("../packages/vinext/src/shims/metadata.js").ViewportHead;
let mergeViewport: typeof import("../packages/vinext/src/shims/metadata.js").mergeViewport;
let React: typeof import("react");
let renderToStaticMarkup: typeof import("react-dom/server").renderToStaticMarkup;
beforeAll(async () => {
const mod = await import("../packages/vinext/src/shims/metadata.js");
ViewportHead = mod.ViewportHead;
mergeViewport = mod.mergeViewport;
React = await import("react");
renderToStaticMarkup = (await import("react-dom/server")).renderToStaticMarkup;
});
it("renders default viewport with width=device-width and initial-scale=1", () => {
const html = renderToStaticMarkup(
React.createElement(ViewportHead, {
viewport: mergeViewport([{ width: "device-width", initialScale: 1 }]),
}),
);
expect(html).toContain('name="viewport"');
expect(html).toContain("width=device-width");
expect(html).toContain("initial-scale=1");
});
it("renders custom viewport with all options", () => {
// Ported from Next.js: packages/next/src/lib/metadata/resolve-metadata.test.ts
// https://github.com/vercel/next.js/blob/canary/packages/next/src/lib/metadata/resolve-metadata.test.ts
const html = renderToStaticMarkup(
React.createElement(ViewportHead, {
viewport: mergeViewport([
{
width: "device-width",
height: "device-height",
initialScale: 1,
minimumScale: 0.5,
maximumScale: 1,
userScalable: false,
viewportFit: "cover",
interactiveWidget: "overlays-content",
},
]),
}),
);
expect(html).toContain(
'content="width=device-width, height=device-height, initial-scale=1, minimum-scale=0.5, maximum-scale=1, user-scalable=no, viewport-fit=cover, interactive-widget=overlays-content"',
);
});
it("renders theme-color meta tag", () => {
const html = renderToStaticMarkup(
React.createElement(ViewportHead, {
viewport: mergeViewport([{ themeColor: "#000000" }]),
}),
);
expect(html).toContain('name="theme-color"');
expect(html).toContain('content="#000000"');
});
it("renders a single theme-color descriptor with its media query", () => {
const html = renderToStaticMarkup(
React.createElement(ViewportHead, {
viewport: mergeViewport([
{
themeColor: { media: "(prefers-color-scheme: dark)", color: "#000000" },
},
]),
}),
);
expect(html).toContain('name="theme-color"');
expect(html).toContain('content="#000000"');
expect(html).toContain('media="(prefers-color-scheme: dark)"');
});
it("renders multiple theme-color entries with media queries", () => {
const html = renderToStaticMarkup(
React.createElement(ViewportHead, {
viewport: mergeViewport([
{
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "#fff" },
{ media: "(prefers-color-scheme: dark)", color: "#000" },
],
},
]),
}),
);
expect(html).toContain('content="#fff"');
expect(html).toContain('content="#000"');
expect(html).toContain("prefers-color-scheme: light");
expect(html).toContain("prefers-color-scheme: dark");
});
it("renders color-scheme meta tag", () => {
const html = renderToStaticMarkup(
React.createElement(ViewportHead, {
viewport: mergeViewport([{ colorScheme: "dark" }]),
}),
);
expect(html).toContain('name="color-scheme"');
expect(html).toContain('content="dark"');
});
// mergeViewport default injection tests
it("mergeViewport includes default width and initialScale when not provided", () => {
const result = mergeViewport([{ themeColor: "#000" }]);
expect(result.width).toBe("device-width");
expect(result.initialScale).toBe(1);
expect(result.themeColor).toEqual([{ color: "#000" }]);
});
it("mergeViewport allows overriding defaults", () => {
const result = mergeViewport([{ width: 1024, initialScale: 0.5 }]);
expect(result.width).toBe(1024);
expect(result.initialScale).toBe(0.5);
});
it("mergeViewport returns defaults for empty list", () => {
const result = mergeViewport([]);
expect(result.width).toBe("device-width");
expect(result.initialScale).toBe(1);
});
it("mergeViewport later entries override earlier ones including defaults", () => {
const result = mergeViewport([{ width: 800 }, { width: 1024, themeColor: "#fff" }]);
expect(result.width).toBe(1024);
expect(result.initialScale).toBe(1);
expect(result.themeColor).toEqual([{ color: "#fff" }]);
});
it("preserves an undefined media field on a theme-color descriptor", () => {
// Next.js's resolveThemeColor copies the descriptor fields explicitly:
// https://github.com/vercel/next.js/blob/canary/packages/next/src/lib/metadata/resolvers/resolve-basics.ts
const result = mergeViewport([{ themeColor: { color: "#000" } }]);
expect(result.themeColor).toEqual([{ color: "#000", media: undefined }]);
});
it("renders viewport meta even when only themeColor is provided (defaults injected)", () => {
const merged = mergeViewport([{ themeColor: "#000" }]);
const html = renderToStaticMarkup(React.createElement(ViewportHead, { viewport: merged }));
expect(html).toContain('name="viewport"');
expect(html).toContain("width=device-width");
expect(html).toContain("initial-scale=1");
expect(html).toContain('name="theme-color"');
});
});
describe("fetch cache (extended fetch with next options)", () => {
// We need a mock server for fetch to hit
let mockServerUrl: string;
let mockServer: any;
let fetchCallCount: number;
beforeAll(async () => {
const http = await import("node:http");
fetchCallCount = 0;
mockServer = http.createServer((_req, res) => {
fetchCallCount++;
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ count: fetchCallCount, timestamp: Date.now() }));
});
await new Promise<void>((resolve) => {
mockServer.listen(0, "127.0.0.1", () => resolve());
});
const addr = mockServer.address();
mockServerUrl = `http://127.0.0.1:${addr.port}`;
});
afterAll(async () => {
await new Promise<void>((resolve) => {
mockServer.close(() => resolve());
});
});
it("exports withFetchCache, runWithFetchCache, getOriginalFetch, getCollectedFetchTags", async () => {
const mod = await import("../packages/vinext/src/shims/fetch-cache.js");
expect(typeof mod.withFetchCache).toBe("function");
expect(typeof mod.runWithFetchCache).toBe("function");
expect(typeof mod.getOriginalFetch).toBe("function");
expect(typeof mod.getCollectedFetchTags).toBe("function");
});
it("passes through fetch without next options to persistent cache but dedupes within a render", async () => {
const { withFetchCache } = await import("../packages/vinext/src/shims/fetch-cache.js");
fetchCallCount = 0;
let cleanup = withFetchCache();
try {
const resp1 = await fetch(`${mockServerUrl}/plain`);
const data1 = await resp1.json();
expect(data1.count).toBe(1);
const resp2 = await fetch(`${mockServerUrl}/plain`);
const data2 = await resp2.json();
expect(data2.count).toBe(1); // Same render fetch is deduped
cleanup();
cleanup = withFetchCache();
const resp3 = await fetch(`${mockServerUrl}/plain`);
const data3 = await resp3.json();
expect(data3.count).toBe(2); // New render still bypasses persistent cache
} finally {
cleanup();
}
});
it("caches fetch with { next: { revalidate } }", async () => {
const { withFetchCache } = await import("../packages/vinext/src/shims/fetch-cache.js");
const { setCacheHandler, MemoryCacheHandler } =
await import("../packages/vinext/src/shims/cache.js");
// Fresh cache handler for isolation
setCacheHandler(new MemoryCacheHandler());
fetchCallCount = 0;
const cleanup = withFetchCache();
try {
const resp1 = await fetch(`${mockServerUrl}/cached`, {
next: { revalidate: 60 },
});
const data1 = await resp1.json();
expect(data1.count).toBe(1);
expect(fetchCallCount).toBe(1);
// Second fetch should come from cache
const resp2 = await fetch(`${mockServerUrl}/cached`, {
next: { revalidate: 60 },
});
const data2 = await resp2.json();
expect(data2.count).toBe(1); // Same data!
expect(fetchCallCount).toBe(1); // No additional network call
} finally {
cleanup();
setCacheHandler(new MemoryCacheHandler());
}
});
it("serves stale fetch cache entry and triggers background revalidation", async () => {
const { withFetchCache } = await import("../packages/vinext/src/shims/fetch-cache.js");
const { setCacheHandler, MemoryCacheHandler } =
await import("../packages/vinext/src/shims/cache.js");
// Fresh cache handler for isolation
setCacheHandler(new MemoryCacheHandler());
fetchCallCount = 0;
// Make the staleness deterministic without sleeping.
const originalNow = Date.now;
let now = originalNow();
Date.now = () => now;
const cleanup = withFetchCache();
try {
const resp1 = await fetch(`${mockServerUrl}/swr`, {
next: { revalidate: 1 },
});
const data1 = await resp1.json();
expect(data1.count).toBe(1);
expect(fetchCallCount).toBe(1);
// Advance time past the revalidate window so the entry becomes stale.
now += 2000;
// Second fetch returns stale cached value but triggers background refetch.
const resp2 = await fetch(`${mockServerUrl}/swr`, {
next: { revalidate: 1 },
});
const data2 = await resp2.json();
expect(data2.count).toBe(1);
// Wait for the background revalidation fetch to hit the network.
await new Promise<void>((resolve, reject) => {
const start = originalNow();
const tick = () => {
if (fetchCallCount >= 2) return resolve();
if (originalNow() - start > 1000) {
return reject(new Error("timed out waiting for background revalidation"));
}
setTimeout(tick, 10);
};
tick();
});
// Third fetch should read the refreshed cache value.
const resp3 = await fetch(`${mockServerUrl}/swr`, {
next: { revalidate: 1 },
});
const data3 = await resp3.json();
expect(data3.count).toBe(2);
expect(fetchCallCount).toBe(2);
} finally {
cleanup();
setCacheHandler(new MemoryCacheHandler());
Date.now = originalNow;
}
});
it("cache: 'force-cache' caches indefinitely", async () => {
const { withFetchCache } = await import("../packages/vinext/src/shims/fetch-cache.js");
const { setCacheHandler, MemoryCacheHandler } =
await import("../packages/vinext/src/shims/cache.js");
setCacheHandler(new MemoryCacheHandler());
fetchCallCount = 0;
const cleanup = withFetchCache();
try {
const resp1 = await fetch(`${mockServerUrl}/force`, {
cache: "force-cache",
});
const data1 = await resp1.json();
expect(data1.count).toBe(1);
// Second fetch should be cached
const resp2 = await fetch(`${mockServerUrl}/force`, {
cache: "force-cache",
});
const data2 = await resp2.json();
expect(data2.count).toBe(1);
expect(fetchCallCount).toBe(1);
} finally {
cleanup();
setCacheHandler(new MemoryCacheHandler());
}
});
it("cache: 'no-store' bypasses persistent cache but dedupes within a render", async () => {
const { withFetchCache } = await import("../packages/vinext/src/shims/fetch-cache.js");
const { setCacheHandler, MemoryCacheHandler } =
await import("../packages/vinext/src/shims/cache.js");
setCacheHandler(new MemoryCacheHandler());
fetchCallCount = 0;
let cleanup = withFetchCache();
try {
const resp1 = await fetch(`${mockServerUrl}/no-store`, {
cache: "no-store",
});
const data1 = await resp1.json();
expect(data1.count).toBe(1);
const resp2 = await fetch(`${mockServerUrl}/no-store`, {
cache: "no-store",
});
const data2 = await resp2.json();
expect(data2.count).toBe(1); // Same render fetch is deduped
cleanup();
cleanup = withFetchCache();
const resp3 = await fetch(`${mockServerUrl}/no-store`, {
cache: "no-store",
});
const data3 = await resp3.json();
expect(data3.count).toBe(2); // New render still bypasses persistent cache
} finally {
cleanup();
setCacheHandler(new MemoryCacheHandler());
}
});
it("next.revalidate: false caches indefinitely across render scopes", async () => {
const { withFetchCache } = await import("../packages/vinext/src/shims/fetch-cache.js");
const { setCacheHandler, MemoryCacheHandler } =
await import("../packages/vinext/src/shims/cache.js");
setCacheHandler(new MemoryCacheHandler());
fetchCallCount = 0;
let cleanup = withFetchCache();
try {
await fetch(`${mockServerUrl}/no-rev`, { next: { revalidate: false } });
await fetch(`${mockServerUrl}/no-rev`, { next: { revalidate: false } });
expect(fetchCallCount).toBe(1);
cleanup();
cleanup = withFetchCache();
await fetch(`${mockServerUrl}/no-rev`, { next: { revalidate: false } });
expect(fetchCallCount).toBe(1); // Still cached across render scopes
} finally {
cleanup();
setCacheHandler(new MemoryCacheHandler());
}
});
it("next.revalidate: 0 bypasses persistent cache but dedupes within a render", async () => {
const { withFetchCache } = await import("../packages/vinext/src/shims/fetch-cache.js");
const { setCacheHandler, MemoryCacheHandler } =
await import("../packages/vinext/src/shims/cache.js");
setCacheHandler(new MemoryCacheHandler());
fetchCallCount = 0;
let cleanup = withFetchCache();
try {
await fetch(`${mockServerUrl}/zero`, { next: { revalidate: 0 } });
await fetch(`${mockServerUrl}/zero`, { next: { revalidate: 0 } });
expect(fetchCallCount).toBe(1);
cleanup();
cleanup = withFetchCache();
await fetch(`${mockServerUrl}/zero`, { next: { revalidate: 0 } });
expect(fetchCallCount).toBe(2);
} finally {
cleanup();
setCacheHandler(new MemoryCacheHandler());
}
});
it("revalidateTag invalidates fetch cache entries", async () => {
const { withFetchCache } = await import("../packages/vinext/src/shims/fetch-cache.js");
const { setCacheHandler, MemoryCacheHandler, revalidateTag } =
await import("../packages/vinext/src/shims/cache.js");
setCacheHandler(new MemoryCacheHandler());
fetchCallCount = 0;
let cleanup = withFetchCache();
try {
// First fetch — cache miss, hits network
const resp1 = await fetch(`${mockServerUrl}/tagged`, {
next: { revalidate: 3600, tags: ["posts"] },
});
const data1 = await resp1.json();
expect(data1.count).toBe(1);
expect(fetchCallCount).toBe(1);
// Second fetch — cache hit
const resp2 = await fetch(`${mockServerUrl}/tagged`, {
next: { revalidate: 3600, tags: ["posts"] },
});
const data2 = await resp2.json();
expect(data2.count).toBe(1);
expect(fetchCallCount).toBe(1);
// Invalidate the tag
await Promise.resolve(revalidateTag("posts"));
cleanup();
cleanup = withFetchCache();
// Third fetch — cache miss after tag invalidation in a later render
const resp3 = await fetch(`${mockServerUrl}/tagged`, {
next: { revalidate: 3600, tags: ["posts"] },
});
const data3 = await resp3.json();
expect(data3.count).toBe(2); // New data from network
expect(fetchCallCount).toBe(2);
} finally {
cleanup();
setCacheHandler(new MemoryCacheHandler());
}
});
it("collects fetch tags during render pass", async () => {
const { withFetchCache, getCollectedFetchTags } =
await import("../packages/vinext/src/shims/fetch-cache.js");
const { setCacheHandler, MemoryCacheHandler } =
await import("../packages/vinext/src/shims/cache.js");
setCacheHandler(new MemoryCacheHandler());
const cleanup = withFetchCache();
try {
await fetch(`${mockServerUrl}/a`, { next: { revalidate: 60, tags: ["tag-a", "tag-b"] } });
await fetch(`${mockServerUrl}/b`, { next: { revalidate: 60, tags: ["tag-b", "tag-c"] } });
const tags = getCollectedFetchTags();
expect(tags).toContain("tag-a");
expect(tags).toContain("tag-b");
expect(tags).toContain("tag-c");
expect(tags.length).toBe(3); // Deduplicated
} finally {
cleanup();
setCacheHandler(new MemoryCacheHandler());
}
});
it("different URLs produce separate cache entries", async () => {
const { withFetchCache } = await import("../packages/vinext/src/shims/fetch-cache.js");
const { setCacheHandler, MemoryCacheHandler } =
await import("../packages/vinext/src/shims/cache.js");
setCacheHandler(new MemoryCacheHandler());
fetchCallCount = 0;
const cleanup = withFetchCache();
try {
await fetch(`${mockServerUrl}/url-a`, { next: { revalidate: 60 } });
await fetch(`${mockServerUrl}/url-b`, { next: { revalidate: 60 } });
expect(fetchCallCount).toBe(2); // Two different URLs = two network calls
// Now re-fetch both — should be cached
await fetch(`${mockServerUrl}/url-a`, { next: { revalidate: 60 } });
await fetch(`${mockServerUrl}/url-b`, { next: { revalidate: 60 } });
expect(fetchCallCount).toBe(2); // Still 2, both from cache
} finally {
cleanup();
setCacheHandler(new MemoryCacheHandler());
}
});
it("fetch with only tags (no revalidate) caches indefinitely", async () => {
const { withFetchCache } = await import("../packages/vinext/src/shims/fetch-cache.js");
const { setCacheHandler, MemoryCacheHandler } =
await import("../packages/vinext/src/shims/cache.js");
setCacheHandler(new MemoryCacheHandler());
fetchCallCount = 0;
const cleanup = withFetchCache();
try {
await fetch(`${mockServerUrl}/tags-only`, { next: { tags: ["items"] } });
await fetch(`${mockServerUrl}/tags-only`, { next: { tags: ["items"] } });
expect(fetchCallCount).toBe(1); // Cached because tags imply force-cache
} finally {
cleanup();
setCacheHandler(new MemoryCacheHandler());
}
});
it("cleanup clears per-request tag state", async () => {
const { withFetchCache, getCollectedFetchTags } =
await import("../packages/vinext/src/shims/fetch-cache.js");
const { setCacheHandler, MemoryCacheHandler } =
await import("../packages/vinext/src/shims/cache.js");
setCacheHandler(new MemoryCacheHandler());
const cleanup = withFetchCache();
await fetch(`${mockServerUrl}/cleanup-tag-test`, { next: { tags: ["cleanup-t"] } });
expect(getCollectedFetchTags()).toContain("cleanup-t");
cleanup();
expect(getCollectedFetchTags()).toHaveLength(0);
});
it("runWithFetchCache isolates tags and returns result", async () => {
const { runWithFetchCache, getCollectedFetchTags } =
await import("../packages/vinext/src/shims/fetch-cache.js");
const { setCacheHandler, MemoryCacheHandler } =
await import("../packages/vinext/src/shims/cache.js");
setCacheHandler(new MemoryCacheHandler());
fetchCallCount = 0;
const result = await runWithFetchCache(async () => {
const resp = await fetch(`${mockServerUrl}/auto`, { next: { revalidate: 60 } });
return resp.json();
});
expect(result.count).toBe(fetchCallCount);
// Tags should be empty outside the runWithFetchCache scope
expect(getCollectedFetchTags()).toHaveLength(0);
setCacheHandler(new MemoryCacheHandler());
});
it("strips next property before passing to real fetch", async () => {
const { withFetchCache } = await import("../packages/vinext/src/shims/fetch-cache.js");
const { setCacheHandler, MemoryCacheHandler } =
await import("../packages/vinext/src/shims/cache.js");
setCacheHandler(new MemoryCacheHandler());
// This test verifies the real fetch doesn't receive the `next` property
// which would cause warnings in some environments. If it throws, the test fails.
const cleanup = withFetchCache();
try {
const resp = await fetch(`${mockServerUrl}/strip`, {
next: { revalidate: 60, tags: ["test"] },
method: "GET",
});
expect(resp.ok).toBe(true);
} finally {
cleanup();
setCacheHandler(new MemoryCacheHandler());
}
});
});
// ---------------------------------------------------------------------------
// instrumentation.ts support
// ---------------------------------------------------------------------------
describe("instrumentation.ts support", () => {
it("exports findInstrumentationFile", async () => {
const mod = await import("../packages/vinext/src/server/instrumentation.js");
expect(typeof mod.findInstrumentationFile).toBe("function");
});
it("findInstrumentationFile returns null when no file exists", async () => {
const { findInstrumentationFile } =
await import("../packages/vinext/src/server/instrumentation.js");
const result = findInstrumentationFile("/nonexistent/path", createValidFileMatcher());
expect(result).toBeNull();
});
it("findInstrumentationFile detects instrumentation.ts", async () => {
const { findInstrumentationFile } =
await import("../packages/vinext/src/server/instrumentation.js");
const os = await import("node:os");
const fs = await import("node:fs");
const path = await import("node:path");
// Create a temp directory with an instrumentation.ts file. Production
// always passes a forward-slash root (the config hook normalizes it), so
// mirror that — findInstrumentationFile returns forward-slash paths.
const tmpDir = toSlash(fs.mkdtempSync(path.join(os.tmpdir(), "vinext-inst-")));
fs.writeFileSync(
path.join(tmpDir, "instrumentation.ts"),
'export function register() { console.log("registered"); }',
);
const result = findInstrumentationFile(tmpDir, createValidFileMatcher());
expect(result).toBe(path.posix.join(tmpDir, "instrumentation.ts"));
// Cleanup
fs.rmSync(tmpDir, { recursive: true });
});
it("findInstrumentationFile detects src/instrumentation.ts", async () => {
const { findInstrumentationFile } =
await import("../packages/vinext/src/server/instrumentation.js");
const os = await import("node:os");
const fs = await import("node:fs");
const path = await import("node:path");
const tmpDir = toSlash(fs.mkdtempSync(path.join(os.tmpdir(), "vinext-inst-")));
fs.mkdirSync(path.join(tmpDir, "src"));
fs.writeFileSync(
path.join(tmpDir, "src", "instrumentation.ts"),
"export function register() {}",
);
const result = findInstrumentationFile(tmpDir, createValidFileMatcher());
expect(result).toBe(path.posix.join(tmpDir, "src", "instrumentation.ts"));
fs.rmSync(tmpDir, { recursive: true });
});
it("runInstrumentation calls register() and stores onRequestError", async () => {
const { runInstrumentation, getOnRequestErrorHandler } =
await import("../packages/vinext/src/server/instrumentation.js");
let registerCalled = false;
const mockOnRequestError = (_error: any, _request: any, _context: any) => {};
// Create a mock ModuleRunner
const mockRunner = {
import: async (_id: string) => ({
register: () => {
registerCalled = true;
},
onRequestError: mockOnRequestError,
}),
};
await runInstrumentation(mockRunner, "/fake/instrumentation.ts");
expect(registerCalled).toBe(true);
expect(getOnRequestErrorHandler()).toBe(mockOnRequestError);
});
it("reportRequestError calls onRequestError handler", async () => {
const { runInstrumentation, reportRequestError } =
await import("../packages/vinext/src/server/instrumentation.js");
const reportedErrors: { error: Error; request: any; context: any }[] = [];
const mockRunner = {
import: async (_id: string) => ({
register: () => {},
onRequestError: (error: Error, request: any, context: any) => {
reportedErrors.push({ error, request, context });
},
}),
};
await runInstrumentation(mockRunner, "/fake/instrumentation.ts");
const testError = new Error("test error");
await reportRequestError(
testError,
{ path: "/blog/1", method: "GET", headers: {} },
{ routerKind: "Pages Router", routePath: "/blog/[slug]", routeType: "render" },
);
expect(reportedErrors.length).toBe(1);
expect(reportedErrors[0].error).toBe(testError);
expect(reportedErrors[0].request.path).toBe("/blog/1");
expect(reportedErrors[0].context.routerKind).toBe("Pages Router");
});
it("reportRequestError is a no-op when no handler is registered", async () => {
const { reportRequestError, runInstrumentation } =
await import("../packages/vinext/src/server/instrumentation.js");
// Register a module with no onRequestError
const mockRunner = {
import: async (_id: string) => ({
register: () => {},
}),
};
await runInstrumentation(mockRunner, "/fake/no-error-handler.ts");
// Should not throw
await reportRequestError(
new Error("test"),
{ path: "/", method: "GET", headers: {} },
{ routerKind: "App Router", routePath: "/", routeType: "render" },
);
});
it("runInstrumentation handles missing register gracefully", async () => {
const { runInstrumentation } = await import("../packages/vinext/src/server/instrumentation.js");
// Module with no register() or onRequestError()
const mockRunner = {
import: async (_id: string) => ({}),
};
// Should not throw
await runInstrumentation(mockRunner, "/fake/empty-instrumentation.ts");
});
it("runInstrumentation handles import errors gracefully", async () => {
const { runInstrumentation } = await import("../packages/vinext/src/server/instrumentation.js");
const mockRunner = {
import: async (_id: string) => {
throw new TypeError("Cannot read properties of undefined (reading 'outsideEmitter')");
},
};
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
try {
await runInstrumentation(mockRunner, "/fake/instrumentation.ts");
expect(consoleErrorSpy).toHaveBeenCalledWith(
"[vinext] Failed to load instrumentation:",
"Cannot read properties of undefined (reading 'outsideEmitter')",
);
} finally {
consoleErrorSpy.mockRestore();
}
});
});
describe("production server compression", () => {
it("negotiateEncoding returns br when Accept-Encoding includes br", async () => {
const { negotiateEncoding } = await import("../packages/vinext/src/server/prod-server.js");
const req = { headers: { "accept-encoding": "gzip, deflate, br" } };
expect(negotiateEncoding(req as any)).toBe("br");
});
it("negotiateEncoding returns gzip when br is not available", async () => {
const { negotiateEncoding } = await import("../packages/vinext/src/server/prod-server.js");
const req = { headers: { "accept-encoding": "gzip, deflate" } };
expect(negotiateEncoding(req as any)).toBe("gzip");
});
it("negotiateEncoding returns identity when no encoding header", async () => {
const { negotiateEncoding } = await import("../packages/vinext/src/server/prod-server.js");
const req = { headers: {} };
expect(negotiateEncoding(req as any)).toBe("identity");
});
it("COMPRESSIBLE_TYPES includes expected content types", async () => {
const { COMPRESSIBLE_TYPES } = await import("../packages/vinext/src/server/prod-server.js");
expect(COMPRESSIBLE_TYPES.has("text/html")).toBe(true);
expect(COMPRESSIBLE_TYPES.has("application/javascript")).toBe(true);
expect(COMPRESSIBLE_TYPES.has("application/json")).toBe(true);
expect(COMPRESSIBLE_TYPES.has("text/css")).toBe(true);
expect(COMPRESSIBLE_TYPES.has("image/svg+xml")).toBe(true);
// Binary formats should not be compressible
expect(COMPRESSIBLE_TYPES.has("image/png")).toBe(false);
expect(COMPRESSIBLE_TYPES.has("image/jpeg")).toBe(false);
});
it("COMPRESS_THRESHOLD is a reasonable minimum", async () => {
const { COMPRESS_THRESHOLD } = await import("../packages/vinext/src/server/prod-server.js");
expect(COMPRESS_THRESHOLD).toBeGreaterThanOrEqual(256);
expect(COMPRESS_THRESHOLD).toBeLessThanOrEqual(4096);
});
it("sendCompressed compresses text/html with gzip", async () => {
const { sendCompressed } = await import("../packages/vinext/src/server/prod-server.js");
const body = "<html>" + "x".repeat(2000) + "</html>";
const req = { headers: { "accept-encoding": "gzip" } };
const chunks: Buffer[] = [];
let writtenStatus = 0;
let writtenHeaders: Record<string, string> = {};
const res = {
writeHead: (status: number, headers: Record<string, string>) => {
writtenStatus = status;
writtenHeaders = headers;
},
write: (chunk: Buffer) => {
chunks.push(chunk);
return true;
},
end: (chunk?: Buffer) => {
if (chunk) chunks.push(chunk);
},
on: () => {},
once: () => {},
emit: () => false,
removeListener: () => {},
};
await new Promise<void>((resolve) => {
// Override end to capture completion
const origEnd = res.end;
(res as any).end = (chunk?: Buffer) => {
origEnd(chunk);
resolve();
};
sendCompressed(req as any, res as any, body, "text/html", 200, {}, true);
// Give pipeline time to complete
setTimeout(resolve, 100);
});
expect(writtenStatus).toBe(200);
expect(writtenHeaders["Content-Encoding"]).toBe("gzip");
expect(writtenHeaders["Vary"]).toBe("Accept-Encoding");
});
it("sendCompressed does not compress when disabled", async () => {
const { sendCompressed } = await import("../packages/vinext/src/server/prod-server.js");
const body = "<html>" + "x".repeat(2000) + "</html>";
const req = { headers: { "accept-encoding": "gzip" } };
let writtenHeaders: Record<string, string> = {};
let writtenBody: Buffer | null = null;
const res = {
writeHead: (_status: number, headers: Record<string, string>) => {
writtenHeaders = headers;
},
end: (chunk?: Buffer) => {
if (chunk) writtenBody = chunk;
},
};
sendCompressed(req as any, res as any, body, "text/html", 200, {}, false);
// Should NOT have Content-Encoding
expect(writtenHeaders["Content-Encoding"]).toBeUndefined();
// Should have Content-Length
expect(writtenHeaders["Content-Length"]).toBeDefined();
expect(writtenBody).toBeTruthy();
});
it("sendCompressed keeps the size threshold even when identity is refused", async () => {
const { sendCompressed } = await import("../packages/vinext/src/server/prod-server.js");
const req = { headers: { "accept-encoding": "gzip;q=1, identity;q=0" } };
const chunks: Buffer[] = [];
let writtenHeaders: Record<string, string> = {};
const res = {
writeHead: (_status: number, headers: Record<string, string>) => {
writtenHeaders = headers;
},
end: (chunk?: Buffer) => {
if (chunk) chunks.push(chunk);
},
};
sendCompressed(req as any, res as any, "tiny", "text/plain", 200, {}, true);
expect(writtenHeaders["Content-Encoding"]).toBeUndefined();
expect(Buffer.concat(chunks).toString()).toBe("tiny");
});
it("sendCompressed does not vary non-compressible responses", async () => {
const { sendCompressed } = await import("../packages/vinext/src/server/prod-server.js");
const req = { headers: { "accept-encoding": "gzip" } };
let writtenHeaders: Record<string, string> = {};
const res = {
writeHead: (_status: number, headers: Record<string, string>) => {
writtenHeaders = headers;
},
end: () => {},
};
sendCompressed(req as any, res as any, Buffer.from([1, 2, 3]), "image/png", 200, {}, true);
expect(writtenHeaders.Vary).toBeUndefined();
});
it("sendCompressed replaces an empty Vary header", async () => {
const { sendCompressed } = await import("../packages/vinext/src/server/prod-server.js");
const req = { headers: { "accept-encoding": "gzip" } };
let writtenHeaders: Record<string, string> = {};
const res = {
writeHead: (_status: number, headers: Record<string, string>) => {
writtenHeaders = headers;
},
end: () => {},
};
sendCompressed(req as any, res as any, "tiny", "text/plain", 200, { Vary: "" }, true);
expect(writtenHeaders.Vary).toBe("Accept-Encoding");
});
it("sendCompressed does not compress small bodies", async () => {
const { sendCompressed } = await import("../packages/vinext/src/server/prod-server.js");
const body = "<html>small</html>";
const req = { headers: { "accept-encoding": "gzip" } };
let writtenHeaders: Record<string, string> = {};
const res = {
writeHead: (_status: number, headers: Record<string, string>) => {
writtenHeaders = headers;
},
end: () => {},
};
sendCompressed(req as any, res as any, body, "text/html", 200, {}, true);
// Body is too small for compression
expect(writtenHeaders["Content-Encoding"]).toBeUndefined();
expect(writtenHeaders["Content-Length"]).toBeDefined();
});
it("sendCompressed does not compress non-compressible types", async () => {
const { sendCompressed } = await import("../packages/vinext/src/server/prod-server.js");
const body = Buffer.alloc(2000, 0xff); // binary content
const req = { headers: { "accept-encoding": "gzip" } };
let writtenHeaders: Record<string, string> = {};
const res = {
writeHead: (_status: number, headers: Record<string, string>) => {
writtenHeaders = headers;
},
end: () => {},
};
sendCompressed(req as any, res as any, body, "image/png", 200, {}, true);
// PNG should not be compressed
expect(writtenHeaders["Content-Encoding"]).toBeUndefined();
expect(writtenHeaders["Vary"]).toBeUndefined();
});
it("sendCompressed omits the body for HEAD on a compressible response", async () => {
const { sendCompressed } = await import("../packages/vinext/src/server/prod-server.js");
const body = "<html>" + "x".repeat(2000) + "</html>";
const req = { method: "HEAD", headers: { "accept-encoding": "gzip" } };
const chunks: Buffer[] = [];
let writtenStatus = 0;
let writtenHeaders: Record<string, string> = {};
let ended = false;
const res = {
writeHead: (status: number, headers: Record<string, string>) => {
writtenStatus = status;
writtenHeaders = headers;
},
write: (chunk: Buffer) => {
chunks.push(chunk);
return true;
},
end: (chunk?: Buffer) => {
if (chunk) chunks.push(chunk);
ended = true;
},
on: () => {},
once: () => {},
emit: () => false,
removeListener: () => {},
};
sendCompressed(req as any, res as any, body, "text/html", 200, {}, true);
expect(writtenStatus).toBe(200);
// Headers match what a GET would send (negotiated compression announced)…
expect(writtenHeaders["Content-Encoding"]).toBe("gzip");
expect(writtenHeaders["Vary"]).toBe("Accept-Encoding");
// …but no body is written and the compressor is never spun up.
expect(ended).toBe(true);
expect(chunks).toEqual([]);
});
it("sendCompressed omits the body for HEAD but keeps Content-Length (uncompressed)", async () => {
const { sendCompressed } = await import("../packages/vinext/src/server/prod-server.js");
const body = "<html>" + "x".repeat(2000) + "</html>";
const req = { method: "HEAD", headers: {} };
const chunks: Buffer[] = [];
let writtenHeaders: Record<string, string> = {};
const res = {
writeHead: (_status: number, headers: Record<string, string>) => {
writtenHeaders = headers;
},
write: (chunk: Buffer) => {
chunks.push(chunk);
return true;
},
end: (chunk?: Buffer) => {
if (chunk) chunks.push(chunk);
},
};
// compress=false → uncompressed branch; HEAD → no body.
sendCompressed(req as any, res as any, body, "text/html", 200, {}, false);
// Content-Length still reflects the full GET body size (RFC 9110)…
expect(writtenHeaders["Content-Length"]).toBe(String(Buffer.byteLength(body)));
// …but the body itself is not sent.
expect(chunks).toEqual([]);
});
it("sendCompressed writes the full body for non-HEAD requests", async () => {
const { sendCompressed } = await import("../packages/vinext/src/server/prod-server.js");
const body = '{"ok":true}';
const req = { method: "GET", headers: {} };
const chunks: Buffer[] = [];
const res = {
writeHead: () => {},
write: (chunk: Buffer) => {
chunks.push(chunk);
return true;
},
end: (chunk?: Buffer) => {
if (chunk) chunks.push(chunk);
},
};
// The HEAD guard must not over-trigger: a GET still gets the full body.
sendCompressed(req as any, res as any, body, "application/octet-stream", 200, {}, false);
expect(Buffer.concat(chunks).toString()).toBe(body);
});
});
describe("Set-Cookie header preservation in prod-server", () => {
it("mergeResponseHeaders preserves multiple Set-Cookie from response", async () => {
const { mergeResponseHeaders } = await import("../packages/vinext/src/server/prod-server.js");
const middlewareHeaders: Record<string, string | string[]> = {};
const response = new Response("ok", {
headers: [
["set-cookie", "a=1; Path=/"],
["set-cookie", "b=2; Path=/"],
["content-type", "text/html"],
],
});
const merged = mergeResponseHeaders(middlewareHeaders, response);
expect(merged["set-cookie"]).toEqual(["a=1; Path=/", "b=2; Path=/"]);
expect(merged["content-type"]).toBe("text/html");
});
it("mergeResponseHeaders merges middleware and response Set-Cookie", async () => {
const { mergeResponseHeaders } = await import("../packages/vinext/src/server/prod-server.js");
const middlewareHeaders: Record<string, string | string[]> = {
"set-cookie": ["mw=1; Path=/"],
};
const response = new Response("ok", {
headers: [["set-cookie", "resp=2; Path=/"]],
});
const merged = mergeResponseHeaders(middlewareHeaders, response);
expect(merged["set-cookie"]).toEqual(["mw=1; Path=/", "resp=2; Path=/"]);
});
it("mergeResponseHeaders handles middleware cookie as plain string", async () => {
const { mergeResponseHeaders } = await import("../packages/vinext/src/server/prod-server.js");
const middlewareHeaders: Record<string, string | string[]> = {
"set-cookie": "mw=1; Path=/",
};
const response = new Response("ok", {
headers: [["set-cookie", "resp=2; Path=/"]],
});
const merged = mergeResponseHeaders(middlewareHeaders, response);
expect(merged["set-cookie"]).toEqual(["mw=1; Path=/", "resp=2; Path=/"]);
});
it("mergeResponseHeaders does not duplicate non-Set-Cookie headers", async () => {
const { mergeResponseHeaders } = await import("../packages/vinext/src/server/prod-server.js");
const middlewareHeaders: Record<string, string | string[]> = {
"x-custom": "from-middleware",
};
const response = new Response("ok", {
headers: [
["x-custom", "from-response"],
["content-type", "text/html"],
],
});
const merged = mergeResponseHeaders(middlewareHeaders, response);
// Response headers should override middleware headers for non-Set-Cookie
expect(merged["x-custom"]).toBe("from-response");
});
it("mergeWebResponse preserves the original body stream while applying header overrides", async () => {
const { mergeWebResponse } = await import("../packages/vinext/src/server/prod-server.js");
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode("hello"));
controller.close();
},
});
const response = new Response(stream, {
status: 200,
headers: {
"content-type": "text/plain",
"x-custom": "from-response",
},
});
const merged = mergeWebResponse({ "x-custom": "from-middleware" }, response, 201);
expect(merged.status).toBe(201);
expect(merged.headers.get("x-custom")).toBe("from-response");
expect(merged.headers.get("content-type")).toBe("text/plain");
expect(await merged.text()).toBe("hello");
});
it("mergeWebResponse cancels discarded body streams for no-body statuses", async () => {
const { mergeWebResponse } = await import("../packages/vinext/src/server/prod-server.js");
let started = false;
let canceled = false;
const response = new Response(
new ReadableStream<Uint8Array>({
async start(controller) {
started = true;
await new Promise((resolve) => setTimeout(resolve, 25));
if (canceled) return;
controller.enqueue(new TextEncoder().encode("hello"));
controller.close();
},
cancel() {
canceled = true;
},
}),
{
headers: {
"content-type": "text/plain",
"content-length": "5",
},
},
);
const merged = mergeWebResponse({}, response, 204);
expect(merged.status).toBe(204);
expect(merged.headers.get("content-type")).toBeNull();
expect(merged.headers.get("content-length")).toBeNull();
expect(await merged.text()).toBe("");
await new Promise((resolve) => setTimeout(resolve, 50));
expect(started).toBe(true);
expect(canceled).toBe(true);
});
it("mergeWebResponse strips stale content-length only for tagged streamed Pages HTML", async () => {
const { mergeWebResponse } = await import("../packages/vinext/src/server/prod-server.js");
const response = new Response(
new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode("hello"));
controller.close();
},
}),
{
status: 200,
headers: {
"content-type": "text/html; charset=utf-8",
"content-length": "1",
},
},
) as Response & { __vinextStreamedHtmlResponse?: boolean };
response.__vinextStreamedHtmlResponse = true;
const merged = mergeWebResponse({}, response);
expect(merged.headers.get("content-length")).toBeNull();
expect(merged.headers.get("content-type")).toBe("text/html; charset=utf-8");
expect(await merged.text()).toBe("hello");
});
it("mergeWebResponse preserves content-length for untagged custom responses", async () => {
const { mergeWebResponse } = await import("../packages/vinext/src/server/prod-server.js");
const response = new Response(Buffer.from([1, 2, 3]), {
status: 200,
headers: {
"content-type": "application/octet-stream",
"content-length": "3",
},
});
const merged = mergeWebResponse({ "x-custom-middleware": "active" }, response);
expect(merged.headers.get("content-length")).toBe("3");
expect(merged.headers.get("content-type")).toBe("application/octet-stream");
expect(merged.headers.get("x-custom-middleware")).toBe("active");
const body = Buffer.from(await merged.arrayBuffer());
expect(body.equals(Buffer.from([1, 2, 3]))).toBe(true);
});
it("mergeWebResponse ignores middleware-only content-length overrides", async () => {
const { mergeWebResponse } = await import("../packages/vinext/src/server/prod-server.js");
const response = new Response(Buffer.from([1, 2, 3]), {
status: 200,
headers: {
"content-type": "application/octet-stream",
"content-length": "3",
},
});
const merged = mergeWebResponse({ "content-length": "1" }, response);
expect(merged).toBe(response);
expect(merged.headers.get("content-length")).toBe("3");
const body = Buffer.from(await merged.arrayBuffer());
expect(body.equals(Buffer.from([1, 2, 3]))).toBe(true);
});
for (const encoding of ["gzip", "br"] as const) {
it(`sendWebResponse streams ${encoding}-compressed HTML progressively`, async () => {
const result = await captureCompressedWebResponse(encoding);
expect(result.statusCode).toBe(200);
expect(result.headers["Content-Encoding"]).toBe(encoding);
expect(String(result.headers["Vary"])).toContain("Accept-Encoding");
expect(result.headers["content-length"]).toBeUndefined();
expect(result.firstDecodedMs).toBeGreaterThanOrEqual(0);
expect(result.firstDecodedMs).toBeLessThan(250);
// The delayed chunk uses setTimeout(250ms); allow 50ms slack for JS timer imprecision in CI.
expect(result.endMs).toBeGreaterThanOrEqual(200);
expect(result.rawBody.byteLength).toBeGreaterThan(0);
expect(result.body).toContain("Streaming SSR Test");
expect(result.body).toContain("Loading delayed chunk...");
expect(result.body).toContain("Delayed stream content loaded");
});
}
it("sendWebResponse cancels streamed bodies for HEAD requests", async () => {
const { sendWebResponse } = await import("../packages/vinext/src/server/prod-server.js");
let canceled = false;
const response = new Response(
new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode("hello"));
},
cancel() {
canceled = true;
},
}),
{
status: 200,
headers: {
"content-type": "text/html; charset=utf-8",
},
},
);
let status = 0;
let writtenHeaders: Record<string, string | string[]> = {};
let ended = false;
const req = {
method: "HEAD",
headers: { "accept-encoding": "br" },
};
const res = new CapturingNodeResponse();
const chunks: Buffer[] = [];
res.on("data", (chunk: Buffer) => {
chunks.push(Buffer.from(chunk));
});
await sendWebResponse(response, req as any, res as any, true);
await finished(res);
status = res.statusCode;
writtenHeaders = res.headers;
ended = res.writableEnded;
expect(status).toBe(200);
expect(writtenHeaders["content-type"]).toBe("text/html; charset=utf-8");
expect(writtenHeaders["Content-Encoding"]).toBe("br");
expect(writtenHeaders["Vary"]).toContain("Accept-Encoding");
expect(ended).toBe(true);
expect(chunks).toEqual([]);
expect(canceled).toBe(true);
});
it("sendWebResponse preserves an existing upstream encoding", async () => {
const { sendWebResponse } = await import("../packages/vinext/src/server/prod-server.js");
const response = new Response("encoded", {
headers: {
"content-encoding": "br",
"content-type": "text/html; charset=utf-8",
},
});
const req = {
method: "GET",
headers: { "accept-encoding": "gzip, br;q=0, identity;q=0" },
};
const res = new CapturingNodeResponse();
const chunks: Buffer[] = [];
res.on("data", (chunk: Buffer) => chunks.push(Buffer.from(chunk)));
await sendWebResponse(response, req as any, res as any, true);
await finished(res);
expect(res.statusCode).toBe(200);
expect(res.headers["Vary"]).toBeUndefined();
expect(Buffer.concat(chunks).toString()).toBe("encoded");
});
it("sendWebResponse varies identity responses by Accept-Encoding", async () => {
const { sendWebResponse } = await import("../packages/vinext/src/server/prod-server.js");
const response = new Response("small", {
headers: {
"content-type": "text/html; charset=utf-8",
vary: "RSC",
},
});
const req = {
method: "GET",
headers: { "accept-encoding": "br;q=0.5" },
};
const res = new CapturingNodeResponse();
res.resume();
await sendWebResponse(response, req as any, res as any, true);
await finished(res);
expect(res.statusCode).toBe(200);
expect(res.headers["Content-Encoding"]).toBe("br");
expect(res.headers.vary).toBe("RSC, Accept-Encoding");
});
it("sendWebResponse cancels compressed streams on client disconnect", async () => {
const { sendWebResponse } = await import("../packages/vinext/src/server/prod-server.js");
let canceled = false;
const response = new Response(
createStreamingBody(
[
{ text: "<h1>Streaming SSR Test</h1>" },
{ text: "<p>Delayed stream content loaded</p>", delayMs: 150 },
],
() => {
canceled = true;
},
),
{
status: 200,
headers: {
"content-type": "text/html; charset=utf-8",
},
},
);
const req = {
method: "GET",
headers: { "accept-encoding": "br" },
};
const res = new DisconnectingNodeResponse();
res.on("error", () => {
/* expected client disconnect */
});
await sendWebResponse(response, req as any, res as any, true);
await new Promise((resolve) => setTimeout(resolve, 50));
expect(res.statusCode).toBe(200);
expect(res.headers["Content-Encoding"]).toBe("br");
expect(res.writeCount).toBeGreaterThan(0);
expect(canceled).toBe(true);
});
it("sendCompressed passes array-valued Set-Cookie to writeHead", async () => {
const { sendCompressed } = await import("../packages/vinext/src/server/prod-server.js");
let writtenHeaders: Record<string, string | string[]> = {};
const req = { headers: {} };
const res = {
writeHead: (_status: number, headers: Record<string, string | string[]>) => {
writtenHeaders = headers;
},
end: () => {},
};
const extraHeaders: Record<string, string | string[]> = {
"set-cookie": ["a=1; Path=/", "b=2; Path=/"],
};
sendCompressed(req as any, res as any, "small body", "text/html", 200, extraHeaders, false);
expect(writtenHeaders["set-cookie"]).toEqual(["a=1; Path=/", "b=2; Path=/"]);
});
it("sendCompressed replaces any existing content-type and content-length headers", async () => {
const { sendCompressed } = await import("../packages/vinext/src/server/prod-server.js");
let writtenHeaders: Record<string, string | string[]> = {};
const req = { headers: {} };
const res = {
writeHead: (_status: number, headers: Record<string, string | string[]>) => {
writtenHeaders = headers;
},
end: () => {},
};
sendCompressed(
req as any,
res as any,
"hello",
"application/json",
200,
{
"content-type": "text/plain",
"content-length": "999",
"x-custom": "active",
},
false,
);
expect(writtenHeaders["Content-Type"]).toBe("application/json");
expect(writtenHeaders["Content-Length"]).toBe("5");
expect(writtenHeaders["content-type"]).toBeUndefined();
expect(writtenHeaders["content-length"]).toBeUndefined();
expect(writtenHeaders["x-custom"]).toBe("active");
});
});
describe("host header poisoning prevention", () => {
it("resolveHost ignores X-Forwarded-Host by default", async () => {
const { resolveHost } = await import("../packages/vinext/src/server/prod-server.js");
const req = {
headers: {
"x-forwarded-host": "evil.com",
host: "legit.com",
},
};
expect(resolveHost(req as any, "localhost")).toBe("legit.com");
});
it("resolveHost uses Host header when X-Forwarded-Host is absent", async () => {
const { resolveHost } = await import("../packages/vinext/src/server/prod-server.js");
const req = { headers: { host: "myapp.com:3000" } };
expect(resolveHost(req as any, "localhost")).toBe("myapp.com:3000");
});
it("resolveHost returns fallback when no host headers are present", async () => {
const { resolveHost } = await import("../packages/vinext/src/server/prod-server.js");
const req = { headers: {} };
expect(resolveHost(req as any, "fallback.local")).toBe("fallback.local");
});
it("resolveHost trusts X-Forwarded-Host when it is in the trusted hosts set", async () => {
const { resolveHost, trustedHosts } =
await import("../packages/vinext/src/server/prod-server.js");
// Temporarily add a host to the trusted set
trustedHosts.add("cdn.example.com");
try {
const req = {
headers: {
"x-forwarded-host": "cdn.example.com",
host: "internal.local",
},
};
expect(resolveHost(req as any, "localhost")).toBe("cdn.example.com");
} finally {
trustedHosts.delete("cdn.example.com");
}
});
it("resolveHost still rejects untrusted X-Forwarded-Host when trusted set is non-empty", async () => {
const { resolveHost, trustedHosts } =
await import("../packages/vinext/src/server/prod-server.js");
trustedHosts.add("trusted.example.com");
try {
const req = {
headers: {
"x-forwarded-host": "evil.com",
host: "legit.com",
},
};
expect(resolveHost(req as any, "localhost")).toBe("legit.com");
} finally {
trustedHosts.delete("trusted.example.com");
}
});
it("resolveHost handles case-insensitive host matching", async () => {
const { resolveHost, trustedHosts } =
await import("../packages/vinext/src/server/prod-server.js");
trustedHosts.add("cdn.example.com");
try {
const req = {
headers: {
"x-forwarded-host": "CDN.Example.COM",
host: "internal.local",
},
};
// DNS hostnames are case-insensitive, should match
expect(resolveHost(req as any, "localhost")).toBe("cdn.example.com");
} finally {
trustedHosts.delete("cdn.example.com");
}
});
it("resolveHost extracts first value from comma-separated X-Forwarded-Host", async () => {
const { resolveHost, trustedHosts } =
await import("../packages/vinext/src/server/prod-server.js");
trustedHosts.add("cdn.example.com");
try {
const req = {
headers: {
"x-forwarded-host": "cdn.example.com, proxy2.internal",
host: "internal.local",
},
};
// Multiple proxies produce comma-separated values; first is client-facing
expect(resolveHost(req as any, "localhost")).toBe("cdn.example.com");
} finally {
trustedHosts.delete("cdn.example.com");
}
});
});
// ---------------------------------------------------------------------------
// X-Forwarded-Proto trust proxy gating
// ---------------------------------------------------------------------------
describe("X-Forwarded-Proto trust proxy gating", () => {
it("nodeToWebRequest ignores X-Forwarded-Proto by default (trustProxy=false)", async () => {
const mod = await import("../packages/vinext/src/server/prod-server.js");
// Ensure trustProxy is false by default (no env vars set)
// Note: trustProxy is computed at module load time from env vars.
// In test env, VINEXT_TRUST_PROXY and VINEXT_TRUSTED_HOSTS are not set.
const req = {
headers: {
"x-forwarded-proto": "https",
host: "localhost:3000",
},
url: "/test",
method: "GET",
};
const webReq = mod.nodeToWebRequest(req as any);
// Without trust proxy, should default to http://
expect(webReq.url).toMatch(/^http:\/\//);
});
it("trustProxy is false when no env vars are set", async () => {
const mod = await import("../packages/vinext/src/server/prod-server.js");
expect(mod.trustProxy).toBe(false);
});
it("trustProxy is true when VINEXT_TRUSTED_HOSTS is non-empty", async () => {
vi.stubEnv("VINEXT_TRUSTED_HOSTS", "example.com");
vi.resetModules();
const mod = await import("../packages/vinext/src/server/prod-server.js");
expect(mod.trustProxy).toBe(true);
expect(mod.trustedHosts.has("example.com")).toBe(true);
vi.unstubAllEnvs();
});
it("trustProxy is false when VINEXT_TRUSTED_HOSTS is empty string", async () => {
vi.stubEnv("VINEXT_TRUSTED_HOSTS", "");
vi.resetModules();
const mod = await import("../packages/vinext/src/server/prod-server.js");
expect(mod.trustProxy).toBe(false);
expect(mod.trustedHosts.size).toBe(0);
vi.unstubAllEnvs();
});
it("nodeToWebRequest uses http:// when X-Forwarded-Proto is missing", async () => {
const mod = await import("../packages/vinext/src/server/prod-server.js");
const req = {
headers: { host: "localhost:3000" },
url: "/test",
method: "GET",
};
const webReq = mod.nodeToWebRequest(req as any);
expect(webReq.url).toMatch(/^http:\/\/localhost:3000\/test$/);
});
});
// ---------------------------------------------------------------------------
// Malformed percent-encoded URL regression tests (HackerOne #3575154)
// ---------------------------------------------------------------------------
describe("malformed percent-encoded URLs return 400 instead of crashing", () => {
// Dev server test: uses the Pages Router fixture
let malformedServer: ViteDevServer;
let malformedBaseUrl: string;
beforeAll(async () => {
({ server: malformedServer, baseUrl: malformedBaseUrl } =
await startFixtureServer(FIXTURE_DIR));
}, 30000);
afterAll(async () => {
await malformedServer?.close();
});
it("dev server returns 400 for malformed percent-encoded path", async () => {
const res = await fetch(`${malformedBaseUrl}/%E0%A4%A`);
expect(res.status).toBe(400);
const body = await res.text();
expect(body).toContain("Bad Request");
});
it("dev server returns 400 for truncated percent sequence", async () => {
const res = await fetch(`${malformedBaseUrl}/%E0%A4`);
expect(res.status).toBe(400);
const body = await res.text();
expect(body).toContain("Bad Request");
});
it("dev server returns 400 for bare percent sign", async () => {
const res = await fetch(`${malformedBaseUrl}/%`);
expect(res.status).toBe(400);
const body = await res.text();
expect(body).toContain("Bad Request");
});
it("dev server still serves valid percent-encoded paths", async () => {
// %2F is a valid encoding for "/"
const res = await fetch(`${malformedBaseUrl}/about`);
expect(res.status).toBe(200);
});
});
// ---------------------------------------------------------------------------
// Edge cases from Next.js test suite
// ---------------------------------------------------------------------------
describe("Next.js edge cases", () => {
// Uses the Pages Router fixture (FIXTURE_DIR = PAGES_FIXTURE_DIR)
let edgeServer: ViteDevServer;
let edgeBaseUrl: string;
beforeAll(async () => {
({ server: edgeServer, baseUrl: edgeBaseUrl } = await startFixtureServer(FIXTURE_DIR));
}, 30000);
afterAll(async () => {
await edgeServer?.close();
});
// --- Edge case 1: Query params must NOT leak into getStaticProps params ---
it("getStaticProps query params do not leak into params", async () => {
// Visit a static page with query params — params should only contain the dynamic segments
const res = await fetch(`${edgeBaseUrl}/articles/1?utm_source=test&ref=google`);
expect(res.status).toBe(200);
const html = await res.text();
// The page renders the article title based on params.id
expect(html).toContain("First Article");
// React SSR may insert comment nodes: "Article ID: <!-- -->1"
expect(html).toMatch(/Article ID:.*1/);
// Query params should NOT affect the rendered page content (they may appear in Vite module URLs)
// Check the __NEXT_DATA__ doesn't leak query params into getStaticProps
const dataMatch = html.match(
/<script id="__NEXT_DATA__" type="application\/json">([\s\S]*?)<\/script>/,
);
expect(dataMatch).not.toBeNull();
const data = JSON.parse(dataMatch![1]);
expect(data.props.pageProps.id).toBe("1");
expect(data.props.pageProps.title).toBe("First Article");
// getStaticProps params should only contain route params, not URL query
expect(data.query).not.toHaveProperty("utm_source");
expect(data.query).not.toHaveProperty("ref");
});
it("__NEXT_DATA__ query contains dynamic params for static pages", async () => {
const res = await fetch(`${edgeBaseUrl}/articles/2?extra=value`);
const html = await res.text();
const dataMatch = html.match(
/<script id="__NEXT_DATA__" type="application\/json">([\s\S]*?)<\/script>/,
);
expect(dataMatch).not.toBeNull();
const data = JSON.parse(dataMatch![1]);
// query should contain the dynamic segment
expect(data.query.id).toBe("2");
// Verify the page props are correct
expect(data.props.pageProps.id).toBe("2");
expect(data.props.pageProps.title).toBe("Second Article");
});
// --- Edge case 2: getServerSideProps with notFound returns 404 status ---
it("getServerSideProps notFound returns 404 status code", async () => {
// The /posts/missing page always returns notFound: true
const res = await fetch(`${edgeBaseUrl}/posts/missing`);
expect(res.status).toBe(404);
});
it("getServerSideProps notFound renders custom 404 page content", async () => {
const res = await fetch(`${edgeBaseUrl}/posts/missing`);
const html = await res.text();
// Should render the custom 404 page
expect(html).toContain("404");
});
// --- Edge case 3: UTF-8 encoding in SSR ---
it("SSR response declares UTF-8 charset", async () => {
const res = await fetch(`${edgeBaseUrl}/about`);
const html = await res.text();
// React outputs charSet (camelCase) in JSX which becomes charset in HTML
expect(html).toMatch(/char[Ss]et.*utf-8/i);
});
});
describe("multi-byte character SSR", () => {
let mbServer: ViteDevServer;
let mbBaseUrl: string;
let mbTmpDir: string;
beforeAll(async () => {
const os = await import("node:os");
const fsp = await import("node:fs/promises");
mbTmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-mb-"));
const rootNodeModules = path.resolve(import.meta.dirname, "../node_modules");
await fsp.symlink(rootNodeModules, path.join(mbTmpDir, "node_modules"), "junction");
await fsp.writeFile(path.join(mbTmpDir, "next.config.mjs"), `export default {};`);
await fsp.mkdir(path.join(mbTmpDir, "pages"), { recursive: true });
// Page with multi-byte characters (Japanese, Chinese, Korean, emoji)
await fsp.writeFile(
path.join(mbTmpDir, "pages", "index.tsx"),
`export default function MultiByteTest() {
const japanese = "マルチバイト".repeat(28);
const chinese = "你好世界";
const korean = "안녕하세요";
const emoji = "🎉🚀💻🌍";
return (
<div>
<h1>Multi-Byte Test</h1>
<p id="japanese">{japanese}</p>
<p id="chinese">{chinese}</p>
<p id="korean">{korean}</p>
<p id="emoji">{emoji}</p>
</div>
);
}`,
);
const vinext = (await import("../packages/vinext/src/index.js")).default;
const plugins: any[] = [vinext()];
mbServer = await createServer({
root: mbTmpDir,
configFile: false,
plugins,
server: { port: 0 },
logLevel: "silent",
});
await mbServer.listen();
const addr = mbServer.httpServer?.address();
if (addr && typeof addr === "object") {
mbBaseUrl = `http://localhost:${addr.port}`;
}
}, 30000);
afterAll(async () => {
try {
(mbServer?.httpServer as any)?.closeAllConnections?.();
await Promise.race([mbServer?.close(), new Promise((resolve) => setTimeout(resolve, 5000))]);
} catch {
/* ignore */
}
const fsp = await import("node:fs/promises");
await fsp.rm(mbTmpDir, { recursive: true, force: true }).catch(() => {});
}, 15000);
it("renders Japanese characters correctly (28 repetitions)", async () => {
const res = await fetch(`${mbBaseUrl}/`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("マルチバイト".repeat(28));
});
it("renders Chinese characters correctly", async () => {
const res = await fetch(`${mbBaseUrl}/`);
const html = await res.text();
expect(html).toContain("你好世界");
});
it("renders Korean characters correctly", async () => {
const res = await fetch(`${mbBaseUrl}/`);
const html = await res.text();
expect(html).toContain("안녕하세요");
});
it("renders emoji correctly", async () => {
const res = await fetch(`${mbBaseUrl}/`);
const html = await res.text();
expect(html).toContain("🎉🚀💻🌍");
});
it("response has correct UTF-8 content-type", async () => {
const res = await fetch(`${mbBaseUrl}/`);
const contentType = res.headers.get("content-type") ?? "";
expect(contentType).toContain("text/html");
});
});
// --- Edge case: Cross-locale redirect from getServerSideProps ---
describe("i18n cross-locale redirect from getServerSideProps", () => {
let localeRedirectServer: ViteDevServer;
let localeRedirectBaseUrl: string;
let localeRedirectTmpDir: string;
beforeAll(async () => {
const os = await import("node:os");
const fsp = await import("node:fs/promises");
localeRedirectTmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-locale-redirect-"));
const rootNodeModules = path.resolve(import.meta.dirname, "../node_modules");
await fsp.symlink(rootNodeModules, path.join(localeRedirectTmpDir, "node_modules"), "junction");
await fsp.writeFile(
path.join(localeRedirectTmpDir, "next.config.mjs"),
`export default {
i18n: {
locales: ["en", "fr", "de"],
defaultLocale: "en",
},
};`,
);
await fsp.mkdir(path.join(localeRedirectTmpDir, "pages"), { recursive: true });
// Home page
await fsp.writeFile(
path.join(localeRedirectTmpDir, "pages", "index.tsx"),
`export default function Home() { return <h1>Home</h1>; }`,
);
// Page that redirects to a different locale
await fsp.writeFile(
path.join(localeRedirectTmpDir, "pages", "redirect-to-fr.tsx"),
`export function getServerSideProps({ locale }) {
if (locale !== "fr") {
return {
redirect: {
destination: "/fr/redirect-to-fr",
permanent: false,
},
};
}
return { props: { locale } };
}
export default function Page({ locale }) {
return <h1>French page: {locale}</h1>;
}`,
);
// Page that returns notFound for non-default locale
await fsp.writeFile(
path.join(localeRedirectTmpDir, "pages", "en-only.tsx"),
`export function getServerSideProps({ locale }) {
if (locale !== "en") {
return { notFound: true };
}
return { props: { locale } };
}
export default function Page({ locale }) {
return <h1>English only: {locale}</h1>;
}`,
);
const vinext = (await import("../packages/vinext/src/index.js")).default;
const plugins: any[] = [vinext()];
localeRedirectServer = await createServer({
root: localeRedirectTmpDir,
configFile: false,
plugins,
server: { port: 0 },
logLevel: "silent",
});
await localeRedirectServer.listen();
const addr = localeRedirectServer.httpServer?.address();
if (addr && typeof addr === "object") {
localeRedirectBaseUrl = `http://localhost:${addr.port}`;
}
}, 30000);
afterAll(async () => {
try {
(localeRedirectServer?.httpServer as any)?.closeAllConnections?.();
await Promise.race([
localeRedirectServer?.close(),
new Promise((resolve) => setTimeout(resolve, 5000)),
]);
} catch {
/* ignore */
}
const fsp = await import("node:fs/promises");
await fsp.rm(localeRedirectTmpDir, { recursive: true, force: true }).catch(() => {});
}, 15000);
it("getServerSideProps can redirect to a different locale", async () => {
const res = await fetch(`${localeRedirectBaseUrl}/redirect-to-fr`, {
redirect: "manual",
});
expect(res.status).toBe(307);
expect(res.headers.get("location")).toBe("/fr/redirect-to-fr");
});
it("following the cross-locale redirect renders the correct page", async () => {
const res = await fetch(`${localeRedirectBaseUrl}/fr/redirect-to-fr`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("French page");
});
it("getServerSideProps notFound for non-default locale returns 404", async () => {
const res = await fetch(`${localeRedirectBaseUrl}/fr/en-only`);
expect(res.status).toBe(404);
});
it("getServerSideProps renders for default locale", async () => {
const res = await fetch(`${localeRedirectBaseUrl}/en-only`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("English only");
});
});
// ---------------------------------------------------------------------------
// applyNavigationLocale (router.push/replace locale option)
// ---------------------------------------------------------------------------
describe("applyNavigationLocale", () => {
let applyNavigationLocale: (url: string, locale?: string) => string;
beforeAll(async () => {
const mod = await import("../packages/vinext/src/shims/router.js");
applyNavigationLocale = mod.applyNavigationLocale;
});
beforeEach(() => {
// Set up window globals that applyNavigationLocale reads
(globalThis as any).window = globalThis;
(globalThis as any).__VINEXT_DEFAULT_LOCALE__ = "en";
(globalThis as any).__NEXT_DATA__ = undefined;
});
afterEach(() => {
delete (globalThis as any).__VINEXT_DEFAULT_LOCALE__;
delete (globalThis as any).__NEXT_DATA__;
delete (globalThis as any).location;
delete (globalThis as any).window;
});
it("returns url unchanged when no locale is specified", () => {
expect(applyNavigationLocale("/about")).toBe("/about");
});
it("returns url unchanged when locale is undefined", () => {
expect(applyNavigationLocale("/about", undefined)).toBe("/about");
});
it("returns url unchanged when locale matches default locale", () => {
expect(applyNavigationLocale("/about", "en")).toBe("/about");
});
it("prefixes non-default locale to absolute path", () => {
expect(applyNavigationLocale("/about", "fr")).toBe("/fr/about");
});
it("prefixes non-default locale to root path", () => {
expect(applyNavigationLocale("/", "fr")).toBe("/fr/");
});
it("prefixes non-default locale to nested path", () => {
expect(applyNavigationLocale("/blog/post-1", "de")).toBe("/de/blog/post-1");
});
it("does not double-prefix if URL already starts with locale", () => {
expect(applyNavigationLocale("/fr/about", "fr")).toBe("/fr/about");
});
it("does not double-prefix if URL is exactly the locale", () => {
expect(applyNavigationLocale("/fr", "fr")).toBe("/fr");
});
it("handles relative paths by adding leading slash", () => {
expect(applyNavigationLocale("about", "fr")).toBe("/fr/about");
});
it("preserves query strings when prefixing locale", () => {
expect(applyNavigationLocale("/search?q=hello", "fr")).toBe("/fr/search?q=hello");
});
it("preserves hash when prefixing locale", () => {
expect(applyNavigationLocale("/docs#intro", "de")).toBe("/de/docs#intro");
});
it("returns an absolute cross-domain URL when the locale belongs to another domain", () => {
(globalThis as any).__NEXT_DATA__ = {
domainLocales: [
{ domain: "example.com", defaultLocale: "en" },
{ domain: "example.fr", defaultLocale: "fr", http: true },
],
};
(globalThis as any).location = {
protocol: "https:",
hostname: "example.com",
host: "example.com",
};
expect(applyNavigationLocale("/about", "fr")).toBe("http://example.fr/about");
});
it("includes basePath in cross-domain locale navigation URLs", async () => {
const originalBasePath = process.env.__NEXT_ROUTER_BASEPATH;
process.env.__NEXT_ROUTER_BASEPATH = "/app";
vi.resetModules();
try {
(globalThis as any).window = {
__NEXT_DATA__: {
domainLocales: [
{ domain: "example.com", defaultLocale: "en" },
{ domain: "example.fr", defaultLocale: "fr", http: true },
],
},
__VINEXT_DEFAULT_LOCALE__: "en",
location: {
hostname: "example.com",
pathname: "/app/about",
search: "",
},
addEventListener() {},
removeEventListener() {},
};
const mod = await import("../packages/vinext/src/shims/router.js");
expect(mod.applyNavigationLocale("/about", "fr")).toBe("http://example.fr/app/about");
} finally {
if (originalBasePath === undefined) {
delete process.env.__NEXT_ROUTER_BASEPATH;
} else {
process.env.__NEXT_ROUTER_BASEPATH = originalBasePath;
}
vi.resetModules();
}
});
});
// ---------------------------------------------------------------------------
// NEXT_LOCALE cookie integration (i18n redirect behavior)
// ---------------------------------------------------------------------------
describe("NEXT_LOCALE cookie redirect behavior", () => {
let cookieServer: ViteDevServer;
let cookieBaseUrl: string;
let cookieTmpDir: string;
beforeAll(async () => {
const os = await import("node:os");
const fsp = await import("node:fs/promises");
cookieTmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-cookie-locale-"));
const rootNodeModules = path.resolve(import.meta.dirname, "../node_modules");
await fsp.symlink(rootNodeModules, path.join(cookieTmpDir, "node_modules"), "junction");
await fsp.writeFile(
path.join(cookieTmpDir, "next.config.mjs"),
`export default {
i18n: {
locales: ["en", "fr", "de"],
defaultLocale: "en",
},
};`,
);
await fsp.mkdir(path.join(cookieTmpDir, "pages"), { recursive: true });
await fsp.writeFile(
path.join(cookieTmpDir, "pages", "index.tsx"),
`export default function Home() { return <h1>Home</h1>; }`,
);
await fsp.writeFile(
path.join(cookieTmpDir, "pages", "about.tsx"),
`export default function About() { return <h1>About</h1>; }`,
);
const vinext = (await import("../packages/vinext/src/index.js")).default;
const plugins: any[] = [vinext()];
cookieServer = await createServer({
root: cookieTmpDir,
configFile: false,
plugins,
server: { port: 0 },
logLevel: "silent",
});
await cookieServer.listen();
const addr = cookieServer.httpServer?.address();
if (addr && typeof addr === "object") {
cookieBaseUrl = `http://localhost:${addr.port}`;
}
}, 30000);
afterAll(async () => {
try {
(cookieServer?.httpServer as any)?.closeAllConnections?.();
await Promise.race([
cookieServer?.close(),
new Promise((resolve) => setTimeout(resolve, 5000)),
]);
} catch {
/* ignore */
}
const fsp = await import("node:fs/promises");
await fsp.rm(cookieTmpDir, { recursive: true, force: true }).catch(() => {});
}, 15000);
it("NEXT_LOCALE cookie redirects to non-default locale", async () => {
const res = await fetch(`${cookieBaseUrl}/`, {
redirect: "manual",
headers: { Cookie: "NEXT_LOCALE=fr" },
});
expect(res.status).toBe(307);
expect(res.headers.get("location")).toBe("/fr");
});
it("NEXT_LOCALE cookie redirects on non-root paths", async () => {
const res = await fetch(`${cookieBaseUrl}/about`, {
redirect: "manual",
headers: { Cookie: "NEXT_LOCALE=de" },
});
expect(res.status).toBe(200);
expect(res.headers.get("location")).toBeNull();
});
it("NEXT_LOCALE cookie matching default locale does NOT redirect", async () => {
const res = await fetch(`${cookieBaseUrl}/`, {
redirect: "manual",
headers: { Cookie: "NEXT_LOCALE=en" },
});
expect(res.status).toBe(200);
});
it("NEXT_LOCALE cookie takes priority over Accept-Language", async () => {
const res = await fetch(`${cookieBaseUrl}/`, {
redirect: "manual",
headers: {
Cookie: "NEXT_LOCALE=de",
"Accept-Language": "fr",
},
});
expect(res.status).toBe(307);
// Cookie says "de", Accept-Language says "fr" — cookie wins
expect(res.headers.get("location")).toBe("/de");
});
it("NEXT_LOCALE cookie with default locale suppresses Accept-Language redirect", async () => {
const res = await fetch(`${cookieBaseUrl}/`, {
redirect: "manual",
headers: {
Cookie: "NEXT_LOCALE=en",
"Accept-Language": "fr",
},
});
// Cookie says "en" (default) — should NOT redirect even though Accept-Language says "fr"
expect(res.status).toBe(200);
});
it("invalid NEXT_LOCALE cookie falls through to Accept-Language", async () => {
const res = await fetch(`${cookieBaseUrl}/`, {
redirect: "manual",
headers: {
Cookie: "NEXT_LOCALE=es",
"Accept-Language": "fr",
},
});
// "es" is not a valid locale, so cookie is ignored and Accept-Language kicks in
expect(res.status).toBe(307);
expect(res.headers.get("location")).toBe("/fr");
});
it("no NEXT_LOCALE cookie falls through to Accept-Language", async () => {
const res = await fetch(`${cookieBaseUrl}/`, {
redirect: "manual",
headers: {
Cookie: "theme=dark",
"Accept-Language": "de",
},
});
expect(res.status).toBe(307);
expect(res.headers.get("location")).toBe("/de");
});
it("NEXT_LOCALE does not redirect when URL already has locale prefix", async () => {
const res = await fetch(`${cookieBaseUrl}/fr/about`, {
redirect: "manual",
headers: { Cookie: "NEXT_LOCALE=de" },
});
// URL has /fr/ prefix — locale is already specified, cookie should NOT cause redirect
expect(res.status).toBe(200);
});
});
// ---------------------------------------------------------------------------
// Chained middleware → config rewrites
// ---------------------------------------------------------------------------
describe("chained middleware → config rewrites", () => {
let chainServer: ViteDevServer;
let chainBaseUrl: string;
let chainTmpDir: string;
beforeAll(async () => {
const os = await import("node:os");
const fsp = await import("node:fs/promises");
chainTmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-chain-rewrite-"));
const rootNodeModules = path.resolve(import.meta.dirname, "../node_modules");
await fsp.symlink(rootNodeModules, path.join(chainTmpDir, "node_modules"), "junction");
// Config with afterFiles rewrites:
// - /rewrite-source lacks a page file, so afterFiles can rewrite it to /final.
// - /intermediate owns a page file, so it wins before afterFiles.
await fsp.writeFile(
path.join(chainTmpDir, "next.config.mjs"),
`export default {
async rewrites() {
return [
{ source: "/rewrite-source", destination: "/final" },
{ source: "/intermediate", destination: "/final" },
];
},
};`,
);
await fsp.mkdir(path.join(chainTmpDir, "pages"), { recursive: true });
// Middleware: rewrites /original → /rewrite-source
await fsp.writeFile(
path.join(chainTmpDir, "middleware.ts"),
`import { NextResponse } from "next/server";
export function middleware(request) {
const url = new URL(request.url);
if (url.pathname === "/original") {
return NextResponse.rewrite(new URL("/rewrite-source", request.url));
}
return NextResponse.next();
}`,
);
// /original — should NOT be rendered (middleware rewrites away)
await fsp.writeFile(
path.join(chainTmpDir, "pages", "original.tsx"),
`export default function Original() { return <h1>ORIGINAL PAGE</h1>; }`,
);
// /intermediate — concrete page files win before afterFiles rewrites.
await fsp.writeFile(
path.join(chainTmpDir, "pages", "intermediate.tsx"),
`export default function Intermediate() { return <h1>INTERMEDIATE PAGE</h1>; }`,
);
// /final — the ultimate destination after both rewrites
await fsp.writeFile(
path.join(chainTmpDir, "pages", "final.tsx"),
`export default function Final() { return <h1>FINAL PAGE</h1>; }`,
);
await fsp.writeFile(
path.join(chainTmpDir, "pages", "index.tsx"),
`export default function Home() { return <h1>Home</h1>; }`,
);
const vinext = (await import("../packages/vinext/src/index.js")).default;
const plugins: any[] = [vinext()];
chainServer = await createServer({
root: chainTmpDir,
configFile: false,
plugins,
server: { port: 0 },
logLevel: "silent",
});
await chainServer.listen();
const addr = chainServer.httpServer?.address();
if (addr && typeof addr === "object") {
chainBaseUrl = `http://localhost:${addr.port}`;
}
}, 30000);
afterAll(async () => {
try {
(chainServer?.httpServer as any)?.closeAllConnections?.();
await Promise.race([
chainServer?.close(),
new Promise((resolve) => setTimeout(resolve, 5000)),
]);
} catch {
/* ignore */
}
const fsp = await import("node:fs/promises");
await fsp.rm(chainTmpDir, { recursive: true, force: true }).catch(() => {});
}, 15000);
it("chains middleware rewrites into afterFiles rewrites when no page file wins", async () => {
// Middleware rewrites /original to /rewrite-source. There is no page file at
// /rewrite-source, so the afterFiles rewrite can continue to /final.
const res = await fetch(`${chainBaseUrl}/original`);
const html = await res.text();
expect(html).toContain("FINAL PAGE");
expect(html).not.toContain("ORIGINAL PAGE");
});
it("applies afterFiles rewrites for paths with no page file", async () => {
const res = await fetch(`${chainBaseUrl}/rewrite-source`);
const html = await res.text();
expect(html).toContain("FINAL PAGE");
});
it("does not let afterFiles rewrites override concrete page files", async () => {
const res = await fetch(`${chainBaseUrl}/intermediate`);
const html = await res.text();
expect(html).toContain("INTERMEDIATE PAGE");
expect(html).not.toContain("FINAL PAGE");
});
it("chained: /original → middleware → /rewrite-source → config → /final", async () => {
const res = await fetch(`${chainBaseUrl}/original`);
expect(res.status).toBe(200);
const html = await res.text();
// Must reach the final page through both rewrites
expect(html).toContain("FINAL PAGE");
expect(html).not.toContain("ORIGINAL PAGE");
expect(html).not.toContain("INTERMEDIATE PAGE");
});
it("/final is directly accessible", async () => {
const res = await fetch(`${chainBaseUrl}/final`);
const html = await res.text();
expect(html).toContain("FINAL PAGE");
});
});
// ---------------------------------------------------------------------------
// Middleware rewrite status propagation (Pages Router dev)
// ---------------------------------------------------------------------------
describe("middleware rewriteStatus propagation (Pages Router dev)", () => {
let statusServer: ViteDevServer;
let statusBaseUrl: string;
let statusTmpDir: string;
beforeAll(async () => {
const os = await import("node:os");
const fsp = await import("node:fs/promises");
statusTmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-rewrite-status-"));
const rootNodeModules = path.resolve(import.meta.dirname, "../node_modules");
await fsp.symlink(rootNodeModules, path.join(statusTmpDir, "node_modules"), "junction");
await fsp.mkdir(path.join(statusTmpDir, "pages"), { recursive: true });
// Middleware: rewrites /blocked to /allowed with status 403
await fsp.writeFile(
path.join(statusTmpDir, "middleware.ts"),
`import { NextResponse } from "next/server";
export function middleware(request) {
const url = new URL(request.url);
if (url.pathname === "/blocked") {
return NextResponse.rewrite(new URL("/allowed", request.url), { status: 403 });
}
if (url.pathname === "/next-status") {
return NextResponse.next({ status: 404 });
}
return NextResponse.next();
}`,
);
await fsp.writeFile(
path.join(statusTmpDir, "pages", "allowed.tsx"),
`export default function Allowed() { return <h1>ALLOWED PAGE</h1>; }`,
);
await fsp.writeFile(
path.join(statusTmpDir, "pages", "index.tsx"),
`export default function Home() { return <h1>Home</h1>; }`,
);
await fsp.writeFile(
path.join(statusTmpDir, "pages", "next-status.tsx"),
`export default function NextStatus() { return <h1>NEXT STATUS PAGE</h1>; }`,
);
const vinext = (await import("../packages/vinext/src/index.js")).default;
statusServer = await createServer({
root: statusTmpDir,
configFile: false,
plugins: [vinext()],
server: { port: 0 },
logLevel: "silent",
});
await statusServer.listen();
const addr = statusServer.httpServer?.address();
if (addr && typeof addr === "object") {
statusBaseUrl = `http://localhost:${addr.port}`;
}
}, 30000);
afterAll(async () => {
try {
(statusServer?.httpServer as any)?.closeAllConnections?.();
await Promise.race([
statusServer?.close(),
new Promise((resolve) => setTimeout(resolve, 5000)),
]);
} catch {
/* ignore */
}
const fsp = await import("node:fs/promises");
await fsp.rm(statusTmpDir, { recursive: true, force: true }).catch(() => {});
}, 15000);
it("middleware rewrite with custom status returns that status code", async () => {
const res = await fetch(`${statusBaseUrl}/blocked`);
// Page content should be from /allowed (rewrite target)
const html = await res.text();
expect(html).toContain("ALLOWED PAGE");
// Status code should be 403 from the middleware rewrite
expect(res.status).toBe(403);
});
it("middleware next with custom status returns that status code", async () => {
const res = await fetch(`${statusBaseUrl}/next-status`);
const html = await res.text();
expect(html).toContain("NEXT STATUS PAGE");
expect(res.status).toBe(404);
});
it("normal requests without rewrite status return 200", async () => {
const res = await fetch(`${statusBaseUrl}/allowed`);
const html = await res.text();
expect(html).toContain("ALLOWED PAGE");
expect(res.status).toBe(200);
});
});
// ---------------------------------------------------------------------------
// Additional Next.js edge cases (batch 2)
// ---------------------------------------------------------------------------
describe("Pages Router edge cases (batch 2)", () => {
let edgeServer: ViteDevServer;
let edgeBaseUrl: string;
let edgeTmpDir: string;
beforeAll(async () => {
const os = await import("node:os");
const fsp = await import("node:fs/promises");
edgeTmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-edge2-"));
const rootNodeModules = path.resolve(import.meta.dirname, "../node_modules");
await fsp.symlink(rootNodeModules, path.join(edgeTmpDir, "node_modules"), "junction");
await fsp.writeFile(path.join(edgeTmpDir, "next.config.mjs"), `export default {};`);
await fsp.mkdir(path.join(edgeTmpDir, "pages"), { recursive: true });
await fsp.mkdir(path.join(edgeTmpDir, "pages", "api"), { recursive: true });
// Index page
await fsp.writeFile(
path.join(edgeTmpDir, "pages", "index.tsx"),
`export default function Home() { return <h1>Home</h1>; }`,
);
// GSSP permanent redirect
await fsp.writeFile(
path.join(edgeTmpDir, "pages", "perm-redirect.tsx"),
`export async function getServerSideProps() {
return { redirect: { destination: "/target", permanent: true } };
}
export default function PermanentRedirect() { return <h1>Should not see this</h1>; }`,
);
// GSSP redirect with custom statusCode
await fsp.writeFile(
path.join(edgeTmpDir, "pages", "custom-redirect.tsx"),
`export async function getServerSideProps() {
return { redirect: { destination: "/target", statusCode: 302 } };
}
export default function CustomRedirect() { return <h1>Should not see this</h1>; }`,
);
// Target of redirects
await fsp.writeFile(
path.join(edgeTmpDir, "pages", "target.tsx"),
`export default function Target() { return <h1>Target Page</h1>; }`,
);
// getStaticProps notFound
await fsp.writeFile(
path.join(edgeTmpDir, "pages", "gsp-notfound.tsx"),
`export async function getStaticProps() {
return { notFound: true };
}
export default function GspNotFound() { return <h1>Should not see this</h1>; }`,
);
// getStaticProps with empty props
await fsp.writeFile(
path.join(edgeTmpDir, "pages", "empty-props.tsx"),
`export async function getStaticProps() {
return { props: {} };
}
export default function EmptyProps() { return <h1>Empty Props Page</h1>; }`,
);
// GSSP props with nested objects
await fsp.writeFile(
path.join(edgeTmpDir, "pages", "nested-props.tsx"),
`export async function getServerSideProps() {
return { props: { user: { name: "Alice", settings: { theme: "dark", lang: "en" } } } };
}
export default function NestedProps({ user }) {
return <div><h1>Nested Props</h1><p id="name">{user.name}</p><p id="theme">{user.settings.theme}</p></div>;
}`,
);
// API route with different methods
await fsp.writeFile(
path.join(edgeTmpDir, "pages", "api", "methods.ts"),
`export default function handler(req, res) {
if (req.method === "POST") {
res.status(201).json({ method: "POST", received: true });
} else if (req.method === "PUT") {
res.status(200).json({ method: "PUT", updated: true });
} else if (req.method === "DELETE") {
res.status(200).json({ method: "DELETE", deleted: true });
} else if (req.method === "PATCH") {
res.status(200).json({ method: "PATCH", patched: true });
} else {
res.status(200).json({ method: req.method });
}
}`,
);
// API route with request body parsing
await fsp.writeFile(
path.join(edgeTmpDir, "pages", "api", "echo.ts"),
`export default async function handler(req, res) {
if (req.method === "POST") {
// Body should be parsed by the dev server
const body = req.body;
res.status(200).json({ echo: body });
} else {
res.status(405).json({ error: "Method not allowed" });
}
}`,
);
// Custom 404 page
await fsp.writeFile(
path.join(edgeTmpDir, "pages", "404.tsx"),
`export default function Custom404() { return <h1>Custom 404 - Not Found</h1>; }`,
);
const vinext = (await import("../packages/vinext/src/index.js")).default;
const plugins: any[] = [vinext()];
edgeServer = await createServer({
root: edgeTmpDir,
configFile: false,
plugins,
server: { port: 0 },
logLevel: "silent",
});
await edgeServer.listen();
const addr = edgeServer.httpServer?.address();
if (addr && typeof addr === "object") {
edgeBaseUrl = `http://localhost:${addr.port}`;
}
}, 30000);
afterAll(async () => {
try {
(edgeServer?.httpServer as any)?.closeAllConnections?.();
await Promise.race([
edgeServer?.close(),
new Promise((resolve) => setTimeout(resolve, 5000)),
]);
} catch {
/* ignore */
}
const fsp = await import("node:fs/promises");
await fsp.rm(edgeTmpDir, { recursive: true, force: true }).catch(() => {});
}, 15000);
// --- GSSP redirect variants ---
it("GSSP redirect with permanent: true returns 308", async () => {
const res = await fetch(`${edgeBaseUrl}/perm-redirect`, { redirect: "manual" });
expect(res.status).toBe(308);
expect(res.headers.get("location")).toBe("/target");
});
it("GSSP redirect with statusCode: 302 returns 302", async () => {
const res = await fetch(`${edgeBaseUrl}/custom-redirect`, { redirect: "manual" });
expect(res.status).toBe(302);
expect(res.headers.get("location")).toBe("/target");
});
// --- getStaticProps edge cases ---
it("getStaticProps notFound returns 404", async () => {
const res = await fetch(`${edgeBaseUrl}/gsp-notfound`);
expect(res.status).toBe(404);
});
it("getStaticProps with empty props renders page", async () => {
const res = await fetch(`${edgeBaseUrl}/empty-props`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("Empty Props Page");
});
// --- GSSP with nested objects ---
it("GSSP passes nested object props correctly", async () => {
const res = await fetch(`${edgeBaseUrl}/nested-props`);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("Alice");
expect(html).toContain("dark");
});
it("__NEXT_DATA__ contains nested GSSP props", async () => {
const res = await fetch(`${edgeBaseUrl}/nested-props`);
const html = await res.text();
// __NEXT_DATA__ is injected as: <script>window.__NEXT_DATA__ = {...}</script>
const match = html.match(
/<script id="__NEXT_DATA__" type="application\/json">([\s\S]*?)<\/script>/,
);
expect(match).not.toBeNull();
const data = JSON.parse(match![1]);
expect(data.props.pageProps.user.name).toBe("Alice");
expect(data.props.pageProps.user.settings.theme).toBe("dark");
expect(data.props.pageProps.user.settings.lang).toBe("en");
});
// --- API route HTTP methods ---
it("API route handles POST", async () => {
const res = await fetch(`${edgeBaseUrl}/api/methods`, { method: "POST" });
expect(res.status).toBe(201);
const json = await res.json();
expect(json.method).toBe("POST");
expect(json.received).toBe(true);
});
it("API route handles PUT", async () => {
const res = await fetch(`${edgeBaseUrl}/api/methods`, { method: "PUT" });
expect(res.status).toBe(200);
const json = await res.json();
expect(json.method).toBe("PUT");
expect(json.updated).toBe(true);
});
it("API route handles DELETE", async () => {
const res = await fetch(`${edgeBaseUrl}/api/methods`, { method: "DELETE" });
expect(res.status).toBe(200);
const json = await res.json();
expect(json.method).toBe("DELETE");
expect(json.deleted).toBe(true);
});
it("API route handles PATCH", async () => {
const res = await fetch(`${edgeBaseUrl}/api/methods`, { method: "PATCH" });
expect(res.status).toBe(200);
const json = await res.json();
expect(json.method).toBe("PATCH");
expect(json.patched).toBe(true);
});
// --- 404 edge cases ---
it("custom 404 page renders for unknown routes", async () => {
const res = await fetch(`${edgeBaseUrl}/nonexistent`);
expect(res.status).toBe(404);
const html = await res.text();
expect(html).toContain("Custom 404 - Not Found");
});
it("custom 404 page renders for getStaticProps notFound", async () => {
const res = await fetch(`${edgeBaseUrl}/gsp-notfound`);
expect(res.status).toBe(404);
const html = await res.text();
expect(html).toContain("Custom 404 - Not Found");
});
});
// ---------------------------------------------------------------------------
// .env file loading (Issue #228)
// ---------------------------------------------------------------------------
describe(".env file loading (Issue #228)", () => {
let envServer: ViteDevServer;
let envBaseUrl: string;
let tmpDir: string;
beforeAll(async () => {
const os = await import("node:os");
const fsp = await import("node:fs/promises");
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-env-"));
// Symlink node_modules
const rootNodeModules = path.resolve(import.meta.dirname, "../node_modules");
await fsp.symlink(rootNodeModules, path.join(tmpDir, "node_modules"), "junction");
// .env file with NEXT_PUBLIC_ and server-only vars
await fsp.writeFile(
path.join(tmpDir, ".env"),
`NEXT_PUBLIC_APP_NAME=vinext-test-app
SERVER_ONLY_SECRET=super-secret-123
BETTER_AUTH_URL=http://localhost:9999
`,
);
// pages directory with a page that renders env vars
await fsp.mkdir(path.join(tmpDir, "pages"), { recursive: true });
await fsp.writeFile(
path.join(tmpDir, "pages", "index.tsx"),
`export function getServerSideProps() {
return {
props: {
publicVar: process.env.NEXT_PUBLIC_APP_NAME ?? "NOT_LOADED",
serverVar: process.env.SERVER_ONLY_SECRET ?? "NOT_LOADED",
authUrl: process.env.BETTER_AUTH_URL ?? "NOT_LOADED",
},
};
}
export default function Home({ publicVar, serverVar, authUrl }) {
return (
<div>
<p id="public">PUBLIC:{publicVar}</p>
<p id="server">SERVER:{serverVar}</p>
<p id="auth">AUTH:{authUrl}</p>
</div>
);
}
`,
);
// Start server
const plugins: any[] = [vinext()];
envServer = await createServer({
root: tmpDir,
configFile: false,
plugins,
server: { port: 0 },
logLevel: "silent",
});
await envServer.listen();
const addr = envServer.httpServer?.address();
if (addr && typeof addr === "object") {
envBaseUrl = `http://localhost:${addr.port}`;
}
}, 30000);
afterAll(async () => {
// Clean up env vars loaded from .env to avoid polluting other tests
delete process.env.NEXT_PUBLIC_APP_NAME;
delete process.env.SERVER_ONLY_SECRET;
delete process.env.BETTER_AUTH_URL;
try {
(envServer?.httpServer as any)?.closeAllConnections?.();
await Promise.race([envServer?.close(), new Promise((r) => setTimeout(r, 3000))]);
} catch {}
if (tmpDir) {
const fsp = await import("node:fs/promises");
await fsp.rm(tmpDir, { recursive: true, force: true });
}
});
it("NEXT_PUBLIC_ vars from .env are available via process.env", () => {
// Check the Vite define config — NEXT_PUBLIC_ vars from .env should
// be registered as define entries for client-side inlining.
const defineKey = "process.env.NEXT_PUBLIC_APP_NAME";
const defineValue = envServer.config.define?.[defineKey];
expect(defineValue).toBe(JSON.stringify("vinext-test-app"));
});
it("server-only vars from .env are NOT exposed in client defines", () => {
// Only NEXT_PUBLIC_* vars should be inlined into the client bundle.
// Server-only secrets must never appear in config.define, or they
// would be embedded in client JavaScript and sent to the browser.
const defines = envServer.config.define ?? {};
expect(defines["process.env.SERVER_ONLY_SECRET"]).toBeUndefined();
expect(defines["process.env.BETTER_AUTH_URL"]).toBeUndefined();
// Also verify no define key contains the secret value itself
const allDefineValues = Object.values(defines).join(" ");
expect(allDefineValues).not.toContain("super-secret-123");
expect(allDefineValues).not.toContain("localhost:9999");
});
it("server-side code can read .env vars via process.env", async () => {
// getServerSideProps reads process.env at runtime.
// If .env is loaded, these should have the values from .env.
const res = await fetch(`${envBaseUrl}/`);
const html = await res.text();
// React SSR inserts <!-- --> comments between adjacent text nodes,
// so check __NEXT_DATA__ which has the raw props.
expect(html).toContain('"publicVar":"vinext-test-app"');
expect(html).toContain('"serverVar":"super-secret-123"');
expect(html).toContain('"authUrl":"http://localhost:9999"');
});
});