mirror of
https://github.com/vercel/next.js.git
synced 2026-09-20 02:25:18 +08:00
357b2b3c4c
### What? Migrate the five local isolated test installs that explicitly used npm to pnpm. The Nx fixture now has pnpm workspace metadata, while filesystem-layout-sensitive fixtures use pnpm's hoisted linker with copied package files. ### Why? The npm-based Nx install bypassed the repository's centralized supply-chain protections and could select a package immediately after publication, including temporarily incomplete multi-package releases. Using pnpm makes isolated installs inherit the repository's `minimumReleaseAge`, exclusions, and exotic-subdependency policy. The other npm installs depended on npm-style real package directories. On Node versions affected by nodejs/node#65113, hoisted/copy mode preserves that layout without leaving these fixtures outside the shared pnpm security configuration; fixed Node releases use normal pnpm linking. ### How? - Use normal pnpm workspace resolution for the Nx fixture. - Use `node-linker=hoisted` and `package-import-method=copy` for filesystem tests only on affected Node releases; Node 24.21+ and 26.8+ use normal linking. Node 20 CI keeps the workaround because no fixed Node 20 release exists. - Validate local `@next/env` tarballs through the lockfile when hoisted installs do not expose pnpm's virtual-store path marker. - Keep the deployment-environment npm install unchanged. ### Verification - `pnpm build-all` - `pnpm types` - A 9-version throwaway assertion verified the affected/fixed Node release matrix - `pnpm test-dev-turbo test/e2e/app-dir/nx-handling/nx-handling.test.ts test/e2e/handle-non-hoisted-swc-helpers/index.test.ts test/e2e/filesystem-cache/filesystem-cache.test.ts test/e2e/filesystem-cache/warm-restart-task-stats.test.ts test/e2e/filesystem-cache/evict-after-snapshot.test.ts` — all 25 tests passed after installing the sandbox's missing Playwright browser - Production Turbopack: Nx, non-hoisted SWC helper, build-cache-default, and warm restart passed (9/9) - `filesystem-cache.test.ts` production baseline: 15/17 passed; the same two cache-growth bounds fail under both the unchanged npm fixture and the pnpm fixture at nearly identical percentages, so they are pre-existing sandbox-specific failures - Generated-layout inspection: no package symlinks outside expected `.bin` command shims; package files are copied; `node_modules/.pnpm` is metadata-only <!-- NEXT_JS_LLM --> <!-- fleet 81cd457d-6956-4cf9-b6f6-9ebf9d95f285 --> --------- Co-authored-by: vercel-fleet-prod[bot] <318278635+vercel-fleet-prod[bot]@users.noreply.github.com> Co-authored-by: Tobias Koppers <1365881+sokra@users.noreply.github.com>
461 lines
14 KiB
JavaScript
461 lines
14 KiB
JavaScript
const os = require('os')
|
|
const path = require('path')
|
|
const execa = require('execa')
|
|
const fs = require('fs-extra')
|
|
const childProcess = require('child_process')
|
|
const { randomBytes } = require('crypto')
|
|
const { linkPackages } = require('./link-packed-packages')
|
|
const yaml = require('js-yaml')
|
|
const {
|
|
getPnpmSecuritySettings,
|
|
mergePnpmSecuritySettingsIntoYaml,
|
|
getYarnSecuritySettings,
|
|
mergeYarnSecuritySettingsIntoYaml,
|
|
} = require('./pnpm-security-settings')
|
|
|
|
const PREFER_OFFLINE = process.env.NEXT_TEST_PREFER_OFFLINE === '1'
|
|
const useRspack = process.env.NEXT_TEST_USE_RSPACK === '1'
|
|
const ROOT_PACKAGE_MANAGER = require('../../package.json').packageManager
|
|
|
|
async function installDependencies(cwd, tmpDir) {
|
|
const args = [
|
|
'install',
|
|
'--strict-peer-dependencies=false',
|
|
'--no-frozen-lockfile',
|
|
`--config.cacheDir=${tmpDir}`,
|
|
]
|
|
|
|
if (PREFER_OFFLINE) {
|
|
args.push('--prefer-offline')
|
|
}
|
|
|
|
await execa('pnpm', args, {
|
|
cwd,
|
|
stdio: ['ignore', 'inherit', 'inherit'],
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Finds `fileName` in the dirs from `installDir` up to `isolationRoot`
|
|
* (inclusive), or null if absent.
|
|
*
|
|
* @param {string} fileName
|
|
* @param {string} installDir
|
|
* @param {string} isolationRoot
|
|
* @returns {Promise<string | null>}
|
|
*/
|
|
async function findConfigFile(fileName, installDir, isolationRoot) {
|
|
let dir = path.resolve(installDir)
|
|
const stopDir = path.resolve(isolationRoot)
|
|
while (true) {
|
|
const file = path.join(dir, fileName)
|
|
if (await fs.pathExists(file)) {
|
|
return file
|
|
}
|
|
if (dir === stopDir) break
|
|
const parent = path.dirname(dir)
|
|
if (parent === dir) break
|
|
dir = parent
|
|
}
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Applies the supply-chain security settings from the repo root
|
|
* `pnpm-workspace.yaml` to installs in the isolated test dir, by writing (or
|
|
* merging into) a `pnpm-workspace.yaml` and a `.yarnrc.yml`. npm added
|
|
* equivalent functionality in 11.10.0; we can configure it here once we
|
|
* upgrade npm.
|
|
*
|
|
* @param {string} installDir
|
|
* @param {string} isolationRoot
|
|
* @returns {Promise<void>}
|
|
*/
|
|
async function applyInstallSecuritySettings(installDir, isolationRoot) {
|
|
const workspaceFile = await findConfigFile(
|
|
'pnpm-workspace.yaml',
|
|
installDir,
|
|
isolationRoot
|
|
)
|
|
if (workspaceFile !== null) {
|
|
await fs.writeFile(
|
|
workspaceFile,
|
|
mergePnpmSecuritySettingsIntoYaml(
|
|
await fs.readFile(workspaceFile, 'utf8')
|
|
)
|
|
)
|
|
} else {
|
|
await fs.writeFile(
|
|
path.join(installDir, 'pnpm-workspace.yaml'),
|
|
yaml.dump(getPnpmSecuritySettings())
|
|
)
|
|
}
|
|
|
|
const yarnrcFile = await findConfigFile(
|
|
'.yarnrc.yml',
|
|
installDir,
|
|
isolationRoot
|
|
)
|
|
if (yarnrcFile !== null) {
|
|
await fs.writeFile(
|
|
yarnrcFile,
|
|
mergeYarnSecuritySettingsIntoYaml(await fs.readFile(yarnrcFile, 'utf8'))
|
|
)
|
|
} else {
|
|
await fs.writeFile(
|
|
path.join(installDir, '.yarnrc.yml'),
|
|
yaml.dump(getYarnSecuritySettings())
|
|
)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* pnpm only honors overrides at the workspace root, so they go into the
|
|
* `pnpm-workspace.yaml` that governs the install.
|
|
*
|
|
* @param {string} installDir
|
|
* @param {string} isolationRoot
|
|
* @param {Record<string, string>} overrides
|
|
* @returns {Promise<void>}
|
|
*/
|
|
async function applyWorkspaceOverrides(installDir, isolationRoot, overrides) {
|
|
const workspaceFile = await findConfigFile(
|
|
'pnpm-workspace.yaml',
|
|
installDir,
|
|
isolationRoot
|
|
)
|
|
if (workspaceFile === null) {
|
|
return
|
|
}
|
|
|
|
const workspaceConfig =
|
|
/** @type {Record<string, any>} */ (
|
|
yaml.load(await fs.readFile(workspaceFile, 'utf8'))
|
|
) ?? {}
|
|
workspaceConfig.overrides = {
|
|
...overrides,
|
|
...(workspaceConfig.overrides || {}),
|
|
}
|
|
await fs.writeFile(workspaceFile, yaml.dump(workspaceConfig))
|
|
}
|
|
|
|
/**
|
|
* pnpm's hoisted linker does not expose the `@pkg+name@file` virtual-store
|
|
* path used by the default linker. Verify the exact local tarball through the
|
|
* lockfile instead.
|
|
*
|
|
* @param {string} installDir
|
|
* @param {string} packageName
|
|
* @param {string} expectedTarballPath
|
|
* @returns {Promise<boolean>}
|
|
*/
|
|
async function lockfileResolvesLocalTarball(
|
|
installDir,
|
|
packageName,
|
|
expectedTarballPath
|
|
) {
|
|
const lockfile = /** @type {Record<string, any>} */ (
|
|
yaml.load(
|
|
await fs.readFile(path.join(installDir, 'pnpm-lock.yaml'), 'utf8')
|
|
)
|
|
)
|
|
const expectedRealpath = await fs.realpath(expectedTarballPath)
|
|
|
|
for (const [key, pkg] of Object.entries(lockfile.packages || {})) {
|
|
const tarball = pkg?.resolution?.tarball
|
|
if (
|
|
!key.startsWith(`${packageName}@file:`) ||
|
|
typeof tarball !== 'string' ||
|
|
!tarball.startsWith('file:')
|
|
) {
|
|
continue
|
|
}
|
|
|
|
const resolvedTarball = path.resolve(
|
|
installDir,
|
|
tarball.slice('file:'.length)
|
|
)
|
|
if ((await fs.realpath(resolvedTarball)) === expectedRealpath) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* @param {import('next/dist/trace').Span} parentSpan
|
|
* @returns {Promise<Map<string, string>>}
|
|
*/
|
|
async function packPackages(parentSpan) {
|
|
const repositoryDirectory = path.join(__dirname, '../..')
|
|
await parentSpan.traceChild('turbo-run-pack').traceAsyncFn(() =>
|
|
execa(
|
|
'pnpm',
|
|
[
|
|
'turbo',
|
|
'run',
|
|
'pack-for-isolated-tests',
|
|
'--output-logs',
|
|
'new-only',
|
|
'--ui',
|
|
'stream',
|
|
],
|
|
{
|
|
cwd: repositoryDirectory,
|
|
stdio: ['ignore', 'inherit', 'inherit'],
|
|
}
|
|
)
|
|
)
|
|
return parentSpan
|
|
.traceChild('linkPackages')
|
|
.traceAsyncFn(() => linkPackages({ repoDir: repositoryDirectory }))
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {object} param0
|
|
* @param {import('@next/telemetry').Span} param0.parentSpan
|
|
* @param {object} [param0.dependencies]
|
|
* @param {object | null} [param0.resolutions]
|
|
* @param { ((ctx: { dependencies: { [key: string]: string } }) => string) | string | null} [param0.installCommand]
|
|
* @param {object} [param0.packageJson]
|
|
* @param {string} [param0.subDir]
|
|
* @param {(span: import('@next/telemetry').Span, installDir: string) => Promise<void>} [param0.beforeInstall]
|
|
* @returns {Promise<{installDir: string, pkgPaths: Map<string, string>}>}
|
|
*/
|
|
async function createNextInstall({
|
|
parentSpan,
|
|
dependencies = {},
|
|
resolutions = null,
|
|
installCommand = null,
|
|
packageJson = {},
|
|
subDir = '',
|
|
beforeInstall,
|
|
}) {
|
|
const tmpDir = await fs.realpath(process.env.NEXT_TEST_DIR || os.tmpdir())
|
|
|
|
return await parentSpan
|
|
.traceChild('createNextInstall')
|
|
.traceAsyncFn(async (rootSpan) => {
|
|
const origRepoDir = path.join(__dirname, '../../')
|
|
const isolationRoot = path.join(
|
|
tmpDir,
|
|
`next-install-${randomBytes(32).toString('hex')}`
|
|
)
|
|
const installDir = path.join(isolationRoot, subDir)
|
|
require('console').log('Creating next instance in:')
|
|
require('console').log(installDir)
|
|
|
|
const pkgPathsEnv = process.env.NEXT_TEST_PKG_PATHS
|
|
let pkgPaths
|
|
|
|
if (pkgPathsEnv) {
|
|
pkgPaths = new Map(JSON.parse(pkgPathsEnv))
|
|
require('console').log('using provided pkg paths')
|
|
} else {
|
|
pkgPaths = await packPackages(rootSpan)
|
|
|
|
if (process.env.NEXT_TEST_WASM) {
|
|
const wasmPath = path.join(origRepoDir, 'crates', 'wasm', 'pkg')
|
|
const hasWasmBinary = fs.existsSync(
|
|
path.join(wasmPath, 'package.json')
|
|
)
|
|
if (hasWasmBinary) {
|
|
process.env.NEXT_TEST_WASM_DIR = wasmPath
|
|
}
|
|
} else {
|
|
const nativePath = path.join(origRepoDir, 'packages/next-swc/native')
|
|
const hasNativeBinary = fs.existsSync(nativePath)
|
|
? fs.readdirSync(nativePath).some((item) => item.endsWith('.node'))
|
|
: false
|
|
|
|
if (hasNativeBinary) {
|
|
process.env.NEXT_TEST_NATIVE_DIR = nativePath
|
|
} else {
|
|
const swcDirectory = fs
|
|
.readdirSync(path.join(origRepoDir, 'node_modules/@next'))
|
|
.find((directory) => directory.startsWith('swc-'))
|
|
process.env.NEXT_TEST_NATIVE_DIR = path.join(
|
|
origRepoDir,
|
|
'node_modules/@next',
|
|
swcDirectory
|
|
)
|
|
}
|
|
}
|
|
|
|
require('console').log({
|
|
swcNativeDirectory: process.env.NEXT_TEST_NATIVE_DIR,
|
|
swcWasmDirectory: process.env.NEXT_TEST_WASM_DIR,
|
|
})
|
|
}
|
|
|
|
const combinedDependencies = {
|
|
next: pkgPaths.get('next'),
|
|
...Object.keys(dependencies).reduce((prev, pkg) => {
|
|
const pkgPath = pkgPaths.get(pkg)
|
|
const version = dependencies[pkg]
|
|
if (version === 'workspace:*') {
|
|
if (pkgPath) {
|
|
prev[pkg] = pkgPath
|
|
} else {
|
|
throw new Error(
|
|
`"${pkg}" is declared as "workspace:*" but no packed tarball was found for it. ` +
|
|
`Only packages in this repository with a "pack-for-isolated-tests" script can be used with "workspace:*".`
|
|
)
|
|
}
|
|
} else {
|
|
prev[pkg] = pkgPath || version
|
|
}
|
|
return prev
|
|
}, {}),
|
|
}
|
|
|
|
if (useRspack) {
|
|
combinedDependencies['next-rspack'] = pkgPaths.get('next-rspack')
|
|
}
|
|
|
|
// Build overrides to resolve transitive workspace deps from local
|
|
// tarballs. Write all three formats so npm, pnpm, and yarn all work.
|
|
const workspacePkgOverrides = {}
|
|
for (const [name, tarballPath] of pkgPaths.entries()) {
|
|
if (!combinedDependencies[name]) {
|
|
workspacePkgOverrides[name] = tarballPath
|
|
}
|
|
}
|
|
|
|
const scripts = {
|
|
debug: `NEXT_PRIVATE_SKIP_CANARY_CHECK=1 NEXT_TELEMETRY_DISABLED=1 NEXT_TEST_NATIVE_DIR=${process.env.NEXT_TEST_NATIVE_DIR} node --inspect --trace-deprecation --enable-source-maps node_modules/next/dist/bin/next`,
|
|
'debug-brk': `NEXT_PRIVATE_SKIP_CANARY_CHECK=1 NEXT_TELEMETRY_DISABLED=1 NEXT_TEST_NATIVE_DIR=${process.env.NEXT_TEST_NATIVE_DIR} node --inspect-brk --trace-deprecation --enable-source-maps node_modules/next/dist/bin/next`,
|
|
...packageJson.scripts,
|
|
}
|
|
|
|
// Pin the same pnpm version the repo uses so corepack resolves a
|
|
// consistent pnpm across isolated test dirs. Without this, `pnpm` may
|
|
// fall back to whatever version is installed at the system level, which
|
|
// can disagree with the repo's `packageManager` field and cause mismatch
|
|
// errors (e.g. pnpm-workspace.yaml written for v10 parsed by v9).
|
|
//
|
|
// Only fall back to the root `packageManager` for the default pnpm
|
|
// install path. Tests that provide their own `installCommand` (e.g.
|
|
// yarn-pnp) need to switch package managers themselves and would be
|
|
// blocked by corepack if the file already pinned `pnpm@...`.
|
|
const rootPackageManager = require(
|
|
path.join(__dirname, '../../package.json')
|
|
).packageManager
|
|
const packageManagerField =
|
|
packageJson.packageManager ||
|
|
(installCommand ? undefined : rootPackageManager)
|
|
|
|
await fs.ensureDir(installDir)
|
|
await fs.writeFile(
|
|
path.join(installDir, 'package.json'),
|
|
JSON.stringify(
|
|
{
|
|
// Pin packageManager so corepack doesn't auto-inject a reference
|
|
// to the latest version (and rewrite this file mid-test).
|
|
// Callers can override via packageJson.packageManager.
|
|
packageManager: ROOT_PACKAGE_MANAGER,
|
|
...packageJson,
|
|
...(packageManagerField && { packageManager: packageManagerField }),
|
|
scripts,
|
|
dependencies: combinedDependencies,
|
|
private: true,
|
|
overrides: {
|
|
...workspacePkgOverrides,
|
|
...(packageJson.overrides || {}),
|
|
},
|
|
resolutions: {
|
|
...workspacePkgOverrides,
|
|
...(resolutions || {}),
|
|
},
|
|
},
|
|
null,
|
|
2
|
|
)
|
|
)
|
|
|
|
if (beforeInstall !== undefined) {
|
|
await rootSpan
|
|
.traceChild('beforeInstall')
|
|
.traceAsyncFn(async (span) => {
|
|
await beforeInstall(span, installDir)
|
|
})
|
|
}
|
|
|
|
const installString = installCommand
|
|
? typeof installCommand === 'function'
|
|
? installCommand({
|
|
dependencies: combinedDependencies,
|
|
resolutions,
|
|
})
|
|
: installCommand
|
|
: null
|
|
|
|
await applyInstallSecuritySettings(installDir, isolationRoot)
|
|
await applyWorkspaceOverrides(installDir, isolationRoot, {
|
|
...workspacePkgOverrides,
|
|
...(resolutions || {}),
|
|
})
|
|
|
|
if (installString !== null) {
|
|
console.log('running install command', installString)
|
|
rootSpan.traceChild('run custom install').traceFn(() => {
|
|
childProcess.execSync(installString, {
|
|
cwd: installDir,
|
|
stdio: ['ignore', 'inherit', 'inherit'],
|
|
})
|
|
})
|
|
} else {
|
|
await rootSpan
|
|
.traceChild('run generic install command', combinedDependencies)
|
|
.traceAsyncFn(() => installDependencies(installDir, tmpDir))
|
|
|
|
// `@next/env` is a dependency of `next`, so it only resolves to the
|
|
// local tarball if the overrides were applied. Every generic isolated
|
|
// install reaches this guard, but the lockfile fallback short-circuits
|
|
// off when the default linker exposes its virtual-store path.
|
|
if (!combinedDependencies['@next/env']) {
|
|
const envDir = await fs.realpath(
|
|
path.join(
|
|
await fs.realpath(path.join(installDir, 'node_modules/next')),
|
|
'../@next/env'
|
|
)
|
|
)
|
|
const envTarballPath = pkgPaths.get('@next/env')
|
|
if (
|
|
!envDir.includes('@next+env@file') &&
|
|
!(
|
|
envTarballPath &&
|
|
(await lockfileResolvesLocalTarball(
|
|
installDir,
|
|
'@next/env',
|
|
envTarballPath
|
|
))
|
|
)
|
|
) {
|
|
throw new Error(
|
|
`@next/env resolved from the npm registry instead of the local tarball (${envDir}), ` +
|
|
'the workspace overrides were not applied to the install'
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
if (useRspack) {
|
|
process.env.NEXT_RSPACK = 'true'
|
|
process.env.RSPACK_CONFIG_VALIDATE = 'loose-silent'
|
|
}
|
|
|
|
return {
|
|
installDir,
|
|
pkgPaths,
|
|
}
|
|
})
|
|
}
|
|
|
|
module.exports = {
|
|
createNextInstall,
|
|
getPkgPaths: linkPackages,
|
|
packPackages,
|
|
}
|