Files
cloudflare__vinext/tests/shims.test.ts
T
James Anderson 23a5cb2ceb refactor: delete app-dev-server.ts, point all callers at entries/ directly (#253 batch 3) (#379)
* refactor: delete app-dev-server.ts and update all references to entries/ directly

The file was already a pure re-export shim with no logic of its own.
Update every import/dynamic-import site to point straight at the
individual entry modules:

  entries/app-rsc-entry.ts    ← generateRscEntry, AppRouterConfig
  entries/app-ssr-entry.ts    ← generateSsrEntry
  entries/app-browser-entry.ts ← generateBrowserEntry

Sites updated:
  packages/vinext/src/index.ts
  tests/entry-templates.test.ts
  tests/app-router.test.ts
  tests/shims.test.ts

Also update stale comments in:
  server/middleware-codegen.ts, server/request-pipeline.ts,
  server/instrumentation.ts, shims/metadata.tsx,
  tests/rsc-streaming.test.ts, tests/nextjs-compat/rsc-context-lazy-stream.test.ts,
  examples/app-router-cloudflare/instrumentation*.ts

* docs: update app-dev-server.ts references in markdown files to entries/
2026-03-09 14:11:07 +00:00

8250 lines
302 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import path from "node:path";
import { PAGES_FIXTURE_DIR } from "./helpers.js";
import { isExternalUrl, isHashOnlyChange } from "../packages/vinext/src/shims/router.js";
import { isValidModulePath } from "../packages/vinext/src/client/validate-module-path.js";
const FIXTURE_DIR = PAGES_FIXTURE_DIR;
describe("next/navigation shim", () => {
it("exports usePathname, useSearchParams, useParams, useRouter", async () => {
const nav = await import(
"../packages/vinext/src/shims/navigation.js"
);
expect(typeof nav.usePathname).toBe("function");
expect(typeof nav.useSearchParams).toBe("function");
expect(typeof nav.useParams).toBe("function");
expect(typeof nav.useRouter).toBe("function");
});
// Regression test: useRouter() must return a stable singleton.
// Next.js returns the same router object reference on every call.
// Components using the router in useMemo/useEffect dependency arrays or
// wrapped in React.memo would re-render unnecessarily if each call
// returned a new object.
// Ported from: https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/hooks/hooks.test.ts
it("useRouter() returns the same object reference on every call (stable singleton)", async () => {
const { useRouter } = await import(
"../packages/vinext/src/shims/navigation.js"
);
const first = useRouter();
const second = useRouter();
const third = useRouter();
expect(first).toBe(second);
expect(second).toBe(third);
});
it("useRouter() singleton exposes the expected navigation methods", async () => {
const { useRouter } = await import(
"../packages/vinext/src/shims/navigation.js"
);
const router = useRouter();
expect(typeof router.push).toBe("function");
expect(typeof router.replace).toBe("function");
expect(typeof router.back).toBe("function");
expect(typeof router.forward).toBe("function");
expect(typeof router.refresh).toBe("function");
expect(typeof router.prefetch).toBe("function");
});
it("exports redirect, notFound, permanentRedirect", async () => {
const nav = await import(
"../packages/vinext/src/shims/navigation.js"
);
expect(typeof nav.redirect).toBe("function");
expect(typeof nav.notFound).toBe("function");
expect(typeof nav.permanentRedirect).toBe("function");
});
it("redirect() throws with correct digest", async () => {
const { redirect } = await import(
"../packages/vinext/src/shims/navigation.js"
);
try {
redirect("/login");
expect.unreachable("should have thrown");
} catch (e: any) {
expect(e.digest).toContain("NEXT_REDIRECT");
// URL is encodeURIComponent-encoded in the digest to prevent delimiter injection
expect(e.digest).toContain(encodeURIComponent("/login"));
}
});
it("redirect() encodes semicolons in URL to prevent digest injection", async () => {
const { redirect } = await import(
"../packages/vinext/src/shims/navigation.js"
);
try {
redirect("http://example.com;301");
expect.unreachable("should have thrown");
} catch (e: any) {
const parts = e.digest.split(";");
// The URL field must not leak into the status code position
expect(parts).toHaveLength(3); // NEXT_REDIRECT, type, encoded-url
expect(parts[0]).toBe("NEXT_REDIRECT");
expect(parts[1]).toBe("replace");
expect(decodeURIComponent(parts[2])).toBe("http://example.com;301");
}
});
it("notFound() throws with correct digest", async () => {
const { notFound } = await import(
"../packages/vinext/src/shims/navigation.js"
);
try {
notFound();
expect.unreachable("should have thrown");
} catch (e: any) {
expect(e.digest).toBe("NEXT_HTTP_ERROR_FALLBACK;404");
}
});
it("forbidden() throws with correct digest", async () => {
const { forbidden } = await import(
"../packages/vinext/src/shims/navigation.js"
);
try {
forbidden();
expect.unreachable("should have thrown");
} catch (e: any) {
expect(e.digest).toBe("NEXT_HTTP_ERROR_FALLBACK;403");
}
});
it("unauthorized() throws with correct digest", async () => {
const { unauthorized } = await import(
"../packages/vinext/src/shims/navigation.js"
);
try {
unauthorized();
expect.unreachable("should have thrown");
} catch (e: any) {
expect(e.digest).toBe("NEXT_HTTP_ERROR_FALLBACK;401");
}
});
it("isHTTPAccessFallbackError detects all HTTP access fallback errors", async () => {
const { notFound, forbidden, unauthorized, isHTTPAccessFallbackError, getAccessFallbackHTTPStatus } = await import(
"../packages/vinext/src/shims/navigation.js"
);
// Test notFound
try { notFound(); } catch (e) {
expect(isHTTPAccessFallbackError(e)).toBe(true);
expect(getAccessFallbackHTTPStatus(e)).toBe(404);
}
// Test forbidden
try { forbidden(); } catch (e) {
expect(isHTTPAccessFallbackError(e)).toBe(true);
expect(getAccessFallbackHTTPStatus(e)).toBe(403);
}
// Test unauthorized
try { unauthorized(); } catch (e) {
expect(isHTTPAccessFallbackError(e)).toBe(true);
expect(getAccessFallbackHTTPStatus(e)).toBe(401);
}
// Test non-access error
expect(isHTTPAccessFallbackError(new Error("random"))).toBe(false);
expect(isHTTPAccessFallbackError(null)).toBe(false);
// Test legacy NEXT_NOT_FOUND format
const legacyErr = new Error("old");
(legacyErr as any).digest = "NEXT_NOT_FOUND";
expect(isHTTPAccessFallbackError(legacyErr)).toBe(true);
expect(getAccessFallbackHTTPStatus(legacyErr)).toBe(404);
});
it("setNavigationContext / useParams works on server side", async () => {
const { setNavigationContext, useParams } = await import(
"../packages/vinext/src/shims/navigation.js"
);
setNavigationContext({
pathname: "/blog/test",
searchParams: new URLSearchParams(""),
params: { slug: "test" },
});
const params = useParams();
expect(params).toEqual({ slug: "test" });
setNavigationContext(null);
});
it("setClientParams provides referential stability for identical params", async () => {
const { setClientParams, getClientParams } = await import(
"../packages/vinext/src/shims/navigation.js"
);
// Set params initially
setClientParams({ slug: "hello" });
const first = getClientParams();
// Set params with same values — should return same object reference
setClientParams({ slug: "hello" });
const second = getClientParams();
expect(first).toBe(second); // referential equality
// Set params with different values — should return new object
setClientParams({ slug: "world" });
const third = getClientParams();
expect(third).not.toBe(first);
expect(third).toEqual({ slug: "world" });
// Clean up
setClientParams({});
});
it("exports useSelectedLayoutSegment and useSelectedLayoutSegments", async () => {
const nav = await import(
"../packages/vinext/src/shims/navigation.js"
);
expect(typeof nav.useSelectedLayoutSegment).toBe("function");
expect(typeof nav.useSelectedLayoutSegments).toBe("function");
});
it("useSelectedLayoutSegments returns empty array outside React context", async () => {
const { useSelectedLayoutSegments } = await import(
"../packages/vinext/src/shims/navigation.js"
);
// Outside a React tree, no LayoutSegmentProvider wraps us,
// so the context defaults to [].
const segments = useSelectedLayoutSegments();
expect(segments).toEqual([]);
});
it("useSelectedLayoutSegment returns null outside React context", async () => {
const { useSelectedLayoutSegment } = await import(
"../packages/vinext/src/shims/navigation.js"
);
// Outside a React tree, no LayoutSegmentProvider wraps us,
// so there are no child segments → null.
expect(useSelectedLayoutSegment()).toBeNull();
});
});
describe("next/headers shim", () => {
it("exports cookies, headers, draftMode", async () => {
const mod = await import(
"../packages/vinext/src/shims/headers.js"
);
expect(typeof mod.cookies).toBe("function");
expect(typeof mod.headers).toBe("function");
expect(typeof mod.draftMode).toBe("function");
});
it("headers() returns request headers from context", async () => {
const { setHeadersContext, headers } = await import(
"../packages/vinext/src/shims/headers.js"
);
const reqHeaders = new Headers({ "x-custom": "test-value" });
setHeadersContext({
headers: reqHeaders,
cookies: new Map(),
});
const h = await headers();
expect(h.get("x-custom")).toBe("test-value");
setHeadersContext(null);
});
it("cookies() returns parsed cookies from context", async () => {
const { setHeadersContext, cookies } = await import(
"../packages/vinext/src/shims/headers.js"
);
setHeadersContext({
headers: new Headers(),
cookies: new Map([
["session", "abc123"],
["theme", "dark"],
]),
});
const c = await cookies();
expect(c.get("session")).toEqual({ name: "session", value: "abc123" });
expect(c.get("theme")).toEqual({ name: "theme", value: "dark" });
expect(c.has("session")).toBe(true);
expect(c.has("missing")).toBe(false);
expect(c.size).toBe(2);
setHeadersContext(null);
});
it("headersContextFromRequest parses cookies from Request", async () => {
const { headersContextFromRequest } = await import(
"../packages/vinext/src/shims/headers.js"
);
const req = new Request("https://example.com", {
headers: { cookie: "a=1; b=2" },
});
const ctx = headersContextFromRequest(req);
expect(ctx.cookies.get("a")).toBe("1");
expect(ctx.cookies.get("b")).toBe("2");
expect(ctx.headers.get("cookie")).toBe("a=1; b=2");
});
it("headersContextFromRequest returns mutable headers (not the immutable Request.headers)", async () => {
// In Cloudflare Workers, Request.headers is immutable. applyMiddlewareRequestHeaders
// needs ctx.headers.set() after middleware runs, so the context must hold a mutable
// copy, not the original Headers reference.
const { headersContextFromRequest } = await import(
"../packages/vinext/src/shims/headers.js"
);
const req = new Request("https://example.com", {
headers: { "x-custom": "original" },
});
const ctx = headersContextFromRequest(req);
// Must be a separate, mutable copy — not the same reference
expect(ctx.headers).not.toBe(req.headers);
// Must be writable without throwing
expect(() => ctx.headers.set("x-custom", "modified")).not.toThrow();
expect(ctx.headers.get("x-custom")).toBe("modified");
// Original request headers must be unaffected
expect(req.headers.get("x-custom")).toBe("original");
});
it("throws when called outside request context", async () => {
const { headers, cookies } = await import(
"../packages/vinext/src/shims/headers.js"
);
// Ensure context is cleared
const { setHeadersContext } = await import(
"../packages/vinext/src/shims/headers.js"
);
setHeadersContext(null);
await expect(headers()).rejects.toThrow("Server Component");
await expect(cookies()).rejects.toThrow("Server Component");
});
it("draftMode() returns isEnabled=false when no bypass cookie", async () => {
const { setHeadersContext, draftMode } = await import(
"../packages/vinext/src/shims/headers.js"
);
setHeadersContext({
headers: new Headers(),
cookies: new Map(),
});
const dm = await draftMode();
expect(dm.isEnabled).toBe(false);
setHeadersContext(null);
});
it("draftMode() returns isEnabled=false for arbitrary cookie values (not signed)", async () => {
const { setHeadersContext, draftMode } = await import(
"../packages/vinext/src/shims/headers.js"
);
// An arbitrary cookie value should NOT enable draft mode — only the
// server-generated secret is valid.
setHeadersContext({
headers: new Headers(),
cookies: new Map([["__prerender_bypass", "1"]]),
});
const dm = await draftMode();
expect(dm.isEnabled).toBe(false);
setHeadersContext(null);
});
it("draftMode().enable() sets the bypass cookie in context", async () => {
const { setHeadersContext, draftMode, getDraftModeCookieHeader } = await import(
"../packages/vinext/src/shims/headers.js"
);
setHeadersContext({
headers: new Headers(),
cookies: new Map(),
});
const dm = await draftMode();
expect(dm.isEnabled).toBe(false);
dm.enable();
// After enabling, the cookie should be set on the context
const dm2 = await draftMode();
expect(dm2.isEnabled).toBe(true);
// The Set-Cookie header should be generated with a non-predictable secret (UUID)
const cookieHeader = getDraftModeCookieHeader();
expect(cookieHeader).toContain("__prerender_bypass=");
const bypassMatch = cookieHeader!.match(/__prerender_bypass=([^;]+)/);
expect(bypassMatch).not.toBeNull();
expect(bypassMatch![1]).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
);
expect(cookieHeader).toContain("HttpOnly");
setHeadersContext(null);
});
it("draftMode().disable() clears the bypass cookie", async () => {
const { setHeadersContext, draftMode, getDraftModeCookieHeader } = await import(
"../packages/vinext/src/shims/headers.js"
);
setHeadersContext({
headers: new Headers(),
cookies: new Map(),
});
// Enable first so the cookie is set to the server secret
const dm = await draftMode();
dm.enable();
// Consume the enable Set-Cookie header
getDraftModeCookieHeader();
const dm1 = await draftMode();
expect(dm1.isEnabled).toBe(true);
dm1.disable();
const dm2 = await draftMode();
expect(dm2.isEnabled).toBe(false);
const cookieHeader = getDraftModeCookieHeader();
expect(cookieHeader).toContain("Max-Age=0");
setHeadersContext(null);
});
});
describe("next/headers writable cookies", () => {
it("cookies().set() updates the cookie map and accumulates Set-Cookie headers", async () => {
const { setHeadersContext, cookies, getAndClearPendingCookies } = await import(
"../packages/vinext/src/shims/headers.js"
);
setHeadersContext({
headers: new Headers(),
cookies: new Map(),
});
const c = await cookies();
c.set("token", "xyz", { path: "/", httpOnly: true, secure: true });
// Cookie should now be readable
expect(c.get("token")).toEqual({ name: "token", value: "xyz" });
expect(c.has("token")).toBe(true);
// Pending Set-Cookie headers should be accumulated
const pending = getAndClearPendingCookies();
expect(pending.length).toBe(1);
expect(pending[0]).toContain("token=xyz");
expect(pending[0]).toContain("Path=/");
expect(pending[0]).toContain("HttpOnly");
expect(pending[0]).toContain("Secure");
// After clearing, should be empty
expect(getAndClearPendingCookies().length).toBe(0);
setHeadersContext(null);
});
it("cookies().delete() removes from map and adds Max-Age=0 header", async () => {
const { setHeadersContext, cookies, getAndClearPendingCookies } = await import(
"../packages/vinext/src/shims/headers.js"
);
setHeadersContext({
headers: new Headers(),
cookies: new Map([["session", "abc"]]),
});
const c = await cookies();
expect(c.has("session")).toBe(true);
c.delete("session");
expect(c.has("session")).toBe(false);
const pending = getAndClearPendingCookies();
expect(pending.length).toBe(1);
expect(pending[0]).toContain("session=");
expect(pending[0]).toContain("Max-Age=0");
setHeadersContext(null);
});
it("cookies().set() with object syntax works", async () => {
const { setHeadersContext, cookies, getAndClearPendingCookies } = await import(
"../packages/vinext/src/shims/headers.js"
);
setHeadersContext({
headers: new Headers(),
cookies: new Map(),
});
const c = await cookies();
c.set({ name: "pref", value: "dark", sameSite: "Lax" });
expect(c.get("pref")?.value).toBe("dark");
const pending = getAndClearPendingCookies();
expect(pending[0]).toContain("pref=dark");
expect(pending[0]).toContain("SameSite=Lax");
setHeadersContext(null);
});
});
describe("next/server shim", () => {
it("NextRequest wraps a standard Request with nextUrl and cookies", async () => {
const { NextRequest } = await import(
"../packages/vinext/src/shims/server.js"
);
const req = new NextRequest("https://example.com/blog?page=2", {
headers: { cookie: "session=abc123; theme=dark" },
});
expect(req.nextUrl.pathname).toBe("/blog");
expect(req.nextUrl.searchParams.get("page")).toBe("2");
expect(req.cookies.get("session")).toEqual({ name: "session", value: "abc123" });
expect(req.cookies.get("theme")).toEqual({ name: "theme", value: "dark" });
expect(req.cookies.has("session")).toBe(true);
expect(req.cookies.has("missing")).toBe(false);
});
it("NextResponse.json() creates a JSON response", async () => {
const { NextResponse } = await import(
"../packages/vinext/src/shims/server.js"
);
const res = NextResponse.json({ message: "hello" }, { status: 201 });
expect(res.status).toBe(201);
expect(res.headers.get("content-type")).toBe("application/json");
const body = await res.json();
expect(body).toEqual({ message: "hello" });
});
it("NextResponse.redirect() creates a redirect response", async () => {
const { NextResponse } = await import(
"../packages/vinext/src/shims/server.js"
);
const res = NextResponse.redirect("https://example.com/new", 308);
expect(res.status).toBe(308);
expect(res.headers.get("Location")).toBe("https://example.com/new");
});
it("NextResponse.rewrite() sets x-middleware-rewrite header", async () => {
const { NextResponse } = await import(
"../packages/vinext/src/shims/server.js"
);
const res = NextResponse.rewrite("https://example.com/internal");
expect(res.headers.get("x-middleware-rewrite")).toBe("https://example.com/internal");
});
it("NextResponse.next() sets x-middleware-next header", async () => {
const { NextResponse } = await import(
"../packages/vinext/src/shims/server.js"
);
const res = NextResponse.next();
expect(res.headers.get("x-middleware-next")).toBe("1");
});
it("ResponseCookies set/get/delete work", async () => {
const { NextResponse } = await import(
"../packages/vinext/src/shims/server.js"
);
const res = new NextResponse();
res.cookies.set("token", "xyz", { path: "/", httpOnly: true });
const cookie = res.cookies.get("token");
expect(cookie).toBeTruthy();
expect(cookie!.value).toBe("xyz");
// Verify the Set-Cookie header was set
const setCookie = res.headers.getSetCookie();
expect(setCookie.length).toBeGreaterThan(0);
expect(setCookie[0]).toContain("token=xyz");
expect(setCookie[0]).toContain("HttpOnly");
});
it("userAgentFromString detects bots", async () => {
const { userAgentFromString } = await import(
"../packages/vinext/src/shims/server.js"
);
const bot = userAgentFromString("Googlebot/2.1");
expect(bot.isBot).toBe(true);
const human = userAgentFromString("Mozilla/5.0");
expect(human.isBot).toBe(false);
});
it("after() runs a callback asynchronously without throwing", async () => {
const { after } = await import(
"../packages/vinext/src/shims/server.js"
);
let called = false;
after(() => {
called = true;
});
// after() schedules as a microtask, so await a tick
await new Promise((r) => setTimeout(r, 10));
expect(called).toBe(true);
});
it("after() handles a promise argument", async () => {
const { after } = await import(
"../packages/vinext/src/shims/server.js"
);
let resolved = false;
const p = new Promise<void>((resolve) => {
setTimeout(() => {
resolved = true;
resolve();
}, 5);
});
after(p);
await new Promise((r) => setTimeout(r, 20));
expect(resolved).toBe(true);
});
it("after() swallows errors from failing tasks", async () => {
const { after } = await import(
"../packages/vinext/src/shims/server.js"
);
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
after(() => {
throw new Error("task failed");
});
await new Promise((r) => setTimeout(r, 10));
expect(consoleError).toHaveBeenCalledWith(
"[vinext] after() task failed:",
expect.any(Error),
);
consoleError.mockRestore();
});
it("connection() returns a resolved promise", async () => {
const { connection } = await import(
"../packages/vinext/src/shims/server.js"
);
const result = connection();
expect(result).toBeInstanceOf(Promise);
await expect(result).resolves.toBeUndefined();
});
it("URLPattern is exported and available in Node 20+", async () => {
const { URLPattern } = await import(
"../packages/vinext/src/shims/server.js"
);
// Node 22+ has URLPattern globally; if available, test it works
if (globalThis.URLPattern) {
expect(URLPattern).toBe(globalThis.URLPattern);
const pattern = new URLPattern({ pathname: "/blog/:slug" });
const match = pattern.exec({ pathname: "/blog/hello-world" });
expect(match).toBeTruthy();
expect(match!.pathname.groups.slug).toBe("hello-world");
} else {
// URLPattern not available — our export should be a fallback that throws
expect(typeof URLPattern).toBe("function");
}
});
});
describe("next/config shim", () => {
it("getConfig returns default empty config", async () => {
const { default: getConfig } = await import(
"../packages/vinext/src/shims/config.js"
);
const config = getConfig();
expect(config).toEqual({
serverRuntimeConfig: {},
publicRuntimeConfig: {},
});
});
it("setConfig updates the runtime config", async () => {
const { default: getConfig, setConfig } = await import(
"../packages/vinext/src/shims/config.js"
);
setConfig({
serverRuntimeConfig: { secret: "s3cr3t" },
publicRuntimeConfig: { appName: "test-app" },
});
const config = getConfig();
expect(config.serverRuntimeConfig.secret).toBe("s3cr3t");
expect(config.publicRuntimeConfig.appName).toBe("test-app");
// Reset for other tests
setConfig({ serverRuntimeConfig: {}, publicRuntimeConfig: {} });
});
});
describe("next/cache shim", () => {
it("exports revalidateTag, revalidatePath, unstable_cache", async () => {
const mod = await import(
"../packages/vinext/src/shims/cache.js"
);
expect(typeof mod.revalidateTag).toBe("function");
expect(typeof mod.revalidatePath).toBe("function");
expect(typeof mod.unstable_cache).toBe("function");
});
it("exports setCacheHandler and getCacheHandler", async () => {
const mod = await import(
"../packages/vinext/src/shims/cache.js"
);
expect(typeof mod.setCacheHandler).toBe("function");
expect(typeof mod.getCacheHandler).toBe("function");
});
it("default handler is MemoryCacheHandler", async () => {
const { getCacheHandler, MemoryCacheHandler } = await import(
"../packages/vinext/src/shims/cache.js"
);
const handler = getCacheHandler();
expect(handler).toBeInstanceOf(MemoryCacheHandler);
});
it("unstable_cache caches function results", async () => {
const { unstable_cache, setCacheHandler, MemoryCacheHandler } =
await import("../packages/vinext/src/shims/cache.js");
// Fresh handler for isolation
setCacheHandler(new MemoryCacheHandler());
let callCount = 0;
const expensive = async (x: number) => {
callCount++;
return x * 2;
};
const cached = unstable_cache(expensive, ["test-fn"], {
tags: ["test-tag"],
});
const r1 = await cached(5);
expect(r1).toBe(10);
expect(callCount).toBe(1);
const r2 = await cached(5);
expect(r2).toBe(10);
expect(callCount).toBe(1); // Should NOT call the function again
const r3 = await cached(10);
expect(r3).toBe(20);
expect(callCount).toBe(2); // Different args = different cache key
// Reset
setCacheHandler(new MemoryCacheHandler());
});
it("unstable_cache caches undefined results", async () => {
const { unstable_cache, setCacheHandler, MemoryCacheHandler } =
await import("../packages/vinext/src/shims/cache.js");
setCacheHandler(new MemoryCacheHandler());
let callCount = 0;
const cached = unstable_cache(async () => {
callCount++;
return undefined;
}, ["undefined-result-test"]);
await expect(cached()).resolves.toBeUndefined();
expect(callCount).toBe(1);
await expect(cached()).resolves.toBeUndefined();
expect(callCount).toBe(1);
setCacheHandler(new MemoryCacheHandler());
});
it("revalidateTag invalidates cached entries", async () => {
const {
unstable_cache,
revalidateTag,
setCacheHandler,
MemoryCacheHandler,
} = await import("../packages/vinext/src/shims/cache.js");
setCacheHandler(new MemoryCacheHandler());
let callCount = 0;
const fn = async () => {
callCount++;
return "result-" + callCount;
};
const cached = unstable_cache(fn, ["revalidate-test"], {
tags: ["my-tag"],
});
const r1 = await cached();
expect(r1).toBe("result-1");
expect(callCount).toBe(1);
// Revalidate the tag
await revalidateTag("my-tag");
// Next call should re-execute the function
const r2 = await cached();
expect(r2).toBe("result-2");
expect(callCount).toBe(2);
// Reset
setCacheHandler(new MemoryCacheHandler());
});
it("revalidateTag accepts optional cacheLife profile (Next.js 16)", async () => {
const {
revalidateTag,
setCacheHandler,
MemoryCacheHandler,
} = await import("../packages/vinext/src/shims/cache.js");
setCacheHandler(new MemoryCacheHandler());
// Should not throw with profile argument
await revalidateTag("my-tag", "max");
await revalidateTag("my-tag", "hours");
await revalidateTag("my-tag", { expire: 3600 });
// Should still work without profile (deprecated single-arg form)
await revalidateTag("my-tag");
setCacheHandler(new MemoryCacheHandler());
});
it("exports updateTag function (Next.js 16)", async () => {
const mod = await import("../packages/vinext/src/shims/cache.js");
expect(typeof mod.updateTag).toBe("function");
});
it("updateTag invalidates cached entries", async () => {
const {
unstable_cache,
updateTag,
setCacheHandler,
MemoryCacheHandler,
} = await import("../packages/vinext/src/shims/cache.js");
setCacheHandler(new MemoryCacheHandler());
let callCount = 0;
const fn = async () => {
callCount++;
return "result-" + callCount;
};
const cached = unstable_cache(fn, ["update-tag-test"], {
tags: ["user-1"],
});
const r1 = await cached();
expect(r1).toBe("result-1");
// updateTag expires the cache
await updateTag("user-1");
const r2 = await cached();
expect(r2).toBe("result-2");
expect(callCount).toBe(2);
setCacheHandler(new MemoryCacheHandler());
});
it("exports refresh function (Next.js 16)", async () => {
const mod = await import("../packages/vinext/src/shims/cache.js");
expect(typeof mod.refresh).toBe("function");
// refresh() is a no-op on the server but should not throw
mod.refresh();
});
it("setCacheHandler swaps the active handler", async () => {
const { setCacheHandler, getCacheHandler, unstable_cache } = await import(
"../packages/vinext/src/shims/cache.js"
);
// Create a custom handler that tracks calls
const calls: string[] = [];
const customHandler = {
async get(key: string) {
calls.push(`get:${key}`);
return null;
},
async set(key: string) {
calls.push(`set:${key}`);
},
async revalidateTag(tags: string | string[]) {
const tagList = Array.isArray(tags) ? tags : [tags];
calls.push(`revalidateTag:${tagList.join(",")}`);
},
resetRequestCache() {
calls.push("reset");
},
};
const originalHandler = getCacheHandler();
setCacheHandler(customHandler);
const cached = unstable_cache(async () => 42, ["custom-test"]);
await cached();
expect(calls.some((c) => c.startsWith("get:"))).toBe(true);
expect(calls.some((c) => c.startsWith("set:"))).toBe(true);
// Restore
setCacheHandler(originalHandler);
});
it("MemoryCacheHandler.get/set round-trips values", async () => {
const { MemoryCacheHandler } = await import(
"../packages/vinext/src/shims/cache.js"
);
const handler = new MemoryCacheHandler();
await handler.set("test-key", {
kind: "FETCH",
data: { headers: {}, body: '{"x":1}', url: "test" },
tags: ["t1"],
revalidate: 3600,
});
const result = await handler.get("test-key");
expect(result).not.toBeNull();
expect(result!.value).not.toBeNull();
expect(result!.value!.kind).toBe("FETCH");
if (result!.value!.kind === "FETCH") {
expect(result!.value!.data.body).toBe('{"x":1}');
}
});
it("MemoryCacheHandler respects tag invalidation", async () => {
const { MemoryCacheHandler } = await import(
"../packages/vinext/src/shims/cache.js"
);
const handler = new MemoryCacheHandler();
await handler.set(
"tagged-entry",
{
kind: "FETCH",
data: { headers: {}, body: '"cached"', url: "test" },
tags: ["fresh-tag"],
revalidate: 3600,
},
{ tags: ["fresh-tag"] },
);
// Should return the entry
let result = await handler.get("tagged-entry");
expect(result).not.toBeNull();
// Invalidate the tag
await handler.revalidateTag("fresh-tag");
// Should now return null (invalidated)
result = await handler.get("tagged-entry");
expect(result).toBeNull();
});
it("exports unstable_noStore and noStore as no-ops", async () => {
const { unstable_noStore, noStore } = await import(
"../packages/vinext/src/shims/cache.js"
);
expect(typeof unstable_noStore).toBe("function");
expect(typeof noStore).toBe("function");
// Both should run without throwing
expect(() => unstable_noStore()).not.toThrow();
expect(() => noStore()).not.toThrow();
});
it("exports cacheLife with built-in profiles", async () => {
const { cacheLife, cacheLifeProfiles } = await import(
"../packages/vinext/src/shims/cache.js"
);
expect(typeof cacheLife).toBe("function");
expect(typeof cacheLifeProfiles).toBe("object");
// Built-in profiles should exist
expect(cacheLifeProfiles).toHaveProperty("default");
expect(cacheLifeProfiles).toHaveProperty("seconds");
expect(cacheLifeProfiles).toHaveProperty("minutes");
expect(cacheLifeProfiles).toHaveProperty("hours");
expect(cacheLifeProfiles).toHaveProperty("days");
expect(cacheLifeProfiles).toHaveProperty("weeks");
expect(cacheLifeProfiles).toHaveProperty("max");
// Profile shapes
expect(cacheLifeProfiles.seconds).toEqual({ stale: 30, revalidate: 1, expire: 60 });
expect(cacheLifeProfiles.max).toEqual({ stale: 300, revalidate: 2592000, expire: 31536000 });
// Should run without throwing with valid inputs
expect(() => cacheLife("default")).not.toThrow();
expect(() => cacheLife("hours")).not.toThrow();
expect(() => cacheLife({ stale: 60, revalidate: 300, expire: 3600 })).not.toThrow();
});
it("cacheLife warns on unknown profile", async () => {
const { cacheLife } = await import(
"../packages/vinext/src/shims/cache.js"
);
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {});
cacheLife("nonexistent-profile");
expect(consoleWarn).toHaveBeenCalledWith(
expect.stringContaining("unknown profile"),
);
consoleWarn.mockRestore();
});
it("cacheLife warns when expire < revalidate", async () => {
const { cacheLife } = await import(
"../packages/vinext/src/shims/cache.js"
);
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {});
cacheLife({ revalidate: 3600, expire: 60 });
expect(consoleWarn).toHaveBeenCalledWith(
expect.stringContaining("expire must be >= revalidate"),
);
consoleWarn.mockRestore();
});
it("exports cacheTag as a no-op function", async () => {
const { cacheTag } = await import(
"../packages/vinext/src/shims/cache.js"
);
expect(typeof cacheTag).toBe("function");
// Should accept multiple tags without throwing
expect(() => cacheTag("tag1", "tag2", "tag3")).not.toThrow();
});
it("unstable_cache re-fetches when entry is stale (time-expired)", async () => {
const { unstable_cache, setCacheHandler, MemoryCacheHandler } =
await import("../packages/vinext/src/shims/cache.js");
setCacheHandler(new MemoryCacheHandler());
let callCount = 0;
const fn = async () => {
callCount++;
return "value-" + callCount;
};
// Use a very short revalidate (1 second)
const cached = unstable_cache(fn, ["stale-test"], {
revalidate: 1,
});
const r1 = await cached();
expect(r1).toBe("value-1");
expect(callCount).toBe(1);
// Cached call should still return the same value
const r2 = await cached();
expect(r2).toBe("value-1");
expect(callCount).toBe(1);
// Manually expire the entry by advancing time past revalidate window.
// We do this by patching the entry's revalidateAt in the handler.
const handler = (await import("../packages/vinext/src/shims/cache.js")).getCacheHandler();
const store = (handler as any).store as Map<string, any>;
for (const [, entry] of store) {
if (entry.revalidateAt) {
entry.revalidateAt = Date.now() - 1000; // expired 1 second ago
}
}
// Next call should re-fetch because entry is now stale
const r3 = await cached();
expect(r3).toBe("value-2");
expect(callCount).toBe(2);
setCacheHandler(new MemoryCacheHandler());
});
it("unstable_cache with no revalidate option caches indefinitely", async () => {
const { unstable_cache, setCacheHandler, MemoryCacheHandler } =
await import("../packages/vinext/src/shims/cache.js");
setCacheHandler(new MemoryCacheHandler());
let callCount = 0;
const fn = async () => {
callCount++;
return "result-" + callCount;
};
// No revalidate option (should cache indefinitely, not expire at t=0)
const cached = unstable_cache(fn, ["no-revalidate-test"]);
const r1 = await cached();
expect(r1).toBe("result-1");
expect(callCount).toBe(1);
// Should return cached value (not re-fetch)
const r2 = await cached();
expect(r2).toBe("result-1");
expect(callCount).toBe(1);
// Even after "time passes", should still be cached (not stale)
const handler = (await import("../packages/vinext/src/shims/cache.js")).getCacheHandler();
const store = (handler as any).store as Map<string, any>;
for (const [, entry] of store) {
// revalidateAt should be null (indefinite) not 0 or past timestamp
expect(entry.revalidateAt).toBeNull();
}
setCacheHandler(new MemoryCacheHandler());
});
});
// ---------------------------------------------------------------------------
// "use cache" runtime tests
// ---------------------------------------------------------------------------
describe('"use cache" runtime', () => {
it("registerCachedFunction caches return values", async () => {
const { registerCachedFunction } = await import(
"../packages/vinext/src/shims/cache-runtime.js"
);
// Reset state
const { setCacheHandler, MemoryCacheHandler } = await import(
"../packages/vinext/src/shims/cache.js"
);
setCacheHandler(new MemoryCacheHandler());
let callCount = 0;
const fn = async (x: number) => {
callCount++;
return { result: x * 2 };
};
const cached = registerCachedFunction(fn, "test:double");
const r1 = await cached(5);
expect(r1).toEqual({ result: 10 });
expect(callCount).toBe(1);
// Second call with same args — should be cached
const r2 = await cached(5);
expect(r2).toEqual({ result: 10 });
expect(callCount).toBe(1); // Not called again
// Different args — cache miss
const r3 = await cached(7);
expect(r3).toEqual({ result: 14 });
expect(callCount).toBe(2);
});
it("registerCachedFunction respects cacheLife inside cached function", async () => {
const { registerCachedFunction } = await import(
"../packages/vinext/src/shims/cache-runtime.js"
);
const { setCacheHandler, MemoryCacheHandler, cacheLife } = await import(
"../packages/vinext/src/shims/cache.js"
);
setCacheHandler(new MemoryCacheHandler());
let callCount = 0;
const fn = async () => {
cacheLife("seconds"); // revalidate: 1s
callCount++;
return { ts: Date.now() };
};
const cached = registerCachedFunction(fn, "test:cachelife");
await cached();
expect(callCount).toBe(1);
// Immediate second call — cached
await cached();
expect(callCount).toBe(1);
});
it("registerCachedFunction collects cacheTag", async () => {
const { registerCachedFunction } = await import(
"../packages/vinext/src/shims/cache-runtime.js"
);
const { setCacheHandler, MemoryCacheHandler, cacheTag } = await import(
"../packages/vinext/src/shims/cache.js"
);
const handler = new MemoryCacheHandler();
setCacheHandler(handler);
const fn = async () => {
cacheTag("my-tag", "another-tag");
return { data: "tagged" };
};
const cached = registerCachedFunction(fn, "test:tags");
await cached();
// The cache entry should have tags
const entry = await handler.get("use-cache:test:tags");
expect(entry).not.toBeNull();
expect(entry?.value).toHaveProperty("kind", "FETCH");
if (entry?.value && entry.value.kind === "FETCH") {
expect(entry.value.tags).toContain("my-tag");
expect(entry.value.tags).toContain("another-tag");
}
});
it("revalidateTag invalidates cached entries", async () => {
const { registerCachedFunction } = await import(
"../packages/vinext/src/shims/cache-runtime.js"
);
const { setCacheHandler, MemoryCacheHandler, cacheTag, revalidateTag } = await import(
"../packages/vinext/src/shims/cache.js"
);
setCacheHandler(new MemoryCacheHandler());
let callCount = 0;
const fn = async () => {
cacheTag("invalidate-me");
callCount++;
return { count: callCount };
};
const cached = registerCachedFunction(fn, "test:invalidate");
const r1 = await cached();
expect(r1).toEqual({ count: 1 });
expect(callCount).toBe(1);
// Cached
const r2 = await cached();
expect(r2).toEqual({ count: 1 });
expect(callCount).toBe(1);
// Invalidate the tag
await revalidateTag("invalidate-me");
// Should re-execute
const r3 = await cached();
expect(r3).toEqual({ count: 2 });
expect(callCount).toBe(2);
});
it("private variant uses per-request cache", async () => {
const { registerCachedFunction, clearPrivateCache } = await import(
"../packages/vinext/src/shims/cache-runtime.js"
);
let callCount = 0;
const fn = async () => {
callCount++;
return { count: callCount };
};
const cached = registerCachedFunction(fn, "test:private", "private");
const r1 = await cached();
expect(r1).toEqual({ count: 1 });
// Same request — cached
const r2 = await cached();
expect(r2).toEqual({ count: 1 });
// Clear private cache (simulates new request)
clearPrivateCache();
// Should re-execute
const r3 = await cached();
expect(r3).toEqual({ count: 2 });
});
it("cacheLife minimum-wins rule applies", async () => {
const { registerCachedFunction } = await import(
"../packages/vinext/src/shims/cache-runtime.js"
);
const { setCacheHandler, MemoryCacheHandler, cacheLife } = await import(
"../packages/vinext/src/shims/cache.js"
);
const handler = new MemoryCacheHandler();
setCacheHandler(handler);
const fn = async () => {
cacheLife("hours"); // revalidate: 3600
cacheLife("seconds"); // revalidate: 1 — this should win
return { data: "min-wins" };
};
const cached = registerCachedFunction(fn, "test:min-wins");
await cached();
// The entry should have the minimum revalidate (1 second from "seconds" profile)
const entry = await handler.get("use-cache:test:min-wins");
expect(entry).not.toBeNull();
if (entry?.value && entry.value.kind === "FETCH") {
expect(entry.value.revalidate).toBe(1);
}
});
it("getCacheContext returns null outside cache function", async () => {
const { getCacheContext } = await import(
"../packages/vinext/src/shims/cache-runtime.js"
);
expect(getCacheContext()).toBeNull();
});
it("consistent cache keys for same objects regardless of key order", async () => {
const { registerCachedFunction } = await import(
"../packages/vinext/src/shims/cache-runtime.js"
);
const { setCacheHandler, MemoryCacheHandler } = await import(
"../packages/vinext/src/shims/cache.js"
);
setCacheHandler(new MemoryCacheHandler());
let callCount = 0;
const fn = async (_opts: Record<string, unknown>) => {
callCount++;
return { result: "ok" };
};
const cached = registerCachedFunction(fn, "test:stable-key");
// Different key order, same content — should be same cache key
await cached({ b: 2, a: 1 });
expect(callCount).toBe(1);
await cached({ a: 1, b: 2 });
expect(callCount).toBe(1); // Same cache key, still cached
});
it("cached function with no args works correctly", async () => {
const { registerCachedFunction } = await import(
"../packages/vinext/src/shims/cache-runtime.js"
);
const { setCacheHandler, MemoryCacheHandler } = await import(
"../packages/vinext/src/shims/cache.js"
);
setCacheHandler(new MemoryCacheHandler());
let callCount = 0;
const fn = async () => {
callCount++;
return { hello: "world" };
};
const cached = registerCachedFunction(fn, "test:no-args");
const r1 = await cached();
const r2 = await cached();
expect(r1).toEqual({ hello: "world" });
expect(r2).toEqual({ hello: "world" });
expect(callCount).toBe(1);
});
it("falls back to JSON when RSC module is unavailable (test environment)", async () => {
// In vitest, @vitejs/plugin-rsc/react/rsc is not available (no Vite RSC
// environment). The runtime should gracefully fall back to JSON.stringify
// for cache values and stableStringify for cache keys.
const { registerCachedFunction } = await import(
"../packages/vinext/src/shims/cache-runtime.js"
);
const { setCacheHandler, MemoryCacheHandler } = await import(
"../packages/vinext/src/shims/cache.js"
);
const handler = new MemoryCacheHandler();
setCacheHandler(handler);
const fn = async (x: number) => ({ doubled: x * 2 });
const cached = registerCachedFunction(fn, "test:json-fallback");
const r1 = await cached(3);
expect(r1).toEqual({ doubled: 6 });
// Verify the stored value is JSON (no x-vinext-rsc header)
// stableStringify wraps args as an array: [3]
const entry = await handler.get("use-cache:test:json-fallback:[3]");
expect(entry).not.toBeNull();
if (entry?.value && entry.value.kind === "FETCH") {
expect(entry.value.data.headers["x-vinext-rsc"]).toBeUndefined();
expect(JSON.parse(entry.value.data.body)).toEqual({ doubled: 6 });
}
});
it("skips caching for non-serializable args (functions)", async () => {
const { registerCachedFunction } = await import(
"../packages/vinext/src/shims/cache-runtime.js"
);
const { setCacheHandler, MemoryCacheHandler } = await import(
"../packages/vinext/src/shims/cache.js"
);
setCacheHandler(new MemoryCacheHandler());
let callCount = 0;
const fn = async (_cb: () => void) => {
callCount++;
return { called: true };
};
const cached = registerCachedFunction(fn, "test:fn-arg");
// Functions can't be serialized — should execute every time (no caching)
await cached(() => {});
await cached(() => {});
expect(callCount).toBe(2);
});
it("produces different cache entries for Promise-augmented params with different values", async () => {
// Regression test: Next.js 16 params are created via
// Object.assign(Promise.resolve(params), params) — a "thenable object".
// encodeReply with temporaryReferences treats Promises as temp refs,
// which excluded the actual param values from the cache key.
// This caused all dynamic route pages with "use cache" to share one
// cache entry (e.g., /layouts/sports showed /layouts/electronics data).
const { registerCachedFunction } = await import(
"../packages/vinext/src/shims/cache-runtime.js"
);
const { setCacheHandler, MemoryCacheHandler } = await import(
"../packages/vinext/src/shims/cache.js"
);
setCacheHandler(new MemoryCacheHandler());
let callCount = 0;
// Simulates a page component: async function Page({ params }) { ... }
const fn = async (props: { params: any }) => {
callCount++;
const p = typeof props.params.then === "function"
? await props.params
: props.params;
return { section: p.section, data: `data-for-${p.section}` };
};
const cached = registerCachedFunction(fn, "test:thenable-params");
// Create Promise-augmented params (same pattern as entries/app-rsc-entry.ts)
const electronicsParams = { section: "electronics" };
const asyncElectronics = Object.assign(
Promise.resolve(electronicsParams),
electronicsParams,
);
const sportsParams = { section: "sports" };
const asyncSports = Object.assign(
Promise.resolve(sportsParams),
sportsParams,
);
// First call — electronics
const r1 = await cached({ params: asyncElectronics });
expect(r1).toEqual({ section: "electronics", data: "data-for-electronics" });
expect(callCount).toBe(1);
// Second call with SAME params — should be cached
const asyncElectronics2 = Object.assign(
Promise.resolve({ section: "electronics" }),
{ section: "electronics" },
);
const r2 = await cached({ params: asyncElectronics2 });
expect(r2).toEqual({ section: "electronics", data: "data-for-electronics" });
expect(callCount).toBe(1); // Cache hit
// Third call with DIFFERENT params — must be a cache MISS
const r3 = await cached({ params: asyncSports });
expect(r3).toEqual({ section: "sports", data: "data-for-sports" });
expect(callCount).toBe(2); // Must have called the function again!
});
});
describe("replyToCacheKey deterministic hashing", () => {
it("returns string replies as-is", async () => {
const { replyToCacheKey } = await import(
"../packages/vinext/src/shims/cache-runtime.js"
);
expect(await replyToCacheKey("hello")).toBe("hello");
expect(await replyToCacheKey("")).toBe("");
});
it("produces stable hash for FormData with string entries", async () => {
const { replyToCacheKey } = await import(
"../packages/vinext/src/shims/cache-runtime.js"
);
const fd1 = new FormData();
fd1.append("a", "1");
fd1.append("b", "2");
const fd2 = new FormData();
fd2.append("a", "1");
fd2.append("b", "2");
const key1 = await replyToCacheKey(fd1);
const key2 = await replyToCacheKey(fd2);
expect(key1).toBe(key2);
});
it("produces stable hash regardless of entry insertion order", async () => {
const { replyToCacheKey } = await import(
"../packages/vinext/src/shims/cache-runtime.js"
);
const fd1 = new FormData();
fd1.append("b", "2");
fd1.append("a", "1");
const fd2 = new FormData();
fd2.append("a", "1");
fd2.append("b", "2");
const key1 = await replyToCacheKey(fd1);
const key2 = await replyToCacheKey(fd2);
expect(key1).toBe(key2);
});
it("produces stable hash for FormData with Blob entries", async () => {
const { replyToCacheKey } = await import(
"../packages/vinext/src/shims/cache-runtime.js"
);
const blob = new Blob([new Uint8Array([1, 2, 3])], { type: "application/octet-stream" });
const fd1 = new FormData();
fd1.append("data", blob);
const fd2 = new FormData();
fd2.append("data", blob);
const key1 = await replyToCacheKey(fd1);
const key2 = await replyToCacheKey(fd2);
expect(key1).toBe(key2);
});
it("produces different hashes for different FormData content", async () => {
const { replyToCacheKey } = await import(
"../packages/vinext/src/shims/cache-runtime.js"
);
const fd1 = new FormData();
fd1.append("a", "1");
const fd2 = new FormData();
fd2.append("a", "2");
const key1 = await replyToCacheKey(fd1);
const key2 = await replyToCacheKey(fd2);
expect(key1).not.toBe(key2);
});
});
describe("middleware runner", () => {
it("findMiddlewareFile finds middleware.ts at project root", async () => {
const { findMiddlewareFile } = await import(
"../packages/vinext/src/server/middleware.js"
);
// pages-basic fixture has middleware.ts
const result = findMiddlewareFile(FIXTURE_DIR);
expect(result).not.toBeNull();
expect(result).toContain("middleware.ts");
});
it("findMiddlewareFile returns null when no middleware exists", async () => {
const { findMiddlewareFile } = await import(
"../packages/vinext/src/server/middleware.js"
);
const result = findMiddlewareFile("/tmp/nonexistent-dir-" + Date.now());
expect(result).toBeNull();
});
it("findMiddlewareFile prefers proxy.ts over middleware.ts (Next.js 16)", async () => {
const fs = await import("node:fs");
const path = await import("node:path");
const os = await import("node:os");
const { findMiddlewareFile } = await import(
"../packages/vinext/src/server/middleware.js"
);
// Create a temp directory with both proxy.ts and middleware.ts
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-proxy-test-"));
try {
fs.writeFileSync(path.join(tmpDir, "proxy.ts"), "export default function proxy() {}");
fs.writeFileSync(path.join(tmpDir, "middleware.ts"), "export function middleware() {}");
const result = findMiddlewareFile(tmpDir);
expect(result).not.toBeNull();
expect(result).toContain("proxy.ts");
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it("findMiddlewareFile finds proxy.js", async () => {
const fs = await import("node:fs");
const path = await import("node:path");
const os = await import("node:os");
const { findMiddlewareFile } = await import(
"../packages/vinext/src/server/middleware.js"
);
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-proxy-test-"));
try {
fs.writeFileSync(path.join(tmpDir, "proxy.js"), "module.exports = function proxy() {}");
const result = findMiddlewareFile(tmpDir);
expect(result).not.toBeNull();
expect(result).toContain("proxy.js");
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
});
// ---------------------------------------------------------------------------
// Middleware/proxy export validation tests
// Ported from Next.js: test/e2e/app-dir/proxy-missing-export/proxy-missing-export.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/proxy-missing-export/proxy-missing-export.test.ts
describe("middleware/proxy export validation", () => {
it("isProxyFile returns true for proxy files", async () => {
const { isProxyFile } = await import(
"../packages/vinext/src/server/middleware.js"
);
expect(isProxyFile("/app/proxy.ts")).toBe(true);
expect(isProxyFile("/app/proxy.js")).toBe(true);
expect(isProxyFile("/app/proxy.mjs")).toBe(true);
expect(isProxyFile("/app/src/proxy.ts")).toBe(true);
});
it("isProxyFile returns false for middleware files", async () => {
const { isProxyFile } = await import(
"../packages/vinext/src/server/middleware.js"
);
expect(isProxyFile("/app/middleware.ts")).toBe(false);
expect(isProxyFile("/app/middleware.js")).toBe(false);
expect(isProxyFile("/app/middleware.mjs")).toBe(false);
expect(isProxyFile("/app/src/middleware.ts")).toBe(false);
});
it("resolveMiddlewareHandler: proxy.ts with named proxy export", async () => {
const { resolveMiddlewareHandler } = await import(
"../packages/vinext/src/server/middleware.js"
);
const fn = () => {};
const handler = resolveMiddlewareHandler({ proxy: fn }, "/app/proxy.ts");
expect(handler).toBe(fn);
});
it("resolveMiddlewareHandler: proxy.ts with default export", async () => {
const { resolveMiddlewareHandler } = await import(
"../packages/vinext/src/server/middleware.js"
);
const fn = () => {};
const handler = resolveMiddlewareHandler({ default: fn }, "/app/proxy.ts");
expect(handler).toBe(fn);
});
it("resolveMiddlewareHandler: proxy.ts prefers named proxy over default", async () => {
const { resolveMiddlewareHandler } = await import(
"../packages/vinext/src/server/middleware.js"
);
const proxyFn = () => {};
const defaultFn = () => {};
const handler = resolveMiddlewareHandler(
{ proxy: proxyFn, default: defaultFn },
"/app/proxy.ts",
);
expect(handler).toBe(proxyFn);
});
it("resolveMiddlewareHandler: proxy.ts with default arrow function export", async () => {
const { resolveMiddlewareHandler } = await import(
"../packages/vinext/src/server/middleware.js"
);
const fn = () => {};
const handler = resolveMiddlewareHandler({ default: fn }, "/app/proxy.ts");
expect(handler).toBe(fn);
});
it("resolveMiddlewareHandler: proxy.ts throws when only 'middleware' is exported (wrong name)", async () => {
const { resolveMiddlewareHandler } = await import(
"../packages/vinext/src/server/middleware.js"
);
expect(() =>
resolveMiddlewareHandler({ middleware: () => {} }, "/app/proxy.ts"),
).toThrow('must export a function named `proxy` or a `default` function');
});
it("resolveMiddlewareHandler: proxy.ts throws when export is aliased to wrong name", async () => {
const { resolveMiddlewareHandler } = await import(
"../packages/vinext/src/server/middleware.js"
);
expect(() =>
resolveMiddlewareHandler({ handler: () => {} }, "/app/proxy.ts"),
).toThrow('must export a function named `proxy` or a `default` function');
});
it("resolveMiddlewareHandler: proxy.ts throws when no exports", async () => {
const { resolveMiddlewareHandler } = await import(
"../packages/vinext/src/server/middleware.js"
);
expect(() =>
resolveMiddlewareHandler({}, "/app/proxy.ts"),
).toThrow('must export a function named `proxy` or a `default` function');
});
it("resolveMiddlewareHandler: proxy.ts throws when export is not a function", async () => {
const { resolveMiddlewareHandler } = await import(
"../packages/vinext/src/server/middleware.js"
);
expect(() =>
resolveMiddlewareHandler({ proxy: "not a function" }, "/app/proxy.ts"),
).toThrow('must export a function named `proxy` or a `default` function');
});
it("resolveMiddlewareHandler: middleware.ts with named middleware export", async () => {
const { resolveMiddlewareHandler } = await import(
"../packages/vinext/src/server/middleware.js"
);
const fn = () => {};
const handler = resolveMiddlewareHandler({ middleware: fn }, "/app/middleware.ts");
expect(handler).toBe(fn);
});
it("resolveMiddlewareHandler: middleware.ts with default export", async () => {
const { resolveMiddlewareHandler } = await import(
"../packages/vinext/src/server/middleware.js"
);
const fn = () => {};
const handler = resolveMiddlewareHandler({ default: fn }, "/app/middleware.ts");
expect(handler).toBe(fn);
});
it("resolveMiddlewareHandler: middleware.ts throws when only 'proxy' is exported (wrong name)", async () => {
const { resolveMiddlewareHandler } = await import(
"../packages/vinext/src/server/middleware.js"
);
expect(() =>
resolveMiddlewareHandler({ proxy: () => {} }, "/app/middleware.ts"),
).toThrow('must export a function named `middleware` or a `default` function');
});
it("resolveMiddlewareHandler: middleware.ts throws when no exports", async () => {
const { resolveMiddlewareHandler } = await import(
"../packages/vinext/src/server/middleware.js"
);
expect(() =>
resolveMiddlewareHandler({}, "/app/middleware.ts"),
).toThrow('must export a function named `middleware` or a `default` function');
});
});
// ---------------------------------------------------------------------------
// matchPattern / matchesMiddleware unit tests
describe("middleware matcher patterns", () => {
it("matchPattern: exact path match", async () => {
const { matchPattern } = await import(
"../packages/vinext/src/server/middleware.js"
);
expect(matchPattern("/about", "/about")).toBe(true);
expect(matchPattern("/about", "/other")).toBe(false);
expect(matchPattern("/", "/")).toBe(true);
});
it("matchPattern: named parameter (:param)", async () => {
const { matchPattern } = await import(
"../packages/vinext/src/server/middleware.js"
);
expect(matchPattern("/user/123", "/user/:id")).toBe(true);
expect(matchPattern("/user/abc", "/user/:id")).toBe(true);
expect(matchPattern("/user/", "/user/:id")).toBe(false);
expect(matchPattern("/user/123/posts", "/user/:id")).toBe(false);
});
it("matchPattern: wildcard (:path*) matches zero or more segments", async () => {
const { matchPattern } = await import(
"../packages/vinext/src/server/middleware.js"
);
expect(matchPattern("/dashboard", "/dashboard/:path*")).toBe(true);
expect(matchPattern("/dashboard/settings", "/dashboard/:path*")).toBe(true);
expect(matchPattern("/dashboard/settings/profile", "/dashboard/:path*")).toBe(true);
expect(matchPattern("/other", "/dashboard/:path*")).toBe(false);
});
it("matchPattern: one-or-more (:path+) requires at least one segment", async () => {
const { matchPattern } = await import(
"../packages/vinext/src/server/middleware.js"
);
expect(matchPattern("/api/users", "/api/:path+")).toBe(true);
expect(matchPattern("/api/users/123", "/api/:path+")).toBe(true);
expect(matchPattern("/api", "/api/:path+")).toBe(false);
// /api/ has no actual segment after the slash
expect(matchPattern("/api/", "/api/:path+")).toBe(false);
});
it("matchPattern: regex patterns with groups", async () => {
const { matchPattern } = await import(
"../packages/vinext/src/server/middleware.js"
);
// Common Next.js matcher: /((?!api|_next|favicon\.ico).*)
expect(matchPattern("/about", "/((?!api|_next|favicon\\.ico).*)")).toBe(true);
expect(matchPattern("/dashboard/settings", "/((?!api|_next|favicon\\.ico).*)")).toBe(true);
expect(matchPattern("/api/hello", "/((?!api|_next|favicon\\.ico).*)")).toBe(false);
expect(matchPattern("/_next/static/chunk.js", "/((?!api|_next|favicon\\.ico).*)")).toBe(false);
});
it("matchPattern: dots are escaped in paths", async () => {
const { matchPattern } = await import(
"../packages/vinext/src/server/middleware.js"
);
expect(matchPattern("/files/data.json", "/files/data.json")).toBe(true);
expect(matchPattern("/files/dataXjson", "/files/data.json")).toBe(false);
});
it("matchesMiddleware: no matcher — matches all paths (Next.js default)", async () => {
const { matchesMiddleware } = await import(
"../packages/vinext/src/server/middleware.js"
);
// Next.js default: middleware runs on ALL paths when no matcher is configured.
// Users opt out of specific paths by configuring a matcher pattern.
expect(matchesMiddleware("/", undefined)).toBe(true);
expect(matchesMiddleware("/about", undefined)).toBe(true);
expect(matchesMiddleware("/dashboard/settings", undefined)).toBe(true);
expect(matchesMiddleware("/_next/static/chunk.js", undefined)).toBe(true);
expect(matchesMiddleware("/api/hello", undefined)).toBe(true);
expect(matchesMiddleware("/favicon.ico", undefined)).toBe(true);
expect(matchesMiddleware("/image.png", undefined)).toBe(true);
});
it("matchesMiddleware: single string matcher", async () => {
const { matchesMiddleware } = await import(
"../packages/vinext/src/server/middleware.js"
);
expect(matchesMiddleware("/about", "/about")).toBe(true);
expect(matchesMiddleware("/other", "/about")).toBe(false);
});
it("matchesMiddleware: array of string matchers", async () => {
const { matchesMiddleware } = await import(
"../packages/vinext/src/server/middleware.js"
);
const matcher = ["/about", "/dashboard/:path*"];
expect(matchesMiddleware("/about", matcher)).toBe(true);
expect(matchesMiddleware("/dashboard", matcher)).toBe(true);
expect(matchesMiddleware("/dashboard/settings", matcher)).toBe(true);
expect(matchesMiddleware("/other", matcher)).toBe(false);
});
it("matchesMiddleware: array of object matchers with source", async () => {
const { matchesMiddleware } = await import(
"../packages/vinext/src/server/middleware.js"
);
const matcher = [
{ source: "/about" },
{ source: "/dashboard/:path*" },
];
expect(matchesMiddleware("/about", matcher)).toBe(true);
expect(matchesMiddleware("/dashboard/settings", matcher)).toBe(true);
expect(matchesMiddleware("/other", matcher)).toBe(false);
});
it("matchesMiddleware: mixed array of strings and objects", async () => {
const { matchesMiddleware } = await import(
"../packages/vinext/src/server/middleware.js"
);
const matcher = ["/about", { source: "/api/:path+" }] as any;
expect(matchesMiddleware("/about", matcher)).toBe(true);
expect(matchesMiddleware("/api/users", matcher)).toBe(true);
expect(matchesMiddleware("/api", matcher)).toBe(false);
expect(matchesMiddleware("/other", matcher)).toBe(false);
});
it("matchPattern: rejects pathological ReDoS patterns", async () => {
const { matchPattern } = await import(
"../packages/vinext/src/server/middleware.js"
);
// Pathological pattern: (a+)+ causes catastrophic backtracking
// matchPattern should return false (no match) instead of hanging
// lgtm[js/redos] — deliberate pathological regex to test safeRegExp guard
expect(matchPattern("/aaaaaaaaaaaaaaaaaaaac", "(a+)+b")).toBe(false);
});
});
// ---------------------------------------------------------------------------
// normalizePath unit tests
describe("normalizePath", () => {
it("returns root unchanged", async () => {
const { normalizePath } = await import(
"../packages/vinext/src/server/normalize-path.js"
);
expect(normalizePath("/")).toBe("/");
});
it("returns already-canonical paths unchanged", async () => {
const { normalizePath } = await import(
"../packages/vinext/src/server/normalize-path.js"
);
expect(normalizePath("/foo/bar")).toBe("/foo/bar");
expect(normalizePath("/about")).toBe("/about");
expect(normalizePath("/api/users/123")).toBe("/api/users/123");
});
it("collapses double slashes", async () => {
const { normalizePath } = await import(
"../packages/vinext/src/server/normalize-path.js"
);
expect(normalizePath("//foo")).toBe("/foo");
expect(normalizePath("/foo//bar")).toBe("/foo/bar");
expect(normalizePath("/dashboard//settings")).toBe("/dashboard/settings");
expect(normalizePath("///")).toBe("/");
expect(normalizePath("/foo///bar///baz")).toBe("/foo/bar/baz");
});
it("resolves single-dot segments", async () => {
const { normalizePath } = await import(
"../packages/vinext/src/server/normalize-path.js"
);
expect(normalizePath("/foo/./bar")).toBe("/foo/bar");
expect(normalizePath("/./foo")).toBe("/foo");
expect(normalizePath("/foo/.")).toBe("/foo");
});
it("resolves double-dot segments", async () => {
const { normalizePath } = await import(
"../packages/vinext/src/server/normalize-path.js"
);
expect(normalizePath("/foo/../bar")).toBe("/bar");
expect(normalizePath("/foo/bar/../baz")).toBe("/foo/baz");
expect(normalizePath("/foo/..")).toBe("/");
});
it("clamps traversal above root", async () => {
const { normalizePath } = await import(
"../packages/vinext/src/server/normalize-path.js"
);
expect(normalizePath("/../../../etc/passwd")).toBe("/etc/passwd");
expect(normalizePath("/..")).toBe("/");
});
it("ensures leading slash", async () => {
const { normalizePath } = await import(
"../packages/vinext/src/server/normalize-path.js"
);
expect(normalizePath("foo/bar")).toBe("/foo/bar");
expect(normalizePath("")).toBe("/");
});
it("preserves trailing slash on fast path", async () => {
const { normalizePath } = await import(
"../packages/vinext/src/server/normalize-path.js"
);
// Fast path: already canonical with trailing slash
expect(normalizePath("/foo/bar/")).toBe("/foo/bar/");
});
it("handles complex combined cases", async () => {
const { normalizePath } = await import(
"../packages/vinext/src/server/normalize-path.js"
);
expect(normalizePath("/foo/./bar/../baz")).toBe("/foo/baz");
expect(normalizePath("//foo/./bar//baz/../qux")).toBe("/foo/bar/qux");
});
});
// ---------------------------------------------------------------------------
// Codegen parity tests (verify generated code matches runtime behavior)
describe("middleware codegen parity", () => {
it("generateMiddlewareMatcherCode('modern') produces working matchesMiddleware", async () => {
const { generateSafeRegExpCode, generateMiddlewareMatcherCode } = await import(
"../packages/vinext/src/server/middleware-codegen.js"
);
// Eval the generated code and test it behaves identically to the runtime
const code = generateSafeRegExpCode("modern") + generateMiddlewareMatcherCode("modern");
// eslint-disable-next-line no-implied-eval -- intentional: eval generated codegen output
const fn = new Function(code + "\nreturn { matchMiddlewarePattern, matchesMiddleware };");
const { matchMiddlewarePattern, matchesMiddleware } = fn();
// No matcher → matches all (Next.js default)
expect(matchesMiddleware("/", undefined)).toBe(true);
expect(matchesMiddleware("/api/hello", undefined)).toBe(true);
expect(matchesMiddleware("/_next/static/chunk.js", undefined)).toBe(true);
expect(matchesMiddleware("/favicon.ico", undefined)).toBe(true);
// Exact match
expect(matchMiddlewarePattern("/about", "/about")).toBe(true);
expect(matchMiddlewarePattern("/other", "/about")).toBe(false);
// Regex pattern with groups (must NOT corrupt the regex via dot-escaping)
expect(matchMiddlewarePattern("/about", "/((?!api|_next|favicon\\.ico).*)")).toBe(true);
expect(matchMiddlewarePattern("/api/hello", "/((?!api|_next|favicon\\.ico).*)")).toBe(false);
// Named params
expect(matchMiddlewarePattern("/user/123", "/user/:id")).toBe(true);
// Wildcard
expect(matchMiddlewarePattern("/dashboard/settings", "/dashboard/:path*")).toBe(true);
expect(matchMiddlewarePattern("/dashboard", "/dashboard/:path*")).toBe(true);
});
it("generateMiddlewareMatcherCode('es5') produces working matchesMiddleware", async () => {
const { generateSafeRegExpCode, generateMiddlewareMatcherCode } = await import(
"../packages/vinext/src/server/middleware-codegen.js"
);
const code = generateSafeRegExpCode("es5") + generateMiddlewareMatcherCode("es5");
// eslint-disable-next-line no-implied-eval -- intentional: eval generated codegen output
const fn = new Function(code + "\nreturn { matchMiddlewarePattern, matchesMiddleware };");
const { matchMiddlewarePattern, matchesMiddleware } = fn();
// No matcher → matches all
expect(matchesMiddleware("/api/hello", undefined)).toBe(true);
// Regex guard (must not corrupt regex patterns via dot-escaping)
expect(matchMiddlewarePattern("/about", "/((?!api|_next|favicon\\.ico).*)")).toBe(true);
expect(matchMiddlewarePattern("/api/hello", "/((?!api|_next|favicon\\.ico).*)")).toBe(false);
});
it("generateNormalizePathCode produces working __normalizePath", async () => {
const { generateNormalizePathCode } = await import(
"../packages/vinext/src/server/middleware-codegen.js"
);
const code = generateNormalizePathCode("modern");
// eslint-disable-next-line no-implied-eval -- intentional: eval generated codegen output
const fn = new Function(code + "\nreturn __normalizePath;");
const __normalizePath = fn();
expect(__normalizePath("/")).toBe("/");
expect(__normalizePath("/foo/bar")).toBe("/foo/bar");
expect(__normalizePath("//foo")).toBe("/foo");
expect(__normalizePath("/foo//bar")).toBe("/foo/bar");
expect(__normalizePath("/foo/./bar")).toBe("/foo/bar");
expect(__normalizePath("/foo/../bar")).toBe("/bar");
expect(__normalizePath("/../../../etc/passwd")).toBe("/etc/passwd");
});
});
// ---------------------------------------------------------------------------
// Integration: verify decodeURIComponent + normalizePath applied before matching
describe("middleware bypass prevention", () => {
it("percent-encoded path is decoded before matching", async () => {
const { matchPattern, matchesMiddleware } = await import(
"../packages/vinext/src/server/middleware.js"
);
const { normalizePath } = await import(
"../packages/vinext/src/server/normalize-path.js"
);
// /%61dmin decodes to /admin
const encoded = "/%61dmin";
const decoded = normalizePath(decodeURIComponent(encoded));
expect(decoded).toBe("/admin");
expect(matchPattern(decoded, "/admin")).toBe(true);
expect(matchesMiddleware(decoded, "/admin")).toBe(true);
});
it("double-slash path is collapsed before matching", async () => {
const { matchPattern, matchesMiddleware } = await import(
"../packages/vinext/src/server/middleware.js"
);
const { normalizePath } = await import(
"../packages/vinext/src/server/normalize-path.js"
);
// /dashboard//settings collapses to /dashboard/settings
const doubleSlash = "/dashboard//settings";
const normalized = normalizePath(doubleSlash);
expect(normalized).toBe("/dashboard/settings");
expect(matchPattern(normalized, "/dashboard/:path*")).toBe(true);
expect(matchesMiddleware(normalized, "/dashboard/:path*")).toBe(true);
});
it("default matcher (no config) matches all paths including /api", async () => {
const { matchesMiddleware } = await import(
"../packages/vinext/src/server/middleware.js"
);
// When no matcher is configured, middleware must run on ALL paths
expect(matchesMiddleware("/api/hello", undefined)).toBe(true);
expect(matchesMiddleware("/_next/data/build-id/page.json", undefined)).toBe(true);
expect(matchesMiddleware("/favicon.ico", undefined)).toBe(true);
});
it("regex patterns are not corrupted by dot-escaping", async () => {
const { matchPattern } = await import(
"../packages/vinext/src/server/middleware.js"
);
// The common Next.js regex pattern must work correctly:
// /((?!api|_next|favicon\.ico).*) should match /about but NOT /api/hello
const pattern = "/((?!api|_next|favicon\\.ico).*)";
expect(matchPattern("/about", pattern)).toBe(true);
expect(matchPattern("/dashboard/settings", pattern)).toBe(true);
expect(matchPattern("/api/hello", pattern)).toBe(false);
expect(matchPattern("/_next/static/chunk.js", pattern)).toBe(false);
expect(matchPattern("/favicon.ico", pattern)).toBe(false);
});
// ── Config matcher percent-encoding handling ──
it("config redirect matcher works with decoded percent-encoded paths", async () => {
const { matchRedirect } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
const { normalizePath } = await import(
"../packages/vinext/src/server/normalize-path.js"
);
const redirects = [
{ source: "/admin", destination: "/login", permanent: true },
{ source: "/old-blog/:slug", destination: "/blog/:slug", permanent: false },
];
const reqCtx = { headers: new Headers(), cookies: {}, query: new URLSearchParams(), host: "localhost" };
// Decoded path should match
const decoded = normalizePath(decodeURIComponent("/%61dmin"));
expect(decoded).toBe("/admin");
const result = matchRedirect(decoded, redirects, reqCtx);
expect(result).toBeTruthy();
expect(result!.destination).toBe("/login");
// Mixed encoding in parameterized route
const slugDecoded = normalizePath(decodeURIComponent("/%6Fld-blog/my-p%6Fst"));
expect(slugDecoded).toBe("/old-blog/my-post");
const slugResult = matchRedirect(slugDecoded, redirects, reqCtx);
expect(slugResult).toBeTruthy();
expect(slugResult!.destination).toBe("/blog/my-post");
// Raw encoded path must NOT match (matchers expect decoded paths)
const rawResult = matchRedirect("/%61dmin", redirects, reqCtx);
expect(rawResult).toBeNull();
});
it("config header matcher works with decoded percent-encoded paths", async () => {
const { matchHeaders } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
const { normalizePath } = await import(
"../packages/vinext/src/server/normalize-path.js"
);
const headers = [
{ source: "/api/(.*)", headers: [{ key: "X-Custom", value: "true" }] },
];
const reqCtx = { headers: new Headers(), cookies: {}, query: new URLSearchParams(), host: "localhost" };
// Decoded path should match
const decoded = normalizePath(decodeURIComponent("/%61pi/hello"));
expect(decoded).toBe("/api/hello");
const result = matchHeaders(decoded, headers, reqCtx);
expect(result).toHaveLength(1);
expect(result[0].key).toBe("X-Custom");
// Raw encoded path must NOT match
const rawResult = matchHeaders("/%61pi/hello", headers, reqCtx);
expect(rawResult).toHaveLength(0);
});
it("config rewrite matcher works with decoded percent-encoded paths", async () => {
const { matchRewrite } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
const { normalizePath } = await import(
"../packages/vinext/src/server/normalize-path.js"
);
const rewrites = [
{ source: "/before-rewrite", destination: "/about" },
];
const reqCtx = { headers: new Headers(), cookies: {}, query: new URLSearchParams(), host: "localhost" };
// Decoded path should match
const decoded = normalizePath(decodeURIComponent("/%62efore-rewrite"));
expect(decoded).toBe("/before-rewrite");
const result = matchRewrite(decoded, rewrites, reqCtx);
expect(result).toBe("/about");
// Raw encoded path must NOT match
const rawResult = matchRewrite("/%62efore-rewrite", rewrites, reqCtx);
expect(rawResult).toBeNull();
});
it("double-encoded paths are decoded only once", async () => {
const { normalizePath } = await import(
"../packages/vinext/src/server/normalize-path.js"
);
// %2561dmin → first decode → %61dmin (literal text, not /admin)
const doubleEncoded = "/%2561dmin";
const decoded = normalizePath(decodeURIComponent(doubleEncoded));
// Should decode to /%61dmin, NOT to /admin
expect(decoded).toBe("/%61dmin");
expect(decoded).not.toBe("/admin");
});
});
describe("double-encoded path handling in middleware", () => {
it("double-encoded path /%2564ashboard does not match /dashboard middleware pattern", async () => {
const { matchPattern, matchesMiddleware } = await import(
"../packages/vinext/src/server/middleware.js"
);
const { normalizePath } = await import(
"../packages/vinext/src/server/normalize-path.js"
);
// /%2564ashboard with a single decode becomes /%64ashboard (NOT /dashboard).
// The pathname should be decoded exactly once at the entry point.
const testPath = "/%2564ashboard";
const decoded = decodeURIComponent(testPath); // Single decode
const normalized = normalizePath(decoded);
// After one decode, this is NOT /dashboard — it's /%64ashboard
expect(normalized).toBe("/%64ashboard");
// Middleware should NOT match /dashboard for this path
expect(matchPattern(normalized, "/dashboard")).toBe(false);
expect(matchesMiddleware(normalized, "/dashboard")).toBe(false);
});
it("double-encoded slash /foo/..%252fdashboard does not resolve to /dashboard", async () => {
const { matchPattern } = await import(
"../packages/vinext/src/server/middleware.js"
);
const { normalizePath } = await import(
"../packages/vinext/src/server/normalize-path.js"
);
// /foo/..%252fdashboard with a single decode becomes /foo/..%2fdashboard.
// normalizePath does NOT treat %2f as a path separator, so no traversal occurs.
const testPath = "/foo/..%252fdashboard";
const decoded = decodeURIComponent(testPath); // Single decode
const normalized = normalizePath(decoded);
// After one decode + normalize, this should NOT resolve to /dashboard
expect(normalized).not.toBe("/dashboard");
// The .. only traverses if followed by a real /, not an encoded %2f
expect(matchPattern(normalized, "/dashboard")).toBe(false);
});
it("matchRoute in generated code does not double-decode pathnames", async () => {
// Verify that matchRoute no longer calls decodeURIComponent internally.
// The generated RSC entry code is a string — we check it directly.
const { generateRscEntry } = await import(
"../packages/vinext/src/entries/app-rsc-entry.js"
);
const code = generateRscEntry("/tmp/app", [
{
pattern: "/dashboard",
isDynamic: false,
params: [],
pagePath: null,
routePath: null,
layouts: [],
routeSegments: [],
layoutTreePositions: [],
templates: [],
loadingPath: null,
errorPath: null,
layoutErrorPaths: [],
notFoundPath: null,
notFoundPaths: [],
forbiddenPath: null,
unauthorizedPath: null,
parallelSlots: [],
},
]);
// Extract the matchRoute function from generated code
const matchRouteMatch = code.match(/function matchRoute\(url, routes\) \{[\s\S]*?\n\}/);
expect(matchRouteMatch).toBeTruthy();
const matchRouteCode = matchRouteMatch![0];
// Verify it does NOT call decodeURIComponent (the comment mentions it but
// should not have an actual call like `decodeURIComponent(...)`)
expect(matchRouteCode).not.toMatch(/\bdecodeURIComponent\s*\(/);
});
it("middleware always receives a Request with the decoded pathname (not raw URL)", async () => {
const { generateRscEntry } = await import(
"../packages/vinext/src/entries/app-rsc-entry.js"
);
const code = generateRscEntry(
"/tmp/app",
[
{
pattern: "/dashboard",
isDynamic: false,
params: [],
pagePath: null,
routePath: null,
layouts: [],
routeSegments: [],
layoutTreePositions: [],
templates: [],
loadingPath: null,
errorPath: null,
layoutErrorPaths: [],
notFoundPath: null,
notFoundPaths: [],
forbiddenPath: null,
unauthorizedPath: null,
parallelSlots: [],
},
],
"/tmp/middleware.ts",
);
// The generated code should ALWAYS construct a new Request with cleanPathname.
// Verify the generated code constructs a Request with the decoded pathname
// for ALL requests (not just RSC).
expect(code).not.toMatch(/let mwRequest = request;/);
expect(code).toContain("const mwUrl = new URL(request.url)");
expect(code).toContain("mwUrl.pathname = cleanPathname");
expect(code).toContain("const mwRequest = new Request(mwUrl, request)");
});
it("Pages Router runMiddleware passes decoded pathname to middleware function", async () => {
const { runMiddleware } = await import(
"../packages/vinext/src/server/middleware.js"
);
// Create a mock Vite server that returns a middleware module
let capturedUrl: string | undefined;
const mockRunner = {
import: async () => ({
default: (req: Request) => {
capturedUrl = req.url;
return new Response("OK", {
headers: { "x-middleware-next": "1" },
});
},
config: { matcher: "/:path*" },
}),
};
// Send a double-encoded path — after single decode, it should be /%64ashboard
const testUrl = "http://localhost:3000/%2564ashboard";
const request = new Request(testUrl);
await runMiddleware(mockRunner as any, "/tmp/middleware.ts", request);
// Middleware should have received the decoded+normalized URL
expect(capturedUrl).toBeDefined();
const mwPathname = new URL(capturedUrl!).pathname;
// After single decode: %25 → %, so /%2564 → /%64
expect(mwPathname).toBe("/%64ashboard");
// It must NOT be the raw /%2564ashboard
expect(mwPathname).not.toBe("/%2564ashboard");
// It must NOT be double-decoded to /dashboard
expect(mwPathname).not.toBe("/dashboard");
});
it("runMiddleware accepts named proxy export", async () => {
const { runMiddleware } = await import(
"../packages/vinext/src/server/middleware.js"
);
const mockRunner = {
import: async () => ({
proxy: () => {
const response = new Response(null, { status: 307 });
response.headers.set("location", "/login");
return response;
},
config: { matcher: ["/protected"] },
}),
};
const request = new Request("http://localhost/protected");
const result = await runMiddleware(mockRunner as any, "/tmp/proxy.js", request);
expect(result.continue).toBe(false);
expect(result.redirectUrl).toContain("/login");
expect(result.redirectStatus).toBe(307);
});
it("app-router-entry.ts does not double-decode (delegates to RSC handler)", async () => {
// Verify the Cloudflare Worker entry does not decode the pathname itself,
// leaving that responsibility to the RSC handler.
const fs = await import("node:fs");
const entryCode = fs.readFileSync(
new URL("../packages/vinext/src/server/app-router-entry.ts", import.meta.url),
"utf-8",
);
// The entry should validate encoding but NOT normalize+reconstruct the request
// (the RSC handler is the single decode point)
expect(entryCode).not.toMatch(/normalizedRequest\s*=\s*new Request\(normalizedUrl/);
// It should still validate malformed encoding (return 400)
expect(entryCode).toContain("decodeURIComponent(rawPathname)");
// The delegate call should pass `request` (not normalizedRequest)
expect(entryCode).toMatch(/rscHandler\(request\)/);
});
});
// ---------------------------------------------------------------------------
// NextFetchEvent — middleware receives event with waitUntil support
describe("NextFetchEvent passed to middleware", () => {
it("runMiddleware passes NextFetchEvent as second argument", async () => {
const { runMiddleware } = await import(
"../packages/vinext/src/server/middleware.js"
);
// Middleware that accesses event.waitUntil — will throw if event is undefined
let receivedEvent: any;
const mockRunner = {
import: async () => ({
middleware: (req: any, event: any) => {
receivedEvent = event;
event.waitUntil(Promise.resolve("done"));
return new Response(null, {
headers: { "x-middleware-next": "1" },
});
},
config: { matcher: ["/test"] },
}),
};
const request = new Request("http://localhost:3000/test");
const result = await runMiddleware(mockRunner as any, "/tmp/middleware.ts", request);
expect(result.continue).toBe(true);
expect(receivedEvent).toBeDefined();
expect(typeof receivedEvent.waitUntil).toBe("function");
expect(receivedEvent.sourcePage).toBe("/test");
});
it("waitUntil promises are drained after middleware runs", async () => {
const { runMiddleware } = await import(
"../packages/vinext/src/server/middleware.js"
);
let sideEffectRan = false;
const mockRunner = {
import: async () => ({
middleware: (_req: any, event: any) => {
event.waitUntil(
Promise.resolve().then(() => {
sideEffectRan = true;
}),
);
return new Response(null, {
headers: { "x-middleware-next": "1" },
});
},
config: { matcher: ["/drain"] },
}),
};
const request = new Request("http://localhost:3000/drain");
await runMiddleware(mockRunner as any, "/tmp/middleware.ts", request);
// The waitUntil promise should have been resolved.
// Flush the microtask queue so Promise.resolve().then(...) callbacks run.
await new Promise((r) => queueMicrotask(r as () => void));
expect(sideEffectRan).toBe(true);
});
});
// ---------------------------------------------------------------------------
// RequestCookies comprehensive tests
describe("RequestCookies API", () => {
it("get() returns cookie by name", async () => {
const { RequestCookies } = await import(
"../packages/vinext/src/shims/server.js"
);
const headers = new Headers({ cookie: "token=abc123; session=xyz" });
const cookies = new RequestCookies(headers);
const token = cookies.get("token");
expect(token).toEqual({ name: "token", value: "abc123" });
const session = cookies.get("session");
expect(session).toEqual({ name: "session", value: "xyz" });
});
it("get() returns undefined for missing cookie", async () => {
const { RequestCookies } = await import(
"../packages/vinext/src/shims/server.js"
);
const headers = new Headers({ cookie: "token=abc123" });
const cookies = new RequestCookies(headers);
expect(cookies.get("missing")).toBeUndefined();
});
it("getAll() returns all cookies", async () => {
const { RequestCookies } = await import(
"../packages/vinext/src/shims/server.js"
);
const headers = new Headers({ cookie: "a=1; b=2; c=3" });
const cookies = new RequestCookies(headers);
const all = cookies.getAll();
expect(all).toHaveLength(3);
expect(all).toContainEqual({ name: "a", value: "1" });
expect(all).toContainEqual({ name: "b", value: "2" });
expect(all).toContainEqual({ name: "c", value: "3" });
});
it("has() checks cookie existence", async () => {
const { RequestCookies } = await import(
"../packages/vinext/src/shims/server.js"
);
const headers = new Headers({ cookie: "token=abc" });
const cookies = new RequestCookies(headers);
expect(cookies.has("token")).toBe(true);
expect(cookies.has("missing")).toBe(false);
});
it("iterator yields [name, entry] pairs", async () => {
const { RequestCookies } = await import(
"../packages/vinext/src/shims/server.js"
);
const headers = new Headers({ cookie: "x=1; y=2" });
const cookies = new RequestCookies(headers);
const entries = [...cookies];
expect(entries).toHaveLength(2);
expect(entries[0][0]).toBe("x");
expect(entries[0][1]).toEqual({ name: "x", value: "1" });
expect(entries[1][0]).toBe("y");
expect(entries[1][1]).toEqual({ name: "y", value: "2" });
});
it("handles empty cookie header", async () => {
const { RequestCookies } = await import(
"../packages/vinext/src/shims/server.js"
);
const headers = new Headers();
const cookies = new RequestCookies(headers);
expect(cookies.getAll()).toHaveLength(0);
expect(cookies.get("any")).toBeUndefined();
expect(cookies.has("any")).toBe(false);
});
it("handles cookies with = in value", async () => {
const { RequestCookies } = await import(
"../packages/vinext/src/shims/server.js"
);
const headers = new Headers({ cookie: "data=base64=encoded=value" });
const cookies = new RequestCookies(headers);
const data = cookies.get("data");
expect(data).toBeDefined();
expect(data!.value).toBe("base64=encoded=value");
});
});
// ---------------------------------------------------------------------------
// ResponseCookies comprehensive tests
describe("ResponseCookies API", () => {
it("set() creates Set-Cookie header with options", async () => {
const { ResponseCookies } = await import(
"../packages/vinext/src/shims/server.js"
);
const headers = new Headers();
const cookies = new ResponseCookies(headers);
cookies.set("token", "abc123", {
path: "/",
httpOnly: true,
secure: true,
sameSite: "Lax",
maxAge: 3600,
});
const setCookie = headers.getSetCookie();
expect(setCookie).toHaveLength(1);
expect(setCookie[0]).toContain("token=abc123");
expect(setCookie[0]).toContain("Path=/");
expect(setCookie[0]).toContain("HttpOnly");
expect(setCookie[0]).toContain("Secure");
expect(setCookie[0]).toContain("SameSite=Lax");
expect(setCookie[0]).toContain("Max-Age=3600");
});
it("set() multiple cookies appends multiple Set-Cookie headers", async () => {
const { ResponseCookies } = await import(
"../packages/vinext/src/shims/server.js"
);
const headers = new Headers();
const cookies = new ResponseCookies(headers);
cookies.set("a", "1");
cookies.set("b", "2");
const setCookie = headers.getSetCookie();
expect(setCookie).toHaveLength(2);
expect(setCookie[0]).toContain("a=1");
expect(setCookie[1]).toContain("b=2");
});
it("get() retrieves a cookie from Set-Cookie headers", async () => {
const { ResponseCookies } = await import(
"../packages/vinext/src/shims/server.js"
);
const headers = new Headers();
const cookies = new ResponseCookies(headers);
cookies.set("token", "xyz");
const result = cookies.get("token");
expect(result).toEqual({ name: "token", value: "xyz" });
});
it("getAll() returns all set cookies", async () => {
const { ResponseCookies } = await import(
"../packages/vinext/src/shims/server.js"
);
const headers = new Headers();
const cookies = new ResponseCookies(headers);
cookies.set("a", "1");
cookies.set("b", "2");
cookies.set("c", "3");
const all = cookies.getAll();
expect(all).toHaveLength(3);
expect(all).toContainEqual({ name: "a", value: "1" });
expect(all).toContainEqual({ name: "b", value: "2" });
expect(all).toContainEqual({ name: "c", value: "3" });
});
it("delete() sets Max-Age=0", async () => {
const { ResponseCookies } = await import(
"../packages/vinext/src/shims/server.js"
);
const headers = new Headers();
const cookies = new ResponseCookies(headers);
cookies.delete("session");
const setCookie = headers.getSetCookie();
expect(setCookie).toHaveLength(1);
expect(setCookie[0]).toContain("session=");
expect(setCookie[0]).toContain("Max-Age=0");
expect(setCookie[0]).toContain("Path=/");
});
it("set() URL-encodes cookie values", async () => {
const { ResponseCookies } = await import(
"../packages/vinext/src/shims/server.js"
);
const headers = new Headers();
const cookies = new ResponseCookies(headers);
cookies.set("data", "hello world; special=chars");
const setCookie = headers.getSetCookie();
expect(setCookie[0]).toContain("data=hello%20world%3B%20special%3Dchars");
// get() should decode it back
const result = cookies.get("data");
expect(result?.value).toBe("hello world; special=chars");
});
it("iterator yields [name, entry] pairs", async () => {
const { ResponseCookies } = await import(
"../packages/vinext/src/shims/server.js"
);
const headers = new Headers();
const cookies = new ResponseCookies(headers);
cookies.set("x", "1");
cookies.set("y", "2");
const entries = [...cookies];
expect(entries).toHaveLength(2);
expect(entries[0][0]).toBe("x");
expect(entries[0][1]).toEqual({ name: "x", value: "1" });
expect(entries[1][0]).toBe("y");
expect(entries[1][1]).toEqual({ name: "y", value: "2" });
});
it("set() with domain option includes Domain directive", async () => {
const { ResponseCookies } = await import(
"../packages/vinext/src/shims/server.js"
);
const headers = new Headers();
const cookies = new ResponseCookies(headers);
cookies.set("token", "abc", { domain: ".example.com" });
const setCookie = headers.getSetCookie();
expect(setCookie[0]).toContain("Domain=.example.com");
});
it("set() with expires option includes Expires directive", async () => {
const { ResponseCookies } = await import(
"../packages/vinext/src/shims/server.js"
);
const headers = new Headers();
const cookies = new ResponseCookies(headers);
const expires = new Date("2030-01-01T00:00:00Z");
cookies.set("token", "abc", { expires });
const setCookie = headers.getSetCookie();
expect(setCookie[0]).toContain("Expires=");
expect(setCookie[0]).toContain("2030");
});
});
// ---------------------------------------------------------------------------
// Cookie name/value injection prevention (RFC 6265)
describe("cookie name validation", () => {
it("RequestCookies.set() rejects names with = (injection)", async () => {
const headersModule = await import("../packages/vinext/src/shims/headers.js");
headersModule.setHeadersContext({ headers: new Headers(), cookies: new Map() });
const jar = await headersModule.cookies();
expect(() => jar.set("foo=bar; Path=/; Domain=evil.com", "val")).toThrow("Invalid cookie name");
});
it("RequestCookies.set() rejects names with semicolons", async () => {
const headersModule = await import("../packages/vinext/src/shims/headers.js");
headersModule.setHeadersContext({ headers: new Headers(), cookies: new Map() });
const jar = await headersModule.cookies();
expect(() => jar.set("foo; HttpOnly", "val")).toThrow("Invalid cookie name");
});
it("RequestCookies.set() rejects names with newlines", async () => {
const headersModule = await import("../packages/vinext/src/shims/headers.js");
headersModule.setHeadersContext({ headers: new Headers(), cookies: new Map() });
const jar = await headersModule.cookies();
expect(() => jar.set("foo\r\nSet-Cookie: evil=1", "val")).toThrow("Invalid cookie name");
});
it("RequestCookies.set() rejects empty names", async () => {
const headersModule = await import("../packages/vinext/src/shims/headers.js");
headersModule.setHeadersContext({ headers: new Headers(), cookies: new Map() });
const jar = await headersModule.cookies();
expect(() => jar.set("", "val")).toThrow("Invalid cookie name");
});
it("RequestCookies.set() accepts valid cookie names", async () => {
const headersModule = await import("../packages/vinext/src/shims/headers.js");
headersModule.setHeadersContext({ headers: new Headers(), cookies: new Map() });
const jar = await headersModule.cookies();
// These should not throw
jar.set("valid-name", "value");
jar.set("__Host-token", "value");
jar.set("session_id", "value");
jar.set("CSRF.Token", "value");
});
it("RequestCookies.delete() rejects invalid names", async () => {
const headersModule = await import("../packages/vinext/src/shims/headers.js");
headersModule.setHeadersContext({ headers: new Headers(), cookies: new Map() });
const jar = await headersModule.cookies();
expect(() => jar.delete("foo=bar")).toThrow("Invalid cookie name");
});
it("RequestCookies.set() rejects path with semicolons", async () => {
const headersModule = await import("../packages/vinext/src/shims/headers.js");
headersModule.setHeadersContext({ headers: new Headers(), cookies: new Map() });
const jar = await headersModule.cookies();
expect(() => jar.set("name", "val", { path: "/; Domain=evil.com" })).toThrow("Invalid cookie Path");
});
it("ResponseCookies.set() rejects names with = (injection)", async () => {
const { ResponseCookies } = await import("../packages/vinext/src/shims/server.js");
const headers = new Headers();
const cookies = new ResponseCookies(headers);
expect(() => cookies.set("foo=bar; Path=/", "val")).toThrow("Invalid cookie name");
});
it("ResponseCookies.set() rejects domain with control chars", async () => {
const { ResponseCookies } = await import("../packages/vinext/src/shims/server.js");
const headers = new Headers();
const cookies = new ResponseCookies(headers);
expect(() => cookies.set("name", "val", { domain: "evil.com\r\nSet-Cookie: hack=1" })).toThrow("Invalid cookie Domain");
});
it("ResponseCookies.set() accepts valid cookie names and options", async () => {
const { ResponseCookies } = await import("../packages/vinext/src/shims/server.js");
const headers = new Headers();
const cookies = new ResponseCookies(headers);
// These should not throw
cookies.set("valid-name", "value", { path: "/", domain: ".example.com" });
cookies.set("__Secure-token", "abc", { secure: true, httpOnly: true });
});
});
// ---------------------------------------------------------------------------
// NextRequest API tests
describe("NextRequest API", () => {
it("cookies reads request cookies", async () => {
const { NextRequest } = await import(
"../packages/vinext/src/shims/server.js"
);
const req = new NextRequest("http://localhost/test", {
headers: { cookie: "session=abc; theme=dark" },
});
expect(req.cookies.get("session")).toEqual({ name: "session", value: "abc" });
expect(req.cookies.get("theme")).toEqual({ name: "theme", value: "dark" });
expect(req.cookies.has("session")).toBe(true);
expect(req.cookies.has("missing")).toBe(false);
});
it("nextUrl provides URL properties", async () => {
const { NextRequest } = await import(
"../packages/vinext/src/shims/server.js"
);
const req = new NextRequest("http://localhost:3000/api/test?key=value#hash");
expect(req.nextUrl.pathname).toBe("/api/test");
expect(req.nextUrl.search).toBe("?key=value");
expect(req.nextUrl.searchParams.get("key")).toBe("value");
expect(req.nextUrl.host).toBe("localhost:3000");
expect(req.nextUrl.hostname).toBe("localhost");
expect(req.nextUrl.protocol).toBe("http:");
expect(req.nextUrl.hash).toBe("#hash");
});
it("nextUrl.clone() creates independent copy", async () => {
const { NextRequest } = await import(
"../packages/vinext/src/shims/server.js"
);
const req = new NextRequest("http://localhost/test");
const cloned = req.nextUrl.clone();
cloned.pathname = "/other";
expect(req.nextUrl.pathname).toBe("/test");
expect(cloned.pathname).toBe("/other");
});
it("nextUrl supports all URL setters (port, host, hostname, protocol, href)", async () => {
const { NextURL } = await import(
"../packages/vinext/src/shims/server.js"
);
const url = new NextURL("http://example.com:8080/path?q=1#hash");
// port setter
url.port = "9090";
expect(url.port).toBe("9090");
// hostname setter
url.hostname = "other.com";
expect(url.hostname).toBe("other.com");
// host setter (hostname + port)
url.host = "third.com:3000";
expect(url.host).toBe("third.com:3000");
expect(url.hostname).toBe("third.com");
expect(url.port).toBe("3000");
// protocol setter
url.protocol = "https:";
expect(url.protocol).toBe("https:");
// href setter
url.href = "http://new.com/new-path";
expect(url.href).toBe("http://new.com/new-path");
expect(url.hostname).toBe("new.com");
expect(url.pathname).toBe("/new-path");
});
it("nextUrl.clone() setters work for next-intl compatibility", async () => {
// next-intl's getAlternateLinksHeaderValue does: cloned.port = ""; cloned.host = h
// This test ensures those setter operations work without throwing.
const { NextRequest } = await import(
"../packages/vinext/src/shims/server.js"
);
const req = new NextRequest("http://example.com:8080/en");
const cloned = req.nextUrl.clone();
// Should not throw — next-intl strips port when x-forwarded-host is present
cloned.port = "";
expect(cloned.port).toBe("");
// Should not throw — next-intl sets host from x-forwarded-host header
cloned.host = "example.com";
expect(cloned.hostname).toBe("example.com");
// Original should be unaffected
expect(req.nextUrl.port).toBe("8080");
});
it("ip reads x-forwarded-for header", async () => {
const { NextRequest } = await import(
"../packages/vinext/src/shims/server.js"
);
const req = new NextRequest("http://localhost/", {
headers: { "x-forwarded-for": "1.2.3.4, 5.6.7.8" },
});
expect(req.ip).toBe("1.2.3.4");
});
it("ip returns undefined when no header", async () => {
const { NextRequest } = await import(
"../packages/vinext/src/shims/server.js"
);
const req = new NextRequest("http://localhost/");
expect(req.ip).toBeUndefined();
});
it("geo reads Cloudflare headers", async () => {
const { NextRequest } = await import(
"../packages/vinext/src/shims/server.js"
);
const req = new NextRequest("http://localhost/", {
headers: {
"cf-ipcountry": "US",
"cf-ipcity": "San Francisco",
},
});
expect(req.geo?.country).toBe("US");
expect(req.geo?.city).toBe("San Francisco");
});
it("geo returns undefined when no geo headers", async () => {
const { NextRequest } = await import(
"../packages/vinext/src/shims/server.js"
);
const req = new NextRequest("http://localhost/");
expect(req.geo).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// NextResponse.next() with request header forwarding
describe("NextResponse.next() request header forwarding", () => {
it("forwards request headers as x-middleware-request-* headers", async () => {
const { NextResponse } = await import(
"../packages/vinext/src/shims/server.js"
);
const res = NextResponse.next({
request: {
headers: new Headers({
"x-custom-header": "custom-value",
"authorization": "Bearer token123",
}),
},
});
expect(res.headers.get("x-middleware-next")).toBe("1");
expect(res.headers.get("x-middleware-request-x-custom-header")).toBe("custom-value");
expect(res.headers.get("x-middleware-request-authorization")).toBe("Bearer token123");
});
});
// ---------------------------------------------------------------------------
// NextResponse.redirect() with different status codes
describe("NextResponse.redirect() status codes", () => {
it("defaults to 307 Temporary Redirect", async () => {
const { NextResponse } = await import(
"../packages/vinext/src/shims/server.js"
);
const res = NextResponse.redirect("https://example.com");
expect(res.status).toBe(307);
});
it("supports 301 Permanent Redirect", async () => {
const { NextResponse } = await import(
"../packages/vinext/src/shims/server.js"
);
const res = NextResponse.redirect("https://example.com", 301);
expect(res.status).toBe(301);
expect(res.headers.get("Location")).toBe("https://example.com");
});
it("supports 302 Found", async () => {
const { NextResponse } = await import(
"../packages/vinext/src/shims/server.js"
);
const res = NextResponse.redirect("https://example.com", 302);
expect(res.status).toBe(302);
});
it("supports 308 Permanent Redirect", async () => {
const { NextResponse } = await import(
"../packages/vinext/src/shims/server.js"
);
const res = NextResponse.redirect("https://example.com", 308);
expect(res.status).toBe(308);
});
it("accepts URL object", async () => {
const { NextResponse } = await import(
"../packages/vinext/src/shims/server.js"
);
const url = new URL("https://example.com/target");
const res = NextResponse.redirect(url);
expect(res.headers.get("Location")).toBe("https://example.com/target");
});
});
// ---------------------------------------------------------------------------
// matchConfigPattern unit tests (next.config.js redirects/rewrites)
describe("matchConfigPattern", () => {
it("matches exact paths", async () => {
const { matchConfigPattern } = await import(
"../packages/vinext/src/index.js"
);
expect(matchConfigPattern("/about", "/about")).toEqual({});
expect(matchConfigPattern("/", "/")).toEqual({});
expect(matchConfigPattern("/about", "/other")).toBeNull();
});
it("matches single :param segments", async () => {
const { matchConfigPattern } = await import(
"../packages/vinext/src/index.js"
);
const result = matchConfigPattern("/blog/hello-world", "/blog/:slug");
expect(result).toEqual({ slug: "hello-world" });
});
it("matches multiple :param segments", async () => {
const { matchConfigPattern } = await import(
"../packages/vinext/src/index.js"
);
const result = matchConfigPattern("/blog/2024/my-post", "/blog/:year/:slug");
expect(result).toEqual({ year: "2024", slug: "my-post" });
});
it("rejects when segment count differs for non-wildcard patterns", async () => {
const { matchConfigPattern } = await import(
"../packages/vinext/src/index.js"
);
expect(matchConfigPattern("/blog/a/b", "/blog/:slug")).toBeNull();
expect(matchConfigPattern("/blog", "/blog/:slug")).toBeNull();
});
it("matches :path* catch-all (zero or more segments)", async () => {
const { matchConfigPattern } = await import(
"../packages/vinext/src/index.js"
);
// Zero segments
expect(matchConfigPattern("/docs", "/docs/:path*")).toEqual({ path: "" });
// One segment
expect(matchConfigPattern("/docs/intro", "/docs/:path*")).toEqual({ path: "intro" });
// Multiple segments
expect(matchConfigPattern("/docs/guide/getting-started", "/docs/:path*")).toEqual({
path: "guide/getting-started",
});
});
it("matches :path+ catch-all (one or more segments)", async () => {
const { matchConfigPattern } = await import(
"../packages/vinext/src/index.js"
);
// One segment
expect(matchConfigPattern("/api/users", "/api/:path+")).toEqual({ path: "users" });
// Multiple segments
expect(matchConfigPattern("/api/users/123", "/api/:path+")).toEqual({ path: "users/123" });
// Zero segments — should NOT match
expect(matchConfigPattern("/api", "/api/:path+")).toBeNull();
});
it("matches regex group patterns", async () => {
const { matchConfigPattern } = await import(
"../packages/vinext/src/index.js"
);
// Common Next.js pattern: /:path(\\d+) for numeric paths
const result = matchConfigPattern("/123", "/:id(\\d+)");
if (result) {
expect(result.id).toBe("123");
}
// Non-numeric should not match
expect(matchConfigPattern("/abc", "/:id(\\d+)")).toBeNull();
});
it("handles dots in patterns", async () => {
const { matchConfigPattern } = await import(
"../packages/vinext/src/index.js"
);
expect(matchConfigPattern("/feed.xml", "/feed.xml")).toEqual({});
// Dot should not match any character
expect(matchConfigPattern("/feedXxml", "/feed.xml")).toBeNull();
});
it("matches :path* with literal suffix (e.g. /:path*.md)", async () => {
const { matchConfigPattern } = await import(
"../packages/vinext/src/index.js"
);
// Should match URLs ending in .md
expect(matchConfigPattern("/article.md", "/:path*.md")).toEqual({ path: "article" });
expect(matchConfigPattern("/news/my-article.md", "/:path*.md")).toEqual({ path: "news/my-article" });
expect(matchConfigPattern("/docs/guide/intro.md", "/:path*.md")).toEqual({ path: "docs/guide/intro" });
// Should NOT match URLs without .md suffix
expect(matchConfigPattern("/", "/:path*.md")).toBeNull();
expect(matchConfigPattern("/about", "/:path*.md")).toBeNull();
expect(matchConfigPattern("/news", "/:path*.md")).toBeNull();
expect(matchConfigPattern("/article.txt", "/:path*.md")).toBeNull();
});
it("matches :path+ with literal suffix (e.g. /:path+.json)", async () => {
const { matchConfigPattern } = await import(
"../packages/vinext/src/index.js"
);
// Should match URLs ending in .json with at least one path segment
expect(matchConfigPattern("/data.json", "/:path+.json")).toEqual({ path: "data" });
expect(matchConfigPattern("/api/users.json", "/:path+.json")).toEqual({ path: "api/users" });
// Should NOT match bare .json (zero segments before suffix)
expect(matchConfigPattern("/.json", "/:path+.json")).toBeNull();
// Should NOT match URLs without .json suffix
expect(matchConfigPattern("/data", "/:path+.json")).toBeNull();
expect(matchConfigPattern("/", "/:path+.json")).toBeNull();
});
it("matches :path* with prefix and suffix (e.g. /docs/:path*.md)", async () => {
const { matchConfigPattern } = await import(
"../packages/vinext/src/index.js"
);
expect(matchConfigPattern("/docs/intro.md", "/docs/:path*.md")).toEqual({ path: "intro" });
expect(matchConfigPattern("/docs/guide/getting-started.md", "/docs/:path*.md")).toEqual({
path: "guide/getting-started",
});
// Should NOT match without .md
expect(matchConfigPattern("/docs/intro", "/docs/:path*.md")).toBeNull();
// Should NOT match different prefix
expect(matchConfigPattern("/blog/intro.md", "/docs/:path*.md")).toBeNull();
});
it("matches :param with literal suffix (e.g. /:slug.md)", async () => {
const { matchConfigPattern } = await import(
"../packages/vinext/src/index.js"
);
// Should match URLs with the .md suffix and extract the param
expect(matchConfigPattern("/hello-world.md", "/:slug.md")).toEqual({ slug: "hello-world" });
expect(matchConfigPattern("/my-post.md", "/:slug.md")).toEqual({ slug: "my-post" });
// Should NOT match URLs without .md suffix
expect(matchConfigPattern("/", "/:slug.md")).toBeNull();
expect(matchConfigPattern("/hello-world", "/:slug.md")).toBeNull();
expect(matchConfigPattern("/hello-world.txt", "/:slug.md")).toBeNull();
// Should NOT match paths with extra segments
expect(matchConfigPattern("/blog/hello-world.md", "/:slug.md")).toBeNull();
});
it("matches :param with literal suffix via config-matchers module", async () => {
const { matchConfigPattern } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
expect(matchConfigPattern("/hello-world.md", "/:slug.md")).toEqual({ slug: "hello-world" });
expect(matchConfigPattern("/", "/:slug.md")).toBeNull();
expect(matchConfigPattern("/hello-world", "/:slug.md")).toBeNull();
});
it("still matches plain :path* catch-all (no suffix) correctly", async () => {
const { matchConfigPattern } = await import(
"../packages/vinext/src/index.js"
);
// Ensure the fix doesn't regress existing catch-all behavior
expect(matchConfigPattern("/docs", "/docs/:path*")).toEqual({ path: "" });
expect(matchConfigPattern("/docs/intro", "/docs/:path*")).toEqual({ path: "intro" });
expect(matchConfigPattern("/docs/guide/getting-started", "/docs/:path*")).toEqual({
path: "guide/getting-started",
});
});
// Regression test for: catch-all prefix overmatch
// /foobar was incorrectly matched by /foo/:path* because startsWith("/foo")
// passed without checking for a segment boundary after the prefix.
// https://github.com/cloudflare/vinext/pull/368
it("regression: does not overmatch catch-all when pathname shares a prefix but not a segment boundary", async () => {
const { matchConfigPattern } = await import(
"../packages/vinext/src/index.js"
);
// Core regression case: /foobar must NOT match /foo/:path*
expect(matchConfigPattern("/foobar", "/foo/:path*")).toBeNull();
// Similarly for :path+
expect(matchConfigPattern("/foobar", "/foo/:path+")).toBeNull();
// A legitimate sub-path still matches
expect(matchConfigPattern("/foo/bar", "/foo/:path*")).toEqual({ path: "bar" });
// An exact prefix (zero segments) still matches for :path*
expect(matchConfigPattern("/foo", "/foo/:path*")).toEqual({ path: "" });
// An exact prefix (zero segments) still does NOT match for :path+
expect(matchConfigPattern("/foo", "/foo/:path+")).toBeNull();
// Deeper false-prefix: /football must NOT match /foot/:path*
expect(matchConfigPattern("/football", "/foot/:path*")).toBeNull();
// But /foot/ball should match
expect(matchConfigPattern("/foot/ball", "/foot/:path*")).toEqual({ path: "ball" });
});
it("regression: catch-all prefix overmatch via config-matchers module", async () => {
const { matchConfigPattern } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
// Same cases exercised against the standalone config-matchers module
expect(matchConfigPattern("/foobar", "/foo/:path*")).toBeNull();
expect(matchConfigPattern("/foobar", "/foo/:path+")).toBeNull();
expect(matchConfigPattern("/foo/bar", "/foo/:path*")).toEqual({ path: "bar" });
expect(matchConfigPattern("/foo", "/foo/:path*")).toEqual({ path: "" });
expect(matchConfigPattern("/foo", "/foo/:path+")).toBeNull();
});
});
// ---------------------------------------------------------------------------
// isSafeRegex / safeRegExp unit tests (ReDoS prevention)
describe("isSafeRegex", () => {
it("accepts simple patterns", async () => {
const { isSafeRegex } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
expect(isSafeRegex("^/about$")).toBe(true);
expect(isSafeRegex("^/blog/[^/]+$")).toBe(true);
expect(isSafeRegex("^/docs/(.*)$")).toBe(true);
expect(isSafeRegex("^/api/(.+)$")).toBe(true);
expect(isSafeRegex("\\d+")).toBe(true);
expect(isSafeRegex("^/feed\\.xml$")).toBe(true);
});
it("accepts non-nested quantifiers inside groups", async () => {
const { isSafeRegex } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
// A single quantifier inside a group without a quantifier on the group itself
expect(isSafeRegex("(a+)")).toBe(true);
expect(isSafeRegex("([^/]+)")).toBe(true);
expect(isSafeRegex("(\\d{2,4})")).toBe(true);
});
it("rejects nested quantifiers: (a+)+", async () => {
const { isSafeRegex } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
expect(isSafeRegex("(a+)+")).toBe(false);
});
it("rejects nested quantifiers: (a+)*", async () => {
const { isSafeRegex } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
expect(isSafeRegex("(a+)*")).toBe(false);
});
it("rejects nested quantifiers: (.*)*", async () => {
const { isSafeRegex } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
expect(isSafeRegex("(.*)*")).toBe(false);
});
it("rejects nested quantifiers: (a*)+", async () => {
const { isSafeRegex } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
expect(isSafeRegex("(a*)+")).toBe(false);
});
it("rejects nested quantifiers: ([^/]+)+", async () => {
const { isSafeRegex } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
expect(isSafeRegex("([^/]+)+")).toBe(false);
});
it("rejects nested quantifiers with braces: (a+){2,}", async () => {
const { isSafeRegex } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
expect(isSafeRegex("(a+){2,}")).toBe(false);
});
it("accepts quantifier on group without inner quantifier", async () => {
const { isSafeRegex } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
// (ab)+ is fine — no inner quantifier
expect(isSafeRegex("(ab)+")).toBe(true);
expect(isSafeRegex("(foo|bar)*")).toBe(true);
});
it("treats escaped characters as safe", async () => {
const { isSafeRegex } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
// \\+ is a literal +, not a quantifier
expect(isSafeRegex("(a\\+)+")).toBe(true);
});
it("treats quantifiers inside character classes as safe", async () => {
const { isSafeRegex } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
// [+*] is a character class, not a quantifier
expect(isSafeRegex("([+*])+")).toBe(true);
});
it("rejects nested optional quantifiers: (a?)+", async () => {
const { isSafeRegex } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
// '?' inside group + quantifier on group = catastrophic backtracking
expect(isSafeRegex("(a?)+")).toBe(false);
expect(isSafeRegex("(a?)+b")).toBe(false);
});
it("rejects nested optional quantifiers: (.?)+", async () => {
const { isSafeRegex } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
expect(isSafeRegex("(.?)+")).toBe(false);
});
it("rejects nested optional quantifiers: (a?)*", async () => {
const { isSafeRegex } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
expect(isSafeRegex("(a?)*")).toBe(false);
});
it("accepts outer '?' on group (zero-or-one is not unbounded repetition)", async () => {
const { isSafeRegex } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
// '?' means zero or one — only 2 paths, not exponential backtracking
// This is safe even with inner quantifiers (e.g. URL patterns like (?:/.*)? )
expect(isSafeRegex("(a+)?")).toBe(true);
expect(isSafeRegex("(?:/.*)?")).toBe(true);
});
it("treats non-greedy modifier as safe, not as quantifier", async () => {
const { isSafeRegex } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
// a+? is non-greedy '+', not a nested quantifier
expect(isSafeRegex("(a+?)")).toBe(true);
// (a*?) is non-greedy '*', still just one quantifier
expect(isSafeRegex("(a*?)")).toBe(true);
});
});
describe("safeRegExp", () => {
it("returns RegExp for safe patterns", async () => {
const { safeRegExp } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
const re = safeRegExp("^/about$");
expect(re).toBeInstanceOf(RegExp);
expect(re!.test("/about")).toBe(true);
});
it("returns null for pathological patterns", async () => {
const { safeRegExp } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
// lgtm[js/redos] — deliberate pathological regex to test safeRegExp guard
const re = safeRegExp("(a+)+b");
expect(re).toBeNull();
});
it("returns null for invalid regex syntax", async () => {
const { safeRegExp } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
const re = safeRegExp("(?P<name>");
expect(re).toBeNull();
});
});
describe("escapeHeaderSource", () => {
it("passes through literal paths unchanged", async () => {
const { escapeHeaderSource } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
expect(escapeHeaderSource("/api/users")).toBe("/api/users");
});
it("escapes dots", async () => {
const { escapeHeaderSource } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
expect(escapeHeaderSource("/file.txt")).toBe("/file\\.txt");
});
it("converts named param to [^/]+", async () => {
const { escapeHeaderSource } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
expect(escapeHeaderSource("/user/:id")).toBe("/user/[^/]+");
});
it("converts glob * to .*", async () => {
const { escapeHeaderSource } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
expect(escapeHeaderSource("/api/*")).toBe("/api/.*");
});
it("escapes + and ?", async () => {
const { escapeHeaderSource } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
expect(escapeHeaderSource("/path+query")).toBe("/path\\+query");
expect(escapeHeaderSource("/maybe?")).toBe("/maybe\\?");
});
it("handles constrained param :param(constraint)", async () => {
const { escapeHeaderSource } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
expect(escapeHeaderSource("/api/:version(\\d+)/users")).toBe("/api/(\\d+)/users");
});
it("handles constrained param with alternation", async () => {
const { escapeHeaderSource } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
expect(escapeHeaderSource("/:lang(en|fr)/page")).toBe("/(en|fr)/page");
});
it("preserves standalone regex groups", async () => {
const { escapeHeaderSource } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
expect(escapeHeaderSource("/api/(v1|v2)/users")).toBe("/api/(v1|v2)/users");
});
it("handles multiple groups and params", async () => {
const { escapeHeaderSource } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
expect(escapeHeaderSource("/:lang(en|fr)/:id(\\d+)/page")).toBe("/(en|fr)/(\\d+)/page");
});
});
describe("matchConfigPattern rejects ReDoS patterns", () => {
it("returns null for pathological source patterns", async () => {
const { matchConfigPattern } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
// This pattern has nested quantifiers: the compiled regex would be (a+)+b
// which causes catastrophic backtracking. matchConfigPattern should return
// null (no match) rather than hanging.
// lgtm[js/redos] — deliberate pathological regex to test safeRegExp guard
const result = matchConfigPattern(
"/aaaaaaaaaaaaaaaaaaaac",
"/:id((a+)+b)",
);
expect(result).toBeNull();
});
});
describe("matchConfigPattern handles parameterized suffix patterns", () => {
it("matches :path* with literal suffix (e.g. /:path*.md)", async () => {
const { matchConfigPattern } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
// Should match URLs ending in .md
expect(matchConfigPattern("/article.md", "/:path*.md")).toEqual({ path: "article" });
expect(matchConfigPattern("/news/my-article.md", "/:path*.md")).toEqual({ path: "news/my-article" });
// Should NOT match URLs without .md suffix
expect(matchConfigPattern("/", "/:path*.md")).toBeNull();
expect(matchConfigPattern("/about", "/:path*.md")).toBeNull();
expect(matchConfigPattern("/news", "/:path*.md")).toBeNull();
});
it("matches :path+ with literal suffix (e.g. /:path+.json)", async () => {
const { matchConfigPattern } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
expect(matchConfigPattern("/data.json", "/:path+.json")).toEqual({ path: "data" });
expect(matchConfigPattern("/api/users.json", "/:path+.json")).toEqual({ path: "api/users" });
// Zero segments before suffix — should NOT match for :path+
expect(matchConfigPattern("/.json", "/:path+.json")).toBeNull();
expect(matchConfigPattern("/", "/:path+.json")).toBeNull();
});
it("does not regress plain :path* catch-all (no suffix)", async () => {
const { matchConfigPattern } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
expect(matchConfigPattern("/docs", "/docs/:path*")).toEqual({ path: "" });
expect(matchConfigPattern("/docs/intro", "/docs/:path*")).toEqual({ path: "intro" });
expect(matchConfigPattern("/docs/guide/getting-started", "/docs/:path*")).toEqual({
path: "guide/getting-started",
});
});
});
// ---------------------------------------------------------------------------
// has/missing condition matching unit tests (next.config.js redirects/rewrites)
describe("parseCookies", () => {
it("parses standard cookie header", async () => {
const { parseCookies } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
expect(parseCookies("a=1; b=2; c=three")).toEqual({ a: "1", b: "2", c: "three" });
});
it("returns empty object for null", async () => {
const { parseCookies } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
expect(parseCookies(null)).toEqual({});
});
it("returns empty object for empty string", async () => {
const { parseCookies } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
expect(parseCookies("")).toEqual({});
});
it("handles cookies with = in value", async () => {
const { parseCookies } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
expect(parseCookies("token=abc=def")).toEqual({ token: "abc=def" });
});
it("trims whitespace around keys and values", async () => {
const { parseCookies } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
expect(parseCookies(" a = 1 ; b = 2 ")).toEqual({ a: "1", b: "2" });
});
});
describe("checkHasConditions", () => {
function makeCtx(overrides: Partial<{
headers: Record<string, string>;
cookies: Record<string, string>;
query: Record<string, string>;
host: string;
}> = {}) {
const headers = new Headers(overrides.headers ?? {});
if (overrides.cookies) {
headers.set("cookie", Object.entries(overrides.cookies).map(([k, v]) => `${k}=${v}`).join("; "));
}
const query = new URLSearchParams(overrides.query ?? {});
return {
headers,
cookies: overrides.cookies ?? {},
query,
host: overrides.host ?? "localhost",
};
}
it("returns true when no conditions", async () => {
const { checkHasConditions } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
expect(checkHasConditions(undefined, undefined, makeCtx())).toBe(true);
});
// -- header conditions --
it("has header: passes when header present", async () => {
const { checkHasConditions } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
const ctx = makeCtx({ headers: { "x-custom": "yes" } });
expect(checkHasConditions([{ type: "header", key: "x-custom" }], undefined, ctx)).toBe(true);
});
it("has header: fails when header absent", async () => {
const { checkHasConditions } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
const ctx = makeCtx({});
expect(checkHasConditions([{ type: "header", key: "x-custom" }], undefined, ctx)).toBe(false);
});
it("has header with value: matches regex", async () => {
const { checkHasConditions } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
const ctx = makeCtx({ headers: { "x-auth": "yes" } });
expect(checkHasConditions([{ type: "header", key: "x-auth", value: "(?:yes|true)" }], undefined, ctx)).toBe(true);
expect(checkHasConditions([{ type: "header", key: "x-auth", value: "(?:no|false)" }], undefined, ctx)).toBe(false);
});
// -- cookie conditions --
it("has cookie: passes when cookie present", async () => {
const { checkHasConditions } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
const ctx = makeCtx({ cookies: { "session": "abc" } });
expect(checkHasConditions([{ type: "cookie", key: "session" }], undefined, ctx)).toBe(true);
});
it("has cookie: fails when cookie absent", async () => {
const { checkHasConditions } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
const ctx = makeCtx({ cookies: {} });
expect(checkHasConditions([{ type: "cookie", key: "session" }], undefined, ctx)).toBe(false);
});
it("has cookie with exact value", async () => {
const { checkHasConditions } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
const ctx = makeCtx({ cookies: { "authorized": "true" } });
expect(checkHasConditions([{ type: "cookie", key: "authorized", value: "true" }], undefined, ctx)).toBe(true);
expect(checkHasConditions([{ type: "cookie", key: "authorized", value: "false" }], undefined, ctx)).toBe(false);
});
// -- query conditions --
it("has query: passes when query param present", async () => {
const { checkHasConditions } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
const ctx = makeCtx({ query: { page: "home" } });
expect(checkHasConditions([{ type: "query", key: "page" }], undefined, ctx)).toBe(true);
});
it("has query: fails when query param absent", async () => {
const { checkHasConditions } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
const ctx = makeCtx({ query: {} });
expect(checkHasConditions([{ type: "query", key: "page" }], undefined, ctx)).toBe(false);
});
it("has query with regex value", async () => {
const { checkHasConditions } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
const ctx = makeCtx({ query: { page: "home" } });
expect(checkHasConditions([{ type: "query", key: "page", value: "home|about" }], undefined, ctx)).toBe(true);
expect(checkHasConditions([{ type: "query", key: "page", value: "^settings$" }], undefined, ctx)).toBe(false);
});
// -- host conditions --
it("has host: matches exact value", async () => {
const { checkHasConditions } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
const ctx = makeCtx({ host: "example.com" });
expect(checkHasConditions([{ type: "host", key: "", value: "example.com" }], undefined, ctx)).toBe(true);
expect(checkHasConditions([{ type: "host", key: "", value: "other.com" }], undefined, ctx)).toBe(false);
});
it("has host: matches regex value", async () => {
const { checkHasConditions } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
const ctx = makeCtx({ host: "staging.example.com" });
expect(checkHasConditions([{ type: "host", key: "", value: ".*\\.example\\.com" }], undefined, ctx)).toBe(true);
});
// -- missing conditions --
it("missing header: passes when header absent", async () => {
const { checkHasConditions } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
const ctx = makeCtx({});
expect(checkHasConditions(undefined, [{ type: "header", key: "x-block" }], ctx)).toBe(true);
});
it("missing header: fails when header present", async () => {
const { checkHasConditions } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
const ctx = makeCtx({ headers: { "x-block": "1" } });
expect(checkHasConditions(undefined, [{ type: "header", key: "x-block" }], ctx)).toBe(false);
});
it("missing cookie: passes when cookie absent", async () => {
const { checkHasConditions } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
const ctx = makeCtx({ cookies: {} });
expect(checkHasConditions(undefined, [{ type: "cookie", key: "stay-here" }], ctx)).toBe(true);
});
it("missing cookie: fails when cookie present", async () => {
const { checkHasConditions } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
const ctx = makeCtx({ cookies: { "stay-here": "1" } });
expect(checkHasConditions(undefined, [{ type: "cookie", key: "stay-here" }], ctx)).toBe(false);
});
// -- combined has + missing --
it("both has and missing must pass", async () => {
const { checkHasConditions } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
const ctx = makeCtx({ cookies: { "auth": "yes" } });
// has: cookie auth present (passes), missing: cookie block absent (passes)
expect(checkHasConditions(
[{ type: "cookie", key: "auth" }],
[{ type: "cookie", key: "block" }],
ctx,
)).toBe(true);
// has: cookie auth present (passes), missing: cookie auth absent (fails — it's present)
expect(checkHasConditions(
[{ type: "cookie", key: "auth" }],
[{ type: "cookie", key: "auth" }],
ctx,
)).toBe(false);
});
it("all has conditions must match (conjunction)", async () => {
const { checkHasConditions } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
const ctx = makeCtx({ cookies: { "a": "1" }, query: { "page": "home" } });
// Both match
expect(checkHasConditions(
[{ type: "cookie", key: "a" }, { type: "query", key: "page" }],
undefined,
ctx,
)).toBe(true);
// One doesn't match
expect(checkHasConditions(
[{ type: "cookie", key: "a" }, { type: "query", key: "missing" }],
undefined,
ctx,
)).toBe(false);
});
});
describe("matchHeaders", () => {
function makeCtx(overrides: Partial<{
headers: Record<string, string>;
cookies: Record<string, string>;
query: Record<string, string>;
host: string;
}> = {}) {
const headers = new Headers(overrides.headers ?? {});
if (overrides.cookies) {
headers.set("cookie", Object.entries(overrides.cookies).map(([k, v]) => `${k}=${v}`).join("; "));
}
const query = new URLSearchParams(overrides.query ?? {});
return {
headers,
cookies: overrides.cookies ?? {},
query,
host: overrides.host ?? "localhost",
};
}
it("applies headers when has header condition is satisfied", async () => {
const { matchHeaders } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
const rules: any[] = [
{
source: "/about",
has: [{ type: "header", key: "x-user-tier", value: "pro" }],
headers: [{ key: "x-conditional-header", value: "enabled" }],
},
];
const matched = matchHeaders("/about", rules, makeCtx({ headers: { "x-user-tier": "pro" } }));
expect(matched).toEqual([{ key: "x-conditional-header", value: "enabled" }]);
});
it("does not apply headers when missing cookie condition fails", async () => {
const { matchHeaders } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
const rules: any[] = [
{
source: "/about",
missing: [{ type: "cookie", key: "no-config-header" }],
headers: [{ key: "x-conditional-header", value: "enabled" }],
},
];
const matched = matchHeaders("/about", rules, makeCtx({ cookies: { "no-config-header": "1" } }));
expect(matched).toEqual([]);
});
it("applies headers when has query condition is satisfied", async () => {
const { matchHeaders } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
const rules: any[] = [
{
source: "/about",
has: [{ type: "query", key: "preview", value: "1" }],
headers: [{ key: "x-preview-header", value: "true" }],
},
];
const matched = matchHeaders("/about", rules, makeCtx({ query: { preview: "1" } }));
expect(matched).toEqual([{ key: "x-preview-header", value: "true" }]);
});
it("skips conditional header rule when has condition is not met", async () => {
const { matchHeaders } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
const rules: any[] = [
{
source: "/about",
has: [{ type: "header", key: "x-user-tier", value: "pro" }],
headers: [{ key: "x-conditional-header", value: "enabled" }],
},
];
// Request without the required header should not match
const matched = matchHeaders("/about", rules, makeCtx());
expect(matched).toEqual([]);
});
});
// ---------------------------------------------------------------------------
// isExternalUrl unit tests (external rewrite detection)
describe("isExternalUrl", () => {
it("returns true for https:// URLs", async () => {
const { isExternalUrl } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
expect(isExternalUrl("https://example.com/path")).toBe(true);
expect(isExternalUrl("https://us.i.posthog.com/decide?v=3")).toBe(true);
});
it("returns true for http:// URLs", async () => {
const { isExternalUrl } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
expect(isExternalUrl("http://example.com/api")).toBe(true);
});
it("returns false for relative paths", async () => {
const { isExternalUrl } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
expect(isExternalUrl("/about")).toBe(false);
expect(isExternalUrl("/api/test")).toBe(false);
expect(isExternalUrl("/")).toBe(false);
});
it("returns true for protocol-relative URLs", async () => {
const { isExternalUrl } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
expect(isExternalUrl("//example.com")).toBe(true);
expect(isExternalUrl("//cdn.example.com/image.png")).toBe(true);
});
it("returns true for exotic URL schemes (data:, javascript:, blob:, ftp:)", async () => {
const { isExternalUrl } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
expect(isExternalUrl("data:text/html,<h1>hi</h1>")).toBe(true);
expect(isExternalUrl("javascript:alert(1)")).toBe(true);
expect(isExternalUrl("blob:http://localhost/abc")).toBe(true);
expect(isExternalUrl("ftp://files.example.com/pub")).toBe(true);
});
it("returns false for hash-only and bare strings", async () => {
const { isExternalUrl } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
expect(isExternalUrl("#section")).toBe(false);
expect(isExternalUrl("about")).toBe(false);
});
});
// ---------------------------------------------------------------------------
// proxyExternalRequest unit tests
describe("proxyExternalRequest", () => {
it("proxies request to external URL and returns upstream response", async () => {
const { proxyExternalRequest } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
// Use a well-known public URL that returns a predictable response
const request = new Request("http://localhost:3000/test?extra=1", {
method: "GET",
headers: { "user-agent": "vinext-test" },
});
// We test the function constructs the right request by mocking fetch
const originalFetch = globalThis.fetch;
let capturedUrl: string | undefined;
let capturedInit: any;
globalThis.fetch = async (url: any, init: any) => {
capturedUrl = typeof url === "string" ? url : url.toString();
capturedInit = init;
return new Response("proxied body", {
status: 200,
headers: { "content-type": "text/plain", "x-upstream": "true" },
});
};
try {
const response = await proxyExternalRequest(request, "https://api.example.com/endpoint");
expect(capturedUrl).toContain("https://api.example.com/endpoint");
// Extra query param from original request should be merged
expect(capturedUrl).toContain("extra=1");
expect(capturedInit.method).toBe("GET");
expect(capturedInit.redirect).toBe("manual");
// Host header should be set to the external target
expect(capturedInit.headers.get("host")).toBe("api.example.com");
expect(response.status).toBe(200);
expect(response.headers.get("x-upstream")).toBe("true");
const body = await response.text();
expect(body).toBe("proxied body");
} finally {
globalThis.fetch = originalFetch;
}
});
it("preserves query parameters from the rewrite destination", async () => {
const { proxyExternalRequest } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
const request = new Request("http://localhost:3000/test", {
method: "GET",
});
const originalFetch = globalThis.fetch;
let capturedUrl: string | undefined;
globalThis.fetch = async (url: any, _init: any) => {
capturedUrl = typeof url === "string" ? url : url.toString();
return new Response("ok", { status: 200 });
};
try {
await proxyExternalRequest(request, "https://api.example.com/v1?key=abc");
expect(capturedUrl).toContain("key=abc");
} finally {
globalThis.fetch = originalFetch;
}
});
it("strips hop-by-hop headers from upstream response", async () => {
const { proxyExternalRequest } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
const request = new Request("http://localhost:3000/test");
const originalFetch = globalThis.fetch;
globalThis.fetch = async (_url: any, _init: any) => {
return new Response("ok", {
status: 200,
headers: {
"content-type": "text/plain",
"x-custom": "preserved",
// Note: "transfer-encoding" and "connection" are hop-by-hop headers
// that should be stripped. However, the fetch API may not allow
// setting them on Response, so we test with headers that can be set.
},
});
};
try {
const response = await proxyExternalRequest(request, "https://api.example.com/test");
expect(response.headers.get("content-type")).toBe("text/plain");
expect(response.headers.get("x-custom")).toBe("preserved");
} finally {
globalThis.fetch = originalFetch;
}
});
it("passes through non-200 status codes", async () => {
const { proxyExternalRequest } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
const request = new Request("http://localhost:3000/test");
const originalFetch = globalThis.fetch;
globalThis.fetch = async (_url: any, _init: any) => {
return new Response("Not Found", { status: 404 });
};
try {
const response = await proxyExternalRequest(request, "https://api.example.com/missing");
expect(response.status).toBe(404);
} finally {
globalThis.fetch = originalFetch;
}
});
it("forwards credential headers and strips x-middleware-* headers from proxied requests", async () => {
const { proxyExternalRequest } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
const request = new Request("http://localhost:3000/proxy", {
method: "GET",
headers: {
"cookie": "session=secret123",
"authorization": "Bearer tok_secret",
"x-api-key": "sk_live_secret",
"proxy-authorization": "Basic cHJveHk=",
"x-middleware-rewrite": "/internal",
"x-middleware-next": "1",
"x-custom-header": "keep-me",
"user-agent": "vinext-test",
},
});
const originalFetch = globalThis.fetch;
let capturedHeaders: Headers | undefined;
globalThis.fetch = async (_url: any, init: any) => {
capturedHeaders = init.headers;
return new Response("ok", { status: 200 });
};
try {
await proxyExternalRequest(request, "https://api.example.com/data");
expect(capturedHeaders).toBeDefined();
// Credential headers must be forwarded (matching Next.js behavior)
expect(capturedHeaders!.get("cookie")).toBe("session=secret123");
expect(capturedHeaders!.get("authorization")).toBe("Bearer tok_secret");
expect(capturedHeaders!.get("x-api-key")).toBe("sk_live_secret");
expect(capturedHeaders!.get("proxy-authorization")).toBe("Basic cHJveHk=");
// Internal middleware headers must be stripped
expect(capturedHeaders!.get("x-middleware-rewrite")).toBeNull();
expect(capturedHeaders!.get("x-middleware-next")).toBeNull();
// Non-sensitive headers must be preserved
expect(capturedHeaders!.get("x-custom-header")).toBe("keep-me");
expect(capturedHeaders!.get("user-agent")).toBe("vinext-test");
} finally {
globalThis.fetch = originalFetch;
}
});
it("forwards redirect responses without following them", async () => {
const { proxyExternalRequest } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
const request = new Request("http://localhost:3000/test");
const originalFetch = globalThis.fetch;
globalThis.fetch = async (_url: any, init: any) => {
// Verify redirect: "manual" was set
expect(init.redirect).toBe("manual");
return new Response(null, {
status: 301,
headers: { "location": "https://other.example.com/new" },
});
};
try {
const response = await proxyExternalRequest(request, "https://api.example.com/old");
expect(response.status).toBe(301);
expect(response.headers.get("location")).toBe("https://other.example.com/new");
} finally {
globalThis.fetch = originalFetch;
}
});
it("strips content-encoding and content-length from proxied response to avoid double-decompression", async () => {
const { proxyExternalRequest } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
const request = new Request("http://localhost:3000/test");
const originalFetch = globalThis.fetch;
globalThis.fetch = async (_url: any, _init: any) => {
// Simulate an upstream that sent gzip-encoded content.
// Node.js fetch() auto-decompresses, but the original headers remain.
return new Response("decompressed body", {
status: 200,
headers: {
"content-type": "application/json",
"content-encoding": "gzip",
"content-length": "42",
"x-custom": "keep",
},
});
};
try {
const response = await proxyExternalRequest(request, "https://api.example.com/data");
// content-encoding and content-length must be stripped to prevent
// ERR_CONTENT_DECODING_FAILED in the browser (double-decompression bug).
expect(response.headers.get("content-encoding")).toBeNull();
expect(response.headers.get("content-length")).toBeNull();
// Other headers must be preserved
expect(response.headers.get("content-type")).toBe("application/json");
expect(response.headers.get("x-custom")).toBe("keep");
} finally {
globalThis.fetch = originalFetch;
}
});
});
// ---------------------------------------------------------------------------
// matchRewrite + isExternalUrl integration (config-matchers)
describe("matchRewrite with external URLs", () => {
const emptyCtx = { headers: new Headers(), cookies: {}, query: new URLSearchParams(), host: "localhost" };
it("returns full external URL when destination is external", async () => {
const { matchRewrite, isExternalUrl } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
const rewrites = [
{ source: "/ph/:path*", destination: "https://us.i.posthog.com/:path*" },
];
const result = matchRewrite("/ph/decide", rewrites, emptyCtx);
expect(result).toBe("https://us.i.posthog.com/decide");
expect(isExternalUrl(result!)).toBe(true);
});
it("returns full external URL for static path rewrites", async () => {
const { matchRewrite, isExternalUrl } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
const rewrites = [
{ source: "/ph/static/:path*", destination: "https://us-assets.i.posthog.com/static/:path*" },
];
const result = matchRewrite("/ph/static/array.js", rewrites, emptyCtx);
expect(result).toBe("https://us-assets.i.posthog.com/static/array.js");
expect(isExternalUrl(result!)).toBe(true);
});
it("returns internal path for non-external rewrites", async () => {
const { matchRewrite, isExternalUrl } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
const rewrites = [
{ source: "/posts/:id", destination: "/blog/:id" },
];
const result = matchRewrite("/posts/hello", rewrites, emptyCtx);
expect(result).toBe("/blog/hello");
expect(isExternalUrl(result!)).toBe(false);
});
});
// ---------------------------------------------------------------------------
// sanitizeDestination — protocol-relative URL handling
describe("sanitizeDestination", () => {
it("collapses leading // to / for relative URLs", async () => {
const { sanitizeDestination } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
expect(sanitizeDestination("//evil.com")).toBe("/evil.com");
expect(sanitizeDestination("///evil.com")).toBe("/evil.com");
expect(sanitizeDestination("////evil.com/path")).toBe("/evil.com/path");
});
it("preserves external http:// and https:// URLs", async () => {
const { sanitizeDestination } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
expect(sanitizeDestination("https://example.com/path")).toBe("https://example.com/path");
expect(sanitizeDestination("http://example.com")).toBe("http://example.com");
});
it("normalizes leading backslashes (browsers treat \\ as /)", async () => {
const { sanitizeDestination } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
expect(sanitizeDestination("\\/evil.com")).toBe("/evil.com");
expect(sanitizeDestination("\\\\evil.com")).toBe("/evil.com");
expect(sanitizeDestination("\\\\/evil.com")).toBe("/evil.com");
expect(sanitizeDestination("/\\evil.com")).toBe("/evil.com");
});
it("preserves normal relative paths", async () => {
const { sanitizeDestination } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
expect(sanitizeDestination("/about")).toBe("/about");
expect(sanitizeDestination("/blog/hello")).toBe("/blog/hello");
expect(sanitizeDestination("/")).toBe("/");
});
});
// ---------------------------------------------------------------------------
// Catch-all redirect destination sanitization
describe("open redirect prevention in catch-all redirects", () => {
const emptyCtx = { headers: new Headers(), cookies: {}, query: new URLSearchParams(), host: "localhost" };
it("matchRedirect sanitizes decoded %2F that would produce //evil.com", async () => {
const { matchRedirect } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
// In the real request flow, the entry point decodes %2F to / and
// normalizePath collapses // to /. So /old/%2Fevil.com arrives as
// /old/evil.com (after decode + normalize).
// Test with the already-decoded path (how matchRedirect is actually called).
const redirects = [
{ source: "/old/:path*", destination: "/:path*", permanent: false },
];
const result = matchRedirect("/old/evil.com", redirects, emptyCtx);
expect(result).not.toBeNull();
expect(result!.destination).toBe("/evil.com");
// Verify it does NOT start with // (protocol-relative)
expect(result!.destination.startsWith("//")).toBe(false);
});
it("matchRedirect sanitizes double-slash in already-decoded paths", async () => {
const { matchRedirect } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
const redirects = [
{ source: "/old/:path*", destination: "/:path*", permanent: false },
];
// Even if an already-decoded path somehow contains //, the sanitizer should handle it
const result = matchRedirect("/old//evil.com", redirects, emptyCtx);
expect(result).not.toBeNull();
expect(result!.destination.startsWith("//")).toBe(false);
});
it("matchRedirect preserves valid external redirect destinations", async () => {
const { matchRedirect } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
const redirects = [
{ source: "/go/:path*", destination: "https://example.com/:path*", permanent: false },
];
const result = matchRedirect("/go/page", redirects, emptyCtx);
expect(result).not.toBeNull();
expect(result!.destination).toBe("https://example.com/page");
});
it("matchRewrite sanitizes decoded %2F that would produce //evil.com", async () => {
const { matchRewrite } = await import(
"../packages/vinext/src/config/config-matchers.js"
);
const rewrites = [
{ source: "/old/:path*", destination: "/:path*" },
];
// In the real request flow, the entry point decodes and normalizePath
// collapses //. Test with already-decoded path.
const result = matchRewrite("/old/evil.com", rewrites, emptyCtx);
expect(result).not.toBeNull();
expect(result!).toBe("/evil.com");
expect(result!.startsWith("//")).toBe(false);
});
});
describe("next/form shim", () => {
it("exports default Form component", async () => {
const mod = await import("../packages/vinext/src/shims/form.js");
expect(mod.default).toBeDefined();
expect(typeof mod.default).toBe("object"); // forwardRef returns an object
});
it("re-exports useActionState from React", async () => {
const mod = await import("../packages/vinext/src/shims/form.js");
expect(typeof mod.useActionState).toBe("function");
});
it("renders a form element with string action in SSR", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const { default: Form } = await import("../packages/vinext/src/shims/form.js");
const html = renderToStaticMarkup(
React.createElement(
Form,
{ action: "/search" },
React.createElement("input", { name: "q" }),
React.createElement("button", { type: "submit" }, "Search"),
),
);
expect(html).toContain("<form");
expect(html).toContain('action="/search"');
expect(html).toContain('name="q"');
expect(html).toContain("Search");
});
it("renders a form with method prop", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const { default: Form } = await import("../packages/vinext/src/shims/form.js");
const html = renderToStaticMarkup(
React.createElement(
Form,
{ action: "/api/submit", method: "POST" },
React.createElement("button", { type: "submit" }, "Submit"),
),
);
expect(html).toContain("<form");
expect(html).toContain('method="POST"');
});
it("renders children inside the form", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const { default: Form } = await import("../packages/vinext/src/shims/form.js");
const html = renderToStaticMarkup(
React.createElement(
Form,
{ action: "/search" },
React.createElement("label", null, "Query:"),
React.createElement("input", { name: "q", placeholder: "Search..." }),
React.createElement("button", null, "Go"),
),
);
expect(html).toContain("Query:");
expect(html).toContain('placeholder="Search..."');
expect(html).toContain("Go");
});
it("passes className and id through to form element", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const { default: Form } = await import("../packages/vinext/src/shims/form.js");
const html = renderToStaticMarkup(
React.createElement(
Form,
{ action: "/search", className: "search-form", id: "main-search" },
),
);
expect(html).toContain('class="search-form"');
expect(html).toContain('id="main-search"');
});
});
describe("next/font/google shim", () => {
it("returns className, style, and variable for a Google Font", async () => {
const { Inter } = await import(
"../packages/vinext/src/shims/font-google.js"
);
const result = Inter({ subsets: ["latin"], weight: ["400", "700"] });
expect(result.className).toMatch(/^__font_inter_/);
expect(result.style.fontFamily).toContain("Inter");
// In Next.js, `variable` returns a CLASS NAME that sets the CSS variable.
// Users apply this class to set the CSS variable on that element.
expect(result.variable).toMatch(/^__variable_inter_/);
});
it("Proxy returns font loaders for any family", async () => {
const mod = await import(
"../packages/vinext/src/shims/font-google.js"
);
const googleFonts = mod.default;
const loader = googleFonts.Poppins;
expect(typeof loader).toBe("function");
const result = loader({ weight: "400" });
expect(result.className).toMatch(/^__font_poppins_/);
expect(result.style.fontFamily).toContain("Poppins");
});
it("converts PascalCase to font family name", async () => {
const mod = await import(
"../packages/vinext/src/shims/font-google.js"
);
const googleFonts = mod.default;
const result = googleFonts.RobotoMono({ weight: "400" });
expect(result.style.fontFamily).toContain("Roboto Mono");
// In Next.js, `variable` returns a CLASS NAME that sets the CSS variable.
expect(result.variable).toMatch(/^__variable_roboto_mono_/);
});
it("uses custom variable name when provided", async () => {
const { Inter } = await import(
"../packages/vinext/src/shims/font-google.js"
);
const result = Inter({ variable: "--custom-font" });
// When custom variable is provided, the generated class still sets that variable
// The returned value is still a class name, not the CSS variable name itself
expect(result.variable).toMatch(/^__variable_inter_/);
});
it("uses custom fallback fonts", async () => {
const { Inter } = await import(
"../packages/vinext/src/shims/font-google.js"
);
const result = Inter({ fallback: ["Helvetica", "Arial", "sans-serif"] });
expect(result.style.fontFamily).toContain("Helvetica");
expect(result.style.fontFamily).toContain("Arial");
});
it("generates CSS rules for className (SSR)", async () => {
const { Inter, getSSRFontStyles } = await import(
"../packages/vinext/src/shims/font-google.js"
);
// Clear any previously collected styles
getSSRFontStyles();
const result = Inter({ subsets: ["latin"], weight: ["400"] });
// getSSRFontStyles should return CSS rules mapping className to font-family
const styles = getSSRFontStyles();
const allCss = styles.join("\n");
expect(allCss).toContain(`.${result.className}`);
expect(allCss).toContain("font-family:");
expect(allCss).toContain("Inter");
});
it("generates CSS variable rule when variable is specified", async () => {
const { Inter, getSSRFontStyles } = await import(
"../packages/vinext/src/shims/font-google.js"
);
getSSRFontStyles(); // clear
Inter({ variable: "--font-inter" });
const styles = getSSRFontStyles();
const allCss = styles.join("\n");
expect(allCss).toContain("--font-inter:");
});
});
describe("next/font/local shim", () => {
it("returns className, style for a local font", async () => {
const { default: localFont } = await import(
"../packages/vinext/src/shims/font-local.js"
);
const result = localFont({ src: "./my-font.woff2" });
expect(result.className).toMatch(/^__font_local_/);
expect(result.style.fontFamily).toMatch(/__local_font_/);
});
it("includes variable as generated class name when specified", async () => {
const { default: localFont } = await import(
"../packages/vinext/src/shims/font-local.js"
);
const result = localFont({
src: "./my-font.woff2",
variable: "--font-custom",
});
// variable should be a generated class name, not the raw CSS variable
expect(result.variable).toMatch(/^__variable_local_/);
expect(result.variable).not.toBe("--font-custom");
});
it("accepts array of font sources", async () => {
const { default: localFont } = await import(
"../packages/vinext/src/shims/font-local.js"
);
const result = localFont({
src: [
{ path: "./regular.woff2", weight: "400" },
{ path: "./bold.woff2", weight: "700" },
],
});
expect(result.className).toMatch(/^__font_local_/);
expect(result.style.fontFamily).toBeTruthy();
});
it("does not include variable when not specified", async () => {
const { default: localFont } = await import(
"../packages/vinext/src/shims/font-local.js"
);
const result = localFont({ src: "./no-var.woff2" });
expect(result.variable).toBeUndefined();
});
it("generates SSR font styles for className rules", async () => {
const fontLocal = await import(
"../packages/vinext/src/shims/font-local.js"
);
const localFont = fontLocal.default;
// In test (Node), typeof document === "undefined", so SSR path is used
const result = localFont({
src: "./ssr-test.woff2",
variable: "--font-ssr-test",
});
const ssrStyles = fontLocal.getSSRFontStyles();
expect(ssrStyles.length).toBeGreaterThan(0);
// Should contain @font-face rule
const allCSS = ssrStyles.join("\n");
expect(allCSS).toContain("@font-face");
expect(allCSS).toContain("ssr-test.woff2");
// Should contain className rule
expect(allCSS).toContain(`.${result.className}`);
expect(allCSS).toContain("font-family:");
// Should contain variable class rule with :root fallback
expect(allCSS).toContain(`.${result.variable}`);
expect(allCSS).toContain("--font-ssr-test");
expect(allCSS).toContain(":root");
});
it("generates unique classNames and variableClassNames", async () => {
const { default: localFont } = await import(
"../packages/vinext/src/shims/font-local.js"
);
const a = localFont({ src: "./a.woff2", variable: "--font-a" });
const b = localFont({ src: "./b.woff2", variable: "--font-b" });
expect(a.className).not.toBe(b.className);
expect(a.variable).not.toBe(b.variable);
expect(a.variable).toMatch(/^__variable_local_/);
expect(b.variable).toMatch(/^__variable_local_/);
});
it("exports getSSRFontPreloads function", async () => {
const fontLocal = await import(
"../packages/vinext/src/shims/font-local.js"
);
expect(typeof fontLocal.getSSRFontPreloads).toBe("function");
});
it("collects preload data for fonts with absolute URLs", async () => {
const fontLocal = await import(
"../packages/vinext/src/shims/font-local.js"
);
const localFont = fontLocal.default;
// Simulate a font with an absolute URL (as resolved by Vite transform)
localFont({ src: "/assets/my-font-abc123.woff2" });
const preloads = fontLocal.getSSRFontPreloads();
const match = preloads.find(
(p: any) => p.href === "/assets/my-font-abc123.woff2",
);
expect(match).toBeDefined();
expect(match!.type).toBe("font/woff2");
});
it("collects preload data for array font sources with absolute URLs", async () => {
const fontLocal = await import(
"../packages/vinext/src/shims/font-local.js"
);
const localFont = fontLocal.default;
localFont({
src: [
{ path: "/assets/regular-abc.woff2", weight: "400" },
{ path: "/assets/bold-def.woff", weight: "700" },
],
});
const preloads = fontLocal.getSSRFontPreloads();
const woff2 = preloads.find(
(p: any) => p.href === "/assets/regular-abc.woff2",
);
const woff = preloads.find(
(p: any) => p.href === "/assets/bold-def.woff",
);
expect(woff2).toBeDefined();
expect(woff2!.type).toBe("font/woff2");
expect(woff).toBeDefined();
expect(woff!.type).toBe("font/woff");
});
it("does not collect preload data for relative URLs", async () => {
const fontLocal = await import(
"../packages/vinext/src/shims/font-local.js"
);
const localFont = fontLocal.default;
const preloadsBefore = fontLocal.getSSRFontPreloads().length;
localFont({ src: "./relative-font.woff2" });
const preloadsAfter = fontLocal.getSSRFontPreloads().length;
// Relative URLs should NOT be added to preloads
expect(preloadsAfter).toBe(preloadsBefore);
});
it("deduplicates preload entries by href", async () => {
const fontLocal = await import(
"../packages/vinext/src/shims/font-local.js"
);
const localFont = fontLocal.default;
// Call twice with the same font URL
localFont({ src: "/assets/dedup-test.woff2" });
localFont({ src: "/assets/dedup-test.woff2" });
const preloads = fontLocal.getSSRFontPreloads();
const matches = preloads.filter(
(p: any) => p.href === "/assets/dedup-test.woff2",
);
expect(matches.length).toBe(1);
});
});
describe("next/og shim", () => {
it("exports ImageResponse class", async () => {
const og = await import(
"../packages/vinext/src/shims/og.js"
);
expect(og.ImageResponse).toBeDefined();
expect(typeof og.ImageResponse).toBe("function");
});
it("ImageResponse extends Response", async () => {
const og = await import(
"../packages/vinext/src/shims/og.js"
);
// Check the prototype chain
expect(og.ImageResponse.prototype instanceof Response).toBe(true);
});
it("generates a PNG image from JSX", async () => {
const React = await import("react");
const og = await import(
"../packages/vinext/src/shims/og.js"
);
// Simple colored div — no text so no font needed
const element = React.createElement(
"div",
{
style: {
display: "flex",
width: "100%",
height: "100%",
backgroundColor: "#ff6600",
alignItems: "center",
justifyContent: "center",
},
},
);
const response = new og.ImageResponse(element, {
width: 100,
height: 100,
});
expect(response).toBeInstanceOf(Response);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toBe("image/png");
// Read the response body — should be valid PNG data
const buffer = await response.arrayBuffer();
const bytes = new Uint8Array(buffer);
// PNG magic bytes: 0x89 0x50 0x4E 0x47
expect(bytes[0]).toBe(0x89);
expect(bytes[1]).toBe(0x50); // P
expect(bytes[2]).toBe(0x4e); // N
expect(bytes[3]).toBe(0x47); // G
});
it("respects custom status and headers", async () => {
const React = await import("react");
const og = await import(
"../packages/vinext/src/shims/og.js"
);
const element = React.createElement("div", {
style: { display: "flex", width: "100%", height: "100%", backgroundColor: "blue" },
});
const response = new og.ImageResponse(element, {
width: 50,
height: 50,
status: 201,
headers: { "x-custom": "test-value" },
});
expect(response.status).toBe(201);
expect(response.headers.get("x-custom")).toBe("test-value");
expect(response.headers.get("content-type")).toBe("image/png");
});
it("uses default dimensions of 1200x630", async () => {
const React = await import("react");
const og = await import(
"../packages/vinext/src/shims/og.js"
);
const element = React.createElement("div", {
style: { display: "flex", width: "100%", height: "100%", backgroundColor: "green" },
});
// No width/height specified — should use defaults
const response = new og.ImageResponse(element);
expect(response).toBeInstanceOf(Response);
// Verify it produces valid PNG
const buffer = await response.arrayBuffer();
const bytes = new Uint8Array(buffer);
expect(bytes[0]).toBe(0x89); // PNG magic
});
});
describe("metadata route serializers", () => {
it("sitemapToXml converts sitemap entries to valid XML", async () => {
const { sitemapToXml } = await import(
"../packages/vinext/src/server/metadata-routes.js"
);
const xml = sitemapToXml([
{ url: "https://example.com", lastModified: "2025-01-01", priority: 1 },
{ url: "https://example.com/about", changeFrequency: "monthly" as const },
]);
expect(xml).toContain('<?xml version="1.0" encoding="UTF-8"?>');
expect(xml).toContain("<urlset");
expect(xml).toContain("<loc>https://example.com</loc>");
expect(xml).toContain("<lastmod>2025-01-01</lastmod>");
expect(xml).toContain("<priority>1</priority>");
expect(xml).toContain("<loc>https://example.com/about</loc>");
expect(xml).toContain("<changefreq>monthly</changefreq>");
});
it("sitemapToXml handles Date objects", async () => {
const { sitemapToXml } = await import(
"../packages/vinext/src/server/metadata-routes.js"
);
const xml = sitemapToXml([
{ url: "https://example.com", lastModified: new Date("2025-06-15") },
]);
expect(xml).toContain("<lastmod>2025-06-15T00:00:00.000Z</lastmod>");
});
it("robotsToText converts robots config to text", async () => {
const { robotsToText } = await import(
"../packages/vinext/src/server/metadata-routes.js"
);
const text = robotsToText({
rules: { userAgent: "*", allow: "/", disallow: "/private/" },
sitemap: "https://example.com/sitemap.xml",
});
expect(text).toContain("User-Agent: *");
expect(text).toContain("Allow: /");
expect(text).toContain("Disallow: /private/");
expect(text).toContain("Sitemap: https://example.com/sitemap.xml");
});
it("robotsToText handles multiple rules", async () => {
const { robotsToText } = await import(
"../packages/vinext/src/server/metadata-routes.js"
);
const text = robotsToText({
rules: [
{ userAgent: "Googlebot", allow: "/" },
{ userAgent: "Bingbot", disallow: "/secret" },
],
});
expect(text).toContain("User-Agent: Googlebot");
expect(text).toContain("User-Agent: Bingbot");
expect(text).toContain("Disallow: /secret");
});
it("manifestToJson converts manifest config to JSON", async () => {
const { manifestToJson } = await import(
"../packages/vinext/src/server/metadata-routes.js"
);
const json = manifestToJson({
name: "Test App",
short_name: "Test",
start_url: "/",
display: "standalone",
});
const parsed = JSON.parse(json);
expect(parsed.name).toBe("Test App");
expect(parsed.display).toBe("standalone");
});
it("scanMetadataFiles discovers metadata files in app directory", async () => {
const { scanMetadataFiles } = await import(
"../packages/vinext/src/server/metadata-routes.js"
);
const appDir = path.resolve(import.meta.dirname, "./fixtures/app-basic/app");
const routes = scanMetadataFiles(appDir);
// Should find our test fixture files
const types = routes.map((r: { type: string }) => r.type);
expect(types).toContain("sitemap");
expect(types).toContain("robots");
expect(types).toContain("manifest");
expect(types).toContain("favicon");
// Sitemap should be dynamic (.ts)
const sitemap = routes.find((r: { type: string }) => r.type === "sitemap");
expect(sitemap).toBeDefined();
expect(sitemap!.isDynamic).toBe(true);
expect(sitemap!.servedUrl).toBe("/sitemap.xml");
expect(sitemap!.contentType).toBe("application/xml");
// Favicon should be static (.ico)
const favicon = routes.find((r: { type: string }) => r.type === "favicon");
expect(favicon).toBeDefined();
expect(favicon!.isDynamic).toBe(false);
expect(favicon!.servedUrl).toBe("/favicon.ico");
expect(favicon!.contentType).toBe("image/x-icon");
});
});
describe("next/dynamic shim", () => {
it("exports a default function", async () => {
const mod = await import(
"../packages/vinext/src/shims/dynamic.js"
);
expect(typeof mod.default).toBe("function");
});
it("exports flushPreloads", async () => {
const mod = await import(
"../packages/vinext/src/shims/dynamic.js"
);
expect(typeof mod.flushPreloads).toBe("function");
});
it("returns a component for SSR-enabled dynamic imports", async () => {
const { default: dynamic } = await import(
"../packages/vinext/src/shims/dynamic.js"
);
const React = await import("react");
const { renderToReadableStream } = await import("react-dom/server.edge");
const FakeComponent = () => React.createElement("div", null, "Hello from dynamic");
const DynamicComponent = dynamic(() =>
Promise.resolve({ default: FakeComponent }),
);
// renderToReadableStream handles React.lazy + Suspense
const stream = await renderToReadableStream(React.createElement(DynamicComponent));
await stream.allReady;
const html = await new Response(stream).text();
expect(html).toContain("Hello from dynamic");
});
it("renders loading state for ssr: false on server", async () => {
const { default: dynamic } = await import(
"../packages/vinext/src/shims/dynamic.js"
);
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const FakeComponent = () => React.createElement("div", null, "Should not appear");
const Loading = () => React.createElement("span", null, "Loading...");
const DynamicComponent = dynamic(
() => Promise.resolve({ default: FakeComponent }),
{ ssr: false, loading: Loading },
);
// On server with ssr: false, should render loading, not the component
const html = renderToStaticMarkup(React.createElement(DynamicComponent));
expect(html).toContain("Loading...");
expect(html).not.toContain("Should not appear");
});
it("renders nothing for ssr: false without loading on server", async () => {
const { default: dynamic } = await import(
"../packages/vinext/src/shims/dynamic.js"
);
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const FakeComponent = () => React.createElement("div", null, "Should not appear");
const DynamicComponent = dynamic(
() => Promise.resolve({ default: FakeComponent }),
{ ssr: false },
);
// On server with ssr: false and no loading component, should render nothing
const html = renderToStaticMarkup(React.createElement(DynamicComponent));
expect(html).toBe("");
});
it("accepts module without default export (bare component)", async () => {
const { default: dynamic } = await import(
"../packages/vinext/src/shims/dynamic.js"
);
const React = await import("react");
const { renderToReadableStream } = await import("react-dom/server.edge");
const BareComponent = () => React.createElement("p", null, "Bare export");
const DynamicComponent = dynamic(() => Promise.resolve(BareComponent));
const stream = await renderToReadableStream(React.createElement(DynamicComponent));
await stream.allReady;
const html = await new Response(stream).text();
expect(html).toContain("Bare export");
});
it("forwards props to the underlying component", async () => {
const { default: dynamic } = await import(
"../packages/vinext/src/shims/dynamic.js"
);
const React = await import("react");
const { renderToReadableStream } = await import("react-dom/server.edge");
const Greeter = ({ name }: { name: string }) =>
React.createElement("span", null, `Hello ${name}`);
const DynamicGreeter = dynamic(() => Promise.resolve({ default: Greeter }));
const stream = await renderToReadableStream(
React.createElement(DynamicGreeter, { name: "World" }),
);
await stream.allReady;
const html = await new Response(stream).text();
expect(html).toContain("Hello World");
});
it("renders loading fallback when component not yet resolved (SSR)", async () => {
const { default: dynamic } = await import(
"../packages/vinext/src/shims/dynamic.js"
);
const React = await import("react");
const { renderToReadableStream } = await import("react-dom/server.edge");
let resolveLoader!: (val: any) => void;
const loaderPromise = new Promise((r) => { resolveLoader = r; });
const SlowComponent = () => React.createElement("div", null, "Loaded");
const Loading = () => React.createElement("span", null, "Please wait...");
const DynamicSlow = dynamic(() => loaderPromise as any, { loading: Loading });
// Start streaming — the shell includes the Suspense fallback
const stream = await renderToReadableStream(React.createElement(DynamicSlow));
// Resolve the loader so the stream can complete
resolveLoader({ default: SlowComponent });
await stream.allReady;
const html = await new Response(stream).text();
// The final HTML should contain the resolved component, but the Suspense
// fallback ("Please wait...") was sent as part of the shell before it resolved
expect(html).toContain("Loaded");
});
it("streaming renderer resolves multiple dynamic components", async () => {
const { default: dynamic } = await import(
"../packages/vinext/src/shims/dynamic.js"
);
const React = await import("react");
const { renderToReadableStream } = await import("react-dom/server.edge");
const CompA = () => React.createElement("div", null, "Component A");
const CompB = () => React.createElement("div", null, "Component B");
const DynA = dynamic(() =>
new Promise<any>((r) => setTimeout(() => r({ default: CompA }), 10)),
);
const DynB = dynamic(() =>
new Promise<any>((r) => setTimeout(() => r({ default: CompB }), 10)),
);
// renderToReadableStream handles React.lazy via Suspense
const streamA = await renderToReadableStream(React.createElement(DynA));
await streamA.allReady;
const htmlA = await new Response(streamA).text();
const streamB = await renderToReadableStream(React.createElement(DynB));
await streamB.allReady;
const htmlB = await new Response(streamB).text();
expect(htmlA).toContain("Component A");
expect(htmlB).toContain("Component B");
});
it("flushPreloads second call resolves immediately (queue drained)", async () => {
const { flushPreloads } = await import(
"../packages/vinext/src/shims/dynamic.js"
);
// First call should drain whatever's in the queue
await flushPreloads();
// Second call should resolve immediately with empty array
const result = await flushPreloads();
expect(result).toEqual([]);
});
it("loading component receives isLoading and pastDelay props", async () => {
const { default: dynamic } = await import(
"../packages/vinext/src/shims/dynamic.js"
);
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
let receivedProps: any = null;
const Loading = (props: any) => {
receivedProps = props;
return React.createElement("span", null, "Loading");
};
const FakeComp = () => React.createElement("div", null, "Content");
const DynComp = dynamic(
() => Promise.resolve({ default: FakeComp }),
{ ssr: false, loading: Loading },
);
renderToStaticMarkup(React.createElement(DynComp));
expect(receivedProps).not.toBeNull();
expect(receivedProps.isLoading).toBe(true);
expect(receivedProps.pastDelay).toBe(true);
expect(receivedProps.error).toBeNull();
});
it("renders loading fallback for ssr: false with props forwarded", async () => {
const { default: dynamic } = await import(
"../packages/vinext/src/shims/dynamic.js"
);
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const HeavyChart = ({ title }: { title: string }) =>
React.createElement("canvas", null, title);
const Loading = () => React.createElement("div", null, "Chart loading...");
const DynamicChart = dynamic(
() => Promise.resolve({ default: HeavyChart }),
{ ssr: false, loading: Loading },
);
// On server: should show loading, not the chart
const html = renderToStaticMarkup(
React.createElement(DynamicChart, { title: "Revenue" }),
);
expect(html).toContain("Chart loading...");
expect(html).not.toContain("Revenue");
});
it("handles module with both default and named exports", async () => {
const { default: dynamic } = await import(
"../packages/vinext/src/shims/dynamic.js"
);
const React = await import("react");
const { renderToReadableStream } = await import("react-dom/server.edge");
const MainComponent = () => React.createElement("div", null, "Main");
const namedHelper = () => "helper";
const DynComp = dynamic(() =>
Promise.resolve({ default: MainComponent, namedHelper }),
);
const stream = await renderToReadableStream(React.createElement(DynComp));
await stream.allReady;
const html = await new Response(stream).text();
expect(html).toContain("Main");
});
it("loader rejection does not crash flushPreloads", async () => {
const { default: dynamic, flushPreloads } = await import(
"../packages/vinext/src/shims/dynamic.js"
);
dynamic(() => Promise.reject(new Error("Module not found")));
// flushPreloads should not throw (it's now a no-op for the server lazy path,
// but kept for backward compatibility with Pages Router)
await expect(flushPreloads()).resolves.not.toThrow();
});
it("loader rejection renders loading component with error", async () => {
const { default: dynamic } = await import(
"../packages/vinext/src/shims/dynamic.js"
);
const React = await import("react");
const { renderToReadableStream } = await import("react-dom/server.edge");
const LoadingComp = (props: { error?: Error | null; isLoading?: boolean }) => {
if (props.error) {
return React.createElement("div", null, `Error: ${props.error.message}`);
}
return React.createElement("div", null, "Loading...");
};
const DynComp = dynamic(
() => Promise.reject(new Error("chunk load fail")),
{ loading: LoadingComp },
);
// The error boundary renders the loading component with the error
const stream = await renderToReadableStream(React.createElement(DynComp));
await stream.allReady;
const html = await new Response(stream).text();
expect(html).toContain("Error: chunk load fail");
});
it("loader rejection without loading component propagates via onError", async () => {
const { default: dynamic } = await import(
"../packages/vinext/src/shims/dynamic.js"
);
const React = await import("react");
const { renderToReadableStream } = await import("react-dom/server.edge");
const DynComp = dynamic(
() => Promise.reject(new Error("fail")),
);
// Without a loading component, the Suspense fallback is null.
// The rejected loader throws during rendering, caught by onError.
const errors: Error[] = [];
const stream = await renderToReadableStream(
React.createElement(DynComp),
{ onError(err: unknown) { if (err instanceof Error) errors.push(err); } },
);
await stream.allReady.catch(() => {});
expect(errors.some((e) => e.message === "fail")).toBe(true);
});
it("loader rejection with non-Error value is caught during SSR", async () => {
const { default: dynamic } = await import(
"../packages/vinext/src/shims/dynamic.js"
);
const React = await import("react");
const { renderToReadableStream } = await import("react-dom/server.edge");
const DynComp = dynamic(
() => Promise.reject("string error"),
);
// Non-Error rejection values are caught by React's SSR error handling
const errors: unknown[] = [];
const stream = await renderToReadableStream(
React.createElement(DynComp),
{ onError(err: unknown) { errors.push(err); } },
);
await stream.allReady.catch(() => {});
expect(errors.length).toBeGreaterThan(0);
});
});
describe("basePath config validation", () => {
it("resolveNextConfig preserves basePath with leading slash", async () => {
const { resolveNextConfig } = await import(
"../packages/vinext/src/config/next-config.js"
);
const config = await resolveNextConfig({ basePath: "/my-app" });
expect(config.basePath).toBe("/my-app");
});
it("resolveNextConfig handles nested basePath", async () => {
const { resolveNextConfig } = await import(
"../packages/vinext/src/config/next-config.js"
);
const config = await resolveNextConfig({ basePath: "/a/b/c" });
expect(config.basePath).toBe("/a/b/c");
});
it("resolveNextConfig defaults to empty string", async () => {
const { resolveNextConfig } = await import(
"../packages/vinext/src/config/next-config.js"
);
const config = await resolveNextConfig({});
expect(config.basePath).toBe("");
});
it("resolveNextConfig handles undefined basePath", async () => {
const { resolveNextConfig } = await import(
"../packages/vinext/src/config/next-config.js"
);
const config = await resolveNextConfig({ basePath: undefined });
expect(config.basePath).toBe("");
});
});
describe("pageExtensions config", () => {
it("resolveNextConfig defaults pageExtensions to Next.js defaults", async () => {
const { resolveNextConfig } = await import(
"../packages/vinext/src/config/next-config.js"
);
const config = await resolveNextConfig({});
expect(config.pageExtensions).toEqual(["tsx", "ts", "jsx", "js"]);
});
it("resolveNextConfig reads pageExtensions from config", async () => {
const { resolveNextConfig } = await import(
"../packages/vinext/src/config/next-config.js"
);
const config = await resolveNextConfig({
pageExtensions: ["js", "jsx", "ts", "tsx", "mdx"],
});
expect(config.pageExtensions).toEqual(["js", "jsx", "ts", "tsx", "mdx"]);
});
it("resolveNextConfig strips leading dots and whitespace from pageExtensions entries", async () => {
const { resolveNextConfig } = await import(
"../packages/vinext/src/config/next-config.js"
);
const config = await resolveNextConfig({
pageExtensions: [".tsx", " ts ", "tsx", "", ".mdx"],
});
expect(config.pageExtensions).toEqual(["tsx", "ts", "tsx", "mdx"]);
});
});
describe("cacheComponents config (Next.js 16)", () => {
it("resolveNextConfig defaults cacheComponents to false", async () => {
const { resolveNextConfig } = await import(
"../packages/vinext/src/config/next-config.js"
);
const config = await resolveNextConfig({});
expect(config.cacheComponents).toBe(false);
});
it("resolveNextConfig reads cacheComponents: true", async () => {
const { resolveNextConfig } = await import(
"../packages/vinext/src/config/next-config.js"
);
const config = await resolveNextConfig({ cacheComponents: true });
expect(config.cacheComponents).toBe(true);
});
it("resolveNextConfig reads cacheComponents: false", async () => {
const { resolveNextConfig } = await import(
"../packages/vinext/src/config/next-config.js"
);
const config = await resolveNextConfig({ cacheComponents: false });
expect(config.cacheComponents).toBe(false);
});
it("resolveNextConfig handles null input with cacheComponents default", async () => {
const { resolveNextConfig } = await import(
"../packages/vinext/src/config/next-config.js"
);
const config = await resolveNextConfig(null);
expect(config.cacheComponents).toBe(false);
});
it("resolveNextConfig defaults mdx to null", async () => {
const { resolveNextConfig } = await import(
"../packages/vinext/src/config/next-config.js"
);
const config = await resolveNextConfig({});
expect(config.mdx).toBeNull();
});
it("resolveNextConfig returns null mdx for null input", async () => {
const { resolveNextConfig } = await import(
"../packages/vinext/src/config/next-config.js"
);
const config = await resolveNextConfig(null);
expect(config.mdx).toBeNull();
});
it("resolveNextConfig resolves serverActionsAllowedOrigins from experimental.serverActions", async () => {
const { resolveNextConfig } = await import(
"../packages/vinext/src/config/next-config.js"
);
const config = await resolveNextConfig({
experimental: {
serverActions: {
allowedOrigins: ["my-proxy.com", "*.my-domain.com"],
},
},
});
expect(config.serverActionsAllowedOrigins).toEqual(["my-proxy.com", "*.my-domain.com"]);
});
it("resolveNextConfig resolves allowedDevOrigins from top-level config", async () => {
const { resolveNextConfig } = await import(
"../packages/vinext/src/config/next-config.js"
);
const config = await resolveNextConfig({
allowedDevOrigins: ["staging.example.com", "*.preview.dev"],
});
expect(config.allowedDevOrigins).toEqual(["staging.example.com", "*.preview.dev"]);
});
it("resolveNextConfig keeps allowedDevOrigins separate from serverActionsAllowedOrigins", async () => {
const { resolveNextConfig } = await import(
"../packages/vinext/src/config/next-config.js"
);
const config = await resolveNextConfig({
allowedDevOrigins: ["dev.example.com"],
experimental: {
serverActions: {
allowedOrigins: ["actions.example.com"],
},
},
});
expect(config.allowedDevOrigins).toEqual(["dev.example.com"]);
expect(config.serverActionsAllowedOrigins).toEqual(["actions.example.com"]);
});
it("resolveNextConfig defaults allowedDevOrigins to empty array", async () => {
const { resolveNextConfig } = await import(
"../packages/vinext/src/config/next-config.js"
);
const config = await resolveNextConfig({});
expect(config.allowedDevOrigins).toEqual([]);
});
it("resolveNextConfig handles null input with empty allowedDevOrigins", async () => {
const { resolveNextConfig } = await import(
"../packages/vinext/src/config/next-config.js"
);
const config = await resolveNextConfig(null);
expect(config.allowedDevOrigins).toEqual([]);
});
it("resolveNextConfig defaults serverActionsAllowedOrigins to empty array", async () => {
const { resolveNextConfig } = await import(
"../packages/vinext/src/config/next-config.js"
);
const config = await resolveNextConfig({});
expect(config.serverActionsAllowedOrigins).toEqual([]);
});
it("resolveNextConfig handles null input with empty serverActionsAllowedOrigins", async () => {
const { resolveNextConfig } = await import(
"../packages/vinext/src/config/next-config.js"
);
const config = await resolveNextConfig(null);
expect(config.serverActionsAllowedOrigins).toEqual([]);
});
});
describe("loadNextConfig CJS support", () => {
let tmpDir: string;
beforeEach(async () => {
const os = await import("node:os");
const fsp = await import("node:fs/promises");
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-cjs-cfg-"));
});
afterEach(async () => {
const fsp = await import("node:fs/promises");
await fsp.rm(tmpDir, { recursive: true, force: true });
});
it("loads a CJS next.config.js that uses module.exports", async () => {
const fsp = await import("node:fs/promises");
const { loadNextConfig } = await import(
"../packages/vinext/src/config/next-config.js"
);
await fsp.writeFile(
path.join(tmpDir, "next.config.js"),
`module.exports = { basePath: "/cjs-app", trailingSlash: true };`,
);
const config = await loadNextConfig(tmpDir);
expect(config).not.toBeNull();
expect(config!.basePath).toBe("/cjs-app");
expect(config!.trailingSlash).toBe(true);
});
it("loads a CJS next.config.js with require() plugin wrapper", async () => {
const fsp = await import("node:fs/promises");
const { loadNextConfig } = await import(
"../packages/vinext/src/config/next-config.js"
);
// Simulate a CJS plugin wrapper like nextra/next-intl/etc.
// Create a fake plugin module that wraps the config
await fsp.mkdir(path.join(tmpDir, "node_modules", "fake-plugin"), {
recursive: true,
});
await fsp.writeFile(
path.join(tmpDir, "node_modules", "fake-plugin", "index.js"),
`module.exports = function fakePlugin(pluginOpts) {
return function withPlugin(nextConfig) {
return Object.assign({}, nextConfig, { env: { PLUGIN: "loaded" } });
};
};`,
);
await fsp.writeFile(
path.join(tmpDir, "node_modules", "fake-plugin", "package.json"),
JSON.stringify({ name: "fake-plugin", version: "1.0.0", main: "index.js" }),
);
// Write a next.config.js that uses require() — this is the pattern that fails
await fsp.writeFile(
path.join(tmpDir, "next.config.js"),
`const withPlugin = require('fake-plugin')({ theme: 'docs' });
module.exports = withPlugin({ basePath: "/wrapped" });`,
);
const config = await loadNextConfig(tmpDir);
expect(config).not.toBeNull();
expect(config!.basePath).toBe("/wrapped");
expect(config!.env).toEqual({ PLUGIN: "loaded" });
});
it("loads a CJS function-form next.config.js", async () => {
const fsp = await import("node:fs/promises");
const { loadNextConfig } = await import(
"../packages/vinext/src/config/next-config.js"
);
await fsp.writeFile(
path.join(tmpDir, "next.config.js"),
`module.exports = function(phase, { defaultConfig }) {
return { basePath: "/fn-" + phase.split("-")[1] };
};`,
);
const config = await loadNextConfig(tmpDir);
expect(config).not.toBeNull();
// phase is "phase-development-server", split("-")[1] = "development"
expect(config!.basePath).toBe("/fn-development");
});
it("loads a .cjs config file", async () => {
const fsp = await import("node:fs/promises");
const { loadNextConfig } = await import(
"../packages/vinext/src/config/next-config.js"
);
await fsp.writeFile(
path.join(tmpDir, "next.config.cjs"),
`module.exports = { basePath: "/cjs-ext" };`,
);
const config = await loadNextConfig(tmpDir);
expect(config).not.toBeNull();
expect(config!.basePath).toBe("/cjs-ext");
});
it("loads an ESM next.config.mjs normally", async () => {
const fsp = await import("node:fs/promises");
const { loadNextConfig } = await import(
"../packages/vinext/src/config/next-config.js"
);
await fsp.writeFile(
path.join(tmpDir, "next.config.mjs"),
`export default { basePath: "/esm-app" };`,
);
const config = await loadNextConfig(tmpDir);
expect(config).not.toBeNull();
expect(config!.basePath).toBe("/esm-app");
});
it("loads next.config.ts with extensionless local imports", async () => {
const fsp = await import("node:fs/promises");
const { loadNextConfig } = await import(
"../packages/vinext/src/config/next-config.js"
);
await fsp.writeFile(path.join(tmpDir, "env.ts"), `export const BASE_PATH = "/from-env";`);
await fsp.writeFile(
path.join(tmpDir, "next.config.ts"),
`import { BASE_PATH } from "./env";
export default {
basePath: BASE_PATH,
trailingSlash: true,
};`,
);
const config = await loadNextConfig(tmpDir);
expect(config).not.toBeNull();
expect(config!.basePath).toBe("/from-env");
expect(config!.trailingSlash).toBe(true);
});
it("returns null when no config file exists", async () => {
const { loadNextConfig } = await import(
"../packages/vinext/src/config/next-config.js"
);
const config = await loadNextConfig(tmpDir);
expect(config).toBeNull();
});
});
describe("extractMdxOptions", () => {
it("returns null when no webpack function", async () => {
const { extractMdxOptions } = await import(
"../packages/vinext/src/config/next-config.js"
);
await expect(extractMdxOptions({})).resolves.toBeNull();
await expect(extractMdxOptions({ webpack: "not a function" })).resolves.toBeNull();
});
it("extracts remarkPlugins from webpack rule", async () => {
const { extractMdxOptions } = await import(
"../packages/vinext/src/config/next-config.js"
);
const fakeRemarkPlugin = () => {};
const config = {
webpack: (webpackConfig: any) => {
webpackConfig.module.rules.push({
test: /\.mdx$/,
use: [
{
loader: "@next/mdx/mdx-js-loader",
options: {
remarkPlugins: [[fakeRemarkPlugin, { option: true }]],
rehypePlugins: [],
},
},
],
});
return webpackConfig;
},
};
const result = await extractMdxOptions(config);
expect(result).not.toBeNull();
expect(result!.remarkPlugins).toHaveLength(1);
expect(result!.remarkPlugins![0]).toEqual([fakeRemarkPlugin, { option: true }]);
expect(result!.rehypePlugins).toBeUndefined();
});
it("extracts rehypePlugins from webpack rule", async () => {
const { extractMdxOptions } = await import(
"../packages/vinext/src/config/next-config.js"
);
const fakeRehypePlugin = () => {};
const config = {
webpack: (webpackConfig: any) => {
webpackConfig.module.rules.push({
test: /\.mdx$/,
use: [
{
loader: "@next/mdx/mdx-js-loader",
options: {
remarkPlugins: [],
rehypePlugins: [fakeRehypePlugin],
},
},
],
});
return webpackConfig;
},
};
const result = await extractMdxOptions(config);
expect(result).not.toBeNull();
expect(result!.rehypePlugins).toHaveLength(1);
expect(result!.remarkPlugins).toBeUndefined();
});
it("extracts recmaPlugins from webpack rule", async () => {
const { extractMdxOptions } = await import(
"../packages/vinext/src/config/next-config.js"
);
const fakeRecmaPlugin = () => {};
const config = {
webpack: (webpackConfig: any) => {
webpackConfig.module.rules.push({
test: /\.mdx$/,
use: [
{
loader: "@next/mdx/mdx-js-loader",
options: {
recmaPlugins: [fakeRecmaPlugin],
},
},
],
});
return webpackConfig;
},
};
const result = await extractMdxOptions(config);
expect(result).not.toBeNull();
expect(result!.recmaPlugins).toHaveLength(1);
});
it("handles oneOf nested rules", async () => {
const { extractMdxOptions } = await import(
"../packages/vinext/src/config/next-config.js"
);
const fakeRemarkPlugin = () => {};
const config = {
webpack: (webpackConfig: any) => {
webpackConfig.module.rules.push({
oneOf: [
{
test: /\.mdx$/,
use: [
{
loader: "@next/mdx/mdx-js-loader",
options: {
remarkPlugins: [fakeRemarkPlugin],
},
},
],
},
],
});
return webpackConfig;
},
};
const result = await extractMdxOptions(config);
expect(result).not.toBeNull();
expect(result!.remarkPlugins).toHaveLength(1);
});
it("returns null when webpack throws", async () => {
const { extractMdxOptions } = await import(
"../packages/vinext/src/config/next-config.js"
);
const config = {
webpack: () => {
throw new Error("some webpack error");
},
};
await expect(extractMdxOptions(config)).resolves.toBeNull();
});
it("returns null when webpack has no MDX loader", async () => {
const { extractMdxOptions } = await import(
"../packages/vinext/src/config/next-config.js"
);
const config = {
webpack: (webpackConfig: any) => {
webpackConfig.module.rules.push({
test: /\.css$/,
use: [{ loader: "css-loader" }],
});
return webpackConfig;
},
};
await expect(extractMdxOptions(config)).resolves.toBeNull();
});
it("returns null when MDX loader has empty plugin arrays", async () => {
const { extractMdxOptions } = await import(
"../packages/vinext/src/config/next-config.js"
);
const config = {
webpack: (webpackConfig: any) => {
webpackConfig.module.rules.push({
test: /\.mdx$/,
use: [
{
loader: "@next/mdx/mdx-js-loader",
options: {
remarkPlugins: [],
rehypePlugins: [],
},
},
],
});
return webpackConfig;
},
};
await expect(extractMdxOptions(config)).resolves.toBeNull();
});
it("resolveNextConfig extracts mdx from webpack closure", async () => {
const { resolveNextConfig } = await import(
"../packages/vinext/src/config/next-config.js"
);
const fakeRemarkPlugin = () => {};
const config = await resolveNextConfig({
webpack: (webpackConfig: any) => {
webpackConfig.module.rules.push({
test: /\.mdx$/,
use: [
{
loader: "@next/mdx/mdx-js-loader",
options: {
remarkPlugins: [fakeRemarkPlugin],
},
},
],
});
return webpackConfig;
},
});
expect(config.mdx).not.toBeNull();
expect(config.mdx!.remarkPlugins).toHaveLength(1);
});
});
describe("next/web-vitals shim", () => {
it("exports useReportWebVitals as a no-op function", async () => {
const { useReportWebVitals } = await import(
"../packages/vinext/src/shims/web-vitals.js"
);
expect(typeof useReportWebVitals).toBe("function");
// Should run without throwing
expect(() => useReportWebVitals(() => {})).not.toThrow();
});
});
describe("next/amp shim", () => {
it("exports useAmp and isInAmpMode as no-op functions", async () => {
const { useAmp, isInAmpMode } = await import(
"../packages/vinext/src/shims/amp.js"
);
expect(typeof useAmp).toBe("function");
expect(typeof isInAmpMode).toBe("function");
// Both always return false
expect(useAmp()).toBe(false);
expect(isInAmpMode()).toBe(false);
});
});
describe("next/compat/router shim", () => {
it("exports useRouter as a function", async () => {
const mod = await import(
"../packages/vinext/src/shims/compat-router.js"
);
// useRouter should be a named export, not a default export (unlike next/router).
// Returns null in App Router context instead of throwing.
expect(typeof mod.useRouter).toBe("function");
expect((mod as Record<string, unknown>).default).toBeUndefined();
});
it("useRouter returns null when no RouterContext.Provider wraps the tree", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const { useRouter } = await import(
"../packages/vinext/src/shims/compat-router.js"
);
let captured: unknown = "NOT_SET";
function Probe() {
captured = useRouter();
return React.createElement("div", null, "probe");
}
renderToStaticMarkup(React.createElement(Probe));
expect(captured).toBeNull();
});
it("useRouter returns the router when wrapWithRouterContext wraps the tree", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const { useRouter: useCompatRouter } = await import(
"../packages/vinext/src/shims/compat-router.js"
);
const { wrapWithRouterContext } = await import(
"../packages/vinext/src/shims/router.js"
);
let captured: unknown = "NOT_SET";
function Probe() {
captured = useCompatRouter();
return React.createElement("div", null, "probe");
}
const element = wrapWithRouterContext(React.createElement(Probe));
renderToStaticMarkup(element);
expect(captured).not.toBeNull();
expect(typeof (captured as any).pathname).toBe("string");
expect(typeof (captured as any).push).toBe("function");
expect(typeof (captured as any).replace).toBe("function");
expect(typeof (captured as any).back).toBe("function");
expect(typeof (captured as any).reload).toBe("function");
});
it("useRouter returns router reflecting SSR context when set", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const { useRouter: useCompatRouter } = await import(
"../packages/vinext/src/shims/compat-router.js"
);
const { wrapWithRouterContext, setSSRContext } = await import(
"../packages/vinext/src/shims/router.js"
);
setSSRContext({
pathname: "/posts/42",
query: { id: "42" },
asPath: "/posts/42?tab=comments",
});
let captured: unknown = "NOT_SET";
function Probe() {
captured = useCompatRouter();
return React.createElement("div", null, "probe");
}
const element = wrapWithRouterContext(React.createElement(Probe));
renderToStaticMarkup(element);
setSSRContext(null);
expect(captured).not.toBeNull();
expect((captured as any).pathname).toBe("/posts/42");
expect((captured as any).asPath).toBe("/posts/42?tab=comments");
expect((captured as any).query.id).toBe("42");
});
});
describe("Pages Router router helpers", () => {
it("exports wrapWithRouterContext function", async () => {
const mod = await import("../packages/vinext/src/shims/router.js");
expect(typeof mod.wrapWithRouterContext).toBe("function");
});
describe("isExternalUrl", () => {
it("detects https:// as external", () => {
expect(isExternalUrl("https://example.com")).toBe(true);
expect(isExternalUrl("https://example.com/path")).toBe(true);
});
it("detects http:// as external", () => {
expect(isExternalUrl("http://example.com")).toBe(true);
});
it("detects protocol-relative // as external", () => {
expect(isExternalUrl("//cdn.example.com/img.png")).toBe(true);
});
it("returns false for relative paths", () => {
expect(isExternalUrl("/about")).toBe(false);
expect(isExternalUrl("/")).toBe(false);
expect(isExternalUrl("about")).toBe(false);
});
it("returns false for hash-only", () => {
expect(isExternalUrl("#section")).toBe(false);
});
it("returns false for query-only", () => {
expect(isExternalUrl("?foo=1")).toBe(false);
});
});
describe("isHashOnlyChange", () => {
it("returns true for hash-only strings starting with #", () => {
// This works even without window because of the startsWith check
expect(isHashOnlyChange("#foo")).toBe(true);
expect(isHashOnlyChange("#")).toBe(true);
expect(isHashOnlyChange("#section-2")).toBe(true);
});
it("returns false for absolute paths without window context", () => {
// Without a real browser window, URL-based comparison returns false
// because typeof window === "undefined" → returns false
expect(isHashOnlyChange("/about")).toBe(false);
expect(isHashOnlyChange("/about#foo")).toBe(false);
});
it("returns false for full URLs without window context", () => {
expect(isHashOnlyChange("https://example.com#foo")).toBe(false);
});
});
});
describe("next/server enhancements", () => {
it("NextRequest.ip extracts from x-forwarded-for header", async () => {
const { NextRequest } = await import(
"../packages/vinext/src/shims/server.js"
);
const req = new NextRequest("https://example.com", {
headers: { "x-forwarded-for": "1.2.3.4, 5.6.7.8" },
});
expect(req.ip).toBe("1.2.3.4");
});
it("NextRequest.ip returns undefined without forwarded header", async () => {
const { NextRequest } = await import(
"../packages/vinext/src/shims/server.js"
);
const req = new NextRequest("https://example.com");
expect(req.ip).toBeUndefined();
});
it("NextRequest.geo extracts from Cloudflare/Vercel headers", async () => {
const { NextRequest } = await import(
"../packages/vinext/src/shims/server.js"
);
const req = new NextRequest("https://example.com", {
headers: {
"cf-ipcountry": "US",
"cf-ipcity": "San Francisco",
},
});
const geo = req.geo;
expect(geo).toBeDefined();
expect(geo!.country).toBe("US");
expect(geo!.city).toBe("San Francisco");
});
it("NextRequest.geo returns undefined without geo headers", async () => {
const { NextRequest } = await import(
"../packages/vinext/src/shims/server.js"
);
const req = new NextRequest("https://example.com");
expect(req.geo).toBeUndefined();
});
it("ResponseCookies.getAll returns all set cookies", async () => {
const { ResponseCookies } = await import(
"../packages/vinext/src/shims/server.js"
);
const headers = new Headers();
const cookies = new ResponseCookies(headers);
cookies.set("a", "1");
cookies.set("b", "2");
const all = cookies.getAll();
expect(all).toHaveLength(2);
expect(all.find((c: any) => c.name === "a")?.value).toBe("1");
expect(all.find((c: any) => c.name === "b")?.value).toBe("2");
});
});
describe("next/image enhancements", () => {
it("exports StaticImageData type", async () => {
const imageModule = await import(
"../packages/vinext/src/shims/image.js"
);
// StaticImageData is an interface, so we can't check at runtime
// but getImageProps uses it — verify that function exists
expect(typeof imageModule.getImageProps).toBe("function");
});
it("getImageProps returns img props from Image props", async () => {
const { getImageProps } = await import(
"../packages/vinext/src/shims/image.js"
);
const result = getImageProps({
src: "/photo.jpg",
alt: "Test",
width: 800,
height: 600,
priority: true,
});
// Local images now route through the optimization endpoint
expect(result.props.src).toContain("/_vinext/image");
expect(result.props.src).toContain("url=%2Fphoto.jpg");
expect(result.props.src).toContain("w=800");
expect(result.props.alt).toBe("Test");
expect(result.props.width).toBe(800);
expect(result.props.height).toBe(600);
expect(result.props.loading).toBe("eager");
});
it("getImageProps handles fill mode", async () => {
const { getImageProps } = await import(
"../packages/vinext/src/shims/image.js"
);
const result = getImageProps({
src: "/bg.jpg",
alt: "Background",
fill: true,
});
expect(result.props.width).toBeUndefined();
expect(result.props.height).toBeUndefined();
expect(result.props.style?.position).toBe("absolute");
});
it("getImageProps handles StaticImageData", async () => {
const { getImageProps } = await import(
"../packages/vinext/src/shims/image.js"
);
const result = getImageProps({
src: { src: "/imported.jpg", width: 1200, height: 800, blurDataURL: "data:..." },
alt: "Imported",
});
expect(result.props.src).toContain("/_vinext/image");
expect(result.props.src).toContain("url=%2Fimported.jpg");
expect(result.props.src).toContain("w=1200");
expect(result.props.width).toBe(1200);
expect(result.props.height).toBe(800);
});
it("getImageProps generates srcSet for local images with width", async () => {
const { getImageProps } = await import(
"../packages/vinext/src/shims/image.js"
);
const result = getImageProps({
src: "/photo.jpg",
alt: "Test",
width: 1200,
height: 800,
});
expect(result.props.srcSet).toBeDefined();
// srcSet entries point to /_vinext/image optimization endpoint
expect(result.props.srcSet).toContain("/_vinext/image");
expect(result.props.srcSet).toContain("url=%2Fphoto.jpg");
expect(result.props.srcSet).toContain("w");
});
it("getImageProps does not generate srcSet for fill images", async () => {
const { getImageProps } = await import(
"../packages/vinext/src/shims/image.js"
);
const result = getImageProps({
src: "/bg.jpg",
alt: "Background",
fill: true,
});
expect(result.props.srcSet).toBeUndefined();
expect(result.props.sizes).toBe("100vw"); // fill implies 100vw
});
it("getImageProps includes fetchPriority for priority images", async () => {
const { getImageProps } = await import(
"../packages/vinext/src/shims/image.js"
);
const result = getImageProps({
src: "/hero.jpg",
alt: "Hero",
width: 1200,
height: 800,
priority: true,
});
expect(result.props.fetchPriority).toBe("high");
expect(result.props.loading).toBe("eager");
});
it("getImageProps includes data-nimg attribute", async () => {
const { getImageProps } = await import(
"../packages/vinext/src/shims/image.js"
);
const result = getImageProps({
src: "/photo.jpg",
alt: "Photo",
width: 800,
height: 600,
});
expect((result.props as any)["data-nimg"]).toBe("1");
const fillResult = getImageProps({
src: "/bg.jpg",
alt: "BG",
fill: true,
});
expect((fillResult.props as any)["data-nimg"]).toBe("fill");
});
it("getImageProps includes blur placeholder background styles", async () => {
const { getImageProps } = await import(
"../packages/vinext/src/shims/image.js"
);
const blurUrl = "data:image/jpeg;base64,/9j/4AAQ";
const result = getImageProps({
src: "/photo.jpg",
alt: "Blurry",
width: 800,
height: 600,
placeholder: "blur",
blurDataURL: blurUrl,
});
expect(result.props.style?.backgroundImage).toContain(blurUrl);
expect(result.props.style?.backgroundSize).toBe("cover");
});
it("getImageProps uses custom loader function", async () => {
const { getImageProps } = await import(
"../packages/vinext/src/shims/image.js"
);
const result = getImageProps({
src: "/photo.jpg",
alt: "Custom",
width: 800,
height: 600,
loader: ({ src, width, quality }) => `https://cdn.example.com${src}?w=${width}&q=${quality}`,
});
// Custom loader bypasses the /_vinext/image endpoint
expect(result.props.src).toBe("https://cdn.example.com/photo.jpg?w=800&q=75");
expect(result.props.src).not.toContain("/_vinext/image");
});
it("unoptimized prop bypasses /_vinext/image endpoint", async () => {
const { getImageProps } = await import("../packages/vinext/src/shims/image.js");
const result = getImageProps({
src: "/photo.jpg",
alt: "Unoptimized",
width: 800,
height: 600,
unoptimized: true,
});
// unoptimized=true should serve the raw src, not the optimization endpoint
expect(result.props.src).toBe("/photo.jpg");
expect(result.props.src).not.toContain("/_vinext/image");
});
it("SVG src auto-skips optimization endpoint (default behavior)", async () => {
const { getImageProps } = await import("../packages/vinext/src/shims/image.js");
const result = getImageProps({
src: "/logo.svg",
alt: "SVG logo",
width: 200,
height: 200,
});
// By default (dangerouslyAllowSVG not set), .svg sources bypass the optimizer
expect(result.props.src).toBe("/logo.svg");
expect(result.props.src).not.toContain("/_vinext/image");
});
it("non-SVG src still uses optimization endpoint", async () => {
const { getImageProps } = await import("../packages/vinext/src/shims/image.js");
const result = getImageProps({
src: "/photo.png",
alt: "PNG photo",
width: 256,
height: 256,
});
expect(result.props.src).toContain("/_vinext/image");
});
});
describe("next/image component rendering", () => {
it("renders basic image with src, alt, width, height", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const Image = (await import("../packages/vinext/src/shims/image.js")).default;
const html = renderToStaticMarkup(
React.createElement(Image, { src: "/photo.jpg", alt: "Test photo", width: 800, height: 600 }),
);
// Local images route through the optimization endpoint
expect(html).toContain("/_vinext/image");
expect(html).toContain("url=%2Fphoto.jpg");
expect(html).toContain('alt="Test photo"');
expect(html).toContain('width="800"');
expect(html).toContain('height="600"');
});
it("renders fill image with absolute positioning", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const Image = (await import("../packages/vinext/src/shims/image.js")).default;
const html = renderToStaticMarkup(
React.createElement(Image, { src: "/bg.jpg", alt: "Background", fill: true }),
);
expect(html).toContain("position:absolute");
expect(html).toContain('data-nimg="fill"');
// fill images should not have width/height attributes
expect(html).not.toContain('width=');
expect(html).not.toContain('height=');
});
it("renders priority image with fetchpriority=high and loading=eager", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const Image = (await import("../packages/vinext/src/shims/image.js")).default;
const html = renderToStaticMarkup(
React.createElement(Image, { src: "/hero.jpg", alt: "Hero", width: 1200, height: 800, priority: true }),
);
expect(html).toContain('fetchPriority="high"');
expect(html).toContain('loading="eager"');
});
it("renders lazy loading by default (no priority)", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const Image = (await import("../packages/vinext/src/shims/image.js")).default;
const html = renderToStaticMarkup(
React.createElement(Image, { src: "/photo.jpg", alt: "Photo", width: 800, height: 600 }),
);
expect(html).toContain('loading="lazy"');
expect(html).not.toContain('fetchPriority');
});
it("renders srcSet for local images with width", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const Image = (await import("../packages/vinext/src/shims/image.js")).default;
const html = renderToStaticMarkup(
React.createElement(Image, { src: "/photo.jpg", alt: "Photo", width: 1200, height: 800 }),
);
expect(html).toContain("srcSet");
// srcSet entries point to /_vinext/image optimization endpoint
expect(html).toContain("/_vinext/image");
expect(html).toContain("url=%2Fphoto.jpg");
});
it("renders blur placeholder with background-image", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const Image = (await import("../packages/vinext/src/shims/image.js")).default;
const blurUrl = "data:image/jpeg;base64,/9j/4AAQ";
const html = renderToStaticMarkup(
React.createElement(Image, {
src: "/photo.jpg",
alt: "Blurry",
width: 800,
height: 600,
placeholder: "blur",
blurDataURL: blurUrl,
}),
);
expect(html).toContain(blurUrl);
expect(html).toContain("background-image");
expect(html).toContain("background-size:cover");
});
it("renders with custom loader function", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const Image = (await import("../packages/vinext/src/shims/image.js")).default;
const html = renderToStaticMarkup(
React.createElement(Image, {
src: "/photo.jpg",
alt: "Custom",
width: 800,
height: 600,
loader: ({ src, width, quality }: { src: string; width: number; quality?: number }) =>
`https://cdn.example.com${src}?w=${width}&q=${quality}`,
}),
);
expect(html).toContain('src="https://cdn.example.com/photo.jpg?w=800&amp;q=75"');
});
it("renders with custom sizes attribute", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const Image = (await import("../packages/vinext/src/shims/image.js")).default;
const html = renderToStaticMarkup(
React.createElement(Image, {
src: "/photo.jpg",
alt: "Responsive",
width: 1200,
height: 800,
sizes: "(max-width: 768px) 100vw, 50vw",
}),
);
expect(html).toContain('sizes="(max-width: 768px) 100vw, 50vw"');
});
it("renders fill image with sizes=100vw by default", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const Image = (await import("../packages/vinext/src/shims/image.js")).default;
const html = renderToStaticMarkup(
React.createElement(Image, { src: "/bg.jpg", alt: "BG", fill: true }),
);
expect(html).toContain('sizes="100vw"');
});
it("handles StaticImageData import objects", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const Image = (await import("../packages/vinext/src/shims/image.js")).default;
const staticImport = { src: "/imported.jpg", width: 1200, height: 800, blurDataURL: "data:..." };
const html = renderToStaticMarkup(
React.createElement(Image, { src: staticImport, alt: "Imported" }),
);
expect(html).toContain("/_vinext/image");
expect(html).toContain("url=%2Fimported.jpg");
expect(html).toContain('width="1200"');
expect(html).toContain('height="800"');
});
it("renders with className", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const Image = (await import("../packages/vinext/src/shims/image.js")).default;
const html = renderToStaticMarkup(
React.createElement(Image, {
src: "/photo.jpg",
alt: "Styled",
width: 800,
height: 600,
className: "rounded-lg shadow-md",
}),
);
expect(html).toContain('class="rounded-lg shadow-md"');
});
it("includes data-nimg=1 for non-fill images", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const Image = (await import("../packages/vinext/src/shims/image.js")).default;
const html = renderToStaticMarkup(
React.createElement(Image, { src: "/photo.jpg", alt: "Test", width: 800, height: 600 }),
);
expect(html).toContain('data-nimg="1"');
});
it("always sets decoding=async", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const Image = (await import("../packages/vinext/src/shims/image.js")).default;
const html = renderToStaticMarkup(
React.createElement(Image, { src: "/photo.jpg", alt: "Test", width: 800, height: 600 }),
);
expect(html).toContain('decoding="async"');
});
});
describe("image remote pattern matching", () => {
it("matchRemotePattern matches exact hostname", async () => {
const { matchRemotePattern } = await import("../packages/vinext/src/shims/image-config.js");
const pattern = { hostname: "cdn.example.com" };
expect(matchRemotePattern(pattern, new URL("https://cdn.example.com/img.jpg"))).toBe(true);
expect(matchRemotePattern(pattern, new URL("https://other.example.com/img.jpg"))).toBe(false);
});
it("matchRemotePattern matches wildcard hostname with *", async () => {
const { matchRemotePattern } = await import("../packages/vinext/src/shims/image-config.js");
const pattern = { hostname: "*.example.com" };
expect(matchRemotePattern(pattern, new URL("https://cdn.example.com/img.jpg"))).toBe(true);
expect(matchRemotePattern(pattern, new URL("https://images.example.com/img.jpg"))).toBe(true);
// Single * should NOT match nested subdomains
expect(matchRemotePattern(pattern, new URL("https://a.b.example.com/img.jpg"))).toBe(false);
// Should not match bare domain
expect(matchRemotePattern(pattern, new URL("https://example.com/img.jpg"))).toBe(false);
});
it("matchRemotePattern matches ** hostname for any depth", async () => {
const { matchRemotePattern } = await import("../packages/vinext/src/shims/image-config.js");
const pattern = { hostname: "**.example.com" };
expect(matchRemotePattern(pattern, new URL("https://cdn.example.com/img.jpg"))).toBe(true);
expect(matchRemotePattern(pattern, new URL("https://a.b.c.example.com/img.jpg"))).toBe(true);
// ** requires at least one segment
expect(matchRemotePattern(pattern, new URL("https://example.com/img.jpg"))).toBe(false);
});
it("matchRemotePattern checks protocol when specified", async () => {
const { matchRemotePattern } = await import("../packages/vinext/src/shims/image-config.js");
const pattern = { protocol: "https", hostname: "cdn.example.com" };
expect(matchRemotePattern(pattern, new URL("https://cdn.example.com/img.jpg"))).toBe(true);
expect(matchRemotePattern(pattern, new URL("http://cdn.example.com/img.jpg"))).toBe(false);
});
it("matchRemotePattern checks port when specified", async () => {
const { matchRemotePattern } = await import("../packages/vinext/src/shims/image-config.js");
const pattern = { hostname: "localhost", port: "3000" };
expect(matchRemotePattern(pattern, new URL("http://localhost:3000/img.jpg"))).toBe(true);
expect(matchRemotePattern(pattern, new URL("http://localhost:8080/img.jpg"))).toBe(false);
});
it("matchRemotePattern checks pathname when specified", async () => {
const { matchRemotePattern } = await import("../packages/vinext/src/shims/image-config.js");
const pattern = { hostname: "cdn.example.com", pathname: "/images/**" };
expect(matchRemotePattern(pattern, new URL("https://cdn.example.com/images/photo.jpg"))).toBe(true);
expect(matchRemotePattern(pattern, new URL("https://cdn.example.com/images/nested/photo.jpg"))).toBe(true);
expect(matchRemotePattern(pattern, new URL("https://cdn.example.com/other/photo.jpg"))).toBe(false);
});
it("matchRemotePattern checks search when specified", async () => {
const { matchRemotePattern } = await import("../packages/vinext/src/shims/image-config.js");
const pattern = { hostname: "cdn.example.com", search: "?v=123" };
expect(matchRemotePattern(pattern, new URL("https://cdn.example.com/img.jpg?v=123"))).toBe(true);
expect(matchRemotePattern(pattern, new URL("https://cdn.example.com/img.jpg?v=456"))).toBe(false);
expect(matchRemotePattern(pattern, new URL("https://cdn.example.com/img.jpg"))).toBe(false);
});
it("matchRemotePattern defaults pathname to ** when not specified", async () => {
const { matchRemotePattern } = await import("../packages/vinext/src/shims/image-config.js");
const pattern = { hostname: "cdn.example.com" };
expect(matchRemotePattern(pattern, new URL("https://cdn.example.com/any/deep/path/img.jpg"))).toBe(true);
expect(matchRemotePattern(pattern, new URL("https://cdn.example.com/"))).toBe(true);
});
it("matchRemotePattern handles protocol with trailing colon", async () => {
const { matchRemotePattern } = await import("../packages/vinext/src/shims/image-config.js");
const pattern = { protocol: "https:", hostname: "cdn.example.com" };
expect(matchRemotePattern(pattern, new URL("https://cdn.example.com/img.jpg"))).toBe(true);
});
it("hasRemoteMatch checks domains list", async () => {
const { hasRemoteMatch } = await import("../packages/vinext/src/shims/image-config.js");
const domains = ["cdn.example.com", "images.example.com"];
expect(hasRemoteMatch(domains, [], new URL("https://cdn.example.com/img.jpg"))).toBe(true);
expect(hasRemoteMatch(domains, [], new URL("https://images.example.com/img.jpg"))).toBe(true);
expect(hasRemoteMatch(domains, [], new URL("https://other.example.com/img.jpg"))).toBe(false);
});
it("hasRemoteMatch checks remotePatterns when domains don't match", async () => {
const { hasRemoteMatch } = await import("../packages/vinext/src/shims/image-config.js");
const patterns = [{ protocol: "https", hostname: "**.cdn.example.com" }];
expect(hasRemoteMatch([], patterns, new URL("https://us.cdn.example.com/img.jpg"))).toBe(true);
expect(hasRemoteMatch([], patterns, new URL("http://us.cdn.example.com/img.jpg"))).toBe(false);
});
it("matchRemotePattern handles single * in pathname", async () => {
const { matchRemotePattern } = await import("../packages/vinext/src/shims/image-config.js");
const pattern = { hostname: "cdn.example.com", pathname: "/images/*" };
expect(matchRemotePattern(pattern, new URL("https://cdn.example.com/images/photo.jpg"))).toBe(true);
// Single * should not match nested paths
expect(matchRemotePattern(pattern, new URL("https://cdn.example.com/images/nested/photo.jpg"))).toBe(false);
});
it("matchRemotePattern handles regex special chars in hostname", async () => {
const { matchRemotePattern } = await import("../packages/vinext/src/shims/image-config.js");
// Dots in hostname are literal (escaped for regex)
const pattern = { hostname: "cdn.example.com" };
expect(matchRemotePattern(pattern, new URL("https://cdn.example.com/img.jpg"))).toBe(true);
// "cdnXexampleXcom" should not match "cdn.example.com"
expect(matchRemotePattern(pattern, new URL("https://cdnXexample.com/img.jpg"))).toBe(false);
});
});
describe("image optimization URL generation", () => {
it("imageOptimizationUrl generates correct URL", async () => {
const { imageOptimizationUrl } = await import("../packages/vinext/src/shims/image.js");
const url = imageOptimizationUrl("/images/hero.webp", 1200, 75);
expect(url).toBe("/_vinext/image?url=%2Fimages%2Fhero.webp&w=1200&q=75");
});
it("imageOptimizationUrl encodes special characters", async () => {
const { imageOptimizationUrl } = await import("../packages/vinext/src/shims/image.js");
const url = imageOptimizationUrl("/images/my photo.jpg", 800, 80);
expect(url).toContain("url=%2Fimages%2Fmy%20photo.jpg");
expect(url).toContain("w=800");
expect(url).toContain("q=80");
});
it("imageOptimizationUrl uses default quality of 75", async () => {
const { imageOptimizationUrl } = await import("../packages/vinext/src/shims/image.js");
const url = imageOptimizationUrl("/img.png", 640);
expect(url).toContain("q=75");
});
});
describe("image optimization request parsing", () => {
it("parseImageParams extracts url, width, quality", async () => {
const { parseImageParams } = await import("../packages/vinext/src/server/image-optimization.js");
const url = new URL("http://localhost/_vinext/image?url=%2Fimages%2Fhero.webp&w=1200&q=75");
const params = parseImageParams(url);
expect(params).not.toBeNull();
expect(params!.imageUrl).toBe("/images/hero.webp");
expect(params!.width).toBe(1200);
expect(params!.quality).toBe(75);
});
it("parseImageParams returns null when url is missing", async () => {
const { parseImageParams } = await import("../packages/vinext/src/server/image-optimization.js");
const url = new URL("http://localhost/_vinext/image?w=800&q=75");
expect(parseImageParams(url)).toBeNull();
});
it("parseImageParams blocks absolute http URLs", async () => {
const { parseImageParams } = await import("../packages/vinext/src/server/image-optimization.js");
const url = new URL("http://localhost/_vinext/image?url=http%3A%2F%2Fevil.com%2Fimg.jpg&w=800");
expect(parseImageParams(url)).toBeNull();
});
it("parseImageParams blocks absolute https URLs", async () => {
const { parseImageParams } = await import("../packages/vinext/src/server/image-optimization.js");
const url = new URL("http://localhost/_vinext/image?url=https%3A%2F%2Fevil.com%2Fimg.jpg&w=800");
expect(parseImageParams(url)).toBeNull();
});
it("parseImageParams blocks protocol-relative URLs", async () => {
const { parseImageParams } = await import("../packages/vinext/src/server/image-optimization.js");
const url = new URL("http://localhost/_vinext/image?url=%2F%2Fevil.com%2Fimg.jpg&w=800");
expect(parseImageParams(url)).toBeNull();
});
it("parseImageParams defaults width to 0 and quality to 75", async () => {
const { parseImageParams } = await import("../packages/vinext/src/server/image-optimization.js");
const url = new URL("http://localhost/_vinext/image?url=%2Fimg.jpg");
const params = parseImageParams(url);
expect(params).not.toBeNull();
expect(params!.width).toBe(0);
expect(params!.quality).toBe(75);
});
it("parseImageParams blocks data: URIs (exotic scheme bypass)", async () => {
const { parseImageParams } = await import("../packages/vinext/src/server/image-optimization.js");
expect(parseImageParams(new URL("http://localhost/_vinext/image?url=data%3Atext%2Fhtml%2C%3Cscript%3Ealert(1)%3C%2Fscript%3E&w=800"))).toBeNull();
});
it("parseImageParams blocks javascript: URIs", async () => {
const { parseImageParams } = await import("../packages/vinext/src/server/image-optimization.js");
expect(parseImageParams(new URL("http://localhost/_vinext/image?url=javascript%3Aalert(1)&w=800"))).toBeNull();
});
it("parseImageParams blocks bare filenames (no leading slash)", async () => {
const { parseImageParams } = await import("../packages/vinext/src/server/image-optimization.js");
expect(parseImageParams(new URL("http://localhost/_vinext/image?url=img.jpg&w=800"))).toBeNull();
});
it("parseImageParams rejects quality outside 1-100", async () => {
const { parseImageParams } = await import("../packages/vinext/src/server/image-optimization.js");
expect(parseImageParams(new URL("http://localhost/_vinext/image?url=%2Fimg.jpg&q=0"))).toBeNull();
expect(parseImageParams(new URL("http://localhost/_vinext/image?url=%2Fimg.jpg&q=101"))).toBeNull();
});
it("parseImageParams blocks backslash-based open redirect (/\\evil.com)", async () => {
const { parseImageParams } = await import("../packages/vinext/src/server/image-optimization.js");
// /\evil.com — browsers and the URL constructor treat this as //evil.com
expect(parseImageParams(new URL("http://localhost/_vinext/image?url=%2F%5Cevil.com&w=800"))).toBeNull();
});
it("parseImageParams blocks encoded backslash variants", async () => {
const { parseImageParams } = await import("../packages/vinext/src/server/image-optimization.js");
// /\evil.com/img.jpg
expect(parseImageParams(new URL("http://localhost/_vinext/image?url=%2F%5Cevil.com%2Fimg.jpg&w=800"))).toBeNull();
// /\\evil.com (double backslash)
expect(parseImageParams(new URL("http://localhost/_vinext/image?url=%2F%5C%5Cevil.com&w=800"))).toBeNull();
});
it("parseImageParams validates origin hasn't changed after URL construction", async () => {
const { parseImageParams } = await import("../packages/vinext/src/server/image-optimization.js");
// This tests defense-in-depth: even if a future parser differential is found,
// the origin check catches it.
// A valid relative URL should pass
const good = parseImageParams(new URL("http://localhost/_vinext/image?url=%2Fimages%2Fhero.webp&w=800"));
expect(good).not.toBeNull();
expect(good!.imageUrl).toBe("/images/hero.webp");
});
it("parseImageParams normalizes backslashes in returned imageUrl", async () => {
const { parseImageParams } = await import("../packages/vinext/src/server/image-optimization.js");
// /images\hero.webp should be normalized to /images/hero.webp
// (backslash in the middle of a valid path)
const result = parseImageParams(new URL("http://localhost/_vinext/image?url=%2Fimages%5Chero.webp&w=800"));
expect(result).not.toBeNull();
expect(result!.imageUrl).toBe("/images/hero.webp");
});
it("parseImageParams rejects width exceeding absolute maximum (3840)", async () => {
const { parseImageParams } = await import("../packages/vinext/src/server/image-optimization.js");
expect(parseImageParams(new URL("http://localhost/_vinext/image?url=%2Fimg.jpg&w=3841"))).toBeNull();
expect(parseImageParams(new URL("http://localhost/_vinext/image?url=%2Fimg.jpg&w=999999999"))).toBeNull();
expect(parseImageParams(new URL("http://localhost/_vinext/image?url=%2Fimg.jpg&w=2147483647"))).toBeNull();
});
it("parseImageParams accepts width at the absolute maximum (3840)", async () => {
const { parseImageParams } = await import("../packages/vinext/src/server/image-optimization.js");
const params = parseImageParams(new URL("http://localhost/_vinext/image?url=%2Fimg.jpg&w=3840"));
expect(params).not.toBeNull();
expect(params!.width).toBe(3840);
});
it("parseImageParams validates against allowedWidths when provided", async () => {
const { parseImageParams } = await import("../packages/vinext/src/server/image-optimization.js");
const allowedWidths = [640, 750, 828, 1080, 1200, 1920, 2048, 3840];
// Allowed width passes
const params = parseImageParams(
new URL("http://localhost/_vinext/image?url=%2Fimg.jpg&w=1080"),
allowedWidths,
);
expect(params).not.toBeNull();
expect(params!.width).toBe(1080);
// Non-allowed width is rejected
expect(parseImageParams(
new URL("http://localhost/_vinext/image?url=%2Fimg.jpg&w=999"),
allowedWidths,
)).toBeNull();
// w=0 (no resize) is always allowed even with allowedWidths
const noResize = parseImageParams(
new URL("http://localhost/_vinext/image?url=%2Fimg.jpg&w=0"),
allowedWidths,
);
expect(noResize).not.toBeNull();
expect(noResize!.width).toBe(0);
});
it("parseImageParams allows imageSizes (small widths) in allowedWidths", async () => {
const { parseImageParams } = await import("../packages/vinext/src/server/image-optimization.js");
const allowedWidths = [16, 32, 48, 64, 96, 128, 256, 384, 640, 750, 828, 1080, 1200, 1920, 2048, 3840];
const params = parseImageParams(
new URL("http://localhost/_vinext/image?url=%2Fimg.jpg&w=64"),
allowedWidths,
);
expect(params).not.toBeNull();
expect(params!.width).toBe(64);
});
it("negotiateImageFormat prefers AVIF over WebP", async () => {
const { negotiateImageFormat } = await import("../packages/vinext/src/server/image-optimization.js");
expect(negotiateImageFormat("image/avif,image/webp,image/jpeg")).toBe("image/avif");
});
it("negotiateImageFormat selects WebP when no AVIF", async () => {
const { negotiateImageFormat } = await import("../packages/vinext/src/server/image-optimization.js");
expect(negotiateImageFormat("image/webp,image/jpeg")).toBe("image/webp");
});
it("negotiateImageFormat falls back to JPEG", async () => {
const { negotiateImageFormat } = await import("../packages/vinext/src/server/image-optimization.js");
expect(negotiateImageFormat("image/png,image/jpeg")).toBe("image/jpeg");
expect(negotiateImageFormat(null)).toBe("image/jpeg");
});
it("IMAGE_OPTIMIZATION_PATH is /_vinext/image", async () => {
const { IMAGE_OPTIMIZATION_PATH } = await import("../packages/vinext/src/server/image-optimization.js");
expect(IMAGE_OPTIMIZATION_PATH).toBe("/_vinext/image");
});
it("exports DEFAULT_DEVICE_SIZES and DEFAULT_IMAGE_SIZES matching Next.js defaults", async () => {
const { DEFAULT_DEVICE_SIZES, DEFAULT_IMAGE_SIZES } = await import("../packages/vinext/src/server/image-optimization.js");
expect(DEFAULT_DEVICE_SIZES).toEqual([640, 750, 828, 1080, 1200, 1920, 2048, 3840]);
expect(DEFAULT_IMAGE_SIZES).toEqual([16, 32, 48, 64, 96, 128, 256, 384]);
});
});
describe("isSafeImageContentType", () => {
it("accepts safe image content types", async () => {
const { isSafeImageContentType } = await import("../packages/vinext/src/server/image-optimization.js");
expect(isSafeImageContentType("image/jpeg")).toBe(true);
expect(isSafeImageContentType("image/png")).toBe(true);
expect(isSafeImageContentType("image/gif")).toBe(true);
expect(isSafeImageContentType("image/webp")).toBe(true);
expect(isSafeImageContentType("image/avif")).toBe(true);
expect(isSafeImageContentType("image/x-icon")).toBe(true);
expect(isSafeImageContentType("image/bmp")).toBe(true);
expect(isSafeImageContentType("image/tiff")).toBe(true);
});
it("rejects SVG content type", async () => {
const { isSafeImageContentType } = await import("../packages/vinext/src/server/image-optimization.js");
expect(isSafeImageContentType("image/svg+xml")).toBe(false);
});
it("rejects non-image content types", async () => {
const { isSafeImageContentType } = await import("../packages/vinext/src/server/image-optimization.js");
expect(isSafeImageContentType("text/html")).toBe(false);
expect(isSafeImageContentType("application/javascript")).toBe(false);
expect(isSafeImageContentType("text/xml")).toBe(false);
expect(isSafeImageContentType("application/octet-stream")).toBe(false);
});
it("rejects null content type", async () => {
const { isSafeImageContentType } = await import("../packages/vinext/src/server/image-optimization.js");
expect(isSafeImageContentType(null)).toBe(false);
});
it("handles content type with parameters (charset, etc.)", async () => {
const { isSafeImageContentType } = await import("../packages/vinext/src/server/image-optimization.js");
expect(isSafeImageContentType("image/jpeg; charset=utf-8")).toBe(true);
expect(isSafeImageContentType("image/svg+xml; charset=utf-8")).toBe(false);
});
it("is case-insensitive", async () => {
const { isSafeImageContentType } = await import("../packages/vinext/src/server/image-optimization.js");
expect(isSafeImageContentType("Image/JPEG")).toBe(true);
expect(isSafeImageContentType("IMAGE/SVG+XML")).toBe(false);
});
it("allows SVG when dangerouslyAllowSVG is true", async () => {
const { isSafeImageContentType } = await import("../packages/vinext/src/server/image-optimization.js");
expect(isSafeImageContentType("image/svg+xml", true)).toBe(true);
});
it("allows SVG with parameters when dangerouslyAllowSVG is true", async () => {
const { isSafeImageContentType } = await import("../packages/vinext/src/server/image-optimization.js");
expect(isSafeImageContentType("image/svg+xml; charset=utf-8", true)).toBe(true);
});
it("still rejects non-image types when dangerouslyAllowSVG is true", async () => {
const { isSafeImageContentType } = await import("../packages/vinext/src/server/image-optimization.js");
expect(isSafeImageContentType("text/html", true)).toBe(false);
expect(isSafeImageContentType("application/javascript", true)).toBe(false);
expect(isSafeImageContentType(null, true)).toBe(false);
});
});
describe("handleImageOptimization", () => {
it("returns 400 for invalid params", async () => {
const { handleImageOptimization } = await import("../packages/vinext/src/server/image-optimization.js");
const request = new Request("http://localhost/_vinext/image");
const handlers = {
fetchAsset: async () => new Response("", { status: 200 }),
};
const response = await handleImageOptimization(request, handlers);
expect(response.status).toBe(400);
});
it("returns 404 when fetchAsset fails", async () => {
const { handleImageOptimization } = await import("../packages/vinext/src/server/image-optimization.js");
const request = new Request("http://localhost/_vinext/image?url=%2Fimg.jpg&w=800");
const handlers = {
fetchAsset: async () => new Response("", { status: 404 }),
};
const response = await handleImageOptimization(request, handlers);
expect(response.status).toBe(404);
});
it("returns original image when no transformImage handler", async () => {
const { handleImageOptimization } = await import("../packages/vinext/src/server/image-optimization.js");
const request = new Request("http://localhost/_vinext/image?url=%2Fimg.jpg&w=800");
const handlers = {
fetchAsset: async () => new Response("original-image-data", {
status: 200,
headers: { "Content-Type": "image/jpeg" },
}),
};
const response = await handleImageOptimization(request, handlers);
expect(response.status).toBe(200);
expect(await response.text()).toBe("original-image-data");
expect(response.headers.get("Cache-Control")).toBe("public, max-age=31536000, immutable");
expect(response.headers.get("Vary")).toBe("Accept");
});
it("calls transformImage when provided", async () => {
const { handleImageOptimization } = await import("../packages/vinext/src/server/image-optimization.js");
const request = new Request("http://localhost/_vinext/image?url=%2Fimg.jpg&w=800&q=90", {
headers: { Accept: "image/webp" },
});
let capturedOptions: { width: number; format: string; quality: number } | null = null;
const handlers = {
fetchAsset: async () => new Response("original", {
status: 200,
headers: { "Content-Type": "image/jpeg" },
}),
transformImage: async (_body: ReadableStream, options: { width: number; format: string; quality: number }) => {
capturedOptions = options;
return new Response("transformed", { headers: { "Content-Type": options.format } });
},
};
const response = await handleImageOptimization(request, handlers);
expect(response.status).toBe(200);
expect(await response.text()).toBe("transformed");
expect(capturedOptions).toEqual({ width: 800, format: "image/webp", quality: 90 });
});
it("falls back to original on transform error", async () => {
const { handleImageOptimization } = await import("../packages/vinext/src/server/image-optimization.js");
const request = new Request("http://localhost/_vinext/image?url=%2Fimg.jpg&w=800");
const handlers = {
fetchAsset: async () => new Response("original", {
status: 200,
headers: { "Content-Type": "image/png" },
}),
transformImage: async () => {
throw new Error("transform failed");
},
};
const response = await handleImageOptimization(request, handlers);
expect(response.status).toBe(200);
expect(await response.text()).toBe("original");
});
it("returns 400 for backslash open redirect (/\\evil.com)", async () => {
const { handleImageOptimization } = await import("../packages/vinext/src/server/image-optimization.js");
const request = new Request("http://localhost/_vinext/image?url=%2F%5Cevil.com&w=800");
const handlers = {
fetchAsset: async () => new Response("should not be called", { status: 200 }),
};
const response = await handleImageOptimization(request, handlers);
expect(response.status).toBe(400);
});
it("does not call fetchAsset for backslash URLs", async () => {
const { handleImageOptimization } = await import("../packages/vinext/src/server/image-optimization.js");
const request = new Request("http://localhost/_vinext/image?url=%2F%5Cgoogle.com%2Fimg.jpg&w=800");
let fetchCalled = false;
const handlers = {
fetchAsset: async () => {
fetchCalled = true;
return new Response("", { status: 200 });
},
};
const response = await handleImageOptimization(request, handlers);
expect(response.status).toBe(400);
expect(fetchCalled).toBe(false);
});
it("blocks SVG content type", async () => {
const { handleImageOptimization } = await import("../packages/vinext/src/server/image-optimization.js");
const request = new Request("http://localhost/_vinext/image?url=%2Fmalicious.svg&w=100&q=75");
const handlers = {
fetchAsset: async () => new Response('<svg><script>alert(1)</script></svg>', {
status: 200,
headers: { "Content-Type": "image/svg+xml" },
}),
};
const response = await handleImageOptimization(request, handlers);
expect(response.status).toBe(400);
expect(await response.text()).toBe("The requested resource is not an allowed image type");
});
it("blocks text/html content type", async () => {
const { handleImageOptimization } = await import("../packages/vinext/src/server/image-optimization.js");
const request = new Request("http://localhost/_vinext/image?url=%2Ffake.jpg&w=100&q=75");
const handlers = {
fetchAsset: async () => new Response('<html><script>alert(1)</script></html>', {
status: 200,
headers: { "Content-Type": "text/html" },
}),
};
const response = await handleImageOptimization(request, handlers);
expect(response.status).toBe(400);
});
it("blocks responses with no Content-Type", async () => {
const { handleImageOptimization } = await import("../packages/vinext/src/server/image-optimization.js");
const request = new Request("http://localhost/_vinext/image?url=%2Fimg.jpg&w=800");
const handlers = {
fetchAsset: async () => new Response("data", { status: 200 }),
};
const response = await handleImageOptimization(request, handlers);
expect(response.status).toBe(400);
});
it("sets Content-Security-Policy header on fallback responses", async () => {
const { handleImageOptimization } = await import("../packages/vinext/src/server/image-optimization.js");
const request = new Request("http://localhost/_vinext/image?url=%2Fimg.jpg&w=800");
const handlers = {
fetchAsset: async () => new Response("image-data", {
status: 200,
headers: { "Content-Type": "image/jpeg" },
}),
};
const response = await handleImageOptimization(request, handlers);
expect(response.status).toBe(200);
expect(response.headers.get("Content-Security-Policy")).toBe("script-src 'none'; frame-src 'none'; sandbox;");
expect(response.headers.get("X-Content-Type-Options")).toBe("nosniff");
expect(response.headers.get("Content-Disposition")).toBe("inline");
});
it("sets Content-Security-Policy header on transformed responses", async () => {
const { handleImageOptimization } = await import("../packages/vinext/src/server/image-optimization.js");
const request = new Request("http://localhost/_vinext/image?url=%2Fimg.jpg&w=800&q=90", {
headers: { Accept: "image/webp" },
});
const handlers = {
fetchAsset: async () => new Response("original", {
status: 200,
headers: { "Content-Type": "image/jpeg" },
}),
transformImage: async (_body: ReadableStream, options: { width: number; format: string; quality: number }) => {
return new Response("transformed", { headers: { "Content-Type": options.format } });
},
};
const response = await handleImageOptimization(request, handlers);
expect(response.status).toBe(200);
expect(response.headers.get("Content-Security-Policy")).toBe("script-src 'none'; frame-src 'none'; sandbox;");
expect(response.headers.get("X-Content-Type-Options")).toBe("nosniff");
expect(response.headers.get("Content-Disposition")).toBe("inline");
});
it("overrides unsafe Content-Type from transform handler", async () => {
const { handleImageOptimization } = await import("../packages/vinext/src/server/image-optimization.js");
const request = new Request("http://localhost/_vinext/image?url=%2Fimg.jpg&w=800&q=90", {
headers: { Accept: "image/webp" },
});
const handlers = {
fetchAsset: async () => new Response("original", {
status: 200,
headers: { "Content-Type": "image/jpeg" },
}),
transformImage: async () => {
// Buggy transform that returns text/html
return new Response("transformed", { headers: { "Content-Type": "text/html" } });
},
};
const response = await handleImageOptimization(request, handlers);
expect(response.status).toBe(200);
// Should override to the negotiated format, not pass through text/html
expect(response.headers.get("Content-Type")).toBe("image/webp");
});
it("allows SVG passthrough with dangerouslyAllowSVG: true", async () => {
const { handleImageOptimization } = await import("../packages/vinext/src/server/image-optimization.js");
const request = new Request("http://localhost/_vinext/image?url=%2Flogo.svg&w=100&q=75");
const handlers = {
fetchAsset: async () => new Response('<svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>', {
status: 200,
headers: { "Content-Type": "image/svg+xml" },
}),
};
const response = await handleImageOptimization(request, handlers, undefined, { dangerouslyAllowSVG: true });
expect(response.status).toBe(200);
expect(await response.text()).toBe('<svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>');
expect(response.headers.get("Content-Type")).toBe("image/svg+xml");
});
it("still blocks SVG when dangerouslyAllowSVG is false", async () => {
const { handleImageOptimization } = await import("../packages/vinext/src/server/image-optimization.js");
const request = new Request("http://localhost/_vinext/image?url=%2Flogo.svg&w=100&q=75");
const handlers = {
fetchAsset: async () => new Response("<svg></svg>", {
status: 200,
headers: { "Content-Type": "image/svg+xml" },
}),
};
const response = await handleImageOptimization(request, handlers, undefined, { dangerouslyAllowSVG: false });
expect(response.status).toBe(400);
});
it("SVG passthrough skips transformImage", async () => {
const { handleImageOptimization } = await import("../packages/vinext/src/server/image-optimization.js");
const request = new Request("http://localhost/_vinext/image?url=%2Flogo.svg&w=100&q=75");
let transformCalled = false;
const handlers = {
fetchAsset: async () => new Response("<svg></svg>", {
status: 200,
headers: { "Content-Type": "image/svg+xml" },
}),
transformImage: async () => {
transformCalled = true;
return new Response("transformed");
},
};
const response = await handleImageOptimization(request, handlers, undefined, { dangerouslyAllowSVG: true });
expect(response.status).toBe(200);
expect(transformCalled).toBe(false);
expect(await response.text()).toBe("<svg></svg>");
});
it("applies security headers on SVG passthrough", async () => {
const { handleImageOptimization } = await import("../packages/vinext/src/server/image-optimization.js");
const request = new Request("http://localhost/_vinext/image?url=%2Flogo.svg&w=100&q=75");
const handlers = {
fetchAsset: async () => new Response("<svg></svg>", {
status: 200,
headers: { "Content-Type": "image/svg+xml" },
}),
};
const response = await handleImageOptimization(request, handlers, undefined, { dangerouslyAllowSVG: true });
expect(response.status).toBe(200);
expect(response.headers.get("Content-Security-Policy")).toBe("script-src 'none'; frame-src 'none'; sandbox;");
expect(response.headers.get("X-Content-Type-Options")).toBe("nosniff");
expect(response.headers.get("Content-Disposition")).toBe("inline");
});
it("applies custom contentDispositionType", async () => {
const { handleImageOptimization } = await import("../packages/vinext/src/server/image-optimization.js");
const request = new Request("http://localhost/_vinext/image?url=%2Fimg.jpg&w=800");
const handlers = {
fetchAsset: async () => new Response("image-data", {
status: 200,
headers: { "Content-Type": "image/jpeg" },
}),
};
const response = await handleImageOptimization(request, handlers, undefined, { contentDispositionType: "attachment" });
expect(response.status).toBe(200);
expect(response.headers.get("Content-Disposition")).toBe("attachment");
});
it("applies custom contentSecurityPolicy", async () => {
const { handleImageOptimization } = await import("../packages/vinext/src/server/image-optimization.js");
const request = new Request("http://localhost/_vinext/image?url=%2Fimg.jpg&w=800");
const handlers = {
fetchAsset: async () => new Response("image-data", {
status: 200,
headers: { "Content-Type": "image/jpeg" },
}),
};
const customCSP = "default-src 'self'; script-src 'none';";
const response = await handleImageOptimization(request, handlers, undefined, { contentSecurityPolicy: customCSP });
expect(response.status).toBe(200);
expect(response.headers.get("Content-Security-Policy")).toBe(customCSP);
});
it("default behavior unchanged when no imageConfig provided", async () => {
const { handleImageOptimization } = await import("../packages/vinext/src/server/image-optimization.js");
const request = new Request("http://localhost/_vinext/image?url=%2Fimg.jpg&w=800");
const handlers = {
fetchAsset: async () => new Response("image-data", {
status: 200,
headers: { "Content-Type": "image/jpeg" },
}),
};
const response = await handleImageOptimization(request, handlers);
expect(response.status).toBe(200);
expect(response.headers.get("Content-Security-Policy")).toBe("script-src 'none'; frame-src 'none'; sandbox;");
expect(response.headers.get("Content-Disposition")).toBe("inline");
});
});
describe("next/navigation enhancements", () => {
it("exports ReadonlyURLSearchParams type alias", async () => {
// This is a type-only export, we verify the module loads without error
const nav = await import(
"../packages/vinext/src/shims/navigation.js"
);
// ReadonlyURLSearchParams is a type export, not a runtime value
// But useServerInsertedHTML should be exported
expect(typeof nav.useServerInsertedHTML).toBe("function");
});
it("useServerInsertedHTML is a no-op function", async () => {
const { useServerInsertedHTML } = await import(
"../packages/vinext/src/shims/navigation.js"
);
// Should not throw
expect(() => useServerInsertedHTML(() => null)).not.toThrow();
});
});
describe("next/legacy/image shim", () => {
it("renders LegacyImage with layout=fill as modern Image with fill prop", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const LegacyImage = (await import("../packages/vinext/src/shims/legacy-image.js")).default;
const html = renderToStaticMarkup(
React.createElement(LegacyImage, {
src: "/photo.jpg",
alt: "Test",
layout: "fill",
objectFit: "cover",
objectPosition: "center",
}),
);
expect(html).toContain("photo.jpg");
expect(html).toContain("alt");
// fill mode should produce absolute positioning styles
expect(html).toContain("position:absolute");
});
it("renders LegacyImage with layout=intrinsic using width/height", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const LegacyImage = (await import("../packages/vinext/src/shims/legacy-image.js")).default;
const html = renderToStaticMarkup(
React.createElement(LegacyImage, {
src: "/photo.jpg",
alt: "Test",
layout: "intrinsic",
width: 640,
height: 480,
}),
);
expect(html).toContain('width="640"');
expect(html).toContain('height="480"');
});
it("renders LegacyImage with string width/height (converts to number)", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const LegacyImage = (await import("../packages/vinext/src/shims/legacy-image.js")).default;
const html = renderToStaticMarkup(
React.createElement(LegacyImage, {
src: "/photo.jpg",
alt: "Test",
width: "200",
height: "150",
}),
);
expect(html).toContain('width="200"');
expect(html).toContain('height="150"');
});
});
describe("next/error shim", () => {
it("renders 404 error page", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const ErrorComponent = (await import("../packages/vinext/src/shims/error.js")).default;
const html = renderToStaticMarkup(
React.createElement(ErrorComponent, { statusCode: 404 }),
);
expect(html).toContain("404");
expect(html).toContain("could not be found");
});
it("renders 500 error page", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const ErrorComponent = (await import("../packages/vinext/src/shims/error.js")).default;
const html = renderToStaticMarkup(
React.createElement(ErrorComponent, { statusCode: 500 }),
);
expect(html).toContain("500");
expect(html).toContain("Internal Server Error");
});
it("renders custom title", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const ErrorComponent = (await import("../packages/vinext/src/shims/error.js")).default;
const html = renderToStaticMarkup(
React.createElement(ErrorComponent, { statusCode: 403, title: "Forbidden" }),
);
expect(html).toContain("403");
expect(html).toContain("Forbidden");
});
});
describe("next/constants shim", () => {
it("exports all phase constants", async () => {
const constants = await import("../packages/vinext/src/shims/constants.js");
const constantsFromNext = await import("next/constants");
const normalizeConstants = (mod: Record<string, unknown>) => {
const {
__esModule: _esModule,
default: _default,
CLIENT_STATIC_FILES_RUNTIME_POLYFILLS_SYMBOL,
...rest
} = mod;
return {
...rest,
CLIENT_STATIC_FILES_RUNTIME_POLYFILLS_SYMBOL:
typeof CLIENT_STATIC_FILES_RUNTIME_POLYFILLS_SYMBOL === "symbol"
? CLIENT_STATIC_FILES_RUNTIME_POLYFILLS_SYMBOL.description
: CLIENT_STATIC_FILES_RUNTIME_POLYFILLS_SYMBOL,
};
};
expect(normalizeConstants(constants)).toEqual(normalizeConstants(constantsFromNext));
});
});
describe("next/script SSR rendering", () => {
it("beforeInteractive renders <script> tag in SSR", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const Script = (await import("../packages/vinext/src/shims/script.js")).default;
const html = renderToStaticMarkup(
React.createElement(Script, {
src: "https://example.com/analytics.js",
strategy: "beforeInteractive",
id: "analytics",
}),
);
expect(html).toContain("<script");
expect(html).toContain('src="https://example.com/analytics.js"');
expect(html).toContain('id="analytics"');
});
it("afterInteractive returns null in SSR", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const Script = (await import("../packages/vinext/src/shims/script.js")).default;
const html = renderToStaticMarkup(
React.createElement(Script, {
src: "https://example.com/chat.js",
strategy: "afterInteractive",
}),
);
// afterInteractive should not render anything server-side
expect(html).toBe("");
});
it("lazyOnload returns null in SSR", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const Script = (await import("../packages/vinext/src/shims/script.js")).default;
const html = renderToStaticMarkup(
React.createElement(Script, {
src: "https://example.com/tracking.js",
strategy: "lazyOnload",
}),
);
expect(html).toBe("");
});
it("default strategy (no strategy prop) returns null in SSR", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const Script = (await import("../packages/vinext/src/shims/script.js")).default;
const html = renderToStaticMarkup(
React.createElement(Script, {
src: "https://example.com/default.js",
}),
);
// Default is afterInteractive → null in SSR
expect(html).toBe("");
});
it("beforeInteractive with dangerouslySetInnerHTML renders inline script", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const Script = (await import("../packages/vinext/src/shims/script.js")).default;
const html = renderToStaticMarkup(
React.createElement(Script, {
strategy: "beforeInteractive",
id: "inline-script",
dangerouslySetInnerHTML: { __html: "console.log('hello')" },
}),
);
expect(html).toContain("<script");
expect(html).toContain('id="inline-script"');
expect(html).toContain("console.log('hello')");
});
it("beforeInteractive escapes </script> in dangerouslySetInnerHTML", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const Script = (await import("../packages/vinext/src/shims/script.js")).default;
const html = renderToStaticMarkup(
React.createElement(Script, {
strategy: "beforeInteractive",
id: "escape-test",
dangerouslySetInnerHTML: {
__html: 'var x = "</script><img src=x onerror=alert(1)>";',
},
}),
);
// The raw </script> must NOT appear — it would break the tag boundary
expect(html).not.toContain("</script><img");
// The escaped form should be present instead
expect(html).toContain("<\\/script>");
});
it("beforeInteractive escapes </script> case-insensitively", async () => {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const Script = (await import("../packages/vinext/src/shims/script.js")).default;
const html = renderToStaticMarkup(
React.createElement(Script, {
strategy: "beforeInteractive",
id: "case-test",
dangerouslySetInnerHTML: {
__html: 'var x = "</SCRIPT><img src=x onerror=alert(1)>";',
},
}),
);
// Mixed-case </SCRIPT> must also be escaped
expect(html).not.toContain("</SCRIPT>");
expect(html).toContain("<\\/SCRIPT>");
});
it("exports handleClientScriptLoad and initScriptLoader", async () => {
const scriptModule = await import("../packages/vinext/src/shims/script.js");
expect(typeof scriptModule.handleClientScriptLoad).toBe("function");
expect(typeof scriptModule.initScriptLoader).toBe("function");
});
});
describe("next/dist/* internal import shims", () => {
it("app-router-context exports AppRouterContext and types", async () => {
const mod = await import(
"../packages/vinext/src/shims/internal/app-router-context.js"
);
expect(mod.AppRouterContext).toBeDefined();
expect(mod.GlobalLayoutRouterContext).toBeDefined();
expect(mod.LayoutRouterContext).toBeDefined();
expect(mod.MissingSlotContext).toBeDefined();
expect(mod.TemplateContext).toBeDefined();
});
it("utils exports NEXT_DATA type helpers", async () => {
const mod = await import(
"../packages/vinext/src/shims/internal/utils.js"
);
expect(typeof mod.execOnce).toBe("function");
expect(typeof mod.getLocationOrigin).toBe("function");
expect(typeof mod.getURL).toBe("function");
// execOnce should only call the function once
let count = 0;
const fn = mod.execOnce(() => ++count);
fn(); fn(); fn();
expect(count).toBe(1);
});
it("api-utils exports NextApiRequestCookies type", async () => {
// This module is primarily type-only, but should resolve without errors
const mod = await import(
"../packages/vinext/src/shims/internal/api-utils.js"
);
expect(mod).toBeDefined();
});
it("cookies shim re-exports RequestCookies and ResponseCookies", async () => {
const mod = await import(
"../packages/vinext/src/shims/internal/cookies.js"
);
expect(mod.RequestCookies).toBeDefined();
expect(mod.ResponseCookies).toBeDefined();
});
it("work-unit-async-storage exports AsyncLocalStorage instances", async () => {
const mod = await import(
"../packages/vinext/src/shims/internal/work-unit-async-storage.js"
);
expect(mod.workUnitAsyncStorage).toBeDefined();
expect(mod.requestAsyncStorage).toBeDefined();
// Both should be the same AsyncLocalStorage instance
expect(mod.workUnitAsyncStorage).toBe(mod.requestAsyncStorage);
});
it("router-context exports RouterContext", async () => {
const mod = await import(
"../packages/vinext/src/shims/internal/router-context.js"
);
expect(mod.RouterContext).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// Cloudflare KV CacheHandler
// ---------------------------------------------------------------------------
describe("KVCacheHandler", () => {
// In-memory mock of Cloudflare KV namespace
function createMockKV() {
const store = new Map<string, { value: string; expirationTtl?: number }>();
return {
store,
async get(key: string): Promise<string | null> {
return store.get(key)?.value ?? null;
},
async put(
key: string,
value: string,
options?: { expirationTtl?: number },
): Promise<void> {
store.set(key, { value, expirationTtl: options?.expirationTtl });
},
async delete(key: string): Promise<void> {
store.delete(key);
},
async list(options?: { prefix?: string; limit?: number; cursor?: string }) {
const prefix = options?.prefix ?? "";
const keys: Array<{ name: string }> = [];
for (const k of store.keys()) {
if (k.startsWith(prefix)) keys.push({ name: k });
}
return { keys, list_complete: true };
},
};
}
it("stores and retrieves a cache entry", async () => {
const { KVCacheHandler } = await import(
"../packages/vinext/src/cloudflare/kv-cache-handler.js"
);
const kv = createMockKV();
const handler = new KVCacheHandler(kv as any);
await handler.set("test-key", {
kind: "PAGES",
html: "<h1>Hello</h1>",
pageData: { props: {} },
headers: undefined,
status: 200,
});
const result = await handler.get("test-key");
expect(result).not.toBeNull();
expect(result!.value).not.toBeNull();
expect(result!.value!.kind).toBe("PAGES");
if (result!.value!.kind === "PAGES") {
expect(result!.value!.html).toBe("<h1>Hello</h1>");
expect(result!.value!.status).toBe(200);
}
});
it("returns null for missing keys", async () => {
const { KVCacheHandler } = await import(
"../packages/vinext/src/cloudflare/kv-cache-handler.js"
);
const kv = createMockKV();
const handler = new KVCacheHandler(kv as any);
const result = await handler.get("nonexistent");
expect(result).toBeNull();
});
it("handles tag-based invalidation", async () => {
const { KVCacheHandler } = await import(
"../packages/vinext/src/cloudflare/kv-cache-handler.js"
);
const kv = createMockKV();
const handler = new KVCacheHandler(kv as any);
await handler.set(
"tagged-entry",
{
kind: "FETCH",
data: { headers: {}, body: '{"result":1}', url: "test" },
tags: ["my-tag"],
revalidate: 0,
},
{ tags: ["my-tag"] },
);
// Before invalidation — entry exists
const before = await handler.get("tagged-entry");
expect(before).not.toBeNull();
// Invalidate the tag
await handler.revalidateTag("my-tag");
// After invalidation — entry should be treated as miss
const after = await handler.get("tagged-entry");
expect(after).toBeNull();
});
it("returns stale entry when past revalidation time", async () => {
const { KVCacheHandler } = await import(
"../packages/vinext/src/cloudflare/kv-cache-handler.js"
);
const kv = createMockKV();
const handler = new KVCacheHandler(kv as any);
// Set with very short revalidation (already expired)
await handler.set(
"stale-key",
{
kind: "PAGES",
html: "<h1>Stale</h1>",
pageData: {},
headers: undefined,
status: 200,
},
{ revalidate: -1 }, // already past
);
// Manually fix the revalidateAt to be in the past
const raw = await kv.get("cache:stale-key");
const entry = JSON.parse(raw!);
entry.revalidateAt = Date.now() - 1000;
await kv.put("cache:stale-key", JSON.stringify(entry));
const result = await handler.get("stale-key");
expect(result).not.toBeNull();
expect(result!.cacheState).toBe("stale");
expect(result!.value!.kind).toBe("PAGES");
});
it("serializes and restores APP_PAGE with rscData ArrayBuffer", async () => {
const { KVCacheHandler } = await import(
"../packages/vinext/src/cloudflare/kv-cache-handler.js"
);
const kv = createMockKV();
const handler = new KVCacheHandler(kv as any);
const originalData = new TextEncoder().encode("RSC payload data");
await handler.set("app-page-key", {
kind: "APP_PAGE",
html: "<div>App page</div>",
rscData: originalData.buffer as ArrayBuffer,
headers: { "x-custom": "value" },
postponed: undefined,
status: 200,
});
const result = await handler.get("app-page-key");
expect(result).not.toBeNull();
expect(result!.value!.kind).toBe("APP_PAGE");
if (result!.value!.kind === "APP_PAGE") {
expect(result!.value!.html).toBe("<div>App page</div>");
// rscData should be restored as ArrayBuffer
expect(result!.value!.rscData).toBeInstanceOf(ArrayBuffer);
const restored = new TextDecoder().decode(result!.value!.rscData!);
expect(restored).toBe("RSC payload data");
}
});
it("serializes and restores APP_ROUTE with body ArrayBuffer", async () => {
const { KVCacheHandler } = await import(
"../packages/vinext/src/cloudflare/kv-cache-handler.js"
);
const kv = createMockKV();
const handler = new KVCacheHandler(kv as any);
const body = new TextEncoder().encode('{"ok":true}');
await handler.set("route-key", {
kind: "APP_ROUTE",
body: body.buffer as ArrayBuffer,
status: 200,
headers: { "content-type": "application/json" },
});
const result = await handler.get("route-key");
expect(result).not.toBeNull();
if (result!.value!.kind === "APP_ROUTE") {
const restored = new TextDecoder().decode(result!.value!.body);
expect(restored).toBe('{"ok":true}');
}
});
it("sets KV expiration TTL based on revalidation period", async () => {
const { KVCacheHandler } = await import(
"../packages/vinext/src/cloudflare/kv-cache-handler.js"
);
const kv = createMockKV();
const handler = new KVCacheHandler(kv as any);
await handler.set(
"ttl-key",
{
kind: "PAGES",
html: "<h1>TTL</h1>",
pageData: {},
headers: undefined,
status: 200,
},
{ revalidate: 60 }, // 60 seconds
);
// The KV entry should have an expiration TTL set
const stored = kv.store.get("cache:ttl-key");
expect(stored).toBeDefined();
expect(stored!.expirationTtl).toBeDefined();
// 10x the revalidation period = 600, but minimum is 60
expect(stored!.expirationTtl).toBe(600);
});
it("handles multiple tag invalidation in parallel", async () => {
const { KVCacheHandler } = await import(
"../packages/vinext/src/cloudflare/kv-cache-handler.js"
);
const kv = createMockKV();
const handler = new KVCacheHandler(kv as any);
await handler.set(
"multi-tag",
{
kind: "FETCH",
data: { headers: {}, body: "{}", url: "test" },
tags: ["tag-a", "tag-b"],
revalidate: 0,
},
{ tags: ["tag-a", "tag-b"] },
);
// Invalidate both tags at once
await handler.revalidateTag(["tag-a", "tag-b"]);
const result = await handler.get("multi-tag");
expect(result).toBeNull();
});
it("handles corrupted KV entries gracefully", async () => {
const { KVCacheHandler } = await import(
"../packages/vinext/src/cloudflare/kv-cache-handler.js"
);
const kv = createMockKV();
const handler = new KVCacheHandler(kv as any);
// Put corrupted data directly
await kv.put("cache:corrupt-key", "not valid json {{{");
const result = await handler.get("corrupt-key");
expect(result).toBeNull();
// The corrupted entry should be cleaned up
expect(await kv.get("cache:corrupt-key")).toBeNull();
});
});
// ─── server-only / client-only shims ─────────────────────────────────────────
describe("server-only shim", () => {
it("can be imported without error", async () => {
const mod = await import("../packages/vinext/src/shims/server-only.js");
expect(mod).toBeDefined();
});
it("exports nothing (empty marker module)", async () => {
const mod = await import("../packages/vinext/src/shims/server-only.js");
// The module should have no named exports (just the default module namespace)
const keys = Object.keys(mod).filter((k) => k !== "__esModule" && k !== "default");
expect(keys).toHaveLength(0);
});
});
describe("client-only shim", () => {
it("can be imported without error", async () => {
const mod = await import("../packages/vinext/src/shims/client-only.js");
expect(mod).toBeDefined();
});
it("exports nothing (empty marker module)", async () => {
const mod = await import("../packages/vinext/src/shims/client-only.js");
const keys = Object.keys(mod).filter((k) => k !== "__esModule" && k !== "default");
expect(keys).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// next/link — onNavigate / NavigateEvent (View Transitions support, Issue #38)
// ---------------------------------------------------------------------------
describe("next/link onNavigate / NavigateEvent", () => {
it("exports Link as default and useLinkStatus as named export", async () => {
const mod = await import("../packages/vinext/src/shims/link.js");
expect(typeof mod.default).toBe("object"); // forwardRef returns an object
expect(typeof mod.useLinkStatus).toBe("function");
});
it("NavigateEvent.preventDefault() sets defaultPrevented to true", () => {
// Mirrors the NavigateEvent construction in the Link click handler
let prevented = false;
const navEvent = {
url: new URL("/about", "http://localhost"),
preventDefault() { prevented = true; },
get defaultPrevented() { return prevented; },
};
expect(navEvent.defaultPrevented).toBe(false);
navEvent.preventDefault();
expect(navEvent.defaultPrevented).toBe(true);
});
it("NavigateEvent.defaultPrevented is false when preventDefault is not called", () => {
let prevented = false;
const navEvent = {
url: new URL("/products/1", "http://localhost"),
preventDefault() { prevented = true; },
get defaultPrevented() { return prevented; },
};
expect(navEvent.defaultPrevented).toBe(false);
expect(navEvent.url.pathname).toBe("/products/1");
});
it("onNavigate callback receives event with correct url", () => {
// Simulate what the Link component does in its click handler
const resolvedHref = "/view-transitions/posts/42";
const navUrl = new URL(resolvedHref, "http://localhost:3000");
let prevented = false;
const navEvent = {
url: navUrl,
preventDefault() { prevented = true; },
get defaultPrevented() { return prevented; },
};
// Simulated TransitionLink-style callback
const onNavigate = (event: typeof navEvent) => {
event.preventDefault();
};
onNavigate(navEvent);
expect(navEvent.defaultPrevented).toBe(true);
expect(navEvent.url.pathname).toBe("/view-transitions/posts/42");
});
it("multiple preventDefault() calls are idempotent", () => {
let prevented = false;
const navEvent = {
url: new URL("/", "http://localhost"),
preventDefault() { prevented = true; },
get defaultPrevented() { return prevented; },
};
navEvent.preventDefault();
navEvent.preventDefault();
navEvent.preventDefault();
expect(navEvent.defaultPrevented).toBe(true);
});
});
// ---------------------------------------------------------------------------
// vinext:react-canary — ViewTransition & addTransitionType polyfills (Issue #42)
// ---------------------------------------------------------------------------
describe("vinext:react-canary transform logic", () => {
// These tests verify the regex patterns used by the vinext:react-canary plugin
// to detect and rewrite imports of React canary APIs.
const canaryImportRegex = /import\s*\{[^}]*(ViewTransition|addTransitionType)[^}]*\}\s*from\s*['"]react['"]/;
it("detects ViewTransition import from react", () => {
const code = `import { ViewTransition } from "react";`;
expect(canaryImportRegex.test(code)).toBe(true);
});
it("detects addTransitionType import from react", () => {
const code = `import { addTransitionType } from "react";`;
expect(canaryImportRegex.test(code)).toBe(true);
});
it("detects ViewTransition alongside other React imports", () => {
const code = `import { useState, ViewTransition, useEffect } from "react";`;
expect(canaryImportRegex.test(code)).toBe(true);
});
it("detects addTransitionType alongside other React imports", () => {
const code = `import { useRef, addTransitionType } from 'react';`;
expect(canaryImportRegex.test(code)).toBe(true);
});
it("detects both canary APIs in a single import", () => {
const code = `import { ViewTransition, addTransitionType } from "react";`;
expect(canaryImportRegex.test(code)).toBe(true);
});
it("does NOT match imports from other modules", () => {
const code = `import { ViewTransition } from "some-other-lib";`;
expect(canaryImportRegex.test(code)).toBe(false);
});
it("does NOT match non-canary React imports", () => {
const code = `import { useState, useEffect } from "react";`;
expect(canaryImportRegex.test(code)).toBe(false);
});
it("does NOT match default React import", () => {
const code = `import React from "react";`;
expect(canaryImportRegex.test(code)).toBe(false);
});
it("does NOT match namespace React import", () => {
const code = `import * as React from "react";`;
expect(canaryImportRegex.test(code)).toBe(false);
});
it("rewrites all 'from react' occurrences in a file with canary imports", () => {
const code = [
`import { useState } from "react";`,
`import { ViewTransition } from "react";`,
``,
`export default function Template({ children }) {`,
` return <ViewTransition>{children}</ViewTransition>;`,
`}`,
].join("\n");
// The transform replaces all `from "react"` in the file
const result = code.replace(
/from\s*['"]react['"]/g,
'from "virtual:vinext-react-canary"',
);
expect(result).toContain('from "virtual:vinext-react-canary"');
expect(result).not.toContain("from \"react\"");
// Both import lines should be rewritten
expect(result.match(/virtual:vinext-react-canary/g)?.length).toBe(2);
});
it("handles single-quoted imports", () => {
const code = `import { ViewTransition } from 'react';`;
expect(canaryImportRegex.test(code)).toBe(true);
const result = code.replace(
/from\s*['"]react['"]/g,
'from "virtual:vinext-react-canary"',
);
expect(result).toBe(`import { ViewTransition } from "virtual:vinext-react-canary";`);
});
it("handles multiline imports", () => {
const code = `import {\n ViewTransition,\n useState,\n} from "react";`;
expect(canaryImportRegex.test(code)).toBe(true);
});
});
describe("ViewTransition polyfill behavior", () => {
it("provides a passthrough component when React lacks ViewTransition", () => {
// Simulate the polyfill logic from the virtual module
const React = { ViewTransition: undefined };
const ViewTransition = React.ViewTransition || function ViewTransition({ children }: { children: any }) { return children; };
// ViewTransition should be a function
expect(typeof ViewTransition).toBe("function");
// It should pass through children unchanged
const children = { type: "div", props: {} };
const result = ViewTransition({ children });
expect(result).toBe(children);
});
it("uses native ViewTransition when React exports it", () => {
// Simulate React canary that HAS ViewTransition
const nativeViewTransition = function NativeViewTransition({ children }: { children: any }) {
return { wrapped: children };
};
const React = { ViewTransition: nativeViewTransition };
const ViewTransition = React.ViewTransition || function ViewTransition({ children }: { children: any }) { return children; };
expect(ViewTransition).toBe(nativeViewTransition);
});
it("provides a no-op addTransitionType when React lacks it", () => {
const React = { addTransitionType: undefined };
const addTransitionType = React.addTransitionType || function addTransitionType() {};
expect(typeof addTransitionType).toBe("function");
// Should not throw when called
expect(() => addTransitionType()).not.toThrow();
});
it("uses native addTransitionType when React exports it", () => {
const nativeAddTransitionType = function nativeAddTransitionType(type: string) { return type; };
const React = { addTransitionType: nativeAddTransitionType };
const addTransitionType = React.addTransitionType || function addTransitionType() {};
expect(addTransitionType).toBe(nativeAddTransitionType);
});
});
// ---------------------------------------------------------------------------
// next/head SSR security tests
// ---------------------------------------------------------------------------
describe("next/head SSR security", () => {
async function collectHeadHTML(children: React.ReactElement[]) {
const React = await import("react");
const { renderToStaticMarkup } = await import("react-dom/server");
const { default: Head, resetSSRHead, getSSRHeadHTML } = await import(
"../packages/vinext/src/shims/head.js"
);
resetSSRHead();
// Render Head with children — SSR path collects elements
renderToStaticMarkup(
React.createElement(Head, null, ...children),
);
return getSSRHeadHTML();
}
it("escapes HTML special characters in title children", async () => {
const React = await import("react");
const html = await collectHeadHTML([
React.createElement("title", null, '</title><script>alert("xss")</script>'),
]);
// The injected script tag must be escaped, not raw
expect(html).not.toContain("<script>");
expect(html).not.toContain("</title><script>");
expect(html).toContain("&lt;/title&gt;&lt;script&gt;");
});
it("escapes ampersands and angle brackets in children", async () => {
const React = await import("react");
const html = await collectHeadHTML([
React.createElement("title", null, "Tom & Jerry < Friends > Foes"),
]);
expect(html).toContain("Tom &amp; Jerry &lt; Friends &gt; Foes");
expect(html).not.toContain("Tom & Jerry < Friends > Foes");
});
it("still allows dangerouslySetInnerHTML (intentionally raw)", async () => {
const React = await import("react");
const html = await collectHeadHTML([
React.createElement("style", {
dangerouslySetInnerHTML: { __html: "body { color: red; }" },
}),
]);
expect(html).toContain("body { color: red; }");
});
it("escapes </script> in dangerouslySetInnerHTML for script tags", async () => {
const React = await import("react");
const html = await collectHeadHTML([
React.createElement("script", {
dangerouslySetInnerHTML: {
__html: 'var x = "</script><img src=x onerror=alert(1)>";',
},
}),
]);
// The raw </script> must NOT appear in the output
expect(html).not.toContain("</script><img");
// The escaped form preserves the JS string content
expect(html).toContain("<\\/script>");
});
it("escapes </style> in dangerouslySetInnerHTML for style tags", async () => {
const React = await import("react");
const html = await collectHeadHTML([
React.createElement("style", {
dangerouslySetInnerHTML: {
__html: "body::after { content: '</style><img src=x onerror=alert(1)>'; }",
},
}),
]);
// The raw </style> must NOT appear
expect(html).not.toContain("</style><img");
expect(html).toContain("<\\/style>");
});
it("escapes case-insensitive closing tags in dangerouslySetInnerHTML", async () => {
const React = await import("react");
const html = await collectHeadHTML([
React.createElement("script", {
dangerouslySetInnerHTML: {
__html: 'var x = "</SCRIPT><img src=x onerror=alert(1)>";',
},
}),
]);
expect(html).not.toContain("</SCRIPT>");
expect(html).toContain("<\\/SCRIPT>");
});
it("attributes are still properly escaped", async () => {
const React = await import("react");
const html = await collectHeadHTML([
React.createElement("meta", {
name: "description",
content: 'He said "hello" & <goodbye>',
}),
]);
expect(html).toContain("&quot;hello&quot;");
expect(html).toContain("&amp;");
expect(html).toContain("&lt;goodbye&gt;");
});
it("rejects disallowed tag types (iframe)", async () => {
const React = await import("react");
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {});
const html = await collectHeadHTML([
React.createElement("iframe" as any, { src: "https://evil.com" }),
]);
expect(html).toBe("");
expect(consoleWarn).toHaveBeenCalledWith(
expect.stringContaining("ignoring disallowed tag <iframe>"),
);
consoleWarn.mockRestore();
});
it("rejects disallowed tag types (object, embed, form)", async () => {
const React = await import("react");
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {});
const html = await collectHeadHTML([
React.createElement("object" as any, { data: "https://evil.com" }),
React.createElement("embed" as any, { src: "https://evil.com" }),
React.createElement("form" as any, { action: "https://evil.com" }),
]);
expect(html).toBe("");
consoleWarn.mockRestore();
});
it("allows all valid head tags", async () => {
const React = await import("react");
const { resetSSRHead } = await import(
"../packages/vinext/src/shims/head.js"
);
const allowedTags = ["title", "meta", "link", "style", "script", "base", "noscript"];
for (const tag of allowedTags) {
resetSSRHead();
const selfClosing = ["meta", "link", "base"].includes(tag);
const el = selfClosing
? React.createElement(tag, { name: "test", content: "test" })
: React.createElement(tag, null, "test content");
const html = await collectHeadHTML([el]);
expect(html).toContain(`<${tag}`);
expect(html).toContain('data-vinext-head="true"');
}
});
});
describe("escapeInlineContent", () => {
it("escapes </script> within script content", async () => {
const { escapeInlineContent } = await import("../packages/vinext/src/shims/head.js");
const input = 'var x = "</script><img src=x onerror=alert(1)>";';
const result = escapeInlineContent(input, "script");
expect(result).toBe('var x = "<\\/script><img src=x onerror=alert(1)>";');
expect(result).not.toContain("</script>");
});
it("escapes </style> within style content", async () => {
const { escapeInlineContent } = await import("../packages/vinext/src/shims/head.js");
const input = "body::after { content: '</style><div>'; }";
const result = escapeInlineContent(input, "style");
expect(result).toBe("body::after { content: '<\\/style><div>'; }");
expect(result).not.toContain("</style>");
});
it("handles case-insensitive closing tags", async () => {
const { escapeInlineContent } = await import("../packages/vinext/src/shims/head.js");
expect(escapeInlineContent("</Script>", "script")).toBe("<\\/Script>");
expect(escapeInlineContent("</SCRIPT>", "script")).toBe("<\\/SCRIPT>");
expect(escapeInlineContent("</sCrIpT>", "script")).toBe("<\\/sCrIpT>");
});
it("handles multiple occurrences", async () => {
const { escapeInlineContent } = await import("../packages/vinext/src/shims/head.js");
const input = '</script></script></SCRIPT>';
const result = escapeInlineContent(input, "script");
expect(result).toBe('<\\/script><\\/script><\\/SCRIPT>');
expect(result).not.toContain("</script>");
expect(result).not.toContain("</SCRIPT>");
});
it("does not escape unrelated closing tags", async () => {
const { escapeInlineContent } = await import("../packages/vinext/src/shims/head.js");
// Escaping for "script" should not touch </style>
const input = '</style></div>';
const result = escapeInlineContent(input, "script");
expect(result).toBe('</style></div>');
});
it("passes through content with no closing tags", async () => {
const { escapeInlineContent } = await import("../packages/vinext/src/shims/head.js");
const input = "console.log('hello world');";
expect(escapeInlineContent(input, "script")).toBe(input);
});
});
describe("isValidModulePath", () => {
it("accepts valid absolute paths", () => {
expect(isValidModulePath("/src/pages/index.tsx")).toBe(true);
expect(isValidModulePath("/pages/about.js")).toBe(true);
expect(isValidModulePath("/src/pages/posts/[id].tsx")).toBe(true);
});
it("accepts valid relative paths starting with ./", () => {
expect(isValidModulePath("./src/pages/index.tsx")).toBe(true);
expect(isValidModulePath("./pages/about.js")).toBe(true);
});
it("rejects external https:// URLs", () => {
expect(isValidModulePath("https://evil.com/steal-cookies.js")).toBe(false);
});
it("rejects external http:// URLs", () => {
expect(isValidModulePath("http://evil.com/steal-cookies.js")).toBe(false);
});
it("rejects protocol-relative URLs (//)", () => {
expect(isValidModulePath("//evil.com/steal-cookies.js")).toBe(false);
expect(isValidModulePath("//cdn.example.com/script.js")).toBe(false);
});
it("rejects directory traversal", () => {
expect(isValidModulePath("/src/../../../etc/passwd")).toBe(false);
expect(isValidModulePath("./../../secret.js")).toBe(false);
expect(isValidModulePath("/pages/..%2F..%2Fsecret.js")).toBe(false);
});
it("rejects data: URLs", () => {
expect(isValidModulePath("data:text/javascript,alert(1)")).toBe(false);
});
it("rejects blob: URLs", () => {
expect(isValidModulePath("blob:http://localhost/abc")).toBe(false);
});
it("rejects bare specifiers", () => {
expect(isValidModulePath("evil-package")).toBe(false);
expect(isValidModulePath("@evil/package")).toBe(false);
});
it("rejects non-string values", () => {
expect(isValidModulePath(null)).toBe(false);
expect(isValidModulePath(undefined)).toBe(false);
expect(isValidModulePath(42)).toBe(false);
expect(isValidModulePath({})).toBe(false);
expect(isValidModulePath("")).toBe(false);
});
it("rejects javascript: protocol", () => {
expect(isValidModulePath("javascript:alert(1)")).toBe(false);
});
it("rejects ftp:// protocol", () => {
expect(isValidModulePath("ftp://evil.com/script.js")).toBe(false);
});
});
// ---------------------------------------------------------------------------
// Cache scope guards — headers()/cookies()/connection() must throw inside
// "use cache" and unstable_cache() scopes (matches Next.js behavior).
// Ported from Next.js: test/e2e/app-dir/use-cache/use-cache.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/use-cache/use-cache.test.ts
describe("cache scope guards for dynamic APIs", () => {
it('headers() throws inside "use cache" scope', async () => {
const { cacheContextStorage } = await import(
"../packages/vinext/src/shims/cache-runtime.js"
);
const { headers, setHeadersContext } = await import(
"../packages/vinext/src/shims/headers.js"
);
// Set up a valid headers context so the "no context" error doesn't fire
setHeadersContext({ headers: new Headers(), cookies: new Map() });
// Run inside a "use cache" ALS scope
await cacheContextStorage.run(
{ tags: [], lifeConfigs: [], variant: "default" },
async () => {
await expect(headers()).rejects.toThrow(
/cannot be called inside "use cache"/,
);
},
);
setHeadersContext(null);
});
it('cookies() throws inside "use cache" scope', async () => {
const { cacheContextStorage } = await import(
"../packages/vinext/src/shims/cache-runtime.js"
);
const { cookies, setHeadersContext } = await import(
"../packages/vinext/src/shims/headers.js"
);
setHeadersContext({ headers: new Headers(), cookies: new Map() });
await cacheContextStorage.run(
{ tags: [], lifeConfigs: [], variant: "default" },
async () => {
await expect(cookies()).rejects.toThrow(
/cannot be called inside "use cache"/,
);
},
);
setHeadersContext(null);
});
it('connection() throws inside "use cache" scope', async () => {
const { cacheContextStorage } = await import(
"../packages/vinext/src/shims/cache-runtime.js"
);
const { connection } = await import(
"../packages/vinext/src/shims/server.js"
);
await cacheContextStorage.run(
{ tags: [], lifeConfigs: [], variant: "default" },
async () => {
await expect(connection()).rejects.toThrow(
/cannot be called inside "use cache"/,
);
},
);
});
it("headers() throws inside unstable_cache() scope", async () => {
const { unstable_cache, setCacheHandler, MemoryCacheHandler } =
await import("../packages/vinext/src/shims/cache.js");
const { headers, setHeadersContext } = await import(
"../packages/vinext/src/shims/headers.js"
);
setCacheHandler(new MemoryCacheHandler());
setHeadersContext({ headers: new Headers(), cookies: new Map() });
const cached = unstable_cache(
async () => {
// This should throw because we're inside an unstable_cache scope
const h = await headers();
return h.get("x-test");
},
["test-headers-in-cache"],
);
await expect(cached()).rejects.toThrow(/unstable_cache/);
setHeadersContext(null);
setCacheHandler(new MemoryCacheHandler());
});
it("cookies() throws inside unstable_cache() scope", async () => {
const { unstable_cache, setCacheHandler, MemoryCacheHandler } =
await import("../packages/vinext/src/shims/cache.js");
const { cookies, setHeadersContext } = await import(
"../packages/vinext/src/shims/headers.js"
);
setCacheHandler(new MemoryCacheHandler());
setHeadersContext({
headers: new Headers(),
cookies: new Map([["session", "abc"]]),
});
const cached = unstable_cache(
async () => {
const c = await cookies();
return c.get("session");
},
["test-cookies-in-cache"],
);
await expect(cached()).rejects.toThrow(/unstable_cache/);
setHeadersContext(null);
setCacheHandler(new MemoryCacheHandler());
});
it("connection() throws inside unstable_cache() scope", async () => {
const { unstable_cache, setCacheHandler, MemoryCacheHandler } =
await import("../packages/vinext/src/shims/cache.js");
const { connection } = await import(
"../packages/vinext/src/shims/server.js"
);
setCacheHandler(new MemoryCacheHandler());
const cached = unstable_cache(
async () => {
await connection();
},
["test-connection-in-cache"],
);
await expect(cached()).rejects.toThrow(/unstable_cache/);
setCacheHandler(new MemoryCacheHandler());
});
it("headers() works normally outside cache scopes", async () => {
const { headers, setHeadersContext } = await import(
"../packages/vinext/src/shims/headers.js"
);
setHeadersContext({
headers: new Headers({ "x-test": "works" }),
cookies: new Map(),
});
// Should not throw outside any cache scope
const h = await headers();
expect(h.get("x-test")).toBe("works");
setHeadersContext(null);
});
it("cookies() works normally outside cache scopes", async () => {
const { cookies, setHeadersContext } = await import(
"../packages/vinext/src/shims/headers.js"
);
setHeadersContext({
headers: new Headers(),
cookies: new Map([["token", "abc"]]),
});
// Should not throw outside any cache scope
const c = await cookies();
expect(c.get("token")).toEqual({ name: "token", value: "abc" });
setHeadersContext(null);
});
it('draftMode() throws inside "use cache" scope', async () => {
const { cacheContextStorage } = await import(
"../packages/vinext/src/shims/cache-runtime.js"
);
const { draftMode, setHeadersContext } = await import(
"../packages/vinext/src/shims/headers.js"
);
setHeadersContext({ headers: new Headers(), cookies: new Map() });
await cacheContextStorage.run(
{ tags: [], lifeConfigs: [], variant: "default" },
async () => {
await expect(draftMode()).rejects.toThrow(
/cannot be called inside "use cache"/,
);
},
);
setHeadersContext(null);
});
it("draftMode() throws inside unstable_cache() scope", async () => {
const { unstable_cache, setCacheHandler, MemoryCacheHandler } =
await import("../packages/vinext/src/shims/cache.js");
const { draftMode, setHeadersContext } = await import(
"../packages/vinext/src/shims/headers.js"
);
setCacheHandler(new MemoryCacheHandler());
setHeadersContext({ headers: new Headers(), cookies: new Map() });
const cached = unstable_cache(
async () => {
await draftMode();
},
["test-draftmode-in-cache"],
);
await expect(cached()).rejects.toThrow(/unstable_cache/);
setHeadersContext(null);
setCacheHandler(new MemoryCacheHandler());
});
it("headers() throws inside nested scopes (unstable_cache inside \"use cache\")", async () => {
const { cacheContextStorage } = await import(
"../packages/vinext/src/shims/cache-runtime.js"
);
const { unstable_cache, setCacheHandler, MemoryCacheHandler } =
await import("../packages/vinext/src/shims/cache.js");
const { headers, setHeadersContext } = await import(
"../packages/vinext/src/shims/headers.js"
);
setCacheHandler(new MemoryCacheHandler());
setHeadersContext({ headers: new Headers(), cookies: new Map() });
// Nest unstable_cache inside a "use cache" scope: the outermost
// scope ("use cache") should be detected first.
await cacheContextStorage.run(
{ tags: [], lifeConfigs: [], variant: "default" },
async () => {
const cached = unstable_cache(
async () => {
const h = await headers();
return h.get("x-test");
},
["test-nested-scopes"],
);
// Either scope's guard triggers (the "use cache" check runs first)
await expect(cached()).rejects.toThrow(
/cannot be called inside/,
);
},
);
setHeadersContext(null);
setCacheHandler(new MemoryCacheHandler());
});
it("existing unstable_cache tests still pass (cache miss executes callback)", async () => {
// Verify that the ALS wrapping in unstable_cache doesn't break normal caching
const { unstable_cache, setCacheHandler, MemoryCacheHandler } =
await import("../packages/vinext/src/shims/cache.js");
setCacheHandler(new MemoryCacheHandler());
let callCount = 0;
const cached = unstable_cache(
async (x: number) => {
callCount++;
return x * 2;
},
["regression-test"],
);
const r1 = await cached(5);
expect(r1).toBe(10);
expect(callCount).toBe(1);
const r2 = await cached(5);
expect(r2).toBe(10);
expect(callCount).toBe(1); // Cached, not called again
setCacheHandler(new MemoryCacheHandler());
});
});