mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
3731 lines
135 KiB
TypeScript
3731 lines
135 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, vi } from "vite-plus/test";
|
||
import fs from "node:fs";
|
||
import path from "node:path";
|
||
import os from "node:os";
|
||
import { execFileSync, type ChildProcess, type spawn } from "node:child_process";
|
||
import { EventEmitter } from "node:events";
|
||
import { PassThrough } from "node:stream";
|
||
import {
|
||
deploy,
|
||
buildNodeCliInvocation,
|
||
buildWranglerKVBulkPutArgs,
|
||
buildWranglerInvocation,
|
||
buildWranglerDeployArgs,
|
||
getZeroPercentStagingTraffic,
|
||
parseDeployArgs,
|
||
resolveWorkerNameForVersionOverride,
|
||
resolveWranglerBin,
|
||
runWranglerKVBulkPut,
|
||
runWranglerDeploy,
|
||
validateWranglerEnvName,
|
||
withCloudflareEnv,
|
||
} from "../packages/cloudflare/src/deploy.js";
|
||
import {
|
||
detectPackageManager,
|
||
detectPackageManagerName,
|
||
detectProject,
|
||
findInNodeModules,
|
||
formatMissingCloudflarePluginError,
|
||
getMissingDeps,
|
||
hasWranglerConfig,
|
||
ensureESModule,
|
||
renameCJSConfigs,
|
||
ensureViteConfigCompatibility,
|
||
isPackageResolvable,
|
||
} from "../packages/vinext/src/utils/project.js";
|
||
import {
|
||
formatMissingCacheAdapterError,
|
||
formatImageOptimizationHint,
|
||
resolveKvDataAdapterConfig,
|
||
viteConfigHasCacheAdapter,
|
||
viteConfigHasCloudflarePlugin,
|
||
viteConfigHasImageAdapter,
|
||
workerEntryHasCacheHandler,
|
||
} from "../packages/cloudflare/src/deploy-config.js";
|
||
import {
|
||
generateWranglerConfig,
|
||
generateAppRouterViteConfig,
|
||
generatePagesRouterViteConfig,
|
||
} from "../packages/vinext/src/init-cloudflare.js";
|
||
import { readPagesRouterEntrySource } from "./worker-entry-source.js";
|
||
import { scanPublicFileRoutes } from "../packages/vinext/src/utils/public-routes.js";
|
||
import { isUnknownRecord } from "../packages/vinext/src/utils/record.js";
|
||
import { computeClientRuntimeMetadata } from "../packages/vinext/src/utils/client-runtime-metadata.js";
|
||
import { toSlash } from "pathslash";
|
||
import {
|
||
buildPagesClientAssetsModule,
|
||
writePagesClientAssetsModuleIfMissing,
|
||
} from "../packages/vinext/src/build/pages-client-assets-module.js";
|
||
import { fetchWorkerFilesystemRoute } from "../packages/vinext/src/server/pages-request-pipeline.js";
|
||
import {
|
||
createStaticAssetRequest,
|
||
finalizeMissingStaticAssetResponse,
|
||
mergeHeaders,
|
||
resolveStaticAssetSignal,
|
||
} from "../packages/vinext/src/server/worker-utils.js";
|
||
import { createStaticFileSignal } from "../packages/vinext/src/server/request-pipeline.js";
|
||
import { domainCandidates, parseWranglerConfig, runTPR } from "../packages/cloudflare/src/tpr.js";
|
||
import {
|
||
parseCdnWarmupDeploymentUrl,
|
||
parseWorkerDeploymentUrl,
|
||
} from "../packages/cloudflare/src/worker-deployment-url.js";
|
||
|
||
// ─── Test Helpers ────────────────────────────────────────────────────────────
|
||
|
||
let tmpDir: string;
|
||
|
||
function createTmpDir(): string {
|
||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-deploy-test-"));
|
||
return dir;
|
||
}
|
||
|
||
function writeFile(dir: string, relativePath: string, content: string): void {
|
||
const fullPath = path.join(dir, relativePath);
|
||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||
fs.writeFileSync(fullPath, content, "utf-8");
|
||
}
|
||
|
||
function mkdir(dir: string, relativePath: string): void {
|
||
fs.mkdirSync(path.join(dir, relativePath), { recursive: true });
|
||
}
|
||
|
||
function writeWranglerPackageForTest(
|
||
dir: string,
|
||
bin: string | Record<string, string> = { wrangler: "bin/wrangler.js" },
|
||
) {
|
||
writeFile(dir, "node_modules/wrangler/package.json", JSON.stringify({ name: "wrangler", bin }));
|
||
writeFile(dir, "node_modules/wrangler/bin/wrangler.js", "#!/usr/bin/env node");
|
||
}
|
||
|
||
function expectedWranglerBinForTest(dir: string): string {
|
||
return fs.realpathSync(path.join(dir, "node_modules", "wrangler", "bin", "wrangler.js"));
|
||
}
|
||
|
||
function createMockChildProcess(output = "", code = 0): ChildProcess {
|
||
const child = new EventEmitter() as ChildProcess;
|
||
const childStdout = new PassThrough();
|
||
child.stdout = childStdout;
|
||
child.stderr = new PassThrough();
|
||
queueMicrotask(() => {
|
||
if (output) childStdout.write(output);
|
||
child.emit("close", code, null);
|
||
});
|
||
return child;
|
||
}
|
||
|
||
function readVinextPackageExports(): Record<string, unknown> {
|
||
const packageJsonPath = path.resolve("packages/vinext/package.json");
|
||
const parsed: unknown = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
|
||
if (!isUnknownRecord(parsed) || !isUnknownRecord(parsed.exports)) {
|
||
throw new Error("packages/vinext/package.json must define an exports object");
|
||
}
|
||
return parsed.exports;
|
||
}
|
||
|
||
function readCloudflarePackagePeerDependencies(): Record<string, unknown> {
|
||
const packageJsonPath = path.resolve("packages/cloudflare/package.json");
|
||
const parsed: unknown = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
|
||
if (!isUnknownRecord(parsed) || !isUnknownRecord(parsed.peerDependencies)) {
|
||
throw new Error("packages/cloudflare/package.json must define peerDependencies");
|
||
}
|
||
return parsed.peerDependencies;
|
||
}
|
||
|
||
function hasPackageExport(exportsMap: Record<string, unknown>, subpath: string): boolean {
|
||
if (Object.hasOwn(exportsMap, subpath)) return true;
|
||
|
||
for (const exportKey of Object.keys(exportsMap)) {
|
||
if (!exportKey.includes("*")) continue;
|
||
const [prefix, suffix] = exportKey.split("*");
|
||
if (prefix === undefined || suffix === undefined) continue;
|
||
if (subpath.startsWith(prefix) && subpath.endsWith(suffix)) return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTmpDir();
|
||
});
|
||
|
||
afterEach(() => {
|
||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||
});
|
||
|
||
// ─── Wrangler deploy args ───────────────────────────────────────────────────
|
||
|
||
describe("buildWranglerDeployArgs", () => {
|
||
it("uses plain deploy for production by default", () => {
|
||
expect(buildWranglerDeployArgs({})).toEqual({ args: ["deploy"], env: undefined });
|
||
});
|
||
|
||
it("maps --preview to wrangler --env preview", () => {
|
||
expect(buildWranglerDeployArgs({ preview: true })).toEqual({
|
||
args: ["deploy", "--env", "preview"],
|
||
env: "preview",
|
||
});
|
||
});
|
||
|
||
it("passes through explicit env names", () => {
|
||
expect(buildWranglerDeployArgs({ env: "staging" })).toEqual({
|
||
args: ["deploy", "--env", "staging"],
|
||
env: "staging",
|
||
});
|
||
});
|
||
|
||
it("passes through explicit Worker names", () => {
|
||
expect(buildWranglerDeployArgs({ name: "custom-worker", env: "staging" })).toEqual({
|
||
args: ["deploy", "--name", "custom-worker", "--env", "staging"],
|
||
env: "staging",
|
||
});
|
||
});
|
||
|
||
it("passes through explicit Wrangler config paths", () => {
|
||
expect(buildWranglerDeployArgs({ config: "dist/server/wrangler.json" })).toEqual({
|
||
args: ["deploy", "--config", "dist/server/wrangler.json"],
|
||
env: undefined,
|
||
});
|
||
});
|
||
|
||
it("prefers explicit env over --preview shorthand", () => {
|
||
expect(buildWranglerDeployArgs({ preview: true, env: "qa" })).toEqual({
|
||
args: ["deploy", "--env", "qa"],
|
||
env: "qa",
|
||
});
|
||
});
|
||
|
||
it("treats empty string env as production", () => {
|
||
expect(buildWranglerDeployArgs({ env: "" })).toEqual({ args: ["deploy"], env: undefined });
|
||
});
|
||
|
||
it("preserves shell metacharacters as a literal environment argument", () => {
|
||
const env = "preview & whoami > vinext-pwned.txt & rem";
|
||
expect(buildWranglerDeployArgs({ env })).toEqual({
|
||
args: ["deploy", "--env", env],
|
||
env,
|
||
});
|
||
});
|
||
|
||
it.each(["production", "preview-1", "staging_eu", "release.2026", "team/app @ 1"])(
|
||
"accepts Wrangler environment name %s",
|
||
(env) => {
|
||
expect(validateWranglerEnvName(env)).toBe(env);
|
||
},
|
||
);
|
||
|
||
it("rejects null bytes without imposing an artificial length limit", () => {
|
||
expect(() => validateWranglerEnvName("preview\0prod")).toThrow("null bytes");
|
||
expect(validateWranglerEnvName("a".repeat(1024))).toBe("a".repeat(1024));
|
||
});
|
||
});
|
||
|
||
describe("buildWranglerKVBulkPutArgs", () => {
|
||
it("uploads a bulk JSON file to the configured KV binding", () => {
|
||
expect(
|
||
buildWranglerKVBulkPutArgs({
|
||
binding: "VINEXT_KV_CACHE",
|
||
filePath: "/tmp/prerender-kv.json",
|
||
}),
|
||
).toEqual({
|
||
args: [
|
||
"kv",
|
||
"bulk",
|
||
"put",
|
||
"/tmp/prerender-kv.json",
|
||
"--binding",
|
||
"VINEXT_KV_CACHE",
|
||
"--remote",
|
||
],
|
||
env: undefined,
|
||
});
|
||
});
|
||
|
||
it("passes through the Wrangler environment when deploy targets one", () => {
|
||
expect(
|
||
buildWranglerKVBulkPutArgs({
|
||
binding: "VINEXT_KV_CACHE",
|
||
env: "staging",
|
||
filePath: "/tmp/prerender-kv.json",
|
||
}),
|
||
).toEqual({
|
||
args: [
|
||
"kv",
|
||
"bulk",
|
||
"put",
|
||
"/tmp/prerender-kv.json",
|
||
"--binding",
|
||
"VINEXT_KV_CACHE",
|
||
"--remote",
|
||
"--env",
|
||
"staging",
|
||
],
|
||
env: "staging",
|
||
});
|
||
});
|
||
|
||
it("rejects null bytes in Wrangler environment names", () => {
|
||
expect(() =>
|
||
buildWranglerKVBulkPutArgs({
|
||
binding: "VINEXT_KV_CACHE",
|
||
env: "preview\0prod",
|
||
filePath: "/tmp/prerender-kv.json",
|
||
}),
|
||
).toThrow("null bytes");
|
||
});
|
||
});
|
||
|
||
describe("deploy environment validation", () => {
|
||
it("rejects invalid environment names before project side effects", async () => {
|
||
writeFile(tmpDir, "package.json", '{"name":"unchanged"}\n');
|
||
const before = fs.readFileSync(path.join(tmpDir, "package.json"), "utf-8");
|
||
|
||
await expect(deploy({ root: tmpDir, env: "preview\0prod", dryRun: true })).rejects.toThrow(
|
||
"null bytes",
|
||
);
|
||
|
||
expect(fs.readFileSync(path.join(tmpDir, "package.json"), "utf-8")).toBe(before);
|
||
expect(fs.existsSync(path.join(tmpDir, ".vinext"))).toBe(false);
|
||
});
|
||
|
||
it("does not scaffold or mutate a project that has not run Cloudflare init", async () => {
|
||
writeFile(tmpDir, "package.json", '{"name":"unchanged"}\n');
|
||
writeFile(tmpDir, "app/page.tsx", "export default function Page() { return null; }\n");
|
||
const before = fs.readFileSync(path.join(tmpDir, "package.json"), "utf-8");
|
||
|
||
await expect(deploy({ root: tmpDir, dryRun: true })).rejects.toThrow(
|
||
"Run `vinext init --platform=cloudflare` first.",
|
||
);
|
||
|
||
expect(fs.readFileSync(path.join(tmpDir, "package.json"), "utf-8")).toBe(before);
|
||
expect(fs.existsSync(path.join(tmpDir, "vite.config.ts"))).toBe(false);
|
||
expect(fs.existsSync(path.join(tmpDir, "wrangler.jsonc"))).toBe(false);
|
||
expect(fs.existsSync(path.join(tmpDir, "worker"))).toBe(false);
|
||
});
|
||
|
||
it("does not require a custom Worker entry for App Router deployments", async () => {
|
||
writeFile(tmpDir, "package.json", '{"name":"app"}\n');
|
||
writeFile(tmpDir, "app/page.tsx", "export default function Page() { return null; }\n");
|
||
writeFile(tmpDir, "vite.config.ts", "export default {};\n");
|
||
writeFile(
|
||
tmpDir,
|
||
"wrangler.jsonc",
|
||
'{"main":"vinext/server/fetch-handler","assets":{"directory":"dist/client"}}\n',
|
||
);
|
||
|
||
await expect(deploy({ root: tmpDir, dryRun: true })).rejects.not.toThrow("Worker entry");
|
||
});
|
||
|
||
it("does not require a custom Worker entry for Pages Router deployments", async () => {
|
||
writeFile(tmpDir, "package.json", '{"name":"pages"}\n');
|
||
writeFile(tmpDir, "pages/index.tsx", "export default function Page() { return null; }\n");
|
||
writeFile(tmpDir, "vite.config.ts", "export default {};\n");
|
||
writeFile(
|
||
tmpDir,
|
||
"wrangler.jsonc",
|
||
'{"main":"vinext/server/fetch-handler","assets":{"directory":"dist/client"}}\n',
|
||
);
|
||
|
||
await expect(deploy({ root: tmpDir, dryRun: true })).rejects.not.toThrow("Worker entry");
|
||
});
|
||
});
|
||
|
||
// ─── CLOUDFLARE_ENV propagation (issue #1210) ──────────────────────────────
|
||
|
||
describe("withCloudflareEnv", () => {
|
||
let priorEnv: string | undefined;
|
||
let priorHadEnv: boolean;
|
||
|
||
beforeEach(() => {
|
||
priorHadEnv = "CLOUDFLARE_ENV" in process.env;
|
||
priorEnv = process.env.CLOUDFLARE_ENV;
|
||
delete process.env.CLOUDFLARE_ENV;
|
||
});
|
||
|
||
afterEach(() => {
|
||
if (priorHadEnv) {
|
||
process.env.CLOUDFLARE_ENV = priorEnv;
|
||
} else {
|
||
delete process.env.CLOUDFLARE_ENV;
|
||
}
|
||
});
|
||
|
||
it("sets CLOUDFLARE_ENV for the duration of the callback", async () => {
|
||
let observed: string | undefined;
|
||
await withCloudflareEnv("staging", async () => {
|
||
observed = process.env.CLOUDFLARE_ENV;
|
||
});
|
||
expect(observed).toBe("staging");
|
||
});
|
||
|
||
it("restores absence of CLOUDFLARE_ENV after the callback when no prior value existed", async () => {
|
||
await withCloudflareEnv("hml", async () => {
|
||
// no-op
|
||
});
|
||
expect("CLOUDFLARE_ENV" in process.env).toBe(false);
|
||
});
|
||
|
||
it("restores the prior CLOUDFLARE_ENV value after the callback", async () => {
|
||
process.env.CLOUDFLARE_ENV = "preview";
|
||
await withCloudflareEnv("staging", async () => {
|
||
expect(process.env.CLOUDFLARE_ENV).toBe("staging");
|
||
});
|
||
expect(process.env.CLOUDFLARE_ENV).toBe("preview");
|
||
});
|
||
|
||
it("does not touch process.env when env is undefined", async () => {
|
||
let observed: string | undefined = "sentinel";
|
||
await withCloudflareEnv(undefined, async () => {
|
||
observed = process.env.CLOUDFLARE_ENV;
|
||
});
|
||
expect(observed).toBeUndefined();
|
||
expect("CLOUDFLARE_ENV" in process.env).toBe(false);
|
||
});
|
||
|
||
it("does not touch process.env when env is empty string", async () => {
|
||
await withCloudflareEnv("", async () => {
|
||
expect("CLOUDFLARE_ENV" in process.env).toBe(false);
|
||
});
|
||
expect("CLOUDFLARE_ENV" in process.env).toBe(false);
|
||
});
|
||
|
||
it("restores CLOUDFLARE_ENV after the callback throws", async () => {
|
||
process.env.CLOUDFLARE_ENV = "preview";
|
||
await expect(
|
||
withCloudflareEnv("staging", async () => {
|
||
throw new Error("boom");
|
||
}),
|
||
).rejects.toThrow("boom");
|
||
expect(process.env.CLOUDFLARE_ENV).toBe("preview");
|
||
});
|
||
|
||
it("returns the callback's resolved value", async () => {
|
||
const result = await withCloudflareEnv("staging", async () => 42);
|
||
expect(result).toBe(42);
|
||
});
|
||
});
|
||
|
||
// ─── Wrangler JavaScript entrypoint resolution ──────────────────────────────
|
||
|
||
describe("resolveWranglerBin", () => {
|
||
function writeWranglerPackage(
|
||
bin: string | Record<string, string> = { wrangler: "bin/wrangler.js" },
|
||
) {
|
||
writeWranglerPackageForTest(tmpDir, bin);
|
||
}
|
||
|
||
function expectedWranglerBin(): string {
|
||
return expectedWranglerBinForTest(tmpDir);
|
||
}
|
||
|
||
it("resolves the JavaScript entrypoint from Wrangler's bin map", () => {
|
||
writeWranglerPackage();
|
||
expect(resolveWranglerBin(tmpDir)).toBe(expectedWranglerBin());
|
||
});
|
||
|
||
it("supports a string-valued package bin", () => {
|
||
writeWranglerPackage("bin/wrangler.js");
|
||
expect(resolveWranglerBin(tmpDir)).toBe(expectedWranglerBin());
|
||
});
|
||
|
||
it("walks up to a hoisted workspace node_modules", () => {
|
||
mkdir(tmpDir, "apps/web");
|
||
writeWranglerPackage();
|
||
expect(resolveWranglerBin(path.join(tmpDir, "apps", "web"))).toBe(expectedWranglerBin());
|
||
});
|
||
|
||
it("supports package resolvers such as Yarn Plug'n'Play", () => {
|
||
writeWranglerPackage();
|
||
const packageJsonPath = path.join(tmpDir, "node_modules", "wrangler", "package.json");
|
||
expect(resolveWranglerBin("/virtual/project", () => packageJsonPath)).toBe(
|
||
path.join(tmpDir, "node_modules", "wrangler", "bin", "wrangler.js"),
|
||
);
|
||
});
|
||
|
||
it("returns a clear fallback path when Wrangler is missing", () => {
|
||
expect(resolveWranglerBin(tmpDir, () => null)).toBe(
|
||
path.join(tmpDir, "node_modules", "wrangler", "bin", "wrangler.js"),
|
||
);
|
||
});
|
||
|
||
it("passes deploy arguments literally to Node without a command shell", () => {
|
||
writeWranglerPackage();
|
||
expect(buildWranglerInvocation(tmpDir, { env: "preview-1" }, "node.exe")).toEqual({
|
||
file: "node.exe",
|
||
args: [expectedWranglerBin(), "deploy", "--env", "preview-1"],
|
||
env: "preview-1",
|
||
});
|
||
});
|
||
|
||
it("keeps Windows shell metacharacters in one literal argument", () => {
|
||
const payload = "preview & whoami > vinext-pwned.txt & rem";
|
||
expect(buildNodeCliInvocation("wrangler.js", ["deploy", "--env", payload], "node.exe")).toEqual(
|
||
{
|
||
file: "node.exe",
|
||
args: ["wrangler.js", "deploy", "--env", payload],
|
||
},
|
||
);
|
||
});
|
||
|
||
it("executes Wrangler with shell disabled and literal metacharacters", async () => {
|
||
writeWranglerPackage();
|
||
const payload = "preview & whoami > vinext-pwned.txt & rem";
|
||
let observed: Parameters<typeof spawn> | undefined;
|
||
const execute = ((...args: Parameters<typeof spawn>) => {
|
||
observed = args;
|
||
const child = new EventEmitter() as ChildProcess;
|
||
child.stdout = new PassThrough();
|
||
child.stderr = new PassThrough();
|
||
queueMicrotask(() => child.emit("close", 0, null));
|
||
return child;
|
||
}) as typeof spawn;
|
||
|
||
await runWranglerDeploy(tmpDir, { env: payload }, execute);
|
||
|
||
expect(observed?.[0]).toBe(process.execPath);
|
||
expect(observed?.[1]).toEqual([expectedWranglerBin(), "deploy", "--env", payload]);
|
||
expect(observed?.[2]).toMatchObject({ shell: false });
|
||
});
|
||
|
||
it("streams Wrangler output before the process exits", async () => {
|
||
writeWranglerPackage();
|
||
const child = new EventEmitter() as ChildProcess;
|
||
const childStdout = new PassThrough();
|
||
const childStderr = new PassThrough();
|
||
child.stdout = childStdout;
|
||
child.stderr = childStderr;
|
||
const execute = vi.fn(() => child) as unknown as typeof spawn;
|
||
const stdoutWrite = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
||
const stderrWrite = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
||
|
||
const deployment = runWranglerDeploy(tmpDir, {}, execute);
|
||
childStdout.write("Uploading assets...\n");
|
||
childStderr.write("Uploaded 10/20 files\n");
|
||
|
||
expect(stdoutWrite).toHaveBeenCalledWith("Uploading assets...\n");
|
||
expect(stderrWrite).toHaveBeenCalledWith("Uploaded 10/20 files\n");
|
||
|
||
childStdout.write("https://app.example.workers.dev\n");
|
||
child.emit("close", 0, null);
|
||
|
||
await expect(deployment).resolves.toBe("https://app.example.workers.dev");
|
||
stdoutWrite.mockRestore();
|
||
stderrWrite.mockRestore();
|
||
});
|
||
|
||
it("returns a custom domain from Wrangler deployment output", async () => {
|
||
writeWranglerPackage();
|
||
const child = new EventEmitter() as ChildProcess;
|
||
const childStdout = new PassThrough();
|
||
child.stdout = childStdout;
|
||
child.stderr = new PassThrough();
|
||
const execute = vi.fn(() => child) as unknown as typeof spawn;
|
||
const stdoutWrite = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
||
|
||
const deployment = runWranglerDeploy(tmpDir, {}, execute);
|
||
childStdout.write(
|
||
"Uploaded example-worker\nDeployed example-worker triggers\n app.example.com (custom domain)\n",
|
||
);
|
||
child.emit("close", 0, null);
|
||
|
||
await expect(deployment).resolves.toBe("https://app.example.com");
|
||
stdoutWrite.mockRestore();
|
||
});
|
||
|
||
it("does not execute metacharacters in a real subprocess", () => {
|
||
const argvPath = path.join(tmpDir, "argv.json");
|
||
const pwnedPath = path.join(tmpDir, "vinext-pwned.txt");
|
||
const scriptPath = path.join(tmpDir, "capture-argv.cjs");
|
||
const payload = `preview & echo pwned > ${pwnedPath} & rem`;
|
||
writeFile(
|
||
tmpDir,
|
||
"capture-argv.cjs",
|
||
`require("node:fs").writeFileSync(${JSON.stringify(argvPath)}, JSON.stringify(process.argv.slice(2)))`,
|
||
);
|
||
const invocation = buildNodeCliInvocation(scriptPath, ["deploy", "--env", payload]);
|
||
|
||
execFileSync(invocation.file, invocation.args, { cwd: tmpDir, shell: false });
|
||
|
||
expect(JSON.parse(fs.readFileSync(argvPath, "utf-8"))).toEqual(["deploy", "--env", payload]);
|
||
expect(fs.existsSync(pwnedPath)).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe("parseWorkerDeploymentUrl", () => {
|
||
it.each([
|
||
"app.example.com (custom domain - zone id: 023e105f4ecef8ad9ca31a8372d0c353)",
|
||
"app.example.com (custom domain - zone name: example.com)",
|
||
"app.example.com (custom domain) [enabled, previews: disabled]",
|
||
])("parses Wrangler custom-domain target variants: %s", (target) => {
|
||
expect(parseWorkerDeploymentUrl(`Deployed app triggers\n ${target}\n`)).toBe(
|
||
"https://app.example.com",
|
||
);
|
||
});
|
||
|
||
it("does not report an ordinary route pattern as a canonical URL", () => {
|
||
expect(parseWorkerDeploymentUrl("Deployed app triggers\n app.example.com/*\n")).toBeNull();
|
||
});
|
||
|
||
it.each([
|
||
["app.example.com/*", "https://app.example.com"],
|
||
["https://app.example.com/*", "https://app.example.com"],
|
||
["http://app.example.com/*", "http://app.example.com"],
|
||
])("uses a concrete catch-all Worker route for CDN warmup: %s", (route, expected) => {
|
||
expect(parseCdnWarmupDeploymentUrl(`Deployed app triggers\n ${route}\n`)).toBe(expected);
|
||
});
|
||
|
||
it("prefers a concrete Worker route over a workers.dev fallback", () => {
|
||
expect(
|
||
parseCdnWarmupDeploymentUrl(
|
||
"Deployed app triggers\n https://app.account.workers.dev\n app.example.com/*\n",
|
||
),
|
||
).toBe("https://app.example.com");
|
||
});
|
||
|
||
it.each(["*.example.com/*", "app.example.com/api/*", "app.example.com/"])(
|
||
"rejects a non-canonical Worker route for CDN warmup: %s",
|
||
(route) => {
|
||
expect(parseCdnWarmupDeploymentUrl(`Deployed app triggers\n ${route}\n`)).toBeNull();
|
||
},
|
||
);
|
||
|
||
it("does not report a disabled custom domain", () => {
|
||
expect(
|
||
parseWorkerDeploymentUrl(
|
||
"Deployed app triggers\n app.example.com (custom domain) [disabled]\n",
|
||
),
|
||
).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe("runWranglerKVBulkPut", () => {
|
||
it("writes prerender pairs to a temporary file and invokes Wrangler without a shell", async () => {
|
||
writeWranglerPackageForTest(tmpDir);
|
||
let observed: Parameters<typeof spawn> | undefined;
|
||
let bulkFilePath = "";
|
||
let bulkFileContent: unknown;
|
||
const execute = ((...args: Parameters<typeof spawn>) => {
|
||
observed = args;
|
||
const wranglerArgs = args[1] as string[];
|
||
bulkFilePath = wranglerArgs[4] ?? "";
|
||
bulkFileContent = JSON.parse(fs.readFileSync(bulkFilePath, "utf-8"));
|
||
return createMockChildProcess();
|
||
}) as typeof spawn;
|
||
|
||
await runWranglerKVBulkPut(
|
||
tmpDir,
|
||
{
|
||
binding: "VINEXT_KV_CACHE",
|
||
env: "staging",
|
||
pairs: [
|
||
{
|
||
key: "cache:app:build:/about:html",
|
||
value: '{"value":{"kind":"APP_PAGE"}}',
|
||
expiration_ttl: 86400,
|
||
metadata: { tags: ["/about"] },
|
||
},
|
||
],
|
||
tempDir: tmpDir,
|
||
},
|
||
execute,
|
||
"node.exe",
|
||
);
|
||
|
||
expect(observed?.[0]).toBe("node.exe");
|
||
expect(observed?.[1]).toEqual([
|
||
expectedWranglerBinForTest(tmpDir),
|
||
"kv",
|
||
"bulk",
|
||
"put",
|
||
bulkFilePath,
|
||
"--binding",
|
||
"VINEXT_KV_CACHE",
|
||
"--remote",
|
||
"--env",
|
||
"staging",
|
||
]);
|
||
expect(observed?.[2]).toMatchObject({ cwd: tmpDir, shell: false, stdio: "inherit" });
|
||
expect(bulkFileContent).toEqual([
|
||
{
|
||
key: "cache:app:build:/about:html",
|
||
value: '{"value":{"kind":"APP_PAGE"}}',
|
||
expiration_ttl: 86400,
|
||
metadata: { tags: ["/about"] },
|
||
},
|
||
]);
|
||
expect(fs.existsSync(path.dirname(bulkFilePath))).toBe(false);
|
||
});
|
||
|
||
it("uploads prerender pairs in OpenNext-style chunks", async () => {
|
||
writeWranglerPackageForTest(tmpDir);
|
||
const bulkFileContents: unknown[] = [];
|
||
const execute = ((...args: Parameters<typeof spawn>) => {
|
||
const wranglerArgs = args[1] as string[];
|
||
bulkFileContents.push(JSON.parse(fs.readFileSync(wranglerArgs[4] ?? "", "utf-8")));
|
||
return createMockChildProcess();
|
||
}) as typeof spawn;
|
||
|
||
await runWranglerKVBulkPut(
|
||
tmpDir,
|
||
{
|
||
binding: "VINEXT_KV_CACHE",
|
||
pairs: Array.from({ length: 26 }, (_, i) => ({
|
||
key: `cache:app:build:/route-${i}:html`,
|
||
value: String(i),
|
||
})),
|
||
tempDir: tmpDir,
|
||
},
|
||
execute,
|
||
"node.exe",
|
||
);
|
||
|
||
expect(bulkFileContents).toHaveLength(2);
|
||
expect(bulkFileContents).toEqual([
|
||
Array.from({ length: 25 }, (_, i) => ({
|
||
key: `cache:app:build:/route-${i}:html`,
|
||
value: String(i),
|
||
})),
|
||
[
|
||
{
|
||
key: "cache:app:build:/route-25:html",
|
||
value: "25",
|
||
},
|
||
],
|
||
]);
|
||
});
|
||
});
|
||
|
||
// ─── Deploy CLI arg parsing ─────────────────────────────────────────────────
|
||
|
||
describe("parseDeployArgs", () => {
|
||
it("forwards the parsed Wrangler config through the deploy CLI", () => {
|
||
const cliSource = fs.readFileSync(
|
||
path.join(process.cwd(), "packages/cloudflare/src/cli.ts"),
|
||
"utf-8",
|
||
);
|
||
|
||
expect(cliSource).toMatch(/config:\s*parsed\.config/);
|
||
});
|
||
|
||
it("defaults to production deploy with no flags", () => {
|
||
const parsed = parseDeployArgs([]);
|
||
expect(parsed.preview).toBe(false);
|
||
expect(parsed.env).toBeUndefined();
|
||
expect(parsed.name).toBeUndefined();
|
||
expect(parsed.skipBuild).toBe(false);
|
||
expect(parsed.dryRun).toBe(false);
|
||
expect(parsed.warmCdnCache).toBe(false);
|
||
expect(parsed.dangerouslyPromoteOnCdnWarmError).toBe(false);
|
||
});
|
||
|
||
it("parses --env with space-separated value", () => {
|
||
expect(parseDeployArgs(["--env", "staging"]).env).toBe("staging");
|
||
});
|
||
|
||
it("parses --env=value form", () => {
|
||
expect(parseDeployArgs(["--env=staging"]).env).toBe("staging");
|
||
});
|
||
|
||
it("parses --name with space-separated value", () => {
|
||
expect(parseDeployArgs(["--name", "my-app"]).name).toBe("my-app");
|
||
});
|
||
|
||
it("parses --name=value form", () => {
|
||
expect(parseDeployArgs(["--name=my-app"]).name).toBe("my-app");
|
||
});
|
||
|
||
it("parses --config with space-separated value", () => {
|
||
expect(parseDeployArgs(["--config", "dist/server/wrangler.json"]).config).toBe(
|
||
"dist/server/wrangler.json",
|
||
);
|
||
});
|
||
|
||
it("parses boolean flags", () => {
|
||
const parsed = parseDeployArgs(["--preview", "--skip-build", "--dry-run"]);
|
||
expect(parsed.preview).toBe(true);
|
||
expect(parsed.skipBuild).toBe(true);
|
||
expect(parsed.dryRun).toBe(true);
|
||
});
|
||
|
||
it("parses numeric TPR flags from string values", () => {
|
||
const parsed = parseDeployArgs([
|
||
"--experimental-tpr",
|
||
"--tpr-coverage",
|
||
"95",
|
||
"--tpr-limit",
|
||
"500",
|
||
"--tpr-window",
|
||
"48",
|
||
]);
|
||
expect(parsed.experimentalTPR).toBe(true);
|
||
expect(parsed.tprCoverage).toBe(95);
|
||
expect(parsed.tprLimit).toBe(500);
|
||
expect(parsed.tprWindow).toBe(48);
|
||
});
|
||
|
||
it("parses --prerender-concurrency with space-separated value", () => {
|
||
expect(parseDeployArgs(["--prerender-concurrency", "4"]).prerenderConcurrency).toBe(4);
|
||
});
|
||
|
||
it("parses --prerender-concurrency=value form", () => {
|
||
expect(parseDeployArgs(["--prerender-concurrency=4"]).prerenderConcurrency).toBe(4);
|
||
});
|
||
|
||
it("throws for missing --prerender-concurrency value", () => {
|
||
expect(() => parseDeployArgs(["--prerender-concurrency"])).toThrow();
|
||
});
|
||
|
||
it("throws for invalid --prerender-concurrency value", () => {
|
||
expect(() => parseDeployArgs(["--prerender-concurrency", "abc"])).toThrow(
|
||
'--prerender-concurrency expects a positive integer, but got "abc".',
|
||
);
|
||
});
|
||
|
||
it("throws for zero --prerender-concurrency value", () => {
|
||
expect(() => parseDeployArgs(["--prerender-concurrency=0"])).toThrow(
|
||
'--prerender-concurrency expects a positive integer, but got "0".',
|
||
);
|
||
});
|
||
|
||
it("parses CDN warmup flags", () => {
|
||
const parsed = parseDeployArgs([
|
||
"--experimental-warm-cdn-cache",
|
||
"--warm-cdn-concurrency",
|
||
"6",
|
||
"--warm-cdn-timeout=1500",
|
||
"--warm-cdn-retries",
|
||
"0",
|
||
"--warm-cdn-discovery-timeout=90000",
|
||
"--warm-cdn-discovery-retries=7",
|
||
"--warm-cdn-readiness-timeout=45000",
|
||
"--warm-cdn-readiness-retries=9",
|
||
"--warm-cdn-readiness-probes=8",
|
||
"--warm-cdn-readiness-probe-delay",
|
||
"750",
|
||
"--dangerously-promote-on-cdn-warm-error",
|
||
"--warm-cdn-no-promote",
|
||
"--warm-cdn-promotion-delay=2500",
|
||
"--warm-cdn-include-fallbacks",
|
||
]);
|
||
|
||
expect(parsed.warmCdnCache).toBe(true);
|
||
expect(parsed.warmCdnConcurrency).toBe(6);
|
||
expect(parsed.warmCdnTimeout).toBe(1500);
|
||
expect(parsed.warmCdnRetries).toBe(0);
|
||
expect(parsed.warmCdnDiscoveryTimeout).toBe(90_000);
|
||
expect(parsed.warmCdnDiscoveryRetries).toBe(7);
|
||
expect(parsed.warmCdnReadinessTimeout).toBe(45_000);
|
||
expect(parsed.warmCdnReadinessRetries).toBe(9);
|
||
expect(parsed.warmCdnReadinessProbes).toBe(8);
|
||
expect(parsed.warmCdnReadinessProbeDelay).toBe(750);
|
||
expect(parsed.dangerouslyPromoteOnCdnWarmError).toBe(true);
|
||
expect(parsed.warmCdnPromote).toBe(false);
|
||
expect(parsed.warmCdnPromotionDelay).toBe(2500);
|
||
expect(parsed.warmCdnIncludeFallbacks).toBe(true);
|
||
});
|
||
|
||
it("promotes warmed Worker versions by default", () => {
|
||
expect(parseDeployArgs([]).warmCdnPromote).toBe(true);
|
||
});
|
||
|
||
it("allows the CDN warmup promotion delay to be set to zero", () => {
|
||
expect(parseDeployArgs(["--warm-cdn-promotion-delay=0"]).warmCdnPromotionDelay).toBe(0);
|
||
});
|
||
|
||
it("allows the staged-readiness probe delay to be set to zero", () => {
|
||
expect(parseDeployArgs(["--warm-cdn-readiness-probe-delay=0"]).warmCdnReadinessProbeDelay).toBe(
|
||
0,
|
||
);
|
||
});
|
||
|
||
it("throws for invalid CDN warmup numeric flags", () => {
|
||
expect(() => parseDeployArgs(["--warm-cdn-concurrency=0"])).toThrow(
|
||
'--warm-cdn-concurrency expects a positive integer, but got "0".',
|
||
);
|
||
expect(() => parseDeployArgs(["--warm-cdn-retries=-1"])).toThrow(
|
||
'--warm-cdn-retries expects a non-negative integer, but got "-1".',
|
||
);
|
||
expect(() => parseDeployArgs(["--warm-cdn-discovery-timeout=0"])).toThrow(
|
||
'--warm-cdn-discovery-timeout expects a positive integer, but got "0".',
|
||
);
|
||
expect(() => parseDeployArgs(["--warm-cdn-discovery-retries=-1"])).toThrow(
|
||
'--warm-cdn-discovery-retries expects a non-negative integer, but got "-1".',
|
||
);
|
||
expect(() => parseDeployArgs(["--warm-cdn-readiness-timeout=0"])).toThrow(
|
||
'--warm-cdn-readiness-timeout expects a positive integer, but got "0".',
|
||
);
|
||
expect(() => parseDeployArgs(["--warm-cdn-readiness-retries=-1"])).toThrow(
|
||
'--warm-cdn-readiness-retries expects a non-negative integer, but got "-1".',
|
||
);
|
||
expect(() => parseDeployArgs(["--warm-cdn-readiness-probes=0"])).toThrow(
|
||
'--warm-cdn-readiness-probes expects a positive integer, but got "0".',
|
||
);
|
||
expect(() => parseDeployArgs(["--warm-cdn-readiness-probes=1.5"])).toThrow(
|
||
'--warm-cdn-readiness-probes expects a positive integer, but got "1.5".',
|
||
);
|
||
expect(() => parseDeployArgs(["--warm-cdn-readiness-probe-delay=-1"])).toThrow(
|
||
'--warm-cdn-readiness-probe-delay expects a non-negative integer, but got "-1".',
|
||
);
|
||
expect(() => parseDeployArgs(["--warm-cdn-readiness-probe-delay=2147483648"])).toThrow(
|
||
'--warm-cdn-readiness-probe-delay must not exceed 2147483647 milliseconds, but got "2147483648".',
|
||
);
|
||
expect(() => parseDeployArgs(["--warm-cdn-promotion-delay=-1"])).toThrow(
|
||
'--warm-cdn-promotion-delay expects a non-negative integer, but got "-1".',
|
||
);
|
||
expect(() => parseDeployArgs(["--warm-cdn-promotion-delay=2147483648"])).toThrow(
|
||
'--warm-cdn-promotion-delay must not exceed 2147483647 milliseconds, but got "2147483648".',
|
||
);
|
||
});
|
||
|
||
it("trims whitespace from --env value", () => {
|
||
expect(parseDeployArgs(["--env", " staging "]).env).toBe("staging");
|
||
});
|
||
|
||
it("treats whitespace-only --env as undefined", () => {
|
||
expect(parseDeployArgs(["--env", " "]).env).toBeUndefined();
|
||
});
|
||
|
||
it("throws on unknown flags (strict mode)", () => {
|
||
expect(() => parseDeployArgs(["--bogus"])).toThrow();
|
||
});
|
||
});
|
||
|
||
// ─── detectProject ──────────────────────────────────────────────────────────
|
||
|
||
describe("detectProject", () => {
|
||
it("detects App Router when app/ exists", () => {
|
||
mkdir(tmpDir, "app");
|
||
writeFile(tmpDir, "app/page.tsx", "export default function Home() { return <div>hi</div> }");
|
||
const info = detectProject(tmpDir);
|
||
expect(info.isAppRouter).toBe(true);
|
||
expect(info.isPagesRouter).toBe(false);
|
||
});
|
||
|
||
it("detects Pages Router when only pages/ exists", () => {
|
||
mkdir(tmpDir, "pages");
|
||
writeFile(tmpDir, "pages/index.tsx", "export default function Home() { return <div>hi</div> }");
|
||
const info = detectProject(tmpDir);
|
||
expect(info.isAppRouter).toBe(false);
|
||
expect(info.isPagesRouter).toBe(true);
|
||
});
|
||
|
||
it("prefers App Router when both app/ and pages/ exist", () => {
|
||
mkdir(tmpDir, "app");
|
||
mkdir(tmpDir, "pages");
|
||
const info = detectProject(tmpDir);
|
||
expect(info.isAppRouter).toBe(true);
|
||
expect(info.isPagesRouter).toBe(false);
|
||
});
|
||
|
||
it("detects neither when no app/ or pages/", () => {
|
||
const info = detectProject(tmpDir);
|
||
expect(info.isAppRouter).toBe(false);
|
||
expect(info.isPagesRouter).toBe(false);
|
||
});
|
||
|
||
it("does not treat app or pages files as router directories", () => {
|
||
writeFile(tmpDir, "app", "not a directory");
|
||
writeFile(tmpDir, "pages", "not a directory");
|
||
const info = detectProject(tmpDir);
|
||
expect(info.isAppRouter).toBe(false);
|
||
expect(info.isPagesRouter).toBe(false);
|
||
});
|
||
|
||
it("detects vite.config.ts", () => {
|
||
mkdir(tmpDir, "app");
|
||
writeFile(tmpDir, "vite.config.ts", "export default {}");
|
||
const info = detectProject(tmpDir);
|
||
expect(info.hasViteConfig).toBe(true);
|
||
});
|
||
|
||
it("detects vite.config.mjs", () => {
|
||
mkdir(tmpDir, "app");
|
||
writeFile(tmpDir, "vite.config.mjs", "export default {}");
|
||
const info = detectProject(tmpDir);
|
||
expect(info.hasViteConfig).toBe(true);
|
||
});
|
||
|
||
it("detects no vite config", () => {
|
||
mkdir(tmpDir, "app");
|
||
const info = detectProject(tmpDir);
|
||
expect(info.hasViteConfig).toBe(false);
|
||
});
|
||
|
||
it("detects wrangler.jsonc", () => {
|
||
mkdir(tmpDir, "app");
|
||
writeFile(tmpDir, "wrangler.jsonc", "{}");
|
||
const info = detectProject(tmpDir);
|
||
expect(info.hasWranglerConfig).toBe(true);
|
||
});
|
||
|
||
it("detects wrangler.toml", () => {
|
||
mkdir(tmpDir, "app");
|
||
writeFile(tmpDir, "wrangler.toml", "[vars]");
|
||
const info = detectProject(tmpDir);
|
||
expect(info.hasWranglerConfig).toBe(true);
|
||
});
|
||
|
||
it("detects cloudflare.config.ts", () => {
|
||
mkdir(tmpDir, "app");
|
||
writeFile(tmpDir, "cloudflare.config.ts", "export default {};");
|
||
expect(detectProject(tmpDir).hasWranglerConfig).toBe(true);
|
||
});
|
||
|
||
it("detects worker/index.ts", () => {
|
||
mkdir(tmpDir, "app");
|
||
writeFile(tmpDir, "worker/index.ts", "export default {}");
|
||
const info = detectProject(tmpDir);
|
||
expect(info.hasWorkerEntry).toBe(true);
|
||
});
|
||
|
||
it("detects worker/index.js", () => {
|
||
mkdir(tmpDir, "app");
|
||
writeFile(tmpDir, "worker/index.js", "export default {}");
|
||
const info = detectProject(tmpDir);
|
||
expect(info.hasWorkerEntry).toBe(true);
|
||
});
|
||
|
||
it("derives project name from package.json", () => {
|
||
mkdir(tmpDir, "app");
|
||
writeFile(tmpDir, "package.json", JSON.stringify({ name: "my-cool-app" }));
|
||
const info = detectProject(tmpDir);
|
||
expect(info.projectName).toBe("my-cool-app");
|
||
});
|
||
|
||
it("strips npm scope from project name", () => {
|
||
mkdir(tmpDir, "app");
|
||
writeFile(tmpDir, "package.json", JSON.stringify({ name: "@org/my-app" }));
|
||
const info = detectProject(tmpDir);
|
||
expect(info.projectName).toBe("my-app");
|
||
});
|
||
|
||
it("sanitizes project name for Workers", () => {
|
||
mkdir(tmpDir, "app");
|
||
writeFile(tmpDir, "package.json", JSON.stringify({ name: "My App_v2!" }));
|
||
const info = detectProject(tmpDir);
|
||
// Workers names: lowercase alphanumeric + hyphens
|
||
expect(info.projectName).toBe("my-app-v2");
|
||
});
|
||
|
||
it("falls back to directory name when no package.json", () => {
|
||
mkdir(tmpDir, "app");
|
||
const info = detectProject(tmpDir);
|
||
expect(info.projectName).toBe(path.basename(tmpDir));
|
||
});
|
||
|
||
it("detects ISR usage in App Router", () => {
|
||
mkdir(tmpDir, "app");
|
||
writeFile(
|
||
tmpDir,
|
||
"app/posts/page.tsx",
|
||
`export const revalidate = 60;\nexport default function Posts() { return <div>posts</div> }`,
|
||
);
|
||
const info = detectProject(tmpDir);
|
||
expect(info.hasISR).toBe(true);
|
||
});
|
||
|
||
it("does not detect ISR when no revalidate export", () => {
|
||
mkdir(tmpDir, "app");
|
||
writeFile(tmpDir, "app/page.tsx", "export default function Home() { return <div>hi</div> }");
|
||
const info = detectProject(tmpDir);
|
||
expect(info.hasISR).toBe(false);
|
||
});
|
||
|
||
it("detects ISR for Pages Router getStaticProps", () => {
|
||
mkdir(tmpDir, "pages");
|
||
writeFile(
|
||
tmpDir,
|
||
"pages/index.tsx",
|
||
"export async function getStaticProps() { return { props: {}, revalidate: 60 }; }",
|
||
);
|
||
const info = detectProject(tmpDir);
|
||
expect(info.hasISR).toBe(true);
|
||
});
|
||
|
||
it("detects caching from cacheComponents", () => {
|
||
mkdir(tmpDir, "app");
|
||
writeFile(tmpDir, "next.config.ts", "export default { cacheComponents: true };");
|
||
expect(detectProject(tmpDir).hasISR).toBe(true);
|
||
});
|
||
});
|
||
|
||
// ─── generateWranglerConfig ─────────────────────────────────────────────────
|
||
|
||
describe("generateWranglerConfig", () => {
|
||
it("generates valid JSON with required fields", () => {
|
||
mkdir(tmpDir, "app");
|
||
const info = detectProject(tmpDir);
|
||
const config = generateWranglerConfig(info);
|
||
const parsed = JSON.parse(config);
|
||
|
||
expect(parsed.name).toBe(info.projectName);
|
||
expect(parsed.compatibility_flags).toContain("nodejs_compat");
|
||
expect(parsed.main).toBe("vinext/server/fetch-handler");
|
||
expect(parsed.assets).toEqual({
|
||
directory: "dist/client",
|
||
not_found_handling: "none",
|
||
binding: "ASSETS",
|
||
});
|
||
expect(parsed.$schema).toBe("node_modules/wrangler/config-schema.json");
|
||
});
|
||
|
||
it("points Pages Router apps at the built-in fetch handler", () => {
|
||
mkdir(tmpDir, "pages");
|
||
writeFile(tmpDir, "pages/index.tsx", "export default function Page() { return null; }");
|
||
const info = detectProject(tmpDir);
|
||
const config = generateWranglerConfig(info);
|
||
const parsed = JSON.parse(config);
|
||
|
||
expect(parsed.main).toBe("vinext/server/fetch-handler");
|
||
});
|
||
|
||
it("sets compatibility_date to today", () => {
|
||
mkdir(tmpDir, "app");
|
||
const info = detectProject(tmpDir);
|
||
const config = generateWranglerConfig(info);
|
||
const parsed = JSON.parse(config);
|
||
|
||
const today = new Date().toISOString().split("T")[0];
|
||
expect(parsed.compatibility_date).toBe(today);
|
||
});
|
||
|
||
it("includes the default KV namespace", () => {
|
||
mkdir(tmpDir, "app");
|
||
writeFile(
|
||
tmpDir,
|
||
"app/page.tsx",
|
||
"export const revalidate = 30;\nexport default function() { return <div/> }",
|
||
);
|
||
const info = detectProject(tmpDir);
|
||
const config = generateWranglerConfig(info);
|
||
const parsed = JSON.parse(config);
|
||
|
||
expect(parsed.kv_namespaces).toBeDefined();
|
||
expect(parsed.kv_namespaces[0].binding).toBe("VINEXT_KV_CACHE");
|
||
});
|
||
|
||
it("omits KV namespace when KV caches are disabled", () => {
|
||
mkdir(tmpDir, "app");
|
||
writeFile(tmpDir, "app/page.tsx", "export default function() { return <div/> }");
|
||
const info = detectProject(tmpDir);
|
||
const config = generateWranglerConfig(info, {
|
||
dataCache: "none",
|
||
cdnCache: "workers-cache",
|
||
imageOptimization: "cloudflare-images",
|
||
});
|
||
const parsed = JSON.parse(config);
|
||
|
||
expect(parsed.kv_namespaces).toBeUndefined();
|
||
});
|
||
|
||
it("includes assets.directory pointing to dist/client (required by wrangler 4.69+)", () => {
|
||
mkdir(tmpDir, "app");
|
||
const info = detectProject(tmpDir);
|
||
const config = generateWranglerConfig(info);
|
||
const parsed = JSON.parse(config);
|
||
|
||
// Wrangler 4.69+ rejects assets blocks that lack `directory`.
|
||
// The @cloudflare/vite-plugin always writes static assets to dist/client/.
|
||
expect(parsed.assets.directory).toBe("dist/client");
|
||
});
|
||
|
||
it("includes Cloudflare Images binding for image optimization", () => {
|
||
mkdir(tmpDir, "app");
|
||
const info = detectProject(tmpDir);
|
||
const config = generateWranglerConfig(info);
|
||
const parsed = JSON.parse(config);
|
||
|
||
expect(parsed.images).toBeDefined();
|
||
expect(parsed.images.binding).toBe("IMAGES");
|
||
});
|
||
});
|
||
|
||
describe("viteConfigHasCacheAdapter", () => {
|
||
it("detects a data field assigned an adapter", () => {
|
||
writeFile(
|
||
tmpDir,
|
||
"vite.config.ts",
|
||
`import { kvDataAdapter } from "@vinext/cloudflare/cache/kv-data-adapter";
|
||
export default { plugins: [vinext({ cache: { data: kvDataAdapter() } })] };`,
|
||
);
|
||
expect(viteConfigHasCacheAdapter(tmpDir)).toBe(true);
|
||
});
|
||
|
||
it("detects a cdn field assigned an adapter", () => {
|
||
writeFile(
|
||
tmpDir,
|
||
"vite.config.ts",
|
||
`export default { plugins: [vinext({ cache: { cdn: cdnAdapter() } })] };`,
|
||
);
|
||
expect(viteConfigHasCacheAdapter(tmpDir)).toBe(true);
|
||
});
|
||
|
||
it("detects a hand-written descriptor object on the data field", () => {
|
||
writeFile(
|
||
tmpDir,
|
||
"vite.config.ts",
|
||
`export default {
|
||
plugins: [vinext({ cache: { data: { adapter: "./x.js", options: {} } } })],
|
||
};`,
|
||
);
|
||
expect(viteConfigHasCacheAdapter(tmpDir)).toBe(true);
|
||
});
|
||
|
||
it("returns false when the cache object is empty", () => {
|
||
writeFile(tmpDir, "vite.config.ts", `export default { plugins: [vinext({ cache: {} })] };`);
|
||
expect(viteConfigHasCacheAdapter(tmpDir)).toBe(false);
|
||
});
|
||
|
||
it("returns false when cdn/data are explicitly undefined", () => {
|
||
writeFile(
|
||
tmpDir,
|
||
"vite.config.ts",
|
||
`export default { plugins: [vinext({ cache: { cdn: undefined, data: undefined } })] };`,
|
||
);
|
||
expect(viteConfigHasCacheAdapter(tmpDir)).toBe(false);
|
||
});
|
||
|
||
it("returns false when there is no cache config at all", () => {
|
||
writeFile(tmpDir, "vite.config.ts", `export default { plugins: [vinext()] };`);
|
||
expect(viteConfigHasCacheAdapter(tmpDir)).toBe(false);
|
||
});
|
||
|
||
it("returns true (does not block) when there is no Vite config to inspect", () => {
|
||
expect(viteConfigHasCacheAdapter(tmpDir)).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe("resolveKvDataAdapterConfig", () => {
|
||
it("requires a Vite cache data descriptor even when a legacy worker handler exists", () => {
|
||
writeFile(
|
||
tmpDir,
|
||
"worker/index.ts",
|
||
`import { setDataCacheHandler } from "vinext/shims/cache";
|
||
setDataCacheHandler(handler);`,
|
||
);
|
||
|
||
expect(workerEntryHasCacheHandler(tmpDir)).toBe(true);
|
||
expect(resolveKvDataAdapterConfig(undefined)).toBeNull();
|
||
expect(resolveKvDataAdapterConfig({})).toBeNull();
|
||
});
|
||
|
||
it("returns null for non-KV data adapters", () => {
|
||
expect(resolveKvDataAdapterConfig({ data: { adapter: "custom-adapter" } })).toBeNull();
|
||
expect(resolveKvDataAdapterConfig({ cdn: { adapter: "cdn-adapter" } })).toBeNull();
|
||
});
|
||
|
||
it("detects Cloudflare KV runtime descriptors and preserves options", () => {
|
||
expect(
|
||
resolveKvDataAdapterConfig({
|
||
data: {
|
||
adapter: "/project/node_modules/@vinext/cloudflare/dist/cache/kv-data-adapter.runtime.js",
|
||
options: { binding: "MY_KV", appPrefix: "docs", ttlSeconds: 60 },
|
||
},
|
||
}),
|
||
).toEqual({ binding: "MY_KV", appPrefix: "docs", ttlSeconds: 60 });
|
||
});
|
||
|
||
it("uses the default KV binding when the adapter has no binding option", () => {
|
||
expect(
|
||
resolveKvDataAdapterConfig({
|
||
data: { adapter: "/x/cache/kv-data-adapter.runtime.js" },
|
||
}),
|
||
).toEqual({ binding: "VINEXT_KV_CACHE" });
|
||
});
|
||
});
|
||
|
||
describe("workerEntryHasCacheHandler", () => {
|
||
it("detects a setCacheHandler call in worker/index.ts", () => {
|
||
writeFile(
|
||
tmpDir,
|
||
"worker/index.ts",
|
||
`import { setCacheHandler } from "vinext/shims/cache";
|
||
setCacheHandler(new KVCacheHandler(env.VINEXT_KV_CACHE));`,
|
||
);
|
||
expect(workerEntryHasCacheHandler(tmpDir)).toBe(true);
|
||
});
|
||
|
||
it("detects a setDataCacheHandler call", () => {
|
||
writeFile(
|
||
tmpDir,
|
||
"worker/index.ts",
|
||
`import { setDataCacheHandler } from "vinext/shims/cache";
|
||
setDataCacheHandler(handler);`,
|
||
);
|
||
expect(workerEntryHasCacheHandler(tmpDir)).toBe(true);
|
||
});
|
||
|
||
it("detects a setCdnCacheAdapter call", () => {
|
||
writeFile(
|
||
tmpDir,
|
||
"worker/index.ts",
|
||
`import { setCdnCacheAdapter } from "vinext/shims/cdn-cache";
|
||
setCdnCacheAdapter(adapter);`,
|
||
);
|
||
expect(workerEntryHasCacheHandler(tmpDir)).toBe(true);
|
||
});
|
||
|
||
it("detects a setter in worker/index.js", () => {
|
||
writeFile(
|
||
tmpDir,
|
||
"worker/index.js",
|
||
`setCacheHandler(new KVCacheHandler(env.VINEXT_KV_CACHE));`,
|
||
);
|
||
expect(workerEntryHasCacheHandler(tmpDir)).toBe(true);
|
||
});
|
||
|
||
it("returns false when the Worker entry has no cache setter", () => {
|
||
writeFile(tmpDir, "worker/index.ts", `export default { fetch() {} };`);
|
||
expect(workerEntryHasCacheHandler(tmpDir)).toBe(false);
|
||
});
|
||
|
||
it("returns false when there is no Worker entry to inspect", () => {
|
||
expect(workerEntryHasCacheHandler(tmpDir)).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe("viteConfigHasImageAdapter", () => {
|
||
it("detects a configured image optimizer", () => {
|
||
writeFile(
|
||
tmpDir,
|
||
"vite.config.ts",
|
||
`export default { plugins: [vinext({ images: { optimizer: imagesOptimizer() } })] };`,
|
||
);
|
||
expect(viteConfigHasImageAdapter(tmpDir)).toBe(true);
|
||
});
|
||
|
||
it("returns false when image optimization is omitted", () => {
|
||
writeFile(tmpDir, "vite.config.ts", `export default { plugins: [vinext()] };`);
|
||
expect(viteConfigHasImageAdapter(tmpDir)).toBe(false);
|
||
});
|
||
|
||
it("returns false for an explicitly disabled optimizer", () => {
|
||
writeFile(
|
||
tmpDir,
|
||
"vite.config.ts",
|
||
`export default { plugins: [vinext({ images: { optimizer: undefined } })] };`,
|
||
);
|
||
expect(viteConfigHasImageAdapter(tmpDir)).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe("formatMissingCacheAdapterError", () => {
|
||
it("names the data adapter builder and the KV namespace command", () => {
|
||
const msg = formatMissingCacheAdapterError({});
|
||
expect(msg).toContain("no cache adapter is configured");
|
||
expect(msg).toContain("kvDataAdapter()");
|
||
expect(msg).toContain("npx wrangler kv namespace create VINEXT_KV_CACHE");
|
||
});
|
||
|
||
it("no longer references the cdn adapter", () => {
|
||
const msg = formatMissingCacheAdapterError({});
|
||
expect(msg).not.toContain("cdnAdapter");
|
||
expect(msg).not.toContain("cdn-adapter");
|
||
});
|
||
});
|
||
|
||
describe("formatImageOptimizationHint", () => {
|
||
it("points users to additive init configuration", () => {
|
||
const message = formatImageOptimizationHint();
|
||
expect(message).toContain("--image-optimization=cloudflare-images");
|
||
expect(message).toContain("imagesOptimizer()");
|
||
expect(message).toContain("IMAGES binding");
|
||
});
|
||
});
|
||
|
||
describe("scanPublicFileRoutes", () => {
|
||
it("rescans public files on each call instead of returning stale cached results", () => {
|
||
mkdir(tmpDir, "public");
|
||
writeFile(tmpDir, "public/first.txt", "one");
|
||
|
||
expect(scanPublicFileRoutes(tmpDir)).toEqual(["/first.txt"]);
|
||
|
||
writeFile(tmpDir, "public/nested/second.txt", "two");
|
||
|
||
expect(scanPublicFileRoutes(tmpDir)).toEqual(["/first.txt", "/nested/second.txt"]);
|
||
});
|
||
|
||
it("scans the configured public directory and respects publicDir: false", () => {
|
||
writeFile(tmpDir, "custom-public/custom.txt", "custom");
|
||
writeFile(tmpDir, "public/default.txt", "default");
|
||
|
||
expect(scanPublicFileRoutes(tmpDir, "custom-public")).toEqual(["/custom.txt"]);
|
||
expect(scanPublicFileRoutes(tmpDir, false)).toEqual([]);
|
||
// Vite's resolved config represents `publicDir: false` as an empty string.
|
||
expect(scanPublicFileRoutes(tmpDir, "")).toEqual([]);
|
||
});
|
||
|
||
// Ported from Next.js: test/e2e/dynamic-routing/shared.ts
|
||
// https://github.com/vercel/next.js/blob/canary/test/e2e/dynamic-routing/shared.ts
|
||
it("stores public-file routes with URL-encoded path segments", () => {
|
||
writeFile(tmpDir, "public/hello copy.txt", "space");
|
||
writeFile(tmpDir, "public/hello+copy.txt", "plus");
|
||
writeFile(tmpDir, "public/hello%20copy.txt", "percent");
|
||
|
||
expect(scanPublicFileRoutes(tmpDir)).toEqual([
|
||
"/hello copy.txt",
|
||
"/hello%20copy.txt",
|
||
"/hello%2520copy.txt",
|
||
"/hello%2Bcopy.txt",
|
||
"/hello+copy.txt",
|
||
]);
|
||
});
|
||
});
|
||
|
||
describe("readPagesRouterEntrySource", () => {
|
||
it("renders without request-level development asset URLs", () => {
|
||
const content = readPagesRouterEntrySource();
|
||
expect(content).toContain("renderPage(req, resolvedUrl, null, ctx, stagedHeaders, options)");
|
||
expect(content).not.toContain("clientEntryUrl");
|
||
expect(content).not.toContain("clientPreambleUrl");
|
||
});
|
||
|
||
it("keeps Cloudflare dev _next/data URLs intact for Worker normalization", () => {
|
||
const indexSource = fs.readFileSync(
|
||
path.join(import.meta.dirname, "../packages/vinext/src/index.ts"),
|
||
"utf8",
|
||
);
|
||
const delegation = indexSource.indexOf("if (hasCloudflarePlugin) return next();");
|
||
const dataNormalization = indexSource.indexOf(
|
||
"// ── `_next/data` normalization (Pages Router) ──────────────",
|
||
);
|
||
|
||
expect(delegation).toBeGreaterThanOrEqual(0);
|
||
expect(dataNormalization).toBeGreaterThanOrEqual(0);
|
||
expect(delegation).toBeLessThan(dataNormalization);
|
||
});
|
||
|
||
it("generates valid TypeScript", () => {
|
||
const content = readPagesRouterEntrySource();
|
||
expect(content).toContain("export default");
|
||
expect(content).toContain("async fetch(");
|
||
expect(content).toContain("env?: PagesWorkerEnv");
|
||
expect(content).toContain("ctx?: PagesWorkerExecutionContext");
|
||
expect(content).toContain("Promise<Response>");
|
||
});
|
||
|
||
it("runs middleware before routing", () => {
|
||
const content = readPagesRouterEntrySource();
|
||
// Ordering is now enforced by runPagesRequest (the pipeline owner).
|
||
// The worker entry wraps runMiddleware via the shared
|
||
// wrapMiddlewareWithBasePath helper to re-add the basePath before
|
||
// handing the request to the middleware function, then delegates via
|
||
// runPagesRequest.
|
||
expect(content).toContain('typeof runMiddleware === "function"');
|
||
expect(content).toContain("wrapMiddlewareWithBasePath(runMiddleware, basePath, hadBasePath)");
|
||
expect(content).toContain("const dataNorm = normalizeDataRequest(request)");
|
||
expect(content).toContain("isDataRequest: isDataReq");
|
||
expect(content).toContain("runPagesRequest(request, deps)");
|
||
});
|
||
|
||
it("applies next.config.js redirects before middleware", () => {
|
||
const content = readPagesRouterEntrySource();
|
||
// Ordering is now enforced by runPagesRequest. The worker passes
|
||
// configRedirects as a dep and delegates to the pipeline owner.
|
||
expect(content).toContain("configRedirects,");
|
||
expect(content).toContain("runPagesRequest(request, deps)");
|
||
});
|
||
|
||
it("handles middleware redirects", () => {
|
||
const content = readPagesRouterEntrySource();
|
||
// Middleware redirect handling is now inside runPagesRequest.
|
||
// The worker entry supplies a wrapped runMiddleware dep and checks result.type.
|
||
expect(content).toContain('typeof runMiddleware === "function"');
|
||
expect(content).toContain('result.type === "response"');
|
||
});
|
||
|
||
it("preserves responseHeaders on middleware redirect", () => {
|
||
const content = readPagesRouterEntrySource();
|
||
// responseHeaders handling is now inside runPagesRequest.
|
||
// Verify the worker passes a wrapped runMiddleware dep (which carries responseHeaders).
|
||
expect(content).toContain('typeof runMiddleware === "function"');
|
||
expect(content).toContain("runPagesRequest(request, deps)");
|
||
});
|
||
|
||
it("handles middleware rewrites", () => {
|
||
const content = readPagesRouterEntrySource();
|
||
// Middleware rewrite handling is now inside runPagesRequest.
|
||
// The worker entry supplies a wrapped runMiddleware dep and gets a {type:"response"} result.
|
||
expect(content).toContain('typeof runMiddleware === "function"');
|
||
expect(content).toContain("runPagesRequest(request, deps)");
|
||
});
|
||
|
||
// Ported from Next.js: test/e2e/middleware-rewrites/test/index.test.ts
|
||
// https://github.com/vercel/next.js/blob/canary/test/e2e/middleware-rewrites/test/index.test.ts
|
||
it("proxies external middleware rewrites before local route handling", () => {
|
||
const content = readPagesRouterEntrySource();
|
||
// External proxy for middleware rewrites is now inside runPagesRequest.
|
||
// The worker entry supplies a wrapped runMiddleware dep and delegates to the pipeline.
|
||
expect(content).toContain('typeof runMiddleware === "function"');
|
||
expect(content).toContain("runPagesRequest(request, deps)");
|
||
});
|
||
|
||
it("handles middleware access control responses", () => {
|
||
const content = readPagesRouterEntrySource();
|
||
// Access control (continue=false) is now inside runPagesRequest.
|
||
// Worker supplies a wrapped runMiddleware dep.
|
||
expect(content).toContain('typeof runMiddleware === "function"');
|
||
expect(content).toContain("runPagesRequest(request, deps)");
|
||
});
|
||
|
||
it("applies next.config.js redirects", () => {
|
||
const content = readPagesRouterEntrySource();
|
||
// Redirect matching is now inside runPagesRequest.
|
||
// Worker passes configRedirects and i18nConfig deps.
|
||
expect(content).toContain("configRedirects,");
|
||
expect(content).toContain("i18nConfig,");
|
||
expect(content).toContain("runPagesRequest(request, deps)");
|
||
});
|
||
|
||
it("applies next.config.js rewrites (beforeFiles, afterFiles, fallback)", () => {
|
||
const content = readPagesRouterEntrySource();
|
||
// Rewrite handling is now inside runPagesRequest.
|
||
// Worker passes configRewrites dep with all three phases.
|
||
expect(content).toContain("configRewrites,");
|
||
expect(content).toContain(
|
||
'matchApiRoute: typeof matchApiRoute === "function" ? matchApiRoute : null',
|
||
);
|
||
expect(content).toContain(
|
||
'matchPageRoute: typeof matchPageRoute === "function" ? matchPageRoute : null',
|
||
);
|
||
expect(content).toContain("runPagesRequest(request, deps)");
|
||
});
|
||
|
||
it("applies next.config.js custom headers", () => {
|
||
const content = readPagesRouterEntrySource();
|
||
// Config header application is now inside runPagesRequest.
|
||
// Worker passes configHeaders dep.
|
||
expect(content).toContain("configHeaders,");
|
||
expect(content).toContain("runPagesRequest(request, deps)");
|
||
});
|
||
|
||
it("handles basePath stripping and clones the request with the stripped URL", () => {
|
||
const content = readPagesRouterEntrySource();
|
||
expect(content).toContain("basePath");
|
||
expect(content).toContain('from "../utils/base-path.js"');
|
||
expect(content).toContain("const stripped = stripBasePath(pathname, basePath);");
|
||
// After stripping, clone with the stripped URL so runPagesRequest receives
|
||
// a clean basePath-free request without dropping Worker metadata.
|
||
expect(content).toContain("strippedUrl.pathname = stripped");
|
||
expect(content).toContain("cloneRequestWithUrl(request, strippedUrl.toString())");
|
||
});
|
||
|
||
it("handles trailing slash normalization", () => {
|
||
const content = readPagesRouterEntrySource();
|
||
// Trailing slash normalization is now inside runPagesRequest.
|
||
// Worker passes trailingSlash dep.
|
||
expect(content).toContain("trailingSlash,");
|
||
expect(content).toContain("runPagesRequest(request, deps)");
|
||
});
|
||
|
||
it("routes /api/ to handleApiRoute using resolved URL and forwards ctx", () => {
|
||
const content = readPagesRouterEntrySource();
|
||
// API routing (including locale prefix stripping) is now inside runPagesRequest.
|
||
// Worker supplies handleApi dep that wraps handleApiRoute with ctx.
|
||
// Locale stripping, /api/ prefix check, and ctx forwarding are all inside the owner.
|
||
expect(content).toContain("handleApi:");
|
||
expect(content).toContain('typeof handleApiRoute === "function"');
|
||
expect(content).toContain(
|
||
'handleApiRoute(req, apiUrl, ctx, new URL(req.url).origin, "worker")',
|
||
);
|
||
expect(content).toContain("runPagesRequest(request, deps)");
|
||
});
|
||
|
||
it("preserves request metadata when stripping Pages Router basePath", () => {
|
||
const content = readPagesRouterEntrySource();
|
||
expect(content).toContain("cloneRequestWithUrl(request, strippedUrl.toString())");
|
||
});
|
||
|
||
it("includes error handling", () => {
|
||
const content = readPagesRouterEntrySource();
|
||
expect(content).toContain("catch (error)");
|
||
expect(content).toContain("Internal Server Error");
|
||
});
|
||
|
||
it("includes image optimization handler", () => {
|
||
const content = readPagesRouterEntrySource();
|
||
expect(content).toContain("isImageOptimizationPath");
|
||
expect(content).toContain("handleConfiguredImageOptimization");
|
||
expect(content).toContain("registerConfiguredImageOptimizer(env)");
|
||
});
|
||
|
||
it("does not declare an Images binding in the Worker", () => {
|
||
const content = readPagesRouterEntrySource();
|
||
expect(content).toContain("type PagesWorkerEnv");
|
||
expect(content).not.toContain("IMAGES");
|
||
expect(content).toContain("ASSETS");
|
||
});
|
||
|
||
it("includes an open-redirect guard that rejects encoded backslash and slash", () => {
|
||
const content = readPagesRouterEntrySource();
|
||
expect(content).toContain("isOpenRedirectShaped");
|
||
expect(content).toContain('from "./request-pipeline.js"');
|
||
expect(content).toContain("isOpenRedirectShaped(pathname)");
|
||
});
|
||
|
||
it("delegates image transforms to the configured adapter", () => {
|
||
const content = readPagesRouterEntrySource();
|
||
expect(content).toContain("handleConfiguredImageOptimization(");
|
||
expect(content).toContain("env.ASSETS!.fetch");
|
||
expect(content).not.toContain("env.IMAGES");
|
||
});
|
||
|
||
it("re-enters the ASSETS binding after beforeFiles rewrites", () => {
|
||
const content = readPagesRouterEntrySource();
|
||
expect(content).toContain("serveFilesystemRoute: async");
|
||
expect(content).toContain("fetchWorkerFilesystemRoute(");
|
||
expect(content).toContain("env.ASSETS!.fetch(assetRequest)");
|
||
expect(content).toContain("publicFiles");
|
||
});
|
||
|
||
it("exports the built-in fetch handler and router-specific worker entries", () => {
|
||
const exportsMap = readVinextPackageExports();
|
||
expect(hasPackageExport(exportsMap, "./client")).toBe(true);
|
||
expect(hasPackageExport(exportsMap, "./server/fetch-handler")).toBe(true);
|
||
expect(hasPackageExport(exportsMap, "./server/app-router-entry")).toBe(true);
|
||
expect(hasPackageExport(exportsMap, "./server/pages-router-entry")).toBe(true);
|
||
});
|
||
|
||
it("exports internal deploy dependencies consumed by @vinext/cloudflare", () => {
|
||
const exportsMap = readVinextPackageExports();
|
||
expect(hasPackageExport(exportsMap, "./internal/build/run-prerender")).toBe(true);
|
||
expect(hasPackageExport(exportsMap, "./internal/build/prerender-paths")).toBe(true);
|
||
expect(hasPackageExport(exportsMap, "./internal/config/dotenv")).toBe(true);
|
||
expect(hasPackageExport(exportsMap, "./internal/config/next-config")).toBe(true);
|
||
expect(hasPackageExport(exportsMap, "./internal/config/prerender")).toBe(true);
|
||
expect(hasPackageExport(exportsMap, "./internal/server/pregenerated-concrete-paths")).toBe(
|
||
true,
|
||
);
|
||
expect(hasPackageExport(exportsMap, "./internal/utils/project")).toBe(true);
|
||
});
|
||
|
||
it("publishes a vinext peer range that includes matching prereleases", () => {
|
||
const peerDependencies = readCloudflarePackagePeerDependencies();
|
||
expect(peerDependencies.vinext).toBe("workspace:^");
|
||
});
|
||
|
||
it("merges middleware and config headers into responses with correct precedence", () => {
|
||
const content = readPagesRouterEntrySource();
|
||
// mergeHeaders is now called inside runPagesRequest.
|
||
// The worker returns result.response directly from the pipeline result.
|
||
expect(content).toContain("runPagesRequest(request, deps)");
|
||
expect(content).toContain('result.type === "response"');
|
||
expect(content).toContain("finalizeMissingStaticAssetResponse(result.response");
|
||
});
|
||
|
||
it("mergeHeaders preserves multiple Set-Cookie headers from both middleware and response", () => {
|
||
const response = new Response("body", {
|
||
headers: [
|
||
["set-cookie", "resp=1; Path=/"],
|
||
["set-cookie", "resp=2; Path=/"],
|
||
["content-type", "text/html"],
|
||
],
|
||
});
|
||
const extraHeaders: Record<string, string | string[]> = {
|
||
"set-cookie": ["mw=1; Path=/"],
|
||
"x-custom": "from-middleware",
|
||
};
|
||
|
||
const merged = mergeHeaders(response, extraHeaders);
|
||
const cookies = merged.headers.getSetCookie();
|
||
expect(cookies).toContain("mw=1; Path=/");
|
||
expect(cookies).toContain("resp=1; Path=/");
|
||
expect(cookies).toContain("resp=2; Path=/");
|
||
// Response takes precedence for non-Set-Cookie headers
|
||
expect(merged.headers.get("content-type")).toBe("text/html");
|
||
// Middleware-only headers are preserved
|
||
expect(merged.headers.get("x-custom")).toBe("from-middleware");
|
||
});
|
||
|
||
it("mergeHeaders drops the body for no-body middleware rewrite statuses", async () => {
|
||
for (const status of [204, 205, 304]) {
|
||
const response = new Response("body", {
|
||
status: 200,
|
||
headers: { "content-type": "text/plain", "content-length": "4" },
|
||
});
|
||
|
||
const merged = mergeHeaders(response, { "x-custom": "from-middleware" }, status);
|
||
|
||
expect(merged.status).toBe(status);
|
||
expect(merged.headers.get("x-custom")).toBe("from-middleware");
|
||
expect(merged.headers.get("content-type")).toBeNull();
|
||
expect(merged.headers.get("content-length")).toBeNull();
|
||
expect(await merged.text()).toBe("");
|
||
}
|
||
});
|
||
|
||
it("mergeHeaders cancels discarded body streams for no-body statuses", async () => {
|
||
let started = false;
|
||
let canceled = false;
|
||
const response = new Response(
|
||
new ReadableStream<Uint8Array>({
|
||
async start(controller) {
|
||
started = true;
|
||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||
if (canceled) return;
|
||
controller.enqueue(new TextEncoder().encode("hello"));
|
||
controller.close();
|
||
},
|
||
cancel() {
|
||
canceled = true;
|
||
},
|
||
}),
|
||
{
|
||
headers: {
|
||
"content-type": "text/plain",
|
||
"content-length": "5",
|
||
},
|
||
},
|
||
);
|
||
|
||
const merged = mergeHeaders(response, { "x-custom": "from-middleware" }, 204);
|
||
|
||
expect(merged.status).toBe(204);
|
||
expect(merged.headers.get("content-type")).toBeNull();
|
||
expect(merged.headers.get("content-length")).toBeNull();
|
||
expect(await merged.text()).toBe("");
|
||
|
||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||
expect(started).toBe(true);
|
||
expect(canceled).toBe(true);
|
||
});
|
||
|
||
it("mergeHeaders strips stale content-length only for tagged streamed Pages HTML", async () => {
|
||
const response = new Response(
|
||
new ReadableStream<Uint8Array>({
|
||
start(controller) {
|
||
controller.enqueue(new TextEncoder().encode("hello"));
|
||
controller.close();
|
||
},
|
||
}),
|
||
{
|
||
status: 200,
|
||
headers: {
|
||
"content-type": "text/html; charset=utf-8",
|
||
"content-length": "1",
|
||
},
|
||
},
|
||
) as Response & { __vinextStreamedHtmlResponse?: boolean };
|
||
response.__vinextStreamedHtmlResponse = true;
|
||
|
||
const merged = mergeHeaders(response, { "x-custom": "from-middleware" });
|
||
|
||
expect(merged.headers.get("content-length")).toBeNull();
|
||
expect(merged.headers.get("content-type")).toBe("text/html; charset=utf-8");
|
||
expect(merged.headers.get("x-custom")).toBe("from-middleware");
|
||
expect(await merged.text()).toBe("hello");
|
||
});
|
||
|
||
it("mergeHeaders strips middleware-provided content-length for untagged responses", async () => {
|
||
const response = new Response("body", {
|
||
status: 200,
|
||
headers: {
|
||
"content-type": "text/plain",
|
||
},
|
||
});
|
||
|
||
const merged = mergeHeaders(response, {
|
||
"content-length": "1",
|
||
"x-custom": "from-middleware",
|
||
});
|
||
|
||
expect(merged.headers.get("content-length")).toBeNull();
|
||
expect(merged.headers.get("content-type")).toBe("text/plain");
|
||
expect(merged.headers.get("x-custom")).toBe("from-middleware");
|
||
expect(await merged.text()).toBe("body");
|
||
});
|
||
|
||
it("mergeHeaders preserves response content-length over middleware content-length for untagged custom responses", async () => {
|
||
const response = new Response(Buffer.from([1, 2, 3]), {
|
||
status: 200,
|
||
headers: {
|
||
"content-type": "application/octet-stream",
|
||
"content-length": "3",
|
||
},
|
||
});
|
||
|
||
const merged = mergeHeaders(response, {
|
||
"content-length": "1",
|
||
"x-custom": "from-middleware",
|
||
});
|
||
|
||
expect(merged.headers.get("content-length")).toBe("3");
|
||
expect(merged.headers.get("content-type")).toBe("application/octet-stream");
|
||
expect(merged.headers.get("x-custom")).toBe("from-middleware");
|
||
const body = Buffer.from(await merged.arrayBuffer());
|
||
expect(body.equals(Buffer.from([1, 2, 3]))).toBe(true);
|
||
});
|
||
|
||
it("generated worker entry includes the no-body and streamed content-length merge guards", () => {
|
||
const content = readPagesRouterEntrySource();
|
||
// mergeHeaders (including no-body and streamed content-length guards) is
|
||
// now called inside runPagesRequest. The worker delegates to the pipeline.
|
||
expect(content).toContain("runPagesRequest(request, deps)");
|
||
expect(content).toContain('result.type === "response"');
|
||
expect(content).toContain(
|
||
"return finalizeMissingStaticAssetResponse(result.response, missingBuildAsset)",
|
||
);
|
||
});
|
||
|
||
it("finalizes only missing build-asset 404 responses", async () => {
|
||
let canceled = false;
|
||
const routed404 = new Response(
|
||
new ReadableStream<Uint8Array>({
|
||
start(controller) {
|
||
controller.enqueue(new TextEncoder().encode("rendered 404"));
|
||
},
|
||
cancel() {
|
||
canceled = true;
|
||
},
|
||
}),
|
||
{
|
||
status: 404,
|
||
headers: {
|
||
"content-length": "12",
|
||
"content-type": "text/html",
|
||
"req-url-path": "/_next/static/build/_devMiddlewareManifest.json?foo=1",
|
||
},
|
||
},
|
||
);
|
||
|
||
const finalized = finalizeMissingStaticAssetResponse(routed404, true);
|
||
expect(finalized.status).toBe(404);
|
||
expect(finalized.headers.get("content-type")).toBe("text/plain; charset=utf-8");
|
||
expect(finalized.headers.get("content-length")).toBeNull();
|
||
expect(finalized.headers.get("req-url-path")).toBe(
|
||
"/_next/static/build/_devMiddlewareManifest.json?foo=1",
|
||
);
|
||
expect(await finalized.text()).toBe("Not Found");
|
||
await vi.waitFor(() => expect(canceled).toBe(true));
|
||
|
||
const middlewareResponse = new Response("rewritten missing asset", { status: 200 });
|
||
expect(finalizeMissingStaticAssetResponse(middlewareResponse, true)).toBe(middlewareResponse);
|
||
|
||
const regular404 = new Response("rendered 404", { status: 404 });
|
||
expect(finalizeMissingStaticAssetResponse(regular404, false)).toBe(regular404);
|
||
});
|
||
|
||
it("finalizes missing build-asset 404s in both Node production routers", () => {
|
||
const content = fs.readFileSync(
|
||
path.join(import.meta.dirname, "../packages/vinext/src/server/prod-server.ts"),
|
||
"utf8",
|
||
);
|
||
|
||
expect(content.match(/finalizeMissingStaticAssetResponse\(/g)).toHaveLength(2);
|
||
});
|
||
|
||
it("resolveStaticAssetSignal fetches and merges static asset responses with middleware status", async () => {
|
||
const signalResponse = createStaticFileSignal("/logo/logo.svg", {
|
||
status: 403,
|
||
headers: new Headers([
|
||
["x-vinext-static-file", "/application-value.txt"],
|
||
["x-middleware", "blocked"],
|
||
["content-type", "text/plain"],
|
||
]),
|
||
});
|
||
|
||
const resolved = await resolveStaticAssetSignal(signalResponse, {
|
||
fetchAsset: async (path) =>
|
||
new Response("<svg />", {
|
||
status: 200,
|
||
headers: {
|
||
"content-type": "image/svg+xml",
|
||
"cache-control": "public, max-age=3600",
|
||
"x-asset-path": path,
|
||
},
|
||
}),
|
||
});
|
||
|
||
expect(resolved).not.toBeNull();
|
||
expect(resolved!.status).toBe(403);
|
||
expect(resolved!.headers.get("content-type")).toBe("image/svg+xml");
|
||
expect(resolved!.headers.get("x-middleware")).toBe("blocked");
|
||
expect(resolved!.headers.get("x-vinext-static-file")).toBe("/application-value.txt");
|
||
expect(resolved!.headers.get("x-asset-path")).toBe("/logo/logo.svg");
|
||
expect(await resolved!.text()).toBe("<svg />");
|
||
});
|
||
|
||
it("does not trust an unmarked response carrying a forged static-file header", async () => {
|
||
const response = new Response("route handler body", {
|
||
headers: { "x-vinext-static-file": encodeURIComponent("/private.txt") },
|
||
});
|
||
let fetched = false;
|
||
|
||
const resolved = await resolveStaticAssetSignal(response, {
|
||
fetchAsset: async () => {
|
||
fetched = true;
|
||
return new Response("private asset");
|
||
},
|
||
});
|
||
|
||
expect(resolved).toBeNull();
|
||
expect(fetched).toBe(false);
|
||
await expect(response.text()).resolves.toBe("route handler body");
|
||
});
|
||
|
||
it("preserves partial asset status over a middleware status override", async () => {
|
||
const signalResponse = createStaticFileSignal("/asset.txt", {
|
||
status: 403,
|
||
headers: new Headers({
|
||
"x-middleware": "blocked",
|
||
}),
|
||
});
|
||
|
||
const resolved = await resolveStaticAssetSignal(signalResponse, {
|
||
fetchAsset: async () =>
|
||
new Response("par", {
|
||
status: 206,
|
||
headers: {
|
||
"content-length": "3",
|
||
"content-range": "bytes 0-2/7",
|
||
},
|
||
}),
|
||
});
|
||
|
||
expect(resolved).not.toBeNull();
|
||
expect(resolved!.status).toBe(206);
|
||
expect(resolved!.headers.get("content-length")).toBe("3");
|
||
expect(resolved!.headers.get("content-range")).toBe("bytes 0-2/7");
|
||
expect(resolved!.headers.get("x-middleware")).toBe("blocked");
|
||
expect(await resolved!.text()).toBe("par");
|
||
});
|
||
|
||
it("retargets Worker assets without dropping conditional and range fields", () => {
|
||
const source = new Request("https://example.com/rewrite", {
|
||
method: "HEAD",
|
||
headers: {
|
||
"cache-control": "no-cache",
|
||
"if-none-match": 'W/"asset"',
|
||
"if-modified-since": "Sun, 26 Jul 2026 12:34:56 GMT",
|
||
"if-range": '"asset"',
|
||
range: "bytes=0-2",
|
||
},
|
||
});
|
||
|
||
const assetRequest = createStaticAssetRequest("/asset.txt", source);
|
||
expect(assetRequest.url).toBe("https://example.com/asset.txt");
|
||
expect(assetRequest.method).toBe("HEAD");
|
||
expect(Object.fromEntries(assetRequest.headers)).toEqual(Object.fromEntries(source.headers));
|
||
});
|
||
|
||
it("preserves x-middleware-request-* headers for prod request override handling", () => {
|
||
const content = readPagesRouterEntrySource();
|
||
// applyMiddlewareRequestHeaders is now called inside runPagesRequest.
|
||
// The worker entry delegates to the pipeline owner via runPagesRequest.
|
||
expect(content).toContain("runPagesRequest(request, deps)");
|
||
expect(content).toContain('typeof runMiddleware === "function"');
|
||
});
|
||
|
||
it("handles external rewrites via proxyExternalRequest", () => {
|
||
const content = readPagesRouterEntrySource();
|
||
// External rewrite proxying is now inside runPagesRequest.
|
||
// The worker entry delegates to the pipeline owner.
|
||
expect(content).toContain("runPagesRequest(request, deps)");
|
||
expect(content).toContain("configRewrites,");
|
||
});
|
||
|
||
it("guards renderPage with typeof check", () => {
|
||
const content = readPagesRouterEntrySource();
|
||
// The typeof guard is now in the adapter deps wiring.
|
||
expect(content).toContain('typeof renderPage === "function"');
|
||
});
|
||
|
||
it("does not defer error page rendering for data requests", () => {
|
||
const content = readPagesRouterEntrySource();
|
||
// shouldDeferErrorPageOnMiss logic is now inside runPagesRequest.
|
||
// The worker normalizes the build-ID-aware URL before the pipeline and
|
||
// passes the trusted classification alongside matchPageRoute.
|
||
expect(content).toContain("isDataReq,");
|
||
expect(content).toContain("isDataRequest: isDataReq");
|
||
expect(content).toContain(
|
||
'matchPageRoute: typeof matchPageRoute === "function" ? matchPageRoute : null',
|
||
);
|
||
expect(content).toContain("runPagesRequest(request, deps)");
|
||
});
|
||
|
||
it("builds reqCtx before middleware runs", () => {
|
||
const content = readPagesRouterEntrySource();
|
||
// reqCtx is now built inside runPagesRequest before middleware.
|
||
// The worker passes configRedirects and a wrapped runMiddleware dep; ordering is
|
||
// guaranteed by the pipeline owner.
|
||
expect(content).toContain("configRedirects,");
|
||
expect(content).toContain('typeof runMiddleware === "function"');
|
||
expect(content).toContain("runPagesRequest(request, deps)");
|
||
});
|
||
|
||
it("dispatches image optimization through the post-middleware filesystem phase", () => {
|
||
const content = readPagesRouterEntrySource();
|
||
const basePathPos = content.indexOf("const stripped = stripBasePath(pathname, basePath);");
|
||
const filesystemPos = content.indexOf("serveFilesystemRoute: async");
|
||
const imagePos = content.indexOf("isImageOptimizationPath(requestPathname)");
|
||
expect(basePathPos).toBeGreaterThan(-1);
|
||
expect(filesystemPos).toBeGreaterThan(-1);
|
||
expect(imagePos).toBeGreaterThan(-1);
|
||
expect(basePathPos).toBeLessThan(filesystemPos);
|
||
expect(filesystemPos).toBeLessThan(imagePos);
|
||
expect(content).not.toContain("isImageOptimizationPath(pathname)");
|
||
expect(content).toContain("new URL(resolvedUrl, request.url)");
|
||
});
|
||
|
||
it("threads configured image widths and qualities into optimization validation", () => {
|
||
const content = readPagesRouterEntrySource();
|
||
expect(content).toContain("vinextConfig?.images?.deviceSizes ?? DEFAULT_DEVICE_SIZES");
|
||
expect(content).toContain("vinextConfig?.images?.imageSizes ?? DEFAULT_IMAGE_SIZES");
|
||
expect(content).toContain("qualities: vinextConfig.images.qualities");
|
||
expect(content).toContain("allowedWidths,");
|
||
expect(content).toContain("imageConfig,");
|
||
});
|
||
|
||
it("uses segment-boundary check before skipping redirect destination prefixing", () => {
|
||
const content = readPagesRouterEntrySource();
|
||
// Segment-boundary checks for redirect destination prefixing are now
|
||
// inside runPagesRequest. The worker passes hadBasePath and basePath deps.
|
||
expect(content).toContain("hadBasePath,");
|
||
expect(content).toContain("basePath,");
|
||
expect(content).toContain("runPagesRequest(request, deps)");
|
||
});
|
||
|
||
// Ported from Next.js: test/e2e/middleware-general/test/index.test.ts
|
||
// https://github.com/vercel/next.js/blob/canary/test/e2e/middleware-general/test/index.test.ts
|
||
it("runs middleware before finalizing missing `_next/static/*` responses", () => {
|
||
const content = readPagesRouterEntrySource();
|
||
expect(content).toContain('from "./http-error-responses.js"');
|
||
expect(content).toContain('from "../utils/asset-prefix.js"');
|
||
expect(content).toContain("assetPrefixPathname(vinextConfig?.assetPrefix");
|
||
expect(content).toContain(
|
||
"const missingBuildAsset = isNextStaticPath(pathname, basePath, assetPathPrefix)",
|
||
);
|
||
expect(content).toContain(
|
||
"finalizeMissingStaticAssetResponse(result.response, missingBuildAsset)",
|
||
);
|
||
|
||
// Detection happens before routing, but the response is finalized only
|
||
// after runPagesRequest has given middleware a chance to handle the miss.
|
||
const staticPos = content.indexOf("isNextStaticPath(pathname, basePath, assetPathPrefix)");
|
||
const pipelinePos = content.indexOf("runPagesRequest(request, deps)");
|
||
const finalizePos = content.indexOf(
|
||
"finalizeMissingStaticAssetResponse(result.response, missingBuildAsset)",
|
||
);
|
||
expect(staticPos).toBeGreaterThan(-1);
|
||
expect(pipelinePos).toBeGreaterThan(staticPos);
|
||
expect(finalizePos).toBeGreaterThan(pipelinePos);
|
||
});
|
||
});
|
||
|
||
describe("fetchWorkerFilesystemRoute", () => {
|
||
it.each(["beforeFiles", "afterFiles", "fallback"] as const)(
|
||
"fetches rewritten assets during %s",
|
||
async (phase) => {
|
||
const fetchAsset = vi.fn(
|
||
async (request: Request) =>
|
||
new Response(`asset:${new URL(request.url).pathname}`, {
|
||
headers: { "content-type": "text/plain" },
|
||
}),
|
||
);
|
||
const result = await fetchWorkerFilesystemRoute(
|
||
new Request("https://example.com/sv/source?ignored=1"),
|
||
"/file.txt",
|
||
phase,
|
||
fetchAsset,
|
||
);
|
||
|
||
expect(result).toBeInstanceOf(Response);
|
||
if (!(result instanceof Response)) return;
|
||
await expect(result.text()).resolves.toBe("asset:/file.txt");
|
||
expect(fetchAsset).toHaveBeenCalledOnce();
|
||
expect(new URL(fetchAsset.mock.calls[0][0].url).search).toBe("");
|
||
},
|
||
);
|
||
|
||
it("falls through on asset misses and preserves HEAD", async () => {
|
||
const fetchAsset = vi.fn(async (request: Request) => {
|
||
expect(request.method).toBe("HEAD");
|
||
return new Response(null, { status: 404 });
|
||
});
|
||
const result = await fetchWorkerFilesystemRoute(
|
||
new Request("https://example.com/source", { method: "HEAD" }),
|
||
"/missing.txt",
|
||
"afterFiles",
|
||
fetchAsset,
|
||
);
|
||
|
||
expect(result).toBe(false);
|
||
expect(fetchAsset).toHaveBeenCalledOnce();
|
||
});
|
||
|
||
it.each(["POST", "DELETE"])(
|
||
"uses a bodyless HEAD probe before returning 405 for an existing asset on %s",
|
||
async (method) => {
|
||
const fetchAsset = vi.fn(async (request: Request) => {
|
||
expect(request.method).toBe("HEAD");
|
||
expect(request.body).toBeNull();
|
||
return new Response(null, { status: 200 });
|
||
});
|
||
|
||
const sourceRequest =
|
||
method === "POST"
|
||
? new Request("https://example.com/file.txt", {
|
||
method: "POST",
|
||
body: "must-not-forward",
|
||
})
|
||
: new Request("https://example.com/file.txt", { method: "DELETE" });
|
||
const result = await fetchWorkerFilesystemRoute(
|
||
sourceRequest,
|
||
"/file.txt",
|
||
"direct",
|
||
fetchAsset,
|
||
new Set(["/file.txt"]),
|
||
);
|
||
|
||
expect(result).toBeInstanceOf(Response);
|
||
if (!(result instanceof Response)) return;
|
||
expect(result.status).toBe(405);
|
||
expect(result.headers.get("allow")).toBe("GET, HEAD");
|
||
await expect(result.text()).resolves.toBe("Method Not Allowed");
|
||
},
|
||
);
|
||
|
||
it("falls through after a HEAD probe misses for an unsupported method", async () => {
|
||
const fetchAsset = vi.fn(async (request: Request) => {
|
||
expect(request.method).toBe("HEAD");
|
||
return new Response(null, { status: 404 });
|
||
});
|
||
|
||
await expect(
|
||
fetchWorkerFilesystemRoute(
|
||
new Request("https://example.com/missing.txt", { method: "POST" }),
|
||
"/missing.txt",
|
||
"direct",
|
||
fetchAsset,
|
||
new Set(["/missing.txt"]),
|
||
),
|
||
).resolves.toBe(false);
|
||
});
|
||
|
||
it("skips direct mutation probes when the pathname is not a public file", async () => {
|
||
const fetchAsset = vi.fn(async () => new Response(null, { status: 200 }));
|
||
|
||
await expect(
|
||
fetchWorkerFilesystemRoute(
|
||
new Request("https://example.com/checkout", { method: "POST", body: "order=1" }),
|
||
"/checkout",
|
||
"direct",
|
||
fetchAsset,
|
||
new Set(["/file.txt"]),
|
||
),
|
||
).resolves.toBe(false);
|
||
expect(fetchAsset).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it.each(["/hello%20copy.txt", "/hello copy.txt"])(
|
||
"matches encoded public-file inventory for %s",
|
||
async (requestPathname) => {
|
||
const fetchAsset = vi.fn(async () => new Response(null, { status: 200 }));
|
||
|
||
const result = await fetchWorkerFilesystemRoute(
|
||
new Request("https://example.com/hello%20copy.txt", { method: "POST" }),
|
||
requestPathname,
|
||
"direct",
|
||
fetchAsset,
|
||
new Set(["/hello copy.txt", "/hello%20copy.txt"]),
|
||
);
|
||
|
||
expect(result).toBeInstanceOf(Response);
|
||
expect(fetchAsset).toHaveBeenCalledOnce();
|
||
},
|
||
);
|
||
|
||
it("still probes direct built assets outside the public-file inventory", async () => {
|
||
const fetchAsset = vi.fn(async () => new Response(null, { status: 200 }));
|
||
|
||
const result = await fetchWorkerFilesystemRoute(
|
||
new Request("https://example.com/_next/static/chunks/app.js", { method: "POST" }),
|
||
"/_next/static/chunks/app.js",
|
||
"direct",
|
||
fetchAsset,
|
||
new Set(),
|
||
true,
|
||
);
|
||
|
||
expect(result).toBeInstanceOf(Response);
|
||
expect(fetchAsset).toHaveBeenCalledOnce();
|
||
});
|
||
|
||
it("skips direct and API filesystem probes", async () => {
|
||
const fetchAsset = vi.fn(async () => new Response("unexpected"));
|
||
|
||
expect(
|
||
await fetchWorkerFilesystemRoute(
|
||
new Request("https://example.com/file.txt"),
|
||
"/file.txt",
|
||
"direct",
|
||
fetchAsset,
|
||
),
|
||
).toBe(false);
|
||
expect(
|
||
await fetchWorkerFilesystemRoute(
|
||
new Request("https://example.com/source"),
|
||
"/api/hello",
|
||
"fallback",
|
||
fetchAsset,
|
||
),
|
||
).toBe(false);
|
||
expect(fetchAsset).not.toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
// ─── Vite Config Generation ─────────────────────────────────────────────<E29480><E29480>───
|
||
|
||
describe("generateAppRouterViteConfig", () => {
|
||
it("includes vinext and cloudflare plugins", () => {
|
||
const content = generateAppRouterViteConfig();
|
||
expect(content).toContain('import vinext from "vinext"');
|
||
expect(content).toContain('from "@cloudflare/vite-plugin"');
|
||
expect(content).toContain("vinext({");
|
||
expect(content).toContain("cloudflare(");
|
||
});
|
||
|
||
it("configures viteEnvironment with name: rsc and childEnvironments for Workers", () => {
|
||
const content = generateAppRouterViteConfig();
|
||
expect(content).toContain('name: "rsc"');
|
||
expect(content).toContain('childEnvironments: ["ssr"]');
|
||
});
|
||
});
|
||
|
||
describe("generatePagesRouterViteConfig", () => {
|
||
it("includes vinext and cloudflare plugins only", () => {
|
||
const content = generatePagesRouterViteConfig();
|
||
expect(content).toContain('import vinext from "vinext"');
|
||
expect(content).toContain('from "@cloudflare/vite-plugin"');
|
||
expect(content).toContain("vinext({");
|
||
expect(content).toContain("cloudflare()");
|
||
// Should NOT include RSC plugin
|
||
expect(content).not.toContain("plugin-rsc");
|
||
});
|
||
});
|
||
|
||
// ─── getMissingDeps ──────────────────────────────────────────────────────────
|
||
|
||
describe("getMissingDeps", () => {
|
||
it("reports missing @vitejs/plugin-react", () => {
|
||
mkdir(tmpDir, "pages");
|
||
const info = detectProject(tmpDir);
|
||
info.hasCloudflarePlugin = true;
|
||
info.hasWrangler = true;
|
||
|
||
const notResolvable = () => false;
|
||
const missing = getMissingDeps(info, notResolvable);
|
||
expect(missing).toContainEqual(expect.objectContaining({ name: "@vitejs/plugin-react" }));
|
||
});
|
||
|
||
it("reports missing @cloudflare/vite-plugin", () => {
|
||
mkdir(tmpDir, "app");
|
||
const info = detectProject(tmpDir);
|
||
info.hasCloudflarePlugin = false;
|
||
info.hasWrangler = true;
|
||
info.hasRscPlugin = true;
|
||
|
||
const missing = getMissingDeps(info);
|
||
expect(missing).toContainEqual(expect.objectContaining({ name: "@cloudflare/vite-plugin" }));
|
||
});
|
||
|
||
it("reports missing wrangler", () => {
|
||
mkdir(tmpDir, "app");
|
||
const info = detectProject(tmpDir);
|
||
info.hasCloudflarePlugin = true;
|
||
info.hasWrangler = false;
|
||
info.hasRscPlugin = true;
|
||
|
||
const missing = getMissingDeps(info);
|
||
expect(missing).toContainEqual(expect.objectContaining({ name: "wrangler" }));
|
||
});
|
||
|
||
it("reports missing @vitejs/plugin-rsc for App Router", () => {
|
||
mkdir(tmpDir, "app");
|
||
const info = detectProject(tmpDir);
|
||
info.hasCloudflarePlugin = true;
|
||
info.hasWrangler = true;
|
||
info.hasRscPlugin = false;
|
||
|
||
const missing = getMissingDeps(info);
|
||
expect(missing).toContainEqual(expect.objectContaining({ name: "@vitejs/plugin-rsc" }));
|
||
});
|
||
|
||
it("does not require @vitejs/plugin-rsc for Pages Router", () => {
|
||
mkdir(tmpDir, "pages");
|
||
const info = detectProject(tmpDir);
|
||
info.hasCloudflarePlugin = true;
|
||
info.hasWrangler = true;
|
||
info.hasRscPlugin = false;
|
||
|
||
const missing = getMissingDeps(info);
|
||
expect(missing).not.toContainEqual(expect.objectContaining({ name: "@vitejs/plugin-rsc" }));
|
||
});
|
||
|
||
it("reports missing react-server-dom-webpack for App Router", () => {
|
||
mkdir(tmpDir, "app");
|
||
const info = detectProject(tmpDir);
|
||
info.hasCloudflarePlugin = true;
|
||
info.hasWrangler = true;
|
||
info.hasRscPlugin = true;
|
||
|
||
// Pass a resolver that always returns false to simulate rsdw not being installed.
|
||
// (Vitest's createRequire finds rsdw via the monorepo root, so we can't rely
|
||
// on filesystem isolation in tmpdir.)
|
||
const notResolvable = () => false;
|
||
const missing = getMissingDeps(info, notResolvable);
|
||
expect(missing).toContainEqual(expect.objectContaining({ name: "react-server-dom-webpack" }));
|
||
});
|
||
|
||
it("does not require react-server-dom-webpack for Pages Router", () => {
|
||
mkdir(tmpDir, "pages");
|
||
const info = detectProject(tmpDir);
|
||
info.hasCloudflarePlugin = true;
|
||
info.hasWrangler = true;
|
||
info.hasRscPlugin = false;
|
||
|
||
const missing = getMissingDeps(info);
|
||
expect(missing).not.toContainEqual(
|
||
expect.objectContaining({ name: "react-server-dom-webpack" }),
|
||
);
|
||
});
|
||
|
||
it("returns empty array when everything is installed", () => {
|
||
mkdir(tmpDir, "app");
|
||
const info = detectProject(tmpDir);
|
||
info.hasCloudflarePlugin = true;
|
||
info.hasWrangler = true;
|
||
info.hasRscPlugin = true;
|
||
|
||
// Pass a resolver that always returns true to simulate all packages installed.
|
||
const allResolvable = () => true;
|
||
const missing = getMissingDeps(info, allResolvable);
|
||
expect(missing).toHaveLength(0);
|
||
});
|
||
});
|
||
|
||
// ─── isPackageResolvable ─────────────────────────────────────────────────────
|
||
|
||
describe("isPackageResolvable", () => {
|
||
it("returns true when package exists in node_modules", () => {
|
||
// Create a proper resolvable package in the tmpdir
|
||
const pkgDir = path.join(tmpDir, "node_modules", "fake-pkg");
|
||
fs.mkdirSync(pkgDir, { recursive: true });
|
||
fs.writeFileSync(
|
||
path.join(pkgDir, "package.json"),
|
||
JSON.stringify({ name: "fake-pkg", version: "1.0.0", main: "index.js" }),
|
||
);
|
||
fs.writeFileSync(path.join(pkgDir, "index.js"), "");
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, "package.json"),
|
||
JSON.stringify({ name: "test", version: "1.0.0" }),
|
||
);
|
||
|
||
expect(isPackageResolvable(tmpDir, "fake-pkg")).toBe(true);
|
||
});
|
||
|
||
it("returns false when package does not exist", () => {
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, "package.json"),
|
||
JSON.stringify({ name: "test", version: "1.0.0" }),
|
||
);
|
||
// no-such-package-xyz123 should never exist in any node_modules
|
||
expect(isPackageResolvable(tmpDir, "no-such-package-xyz123")).toBe(false);
|
||
});
|
||
});
|
||
|
||
// ─── viteConfigHasCloudflarePlugin ───────────────────────────────────────────
|
||
|
||
describe("viteConfigHasCloudflarePlugin", () => {
|
||
it("returns true when vite.config.ts imports @cloudflare/vite-plugin", () => {
|
||
writeFile(
|
||
tmpDir,
|
||
"vite.config.ts",
|
||
`
|
||
import { cloudflare } from "@cloudflare/vite-plugin";
|
||
import { defineConfig } from "vite";
|
||
export default defineConfig({ plugins: [cloudflare()] });
|
||
`,
|
||
);
|
||
expect(viteConfigHasCloudflarePlugin(tmpDir)).toBe(true);
|
||
});
|
||
|
||
it("returns true for App Router config with viteEnvironment", () => {
|
||
writeFile(
|
||
tmpDir,
|
||
"vite.config.ts",
|
||
`
|
||
import { cloudflare } from "@cloudflare/vite-plugin";
|
||
import vinext from "vinext";
|
||
import { defineConfig } from "vite";
|
||
export default defineConfig({
|
||
plugins: [vinext(), cloudflare({ viteEnvironment: { name: "rsc", childEnvironments: ["ssr"] } })],
|
||
});
|
||
`,
|
||
);
|
||
expect(viteConfigHasCloudflarePlugin(tmpDir)).toBe(true);
|
||
});
|
||
|
||
it("returns false when vite.config.ts does not import @cloudflare/vite-plugin", () => {
|
||
writeFile(
|
||
tmpDir,
|
||
"vite.config.ts",
|
||
`
|
||
import vinext from "vinext";
|
||
import { defineConfig } from "vite";
|
||
export default defineConfig({ plugins: [vinext()] });
|
||
`,
|
||
);
|
||
expect(viteConfigHasCloudflarePlugin(tmpDir)).toBe(false);
|
||
});
|
||
|
||
it("returns false for the minimal config generated by vinext init", () => {
|
||
// init generates a local-dev-only config without the cloudflare plugin
|
||
writeFile(
|
||
tmpDir,
|
||
"vite.config.ts",
|
||
`import vinext from "vinext";
|
||
import { defineConfig } from "vite";
|
||
|
||
export default defineConfig({
|
||
plugins: [vinext()],
|
||
});
|
||
`,
|
||
);
|
||
expect(viteConfigHasCloudflarePlugin(tmpDir)).toBe(false);
|
||
});
|
||
|
||
it("returns true for vite.config.js", () => {
|
||
writeFile(
|
||
tmpDir,
|
||
"vite.config.js",
|
||
`
|
||
import { cloudflare } from "@cloudflare/vite-plugin";
|
||
export default { plugins: [cloudflare()] };
|
||
`,
|
||
);
|
||
expect(viteConfigHasCloudflarePlugin(tmpDir)).toBe(true);
|
||
});
|
||
|
||
it("returns true for vite.config.mjs", () => {
|
||
writeFile(
|
||
tmpDir,
|
||
"vite.config.mjs",
|
||
`
|
||
import { cloudflare } from "@cloudflare/vite-plugin";
|
||
export default { plugins: [cloudflare()] };
|
||
`,
|
||
);
|
||
expect(viteConfigHasCloudflarePlugin(tmpDir)).toBe(true);
|
||
});
|
||
|
||
it("returns true for a CommonJS vite.config.cjs", () => {
|
||
writeFile(
|
||
tmpDir,
|
||
"vite.config.cjs",
|
||
`const { cloudflare } = require("@cloudflare/vite-plugin");
|
||
module.exports = { plugins: [cloudflare()] };
|
||
`,
|
||
);
|
||
expect(viteConfigHasCloudflarePlugin(tmpDir)).toBe(true);
|
||
});
|
||
|
||
it.each(["cjs", "mts", "cts"])("detects a cache adapter in vite.config.%s", (extension) => {
|
||
writeFile(
|
||
tmpDir,
|
||
`vite.config.${extension}`,
|
||
`export default { plugins: [vinext({ cache: { data: kvDataAdapter() } })] };\n`,
|
||
);
|
||
expect(viteConfigHasCacheAdapter(tmpDir)).toBe(true);
|
||
});
|
||
|
||
it.each(["cjs", "mts", "cts"])(
|
||
"detects a missing cache adapter in vite.config.%s",
|
||
(extension) => {
|
||
writeFile(tmpDir, `vite.config.${extension}`, `export default { plugins: [vinext()] };\n`);
|
||
expect(viteConfigHasCacheAdapter(tmpDir)).toBe(false);
|
||
},
|
||
);
|
||
|
||
it("returns false when cloudflare() only appears in a comment", () => {
|
||
writeFile(
|
||
tmpDir,
|
||
"vite.config.ts",
|
||
`import { cloudflare } from "@cloudflare/vite-plugin";
|
||
export default { plugins: [] }; // cloudflare()
|
||
`,
|
||
);
|
||
expect(viteConfigHasCloudflarePlugin(tmpDir)).toBe(false);
|
||
});
|
||
|
||
it("returns false when an unrelated local cloudflare() function is called", () => {
|
||
writeFile(
|
||
tmpDir,
|
||
"vite.config.ts",
|
||
`import { cloudflare as cloudflarePlugin } from "@cloudflare/vite-plugin";
|
||
const cloudflare = () => null;
|
||
export default { plugins: [cloudflare()] };
|
||
`,
|
||
);
|
||
expect(viteConfigHasCloudflarePlugin(tmpDir)).toBe(false);
|
||
});
|
||
|
||
it("uses Vite config precedence when multiple configs exist", () => {
|
||
writeFile(tmpDir, "vite.config.ts", `// cloudflare()\n`);
|
||
writeFile(
|
||
tmpDir,
|
||
"vite.config.js",
|
||
`import { cloudflare } from "@cloudflare/vite-plugin";
|
||
export default { plugins: [cloudflare()] };
|
||
`,
|
||
);
|
||
expect(viteConfigHasCloudflarePlugin(tmpDir)).toBe(true);
|
||
});
|
||
|
||
it("returns false when no vite config file exists", () => {
|
||
// tmpDir has no vite config
|
||
expect(viteConfigHasCloudflarePlugin(tmpDir)).toBe(false);
|
||
});
|
||
});
|
||
|
||
// ─── hasWranglerConfig ───────────────────────────────────────────────────────
|
||
|
||
describe("hasWranglerConfig", () => {
|
||
it("returns true when wrangler.jsonc exists", () => {
|
||
writeFile(tmpDir, "wrangler.jsonc", "{}");
|
||
expect(hasWranglerConfig(tmpDir)).toBe(true);
|
||
});
|
||
|
||
it("returns true when wrangler.json exists", () => {
|
||
writeFile(tmpDir, "wrangler.json", "{}");
|
||
expect(hasWranglerConfig(tmpDir)).toBe(true);
|
||
});
|
||
|
||
it("returns true when wrangler.toml exists", () => {
|
||
writeFile(tmpDir, "wrangler.toml", "");
|
||
expect(hasWranglerConfig(tmpDir)).toBe(true);
|
||
});
|
||
|
||
it("returns true when cloudflare.config.ts exists", () => {
|
||
writeFile(tmpDir, "cloudflare.config.ts", "export default {};");
|
||
expect(hasWranglerConfig(tmpDir)).toBe(true);
|
||
});
|
||
|
||
it("returns false when none exist", () => {
|
||
expect(hasWranglerConfig(tmpDir)).toBe(false);
|
||
});
|
||
});
|
||
|
||
// ─── formatMissingCloudflarePluginError ─────────────────────────────────────
|
||
|
||
describe("formatMissingCloudflarePluginError", () => {
|
||
it("includes viteEnvironment config when isAppRouter is true", () => {
|
||
const msg = formatMissingCloudflarePluginError({ isAppRouter: true });
|
||
expect(msg).toContain("viteEnvironment");
|
||
expect(msg).toContain('childEnvironments: ["ssr"]');
|
||
});
|
||
|
||
it("omits viteEnvironment config when isAppRouter is false", () => {
|
||
const msg = formatMissingCloudflarePluginError({ isAppRouter: false });
|
||
expect(msg).not.toContain("viteEnvironment");
|
||
});
|
||
|
||
it("includes actual config file path when configFile is provided", () => {
|
||
const msg = formatMissingCloudflarePluginError({
|
||
isAppRouter: false,
|
||
configFile: "/project/vite.config.mts",
|
||
});
|
||
expect(msg).toContain("/project/vite.config.mts");
|
||
});
|
||
|
||
it("uses generic 'your Vite config' when configFile is undefined", () => {
|
||
const msg = formatMissingCloudflarePluginError({ isAppRouter: false });
|
||
expect(msg).toContain("your Vite config");
|
||
});
|
||
|
||
it("never hardcodes vite.config.ts when no configFile given", () => {
|
||
const msg = formatMissingCloudflarePluginError({ isAppRouter: false });
|
||
expect(msg).not.toMatch(/vite\.config\.ts/);
|
||
expect(msg).not.toMatch(/vite\.config\.js/);
|
||
expect(msg).not.toMatch(/vite\.config\.mjs/);
|
||
});
|
||
|
||
it("always starts with the [vinext] prefix", () => {
|
||
const msg = formatMissingCloudflarePluginError({ isAppRouter: false });
|
||
expect(msg).toMatch(/^\[vinext\] Missing @cloudflare\/vite-plugin/);
|
||
});
|
||
});
|
||
|
||
// ─── ensureESModule ──────────────────────────────────────────────────────────
|
||
|
||
describe("ensureESModule", () => {
|
||
it("adds 'type': 'module' when missing", () => {
|
||
mkdir(tmpDir, "app");
|
||
writeFile(tmpDir, "package.json", JSON.stringify({ name: "test-app" }));
|
||
|
||
const added = ensureESModule(tmpDir);
|
||
expect(added).toBe(true);
|
||
|
||
const pkg = JSON.parse(fs.readFileSync(path.join(tmpDir, "package.json"), "utf-8"));
|
||
expect(pkg.type).toBe("module");
|
||
});
|
||
|
||
it("returns false when already has 'type': 'module'", () => {
|
||
writeFile(tmpDir, "package.json", JSON.stringify({ name: "test-app", type: "module" }));
|
||
|
||
const added = ensureESModule(tmpDir);
|
||
expect(added).toBe(false);
|
||
});
|
||
|
||
it("returns false when no package.json", () => {
|
||
const added = ensureESModule(tmpDir);
|
||
expect(added).toBe(false);
|
||
});
|
||
|
||
it("preserves existing package.json fields", () => {
|
||
writeFile(
|
||
tmpDir,
|
||
"package.json",
|
||
JSON.stringify({
|
||
name: "test-app",
|
||
version: "1.0.0",
|
||
dependencies: { react: "^19.0.0" },
|
||
}),
|
||
);
|
||
|
||
ensureESModule(tmpDir);
|
||
const pkg = JSON.parse(fs.readFileSync(path.join(tmpDir, "package.json"), "utf-8"));
|
||
expect(pkg.name).toBe("test-app");
|
||
expect(pkg.version).toBe("1.0.0");
|
||
expect(pkg.dependencies.react).toBe("^19.0.0");
|
||
expect(pkg.type).toBe("module");
|
||
});
|
||
});
|
||
|
||
// ─── renameCJSConfigs ────────────────────────────────────────────────────────
|
||
|
||
describe("renameCJSConfigs", () => {
|
||
it("renames postcss.config.js using module.exports to .cjs", () => {
|
||
writeFile(tmpDir, "postcss.config.js", "module.exports = { plugins: {} };");
|
||
|
||
const renamed = renameCJSConfigs(tmpDir);
|
||
expect(renamed).toEqual([["postcss.config.js", "postcss.config.cjs"]]);
|
||
expect(fs.existsSync(path.join(tmpDir, "postcss.config.cjs"))).toBe(true);
|
||
expect(fs.existsSync(path.join(tmpDir, "postcss.config.js"))).toBe(false);
|
||
});
|
||
|
||
it("renames tailwind.config.js using require() to .cjs", () => {
|
||
writeFile(
|
||
tmpDir,
|
||
"tailwind.config.js",
|
||
`const plugin = require("tailwindcss/plugin");\nmodule.exports = {};`,
|
||
);
|
||
|
||
const renamed = renameCJSConfigs(tmpDir);
|
||
expect(renamed).toEqual([["tailwind.config.js", "tailwind.config.cjs"]]);
|
||
});
|
||
|
||
it("does not rename ESM config files", () => {
|
||
writeFile(tmpDir, "postcss.config.js", "export default { plugins: {} };");
|
||
|
||
const renamed = renameCJSConfigs(tmpDir);
|
||
expect(renamed).toEqual([]);
|
||
expect(fs.existsSync(path.join(tmpDir, "postcss.config.js"))).toBe(true);
|
||
});
|
||
|
||
it("renames multiple CJS configs at once", () => {
|
||
writeFile(tmpDir, "postcss.config.js", "module.exports = {};");
|
||
writeFile(tmpDir, "tailwind.config.js", "module.exports = {};");
|
||
writeFile(tmpDir, ".eslintrc.js", "module.exports = {};");
|
||
|
||
const renamed = renameCJSConfigs(tmpDir);
|
||
expect(renamed).toHaveLength(3);
|
||
expect(renamed.map((r) => r[0])).toContain("postcss.config.js");
|
||
expect(renamed.map((r) => r[0])).toContain("tailwind.config.js");
|
||
expect(renamed.map((r) => r[0])).toContain(".eslintrc.js");
|
||
});
|
||
|
||
it("returns empty array when no CJS configs exist", () => {
|
||
const renamed = renameCJSConfigs(tmpDir);
|
||
expect(renamed).toEqual([]);
|
||
});
|
||
});
|
||
|
||
// ─── detectProject: src/ directory support ──────────────────────────────────
|
||
|
||
describe("detectProject — src/ directory convention", () => {
|
||
it("detects App Router when src/app/ exists", () => {
|
||
mkdir(tmpDir, "src/app");
|
||
writeFile(
|
||
tmpDir,
|
||
"src/app/page.tsx",
|
||
"export default function Home() { return <div>hi</div> }",
|
||
);
|
||
const info = detectProject(tmpDir);
|
||
expect(info.isAppRouter).toBe(true);
|
||
expect(info.isPagesRouter).toBe(false);
|
||
});
|
||
|
||
it("detects Pages Router when only src/pages/ exists", () => {
|
||
mkdir(tmpDir, "src/pages");
|
||
writeFile(
|
||
tmpDir,
|
||
"src/pages/index.tsx",
|
||
"export default function Home() { return <div>hi</div> }",
|
||
);
|
||
const info = detectProject(tmpDir);
|
||
expect(info.isAppRouter).toBe(false);
|
||
expect(info.isPagesRouter).toBe(true);
|
||
});
|
||
|
||
it("prefers App Router when both src/app/ and src/pages/ exist", () => {
|
||
mkdir(tmpDir, "src/app");
|
||
mkdir(tmpDir, "src/pages");
|
||
const info = detectProject(tmpDir);
|
||
expect(info.isAppRouter).toBe(true);
|
||
expect(info.isPagesRouter).toBe(false);
|
||
});
|
||
|
||
it("prefers root-level app/ over src/app/", () => {
|
||
mkdir(tmpDir, "app");
|
||
mkdir(tmpDir, "src/app");
|
||
const info = detectProject(tmpDir);
|
||
expect(info.isAppRouter).toBe(true);
|
||
});
|
||
|
||
it("prefers root-level pages/ over src/pages/", () => {
|
||
mkdir(tmpDir, "pages");
|
||
mkdir(tmpDir, "src/pages");
|
||
const info = detectProject(tmpDir);
|
||
expect(info.isPagesRouter).toBe(true);
|
||
});
|
||
|
||
it("detects App Router from root app/ even when src/pages/ exists", () => {
|
||
mkdir(tmpDir, "app");
|
||
mkdir(tmpDir, "src/pages");
|
||
const info = detectProject(tmpDir);
|
||
expect(info.isAppRouter).toBe(true);
|
||
expect(info.isPagesRouter).toBe(false);
|
||
});
|
||
|
||
it("detects ISR in src/app/ directory", () => {
|
||
mkdir(tmpDir, "src/app");
|
||
writeFile(
|
||
tmpDir,
|
||
"src/app/posts/page.tsx",
|
||
`export const revalidate = 60;\nexport default function Posts() { return <div>posts</div> }`,
|
||
);
|
||
const info = detectProject(tmpDir);
|
||
expect(info.isAppRouter).toBe(true);
|
||
expect(info.hasISR).toBe(true);
|
||
});
|
||
|
||
it("does not detect ISR when src/app/ has no revalidate exports", () => {
|
||
mkdir(tmpDir, "src/app");
|
||
writeFile(
|
||
tmpDir,
|
||
"src/app/page.tsx",
|
||
"export default function Home() { return <div>hi</div> }",
|
||
);
|
||
const info = detectProject(tmpDir);
|
||
expect(info.isAppRouter).toBe(true);
|
||
expect(info.hasISR).toBe(false);
|
||
});
|
||
|
||
it("detects MDX in src/app/ directory", () => {
|
||
mkdir(tmpDir, "src/app");
|
||
writeFile(tmpDir, "src/app/about/page.mdx", "# About\nHello world");
|
||
const info = detectProject(tmpDir);
|
||
expect(info.isAppRouter).toBe(true);
|
||
expect(info.hasMDX).toBe(true);
|
||
});
|
||
|
||
it("detects MDX in src/pages/ directory", () => {
|
||
mkdir(tmpDir, "src/pages");
|
||
writeFile(tmpDir, "src/pages/about.mdx", "# About\nHello world");
|
||
const info = detectProject(tmpDir);
|
||
expect(info.isPagesRouter).toBe(true);
|
||
expect(info.hasMDX).toBe(true);
|
||
});
|
||
|
||
it("detects neither when no app/, pages/, src/app/, or src/pages/", () => {
|
||
mkdir(tmpDir, "src/lib");
|
||
const info = detectProject(tmpDir);
|
||
expect(info.isAppRouter).toBe(false);
|
||
expect(info.isPagesRouter).toBe(false);
|
||
});
|
||
});
|
||
|
||
// ─── detectProject: new fields ──────────────────────────────────────────────
|
||
|
||
describe("detectProject — new detection features", () => {
|
||
it("detects hasTypeModule when package.json has type: module", () => {
|
||
mkdir(tmpDir, "app");
|
||
writeFile(tmpDir, "package.json", JSON.stringify({ name: "test", type: "module" }));
|
||
const info = detectProject(tmpDir);
|
||
expect(info.hasTypeModule).toBe(true);
|
||
});
|
||
|
||
it("hasTypeModule is false when missing", () => {
|
||
mkdir(tmpDir, "app");
|
||
writeFile(tmpDir, "package.json", JSON.stringify({ name: "test" }));
|
||
const info = detectProject(tmpDir);
|
||
expect(info.hasTypeModule).toBe(false);
|
||
});
|
||
|
||
it("detects MDX via .mdx files in app/", () => {
|
||
mkdir(tmpDir, "app");
|
||
writeFile(tmpDir, "app/about/page.mdx", "# About\nHello world");
|
||
const info = detectProject(tmpDir);
|
||
expect(info.hasMDX).toBe(true);
|
||
});
|
||
|
||
it("detects MDX via @next/mdx in next.config", () => {
|
||
mkdir(tmpDir, "app");
|
||
writeFile(tmpDir, "next.config.mjs", `import mdx from "@next/mdx";\nexport default mdx()({});`);
|
||
const info = detectProject(tmpDir);
|
||
expect(info.hasMDX).toBe(true);
|
||
});
|
||
|
||
it("hasMDX is false when no MDX usage", () => {
|
||
mkdir(tmpDir, "app");
|
||
writeFile(tmpDir, "app/page.tsx", "export default function Home() { return <div/> }");
|
||
const info = detectProject(tmpDir);
|
||
expect(info.hasMDX).toBe(false);
|
||
});
|
||
|
||
it("detects CodeHike dependency", () => {
|
||
mkdir(tmpDir, "app");
|
||
writeFile(tmpDir, "package.json", JSON.stringify({ dependencies: { codehike: "^1.0.0" } }));
|
||
const info = detectProject(tmpDir);
|
||
expect(info.hasCodeHike).toBe(true);
|
||
});
|
||
|
||
it("hasCodeHike is false when not a dependency", () => {
|
||
mkdir(tmpDir, "app");
|
||
writeFile(tmpDir, "package.json", JSON.stringify({ dependencies: { react: "^19.0.0" } }));
|
||
const info = detectProject(tmpDir);
|
||
expect(info.hasCodeHike).toBe(false);
|
||
});
|
||
|
||
it("detects native modules to stub", () => {
|
||
mkdir(tmpDir, "app");
|
||
writeFile(
|
||
tmpDir,
|
||
"package.json",
|
||
JSON.stringify({ dependencies: { "@resvg/resvg-js": "^2.0.0", satori: "^0.10.0" } }),
|
||
);
|
||
const info = detectProject(tmpDir);
|
||
expect(info.nativeModulesToStub).toContain("@resvg/resvg-js");
|
||
expect(info.nativeModulesToStub).toContain("satori");
|
||
});
|
||
|
||
it("detects native modules listed only in devDependencies", () => {
|
||
mkdir(tmpDir, "app");
|
||
writeFile(
|
||
tmpDir,
|
||
"package.json",
|
||
JSON.stringify({ devDependencies: { sharp: "^0.33.0", lightningcss: "^1.0.0" } }),
|
||
);
|
||
const info = detectProject(tmpDir);
|
||
expect(info.nativeModulesToStub).toContain("sharp");
|
||
expect(info.nativeModulesToStub).toContain("lightningcss");
|
||
});
|
||
|
||
it("nativeModulesToStub is empty when no native deps", () => {
|
||
mkdir(tmpDir, "app");
|
||
writeFile(tmpDir, "package.json", JSON.stringify({ dependencies: { react: "^19.0.0" } }));
|
||
const info = detectProject(tmpDir);
|
||
expect(info.nativeModulesToStub).toEqual([]);
|
||
});
|
||
|
||
it("detects ISR and MDX from a single app/ tree walk", () => {
|
||
// ISR (`export const revalidate`) and a `.mdx` file live in the same tree;
|
||
// detection shares one recursive walk and reports both.
|
||
mkdir(tmpDir, "app");
|
||
writeFile(
|
||
tmpDir,
|
||
"app/posts/page.tsx",
|
||
"export const revalidate = 60;\nexport default function() { return <div/> }",
|
||
);
|
||
writeFile(tmpDir, "app/about/page.mdx", "# About");
|
||
const info = detectProject(tmpDir);
|
||
expect(info.hasISR).toBe(true);
|
||
expect(info.hasMDX).toBe(true);
|
||
});
|
||
});
|
||
|
||
// ─── Generated Vite config with new features ────────────────────────────────
|
||
|
||
describe("generateAppRouterViteConfig — with project info", () => {
|
||
it("delegates MDX to vinext plugin auto-injection (no separate mdx() call)", () => {
|
||
mkdir(tmpDir, "app");
|
||
writeFile(tmpDir, "app/about/page.mdx", "# About");
|
||
const info = detectProject(tmpDir);
|
||
const config = generateAppRouterViteConfig(info);
|
||
// MDX is now handled by the vinext plugin's auto-injection at runtime,
|
||
// not by a separate mdx() call in the generated config.
|
||
expect(config).toContain("vinext({");
|
||
expect(config).toContain("auto-injects @mdx-js/rollup");
|
||
expect(config).not.toContain('import mdx from "@mdx-js/rollup"');
|
||
});
|
||
|
||
it("does not include CodeHike plugins in generated config (handled by vinext plugin)", () => {
|
||
mkdir(tmpDir, "app");
|
||
writeFile(tmpDir, "app/about/page.mdx", "# About");
|
||
writeFile(tmpDir, "package.json", JSON.stringify({ dependencies: { codehike: "^1.0.0" } }));
|
||
const info = detectProject(tmpDir);
|
||
const config = generateAppRouterViteConfig(info);
|
||
// CodeHike plugins are extracted from next.config at runtime by the vinext plugin
|
||
expect(config).not.toContain("remarkCodeHike");
|
||
expect(config).not.toContain("recmaCodeHike");
|
||
expect(config).toContain("vinext({");
|
||
});
|
||
|
||
it("does not include tsconfig aliases in generated config (handled by plugin at runtime)", () => {
|
||
mkdir(tmpDir, "app");
|
||
writeFile(
|
||
tmpDir,
|
||
"tsconfig.json",
|
||
JSON.stringify({
|
||
compilerOptions: { baseUrl: ".", paths: { "#/*": ["./*"] } },
|
||
}),
|
||
);
|
||
const info = detectProject(tmpDir);
|
||
const config = generateAppRouterViteConfig(info);
|
||
expect(config).not.toContain('"#"');
|
||
});
|
||
|
||
it("includes native module stubs in resolve.alias", () => {
|
||
mkdir(tmpDir, "app");
|
||
writeFile(
|
||
tmpDir,
|
||
"package.json",
|
||
JSON.stringify({ dependencies: { "@resvg/resvg-js": "^2.0.0" } }),
|
||
);
|
||
const info = detectProject(tmpDir);
|
||
const config = generateAppRouterViteConfig(info);
|
||
expect(config).toContain("@resvg/resvg-js");
|
||
expect(config).toContain("empty-stub.js");
|
||
});
|
||
|
||
it("still works without info (backward compatible)", () => {
|
||
const config = generateAppRouterViteConfig();
|
||
expect(config).toContain("vinext({");
|
||
expect(config).toContain("cloudflare(");
|
||
// Generated config no longer includes a separate mdx() import/call
|
||
expect(config).not.toContain('import mdx from "@mdx-js/rollup"');
|
||
expect(config).not.toContain("resolve:");
|
||
});
|
||
});
|
||
|
||
describe("generatePagesRouterViteConfig — with project info", () => {
|
||
it("does not include tsconfig aliases in generated config (handled by plugin at runtime)", () => {
|
||
mkdir(tmpDir, "pages");
|
||
writeFile(
|
||
tmpDir,
|
||
"tsconfig.json",
|
||
JSON.stringify({
|
||
compilerOptions: { baseUrl: ".", paths: { "@/*": ["./src/*"] } },
|
||
}),
|
||
);
|
||
const info = detectProject(tmpDir);
|
||
const config = generatePagesRouterViteConfig(info);
|
||
expect(config).not.toContain('"@"');
|
||
});
|
||
|
||
it("still works without info (backward compatible)", () => {
|
||
const config = generatePagesRouterViteConfig();
|
||
expect(config).toContain("vinext({");
|
||
expect(config).toContain("cloudflare()");
|
||
expect(config).not.toContain("resolve:");
|
||
});
|
||
});
|
||
|
||
// ─── getMissingDeps with MDX ─────────────────────────────────────────────────
|
||
|
||
describe("getMissingDeps — MDX", () => {
|
||
it("reports @mdx-js/rollup when MDX detected but not installed", () => {
|
||
mkdir(tmpDir, "app");
|
||
writeFile(tmpDir, "app/about/page.mdx", "# About");
|
||
const info = detectProject(tmpDir);
|
||
info.hasCloudflarePlugin = true;
|
||
info.hasWrangler = true;
|
||
info.hasRscPlugin = true;
|
||
const missing = getMissingDeps(info, (root, pkg) => pkg !== "@mdx-js/rollup");
|
||
expect(missing).toContainEqual(expect.objectContaining({ name: "@mdx-js/rollup" }));
|
||
});
|
||
|
||
it("does not report @mdx-js/rollup when it is resolvable", () => {
|
||
mkdir(tmpDir, "app");
|
||
writeFile(tmpDir, "app/about/page.mdx", "# About");
|
||
const info = detectProject(tmpDir);
|
||
info.hasCloudflarePlugin = true;
|
||
info.hasWrangler = true;
|
||
info.hasRscPlugin = true;
|
||
|
||
const missing = getMissingDeps(info, () => true);
|
||
expect(missing).not.toContainEqual(expect.objectContaining({ name: "@mdx-js/rollup" }));
|
||
});
|
||
});
|
||
|
||
// ─── Integration: Full Detection of Real Fixtures ────────────────────────────
|
||
|
||
describe("detectProject on real fixtures", () => {
|
||
const fixturesDir = path.resolve(import.meta.dirname, "../fixtures");
|
||
|
||
it("detects app-router-cloudflare fixture correctly", () => {
|
||
const cfApp = path.join(fixturesDir, "app-router-cloudflare");
|
||
if (!fs.existsSync(cfApp)) return; // skip if not available
|
||
|
||
const info = detectProject(cfApp);
|
||
expect(info.isAppRouter).toBe(true);
|
||
expect(info.hasViteConfig).toBe(true);
|
||
expect(info.hasWranglerConfig).toBe(true);
|
||
expect(info.hasWorkerEntry).toBe(true);
|
||
});
|
||
|
||
it("detects pages-router-cloudflare fixture correctly", () => {
|
||
const cfPages = path.join(fixturesDir, "pages-router-cloudflare");
|
||
if (!fs.existsSync(cfPages)) return; // skip if not available
|
||
|
||
const info = detectProject(cfPages);
|
||
expect(info.isPagesRouter).toBe(true);
|
||
expect(info.hasViteConfig).toBe(true);
|
||
expect(info.hasWranglerConfig).toBe(true);
|
||
expect(info.hasWorkerEntry).toBe(true);
|
||
});
|
||
|
||
it("would report missing deps for non-cloudflare fixture", () => {
|
||
const pagesBasic = path.join(fixturesDir, "pages-basic");
|
||
if (!fs.existsSync(pagesBasic)) return;
|
||
|
||
const info = detectProject(pagesBasic);
|
||
// pages-basic doesn't have @cloudflare/vite-plugin or wrangler in its own node_modules
|
||
// (it uses the hoisted root node_modules), but the check is per-project
|
||
// The important thing: getMissingDeps respects the detected flags
|
||
info.hasCloudflarePlugin = false;
|
||
info.hasWrangler = false;
|
||
const missing = getMissingDeps(info);
|
||
expect(missing.length).toBeGreaterThan(0);
|
||
});
|
||
});
|
||
|
||
// ─── Cloudflare _headers generation ─────────────────────────────────────────
|
||
// These tests exercise the same logic used by the vinext:cloudflare-build
|
||
// plugin's closeBundle hook to generate a _headers file for static asset
|
||
// caching on Cloudflare Workers.
|
||
|
||
describe("Cloudflare _headers file generation", () => {
|
||
/** Replicates the _headers generation logic from the closeBundle hook. */
|
||
function generateHeaders(clientDir: string, assetsDir = "_next/static"): void {
|
||
const headersPath = path.join(clientDir, "_headers");
|
||
if (!fs.existsSync(headersPath)) {
|
||
const headersContent = [
|
||
"# Cache content-hashed assets immutably (generated by vinext)",
|
||
`/${assetsDir}/*`,
|
||
" Cache-Control: public, max-age=31536000, immutable",
|
||
"",
|
||
].join("\n");
|
||
fs.writeFileSync(headersPath, headersContent);
|
||
}
|
||
}
|
||
|
||
it("generates _headers with correct Cloudflare format", () => {
|
||
const clientDir = path.join(tmpDir, "dist", "client");
|
||
fs.mkdirSync(clientDir, { recursive: true });
|
||
|
||
generateHeaders(clientDir);
|
||
|
||
const content = fs.readFileSync(path.join(clientDir, "_headers"), "utf-8");
|
||
expect(content).toContain("/_next/static/*");
|
||
expect(content).toContain("Cache-Control: public, max-age=31536000, immutable");
|
||
// Verify Cloudflare _headers format: path on its own line, indented header below
|
||
const lines = content.split("\n");
|
||
const pathLine = lines.findIndex((l) => l === "/_next/static/*");
|
||
expect(pathLine).toBeGreaterThanOrEqual(0);
|
||
expect(lines[pathLine + 1]).toBe(" Cache-Control: public, max-age=31536000, immutable");
|
||
});
|
||
|
||
it("skips generation when _headers already exists", () => {
|
||
const clientDir = path.join(tmpDir, "dist", "client");
|
||
fs.mkdirSync(clientDir, { recursive: true });
|
||
|
||
const userContent = "/custom/*\n X-Custom: true\n";
|
||
fs.writeFileSync(path.join(clientDir, "_headers"), userContent);
|
||
|
||
generateHeaders(clientDir);
|
||
|
||
const content = fs.readFileSync(path.join(clientDir, "_headers"), "utf-8");
|
||
expect(content).toBe(userContent);
|
||
expect(content).not.toContain("/_next/static/*");
|
||
});
|
||
|
||
it("respects custom assetsDir", () => {
|
||
const clientDir = path.join(tmpDir, "dist", "client");
|
||
fs.mkdirSync(clientDir, { recursive: true });
|
||
|
||
generateHeaders(clientDir, "static");
|
||
|
||
const content = fs.readFileSync(path.join(clientDir, "_headers"), "utf-8");
|
||
expect(content).toContain("/static/*");
|
||
expect(content).not.toContain("/_next/static/*");
|
||
});
|
||
|
||
it("ends with a trailing newline", () => {
|
||
const clientDir = path.join(tmpDir, "dist", "client");
|
||
fs.mkdirSync(clientDir, { recursive: true });
|
||
|
||
generateHeaders(clientDir);
|
||
|
||
const content = fs.readFileSync(path.join(clientDir, "_headers"), "utf-8");
|
||
expect(content.endsWith("\n")).toBe(true);
|
||
});
|
||
});
|
||
|
||
// ─── Client asset sidecar generation ────────────────────────────────────────
|
||
|
||
describe("client asset sidecar generation", () => {
|
||
const manifestWithLazyChunks = {
|
||
"virtual:vinext-app-browser-entry": {
|
||
file: "assets/app-entry.js",
|
||
isEntry: true,
|
||
imports: ["node_modules/react/index.js"],
|
||
dynamicImports: ["src/components/MermaidChart.tsx"],
|
||
},
|
||
"node_modules/react/index.js": { file: "assets/framework.js" },
|
||
"src/components/MermaidChart.tsx": {
|
||
file: "assets/mermaid-chart.js",
|
||
isDynamicEntry: true,
|
||
imports: ["node_modules/mermaid/dist/mermaid.js"],
|
||
},
|
||
"node_modules/mermaid/dist/mermaid.js": { file: "assets/mermaid-vendor.js" },
|
||
};
|
||
|
||
function writeClientBuild(root: string, manifest: Record<string, unknown>): string {
|
||
const clientDir = path.join(root, "dist", "client");
|
||
fs.mkdirSync(path.join(clientDir, ".vite"), { recursive: true });
|
||
fs.writeFileSync(path.join(clientDir, ".vite", "manifest.json"), JSON.stringify(manifest));
|
||
return clientDir;
|
||
}
|
||
|
||
function readGeneratedModule(filePath: string): Record<string, unknown> {
|
||
const source = fs.readFileSync(filePath, "utf-8");
|
||
return JSON.parse(source.slice("export default ".length, -2));
|
||
}
|
||
|
||
it("serializes runtime metadata as an importable module", () => {
|
||
const source = buildPagesClientAssetsModule({
|
||
clientEntry: "assets/entry.js",
|
||
appBootstrapPreinitModules: ["/assets/framework.js"],
|
||
ssrManifest: { "pages/index.tsx": ["assets/page.js"] },
|
||
lazyChunks: ["assets/lazy.js"],
|
||
dynamicPreloads: { "src/widget.tsx": ["assets/widget.js"] },
|
||
});
|
||
|
||
expect(source).toBe(
|
||
'export default {"clientEntry":"assets/entry.js","appBootstrapPreinitModules":["/assets/framework.js"],"ssrManifest":{"pages/index.tsx":["assets/page.js"]},"lazyChunks":["assets/lazy.js"],"dynamicPreloads":{"src/widget.tsx":["assets/widget.js"]}};\n',
|
||
);
|
||
});
|
||
|
||
it("serializes root-anchored App bootstrap preinit modules", () => {
|
||
const clientDir = writeClientBuild(tmpDir, manifestWithLazyChunks);
|
||
fs.writeFileSync(
|
||
path.join(clientDir, "vinext-client-entry-manifest.json"),
|
||
JSON.stringify({ appBrowserEntry: "assets/app-entry.js" }),
|
||
);
|
||
|
||
const metadata = computeClientRuntimeMetadata({
|
||
clientDir,
|
||
assetBase: "/",
|
||
assetPrefix: "",
|
||
});
|
||
const sidecarPath = path.join(tmpDir, "vinext-client-assets.js");
|
||
fs.writeFileSync(sidecarPath, buildPagesClientAssetsModule(metadata));
|
||
|
||
expect(readGeneratedModule(sidecarPath).appBootstrapPreinitModules).toEqual([
|
||
"/assets/framework.js",
|
||
]);
|
||
});
|
||
|
||
it("keeps lazy chunks base-relative while applying assetPrefix to dynamic preloads", () => {
|
||
const clientDir = writeClientBuild(tmpDir, manifestWithLazyChunks);
|
||
const metadata = computeClientRuntimeMetadata({
|
||
clientDir,
|
||
assetBase: "/docs/",
|
||
assetPrefix: "/cdn-prefix",
|
||
});
|
||
|
||
expect(metadata.lazyChunks).toEqual([
|
||
"docs/assets/mermaid-chart.js",
|
||
"docs/assets/mermaid-vendor.js",
|
||
]);
|
||
expect(metadata.dynamicPreloads?.["src/components/MermaidChart.tsx"]).toEqual([
|
||
"cdn-prefix/_next/static/assets/mermaid-chart.js",
|
||
"cdn-prefix/_next/static/assets/mermaid-vendor.js",
|
||
]);
|
||
});
|
||
|
||
it("writes a sidecar without mutating the worker entry", () => {
|
||
const workerDir = path.join(tmpDir, "dist", "worker");
|
||
fs.mkdirSync(workerDir, { recursive: true });
|
||
const workerEntry = path.join(workerDir, "index.js");
|
||
fs.writeFileSync(workerEntry, "// worker entry\nexport default { fetch() {} };");
|
||
|
||
const sidecarPath = path.join(workerDir, "vinext-client-assets.js");
|
||
fs.writeFileSync(sidecarPath, buildPagesClientAssetsModule({ lazyChunks: ["assets/lazy.js"] }));
|
||
|
||
expect(fs.readFileSync(workerEntry, "utf-8")).toBe(
|
||
"// worker entry\nexport default { fetch() {} };",
|
||
);
|
||
expect(readGeneratedModule(sidecarPath)).toEqual({ lazyChunks: ["assets/lazy.js"] });
|
||
});
|
||
|
||
it("does not let a later server environment overwrite client asset metadata", () => {
|
||
const outputDir = path.join(tmpDir, "dist", "server");
|
||
const sidecarPath = path.join(outputDir, "vinext-client-assets.js");
|
||
const clientModule = buildPagesClientAssetsModule({
|
||
clientEntry: "_next/static/chunks/index-abcd.js",
|
||
});
|
||
|
||
writePagesClientAssetsModuleIfMissing(outputDir, clientModule);
|
||
writePagesClientAssetsModuleIfMissing(outputDir, buildPagesClientAssetsModule({}));
|
||
|
||
expect(fs.readFileSync(sidecarPath, "utf-8")).toBe(clientModule);
|
||
});
|
||
|
||
it("resolves the Pages client entry for mixed app+pages builds", () => {
|
||
const clientDir = writeClientBuild(tmpDir, manifestWithLazyChunks);
|
||
const chunksDir = path.join(clientDir, "_next", "static", "chunks");
|
||
fs.mkdirSync(chunksDir, { recursive: true });
|
||
fs.writeFileSync(path.join(chunksDir, "vinext-client-entry-abcd.js"), "");
|
||
|
||
const metadata = computeClientRuntimeMetadata({
|
||
clientDir,
|
||
assetBase: "/",
|
||
assetPrefix: "",
|
||
includeClientEntry: "pages-client-entry",
|
||
});
|
||
|
||
expect(metadata.clientEntryFile).toBe("_next/static/chunks/vinext-client-entry-abcd.js");
|
||
});
|
||
|
||
it("does not invent a Pages client entry for a pure App Router build", () => {
|
||
const clientDir = writeClientBuild(tmpDir, manifestWithLazyChunks);
|
||
const metadata = computeClientRuntimeMetadata({
|
||
clientDir,
|
||
assetBase: "/",
|
||
assetPrefix: "",
|
||
includeClientEntry: false,
|
||
});
|
||
|
||
expect(metadata.clientEntryFile).toBeUndefined();
|
||
});
|
||
});
|
||
|
||
describe("detectPackageManager", () => {
|
||
it("detects pnpm from pnpm-lock.yaml", () => {
|
||
writeFile(tmpDir, "pnpm-lock.yaml", "");
|
||
expect(detectPackageManager(tmpDir)).toBe("pnpm add -D");
|
||
expect(detectPackageManagerName(tmpDir)).toBe("pnpm");
|
||
});
|
||
|
||
it("detects yarn from yarn.lock", () => {
|
||
writeFile(tmpDir, "yarn.lock", "");
|
||
expect(detectPackageManager(tmpDir)).toBe("yarn add -D");
|
||
expect(detectPackageManagerName(tmpDir)).toBe("yarn");
|
||
});
|
||
|
||
it("detects bun from bun.lock (text format, Bun v1.0+)", () => {
|
||
writeFile(tmpDir, "bun.lock", "");
|
||
expect(detectPackageManager(tmpDir)).toBe("bun add -D");
|
||
expect(detectPackageManagerName(tmpDir)).toBe("bun");
|
||
});
|
||
|
||
it("detects bun from bun.lockb (legacy binary format)", () => {
|
||
writeFile(tmpDir, "bun.lockb", "");
|
||
expect(detectPackageManager(tmpDir)).toBe("bun add -D");
|
||
expect(detectPackageManagerName(tmpDir)).toBe("bun");
|
||
});
|
||
|
||
it("falls back to npm when no lock file is found", () => {
|
||
// Clear the user-agent env var so the CI runner's package manager (pnpm)
|
||
// doesn't leak into the fallback chain.
|
||
const savedUA = process.env.npm_config_user_agent;
|
||
delete process.env.npm_config_user_agent;
|
||
try {
|
||
expect(detectPackageManager(tmpDir)).toBe("npm install -D");
|
||
expect(detectPackageManagerName(tmpDir)).toBe("npm");
|
||
} finally {
|
||
if (savedUA !== undefined) process.env.npm_config_user_agent = savedUA;
|
||
}
|
||
});
|
||
|
||
it("walks up to parent directory to find lock file (monorepo root)", () => {
|
||
writeFile(tmpDir, "bun.lock", "");
|
||
const appDir = path.join(tmpDir, "apps", "web");
|
||
fs.mkdirSync(appDir, { recursive: true });
|
||
writeFile(appDir, "package.json", JSON.stringify({ name: "web" }));
|
||
|
||
expect(detectPackageManager(appDir)).toBe("bun add -D");
|
||
expect(detectPackageManagerName(appDir)).toBe("bun");
|
||
});
|
||
|
||
it("prefers the closest lock file when both child and parent have one", () => {
|
||
writeFile(tmpDir, "bun.lock", "");
|
||
const appDir = path.join(tmpDir, "apps", "web");
|
||
fs.mkdirSync(appDir, { recursive: true });
|
||
writeFile(appDir, "pnpm-lock.yaml", "");
|
||
|
||
expect(detectPackageManager(appDir)).toBe("pnpm add -D");
|
||
expect(detectPackageManagerName(appDir)).toBe("pnpm");
|
||
});
|
||
});
|
||
|
||
// ─── findInNodeModules ───────────────────────────────────────────────────────
|
||
|
||
describe("findInNodeModules", () => {
|
||
it("finds a package in the immediate node_modules", () => {
|
||
mkdir(tmpDir, "node_modules/@cloudflare/vite-plugin");
|
||
const result = findInNodeModules(tmpDir, "@cloudflare/vite-plugin");
|
||
expect(result).toBe(toSlash(path.join(tmpDir, "node_modules", "@cloudflare", "vite-plugin")));
|
||
});
|
||
|
||
it("finds a binary in node_modules/.bin", () => {
|
||
writeFile(tmpDir, "node_modules/.bin/wrangler", "#!/usr/bin/env node");
|
||
const result = findInNodeModules(tmpDir, ".bin/wrangler");
|
||
expect(result).toBe(toSlash(path.join(tmpDir, "node_modules", ".bin", "wrangler")));
|
||
});
|
||
|
||
it("returns null when not found anywhere", () => {
|
||
expect(findInNodeModules(tmpDir, ".bin/wrangler")).toBeNull();
|
||
});
|
||
|
||
it("walks up to find package in monorepo root node_modules", () => {
|
||
writeFile(tmpDir, "node_modules/.bin/wrangler", "#!/usr/bin/env node");
|
||
const appDir = path.join(tmpDir, "apps", "web-next");
|
||
fs.mkdirSync(appDir, { recursive: true });
|
||
|
||
const result = findInNodeModules(appDir, ".bin/wrangler");
|
||
expect(result).toBe(toSlash(path.join(tmpDir, "node_modules", ".bin", "wrangler")));
|
||
});
|
||
|
||
it("prefers the closest node_modules when both app and root have the package", () => {
|
||
mkdir(tmpDir, "node_modules/@cloudflare/vite-plugin");
|
||
const appDir = path.join(tmpDir, "apps", "web-next");
|
||
mkdir(appDir, "node_modules/@cloudflare/vite-plugin");
|
||
|
||
const result = findInNodeModules(appDir, "@cloudflare/vite-plugin");
|
||
expect(result).toBe(toSlash(path.join(appDir, "node_modules", "@cloudflare", "vite-plugin")));
|
||
});
|
||
});
|
||
|
||
// ─── ESM config compatibility (issue #184) ──────────────────────────────────
|
||
//
|
||
// Tests for ensureViteConfigCompatibility() — the wrapper in utils/project.ts
|
||
// that renames CJS configs + adds type:module before Vite loads the config.
|
||
//
|
||
// The underlying ensureESModule() and renameCJSConfigs() are tested above.
|
||
// These tests cover the wrapper's own guards and the integration of both
|
||
// functions together.
|
||
|
||
describe("ensureViteConfigCompatibility — issue #184", () => {
|
||
it("renames CJS configs and adds type:module when vite.config.ts exists", () => {
|
||
writeFile(tmpDir, "package.json", JSON.stringify({ name: "web", version: "1.0.0" }));
|
||
writeFile(
|
||
tmpDir,
|
||
"vite.config.ts",
|
||
'import { cloudflare } from "@cloudflare/vite-plugin";\nexport default { plugins: [cloudflare()] };',
|
||
);
|
||
writeFile(
|
||
tmpDir,
|
||
"postcss.config.js",
|
||
"module.exports = { plugins: { tailwindcss: {}, autoprefixer: {} } };",
|
||
);
|
||
|
||
const result = ensureViteConfigCompatibility(tmpDir);
|
||
|
||
expect(result).not.toBeNull();
|
||
expect(result!.addedTypeModule).toBe(true);
|
||
expect(result!.renamed).toEqual([["postcss.config.js", "postcss.config.cjs"]]);
|
||
|
||
const pkg = JSON.parse(fs.readFileSync(path.join(tmpDir, "package.json"), "utf-8"));
|
||
expect(pkg.type).toBe("module");
|
||
expect(fs.existsSync(path.join(tmpDir, "postcss.config.cjs"))).toBe(true);
|
||
expect(fs.existsSync(path.join(tmpDir, "postcss.config.js"))).toBe(false);
|
||
});
|
||
|
||
it("returns null when no vite.config exists", () => {
|
||
writeFile(tmpDir, "package.json", JSON.stringify({ name: "web", version: "1.0.0" }));
|
||
|
||
const result = ensureViteConfigCompatibility(tmpDir);
|
||
expect(result).toBeNull();
|
||
|
||
// package.json should not be modified
|
||
const pkg = JSON.parse(fs.readFileSync(path.join(tmpDir, "package.json"), "utf-8"));
|
||
expect(pkg.type).toBeUndefined();
|
||
});
|
||
|
||
it("returns null when package.json already has type:module", () => {
|
||
writeFile(tmpDir, "package.json", JSON.stringify({ name: "web", type: "module" }));
|
||
writeFile(tmpDir, "vite.config.ts", "export default {};");
|
||
|
||
const result = ensureViteConfigCompatibility(tmpDir);
|
||
expect(result).toBeNull();
|
||
});
|
||
|
||
it("does not override explicit 'type': 'commonjs'", () => {
|
||
writeFile(tmpDir, "package.json", JSON.stringify({ name: "web", type: "commonjs" }));
|
||
writeFile(tmpDir, "vite.config.ts", "export default {};");
|
||
writeFile(tmpDir, "postcss.config.js", "module.exports = {};");
|
||
|
||
const result = ensureViteConfigCompatibility(tmpDir);
|
||
expect(result).toBeNull();
|
||
|
||
// package.json should not be modified
|
||
const pkg = JSON.parse(fs.readFileSync(path.join(tmpDir, "package.json"), "utf-8"));
|
||
expect(pkg.type).toBe("commonjs");
|
||
|
||
// CJS configs should not be renamed either
|
||
expect(fs.existsSync(path.join(tmpDir, "postcss.config.js"))).toBe(true);
|
||
});
|
||
|
||
it("returns null when no package.json exists", () => {
|
||
writeFile(tmpDir, "vite.config.ts", "export default {};");
|
||
|
||
const result = ensureViteConfigCompatibility(tmpDir);
|
||
expect(result).toBeNull();
|
||
});
|
||
|
||
it("detects vite.config.js (not only .ts)", () => {
|
||
writeFile(tmpDir, "package.json", JSON.stringify({ name: "web", version: "1.0.0" }));
|
||
writeFile(tmpDir, "vite.config.js", "export default {};");
|
||
|
||
const result = ensureViteConfigCompatibility(tmpDir);
|
||
|
||
expect(result).not.toBeNull();
|
||
expect(result!.addedTypeModule).toBe(true);
|
||
|
||
const pkg = JSON.parse(fs.readFileSync(path.join(tmpDir, "package.json"), "utf-8"));
|
||
expect(pkg.type).toBe("module");
|
||
});
|
||
|
||
it("handles a workspaces monorepo: only updates the leaf package.json", () => {
|
||
const webDir = path.join(tmpDir, "apps", "web");
|
||
fs.mkdirSync(webDir, { recursive: true });
|
||
|
||
// Root package.json (CJS)
|
||
writeFile(tmpDir, "package.json", JSON.stringify({ name: "monorepo", workspaces: ["apps/*"] }));
|
||
// Workspace package.json (no type:module)
|
||
writeFile(webDir, "package.json", JSON.stringify({ name: "web", version: "1.0.0" }));
|
||
writeFile(webDir, "vite.config.ts", "export default {};");
|
||
|
||
const result = ensureViteConfigCompatibility(webDir);
|
||
|
||
expect(result).not.toBeNull();
|
||
expect(result!.addedTypeModule).toBe(true);
|
||
|
||
const rootPkg = JSON.parse(fs.readFileSync(path.join(tmpDir, "package.json"), "utf-8"));
|
||
const webPkg = JSON.parse(fs.readFileSync(path.join(webDir, "package.json"), "utf-8"));
|
||
|
||
// Only the workspace package should be modified
|
||
expect(webPkg.type).toBe("module");
|
||
expect(rootPkg.type).toBeUndefined();
|
||
});
|
||
});
|
||
|
||
// ─── domainCandidates ────────────────────────────────────────────────────────
|
||
|
||
describe("domainCandidates", () => {
|
||
it("returns a single candidate for a bare domain", () => {
|
||
expect(domainCandidates("example.com")).toEqual(["example.com"]);
|
||
});
|
||
|
||
it("starts from the shortest suffix for a simple subdomain", () => {
|
||
expect(domainCandidates("shop.example.com")).toEqual(["example.com", "shop.example.com"]);
|
||
});
|
||
|
||
it("handles multi-part TLDs by trying progressively longer candidates", () => {
|
||
expect(domainCandidates("shop.example.co.uk")).toEqual([
|
||
"co.uk",
|
||
"example.co.uk",
|
||
"shop.example.co.uk",
|
||
]);
|
||
});
|
||
|
||
it("handles deeply nested subdomains", () => {
|
||
expect(domainCandidates("a.b.c.example.com")).toEqual([
|
||
"example.com",
|
||
"c.example.com",
|
||
"b.c.example.com",
|
||
"a.b.c.example.com",
|
||
]);
|
||
});
|
||
});
|
||
|
||
// ─── parseWranglerConfig — TPR fields ────────────────────────────────────────
|
||
|
||
describe("parseWranglerConfig — custom domain extraction", () => {
|
||
it("extracts Worker name", () => {
|
||
writeFile(tmpDir, "wrangler.jsonc", JSON.stringify({ name: "my-worker" }));
|
||
const config = parseWranglerConfig(tmpDir);
|
||
expect(config?.name).toBe("my-worker");
|
||
});
|
||
|
||
it("reads an explicit Wrangler config path", () => {
|
||
writeFile(tmpDir, "dist/server/wrangler.json", JSON.stringify({ name: "generated-worker" }));
|
||
const config = parseWranglerConfig(tmpDir, "dist/server/wrangler.json");
|
||
expect(config?.name).toBe("generated-worker");
|
||
});
|
||
|
||
it("uses an explicit Wrangler config path during TPR", async () => {
|
||
writeFile(tmpDir, "wrangler.jsonc", JSON.stringify({ name: "source-worker" }));
|
||
writeFile(
|
||
tmpDir,
|
||
"dist/server/wrangler.json",
|
||
JSON.stringify({
|
||
name: "generated-worker",
|
||
custom_domains: ["app.example.com"],
|
||
}),
|
||
);
|
||
|
||
const previousToken = process.env.CLOUDFLARE_API_TOKEN;
|
||
process.env.CLOUDFLARE_API_TOKEN = "token";
|
||
try {
|
||
const result = await runTPR({
|
||
root: tmpDir,
|
||
config: "dist/server/wrangler.json",
|
||
coverage: 90,
|
||
limit: 100,
|
||
window: 24,
|
||
});
|
||
|
||
expect(result.skipped).toBe("no VINEXT_KV_CACHE KV namespace configured");
|
||
} finally {
|
||
if (previousToken === undefined) delete process.env.CLOUDFLARE_API_TOKEN;
|
||
else process.env.CLOUDFLARE_API_TOKEN = previousToken;
|
||
}
|
||
});
|
||
|
||
it("parses JSONC comments and trailing commas", () => {
|
||
writeFile(
|
||
tmpDir,
|
||
"wrangler.jsonc",
|
||
`{
|
||
// Wrangler accepts JSONC comments and trailing commas.
|
||
"name": "my-worker",
|
||
"custom_domains": ["app.example.com",],
|
||
"kv_namespaces": [
|
||
{ "binding": "VINEXT_KV_CACHE", "id": "abc123", },
|
||
],
|
||
"env": {
|
||
"staging": {
|
||
"name": "my-worker-staging",
|
||
"custom_domains": ["staging.example.com",],
|
||
},
|
||
},
|
||
}`,
|
||
);
|
||
|
||
const config = parseWranglerConfig(tmpDir);
|
||
expect(config?.name).toBe("my-worker");
|
||
expect(config?.customDomain).toBe("app.example.com");
|
||
expect(config?.kvNamespaceId).toBe("abc123");
|
||
expect(config?.env?.staging).toEqual({
|
||
name: "my-worker-staging",
|
||
customDomain: "staging.example.com",
|
||
});
|
||
});
|
||
|
||
it("extracts custom domain from routes array (string form)", () => {
|
||
writeFile(tmpDir, "wrangler.jsonc", JSON.stringify({ routes: ["example.co.uk/*"] }));
|
||
const config = parseWranglerConfig(tmpDir);
|
||
expect(config?.customDomain).toBe("example.co.uk");
|
||
});
|
||
|
||
it("extracts custom domain from custom_domains array", () => {
|
||
writeFile(tmpDir, "wrangler.json", JSON.stringify({ custom_domains: ["shop.example.com.au"] }));
|
||
const config = parseWranglerConfig(tmpDir);
|
||
expect(config?.customDomain).toBe("shop.example.com.au");
|
||
});
|
||
|
||
it("ignores workers.dev domains", () => {
|
||
writeFile(tmpDir, "wrangler.json", JSON.stringify({ routes: ["my-app.workers.dev/*"] }));
|
||
const config = parseWranglerConfig(tmpDir);
|
||
expect(config?.customDomain).toBeUndefined();
|
||
});
|
||
|
||
it("extracts KV namespace ID for VINEXT_KV_CACHE", () => {
|
||
writeFile(
|
||
tmpDir,
|
||
"wrangler.json",
|
||
JSON.stringify({
|
||
kv_namespaces: [{ binding: "VINEXT_KV_CACHE", id: "abc123" }],
|
||
}),
|
||
);
|
||
const config = parseWranglerConfig(tmpDir);
|
||
expect(config?.kvNamespaceId).toBe("abc123");
|
||
});
|
||
|
||
it("extracts environment Worker names and custom domains", () => {
|
||
writeFile(
|
||
tmpDir,
|
||
"wrangler.jsonc",
|
||
JSON.stringify({
|
||
name: "my-worker",
|
||
env: {
|
||
staging: {
|
||
name: "my-worker-staging-custom",
|
||
custom_domains: ["staging.example.com"],
|
||
},
|
||
},
|
||
}),
|
||
);
|
||
|
||
const config = parseWranglerConfig(tmpDir);
|
||
expect(config?.env?.staging).toEqual({
|
||
name: "my-worker-staging-custom",
|
||
customDomain: "staging.example.com",
|
||
});
|
||
});
|
||
|
||
it("extracts environment custom domains from TOML route arrays", () => {
|
||
writeFile(
|
||
tmpDir,
|
||
"wrangler.toml",
|
||
`
|
||
name = "my-worker"
|
||
|
||
[env.staging]
|
||
routes = ["staging.example.com/*"]
|
||
`,
|
||
);
|
||
|
||
const config = parseWranglerConfig(tmpDir);
|
||
expect(config?.env?.staging?.customDomain).toBe("staging.example.com");
|
||
});
|
||
|
||
it("extracts environment custom domains from TOML route blocks", () => {
|
||
writeFile(
|
||
tmpDir,
|
||
"wrangler.toml",
|
||
`
|
||
name = "my-worker"
|
||
|
||
[env.staging]
|
||
name = "my-worker-staging"
|
||
|
||
[[env.staging.routes]]
|
||
pattern = "staging.example.com/*"
|
||
`,
|
||
);
|
||
|
||
const config = parseWranglerConfig(tmpDir);
|
||
expect(config?.env?.staging).toEqual({
|
||
name: "my-worker-staging",
|
||
customDomain: "staging.example.com",
|
||
});
|
||
});
|
||
});
|
||
|
||
// ─── CDN warmup Worker version overrides ───────────────────────────────────
|
||
|
||
describe("resolveWorkerNameForVersionOverride", () => {
|
||
it("uses the top-level Worker name for production", () => {
|
||
writeFile(tmpDir, "wrangler.jsonc", JSON.stringify({ name: "my-worker" }));
|
||
expect(resolveWorkerNameForVersionOverride(parseWranglerConfig(tmpDir), {})).toBe("my-worker");
|
||
});
|
||
|
||
it("uses the CLI Worker name exactly when provided", () => {
|
||
writeFile(tmpDir, "wrangler.jsonc", JSON.stringify({ name: "config-worker" }));
|
||
expect(
|
||
resolveWorkerNameForVersionOverride(parseWranglerConfig(tmpDir), {
|
||
name: "cli-worker",
|
||
env: "staging",
|
||
}),
|
||
).toBe("cli-worker");
|
||
});
|
||
|
||
it("appends the target environment for Wrangler legacy environments", () => {
|
||
writeFile(tmpDir, "wrangler.jsonc", JSON.stringify({ name: "my-worker" }));
|
||
expect(
|
||
resolveWorkerNameForVersionOverride(parseWranglerConfig(tmpDir), { env: "staging" }),
|
||
).toBe("my-worker-staging");
|
||
});
|
||
|
||
it("uses env-specific Worker names for Wrangler legacy environments", () => {
|
||
writeFile(
|
||
tmpDir,
|
||
"wrangler.jsonc",
|
||
JSON.stringify({
|
||
name: "my-worker",
|
||
env: { staging: { name: "custom-staging-worker" } },
|
||
}),
|
||
);
|
||
expect(
|
||
resolveWorkerNameForVersionOverride(parseWranglerConfig(tmpDir), { env: "staging" }),
|
||
).toBe("custom-staging-worker");
|
||
});
|
||
|
||
it("keeps the service name for Wrangler service environments", () => {
|
||
writeFile(
|
||
tmpDir,
|
||
"wrangler.jsonc",
|
||
JSON.stringify({
|
||
name: "my-worker",
|
||
legacy_env: false,
|
||
env: { staging: {} },
|
||
}),
|
||
);
|
||
expect(
|
||
resolveWorkerNameForVersionOverride(parseWranglerConfig(tmpDir), { env: "staging" }),
|
||
).toBe("my-worker");
|
||
});
|
||
});
|
||
|
||
describe("getZeroPercentStagingTraffic", () => {
|
||
it("stages beside the sole 100% version and replaces stale 0% versions", () => {
|
||
expect(
|
||
getZeroPercentStagingTraffic(
|
||
{
|
||
versions: [
|
||
{ versionId: "11111111-1111-4111-8111-111111111111", percentage: 100 },
|
||
{ versionId: "33333333-3333-4333-8333-333333333333", percentage: 0 },
|
||
],
|
||
output: "{}",
|
||
},
|
||
"22222222-2222-4222-8222-222222222222",
|
||
),
|
||
).toEqual([
|
||
{ versionId: "11111111-1111-4111-8111-111111111111", percentage: 100 },
|
||
{ versionId: "22222222-2222-4222-8222-222222222222", percentage: 0 },
|
||
]);
|
||
});
|
||
|
||
it("does not stage over an active traffic split", () => {
|
||
expect(
|
||
getZeroPercentStagingTraffic(
|
||
{
|
||
versions: [
|
||
{ versionId: "11111111-1111-4111-8111-111111111111", percentage: 50 },
|
||
{ versionId: "33333333-3333-4333-8333-333333333333", percentage: 50 },
|
||
],
|
||
output: "{}",
|
||
},
|
||
"22222222-2222-4222-8222-222222222222",
|
||
),
|
||
).toBeNull();
|
||
});
|
||
|
||
it("does not stage the uploaded version when it is already the current deployment", () => {
|
||
expect(
|
||
getZeroPercentStagingTraffic(
|
||
{
|
||
versions: [{ versionId: "22222222-2222-4222-8222-222222222222", percentage: 100 }],
|
||
output: "{}",
|
||
},
|
||
"22222222-2222-4222-8222-222222222222",
|
||
),
|
||
).toBeNull();
|
||
});
|
||
});
|