cloudflare/vinext

Public

mirrored from https://github.com/cloudflare/vinextAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
v0.0.9

Branches

Tags

  • No tags available.
0Branches0Tags
Go to file
Add file
Code

Clone

HTTPS

Download ZIP

tests/deploy.test.ts

1647lines · modecode

1import { describe, it, expect, beforeEach, afterEach } from "vitest";
2import fs from "node:fs";
3import path from "node:path";
4import os from "node:os";
5import {
6 detectProject,
7 generateWranglerConfig,
8 generateAppRouterWorkerEntry,
9 generatePagesRouterWorkerEntry,
10 generateAppRouterViteConfig,
11 generatePagesRouterViteConfig,
12 getMissingDeps,
13 getFilesToGenerate,
14 ensureESModule,
15 renameCJSConfigs,
16 buildWranglerDeployArgs,
17 parseDeployArgs,
18 isPackageResolvable,
19} from "../packages/vinext/src/deploy.js";
20import { computeLazyChunks } from "../packages/vinext/src/index.js";
21
22// ─── Test Helpers ────────────────────────────────────────────────────────────
23
24let tmpDir: string;
25
26function createTmpDir(): string {
27 const dir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-deploy-test-"));
28 return dir;
29}
30
31function writeFile(dir: string, relativePath: string, content: string): void {
32 const fullPath = path.join(dir, relativePath);
33 fs.mkdirSync(path.dirname(fullPath), { recursive: true });
34 fs.writeFileSync(fullPath, content, "utf-8");
35}
36
37function mkdir(dir: string, relativePath: string): void {
38 fs.mkdirSync(path.join(dir, relativePath), { recursive: true });
39}
40
41beforeEach(() => {
42 tmpDir = createTmpDir();
43});
44
45afterEach(() => {
46 fs.rmSync(tmpDir, { recursive: true, force: true });
47});
48
49// ─── Wrangler deploy args ───────────────────────────────────────────────────
50
51describe("buildWranglerDeployArgs", () => {
52 it("uses plain deploy for production by default", () => {
53 expect(buildWranglerDeployArgs({})).toEqual({ args: ["deploy"], env: undefined });
54 });
55
56 it("maps --preview to wrangler --env preview", () => {
57 expect(buildWranglerDeployArgs({ preview: true })).toEqual({
58 args: ["deploy", "--env", "preview"],
59 env: "preview",
60 });
61 });
62
63 it("passes through explicit env names", () => {
64 expect(buildWranglerDeployArgs({ env: "staging" })).toEqual({
65 args: ["deploy", "--env", "staging"],
66 env: "staging",
67 });
68 });
69
70 it("prefers explicit env over --preview shorthand", () => {
71 expect(buildWranglerDeployArgs({ preview: true, env: "qa" })).toEqual({
72 args: ["deploy", "--env", "qa"],
73 env: "qa",
74 });
75 });
76
77 it("treats empty string env as production", () => {
78 expect(buildWranglerDeployArgs({ env: "" })).toEqual({ args: ["deploy"], env: undefined });
79 });
80});
81
82// ─── Deploy CLI arg parsing ─────────────────────────────────────────────────
83
84describe("parseDeployArgs", () => {
85 it("defaults to production deploy with no flags", () => {
86 const parsed = parseDeployArgs([]);
87 expect(parsed.preview).toBe(false);
88 expect(parsed.env).toBeUndefined();
89 expect(parsed.name).toBeUndefined();
90 expect(parsed.skipBuild).toBe(false);
91 expect(parsed.dryRun).toBe(false);
92 });
93
94 it("parses --env with space-separated value", () => {
95 expect(parseDeployArgs(["--env", "staging"]).env).toBe("staging");
96 });
97
98 it("parses --env=value form", () => {
99 expect(parseDeployArgs(["--env=staging"]).env).toBe("staging");
100 });
101
102 it("parses --name with space-separated value", () => {
103 expect(parseDeployArgs(["--name", "my-app"]).name).toBe("my-app");
104 });
105
106 it("parses --name=value form", () => {
107 expect(parseDeployArgs(["--name=my-app"]).name).toBe("my-app");
108 });
109
110 it("parses boolean flags", () => {
111 const parsed = parseDeployArgs(["--preview", "--skip-build", "--dry-run"]);
112 expect(parsed.preview).toBe(true);
113 expect(parsed.skipBuild).toBe(true);
114 expect(parsed.dryRun).toBe(true);
115 });
116
117 it("parses numeric TPR flags from string values", () => {
118 const parsed = parseDeployArgs([
119 "--experimental-tpr",
120 "--tpr-coverage", "95",
121 "--tpr-limit", "500",
122 "--tpr-window", "48",
123 ]);
124 expect(parsed.experimentalTPR).toBe(true);
125 expect(parsed.tprCoverage).toBe(95);
126 expect(parsed.tprLimit).toBe(500);
127 expect(parsed.tprWindow).toBe(48);
128 });
129
130 it("trims whitespace from --env value", () => {
131 expect(parseDeployArgs(["--env", " staging "]).env).toBe("staging");
132 });
133
134 it("treats whitespace-only --env as undefined", () => {
135 expect(parseDeployArgs(["--env", " "]).env).toBeUndefined();
136 });
137
138 it("throws on unknown flags (strict mode)", () => {
139 expect(() => parseDeployArgs(["--bogus"])).toThrow();
140 });
141});
142
143// ─── detectProject ──────────────────────────────────────────────────────────
144
145describe("detectProject", () => {
146 it("detects App Router when app/ exists", () => {
147 mkdir(tmpDir, "app");
148 writeFile(tmpDir, "app/page.tsx", "export default function Home() { return <div>hi</div> }");
149 const info = detectProject(tmpDir);
150 expect(info.isAppRouter).toBe(true);
151 expect(info.isPagesRouter).toBe(false);
152 });
153
154 it("detects Pages Router when only pages/ exists", () => {
155 mkdir(tmpDir, "pages");
156 writeFile(tmpDir, "pages/index.tsx", "export default function Home() { return <div>hi</div> }");
157 const info = detectProject(tmpDir);
158 expect(info.isAppRouter).toBe(false);
159 expect(info.isPagesRouter).toBe(true);
160 });
161
162 it("prefers App Router when both app/ and pages/ exist", () => {
163 mkdir(tmpDir, "app");
164 mkdir(tmpDir, "pages");
165 const info = detectProject(tmpDir);
166 expect(info.isAppRouter).toBe(true);
167 expect(info.isPagesRouter).toBe(false);
168 });
169
170 it("detects neither when no app/ or pages/", () => {
171 const info = detectProject(tmpDir);
172 expect(info.isAppRouter).toBe(false);
173 expect(info.isPagesRouter).toBe(false);
174 });
175
176 it("detects vite.config.ts", () => {
177 mkdir(tmpDir, "app");
178 writeFile(tmpDir, "vite.config.ts", "export default {}");
179 const info = detectProject(tmpDir);
180 expect(info.hasViteConfig).toBe(true);
181 });
182
183 it("detects vite.config.mjs", () => {
184 mkdir(tmpDir, "app");
185 writeFile(tmpDir, "vite.config.mjs", "export default {}");
186 const info = detectProject(tmpDir);
187 expect(info.hasViteConfig).toBe(true);
188 });
189
190 it("detects no vite config", () => {
191 mkdir(tmpDir, "app");
192 const info = detectProject(tmpDir);
193 expect(info.hasViteConfig).toBe(false);
194 });
195
196 it("detects wrangler.jsonc", () => {
197 mkdir(tmpDir, "app");
198 writeFile(tmpDir, "wrangler.jsonc", "{}");
199 const info = detectProject(tmpDir);
200 expect(info.hasWranglerConfig).toBe(true);
201 });
202
203 it("detects wrangler.toml", () => {
204 mkdir(tmpDir, "app");
205 writeFile(tmpDir, "wrangler.toml", "[vars]");
206 const info = detectProject(tmpDir);
207 expect(info.hasWranglerConfig).toBe(true);
208 });
209
210 it("detects worker/index.ts", () => {
211 mkdir(tmpDir, "app");
212 writeFile(tmpDir, "worker/index.ts", "export default {}");
213 const info = detectProject(tmpDir);
214 expect(info.hasWorkerEntry).toBe(true);
215 });
216
217 it("detects worker/index.js", () => {
218 mkdir(tmpDir, "app");
219 writeFile(tmpDir, "worker/index.js", "export default {}");
220 const info = detectProject(tmpDir);
221 expect(info.hasWorkerEntry).toBe(true);
222 });
223
224 it("derives project name from package.json", () => {
225 mkdir(tmpDir, "app");
226 writeFile(tmpDir, "package.json", JSON.stringify({ name: "my-cool-app" }));
227 const info = detectProject(tmpDir);
228 expect(info.projectName).toBe("my-cool-app");
229 });
230
231 it("strips npm scope from project name", () => {
232 mkdir(tmpDir, "app");
233 writeFile(tmpDir, "package.json", JSON.stringify({ name: "@org/my-app" }));
234 const info = detectProject(tmpDir);
235 expect(info.projectName).toBe("my-app");
236 });
237
238 it("sanitizes project name for Workers", () => {
239 mkdir(tmpDir, "app");
240 writeFile(tmpDir, "package.json", JSON.stringify({ name: "My App_v2!" }));
241 const info = detectProject(tmpDir);
242 // Workers names: lowercase alphanumeric + hyphens
243 expect(info.projectName).toMatch(/^[a-z0-9-]+$/);
244 expect(info.projectName).not.toMatch(/^-|-$/);
245 });
246
247 it("falls back to directory name when no package.json", () => {
248 mkdir(tmpDir, "app");
249 const info = detectProject(tmpDir);
250 expect(info.projectName).toBe(path.basename(tmpDir));
251 });
252
253 it("detects ISR usage in App Router", () => {
254 mkdir(tmpDir, "app");
255 writeFile(
256 tmpDir,
257 "app/posts/page.tsx",
258 `export const revalidate = 60;\nexport default function Posts() { return <div>posts</div> }`,
259 );
260 const info = detectProject(tmpDir);
261 expect(info.hasISR).toBe(true);
262 });
263
264 it("does not detect ISR when no revalidate export", () => {
265 mkdir(tmpDir, "app");
266 writeFile(tmpDir, "app/page.tsx", "export default function Home() { return <div>hi</div> }");
267 const info = detectProject(tmpDir);
268 expect(info.hasISR).toBe(false);
269 });
270
271 it("does not detect ISR for Pages Router", () => {
272 mkdir(tmpDir, "pages");
273 writeFile(tmpDir, "pages/index.tsx", "export default function Home() { return <div>hi</div> }");
274 const info = detectProject(tmpDir);
275 expect(info.hasISR).toBe(false);
276 });
277});
278
279// ─── generateWranglerConfig ─────────────────────────────────────────────────
280
281describe("generateWranglerConfig", () => {
282 it("generates valid JSON with required fields", () => {
283 mkdir(tmpDir, "app");
284 const info = detectProject(tmpDir);
285 const config = generateWranglerConfig(info);
286 const parsed = JSON.parse(config);
287
288 expect(parsed.name).toBe(info.projectName);
289 expect(parsed.compatibility_flags).toContain("nodejs_compat");
290 expect(parsed.main).toBe("./worker/index.ts");
291 expect(parsed.assets).toEqual({ not_found_handling: "none", binding: "ASSETS" });
292 expect(parsed.$schema).toBe("node_modules/wrangler/config-schema.json");
293 });
294
295 it("sets compatibility_date to today", () => {
296 mkdir(tmpDir, "app");
297 const info = detectProject(tmpDir);
298 const config = generateWranglerConfig(info);
299 const parsed = JSON.parse(config);
300
301 const today = new Date().toISOString().split("T")[0];
302 expect(parsed.compatibility_date).toBe(today);
303 });
304
305 it("includes KV namespace when ISR detected", () => {
306 mkdir(tmpDir, "app");
307 writeFile(tmpDir, "app/page.tsx", "export const revalidate = 30;\nexport default function() { return <div/> }");
308 const info = detectProject(tmpDir);
309 const config = generateWranglerConfig(info);
310 const parsed = JSON.parse(config);
311
312 expect(parsed.kv_namespaces).toBeDefined();
313 expect(parsed.kv_namespaces[0].binding).toBe("VINEXT_CACHE");
314 });
315
316 it("omits KV namespace when no ISR", () => {
317 mkdir(tmpDir, "app");
318 writeFile(tmpDir, "app/page.tsx", "export default function() { return <div/> }");
319 const info = detectProject(tmpDir);
320 const config = generateWranglerConfig(info);
321 const parsed = JSON.parse(config);
322
323 expect(parsed.kv_namespaces).toBeUndefined();
324 });
325
326 it("includes Cloudflare Images binding for image optimization", () => {
327 mkdir(tmpDir, "app");
328 const info = detectProject(tmpDir);
329 const config = generateWranglerConfig(info);
330 const parsed = JSON.parse(config);
331
332 expect(parsed.images).toBeDefined();
333 expect(parsed.images.binding).toBe("IMAGES");
334 });
335});
336
337// ─── Worker Entry Generation ─────────────────────────────────────────────────
338
339describe("generateAppRouterWorkerEntry", () => {
340 it("generates valid TypeScript", () => {
341 const content = generateAppRouterWorkerEntry();
342 expect(content).toContain("export default");
343 expect(content).toContain("async fetch(request: Request, env: Env)");
344 expect(content).toContain("Promise<Response>");
345 });
346
347 it("imports handler from vinext/server/app-router-entry", () => {
348 const content = generateAppRouterWorkerEntry();
349 expect(content).toContain('import handler from "vinext/server/app-router-entry"');
350 });
351
352 it("delegates to handler.fetch", () => {
353 const content = generateAppRouterWorkerEntry();
354 expect(content).toContain("handler.fetch(request)");
355 });
356
357 it("includes auto-generated comment", () => {
358 const content = generateAppRouterWorkerEntry();
359 expect(content).toContain("auto-generated by vinext deploy");
360 });
361
362 it("includes /_vinext/image handler", () => {
363 const content = generateAppRouterWorkerEntry();
364 expect(content).toContain("/_vinext/image");
365 expect(content).toContain("handleImageOptimization");
366 });
367
368 it("imports handleImageOptimization from vinext/server/image-optimization", () => {
369 const content = generateAppRouterWorkerEntry();
370 expect(content).toContain('from "vinext/server/image-optimization"');
371 expect(content).toContain("handleImageOptimization");
372 });
373
374 it("declares Env interface with IMAGES binding", () => {
375 const content = generateAppRouterWorkerEntry();
376 expect(content).toContain("interface Env");
377 expect(content).toContain("IMAGES");
378 expect(content).toContain("ASSETS");
379 });
380
381 it("passes image handlers inline to handleImageOptimization", () => {
382 const content = generateAppRouterWorkerEntry();
383 expect(content).toContain("fetchAsset:");
384 expect(content).toContain("transformImage:");
385 expect(content).toContain("env.ASSETS.fetch");
386 expect(content).toContain("env.IMAGES");
387 });
388});
389
390describe("generatePagesRouterWorkerEntry", () => {
391 it("generates valid TypeScript", () => {
392 const content = generatePagesRouterWorkerEntry();
393 expect(content).toContain("export default");
394 expect(content).toContain("async fetch(request: Request, env: Env)");
395 expect(content).toContain("Promise<Response>");
396 });
397
398 it("imports from virtual:vinext-server-entry", () => {
399 const content = generatePagesRouterWorkerEntry();
400 expect(content).toContain('from "virtual:vinext-server-entry"');
401 expect(content).toContain("renderPage");
402 expect(content).toContain("handleApiRoute");
403 });
404
405 it("routes /api/ to handleApiRoute", () => {
406 const content = generatePagesRouterWorkerEntry();
407 expect(content).toContain('pathname.startsWith("/api/")');
408 expect(content).toContain("handleApiRoute");
409 });
410
411 it("includes error handling", () => {
412 const content = generatePagesRouterWorkerEntry();
413 expect(content).toContain("catch (error)");
414 expect(content).toContain("Internal Server Error");
415 });
416
417 it("includes /_vinext/image handler", () => {
418 const content = generatePagesRouterWorkerEntry();
419 expect(content).toContain("/_vinext/image");
420 expect(content).toContain("handleImageOptimization");
421 });
422
423 it("imports handleImageOptimization from vinext/server/image-optimization", () => {
424 const content = generatePagesRouterWorkerEntry();
425 expect(content).toContain('from "vinext/server/image-optimization"');
426 expect(content).toContain("handleImageOptimization");
427 });
428
429 it("declares Env interface with IMAGES binding", () => {
430 const content = generatePagesRouterWorkerEntry();
431 expect(content).toContain("interface Env");
432 expect(content).toContain("IMAGES");
433 expect(content).toContain("ASSETS");
434 });
435
436 it("passes image handlers inline to handleImageOptimization", () => {
437 const content = generatePagesRouterWorkerEntry();
438 expect(content).toContain("fetchAsset:");
439 expect(content).toContain("transformImage:");
440 expect(content).toContain("env.ASSETS.fetch");
441 expect(content).toContain("env.IMAGES");
442 });
443});
444
445// ─── Vite Config Generation ─────────────────────────────────────────────��───
446
447describe("generateAppRouterViteConfig", () => {
448 it("includes vinext and cloudflare plugins", () => {
449 const content = generateAppRouterViteConfig();
450 expect(content).toContain('import vinext from "vinext"');
451 expect(content).toContain('from "@cloudflare/vite-plugin"');
452 expect(content).toContain("vinext()");
453 expect(content).toContain("cloudflare(");
454 });
455
456 it("configures viteEnvironment with name: rsc and childEnvironments for Workers", () => {
457 const content = generateAppRouterViteConfig();
458 expect(content).toContain('name: "rsc"');
459 expect(content).toContain('childEnvironments: ["ssr"]');
460 });
461});
462
463describe("generatePagesRouterViteConfig", () => {
464 it("includes vinext and cloudflare plugins only", () => {
465 const content = generatePagesRouterViteConfig();
466 expect(content).toContain('import vinext from "vinext"');
467 expect(content).toContain('from "@cloudflare/vite-plugin"');
468 expect(content).toContain("vinext()");
469 expect(content).toContain("cloudflare()");
470 // Should NOT include RSC plugin
471 expect(content).not.toContain("plugin-rsc");
472 });
473});
474
475// ─── getMissingDeps ──────────────────────────────────────────────────────────
476
477describe("getMissingDeps", () => {
478 it("reports missing @cloudflare/vite-plugin", () => {
479 mkdir(tmpDir, "app");
480 const info = detectProject(tmpDir);
481 info.hasCloudflarePlugin = false;
482 info.hasWrangler = true;
483 info.hasRscPlugin = true;
484
485 const missing = getMissingDeps(info);
486 expect(missing).toContainEqual(
487 expect.objectContaining({ name: "@cloudflare/vite-plugin" }),
488 );
489 });
490
491 it("reports missing wrangler", () => {
492 mkdir(tmpDir, "app");
493 const info = detectProject(tmpDir);
494 info.hasCloudflarePlugin = true;
495 info.hasWrangler = false;
496 info.hasRscPlugin = true;
497
498 const missing = getMissingDeps(info);
499 expect(missing).toContainEqual(
500 expect.objectContaining({ name: "wrangler" }),
501 );
502 });
503
504 it("reports missing @vitejs/plugin-rsc for App Router", () => {
505 mkdir(tmpDir, "app");
506 const info = detectProject(tmpDir);
507 info.hasCloudflarePlugin = true;
508 info.hasWrangler = true;
509 info.hasRscPlugin = false;
510
511 const missing = getMissingDeps(info);
512 expect(missing).toContainEqual(
513 expect.objectContaining({ name: "@vitejs/plugin-rsc" }),
514 );
515 });
516
517 it("does not require @vitejs/plugin-rsc for Pages Router", () => {
518 mkdir(tmpDir, "pages");
519 const info = detectProject(tmpDir);
520 info.hasCloudflarePlugin = true;
521 info.hasWrangler = true;
522 info.hasRscPlugin = false;
523
524 const missing = getMissingDeps(info);
525 expect(missing).not.toContainEqual(
526 expect.objectContaining({ name: "@vitejs/plugin-rsc" }),
527 );
528 });
529
530 it("reports missing react-server-dom-webpack for App Router", () => {
531 mkdir(tmpDir, "app");
532 const info = detectProject(tmpDir);
533 info.hasCloudflarePlugin = true;
534 info.hasWrangler = true;
535 info.hasRscPlugin = true;
536
537 // Pass a resolver that always returns false to simulate rsdw not being installed.
538 // (Vitest's createRequire finds rsdw via the monorepo root, so we can't rely
539 // on filesystem isolation in tmpdir.)
540 const notResolvable = () => false;
541 const missing = getMissingDeps(info, notResolvable);
542 expect(missing).toContainEqual(
543 expect.objectContaining({ name: "react-server-dom-webpack" }),
544 );
545 });
546
547 it("does not require react-server-dom-webpack for Pages Router", () => {
548 mkdir(tmpDir, "pages");
549 const info = detectProject(tmpDir);
550 info.hasCloudflarePlugin = true;
551 info.hasWrangler = true;
552 info.hasRscPlugin = false;
553
554 const missing = getMissingDeps(info);
555 expect(missing).not.toContainEqual(
556 expect.objectContaining({ name: "react-server-dom-webpack" }),
557 );
558 });
559
560 it("returns empty array when everything is installed", () => {
561 mkdir(tmpDir, "app");
562 const info = detectProject(tmpDir);
563 info.hasCloudflarePlugin = true;
564 info.hasWrangler = true;
565 info.hasRscPlugin = true;
566
567 // Pass a resolver that always returns true to simulate all packages installed.
568 const allResolvable = () => true;
569 const missing = getMissingDeps(info, allResolvable);
570 expect(missing).toHaveLength(0);
571 });
572});
573
574// ─── isPackageResolvable ─────────────────────────────────────────────────────
575
576describe("isPackageResolvable", () => {
577 it("returns true when package exists in node_modules", () => {
578 // Create a proper resolvable package in the tmpdir
579 const pkgDir = path.join(tmpDir, "node_modules", "fake-pkg");
580 fs.mkdirSync(pkgDir, { recursive: true });
581 fs.writeFileSync(
582 path.join(pkgDir, "package.json"),
583 JSON.stringify({ name: "fake-pkg", version: "1.0.0", main: "index.js" }),
584 );
585 fs.writeFileSync(path.join(pkgDir, "index.js"), "");
586 fs.writeFileSync(
587 path.join(tmpDir, "package.json"),
588 JSON.stringify({ name: "test", version: "1.0.0" }),
589 );
590
591 expect(isPackageResolvable(tmpDir, "fake-pkg")).toBe(true);
592 });
593
594 it("returns false when package does not exist", () => {
595 fs.writeFileSync(
596 path.join(tmpDir, "package.json"),
597 JSON.stringify({ name: "test", version: "1.0.0" }),
598 );
599 // no-such-package-xyz123 should never exist in any node_modules
600 expect(isPackageResolvable(tmpDir, "no-such-package-xyz123")).toBe(false);
601 });
602});
603
604// ─── getFilesToGenerate ──────────────────────────────────────────────────────
605
606describe("getFilesToGenerate", () => {
607 it("generates all three files when nothing exists (App Router)", () => {
608 mkdir(tmpDir, "app");
609 const info = detectProject(tmpDir);
610 const files = getFilesToGenerate(info);
611
612 expect(files).toHaveLength(3);
613 const descriptions = files.map((f) => f.description);
614 expect(descriptions).toContain("wrangler.jsonc");
615 expect(descriptions).toContain("worker/index.ts");
616 expect(descriptions).toContain("vite.config.ts");
617 });
618
619 it("generates all three files when nothing exists (Pages Router)", () => {
620 mkdir(tmpDir, "pages");
621 const info = detectProject(tmpDir);
622 const files = getFilesToGenerate(info);
623
624 expect(files).toHaveLength(3);
625 });
626
627 it("skips wrangler.jsonc when it already exists", () => {
628 mkdir(tmpDir, "app");
629 writeFile(tmpDir, "wrangler.jsonc", "{}");
630 const info = detectProject(tmpDir);
631 const files = getFilesToGenerate(info);
632
633 const descriptions = files.map((f) => f.description);
634 expect(descriptions).not.toContain("wrangler.jsonc");
635 expect(files).toHaveLength(2);
636 });
637
638 it("skips worker/index.ts when it already exists", () => {
639 mkdir(tmpDir, "app");
640 writeFile(tmpDir, "worker/index.ts", "export default {}");
641 const info = detectProject(tmpDir);
642 const files = getFilesToGenerate(info);
643
644 const descriptions = files.map((f) => f.description);
645 expect(descriptions).not.toContain("worker/index.ts");
646 });
647
648 it("skips vite.config.ts when it already exists", () => {
649 mkdir(tmpDir, "app");
650 writeFile(tmpDir, "vite.config.ts", "export default {}");
651 const info = detectProject(tmpDir);
652 const files = getFilesToGenerate(info);
653
654 const descriptions = files.map((f) => f.description);
655 expect(descriptions).not.toContain("vite.config.ts");
656 });
657
658 it("generates nothing when all files exist", () => {
659 mkdir(tmpDir, "app");
660 writeFile(tmpDir, "wrangler.jsonc", "{}");
661 writeFile(tmpDir, "worker/index.ts", "export default {}");
662 writeFile(tmpDir, "vite.config.ts", "export default {}");
663 const info = detectProject(tmpDir);
664 const files = getFilesToGenerate(info);
665
666 expect(files).toHaveLength(0);
667 });
668
669 it("generates App Router worker entry for App Router project", () => {
670 mkdir(tmpDir, "app");
671 const info = detectProject(tmpDir);
672 const files = getFilesToGenerate(info);
673
674 const workerFile = files.find((f) => f.description === "worker/index.ts");
675 expect(workerFile).toBeDefined();
676 expect(workerFile!.content).toContain("vinext/server/app-router-entry");
677 expect(workerFile!.content).not.toContain("virtual:vinext-server-entry");
678 });
679
680 it("generates Pages Router worker entry for Pages Router project", () => {
681 mkdir(tmpDir, "pages");
682 const info = detectProject(tmpDir);
683 const files = getFilesToGenerate(info);
684
685 const workerFile = files.find((f) => f.description === "worker/index.ts");
686 expect(workerFile).toBeDefined();
687 expect(workerFile!.content).toContain("virtual:vinext-server-entry");
688 expect(workerFile!.content).not.toContain("viteRsc");
689 });
690
691 it("generates App Router vite config for App Router project", () => {
692 mkdir(tmpDir, "app");
693 const info = detectProject(tmpDir);
694 const files = getFilesToGenerate(info);
695
696 const viteFile = files.find((f) => f.description === "vite.config.ts");
697 expect(viteFile).toBeDefined();
698 expect(viteFile!.content).toContain("vinext()");
699 expect(viteFile!.content).toContain("childEnvironments");
700 });
701
702 it("generates Pages Router vite config for Pages Router project", () => {
703 mkdir(tmpDir, "pages");
704 const info = detectProject(tmpDir);
705 const files = getFilesToGenerate(info);
706
707 const viteFile = files.find((f) => f.description === "vite.config.ts");
708 expect(viteFile).toBeDefined();
709 expect(viteFile!.content).not.toContain("plugin-rsc");
710 });
711});
712
713// ─── ensureESModule ──────────────────────────────────────────────────────────
714
715describe("ensureESModule", () => {
716 it("adds 'type': 'module' when missing", () => {
717 mkdir(tmpDir, "app");
718 writeFile(tmpDir, "package.json", JSON.stringify({ name: "test-app" }));
719
720 const added = ensureESModule(tmpDir);
721 expect(added).toBe(true);
722
723 const pkg = JSON.parse(fs.readFileSync(path.join(tmpDir, "package.json"), "utf-8"));
724 expect(pkg.type).toBe("module");
725 });
726
727 it("returns false when already has 'type': 'module'", () => {
728 writeFile(tmpDir, "package.json", JSON.stringify({ name: "test-app", type: "module" }));
729
730 const added = ensureESModule(tmpDir);
731 expect(added).toBe(false);
732 });
733
734 it("returns false when no package.json", () => {
735 const added = ensureESModule(tmpDir);
736 expect(added).toBe(false);
737 });
738
739 it("preserves existing package.json fields", () => {
740 writeFile(
741 tmpDir,
742 "package.json",
743 JSON.stringify({
744 name: "test-app",
745 version: "1.0.0",
746 dependencies: { react: "^19.0.0" },
747 }),
748 );
749
750 ensureESModule(tmpDir);
751 const pkg = JSON.parse(fs.readFileSync(path.join(tmpDir, "package.json"), "utf-8"));
752 expect(pkg.name).toBe("test-app");
753 expect(pkg.version).toBe("1.0.0");
754 expect(pkg.dependencies.react).toBe("^19.0.0");
755 expect(pkg.type).toBe("module");
756 });
757});
758
759// ─── renameCJSConfigs ────────────────────────────────────────────────────────
760
761describe("renameCJSConfigs", () => {
762 it("renames postcss.config.js using module.exports to .cjs", () => {
763 writeFile(tmpDir, "postcss.config.js", "module.exports = { plugins: {} };");
764
765 const renamed = renameCJSConfigs(tmpDir);
766 expect(renamed).toEqual([["postcss.config.js", "postcss.config.cjs"]]);
767 expect(fs.existsSync(path.join(tmpDir, "postcss.config.cjs"))).toBe(true);
768 expect(fs.existsSync(path.join(tmpDir, "postcss.config.js"))).toBe(false);
769 });
770
771 it("renames tailwind.config.js using require() to .cjs", () => {
772 writeFile(tmpDir, "tailwind.config.js", `const plugin = require("tailwindcss/plugin");\nmodule.exports = {};`);
773
774 const renamed = renameCJSConfigs(tmpDir);
775 expect(renamed).toEqual([["tailwind.config.js", "tailwind.config.cjs"]]);
776 });
777
778 it("does not rename ESM config files", () => {
779 writeFile(tmpDir, "postcss.config.js", "export default { plugins: {} };");
780
781 const renamed = renameCJSConfigs(tmpDir);
782 expect(renamed).toEqual([]);
783 expect(fs.existsSync(path.join(tmpDir, "postcss.config.js"))).toBe(true);
784 });
785
786 it("renames multiple CJS configs at once", () => {
787 writeFile(tmpDir, "postcss.config.js", "module.exports = {};");
788 writeFile(tmpDir, "tailwind.config.js", "module.exports = {};");
789 writeFile(tmpDir, ".eslintrc.js", "module.exports = {};");
790
791 const renamed = renameCJSConfigs(tmpDir);
792 expect(renamed).toHaveLength(3);
793 expect(renamed.map((r) => r[0])).toContain("postcss.config.js");
794 expect(renamed.map((r) => r[0])).toContain("tailwind.config.js");
795 expect(renamed.map((r) => r[0])).toContain(".eslintrc.js");
796 });
797
798 it("returns empty array when no CJS configs exist", () => {
799 const renamed = renameCJSConfigs(tmpDir);
800 expect(renamed).toEqual([]);
801 });
802});
803
804// ─── detectProject: src/ directory support ──────────────────────────────────
805
806describe("detectProject — src/ directory convention", () => {
807 it("detects App Router when src/app/ exists", () => {
808 mkdir(tmpDir, "src/app");
809 writeFile(tmpDir, "src/app/page.tsx", "export default function Home() { return <div>hi</div> }");
810 const info = detectProject(tmpDir);
811 expect(info.isAppRouter).toBe(true);
812 expect(info.isPagesRouter).toBe(false);
813 });
814
815 it("detects Pages Router when only src/pages/ exists", () => {
816 mkdir(tmpDir, "src/pages");
817 writeFile(tmpDir, "src/pages/index.tsx", "export default function Home() { return <div>hi</div> }");
818 const info = detectProject(tmpDir);
819 expect(info.isAppRouter).toBe(false);
820 expect(info.isPagesRouter).toBe(true);
821 });
822
823 it("prefers App Router when both src/app/ and src/pages/ exist", () => {
824 mkdir(tmpDir, "src/app");
825 mkdir(tmpDir, "src/pages");
826 const info = detectProject(tmpDir);
827 expect(info.isAppRouter).toBe(true);
828 expect(info.isPagesRouter).toBe(false);
829 });
830
831 it("prefers root-level app/ over src/app/", () => {
832 mkdir(tmpDir, "app");
833 mkdir(tmpDir, "src/app");
834 const info = detectProject(tmpDir);
835 expect(info.isAppRouter).toBe(true);
836 });
837
838 it("prefers root-level pages/ over src/pages/", () => {
839 mkdir(tmpDir, "pages");
840 mkdir(tmpDir, "src/pages");
841 const info = detectProject(tmpDir);
842 expect(info.isPagesRouter).toBe(true);
843 });
844
845 it("detects App Router from root app/ even when src/pages/ exists", () => {
846 mkdir(tmpDir, "app");
847 mkdir(tmpDir, "src/pages");
848 const info = detectProject(tmpDir);
849 expect(info.isAppRouter).toBe(true);
850 expect(info.isPagesRouter).toBe(false);
851 });
852
853 it("detects ISR in src/app/ directory", () => {
854 mkdir(tmpDir, "src/app");
855 writeFile(
856 tmpDir,
857 "src/app/posts/page.tsx",
858 `export const revalidate = 60;\nexport default function Posts() { return <div>posts</div> }`,
859 );
860 const info = detectProject(tmpDir);
861 expect(info.isAppRouter).toBe(true);
862 expect(info.hasISR).toBe(true);
863 });
864
865 it("does not detect ISR when src/app/ has no revalidate exports", () => {
866 mkdir(tmpDir, "src/app");
867 writeFile(tmpDir, "src/app/page.tsx", "export default function Home() { return <div>hi</div> }");
868 const info = detectProject(tmpDir);
869 expect(info.isAppRouter).toBe(true);
870 expect(info.hasISR).toBe(false);
871 });
872
873 it("detects MDX in src/app/ directory", () => {
874 mkdir(tmpDir, "src/app");
875 writeFile(tmpDir, "src/app/about/page.mdx", "# About\nHello world");
876 const info = detectProject(tmpDir);
877 expect(info.isAppRouter).toBe(true);
878 expect(info.hasMDX).toBe(true);
879 });
880
881 it("detects MDX in src/pages/ directory", () => {
882 mkdir(tmpDir, "src/pages");
883 writeFile(tmpDir, "src/pages/about.mdx", "# About\nHello world");
884 const info = detectProject(tmpDir);
885 expect(info.isPagesRouter).toBe(true);
886 expect(info.hasMDX).toBe(true);
887 });
888
889 it("generates correct files for src/app/ project", () => {
890 mkdir(tmpDir, "src/app");
891 writeFile(tmpDir, "src/app/page.tsx", "export default function Home() { return <div>hi</div> }");
892 const info = detectProject(tmpDir);
893 const files = getFilesToGenerate(info);
894
895 expect(files).toHaveLength(3);
896 const descriptions = files.map((f) => f.description);
897 expect(descriptions).toContain("wrangler.jsonc");
898 expect(descriptions).toContain("worker/index.ts");
899 expect(descriptions).toContain("vite.config.ts");
900
901 // Should generate App Router worker entry
902 const workerFile = files.find((f) => f.description === "worker/index.ts");
903 expect(workerFile!.content).toContain("vinext/server/app-router-entry");
904 });
905
906 it("generates correct files for src/pages/ project", () => {
907 mkdir(tmpDir, "src/pages");
908 writeFile(tmpDir, "src/pages/index.tsx", "export default function Home() { return <div>hi</div> }");
909 const info = detectProject(tmpDir);
910 const files = getFilesToGenerate(info);
911
912 expect(files).toHaveLength(3);
913
914 // Should generate Pages Router worker entry
915 const workerFile = files.find((f) => f.description === "worker/index.ts");
916 expect(workerFile!.content).toContain("virtual:vinext-server-entry");
917 });
918
919 it("detects neither when no app/, pages/, src/app/, or src/pages/", () => {
920 mkdir(tmpDir, "src/lib");
921 const info = detectProject(tmpDir);
922 expect(info.isAppRouter).toBe(false);
923 expect(info.isPagesRouter).toBe(false);
924 });
925});
926
927// ─── detectProject: new fields ──────────────────────────────────────────────
928
929describe("detectProject — new detection features", () => {
930 it("detects hasTypeModule when package.json has type: module", () => {
931 mkdir(tmpDir, "app");
932 writeFile(tmpDir, "package.json", JSON.stringify({ name: "test", type: "module" }));
933 const info = detectProject(tmpDir);
934 expect(info.hasTypeModule).toBe(true);
935 });
936
937 it("hasTypeModule is false when missing", () => {
938 mkdir(tmpDir, "app");
939 writeFile(tmpDir, "package.json", JSON.stringify({ name: "test" }));
940 const info = detectProject(tmpDir);
941 expect(info.hasTypeModule).toBe(false);
942 });
943
944 it("detects MDX via .mdx files in app/", () => {
945 mkdir(tmpDir, "app");
946 writeFile(tmpDir, "app/about/page.mdx", "# About\nHello world");
947 const info = detectProject(tmpDir);
948 expect(info.hasMDX).toBe(true);
949 });
950
951 it("detects MDX via @next/mdx in next.config", () => {
952 mkdir(tmpDir, "app");
953 writeFile(tmpDir, "next.config.mjs", `import mdx from "@next/mdx";\nexport default mdx()({});`);
954 const info = detectProject(tmpDir);
955 expect(info.hasMDX).toBe(true);
956 });
957
958 it("hasMDX is false when no MDX usage", () => {
959 mkdir(tmpDir, "app");
960 writeFile(tmpDir, "app/page.tsx", "export default function Home() { return <div/> }");
961 const info = detectProject(tmpDir);
962 expect(info.hasMDX).toBe(false);
963 });
964
965 it("detects CodeHike dependency", () => {
966 mkdir(tmpDir, "app");
967 writeFile(tmpDir, "package.json", JSON.stringify({ dependencies: { codehike: "^1.0.0" } }));
968 const info = detectProject(tmpDir);
969 expect(info.hasCodeHike).toBe(true);
970 });
971
972 it("hasCodeHike is false when not a dependency", () => {
973 mkdir(tmpDir, "app");
974 writeFile(tmpDir, "package.json", JSON.stringify({ dependencies: { react: "^19.0.0" } }));
975 const info = detectProject(tmpDir);
976 expect(info.hasCodeHike).toBe(false);
977 });
978
979 it("detects native modules to stub", () => {
980 mkdir(tmpDir, "app");
981 writeFile(
982 tmpDir,
983 "package.json",
984 JSON.stringify({ dependencies: { "@resvg/resvg-js": "^2.0.0", satori: "^0.10.0" } }),
985 );
986 const info = detectProject(tmpDir);
987 expect(info.nativeModulesToStub).toContain("@resvg/resvg-js");
988 expect(info.nativeModulesToStub).toContain("satori");
989 });
990
991 it("nativeModulesToStub is empty when no native deps", () => {
992 mkdir(tmpDir, "app");
993 writeFile(tmpDir, "package.json", JSON.stringify({ dependencies: { react: "^19.0.0" } }));
994 const info = detectProject(tmpDir);
995 expect(info.nativeModulesToStub).toEqual([]);
996 });
997});
998
999// ─── Generated Vite config with new features ────────────────────────────────
1000
1001describe("generateAppRouterViteConfig — with project info", () => {
1002 it("delegates MDX to vinext plugin auto-injection (no separate mdx() call)", () => {
1003 mkdir(tmpDir, "app");
1004 writeFile(tmpDir, "app/about/page.mdx", "# About");
1005 const info = detectProject(tmpDir);
1006 const config = generateAppRouterViteConfig(info);
1007 // MDX is now handled by the vinext plugin's auto-injection at runtime,
1008 // not by a separate mdx() call in the generated config.
1009 expect(config).toContain("vinext()");
1010 expect(config).toContain("auto-injects @mdx-js/rollup");
1011 expect(config).not.toContain('import mdx from "@mdx-js/rollup"');
1012 });
1013
1014 it("does not include CodeHike plugins in generated config (handled by vinext plugin)", () => {
1015 mkdir(tmpDir, "app");
1016 writeFile(tmpDir, "app/about/page.mdx", "# About");
1017 writeFile(tmpDir, "package.json", JSON.stringify({ dependencies: { codehike: "^1.0.0" } }));
1018 const info = detectProject(tmpDir);
1019 const config = generateAppRouterViteConfig(info);
1020 // CodeHike plugins are extracted from next.config at runtime by the vinext plugin
1021 expect(config).not.toContain("remarkCodeHike");
1022 expect(config).not.toContain("recmaCodeHike");
1023 expect(config).toContain("vinext()");
1024 });
1025
1026 it("does not include tsconfig aliases in generated config (handled by plugin at runtime)", () => {
1027 mkdir(tmpDir, "app");
1028 writeFile(
1029 tmpDir,
1030 "tsconfig.json",
1031 JSON.stringify({
1032 compilerOptions: { baseUrl: ".", paths: { "#/*": ["./*"] } },
1033 }),
1034 );
1035 const info = detectProject(tmpDir);
1036 const config = generateAppRouterViteConfig(info);
1037 expect(config).not.toContain('"#"');
1038 });
1039
1040 it("includes native module stubs in resolve.alias", () => {
1041 mkdir(tmpDir, "app");
1042 writeFile(
1043 tmpDir,
1044 "package.json",
1045 JSON.stringify({ dependencies: { "@resvg/resvg-js": "^2.0.0" } }),
1046 );
1047 const info = detectProject(tmpDir);
1048 const config = generateAppRouterViteConfig(info);
1049 expect(config).toContain("@resvg/resvg-js");
1050 expect(config).toContain("empty-stub.js");
1051 });
1052
1053 it("still works without info (backward compatible)", () => {
1054 const config = generateAppRouterViteConfig();
1055 expect(config).toContain("vinext()");
1056 expect(config).toContain("cloudflare(");
1057 // Generated config no longer includes a separate mdx() import/call
1058 expect(config).not.toContain('import mdx from "@mdx-js/rollup"');
1059 expect(config).not.toContain("resolve:");
1060 });
1061});
1062
1063describe("generatePagesRouterViteConfig — with project info", () => {
1064 it("does not include tsconfig aliases in generated config (handled by plugin at runtime)", () => {
1065 mkdir(tmpDir, "pages");
1066 writeFile(
1067 tmpDir,
1068 "tsconfig.json",
1069 JSON.stringify({
1070 compilerOptions: { baseUrl: ".", paths: { "@/*": ["./src/*"] } },
1071 }),
1072 );
1073 const info = detectProject(tmpDir);
1074 const config = generatePagesRouterViteConfig(info);
1075 expect(config).not.toContain('"@"');
1076 });
1077
1078 it("still works without info (backward compatible)", () => {
1079 const config = generatePagesRouterViteConfig();
1080 expect(config).toContain("vinext()");
1081 expect(config).toContain("cloudflare()");
1082 expect(config).not.toContain("resolve:");
1083 });
1084});
1085
1086// ─── getMissingDeps with MDX ─────────────────────────────────────────────────
1087
1088describe("getMissingDeps — MDX", () => {
1089 it("reports @mdx-js/rollup when MDX detected but not installed", () => {
1090 mkdir(tmpDir, "app");
1091 writeFile(tmpDir, "app/about/page.mdx", "# About");
1092 const info = detectProject(tmpDir);
1093 info.hasCloudflarePlugin = true;
1094 info.hasWrangler = true;
1095 info.hasRscPlugin = true;
1096 const missing = getMissingDeps(info);
1097 expect(missing).toContainEqual(expect.objectContaining({ name: "@mdx-js/rollup" }));
1098 });
1099});
1100
1101// ─── Integration: Full Detection of Real Fixtures ────────────────────────────
1102
1103describe("detectProject on real fixtures", () => {
1104 const fixturesDir = path.resolve(import.meta.dirname, "../fixtures");
1105
1106 it("detects app-router-cloudflare fixture correctly", () => {
1107 const cfApp = path.join(fixturesDir, "app-router-cloudflare");
1108 if (!fs.existsSync(cfApp)) return; // skip if not available
1109
1110 const info = detectProject(cfApp);
1111 expect(info.isAppRouter).toBe(true);
1112 expect(info.hasViteConfig).toBe(true);
1113 expect(info.hasWranglerConfig).toBe(true);
1114 expect(info.hasWorkerEntry).toBe(true);
1115 });
1116
1117 it("detects pages-router-cloudflare fixture correctly", () => {
1118 const cfPages = path.join(fixturesDir, "pages-router-cloudflare");
1119 if (!fs.existsSync(cfPages)) return; // skip if not available
1120
1121 const info = detectProject(cfPages);
1122 expect(info.isPagesRouter).toBe(true);
1123 expect(info.hasViteConfig).toBe(true);
1124 expect(info.hasWranglerConfig).toBe(true);
1125 expect(info.hasWorkerEntry).toBe(true);
1126 });
1127
1128 it("generates zero files for fully-configured app-router-cloudflare", () => {
1129 const cfApp = path.join(fixturesDir, "app-router-cloudflare");
1130 if (!fs.existsSync(cfApp)) return;
1131
1132 const info = detectProject(cfApp);
1133 const files = getFilesToGenerate(info);
1134 expect(files).toHaveLength(0);
1135 });
1136
1137 it("generates zero files for fully-configured pages-router-cloudflare", () => {
1138 const cfPages = path.join(fixturesDir, "pages-router-cloudflare");
1139 if (!fs.existsSync(cfPages)) return;
1140
1141 const info = detectProject(cfPages);
1142 const files = getFilesToGenerate(info);
1143 expect(files).toHaveLength(0);
1144 });
1145
1146 it("would report missing deps for non-cloudflare fixture", () => {
1147 const pagesBasic = path.join(fixturesDir, "pages-basic");
1148 if (!fs.existsSync(pagesBasic)) return;
1149
1150 const info = detectProject(pagesBasic);
1151 // pages-basic doesn't have @cloudflare/vite-plugin or wrangler in its own node_modules
1152 // (it uses the hoisted root node_modules), but the check is per-project
1153 // The important thing: getMissingDeps respects the detected flags
1154 info.hasCloudflarePlugin = false;
1155 info.hasWrangler = false;
1156 const missing = getMissingDeps(info);
1157 expect(missing.length).toBeGreaterThan(0);
1158 });
1159});
1160
1161// ─── Cloudflare _headers generation ─────────────────────────────────────────
1162// These tests exercise the same logic used by the vinext:cloudflare-build
1163// plugin's closeBundle hook to generate a _headers file for static asset
1164// caching on Cloudflare Workers.
1165
1166describe("Cloudflare _headers file generation", () => {
1167 /** Replicates the _headers generation logic from the closeBundle hook. */
1168 function generateHeaders(clientDir: string, assetsDir = "assets"): void {
1169 const headersPath = path.join(clientDir, "_headers");
1170 if (!fs.existsSync(headersPath)) {
1171 const headersContent = [
1172 "# Cache content-hashed assets immutably (generated by vinext)",
1173 `/${assetsDir}/*`,
1174 " Cache-Control: public, max-age=31536000, immutable",
1175 "",
1176 ].join("\n");
1177 fs.writeFileSync(headersPath, headersContent);
1178 }
1179 }
1180
1181 it("generates _headers with correct Cloudflare format", () => {
1182 const clientDir = path.join(tmpDir, "dist", "client");
1183 fs.mkdirSync(clientDir, { recursive: true });
1184
1185 generateHeaders(clientDir);
1186
1187 const content = fs.readFileSync(path.join(clientDir, "_headers"), "utf-8");
1188 expect(content).toContain("/assets/*");
1189 expect(content).toContain("Cache-Control: public, max-age=31536000, immutable");
1190 // Verify Cloudflare _headers format: path on its own line, indented header below
1191 const lines = content.split("\n");
1192 const pathLine = lines.findIndex((l) => l === "/assets/*");
1193 expect(pathLine).toBeGreaterThanOrEqual(0);
1194 expect(lines[pathLine + 1]).toBe(" Cache-Control: public, max-age=31536000, immutable");
1195 });
1196
1197 it("skips generation when _headers already exists", () => {
1198 const clientDir = path.join(tmpDir, "dist", "client");
1199 fs.mkdirSync(clientDir, { recursive: true });
1200
1201 const userContent = "/custom/*\n X-Custom: true\n";
1202 fs.writeFileSync(path.join(clientDir, "_headers"), userContent);
1203
1204 generateHeaders(clientDir);
1205
1206 const content = fs.readFileSync(path.join(clientDir, "_headers"), "utf-8");
1207 expect(content).toBe(userContent);
1208 expect(content).not.toContain("/assets/*");
1209 });
1210
1211 it("respects custom assetsDir", () => {
1212 const clientDir = path.join(tmpDir, "dist", "client");
1213 fs.mkdirSync(clientDir, { recursive: true });
1214
1215 generateHeaders(clientDir, "static");
1216
1217 const content = fs.readFileSync(path.join(clientDir, "_headers"), "utf-8");
1218 expect(content).toContain("/static/*");
1219 expect(content).not.toContain("/assets/*");
1220 });
1221
1222 it("ends with a trailing newline", () => {
1223 const clientDir = path.join(tmpDir, "dist", "client");
1224 fs.mkdirSync(clientDir, { recursive: true });
1225
1226 generateHeaders(clientDir);
1227
1228 const content = fs.readFileSync(path.join(clientDir, "_headers"), "utf-8");
1229 expect(content.endsWith("\n")).toBe(true);
1230 });
1231});
1232
1233// ─── Cloudflare closeBundle: lazy chunk injection ────────────────────────────
1234// These tests verify that the vinext:cloudflare-build closeBundle hook correctly
1235// injects __VINEXT_LAZY_CHUNKS__ and other globals into the worker entry for
1236// BOTH App Router and Pages Router builds. This was regressed by PR #358 which
1237// added an early return for App Router builds, skipping lazy chunk injection.
1238
1239describe("Cloudflare closeBundle lazy chunk injection", () => {
1240 /**
1241 * Replicates the closeBundle hook logic for App Router builds.
1242 * In #358's architecture, the RSC env IS the worker, so the worker entry
1243 * is at dist/server/index.js. The RSC plugin handles __VINEXT_CLIENT_ENTRY__,
1244 * but we still need to inject __VINEXT_LAZY_CHUNKS__ and __VINEXT_SSR_MANIFEST__.
1245 */
1246 function simulateCloseBundleAppRouter(buildRoot: string): void {
1247 const distDir = path.resolve(buildRoot, "dist");
1248 if (!fs.existsSync(distDir)) return;
1249
1250 const clientDir = path.resolve(buildRoot, "dist", "client");
1251
1252 // Read build manifest and compute lazy chunks
1253 let lazyChunksData: string[] | null = null;
1254 const buildManifestPath = path.join(clientDir, ".vite", "manifest.json");
1255 if (fs.existsSync(buildManifestPath)) {
1256 try {
1257 const buildManifest = JSON.parse(fs.readFileSync(buildManifestPath, "utf-8"));
1258 const lazy = computeLazyChunks(buildManifest);
1259 if (lazy.length > 0) lazyChunksData = lazy;
1260 } catch { /* ignore */ }
1261 }
1262
1263 // Read SSR manifest
1264 let ssrManifestData: Record<string, string[]> | null = null;
1265 const ssrManifestPath = path.join(clientDir, ".vite", "ssr-manifest.json");
1266 if (fs.existsSync(ssrManifestPath)) {
1267 try {
1268 ssrManifestData = JSON.parse(fs.readFileSync(ssrManifestPath, "utf-8"));
1269 } catch { /* ignore */ }
1270 }
1271
1272 // App Router: inject into dist/server/index.js (NOT __VINEXT_CLIENT_ENTRY__)
1273 const workerEntry = path.resolve(distDir, "server", "index.js");
1274 if (fs.existsSync(workerEntry) && (lazyChunksData || ssrManifestData)) {
1275 let code = fs.readFileSync(workerEntry, "utf-8");
1276 const globals: string[] = [];
1277 if (ssrManifestData) {
1278 globals.push(`globalThis.__VINEXT_SSR_MANIFEST__ = ${JSON.stringify(ssrManifestData)};`);
1279 }
1280 if (lazyChunksData) {
1281 globals.push(`globalThis.__VINEXT_LAZY_CHUNKS__ = ${JSON.stringify(lazyChunksData)};`);
1282 }
1283 code = globals.join("\n") + "\n" + code;
1284 fs.writeFileSync(workerEntry, code);
1285 }
1286 }
1287
1288 /**
1289 * Replicates the closeBundle hook logic for Pages Router builds.
1290 * The worker entry is found by scanning dist/ for a directory containing
1291 * wrangler.json. All three globals are injected.
1292 */
1293 function simulateCloseBundlePagesRouter(buildRoot: string): void {
1294 const distDir = path.resolve(buildRoot, "dist");
1295 if (!fs.existsSync(distDir)) return;
1296
1297 const clientDir = path.resolve(buildRoot, "dist", "client");
1298
1299 // Find worker output directory (contains wrangler.json)
1300 let workerOutDir: string | null = null;
1301 for (const entry of fs.readdirSync(distDir)) {
1302 const candidate = path.join(distDir, entry);
1303 if (entry === "client") continue;
1304 if (fs.statSync(candidate).isDirectory() &&
1305 fs.existsSync(path.join(candidate, "wrangler.json"))) {
1306 workerOutDir = candidate;
1307 break;
1308 }
1309 }
1310 if (!workerOutDir) return;
1311
1312 const workerEntry = path.join(workerOutDir, "index.js");
1313 if (!fs.existsSync(workerEntry)) return;
1314
1315 // Read build manifest and compute lazy chunks
1316 let lazyChunksData: string[] | null = null;
1317 let clientEntryFile: string | null = null;
1318 const buildManifestPath = path.join(clientDir, ".vite", "manifest.json");
1319 if (fs.existsSync(buildManifestPath)) {
1320 try {
1321 const buildManifest = JSON.parse(fs.readFileSync(buildManifestPath, "utf-8"));
1322 for (const [, value] of Object.entries(buildManifest) as [string, any][]) {
1323 if (value && value.isEntry && value.file) {
1324 clientEntryFile = value.file;
1325 break;
1326 }
1327 }
1328 const lazy = computeLazyChunks(buildManifest);
1329 if (lazy.length > 0) lazyChunksData = lazy;
1330 } catch { /* ignore */ }
1331 }
1332
1333 // Read SSR manifest
1334 let ssrManifestData: Record<string, string[]> | null = null;
1335 const ssrManifestPath = path.join(clientDir, ".vite", "ssr-manifest.json");
1336 if (fs.existsSync(ssrManifestPath)) {
1337 try {
1338 ssrManifestData = JSON.parse(fs.readFileSync(ssrManifestPath, "utf-8"));
1339 } catch { /* ignore */ }
1340 }
1341
1342 // Pages Router: inject all three globals
1343 if (clientEntryFile || ssrManifestData || lazyChunksData) {
1344 let code = fs.readFileSync(workerEntry, "utf-8");
1345 const globals: string[] = [];
1346 if (clientEntryFile) {
1347 globals.push(`globalThis.__VINEXT_CLIENT_ENTRY__ = ${JSON.stringify(clientEntryFile)};`);
1348 }
1349 if (ssrManifestData) {
1350 globals.push(`globalThis.__VINEXT_SSR_MANIFEST__ = ${JSON.stringify(ssrManifestData)};`);
1351 }
1352 if (lazyChunksData) {
1353 globals.push(`globalThis.__VINEXT_LAZY_CHUNKS__ = ${JSON.stringify(lazyChunksData)};`);
1354 }
1355 code = globals.join("\n") + "\n" + code;
1356 fs.writeFileSync(workerEntry, code);
1357 }
1358 }
1359
1360 /** Sets up a mock App Router build output directory structure. */
1361 function setupAppRouterBuildOutput(
1362 root: string,
1363 manifest: Record<string, any>,
1364 ssrManifest?: Record<string, string[]>,
1365 ): void {
1366 // dist/server/index.js — the RSC worker entry
1367 fs.mkdirSync(path.join(root, "dist", "server"), { recursive: true });
1368 fs.writeFileSync(
1369 path.join(root, "dist", "server", "index.js"),
1370 "// RSC worker entry\nexport default { fetch() {} };",
1371 );
1372
1373 // dist/client/.vite/manifest.json
1374 fs.mkdirSync(path.join(root, "dist", "client", ".vite"), { recursive: true });
1375 fs.writeFileSync(
1376 path.join(root, "dist", "client", ".vite", "manifest.json"),
1377 JSON.stringify(manifest),
1378 );
1379
1380 // dist/client/.vite/ssr-manifest.json (optional)
1381 if (ssrManifest) {
1382 fs.writeFileSync(
1383 path.join(root, "dist", "client", ".vite", "ssr-manifest.json"),
1384 JSON.stringify(ssrManifest),
1385 );
1386 }
1387 }
1388
1389 /** Sets up a mock Pages Router build output directory structure. */
1390 function setupPagesRouterBuildOutput(
1391 root: string,
1392 manifest: Record<string, any>,
1393 ssrManifest?: Record<string, string[]>,
1394 ): void {
1395 // dist/worker/ with wrangler.json and index.js
1396 const workerDir = path.join(root, "dist", "worker");
1397 fs.mkdirSync(workerDir, { recursive: true });
1398 fs.writeFileSync(path.join(workerDir, "wrangler.json"), "{}");
1399 fs.writeFileSync(
1400 path.join(workerDir, "index.js"),
1401 "// Pages Router worker entry\nexport default { fetch() {} };",
1402 );
1403
1404 // dist/client/.vite/manifest.json
1405 fs.mkdirSync(path.join(root, "dist", "client", ".vite"), { recursive: true });
1406 fs.writeFileSync(
1407 path.join(root, "dist", "client", ".vite", "manifest.json"),
1408 JSON.stringify(manifest),
1409 );
1410
1411 // dist/client/.vite/ssr-manifest.json (optional)
1412 if (ssrManifest) {
1413 fs.writeFileSync(
1414 path.join(root, "dist", "client", ".vite", "ssr-manifest.json"),
1415 JSON.stringify(ssrManifest),
1416 );
1417 }
1418 }
1419
1420 // A realistic manifest with both eager and lazy chunks
1421 const manifestWithLazyChunks = {
1422 "virtual:vinext-app-browser-entry": {
1423 file: "assets/app-entry.js",
1424 isEntry: true,
1425 imports: ["node_modules/react/index.js"],
1426 dynamicImports: ["src/components/MermaidChart.tsx"],
1427 },
1428 "node_modules/react/index.js": {
1429 file: "assets/framework.js",
1430 },
1431 "src/components/MermaidChart.tsx": {
1432 file: "assets/mermaid-chart.js",
1433 isDynamicEntry: true,
1434 imports: ["node_modules/mermaid/dist/mermaid.js"],
1435 },
1436 "node_modules/mermaid/dist/mermaid.js": {
1437 file: "assets/mermaid-vendor.js",
1438 },
1439 };
1440
1441 // ── App Router tests ──────────────────────────────────────────────────
1442
1443 it("App Router: injects __VINEXT_LAZY_CHUNKS__ into dist/server/index.js", () => {
1444 setupAppRouterBuildOutput(tmpDir, manifestWithLazyChunks);
1445
1446 simulateCloseBundleAppRouter(tmpDir);
1447
1448 const code = fs.readFileSync(path.join(tmpDir, "dist", "server", "index.js"), "utf-8");
1449 expect(code).toContain("globalThis.__VINEXT_LAZY_CHUNKS__");
1450
1451 // Verify the lazy chunks are correct (mermaid-chart and mermaid-vendor are lazy)
1452 const match = code.match(/globalThis\.__VINEXT_LAZY_CHUNKS__\s*=\s*(\[.*?\]);/);
1453 expect(match).not.toBeNull();
1454 const lazyChunks = JSON.parse(match![1]);
1455 expect(lazyChunks).toContain("assets/mermaid-chart.js");
1456 expect(lazyChunks).toContain("assets/mermaid-vendor.js");
1457 // Eager chunks should NOT be in the lazy list
1458 expect(lazyChunks).not.toContain("assets/app-entry.js");
1459 expect(lazyChunks).not.toContain("assets/framework.js");
1460 });
1461
1462 it("App Router: does NOT inject __VINEXT_CLIENT_ENTRY__", () => {
1463 setupAppRouterBuildOutput(tmpDir, manifestWithLazyChunks);
1464
1465 simulateCloseBundleAppRouter(tmpDir);
1466
1467 const code = fs.readFileSync(path.join(tmpDir, "dist", "server", "index.js"), "utf-8");
1468 // RSC plugin handles client entry via loadBootstrapScriptContent()
1469 expect(code).not.toContain("__VINEXT_CLIENT_ENTRY__");
1470 });
1471
1472 it("App Router: injects __VINEXT_SSR_MANIFEST__ when present", () => {
1473 const ssrManifest = {
1474 "src/app/page.tsx": ["/assets/page.js", "/assets/page.css"],
1475 };
1476 setupAppRouterBuildOutput(tmpDir, manifestWithLazyChunks, ssrManifest);
1477
1478 simulateCloseBundleAppRouter(tmpDir);
1479
1480 const code = fs.readFileSync(path.join(tmpDir, "dist", "server", "index.js"), "utf-8");
1481 expect(code).toContain("globalThis.__VINEXT_SSR_MANIFEST__");
1482 expect(code).toContain("src/app/page.tsx");
1483 });
1484
1485 it("App Router: preserves original worker entry code after injection", () => {
1486 setupAppRouterBuildOutput(tmpDir, manifestWithLazyChunks);
1487
1488 simulateCloseBundleAppRouter(tmpDir);
1489
1490 const code = fs.readFileSync(path.join(tmpDir, "dist", "server", "index.js"), "utf-8");
1491 // Original code should still be present after the injected globals
1492 expect(code).toContain("// RSC worker entry");
1493 expect(code).toContain("export default { fetch() {} };");
1494 });
1495
1496 it("App Router: skips injection when no lazy chunks and no SSR manifest", () => {
1497 // Manifest with only eager (statically imported) chunks
1498 const eagerOnlyManifest = {
1499 "src/entry.ts": {
1500 file: "assets/entry.js",
1501 isEntry: true,
1502 imports: ["src/utils.ts"],
1503 },
1504 "src/utils.ts": {
1505 file: "assets/utils.js",
1506 },
1507 };
1508 setupAppRouterBuildOutput(tmpDir, eagerOnlyManifest);
1509
1510 simulateCloseBundleAppRouter(tmpDir);
1511
1512 const code = fs.readFileSync(path.join(tmpDir, "dist", "server", "index.js"), "utf-8");
1513 // No globals should be injected since there are no lazy chunks and no SSR manifest
1514 expect(code).not.toContain("globalThis.__VINEXT_LAZY_CHUNKS__");
1515 expect(code).not.toContain("globalThis.__VINEXT_SSR_MANIFEST__");
1516 // Original code untouched
1517 expect(code).toBe("// RSC worker entry\nexport default { fetch() {} };");
1518 });
1519
1520 it("App Router: handles missing dist/server/index.js gracefully", () => {
1521 // Only set up client manifest, no server output
1522 fs.mkdirSync(path.join(tmpDir, "dist", "client", ".vite"), { recursive: true });
1523 fs.writeFileSync(
1524 path.join(tmpDir, "dist", "client", ".vite", "manifest.json"),
1525 JSON.stringify(manifestWithLazyChunks),
1526 );
1527
1528 // Should not throw
1529 expect(() => simulateCloseBundleAppRouter(tmpDir)).not.toThrow();
1530 });
1531
1532 // ── Pages Router tests ────────────────────────────────────────────────
1533
1534 it("Pages Router: injects all three globals into worker entry", () => {
1535 const ssrManifest = {
1536 "pages/index.tsx": ["/assets/page-index.js", "/assets/page-index.css"],
1537 };
1538 setupPagesRouterBuildOutput(tmpDir, manifestWithLazyChunks, ssrManifest);
1539
1540 simulateCloseBundlePagesRouter(tmpDir);
1541
1542 const code = fs.readFileSync(path.join(tmpDir, "dist", "worker", "index.js"), "utf-8");
1543 expect(code).toContain("globalThis.__VINEXT_CLIENT_ENTRY__");
1544 expect(code).toContain("globalThis.__VINEXT_SSR_MANIFEST__");
1545 expect(code).toContain("globalThis.__VINEXT_LAZY_CHUNKS__");
1546 });
1547
1548 it("Pages Router: injects correct lazy chunks", () => {
1549 setupPagesRouterBuildOutput(tmpDir, manifestWithLazyChunks);
1550
1551 simulateCloseBundlePagesRouter(tmpDir);
1552
1553 const code = fs.readFileSync(path.join(tmpDir, "dist", "worker", "index.js"), "utf-8");
1554 const match = code.match(/globalThis\.__VINEXT_LAZY_CHUNKS__\s*=\s*(\[.*?\]);/);
1555 expect(match).not.toBeNull();
1556 const lazyChunks = JSON.parse(match![1]);
1557 expect(lazyChunks).toContain("assets/mermaid-chart.js");
1558 expect(lazyChunks).toContain("assets/mermaid-vendor.js");
1559 expect(lazyChunks).not.toContain("assets/app-entry.js");
1560 expect(lazyChunks).not.toContain("assets/framework.js");
1561 });
1562
1563 it("Pages Router: finds worker entry via wrangler.json directory scan", () => {
1564 setupPagesRouterBuildOutput(tmpDir, manifestWithLazyChunks);
1565
1566 simulateCloseBundlePagesRouter(tmpDir);
1567
1568 // Worker entry should have been modified
1569 const code = fs.readFileSync(path.join(tmpDir, "dist", "worker", "index.js"), "utf-8");
1570 expect(code).toContain("globalThis.");
1571 expect(code).toContain("// Pages Router worker entry");
1572 });
1573
1574 it("Pages Router: skips client dir when no wrangler.json found", () => {
1575 // Set up worker dir without wrangler.json
1576 const workerDir = path.join(tmpDir, "dist", "worker");
1577 fs.mkdirSync(workerDir, { recursive: true });
1578 fs.writeFileSync(
1579 path.join(workerDir, "index.js"),
1580 "// unmodified",
1581 );
1582 fs.mkdirSync(path.join(tmpDir, "dist", "client", ".vite"), { recursive: true });
1583 fs.writeFileSync(
1584 path.join(tmpDir, "dist", "client", ".vite", "manifest.json"),
1585 JSON.stringify(manifestWithLazyChunks),
1586 );
1587
1588 simulateCloseBundlePagesRouter(tmpDir);
1589
1590 // Worker entry should NOT have been modified (no wrangler.json found)
1591 const code = fs.readFileSync(path.join(workerDir, "index.js"), "utf-8");
1592 expect(code).toBe("// unmodified");
1593 });
1594
1595 // ── Shared behavior tests ─────────────────────────────────────────────
1596
1597 it("both routers: computeLazyChunks correctly identifies dynamic-only chunks", () => {
1598 const lazy = computeLazyChunks(manifestWithLazyChunks);
1599 // mermaid-chart.js and mermaid-vendor.js are only reachable via dynamicImports
1600 expect(lazy).toContain("assets/mermaid-chart.js");
1601 expect(lazy).toContain("assets/mermaid-vendor.js");
1602 // app-entry.js (entry) and framework.js (static import) are eager
1603 expect(lazy).not.toContain("assets/app-entry.js");
1604 expect(lazy).not.toContain("assets/framework.js");
1605 });
1606
1607 it("both routers: mermaid-like deep dynamic chains are fully lazy", () => {
1608 // Simulates a real-world case: mermaid imports d3 which imports d3-selection etc.
1609 const deepDynamicManifest = {
1610 "virtual:vinext-app-browser-entry": {
1611 file: "assets/entry.js",
1612 isEntry: true,
1613 imports: ["node_modules/react/index.js"],
1614 dynamicImports: ["src/components/Chart.tsx"],
1615 },
1616 "node_modules/react/index.js": {
1617 file: "assets/framework.js",
1618 },
1619 "src/components/Chart.tsx": {
1620 file: "assets/chart.js",
1621 isDynamicEntry: true,
1622 imports: ["node_modules/mermaid/dist/mermaid.js"],
1623 },
1624 "node_modules/mermaid/dist/mermaid.js": {
1625 file: "assets/mermaid.js",
1626 imports: ["node_modules/d3/src/index.js"],
1627 },
1628 "node_modules/d3/src/index.js": {
1629 file: "assets/d3.js",
1630 imports: ["node_modules/d3-selection/src/index.js"],
1631 },
1632 "node_modules/d3-selection/src/index.js": {
1633 file: "assets/d3-selection.js",
1634 },
1635 };
1636
1637 const lazy = computeLazyChunks(deepDynamicManifest);
1638 // All chunks behind the dynamic boundary should be lazy
1639 expect(lazy).toContain("assets/chart.js");
1640 expect(lazy).toContain("assets/mermaid.js");
1641 expect(lazy).toContain("assets/d3.js");
1642 expect(lazy).toContain("assets/d3-selection.js");
1643 // Entry and framework are eager
1644 expect(lazy).not.toContain("assets/entry.js");
1645 expect(lazy).not.toContain("assets/framework.js");
1646 });
1647});
1648