From d05744bc638ab88dd9af5af6b0095f69f3d717e6 Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Tue, 10 Mar 2026 23:47:11 -0700 Subject: [PATCH 1/7] feat: add Next.js API surface tracking system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add machine-readable API tracking infrastructure to enforce compatibility between vinext's shims and the published Next.js API surface. - Extract API manifest from next@16.1.6 (18 modules, scripts/extract-nextjs-api.ts) - Add manifest diff script for detecting API changes (scripts/diff-nextjs-api.ts) - Extract inline shim map into shared registry module (shims/registry.ts) - Add shim coverage checker with known-gaps support (shims/coverage.ts) - Add CI gate for shim coverage (ci.yml shim-coverage job) - Add 5 behavioral contract tests (redirect 303, cookies, headers, middleware, metadata) - Fix server action redirect status: 307 → 303 to match Next.js behavior - Add weekly Next.js stable release tracking workflow (nextjs-api-track.yml) - Enhance tip.yml with optional compat test job Closes #454 --- .github/workflows/ci.yml | 8 + .github/workflows/nextjs-api-track.yml | 93 +++++++ .github/workflows/tip.yml | 20 ++ api-manifest.json | 139 ++++++++++ known-gaps.json | 47 ++++ packages/vinext/src/entries/app-rsc-entry.ts | 2 +- packages/vinext/src/index.ts | 85 +----- packages/vinext/src/shims/coverage.ts | 139 ++++++++++ packages/vinext/src/shims/registry.ts | 116 ++++++++ scripts/check-shim-coverage.ts | 71 +++++ scripts/diff-nextjs-api.ts | 156 +++++++++++ scripts/extract-nextjs-api.ts | 202 ++++++++++++++ tests/api-manifest.test.ts | 253 ++++++++++++++++++ tests/contracts/_helpers.ts | 26 ++ tests/contracts/behavioral.contract.test.ts | 83 ++++++ .../contracts/api/cookies-mutable/route.ts | 8 + .../contracts/api/headers-readonly/route.ts | 13 + .../contracts/api/middleware-headers/route.ts | 9 + .../app/contracts/metadata-merge/layout.tsx | 12 + .../app/contracts/metadata-merge/page.tsx | 9 + .../contracts/redirect-from-action/page.tsx | 14 + tests/fixtures/app-basic/middleware.ts | 1 + .../next-api-manifest/full-package/form.js | 1 + .../next-api-manifest/full-package/headers.js | 3 + .../full-package/navigation.js | 1 + .../full-package/package.json | 4 + .../next-api-manifest/pattern-a/headers.js | 3 + .../pattern-b1/navigation.js | 1 + .../next-api-manifest/pattern-b2/form.js | 1 + .../next-api-manifest/sample-manifest.json | 8 + tests/manifest-diff.test.ts | 130 +++++++++ tests/shim-coverage.test.ts | 175 ++++++++++++ tests/shim-registry.test.ts | 78 ++++++ 33 files changed, 1827 insertions(+), 84 deletions(-) create mode 100644 .github/workflows/nextjs-api-track.yml create mode 100644 api-manifest.json create mode 100644 known-gaps.json create mode 100644 packages/vinext/src/shims/coverage.ts create mode 100644 packages/vinext/src/shims/registry.ts create mode 100644 scripts/check-shim-coverage.ts create mode 100644 scripts/diff-nextjs-api.ts create mode 100644 scripts/extract-nextjs-api.ts create mode 100644 tests/api-manifest.test.ts create mode 100644 tests/contracts/_helpers.ts create mode 100644 tests/contracts/behavioral.contract.test.ts create mode 100644 tests/fixtures/app-basic/app/contracts/api/cookies-mutable/route.ts create mode 100644 tests/fixtures/app-basic/app/contracts/api/headers-readonly/route.ts create mode 100644 tests/fixtures/app-basic/app/contracts/api/middleware-headers/route.ts create mode 100644 tests/fixtures/app-basic/app/contracts/metadata-merge/layout.tsx create mode 100644 tests/fixtures/app-basic/app/contracts/metadata-merge/page.tsx create mode 100644 tests/fixtures/app-basic/app/contracts/redirect-from-action/page.tsx create mode 100644 tests/fixtures/next-api-manifest/full-package/form.js create mode 100644 tests/fixtures/next-api-manifest/full-package/headers.js create mode 100644 tests/fixtures/next-api-manifest/full-package/navigation.js create mode 100644 tests/fixtures/next-api-manifest/full-package/package.json create mode 100644 tests/fixtures/next-api-manifest/pattern-a/headers.js create mode 100644 tests/fixtures/next-api-manifest/pattern-b1/navigation.js create mode 100644 tests/fixtures/next-api-manifest/pattern-b2/form.js create mode 100644 tests/fixtures/next-api-manifest/sample-manifest.json create mode 100644 tests/manifest-diff.test.ts create mode 100644 tests/shim-coverage.test.ts create mode 100644 tests/shim-registry.test.ts 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..0385616d --- /dev/null +++ b/.github/workflows/nextjs-api-track.yml @@ -0,0 +1,93 @@ +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: | + npm install --no-save "next@${{ steps.next-version.outputs.latest }}" + pnpm tsx scripts/extract-nextjs-api.ts node_modules/next new-manifest.json + + - name: Diff manifests + if: steps.next-version.outputs.changed == 'true' + id: diff + run: | + DIFF=$(pnpm 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: 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: | + # Check if an issue already exists for this version + EXISTING=$(gh issue list --label "next-api-tracking" --search "Next.js $NEXT_LATEST" --json number --jq '.[0].number // empty') + if [ -n "$EXISTING" ]; then + echo "Issue #$EXISTING already exists for next@$NEXT_LATEST, skipping" + exit 0 + fi + + gh issue create \ + --title "Next.js API update: ${NEXT_CURRENT} → ${NEXT_LATEST}" \ + --label "next-api-tracking" \ + --body "$(cat <; // 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" || exp === "default") 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..3363b950 --- /dev/null +++ b/scripts/extract-nextjs-api.ts @@ -0,0 +1,202 @@ +import fs from "node:fs"; +import path from "node:path"; + +// Public entry points to scan (maps to next/*.js files) +export const PUBLIC_MODULES = [ + "app", + "cache", + "client", + "constants", + "document", + "dynamic", + "error", + "form", + "head", + "headers", + "image", + "jest", + "link", + "navigation", + "og", + "root-params", + "router", + "script", + "server", +]; + +export interface ApiManifest { + version: string; + extractedAt: string; + modules: Record; // "next/headers" -> ["cookies", "headers", "draftMode"] +} + +/** + * 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): string[] { + const rootFile = path.join(nextPkgDir, `${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 nextPkgDir + // Handle both './dist/...' and 'next/dist/...' paths + const targetPath = target.startsWith("next/") + ? path.join(nextPkgDir, target.slice(5) + ".js") + : path.join(nextPkgDir, target + ".js"); + + if (!fs.existsSync(targetPath)) return []; + + const distSource = fs.readFileSync(targetPath, "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 mod of PUBLIC_MODULES) { + const moduleExports = getModuleExports(nextPkgDir, mod); + if (moduleExports.length > 0) { + modules[`next/${mod}`] = 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/api-manifest.test.ts b/tests/api-manifest.test.ts new file mode 100644 index 00000000..be32b983 --- /dev/null +++ b/tests/api-manifest.test.ts @@ -0,0 +1,253 @@ +import { describe, it, expect } from "vitest"; +import path from "node:path"; +import { + extractExportsFromRootFile, + resolveReExportTarget, + extractExportsFromDistFile, + getModuleExports, + buildManifest, +} from "../scripts/extract-nextjs-api.js"; + +const FIXTURE_DIR = path.join(import.meta.dirname, "fixtures/next-api-manifest"); + +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([]); + }); +}); + +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", () => { + const dir = path.join(FIXTURE_DIR, "full-package"); + const manifest = buildManifest(dir); + // full-package has headers.js, navigation.js, form.js + expect(manifest.modules).toHaveProperty("next/headers"); + expect(manifest.modules).toHaveProperty("next/navigation"); + expect(manifest.modules).toHaveProperty("next/form"); + }); + + it("skips modules with no exports", () => { + 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"); + }); + + 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/contracts/_helpers.ts b/tests/contracts/_helpers.ts new file mode 100644 index 00000000..73ccfe2a --- /dev/null +++ b/tests/contracts/_helpers.ts @@ -0,0 +1,26 @@ +import { startFixtureServer, APP_FIXTURE_DIR, type TestServerResult } from "../helpers"; + +let server: TestServerResult | null = null; + +/** + * Get or create the shared contract test server. + * Uses the existing app-basic fixture. + */ +export async function getContractServer(): Promise { + // Allow overriding with an external URL for future prod testing + if (process.env.CONTRACT_TARGET_URL) { + return { server: null as any, baseUrl: process.env.CONTRACT_TARGET_URL }; + } + + if (!server) { + server = await startFixtureServer(APP_FIXTURE_DIR); + } + return server; +} + +export async function closeContractServer(): Promise { + if (server && !process.env.CONTRACT_TARGET_URL) { + await server.server.close(); + server = null; + } +} diff --git a/tests/contracts/behavioral.contract.test.ts b/tests/contracts/behavioral.contract.test.ts new file mode 100644 index 00000000..ec64aaeb --- /dev/null +++ b/tests/contracts/behavioral.contract.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, afterAll } from "vitest"; +import { getContractServer, closeContractServer } from "./_helpers"; + +describe("behavioral contracts", () => { + afterAll(async () => { + await closeContractServer(); + }); + + // Contract 1: redirect() in server action returns 303 + // Next.js uses 303 See Other for server action redirects (action-handler.ts:1182). + // This ensures vinext matches that behavior rather than using 307. + it("redirect() in server action returns 303 status", async () => { + const { baseUrl } = await getContractServer(); + + // First, get the page to extract the server action ID from the rendered HTML. + // The RSC plugin embeds action IDs as hidden inputs: $ACTION_ID_ + const pageRes = await fetch(`${baseUrl}/contracts/redirect-from-action`); + const html = await pageRes.text(); + + // Extract the action reference ID from the hidden input's name attribute. + // Format: + const actionIdMatch = html.match(/\$ACTION_ID_([^"]+)/); + expect(actionIdMatch).not.toBeNull(); + const actionId = actionIdMatch![1]; + + // Submit the action via POST with x-rsc-action header. + // vinext uses x-rsc-action (not Next-Action) to identify the action. + const res = await fetch(`${baseUrl}/contracts/redirect-from-action`, { + method: "POST", + headers: { + "Content-Type": "text/plain", + "x-rsc-action": actionId, + Accept: "text/x-component", + Origin: baseUrl, + }, + body: "[]", + redirect: "manual", + }); + + // Server action redirects return 200 with x-action-redirect-status header + // (the redirect is handled client-side, not via HTTP redirect). + const redirectStatus = res.headers.get("x-action-redirect-status"); + expect(redirectStatus).toBe("303"); + expect(res.headers.get("x-action-redirect")).toContain("/about"); + }); + + // Contract 2: cookies() is mutable in route handlers + it("cookies() is mutable in route handler", async () => { + const { baseUrl } = await getContractServer(); + const res = await fetch(`${baseUrl}/contracts/api/cookies-mutable`); + expect(res.status).toBe(200); + const setCookie = res.headers.get("set-cookie"); + expect(setCookie).toContain("contract-test=value"); + }); + + // Contract 3: headers() is read-only during render + it("headers() is read-only in route handler render context", 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); + }); + + // Contract 4: Middleware response headers propagate to final response + // The middleware sets x-mw-ran and x-mw-pathname as response headers via + // NextResponse.next(). These should be merged into the final HTTP response. + it("middleware response headers propagate to 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"); + }); + + // Contract 5: generateMetadata() title template merging + it("metadata title template is applied from parent layout", async () => { + const { baseUrl } = await getContractServer(); + const res = await fetch(`${baseUrl}/contracts/metadata-merge`); + 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/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/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/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/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/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/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/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..5b440410 --- /dev/null +++ b/tests/shim-coverage.test.ts @@ -0,0 +1,175 @@ +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 and 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, {}); + expect(result.passed).toBe(true); + }); + + 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"]); + }); +}); 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); + } + }); +}); From bff631a14ed881442db3508ea10619c6f5aae744 Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Wed, 11 Mar 2026 00:35:35 -0700 Subject: [PATCH 2/7] fix: address review findings in API tracking system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix workflow tsx invocations: pnpm tsx → pnpm dlx tsx - Add label creation step before issue search in tracking workflow - Fix HEREDOC indentation in issue body template - Stop skipping default exports in shim coverage check - Add subdirectory module discovery (compat/router, legacy/image, font/google, font/local, web-vitals) to manifest extractor - Regenerate api-manifest.json: 18 → 21 modules covered - Add test fixtures for subdirectory module extraction --- .github/workflows/nextjs-api-track.yml | 28 ++++++--- api-manifest.json | 7 ++- packages/vinext/src/shims/coverage.ts | 2 +- scripts/extract-nextjs-api.ts | 39 +++++++++--- tests/api-manifest.test.ts | 60 ++++++++++++++++++- .../full-package/compat/router.js | 1 + .../full-package/font/google/index.js | 2 + .../full-package/font/local/index.js | 2 + .../full-package/legacy/image.js | 1 + .../full-package/web-vitals.js | 1 + tests/shim-coverage.test.ts | 30 +++++++++- 11 files changed, 150 insertions(+), 23 deletions(-) create mode 100644 tests/fixtures/next-api-manifest/full-package/compat/router.js create mode 100644 tests/fixtures/next-api-manifest/full-package/font/google/index.js create mode 100644 tests/fixtures/next-api-manifest/full-package/font/local/index.js create mode 100644 tests/fixtures/next-api-manifest/full-package/legacy/image.js create mode 100644 tests/fixtures/next-api-manifest/full-package/web-vitals.js diff --git a/.github/workflows/nextjs-api-track.yml b/.github/workflows/nextjs-api-track.yml index 0385616d..fdcc6aed 100644 --- a/.github/workflows/nextjs-api-track.yml +++ b/.github/workflows/nextjs-api-track.yml @@ -38,17 +38,23 @@ jobs: if: steps.next-version.outputs.changed == 'true' run: | npm install --no-save "next@${{ steps.next-version.outputs.latest }}" - pnpm tsx scripts/extract-nextjs-api.ts node_modules/next new-manifest.json + pnpm dlx tsx scripts/extract-nextjs-api.ts node_modules/next new-manifest.json - name: Diff manifests if: steps.next-version.outputs.changed == 'true' id: diff run: | - DIFF=$(pnpm tsx scripts/diff-nextjs-api.ts api-manifest.json new-manifest.json 2>&1) || true + 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: @@ -65,10 +71,7 @@ jobs: exit 0 fi - gh issue create \ - --title "Next.js API update: ${NEXT_CURRENT} → ${NEXT_LATEST}" \ - --label "next-api-tracking" \ - --body "$(cat < = [ + { name: "compat/router", entryFile: "compat/router.js" }, + { name: "legacy/image", entryFile: "legacy/image.js" }, + { name: "font/google", entryFile: "font/google/index.js" }, + { name: "font/local", entryFile: "font/local/index.js" }, ]; export interface ApiManifest { @@ -143,8 +152,12 @@ export function extractExportsFromDistFile(source: string): string[] { /** * Get all exports for a module, handling both Pattern A and B. */ -export function getModuleExports(nextPkgDir: string, moduleName: string): string[] { - const rootFile = path.join(nextPkgDir, `${moduleName}.js`); +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"); @@ -157,11 +170,15 @@ export function getModuleExports(nextPkgDir: string, moduleName: string): string const target = resolveReExportTarget(source); if (!target) return []; - // Resolve target relative to nextPkgDir - // Handle both './dist/...' and 'next/dist/...' paths - const targetPath = target.startsWith("next/") - ? path.join(nextPkgDir, target.slice(5) + ".js") - : path.join(nextPkgDir, target + ".js"); + // 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) + ".js"); + } else { + const entryDir = path.dirname(rootFile); + targetPath = path.join(entryDir, target + ".js"); + } if (!fs.existsSync(targetPath)) return []; @@ -183,6 +200,14 @@ export function buildManifest(nextPkgDir: string): ApiManifest { } } + // Subdirectory modules + for (const { name, entryFile } of SUBDIRECTORY_MODULES) { + const moduleExports = getModuleExports(nextPkgDir, name, entryFile); + if (moduleExports.length > 0) { + modules[`next/${name}`] = moduleExports.sort(); + } + } + return { version: pkgJson.version, extractedAt: new Date().toISOString(), diff --git a/tests/api-manifest.test.ts b/tests/api-manifest.test.ts index be32b983..89db8b3c 100644 --- a/tests/api-manifest.test.ts +++ b/tests/api-manifest.test.ts @@ -6,10 +6,35 @@ import { extractExportsFromDistFile, getModuleExports, buildManifest, + PUBLIC_MODULES, + SUBDIRECTORY_MODULES, } from "../scripts/extract-nextjs-api.js"; const FIXTURE_DIR = path.join(import.meta.dirname, "fixtures/next-api-manifest"); +describe("module lists", () => { + it("PUBLIC_MODULES has 20 entries", () => { + expect(PUBLIC_MODULES).toHaveLength(20); + expect(PUBLIC_MODULES).toContain("web-vitals"); + }); + + it("SUBDIRECTORY_MODULES has 4 entries", () => { + expect(SUBDIRECTORY_MODULES).toHaveLength(4); + expect(SUBDIRECTORY_MODULES.map((m) => m.name)).toEqual([ + "compat/router", + "legacy/image", + "font/google", + "font/local", + ]); + }); + + it("SUBDIRECTORY_MODULES entries have correct entryFile paths", () => { + for (const mod of SUBDIRECTORY_MODULES) { + expect(mod.entryFile).toMatch(/\.js$/); + } + }); +}); + describe("extractExportsFromRootFile", () => { it("extracts Pattern A module.exports.X = ... style exports", () => { const source = ` @@ -209,6 +234,24 @@ describe("getModuleExports (integration with fixtures)", () => { 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([]); + }); }); describe("buildManifest", () => { @@ -218,21 +261,32 @@ describe("buildManifest", () => { expect(manifest.version).toBe("99.0.0-test"); }); - it("has all non-empty modules", () => { + it("has all non-empty modules including subdirectory modules", () => { const dir = path.join(FIXTURE_DIR, "full-package"); const manifest = buildManifest(dir); - // full-package has headers.js, navigation.js, form.js + // Root modules: headers.js, navigation.js, form.js, web-vitals.js 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"); + // Subdirectory modules: compat/router, legacy/image + expect(manifest.modules).toHaveProperty("next/compat/router"); + expect(manifest.modules).toHaveProperty("next/legacy/image"); + // Verify exports + expect(manifest.modules["next/compat/router"]).toEqual(["useRouter"]); + expect(manifest.modules["next/legacy/image"]).toEqual(["default"]); + expect(manifest.modules["next/web-vitals"]).toEqual(["useReportWebVitals"]); }); - it("skips modules with no exports", () => { + 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"); }); it("exports are sorted alphabetically", () => { 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/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/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/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/shim-coverage.test.ts b/tests/shim-coverage.test.ts index 5b440410..8290956d 100644 --- a/tests/shim-coverage.test.ts +++ b/tests/shim-coverage.test.ts @@ -120,7 +120,7 @@ describe("checkModuleCoverage", () => { expect(result.passed).toBe(true); }); - it("skips __esModule and default in export checks", () => { + it("skips __esModule but checks default in export checks", () => { const manifest = makeManifest({ "next/headers": ["__esModule", "default", "cookies"], }); @@ -129,7 +129,35 @@ describe("checkModuleCoverage", () => { "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", () => { From 1201980d416bd4720e1c93a91bcbe08c09e04849 Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Wed, 11 Mar 2026 01:09:22 -0700 Subject: [PATCH 3/7] feat: finish issue 454 API tracking follow-up --- .github/workflows/nextjs-api-track.yml | 13 +- api-manifest.json | 18 +- known-gaps.json | 27 ++- scripts/extract-nextjs-api.ts | 99 ++++++----- tests/api-manifest.test.ts | 75 ++++++--- tests/app-router.test.ts | 28 ++++ tests/contracts/_helpers.ts | 158 +++++++++++++++++- tests/contracts/behavioral.contract.test.ts | 83 --------- tests/contracts/http.contract.test.ts | 44 +++++ .../contracts/metadata-merge/extra/page.tsx | 9 + .../next-api-manifest/full-package/babel.js | 1 + .../experimental/testmode/proxy.js | 1 + tests/helpers.ts | 4 +- tests/shim-coverage.test.ts | 15 ++ 14 files changed, 405 insertions(+), 170 deletions(-) delete mode 100644 tests/contracts/behavioral.contract.test.ts create mode 100644 tests/contracts/http.contract.test.ts create mode 100644 tests/fixtures/app-basic/app/contracts/metadata-merge/extra/page.tsx create mode 100644 tests/fixtures/next-api-manifest/full-package/babel.js create mode 100644 tests/fixtures/next-api-manifest/full-package/experimental/testmode/proxy.js diff --git a/.github/workflows/nextjs-api-track.yml b/.github/workflows/nextjs-api-track.yml index fdcc6aed..8a612b54 100644 --- a/.github/workflows/nextjs-api-track.yml +++ b/.github/workflows/nextjs-api-track.yml @@ -37,8 +37,11 @@ jobs: - name: Extract new API manifest if: steps.next-version.outputs.changed == 'true' run: | - npm install --no-save "next@${{ steps.next-version.outputs.latest }}" - pnpm dlx tsx scripts/extract-nextjs-api.ts node_modules/next new-manifest.json + 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' @@ -64,8 +67,8 @@ jobs: API_DIFF: ${{ steps.diff.outputs.diff }} REPO: ${{ github.repository }} run: | - # Check if an issue already exists for this version - EXISTING=$(gh issue list --label "next-api-tracking" --search "Next.js $NEXT_LATEST" --json number --jq '.[0].number // empty') + 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 @@ -77,6 +80,8 @@ jobs: **Previous version:** ${NEXT_CURRENT} **New version:** ${NEXT_LATEST} + ${MARKER} + ### API Diff \`\`\` diff --git a/api-manifest.json b/api-manifest.json index 75c64c4e..7faf44d1 100644 --- a/api-manifest.json +++ b/api-manifest.json @@ -1,8 +1,9 @@ { "version": "16.1.6", - "extractedAt": "2026-03-11T07:32:38.066Z", + "extractedAt": "2026-03-11T07:56:13.539Z", "modules": { "next/app": ["default"], + "next/babel": ["default"], "next/cache": [ "cacheLife", "cacheTag", @@ -16,6 +17,7 @@ "updateTag" ], "next/client": ["emitter", "hydrate", "initialize", "router", "version"], + "next/compat/router": ["useRouter"], "next/constants": [ "APP_CLIENT_INTERNALS", "APP_PATHS_MANIFEST", @@ -90,11 +92,21 @@ "next/document": ["Head", "Html", "Main", "NextScript", "default"], "next/dynamic": ["default", "noSSR"], "next/error": ["default"], + "next/experimental/testing/server": ["getRedirectUrl", "getRewrittenUrl", "isRewrite"], + "next/experimental/testmode/playwright": [ + "default", + "defaultPlaywrightConfig", + "defineConfig", + "test" + ], + "next/experimental/testmode/playwright/msw": ["default", "defineConfig", "test"], + "next/experimental/testmode/proxy": ["createProxyServer"], "next/form": ["default"], "next/head": ["default", "defaultHead"], "next/headers": ["cookies", "draftMode", "headers"], "next/image": ["default", "getImageProps"], "next/jest": ["default"], + "next/legacy/image": ["default"], "next/link": ["default", "useLinkStatus"], "next/navigation": [ "ReadonlyURLSearchParams", @@ -135,8 +147,6 @@ "userAgent", "userAgentFromString" ], - "next/web-vitals": ["useReportWebVitals"], - "next/compat/router": ["useRouter"], - "next/legacy/image": ["default"] + "next/web-vitals": ["useReportWebVitals"] } } diff --git a/known-gaps.json b/known-gaps.json index 3c4877d9..f07bc10d 100644 --- a/known-gaps.json +++ b/known-gaps.json @@ -4,6 +4,11 @@ "status": "wont-fix", "reason": "vinext uses Vitest, not Jest" }, + "next/babel": { + "exports": ["*"], + "status": "wont-fix", + "reason": "vinext uses Vite and does not provide Next.js's Babel preset entrypoint" + }, "next/client": { "exports": ["*"], "status": "wont-fix", @@ -12,7 +17,27 @@ "next/app": { "exports": ["*"], "status": "wont-fix", - "reason": "next/app only exports a default type (AppProps). Our shim has types but no runtime default export." + "reason": "next/app is a Pages Router entrypoint that vinext does not emulate as a standalone runtime module" + }, + "next/experimental/testing/server": { + "exports": ["*"], + "status": "wont-fix", + "reason": "Experimental testing helpers are outside vinext's compatibility target" + }, + "next/experimental/testmode/playwright": { + "exports": ["*"], + "status": "wont-fix", + "reason": "Experimental Playwright helpers are outside vinext's compatibility target" + }, + "next/experimental/testmode/playwright/msw": { + "exports": ["*"], + "status": "wont-fix", + "reason": "Experimental Playwright/MSW helpers are outside vinext's compatibility target" + }, + "next/experimental/testmode/proxy": { + "exports": ["*"], + "status": "wont-fix", + "reason": "Experimental testmode proxy helpers are outside vinext's compatibility target" }, "next/navigation": { "exports": ["unstable_rethrow", "unstable_isUnrecognizedActionError"], diff --git a/scripts/extract-nextjs-api.ts b/scripts/extract-nextjs-api.ts index 4804dbc8..b38e2881 100644 --- a/scripts/extract-nextjs-api.ts +++ b/scripts/extract-nextjs-api.ts @@ -1,37 +1,10 @@ import fs from "node:fs"; import path from "node:path"; -// Public entry points to scan (maps to next/*.js files) -export const PUBLIC_MODULES = [ - "app", - "cache", - "client", - "constants", - "document", - "dynamic", - "error", - "form", - "head", - "headers", - "image", - "jest", - "link", - "navigation", - "og", - "root-params", - "router", - "script", - "server", - "web-vitals", -]; - -// Public entry points in subdirectories (not at package root) -export const SUBDIRECTORY_MODULES: Array<{ name: string; entryFile: string }> = [ - { name: "compat/router", entryFile: "compat/router.js" }, - { name: "legacy/image", entryFile: "legacy/image.js" }, - { name: "font/google", entryFile: "font/google/index.js" }, - { name: "font/local", entryFile: "font/local/index.js" }, -]; +export interface PublicEntrypoint { + specifier: string; + entryFile: string; +} export interface ApiManifest { version: string; @@ -39,6 +12,46 @@ export interface ApiManifest { 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). * @@ -174,15 +187,17 @@ export function getModuleExports( // 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) + ".js"); + targetPath = path.join(nextPkgDir, target.slice(5)); } else { const entryDir = path.dirname(rootFile); - targetPath = path.join(entryDir, target + ".js"); + targetPath = path.join(entryDir, target); } - if (!fs.existsSync(targetPath)) return []; + const candidatePaths = [`${targetPath}.js`, path.join(targetPath, "index.js")]; + const resolvedTargetPath = candidatePaths.find((candidate) => fs.existsSync(candidate)); + if (!resolvedTargetPath) return []; - const distSource = fs.readFileSync(targetPath, "utf-8"); + const distSource = fs.readFileSync(resolvedTargetPath, "utf-8"); return extractExportsFromDistFile(distSource); } @@ -193,18 +208,10 @@ export function buildManifest(nextPkgDir: string): ApiManifest { const pkgJson = JSON.parse(fs.readFileSync(path.join(nextPkgDir, "package.json"), "utf-8")); const modules: Record = {}; - for (const mod of PUBLIC_MODULES) { - const moduleExports = getModuleExports(nextPkgDir, mod); - if (moduleExports.length > 0) { - modules[`next/${mod}`] = moduleExports.sort(); - } - } - - // Subdirectory modules - for (const { name, entryFile } of SUBDIRECTORY_MODULES) { - const moduleExports = getModuleExports(nextPkgDir, name, entryFile); + for (const { specifier, entryFile } of discoverPublicEntrypoints(nextPkgDir)) { + const moduleExports = getModuleExports(nextPkgDir, specifier.slice(5), entryFile); if (moduleExports.length > 0) { - modules[`next/${name}`] = moduleExports.sort(); + modules[specifier] = moduleExports.sort(); } } diff --git a/tests/api-manifest.test.ts b/tests/api-manifest.test.ts index 89db8b3c..735351d0 100644 --- a/tests/api-manifest.test.ts +++ b/tests/api-manifest.test.ts @@ -4,34 +4,47 @@ import { extractExportsFromRootFile, resolveReExportTarget, extractExportsFromDistFile, + discoverPublicEntrypoints, getModuleExports, buildManifest, - PUBLIC_MODULES, - SUBDIRECTORY_MODULES, } from "../scripts/extract-nextjs-api.js"; const FIXTURE_DIR = path.join(import.meta.dirname, "fixtures/next-api-manifest"); -describe("module lists", () => { - it("PUBLIC_MODULES has 20 entries", () => { - expect(PUBLIC_MODULES).toHaveLength(20); - expect(PUBLIC_MODULES).toContain("web-vitals"); - }); +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); - it("SUBDIRECTORY_MODULES has 4 entries", () => { - expect(SUBDIRECTORY_MODULES).toHaveLength(4); - expect(SUBDIRECTORY_MODULES.map((m) => m.name)).toEqual([ - "compat/router", - "legacy/image", - "font/google", - "font/local", + 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("SUBDIRECTORY_MODULES entries have correct entryFile paths", () => { - for (const mod of SUBDIRECTORY_MODULES) { - expect(mod.entryFile).toMatch(/\.js$/); - } + 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); }); }); @@ -252,6 +265,22 @@ describe("getModuleExports (integration with fixtures)", () => { 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", () => { @@ -264,16 +293,19 @@ describe("buildManifest", () => { it("has all non-empty modules including subdirectory modules", () => { const dir = path.join(FIXTURE_DIR, "full-package"); const manifest = buildManifest(dir); - // Root modules: headers.js, navigation.js, form.js, web-vitals.js + 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"); - // Subdirectory modules: compat/router, legacy/image + 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"); - // Verify exports + + 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"]); }); @@ -287,6 +319,7 @@ describe("buildManifest", () => { // 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", () => { 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 index 73ccfe2a..b9fa5ecc 100644 --- a/tests/contracts/_helpers.ts +++ b/tests/contracts/_helpers.ts @@ -1,26 +1,166 @@ -import { startFixtureServer, APP_FIXTURE_DIR, type TestServerResult } from "../helpers"; +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"; -let server: TestServerResult | null = null; +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. - * Uses the existing app-basic fixture. + * + * 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 { - // Allow overriding with an external URL for future prod testing +export async function getContractServer(): Promise { if (process.env.CONTRACT_TARGET_URL) { - return { server: null as any, baseUrl: process.env.CONTRACT_TARGET_URL }; + return { + baseUrl: process.env.CONTRACT_TARGET_URL, + async close() {}, + }; } if (!server) { - server = await startFixtureServer(APP_FIXTURE_DIR); + 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 && !process.env.CONTRACT_TARGET_URL) { - await server.server.close(); + if (server) { + await server.close(); server = null; } } diff --git a/tests/contracts/behavioral.contract.test.ts b/tests/contracts/behavioral.contract.test.ts deleted file mode 100644 index ec64aaeb..00000000 --- a/tests/contracts/behavioral.contract.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { describe, it, expect, afterAll } from "vitest"; -import { getContractServer, closeContractServer } from "./_helpers"; - -describe("behavioral contracts", () => { - afterAll(async () => { - await closeContractServer(); - }); - - // Contract 1: redirect() in server action returns 303 - // Next.js uses 303 See Other for server action redirects (action-handler.ts:1182). - // This ensures vinext matches that behavior rather than using 307. - it("redirect() in server action returns 303 status", async () => { - const { baseUrl } = await getContractServer(); - - // First, get the page to extract the server action ID from the rendered HTML. - // The RSC plugin embeds action IDs as hidden inputs: $ACTION_ID_ - const pageRes = await fetch(`${baseUrl}/contracts/redirect-from-action`); - const html = await pageRes.text(); - - // Extract the action reference ID from the hidden input's name attribute. - // Format: - const actionIdMatch = html.match(/\$ACTION_ID_([^"]+)/); - expect(actionIdMatch).not.toBeNull(); - const actionId = actionIdMatch![1]; - - // Submit the action via POST with x-rsc-action header. - // vinext uses x-rsc-action (not Next-Action) to identify the action. - const res = await fetch(`${baseUrl}/contracts/redirect-from-action`, { - method: "POST", - headers: { - "Content-Type": "text/plain", - "x-rsc-action": actionId, - Accept: "text/x-component", - Origin: baseUrl, - }, - body: "[]", - redirect: "manual", - }); - - // Server action redirects return 200 with x-action-redirect-status header - // (the redirect is handled client-side, not via HTTP redirect). - const redirectStatus = res.headers.get("x-action-redirect-status"); - expect(redirectStatus).toBe("303"); - expect(res.headers.get("x-action-redirect")).toContain("/about"); - }); - - // Contract 2: cookies() is mutable in route handlers - it("cookies() is mutable in route handler", async () => { - const { baseUrl } = await getContractServer(); - const res = await fetch(`${baseUrl}/contracts/api/cookies-mutable`); - expect(res.status).toBe(200); - const setCookie = res.headers.get("set-cookie"); - expect(setCookie).toContain("contract-test=value"); - }); - - // Contract 3: headers() is read-only during render - it("headers() is read-only in route handler render context", 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); - }); - - // Contract 4: Middleware response headers propagate to final response - // The middleware sets x-mw-ran and x-mw-pathname as response headers via - // NextResponse.next(). These should be merged into the final HTTP response. - it("middleware response headers propagate to 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"); - }); - - // Contract 5: generateMetadata() title template merging - it("metadata title template is applied from parent layout", async () => { - const { baseUrl } = await getContractServer(); - const res = await fetch(`${baseUrl}/contracts/metadata-merge`); - const html = await res.text(); - expect(html).toContain("Merge Test | Contracts"); - }); -}); 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/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/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/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/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/shim-coverage.test.ts b/tests/shim-coverage.test.ts index 8290956d..f0be124e 100644 --- a/tests/shim-coverage.test.ts +++ b/tests/shim-coverage.test.ts @@ -200,4 +200,19 @@ describe("checkModuleCoverage", () => { 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([]); + }); }); From 27ca2c526d9a9e33993fdb5695c1a23e1d54f136 Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Wed, 11 Mar 2026 01:26:14 -0700 Subject: [PATCH 4/7] fix: restore CI manifest fixtures and snapshots --- .gitignore | 2 + .../entry-templates.test.ts.snap | 12 +++--- .../full-package/dist/build/babel/preset.js | 10 +++++ .../full-package/dist/client/compat/router.js | 11 ++++++ .../dist/client/components/navigation.js | 39 +++++++++++++++++++ .../full-package/dist/client/form.js | 12 ++++++ .../full-package/dist/client/legacy/image.js | 12 ++++++ .../full-package/dist/client/web-vitals.js | 11 ++++++ .../dist/experimental/testmode/proxy/index.js | 6 +++ .../dist/client/components/navigation.js | 34 ++++++++++++++++ .../pattern-b2/dist/client/form.js | 12 ++++++ 11 files changed, 155 insertions(+), 6 deletions(-) create mode 100644 tests/fixtures/next-api-manifest/full-package/dist/build/babel/preset.js create mode 100644 tests/fixtures/next-api-manifest/full-package/dist/client/compat/router.js create mode 100644 tests/fixtures/next-api-manifest/full-package/dist/client/components/navigation.js create mode 100644 tests/fixtures/next-api-manifest/full-package/dist/client/form.js create mode 100644 tests/fixtures/next-api-manifest/full-package/dist/client/legacy/image.js create mode 100644 tests/fixtures/next-api-manifest/full-package/dist/client/web-vitals.js create mode 100644 tests/fixtures/next-api-manifest/full-package/dist/experimental/testmode/proxy/index.js create mode 100644 tests/fixtures/next-api-manifest/pattern-b1/dist/client/components/navigation.js create mode 100644 tests/fixtures/next-api-manifest/pattern-b2/dist/client/form.js diff --git a/.gitignore b/.gitignore index 00de3406..40dab5f4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ node_modules/ dist/ +!tests/fixtures/next-api-manifest/**/dist/ +!tests/fixtures/next-api-manifest/**/dist/** out/ *.tsbuildinfo .vite/ 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/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..edf47ecd --- /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..f8ffadb8 --- /dev/null +++ b/tests/fixtures/next-api-manifest/full-package/dist/client/components/navigation.js @@ -0,0 +1,39 @@ +"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..23d7aaff --- /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..baed6f8e --- /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..3dca1411 --- /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..f820ad9f --- /dev/null +++ b/tests/fixtures/next-api-manifest/full-package/dist/experimental/testmode/proxy/index.js @@ -0,0 +1,6 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +0 && + (module.exports = { + createProxyServer: null, + }); 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..21398633 --- /dev/null +++ b/tests/fixtures/next-api-manifest/pattern-b1/dist/client/components/navigation.js @@ -0,0 +1,34 @@ +"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-b2/dist/client/form.js b/tests/fixtures/next-api-manifest/pattern-b2/dist/client/form.js new file mode 100644 index 00000000..23d7aaff --- /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() {}; From 1f813acfd40459adca34145c6e2e82e19e5a383d Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Wed, 11 Mar 2026 01:27:54 -0700 Subject: [PATCH 5/7] style: format manifest fixture dist files --- .../full-package/dist/client/compat/router.js | 10 ++-- .../dist/client/components/navigation.js | 46 ++++++++++--------- .../full-package/dist/client/form.js | 12 ++--- .../full-package/dist/client/legacy/image.js | 12 ++--- .../full-package/dist/client/web-vitals.js | 10 ++-- .../dist/client/components/navigation.js | 40 ++++++++-------- .../pattern-b2/dist/client/form.js | 12 ++--- 7 files changed, 73 insertions(+), 69 deletions(-) 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 index edf47ecd..b2da9345 100644 --- 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 @@ -1,11 +1,11 @@ "use strict"; Object.defineProperty(exports, "__esModule", { - value: true + value: true, }); Object.defineProperty(exports, "useRouter", { - enumerable: true, - get: function() { - return 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 index f8ffadb8..8ac01e16 100644 --- 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 @@ -1,36 +1,38 @@ "use strict"; Object.defineProperty(exports, "__esModule", { - value: true + value: true, }); -0 && (module.exports = { +0 && + (module.exports = { ReadonlyURLSearchParams: null, redirect: null, usePathname: null, useRouter: null, - useSearchParams: null -}); + useSearchParams: null, + }); function _export(target, all) { - for(var name in all)Object.defineProperty(target, name, { - enumerable: true, - get: all[name] + 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; - } + ReadonlyURLSearchParams: function () { + return ReadonlyURLSearchParams; + }, + redirect: function () { + return redirect; + }, + usePathname: function () { + return usePathname; + }, + useRouter: function () { + return useRouter; + }, + useSearchParams: function () { + return useSearchParams; + }, }); const ReadonlyURLSearchParams = {}; const redirect = () => {}; 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 index 23d7aaff..49552f4c 100644 --- a/tests/fixtures/next-api-manifest/full-package/dist/client/form.js +++ b/tests/fixtures/next-api-manifest/full-package/dist/client/form.js @@ -1,12 +1,12 @@ -'use client'; +"use client"; "use strict"; Object.defineProperty(exports, "__esModule", { - value: true + value: true, }); Object.defineProperty(exports, "default", { - enumerable: true, - get: function() { - return _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 index baed6f8e..443ea694 100644 --- 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 @@ -1,12 +1,12 @@ -'use client'; +"use client"; "use strict"; Object.defineProperty(exports, "__esModule", { - value: true + value: true, }); Object.defineProperty(exports, "default", { - enumerable: true, - get: function() { - return Image; - } + 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 index 3dca1411..53887589 100644 --- 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 @@ -1,11 +1,11 @@ "use strict"; Object.defineProperty(exports, "__esModule", { - value: true + value: true, }); Object.defineProperty(exports, "useReportWebVitals", { - enumerable: true, - get: function() { - return useReportWebVitals; - } + enumerable: true, + get: function () { + return useReportWebVitals; + }, }); function useReportWebVitals() {} 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 index 21398633..b7e0519f 100644 --- 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 @@ -1,32 +1,34 @@ "use strict"; Object.defineProperty(exports, "__esModule", { - value: true + value: true, }); -0 && (module.exports = { +0 && + (module.exports = { ReadonlyURLSearchParams: null, usePathname: null, useRouter: null, - useSearchParams: null -}); + useSearchParams: null, + }); function _export(target, all) { - for(var name in all)Object.defineProperty(target, name, { - enumerable: true, - get: all[name] + 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; - } + ReadonlyURLSearchParams: function () { + return ReadonlyURLSearchParams; + }, + usePathname: function () { + return usePathname; + }, + useRouter: function () { + return useRouter; + }, + useSearchParams: function () { + return useSearchParams; + }, }); const ReadonlyURLSearchParams = {}; const usePathname = () => "/"; 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 index 23d7aaff..49552f4c 100644 --- a/tests/fixtures/next-api-manifest/pattern-b2/dist/client/form.js +++ b/tests/fixtures/next-api-manifest/pattern-b2/dist/client/form.js @@ -1,12 +1,12 @@ -'use client'; +"use client"; "use strict"; Object.defineProperty(exports, "__esModule", { - value: true + value: true, }); Object.defineProperty(exports, "default", { - enumerable: true, - get: function() { - return _default; - } + enumerable: true, + get: function () { + return _default; + }, }); const _default = function Form() {}; From 307272e14d005a1b77430a593211073435c233ed Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Wed, 11 Mar 2026 01:32:36 -0700 Subject: [PATCH 6/7] fix: silence lint in manifest fixtures --- .../full-package/dist/client/components/navigation.js | 3 ++- .../full-package/dist/experimental/testmode/proxy/index.js | 3 ++- .../pattern-b1/dist/client/components/navigation.js | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) 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 index 8ac01e16..0ad19e12 100644 --- 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 @@ -1,6 +1,7 @@ +/* eslint-disable no-constant-binary-expression, no-unused-expressions */ "use strict"; Object.defineProperty(exports, "__esModule", { - value: true, + value: true, }); 0 && (module.exports = { 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 index f820ad9f..ac69f84a 100644 --- 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 @@ -1,6 +1,7 @@ +/* eslint-disable no-constant-binary-expression, no-unused-expressions */ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); 0 && - (module.exports = { + (module.exports = { createProxyServer: null, }); 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 index b7e0519f..17ef9fca 100644 --- 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 @@ -1,6 +1,7 @@ +/* eslint-disable no-constant-binary-expression, no-unused-expressions */ "use strict"; Object.defineProperty(exports, "__esModule", { - value: true, + value: true, }); 0 && (module.exports = { From 578e7f5160c784bcaf4daf4e1eacc80376f0bde7 Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Wed, 11 Mar 2026 01:37:45 -0700 Subject: [PATCH 7/7] style: format lint-suppressed manifest fixtures --- .../full-package/dist/client/components/navigation.js | 2 +- .../full-package/dist/experimental/testmode/proxy/index.js | 2 +- .../pattern-b1/dist/client/components/navigation.js | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) 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 index 0ad19e12..dc8263a3 100644 --- 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 @@ -1,7 +1,7 @@ /* eslint-disable no-constant-binary-expression, no-unused-expressions */ "use strict"; Object.defineProperty(exports, "__esModule", { - value: true, + value: true, }); 0 && (module.exports = { 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 index ac69f84a..c231db3c 100644 --- 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 @@ -2,6 +2,6 @@ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); 0 && - (module.exports = { + (module.exports = { createProxyServer: null, }); 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 index 17ef9fca..89025db9 100644 --- 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 @@ -1,7 +1,7 @@ /* eslint-disable no-constant-binary-expression, no-unused-expressions */ "use strict"; Object.defineProperty(exports, "__esModule", { - value: true, + value: true, }); 0 && (module.exports = {