Files
cloudflare__vinext/scripts/e2e-deploy.sh
T
Nathan Nguyen 88144de251 perf(e2e): preinstall compatible React in deploy harness (#1443)
* perf(e2e): preinstall compatible React in deploy harness

App Router deploy-suite fixtures can start with React versions below the react-server-dom-webpack peer range. The first harness install then resolves that older React pair, and vinext build immediately runs its RSC compatibility upgrade, causing a second package-manager install inside each throwaway app.

Normalize App Router fixture manifests before the initial install by replacing older semver React and React DOM specs with the workspace-compatible specs. Pages-only fixtures and non-semver specs are left untouched.

* chore: rerun ci

* fix(e2e): derive React floor from harness spec

The deploy harness normalized App Router fixture React versions against a hardcoded 19.2.6 floor. That duplicated the compatibility threshold already expressed by vinext's install specs and could go stale when the workspace moves to a newer compatible React pair.

Compare each fixture dependency against the replacement spec that the harness would write. This keeps React and React DOM normalization tied to the package metadata instead of a separate constant.
2026-05-22 14:22:27 +01:00

638 lines
20 KiB
Bash
Executable File

#!/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 || true
sleep 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}" -sTCP:LISTEN 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
# Some Next.js tests check for .next/trace existence (telemetry trace file)
# during the test harness's destroy() cleanup. vinext doesn't produce one, so
# create an empty file to satisfy those checks. Do this BEFORE any step that
# can fail (dep install, vinext init, vinext build) so the file exists even
# on failure — otherwise the test harness logs ENOENT noise (303 lines per
# deploy-suite run before this fix).
mkdir -p ".next"
: > ".next/trace"
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'
// App Router fixtures need React to satisfy the same peer range as the
// injected react-server-dom-webpack. If they install an older React pair first,
// `vinext build` runs its RSC compatibility upgrade and pays for a second
// package-manager install inside every throwaway test app. Normalize the temp
// manifest before the first install so the final dependency graph is unchanged
// but setup is single-pass.
function hasAppRouterDir(root) {
return fs.existsSync(path.join(root, 'app')) || fs.existsSync(path.join(root, 'src', 'app'))
}
function compareSemver(a, b) {
for (let index = 0; index < 3; index += 1) {
if (a[index] < b[index]) return -1
if (a[index] > b[index]) return 1
}
return 0
}
function parseSemverSpec(spec) {
const match = /(\d+)\.(\d+)\.(\d+)/.exec(spec)
if (!match) return null
return [Number(match[1]), Number(match[2]), Number(match[3])]
}
function dependencyBucketFor(name) {
for (const bucket of ['dependencies', 'devDependencies', 'peerDependencies']) {
if (pkg[bucket]?.[name]) return bucket
}
return null
}
function normalizeAppRouterReactDeps() {
if (!hasAppRouterDir(process.cwd())) return
for (const dep of ['react', 'react-dom']) {
const bucket = dependencyBucketFor(dep)
if (!bucket) continue
const current = pkg[bucket][dep]
const version = parseSemverSpec(current)
const replacement = dependencySpecFor(dep)
const minimumVersion = parseSemverSpec(replacement)
if (!minimumVersion) continue
if (!version || compareSemver(version, minimumVersion) >= 0) continue
pkg[bucket][dep] = replacement
console.log(
`Bumped ${bucket}.${dep} from ${current} to ${replacement} for RSC compatibility`,
)
}
}
normalizeAppRouterReactDeps()
// Catalog-tracked deps: spec sourced from vinext or workspace root package.json.
// Includes the Vite/RSC peers that vinext consumers must install, plus runtime
// deps of vinext that pnpm doesn't hoist into the test app's top-level
// node_modules (vinext is installed via a `file:` symlink, so its transitive
// deps live under `.vinext-local-package/node_modules`, which Node's ESM
// resolver can't see from `<test-app>/dist/...`).
for (const dep of [
'vite',
'@vitejs/plugin-react',
'@vitejs/plugin-rsc',
'react-server-dom-webpack',
'@mdx-js/rollup',
'@mdx-js/react',
'ipaddr.js',
]) {
if (!pkg.devDependencies[dep] && !pkg.dependencies?.[dep]) {
pkg.devDependencies[dep] = dependencySpecFor(dep)
}
}
// Some Next.js scss test fixtures pin sass to an old version (e.g. 1.54.0)
// that predates `sass.initAsyncCompiler`. Vite 8's built-in vite:css preprocessor
// calls that API and declares `sass@^1.70.0` / `sass-embedded@^1.70.0` as peers.
// If the test app pins a sass/sass-embedded version below the Vite 8 peer range,
// bump it so the SCSS preprocessor can initialise. This is the minimum bump that
// keeps the test app's intent (use sass) while making Vite happy. Without it,
// every test/e2e/app-dir/scss/* and test/e2e/app-dir/scss-modules/* suite fails
// at build time with: TypeError: [sass] sass.initAsyncCompiler is not a function.
//
// Parses the leading `major.minor` out of pinned specs like "1.54.0", "^1.70.0",
// "~1.75.0", ">=1.70.0". Falls through (no rewrite) for git/file/workspace/tag
// specs, which never come from the Next.js test fixtures we're patching here.
function parseMajorMinor(spec) {
const match = /^[\^~>=<\s]*(\d+)\.(\d+)/.exec(spec)
if (!match) return null
return { major: Number(match[1]), minor: Number(match[2]) }
}
function bumpSassDep(name, minSpec) {
for (const bucket of ['dependencies', 'devDependencies', 'peerDependencies']) {
const deps = pkg[bucket]
if (!deps || !deps[name]) continue
const current = deps[name]
const version = parseMajorMinor(current)
if (!version) continue
if (version.major > 1 || (version.major === 1 && version.minor >= 70)) {
continue
}
deps[name] = minSpec
console.log(
`Bumped ${bucket}.${name} from ${current} to ${minSpec} for Vite 8 compatibility`,
)
}
}
bumpSassDep('sass', '^1.70.0')
bumpSassDep('sass-embedded', '^1.70.0')
// Pinned harness-only deps that aren't tracked by the vinext workspace but
// are referenced by Next.js test fixtures. `webpack` is imported at module
// scope by test/e2e/app-dir/next-config/next.config.js (top-level
// `require('webpack').sources.RawSource`) — Next.js's own test harness gets
// it transitively via `next`, but the test fixture's package.json doesn't
// declare it, so vinext's build fails to resolve it after the CJS→ESM rewrite.
for (const [dep, spec] of [
['webpack', '^5.99.0'],
]) {
if (!pkg.devDependencies[dep] && !pkg.dependencies?.[dep]) {
pkg.devDependencies[dep] = spec
}
}
// Detect TypeScript config files. Vite's PostCSS/Tailwind/etc. config loaders
// require either `jiti` or `tsx` to load `*.config.{ts,mts,cts}` (and dotfile
// `.postcssrc.ts` variants). Next.js used to auto-install one of these into
// the test app at build time; once vinext takes over, the test app's
// package.json no longer lists either, so the build fails with:
//
// 'tsx' or 'jiti' is required for the TypeScript configuration files.
// Make sure it is installed
// Cannot find package 'jiti' imported from .../vite/dist/node/chunks/node.js
//
// jiti is the lighter of the two (no native deps) and matches what Vite's
// internal config loader prefers, so inject jiti when any TS-flavoured config
// file is present at the test app root.
const tsConfigFilePatterns = [
/^next\.config\.(?:ts|mts|cts)$/,
/^postcss\.config\.(?:ts|mts|cts)$/,
/^tailwind\.config\.(?:ts|mts|cts)$/,
/^vite\.config\.(?:ts|mts|cts)$/,
/^\.postcssrc\.(?:ts|mts|cts)$/,
]
let hasTsConfig = false
try {
const entries = fs.readdirSync(process.cwd(), { withFileTypes: true })
for (const entry of entries) {
if (!entry.isFile()) continue
if (tsConfigFilePatterns.some((rx) => rx.test(entry.name))) {
hasTsConfig = true
break
}
}
} catch {
// ignore — fall back to not injecting jiti
}
if (hasTsConfig && !pkg.devDependencies.jiti && !pkg.dependencies?.jiti) {
// Pin matches the version already resolved transitively in the vinext
// workspace (via @tailwindcss/node). postcss-load-config and Vite both
// require jiti >=1.21.0; ^2.6.1 satisfies that.
pkg.devDependencies.jiti = '^2.6.1'
}
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.
#
# vinext loads CJS next.config.js in `"type": "module"` packages via a temp
# .cjs sibling (see config/next-config.ts), so we don't rewrite the user's
# config file here.
run_pnpm exec vinext init --skip-check --force >> "${BUILD_LOG}" 2>&1
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
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}"