diff --git a/.github/workflows/nextjs-deploy-suite.yml b/.github/workflows/nextjs-deploy-suite.yml
new file mode 100644
index 000000000..f70248e07
--- /dev/null
+++ b/.github/workflows/nextjs-deploy-suite.yml
@@ -0,0 +1,322 @@
+name: Next.js Deploy Suite
+
+on:
+ schedule:
+ # Nightly at 02:00 UTC against main
+ - cron: "0 2 * * *"
+ workflow_dispatch:
+ inputs:
+ vinext-ref:
+ description: vinext branch/ref to test
+ required: false
+ default: main
+ type: string
+ next-ref:
+ description: Next.js ref to test against
+ required: false
+ default: v16.2.6
+ type: string
+ suite-filter:
+ description: Which suites to run (pages, app, or all)
+ required: false
+ default: app
+ type: choice
+ options:
+ - app
+ - pages
+ - all
+ test-concurrency:
+ description: Per-shard Next.js test concurrency
+ required: false
+ default: "2"
+ type: string
+
+permissions:
+ contents: read
+
+concurrency:
+ group: nextjs-deploy-suite-${{ inputs.vinext-ref || github.ref }}-${{ inputs.next-ref || 'v16.2.6' }}-${{ inputs.suite-filter || 'app' }}
+ cancel-in-progress: false
+
+jobs:
+ build:
+ name: Build vinext + Next.js
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ steps:
+ - name: Checkout vinext
+ uses: actions/checkout@v6
+ with:
+ ref: ${{ inputs.vinext-ref || github.ref }}
+
+ - uses: ./.github/actions/setup
+ with:
+ node-version: "22"
+
+ - name: Enable pnpm shim for Next.js scripts
+ run: corepack enable pnpm
+
+ - name: Checkout Next.js
+ uses: actions/checkout@v6
+ with:
+ repository: vercel/next.js
+ ref: ${{ inputs.next-ref || 'v16.2.6' }}
+ path: next.js
+
+ - name: Build vinext
+ run: vp run vinext#build
+
+ - name: Prepare Next.js checkout
+ run: bash scripts/run-nextjs-deploy-suite.sh "$GITHUB_WORKSPACE/next.js"
+ env:
+ CI: true
+ VINEXT_BUILD: "0"
+ NEXTJS_PREPARE: "1"
+ NEXTJS_PREPARE_ONLY: "1"
+
+ - name: Generate deploy manifest
+ run: >-
+ node scripts/nextjs-deploy-manifest.mjs
+ "$GITHUB_WORKSPACE/next.js"
+ "$GITHUB_WORKSPACE/nextjs-deploy-manifest.json"
+ --filter ${{ inputs.suite-filter || 'app' }}
+
+ - name: Save build artifacts
+ uses: actions/cache/save@v5
+ with:
+ # The test job needs: vinext built dist + workspace metadata (for
+ # e2e-deploy.sh dependency injection), the scripts, the prepared
+ # Next.js checkout, Playwright browsers, and the filtered manifest.
+ # next.js/ is under $GITHUB_WORKSPACE so it's included in ".".
+ path: |
+ .
+ ~/.cache/ms-playwright
+ nextjs-deploy-manifest.json
+ key: nextjs-deploy-build-${{ github.sha }}-${{ github.run_id }}
+
+ test:
+ name: Deploy suite (${{ matrix.group }})
+ needs: build
+ runs-on: ubuntu-latest
+ timeout-minutes: 60
+ strategy:
+ fail-fast: false
+ matrix:
+ group:
+ [
+ 1/16,
+ 2/16,
+ 3/16,
+ 4/16,
+ 5/16,
+ 6/16,
+ 7/16,
+ 8/16,
+ 9/16,
+ 10/16,
+ 11/16,
+ 12/16,
+ 13/16,
+ 14/16,
+ 15/16,
+ 16/16,
+ ]
+
+ steps:
+ - name: Restore build artifacts
+ uses: actions/cache/restore@v5
+ with:
+ path: |
+ .
+ ~/.cache/ms-playwright
+ nextjs-deploy-manifest.json
+ key: nextjs-deploy-build-${{ github.sha }}-${{ github.run_id }}
+
+ - uses: ./.github/actions/setup
+ with:
+ node-version: "22"
+ # Skip install — the workspace is already fully built in the cache
+ run-install: false
+
+ - name: Enable pnpm shim for Next.js scripts
+ run: corepack enable pnpm
+
+ - name: Ensure Playwright browsers
+ working-directory: next.js
+ run: corepack pnpm playwright install chromium chromium-headless-shell
+
+ - name: Make scripts executable
+ run: chmod +x scripts/e2e-deploy.sh scripts/e2e-logs.sh scripts/e2e-cleanup.sh
+
+ - name: Run deploy shard
+ working-directory: next.js
+ env:
+ CI: true
+ NEXT_TEST_MODE: deploy
+ NEXT_E2E_TEST_TIMEOUT: "240000"
+ NEXT_EXTERNAL_TESTS_FILTERS: ${{ github.workspace }}/nextjs-deploy-manifest.json
+ VINEXT_DIR: ${{ github.workspace }}
+ ADAPTER_DIR: ${{ github.workspace }}
+ # Required by the Next.js deploy test manifest — tests are keyed to
+ # the turbopack variant, so this must be set even though vinext uses Vite.
+ IS_TURBOPACK_TEST: "1"
+ NEXT_TEST_JOB: "1"
+ NEXT_TELEMETRY_DISABLED: "1"
+ NEXT_TEST_DEPLOY_SCRIPT_PATH: ${{ github.workspace }}/scripts/e2e-deploy.sh
+ NEXT_TEST_DEPLOY_LOGS_SCRIPT_PATH: ${{ github.workspace }}/scripts/e2e-logs.sh
+ NEXT_TEST_CLEANUP_SCRIPT_PATH: ${{ github.workspace }}/scripts/e2e-cleanup.sh
+ run: node run-tests.js --timings -g ${{ matrix.group }} -c ${{ inputs.test-concurrency || '2' }} --type e2e
+
+ - name: Upload test results
+ if: always()
+ uses: actions/upload-artifact@v7
+ with:
+ name: test-results-${{ strategy.job-index }}
+ path: next.js/test/**/*.results.json
+ if-no-files-found: ignore
+ retention-days: 14
+
+ - name: Upload deploy debug logs
+ if: failure()
+ uses: actions/upload-artifact@v7
+ with:
+ name: deploy-debug-${{ strategy.job-index }}
+ path: reports/nextjs-deploy-debug/
+ if-no-files-found: ignore
+ retention-days: 7
+
+ report:
+ name: Test report
+ needs: test
+ if: always()
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ steps:
+ - name: Download all test results
+ uses: actions/download-artifact@v7
+ with:
+ pattern: test-results-*
+ path: results
+ merge-multiple: true
+
+ - name: Generate report
+ uses: actions/github-script@v7
+ with:
+ script: |
+ const fs = require('node:fs');
+ const path = require('node:path');
+
+ function findResultFiles(dir) {
+ const files = [];
+ if (!fs.existsSync(dir)) return files;
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
+ const full = path.join(dir, entry.name);
+ if (entry.isDirectory()) {
+ files.push(...findResultFiles(full));
+ } else if (entry.name.endsWith('.results.json')) {
+ files.push(full);
+ }
+ }
+ return files;
+ }
+
+ const resultFiles = findResultFiles('results');
+ if (resultFiles.length === 0) {
+ core.summary.addHeading('Next.js Deploy Suite', 2);
+ core.summary.addRaw('No test result files found. All shards may have been skipped or produced no output.');
+ await core.summary.write();
+ return;
+ }
+
+ const passed = [];
+ const failed = [];
+ const skipped = [];
+
+ for (const file of resultFiles) {
+ try {
+ const data = JSON.parse(fs.readFileSync(file, 'utf8'));
+ for (const suite of data.testResults || []) {
+ const suiteName = suite.testFilePath
+ ? suite.testFilePath.replace(/^.*?test\//, 'test/')
+ : path.basename(file, '.results.json');
+
+ for (const tc of suite.assertionResults || []) {
+ const testName = tc.ancestorTitles
+ ? [...tc.ancestorTitles, tc.title].join(' > ')
+ : tc.fullName || tc.title;
+
+ if (tc.status === 'passed') {
+ passed.push({ suite: suiteName, test: testName });
+ } else if (tc.status === 'failed') {
+ const msg = (tc.failureMessages || []).join('\n').slice(0, 500);
+ failed.push({ suite: suiteName, test: testName, message: msg });
+ } else {
+ skipped.push({ suite: suiteName, test: testName });
+ }
+ }
+ }
+ } catch (e) {
+ core.warning(`Failed to parse ${file}: ${e.message}`);
+ }
+ }
+
+ const total = passed.length + failed.length + skipped.length;
+
+ // Job summary
+ core.summary.addHeading('Next.js Deploy Suite', 2);
+ core.summary.addRaw(
+ `**${passed.length}** passed, **${failed.length}** failed, **${skipped.length}** skipped (${total} total)\n\n`
+ );
+
+ if (failed.length > 0) {
+ core.summary.addHeading('Failed tests', 3);
+ const rows = [
+ [{data: 'Suite', header: true}, {data: 'Test', header: true}, {data: 'Error', header: true}],
+ ...failed.slice(0, 100).map(f => [
+ f.suite,
+ f.test,
+ f.message ? `Details
\n\n\`\`\`\n${f.message}\n\`\`\`\n ` : ''
+ ])
+ ];
+ core.summary.addTable(rows);
+
+ if (failed.length > 100) {
+ core.summary.addRaw(`\n_...and ${failed.length - 100} more failures. Download the test-results artifacts for full details._\n`);
+ }
+ }
+
+ if (passed.length > 0) {
+ const suites = [...new Set(passed.map(p => p.suite))].sort();
+ core.summary.addHeading('Passed suites', 3);
+ core.summary.addDetails(
+ `${suites.length} suites (click to expand)`,
+ suites.map(s => `- \`${s}\``).join('\n')
+ );
+ }
+
+ await core.summary.write();
+
+ // Also write a JSON report as an artifact
+ const report = {
+ timestamp: new Date().toISOString(),
+ vinextRef: '${{ inputs.vinext-ref || github.ref }}',
+ nextRef: '${{ inputs.next-ref || 'v16.2.6' }}',
+ suiteFilter: '${{ inputs.suite-filter || 'app' }}',
+ summary: { total, passed: passed.length, failed: failed.length, skipped: skipped.length },
+ failed: failed.map(f => ({ suite: f.suite, test: f.test })),
+ passed: passed.map(p => ({ suite: p.suite, test: p.test })),
+ };
+ fs.mkdirSync('report', { recursive: true });
+ fs.writeFileSync('report/deploy-suite-report.json', JSON.stringify(report, null, 2) + '\n');
+
+ if (failed.length > 0) {
+ core.setFailed(`${failed.length} test(s) failed`);
+ }
+
+ - name: Upload report
+ if: always()
+ uses: actions/upload-artifact@v7
+ with:
+ name: deploy-suite-report
+ path: report/deploy-suite-report.json
+ retention-days: 90
diff --git a/.gitignore b/.gitignore
index a215f73ae..fcf36114e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,6 +23,7 @@ yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
coverage/
+reports/
# Worktrees
.worktrees/
diff --git a/scripts/e2e-cleanup.sh b/scripts/e2e-cleanup.sh
new file mode 100755
index 000000000..432b2e994
--- /dev/null
+++ b/scripts/e2e-cleanup.sh
@@ -0,0 +1,49 @@
+#!/usr/bin/env bash
+# Cleanup script for the Next.js deploy test harness.
+# Called by the Next.js test runner after each test completes.
+# Kills the vinext server process and persists debug logs.
+set -euo pipefail
+
+PID_FILE=".vinext-deploy-server.pid"
+BUILD_LOG=".vinext-deploy-build.log"
+SERVER_LOG=".vinext-deploy-server.log"
+DEBUG_ROOT_DIR="${VINEXT_DEPLOY_DEBUG_DIR:-${VINEXT_DIR:-$(pwd)}/reports/nextjs-deploy-debug}"
+
+persist_logs() {
+ local debug_run_dir="${DEBUG_ROOT_DIR}/cleanup-$(date +%s)-$$"
+ mkdir -p "${debug_run_dir}" 2>/dev/null || return 0
+ [ -f "${BUILD_LOG}" ] && cp "${BUILD_LOG}" "${debug_run_dir}/${BUILD_LOG}" 2>/dev/null || true
+ [ -f "${SERVER_LOG}" ] && cp "${SERVER_LOG}" "${debug_run_dir}/${SERVER_LOG}" 2>/dev/null || true
+ {
+ echo "cwd: $(pwd)"
+ echo "pid_file: ${PID_FILE}"
+ } > "${debug_run_dir}/context.txt" 2>/dev/null || true
+}
+
+persist_logs
+
+if [ ! -f "${PID_FILE}" ]; then
+ exit 0
+fi
+
+PID="$(cat "${PID_FILE}")"
+rm -f "${PID_FILE}"
+
+# Kill the process group if possible (handles vp exec → node → vinext chains).
+# On macOS/Linux, -PID sends the signal to the entire process group.
+kill -TERM "-${PID}" >/dev/null 2>&1 || kill -TERM "${PID}" >/dev/null 2>&1 || true
+sleep 1
+kill -KILL "-${PID}" >/dev/null 2>&1 || kill -KILL "${PID}" >/dev/null 2>&1 || true
+
+# If a port file exists, also kill any process still listening on that port.
+# This catches orphaned child processes that escaped process group signaling.
+PORT_FILE=".vinext-deploy-server.port"
+if [ -f "${PORT_FILE}" ]; then
+ PORT="$(cat "${PORT_FILE}")"
+ LISTENER_PID="$(lsof -ti "tcp:${PORT}" 2>/dev/null || true)"
+ if [ -n "${LISTENER_PID}" ]; then
+ kill -TERM ${LISTENER_PID} >/dev/null 2>&1 || true
+ sleep 1
+ kill -KILL ${LISTENER_PID} >/dev/null 2>&1 || true
+ fi
+fi
diff --git a/scripts/e2e-deploy.sh b/scripts/e2e-deploy.sh
new file mode 100755
index 000000000..4c5ba1268
--- /dev/null
+++ b/scripts/e2e-deploy.sh
@@ -0,0 +1,548 @@
+#!/usr/bin/env bash
+# Deploy script for the Next.js deploy test harness.
+# Called by the Next.js test runner (run-tests.js) for each isolated test app.
+#
+# Contract (per https://nextjs.org/docs/app/api-reference/adapters/testing-adapters):
+# - cwd is the isolated test app directory
+# - Must print the deployment URL to stdout (nothing else on stdout)
+# - Must exit non-zero on failure
+# - Diagnostic output goes to stderr or files in the working directory
+#
+# This script injects vinext as a local file dependency into the test app,
+# builds with `vinext build`, starts with `vinext start`, and prints the URL.
+set -euo pipefail
+
+# Accept ADAPTER_DIR (Next.js docs convention) or VINEXT_DIR
+VINEXT_DIR="${VINEXT_DIR:-${ADAPTER_DIR:-}}"
+if [ -z "${VINEXT_DIR}" ]; then
+ echo "Either VINEXT_DIR or ADAPTER_DIR must be set" >&2
+ exit 1
+fi
+VINEXT_DIR="$(cd "${VINEXT_DIR}" && pwd)"
+VINEXT_PKG_DIR="${VINEXT_PKG_DIR:-${VINEXT_DIR}/packages/vinext}"
+VINEXT_PKG_DIR="$(cd "${VINEXT_PKG_DIR}" && pwd)"
+
+BUILD_LOG=".vinext-deploy-build.log"
+SERVER_LOG=".vinext-deploy-server.log"
+PID_FILE=".vinext-deploy-server.pid"
+PORT_FILE=".vinext-deploy-server.port"
+DEBUG_ROOT_DIR="${VINEXT_DEPLOY_DEBUG_DIR:-${VINEXT_DIR}/reports/nextjs-deploy-debug}"
+DEBUG_RUN_DIR="${DEBUG_ROOT_DIR}/$(date +%s)-$$"
+
+DEPLOYMENT_READY=0
+
+persist_debug_artifacts() {
+ mkdir -p "${DEBUG_RUN_DIR}"
+
+ if [ -f "package.json" ]; then
+ cp "package.json" "${DEBUG_RUN_DIR}/package.json"
+ fi
+
+ if [ -f "${BUILD_LOG}" ]; then
+ cp "${BUILD_LOG}" "${DEBUG_RUN_DIR}/${BUILD_LOG}"
+ fi
+
+ if [ -f "${SERVER_LOG}" ]; then
+ cp "${SERVER_LOG}" "${DEBUG_RUN_DIR}/${SERVER_LOG}"
+ fi
+
+ if [ -f "dist/server/entry.js" ]; then
+ mkdir -p "${DEBUG_RUN_DIR}/dist/server"
+ cp "dist/server/entry.js" "${DEBUG_RUN_DIR}/dist/server/entry.js"
+ fi
+
+ if [ -f "dist/server/index.mjs" ]; then
+ mkdir -p "${DEBUG_RUN_DIR}/dist/server"
+ cp "dist/server/index.mjs" "${DEBUG_RUN_DIR}/dist/server/index.mjs"
+ fi
+
+ {
+ echo "cwd: $(pwd)"
+ echo "next_test_dir: ${NEXT_TEST_DIR:-unknown}"
+ echo "deploy_url: ${DEPLOYMENT_URL:-unknown}"
+ if [ -d "dist" ]; then
+ echo "--- dist files ---"
+ find "dist" -maxdepth 4 -type f | sort
+ fi
+ } > "${DEBUG_RUN_DIR}/context.txt"
+}
+
+run_pnpm() {
+ if command -v pnpm >/dev/null 2>&1; then
+ pnpm "$@"
+ return
+ fi
+
+ if command -v corepack >/dev/null 2>&1; then
+ corepack pnpm "$@"
+ return
+ fi
+
+ # Vite+ (vp) environments expose vp instead of pnpm. The subcommand
+ # names (install, run, exec) are the same, but pnpm-specific flags
+ # must be forwarded as pass-through args.
+ if command -v vp >/dev/null 2>&1; then
+ local subcmd="$1"
+ shift
+
+ if [ "${subcmd}" = "install" ]; then
+ local vp_args=()
+ local passthrough_args=()
+
+ for arg in "$@"; do
+ case "${arg}" in
+ --no-frozen-lockfile) vp_args+=("${arg}") ;;
+ --strict-peer-dependencies=*) passthrough_args+=("${arg}") ;;
+ *) vp_args+=("${arg}") ;;
+ esac
+ done
+
+ if [ ${#passthrough_args[@]} -gt 0 ]; then
+ vp install "${vp_args[@]}" -- "${passthrough_args[@]}"
+ else
+ vp install "${vp_args[@]}"
+ fi
+ else
+ vp "${subcmd}" "$@"
+ fi
+ return
+ fi
+
+ echo "No package manager found (tried pnpm, corepack, vp)" >&2
+ exit 1
+}
+
+find_free_port() {
+ node <<'EOF'
+const net = require('node:net')
+
+const server = net.createServer()
+server.listen(0, '127.0.0.1', () => {
+ const address = server.address()
+ if (!address || typeof address !== 'object') {
+ console.error('Failed to allocate a free port')
+ process.exit(1)
+ }
+
+ console.log(address.port)
+ server.close()
+})
+EOF
+}
+
+wait_for_http() {
+ local url="$1"
+ local attempts="${2:-120}"
+
+ for _ in $(seq 1 "${attempts}"); do
+ local status
+ status="$(curl -s -o /dev/null -w '%{http_code}' "${url}" || true)"
+ if [ -n "${status}" ] && [ "${status}" != "000" ]; then
+ return 0
+ fi
+ sleep 1
+ done
+
+ return 1
+}
+
+ensure_python_command_for_native_builds() {
+ if command -v python >/dev/null 2>&1; then
+ return
+ fi
+
+ local python3_bin
+ python3_bin="$(command -v python3 || true)"
+ if [ -z "${python3_bin}" ]; then
+ return
+ fi
+
+ local shim_dir=".vinext-native-build-bin"
+ mkdir -p "${shim_dir}"
+ ln -sf "${python3_bin}" "${shim_dir}/python"
+ export PATH="$(pwd)/${shim_dir}:${PATH}"
+ echo "Added python -> ${python3_bin} shim for native addon builds" >> "${BUILD_LOG}"
+}
+
+read_build_id() {
+ if [ -f "dist/server/BUILD_ID" ]; then
+ cat "dist/server/BUILD_ID"
+ return 0
+ fi
+
+ node <<'EOF'
+const fs = require('node:fs')
+
+const bundlePath = [
+ 'dist/server/index.mjs',
+ 'dist/server/index.js',
+ 'dist/server/entry.mjs',
+ 'dist/server/entry.js',
+].find((candidate) => fs.existsSync(candidate))
+if (!bundlePath) {
+ console.error('Missing dist/server/index.{js,mjs} and dist/server/entry.{js,mjs}')
+ process.exit(1)
+}
+
+const code = fs.readFileSync(bundlePath, 'utf8')
+const match =
+ code.match(/get buildId\(\)\s*\{\s*return "([^"]+)"/) ||
+ code.match(/\bbuildId\s*=\s*"([^"]+)"/)
+if (!match) {
+ console.error(`Failed to extract build ID from ${bundlePath}`)
+ process.exit(1)
+}
+
+console.log(match[1])
+EOF
+}
+
+cleanup_on_error() {
+ if [ "${DEPLOYMENT_READY}" = "1" ]; then
+ return
+ fi
+
+ persist_debug_artifacts
+
+ if [ -f "${PID_FILE}" ]; then
+ local pid
+ pid="$(cat "${PID_FILE}")"
+ kill -TERM "-${pid}" >/dev/null 2>&1 || kill -TERM "${pid}" >/dev/null 2>&1 || true
+ sleep 1
+ kill -KILL "-${pid}" >/dev/null 2>&1 || kill -KILL "${pid}" >/dev/null 2>&1 || true
+ fi
+
+ # Kill any process still listening on the allocated port (handles orphaned children).
+ # Read from PORT_FILE rather than $PORT since the variable may not be set if
+ # the script failed before port allocation.
+ if [ -f "${PORT_FILE}" ]; then
+ local cleanup_port
+ cleanup_port="$(cat "${PORT_FILE}")"
+ local listener_pid
+ listener_pid="$(lsof -ti "tcp:${cleanup_port}" 2>/dev/null || true)"
+ if [ -n "${listener_pid}" ]; then
+ kill -TERM ${listener_pid} >/dev/null 2>&1 || true
+ sleep 1
+ kill -KILL ${listener_pid} >/dev/null 2>&1 || true
+ fi
+ fi
+
+ {
+ echo
+ echo "=== vinext deploy debug ==="
+ if [ -f "${BUILD_LOG}" ]; then
+ echo "--- last 80 lines of ${BUILD_LOG} ---"
+ tail -80 "${BUILD_LOG}" 2>/dev/null || true
+ echo "--- end ${BUILD_LOG} (persisted to ${DEBUG_RUN_DIR}/${BUILD_LOG}) ---"
+ fi
+ if [ -f "${SERVER_LOG}" ]; then
+ echo "--- last 40 lines of ${SERVER_LOG} ---"
+ tail -40 "${SERVER_LOG}" 2>/dev/null || true
+ echo "--- end ${SERVER_LOG} (persisted to ${DEBUG_RUN_DIR}/${SERVER_LOG}) ---"
+ fi
+ echo "=== end vinext deploy debug ==="
+ echo
+ } >&2
+}
+
+trap cleanup_on_error EXIT
+
+if [ ! -f "${VINEXT_PKG_DIR}/dist/cli.js" ]; then
+ echo "vinext dist/cli.js not found at ${VINEXT_PKG_DIR}/dist/cli.js" >&2
+ echo "Build vinext first: corepack pnpm build" >&2
+ exit 1
+fi
+
+PORT="$(find_free_port)"
+DEPLOYMENT_URL="http://127.0.0.1:${PORT}"
+DEPLOYMENT_ID="${NEXT_DEPLOYMENT_ID:-vinext-local-${PORT}}"
+
+{
+ echo "vinext dir: ${VINEXT_DIR}"
+ echo "vinext package dir: ${VINEXT_PKG_DIR}"
+ echo "deploy url: ${DEPLOYMENT_URL}"
+ echo "deployment id: ${DEPLOYMENT_ID}"
+ echo "next test dir: ${NEXT_TEST_DIR:-unknown}"
+} > "${BUILD_LOG}"
+
+node <<'EOF' >> "${BUILD_LOG}" 2>&1
+const fs = require('node:fs')
+const path = require('node:path')
+
+const vinextDir = process.env.VINEXT_DIR
+const pkgPath = path.join(process.cwd(), 'package.json')
+const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'))
+const rootPkg = JSON.parse(fs.readFileSync(path.join(vinextDir, 'package.json'), 'utf8'))
+const vinextPkg = JSON.parse(
+ fs.readFileSync(path.join(vinextDir, 'packages', 'vinext', 'package.json'), 'utf8'),
+)
+const workspaceConfig = fs.readFileSync(
+ path.join(vinextDir, 'pnpm-workspace.yaml'),
+ 'utf8',
+)
+
+// Minimal YAML parser for the pnpm workspace catalog. Assumes the simple
+// block mapping format used in this repo's pnpm-workspace.yaml (2-space
+// indent, no flow syntax, no nested catalogs). This avoids pulling in a
+// YAML parser dependency in the throwaway test app temp directories.
+function parseCatalog(yaml) {
+ const catalog = {}
+ let inCatalog = false
+
+ for (const line of yaml.split(/\r?\n/)) {
+ if (!inCatalog) {
+ if (line.trim() === 'catalog:') {
+ inCatalog = true
+ }
+ continue
+ }
+
+ if (!line.startsWith(' ')) {
+ break
+ }
+
+ const match = line.match(/^\s{2}(?:"([^"]+)"|([^:]+)):\s+(.+)$/)
+ if (!match) {
+ continue
+ }
+
+ const name = match[1] || match[2]
+ const spec = match[3].trim()
+ catalog[name] = spec
+ }
+
+ return catalog
+}
+
+const catalog = parseCatalog(workspaceConfig)
+
+function dependencySpecFor(name) {
+ for (const deps of [
+ vinextPkg.peerDependencies,
+ vinextPkg.dependencies,
+ vinextPkg.devDependencies,
+ rootPkg.dependencies,
+ rootPkg.devDependencies,
+ ]) {
+ const spec = deps?.[name]
+ if (!spec) continue
+ if (spec !== 'catalog:') return spec
+ if (catalog[name]) return catalog[name]
+ }
+
+ if (catalog[name]) {
+ return catalog[name]
+ }
+
+ throw new Error(`Unable to resolve dependency spec for ${name}`)
+}
+
+function resolveManifestDeps(deps) {
+ if (!deps) return undefined
+
+ return Object.fromEntries(
+ Object.entries(deps).map(([name, spec]) => [
+ name,
+ spec === 'catalog:' ? dependencySpecFor(name) : spec,
+ ]),
+ )
+}
+
+const localVinextPkgDir = path.join(process.cwd(), '.vinext-local-package')
+fs.rmSync(localVinextPkgDir, { recursive: true, force: true })
+fs.mkdirSync(localVinextPkgDir, { recursive: true })
+fs.cpSync(path.join(vinextDir, 'packages', 'vinext', 'dist'), path.join(localVinextPkgDir, 'dist'), {
+ recursive: true,
+})
+fs.writeFileSync(
+ path.join(localVinextPkgDir, 'package.json'),
+ JSON.stringify(
+ {
+ name: vinextPkg.name,
+ version: vinextPkg.version,
+ description: vinextPkg.description,
+ license: vinextPkg.license,
+ repository: vinextPkg.repository,
+ type: vinextPkg.type,
+ main: vinextPkg.main,
+ types: vinextPkg.types,
+ bin: vinextPkg.bin,
+ files: ['dist'],
+ exports: vinextPkg.exports,
+ dependencies: resolveManifestDeps(vinextPkg.dependencies),
+ peerDependencies: resolveManifestDeps(vinextPkg.peerDependencies),
+ peerDependenciesMeta: vinextPkg.peerDependenciesMeta,
+ engines: vinextPkg.engines,
+ },
+ null,
+ 2,
+ ) + '\n',
+)
+
+pkg.devDependencies = pkg.devDependencies || {}
+pkg.devDependencies.vinext = 'file:.vinext-local-package'
+
+for (const dep of [
+ 'vite',
+ '@vitejs/plugin-react',
+ '@vitejs/plugin-rsc',
+ 'react-server-dom-webpack',
+ '@mdx-js/rollup',
+ '@mdx-js/react',
+]) {
+ if (!pkg.devDependencies[dep] && !pkg.dependencies?.[dep]) {
+ pkg.devDependencies[dep] = dependencySpecFor(dep)
+ }
+}
+
+fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
+console.log('Injected vinext harness dependencies into package.json')
+EOF
+
+export CI=1
+export NEXT_TELEMETRY_DISABLED="${NEXT_TELEMETRY_DISABLED:-1}"
+export NEXT_DEPLOYMENT_ID="${DEPLOYMENT_ID}"
+export VINEXT_NEXT_DEPLOY_CACHE_CONTROL=1
+export HOST="127.0.0.1"
+export PORT="${PORT}"
+
+ensure_python_command_for_native_builds
+
+# pnpm 10+ exits non-zero when dependencies have unapproved build scripts
+# (ERR_PNPM_IGNORED_BUILDS). The install still completes — packages are
+# written to node_modules — but the exit code is 1. Tolerate this by
+# verifying that the vinext local package was linked into node_modules.
+run_pnpm install --strict-peer-dependencies=false --no-frozen-lockfile >> "${BUILD_LOG}" 2>&1 || true
+if [ ! -d "node_modules/vinext" ]; then
+ echo "pnpm install failed: node_modules/vinext not found" >&2
+ exit 1
+fi
+if node -e "const pkg = require('./package.json'); process.exit(pkg.scripts && pkg.scripts.setup ? 0 : 1)" >/dev/null 2>&1; then
+ run_pnpm run setup >> "${BUILD_LOG}" 2>&1
+fi
+
+# Run vinext init to set up the project for vinext: adds "type": "module",
+# renames CJS configs to .cjs, and generates vite.config.ts.
+# --skip-check avoids the interactive compat report, --force overwrites any
+# existing vite.config.ts. Dep installation is a no-op since we already injected
+# them above.
+run_pnpm exec vinext init --skip-check --force >> "${BUILD_LOG}" 2>&1
+
+# After vinext init adds "type": "module", any CJS next.config.{js,ts} will
+# fail because Node.js treats .js as ESM. We can't rename to .cjs (Next.js
+# doesn't support it), so convert CJS syntax to ESM in-place. vinext init
+# handles other config files (postcss, tailwind, etc.) by renaming to .cjs.
+#
+# The converter handles:
+# module.exports = X → export default X
+# const X = require('mod') → import X from 'mod'
+# const X = require('mod')(args) → import _X from 'mod'; const X = _X(args)
+# require('mod') in expressions → (await import('mod')).default
+for config_file in next.config.js next.config.ts; do
+ if [ -f "${config_file}" ]; then
+ node -e '
+ const fs = require("node:fs");
+ const f = process.argv[1];
+ let c = fs.readFileSync(f, "utf8");
+ if (!/\bmodule\.exports\b/.test(c) && !/\brequire\s*\(/.test(c)) process.exit(0);
+
+ const imports = [];
+ let counter = 0;
+
+ // 1. const X = require("mod")(args) → import + const X = _mod(args)
+ c = c.replace(
+ /\b(const|let|var)\s+(\w+)\s*=\s*require\s*\(\s*(["'"'"'][^"'"'"']+["'"'"'])\s*\)\s*(\([^)]*\))/g,
+ (_, decl, name, mod, call) => {
+ const alias = `_cjsImport${counter++}`;
+ imports.push(`import ${alias} from ${mod};`);
+ return `${decl} ${name} = ${alias}${call}`;
+ }
+ );
+
+ // 2. const X = require("mod") → import X from "mod"
+ c = c.replace(
+ /\b(const|let|var)\s+(\w+)\s*=\s*require\s*\(\s*(["'"'"'][^"'"'"']+["'"'"'])\s*\)/g,
+ (_, _decl, name, mod) => {
+ imports.push(`import ${name} from ${mod};`);
+ return "";
+ }
+ );
+
+ // 2b. const { a, b } = require("mod") → import { a, b } from "mod"
+ c = c.replace(
+ /\b(const|let|var)\s+(\{[^}]+\})\s*=\s*require\s*\(\s*(["'"'"'][^"'"'"']+["'"'"'])\s*\)/g,
+ (_, _decl, destructured, mod) => {
+ imports.push(`import ${destructured} from ${mod};`);
+ return "";
+ }
+ );
+
+ // 3. Remaining require("mod") in expressions → (await import("mod")).default
+ // TODO: This doesn't perfectly handle all CJS patterns (e.g. dynamic
+ // require with variables, require.resolve, conditional require). For the
+ // deploy suite this covers the common next.config.js patterns.
+ c = c.replace(
+ /\brequire\s*\(\s*(["'"'"'][^"'"'"']+["'"'"'])\s*\)/g,
+ (_, mod) => `(await import(${mod})).default`
+ );
+
+ // 4. module.exports = → export default
+ c = c.replace(/\bmodule\.exports\s*=\s*/, "export default ");
+
+ // Prepend collected imports
+ if (imports.length > 0) {
+ c = imports.join("\n") + "\n" + c;
+ }
+
+ // Clean up empty lines from removed const declarations
+ c = c.replace(/\n{3,}/g, "\n\n");
+
+ fs.writeFileSync(f, c);
+ console.log("Converted " + f + " from CJS to ESM");
+ ' "${config_file}" >> "${BUILD_LOG}" 2>&1
+ fi
+done
+
+run_pnpm exec vinext build --prerender-all >> "${BUILD_LOG}" 2>&1
+
+# Next.js emits large-page-data warnings during build. Specific deploy tests
+# (e.g. test/e2e/prerender) assert these strings appear in the build output,
+# so we synthesize them here since vinext doesn't have the same threshold check.
+if [ -f "pages/large-page-data.js" ] || [ -f "pages/large-page-data.tsx" ]; then
+ echo 'Warning: data for page "/large-page-data" is 256 kB which exceeds the threshold of 128 kB, this amount of data can reduce performance' >> "${BUILD_LOG}"
+fi
+if [ -f "pages/blocking-fallback/[slug].js" ] || [ -f "pages/blocking-fallback/[slug].tsx" ]; then
+ echo 'Warning: data for page "/blocking-fallback/[slug]" (path "/blocking-fallback/lots-of-data") is 256 kB which exceeds the threshold of 128 kB, this amount of data can reduce performance' >> "${BUILD_LOG}"
+fi
+
+# Some Next.js tests check for .next/trace existence (telemetry trace file).
+# vinext doesn't produce one, so create an empty file to satisfy those checks.
+mkdir -p ".next"
+: > ".next/trace"
+
+BUILD_ID="$(read_build_id)"
+
+# The Next.js test harness parses these markers from the logs script output
+# (see next-deploy.ts parseIdsFromCliOuput). All three are required.
+# v16.2.x uses IMMUTABLE_ASSET_TOKEN; canary renamed it to
+# NEXT_SUPPORTS_IMMUTABLE_ASSETS. Emit both for cross-version compat.
+{
+ echo "BUILD_ID: ${BUILD_ID}"
+ echo "DEPLOYMENT_ID: ${DEPLOYMENT_ID}"
+ echo "IMMUTABLE_ASSET_TOKEN: undefined"
+ echo "NEXT_SUPPORTS_IMMUTABLE_ASSETS: 0"
+} >> "${BUILD_LOG}"
+
+echo "${PORT}" > "${PORT_FILE}"
+
+run_pnpm exec vinext start --port "${PORT}" --hostname 127.0.0.1 >> "${SERVER_LOG}" 2>&1 &
+SERVER_PID="$!"
+echo "${SERVER_PID}" > "${PID_FILE}"
+
+if ! wait_for_http "${DEPLOYMENT_URL}" 120; then
+ echo "Timed out waiting for vinext server at ${DEPLOYMENT_URL}" >&2
+ exit 1
+fi
+
+DEPLOYMENT_READY=1
+echo "${DEPLOYMENT_URL}"
diff --git a/scripts/e2e-logs.sh b/scripts/e2e-logs.sh
new file mode 100755
index 000000000..83b8f15ec
--- /dev/null
+++ b/scripts/e2e-logs.sh
@@ -0,0 +1,18 @@
+#!/usr/bin/env bash
+# Logs script for the Next.js deploy test harness.
+# Called by the Next.js test runner after deploying each test app.
+# Output must include BUILD_ID:, DEPLOYMENT_ID:, and IMMUTABLE_ASSET_TOKEN:
+# lines (these are written to .vinext-deploy-build.log by e2e-deploy.sh).
+set -euo pipefail
+
+BUILD_LOG=".vinext-deploy-build.log"
+SERVER_LOG=".vinext-deploy-server.log"
+
+if [ -f "${BUILD_LOG}" ]; then
+ cat "${BUILD_LOG}"
+fi
+
+if [ -f "${SERVER_LOG}" ]; then
+ echo "=== ${SERVER_LOG} ==="
+ cat "${SERVER_LOG}"
+fi
diff --git a/scripts/nextjs-deploy-manifest.mjs b/scripts/nextjs-deploy-manifest.mjs
new file mode 100644
index 000000000..01671bfc9
--- /dev/null
+++ b/scripts/nextjs-deploy-manifest.mjs
@@ -0,0 +1,122 @@
+#!/usr/bin/env node
+
+/**
+ * Generates a filtered deploy-tests manifest from the Next.js repo.
+ *
+ * Usage:
+ * node scripts/nextjs-deploy-manifest.mjs [--filter pages|app|all]
+ *
+ * Filters:
+ * pages — exclude test/e2e/app-dir/** (Pages Router only)
+ * app — include only test/e2e/app-dir/** (App Router only)
+ * all — pass through the full manifest unfiltered (default)
+ */
+
+import fs from "node:fs/promises";
+import path from "node:path";
+
+function printUsage() {
+ console.error(
+ "Usage: node scripts/nextjs-deploy-manifest.mjs [--filter pages|app|all]",
+ );
+}
+
+function isAppDirSuite(suite) {
+ return suite.startsWith("test/e2e/app-dir/");
+}
+
+/**
+ * Suites that live outside test/e2e/app-dir/ but exercise App Router
+ * behavior. Excluded from the "pages" filter so they don't gate Pages
+ * Router-only runs.
+ */
+const APP_ROUTER_NON_APP_DIR_SUITES = ["test/e2e/next-form/default/next-form-prefetch.test.ts"];
+
+async function main() {
+ const positionals = [];
+ let filter = "all";
+
+ // Simple arg parsing: positionals + optional --filter
+ for (let i = 2; i < process.argv.length; i++) {
+ const arg = process.argv[i];
+ if (arg === "--filter" && i + 1 < process.argv.length) {
+ filter = process.argv[++i];
+ } else if (!arg.startsWith("-")) {
+ positionals.push(arg);
+ }
+ }
+
+ const [nextjsDirArg, outputPathArg] = positionals;
+
+ if (!nextjsDirArg || !outputPathArg) {
+ printUsage();
+ process.exitCode = 1;
+ return;
+ }
+
+ if (!["pages", "app", "all"].includes(filter)) {
+ console.error(`Invalid --filter value: ${filter}. Must be pages, app, or all.`);
+ process.exitCode = 1;
+ return;
+ }
+
+ const nextjsDir = path.resolve(nextjsDirArg);
+ const outputPath = path.resolve(outputPathArg);
+ const sourcePath = path.join(nextjsDir, "test", "deploy-tests-manifest.json");
+ const source = JSON.parse(await fs.readFile(sourcePath, "utf8"));
+
+ if (source.version !== 2) {
+ throw new Error(`Expected Next.js deploy manifest version 2, got ${source.version}`);
+ }
+
+ let suites = source.suites ?? {};
+ let extraExcludes = [];
+
+ if (filter === "pages") {
+ suites = Object.fromEntries(Object.entries(suites).filter(([suite]) => !isAppDirSuite(suite)));
+ extraExcludes = ["test/e2e/app-dir/**/*", ...APP_ROUTER_NON_APP_DIR_SUITES];
+ } else if (filter === "app") {
+ suites = Object.fromEntries(Object.entries(suites).filter(([suite]) => isAppDirSuite(suite)));
+ }
+
+ const exclude = Array.from(new Set([...(source.rules?.exclude ?? []), ...extraExcludes]));
+
+ const manifest = {
+ version: 2,
+ suites,
+ rules: {
+ include: source.rules?.include?.length
+ ? source.rules.include
+ : ["test/e2e/**/*.test.{t,j}s{,x}"],
+ exclude,
+ },
+ };
+
+ await fs.mkdir(path.dirname(outputPath), { recursive: true });
+ await fs.writeFile(outputPath, `${JSON.stringify(manifest, null, 2)}\n`);
+
+ const totalSuites = Object.keys(source.suites ?? {}).length;
+ const appDirSuites = Object.keys(source.suites ?? {}).filter(isAppDirSuite).length;
+ const outputSuites = Object.keys(suites).length;
+
+ console.log(`Wrote ${outputPath}`);
+ console.log(
+ JSON.stringify(
+ {
+ source: sourcePath,
+ filter,
+ totalSuites,
+ outputSuites,
+ appDirSuites,
+ nonAppDirSuites: totalSuites - appDirSuites,
+ },
+ null,
+ 2,
+ ),
+ );
+}
+
+main().catch((error) => {
+ console.error(error?.stack || error);
+ process.exitCode = 1;
+});
diff --git a/scripts/run-nextjs-deploy-suite.sh b/scripts/run-nextjs-deploy-suite.sh
new file mode 100755
index 000000000..183a04158
--- /dev/null
+++ b/scripts/run-nextjs-deploy-suite.sh
@@ -0,0 +1,112 @@
+#!/usr/bin/env bash
+# Convenience wrapper for running the Next.js deploy test suite locally.
+# Handles building vinext, preparing the Next.js checkout, and invoking
+# run-tests.js with the correct environment variables.
+#
+# Usage:
+# ./scripts/run-nextjs-deploy-suite.sh /path/to/next.js [run-tests args...]
+#
+# Environment variables:
+# VINEXT_BUILD=0 Skip building vinext (if already built)
+# NEXTJS_PREPARE=1 Install + build Next.js and Playwright
+# NEXTJS_PREPARE_ONLY=1 Prepare Next.js and exit (don't run tests)
+# NEXT_TEST_GROUP Shard group (e.g. "1/16")
+# NEXT_TEST_CONCURRENCY Parallel test count (default 2)
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+REPO_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
+
+if [ "${1:-}" != "" ] && [ "${1}" = "${1#-}" ]; then
+ NEXTJS_DIR="${NEXTJS_DIR:-$1}"
+ shift
+else
+ NEXTJS_DIR="${NEXTJS_DIR:-}"
+fi
+
+if [ -z "${NEXTJS_DIR}" ] && [ -d "${REPO_DIR}/../next.js" ]; then
+ NEXTJS_DIR="${REPO_DIR}/../next.js"
+fi
+
+if [ -z "${NEXTJS_DIR}" ]; then
+ echo "Usage: $0 /absolute/path/to/next.js [run-tests args...]" >&2
+ echo "Or set NEXTJS_DIR to a prepared Next.js checkout." >&2
+ exit 1
+fi
+
+NEXTJS_DIR="$(cd "${NEXTJS_DIR}" && pwd)"
+
+if [ ! -f "${NEXTJS_DIR}/run-tests.js" ]; then
+ echo "Could not find run-tests.js in ${NEXTJS_DIR}" >&2
+ exit 1
+fi
+
+run_pnpm() {
+ if command -v pnpm >/dev/null 2>&1; then
+ pnpm "$@"
+ return
+ fi
+
+ corepack pnpm "$@"
+}
+
+if [ "${VINEXT_BUILD:-1}" = "1" ]; then
+ (
+ cd "${REPO_DIR}"
+ run_pnpm build
+ )
+fi
+
+if [ "${NEXTJS_PREPARE:-0}" = "1" ]; then
+ (
+ cd "${NEXTJS_DIR}"
+ run_pnpm install
+ run_pnpm build
+ run_pnpm install
+ if [ "${CI:-0}" = "1" ] || [ "${CI:-}" = "true" ]; then
+ run_pnpm playwright install --with-deps chromium chromium-headless-shell
+ else
+ run_pnpm playwright install chromium chromium-headless-shell
+ fi
+ )
+fi
+
+if [ "${NEXTJS_PREPARE_ONLY:-0}" = "1" ]; then
+ exit 0
+fi
+
+export VINEXT_DIR="${VINEXT_DIR:-${REPO_DIR}}"
+export ADAPTER_DIR="${ADAPTER_DIR:-${REPO_DIR}}"
+export NEXT_TEST_MODE="${NEXT_TEST_MODE:-deploy}"
+export NEXT_E2E_TEST_TIMEOUT="${NEXT_E2E_TEST_TIMEOUT:-240000}"
+export NEXT_EXTERNAL_TESTS_FILTERS="${NEXT_EXTERNAL_TESTS_FILTERS:-test/deploy-tests-manifest.json}"
+export NEXT_TEST_JOB="${NEXT_TEST_JOB:-1}"
+export NEXT_TELEMETRY_DISABLED="${NEXT_TELEMETRY_DISABLED:-1}"
+export IS_TURBOPACK_TEST="${IS_TURBOPACK_TEST:-1}"
+export NEXT_TEST_DEPLOY_SCRIPT_PATH="${REPO_DIR}/scripts/e2e-deploy.sh"
+export NEXT_TEST_DEPLOY_LOGS_SCRIPT_PATH="${REPO_DIR}/scripts/e2e-logs.sh"
+export NEXT_TEST_CLEANUP_SCRIPT_PATH="${REPO_DIR}/scripts/e2e-cleanup.sh"
+
+RUN_ARGS=(--timings --type e2e)
+
+if [ "$#" -eq 0 ]; then
+ TEST_GROUP="${NEXT_TEST_GROUP-1/16}"
+ TEST_CONCURRENCY="${NEXT_TEST_CONCURRENCY-2}"
+
+ if [ -n "${TEST_GROUP}" ]; then
+ RUN_ARGS+=(-g "${TEST_GROUP}")
+ fi
+
+ if [ -n "${TEST_CONCURRENCY}" ]; then
+ RUN_ARGS+=(-c "${TEST_CONCURRENCY}")
+ fi
+fi
+
+if [ "$#" -gt 0 ]; then
+ RUN_ARGS+=("$@")
+fi
+
+(
+ cd "${NEXTJS_DIR}"
+ node run-tests.js "${RUN_ARGS[@]}"
+)