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[@]}" +)