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/check.test.ts

818lines · 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 scanImports,
7 analyzeConfig,
8 checkLibraries,
9 checkConventions,
10 runCheck,
11 formatReport,
12 type CheckResult,
13} from "../packages/vinext/src/check.js";
14
15// ── Helpers ────────────────────────────────────────────────────────────────
16
17let tmpDir: string;
18
19beforeEach(() => {
20 tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-check-"));
21});
22
23afterEach(() => {
24 fs.rmSync(tmpDir, { recursive: true, force: true });
25});
26
27function writeFile(relPath: string, content: string) {
28 const fullPath = path.join(tmpDir, relPath);
29 fs.mkdirSync(path.dirname(fullPath), { recursive: true });
30 fs.writeFileSync(fullPath, content);
31}
32
33// ── scanImports ────────────────────────────────────────────────────────────
34
35describe("scanImports", () => {
36 it("detects supported next/* imports", () => {
37 writeFile("app/page.tsx", `
38 import Link from "next/link";
39 import Image from "next/image";
40 `);
41
42 const items = scanImports(tmpDir);
43 expect(items).toHaveLength(2);
44 expect(items.find((i) => i.name === "next/link")?.status).toBe("supported");
45 expect(items.find((i) => i.name === "next/image")?.status).toBe("supported");
46 });
47
48 it("detects partial imports", () => {
49 writeFile("app/page.tsx", `import { GoogleFont } from "next/font/google";`);
50
51 const items = scanImports(tmpDir);
52 expect(items).toHaveLength(1);
53 expect(items[0].name).toBe("next/font/google");
54 expect(items[0].status).toBe("partial");
55 });
56
57 it("reports accurate next/font/local detail", () => {
58 writeFile("app/page.tsx", `import localFont from "next/font/local";`);
59
60 const items = scanImports(tmpDir);
61 expect(items).toHaveLength(1);
62 expect(items[0].name).toBe("next/font/local");
63 expect(items[0].status).toBe("partial");
64 expect(items[0].detail).toContain("font.className works");
65 expect(items[0].detail).toContain("font.variable mode broken");
66 });
67
68 it("detects unsupported imports", () => {
69 writeFile("pages/amp.tsx", `import { useAmp } from "next/amp";`);
70
71 const items = scanImports(tmpDir);
72 expect(items).toHaveLength(1);
73 expect(items[0].name).toBe("next/amp");
74 expect(items[0].status).toBe("unsupported");
75 });
76
77 it("detects server-only and client-only", () => {
78 writeFile("lib/db.ts", `import "server-only";`);
79 writeFile("components/button.tsx", `import "client-only";`);
80
81 const items = scanImports(tmpDir);
82 expect(items).toHaveLength(2);
83 expect(items.find((i) => i.name === "server-only")?.status).toBe("supported");
84 expect(items.find((i) => i.name === "client-only")?.status).toBe("supported");
85 });
86
87 it("tracks which files use each import", () => {
88 writeFile("app/page.tsx", `import Link from "next/link";`);
89 writeFile("app/about/page.tsx", `import Link from "next/link";`);
90
91 const items = scanImports(tmpDir);
92 const linkItem = items.find((i) => i.name === "next/link");
93 expect(linkItem?.files).toHaveLength(2);
94 expect(linkItem?.files).toContain("app/page.tsx");
95 expect(linkItem?.files).toContain("app/about/page.tsx");
96 });
97
98 it("detects require() calls too", () => {
99 writeFile("lib/util.js", `const router = require("next/router");`);
100
101 const items = scanImports(tmpDir);
102 expect(items).toHaveLength(1);
103 expect(items[0].name).toBe("next/router");
104 expect(items[0].status).toBe("supported");
105 });
106
107 it("returns empty for projects with no next imports", () => {
108 writeFile("src/index.ts", `import React from "react";`);
109
110 const items = scanImports(tmpDir);
111 expect(items).toHaveLength(0);
112 });
113
114 it("marks unrecognized next/* imports as unsupported", () => {
115 writeFile("app/page.tsx", `import foo from "next/nonexistent";`);
116
117 const items = scanImports(tmpDir);
118 expect(items).toHaveLength(1);
119 expect(items[0].status).toBe("unsupported");
120 expect(items[0].detail).toContain("not recognized");
121 });
122
123 it("recognizes `import { Metadata } from 'next'` as supported", () => {
124 writeFile("app/layout.tsx", `import { Metadata } from "next";\nexport const metadata: Metadata = { title: "App" };`);
125
126 const items = scanImports(tmpDir);
127 expect(items).toHaveLength(1);
128 expect(items[0].name).toBe("next");
129 expect(items[0].status).toBe("supported");
130 });
131
132 it("skips `import type` statements entirely", () => {
133 writeFile("app/page.tsx", `import type { Metadata } from "next";\nimport Link from "next/link";`);
134
135 const items = scanImports(tmpDir);
136 // Should only find next/link, not next (since import type is skipped)
137 expect(items).toHaveLength(1);
138 expect(items[0].name).toBe("next/link");
139 });
140
141 it("skips `import type` for next/* paths too", () => {
142 writeFile("app/page.tsx", `import type { NextRequest } from "next/server";`);
143
144 const items = scanImports(tmpDir);
145 expect(items).toHaveLength(0);
146 });
147
148 it("sorts unsupported first, then partial, then supported", () => {
149 writeFile("app/page.tsx", `
150 import Link from "next/link";
151 import { GoogleFont } from "next/font/google";
152 import { useAmp } from "next/amp";
153 `);
154
155 const items = scanImports(tmpDir);
156 expect(items[0].status).toBe("unsupported");
157 expect(items[1].status).toBe("partial");
158 expect(items[2].status).toBe("supported");
159 });
160
161 it("ignores node_modules and .next directories", () => {
162 writeFile("node_modules/foo/index.ts", `import Link from "next/link";`);
163 writeFile(".next/server.js", `import Link from "next/link";`);
164 writeFile("app/page.tsx", `import Image from "next/image";`);
165
166 const items = scanImports(tmpDir);
167 expect(items).toHaveLength(1);
168 expect(items[0].name).toBe("next/image");
169 });
170
171 it("deduplicates files using the same import", () => {
172 writeFile("app/page.tsx", `
173 import Link from "next/link";
174 import Link from "next/link";
175 `);
176
177 const items = scanImports(tmpDir);
178 const linkItem = items.find((i) => i.name === "next/link");
179 expect(linkItem?.files).toHaveLength(1);
180 });
181});
182
183// ── analyzeConfig ──────────────────────────────────────────────────────────
184
185describe("analyzeConfig", () => {
186 it("reports 'no config file' when none exists", () => {
187 const items = analyzeConfig(tmpDir);
188 expect(items).toHaveLength(1);
189 expect(items[0].name).toBe("next.config");
190 expect(items[0].status).toBe("supported");
191 });
192
193 it("detects supported config options", () => {
194 writeFile(
195 "next.config.mjs",
196 `export default {
197 basePath: "/docs",
198 trailingSlash: true,
199 reactStrictMode: true,
200 };`,
201 );
202
203 const items = analyzeConfig(tmpDir);
204 expect(items.find((i) => i.name === "basePath")?.status).toBe("supported");
205 expect(items.find((i) => i.name === "trailingSlash")?.status).toBe("supported");
206 expect(items.find((i) => i.name === "reactStrictMode")?.status).toBe("supported");
207 });
208
209 it("detects unsupported webpack config", () => {
210 writeFile(
211 "next.config.js",
212 `module.exports = {
213 webpack: (config) => { return config; },
214 };`,
215 );
216
217 const items = analyzeConfig(tmpDir);
218 const webpackItem = items.find((i) => i.name === "webpack");
219 expect(webpackItem?.status).toBe("unsupported");
220 expect(webpackItem?.detail).toContain("Vite replaces webpack");
221 });
222
223 it("detects partial image config", () => {
224 writeFile(
225 "next.config.mjs",
226 `export default {
227 images: { remotePatterns: [{ hostname: "*.example.com" }] },
228 };`,
229 );
230
231 const items = analyzeConfig(tmpDir);
232 expect(items.find((i) => i.name === "images")?.status).toBe("partial");
233 });
234
235 it("detects experimental.ppr as unsupported", () => {
236 writeFile(
237 "next.config.mjs",
238 `export default {
239 experimental: {
240 ppr: true,
241 },
242 };`,
243 );
244
245 const items = analyzeConfig(tmpDir);
246 expect(items.find((i) => i.name === "experimental.ppr")?.status).toBe("unsupported");
247 });
248
249 it("detects experimental.serverActions as supported", () => {
250 writeFile(
251 "next.config.mjs",
252 `export default {
253 experimental: {
254 serverActions: { allowedOrigins: ["my-domain.com"] },
255 },
256 };`,
257 );
258
259 const items = analyzeConfig(tmpDir);
260 expect(items.find((i) => i.name === "experimental.serverActions")?.status).toBe("supported");
261 });
262
263 it("detects i18n.domains as unsupported", () => {
264 writeFile(
265 "next.config.js",
266 `module.exports = {
267 i18n: {
268 locales: ["en", "fr"],
269 defaultLocale: "en",
270 domains: [{ domain: "example.fr", defaultLocale: "fr" }],
271 },
272 };`,
273 );
274
275 const items = analyzeConfig(tmpDir);
276 expect(items.find((i) => i.name === "i18n")?.status).toBe("supported");
277 expect(items.find((i) => i.name === "i18n.domains")?.status).toBe("unsupported");
278 });
279
280 it("reads next.config.ts files", () => {
281 writeFile(
282 "next.config.ts",
283 `const config = { basePath: "/app" }; export default config;`,
284 );
285
286 const items = analyzeConfig(tmpDir);
287 expect(items.find((i) => i.name === "basePath")?.status).toBe("supported");
288 });
289
290 it("sorts unsupported configs first", () => {
291 writeFile(
292 "next.config.mjs",
293 `export default {
294 basePath: "/app",
295 webpack: (config) => config,
296 images: { domains: [] },
297 };`,
298 );
299
300 const items = analyzeConfig(tmpDir);
301 expect(items[0].status).toBe("unsupported"); // webpack
302 expect(items[items.length - 1].status).toBe("supported"); // basePath
303 });
304});
305
306// ── checkLibraries ─────────────────────────────────────────────────────────
307
308describe("checkLibraries", () => {
309 it("returns empty when no package.json", () => {
310 const items = checkLibraries(tmpDir);
311 expect(items).toHaveLength(0);
312 });
313
314 it("returns empty when no known libraries are used", () => {
315 writeFile(
316 "package.json",
317 JSON.stringify({
318 dependencies: { react: "^19.0.0", "some-lib": "^1.0.0" },
319 }),
320 );
321
322 const items = checkLibraries(tmpDir);
323 expect(items).toHaveLength(0);
324 });
325
326 it("detects supported libraries", () => {
327 writeFile(
328 "package.json",
329 JSON.stringify({
330 dependencies: { "next-themes": "^0.3.0", tailwindcss: "^3.0.0", zod: "^3.0.0" },
331 }),
332 );
333
334 const items = checkLibraries(tmpDir);
335 expect(items).toHaveLength(3);
336 expect(items.every((i) => i.status === "supported")).toBe(true);
337 });
338
339 it("detects unsupported libraries", () => {
340 writeFile(
341 "package.json",
342 JSON.stringify({
343 dependencies: { "@clerk/nextjs": "^5.0.0", "next-auth": "^4.0.0" },
344 }),
345 );
346
347 const items = checkLibraries(tmpDir);
348 expect(items).toHaveLength(2);
349 expect(items.every((i) => i.status === "unsupported")).toBe(true);
350 });
351
352 it("detects partial libraries", () => {
353 writeFile(
354 "package.json",
355 JSON.stringify({
356 dependencies: { "styled-components": "^6.0.0" },
357 }),
358 );
359
360 const items = checkLibraries(tmpDir);
361 expect(items).toHaveLength(1);
362 expect(items[0].status).toBe("partial");
363 expect(items[0].detail).toContain("useServerInsertedHTML");
364 });
365
366 it("checks both dependencies and devDependencies", () => {
367 writeFile(
368 "package.json",
369 JSON.stringify({
370 dependencies: { tailwindcss: "^3.0.0" },
371 devDependencies: { prisma: "^5.0.0" },
372 }),
373 );
374
375 const items = checkLibraries(tmpDir);
376 expect(items).toHaveLength(2);
377 expect(items.find((i) => i.name === "tailwindcss")).toBeDefined();
378 expect(items.find((i) => i.name === "prisma")).toBeDefined();
379 });
380
381 it("sorts unsupported libraries first", () => {
382 writeFile(
383 "package.json",
384 JSON.stringify({
385 dependencies: {
386 tailwindcss: "^3.0.0",
387 "next-auth": "^4.0.0",
388 "@sentry/nextjs": "^7.0.0",
389 },
390 }),
391 );
392
393 const items = checkLibraries(tmpDir);
394 expect(items[0].status).toBe("unsupported");
395 expect(items[items.length - 1].status).toBe("supported");
396 });
397});
398
399// ── checkConventions ───────────────────────────────────────────────────────
400
401describe("checkConventions", () => {
402 it("detects pages directory", () => {
403 writeFile("pages/index.tsx", `export default function Home() { return <div/>; }`);
404
405 const items = checkConventions(tmpDir);
406 expect(items.find((i) => i.name === "Pages Router (pages/)")).toBeDefined();
407 expect(items.find((i) => i.name.includes("1 page"))?.status).toBe("supported");
408 });
409
410 it("detects app directory", () => {
411 writeFile("app/page.tsx", `export default function Home() { return <div/>; }`);
412 writeFile("app/layout.tsx", `export default function Layout({ children }) { return <html><body>{children}</body></html>; }`);
413
414 const items = checkConventions(tmpDir);
415 expect(items.find((i) => i.name === "App Router (app/)")).toBeDefined();
416 expect(items.find((i) => i.name.includes("1 page"))?.status).toBe("supported");
417 expect(items.find((i) => i.name.includes("1 layout"))?.status).toBe("supported");
418 });
419
420 it("detects middleware.ts", () => {
421 writeFile("middleware.ts", `export function middleware() {}`);
422
423 // Also need pages or app directory
424 writeFile("app/page.tsx", `export default function Home() { return <div/>; }`);
425
426 const items = checkConventions(tmpDir);
427 const mw = items.find((i) => i.name.includes("middleware.ts"));
428 expect(mw?.status).toBe("supported");
429 expect(mw?.name).toContain("deprecated");
430 });
431
432 it("detects middleware.js", () => {
433 writeFile("middleware.js", `export function middleware() {}`);
434 writeFile("pages/index.tsx", `export default function Home() { return <div/>; }`);
435
436 const items = checkConventions(tmpDir);
437 const mw = items.find((i) => i.name.includes("middleware.ts"));
438 expect(mw?.status).toBe("supported");
439 expect(mw?.name).toContain("deprecated");
440 });
441
442 it("detects proxy.ts (Next.js 16)", () => {
443 writeFile("proxy.ts", `export default function proxy() {}`);
444 writeFile("app/page.tsx", `export default function Home() { return <div/>; }`);
445
446 const items = checkConventions(tmpDir);
447 const proxy = items.find((i) => i.name.includes("proxy.ts"));
448 expect(proxy?.status).toBe("supported");
449 expect(proxy?.name).toContain("Next.js 16");
450 });
451
452 it("prefers proxy.ts over middleware.ts in check", () => {
453 writeFile("proxy.ts", `export default function proxy() {}`);
454 writeFile("middleware.ts", `export function middleware() {}`);
455 writeFile("app/page.tsx", `export default function Home() { return <div/>; }`);
456
457 const items = checkConventions(tmpDir);
458 // Should show proxy.ts, not middleware.ts
459 expect(items.find((i) => i.name.includes("proxy.ts"))).toBeDefined();
460 expect(items.find((i) => i.name.includes("middleware.ts"))).toBeUndefined();
461 });
462
463 it("detects src/app directory when app/ is not at root", () => {
464 writeFile("src/app/page.tsx", `export default function Home() { return <div/>; }`);
465 writeFile("src/app/layout.tsx", `export default function Layout({ children }) { return <html><body>{children}</body></html>; }`);
466
467 const items = checkConventions(tmpDir);
468 expect(items.find((i) => i.name === "App Router (src/app/)")).toBeDefined();
469 expect(items.find((i) => i.name.includes("1 page"))?.status).toBe("supported");
470 expect(items.find((i) => i.name.includes("1 layout"))?.status).toBe("supported");
471 });
472
473 it("detects src/pages directory when pages/ is not at root", () => {
474 writeFile("src/pages/index.tsx", `export default function Home() { return <div/>; }`);
475
476 const items = checkConventions(tmpDir);
477 expect(items.find((i) => i.name === "Pages Router (src/pages/)")).toBeDefined();
478 expect(items.find((i) => i.name.includes("1 page"))?.status).toBe("supported");
479 });
480
481 it("prefers root-level app/ over src/app/", () => {
482 writeFile("app/page.tsx", `export default function Home() { return <div/>; }`);
483 writeFile("src/app/page.tsx", `export default function Home() { return <div/>; }`);
484
485 const items = checkConventions(tmpDir);
486 expect(items.find((i) => i.name === "App Router (app/)")).toBeDefined();
487 // src/app/ should also be detected (both exist)
488 });
489
490 it("reports unsupported when no pages/ or app/ directory", () => {
491 writeFile("src/index.ts", `console.log("hi");`);
492
493 const items = checkConventions(tmpDir);
494 expect(items.find((i) => i.status === "unsupported")).toBeDefined();
495 expect(items.find((i) => i.name.includes("No pages/ or app/"))).toBeDefined();
496 });
497
498 it("counts API routes separately", () => {
499 writeFile("pages/index.tsx", `export default function Home() { return <div/>; }`);
500 writeFile("pages/api/hello.ts", `export default function handler(req, res) { res.json({}) }`);
501 writeFile("pages/api/users.ts", `export default function handler(req, res) { res.json({}) }`);
502
503 const items = checkConventions(tmpDir);
504 expect(items.find((i) => i.name.includes("2 API route"))).toBeDefined();
505 });
506
507 it("detects custom _app and _document", () => {
508 writeFile("pages/index.tsx", `export default function Home() { return <div/>; }`);
509 writeFile("pages/_app.tsx", `export default function App({ Component, pageProps }) { return <Component {...pageProps} /> }`);
510 writeFile("pages/_document.tsx", `export default function Document() {}`);
511
512 const items = checkConventions(tmpDir);
513 expect(items.find((i) => i.name === "Custom _app")?.status).toBe("supported");
514 expect(items.find((i) => i.name === "Custom _document")?.status).toBe("supported");
515 });
516
517 it("detects App Router conventions (loading, error, not-found)", () => {
518 writeFile("app/page.tsx", `export default function Home() { return <div/>; }`);
519 writeFile("app/layout.tsx", `export default function Layout({ children }) { return <html><body>{children}</body></html>; }`);
520 writeFile("app/loading.tsx", `export default function Loading() { return <div>Loading...</div>; }`);
521 writeFile("app/error.tsx", `"use client"; export default function Error() { return <div>Error</div>; }`);
522 writeFile("app/not-found.tsx", `export default function NotFound() { return <div>Not Found</div>; }`);
523
524 const items = checkConventions(tmpDir);
525 expect(items.find((i) => i.name.includes("loading"))?.status).toBe("supported");
526 expect(items.find((i) => i.name.includes("error"))?.status).toBe("supported");
527 expect(items.find((i) => i.name.includes("not-found"))?.status).toBe("supported");
528 });
529
530 it("detects route handlers in App Router", () => {
531 writeFile("app/page.tsx", `export default function Home() { return <div/>; }`);
532 writeFile("app/api/hello/route.ts", `export function GET() { return Response.json({ hello: "world" }); }`);
533
534 const items = checkConventions(tmpDir);
535 expect(items.find((i) => i.name.includes("1 route handler"))).toBeDefined();
536 });
537
538 it("flags missing type:module in package.json", () => {
539 writeFile("app/page.tsx", `export default function Home() { return <div/>; }`);
540 writeFile("package.json", JSON.stringify({ dependencies: { react: "^19.0.0" } }));
541
542 const items = checkConventions(tmpDir);
543 const typeModule = items.find((i) => i.name.includes('"type": "module"'));
544 expect(typeModule).toBeDefined();
545 expect(typeModule?.status).toBe("unsupported");
546 expect(typeModule?.detail).toContain("vinext init");
547 });
548
549 it("does not flag type:module when present", () => {
550 writeFile("app/page.tsx", `export default function Home() { return <div/>; }`);
551 writeFile("package.json", JSON.stringify({ type: "module", dependencies: { react: "^19.0.0" } }));
552
553 const items = checkConventions(tmpDir);
554 const typeModule = items.find((i) => i.name.includes('"type": "module"'));
555 expect(typeModule).toBeUndefined();
556 });
557
558 it("detects ViewTransition import from react", () => {
559 writeFile("app/page.tsx", `import { ViewTransition } from "react";\nexport default function Home() { return <ViewTransition><div/></ViewTransition>; }`);
560
561 const items = checkConventions(tmpDir);
562 const vt = items.find((i) => i.name.includes("ViewTransition"));
563 expect(vt).toBeDefined();
564 expect(vt?.status).toBe("partial");
565 expect(vt?.detail).toContain("passthrough fallback");
566 expect(vt?.files).toHaveLength(1);
567 });
568
569 it("does not flag ViewTransition when not imported", () => {
570 writeFile("app/page.tsx", `import React from "react";\nexport default function Home() { return <div/>; }`);
571
572 const items = checkConventions(tmpDir);
573 const vt = items.find((i) => i.name.includes("ViewTransition"));
574 expect(vt).toBeUndefined();
575 });
576
577 it("detects PostCSS string-form plugins", () => {
578 writeFile("app/page.tsx", `export default function Home() { return <div/>; }`);
579 writeFile("postcss.config.mjs", `export default {\n plugins: ["@tailwindcss/postcss"]\n};`);
580
581 const items = checkConventions(tmpDir);
582 const postcss = items.find((i) => i.name.includes("PostCSS"));
583 expect(postcss).toBeDefined();
584 expect(postcss?.status).toBe("partial");
585 expect(postcss?.detail).toContain("string-form");
586 });
587
588 it("does not flag PostCSS when no config exists", () => {
589 writeFile("app/page.tsx", `export default function Home() { return <div/>; }`);
590
591 const items = checkConventions(tmpDir);
592 const postcss = items.find((i) => i.name.includes("PostCSS"));
593 expect(postcss).toBeUndefined();
594 });
595});
596
597// ── runCheck ───────────────────────────────────────────────────────────────
598
599describe("runCheck", () => {
600 it("returns a complete result with all sections", () => {
601 writeFile("app/page.tsx", `import Link from "next/link";`);
602 writeFile("app/layout.tsx", `export default function Layout({ children }) { return <html><body>{children}</body></html>; }`);
603 writeFile("package.json", JSON.stringify({ type: "module", dependencies: { tailwindcss: "^3.0.0" } }));
604
605 const result = runCheck(tmpDir);
606 expect(result.imports).toBeDefined();
607 expect(result.config).toBeDefined();
608 expect(result.libraries).toBeDefined();
609 expect(result.conventions).toBeDefined();
610 expect(result.summary).toBeDefined();
611 });
612
613 it("calculates score correctly — 100% for all supported", () => {
614 writeFile("app/page.tsx", `import Link from "next/link";`);
615 writeFile("app/layout.tsx", `export default function Layout({ children }) { return <html><body>{children}</body></html>; }`);
616 writeFile("package.json", JSON.stringify({ type: "module", dependencies: { tailwindcss: "^3.0.0" } }));
617
618 const result = runCheck(tmpDir);
619 // All items should be supported: next/link, no config file, tailwindcss, App Router, 1 page, 1 layout
620 expect(result.summary.unsupported).toBe(0);
621 expect(result.summary.score).toBe(100);
622 });
623
624 it("calculates score correctly — partial items count 50%", () => {
625 // 1 supported import (next/link) + 1 partial import (next/font/google) + no-config (supported) + 2 conventions (App Router + 1 page)
626 writeFile("app/page.tsx", `
627 import Link from "next/link";
628 import { GoogleFont } from "next/font/google";
629 `);
630
631 const result = runCheck(tmpDir);
632 expect(result.summary.partial).toBeGreaterThan(0);
633 expect(result.summary.score).toBeLessThan(100);
634 expect(result.summary.score).toBeGreaterThan(0);
635 });
636
637 it("calculates score correctly — unsupported items drag score down", () => {
638 writeFile("app/page.tsx", `
639 import { useAmp } from "next/amp";
640 `);
641 writeFile(
642 "next.config.mjs",
643 `export default { webpack: (config) => config };`,
644 );
645 writeFile("package.json", JSON.stringify({ type: "module", dependencies: { "next-auth": "^4.0.0" } }));
646
647 const result = runCheck(tmpDir);
648 expect(result.summary.unsupported).toBeGreaterThan(0);
649 expect(result.summary.score).toBeLessThan(100);
650 });
651
652 it("reports correct totals", () => {
653 writeFile("app/page.tsx", `import Link from "next/link";`);
654 writeFile("package.json", JSON.stringify({ type: "module", dependencies: { tailwindcss: "^3.0.0" } }));
655
656 const result = runCheck(tmpDir);
657 const total = result.summary.supported + result.summary.partial + result.summary.unsupported;
658 expect(total).toBe(result.summary.total);
659 });
660
661 it("returns 100% score for empty project with no pages or app", () => {
662 // Empty project — only an unsupported "no pages/ or app/" item
663 writeFile("src/index.ts", `console.log("hi");`);
664
665 const result = runCheck(tmpDir);
666 // Should have 1 unsupported item (no pages/app directory)
667 expect(result.summary.unsupported).toBe(1);
668 expect(result.summary.score).toBeLessThan(100);
669 });
670
671 it("calculates score correctly for src/app project", () => {
672 writeFile("src/app/page.tsx", `import Link from "next/link";`);
673 writeFile("src/app/layout.tsx", `export default function Layout({ children }) { return <html><body>{children}</body></html>; }`);
674 writeFile("package.json", JSON.stringify({ type: "module", dependencies: { tailwindcss: "^3.0.0" } }));
675
676 const result = runCheck(tmpDir);
677 expect(result.summary.unsupported).toBe(0);
678 expect(result.summary.score).toBe(100);
679 });
680});
681
682// ── formatReport ───────────────────────────────────────────────────────────
683
684describe("formatReport", () => {
685 it("produces a string with section headers", () => {
686 writeFile("app/page.tsx", `
687 import Link from "next/link";
688 import { GoogleFont } from "next/font/google";
689 `);
690 writeFile("package.json", JSON.stringify({ type: "module", dependencies: { tailwindcss: "^3.0.0" } }));
691
692 const result = runCheck(tmpDir);
693 const report = formatReport(result);
694
695 expect(report).toContain("vinext compatibility report");
696 expect(report).toContain("Imports");
697 expect(report).toContain("Libraries");
698 expect(report).toContain("Project structure");
699 expect(report).toContain("Overall");
700 expect(report).toContain("% compatible");
701 });
702
703 it("shows issues section when there are unsupported items", () => {
704 writeFile("app/page.tsx", `import { useAmp } from "next/amp";`);
705 writeFile("package.json", JSON.stringify({ type: "module", dependencies: {} }));
706
707 const result = runCheck(tmpDir);
708 const report = formatReport(result);
709
710 expect(report).toContain("Issues to address");
711 expect(report).toContain("next/amp");
712 });
713
714 it("shows partial support section when there are partial items", () => {
715 writeFile("app/page.tsx", `import { GoogleFont } from "next/font/google";`);
716 writeFile("package.json", JSON.stringify({ type: "module", dependencies: {} }));
717
718 const result = runCheck(tmpDir);
719 const report = formatReport(result);
720
721 expect(report).toContain("Partial support");
722 expect(report).toContain("next/font/google");
723 });
724
725 it("does not show issues section when everything is supported", () => {
726 writeFile("app/page.tsx", `import Link from "next/link";`);
727 writeFile("app/layout.tsx", `export default function Layout({ children }) { return <html><body>{children}</body></html>; }`);
728 writeFile("package.json", JSON.stringify({ type: "module", dependencies: { tailwindcss: "^3.0.0" } }));
729
730 const result = runCheck(tmpDir);
731 const report = formatReport(result);
732
733 expect(report).not.toContain("Issues to address");
734 expect(report).not.toContain("Partial support");
735 });
736
737 it("includes file count for imports", () => {
738 writeFile("app/page.tsx", `import Link from "next/link";`);
739 writeFile("app/about/page.tsx", `import Link from "next/link";`);
740 writeFile("package.json", JSON.stringify({ type: "module", dependencies: {} }));
741
742 const result = runCheck(tmpDir);
743 const report = formatReport(result);
744
745 expect(report).toContain("2 files");
746 });
747
748 it("handles empty result gracefully", () => {
749 const emptyResult: CheckResult = {
750 imports: [],
751 config: [],
752 libraries: [],
753 conventions: [],
754 summary: { supported: 0, partial: 0, unsupported: 0, total: 0, score: 100 },
755 };
756
757 const report = formatReport(emptyResult);
758 expect(report).toContain("vinext compatibility report");
759 expect(report).toContain("100% compatible");
760 });
761
762 it("includes actionable next steps", () => {
763 writeFile("app/page.tsx", `import Link from "next/link";`);
764 writeFile("package.json", JSON.stringify({ type: "module", dependencies: {} }));
765
766 const result = runCheck(tmpDir);
767 const report = formatReport(result);
768
769 expect(report).toContain("Recommended next steps");
770 expect(report).toContain("vinext init");
771 expect(report).toContain("Or manually");
772 expect(report).toContain('"type": "module"');
773 expect(report).toContain("vite.config.ts");
774 expect(report).toContain("npx vite dev");
775 });
776});
777
778// ── Integration: running against fixtures ──────────────────────────────────
779
780describe("integration: pages-basic fixture", () => {
781 const fixtureDir = path.resolve(import.meta.dirname, "./fixtures/pages-basic");
782
783 it("detects Pages Router conventions", () => {
784 const items = checkConventions(fixtureDir);
785 expect(items.find((i) => i.name === "Pages Router (pages/)")).toBeDefined();
786 });
787
788 it("detects config options from next.config.mjs", () => {
789 const items = analyzeConfig(fixtureDir);
790 expect(items.find((i) => i.name === "redirects")).toBeDefined();
791 expect(items.find((i) => i.name === "rewrites")).toBeDefined();
792 expect(items.find((i) => i.name === "headers")).toBeDefined();
793 expect(items.find((i) => i.name === "env")).toBeDefined();
794 });
795
796 it("runCheck produces a valid report", () => {
797 const result = runCheck(fixtureDir);
798 expect(result.summary.total).toBeGreaterThan(0);
799 expect(result.summary.score).toBeGreaterThanOrEqual(0);
800 expect(result.summary.score).toBeLessThanOrEqual(100);
801 });
802});
803
804describe("integration: app-basic fixture", () => {
805 const fixtureDir = path.resolve(import.meta.dirname, "./fixtures/app-basic");
806
807 it("detects App Router conventions", () => {
808 const items = checkConventions(fixtureDir);
809 expect(items.find((i) => i.name === "App Router (app/)")).toBeDefined();
810 });
811
812 it("runCheck produces a valid report", () => {
813 const result = runCheck(fixtureDir);
814 expect(result.summary.total).toBeGreaterThan(0);
815 expect(result.summary.score).toBeGreaterThanOrEqual(0);
816 expect(result.summary.score).toBeLessThanOrEqual(100);
817 });
818});
819