Files
cloudflare__vinext/tests/next-config.test.ts
T
James Anderson ebf8e59289 fix(app-router): avoid runtime route-tree prefetch sizing (#2467)
* fix(app-router): start optimistic prefetches immediately

* fix(app-router): support route tree prefetch inlining

* fix(app-router): dedupe max prefetch inlining requests

* fix(app-router): cap route-tree prefetch size reads

* fix(app-router): avoid runtime route-tree prefetch sizing

* Fixed CI type error, flagged parity gap

Co-authored-by: james-elicx <james-elicx@users.noreply.github.com>

* fix(app-router): preserve negative prefetch inlining thresholds

* fix(app-router): normalize rsc reference validation ids

* Revert "fix(app-router): normalize rsc reference validation ids"

This reverts commit 68b96b596c.

* fix(app-router): keep route-tree prefetch metadata scoped

---------

Co-authored-by: ask-bonk[bot] <ask-bonk[bot]@users.noreply.github.com>
Co-authored-by: james-elicx <james-elicx@users.noreply.github.com>
2026-07-01 17:47:08 +01:00

2875 lines
100 KiB
TypeScript

import { describe, it, expect, afterEach, vi, beforeEach } from "vite-plus/test";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import {
detectNextIntlConfig,
lightningCssFeatureNamesToMask,
loadNextConfig,
parseBodySizeLimit,
reassignsModuleExports,
referencesCjsGlobals,
resolveNextConfig,
type ResolvedNextConfig,
} from "../packages/vinext/src/config/next-config.js";
import {
PHASE_PRODUCTION_BUILD,
PHASE_DEVELOPMENT_SERVER,
} from "../packages/vinext/src/shims/constants.js";
function makeTempDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), "vinext-config-test-"));
}
describe("invalid config files", () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = makeTempDir();
});
afterEach(() => {
vi.restoreAllMocks();
if (tmpDir) {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it("should throw an error when loading a config fails", async () => {
fs.writeFileSync(path.join(tmpDir, "package.json"), `{ "type": "module" }`);
fs.writeFileSync(
path.join(tmpDir, "next.config.js"),
// Syntactically invalid in any module system.
`module.exports = { invalid: } ;\n`,
);
await expect(loadNextConfig(tmpDir, PHASE_PRODUCTION_BUILD)).rejects.toThrow();
});
});
describe("deprecated config warnings", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("does not warn when no config file exists", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
await resolveNextConfig(null);
expect(warn).not.toHaveBeenCalled();
});
it("matches Next.js warnings for explicitly configured deprecated options", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
await resolveNextConfig({
skipMiddlewareUrlNormalize: true,
experimental: {
middlewarePrefetch: "strict",
instrumentationHook: true,
middlewareClientMaxBodySize: "5mb",
externalMiddlewareRewritesResolve: true,
},
});
expect(warn.mock.calls.map(([message]) => message)).toEqual([
"`experimental.middlewarePrefetch` is deprecated. Please use `experimental.proxyPrefetch` instead in next.config.js.",
"`experimental.middlewareClientMaxBodySize` is deprecated. Please use `experimental.proxyClientMaxBodySize` instead in next.config.js.",
"`experimental.externalMiddlewareRewritesResolve` is deprecated. Please use `experimental.externalProxyRewritesResolve` instead in next.config.js.",
"`skipMiddlewareUrlNormalize` is deprecated. Please use `skipProxyUrlNormalize` instead in next.config.js.",
"`experimental.instrumentationHook` is no longer needed, because `instrumentation.js` is available by default. You can remove it from next.config.js.",
]);
});
it("warns once across repeated config resolution", async () => {
const root = makeTempDir();
fs.writeFileSync(path.join(root, "next.config.mjs"), "export default {}\n");
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
await resolveNextConfig(
{
skipMiddlewareUrlNormalize: false,
experimental: { instrumentationHook: false },
},
root,
);
await resolveNextConfig(
{
skipMiddlewareUrlNormalize: false,
experimental: { instrumentationHook: false },
},
root,
);
expect(warn.mock.calls.map(([message]) => message)).toEqual([
"`skipMiddlewareUrlNormalize` is deprecated. Please use `skipProxyUrlNormalize` instead in next.config.mjs.",
"`experimental.instrumentationHook` is no longer needed, because `instrumentation.js` is available by default. You can remove it from next.config.mjs.",
]);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
});
describe("loadNextConfig with CJS next.config.js under type:module", () => {
// Real-world shape from the Next.js deploy suite: `vinext init` flips
// package.json to `"type": "module"`, but the test fixture's
// `next.config.js` is still written in CJS (module.exports + require).
// vinext must load it as CJS instead of forcing the project to rewrite the
// file to ESM.
let tmpDir: string;
beforeEach(() => {
tmpDir = makeTempDir();
fs.writeFileSync(path.join(tmpDir, "package.json"), `{ "type": "module" }`);
});
afterEach(() => {
vi.restoreAllMocks();
if (tmpDir) {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it("loads module.exports + require() from a .js file", async () => {
fs.writeFileSync(
path.join(tmpDir, "next.config.js"),
`const path = require('node:path');\n` +
// posix.join keeps the result "/docs" on Windows too — the point of
// this fixture is exercising require(), not platform join behavior.
`module.exports = { basePath: path.posix.join('/', 'docs') };\n`,
);
const config = await loadNextConfig(tmpDir);
expect(config?.basePath).toBe("/docs");
});
it("supports __dirname and __filename in a CJS .js config", async () => {
fs.writeFileSync(
path.join(tmpDir, "next.config.js"),
`module.exports = { env: { DIRNAME_SET: String(typeof __dirname === 'string'), FILENAME_SET: String(typeof __filename === 'string') } };\n`,
);
const config = await loadNextConfig(tmpDir);
expect(config?.env?.DIRNAME_SET).toBe("true");
expect(config?.env?.FILENAME_SET).toBe("true");
});
it("supports require(mod)(args) plugin-wrapper pattern", async () => {
// Mirrors @next/bundle-analyzer / nextra plugin shape — the value
// returned from require() is called with options and re-exported.
fs.writeFileSync(
path.join(tmpDir, "wrap.cjs"),
`module.exports = (opts) => (config) => ({ ...config, env: { ...(config.env || {}), WRAPPED: opts.tag } });\n`,
);
fs.writeFileSync(
path.join(tmpDir, "next.config.js"),
`const withWrap = require('./wrap.cjs')({ tag: 'yes' });\n` +
`module.exports = withWrap({ basePath: '/app' });\n`,
);
const config = await loadNextConfig(tmpDir);
expect(config?.basePath).toBe("/app");
expect(config?.env?.WRAPPED).toBe("yes");
});
it("loads nested CommonJS .js dependencies", async () => {
fs.writeFileSync(
path.join(tmpDir, "config-helper.js"),
`const values = require("./config-values.js");
module.exports = { basePath: values.basePath };
`,
);
fs.writeFileSync(
path.join(tmpDir, "config-values.js"),
`module.exports = { basePath: "/nested" };
`,
);
fs.writeFileSync(
path.join(tmpDir, "next.config.js"),
`module.exports = require("./config-helper.js");
`,
);
const config = await loadNextConfig(tmpDir);
expect(config?.basePath).toBe("/nested");
});
it("loads nested CommonJS .js dependencies from read-only symlink targets", async () => {
const packageDir = path.join(tmpDir, "packages", "config-wrapper");
const packageLink = path.join(tmpDir, "node_modules", "config-wrapper");
fs.mkdirSync(packageDir, { recursive: true });
fs.mkdirSync(path.dirname(packageLink), { recursive: true });
fs.writeFileSync(
path.join(packageDir, "package.json"),
JSON.stringify({ name: "config-wrapper", main: "index.js", type: "module" }),
);
fs.writeFileSync(path.join(packageDir, "value.js"), `module.exports = "/linked";\n`);
fs.writeFileSync(
path.join(packageDir, "index.js"),
`module.exports = { basePath: require("./value.js") };\n`,
);
fs.symlinkSync(packageDir, packageLink, "junction");
fs.writeFileSync(
path.join(tmpDir, "next.config.js"),
`module.exports = require("config-wrapper");\n`,
);
fs.chmodSync(packageDir, 0o555);
try {
const config = await loadNextConfig(tmpDir);
expect(config?.basePath).toBe("/linked");
expect(fs.readdirSync(packageDir).some((name) => name.startsWith(".vinext-"))).toBe(false);
} finally {
fs.chmodSync(packageDir, 0o755);
}
});
it("does not write temporary modules beside the config", async () => {
fs.writeFileSync(path.join(tmpDir, "next.config.js"), `module.exports = { basePath: '/x' };\n`);
fs.chmodSync(tmpDir, 0o555);
try {
const config = await loadNextConfig(tmpDir);
expect(config?.basePath).toBe("/x");
} finally {
fs.chmodSync(tmpDir, 0o755);
}
const stray = fs
.readdirSync(tmpDir)
.filter((name) => name.startsWith(".vinext-") && name.endsWith(".cjs"));
expect(stray).toEqual([]);
});
});
describe("loadNextConfig phase argument", () => {
let tmpDir: string;
afterEach(() => {
vi.restoreAllMocks();
if (tmpDir) {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it("passes phase-production-build to function-form config when phase is specified", async () => {
tmpDir = makeTempDir();
fs.writeFileSync(
path.join(tmpDir, "next.config.mjs"),
`export default (phase) => ({ env: { RECEIVED_PHASE: phase } });\n`,
);
const config = await loadNextConfig(tmpDir, PHASE_PRODUCTION_BUILD);
expect(config?.env?.RECEIVED_PHASE).toBe(PHASE_PRODUCTION_BUILD);
});
it("defaults to phase-development-server when no phase is provided", async () => {
tmpDir = makeTempDir();
fs.writeFileSync(
path.join(tmpDir, "next.config.mjs"),
`export default (phase) => ({ env: { RECEIVED_PHASE: phase } });\n`,
);
const config = await loadNextConfig(tmpDir);
expect(config?.env?.RECEIVED_PHASE).toBe(PHASE_DEVELOPMENT_SERVER);
});
it("ignores phase for object-form config", async () => {
tmpDir = makeTempDir();
fs.writeFileSync(
path.join(tmpDir, "next.config.mjs"),
`export default { env: { STATIC: "yes" } };\n`,
);
const config = await loadNextConfig(tmpDir, PHASE_PRODUCTION_BUILD);
expect(config?.env?.STATIC).toBe("yes");
});
});
describe("loadNextConfig with CJS globals in next.config.ts", () => {
// Ported from Next.js: test/e2e/app-dir/next-config-ts/node-api-cjs/
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/next-config-ts/node-api-cjs/next.config.ts
// and test/e2e/app-dir/next-config-ts/import-js-extensions-cjs/.
// Next.js's transpile-config.ts transforms next.config.ts to CommonJS via SWC
// and evaluates it through Node's `Module._compile`, which exposes the CJS
// globals (`__filename`, `__dirname`, `module`, `require`, `exports`) even
// when the source uses ESM syntax. vinext mirrors that behaviour so that
// upstream fixtures referencing these globals continue to load.
let tmpDir: string;
afterEach(() => {
if (tmpDir) {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it("exposes __dirname inside next.config.ts", async () => {
tmpDir = makeTempDir();
fs.writeFileSync(path.join(tmpDir, "foo.txt"), "foo");
fs.writeFileSync(
path.join(tmpDir, "next.config.ts"),
`import fs from "node:fs";\nimport path from "node:path";\nconst foo = fs.readFileSync(path.join(__dirname, "foo.txt"), "utf8");\nexport default { env: { FOO: foo } };\n`,
);
const config = await loadNextConfig(tmpDir);
expect(config?.env?.FOO).toBe("foo");
});
it("exposes __filename inside next.config.ts", async () => {
tmpDir = makeTempDir();
fs.writeFileSync(
path.join(tmpDir, "next.config.ts"),
`export default { env: { NAME: __filename } };\n`,
);
const config = await loadNextConfig(tmpDir);
const name = config?.env?.NAME;
expect(typeof name).toBe("string");
expect((name as string).endsWith("next.config.ts")).toBe(true);
});
it("exposes a working require() inside next.config.ts", async () => {
tmpDir = makeTempDir();
fs.writeFileSync(path.join(tmpDir, "data.json"), `{"value":"json-data"}`);
fs.writeFileSync(
path.join(tmpDir, "next.config.ts"),
`const data = require("./data.json");\nexport default { env: { VAL: data.value } };\n`,
);
const config = await loadNextConfig(tmpDir);
expect(config?.env?.VAL).toBe("json-data");
});
it("exposes a CommonJS module/exports object inside next.config.ts", async () => {
tmpDir = makeTempDir();
fs.writeFileSync(
path.join(tmpDir, "next.config.ts"),
`module.exports = { env: { VIA: "module.exports" } };\n`,
);
const config = await loadNextConfig(tmpDir);
expect(config?.env?.VIA).toBe("module.exports");
});
it("does not inject __dirname when user already declares it", async () => {
// Regression test for https://github.com/cloudflare/vinext/issues/1345.
tmpDir = makeTempDir();
fs.writeFileSync(
path.join(tmpDir, "next.config.ts"),
`import { dirname } from "node:path";\n` +
`import { fileURLToPath } from "node:url";\n` +
`const __dirname = dirname(fileURLToPath(import.meta.url));\n` +
`export default { env: { DIR: __dirname } };\n`,
);
const config = await loadNextConfig(tmpDir);
const dir = config?.env?.DIR;
expect(typeof dir).toBe("string");
expect(fs.realpathSync(dir as string)).toBe(fs.realpathSync(tmpDir));
});
it("does not inject __filename when user already declares it", async () => {
tmpDir = makeTempDir();
fs.writeFileSync(
path.join(tmpDir, "next.config.ts"),
`import { fileURLToPath } from "node:url";\n` +
`const __filename = fileURLToPath(import.meta.url);\n` +
`export default { env: { FILE: __filename } };\n`,
);
const config = await loadNextConfig(tmpDir);
const file = config?.env?.FILE;
expect(typeof file).toBe("string");
expect(fs.realpathSync(file as string)).toBe(
fs.realpathSync(path.join(tmpDir, "next.config.ts")),
);
});
it("does not inject require when user already declares it", async () => {
// The createRequire polyfill commonly appears alongside the __dirname one
// and hits the same duplicate-`const` Rolldown crash if injected blindly.
tmpDir = makeTempDir();
fs.writeFileSync(
path.join(tmpDir, "next.config.ts"),
`import { createRequire } from "node:module";\n` +
`const require = createRequire(import.meta.url);\n` +
`export default { env: { HAS_REQUIRE: typeof require } };\n`,
);
const config = await loadNextConfig(tmpDir);
expect(config?.env?.HAS_REQUIRE).toBe("function");
});
it("loads a pure-ESM next.config.ts without injecting CJS shims", async () => {
// No __filename / __dirname / require / module / exports references —
// the injector transform should short-circuit. We only assert
// functional behaviour: the export const that the transform would add
// (__vinext_cjs_exports) is invisible to user code anyway, so the
// observable contract is just "ESM config loads correctly".
tmpDir = makeTempDir();
fs.writeFileSync(
path.join(tmpDir, "next.config.ts"),
`export default { env: { PURE: "esm" } };\n`,
);
const config = await loadNextConfig(tmpDir);
expect(config?.env?.PURE).toBe("esm");
});
});
describe("referencesCjsGlobals", () => {
it("returns false for pure-ESM source", () => {
expect(referencesCjsGlobals(`export default { env: { FOO: "bar" } };\n`)).toBe(false);
expect(
referencesCjsGlobals(
`import type { NextConfig } from "next";\nconst nextConfig: NextConfig = {};\nexport default nextConfig;\n`,
),
).toBe(false);
expect(referencesCjsGlobals(`export { nextConfig as default };\n`)).toBe(false);
});
it("returns true when any CJS global is referenced", () => {
expect(referencesCjsGlobals(`const x = __filename;`)).toBe(true);
expect(referencesCjsGlobals(`const x = __dirname;`)).toBe(true);
expect(referencesCjsGlobals(`const x = require("./foo");`)).toBe(true);
expect(referencesCjsGlobals(`module.exports = { a: 1 };`)).toBe(true);
expect(referencesCjsGlobals(`exports.foo = 1;`)).toBe(true);
});
it("does not match identifiers that merely contain a global as a substring", () => {
expect(referencesCjsGlobals(`const requireSomething = 1;`)).toBe(false);
expect(referencesCjsGlobals(`const myModule = 1;`)).toBe(false);
expect(referencesCjsGlobals(`const exporter = 1;`)).toBe(false);
// `export default` is a different word boundary from `exports`.
expect(referencesCjsGlobals(`export default {};`)).toBe(false);
});
it("matches inside strings and comments (acceptable false positive)", () => {
// Substring match is intentionally loose: a wasted transform is the
// worst case, never a correctness bug.
expect(referencesCjsGlobals(`// __dirname is shimmed`)).toBe(true);
expect(referencesCjsGlobals(`const s = "module.exports = 1";`)).toBe(true);
});
});
describe("reassignsModuleExports", () => {
it("returns true for direct module.exports reassignment", () => {
expect(reassignsModuleExports(`module.exports = { foo: 1 };`)).toBe(true);
expect(reassignsModuleExports(`module . exports = X;`)).toBe(true);
});
it("returns true for property mutation", () => {
expect(reassignsModuleExports(`module.exports.foo = 1;`)).toBe(true);
expect(reassignsModuleExports(`module.exports["foo"] = 1;`)).toBe(true);
expect(reassignsModuleExports(`module.exports[name] = 1;`)).toBe(true);
});
it("returns false for pure-ESM source", () => {
expect(reassignsModuleExports(`export default { foo: 1 };`)).toBe(false);
expect(reassignsModuleExports(`const x = module;`)).toBe(false);
expect(reassignsModuleExports(`import x from "node:module";`)).toBe(false);
});
it("does not match comparisons or reads", () => {
expect(reassignsModuleExports(`if (module.exports === foo) {}`)).toBe(false);
expect(reassignsModuleExports(`const x = module.exports;`)).toBe(false);
expect(reassignsModuleExports(`const x = module.exports.foo;`)).toBe(false);
});
});
describe("loadNextConfig CJS vs ESM unwrap", () => {
// Exercises the static reassignsModuleExports detection end-to-end:
// pure-ESM configs go through the ESM `default` path, configs that
// reassign module.exports get unwrapped from the injected wrapper.
let tmpDir: string;
afterEach(() => {
if (tmpDir) {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it("returns ESM default for a pure-ESM config", async () => {
tmpDir = makeTempDir();
fs.writeFileSync(
path.join(tmpDir, "next.config.ts"),
`export default { env: { SHAPE: "esm-default" } };\n`,
);
const config = await loadNextConfig(tmpDir);
expect(config?.env?.SHAPE).toBe("esm-default");
});
it("returns module.exports = X for a config that reassigns module.exports", async () => {
tmpDir = makeTempDir();
fs.writeFileSync(
path.join(tmpDir, "next.config.ts"),
`module.exports = { env: { SHAPE: "reassigned" } };\n`,
);
const config = await loadNextConfig(tmpDir);
expect(config?.env?.SHAPE).toBe("reassigned");
});
it("accumulates module.exports.foo = ... assignments", async () => {
tmpDir = makeTempDir();
fs.writeFileSync(
path.join(tmpDir, "next.config.ts"),
`module.exports.env = { SHAPE: "mutated" };\nmodule.exports.basePath = "/m";\n`,
);
const config = await loadNextConfig(tmpDir);
expect(config?.env?.SHAPE).toBe("mutated");
expect(config?.basePath).toBe("/m");
});
it("falls back to ESM default when module.exports reference is only a false positive", async () => {
// Ports the heuristic-false-positive case: the substring matcher
// could see `module.exports = ` inside a string and decide to emit
// the wrapper. The unwrap path checks identity against the initial
// empty exports object and falls back to the ESM default.
tmpDir = makeTempDir();
fs.writeFileSync(
path.join(tmpDir, "next.config.ts"),
`const doc = "module.exports = legacy";\nexport default { env: { SHAPE: "fallback", DOC: doc } };\n`,
);
const config = await loadNextConfig(tmpDir);
expect(config?.env?.SHAPE).toBe("fallback");
expect(config?.env?.DOC).toBe("module.exports = legacy");
});
});
describe("loadNextConfig with tsconfig path aliases", () => {
// Ported from Next.js: test/e2e/app-dir/next-config-ts/import-alias-paths-only/
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/next-config-ts/import-alias-paths-only/
// and import-alias-paths-with-baseurl/.
// Next.js's transpile-config.ts reads compilerOptions.paths/baseUrl from
// tsconfig.json and passes them to SWC so that next.config.ts can import via
// tsconfig aliases and baseUrl bare specifiers. vinext mirrors this with
// Vite resolver settings when calling runnerImport.
let tmpDir: string;
function writePackage(
name: string,
packageJson: Record<string, string>,
files: Record<string, string>,
): void {
const packageDir = path.join(tmpDir, "node_modules", name);
fs.mkdirSync(packageDir, { recursive: true });
fs.writeFileSync(
path.join(packageDir, "package.json"),
JSON.stringify({ name, version: "1.0.0", ...packageJson }),
);
for (const [filename, source] of Object.entries(files)) {
fs.writeFileSync(path.join(packageDir, filename), source);
}
}
function writeBarePackage(name: string, source: string): void {
writePackage(
name,
{ type: "module", exports: "./index.js" },
{
"index.js": source,
},
);
}
afterEach(() => {
if (tmpDir) {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it("resolves '@/*' imports in next.config.ts from tsconfig paths (no baseUrl)", async () => {
tmpDir = makeTempDir();
fs.mkdirSync(path.join(tmpDir, "src"), { recursive: true });
fs.writeFileSync(path.join(tmpDir, "src", "foo.ts"), `export const foo = "foo";\n`);
fs.writeFileSync(
path.join(tmpDir, "tsconfig.json"),
JSON.stringify({
compilerOptions: {
paths: {
"@/*": ["./src/*"],
},
},
}),
);
fs.writeFileSync(
path.join(tmpDir, "next.config.ts"),
`import { foo } from "@/foo";\nexport default { env: { FOO: foo } };\n`,
);
const config = await loadNextConfig(tmpDir);
expect(config?.env?.FOO).toBe("foo");
});
it("resolves '@/*' imports when baseUrl is set", async () => {
tmpDir = makeTempDir();
fs.mkdirSync(path.join(tmpDir, "src"), { recursive: true });
fs.writeFileSync(path.join(tmpDir, "src", "bar.ts"), `export const bar = "bar";\n`);
fs.writeFileSync(
path.join(tmpDir, "tsconfig.json"),
JSON.stringify({
compilerOptions: {
baseUrl: ".",
paths: {
"@/*": ["src/*"],
},
},
}),
);
fs.writeFileSync(
path.join(tmpDir, "next.config.ts"),
`import { bar } from "@/bar";\nexport default { env: { BAR: bar } };\n`,
);
const config = await loadNextConfig(tmpDir);
expect(config?.env?.BAR).toBe("bar");
});
it("follows tsconfig 'extends' when resolving paths", async () => {
tmpDir = makeTempDir();
fs.mkdirSync(path.join(tmpDir, "src"), { recursive: true });
fs.writeFileSync(path.join(tmpDir, "src", "baz.ts"), `export const baz = "baz";\n`);
fs.writeFileSync(
path.join(tmpDir, "tsconfig.base.json"),
JSON.stringify({
compilerOptions: {
paths: {
"@/*": ["./src/*"],
},
},
}),
);
fs.writeFileSync(
path.join(tmpDir, "tsconfig.json"),
JSON.stringify({
extends: "./tsconfig.base.json",
}),
);
fs.writeFileSync(
path.join(tmpDir, "next.config.ts"),
`import { baz } from "@/baz";\nexport default { env: { BAZ: baz } };\n`,
);
const config = await loadNextConfig(tmpDir);
expect(config?.env?.BAZ).toBe("baz");
});
it("follows extended tsconfig baseUrl when resolving bare imports", async () => {
// Ported from Next.js: test/e2e/app-dir/next-config-ts/tsconfig-extends/
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/next-config-ts/tsconfig-extends/next-config-ts-tsconfig-extends-cjs.test.ts
tmpDir = makeTempDir();
fs.mkdirSync(path.join(tmpDir, "src"), { recursive: true });
fs.writeFileSync(path.join(tmpDir, "src", "foo.ts"), `export const foo = "foo";\n`);
fs.writeFileSync(path.join(tmpDir, "bar.ts"), `export const bar = "bar";\n`);
fs.writeFileSync(
path.join(tmpDir, "package.json"),
JSON.stringify({
type: "module",
}),
);
fs.writeFileSync(
path.join(tmpDir, "tsconfig.base.json"),
JSON.stringify({
compilerOptions: {
baseUrl: ".",
paths: {
"@/*": ["./src/*"],
},
},
}),
);
fs.writeFileSync(
path.join(tmpDir, "tsconfig.json"),
JSON.stringify({
extends: "./tsconfig.base.json",
}),
);
fs.writeFileSync(
path.join(tmpDir, "next.config.ts"),
`import { foo } from "@/foo";\n` +
`import { bar } from "bar";\n` +
`export default { env: { VALUE: foo + bar } };\n`,
);
const config = await loadNextConfig(tmpDir);
expect(config?.env?.VALUE).toBe("foobar");
});
it("prefers an installed package over a baseUrl-local file of the same name", async () => {
// When a bare import matches both an installed package and a baseUrl-local
// file, the installed package wins. vinext keeps installed packages
// externalized so that CJS config plugins (e.g. @next/mdx) that call
// `require`/`require.resolve` at runtime keep working; the trade-off is
// that a baseUrl-local file does not shadow a package of the same name.
// (Pure TypeScript baseUrl semantics would prefer the local file, but that
// requires de-externalizing every package, which breaks CJS plugins.)
tmpDir = makeTempDir();
fs.writeFileSync(path.join(tmpDir, "bar.ts"), `export const bar = "local";\n`);
writeBarePackage("bar", `export const bar = "package";\n`);
fs.writeFileSync(
path.join(tmpDir, "tsconfig.json"),
JSON.stringify({
compilerOptions: {
baseUrl: ".",
},
}),
);
fs.writeFileSync(
path.join(tmpDir, "next.config.ts"),
`import { bar } from "bar";\nexport default { env: { BAR: bar } };\n`,
);
const config = await loadNextConfig(tmpDir);
expect(config?.env?.BAR).toBe("package");
});
it("loads a CJS config plugin that calls require.resolve at runtime", async () => {
// Regression for @next/mdx-style plugins: next.config.ts imports a CJS
// package whose factory calls `require.resolve(...)` when invoked. Keeping
// installed packages externalized (rather than forcing them through the
// module runner) ensures `require` is defined. Reproduces the
// app-router-playground deploy failure: "require is not defined".
tmpDir = makeTempDir();
fs.writeFileSync(path.join(tmpDir, "package.json"), JSON.stringify({ type: "module" }));
writePackage(
"fake-mdx",
{ type: "commonjs", main: "index.js" },
{
"index.js":
`module.exports = (opts = {}) => (config = {}) => ({\n` +
` ...config,\n` +
` loaderPath: require.resolve("./loader.js"),\n` +
`});\n`,
"loader.js": `module.exports = "loader";\n`,
},
);
fs.writeFileSync(
path.join(tmpDir, "tsconfig.json"),
JSON.stringify({ compilerOptions: { baseUrl: "." } }),
);
fs.writeFileSync(
path.join(tmpDir, "next.config.ts"),
`import withMDX from "fake-mdx";\n` + `export default withMDX({})({ env: { OK: "yes" } });\n`,
);
const config = await loadNextConfig(tmpDir);
expect(config?.env?.OK).toBe("yes");
});
it("falls through to packages when baseUrl has no local match", async () => {
tmpDir = makeTempDir();
writeBarePackage("bar", `export const bar = "package";\n`);
fs.writeFileSync(
path.join(tmpDir, "tsconfig.json"),
JSON.stringify({
compilerOptions: {
baseUrl: ".",
},
}),
);
fs.writeFileSync(
path.join(tmpDir, "next.config.ts"),
`import { bar } from "bar";\nexport default { env: { BAR: bar } };\n`,
);
const config = await loadNextConfig(tmpDir);
expect(config?.env?.BAR).toBe("package");
});
it("does not apply tsconfig baseUrl package shadowing to next.config.mjs", async () => {
tmpDir = makeTempDir();
fs.writeFileSync(path.join(tmpDir, "bar.ts"), `export const bar = "local";\n`);
writeBarePackage("bar", `export const bar = "package";\n`);
fs.writeFileSync(
path.join(tmpDir, "tsconfig.json"),
JSON.stringify({
compilerOptions: {
baseUrl: ".",
},
}),
);
fs.writeFileSync(
path.join(tmpDir, "next.config.mjs"),
`import { bar } from "bar";\nexport default { env: { BAR: bar } };\n`,
);
const config = await loadNextConfig(tmpDir);
expect(config?.env?.BAR).toBe("package");
});
it("does not apply the app baseUrl to package dependency imports", async () => {
tmpDir = makeTempDir();
fs.writeFileSync(path.join(tmpDir, "shared.ts"), `export const shared = "app";\n`);
writeBarePackage(
"fake-plugin",
`import { shared } from "shared";\nexport const pluginValue = shared;\n`,
);
writeBarePackage("shared", `export const shared = "package";\n`);
fs.writeFileSync(
path.join(tmpDir, "tsconfig.json"),
JSON.stringify({
compilerOptions: {
baseUrl: ".",
},
}),
);
fs.writeFileSync(
path.join(tmpDir, "next.config.ts"),
`import { pluginValue } from "fake-plugin";\n` +
`export default { env: { VALUE: pluginValue } };\n`,
);
const config = await loadNextConfig(tmpDir);
expect(config?.env?.VALUE).toBe("package");
});
it("imports ESM and CommonJS packages from next.config.ts", async () => {
// Ported from Next.js: test/e2e/app-dir/next-config-ts/import-from-node-modules/
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/next-config-ts/import-from-node-modules/next.config.ts
tmpDir = makeTempDir();
writePackage(
"cjs",
{ type: "commonjs", main: "index.cjs" },
{
"index.cjs": `module.exports = "cjs";\n`,
},
);
writePackage(
"mjs",
{ type: "commonjs", main: "index.mjs" },
{
"index.mjs": `export default "mjs";\n`,
},
);
writePackage(
"js-cjs",
{ type: "commonjs", main: "index.js" },
{
"index.js": `module.exports = "jsCJS";\n`,
},
);
writePackage(
"js-esm",
{ type: "module", main: "index.js" },
{
"index.js": `export default "jsESM";\n`,
},
);
fs.writeFileSync(
path.join(tmpDir, "next.config.ts"),
`import cjs from "cjs";\n` +
`import mjs from "mjs";\n` +
`import jsCJS from "js-cjs";\n` +
`import jsESM from "js-esm";\n` +
`export default { env: { cjs, mjs, jsCJS, jsESM } };\n`,
);
const config = await loadNextConfig(tmpDir);
expect(config?.env).toMatchObject({
cjs: "cjs",
mjs: "mjs",
jsCJS: "jsCJS",
jsESM: "jsESM",
});
});
it("loads config without tsconfig.json (no aliases needed)", async () => {
tmpDir = makeTempDir();
fs.writeFileSync(
path.join(tmpDir, "next.config.ts"),
`export default { env: { PLAIN: "yes" } };\n`,
);
const config = await loadNextConfig(tmpDir);
expect(config?.env?.PLAIN).toBe("yes");
});
});
describe("resolveNextConfig image patterns", () => {
it("normalizes URL remote patterns for runtime serialization", async () => {
const config = await resolveNextConfig(
{
images: {
remotePatterns: [new URL("https://image-optimization-test.vercel.app/**")],
},
},
"/tmp/project",
);
expect(config.images?.remotePatterns).toEqual([
{
protocol: "https",
hostname: "image-optimization-test.vercel.app",
port: "",
pathname: "/**",
search: "",
},
]);
expect(JSON.parse(JSON.stringify(config.images?.remotePatterns))).toEqual(
config.images?.remotePatterns,
);
});
});
describe("resolveNextConfig alias extraction", () => {
it("prefers turbopack resolveExtensions and falls back to webpack extensions", async () => {
const fallback = await resolveNextConfig({
webpack(webpackConfig: any) {
webpackConfig.resolve.extensions = ["", ".png", ".jsx", ".js"];
return webpackConfig;
},
});
expect(fallback.resolveExtensions).toEqual(["", ".png", ".jsx", ".js"]);
expect(fallback.serverResolveExtensions).toEqual(["", ".png", ".jsx", ".js"]);
const preferred = await resolveNextConfig({
turbopack: { resolveExtensions: ["", ".web.tsx", ".tsx"] },
webpack(webpackConfig: any) {
webpackConfig.resolve.extensions = ["", ".png", ".js"];
return webpackConfig;
},
});
expect(preferred.resolveExtensions).toEqual(["", ".web.tsx", ".tsx"]);
expect(preferred.serverResolveExtensions).toEqual(["", ".web.tsx", ".tsx"]);
const explicitlyEmpty = await resolveNextConfig({
turbopack: { resolveExtensions: [] },
webpack(webpackConfig: any) {
webpackConfig.resolve.extensions = [".png"];
return webpackConfig;
},
});
expect(explicitlyEmpty.resolveExtensions).toEqual([]);
expect(explicitlyEmpty.serverResolveExtensions).toEqual([]);
});
it("supports legacy experimental.turbo resolveExtensions", async () => {
const legacy = await resolveNextConfig({
experimental: {
turbo: { resolveExtensions: ["", ".legacy.ts", ".ts"] },
},
});
expect(legacy.resolveExtensions).toEqual(["", ".legacy.ts", ".ts"]);
expect(legacy.serverResolveExtensions).toEqual(["", ".legacy.ts", ".ts"]);
const preferred = await resolveNextConfig({
experimental: {
turbo: { resolveExtensions: ["", ".legacy.ts", ".ts"] },
},
turbopack: { resolveExtensions: ["", ".modern.ts", ".ts"] },
});
expect(preferred.resolveExtensions).toEqual(["", ".modern.ts", ".ts"]);
expect(preferred.serverResolveExtensions).toEqual(["", ".modern.ts", ".ts"]);
});
it("provides Next.js webpack defaults to resolve.extensions callbacks", async () => {
const resolved = await resolveNextConfig({
webpack(webpackConfig: any) {
webpackConfig.resolve.extensions = [".web.tsx", ...webpackConfig.resolve.extensions];
return webpackConfig;
},
});
expect(resolved.resolveExtensions).toEqual([
".web.tsx",
".js",
".mjs",
".tsx",
".ts",
".jsx",
".json",
".wasm",
]);
});
it("ignores untouched webpack resolve.extensions defaults", async () => {
const untouched = await resolveNextConfig({
webpack(webpackConfig: any) {
return webpackConfig;
},
});
expect(untouched.resolveExtensions).toBeNull();
const copied = await resolveNextConfig({
webpack(webpackConfig: any) {
webpackConfig.resolve.extensions = [...webpackConfig.resolve.extensions];
return webpackConfig;
},
});
expect(copied.resolveExtensions).toBeNull();
});
it("captures in-place webpack resolve.extensions mutations", async () => {
const resolved = await resolveNextConfig({
webpack(webpackConfig: any) {
webpackConfig.resolve.extensions.unshift(".web.tsx");
return webpackConfig;
},
});
expect(resolved.resolveExtensions).toEqual([
".web.tsx",
".js",
".mjs",
".tsx",
".ts",
".jsx",
".json",
".wasm",
]);
});
it("preserves client/server and dev/build webpack resolve.extensions", async () => {
const webpack = (webpackConfig: any, options: any) => {
webpackConfig.resolve.extensions = [
options.isServer ? ".server.ts" : ".client.ts",
options.dev ? ".dev.ts" : ".prod.ts",
".ts",
];
return webpackConfig;
};
const build = await resolveNextConfig({ webpack });
expect(build.resolveExtensions).toEqual([".client.ts", ".prod.ts", ".ts"]);
expect(build.serverResolveExtensions).toEqual([".server.ts", ".prod.ts", ".ts"]);
const dev = await resolveNextConfig({ webpack }, process.cwd(), { dev: true });
expect(dev.resolveExtensions).toEqual([".client.ts", ".dev.ts", ".ts"]);
expect(dev.serverResolveExtensions).toEqual([".server.ts", ".dev.ts", ".ts"]);
});
let tmpDir: string;
afterEach(() => {
if (tmpDir) {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it("captures webpack resolve.alias from wrapped config plugins", async () => {
tmpDir = makeTempDir();
fs.mkdirSync(path.join(tmpDir, "node_modules", "fake-plugin"), {
recursive: true,
});
fs.writeFileSync(
path.join(tmpDir, "node_modules", "fake-plugin", "index.js"),
`module.exports = function fakePlugin() {
return function withPlugin(nextConfig = {}) {
return Object.assign({}, nextConfig, {
webpack(config) {
config.resolve = config.resolve || {};
config.resolve.alias = config.resolve.alias || {};
config.resolve.alias["wrapped/config"] = "./config/request.ts";
return typeof nextConfig.webpack === "function"
? nextConfig.webpack(config)
: config;
}
});
};
};`,
);
fs.writeFileSync(
path.join(tmpDir, "node_modules", "fake-plugin", "package.json"),
JSON.stringify({ name: "fake-plugin", version: "1.0.0", main: "index.js" }),
);
fs.writeFileSync(
path.join(tmpDir, "next.config.js"),
`const withPlugin = require("fake-plugin")();
module.exports = withPlugin({ basePath: "/wrapped" });`,
);
const rawConfig = await loadNextConfig(tmpDir);
const config = await resolveNextConfig(rawConfig, tmpDir);
expect(config.basePath).toBe("/wrapped");
expect(config.aliases["wrapped/config"]).toBe(path.join(tmpDir, "config", "request.ts"));
});
it("captures turbopack aliases from wrapped config plugins", async () => {
tmpDir = makeTempDir();
fs.writeFileSync(
path.join(tmpDir, "next.config.mjs"),
`export default {
experimental: {
turbo: {
resolveAlias: {
"wrapped/config": "./turbo/request.ts"
}
}
}
};`,
);
const rawConfig = await loadNextConfig(tmpDir);
const config = await resolveNextConfig(rawConfig, tmpDir);
expect(config.aliases["wrapped/config"]).toBe(path.join(tmpDir, "turbo", "request.ts"));
});
it("captures top-level turbopack aliases", async () => {
tmpDir = makeTempDir();
fs.writeFileSync(
path.join(tmpDir, "next.config.mjs"),
`export default {
turbopack: {
resolveAlias: {
"wrapped/config": "./turbopack/request.ts"
}
}
};`,
);
const rawConfig = await loadNextConfig(tmpDir);
const config = await resolveNextConfig(rawConfig, tmpDir);
expect(config.aliases["wrapped/config"]).toBe(path.join(tmpDir, "turbopack", "request.ts"));
});
// Regression test for #1507. Turbopack `resolveAlias` (and webpack
// `resolve.alias`) values can be bare package specifiers — e.g. the upstream
// esm-externals fixture aliases `preact/compat` -> `react`. Resolving those
// against the project root mangled them into bogus `<root>/react` paths,
// which broke the production build with "No such file or directory". Bare
// specifiers must be left verbatim so Vite re-resolves them via node_modules.
it("leaves bare package specifier turbopack aliases verbatim", async () => {
tmpDir = makeTempDir();
fs.writeFileSync(
path.join(tmpDir, "next.config.mjs"),
`export default {
turbopack: {
resolveAlias: {
"preact/compat": "react",
"@scope/pkg": "@scope/replacement",
"subpath": "react/jsx-runtime"
}
}
};`,
);
const rawConfig = await loadNextConfig(tmpDir);
const config = await resolveNextConfig(rawConfig, tmpDir);
// Bare specifiers stay as-is — NOT resolved against tmpDir.
expect(config.aliases["preact/compat"]).toBe("react");
expect(config.aliases["@scope/pkg"]).toBe("@scope/replacement");
expect(config.aliases["subpath"]).toBe("react/jsx-runtime");
});
it("still resolves relative-path turbopack aliases against the project root", async () => {
tmpDir = makeTempDir();
fs.writeFileSync(
path.join(tmpDir, "next.config.mjs"),
`export default {
turbopack: {
resolveAlias: {
"current": ".",
"parent": "..",
"explicit": "./turbo/request.ts",
"up": "../shared/request.ts"
}
}
};`,
);
const rawConfig = await loadNextConfig(tmpDir);
const config = await resolveNextConfig(rawConfig, tmpDir);
expect(config.aliases["current"]).toBe(path.resolve(tmpDir, "."));
expect(config.aliases["parent"]).toBe(path.resolve(tmpDir, ".."));
expect(config.aliases["explicit"]).toBe(path.join(tmpDir, "turbo", "request.ts"));
expect(config.aliases["up"]).toBe(path.resolve(tmpDir, "..", "shared", "request.ts"));
});
it("leaves absolute-path turbopack aliases verbatim", async () => {
tmpDir = makeTempDir();
const absoluteTarget = path.join(tmpDir, "abs", "request.ts");
fs.writeFileSync(
path.join(tmpDir, "next.config.mjs"),
`export default {
turbopack: {
resolveAlias: {
"absolute": ${JSON.stringify(absoluteTarget)}
}
}
};`,
);
const rawConfig = await loadNextConfig(tmpDir);
const config = await resolveNextConfig(rawConfig, tmpDir);
expect(config.aliases["absolute"]).toBe(absoluteTarget);
});
it("leaves bare package specifier webpack aliases verbatim", async () => {
tmpDir = makeTempDir();
fs.writeFileSync(
path.join(tmpDir, "next.config.js"),
`module.exports = {
webpack(config) {
config.resolve = config.resolve || {};
config.resolve.alias = config.resolve.alias || {};
config.resolve.alias["preact/compat"] = "react";
return config;
}
};`,
);
const rawConfig = await loadNextConfig(tmpDir);
const config = await resolveNextConfig(rawConfig, tmpDir);
expect(config.aliases["preact/compat"]).toBe("react");
});
it("does not attribute turbopack aliases to webpack support warnings", async () => {
tmpDir = makeTempDir();
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {});
const rawConfig = {
turbopack: {
resolveAlias: {
"wrapped/config": "./turbopack/request.ts",
},
},
webpack: (webpackConfig: any) => webpackConfig,
};
const config = await resolveNextConfig(rawConfig, tmpDir);
expect(config.aliases["wrapped/config"]).toBe(path.join(tmpDir, "turbopack", "request.ts"));
expect(consoleWarn).toHaveBeenCalledWith(
'[vinext] next.config option "webpack" is not yet supported and will be ignored',
);
});
it("keeps unrelated config resolution unchanged when no aliases exist", async () => {
tmpDir = makeTempDir();
fs.writeFileSync(
path.join(tmpDir, "next.config.mjs"),
`export default {
basePath: "/docs",
env: { FEATURE_FLAG: "on" }
};`,
);
const rawConfig = await loadNextConfig(tmpDir);
const config = await resolveNextConfig(rawConfig, tmpDir);
expect(config.basePath).toBe("/docs");
expect(config.env.FEATURE_FLAG).toBe("on");
expect(config.aliases).toEqual({});
});
it("invokes webpack loader callbacks so build-time process.env mutations land in the Node process", async () => {
// Regression test for #1500.
// Some Next.js webpack loaders mutate `process.env.X = ...` at build
// time, expecting the value to be visible to other modules during the
// same build. vinext doesn't run the webpack loader pipeline, so the
// env mutation never happens. We compensate by invoking each loader's
// callback once during config probing with a dummy source.
tmpDir = makeTempDir();
const loaderPath = path.join(tmpDir, "vinext-1500-loader.cjs");
fs.writeFileSync(
loaderPath,
`module.exports = function (source) {\n` +
` process.env.VINEXT_ISSUE_1500_LOADER_RAN = "yes";\n` +
` return source;\n` +
`};\n`,
);
const previous = process.env.VINEXT_ISSUE_1500_LOADER_RAN;
delete process.env.VINEXT_ISSUE_1500_LOADER_RAN;
try {
const rawConfig = {
webpack: (webpackConfig: any) => {
webpackConfig.module = webpackConfig.module || { rules: [] };
webpackConfig.module.rules.push({
test: /\.svg$/,
use: [loaderPath],
});
return webpackConfig;
},
};
await resolveNextConfig(rawConfig, tmpDir);
expect(process.env.VINEXT_ISSUE_1500_LOADER_RAN).toBe("yes");
} finally {
if (previous === undefined) delete process.env.VINEXT_ISSUE_1500_LOADER_RAN;
else process.env.VINEXT_ISSUE_1500_LOADER_RAN = previous;
}
});
it("extracts aliases and mdx while probing client and server webpack configs", async () => {
tmpDir = makeTempDir();
const invocations: boolean[] = [];
const fakeRemarkPlugin = () => {};
const rawConfig = {
webpack: async (webpackConfig: any, options: any) => {
invocations.push(options.isServer);
webpackConfig.resolve = webpackConfig.resolve || {};
webpackConfig.resolve.alias = webpackConfig.resolve.alias || {};
webpackConfig.resolve.alias["wrapped/config"] = "./config/request.ts";
webpackConfig.module = webpackConfig.module || { rules: [] };
webpackConfig.module.rules.push({
test: /\.mdx$/,
use: [
{
loader: "@next/mdx/mdx-js-loader",
options: {
remarkPlugins: [fakeRemarkPlugin],
},
},
],
});
return webpackConfig;
},
};
const config = await resolveNextConfig(rawConfig, tmpDir);
expect(invocations).toEqual([false, true]);
expect(config.aliases["wrapped/config"]).toBe(path.join(tmpDir, "config", "request.ts"));
expect(config.mdx?.remarkPlugins).toEqual([fakeRemarkPlugin]);
});
});
describe("parseBodySizeLimit", () => {
it("parses megabyte strings", () => {
expect(parseBodySizeLimit("10mb")).toBe(10 * 1024 * 1024);
expect(parseBodySizeLimit("1mb")).toBe(1 * 1024 * 1024);
});
it("parses kilobyte strings", () => {
expect(parseBodySizeLimit("500kb")).toBe(500 * 1024);
});
it("parses gigabyte strings", () => {
expect(parseBodySizeLimit("1gb")).toBe(1 * 1024 * 1024 * 1024);
});
it("parses byte strings", () => {
expect(parseBodySizeLimit("2048b")).toBe(2048);
});
it("passes through numeric values directly", () => {
expect(parseBodySizeLimit(2097152)).toBe(2097152);
});
it("is case-insensitive", () => {
expect(parseBodySizeLimit("10MB")).toBe(10 * 1024 * 1024);
expect(parseBodySizeLimit("500KB")).toBe(500 * 1024);
});
it("handles fractional values", () => {
expect(parseBodySizeLimit("1.5mb")).toBe(Math.floor(1.5 * 1024 * 1024));
});
it("returns default 1MB for undefined", () => {
expect(parseBodySizeLimit(undefined)).toBe(1 * 1024 * 1024);
});
it("returns default 1MB for null", () => {
expect(parseBodySizeLimit(null)).toBe(1 * 1024 * 1024);
});
it("returns default 1MB and warns for invalid strings", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
// In Vitest 4, spyOn on an already-intercepted console returns the same mock,
// which may have accumulated calls from earlier tests. Clear before asserting.
warn.mockClear();
expect(parseBodySizeLimit("invalid")).toBe(1 * 1024 * 1024);
expect(parseBodySizeLimit("10mbb")).toBe(1 * 1024 * 1024);
expect(warn).toHaveBeenCalledTimes(2);
expect(warn.mock.calls[0][0]).toContain("Invalid bodySizeLimit");
warn.mockRestore();
// empty string also falls through to the regex (no match), so it warns too
const warn2 = vi.spyOn(console, "warn").mockImplementation(() => {});
warn2.mockClear();
expect(parseBodySizeLimit("")).toBe(1 * 1024 * 1024);
expect(warn2).toHaveBeenCalledTimes(1);
warn2.mockRestore();
});
it("parses terabyte strings", () => {
expect(parseBodySizeLimit("10tb")).toBe(10 * 1024 * 1024 * 1024 * 1024);
});
it("parses petabyte strings", () => {
expect(parseBodySizeLimit("1pb")).toBe(1 * 1024 * 1024 * 1024 * 1024 * 1024);
});
it("accepts bare number strings as bytes", () => {
expect(parseBodySizeLimit("1048576")).toBe(1048576);
expect(parseBodySizeLimit("2097152")).toBe(2097152);
});
it("throws for zero or negative numeric values", () => {
expect(() => parseBodySizeLimit(0)).toThrow();
expect(() => parseBodySizeLimit(-1)).toThrow();
});
});
describe("resolveNextConfig serverExternalPackages", () => {
it("defaults to empty array when no config is provided", async () => {
const resolved = await resolveNextConfig(null);
expect(resolved.serverExternalPackages).toEqual([]);
});
it("defaults to empty array when not configured", async () => {
const resolved = await resolveNextConfig({ env: {} });
expect(resolved.serverExternalPackages).toEqual([]);
});
it("reads top-level serverExternalPackages", async () => {
const resolved = await resolveNextConfig({
serverExternalPackages: ["payload", "graphql"],
});
expect(resolved.serverExternalPackages).toEqual(["payload", "graphql"]);
});
it("falls back to experimental.serverComponentsExternalPackages (legacy name)", async () => {
const resolved = await resolveNextConfig({
experimental: {
serverComponentsExternalPackages: ["jose", "pg-cloudflare"],
},
});
expect(resolved.serverExternalPackages).toEqual(["jose", "pg-cloudflare"]);
});
it("prefers top-level serverExternalPackages over legacy experimental key", async () => {
const resolved = await resolveNextConfig({
serverExternalPackages: ["payload"],
experimental: {
serverComponentsExternalPackages: ["jose"],
},
});
expect(resolved.serverExternalPackages).toEqual(["payload"]);
});
it("preserves transpilePackages for default external precedence", async () => {
const resolved = await resolveNextConfig({
transpilePackages: ["typescript", "shiki"],
});
expect(resolved.transpilePackages).toEqual(["typescript", "shiki"]);
});
});
describe("resolveNextConfig transpilePackages", () => {
it("keeps Next.js defaults separate from configured transpile packages", async () => {
const resolved = await resolveNextConfig(null);
expect(resolved.transpilePackages).toEqual([]);
expect(resolved.turbopackTranspilePackages).toEqual(["geist"]);
});
it("includes configured packages before Turbopack defaults", async () => {
const resolved = await resolveNextConfig({
transpilePackages: ["custom-package", "@scope/pkg"],
});
expect(resolved.transpilePackages).toEqual(["custom-package", "@scope/pkg"]);
expect(resolved.turbopackTranspilePackages).toEqual(["custom-package", "@scope/pkg", "geist"]);
});
it("preserves Next.js duplicate package semantics", async () => {
const resolved = await resolveNextConfig({
transpilePackages: ["geist", "custom-package", "custom-package"],
});
expect(resolved.transpilePackages).toEqual(["geist", "custom-package", "custom-package"]);
expect(resolved.turbopackTranspilePackages).toEqual([
"geist",
"custom-package",
"custom-package",
"geist",
]);
});
it("does not treat optimized packages as Turbopack-transpiled packages", async () => {
const resolved = await resolveNextConfig({
transpilePackages: ["custom-package"],
experimental: {
optimizePackageImports: ["optimized-package", "geist", "custom-package"],
},
});
expect(resolved.optimizePackageImports).toEqual([
"optimized-package",
"geist",
"custom-package",
]);
expect(resolved.transpilePackages).toEqual(["custom-package"]);
expect(resolved.turbopackTranspilePackages).toEqual(["custom-package", "geist"]);
});
});
describe("resolveNextConfig serverActionsBodySizeLimit", () => {
it("defaults to 1MB when no config is provided", async () => {
const resolved = await resolveNextConfig(null);
expect(resolved.serverActionsBodySizeLimit).toBe(1 * 1024 * 1024);
});
it("defaults to 1MB when serverActions is not configured", async () => {
const resolved = await resolveNextConfig({ env: {} });
expect(resolved.serverActionsBodySizeLimit).toBe(1 * 1024 * 1024);
});
it("parses bodySizeLimit from experimental.serverActions", async () => {
const resolved = await resolveNextConfig({
experimental: {
serverActions: {
bodySizeLimit: "10mb",
},
},
});
expect(resolved.serverActionsBodySizeLimit).toBe(10 * 1024 * 1024);
});
it("accepts numeric bodySizeLimit", async () => {
const resolved = await resolveNextConfig({
experimental: {
serverActions: {
bodySizeLimit: 5242880,
},
},
});
expect(resolved.serverActionsBodySizeLimit).toBe(5242880);
});
// The verbatim config string drives the "Body exceeded {limit} limit" error
// message (matching Next.js), so it must be preserved alongside the parsed
// byte count rather than reconstructed from it.
it("preserves the verbatim bodySizeLimit label, defaulting to Next.js' 1 MB literal", async () => {
const defaulted = await resolveNextConfig(null);
expect(defaulted.serverActionsBodySizeLimitLabel).toBe("1 MB");
const stringLabel = await resolveNextConfig({
experimental: { serverActions: { bodySizeLimit: "2mb" } },
});
expect(stringLabel.serverActionsBodySizeLimitLabel).toBe("2mb");
const numericLabel = await resolveNextConfig({
experimental: { serverActions: { bodySizeLimit: 5242880 } },
});
expect(numericLabel.serverActionsBodySizeLimitLabel).toBe("5242880");
});
});
describe("resolveNextConfig disableOptimizedLoading", () => {
// Regression for #1519: `experimental.disableOptimizedLoading` defaults to
// `false` and is read into the resolved config. The default drives the
// `defer`-in-head behaviour for Pages Router scripts in production.
it("defaults disableOptimizedLoading to false", async () => {
const resolved = await resolveNextConfig({});
expect(resolved.disableOptimizedLoading).toBe(false);
});
it("reads experimental.disableOptimizedLoading from next.config", async () => {
const resolved = await resolveNextConfig({
experimental: { disableOptimizedLoading: true },
});
expect(resolved.disableOptimizedLoading).toBe(true);
});
});
describe("resolveNextConfig scrollRestoration", () => {
it("defaults scrollRestoration to false", async () => {
const resolved = await resolveNextConfig({});
expect(resolved.scrollRestoration).toBe(false);
});
it("reads experimental.scrollRestoration from next.config", async () => {
const resolved = await resolveNextConfig({
experimental: { scrollRestoration: true },
});
expect(resolved.scrollRestoration).toBe(true);
});
});
describe("resolveNextConfig prefetchInlining", () => {
it("reads experimental.prefetchInlining from next.config", async () => {
const disabled = await resolveNextConfig({});
expect(disabled.prefetchInlining).toBe(false);
const enabledByBoolean = await resolveNextConfig({
experimental: { prefetchInlining: true },
});
expect(enabledByBoolean.prefetchInlining).toEqual({
maxBundleSize: 10240,
maxSize: 2048,
});
const enabledByThresholds = await resolveNextConfig({
experimental: { prefetchInlining: { maxSize: Infinity, maxBundleSize: Infinity } },
});
expect(enabledByThresholds.prefetchInlining).toEqual({
maxBundleSize: Number.MAX_SAFE_INTEGER,
maxSize: Number.MAX_SAFE_INTEGER,
});
const enabledByPartialThresholds = await resolveNextConfig({
experimental: { prefetchInlining: { maxSize: 512 } },
});
expect(enabledByPartialThresholds.prefetchInlining).toEqual({
maxBundleSize: 10240,
maxSize: 512,
});
const negativeThresholds = await resolveNextConfig({
experimental: { prefetchInlining: { maxSize: -1, maxBundleSize: -1 } },
});
expect(negativeThresholds.prefetchInlining).toEqual({
maxBundleSize: -1,
maxSize: -1,
});
});
});
describe("resolveNextConfig gestureTransition", () => {
it("defaults experimental.gestureTransition to false", async () => {
const resolved = await resolveNextConfig({});
expect(resolved.gestureTransition).toBe(false);
});
it("reads experimental.gestureTransition from next.config", async () => {
const resolved = await resolveNextConfig({
experimental: { gestureTransition: true },
});
expect(resolved.gestureTransition).toBe(true);
});
});
describe("resolveNextConfig appNavFailHandling", () => {
it("defaults experimental.appNavFailHandling to false", async () => {
const resolved = await resolveNextConfig({});
expect(resolved.appNavFailHandling).toBe(false);
});
it("reads experimental.appNavFailHandling from next.config", async () => {
const resolved = await resolveNextConfig({
experimental: { appNavFailHandling: true },
});
expect(resolved.appNavFailHandling).toBe(true);
});
});
describe("resolveNextConfig globalNotFound", () => {
it("defaults experimental.globalNotFound to false", async () => {
const resolved = await resolveNextConfig({});
expect(resolved.globalNotFound).toBe(false);
});
it("reads experimental.globalNotFound from next.config", async () => {
const resolved = await resolveNextConfig({
experimental: { globalNotFound: true },
});
expect(resolved.globalNotFound).toBe(true);
});
});
describe("resolveNextConfig hashSalt", () => {
const OLD_ENV = process.env.NEXT_HASH_SALT;
afterEach(() => {
if (OLD_ENV !== undefined) {
process.env.NEXT_HASH_SALT = OLD_ENV;
} else {
delete process.env.NEXT_HASH_SALT;
}
});
it("defaults to empty string when no config or env is set", async () => {
const resolved = await resolveNextConfig(null);
expect(resolved.hashSalt).toBe("");
});
it("defaults to empty string when config has no experimental", async () => {
const resolved = await resolveNextConfig({ env: {} });
expect(resolved.hashSalt).toBe("");
});
it("reads outputHashSalt from experimental config", async () => {
const resolved = await resolveNextConfig({
experimental: { outputHashSalt: "v1" },
});
expect(resolved.hashSalt).toBe("v1");
});
it("reads NEXT_HASH_SALT from env var", async () => {
process.env.NEXT_HASH_SALT = "envsalt";
const resolved = await resolveNextConfig(null);
expect(resolved.hashSalt).toBe("envsalt");
});
it("concatenates config salt and env salt (config first)", async () => {
process.env.NEXT_HASH_SALT = "envsalt";
const resolved = await resolveNextConfig({
experimental: { outputHashSalt: "configsalt" },
});
expect(resolved.hashSalt).toBe("configsaltenvsalt");
});
it("handles only env var without config salt", async () => {
process.env.NEXT_HASH_SALT = "onlyenv";
const resolved = await resolveNextConfig({ env: {} });
expect(resolved.hashSalt).toBe("onlyenv");
});
});
describe("resolveNextConfig instrumentationClientInject", () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = makeTempDir();
fs.writeFileSync(path.join(tmpDir, "package.json"), `{ "type": "module" }\n`);
});
afterEach(() => {
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
});
it("loads instrumentationClientInject from next.config.mjs", async () => {
fs.writeFileSync(
path.join(tmpDir, "next.config.mjs"),
`export default { instrumentationClientInject: ["./inject-a.js", "./inject-b.js"] };\n`,
);
const raw = await loadNextConfig(tmpDir);
const resolved = await resolveNextConfig(raw, tmpDir);
expect(resolved.instrumentationClientInject).toEqual(["./inject-a.js", "./inject-b.js"]);
});
});
describe("resolveNextConfig clientTraceMetadata", () => {
it("defaults to undefined when no config is provided", async () => {
const resolved = await resolveNextConfig(null);
expect(resolved.clientTraceMetadata).toBeUndefined();
});
it("defaults to undefined when experimental is not set", async () => {
const resolved = await resolveNextConfig({ env: {} });
expect(resolved.clientTraceMetadata).toBeUndefined();
});
it("defaults to undefined when experimental.clientTraceMetadata is omitted", async () => {
const resolved = await resolveNextConfig({ experimental: {} });
expect(resolved.clientTraceMetadata).toBeUndefined();
});
it("resolves a string array from experimental.clientTraceMetadata", async () => {
const resolved = await resolveNextConfig({
experimental: {
clientTraceMetadata: ["my-test-key-1", "my-test-key-2", "my-parent-span-id"],
},
});
expect(resolved.clientTraceMetadata).toEqual([
"my-test-key-1",
"my-test-key-2",
"my-parent-span-id",
]);
});
it("filters out non-string entries", async () => {
const resolved = await resolveNextConfig({
// oxlint-disable-next-line typescript/no-explicit-any
experimental: { clientTraceMetadata: ["valid-key", 42, null, "another-key"] as any },
});
expect(resolved.clientTraceMetadata).toEqual(["valid-key", "another-key"]);
});
it("returns undefined for a non-array value", async () => {
const resolved = await resolveNextConfig({
// oxlint-disable-next-line typescript/no-explicit-any
experimental: { clientTraceMetadata: "not-an-array" as any },
});
expect(resolved.clientTraceMetadata).toBeUndefined();
});
it("resolves to an empty array when experimental.clientTraceMetadata is an empty array", async () => {
const resolved = await resolveNextConfig({
experimental: { clientTraceMetadata: [] },
});
expect(resolved.clientTraceMetadata).toEqual([]);
});
});
describe("resolveNextConfig removeConsole", () => {
it("resolves `compiler: { removeConsole: true }` to `true`", async () => {
const resolved = await resolveNextConfig({ compiler: { removeConsole: true } });
expect(resolved.removeConsole).toBe(true);
});
it("resolves `compiler: { removeConsole: { exclude: ['error'] } }` to the same shape", async () => {
const resolved = await resolveNextConfig({
compiler: { removeConsole: { exclude: ["error"] } },
});
expect(resolved.removeConsole).toEqual({ exclude: ["error"] });
});
it("resolves `compiler: { removeConsole: {} }` to `{ exclude: [] }`", async () => {
const resolved = await resolveNextConfig({ compiler: { removeConsole: {} } });
expect(resolved.removeConsole).toEqual({ exclude: [] });
});
it("resolves missing `compiler` to `false`", async () => {
const resolved = await resolveNextConfig({});
expect(resolved.removeConsole).toBe(false);
});
it("resolves `compiler: {}` (no removeConsole key) to `false`", async () => {
const resolved = await resolveNextConfig({ compiler: {} });
expect(resolved.removeConsole).toBe(false);
});
it("resolves `compiler: { removeConsole: false }` to `false`", async () => {
const resolved = await resolveNextConfig({ compiler: { removeConsole: false } });
expect(resolved.removeConsole).toBe(false);
});
it("coerces non-string entries in `exclude` away (sanitization)", async () => {
const resolved = await resolveNextConfig({
// oxlint-disable-next-line typescript/no-explicit-any
compiler: { removeConsole: { exclude: ["error", 42, null, "warn"] as any } },
});
expect(resolved.removeConsole).toEqual({ exclude: ["error", "warn"] });
});
});
// Ported from Next.js: test/e2e/define/define.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/define/define.test.ts
describe("resolveNextConfig compiler.define / defineServer", () => {
it("defaults to empty maps when `compiler` is unset", async () => {
const resolved = await resolveNextConfig({});
expect(resolved.compilerDefine).toEqual({});
expect(resolved.compilerDefineServer).toEqual({});
});
it("JSON-stringifies string, number, and boolean values for `define`", async () => {
const resolved = await resolveNextConfig({
compiler: {
define: {
MY_MAGIC_VARIABLE: "foobar",
"process.env.MY_MAGIC_EXPR": "barbaz",
MY_NUMBER_VARIABLE: 42,
MY_BOOLEAN_VARIABLE: true,
},
},
});
expect(resolved.compilerDefine).toEqual({
MY_MAGIC_VARIABLE: '"foobar"',
"process.env.MY_MAGIC_EXPR": '"barbaz"',
MY_NUMBER_VARIABLE: "42",
MY_BOOLEAN_VARIABLE: "true",
});
expect(resolved.compilerDefineServer).toEqual({});
});
it("JSON-stringifies values for `defineServer` and keeps them separate from `define`", async () => {
const resolved = await resolveNextConfig({
compiler: {
define: { CLIENT_SAFE: "shared" },
defineServer: {
MY_SERVER_VARIABLE: "server",
"process.env.MY_MAGIC_SERVER_EXPR": "serverbarbaz",
},
},
});
expect(resolved.compilerDefine).toEqual({ CLIENT_SAFE: '"shared"' });
expect(resolved.compilerDefineServer).toEqual({
MY_SERVER_VARIABLE: '"server"',
"process.env.MY_MAGIC_SERVER_EXPR": '"serverbarbaz"',
});
});
it("ignores entries whose values are not string/number/boolean", async () => {
const resolved = await resolveNextConfig({
compiler: {
// oxlint-disable-next-line typescript/no-explicit-any
define: { OK: "yes", BAD_OBJ: { nope: 1 } as any, BAD_NULL: null as any },
// oxlint-disable-next-line typescript/no-explicit-any
defineServer: { OK_SRV: 1, BAD_ARR: [1, 2] as any },
},
});
expect(resolved.compilerDefine).toEqual({ OK: '"yes"' });
expect(resolved.compilerDefineServer).toEqual({ OK_SRV: "1" });
});
});
describe("resolveNextConfig htmlLimitedBots", () => {
it("serializes RegExp config values to their source", async () => {
const resolved = await resolveNextConfig({ htmlLimitedBots: /Minibot/i });
expect(resolved.htmlLimitedBots).toBe("Minibot");
});
it("accepts valid serialized regex source strings", async () => {
const resolved = await resolveNextConfig({ htmlLimitedBots: "Minibot|Weebot" });
expect(resolved.htmlLimitedBots).toBe("Minibot|Weebot");
});
it("treats empty string config as unset", async () => {
const resolved = await resolveNextConfig({ htmlLimitedBots: "" });
expect(resolved.htmlLimitedBots).toBeUndefined();
});
it("throws a config error for invalid serialized regex sources", async () => {
await expect(resolveNextConfig({ htmlLimitedBots: "[" })).rejects.toThrow(
'Invalid next.config option "htmlLimitedBots"',
);
});
});
describe("resolveNextConfig expireTime", () => {
it("defaults to the Next.js route expire fallback", async () => {
const resolved = await resolveNextConfig(null);
expect(resolved.expireTime).toBe(31_536_000);
});
it("uses configured expireTime", async () => {
const resolved = await resolveNextConfig({ expireTime: 2 });
expect(resolved.expireTime).toBe(2);
});
});
describe("resolveNextConfig reactMaxHeadersLength", () => {
it("defaults to the Next.js default of 6000", async () => {
const resolved = await resolveNextConfig(null);
expect(resolved.reactMaxHeadersLength).toBe(6000);
});
it("uses a configured value", async () => {
const resolved = await resolveNextConfig({ reactMaxHeadersLength: 400 });
expect(resolved.reactMaxHeadersLength).toBe(400);
});
it("preserves 0 (disables emission) rather than falling back to the default", async () => {
const resolved = await resolveNextConfig({ reactMaxHeadersLength: 0 });
expect(resolved.reactMaxHeadersLength).toBe(0);
});
});
// Ported from Next.js: packages/next/src/server/config.ts:528-531
// https://github.com/vercel/next.js/blob/canary/packages/next/src/server/config.ts
describe("resolveNextConfig basePath → assetPrefix parity fallback", () => {
it("falls back to basePath when assetPrefix is empty", async () => {
const resolved = await resolveNextConfig({ basePath: "/app" });
expect(resolved.basePath).toBe("/app");
expect(resolved.assetPrefix).toBe("/app");
});
it("does not override an explicitly set assetPrefix", async () => {
const resolved = await resolveNextConfig({
basePath: "/app",
assetPrefix: "/cdn",
});
expect(resolved.basePath).toBe("/app");
expect(resolved.assetPrefix).toBe("/cdn");
});
it("preserves absolute-URL assetPrefix even when basePath is also set", async () => {
const resolved = await resolveNextConfig({
basePath: "/app",
assetPrefix: "https://cdn.example.com",
});
expect(resolved.assetPrefix).toBe("https://cdn.example.com");
});
it("leaves assetPrefix empty when basePath is also empty", async () => {
const resolved = await resolveNextConfig({});
expect(resolved.basePath).toBe("");
expect(resolved.assetPrefix).toBe("");
});
it("does not fall back when basePath is literal `/` (parity with Next.js)", async () => {
// Next.js rejects basePath === "/" earlier in its config pipeline;
// vinext passes the value through but the fallback explicitly skips
// it to avoid producing assetPrefix === "/" (which would collide
// with the root URL).
const resolved = await resolveNextConfig({ basePath: "/" });
expect(resolved.basePath).toBe("/");
expect(resolved.assetPrefix).toBe("");
});
});
describe("detectNextIntlConfig", () => {
let tmpDir: string;
afterEach(() => {
if (tmpDir) {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
function makeResolved(overrides: Partial<ResolvedNextConfig> = {}): ResolvedNextConfig {
return {
env: {},
assetPrefix: "",
basePath: "",
trailingSlash: false,
output: "",
pageExtensions: ["tsx", "ts", "jsx", "js"],
resolveExtensions: null,
serverResolveExtensions: null,
cacheComponents: false,
appNavFailHandling: false,
gestureTransition: false,
prefetchInlining: false,
redirects: [],
rewrites: { beforeFiles: [], afterFiles: [], fallback: [] },
headers: [],
images: undefined,
i18n: null,
mdx: null,
aliases: {},
allowedDevOrigins: [],
serverActionsAllowedOrigins: [],
optimizePackageImports: [],
transpilePackages: [],
turbopackTranspilePackages: ["geist"],
inlineCss: false,
globalNotFound: false,
serverActionsBodySizeLimit: 1 * 1024 * 1024,
serverActionsBodySizeLimitLabel: "1 MB",
htmlLimitedBots: undefined,
serverExternalPackages: [],
cacheHandler: undefined,
cacheMaxMemorySize: undefined,
hashSalt: "",
enablePrerenderSourceMaps: true,
appShells: false,
expireTime: 31_536_000,
reactMaxHeadersLength: 6000,
buildId: "test-build-id",
deploymentId: undefined,
sassOptions: null,
removeConsole: false,
disableOptimizedLoading: false,
reactStrictMode: null,
scrollRestoration: false,
compilerDefine: {},
compilerDefineServer: {},
instrumentationClientInject: [],
clientTraceMetadata: undefined,
staleTimes: { dynamic: 0, static: 300 },
useLightningcss: false,
lightningCssFeatures: { include: 0, exclude: 0 },
...overrides,
};
}
/** Create a tmpdir with a fake next-intl package so require.resolve("next-intl") works */
function setupWithNextIntl(i18nFile?: string) {
tmpDir = makeTempDir();
// Create a resolvable next-intl package with an entry file.
const nextIntlDir = path.join(tmpDir, "node_modules", "next-intl");
fs.mkdirSync(nextIntlDir, { recursive: true });
fs.writeFileSync(
path.join(nextIntlDir, "package.json"),
JSON.stringify({ name: "next-intl", version: "4.0.0", main: "index.js" }),
);
fs.writeFileSync(path.join(nextIntlDir, "index.js"), "module.exports = {};\n");
// Create root package.json so createRequire works
fs.writeFileSync(path.join(tmpDir, "package.json"), JSON.stringify({ name: "test-project" }));
if (i18nFile) {
const absPath = path.join(tmpDir, i18nFile);
fs.mkdirSync(path.dirname(absPath), { recursive: true });
fs.writeFileSync(absPath, "export default {};\n");
}
}
it("auto-detects i18n/request.ts when next-intl is installed", () => {
setupWithNextIntl("i18n/request.ts");
const resolved = makeResolved();
detectNextIntlConfig(tmpDir, resolved);
expect(resolved.aliases["next-intl/config"]).toBe(path.join(tmpDir, "i18n", "request.ts"));
});
it("auto-detects src/i18n/request.ts", () => {
setupWithNextIntl("src/i18n/request.ts");
const resolved = makeResolved();
detectNextIntlConfig(tmpDir, resolved);
expect(resolved.aliases["next-intl/config"]).toBe(
path.join(tmpDir, "src", "i18n", "request.ts"),
);
});
it("prefers i18n/request.ts over src/i18n/request.ts", () => {
setupWithNextIntl("i18n/request.ts");
// Also create src variant
const srcPath = path.join(tmpDir, "src", "i18n", "request.ts");
fs.mkdirSync(path.dirname(srcPath), { recursive: true });
fs.writeFileSync(srcPath, "export default {};\n");
const resolved = makeResolved();
detectNextIntlConfig(tmpDir, resolved);
expect(resolved.aliases["next-intl/config"]).toBe(path.join(tmpDir, "i18n", "request.ts"));
});
it("detects .js extension variant", () => {
setupWithNextIntl("i18n/request.js");
const resolved = makeResolved();
detectNextIntlConfig(tmpDir, resolved);
expect(resolved.aliases["next-intl/config"]).toBe(path.join(tmpDir, "i18n", "request.js"));
});
it("detects .tsx extension variant", () => {
setupWithNextIntl("i18n/request.tsx");
const resolved = makeResolved();
detectNextIntlConfig(tmpDir, resolved);
expect(resolved.aliases["next-intl/config"]).toBe(path.join(tmpDir, "i18n", "request.tsx"));
});
// Note: "does nothing when next-intl is not installed" cannot be tested
// in this monorepo because vitest's module resolution always finds
// next-intl from the workspace root. The code path is a single try/catch
// that returns early — covered by the "no config file" and "explicit alias" tests.
it("does nothing when no i18n config file exists", () => {
setupWithNextIntl(); // no i18n file
const resolved = makeResolved();
detectNextIntlConfig(tmpDir, resolved);
expect(resolved.aliases["next-intl/config"]).toBeUndefined();
});
it("does not overwrite explicit alias", () => {
setupWithNextIntl("i18n/request.ts");
const explicit = "/custom/path/to/config.ts";
const resolved = makeResolved({
aliases: { "next-intl/config": explicit },
});
detectNextIntlConfig(tmpDir, resolved);
expect(resolved.aliases["next-intl/config"]).toBe(explicit);
});
it("sets trailing slash env var when trailingSlash is true", () => {
setupWithNextIntl("i18n/request.ts");
const resolved = makeResolved({ trailingSlash: true });
detectNextIntlConfig(tmpDir, resolved);
expect(resolved.env._next_intl_trailing_slash).toBe("true");
});
it("does not set trailing slash env var when trailingSlash is false", () => {
setupWithNextIntl("i18n/request.ts");
const resolved = makeResolved({ trailingSlash: false });
detectNextIntlConfig(tmpDir, resolved);
expect(resolved.env._next_intl_trailing_slash).toBeUndefined();
});
});
describe("resolveNextConfig next-intl auto-detection", () => {
let tmpDir: string;
afterEach(() => {
if (tmpDir) {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it("auto-detects next-intl even when config is null", async () => {
tmpDir = makeTempDir();
// Setup next-intl + i18n config file
const nextIntlDir = path.join(tmpDir, "node_modules", "next-intl");
fs.mkdirSync(nextIntlDir, { recursive: true });
fs.writeFileSync(
path.join(nextIntlDir, "package.json"),
JSON.stringify({ name: "next-intl", version: "4.0.0", main: "index.js" }),
);
fs.writeFileSync(path.join(nextIntlDir, "index.js"), "module.exports = {};\n");
fs.writeFileSync(path.join(tmpDir, "package.json"), JSON.stringify({ name: "test-project" }));
fs.mkdirSync(path.join(tmpDir, "i18n"), { recursive: true });
fs.writeFileSync(path.join(tmpDir, "i18n", "request.ts"), "export default {};\n");
const config = await resolveNextConfig(null, tmpDir);
expect(config.aliases["next-intl/config"]).toBe(path.join(tmpDir, "i18n", "request.ts"));
});
it("explicit webpack alias takes precedence over auto-detection", async () => {
tmpDir = makeTempDir();
// Setup next-intl + i18n config file
const nextIntlDir = path.join(tmpDir, "node_modules", "next-intl");
fs.mkdirSync(nextIntlDir, { recursive: true });
fs.writeFileSync(
path.join(nextIntlDir, "package.json"),
JSON.stringify({ name: "next-intl", version: "4.0.0", main: "index.js" }),
);
fs.writeFileSync(path.join(nextIntlDir, "index.js"), "module.exports = {};\n");
fs.writeFileSync(path.join(tmpDir, "package.json"), JSON.stringify({ name: "test-project" }));
fs.mkdirSync(path.join(tmpDir, "i18n"), { recursive: true });
fs.writeFileSync(path.join(tmpDir, "i18n", "request.ts"), "export default {};\n");
// Create a custom config path
fs.mkdirSync(path.join(tmpDir, "custom"), { recursive: true });
fs.writeFileSync(path.join(tmpDir, "custom", "intl.ts"), "export default {};\n");
const rawConfig = {
webpack: (webpackConfig: any) => {
webpackConfig.resolve = webpackConfig.resolve || {};
webpackConfig.resolve.alias = webpackConfig.resolve.alias || {};
webpackConfig.resolve.alias["next-intl/config"] = "./custom/intl.ts";
return webpackConfig;
},
};
const config = await resolveNextConfig(rawConfig, tmpDir);
// Should use the explicit webpack alias, not auto-detected
expect(config.aliases["next-intl/config"]).toBe(path.join(tmpDir, "custom", "intl.ts"));
});
});
// Ported from Next.js: test/integration/production-config/test/index.test.ts
// https://github.com/vercel/next.js/blob/canary/test/integration/production-config/test/index.test.ts
describe("generateBuildId", () => {
it("defaults to a non-empty string when generateBuildId is not set", async () => {
const config = await resolveNextConfig(null);
expect(typeof config.buildId).toBe("string");
expect(config.buildId.length).toBeGreaterThan(0);
});
it("uses the string returned by generateBuildId", async () => {
const config = await resolveNextConfig({ generateBuildId: () => "my-custom-build-id" });
expect(config.buildId).toBe("my-custom-build-id");
});
it("trims whitespace from the returned build ID", async () => {
const config = await resolveNextConfig({ generateBuildId: () => " trimmed " });
expect(config.buildId).toBe("trimmed");
});
it("falls back to a random UUID when generateBuildId returns null", async () => {
const config = await resolveNextConfig({ generateBuildId: () => null });
expect(typeof config.buildId).toBe("string");
expect(config.buildId.length).toBeGreaterThan(0);
});
it("supports async generateBuildId returning a string", async () => {
const config = await resolveNextConfig({
generateBuildId: async () => "async-build-id",
});
expect(config.buildId).toBe("async-build-id");
});
it("supports async generateBuildId returning null (falls back)", async () => {
const config = await resolveNextConfig({
generateBuildId: async () => null,
});
expect(typeof config.buildId).toBe("string");
expect(config.buildId.length).toBeGreaterThan(0);
});
it("throws when generateBuildId returns a non-string, non-null value", async () => {
await expect(
resolveNextConfig({ generateBuildId: () => 42 as unknown as string }),
).rejects.toThrow("generateBuildId did not return a string");
});
it("throws when generateBuildId returns an empty string", async () => {
await expect(resolveNextConfig({ generateBuildId: () => " " })).rejects.toThrow(
"generateBuildId returned an empty string",
);
});
it("two calls with no generateBuildId produce different build IDs (random)", async () => {
const a = await resolveNextConfig(null);
const b = await resolveNextConfig(null);
// UUIDs are random — astronomically unlikely to collide
expect(a.buildId).not.toBe(b.buildId);
});
it("two calls with the same generateBuildId produce the same ID", async () => {
const fn = () => "stable-id";
const a = await resolveNextConfig({ generateBuildId: fn });
const b = await resolveNextConfig({ generateBuildId: fn });
expect(a.buildId).toBe("stable-id");
expect(b.buildId).toBe("stable-id");
});
});
describe("deploymentId", () => {
const OLD_ENV = process.env.NEXT_DEPLOYMENT_ID;
afterEach(() => {
if (OLD_ENV === undefined) {
delete process.env.NEXT_DEPLOYMENT_ID;
} else {
process.env.NEXT_DEPLOYMENT_ID = OLD_ENV;
}
});
it("defaults to undefined when no deployment ID is configured", async () => {
delete process.env.NEXT_DEPLOYMENT_ID;
const config = await resolveNextConfig(null);
expect(config.deploymentId).toBeUndefined();
});
it("uses NEXT_DEPLOYMENT_ID when next.config.js does not set deploymentId", async () => {
process.env.NEXT_DEPLOYMENT_ID = "env-deployment";
const config = await resolveNextConfig({});
expect(config.deploymentId).toBe("env-deployment");
});
it("lets next.config.js deploymentId take precedence over NEXT_DEPLOYMENT_ID", async () => {
process.env.NEXT_DEPLOYMENT_ID = "env-deployment";
const config = await resolveNextConfig({ deploymentId: "config-deployment" });
expect(config.deploymentId).toBe("config-deployment");
});
it("treats an empty next.config.js deploymentId as unset even when NEXT_DEPLOYMENT_ID is set", async () => {
process.env.NEXT_DEPLOYMENT_ID = "env-deployment";
const config = await resolveNextConfig({ deploymentId: "" });
expect(config.deploymentId).toBeUndefined();
});
it("throws when deploymentId contains invalid characters", async () => {
await expect(resolveNextConfig({ deploymentId: "bad value" })).rejects.toThrow(
"Invalid `deploymentId` configuration: contains invalid characters",
);
});
it("throws when deploymentId is not a string", async () => {
await expect(resolveNextConfig({ deploymentId: 42 as unknown as string })).rejects.toThrow(
"Invalid `deploymentId` configuration: must be a string",
);
});
});
describe("resolveNextConfig external rewrite warning", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("emits a warning when rewrites contain external destinations", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
await resolveNextConfig({
rewrites: async () => [
{ source: "/api/:path*", destination: "https://api.example.com/:path*" },
{ source: "/internal", destination: "/other" },
],
});
const externalWarning = warn.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("external rewrite"),
);
expect(externalWarning).toBeDefined();
expect(externalWarning![0]).toContain("1 external rewrite that");
expect(externalWarning![0]).toContain("https://api.example.com/:path*");
expect(externalWarning![0]).toContain("/api/:path*");
expect(externalWarning![0]).toContain("→");
expect(externalWarning![0]).toContain("credential headers");
expect(externalWarning![0]).toContain("forwarded");
expect(externalWarning![0]).toContain("match Next.js behavior");
expect(externalWarning![0]).not.toContain("/other");
});
it("does not warn when all rewrites are internal", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
await resolveNextConfig({
rewrites: async () => [
{ source: "/old", destination: "/new" },
{ source: "/a", destination: "/b" },
],
});
const externalWarning = warn.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("external rewrite"),
);
expect(externalWarning).toBeUndefined();
});
it("warns about multiple external rewrites across beforeFiles, afterFiles, and fallback", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
await resolveNextConfig({
rewrites: async () => ({
beforeFiles: [{ source: "/proxy1", destination: "https://one.example.com/api" }],
afterFiles: [{ source: "/proxy2", destination: "https://two.example.com/api" }],
fallback: [{ source: "/proxy3", destination: "https://three.example.com/api" }],
}),
});
const externalWarning = warn.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("external rewrite"),
);
expect(externalWarning).toBeDefined();
expect(externalWarning![0]).toContain("3 external rewrites");
expect(externalWarning![0]).toContain("https://one.example.com/api");
expect(externalWarning![0]).toContain("https://two.example.com/api");
expect(externalWarning![0]).toContain("https://three.example.com/api");
expect(externalWarning![0]).toContain("/proxy1");
expect(externalWarning![0]).toContain("/proxy2");
expect(externalWarning![0]).toContain("/proxy3");
});
it("does not warn when no rewrites are configured", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
await resolveNextConfig({ env: {} });
const externalWarning = warn.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("external rewrite"),
);
expect(externalWarning).toBeUndefined();
});
});
describe("resolveNextConfig swcEnvOptions warning", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("emits a warning when experimental.swcEnvOptions is set", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
await resolveNextConfig({
experimental: { swcEnvOptions: { mode: "usage" } },
});
const swcWarning = warn.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("swcEnvOptions"),
);
expect(swcWarning).toBeDefined();
expect(swcWarning![0]).toContain("swcEnvOptions");
expect(swcWarning![0]).toContain("not applicable");
expect(swcWarning![0]).toContain("vinext uses Vite");
});
it("does not warn when experimental.swcEnvOptions is not set", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
await resolveNextConfig({
experimental: {},
});
const swcWarning = warn.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("swcEnvOptions"),
);
expect(swcWarning).toBeUndefined();
});
});
describe("resolveNextConfig cachedNavigations warning", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("emits a warning when experimental.cachedNavigations is set without cacheComponents", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
await resolveNextConfig({
experimental: { cachedNavigations: true },
});
const cachedNavigationsWarning = warn.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("cachedNavigations"),
);
expect(cachedNavigationsWarning).toBeDefined();
expect(cachedNavigationsWarning![0]).toContain("experimental.cachedNavigations");
expect(cachedNavigationsWarning![0]).toContain("cacheComponents: true");
});
it("does not warn when experimental.cachedNavigations is set with cacheComponents", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
await resolveNextConfig({
cacheComponents: true,
experimental: { cachedNavigations: true },
});
const cachedNavigationsWarning = warn.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("cachedNavigations"),
);
expect(cachedNavigationsWarning).toBeUndefined();
});
it("does not warn when experimental.cachedNavigations is not set", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
await resolveNextConfig({
experimental: {},
});
const cachedNavigationsWarning = warn.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("cachedNavigations"),
);
expect(cachedNavigationsWarning).toBeUndefined();
});
it("does not warn when experimental.cachedNavigations is false", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
await resolveNextConfig({
experimental: { cachedNavigations: false },
});
const cachedNavigationsWarning = warn.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("cachedNavigations"),
);
expect(cachedNavigationsWarning).toBeUndefined();
});
});
describe("resolveNextConfig rootParams deprecation warning", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("emits a warning when experimental.rootParams is true", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
await resolveNextConfig({
experimental: { rootParams: true },
});
const rootParamsWarning = warn.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("rootParams"),
);
expect(rootParamsWarning).toBeDefined();
expect(rootParamsWarning![0]).toContain("experimental.rootParams");
expect(rootParamsWarning![0]).toContain("no longer needed");
expect(rootParamsWarning![0]).toContain("next/root-params");
expect(rootParamsWarning![0]).toContain("available by default");
});
it("emits a warning when experimental.rootParams is false", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
await resolveNextConfig({
experimental: { rootParams: false },
});
const rootParamsWarning = warn.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("rootParams"),
);
expect(rootParamsWarning).toBeDefined();
});
it("does not warn when experimental.rootParams is not set", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
await resolveNextConfig({
experimental: {},
});
const rootParamsWarning = warn.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("rootParams"),
);
expect(rootParamsWarning).toBeUndefined();
});
it("does not warn when experimental is not set", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
await resolveNextConfig({});
const rootParamsWarning = warn.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("rootParams"),
);
expect(rootParamsWarning).toBeUndefined();
});
});
describe("resolveNextConfig cacheHandler", () => {
it("resolves file:// URLs to filesystem paths", async () => {
// Build the URL with pathToFileURL so it is valid on Windows too, where a
// file:// URL must carry a drive letter (a drive-less file:///… throws in
// fileURLToPath). In production the URL comes from import.meta.resolve, so
// it is always platform-valid.
const handlerPath = path.resolve("/absolute/path/to/handler.js");
const resolved = await resolveNextConfig({
cacheHandler: pathToFileURL(handlerPath).href,
});
expect(resolved.cacheHandler).toBe(handlerPath);
});
it("passes through absolute paths unchanged", async () => {
const resolved = await resolveNextConfig({
cacheHandler: "/absolute/path/to/handler.js",
});
expect(resolved.cacheHandler).toBe("/absolute/path/to/handler.js");
});
it("passes through relative paths unchanged", async () => {
const resolved = await resolveNextConfig({
cacheHandler: "./my-cache-handler.js",
});
expect(resolved.cacheHandler).toBe("./my-cache-handler.js");
});
it("defaults to undefined when not configured", async () => {
const resolved = await resolveNextConfig({});
expect(resolved.cacheHandler).toBeUndefined();
});
it("defaults to undefined when config is null", async () => {
const resolved = await resolveNextConfig(null);
expect(resolved.cacheHandler).toBeUndefined();
});
it("resolves cacheMaxMemorySize when configured", async () => {
const resolved = await resolveNextConfig({
cacheMaxMemorySize: 52428800,
});
expect(resolved.cacheMaxMemorySize).toBe(52428800);
});
it("defaults cacheMaxMemorySize to undefined when not configured", async () => {
const resolved = await resolveNextConfig({});
expect(resolved.cacheMaxMemorySize).toBeUndefined();
});
});
describe("resolveNextConfig enablePrerenderSourceMaps", () => {
it("defaults enablePrerenderSourceMaps to true when not configured", async () => {
const resolved = await resolveNextConfig({});
expect(resolved.enablePrerenderSourceMaps).toBe(true);
});
it("respects explicit enablePrerenderSourceMaps: false", async () => {
const resolved = await resolveNextConfig({
enablePrerenderSourceMaps: false,
});
expect(resolved.enablePrerenderSourceMaps).toBe(false);
});
});
// Regression for issue #1490:
// experimental.staleTimes should be surfaced through ResolvedNextConfig so the
// plugin can inject the values into the client-side router cache.
describe("resolveNextConfig staleTimes (#1490)", () => {
it("defaults to Next.js' { dynamic: 0, static: 300 } when no config is provided", async () => {
const resolved = await resolveNextConfig(null);
expect(resolved.staleTimes).toEqual({ dynamic: 0, static: 300 });
});
it("defaults to Next.js' { dynamic: 0, static: 300 } when experimental is not set", async () => {
const resolved = await resolveNextConfig({});
expect(resolved.staleTimes).toEqual({ dynamic: 0, static: 300 });
});
it("reads experimental.staleTimes.{dynamic,static} verbatim (in seconds)", async () => {
const resolved = await resolveNextConfig({
experimental: { staleTimes: { dynamic: 30, static: 180 } },
});
expect(resolved.staleTimes).toEqual({ dynamic: 30, static: 180 });
});
it("falls back to defaults for individually-omitted keys", async () => {
const resolvedDynOnly = await resolveNextConfig({
experimental: { staleTimes: { dynamic: 45 } },
});
expect(resolvedDynOnly.staleTimes).toEqual({ dynamic: 45, static: 300 });
const resolvedStaticOnly = await resolveNextConfig({
experimental: { staleTimes: { static: 600 } },
});
expect(resolvedStaticOnly.staleTimes).toEqual({ dynamic: 0, static: 600 });
});
it("falls back to defaults when staleTimes contains non-numeric values", async () => {
const resolved = await resolveNextConfig({
experimental: {
staleTimes: { dynamic: "oops" as unknown as number, static: undefined },
},
});
expect(resolved.staleTimes).toEqual({ dynamic: 0, static: 300 });
});
it("falls back to defaults when staleTimes contains negative values", async () => {
// Negative values are rejected at resolution time so we don't pass them
// downstream to `resolvePrefetchCacheTtl`, where they'd be re-validated.
// Matches the `seconds < 0` guard in `shims/navigation.ts`.
const resolved = await resolveNextConfig({
experimental: { staleTimes: { dynamic: -5, static: -1 } },
});
expect(resolved.staleTimes).toEqual({ dynamic: 0, static: 300 });
});
});
describe("resolveNextConfig appShells", () => {
it("defaults appShells to false when not set", async () => {
const resolved = await resolveNextConfig({});
expect(resolved.appShells).toBe(false);
});
it("defaults appShells to false for null config", async () => {
const resolved = await resolveNextConfig(null);
expect(resolved.appShells).toBe(false);
});
it("reads appShells: true from experimental config", async () => {
const resolved = await resolveNextConfig({
experimental: { appShells: true },
});
expect(resolved.appShells).toBe(true);
});
it("reads appShells: false from experimental config", async () => {
const resolved = await resolveNextConfig({
experimental: { appShells: false },
});
expect(resolved.appShells).toBe(false);
});
it("warns when appShells is enabled without required co-flags", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const resolved = await resolveNextConfig({
experimental: { appShells: true },
});
expect(resolved.appShells).toBe(true);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining(
"experimental.appShells is enabled but requires the following co-flags",
),
);
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("cacheComponents"));
warnSpy.mockRestore();
});
it("does not warn when appShells is enabled with all required co-flags", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const resolved = await resolveNextConfig({
cacheComponents: true,
experimental: {
appShells: true,
prefetchInlining: true,
varyParams: true,
optimisticRouting: true,
cachedNavigations: true,
},
});
expect(resolved.appShells).toBe(true);
// The warning should NOT contain the appShells co-flags message
const appShellsWarnings = warnSpy.mock.calls.filter(
(call) =>
typeof call[0] === "string" && call[0].includes("experimental.appShells is enabled"),
);
expect(appShellsWarnings).toHaveLength(0);
warnSpy.mockRestore();
});
});
describe("lightningCssFeatureNamesToMask", () => {
// Bit values are copied from `lightningcss/node/targets.d.ts` (`Features` enum)
// and `.nextjs-ref/crates/next-core/src/next_config.rs`
// (`lightningcss_feature_names_to_mask`).
it("returns 0 for an empty list", () => {
expect(lightningCssFeatureNamesToMask([])).toBe(0);
});
it("maps individual feature names to their canonical bit values", () => {
expect(lightningCssFeatureNamesToMask(["nesting"])).toBe(1);
expect(lightningCssFeatureNamesToMask(["not-selector-list"])).toBe(2);
expect(lightningCssFeatureNamesToMask(["light-dark"])).toBe(1048576);
expect(lightningCssFeatureNamesToMask(["logical-properties"])).toBe(524288);
});
it("maps composite groups to the OR of their constituent bits", () => {
expect(lightningCssFeatureNamesToMask(["selectors"])).toBe(31);
expect(lightningCssFeatureNamesToMask(["media-queries"])).toBe(448);
expect(lightningCssFeatureNamesToMask(["colors"])).toBe(1113088);
});
it("OR-merges multiple names into a single bitmask", () => {
expect(lightningCssFeatureNamesToMask(["nesting", "light-dark"])).toBe(1 | 1048576);
});
it("warns and skips unknown feature names", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
expect(lightningCssFeatureNamesToMask(["nesting", "not-a-real-feature"])).toBe(1);
expect(warn).toHaveBeenCalledWith(expect.stringContaining("not-a-real-feature"));
} finally {
warn.mockRestore();
}
});
});
describe("resolveNextConfig experimental.lightningCssFeatures", () => {
it("defaults to disabled with empty bitmasks when unset", async () => {
const resolved = await resolveNextConfig({});
expect(resolved.useLightningcss).toBe(false);
expect(resolved.lightningCssFeatures).toEqual({ include: 0, exclude: 0 });
});
it("plumbs `useLightningcss: true` through", async () => {
const resolved = await resolveNextConfig({
experimental: { useLightningcss: true },
});
expect(resolved.useLightningcss).toBe(true);
});
it("converts dash-case feature names into the canonical bitmask", async () => {
const resolved = await resolveNextConfig({
experimental: {
useLightningcss: true,
lightningCssFeatures: {
include: ["nesting"],
exclude: ["light-dark", "logical-properties"],
},
},
});
expect(resolved.lightningCssFeatures).toEqual({
include: 1,
exclude: 1048576 | 524288,
});
});
it("warns when lightningCssFeatures is set without useLightningcss", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
const resolved = await resolveNextConfig({
experimental: {
lightningCssFeatures: { exclude: ["light-dark"] },
},
});
expect(resolved.useLightningcss).toBe(false);
expect(resolved.lightningCssFeatures.exclude).toBe(1048576);
expect(warn).toHaveBeenCalledWith(
expect.stringContaining("lightningCssFeatures is set but experimental.useLightningcss"),
);
} finally {
warn.mockRestore();
}
});
});