diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 73d5e044..a016f6d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -99,6 +99,14 @@ jobs: kill "$SERVER_PID" 2>/dev/null || true exit 1 + shim-coverage: + name: Shim Coverage + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup-node-pnpm + - run: pnpm dlx tsx scripts/check-shim-coverage.ts + e2e: name: E2E (${{ matrix.project }}) runs-on: ubuntu-latest diff --git a/.github/workflows/nextjs-api-track.yml b/.github/workflows/nextjs-api-track.yml new file mode 100644 index 00000000..8a612b54 --- /dev/null +++ b/.github/workflows/nextjs-api-track.yml @@ -0,0 +1,108 @@ +name: "Next.js API Tracking" + +on: + schedule: + - cron: "0 8 * * 1" # Monday 8am UTC + workflow_dispatch: # manual trigger + +permissions: + contents: read + issues: write + +concurrency: + group: nextjs-api-track + cancel-in-progress: true + +jobs: + track: + name: Check for API changes + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup-node-pnpm + + - name: Get latest stable Next.js version + id: next-version + run: | + LATEST=$(npm view next version) + CURRENT=$(node -e "console.log(JSON.parse(require('fs').readFileSync('api-manifest.json','utf-8')).version)") + echo "latest=$LATEST" >> "$GITHUB_OUTPUT" + echo "current=$CURRENT" >> "$GITHUB_OUTPUT" + if [ "$LATEST" = "$CURRENT" ]; then + echo "changed=false" >> "$GITHUB_OUTPUT" + else + echo "changed=true" >> "$GITHUB_OUTPUT" + fi + + - name: Extract new API manifest + if: steps.next-version.outputs.changed == 'true' + run: | + TMP_DIR=$(mktemp -d) + cd "$TMP_DIR" + TARBALL=$(npm pack "next@${{ steps.next-version.outputs.latest }}" --silent) + tar -xzf "$TARBALL" + pnpm dlx tsx "$GITHUB_WORKSPACE/scripts/extract-nextjs-api.ts" "$TMP_DIR/package" "$GITHUB_WORKSPACE/new-manifest.json" + + - name: Diff manifests + if: steps.next-version.outputs.changed == 'true' + id: diff + run: | + DIFF=$(pnpm dlx tsx scripts/diff-nextjs-api.ts api-manifest.json new-manifest.json 2>&1) || true + echo "diff<> "$GITHUB_OUTPUT" + echo "$DIFF" >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + + - name: Ensure tracking label exists + if: steps.next-version.outputs.changed == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh label create "next-api-tracking" --description "Automated Next.js API surface tracking" --color "0e8a16" 2>/dev/null || true + + - name: Open tracking issue + if: steps.next-version.outputs.changed == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NEXT_LATEST: ${{ steps.next-version.outputs.latest }} + NEXT_CURRENT: ${{ steps.next-version.outputs.current }} + API_DIFF: ${{ steps.diff.outputs.diff }} + REPO: ${{ github.repository }} + run: | + MARKER="" + EXISTING=$(gh issue list --state open --search "\"next-api-track: next@${NEXT_LATEST}\" in:body" --json number --jq '.[0].number // empty') + if [ -n "$EXISTING" ]; then + echo "Issue #$EXISTING already exists for next@$NEXT_LATEST, skipping" + exit 0 + fi + + read -r -d '' ISSUE_BODY <; // Per-module missing exports + coveredModules: string[]; // Successfully covered + gappedModules: string[]; // Intentionally skipped +} + +/** + * Extract exported names from a TypeScript/JavaScript source file. + * Uses regex-based parsing (not AST) for simplicity. + */ +export function extractExports(source: string): { + runtime: Set; + types: Set; +} { + const runtime = new Set(); + const types = new Set(); + + // export function X / export async function X + for (const m of source.matchAll(/export\s+(?:async\s+)?function\s+(\w+)/g)) { + runtime.add(m[1]); + } + + // export const/let/var X + for (const m of source.matchAll(/export\s+(?:const|let|var)\s+(\w+)/g)) { + runtime.add(m[1]); + } + + // export class X + for (const m of source.matchAll(/export\s+class\s+(\w+)/g)) { + runtime.add(m[1]); + } + + // export enum X + for (const m of source.matchAll(/export\s+enum\s+(\w+)/g)) { + runtime.add(m[1]); + } + + // export default (treat as "default") + if (/export\s+default\s/.test(source)) { + runtime.add("default"); + } + + // export { X, Y as Z } — adds X and Z (the exported name) + for (const m of source.matchAll(/export\s*\{([^}]+)\}/g)) { + const items = m[1].split(","); + for (const item of items) { + const trimmed = item.trim(); + if (!trimmed) continue; + const asMatch = trimmed.match(/\w+\s+as\s+(\w+)/); + const name = asMatch ? asMatch[1] : trimmed.split(/\s/)[0]; + // Check if it's a type export: export { type X } + if (trimmed.startsWith("type ")) { + types.add(name); + } else { + runtime.add(name); + } + } + } + + // export type X / export interface X + for (const m of source.matchAll(/export\s+(?:type|interface)\s+(\w+)/g)) { + types.add(m[1]); + } + + return { runtime, types }; +} + +/** + * Check coverage of manifest exports against shim implementations. + */ +export function checkModuleCoverage( + manifest: { modules: Record }, + registryKeys: Set, // keys from PUBLIC_SHIMS + shimExports: Record>, // module -> exported names from shim file + gaps: KnownGaps, +): CoverageResult { + const missingModules: string[] = []; + const missingExports: Record = {}; + const coveredModules: string[] = []; + const gappedModules: string[] = []; + + for (const [module, exports] of Object.entries(manifest.modules)) { + // Check if entirely gapped + if (gaps[module]?.exports.includes("*")) { + gappedModules.push(module); + continue; + } + + // Check if in registry + if (!registryKeys.has(module)) { + missingModules.push(module); + continue; + } + + // Check individual exports + const shimSet = shimExports[module] || new Set(); + const gapExports = new Set(gaps[module]?.exports || []); + const missing: string[] = []; + + for (const exp of exports) { + if (exp === "__esModule") continue; // skip internals + if (gapExports.has(exp)) continue; // intentionally skipped + if (!shimSet.has(exp)) { + missing.push(exp); + } + } + + if (missing.length > 0) { + missingExports[module] = missing.sort(); + } else { + coveredModules.push(module); + } + } + + return { + passed: missingModules.length === 0 && Object.keys(missingExports).length === 0, + missingModules: missingModules.sort(), + missingExports, + coveredModules: coveredModules.sort(), + gappedModules: gappedModules.sort(), + }; +} diff --git a/packages/vinext/src/shims/registry.ts b/packages/vinext/src/shims/registry.ts new file mode 100644 index 00000000..0015cfcd --- /dev/null +++ b/packages/vinext/src/shims/registry.ts @@ -0,0 +1,116 @@ +import path from "node:path"; + +/** + * Public next/* module shims — maps import specifier to filename relative to shimsDir. + * These are the user-facing modules that app code imports. + */ +export const PUBLIC_SHIMS: Record = { + "next/link": "link", + "next/head": "head", + "next/router": "router", + "next/compat/router": "compat-router", + "next/image": "image", + "next/legacy/image": "legacy-image", + "next/dynamic": "dynamic", + "next/app": "app", + "next/document": "document", + "next/config": "config", + "next/script": "script", + "next/server": "server", + "next/navigation": "navigation", + "next/headers": "headers", + "next/font/google": "font-google", + "next/font/local": "font-local", + "next/cache": "cache", + "next/form": "form", + "next/og": "og", + "next/web-vitals": "web-vitals", + "next/amp": "amp", + "next/error": "error", + "next/constants": "constants", +}; + +/** + * Internal next/dist/* paths used by popular libraries. + * Maps import specifier to filename relative to shimsDir. + */ +export const INTERNAL_SHIMS: Record = { + "next/dist/shared/lib/app-router-context.shared-runtime": "internal/app-router-context", + "next/dist/shared/lib/app-router-context": "internal/app-router-context", + "next/dist/shared/lib/router-context.shared-runtime": "internal/router-context", + "next/dist/shared/lib/utils": "internal/utils", + "next/dist/server/api-utils": "internal/api-utils", + "next/dist/server/web/spec-extension/cookies": "internal/cookies", + "next/dist/compiled/@edge-runtime/cookies": "internal/cookies", + "next/dist/server/app-render/work-unit-async-storage.external": + "internal/work-unit-async-storage", + "next/dist/client/components/work-unit-async-storage.external": + "internal/work-unit-async-storage", + "next/dist/client/components/request-async-storage.external": "internal/work-unit-async-storage", + "next/dist/client/components/request-async-storage": "internal/work-unit-async-storage", + "next/dist/client/components/navigation": "navigation", + "next/dist/server/config-shared": "internal/utils", +}; + +/** + * Non-next-prefixed shim entries resolved from shimsDir. + * Includes server-only/client-only markers and vinext internal modules. + */ +export const VINEXT_SHIMS_DIR_ENTRIES: Record = { + "server-only": "server-only", + "client-only": "client-only", + "vinext/error-boundary": "error-boundary", + "vinext/layout-segment-context": "layout-segment-context", + "vinext/metadata": "metadata", + "vinext/fetch-cache": "fetch-cache", + "vinext/cache-runtime": "cache-runtime", + "vinext/navigation-state": "navigation-state", + "vinext/router-state": "router-state", + "vinext/head-state": "head-state", +}; + +/** + * vinext server entries resolved from srcDir (NOT shimsDir). + * These use path.resolve instead of path.join with shimsDir. + */ +export const VINEXT_SERVER_ENTRIES: Record = { + "vinext/instrumentation": "server/instrumentation", + "vinext/html": "server/html", +}; + +/** + * Build the complete shim alias map with absolute paths. + * + * @param shimsDir - Absolute path to the shims directory + * @param srcDir - Absolute path to the src directory (for server entries) + * @param userAliases - User-provided aliases from nextConfig (applied first, overridden by vinext) + */ +export function buildShimMap( + shimsDir: string, + srcDir: string, + userAliases?: Record, +): Record { + const map: Record = { ...userAliases }; + + // Public shims (shimsDir-based) + for (const [key, value] of Object.entries(PUBLIC_SHIMS)) { + map[key] = path.join(shimsDir, value); + } + + // Internal shims (shimsDir-based) + for (const [key, value] of Object.entries(INTERNAL_SHIMS)) { + map[key] = path.join(shimsDir, value); + } + + // Vinext shimsDir entries + for (const [key, value] of Object.entries(VINEXT_SHIMS_DIR_ENTRIES)) { + map[key] = path.join(shimsDir, value); + } + + // Vinext server entries (srcDir-based) + for (const [key, value] of Object.entries(VINEXT_SERVER_ENTRIES)) { + map[key] = path.resolve(srcDir, value); + } + + return map; +} diff --git a/scripts/check-shim-coverage.ts b/scripts/check-shim-coverage.ts new file mode 100644 index 00000000..67d711fe --- /dev/null +++ b/scripts/check-shim-coverage.ts @@ -0,0 +1,71 @@ +/** + * Shim coverage CLI — compares api-manifest.json against our shim exports + * and known-gaps.json. Exits non-zero if there are uncovered modules/exports. + */ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + checkModuleCoverage, + extractExports, + type KnownGaps, +} from "../packages/vinext/src/shims/coverage.js"; +import { PUBLIC_SHIMS } from "../packages/vinext/src/shims/registry.js"; + +const __dirname = import.meta.dirname ?? path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(__dirname, ".."); +const manifestPath = path.join(root, "api-manifest.json"); +const gapsPath = path.join(root, "known-gaps.json"); +const shimsDir = path.join(root, "packages/vinext/src/shims"); + +// Load manifest +const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8")); + +// Load known gaps +const gaps: KnownGaps = JSON.parse(fs.readFileSync(gapsPath, "utf-8")); + +// Extract exports from each shim file +const shimExports: Record> = {}; +for (const [module, shimFile] of Object.entries(PUBLIC_SHIMS)) { + const fullPath = path.join(shimsDir, shimFile + ".ts"); + const tsxPath = path.join(shimsDir, shimFile + ".tsx"); + const filePath = fs.existsSync(fullPath) ? fullPath : fs.existsSync(tsxPath) ? tsxPath : null; + + if (filePath) { + const source = fs.readFileSync(filePath, "utf-8"); + const { runtime } = extractExports(source); + shimExports[module] = runtime; + } +} + +// Check coverage +const result = checkModuleCoverage(manifest, new Set(Object.keys(PUBLIC_SHIMS)), shimExports, gaps); + +// Report +console.log(`\nShim Coverage Report (next@${manifest.version})`); +console.log(`${"─".repeat(50)}`); +console.log(`Covered modules: ${result.coveredModules.length}`); +console.log(`Gapped modules: ${result.gappedModules.length}`); +console.log(`Missing modules: ${result.missingModules.length}`); +console.log(`Modules with missing exports: ${Object.keys(result.missingExports).length}`); + +if (result.missingModules.length > 0) { + console.log(`\nMissing modules (not in registry or known-gaps):`); + for (const mod of result.missingModules) { + console.log(` - ${mod}`); + } +} + +if (Object.keys(result.missingExports).length > 0) { + console.log(`\nMissing exports:`); + for (const [mod, exports] of Object.entries(result.missingExports)) { + console.log(` ${mod}: ${exports.join(", ")}`); + } +} + +if (!result.passed) { + console.log(`\nCoverage check FAILED`); + process.exit(1); +} else { + console.log(`\nCoverage check PASSED`); +} diff --git a/scripts/diff-nextjs-api.ts b/scripts/diff-nextjs-api.ts new file mode 100644 index 00000000..decd3c8a --- /dev/null +++ b/scripts/diff-nextjs-api.ts @@ -0,0 +1,156 @@ +import fs from "node:fs"; +import type { ApiManifest } from "./extract-nextjs-api.js"; + +export interface ManifestDiff { + added: Record; // new exports per module + removed: Record; // removed exports per module + newModules: string[]; // entirely new modules + removedModules: string[]; // entirely removed modules +} + +/** + * Load and parse a manifest JSON file. + */ +export function loadManifest(filePath: string): ApiManifest { + const raw = fs.readFileSync(filePath, "utf-8"); + return JSON.parse(raw) as ApiManifest; +} + +/** + * Diff two manifests. Returns added/removed exports and modules. + */ +export function diffManifests(oldManifest: ApiManifest, newManifest: ApiManifest): ManifestDiff { + const oldModules = new Set(Object.keys(oldManifest.modules)); + const newModules = new Set(Object.keys(newManifest.modules)); + + const newModulesList: string[] = []; + const removedModulesList: string[] = []; + const added: Record = {}; + const removed: Record = {}; + + // Find entirely new modules + for (const mod of newModules) { + if (!oldModules.has(mod)) { + newModulesList.push(mod); + } + } + + // Find entirely removed modules + for (const mod of oldModules) { + if (!newModules.has(mod)) { + removedModulesList.push(mod); + } + } + + // For modules that exist in both, find added/removed exports + for (const mod of newModules) { + if (!oldModules.has(mod)) continue; + const oldExports = new Set(oldManifest.modules[mod]); + const newExports = new Set(newManifest.modules[mod]); + + const addedExports: string[] = []; + for (const exp of newExports) { + if (!oldExports.has(exp)) { + addedExports.push(exp); + } + } + if (addedExports.length > 0) { + added[mod] = addedExports.sort(); + } + + const removedExports: string[] = []; + for (const exp of oldExports) { + if (!newExports.has(exp)) { + removedExports.push(exp); + } + } + if (removedExports.length > 0) { + removed[mod] = removedExports.sort(); + } + } + + return { + added, + removed, + newModules: newModulesList.sort(), + removedModules: removedModulesList.sort(), + }; +} + +/** + * Format a diff for human-readable output. + */ +function formatDiff(diff: ManifestDiff, oldVersion: string, newVersion: string): string { + const lines: string[] = []; + lines.push(`API diff: next@${oldVersion} → next@${newVersion}`); + lines.push(""); + + const isEmpty = + diff.newModules.length === 0 && + diff.removedModules.length === 0 && + Object.keys(diff.added).length === 0 && + Object.keys(diff.removed).length === 0; + + if (isEmpty) { + lines.push("No changes."); + return lines.join("\n"); + } + + if (diff.newModules.length > 0) { + lines.push("New modules:"); + for (const mod of diff.newModules) { + lines.push(` + ${mod}`); + } + lines.push(""); + } + + if (diff.removedModules.length > 0) { + lines.push("Removed modules:"); + for (const mod of diff.removedModules) { + lines.push(` - ${mod}`); + } + lines.push(""); + } + + if (Object.keys(diff.added).length > 0) { + lines.push("Added exports:"); + for (const [mod, exports] of Object.entries(diff.added).sort(([a], [b]) => + a.localeCompare(b), + )) { + for (const exp of exports) { + lines.push(` + ${mod}.${exp}`); + } + } + lines.push(""); + } + + if (Object.keys(diff.removed).length > 0) { + lines.push("Removed exports:"); + for (const [mod, exports] of Object.entries(diff.removed).sort(([a], [b]) => + a.localeCompare(b), + )) { + for (const exp of exports) { + lines.push(` - ${mod}.${exp}`); + } + } + lines.push(""); + } + + return lines.join("\n"); +} + +// CLI entry +if (process.argv[1] === import.meta.filename) { + const oldPath = process.argv[2]; + const newPath = process.argv[3]; + + if (!oldPath || !newPath) { + console.error("Usage: tsx scripts/diff-nextjs-api.ts "); + process.exit(1); + } + + const oldManifest = loadManifest(oldPath); + const newManifest = loadManifest(newPath); + const diff = diffManifests(oldManifest, newManifest); + console.log(formatDiff(diff, oldManifest.version, newManifest.version)); +} diff --git a/scripts/extract-nextjs-api.ts b/scripts/extract-nextjs-api.ts new file mode 100644 index 00000000..b38e2881 --- /dev/null +++ b/scripts/extract-nextjs-api.ts @@ -0,0 +1,234 @@ +import fs from "node:fs"; +import path from "node:path"; + +export interface PublicEntrypoint { + specifier: string; + entryFile: string; +} + +export interface ApiManifest { + version: string; + extractedAt: string; + modules: Record; // "next/headers" -> ["cookies", "headers", "draftMode"] +} + +/** + * Discover public Next.js entry files from the published package layout. + * + * We treat any .js file outside dist/ as a candidate public entrypoint and + * let export extraction decide whether it has runtime exports worth tracking. + * This keeps the manifest resilient to newly added entrypoints without + * maintaining a second hard-coded module list. + */ +export function discoverPublicEntrypoints(nextPkgDir: string): PublicEntrypoint[] { + const found: PublicEntrypoint[] = []; + + function walk(currentDir: string, relativeDir = ""): void { + const dirents = fs + .readdirSync(currentDir, { withFileTypes: true }) + .sort((a, b) => a.name.localeCompare(b.name)); + + for (const dirent of dirents) { + const relativePath = relativeDir ? path.posix.join(relativeDir, dirent.name) : dirent.name; + + if (dirent.isDirectory()) { + if (relativePath === "dist") continue; + walk(path.join(currentDir, dirent.name), relativePath); + continue; + } + + if (!dirent.isFile() || !dirent.name.endsWith(".js")) continue; + + const modulePath = relativePath.replace(/\.js$/, "").replace(/\/index$/, ""); + + found.push({ + specifier: `next/${modulePath}`, + entryFile: relativePath, + }); + } + } + + walk(nextPkgDir); + return found.sort((a, b) => a.specifier.localeCompare(b.specifier)); +} + +/** + * Extract exports from a Pattern A root file (direct exports like headers.js, server.js, cache.js). + * + * Pattern A files have individual export assignments: + * - module.exports.X = ... + * - exports.X = ... + * - Object literal: const serverExports = { X: require(...).X, Y: ... } + * + * Returns array of export names, or null if this is a Pattern B re-export + * (module.exports = require('./dist/...')) with no individual exports. + */ +export function extractExportsFromRootFile(source: string): string[] | null { + const exports = new Set(); + + // Check for individual export assignments: module.exports.X = ... or exports.X = ... + const individualExportRe = /(?:module\.exports|exports)\.(\w+)\s*=/g; + let match: RegExpExecArray | null; + while ((match = individualExportRe.exec(source)) !== null) { + exports.add(match[1]); + } + + // Check for object literal assignments like: + // const serverExports = { X: require(...)..., Y: ... } + // const cacheExports = { X: require(...)..., Y: ... } + const objLiteralRe = /(?:const|let|var)\s+\w+\s*=\s*\{([^}]+)\}/gs; + while ((match = objLiteralRe.exec(source)) !== null) { + const body = match[1]; + // Extract keys from object literal (key: value pairs) + const keyRe = /(\w+)\s*:/g; + let keyMatch: RegExpExecArray | null; + while ((keyMatch = keyRe.exec(body)) !== null) { + exports.add(keyMatch[1]); + } + } + + // If we found individual exports, this is Pattern A + if (exports.size > 0) { + exports.delete("__esModule"); + return [...exports].sort(); + } + + // Check if this is a pure Pattern B re-export: module.exports = require('./dist/...') + if (/module\.exports\s*=\s*require\s*\(/.test(source)) { + return null; + } + + // Check if it's a types-only stub (just throws) + if (/throw\s+new\s+Error/.test(source) && exports.size === 0) { + return []; + } + + // No exports found + return []; +} + +/** + * Resolve re-export target path from a Pattern B root file. + * Matches: module.exports = require('./dist/...') + */ +export function resolveReExportTarget(source: string): string | null { + const match = source.match(/module\.exports\s*=\s*require\s*\(\s*['"]([^'"]+)['"]\s*\)/); + return match ? match[1] : null; +} + +/** + * Extract exports from a dist file (Pattern B1/B2/B3). + * + * B1: 0 && (module.exports = { X: null, Y: null, ... }) + * B2: _export(exports, { X: fn, Y: fn }) + * B3: Object.defineProperty(exports, "X", { ... }) + */ +export function extractExportsFromDistFile(source: string): string[] { + const exports = new Set(); + + // B1: 0 && (module.exports = { X: null, Y: null, ... }) + const b1Re = /0\s*&&\s*\(\s*module\.exports\s*=\s*\{([^}]+)\}\s*\)/gs; + let match: RegExpExecArray | null; + while ((match = b1Re.exec(source)) !== null) { + const body = match[1]; + const keyRe = /(\w+)\s*:/g; + let keyMatch: RegExpExecArray | null; + while ((keyMatch = keyRe.exec(body)) !== null) { + exports.add(keyMatch[1]); + } + } + + // B2: _export(exports, { X: function() { ... }, Y: function() { ... } }) + const b2Re = /_export\s*\(\s*exports\s*,\s*\{([\s\S]*?)\}\s*\)/g; + while ((match = b2Re.exec(source)) !== null) { + const body = match[1]; + // Match keys -- they can have comments before the colon + const keyRe = /(?:\/\*[\s\S]*?\*\/\s*)?(\w+)\s*:\s*function/g; + let keyMatch: RegExpExecArray | null; + while ((keyMatch = keyRe.exec(body)) !== null) { + exports.add(keyMatch[1]); + } + } + + // B3: Object.defineProperty(exports, "X", { ... }) + // Also handles the variant with a comment before the string: + // Object.defineProperty(exports, /**...*/ "default", { ... }) + const b3Re = /Object\.defineProperty\s*\(\s*exports\s*,\s*(?:\/\*[\s\S]*?\*\/\s*)?["'](\w+)["']/g; + while ((match = b3Re.exec(source)) !== null) { + exports.add(match[1]); + } + + exports.delete("__esModule"); + return [...exports].sort(); +} + +/** + * Get all exports for a module, handling both Pattern A and B. + */ +export function getModuleExports( + nextPkgDir: string, + moduleName: string, + entryFile?: string, +): string[] { + const rootFile = path.join(nextPkgDir, entryFile || `${moduleName}.js`); + if (!fs.existsSync(rootFile)) return []; + + const source = fs.readFileSync(rootFile, "utf-8"); + + // Try Pattern A first + const directExports = extractExportsFromRootFile(source); + if (directExports !== null) return directExports; + + // Pattern B: resolve re-export target + const target = resolveReExportTarget(source); + if (!target) return []; + + // Resolve target relative to the entry file's directory + // Handle both './dist/...' (relative to entry file) and 'next/dist/...' paths + let targetPath: string; + if (target.startsWith("next/")) { + targetPath = path.join(nextPkgDir, target.slice(5)); + } else { + const entryDir = path.dirname(rootFile); + targetPath = path.join(entryDir, target); + } + + const candidatePaths = [`${targetPath}.js`, path.join(targetPath, "index.js")]; + const resolvedTargetPath = candidatePaths.find((candidate) => fs.existsSync(candidate)); + if (!resolvedTargetPath) return []; + + const distSource = fs.readFileSync(resolvedTargetPath, "utf-8"); + return extractExportsFromDistFile(distSource); +} + +/** + * Build the full manifest from a next package directory. + */ +export function buildManifest(nextPkgDir: string): ApiManifest { + const pkgJson = JSON.parse(fs.readFileSync(path.join(nextPkgDir, "package.json"), "utf-8")); + + const modules: Record = {}; + for (const { specifier, entryFile } of discoverPublicEntrypoints(nextPkgDir)) { + const moduleExports = getModuleExports(nextPkgDir, specifier.slice(5), entryFile); + if (moduleExports.length > 0) { + modules[specifier] = moduleExports.sort(); + } + } + + return { + version: pkgJson.version, + extractedAt: new Date().toISOString(), + modules, + }; +} + +// CLI entry +if (process.argv[1] === import.meta.filename) { + const nextDir = process.argv[2] || path.resolve("node_modules/next"); + const manifest = buildManifest(nextDir); + const output = process.argv[3] || "api-manifest.json"; + fs.writeFileSync(output, JSON.stringify(manifest, null, 2) + "\n"); + console.log( + `Wrote ${output} (next@${manifest.version}, ${Object.keys(manifest.modules).length} modules)`, + ); +} diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index cb23dad7..6ee0e3e9 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -1914,7 +1914,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { actionRedirect = { url: decodeURIComponent(parts[2]), type: parts[1] || "replace", // "push" or "replace" - status: parts[3] ? parseInt(parts[3], 10) : 307, + status: parts[3] ? parseInt(parts[3], 10) : 303, }; returnValue = { ok: true, data: undefined }; } else if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) { @@ -4629,7 +4629,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { actionRedirect = { url: decodeURIComponent(parts[2]), type: parts[1] || "replace", // "push" or "replace" - status: parts[3] ? parseInt(parts[3], 10) : 307, + status: parts[3] ? parseInt(parts[3], 10) : 303, }; returnValue = { ok: true, data: undefined }; } else if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) { @@ -7371,7 +7371,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { actionRedirect = { url: decodeURIComponent(parts[2]), type: parts[1] || "replace", // "push" or "replace" - status: parts[3] ? parseInt(parts[3], 10) : 307, + status: parts[3] ? parseInt(parts[3], 10) : 303, }; returnValue = { ok: true, data: undefined }; } else if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) { @@ -10123,7 +10123,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { actionRedirect = { url: decodeURIComponent(parts[2]), type: parts[1] || "replace", // "push" or "replace" - status: parts[3] ? parseInt(parts[3], 10) : 307, + status: parts[3] ? parseInt(parts[3], 10) : 303, }; returnValue = { ok: true, data: undefined }; } else if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) { @@ -12842,7 +12842,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { actionRedirect = { url: decodeURIComponent(parts[2]), type: parts[1] || "replace", // "push" or "replace" - status: parts[3] ? parseInt(parts[3], 10) : 307, + status: parts[3] ? parseInt(parts[3], 10) : 303, }; returnValue = { ok: true, data: undefined }; } else if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) { @@ -15832,7 +15832,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx, ctx) { actionRedirect = { url: decodeURIComponent(parts[2]), type: parts[1] || "replace", // "push" or "replace" - status: parts[3] ? parseInt(parts[3], 10) : 307, + status: parts[3] ? parseInt(parts[3], 10) : 303, }; returnValue = { ok: true, data: undefined }; } else if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) { diff --git a/tests/api-manifest.test.ts b/tests/api-manifest.test.ts new file mode 100644 index 00000000..735351d0 --- /dev/null +++ b/tests/api-manifest.test.ts @@ -0,0 +1,340 @@ +import { describe, it, expect } from "vitest"; +import path from "node:path"; +import { + extractExportsFromRootFile, + resolveReExportTarget, + extractExportsFromDistFile, + discoverPublicEntrypoints, + getModuleExports, + buildManifest, +} from "../scripts/extract-nextjs-api.js"; + +const FIXTURE_DIR = path.join(import.meta.dirname, "fixtures/next-api-manifest"); + +describe("discoverPublicEntrypoints", () => { + it("discovers root and nested public entry files without a hard-coded module list", () => { + const dir = path.join(FIXTURE_DIR, "full-package"); + const discovered = discoverPublicEntrypoints(dir); + + expect(discovered.map((entry) => entry.specifier)).toEqual([ + "next/babel", + "next/compat/router", + "next/experimental/testmode/proxy", + "next/font/google", + "next/font/local", + "next/form", + "next/headers", + "next/legacy/image", + "next/navigation", + "next/web-vitals", + ]); + }); + + it("records entryFile paths relative to the package root", () => { + const dir = path.join(FIXTURE_DIR, "full-package"); + const discovered = discoverPublicEntrypoints(dir); + expect(discovered.find((entry) => entry.specifier === "next/compat/router")?.entryFile).toBe( + "compat/router.js", + ); + expect( + discovered.find((entry) => entry.specifier === "next/experimental/testmode/proxy")?.entryFile, + ).toBe("experimental/testmode/proxy.js"); + }); + + it("skips dist/ files from discovery", () => { + const dir = path.join(FIXTURE_DIR, "full-package"); + const discovered = discoverPublicEntrypoints(dir); + expect(discovered.some((entry) => entry.entryFile.startsWith("dist/"))).toBe(false); + }); +}); + +describe("extractExportsFromRootFile", () => { + it("extracts Pattern A module.exports.X = ... style exports", () => { + const source = ` +module.exports.cookies = require('./dist/server/request/cookies').cookies +module.exports.headers = require('./dist/server/request/headers').headers +module.exports.draftMode = require('./dist/server/request/draft-mode').draftMode +`; + const result = extractExportsFromRootFile(source); + expect(result).toEqual(["cookies", "draftMode", "headers"]); + }); + + it("extracts Pattern A exports.X = ... style exports", () => { + const source = ` +exports.NextRequest = serverExports.NextRequest +exports.NextResponse = serverExports.NextResponse +exports.after = serverExports.after +`; + const result = extractExportsFromRootFile(source); + expect(result).toEqual(["NextRequest", "NextResponse", "after"]); + }); + + it("extracts Pattern A cache.js style (object literal then exports)", () => { + const source = ` +const cacheExports = { + unstable_cache: require('next/dist/server/web/spec-extension/unstable-cache').unstable_cache, + revalidateTag: require('next/dist/server/web/spec-extension/revalidate').revalidateTag, + revalidatePath: require('next/dist/server/web/spec-extension/revalidate').revalidatePath, +} + +module.exports = cacheExports + +exports.unstable_cache = cacheExports.unstable_cache +exports.revalidatePath = cacheExports.revalidatePath +exports.revalidateTag = cacheExports.revalidateTag +`; + const result = extractExportsFromRootFile(source); + expect(result).not.toBeNull(); + expect(result).toContain("unstable_cache"); + expect(result).toContain("revalidateTag"); + expect(result).toContain("revalidatePath"); + }); + + it("returns null for Pattern B pure re-export", () => { + const source = `module.exports = require('./dist/client/components/navigation')\n`; + const result = extractExportsFromRootFile(source); + expect(result).toBeNull(); + }); + + it("returns [] for types-only stub (just throw)", () => { + const source = ` +throw new Error( + "This module is a placeholder for 'next/root-params' and should be replaced by the compiler." +) +`; + const result = extractExportsFromRootFile(source); + expect(result).toEqual([]); + }); + + it("excludes __esModule", () => { + const source = ` +exports.__esModule = true +exports.cookies = require('./dist/cookies').cookies +`; + const result = extractExportsFromRootFile(source); + expect(result).toEqual(["cookies"]); + expect(result).not.toContain("__esModule"); + }); +}); + +describe("resolveReExportTarget", () => { + it("extracts require path from module.exports = require('./dist/...')", () => { + const source = `module.exports = require('./dist/client/components/navigation')`; + expect(resolveReExportTarget(source)).toBe("./dist/client/components/navigation"); + }); + + it("returns null for non-re-export", () => { + const source = `module.exports.cookies = require('./dist/cookies').cookies`; + expect(resolveReExportTarget(source)).toBeNull(); + }); +}); + +describe("extractExportsFromDistFile", () => { + it("extracts Pattern B1 dead-code hint exports", () => { + const source = ` +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +0 && (module.exports = { + ReadonlyURLSearchParams: null, + usePathname: null, + useRouter: null, + useSearchParams: null +}); +`; + const result = extractExportsFromDistFile(source); + expect(result).toEqual([ + "ReadonlyURLSearchParams", + "usePathname", + "useRouter", + "useSearchParams", + ]); + }); + + it("extracts Pattern B3 Object.defineProperty exports", () => { + const source = ` +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +Object.defineProperty(exports, "default", { + enumerable: true, + get: function() { return _default; } +}); +`; + const result = extractExportsFromDistFile(source); + expect(result).toEqual(["default"]); + }); + + it("extracts Pattern B2 _export block exports", () => { + const source = ` +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +function _export(target, all) { + for(var name in all)Object.defineProperty(target, name, { + enumerable: true, + get: all[name] + }); +} +_export(exports, { + ReadonlyURLSearchParams: function() { return ReadonlyURLSearchParams; }, + usePathname: function() { return usePathname; }, + useRouter: function() { return useRouter; } +}); +`; + const result = extractExportsFromDistFile(source); + expect(result).toEqual(["ReadonlyURLSearchParams", "usePathname", "useRouter"]); + }); + + it("deduplicates when B1 + B2 both present", () => { + const source = ` +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +0 && (module.exports = { + useRouter: null, + usePathname: null +}); +function _export(target, all) { + for(var name in all)Object.defineProperty(target, name, { + enumerable: true, + get: all[name] + }); +} +_export(exports, { + useRouter: function() { return useRouter; }, + usePathname: function() { return usePathname; } +}); +`; + const result = extractExportsFromDistFile(source); + expect(result).toEqual(["usePathname", "useRouter"]); + }); + + it("excludes __esModule", () => { + const source = ` +Object.defineProperty(exports, "__esModule", { value: true }); +0 && (module.exports = { + __esModule: null, + useRouter: null +}); +`; + const result = extractExportsFromDistFile(source); + expect(result).toEqual(["useRouter"]); + expect(result).not.toContain("__esModule"); + }); +}); + +describe("getModuleExports (integration with fixtures)", () => { + it("Pattern A fixture returns correct exports", () => { + const dir = path.join(FIXTURE_DIR, "pattern-a"); + const result = getModuleExports(dir, "headers"); + expect(result).toEqual(["cookies", "draftMode", "headers"]); + }); + + it("Pattern B1 fixture returns correct exports", () => { + const dir = path.join(FIXTURE_DIR, "pattern-b1"); + const result = getModuleExports(dir, "navigation"); + expect(result).toContain("useRouter"); + expect(result).toContain("usePathname"); + expect(result).toContain("ReadonlyURLSearchParams"); + expect(result).toContain("useSearchParams"); + }); + + it("Pattern B2 fixture returns correct exports (default only)", () => { + const dir = path.join(FIXTURE_DIR, "pattern-b2"); + const result = getModuleExports(dir, "form"); + expect(result).toEqual(["default"]); + }); + + it("returns [] for nonexistent module", () => { + const dir = path.join(FIXTURE_DIR, "pattern-a"); + const result = getModuleExports(dir, "nonexistent"); + expect(result).toEqual([]); + }); + + it("resolves subdirectory module with custom entryFile", () => { + const dir = path.join(FIXTURE_DIR, "full-package"); + const result = getModuleExports(dir, "compat/router", "compat/router.js"); + expect(result).toEqual(["useRouter"]); + }); + + it("resolves subdirectory module with default export", () => { + const dir = path.join(FIXTURE_DIR, "full-package"); + const result = getModuleExports(dir, "legacy/image", "legacy/image.js"); + expect(result).toEqual(["default"]); + }); + + it("returns [] for empty subdirectory entry file", () => { + const dir = path.join(FIXTURE_DIR, "full-package"); + const result = getModuleExports(dir, "font/google", "font/google/index.js"); + expect(result).toEqual([]); + }); + + it("resolves newly discovered root modules outside the previous allowlist", () => { + const dir = path.join(FIXTURE_DIR, "full-package"); + const result = getModuleExports(dir, "babel", "babel.js"); + expect(result).toEqual(["default"]); + }); + + it("resolves re-export targets that point at a directory index", () => { + const dir = path.join(FIXTURE_DIR, "full-package"); + const result = getModuleExports( + dir, + "experimental/testmode/proxy", + "experimental/testmode/proxy.js", + ); + expect(result).toEqual(["createProxyServer"]); + }); +}); + +describe("buildManifest", () => { + it("returns manifest with correct version", () => { + const dir = path.join(FIXTURE_DIR, "full-package"); + const manifest = buildManifest(dir); + expect(manifest.version).toBe("99.0.0-test"); + }); + + it("has all non-empty modules including subdirectory modules", () => { + const dir = path.join(FIXTURE_DIR, "full-package"); + const manifest = buildManifest(dir); + + expect(manifest.modules).toHaveProperty("next/headers"); + expect(manifest.modules).toHaveProperty("next/navigation"); + expect(manifest.modules).toHaveProperty("next/form"); + expect(manifest.modules).toHaveProperty("next/web-vitals"); + expect(manifest.modules).toHaveProperty("next/babel"); + expect(manifest.modules).toHaveProperty("next/compat/router"); + expect(manifest.modules).toHaveProperty("next/experimental/testmode/proxy"); + expect(manifest.modules).toHaveProperty("next/legacy/image"); + + expect(manifest.modules["next/babel"]).toEqual(["default"]); + expect(manifest.modules["next/compat/router"]).toEqual(["useRouter"]); + expect(manifest.modules["next/experimental/testmode/proxy"]).toEqual(["createProxyServer"]); + expect(manifest.modules["next/legacy/image"]).toEqual(["default"]); + expect(manifest.modules["next/web-vitals"]).toEqual(["useReportWebVitals"]); + }); + + it("skips modules with no exports including empty subdirectory entries", () => { + const dir = path.join(FIXTURE_DIR, "full-package"); + const manifest = buildManifest(dir); + // full-package doesn't have server.js, cache.js, etc. + expect(manifest.modules).not.toHaveProperty("next/server"); + expect(manifest.modules).not.toHaveProperty("next/cache"); + // font/google and font/local have empty entry files + expect(manifest.modules).not.toHaveProperty("next/font/google"); + expect(manifest.modules).not.toHaveProperty("next/font/local"); + expect(manifest.modules).not.toHaveProperty("next/root-params"); + }); + + it("exports are sorted alphabetically", () => { + const dir = path.join(FIXTURE_DIR, "full-package"); + const manifest = buildManifest(dir); + for (const [, exports] of Object.entries(manifest.modules)) { + const sorted = [...exports].sort(); + expect(exports).toEqual(sorted); + } + }); + + it("extractedAt is an ISO timestamp", () => { + const dir = path.join(FIXTURE_DIR, "full-package"); + const manifest = buildManifest(dir); + expect(() => new Date(manifest.extractedAt)).not.toThrow(); + expect(manifest.extractedAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/); + }); +}); diff --git a/tests/app-router.test.ts b/tests/app-router.test.ts index d7180d60..f1bc4658 100644 --- a/tests/app-router.test.ts +++ b/tests/app-router.test.ts @@ -874,6 +874,34 @@ describe("App Router integration", () => { expect(html).toContain('data-testid="message-input"'); }); + it("uses 303 for server action redirects", async () => { + // Ported from Next.js: + // - test/e2e/app-dir/actions/app-action.test.ts + // - test/e2e/app-dir/actions/app-action-progressive-enhancement.test.ts + const pageRes = await fetch(`${baseUrl}/contracts/redirect-from-action`); + expect(pageRes.status).toBe(200); + + const html = await pageRes.text(); + const actionIdMatch = html.match(/\$ACTION_ID_([^"]+)/); + expect(actionIdMatch).not.toBeNull(); + + const res = await fetch(`${baseUrl}/contracts/redirect-from-action`, { + method: "POST", + headers: { + "Content-Type": "text/plain", + "x-rsc-action": actionIdMatch![1], + Accept: "text/x-component", + Origin: baseUrl, + }, + body: "[]", + redirect: "manual", + }); + + expect(res.status).toBe(200); + expect(res.headers.get("x-action-redirect-status")).toBe("303"); + expect(res.headers.get("x-action-redirect")).toContain("/about"); + }); + it("renders template.tsx wrapper around page content", async () => { const { html } = await fetchHtml(baseUrl, "/"); expect(html).toContain('data-testid="root-template"'); diff --git a/tests/contracts/_helpers.ts b/tests/contracts/_helpers.ts new file mode 100644 index 00000000..b9fa5ecc --- /dev/null +++ b/tests/contracts/_helpers.ts @@ -0,0 +1,166 @@ +import { spawn, type ChildProcessByStdio } from "node:child_process"; +import net from "node:net"; +import type { Readable } from "node:stream"; +import { APP_FIXTURE_DIR, startFixtureServer } from "../helpers"; + +type SpawnedContractProcess = ChildProcessByStdio; + +export interface ContractServer { + baseUrl: string; + close(): Promise; +} + +let server: ContractServer | null = null; + +function getContractTarget(): "vinext" | "nextjs" { + return process.env.CONTRACT_TARGET === "nextjs" ? "nextjs" : "vinext"; +} + +async function getAvailablePort(): Promise { + return await new Promise((resolve, reject) => { + const srv = net.createServer(); + srv.once("error", reject); + srv.listen(0, "127.0.0.1", () => { + const address = srv.address(); + if (!address || typeof address === "string") { + reject(new Error("Failed to resolve an available port")); + return; + } + const port = address.port; + srv.close((error) => { + if (error) reject(error); + else resolve(port); + }); + }); + }); +} + +async function waitForServerReady( + baseUrl: string, + proc: SpawnedContractProcess, + output: { value: string }, +): Promise { + const deadline = Date.now() + 30_000; + + while (Date.now() < deadline) { + if (proc.exitCode !== null) { + throw new Error( + `next dev exited before becoming ready (code ${proc.exitCode})\n${output.value.trim()}`, + ); + } + + try { + const res = await fetch(`${baseUrl}/about`, { redirect: "manual" }); + if (res.status > 0) return; + } catch { + // Retry until the deadline. + } + + await new Promise((resolve) => setTimeout(resolve, 250)); + } + + throw new Error(`Timed out waiting for next dev at ${baseUrl}\n${output.value.trim()}`); +} + +async function stopProcess(proc: SpawnedContractProcess): Promise { + if (proc.exitCode !== null) return; + + proc.kill("SIGTERM"); + + await Promise.race([ + new Promise((resolve) => proc.once("exit", () => resolve())), + new Promise((resolve) => + setTimeout(() => { + if (proc.exitCode === null) proc.kill("SIGKILL"); + resolve(); + }, 5_000), + ), + ]); +} + +async function startNextjsContractServer(): Promise { + const port = await getAvailablePort(); + const baseUrl = `http://127.0.0.1:${port}`; + const output = { value: "" }; + + const proc = spawn( + "pnpm", + [ + "--dir", + APP_FIXTURE_DIR, + "exec", + "next", + "dev", + "--hostname", + "127.0.0.1", + "--port", + String(port), + ], + { + cwd: APP_FIXTURE_DIR, + env: { + ...process.env, + NEXT_TELEMETRY_DISABLED: "1", + }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + + const appendOutput = (chunk: Buffer) => { + output.value += chunk.toString("utf-8"); + if (output.value.length > 8_000) { + output.value = output.value.slice(-8_000); + } + }; + + proc.stdout.on("data", appendOutput); + proc.stderr.on("data", appendOutput); + + await waitForServerReady(baseUrl, proc, output); + + return { + baseUrl, + async close() { + await stopProcess(proc); + }, + }; +} + +/** + * Get or create the shared contract test server. + * + * CONTRACT_TARGET=vinext (default) boots the Vite/vinext fixture. + * CONTRACT_TARGET=nextjs boots a real `next dev` server against the same fixture. + * CONTRACT_TARGET_URL bypasses local boot entirely and targets an external URL. + */ +export async function getContractServer(): Promise { + if (process.env.CONTRACT_TARGET_URL) { + return { + baseUrl: process.env.CONTRACT_TARGET_URL, + async close() {}, + }; + } + + if (!server) { + if (getContractTarget() === "nextjs") { + server = await startNextjsContractServer(); + } else { + const viteServer = await startFixtureServer(APP_FIXTURE_DIR); + server = { + baseUrl: viteServer.baseUrl, + async close() { + await viteServer.server.close(); + }, + }; + } + } + + return server; +} + +export async function closeContractServer(): Promise { + if (server) { + await server.close(); + server = null; + } +} diff --git a/tests/contracts/http.contract.test.ts b/tests/contracts/http.contract.test.ts new file mode 100644 index 00000000..e7e031a8 --- /dev/null +++ b/tests/contracts/http.contract.test.ts @@ -0,0 +1,44 @@ +import { afterAll, describe, expect, it } from "vitest"; +import { closeContractServer, getContractServer } from "./_helpers"; + +// Ported from Next.js metadata and middleware behavior coverage: +// - test/e2e/app-dir/metadata/metadata.test.ts +// - test/e2e/vary-header/test/index.test.ts +// - test/e2e/middleware-request-header-overrides/test/index.test.ts + +describe("shared HTTP contracts", () => { + afterAll(async () => { + await closeContractServer(); + }); + + it("cookies() is mutable in route handlers", async () => { + const { baseUrl } = await getContractServer(); + const res = await fetch(`${baseUrl}/contracts/api/cookies-mutable`); + expect(res.status).toBe(200); + expect(res.headers.get("set-cookie")).toContain("contract-test=value"); + }); + + it("headers() remains read-only in request-bound contexts", async () => { + const { baseUrl } = await getContractServer(); + const res = await fetch(`${baseUrl}/contracts/api/headers-readonly`); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.readonlyEnforced).toBe(true); + }); + + it("middleware response headers propagate to the final response", async () => { + const { baseUrl } = await getContractServer(); + const res = await fetch(`${baseUrl}/contracts/api/middleware-headers`); + expect(res.status).toBe(200); + expect(res.headers.get("x-mw-ran")).toBe("true"); + expect(res.headers.get("x-mw-pathname")).toBe("/contracts/api/middleware-headers"); + }); + + it("title templates apply from parent layouts to child routes", async () => { + const { baseUrl } = await getContractServer(); + const res = await fetch(`${baseUrl}/contracts/metadata-merge/extra`); + expect(res.status).toBe(200); + const html = await res.text(); + expect(html).toContain("Merge Test | Contracts"); + }); +}); diff --git a/tests/fixtures/app-basic/app/contracts/api/cookies-mutable/route.ts b/tests/fixtures/app-basic/app/contracts/api/cookies-mutable/route.ts new file mode 100644 index 00000000..2c891f3f --- /dev/null +++ b/tests/fixtures/app-basic/app/contracts/api/cookies-mutable/route.ts @@ -0,0 +1,8 @@ +import { cookies } from "next/headers"; +import { NextResponse } from "next/server"; + +export async function GET() { + const cookieStore = await cookies(); + cookieStore.set("contract-test", "value", { path: "/" }); + return NextResponse.json({ ok: true }); +} diff --git a/tests/fixtures/app-basic/app/contracts/api/headers-readonly/route.ts b/tests/fixtures/app-basic/app/contracts/api/headers-readonly/route.ts new file mode 100644 index 00000000..53413f28 --- /dev/null +++ b/tests/fixtures/app-basic/app/contracts/api/headers-readonly/route.ts @@ -0,0 +1,13 @@ +import { headers } from "next/headers"; +import { NextResponse } from "next/server"; + +export async function GET() { + const h = await headers(); + let threw = false; + try { + (h as any).set("x-test", "value"); + } catch { + threw = true; + } + return NextResponse.json({ readonlyEnforced: threw }); +} diff --git a/tests/fixtures/app-basic/app/contracts/api/middleware-headers/route.ts b/tests/fixtures/app-basic/app/contracts/api/middleware-headers/route.ts new file mode 100644 index 00000000..a284e9c3 --- /dev/null +++ b/tests/fixtures/app-basic/app/contracts/api/middleware-headers/route.ts @@ -0,0 +1,9 @@ +import { NextResponse } from "next/server"; + +export async function GET() { + // The middleware sets x-mw-ran and x-mw-pathname as response headers. + // These are merged into the final response by vinext's middleware pipeline. + // This route handler just returns a simple response; the middleware headers + // will be appended to it by the framework. + return NextResponse.json({ ok: true }); +} diff --git a/tests/fixtures/app-basic/app/contracts/metadata-merge/extra/page.tsx b/tests/fixtures/app-basic/app/contracts/metadata-merge/extra/page.tsx new file mode 100644 index 00000000..9cf70c14 --- /dev/null +++ b/tests/fixtures/app-basic/app/contracts/metadata-merge/extra/page.tsx @@ -0,0 +1,9 @@ +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Merge Test", +}; + +export default function MetadataMergeExtraPage() { + return
Metadata merge test
; +} diff --git a/tests/fixtures/app-basic/app/contracts/metadata-merge/layout.tsx b/tests/fixtures/app-basic/app/contracts/metadata-merge/layout.tsx new file mode 100644 index 00000000..24ebe0d8 --- /dev/null +++ b/tests/fixtures/app-basic/app/contracts/metadata-merge/layout.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: { + template: "%s | Contracts", + default: "Contracts", + }, +}; + +export default function Layout({ children }: { children: React.ReactNode }) { + return <>{children}; +} diff --git a/tests/fixtures/app-basic/app/contracts/metadata-merge/page.tsx b/tests/fixtures/app-basic/app/contracts/metadata-merge/page.tsx new file mode 100644 index 00000000..1316ffb9 --- /dev/null +++ b/tests/fixtures/app-basic/app/contracts/metadata-merge/page.tsx @@ -0,0 +1,9 @@ +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Merge Test", +}; + +export default function MetadataMergePage() { + return
Metadata merge test
; +} diff --git a/tests/fixtures/app-basic/app/contracts/redirect-from-action/page.tsx b/tests/fixtures/app-basic/app/contracts/redirect-from-action/page.tsx new file mode 100644 index 00000000..8a1e6757 --- /dev/null +++ b/tests/fixtures/app-basic/app/contracts/redirect-from-action/page.tsx @@ -0,0 +1,14 @@ +import { redirect } from "next/navigation"; + +async function doRedirect() { + "use server"; + redirect("/about"); +} + +export default function RedirectFromActionPage() { + return ( +
+ +
+ ); +} diff --git a/tests/fixtures/app-basic/middleware.ts b/tests/fixtures/app-basic/middleware.ts index 0325a2fd..2161157a 100644 --- a/tests/fixtures/app-basic/middleware.ts +++ b/tests/fixtures/app-basic/middleware.ts @@ -165,5 +165,6 @@ export const config = { missing: [{ type: "cookie", key: "mw-blocked" }], }, "/mw-gated-fallback-pages", + "/contracts/api/middleware-headers", ], }; diff --git a/tests/fixtures/next-api-manifest/full-package/babel.js b/tests/fixtures/next-api-manifest/full-package/babel.js new file mode 100644 index 00000000..dfa68ce6 --- /dev/null +++ b/tests/fixtures/next-api-manifest/full-package/babel.js @@ -0,0 +1 @@ +module.exports = require("./dist/build/babel/preset"); diff --git a/tests/fixtures/next-api-manifest/full-package/compat/router.js b/tests/fixtures/next-api-manifest/full-package/compat/router.js new file mode 100644 index 00000000..56b60d51 --- /dev/null +++ b/tests/fixtures/next-api-manifest/full-package/compat/router.js @@ -0,0 +1 @@ +module.exports = require("../dist/client/compat/router"); diff --git a/tests/fixtures/next-api-manifest/full-package/dist/build/babel/preset.js b/tests/fixtures/next-api-manifest/full-package/dist/build/babel/preset.js new file mode 100644 index 00000000..c0220beb --- /dev/null +++ b/tests/fixtures/next-api-manifest/full-package/dist/build/babel/preset.js @@ -0,0 +1,10 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +Object.defineProperty(exports, "default", { + enumerable: true, + get: function () { + return _default; + }, +}); + +function _default() {} diff --git a/tests/fixtures/next-api-manifest/full-package/dist/client/compat/router.js b/tests/fixtures/next-api-manifest/full-package/dist/client/compat/router.js new file mode 100644 index 00000000..b2da9345 --- /dev/null +++ b/tests/fixtures/next-api-manifest/full-package/dist/client/compat/router.js @@ -0,0 +1,11 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { + value: true, +}); +Object.defineProperty(exports, "useRouter", { + enumerable: true, + get: function () { + return useRouter; + }, +}); +function useRouter() {} diff --git a/tests/fixtures/next-api-manifest/full-package/dist/client/components/navigation.js b/tests/fixtures/next-api-manifest/full-package/dist/client/components/navigation.js new file mode 100644 index 00000000..dc8263a3 --- /dev/null +++ b/tests/fixtures/next-api-manifest/full-package/dist/client/components/navigation.js @@ -0,0 +1,42 @@ +/* eslint-disable no-constant-binary-expression, no-unused-expressions */ +"use strict"; +Object.defineProperty(exports, "__esModule", { + value: true, +}); +0 && + (module.exports = { + ReadonlyURLSearchParams: null, + redirect: null, + usePathname: null, + useRouter: null, + useSearchParams: null, + }); +function _export(target, all) { + for (var name in all) + Object.defineProperty(target, name, { + enumerable: true, + get: all[name], + }); +} +_export(exports, { + ReadonlyURLSearchParams: function () { + return ReadonlyURLSearchParams; + }, + redirect: function () { + return redirect; + }, + usePathname: function () { + return usePathname; + }, + useRouter: function () { + return useRouter; + }, + useSearchParams: function () { + return useSearchParams; + }, +}); +const ReadonlyURLSearchParams = {}; +const redirect = () => {}; +const usePathname = () => "/"; +const useRouter = () => ({}); +const useSearchParams = () => new URLSearchParams(); diff --git a/tests/fixtures/next-api-manifest/full-package/dist/client/form.js b/tests/fixtures/next-api-manifest/full-package/dist/client/form.js new file mode 100644 index 00000000..49552f4c --- /dev/null +++ b/tests/fixtures/next-api-manifest/full-package/dist/client/form.js @@ -0,0 +1,12 @@ +"use client"; +"use strict"; +Object.defineProperty(exports, "__esModule", { + value: true, +}); +Object.defineProperty(exports, "default", { + enumerable: true, + get: function () { + return _default; + }, +}); +const _default = function Form() {}; diff --git a/tests/fixtures/next-api-manifest/full-package/dist/client/legacy/image.js b/tests/fixtures/next-api-manifest/full-package/dist/client/legacy/image.js new file mode 100644 index 00000000..443ea694 --- /dev/null +++ b/tests/fixtures/next-api-manifest/full-package/dist/client/legacy/image.js @@ -0,0 +1,12 @@ +"use client"; +"use strict"; +Object.defineProperty(exports, "__esModule", { + value: true, +}); +Object.defineProperty(exports, "default", { + enumerable: true, + get: function () { + return Image; + }, +}); +function Image() {} diff --git a/tests/fixtures/next-api-manifest/full-package/dist/client/web-vitals.js b/tests/fixtures/next-api-manifest/full-package/dist/client/web-vitals.js new file mode 100644 index 00000000..53887589 --- /dev/null +++ b/tests/fixtures/next-api-manifest/full-package/dist/client/web-vitals.js @@ -0,0 +1,11 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { + value: true, +}); +Object.defineProperty(exports, "useReportWebVitals", { + enumerable: true, + get: function () { + return useReportWebVitals; + }, +}); +function useReportWebVitals() {} diff --git a/tests/fixtures/next-api-manifest/full-package/dist/experimental/testmode/proxy/index.js b/tests/fixtures/next-api-manifest/full-package/dist/experimental/testmode/proxy/index.js new file mode 100644 index 00000000..c231db3c --- /dev/null +++ b/tests/fixtures/next-api-manifest/full-package/dist/experimental/testmode/proxy/index.js @@ -0,0 +1,7 @@ +/* eslint-disable no-constant-binary-expression, no-unused-expressions */ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +0 && + (module.exports = { + createProxyServer: null, + }); diff --git a/tests/fixtures/next-api-manifest/full-package/experimental/testmode/proxy.js b/tests/fixtures/next-api-manifest/full-package/experimental/testmode/proxy.js new file mode 100644 index 00000000..a987f8c4 --- /dev/null +++ b/tests/fixtures/next-api-manifest/full-package/experimental/testmode/proxy.js @@ -0,0 +1 @@ +module.exports = require("../../dist/experimental/testmode/proxy"); diff --git a/tests/fixtures/next-api-manifest/full-package/font/google/index.js b/tests/fixtures/next-api-manifest/full-package/font/google/index.js new file mode 100644 index 00000000..1243b4da --- /dev/null +++ b/tests/fixtures/next-api-manifest/full-package/font/google/index.js @@ -0,0 +1,2 @@ +// Simulates next/font/google entry point (0 bytes in published package) +module.exports = {}; diff --git a/tests/fixtures/next-api-manifest/full-package/font/local/index.js b/tests/fixtures/next-api-manifest/full-package/font/local/index.js new file mode 100644 index 00000000..938ce5ed --- /dev/null +++ b/tests/fixtures/next-api-manifest/full-package/font/local/index.js @@ -0,0 +1,2 @@ +// Simulates next/font/local entry point (0 bytes in published package) +module.exports = {}; diff --git a/tests/fixtures/next-api-manifest/full-package/form.js b/tests/fixtures/next-api-manifest/full-package/form.js new file mode 100644 index 00000000..75b4f248 --- /dev/null +++ b/tests/fixtures/next-api-manifest/full-package/form.js @@ -0,0 +1 @@ +module.exports = require("./dist/client/form"); diff --git a/tests/fixtures/next-api-manifest/full-package/headers.js b/tests/fixtures/next-api-manifest/full-package/headers.js new file mode 100644 index 00000000..b16091dd --- /dev/null +++ b/tests/fixtures/next-api-manifest/full-package/headers.js @@ -0,0 +1,3 @@ +module.exports.cookies = require("./dist/server/request/cookies").cookies; +module.exports.headers = require("./dist/server/request/headers").headers; +module.exports.draftMode = require("./dist/server/request/draft-mode").draftMode; diff --git a/tests/fixtures/next-api-manifest/full-package/legacy/image.js b/tests/fixtures/next-api-manifest/full-package/legacy/image.js new file mode 100644 index 00000000..e24f77f6 --- /dev/null +++ b/tests/fixtures/next-api-manifest/full-package/legacy/image.js @@ -0,0 +1 @@ +module.exports = require("../dist/client/legacy/image"); diff --git a/tests/fixtures/next-api-manifest/full-package/navigation.js b/tests/fixtures/next-api-manifest/full-package/navigation.js new file mode 100644 index 00000000..a52ccbb0 --- /dev/null +++ b/tests/fixtures/next-api-manifest/full-package/navigation.js @@ -0,0 +1 @@ +module.exports = require("./dist/client/components/navigation"); diff --git a/tests/fixtures/next-api-manifest/full-package/package.json b/tests/fixtures/next-api-manifest/full-package/package.json new file mode 100644 index 00000000..643b27cd --- /dev/null +++ b/tests/fixtures/next-api-manifest/full-package/package.json @@ -0,0 +1,4 @@ +{ + "name": "next", + "version": "99.0.0-test" +} diff --git a/tests/fixtures/next-api-manifest/full-package/web-vitals.js b/tests/fixtures/next-api-manifest/full-package/web-vitals.js new file mode 100644 index 00000000..91a94eee --- /dev/null +++ b/tests/fixtures/next-api-manifest/full-package/web-vitals.js @@ -0,0 +1 @@ +module.exports = require("./dist/client/web-vitals"); diff --git a/tests/fixtures/next-api-manifest/pattern-a/headers.js b/tests/fixtures/next-api-manifest/pattern-a/headers.js new file mode 100644 index 00000000..b16091dd --- /dev/null +++ b/tests/fixtures/next-api-manifest/pattern-a/headers.js @@ -0,0 +1,3 @@ +module.exports.cookies = require("./dist/server/request/cookies").cookies; +module.exports.headers = require("./dist/server/request/headers").headers; +module.exports.draftMode = require("./dist/server/request/draft-mode").draftMode; diff --git a/tests/fixtures/next-api-manifest/pattern-b1/dist/client/components/navigation.js b/tests/fixtures/next-api-manifest/pattern-b1/dist/client/components/navigation.js new file mode 100644 index 00000000..89025db9 --- /dev/null +++ b/tests/fixtures/next-api-manifest/pattern-b1/dist/client/components/navigation.js @@ -0,0 +1,37 @@ +/* eslint-disable no-constant-binary-expression, no-unused-expressions */ +"use strict"; +Object.defineProperty(exports, "__esModule", { + value: true, +}); +0 && + (module.exports = { + ReadonlyURLSearchParams: null, + usePathname: null, + useRouter: null, + useSearchParams: null, + }); +function _export(target, all) { + for (var name in all) + Object.defineProperty(target, name, { + enumerable: true, + get: all[name], + }); +} +_export(exports, { + ReadonlyURLSearchParams: function () { + return ReadonlyURLSearchParams; + }, + usePathname: function () { + return usePathname; + }, + useRouter: function () { + return useRouter; + }, + useSearchParams: function () { + return useSearchParams; + }, +}); +const ReadonlyURLSearchParams = {}; +const usePathname = () => "/"; +const useRouter = () => ({}); +const useSearchParams = () => new URLSearchParams(); diff --git a/tests/fixtures/next-api-manifest/pattern-b1/navigation.js b/tests/fixtures/next-api-manifest/pattern-b1/navigation.js new file mode 100644 index 00000000..a52ccbb0 --- /dev/null +++ b/tests/fixtures/next-api-manifest/pattern-b1/navigation.js @@ -0,0 +1 @@ +module.exports = require("./dist/client/components/navigation"); diff --git a/tests/fixtures/next-api-manifest/pattern-b2/dist/client/form.js b/tests/fixtures/next-api-manifest/pattern-b2/dist/client/form.js new file mode 100644 index 00000000..49552f4c --- /dev/null +++ b/tests/fixtures/next-api-manifest/pattern-b2/dist/client/form.js @@ -0,0 +1,12 @@ +"use client"; +"use strict"; +Object.defineProperty(exports, "__esModule", { + value: true, +}); +Object.defineProperty(exports, "default", { + enumerable: true, + get: function () { + return _default; + }, +}); +const _default = function Form() {}; diff --git a/tests/fixtures/next-api-manifest/pattern-b2/form.js b/tests/fixtures/next-api-manifest/pattern-b2/form.js new file mode 100644 index 00000000..75b4f248 --- /dev/null +++ b/tests/fixtures/next-api-manifest/pattern-b2/form.js @@ -0,0 +1 @@ +module.exports = require("./dist/client/form"); diff --git a/tests/fixtures/next-api-manifest/sample-manifest.json b/tests/fixtures/next-api-manifest/sample-manifest.json new file mode 100644 index 00000000..f96bb0b2 --- /dev/null +++ b/tests/fixtures/next-api-manifest/sample-manifest.json @@ -0,0 +1,8 @@ +{ + "version": "99.0.0", + "extractedAt": "2026-01-01T00:00:00.000Z", + "modules": { + "next/foo": ["bar", "baz"], + "next/qux": ["alpha", "beta"] + } +} diff --git a/tests/helpers.ts b/tests/helpers.ts index 735601fc..182ea1e3 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -59,7 +59,7 @@ export async function startFixtureServer( optimizeDeps: { holdUntilCrawlEnd: true, }, - server: { port: 0, cors: false }, + server: { host: "127.0.0.1", port: 0, cors: false }, logLevel: "silent", }); @@ -68,7 +68,7 @@ export async function startFixtureServer( await server.listen(); const addr = server.httpServer?.address(); if (addr && typeof addr === "object") { - baseUrl = `http://localhost:${addr.port}`; + baseUrl = `http://127.0.0.1:${addr.port}`; } } diff --git a/tests/manifest-diff.test.ts b/tests/manifest-diff.test.ts new file mode 100644 index 00000000..56f17607 --- /dev/null +++ b/tests/manifest-diff.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect } from "vitest"; +import path from "node:path"; +import type { ApiManifest } from "../scripts/extract-nextjs-api.js"; +import { diffManifests, loadManifest } from "../scripts/diff-nextjs-api.js"; + +const FIXTURE_DIR = path.join(import.meta.dirname, "fixtures/next-api-manifest"); + +function makeManifest(modules: Record, version = "1.0.0"): ApiManifest { + return { + version, + extractedAt: "2026-01-01T00:00:00.000Z", + modules, + }; +} + +describe("diffManifests", () => { + it("identical manifests produce empty diff", () => { + const m = makeManifest({ "next/headers": ["cookies", "headers"] }); + const diff = diffManifests(m, m); + expect(diff.added).toEqual({}); + expect(diff.removed).toEqual({}); + expect(diff.newModules).toEqual([]); + expect(diff.removedModules).toEqual([]); + }); + + it("detects added exports in existing module", () => { + const old = makeManifest({ "next/headers": ["cookies"] }); + const updated = makeManifest({ + "next/headers": ["cookies", "draftMode", "headers"], + }); + const diff = diffManifests(old, updated); + expect(diff.added).toEqual({ + "next/headers": ["draftMode", "headers"], + }); + expect(diff.removed).toEqual({}); + }); + + it("detects removed exports", () => { + const old = makeManifest({ + "next/headers": ["cookies", "draftMode", "headers"], + }); + const updated = makeManifest({ "next/headers": ["cookies"] }); + const diff = diffManifests(old, updated); + expect(diff.removed).toEqual({ + "next/headers": ["draftMode", "headers"], + }); + expect(diff.added).toEqual({}); + }); + + it("detects entirely new module", () => { + const old = makeManifest({ "next/headers": ["cookies"] }); + const updated = makeManifest({ + "next/headers": ["cookies"], + "next/cache": ["revalidateTag"], + }); + const diff = diffManifests(old, updated); + expect(diff.newModules).toEqual(["next/cache"]); + // Added exports for new modules are NOT in `added` (they're in newModules) + expect(diff.added).toEqual({}); + }); + + it("detects entirely removed module", () => { + const old = makeManifest({ + "next/headers": ["cookies"], + "next/cache": ["revalidateTag"], + }); + const updated = makeManifest({ "next/headers": ["cookies"] }); + const diff = diffManifests(old, updated); + expect(diff.removedModules).toEqual(["next/cache"]); + expect(diff.removed).toEqual({}); + }); + + it("handles simultaneous add+remove across modules", () => { + const old = makeManifest({ + "next/headers": ["cookies", "draftMode"], + "next/server": ["NextRequest"], + }); + const updated = makeManifest({ + "next/headers": ["cookies", "headers"], + "next/server": ["NextRequest", "NextResponse"], + }); + const diff = diffManifests(old, updated); + expect(diff.added).toEqual({ + "next/headers": ["headers"], + "next/server": ["NextResponse"], + }); + expect(diff.removed).toEqual({ + "next/headers": ["draftMode"], + }); + }); + + it("sorts results deterministically", () => { + const old = makeManifest({ + "next/z": ["a"], + "next/a": ["z"], + }); + const updated = makeManifest({ + "next/z": ["a"], + "next/a": ["z"], + "next/m": ["x"], + "next/b": ["y"], + }); + const diff = diffManifests(old, updated); + expect(diff.newModules).toEqual(["next/b", "next/m"]); + }); + + it("empty manifests produce empty diff", () => { + const old = makeManifest({}); + const updated = makeManifest({}); + const diff = diffManifests(old, updated); + expect(diff.added).toEqual({}); + expect(diff.removed).toEqual({}); + expect(diff.newModules).toEqual([]); + expect(diff.removedModules).toEqual([]); + }); +}); + +describe("loadManifest", () => { + it("reads and parses JSON file", () => { + const manifest = loadManifest(path.join(FIXTURE_DIR, "sample-manifest.json")); + expect(manifest.version).toBe("99.0.0"); + expect(manifest.modules).toHaveProperty("next/foo"); + expect(manifest.modules["next/foo"]).toEqual(["bar", "baz"]); + }); + + it("throws on invalid JSON", () => { + // Use a file that definitely isn't valid JSON + expect(() => loadManifest(path.join(FIXTURE_DIR, "pattern-a/headers.js"))).toThrow(); + }); +}); diff --git a/tests/shim-coverage.test.ts b/tests/shim-coverage.test.ts new file mode 100644 index 00000000..f0be124e --- /dev/null +++ b/tests/shim-coverage.test.ts @@ -0,0 +1,218 @@ +import { describe, it, expect } from "vitest"; +import { + extractExports, + checkModuleCoverage, + type KnownGaps, +} from "../packages/vinext/src/shims/coverage"; + +describe("extractExports", () => { + it("parses function, const, class, and default exports", () => { + const source = ` + export function foo() {} + export const bar = 1; + export class Baz {} + export default function() {} + `; + const { runtime } = extractExports(source); + expect(runtime).toEqual(new Set(["foo", "bar", "Baz", "default"])); + }); + + it("handles re-exports with as clauses", () => { + const source = `export { X, Y as Z }`; + const { runtime } = extractExports(source); + expect(runtime).toEqual(new Set(["X", "Z"])); + }); + + it("handles async functions and enums", () => { + const source = ` + export async function fetchData() {} + export enum Color { Red, Green } + `; + const { runtime } = extractExports(source); + expect(runtime).toEqual(new Set(["fetchData", "Color"])); + }); + + it("separates type exports from runtime exports", () => { + const source = ` + export type Foo = string; + export interface Bar { x: number; } + export function baz() {} + export { type Qux } from './other'; + `; + const { runtime, types } = extractExports(source); + expect(runtime).toEqual(new Set(["baz"])); + expect(types).toContain("Foo"); + expect(types).toContain("Bar"); + }); + + it("handles let and var exports", () => { + const source = ` + export let mutableValue = 0; + export var legacyValue = "hello"; + `; + const { runtime } = extractExports(source); + expect(runtime).toEqual(new Set(["mutableValue", "legacyValue"])); + }); +}); + +describe("checkModuleCoverage", () => { + const makeManifest = (modules: Record) => ({ + modules, + }); + + it("passes when all exports are covered", () => { + const manifest = makeManifest({ + "next/headers": ["cookies", "headers"], + }); + const registry = new Set(["next/headers"]); + const shimExports = { + "next/headers": new Set(["cookies", "headers"]), + }; + const result = checkModuleCoverage(manifest, registry, shimExports, {}); + expect(result.passed).toBe(true); + expect(result.coveredModules).toEqual(["next/headers"]); + }); + + it("fails when export missing from shim", () => { + const manifest = makeManifest({ + "next/headers": ["cookies", "headers", "draftMode"], + }); + const registry = new Set(["next/headers"]); + const shimExports = { + "next/headers": new Set(["cookies", "headers"]), + }; + const result = checkModuleCoverage(manifest, registry, shimExports, {}); + expect(result.passed).toBe(false); + expect(result.missingExports["next/headers"]).toEqual(["draftMode"]); + }); + + it("respects wildcard known-gaps", () => { + const manifest = makeManifest({ "next/jest": ["default"] }); + const gaps: KnownGaps = { + "next/jest": { exports: ["*"], status: "wont-fix", reason: "test" }, + }; + const result = checkModuleCoverage(manifest, new Set(), {}, gaps); + expect(result.passed).toBe(true); + expect(result.gappedModules).toEqual(["next/jest"]); + }); + + it("reports missing module when not in registry or gaps", () => { + const manifest = makeManifest({ "next/unknown": ["foo"] }); + const result = checkModuleCoverage(manifest, new Set(), {}, {}); + expect(result.passed).toBe(false); + expect(result.missingModules).toEqual(["next/unknown"]); + }); + + it("respects per-export known-gaps", () => { + const manifest = makeManifest({ + "next/amp": ["useAmp", "anotherExport"], + }); + const registry = new Set(["next/amp"]); + const shimExports = { "next/amp": new Set(["useAmp"]) }; + const gaps: KnownGaps = { + "next/amp": { + exports: ["anotherExport"], + status: "stub", + reason: "test", + }, + }; + const result = checkModuleCoverage(manifest, registry, shimExports, gaps); + expect(result.passed).toBe(true); + }); + + it("skips __esModule but checks default in export checks", () => { + const manifest = makeManifest({ + "next/headers": ["__esModule", "default", "cookies"], + }); + const registry = new Set(["next/headers"]); + const shimExports = { + "next/headers": new Set(["cookies"]), + }; + const result = checkModuleCoverage(manifest, registry, shimExports, {}); + // Should fail because "default" is not in shimExports + expect(result.passed).toBe(false); + expect(result.missingExports["next/headers"]).toEqual(["default"]); + }); + + it("passes when default export is present in shim", () => { + const manifest = makeManifest({ + "next/form": ["__esModule", "default"], + }); + const registry = new Set(["next/form"]); + const shimExports = { + "next/form": new Set(["default"]), + }; + const result = checkModuleCoverage(manifest, registry, shimExports, {}); + expect(result.passed).toBe(true); + expect(result.coveredModules).toEqual(["next/form"]); + }); + + it("catches missing default export", () => { + const manifest = makeManifest({ + "next/error": ["default"], + }); + const registry = new Set(["next/error"]); + const shimExports = { + "next/error": new Set([]), + }; + const result = checkModuleCoverage(manifest, registry, shimExports, {}); + expect(result.passed).toBe(false); + expect(result.missingExports["next/error"]).toEqual(["default"]); + }); + + it("handles multiple modules with mixed coverage", () => { + const manifest = makeManifest({ + "next/headers": ["cookies", "headers"], + "next/cache": ["revalidateTag", "missingFn"], + "next/jest": ["default"], + }); + const registry = new Set(["next/headers", "next/cache"]); + const shimExports = { + "next/headers": new Set(["cookies", "headers"]), + "next/cache": new Set(["revalidateTag"]), + }; + const gaps: KnownGaps = { + "next/jest": { exports: ["*"], status: "wont-fix", reason: "test" }, + }; + const result = checkModuleCoverage(manifest, registry, shimExports, gaps); + expect(result.passed).toBe(false); + expect(result.coveredModules).toEqual(["next/headers"]); + expect(result.gappedModules).toEqual(["next/jest"]); + expect(result.missingExports["next/cache"]).toEqual(["missingFn"]); + }); + + it("combines per-export gaps with shim exports for full coverage", () => { + const manifest = makeManifest({ + "next/navigation": ["useRouter", "redirect", "unstable_rethrow"], + }); + const registry = new Set(["next/navigation"]); + const shimExports = { + "next/navigation": new Set(["useRouter", "redirect"]), + }; + const gaps: KnownGaps = { + "next/navigation": { + exports: ["unstable_rethrow"], + status: "planned", + reason: "Not yet implemented", + }, + }; + const result = checkModuleCoverage(manifest, registry, shimExports, gaps); + expect(result.passed).toBe(true); + expect(result.coveredModules).toEqual(["next/navigation"]); + }); + + it("ignores registry-only shims that are outside the current manifest", () => { + const manifest = makeManifest({ + "next/headers": ["cookies"], + }); + const registry = new Set(["next/headers", "next/config"]); + const shimExports = { + "next/headers": new Set(["cookies"]), + "next/config": new Set(["default"]), + }; + const result = checkModuleCoverage(manifest, registry, shimExports, {}); + expect(result.passed).toBe(true); + expect(result.coveredModules).toEqual(["next/headers"]); + expect(result.missingModules).toEqual([]); + }); +}); diff --git a/tests/shim-registry.test.ts b/tests/shim-registry.test.ts new file mode 100644 index 00000000..60dbdc9c --- /dev/null +++ b/tests/shim-registry.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from "vitest"; +import path from "node:path"; +import fs from "node:fs"; +import { + PUBLIC_SHIMS, + INTERNAL_SHIMS, + VINEXT_SHIMS_DIR_ENTRIES, + VINEXT_SERVER_ENTRIES, + buildShimMap, +} from "../packages/vinext/src/shims/registry"; + +describe("shim registry", () => { + // 1. PUBLIC_SHIMS has the right number of entries + it("PUBLIC_SHIMS contains 23 entries with correct keys", () => { + expect(Object.keys(PUBLIC_SHIMS)).toHaveLength(23); + expect(PUBLIC_SHIMS["next/link"]).toBe("link"); + expect(PUBLIC_SHIMS["next/navigation"]).toBe("navigation"); + expect(PUBLIC_SHIMS["next/headers"]).toBe("headers"); + }); + + // 2. buildShimMap produces absolute paths + it("buildShimMap produces absolute paths for all entries", () => { + const map = buildShimMap("/fake/shims", "/fake/src"); + // All values should be absolute paths + for (const [key, value] of Object.entries(map)) { + expect(path.isAbsolute(value), `${key} -> ${value} should be absolute`).toBe(true); + } + }); + + // 3. User aliases are overridden by vinext mappings + it("user aliases are overridden by vinext mappings", () => { + const map = buildShimMap("/fake/shims", "/fake/src", { + "next/link": "/user/custom/link", + "custom/thing": "/user/custom/thing", + }); + // vinext mapping wins + expect(map["next/link"]).toBe("/fake/shims/link"); + // user-only alias preserved + expect(map["custom/thing"]).toBe("/user/custom/thing"); + }); + + // 4. Total entry count + it("total entry count across all categories is 46", () => { + const total = + Object.keys(PUBLIC_SHIMS).length + + Object.keys(INTERNAL_SHIMS).length + + Object.keys(VINEXT_SHIMS_DIR_ENTRIES).length + + Object.keys(VINEXT_SERVER_ENTRIES).length; + expect(total).toBe(48); + }); + + // 5. All shim file targets exist on disk + it("all shim file targets exist on disk", () => { + const shimsDir = path.resolve(import.meta.dirname, "../packages/vinext/src/shims"); + const srcDir = path.resolve(import.meta.dirname, "../packages/vinext/src"); + + const allEntries = { + ...PUBLIC_SHIMS, + ...INTERNAL_SHIMS, + ...VINEXT_SHIMS_DIR_ENTRIES, + }; + + for (const [key, relPath] of Object.entries(allEntries)) { + const fullPath = path.join(shimsDir, relPath); + const exists = + fs.existsSync(fullPath + ".ts") || + fs.existsSync(fullPath + ".tsx") || + fs.existsSync(fullPath + "/index.ts"); + expect(exists, `Shim target for "${key}" not found at ${fullPath}`).toBe(true); + } + + for (const [key, relPath] of Object.entries(VINEXT_SERVER_ENTRIES)) { + const fullPath = path.resolve(srcDir, relPath); + const exists = fs.existsSync(fullPath + ".ts") || fs.existsSync(fullPath + ".tsx"); + expect(exists, `Server entry for "${key}" not found at ${fullPath}`).toBe(true); + } + }); +});