mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
f6aedd3615
* feat: add standalone self-host output for vinext build Generate dist/standalone from output: 'standalone' with a runnable server entry and runtime deps, and align init scripts with vinext build/start for production self-host workflows. * fmt * Address bonk review: fix duplicate PHASE constant, complete lazy-chunks extraction, remove dead imports, add process.exit to standalone path * Address bonk nits: document regex patterns, move PHASE re-exports to bottom, add assets field to BuildManifestChunk * refactor(standalone): use Vite bundle graph for server externals instead of regex scan Replace the fragile post-build regex scan of emitted JS files and the package.json#dependencies over-seeding with a new vinext:server-externals-manifest Vite plugin. The plugin collects chunk.imports + chunk.dynamicImports from the SSR/RSC writeBundle hook and writes dist/server/vinext-externals.json — the authoritative, compiler-derived list of packages left external in the server bundle. emitStandaloneOutput now reads this manifest as its sole seed for the BFS node_modules copy, so standalone output only includes packages the server actually imports at runtime. Removed collectServerExternalPackages, walkFiles, the four regex constants, and the package.json deps seeding. * fix(standalone): copy vinext runtime deps, add node_modules filter to cpSync, remove unused pendingWrites * fix(standalone): address round-6 bonk nits — explicit root, manifest comment, pre-flight check, bare-specifier comment, export-from re-export * fix(standalone): address round 7 bonk review comments - harden outDir resolution in server-externals-manifest: replace fragile walk-up heuristic with direct basename check (ssr -> dirname, else dir), avoiding misfires when a user's project path contains a 'server' segment - remove redundant 'chunk' alias in server-externals-manifest writeBundle; use 'item' directly after the type guard narrows it to OutputChunk - add node_modules filter to vinext dist/ cpSync in standalone.ts, matching the same defensive filter used for app package copies - rewrite standalone server.js as pure ESM using import.meta.dirname (Node >= 21.2, vinext requires >= 22); drop CJS require/__dirname and switch standalone package.json to 'type':'module' - add explanatory comment for DEFAULT_PHASE constant in next-config.ts - add comment in cli.ts noting pre-flight path and resolveVinextPackageRoot must stay in sync * fix(standalone): address round 8 bonk review comments - fix test import: use 'vite-plus/test' instead of 'vitest' in standalone-build.test.ts, matching the convention used by every other test file in the repo (documented in AGENTS.md) - extract resolveVinextPackageRoot into utils/vinext-root.ts so cli.ts and standalone.ts share a single source of truth; removes the coupling comment added in round 7 and the duplicated path traversal logic - add node_modules filter to dist/client and dist/server cpSync calls for defensive consistency with the vinext dist/ and package copies * interface -> type * fix(standalone): address round 9 bonk review comments - add node_modules filter to public/ cpSync for defensive consistency with all other cpSync calls in emitStandaloneOutput - warn on malformed vinext-externals.json instead of silently returning an empty list; includes the error string to aid debugging - add doc comment on copyPackageAndRuntimeDeps clarifying that the return value is the full accumulated set (including pre-existing entries), not only packages copied by the current call - document HOST (not HOSTNAME) env var in README standalone section, with a note explaining the difference from Next.js standalone behaviour * fix(standalone): address round 10 bonk review comments - add comment in writeStandaloneServerEntry explaining why the import of vinext/server/prod-server is intentional as a static import: it is a documented exports-map entry, always present in the copied dist tree, and a static import gives a clearer ERR_MODULE_NOT_FOUND at startup - generalise outDir sub-directory detection in server-externals-manifest: replace basename === 'ssr' with basename !== 'server' so any future sub-directory environment (e.g. 'edge') is handled without code changes - add JSDoc on runtimeDeps clarifying that it returns both dependencies and optionalDependencies * fix(next-config): drop PHASE_DEVELOPMENT_SERVER from re-export No external consumer imports it from next-config.ts — only PHASE_PRODUCTION_BUILD is used externally (by cli.ts). Removing it minimises the public surface area of the module. --------- Co-authored-by: GPU VM <copilot-gpu-vm@example.com> Co-authored-by: James <james@eli.cx>
859 lines
29 KiB
TypeScript
859 lines
29 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 {
|
||
init,
|
||
generateViteConfig,
|
||
addScripts,
|
||
getInitDeps,
|
||
isDepInstalled,
|
||
getReactUpgradeDeps,
|
||
updateGitignore,
|
||
type InitOptions,
|
||
} from "../packages/vinext/src/init.js";
|
||
|
||
// ─── Test Helpers ────────────────────────────────────────────────────────────
|
||
|
||
let tmpDir: string;
|
||
|
||
function createTmpDir(): string {
|
||
return fs.mkdtempSync(path.join(os.tmpdir(), "vinext-init-test-"));
|
||
}
|
||
|
||
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 readPkg(dir: string): Record<string, unknown> {
|
||
return JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf-8"));
|
||
}
|
||
|
||
function readFile(dir: string, relativePath: string): string {
|
||
return fs.readFileSync(path.join(dir, relativePath), "utf-8");
|
||
}
|
||
|
||
/**
|
||
* Create a minimal Next.js-like project structure in a temp directory.
|
||
*/
|
||
function setupProject(
|
||
dir: string,
|
||
opts: {
|
||
router?: "app" | "pages";
|
||
typeModule?: boolean;
|
||
extraPkg?: Record<string, unknown>;
|
||
} = {},
|
||
): void {
|
||
const router = opts.router ?? "app";
|
||
const pkg: Record<string, unknown> = {
|
||
name: "test-project",
|
||
version: "1.0.0",
|
||
dependencies: { react: "^19.0.0", "react-dom": "^19.0.0", next: "^15.0.0" },
|
||
...opts.extraPkg,
|
||
};
|
||
if (opts.typeModule) {
|
||
pkg.type = "module";
|
||
}
|
||
|
||
writeFile(dir, "package.json", JSON.stringify(pkg, null, 2));
|
||
|
||
if (router === "app") {
|
||
mkdir(dir, "app");
|
||
writeFile(dir, "app/page.tsx", "export default function Home() { return <div>hi</div> }");
|
||
writeFile(
|
||
dir,
|
||
"app/layout.tsx",
|
||
"export default function Layout({ children }) { return <html><body>{children}</body></html> }",
|
||
);
|
||
} else {
|
||
mkdir(dir, "pages");
|
||
writeFile(dir, "pages/index.tsx", "export default function Home() { return <div>hi</div> }");
|
||
}
|
||
}
|
||
|
||
/** No-op exec for tests — records calls for assertions */
|
||
function noopExec(): {
|
||
exec: (cmd: string, opts: { cwd: string; stdio: string }) => void;
|
||
calls: Array<{ cmd: string; opts: { cwd: string; stdio: string } }>;
|
||
} {
|
||
const calls: Array<{ cmd: string; opts: { cwd: string; stdio: string } }> = [];
|
||
return {
|
||
exec: (cmd: string, opts: { cwd: string; stdio: string }) => {
|
||
calls.push({ cmd, opts });
|
||
},
|
||
calls,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Run init with a no-op exec and suppressed console output.
|
||
*/
|
||
async function runInit(
|
||
dir: string,
|
||
opts: Partial<InitOptions> = {},
|
||
): Promise<{ result: Awaited<ReturnType<typeof init>>; execCalls: Array<{ cmd: string }> }> {
|
||
const { exec, calls } = noopExec();
|
||
|
||
// Suppress console output during tests
|
||
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||
const consoleErrSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||
|
||
// Mock process.exit to prevent test from exiting
|
||
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: string | number | null) => {
|
||
throw new Error(`process.exit(${code})`);
|
||
}) as never);
|
||
|
||
try {
|
||
const result = await init({
|
||
root: dir,
|
||
skipCheck: true,
|
||
_exec: exec,
|
||
...opts,
|
||
});
|
||
return { result, execCalls: calls };
|
||
} finally {
|
||
consoleSpy.mockRestore();
|
||
consoleErrSpy.mockRestore();
|
||
exitSpy.mockRestore();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Run init expecting it to fail (process.exit).
|
||
*/
|
||
async function runInitExpectExit(dir: string, opts: Partial<InitOptions> = {}): Promise<string> {
|
||
const { exec } = noopExec();
|
||
|
||
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||
const consoleErrSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: string | number | null) => {
|
||
throw new Error(`process.exit(${code})`);
|
||
}) as never);
|
||
|
||
try {
|
||
await init({
|
||
root: dir,
|
||
skipCheck: true,
|
||
_exec: exec,
|
||
...opts,
|
||
});
|
||
throw new Error("Expected process.exit to be called");
|
||
} catch (e) {
|
||
return (e as Error).message;
|
||
} finally {
|
||
consoleSpy.mockRestore();
|
||
consoleErrSpy.mockRestore();
|
||
exitSpy.mockRestore();
|
||
}
|
||
}
|
||
|
||
beforeEach(() => {
|
||
tmpDir = createTmpDir();
|
||
});
|
||
|
||
afterEach(() => {
|
||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||
});
|
||
|
||
// ─── Unit Tests: generateViteConfig ──────────────────────────────────────────
|
||
|
||
describe("generateViteConfig", () => {
|
||
it("generates App Router config with RSC plugin", () => {
|
||
const config = generateViteConfig(true);
|
||
expect(config).toContain('import vinext from "vinext"');
|
||
expect(config).toContain("vinext()");
|
||
});
|
||
|
||
it("generates Pages Router config without RSC", () => {
|
||
const config = generateViteConfig(false);
|
||
expect(config).toContain('import vinext from "vinext"');
|
||
expect(config).toContain("vinext()");
|
||
expect(config).not.toContain("plugin-rsc");
|
||
expect(config).not.toContain("rsc(");
|
||
});
|
||
|
||
it("does not include cloudflare plugin", () => {
|
||
expect(generateViteConfig(true)).not.toContain("cloudflare");
|
||
expect(generateViteConfig(false)).not.toContain("cloudflare");
|
||
});
|
||
|
||
it("includes defineConfig import", () => {
|
||
expect(generateViteConfig(true)).toContain("defineConfig");
|
||
expect(generateViteConfig(false)).toContain("defineConfig");
|
||
});
|
||
});
|
||
|
||
// ─── Unit Tests: addScripts ──────────────────────────────────────────────────
|
||
|
||
describe("addScripts", () => {
|
||
it("adds dev:vinext, build:vinext, and start:vinext scripts", () => {
|
||
setupProject(tmpDir, { router: "app" });
|
||
|
||
const added = addScripts(tmpDir, 3001);
|
||
|
||
expect(added).toContain("dev:vinext");
|
||
expect(added).toContain("build:vinext");
|
||
expect(added).toContain("start:vinext");
|
||
|
||
const pkg = readPkg(tmpDir) as { scripts: Record<string, string> };
|
||
expect(pkg.scripts["dev:vinext"]).toBe("vinext dev --port 3001");
|
||
expect(pkg.scripts["build:vinext"]).toBe("vinext build");
|
||
expect(pkg.scripts["start:vinext"]).toBe("vinext start");
|
||
});
|
||
|
||
it("uses custom port", () => {
|
||
setupProject(tmpDir, { router: "app" });
|
||
|
||
addScripts(tmpDir, 4000);
|
||
|
||
const pkg = readPkg(tmpDir) as { scripts: Record<string, string> };
|
||
expect(pkg.scripts["dev:vinext"]).toBe("vinext dev --port 4000");
|
||
});
|
||
|
||
it("does not overwrite existing scripts", () => {
|
||
setupProject(tmpDir, {
|
||
router: "app",
|
||
extraPkg: { scripts: { "dev:vinext": "custom-command" } },
|
||
});
|
||
|
||
const added = addScripts(tmpDir, 3001);
|
||
|
||
expect(added).not.toContain("dev:vinext");
|
||
expect(added).toContain("build:vinext");
|
||
expect(added).toContain("start:vinext");
|
||
|
||
const pkg = readPkg(tmpDir) as { scripts: Record<string, string> };
|
||
expect(pkg.scripts["dev:vinext"]).toBe("custom-command");
|
||
});
|
||
|
||
it("creates scripts object if missing", () => {
|
||
writeFile(tmpDir, "package.json", JSON.stringify({ name: "test" }));
|
||
|
||
const added = addScripts(tmpDir, 3001);
|
||
|
||
expect(added).toContain("dev:vinext");
|
||
const pkg = readPkg(tmpDir) as { scripts: Record<string, string> };
|
||
expect(pkg.scripts["dev:vinext"]).toBeDefined();
|
||
});
|
||
|
||
it("returns empty array when no package.json", () => {
|
||
const added = addScripts(tmpDir, 3001);
|
||
expect(added).toEqual([]);
|
||
});
|
||
});
|
||
|
||
// ─── Unit Tests: getInitDeps / isDepInstalled ────────────────────────────────
|
||
|
||
describe("getInitDeps", () => {
|
||
it("returns vinext + vite + @vitejs/plugin-react + App Router deps for App Router", () => {
|
||
const deps = getInitDeps(true);
|
||
expect(deps).toContain("vinext");
|
||
expect(deps).toContain("vite");
|
||
expect(deps).toContain("@vitejs/plugin-react");
|
||
expect(deps).toContain("@vitejs/plugin-rsc");
|
||
expect(deps).toContain("react-server-dom-webpack");
|
||
});
|
||
|
||
it("returns vinext + vite + @vitejs/plugin-react for Pages Router", () => {
|
||
const deps = getInitDeps(false);
|
||
expect(deps).toContain("vinext");
|
||
expect(deps).toContain("vite");
|
||
expect(deps).toContain("@vitejs/plugin-react");
|
||
expect(deps).not.toContain("@vitejs/plugin-rsc");
|
||
expect(deps).not.toContain("react-server-dom-webpack");
|
||
});
|
||
});
|
||
|
||
/** Helper: create a fake resolvable react package in node_modules */
|
||
function setupFakeReact(dir: string, version: string): void {
|
||
const reactDir = path.join(dir, "node_modules", "react");
|
||
fs.mkdirSync(reactDir, { recursive: true });
|
||
fs.writeFileSync(
|
||
path.join(reactDir, "package.json"),
|
||
JSON.stringify({ name: "react", version, main: "index.js" }),
|
||
);
|
||
fs.writeFileSync(path.join(reactDir, "index.js"), "");
|
||
}
|
||
|
||
describe("getReactUpgradeDeps", () => {
|
||
it("returns react@latest + react-dom@latest when React is too old", () => {
|
||
setupProject(tmpDir, { router: "app" });
|
||
setupFakeReact(tmpDir, "19.2.3");
|
||
|
||
const deps = getReactUpgradeDeps(tmpDir);
|
||
expect(deps).toEqual(["react@latest", "react-dom@latest"]);
|
||
});
|
||
|
||
it("returns empty array when React is new enough", () => {
|
||
setupProject(tmpDir, { router: "app" });
|
||
setupFakeReact(tmpDir, "19.2.4");
|
||
|
||
const deps = getReactUpgradeDeps(tmpDir);
|
||
expect(deps).toEqual([]);
|
||
});
|
||
|
||
it("returns empty array when React is a newer minor version", () => {
|
||
setupProject(tmpDir, { router: "app" });
|
||
setupFakeReact(tmpDir, "19.3.0");
|
||
|
||
const deps = getReactUpgradeDeps(tmpDir);
|
||
expect(deps).toEqual([]);
|
||
});
|
||
|
||
it("returns upgrade deps when React major is below 19", () => {
|
||
setupProject(tmpDir, { router: "app" });
|
||
setupFakeReact(tmpDir, "18.3.1");
|
||
|
||
const deps = getReactUpgradeDeps(tmpDir);
|
||
expect(deps).toEqual(["react@latest", "react-dom@latest"]);
|
||
});
|
||
|
||
it("returns empty array when node_modules/react does not exist", () => {
|
||
setupProject(tmpDir, { router: "app" });
|
||
const deps = getReactUpgradeDeps(tmpDir);
|
||
expect(deps).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe("isDepInstalled", () => {
|
||
it("returns true when dep is in dependencies", () => {
|
||
setupProject(tmpDir, { router: "app" });
|
||
expect(isDepInstalled(tmpDir, "react")).toBe(true);
|
||
});
|
||
|
||
it("returns true when dep is in devDependencies", () => {
|
||
setupProject(tmpDir, {
|
||
router: "app",
|
||
extraPkg: { devDependencies: { vite: "^7.0.0" } },
|
||
});
|
||
expect(isDepInstalled(tmpDir, "vite")).toBe(true);
|
||
});
|
||
|
||
it("returns false when dep is not installed", () => {
|
||
setupProject(tmpDir, { router: "app" });
|
||
expect(isDepInstalled(tmpDir, "vite")).toBe(false);
|
||
});
|
||
|
||
it("returns false when no package.json", () => {
|
||
expect(isDepInstalled(tmpDir, "vite")).toBe(false);
|
||
});
|
||
});
|
||
|
||
// ─── Integration: init() ─────────────────────────────────────────────────────
|
||
|
||
describe("init — basic functionality", () => {
|
||
it("generates vite.config.ts for App Router project", async () => {
|
||
setupProject(tmpDir, { router: "app" });
|
||
|
||
const { result } = await runInit(tmpDir);
|
||
|
||
expect(result.generatedViteConfig).toBe(true);
|
||
expect(fs.existsSync(path.join(tmpDir, "vite.config.ts"))).toBe(true);
|
||
|
||
const config = readFile(tmpDir, "vite.config.ts");
|
||
expect(config).toContain('import vinext from "vinext"');
|
||
});
|
||
|
||
it("generates vite.config.ts for Pages Router project", async () => {
|
||
setupProject(tmpDir, { router: "pages" });
|
||
|
||
const { result } = await runInit(tmpDir);
|
||
|
||
expect(result.generatedViteConfig).toBe(true);
|
||
const config = readFile(tmpDir, "vite.config.ts");
|
||
expect(config).toContain("vinext()");
|
||
expect(config).not.toContain("plugin-rsc");
|
||
});
|
||
|
||
it("adds 'type': 'module' to package.json", async () => {
|
||
setupProject(tmpDir, { router: "app" });
|
||
|
||
const { result } = await runInit(tmpDir);
|
||
|
||
expect(result.addedTypeModule).toBe(true);
|
||
const pkg = readPkg(tmpDir);
|
||
expect(pkg.type).toBe("module");
|
||
});
|
||
|
||
it("skips adding 'type': 'module' when already present", async () => {
|
||
setupProject(tmpDir, { router: "app", typeModule: true });
|
||
|
||
const { result } = await runInit(tmpDir);
|
||
|
||
expect(result.addedTypeModule).toBe(false);
|
||
});
|
||
|
||
it("adds dev:vinext, build:vinext, and start:vinext scripts", async () => {
|
||
setupProject(tmpDir, { router: "app" });
|
||
|
||
const { result } = await runInit(tmpDir);
|
||
|
||
expect(result.addedScripts).toContain("dev:vinext");
|
||
expect(result.addedScripts).toContain("build:vinext");
|
||
expect(result.addedScripts).toContain("start:vinext");
|
||
|
||
const pkg = readPkg(tmpDir) as { scripts: Record<string, string> };
|
||
expect(pkg.scripts["dev:vinext"]).toBe("vinext dev --port 3001");
|
||
expect(pkg.scripts["build:vinext"]).toBe("vinext build");
|
||
expect(pkg.scripts["start:vinext"]).toBe("vinext start");
|
||
});
|
||
|
||
it("uses custom port in dev:vinext script", async () => {
|
||
setupProject(tmpDir, { router: "app" });
|
||
|
||
await runInit(tmpDir, { port: 4000 });
|
||
|
||
const pkg = readPkg(tmpDir) as { scripts: Record<string, string> };
|
||
expect(pkg.scripts["dev:vinext"]).toBe("vinext dev --port 4000");
|
||
});
|
||
|
||
it("does not overwrite existing scripts", async () => {
|
||
setupProject(tmpDir, {
|
||
router: "app",
|
||
extraPkg: { scripts: { "dev:vinext": "custom-command" } },
|
||
});
|
||
|
||
const { result } = await runInit(tmpDir);
|
||
|
||
expect(result.addedScripts).not.toContain("dev:vinext");
|
||
expect(result.addedScripts).toContain("build:vinext");
|
||
|
||
const pkg = readPkg(tmpDir) as { scripts: Record<string, string> };
|
||
expect(pkg.scripts["dev:vinext"]).toBe("custom-command");
|
||
});
|
||
});
|
||
|
||
// ─── CJS Config Renaming ────────────────────────────────────────────────────
|
||
|
||
describe("init — CJS config renaming", () => {
|
||
it("renames CJS postcss.config.js to .cjs", async () => {
|
||
setupProject(tmpDir, { router: "app" });
|
||
writeFile(tmpDir, "postcss.config.js", "module.exports = { plugins: {} };");
|
||
|
||
const { result } = await runInit(tmpDir);
|
||
|
||
expect(result.renamedConfigs).toContainEqual(["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("does not rename ESM config files", async () => {
|
||
setupProject(tmpDir, { router: "app" });
|
||
writeFile(tmpDir, "postcss.config.js", "export default { plugins: {} };");
|
||
|
||
const { result } = await runInit(tmpDir);
|
||
|
||
expect(result.renamedConfigs).toEqual([]);
|
||
expect(fs.existsSync(path.join(tmpDir, "postcss.config.js"))).toBe(true);
|
||
});
|
||
});
|
||
|
||
// ─── Dependency Installation ─────────────────────────────────────────────────
|
||
|
||
describe("init — dependency installation", () => {
|
||
it("detects missing vinext and vite dependencies and installs them", async () => {
|
||
setupProject(tmpDir, { router: "app" });
|
||
|
||
const { result } = await runInit(tmpDir);
|
||
|
||
expect(result.installedDeps).toContain("vinext");
|
||
expect(result.installedDeps).toContain("vite");
|
||
});
|
||
|
||
it("detects missing @vitejs/plugin-rsc for App Router", async () => {
|
||
setupProject(tmpDir, { router: "app" });
|
||
|
||
const { result } = await runInit(tmpDir);
|
||
|
||
expect(result.installedDeps).toContain("@vitejs/plugin-react");
|
||
expect(result.installedDeps).toContain("@vitejs/plugin-rsc");
|
||
});
|
||
|
||
it("treats src/app projects as App Router", async () => {
|
||
setupProject(tmpDir);
|
||
fs.rmSync(path.join(tmpDir, "app"), { recursive: true, force: true });
|
||
|
||
mkdir(tmpDir, "src/app");
|
||
writeFile(
|
||
tmpDir,
|
||
"src/app/page.tsx",
|
||
"export default function Home() { return <div>hi</div> }",
|
||
);
|
||
writeFile(
|
||
tmpDir,
|
||
"src/app/layout.tsx",
|
||
"export default function Layout({ children }) { return <html><body>{children}</body></html> }",
|
||
);
|
||
|
||
const { result } = await runInit(tmpDir);
|
||
|
||
expect(result.installedDeps).toContain("@vitejs/plugin-react");
|
||
expect(result.installedDeps).toContain("@vitejs/plugin-rsc");
|
||
expect(result.installedDeps).toContain("react-server-dom-webpack");
|
||
});
|
||
|
||
it("detects missing react-server-dom-webpack for App Router", async () => {
|
||
setupProject(tmpDir, { router: "app" });
|
||
|
||
const { result } = await runInit(tmpDir);
|
||
|
||
expect(result.installedDeps).toContain("@vitejs/plugin-react");
|
||
expect(result.installedDeps).toContain("react-server-dom-webpack");
|
||
});
|
||
|
||
it("does not require @vitejs/plugin-rsc for Pages Router", async () => {
|
||
setupProject(tmpDir, { router: "pages" });
|
||
|
||
const { result } = await runInit(tmpDir);
|
||
|
||
expect(result.installedDeps).toContain("@vitejs/plugin-react");
|
||
expect(result.installedDeps).not.toContain("@vitejs/plugin-rsc");
|
||
});
|
||
|
||
it("does not require react-server-dom-webpack for Pages Router", async () => {
|
||
setupProject(tmpDir, { router: "pages" });
|
||
|
||
const { result } = await runInit(tmpDir);
|
||
|
||
expect(result.installedDeps).toContain("@vitejs/plugin-react");
|
||
expect(result.installedDeps).not.toContain("react-server-dom-webpack");
|
||
});
|
||
|
||
it("upgrades React before installing dev deps when React is too old (App Router)", async () => {
|
||
setupProject(tmpDir, { router: "app" });
|
||
setupFakeReact(tmpDir, "19.2.3");
|
||
|
||
const { execCalls } = await runInit(tmpDir);
|
||
|
||
// The first exec call should be the React upgrade (without -D)
|
||
const reactUpgradeCall = execCalls.find(
|
||
(c) => c.cmd.includes("react@latest") && c.cmd.includes("react-dom@latest"),
|
||
);
|
||
expect(reactUpgradeCall).toBeDefined();
|
||
// The React upgrade should NOT use -D flag (keeps them in dependencies)
|
||
expect(reactUpgradeCall!.cmd).not.toContain("-D");
|
||
|
||
// The second exec call should be the dev deps install (with -D)
|
||
const devDepsCall = execCalls.find(
|
||
(c) =>
|
||
c.cmd.includes("@vitejs/plugin-react") &&
|
||
c.cmd.includes("react-server-dom-webpack") &&
|
||
c.cmd.includes("-D"),
|
||
);
|
||
expect(devDepsCall).toBeDefined();
|
||
|
||
// React upgrade should come before dev deps install
|
||
const upgradeIdx = execCalls.indexOf(reactUpgradeCall!);
|
||
const devDepsIdx = execCalls.indexOf(devDepsCall!);
|
||
expect(upgradeIdx).toBeLessThan(devDepsIdx);
|
||
});
|
||
|
||
it("does not upgrade React when version is already compatible", async () => {
|
||
setupProject(tmpDir, { router: "app" });
|
||
setupFakeReact(tmpDir, "19.2.4");
|
||
|
||
const { execCalls } = await runInit(tmpDir);
|
||
|
||
// No React upgrade call
|
||
const reactUpgradeCall = execCalls.find((c) => c.cmd.includes("react@latest"));
|
||
expect(reactUpgradeCall).toBeUndefined();
|
||
});
|
||
|
||
function withUserAgent<T>(value: string | undefined, run: () => Promise<T>): Promise<T> {
|
||
const previous = process.env.npm_config_user_agent;
|
||
if (value === undefined) {
|
||
delete process.env.npm_config_user_agent;
|
||
} else {
|
||
process.env.npm_config_user_agent = value;
|
||
}
|
||
|
||
return run().finally(() => {
|
||
if (previous === undefined) {
|
||
delete process.env.npm_config_user_agent;
|
||
} else {
|
||
process.env.npm_config_user_agent = previous;
|
||
}
|
||
});
|
||
}
|
||
|
||
it("calls exec with correct package manager for pnpm", async () => {
|
||
setupProject(tmpDir, { router: "pages" });
|
||
writeFile(tmpDir, "pnpm-lock.yaml", "lockfileVersion: 5");
|
||
|
||
const { execCalls } = await runInit(tmpDir);
|
||
|
||
const installCall = execCalls.find(
|
||
(c) => c.cmd.includes("add -D") || c.cmd.includes("install -D"),
|
||
);
|
||
expect(installCall).toBeDefined();
|
||
expect(installCall!.cmd).toMatch(/^pnpm add -D/);
|
||
});
|
||
|
||
it("calls exec with bun when bun.lock exists", async () => {
|
||
setupProject(tmpDir, { router: "pages" });
|
||
writeFile(tmpDir, "bun.lock", "# bun lockfile");
|
||
|
||
const { execCalls } = await runInit(tmpDir);
|
||
|
||
const installCall = execCalls.find(
|
||
(c) => c.cmd.includes("add -D") || c.cmd.includes("install -D"),
|
||
);
|
||
expect(installCall).toBeDefined();
|
||
expect(installCall!.cmd).toMatch(/^bun add -D/);
|
||
});
|
||
|
||
it("uses package.json#packageManager when lock files are missing", async () => {
|
||
setupProject(tmpDir, {
|
||
router: "pages",
|
||
extraPkg: { packageManager: "bun@1.2.3" },
|
||
});
|
||
|
||
const { execCalls } = await runInit(tmpDir);
|
||
|
||
const installCall = execCalls.find(
|
||
(c) => c.cmd.includes("add -D") || c.cmd.includes("install -D"),
|
||
);
|
||
expect(installCall).toBeDefined();
|
||
expect(installCall!.cmd).toMatch(/^bun add -D/);
|
||
});
|
||
|
||
it("uses invoking package manager from npm_config_user_agent when project has no PM hints", async () => {
|
||
setupProject(tmpDir, { router: "pages" });
|
||
|
||
const { execCalls } = await withUserAgent("bun/1.2.3 npm/? node/v22.0.0", () =>
|
||
runInit(tmpDir),
|
||
);
|
||
|
||
const installCall = execCalls.find(
|
||
(c) => c.cmd.includes("add -D") || c.cmd.includes("install -D"),
|
||
);
|
||
expect(installCall).toBeDefined();
|
||
expect(installCall!.cmd).toMatch(/^bun add -D/);
|
||
});
|
||
|
||
it("falls back to npm when no lock file, no packageManager, and no user-agent hint", async () => {
|
||
setupProject(tmpDir, { router: "pages" });
|
||
|
||
const { execCalls } = await withUserAgent(undefined, () => runInit(tmpDir));
|
||
|
||
const installCall = execCalls.find(
|
||
(c) => c.cmd.includes("install -D") || c.cmd.includes("add -D"),
|
||
);
|
||
expect(installCall).toBeDefined();
|
||
expect(installCall!.cmd).toMatch(/^npm install -D/);
|
||
});
|
||
});
|
||
|
||
// ─── Guard Rails ─────────────────────────────────────────────────────────────
|
||
|
||
describe("init — guard rails", () => {
|
||
it("skips vite.config.ts when it already exists (without --force)", async () => {
|
||
setupProject(tmpDir, { router: "app" });
|
||
writeFile(tmpDir, "vite.config.ts", "export default {}");
|
||
|
||
const { result } = await runInit(tmpDir);
|
||
|
||
expect(result.generatedViteConfig).toBe(false);
|
||
expect(result.skippedViteConfig).toBe(true);
|
||
// Original config should be preserved
|
||
const config = readFile(tmpDir, "vite.config.ts");
|
||
expect(config).toBe("export default {}");
|
||
});
|
||
|
||
it("still runs all other steps when vite.config.ts exists", async () => {
|
||
setupProject(tmpDir, { router: "app" });
|
||
writeFile(tmpDir, "vite.config.ts", "export default {}");
|
||
|
||
const { result } = await runInit(tmpDir);
|
||
|
||
// Dependencies should still be installed
|
||
expect(result.installedDeps).toContain("vite");
|
||
// ESM migration should still happen
|
||
expect(result.addedTypeModule).toBe(true);
|
||
// Scripts should still be added
|
||
expect(result.addedScripts).toContain("dev:vinext");
|
||
expect(result.addedScripts).toContain("build:vinext");
|
||
expect(result.addedScripts).toContain("start:vinext");
|
||
// But vite config should be skipped
|
||
expect(result.generatedViteConfig).toBe(false);
|
||
expect(result.skippedViteConfig).toBe(true);
|
||
});
|
||
|
||
it("overwrites vite.config.ts with --force", async () => {
|
||
setupProject(tmpDir, { router: "app" });
|
||
writeFile(tmpDir, "vite.config.ts", "export default {}");
|
||
|
||
const { result } = await runInit(tmpDir, { force: true });
|
||
|
||
expect(result.generatedViteConfig).toBe(true);
|
||
expect(result.skippedViteConfig).toBe(false);
|
||
const config = readFile(tmpDir, "vite.config.ts");
|
||
expect(config).toContain("vinext()");
|
||
});
|
||
|
||
it("exits when no package.json exists", async () => {
|
||
mkdir(tmpDir, "app");
|
||
|
||
const msg = await runInitExpectExit(tmpDir);
|
||
expect(msg).toContain("process.exit(1)");
|
||
});
|
||
});
|
||
|
||
// ─── Preserves Existing Project ─────────<E29480><E29480>────────────────────────────────────
|
||
|
||
describe("init — non-destructive", () => {
|
||
it("preserves existing package.json fields", async () => {
|
||
setupProject(tmpDir, {
|
||
router: "app",
|
||
extraPkg: {
|
||
scripts: { dev: "next dev", build: "next build" },
|
||
dependencies: { react: "^19.0.0", next: "^15.0.0" },
|
||
},
|
||
});
|
||
|
||
await runInit(tmpDir);
|
||
|
||
const pkg = readPkg(tmpDir) as Record<string, Record<string, string>>;
|
||
expect(pkg.scripts.dev).toBe("next dev");
|
||
expect(pkg.scripts.build).toBe("next build");
|
||
expect(pkg.dependencies.react).toBe("^19.0.0");
|
||
expect(pkg.dependencies.next).toBe("^15.0.0");
|
||
});
|
||
|
||
it("does not modify source files", async () => {
|
||
setupProject(tmpDir, { router: "app" });
|
||
const originalPage = readFile(tmpDir, "app/page.tsx");
|
||
|
||
await runInit(tmpDir);
|
||
|
||
expect(readFile(tmpDir, "app/page.tsx")).toBe(originalPage);
|
||
});
|
||
|
||
it("does not modify next.config", async () => {
|
||
setupProject(tmpDir, { router: "app" });
|
||
writeFile(tmpDir, "next.config.mjs", "export default {};");
|
||
const originalConfig = readFile(tmpDir, "next.config.mjs");
|
||
|
||
await runInit(tmpDir);
|
||
|
||
expect(readFile(tmpDir, "next.config.mjs")).toBe(originalConfig);
|
||
});
|
||
});
|
||
|
||
// ─── Unit Tests: updateGitignore ─────────────────────────────────────────────
|
||
|
||
describe("updateGitignore", () => {
|
||
it("creates .gitignore with /dist/ when file does not exist", () => {
|
||
const result = updateGitignore(tmpDir);
|
||
|
||
expect(result).toBe(true);
|
||
const content = readFile(tmpDir, ".gitignore");
|
||
expect(content).toBe("/dist/\n");
|
||
});
|
||
|
||
it("appends /dist/ to existing .gitignore", () => {
|
||
writeFile(tmpDir, ".gitignore", "node_modules/\n.env\n");
|
||
|
||
const result = updateGitignore(tmpDir);
|
||
|
||
expect(result).toBe(true);
|
||
const content = readFile(tmpDir, ".gitignore");
|
||
expect(content).toBe("node_modules/\n.env\n/dist/\n");
|
||
});
|
||
|
||
it("does not duplicate /dist/ when already present", () => {
|
||
writeFile(tmpDir, ".gitignore", "node_modules/\n/dist/\n");
|
||
|
||
const result = updateGitignore(tmpDir);
|
||
|
||
expect(result).toBe(false);
|
||
const content = readFile(tmpDir, ".gitignore");
|
||
expect(content).toBe("node_modules/\n/dist/\n");
|
||
});
|
||
|
||
it("handles .gitignore without trailing newline", () => {
|
||
writeFile(tmpDir, ".gitignore", "node_modules/");
|
||
|
||
const result = updateGitignore(tmpDir);
|
||
|
||
expect(result).toBe(true);
|
||
const content = readFile(tmpDir, ".gitignore");
|
||
expect(content).toBe("node_modules/\n/dist/\n");
|
||
});
|
||
|
||
it("handles /dist/ with surrounding whitespace in existing file", () => {
|
||
writeFile(tmpDir, ".gitignore", "node_modules/\n /dist/ \n");
|
||
|
||
const result = updateGitignore(tmpDir);
|
||
|
||
expect(result).toBe(false);
|
||
});
|
||
|
||
it("does not add /dist/ when dist/ (without leading slash) is already present", () => {
|
||
writeFile(tmpDir, ".gitignore", "node_modules/\ndist/\n");
|
||
|
||
const result = updateGitignore(tmpDir);
|
||
|
||
expect(result).toBe(false);
|
||
const content = readFile(tmpDir, ".gitignore");
|
||
expect(content).toBe("node_modules/\ndist/\n");
|
||
});
|
||
|
||
it("does not add /dist/ when bare dist is already present", () => {
|
||
writeFile(tmpDir, ".gitignore", "node_modules/\ndist\n");
|
||
|
||
const result = updateGitignore(tmpDir);
|
||
|
||
expect(result).toBe(false);
|
||
const content = readFile(tmpDir, ".gitignore");
|
||
expect(content).toBe("node_modules/\ndist\n");
|
||
});
|
||
});
|
||
|
||
// ─── Integration: init updates .gitignore ────────────────────────────────────
|
||
|
||
describe("init — .gitignore", () => {
|
||
it("adds /dist/ to .gitignore during init", async () => {
|
||
setupProject(tmpDir, { router: "app" });
|
||
|
||
const { result } = await runInit(tmpDir);
|
||
|
||
expect(result.updatedGitignore).toBe(true);
|
||
const content = readFile(tmpDir, ".gitignore");
|
||
expect(content).toContain("/dist/");
|
||
});
|
||
|
||
it("does not duplicate /dist/ if already in .gitignore", async () => {
|
||
setupProject(tmpDir, { router: "app" });
|
||
writeFile(tmpDir, ".gitignore", "node_modules/\n/dist/\n");
|
||
|
||
const { result } = await runInit(tmpDir);
|
||
|
||
expect(result.updatedGitignore).toBe(false);
|
||
// Ensure no duplication
|
||
const content = readFile(tmpDir, ".gitignore");
|
||
const matches = content.split("\n").filter((l: string) => l.trim() === "/dist/");
|
||
expect(matches.length).toBe(1);
|
||
});
|
||
|
||
it("preserves existing .gitignore entries when adding /dist/", async () => {
|
||
setupProject(tmpDir, { router: "app" });
|
||
writeFile(tmpDir, ".gitignore", "node_modules/\n.env\n.next/\n");
|
||
|
||
const { result } = await runInit(tmpDir);
|
||
|
||
expect(result.updatedGitignore).toBe(true);
|
||
const content = readFile(tmpDir, ".gitignore");
|
||
expect(content).toContain("node_modules/");
|
||
expect(content).toContain(".env");
|
||
expect(content).toContain(".next/");
|
||
expect(content).toContain("/dist/");
|
||
});
|
||
});
|