mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
2d08de067d
* fix(pages): preserve raw data URLs for middleware * fix(pages): preserve data route path with skipped normalization
1758 lines
64 KiB
TypeScript
1758 lines
64 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach } from "vite-plus/test";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import os from "node:os";
|
|
import {
|
|
scanImports,
|
|
analyzeConfig,
|
|
checkLibraries,
|
|
checkConventions,
|
|
hasFreeCjsGlobal,
|
|
runCheck,
|
|
formatReport,
|
|
type CheckResult,
|
|
} from "../packages/vinext/src/check.js";
|
|
|
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
|
|
let tmpDir: string;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-check-"));
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
function writeFile(relPath: string, content: string) {
|
|
const fullPath = path.join(tmpDir, relPath);
|
|
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
|
fs.writeFileSync(fullPath, content);
|
|
}
|
|
|
|
// ── scanImports ────────────────────────────────────────────────────────────
|
|
|
|
describe("scanImports", () => {
|
|
it("detects supported next/* imports", () => {
|
|
writeFile(
|
|
"app/page.tsx",
|
|
`
|
|
import Link from "next/link";
|
|
import Image from "next/image";
|
|
`,
|
|
);
|
|
|
|
const items = scanImports(tmpDir);
|
|
expect(items).toHaveLength(2);
|
|
expect(items.find((i) => i.name === "next/link")?.status).toBe("supported");
|
|
expect(items.find((i) => i.name === "next/image")?.status).toBe("supported");
|
|
});
|
|
|
|
it("detects partial imports", () => {
|
|
writeFile("app/page.tsx", `import { GoogleFont } from "next/font/google";`);
|
|
|
|
const items = scanImports(tmpDir);
|
|
expect(items).toHaveLength(1);
|
|
expect(items[0].name).toBe("next/font/google");
|
|
expect(items[0].status).toBe("partial");
|
|
});
|
|
|
|
it("reports accurate next/font/local detail", () => {
|
|
writeFile("app/page.tsx", `import localFont from "next/font/local";`);
|
|
|
|
const items = scanImports(tmpDir);
|
|
expect(items).toHaveLength(1);
|
|
expect(items[0].name).toBe("next/font/local");
|
|
expect(items[0].status).toBe("supported");
|
|
expect(items[0].detail).toContain("className and variable modes both work");
|
|
});
|
|
|
|
it("detects unsupported imports", () => {
|
|
writeFile("pages/amp.tsx", `import { useAmp } from "next/amp";`);
|
|
|
|
const items = scanImports(tmpDir);
|
|
expect(items).toHaveLength(1);
|
|
expect(items[0].name).toBe("next/amp");
|
|
expect(items[0].status).toBe("unsupported");
|
|
});
|
|
|
|
it("detects server-only and client-only", () => {
|
|
writeFile("lib/db.ts", `import "server-only";`);
|
|
writeFile("components/button.tsx", `import "client-only";`);
|
|
|
|
const items = scanImports(tmpDir);
|
|
expect(items).toHaveLength(2);
|
|
expect(items.find((i) => i.name === "server-only")?.status).toBe("supported");
|
|
expect(items.find((i) => i.name === "client-only")?.status).toBe("supported");
|
|
});
|
|
|
|
it("tracks which files use each import", () => {
|
|
writeFile("app/page.tsx", `import Link from "next/link";`);
|
|
writeFile("app/about/page.tsx", `import Link from "next/link";`);
|
|
|
|
const items = scanImports(tmpDir);
|
|
const linkItem = items.find((i) => i.name === "next/link");
|
|
expect(linkItem?.files).toHaveLength(2);
|
|
expect(linkItem?.files).toContain("app/page.tsx");
|
|
expect(linkItem?.files).toContain("app/about/page.tsx");
|
|
});
|
|
|
|
it("detects require() calls too", () => {
|
|
writeFile("lib/util.js", `const router = require("next/router");`);
|
|
|
|
const items = scanImports(tmpDir);
|
|
expect(items).toHaveLength(1);
|
|
expect(items[0].name).toBe("next/router");
|
|
expect(items[0].status).toBe("supported");
|
|
});
|
|
|
|
it("returns empty for projects with no next imports", () => {
|
|
writeFile("src/index.ts", `import React from "react";`);
|
|
|
|
const items = scanImports(tmpDir);
|
|
expect(items).toHaveLength(0);
|
|
});
|
|
|
|
it("marks unrecognized next/* imports as unsupported", () => {
|
|
writeFile("app/page.tsx", `import foo from "next/nonexistent";`);
|
|
|
|
const items = scanImports(tmpDir);
|
|
expect(items).toHaveLength(1);
|
|
expect(items[0].status).toBe("unsupported");
|
|
expect(items[0].detail).toContain("not recognized");
|
|
});
|
|
|
|
it("recognizes next/compat/router as supported", () => {
|
|
writeFile("components/shared-nav.tsx", `import { useRouter } from "next/compat/router";`);
|
|
|
|
const items = scanImports(tmpDir);
|
|
expect(items).toHaveLength(1);
|
|
expect(items[0].name).toBe("next/compat/router");
|
|
expect(items[0].status).toBe("supported");
|
|
});
|
|
|
|
it("recognizes next/form as supported", () => {
|
|
writeFile("app/page.tsx", `import Form from "next/form";`);
|
|
|
|
const items = scanImports(tmpDir);
|
|
expect(items).toHaveLength(1);
|
|
expect(items[0].name).toBe("next/form");
|
|
expect(items[0].status).toBe("supported");
|
|
});
|
|
|
|
it("recognizes next/web-vitals as supported", () => {
|
|
writeFile("pages/_app.tsx", `import { reportWebVitals } from "next/web-vitals";`);
|
|
|
|
const items = scanImports(tmpDir);
|
|
expect(items).toHaveLength(1);
|
|
expect(items[0].name).toBe("next/web-vitals");
|
|
expect(items[0].status).toBe("supported");
|
|
});
|
|
|
|
it("recognizes next/constants as supported", () => {
|
|
writeFile("lib/phases.ts", `import { PHASE_DEVELOPMENT_SERVER } from "next/constants";`);
|
|
|
|
const items = scanImports(tmpDir);
|
|
expect(items).toHaveLength(1);
|
|
expect(items[0].name).toBe("next/constants");
|
|
expect(items[0].status).toBe("supported");
|
|
});
|
|
|
|
it("recognizes `import { Metadata } from 'next'` as supported", () => {
|
|
writeFile(
|
|
"app/layout.tsx",
|
|
`import { Metadata } from "next";\nexport const metadata: Metadata = { title: "App" };`,
|
|
);
|
|
|
|
const items = scanImports(tmpDir);
|
|
expect(items).toHaveLength(1);
|
|
expect(items[0].name).toBe("next");
|
|
expect(items[0].status).toBe("supported");
|
|
});
|
|
|
|
it("skips `import type` statements entirely", () => {
|
|
writeFile(
|
|
"app/page.tsx",
|
|
`import type { Metadata } from "next";\nimport Link from "next/link";`,
|
|
);
|
|
|
|
const items = scanImports(tmpDir);
|
|
// Should only find next/link, not next (since import type is skipped)
|
|
expect(items).toHaveLength(1);
|
|
expect(items[0].name).toBe("next/link");
|
|
});
|
|
|
|
it("skips `import type` for next/* paths too", () => {
|
|
writeFile("app/page.tsx", `import type { NextRequest } from "next/server";`);
|
|
|
|
const items = scanImports(tmpDir);
|
|
expect(items).toHaveLength(0);
|
|
});
|
|
|
|
it("sorts unsupported first, then partial, then supported", () => {
|
|
writeFile(
|
|
"app/page.tsx",
|
|
`
|
|
import Link from "next/link";
|
|
import { GoogleFont } from "next/font/google";
|
|
import { useAmp } from "next/amp";
|
|
`,
|
|
);
|
|
|
|
const items = scanImports(tmpDir);
|
|
expect(items[0].status).toBe("unsupported");
|
|
expect(items[1].status).toBe("partial");
|
|
expect(items[2].status).toBe("supported");
|
|
});
|
|
|
|
it("ignores node_modules and .next directories", () => {
|
|
writeFile("node_modules/foo/index.ts", `import Link from "next/link";`);
|
|
writeFile(".next/server.js", `import Link from "next/link";`);
|
|
writeFile("app/page.tsx", `import Image from "next/image";`);
|
|
|
|
const items = scanImports(tmpDir);
|
|
expect(items).toHaveLength(1);
|
|
expect(items[0].name).toBe("next/image");
|
|
});
|
|
|
|
it("ignores imports used only by test modules and tool config files", () => {
|
|
writeFile("app/page.test.tsx", `import { useAmp } from "next/amp";`);
|
|
writeFile("vitest.config.ts", `import { useAmp } from "next/amp";`);
|
|
writeFile("site.config.ts", `import { useAmp } from "next/amp";`);
|
|
writeFile("app/page.tsx", `import Link from "next/link";`);
|
|
|
|
const items = scanImports(tmpDir);
|
|
|
|
expect(items.map((item) => item.name)).toEqual(["next/amp", "next/link"]);
|
|
});
|
|
|
|
it("deduplicates files using the same import", () => {
|
|
writeFile(
|
|
"app/page.tsx",
|
|
`
|
|
import Link from "next/link";
|
|
import Link from "next/link";
|
|
`,
|
|
);
|
|
|
|
const items = scanImports(tmpDir);
|
|
const linkItem = items.find((i) => i.name === "next/link");
|
|
expect(linkItem?.files).toHaveLength(1);
|
|
});
|
|
|
|
it("recognizes next/dist/shared/lib/router-context.shared-runtime as supported", () => {
|
|
writeFile(
|
|
"lib/router.tsx",
|
|
`import { RouterContext } from "next/dist/shared/lib/router-context.shared-runtime";`,
|
|
);
|
|
|
|
const items = scanImports(tmpDir);
|
|
const item = items.find((i) => i.name === "next/dist/shared/lib/router-context.shared-runtime");
|
|
expect(item?.status).toBe("supported");
|
|
});
|
|
|
|
it("recognizes all other shimmed next/dist/* paths as supported", () => {
|
|
const distImports = [
|
|
"next/dist/shared/lib/app-router-context.shared-runtime",
|
|
"next/dist/shared/lib/app-router-context",
|
|
"next/dist/shared/lib/utils",
|
|
"next/dist/server/api-utils",
|
|
"next/dist/server/web/spec-extension/cookies",
|
|
"next/dist/compiled/@edge-runtime/cookies",
|
|
"next/dist/server/app-render/work-unit-async-storage.external",
|
|
"next/dist/client/components/work-unit-async-storage.external",
|
|
"next/dist/client/components/request-async-storage.external",
|
|
"next/dist/client/components/request-async-storage",
|
|
"next/dist/client/components/navigation",
|
|
"next/dist/server/config-shared",
|
|
];
|
|
|
|
writeFile("lib/internals.ts", distImports.map((p) => `import * as m from "${p}";`).join("\n"));
|
|
|
|
const items = scanImports(tmpDir);
|
|
for (const p of distImports) {
|
|
const item = items.find((i) => i.name === p);
|
|
expect(item?.status, `expected ${p} to be supported`).toBe("supported");
|
|
}
|
|
});
|
|
});
|
|
|
|
// ── analyzeConfig ──────────────────────────────────────────────────────────
|
|
|
|
describe("analyzeConfig", () => {
|
|
it("reports 'no config file' when none exists", () => {
|
|
const items = analyzeConfig(tmpDir);
|
|
expect(items).toHaveLength(1);
|
|
expect(items[0].name).toBe("next.config");
|
|
expect(items[0].status).toBe("supported");
|
|
});
|
|
|
|
it("detects supported config options", () => {
|
|
writeFile(
|
|
"next.config.mjs",
|
|
`export default {
|
|
basePath: "/docs",
|
|
trailingSlash: true,
|
|
reactStrictMode: true,
|
|
};`,
|
|
);
|
|
|
|
const items = analyzeConfig(tmpDir);
|
|
expect(items.find((i) => i.name === "basePath")?.status).toBe("supported");
|
|
expect(items.find((i) => i.name === "trailingSlash")?.status).toBe("supported");
|
|
// reactStrictMode is enforced for the Pages Router (client root wrapped in
|
|
// `<React.StrictMode>` when `true`) but the App Router is not yet wrapped,
|
|
// so the status is `partial`. See `packages/vinext/src/check.ts`.
|
|
expect(items.find((i) => i.name === "reactStrictMode")?.status).toBe("partial");
|
|
});
|
|
|
|
it("detects unsupported webpack config", () => {
|
|
writeFile(
|
|
"next.config.js",
|
|
`module.exports = {
|
|
webpack: (config) => { return config; },
|
|
};`,
|
|
);
|
|
|
|
const items = analyzeConfig(tmpDir);
|
|
const webpackItem = items.find((i) => i.name === "webpack");
|
|
expect(webpackItem?.status).toBe("unsupported");
|
|
expect(webpackItem?.detail).toContain("Vite replaces webpack");
|
|
});
|
|
|
|
it("detects cacheComponents as partially supported", () => {
|
|
writeFile(
|
|
"next.config.mjs",
|
|
`export default {
|
|
cacheComponents: true,
|
|
};`,
|
|
);
|
|
|
|
const items = analyzeConfig(tmpDir);
|
|
const cacheComponentsItem = items.find((i) => i.name === "cacheComponents");
|
|
expect(cacheComponentsItem?.status).toBe("partial");
|
|
expect(cacheComponentsItem?.detail).toContain("experimental support");
|
|
});
|
|
|
|
it("does not flag webpack when it only appears in a comment", () => {
|
|
writeFile(
|
|
"next.config.js",
|
|
`// We removed our custom webpack config when migrating to vinext.
|
|
module.exports = {
|
|
reactStrictMode: true,
|
|
};`,
|
|
);
|
|
|
|
const items = analyzeConfig(tmpDir);
|
|
expect(items.find((i) => i.name === "webpack")).toBeUndefined();
|
|
});
|
|
|
|
it("does not flag webpack when it only appears as a substring of a value", () => {
|
|
writeFile(
|
|
"next.config.js",
|
|
`module.exports = {
|
|
env: { RSD: "react-server-dom-webpack" },
|
|
};`,
|
|
);
|
|
|
|
const items = analyzeConfig(tmpDir);
|
|
expect(items.find((i) => i.name === "webpack")).toBeUndefined();
|
|
});
|
|
|
|
it("does not flag an option mentioned in a comment with trailing punctuation", () => {
|
|
// Reviewer case: the boundary + `:`/`(`/`=` follower alone would still
|
|
// match these because the leading space satisfies the boundary. Comment
|
|
// stripping is what prevents the false positive.
|
|
writeFile(
|
|
"next.config.js",
|
|
`// TODO: webpack: removed, migrate to vite
|
|
/* old webpack(config) hook lived here */
|
|
// headers: we no longer set custom headers
|
|
module.exports = {
|
|
reactStrictMode: true,
|
|
};`,
|
|
);
|
|
|
|
const items = analyzeConfig(tmpDir);
|
|
expect(items.find((i) => i.name === "webpack")).toBeUndefined();
|
|
expect(items.find((i) => i.name === "headers")).toBeUndefined();
|
|
});
|
|
|
|
it("does not flag an option name embedded in a string value", () => {
|
|
// Reviewer case: a `"<opt>:..."` value would slip past the optional-quote
|
|
// branch of the regex; string stripping prevents it.
|
|
writeFile(
|
|
"next.config.js",
|
|
`module.exports = {
|
|
images: { domains: ["webpack:1234"] },
|
|
env: { X: "(headers:foo)" },
|
|
};`,
|
|
);
|
|
|
|
const items = analyzeConfig(tmpDir);
|
|
expect(items.find((i) => i.name === "webpack")).toBeUndefined();
|
|
expect(items.find((i) => i.name === "headers")).toBeUndefined();
|
|
// The real keys are still detected.
|
|
expect(items.find((i) => i.name === "images")?.status).toBe("partial");
|
|
expect(items.find((i) => i.name === "env")?.status).toBe("supported");
|
|
});
|
|
|
|
it("detects webpack when written as a method shorthand", () => {
|
|
writeFile(
|
|
"next.config.js",
|
|
`module.exports = {
|
|
webpack(config) { return config; },
|
|
};`,
|
|
);
|
|
|
|
const items = analyzeConfig(tmpDir);
|
|
expect(items.find((i) => i.name === "webpack")?.status).toBe("unsupported");
|
|
});
|
|
|
|
it("detects webpack when written as a quoted property key", () => {
|
|
writeFile(
|
|
"next.config.js",
|
|
`module.exports = {
|
|
"webpack": (config) => config,
|
|
};`,
|
|
);
|
|
|
|
const items = analyzeConfig(tmpDir);
|
|
expect(items.find((i) => i.name === "webpack")?.status).toBe("unsupported");
|
|
});
|
|
|
|
it("detects partial image config", () => {
|
|
writeFile(
|
|
"next.config.mjs",
|
|
`export default {
|
|
images: { remotePatterns: [{ hostname: "*.example.com" }] },
|
|
};`,
|
|
);
|
|
|
|
const items = analyzeConfig(tmpDir);
|
|
expect(items.find((i) => i.name === "images")?.status).toBe("partial");
|
|
});
|
|
|
|
it("detects experimental.ppr as unsupported", () => {
|
|
writeFile(
|
|
"next.config.mjs",
|
|
`export default {
|
|
experimental: {
|
|
ppr: true,
|
|
},
|
|
};`,
|
|
);
|
|
|
|
const items = analyzeConfig(tmpDir);
|
|
expect(items.find((i) => i.name === "experimental.ppr")?.status).toBe("unsupported");
|
|
});
|
|
|
|
// Mirrors Next.js: test/e2e/app-dir/app-shells
|
|
it("detects experimental.appShells as partial (config recognized, behavior not implemented)", () => {
|
|
writeFile(
|
|
"next.config.mjs",
|
|
`export default {
|
|
experimental: {
|
|
appShells: true,
|
|
},
|
|
};`,
|
|
);
|
|
|
|
const items = analyzeConfig(tmpDir);
|
|
expect(items.find((i) => i.name === "experimental.appShells")?.status).toBe("partial");
|
|
});
|
|
|
|
it("detects experimental.serverActions as supported", () => {
|
|
writeFile(
|
|
"next.config.mjs",
|
|
`export default {
|
|
experimental: {
|
|
serverActions: { allowedOrigins: ["my-domain.com"] },
|
|
},
|
|
};`,
|
|
);
|
|
|
|
const items = analyzeConfig(tmpDir);
|
|
expect(items.find((i) => i.name === "experimental.serverActions")?.status).toBe("supported");
|
|
});
|
|
|
|
it("detects experimental.prefetchInlining as partial", () => {
|
|
writeFile(
|
|
"next.config.mjs",
|
|
`export default {
|
|
experimental: {
|
|
prefetchInlining: true,
|
|
},
|
|
};`,
|
|
);
|
|
|
|
const items = analyzeConfig(tmpDir);
|
|
expect(items.find((i) => i.name === "experimental.prefetchInlining")?.status).toBe("partial");
|
|
});
|
|
|
|
it("detects experimental.varyParams as partial", () => {
|
|
writeFile(
|
|
"next.config.mjs",
|
|
`export default {
|
|
experimental: {
|
|
varyParams: true,
|
|
},
|
|
};`,
|
|
);
|
|
|
|
const items = analyzeConfig(tmpDir);
|
|
expect(items.find((i) => i.name === "experimental.varyParams")?.status).toBe("partial");
|
|
});
|
|
|
|
it("detects experimental.optimisticRouting as partial", () => {
|
|
writeFile(
|
|
"next.config.mjs",
|
|
`export default {
|
|
experimental: {
|
|
optimisticRouting: true,
|
|
},
|
|
};`,
|
|
);
|
|
|
|
const items = analyzeConfig(tmpDir);
|
|
expect(items.find((i) => i.name === "experimental.optimisticRouting")?.status).toBe("partial");
|
|
});
|
|
|
|
it("detects experimental.cachedNavigations as partial", () => {
|
|
writeFile(
|
|
"next.config.mjs",
|
|
`export default {
|
|
experimental: {
|
|
cachedNavigations: true,
|
|
},
|
|
};`,
|
|
);
|
|
|
|
const items = analyzeConfig(tmpDir);
|
|
expect(items.find((i) => i.name === "experimental.cachedNavigations")?.status).toBe("partial");
|
|
});
|
|
|
|
it("detects experimental.swcEnvOptions as unsupported", () => {
|
|
writeFile(
|
|
"next.config.mjs",
|
|
`export default {
|
|
experimental: {
|
|
swcEnvOptions: {
|
|
mode: "usage",
|
|
coreJs: "3",
|
|
},
|
|
},
|
|
};`,
|
|
);
|
|
|
|
const items = analyzeConfig(tmpDir);
|
|
const item = items.find((i) => i.name === "experimental.swcEnvOptions");
|
|
expect(item?.status).toBe("unsupported");
|
|
expect(item?.detail).toContain("not applicable");
|
|
});
|
|
|
|
it("detects middleware and proxy config support", () => {
|
|
writeFile(
|
|
"next.config.mjs",
|
|
`export default {
|
|
skipMiddlewareUrlNormalize: true,
|
|
skipProxyUrlNormalize: true,
|
|
experimental: {
|
|
middlewarePrefetch: "strict",
|
|
proxyPrefetch: "strict",
|
|
middlewareClientMaxBodySize: "5mb",
|
|
proxyClientMaxBodySize: "5mb",
|
|
externalMiddlewareRewritesResolve: true,
|
|
externalProxyRewritesResolve: true,
|
|
instrumentationHook: true,
|
|
},
|
|
};`,
|
|
);
|
|
|
|
const items = analyzeConfig(tmpDir);
|
|
expect(items.find((item) => item.name === "skipMiddlewareUrlNormalize")?.status).toBe(
|
|
"partial",
|
|
);
|
|
expect(items.find((item) => item.name === "skipProxyUrlNormalize")?.status).toBe("partial");
|
|
const unsupportedNames = items
|
|
.filter((item) => item.status === "unsupported")
|
|
.map((item) => item.name);
|
|
|
|
expect(unsupportedNames).toEqual([
|
|
"experimental.middlewarePrefetch",
|
|
"experimental.proxyPrefetch",
|
|
"experimental.middlewareClientMaxBodySize",
|
|
"experimental.proxyClientMaxBodySize",
|
|
"experimental.externalMiddlewareRewritesResolve",
|
|
"experimental.externalProxyRewritesResolve",
|
|
"experimental.instrumentationHook",
|
|
]);
|
|
});
|
|
|
|
it("detects allowedDevOrigins as supported", () => {
|
|
writeFile(
|
|
"next.config.mjs",
|
|
`export default {
|
|
allowedDevOrigins: ["staging.example.com"],
|
|
};`,
|
|
);
|
|
|
|
const items = analyzeConfig(tmpDir);
|
|
expect(items.find((i) => i.name === "allowedDevOrigins")?.status).toBe("supported");
|
|
});
|
|
|
|
it("detects i18n.domains as partial support", () => {
|
|
writeFile(
|
|
"next.config.js",
|
|
`module.exports = {
|
|
i18n: {
|
|
locales: ["en", "fr"],
|
|
defaultLocale: "en",
|
|
domains: [{ domain: "example.fr", defaultLocale: "fr" }],
|
|
},
|
|
};`,
|
|
);
|
|
|
|
const items = analyzeConfig(tmpDir);
|
|
expect(items.find((i) => i.name === "i18n")?.status).toBe("supported");
|
|
expect(items.find((i) => i.name === "i18n.domains")?.status).toBe("partial");
|
|
});
|
|
|
|
it("does not flag i18n.domains when domains belongs to images, not i18n", () => {
|
|
// Reviewer case: the old check tested parent and child as independent
|
|
// regexes, so any config with both i18n and images.domains wrongly reported
|
|
// i18n.domains. Scoping the child lookup to the i18n block fixes this.
|
|
writeFile(
|
|
"next.config.js",
|
|
`module.exports = {
|
|
i18n: { locales: ["en"], defaultLocale: "en" },
|
|
images: { domains: ["x.com"] },
|
|
};`,
|
|
);
|
|
|
|
const items = analyzeConfig(tmpDir);
|
|
expect(items.find((i) => i.name === "i18n.domains")).toBeUndefined();
|
|
expect(items.find((i) => i.name === "i18n")?.status).toBe("supported");
|
|
});
|
|
|
|
it("does not flag experimental.ppr when ppr is outside the experimental block", () => {
|
|
writeFile(
|
|
"next.config.js",
|
|
`const ppr = true;
|
|
module.exports = {
|
|
experimental: { inlineCss: true },
|
|
};`,
|
|
);
|
|
|
|
const items = analyzeConfig(tmpDir);
|
|
expect(items.find((i) => i.name === "experimental.ppr")).toBeUndefined();
|
|
});
|
|
|
|
it("detects a nested option that only appears in a later same-named block", () => {
|
|
// The block scan inspects every matching parent block, not just the first,
|
|
// so a child living in a second `experimental: {}` is still found.
|
|
writeFile(
|
|
"next.config.js",
|
|
`const a = { experimental: { inlineCss: true } };
|
|
module.exports = { experimental: { ppr: true } };`,
|
|
);
|
|
|
|
const items = analyzeConfig(tmpDir);
|
|
expect(items.find((i) => i.name === "experimental.ppr")?.status).toBe("unsupported");
|
|
});
|
|
|
|
it("detects a nested option under a quoted parent key", () => {
|
|
writeFile(
|
|
"next.config.js",
|
|
`module.exports = {
|
|
"experimental": { ppr: true },
|
|
};`,
|
|
);
|
|
|
|
const items = analyzeConfig(tmpDir);
|
|
expect(items.find((i) => i.name === "experimental.ppr")?.status).toBe("unsupported");
|
|
});
|
|
|
|
it("detects options when the config is wrapped in a plugin call", () => {
|
|
writeFile(
|
|
"next.config.mjs",
|
|
`import withMDX from "@next/mdx";
|
|
const nextConfig = { basePath: "/docs", webpack: (c) => c };
|
|
export default withMDX()(nextConfig);`,
|
|
);
|
|
|
|
const items = analyzeConfig(tmpDir);
|
|
expect(items.find((i) => i.name === "basePath")?.status).toBe("supported");
|
|
expect(items.find((i) => i.name === "webpack")?.status).toBe("unsupported");
|
|
});
|
|
|
|
it("detects options through a `satisfies` annotation", () => {
|
|
writeFile(
|
|
"next.config.ts",
|
|
`export default { trailingSlash: true } satisfies import("next").NextConfig;`,
|
|
);
|
|
|
|
const items = analyzeConfig(tmpDir);
|
|
expect(items.find((i) => i.name === "trailingSlash")?.status).toBe("supported");
|
|
});
|
|
|
|
it("detects options in a concise arrow function config", () => {
|
|
// Documented Next.js function form: (phase) => config
|
|
writeFile(
|
|
"next.config.js",
|
|
`module.exports = (phase) => ({
|
|
basePath: "/app",
|
|
webpack: (config) => config,
|
|
});`,
|
|
);
|
|
|
|
const items = analyzeConfig(tmpDir);
|
|
expect(items.find((i) => i.name === "basePath")?.status).toBe("supported");
|
|
expect(items.find((i) => i.name === "webpack")?.status).toBe("unsupported");
|
|
});
|
|
|
|
it("detects options in a block-body function config (next/constants PHASE_*)", () => {
|
|
writeFile(
|
|
"next.config.js",
|
|
`module.exports = function (phase, { defaultConfig }) {
|
|
const isDev = phase === "phase-development-server";
|
|
return {
|
|
trailingSlash: true,
|
|
experimental: { ppr: true },
|
|
};
|
|
};`,
|
|
);
|
|
|
|
const items = analyzeConfig(tmpDir);
|
|
expect(items.find((i) => i.name === "trailingSlash")?.status).toBe("supported");
|
|
expect(items.find((i) => i.name === "experimental.ppr")?.status).toBe("unsupported");
|
|
});
|
|
|
|
it("detects options in an `export default function` config", () => {
|
|
// `export default function (phase) {…}` parses as a FunctionDeclaration,
|
|
// unlike the `module.exports = function (…)` (FunctionExpression) form.
|
|
writeFile(
|
|
"next.config.mjs",
|
|
`export default function (phase) {
|
|
return {
|
|
trailingSlash: true,
|
|
webpack: (config) => config,
|
|
experimental: { ppr: true },
|
|
};
|
|
}`,
|
|
);
|
|
|
|
const items = analyzeConfig(tmpDir);
|
|
expect(items.find((i) => i.name === "trailingSlash")?.status).toBe("supported");
|
|
expect(items.find((i) => i.name === "webpack")?.status).toBe("unsupported");
|
|
expect(items.find((i) => i.name === "experimental.ppr")?.status).toBe("unsupported");
|
|
});
|
|
|
|
it("detects options across all branches of a multi-phase function config", () => {
|
|
// Canonical next/constants multi-phase form: the phase-specific config is in
|
|
// an early return nested in an `if`, and the default config is the trailing
|
|
// return. Keys from both branches should be reported.
|
|
writeFile(
|
|
"next.config.js",
|
|
`const { PHASE_DEVELOPMENT_SERVER } = require("next/constants");
|
|
module.exports = (phase, { defaultConfig }) => {
|
|
if (phase === PHASE_DEVELOPMENT_SERVER) {
|
|
return { trailingSlash: true, experimental: { ppr: true } };
|
|
}
|
|
return { webpack: (config) => config };
|
|
};`,
|
|
);
|
|
|
|
const items = analyzeConfig(tmpDir);
|
|
expect(items.find((i) => i.name === "trailingSlash")?.status).toBe("supported");
|
|
expect(items.find((i) => i.name === "experimental.ppr")?.status).toBe("unsupported");
|
|
expect(items.find((i) => i.name === "webpack")?.status).toBe("unsupported");
|
|
});
|
|
|
|
it("detects options across both branches of a ternary function config", () => {
|
|
writeFile(
|
|
"next.config.mjs",
|
|
`export default (phase) =>
|
|
phase === "phase-development-server"
|
|
? { trailingSlash: true }
|
|
: { basePath: "/app" };`,
|
|
);
|
|
|
|
const items = analyzeConfig(tmpDir);
|
|
expect(items.find((i) => i.name === "trailingSlash")?.status).toBe("supported");
|
|
expect(items.find((i) => i.name === "basePath")?.status).toBe("supported");
|
|
});
|
|
|
|
it.each([
|
|
["experimental.ppr", "experimental", "ppr: true"],
|
|
["experimental.typedRoutes", "experimental", "typedRoutes: true"],
|
|
["experimental.serverActions", "experimental", "serverActions: { allowedOrigins: [] }"],
|
|
["experimental.prefetchInlining", "experimental", "prefetchInlining: true"],
|
|
["experimental.swcEnvOptions", "experimental", 'swcEnvOptions: { mode: "usage" }'],
|
|
["experimental.appShells", "experimental", "appShells: true"],
|
|
["experimental.varyParams", "experimental", "varyParams: true"],
|
|
["experimental.optimisticRouting", "experimental", "optimisticRouting: true"],
|
|
["experimental.cachedNavigations", "experimental", "cachedNavigations: true"],
|
|
["i18n.domains", "i18n", "domains: []"],
|
|
])("detects %s via generic dot-notation handling", (name, parent, body) => {
|
|
writeFile("next.config.mjs", `export default { ${parent}: { ${body} } };`);
|
|
const items = analyzeConfig(tmpDir);
|
|
expect(items.find((i) => i.name === name)).toBeDefined();
|
|
});
|
|
|
|
it("reads next.config.ts files", () => {
|
|
writeFile("next.config.ts", `const config = { basePath: "/app" }; export default config;`);
|
|
|
|
const items = analyzeConfig(tmpDir);
|
|
expect(items.find((i) => i.name === "basePath")?.status).toBe("supported");
|
|
});
|
|
|
|
it("sorts unsupported configs first", () => {
|
|
writeFile(
|
|
"next.config.mjs",
|
|
`export default {
|
|
basePath: "/app",
|
|
webpack: (config) => config,
|
|
images: { domains: [] },
|
|
};`,
|
|
);
|
|
|
|
const items = analyzeConfig(tmpDir);
|
|
expect(items[0].status).toBe("unsupported"); // webpack
|
|
expect(items[items.length - 1].status).toBe("supported"); // basePath
|
|
});
|
|
});
|
|
|
|
// ── checkLibraries ─────────────────────────────────────────────────────────
|
|
|
|
describe("checkLibraries", () => {
|
|
it("returns empty when no package.json", () => {
|
|
const items = checkLibraries(tmpDir);
|
|
expect(items).toHaveLength(0);
|
|
});
|
|
|
|
it("returns empty when no known libraries are used", () => {
|
|
writeFile(
|
|
"package.json",
|
|
JSON.stringify({
|
|
dependencies: { react: "^19.0.0", "some-lib": "^1.0.0" },
|
|
}),
|
|
);
|
|
|
|
const items = checkLibraries(tmpDir);
|
|
expect(items).toHaveLength(0);
|
|
});
|
|
|
|
it("detects supported libraries", () => {
|
|
writeFile(
|
|
"package.json",
|
|
JSON.stringify({
|
|
dependencies: { "next-themes": "^0.3.0", tailwindcss: "^3.0.0", zod: "^3.0.0" },
|
|
}),
|
|
);
|
|
|
|
const items = checkLibraries(tmpDir);
|
|
expect(items).toHaveLength(3);
|
|
expect(items.every((i) => i.status === "supported")).toBe(true);
|
|
});
|
|
|
|
it("detects unsupported libraries", () => {
|
|
writeFile(
|
|
"package.json",
|
|
JSON.stringify({
|
|
dependencies: { "@auth/nextjs": "^5.0.0", "next-auth": "^4.0.0" },
|
|
}),
|
|
);
|
|
|
|
const items = checkLibraries(tmpDir);
|
|
expect(items).toHaveLength(2);
|
|
expect(items.every((i) => i.status === "unsupported")).toBe(true);
|
|
});
|
|
|
|
it("detects @clerk/nextjs as partial", () => {
|
|
writeFile(
|
|
"package.json",
|
|
JSON.stringify({
|
|
dependencies: { "@clerk/nextjs": "^7.0.0" },
|
|
}),
|
|
);
|
|
|
|
const items = checkLibraries(tmpDir);
|
|
expect(items).toHaveLength(1);
|
|
expect(items[0].status).toBe("partial");
|
|
expect(items[0].detail).toContain("clerkMiddleware");
|
|
});
|
|
|
|
it("detects supported CSS-in-JS libraries", () => {
|
|
writeFile(
|
|
"package.json",
|
|
JSON.stringify({
|
|
dependencies: { "styled-components": "^6.0.0" },
|
|
}),
|
|
);
|
|
|
|
const items = checkLibraries(tmpDir);
|
|
expect(items).toHaveLength(1);
|
|
expect(items[0].status).toBe("supported");
|
|
expect(items[0].detail).toContain("useServerInsertedHTML");
|
|
});
|
|
|
|
it("checks both dependencies and devDependencies", () => {
|
|
writeFile(
|
|
"package.json",
|
|
JSON.stringify({
|
|
dependencies: { tailwindcss: "^3.0.0" },
|
|
devDependencies: { prisma: "^5.0.0" },
|
|
}),
|
|
);
|
|
|
|
const items = checkLibraries(tmpDir);
|
|
expect(items).toHaveLength(2);
|
|
expect(items.find((i) => i.name === "tailwindcss")).toBeDefined();
|
|
expect(items.find((i) => i.name === "prisma")).toBeDefined();
|
|
});
|
|
|
|
it("sorts unsupported libraries first", () => {
|
|
writeFile(
|
|
"package.json",
|
|
JSON.stringify({
|
|
dependencies: {
|
|
tailwindcss: "^3.0.0",
|
|
"next-auth": "^4.0.0",
|
|
"@sentry/nextjs": "^7.0.0",
|
|
},
|
|
}),
|
|
);
|
|
|
|
const items = checkLibraries(tmpDir);
|
|
expect(items[0].status).toBe("unsupported");
|
|
expect(items[items.length - 1].status).toBe("supported");
|
|
});
|
|
});
|
|
|
|
// ── checkConventions ───────────────────────────────────────────────────────
|
|
|
|
describe("checkConventions", () => {
|
|
it("detects pages directory", () => {
|
|
writeFile("pages/index.tsx", `export default function Home() { return <div/>; }`);
|
|
|
|
const items = checkConventions(tmpDir);
|
|
expect(items.find((i) => i.name === "Pages Router (pages/)")).toBeDefined();
|
|
expect(items.find((i) => i.name.includes("1 page"))?.status).toBe("supported");
|
|
});
|
|
|
|
it("detects app directory", () => {
|
|
writeFile("app/page.tsx", `export default function Home() { return <div/>; }`);
|
|
writeFile(
|
|
"app/layout.tsx",
|
|
`export default function Layout({ children }) { return <html><body>{children}</body></html>; }`,
|
|
);
|
|
|
|
const items = checkConventions(tmpDir);
|
|
expect(items.find((i) => i.name === "App Router (app/)")).toBeDefined();
|
|
expect(items.find((i) => i.name.includes("1 page"))?.status).toBe("supported");
|
|
expect(items.find((i) => i.name.includes("1 layout"))?.status).toBe("supported");
|
|
});
|
|
|
|
it("detects middleware.ts", () => {
|
|
writeFile("middleware.ts", `export function middleware() {}`);
|
|
|
|
// Also need pages or app directory
|
|
writeFile("app/page.tsx", `export default function Home() { return <div/>; }`);
|
|
|
|
const items = checkConventions(tmpDir);
|
|
const mw = items.find((i) => i.name.includes("middleware.ts"));
|
|
expect(mw?.status).toBe("supported");
|
|
expect(mw?.name).toContain("deprecated");
|
|
});
|
|
|
|
it("detects middleware.js", () => {
|
|
writeFile("middleware.js", `export function middleware() {}`);
|
|
writeFile("pages/index.tsx", `export default function Home() { return <div/>; }`);
|
|
|
|
const items = checkConventions(tmpDir);
|
|
const mw = items.find((i) => i.name.includes("middleware.ts"));
|
|
expect(mw?.status).toBe("supported");
|
|
expect(mw?.name).toContain("deprecated");
|
|
});
|
|
|
|
it("detects proxy.ts (Next.js 16)", () => {
|
|
writeFile("proxy.ts", `export default function proxy() {}`);
|
|
writeFile("app/page.tsx", `export default function Home() { return <div/>; }`);
|
|
|
|
const items = checkConventions(tmpDir);
|
|
const proxy = items.find((i) => i.name.includes("proxy.ts"));
|
|
expect(proxy?.status).toBe("supported");
|
|
expect(proxy?.name).toContain("Next.js 16");
|
|
});
|
|
|
|
it("prefers proxy.ts over middleware.ts in check", () => {
|
|
writeFile("proxy.ts", `export default function proxy() {}`);
|
|
writeFile("middleware.ts", `export function middleware() {}`);
|
|
writeFile("app/page.tsx", `export default function Home() { return <div/>; }`);
|
|
|
|
const items = checkConventions(tmpDir);
|
|
// Should show proxy.ts, not middleware.ts
|
|
expect(items.find((i) => i.name.includes("proxy.ts"))).toBeDefined();
|
|
expect(items.find((i) => i.name.includes("middleware.ts"))).toBeUndefined();
|
|
});
|
|
|
|
it("detects src/app directory when app/ is not at root", () => {
|
|
writeFile("src/app/page.tsx", `export default function Home() { return <div/>; }`);
|
|
writeFile(
|
|
"src/app/layout.tsx",
|
|
`export default function Layout({ children }) { return <html><body>{children}</body></html>; }`,
|
|
);
|
|
|
|
const items = checkConventions(tmpDir);
|
|
expect(items.find((i) => i.name === "App Router (src/app/)")).toBeDefined();
|
|
expect(items.find((i) => i.name.includes("1 page"))?.status).toBe("supported");
|
|
expect(items.find((i) => i.name.includes("1 layout"))?.status).toBe("supported");
|
|
});
|
|
|
|
it("detects src/pages directory when pages/ is not at root", () => {
|
|
writeFile("src/pages/index.tsx", `export default function Home() { return <div/>; }`);
|
|
|
|
const items = checkConventions(tmpDir);
|
|
expect(items.find((i) => i.name === "Pages Router (src/pages/)")).toBeDefined();
|
|
expect(items.find((i) => i.name.includes("1 page"))?.status).toBe("supported");
|
|
});
|
|
|
|
it("prefers root-level app/ over src/app/", () => {
|
|
writeFile("app/page.tsx", `export default function Home() { return <div/>; }`);
|
|
writeFile("src/app/page.tsx", `export default function Home() { return <div/>; }`);
|
|
|
|
const items = checkConventions(tmpDir);
|
|
expect(items.find((i) => i.name === "App Router (app/)")).toBeDefined();
|
|
// src/app/ should also be detected (both exist)
|
|
});
|
|
|
|
it("reports unsupported when no pages/ or app/ directory", () => {
|
|
writeFile("src/index.ts", `console.log("hi");`);
|
|
|
|
const items = checkConventions(tmpDir);
|
|
expect(items.find((i) => i.status === "unsupported")).toBeDefined();
|
|
expect(items.find((i) => i.name.includes("No pages/ or app/"))).toBeDefined();
|
|
});
|
|
|
|
it("counts API routes separately", () => {
|
|
writeFile("pages/index.tsx", `export default function Home() { return <div/>; }`);
|
|
writeFile("pages/api/hello.ts", `export default function handler(req, res) { res.json({}) }`);
|
|
writeFile("pages/api/users.ts", `export default function handler(req, res) { res.json({}) }`);
|
|
|
|
const items = checkConventions(tmpDir);
|
|
expect(items.find((i) => i.name.includes("2 API route"))).toBeDefined();
|
|
});
|
|
|
|
it("detects custom _app and _document", () => {
|
|
writeFile("pages/index.tsx", `export default function Home() { return <div/>; }`);
|
|
writeFile(
|
|
"pages/_app.tsx",
|
|
`export default function App({ Component, pageProps }) { return <Component {...pageProps} /> }`,
|
|
);
|
|
writeFile("pages/_document.tsx", `export default function Document() {}`);
|
|
|
|
const items = checkConventions(tmpDir);
|
|
expect(items.find((i) => i.name === "Custom _app")?.status).toBe("supported");
|
|
expect(items.find((i) => i.name === "Custom _document")?.status).toBe("supported");
|
|
});
|
|
|
|
it("detects App Router conventions (loading, error, not-found)", () => {
|
|
writeFile("app/page.tsx", `export default function Home() { return <div/>; }`);
|
|
writeFile(
|
|
"app/layout.tsx",
|
|
`export default function Layout({ children }) { return <html><body>{children}</body></html>; }`,
|
|
);
|
|
writeFile(
|
|
"app/loading.tsx",
|
|
`export default function Loading() { return <div>Loading...</div>; }`,
|
|
);
|
|
writeFile(
|
|
"app/error.tsx",
|
|
`"use client"; export default function Error() { return <div>Error</div>; }`,
|
|
);
|
|
writeFile(
|
|
"app/not-found.tsx",
|
|
`export default function NotFound() { return <div>Not Found</div>; }`,
|
|
);
|
|
|
|
const items = checkConventions(tmpDir);
|
|
expect(items.find((i) => i.name.includes("loading"))?.status).toBe("supported");
|
|
expect(items.find((i) => i.name.includes("error"))?.status).toBe("supported");
|
|
expect(items.find((i) => i.name.includes("not-found"))?.status).toBe("supported");
|
|
});
|
|
|
|
it("detects route handlers in App Router", () => {
|
|
writeFile("app/page.tsx", `export default function Home() { return <div/>; }`);
|
|
writeFile(
|
|
"app/api/hello/route.ts",
|
|
`export function GET() { return Response.json({ hello: "world" }); }`,
|
|
);
|
|
|
|
const items = checkConventions(tmpDir);
|
|
expect(items.find((i) => i.name.includes("1 route handler"))).toBeDefined();
|
|
});
|
|
|
|
it("flags missing type:module in package.json", () => {
|
|
writeFile("app/page.tsx", `export default function Home() { return <div/>; }`);
|
|
writeFile("package.json", JSON.stringify({ dependencies: { react: "^19.0.0" } }));
|
|
|
|
const items = checkConventions(tmpDir);
|
|
const typeModule = items.find((i) => i.name.includes('"type": "module"'));
|
|
expect(typeModule).toBeDefined();
|
|
expect(typeModule?.status).toBe("unsupported");
|
|
expect(typeModule?.detail).toContain("vinext init");
|
|
});
|
|
|
|
it("does not flag type:module when present", () => {
|
|
writeFile("app/page.tsx", `export default function Home() { return <div/>; }`);
|
|
writeFile(
|
|
"package.json",
|
|
JSON.stringify({ type: "module", dependencies: { react: "^19.0.0" } }),
|
|
);
|
|
|
|
const items = checkConventions(tmpDir);
|
|
const typeModule = items.find((i) => i.name.includes('"type": "module"'));
|
|
expect(typeModule).toBeUndefined();
|
|
});
|
|
|
|
it("detects ViewTransition import from react", () => {
|
|
writeFile(
|
|
"app/page.tsx",
|
|
`import { ViewTransition } from "react";\nexport default function Home() { return <ViewTransition><div/></ViewTransition>; }`,
|
|
);
|
|
|
|
const items = checkConventions(tmpDir);
|
|
const vt = items.find((i) => i.name.includes("ViewTransition"));
|
|
expect(vt).toBeDefined();
|
|
expect(vt?.status).toBe("partial");
|
|
expect(vt?.detail).toContain("passthrough fallback");
|
|
expect(vt?.files).toHaveLength(1);
|
|
});
|
|
|
|
it("does not flag ViewTransition when not imported", () => {
|
|
writeFile(
|
|
"app/page.tsx",
|
|
`import React from "react";\nexport default function Home() { return <div/>; }`,
|
|
);
|
|
|
|
const items = checkConventions(tmpDir);
|
|
const vt = items.find((i) => i.name.includes("ViewTransition"));
|
|
expect(vt).toBeUndefined();
|
|
});
|
|
|
|
it("detects PostCSS string-form plugins", () => {
|
|
writeFile("app/page.tsx", `export default function Home() { return <div/>; }`);
|
|
writeFile("postcss.config.mjs", `export default {\n plugins: ["@tailwindcss/postcss"]\n};`);
|
|
|
|
const items = checkConventions(tmpDir);
|
|
const postcss = items.find((i) => i.name.includes("PostCSS"));
|
|
expect(postcss).toBeDefined();
|
|
expect(postcss?.status).toBe("partial");
|
|
expect(postcss?.detail).toContain("string-form");
|
|
});
|
|
|
|
it("does not flag PostCSS when no config exists", () => {
|
|
writeFile("app/page.tsx", `export default function Home() { return <div/>; }`);
|
|
|
|
const items = checkConventions(tmpDir);
|
|
const postcss = items.find((i) => i.name.includes("PostCSS"));
|
|
expect(postcss).toBeUndefined();
|
|
});
|
|
|
|
it("detects multiline PostCSS string-form plugins", () => {
|
|
writeFile("app/page.tsx", `export default function Home() { return <div/>; }`);
|
|
writeFile(
|
|
"postcss.config.mjs",
|
|
`export default {\n plugins: [\n "@tailwindcss/postcss",\n "autoprefixer",\n ],\n};`,
|
|
);
|
|
|
|
const items = checkConventions(tmpDir);
|
|
const postcss = items.find((i) => i.name.includes("PostCSS"));
|
|
expect(postcss?.status).toBe("partial");
|
|
});
|
|
|
|
it("does not flag require()-form PostCSS plugins", () => {
|
|
writeFile("app/page.tsx", `export default function Home() { return <div/>; }`);
|
|
writeFile(
|
|
"postcss.config.cjs",
|
|
`module.exports = {\n plugins: [require("@tailwindcss/postcss"), require("autoprefixer")]\n};`,
|
|
);
|
|
|
|
const items = checkConventions(tmpDir);
|
|
const postcss = items.find((i) => i.name.includes("PostCSS"));
|
|
expect(postcss).toBeUndefined();
|
|
});
|
|
|
|
// Regression: a very large config whose `plugins: [` array is never closed used to
|
|
// send the old `/plugins\s*:\s*\[[\s\S]*?(['"]…['"])[\s\S]*?\]/` regex into quadratic
|
|
// backtracking, hanging the process / overflowing the regex stack. The anchored
|
|
// replacement runs in linear time, so this must complete near-instantly.
|
|
it("handles a huge unterminated PostCSS plugins array without hanging", () => {
|
|
writeFile("app/page.tsx", `export default function Home() { return <div/>; }`);
|
|
// ~1.2MB of quoted entries inside an array that is never closed with `]`.
|
|
const huge = `export default {\n plugins: [\n` + ` "plugin-x",\n`.repeat(200_000);
|
|
writeFile("postcss.config.mjs", huge);
|
|
|
|
const start = Date.now();
|
|
const items = checkConventions(tmpDir);
|
|
const elapsed = Date.now() - start;
|
|
|
|
// The first element is a bare string, so it is still correctly flagged...
|
|
const postcss = items.find((i) => i.name.includes("PostCSS"));
|
|
expect(postcss?.status).toBe("partial");
|
|
// ...and crucially it returns quickly instead of backtracking for minutes.
|
|
expect(elapsed).toBeLessThan(2000);
|
|
});
|
|
|
|
// Regression: the catastrophic case for the old regex — a large array with quoted
|
|
// tokens but no closing `]`, where the trailing `[\s\S]*?\]` forced repeated full
|
|
// re-scans. The anchored regex resolves this in a single linear pass.
|
|
it("handles a huge unterminated array with no leading string quickly", () => {
|
|
writeFile("app/page.tsx", `export default function Home() { return <div/>; }`);
|
|
// No leading quote after `[` (require-style head) then a huge unterminated tail.
|
|
const huge =
|
|
`export default {\n plugins: [\n require("a"),\n` + ` require("x"),\n`.repeat(200_000);
|
|
writeFile("postcss.config.mjs", huge);
|
|
|
|
const start = Date.now();
|
|
const items = checkConventions(tmpDir);
|
|
const elapsed = Date.now() - start;
|
|
|
|
// require()-style head → not flagged as string-form.
|
|
const postcss = items.find((i) => i.name.includes("PostCSS"));
|
|
expect(postcss).toBeUndefined();
|
|
expect(elapsed).toBeLessThan(2000);
|
|
});
|
|
|
|
it("detects __dirname usage in server files", () => {
|
|
writeFile("lib/db.ts", `import path from "path";\nconst dir = path.join(__dirname, "data");`);
|
|
writeFile("app/page.tsx", `export default function Home() { return <div/>; }`);
|
|
|
|
const items = checkConventions(tmpDir);
|
|
const cjs = items.find((i) => i.name.includes("__dirname"));
|
|
expect(cjs).toBeDefined();
|
|
expect(cjs?.status).toBe("unsupported");
|
|
expect(cjs?.detail).toContain("fileURLToPath");
|
|
expect(cjs?.detail).toContain("import.meta.dirname");
|
|
expect(cjs?.files).toContain("lib/db.ts");
|
|
});
|
|
|
|
it("ignores CJS globals in test modules and tool config files", () => {
|
|
writeFile(
|
|
"src/app/mobile-layout-alignment.test.ts",
|
|
`const css = readFileSync(resolve(__dirname, "film.module.css"), "utf-8");`,
|
|
);
|
|
writeFile("vitest.config.ts", `export default { root: path.resolve(__dirname, "./src") };`);
|
|
writeFile("app/page.tsx", `export default function Home() { return <div/>; }`);
|
|
|
|
const items = checkConventions(tmpDir);
|
|
const cjs = items.find((i) => i.name.includes("__dirname"));
|
|
|
|
expect(cjs).toBeUndefined();
|
|
});
|
|
|
|
it("still reports CJS globals in runtime source alongside excluded files", () => {
|
|
writeFile("lib/db.ts", `const dir = path.join(__dirname, "data");`);
|
|
writeFile("site.config.ts", `const root = path.join(__dirname, "content");`);
|
|
writeFile("lib/db.spec.ts", `const fixture = path.join(__dirname, "fixtures");`);
|
|
writeFile("vitest.config.ts", `export default { root: path.resolve(__dirname, "./src") };`);
|
|
writeFile("app/page.tsx", `export default function Home() { return <div/>; }`);
|
|
|
|
const items = checkConventions(tmpDir);
|
|
const cjs = items.find((i) => i.name.includes("__dirname"));
|
|
|
|
expect(cjs?.files).toEqual(["lib/db.ts", "site.config.ts"]);
|
|
});
|
|
|
|
it("detects __filename usage", () => {
|
|
writeFile("lib/logger.ts", `const file = __filename;`);
|
|
writeFile("app/page.tsx", `export default function Home() { return <div/>; }`);
|
|
|
|
const items = checkConventions(tmpDir);
|
|
const cjs = items.find((i) => i.name.includes("__dirname"));
|
|
expect(cjs).toBeDefined();
|
|
expect(cjs?.files).toContain("lib/logger.ts");
|
|
});
|
|
|
|
it("detects both __dirname and __filename in same file", () => {
|
|
writeFile("lib/util.ts", `const dir = __dirname;\nconst file = __filename;`);
|
|
writeFile("app/page.tsx", `export default function Home() { return <div/>; }`);
|
|
|
|
const items = checkConventions(tmpDir);
|
|
const cjs = items.find((i) => i.name.includes("__dirname"));
|
|
expect(cjs).toBeDefined();
|
|
expect(cjs?.files).toContain("lib/util.ts");
|
|
// Only one item for both globals
|
|
expect(
|
|
items.filter((i) => i.name.includes("__dirname") || i.name.includes("__filename")),
|
|
).toHaveLength(1);
|
|
});
|
|
|
|
it("does not flag __dirname inside string literals", () => {
|
|
writeFile(
|
|
"lib/comment.ts",
|
|
`const msg = "use __dirname instead";\nexport default function Home() { return null; }`,
|
|
);
|
|
writeFile("app/page.tsx", `export default function Home() { return <div/>; }`);
|
|
|
|
const items = checkConventions(tmpDir);
|
|
const cjs = items.find((i) => i.name.includes("__dirname"));
|
|
expect(cjs).toBeUndefined();
|
|
});
|
|
|
|
it("does not flag __dirname inside comments", () => {
|
|
writeFile("lib/note.ts", `// Previously used __dirname here\nexport const x = 1;`);
|
|
writeFile("app/page.tsx", `export default function Home() { return <div/>; }`);
|
|
|
|
const items = checkConventions(tmpDir);
|
|
const cjs = items.find((i) => i.name.includes("__dirname"));
|
|
expect(cjs).toBeUndefined();
|
|
});
|
|
|
|
it("does not flag __dirname inside a plain template literal (no interpolation)", () => {
|
|
writeFile("lib/msg.ts", "const msg = `use __dirname instead`;");
|
|
writeFile("app/page.tsx", `export default function Home() { return <div/>; }`);
|
|
|
|
const items = checkConventions(tmpDir);
|
|
const cjs = items.find((i) => i.name.includes("__dirname"));
|
|
expect(cjs).toBeUndefined();
|
|
});
|
|
|
|
it("detects __dirname inside a template expression ${...}", () => {
|
|
writeFile("lib/db.ts", "const dir = `${__dirname}/views`;");
|
|
writeFile("app/page.tsx", `export default function Home() { return <div/>; }`);
|
|
|
|
const items = checkConventions(tmpDir);
|
|
const cjs = items.find((i) => i.name.includes("__dirname"));
|
|
expect(cjs).toBeDefined();
|
|
expect(cjs?.files).toContain("lib/db.ts");
|
|
});
|
|
|
|
it("does not flag __dirname when not used at all", () => {
|
|
writeFile(
|
|
"lib/esm.ts",
|
|
`import { fileURLToPath } from "url";\nimport { dirname } from "path";\nconst __dirname = dirname(fileURLToPath(import.meta.url));`,
|
|
);
|
|
writeFile("app/page.tsx", `export default function Home() { return <div/>; }`);
|
|
|
|
// The ESM pattern itself reassigns __dirname — this is fine and should not be flagged
|
|
// because users are already using the correct ESM idiom.
|
|
// Our scanner will see `__dirname` in the assignment target — that's an edge case we accept.
|
|
// This test just ensures we don't crash.
|
|
const items = checkConventions(tmpDir);
|
|
// No assertion on presence/absence — just verify it doesn't throw
|
|
expect(Array.isArray(items)).toBe(true);
|
|
});
|
|
|
|
it("tracks multiple files that use __dirname", () => {
|
|
writeFile("lib/a.ts", `const d = __dirname;`);
|
|
writeFile("lib/b.ts", `const f = __filename;`);
|
|
writeFile("app/page.tsx", `export default function Home() { return <div/>; }`);
|
|
|
|
const items = checkConventions(tmpDir);
|
|
const cjs = items.find((i) => i.name.includes("__dirname"));
|
|
expect(cjs).toBeDefined();
|
|
expect(cjs?.files).toHaveLength(2);
|
|
expect(cjs?.files).toContain("lib/a.ts");
|
|
expect(cjs?.files).toContain("lib/b.ts");
|
|
});
|
|
});
|
|
|
|
// ── hasFreeCjsGlobal ─────────────────────────────────────────────────────────
|
|
|
|
describe("hasFreeCjsGlobal", () => {
|
|
it("detects free __dirname / __filename in code", () => {
|
|
expect(hasFreeCjsGlobal(`const d = __dirname;`)).toBe(true);
|
|
expect(hasFreeCjsGlobal(`const f = __filename;`)).toBe(true);
|
|
expect(hasFreeCjsGlobal(`path.join(__dirname, "data")`)).toBe(true);
|
|
});
|
|
|
|
it("ignores occurrences inside strings, comments and plain templates", () => {
|
|
expect(hasFreeCjsGlobal(`const m = "use __dirname instead";`)).toBe(false);
|
|
expect(hasFreeCjsGlobal(`const m = 'use __dirname instead';`)).toBe(false);
|
|
expect(hasFreeCjsGlobal(`// previously used __dirname here`)).toBe(false);
|
|
expect(hasFreeCjsGlobal(`/* block __dirname comment */`)).toBe(false);
|
|
expect(hasFreeCjsGlobal("const m = `use __dirname instead`;")).toBe(false);
|
|
});
|
|
|
|
it("detects __dirname inside a template expression", () => {
|
|
expect(hasFreeCjsGlobal("const dir = `${__dirname}/views`;")).toBe(true);
|
|
// nested template inside the expression, real use deeper in
|
|
expect(hasFreeCjsGlobal("const x = `a${ `b${__filename}c` }d`;")).toBe(true);
|
|
// __dirname only appears in the inner plain-template text → not a free use
|
|
expect(hasFreeCjsGlobal("const x = `a${ `__dirname` }d`;")).toBe(false);
|
|
});
|
|
|
|
it("does not match identifiers that merely contain the substring", () => {
|
|
expect(hasFreeCjsGlobal(`const my__dirname = 1;`)).toBe(false);
|
|
expect(hasFreeCjsGlobal(`const __dirnameSuffix = 1;`)).toBe(false);
|
|
});
|
|
|
|
it("does not let a regex literal hide a later __dirname", () => {
|
|
// A stray quote/backtick inside a regex literal must not hijack string/template
|
|
// state and swallow real code that follows it.
|
|
expect(hasFreeCjsGlobal(`const r = /'/; const d = __dirname;`)).toBe(true);
|
|
expect(hasFreeCjsGlobal("const r = /`/;\nconst d = __dirname;")).toBe(true);
|
|
expect(hasFreeCjsGlobal("const r = /['\"`]/; const d = __filename;")).toBe(true);
|
|
// `/` after a `return` keyword is a regex; the `__dirname` after still counts.
|
|
expect(hasFreeCjsGlobal(`function f() { return /'/; }\nconst d = __dirname;`)).toBe(true);
|
|
});
|
|
|
|
it("does not treat division as a regex literal", () => {
|
|
// `/` after a value is division, not a regex — the second operand still scans.
|
|
expect(hasFreeCjsGlobal(`const x = a / b; const d = __dirname;`)).toBe(true);
|
|
expect(hasFreeCjsGlobal(`const x = total / __dirname.length;`)).toBe(true);
|
|
});
|
|
|
|
it("ignores __dirname inside a regex literal", () => {
|
|
expect(hasFreeCjsGlobal(`const r = /__dirname/; const x = 1;`)).toBe(false);
|
|
});
|
|
|
|
it("does not misread division after } or postfix ++/-- as a regex literal", () => {
|
|
// Division after a postfix `++`/`--` or a `}` must not be parsed as a regex
|
|
// literal (which would swallow the rest of the line and hide the __dirname).
|
|
expect(hasFreeCjsGlobal("i++ / 2; const d = __dirname;")).toBe(true);
|
|
expect(hasFreeCjsGlobal("i-- / 2; const d = __dirname;")).toBe(true);
|
|
expect(hasFreeCjsGlobal("const x = {a:1} / 2; const d = __dirname;")).toBe(true);
|
|
expect(hasFreeCjsGlobal("i++ / __dirname / b; z;")).toBe(true);
|
|
expect(hasFreeCjsGlobal("const half = list.pop() ? a-- / 2 : 0; const root = __dirname;")).toBe(
|
|
true,
|
|
);
|
|
});
|
|
|
|
it("still parses a real regex after a prefix ++ or keyword", () => {
|
|
// Prefix `++i` keeps operator position; the regex that follows is still a regex.
|
|
expect(hasFreeCjsGlobal("if (++i) { return /'/; }\nconst d = __dirname;")).toBe(true);
|
|
});
|
|
|
|
// Known limitation (documented on hasFreeCjsGlobal): distinguishing a value-position
|
|
// regex literal from division after `}` needs a real parser. We bias to division, so
|
|
// a statement-start regex after a block `}` whose body has an unpaired quote can mask
|
|
// a same-line __dirname. Accepted for an advisory check; pinned here so the gap is
|
|
// explicit. The multi-line variant is unaffected (string scanning stops at \n).
|
|
it("does not detect a __dirname hidden by a value-position regex on the same line", () => {
|
|
expect(hasFreeCjsGlobal("function f(){} /'/.test(x); const d = __dirname;")).toBe(false);
|
|
});
|
|
|
|
it("still detects __dirname on a later line after a value-position regex", () => {
|
|
expect(hasFreeCjsGlobal("function f(){} /'/.test(x);\nconst d = __dirname;")).toBe(true);
|
|
});
|
|
|
|
// Regression: the old alternation regex's `(?:[^"\\]|\\.)*` string-body loop
|
|
// overflowed V8's regex stack ("Maximum call stack size exceeded") on very large
|
|
// files. These inputs reproduce that; the scanner must return in linear time.
|
|
it("handles a multi-megabyte unterminated string literal without overflowing", () => {
|
|
const content = `const s = "` + "a".repeat(20_000_000); // ~20MB, no closing quote
|
|
const start = Date.now();
|
|
expect(hasFreeCjsGlobal(content)).toBe(false);
|
|
expect(Date.now() - start).toBeLessThan(2000);
|
|
});
|
|
|
|
it("handles an escape-heavy unterminated string without overflowing", () => {
|
|
// Unrolling the regex did not help this shape — it still overflowed. The scanner does not.
|
|
const content = `const s = "` + 'a\\"'.repeat(10_000_000); // ~30MB of escaped quotes
|
|
const start = Date.now();
|
|
expect(hasFreeCjsGlobal(content)).toBe(false);
|
|
expect(Date.now() - start).toBeLessThan(3000);
|
|
});
|
|
|
|
it("still finds a real __dirname after a huge benign string", () => {
|
|
const content = `const s = "${"x".repeat(5_000_000)}";\nconst d = __dirname;`;
|
|
const start = Date.now();
|
|
expect(hasFreeCjsGlobal(content)).toBe(true);
|
|
expect(Date.now() - start).toBeLessThan(2000);
|
|
});
|
|
});
|
|
|
|
// ── runCheck ───────────────────────────────────────────────────────────────
|
|
|
|
describe("runCheck", () => {
|
|
it("returns a complete result with all sections", () => {
|
|
writeFile("app/page.tsx", `import Link from "next/link";`);
|
|
writeFile(
|
|
"app/layout.tsx",
|
|
`export default function Layout({ children }) { return <html><body>{children}</body></html>; }`,
|
|
);
|
|
writeFile(
|
|
"package.json",
|
|
JSON.stringify({ type: "module", dependencies: { tailwindcss: "^3.0.0" } }),
|
|
);
|
|
|
|
const result = runCheck(tmpDir);
|
|
expect(result.imports).toBeDefined();
|
|
expect(result.config).toBeDefined();
|
|
expect(result.libraries).toBeDefined();
|
|
expect(result.conventions).toBeDefined();
|
|
expect(result.summary).toBeDefined();
|
|
});
|
|
|
|
it("calculates score correctly — 100% for all supported", () => {
|
|
writeFile("app/page.tsx", `import Link from "next/link";`);
|
|
writeFile(
|
|
"app/layout.tsx",
|
|
`export default function Layout({ children }) { return <html><body>{children}</body></html>; }`,
|
|
);
|
|
writeFile(
|
|
"package.json",
|
|
JSON.stringify({ type: "module", dependencies: { tailwindcss: "^3.0.0" } }),
|
|
);
|
|
|
|
const result = runCheck(tmpDir);
|
|
// All items should be supported: next/link, no config file, tailwindcss, App Router, 1 page, 1 layout
|
|
expect(result.summary.unsupported).toBe(0);
|
|
expect(result.summary.score).toBe(100);
|
|
});
|
|
|
|
it("calculates score correctly — partial items count 50%", () => {
|
|
// 1 supported import (next/link) + 1 partial import (next/font/google) + no-config (supported) + 2 conventions (App Router + 1 page)
|
|
writeFile(
|
|
"app/page.tsx",
|
|
`
|
|
import Link from "next/link";
|
|
import { GoogleFont } from "next/font/google";
|
|
`,
|
|
);
|
|
|
|
const result = runCheck(tmpDir);
|
|
expect(result.summary.partial).toBeGreaterThan(0);
|
|
expect(result.summary.score).toBeLessThan(100);
|
|
expect(result.summary.score).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("calculates score correctly — unsupported items drag score down", () => {
|
|
writeFile(
|
|
"app/page.tsx",
|
|
`
|
|
import { useAmp } from "next/amp";
|
|
`,
|
|
);
|
|
writeFile("next.config.mjs", `export default { webpack: (config) => config };`);
|
|
writeFile(
|
|
"package.json",
|
|
JSON.stringify({ type: "module", dependencies: { "next-auth": "^4.0.0" } }),
|
|
);
|
|
|
|
const result = runCheck(tmpDir);
|
|
expect(result.summary.unsupported).toBeGreaterThan(0);
|
|
expect(result.summary.score).toBeLessThan(100);
|
|
});
|
|
|
|
it("reports correct totals", () => {
|
|
writeFile("app/page.tsx", `import Link from "next/link";`);
|
|
writeFile(
|
|
"package.json",
|
|
JSON.stringify({ type: "module", dependencies: { tailwindcss: "^3.0.0" } }),
|
|
);
|
|
|
|
const result = runCheck(tmpDir);
|
|
const total = result.summary.supported + result.summary.partial + result.summary.unsupported;
|
|
expect(total).toBe(result.summary.total);
|
|
});
|
|
|
|
it("returns 100% score for empty project with no pages or app", () => {
|
|
// Empty project — only an unsupported "no pages/ or app/" item
|
|
writeFile("src/index.ts", `console.log("hi");`);
|
|
|
|
const result = runCheck(tmpDir);
|
|
// Should have 1 unsupported item (no pages/app directory)
|
|
expect(result.summary.unsupported).toBe(1);
|
|
expect(result.summary.score).toBeLessThan(100);
|
|
});
|
|
|
|
it("calculates score correctly for src/app project", () => {
|
|
writeFile("src/app/page.tsx", `import Link from "next/link";`);
|
|
writeFile(
|
|
"src/app/layout.tsx",
|
|
`export default function Layout({ children }) { return <html><body>{children}</body></html>; }`,
|
|
);
|
|
writeFile(
|
|
"package.json",
|
|
JSON.stringify({ type: "module", dependencies: { tailwindcss: "^3.0.0" } }),
|
|
);
|
|
|
|
const result = runCheck(tmpDir);
|
|
expect(result.summary.unsupported).toBe(0);
|
|
expect(result.summary.score).toBe(100);
|
|
});
|
|
});
|
|
|
|
// ── formatReport ───────────────────────────────────────────────────────────
|
|
|
|
describe("formatReport", () => {
|
|
it("produces a string with section headers", () => {
|
|
writeFile(
|
|
"app/page.tsx",
|
|
`
|
|
import Link from "next/link";
|
|
import { GoogleFont } from "next/font/google";
|
|
`,
|
|
);
|
|
writeFile(
|
|
"package.json",
|
|
JSON.stringify({ type: "module", dependencies: { tailwindcss: "^3.0.0" } }),
|
|
);
|
|
|
|
const result = runCheck(tmpDir);
|
|
const report = formatReport(result);
|
|
|
|
expect(report).toContain("vinext compatibility report");
|
|
expect(report).toContain("Imports");
|
|
expect(report).toContain("Libraries");
|
|
expect(report).toContain("Project structure");
|
|
expect(report).toContain("Overall");
|
|
expect(report).toContain("% compatible");
|
|
});
|
|
|
|
it("shows issues section when there are unsupported items", () => {
|
|
writeFile("app/page.tsx", `import { useAmp } from "next/amp";`);
|
|
writeFile("package.json", JSON.stringify({ type: "module", dependencies: {} }));
|
|
|
|
const result = runCheck(tmpDir);
|
|
const report = formatReport(result);
|
|
|
|
expect(report).toContain("Issues to address");
|
|
expect(report).toContain("next/amp");
|
|
});
|
|
|
|
it("lists affected files under unsupported items in issues section", () => {
|
|
writeFile("lib/db.ts", `const dir = path.join(__dirname, "data");`);
|
|
writeFile("app/page.tsx", `export default function Home() { return <div/>; }`);
|
|
writeFile("package.json", JSON.stringify({ type: "module", dependencies: {} }));
|
|
|
|
const result = runCheck(tmpDir);
|
|
const report = formatReport(result);
|
|
|
|
expect(report).toContain("Issues to address");
|
|
expect(report).toContain("__dirname");
|
|
expect(report).toContain("lib/db.ts");
|
|
});
|
|
|
|
it("shows partial support section when there are partial items", () => {
|
|
writeFile("app/page.tsx", `import { GoogleFont } from "next/font/google";`);
|
|
writeFile("package.json", JSON.stringify({ type: "module", dependencies: {} }));
|
|
|
|
const result = runCheck(tmpDir);
|
|
const report = formatReport(result);
|
|
|
|
expect(report).toContain("Partial support");
|
|
expect(report).toContain("next/font/google");
|
|
});
|
|
|
|
it("does not show issues section when everything is supported", () => {
|
|
writeFile("app/page.tsx", `import Link from "next/link";`);
|
|
writeFile(
|
|
"app/layout.tsx",
|
|
`export default function Layout({ children }) { return <html><body>{children}</body></html>; }`,
|
|
);
|
|
writeFile(
|
|
"package.json",
|
|
JSON.stringify({ type: "module", dependencies: { tailwindcss: "^3.0.0" } }),
|
|
);
|
|
|
|
const result = runCheck(tmpDir);
|
|
const report = formatReport(result);
|
|
|
|
expect(report).not.toContain("Issues to address");
|
|
expect(report).not.toContain("Partial support");
|
|
});
|
|
|
|
it("includes file count for imports", () => {
|
|
writeFile("app/page.tsx", `import Link from "next/link";`);
|
|
writeFile("app/about/page.tsx", `import Link from "next/link";`);
|
|
writeFile("package.json", JSON.stringify({ type: "module", dependencies: {} }));
|
|
|
|
const result = runCheck(tmpDir);
|
|
const report = formatReport(result);
|
|
|
|
expect(report).toContain("2 files");
|
|
});
|
|
|
|
it("handles empty result gracefully", () => {
|
|
const emptyResult: CheckResult = {
|
|
imports: [],
|
|
config: [],
|
|
libraries: [],
|
|
conventions: [],
|
|
summary: { supported: 0, partial: 0, unsupported: 0, total: 0, score: 100 },
|
|
};
|
|
|
|
const report = formatReport(emptyResult);
|
|
expect(report).toContain("vinext compatibility report");
|
|
expect(report).toContain("100% compatible");
|
|
});
|
|
|
|
it("includes actionable next steps", () => {
|
|
writeFile("app/page.tsx", `import Link from "next/link";`);
|
|
writeFile("package.json", JSON.stringify({ type: "module", dependencies: {} }));
|
|
|
|
const result = runCheck(tmpDir);
|
|
const report = formatReport(result);
|
|
|
|
expect(report).toContain("Recommended next steps");
|
|
expect(report).toContain("vinext init");
|
|
expect(report).toContain("Or manually");
|
|
expect(report).toContain('"type": "module"');
|
|
expect(report).toContain("@vitejs/plugin-react");
|
|
expect(report).toContain("@vitejs/plugin-rsc");
|
|
expect(report).toContain("react-server-dom-webpack");
|
|
expect(report).toContain("vite.config.ts");
|
|
expect(report).toContain("npx vite dev");
|
|
});
|
|
|
|
it("does not list App Router-only packages in manual install steps for Pages Router projects", () => {
|
|
writeFile("pages/index.tsx", `export default function Home() { return <div />; }`);
|
|
writeFile("package.json", JSON.stringify({ type: "module", dependencies: {} }));
|
|
|
|
const result = runCheck(tmpDir);
|
|
const report = formatReport(result);
|
|
|
|
expect(report).toContain("@vitejs/plugin-react");
|
|
expect(report).not.toContain("@vitejs/plugin-rsc");
|
|
expect(report).not.toContain("react-server-dom-webpack");
|
|
});
|
|
});
|
|
|
|
// ── Integration: running against fixtures ──────────────────────────────────
|
|
|
|
describe("integration: pages-basic fixture", () => {
|
|
const fixtureDir = path.resolve(import.meta.dirname, "./fixtures/pages-basic");
|
|
|
|
it("detects Pages Router conventions", () => {
|
|
const items = checkConventions(fixtureDir);
|
|
expect(items.find((i) => i.name === "Pages Router (pages/)")).toBeDefined();
|
|
});
|
|
|
|
it("detects config options from next.config.mjs", () => {
|
|
const items = analyzeConfig(fixtureDir);
|
|
expect(items.find((i) => i.name === "redirects")).toBeDefined();
|
|
expect(items.find((i) => i.name === "rewrites")).toBeDefined();
|
|
expect(items.find((i) => i.name === "headers")).toBeDefined();
|
|
expect(items.find((i) => i.name === "env")).toBeDefined();
|
|
});
|
|
|
|
it("runCheck produces a valid report", () => {
|
|
const result = runCheck(fixtureDir);
|
|
expect(result.summary.total).toBeGreaterThan(0);
|
|
expect(result.summary.score).toBeGreaterThanOrEqual(0);
|
|
expect(result.summary.score).toBeLessThanOrEqual(100);
|
|
});
|
|
});
|
|
|
|
describe("integration: app-basic fixture", () => {
|
|
const fixtureDir = path.resolve(import.meta.dirname, "./fixtures/app-basic");
|
|
|
|
it("detects App Router conventions", () => {
|
|
const items = checkConventions(fixtureDir);
|
|
expect(items.find((i) => i.name === "App Router (app/)")).toBeDefined();
|
|
});
|
|
|
|
it("runCheck produces a valid report", () => {
|
|
const result = runCheck(fixtureDir);
|
|
expect(result.summary.total).toBeGreaterThan(0);
|
|
expect(result.summary.score).toBeGreaterThanOrEqual(0);
|
|
expect(result.summary.score).toBeLessThanOrEqual(100);
|
|
});
|
|
});
|