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>
442 lines
15 KiB
TypeScript
442 lines
15 KiB
TypeScript
/* eslint-disable jest/no-standalone-expect */
|
|
import { nextTestSetup, isNextDev } from 'e2e-utils'
|
|
import { waitFor } from 'next-test-utils'
|
|
import fs from 'fs/promises'
|
|
import { existsSync } from 'fs'
|
|
import path from 'path'
|
|
import { parseTraceEvents } from '../../lib/parse-trace-file'
|
|
import { getPnpmRealpathWorkaround } from '../../lib/pnpm-realpath-workaround'
|
|
|
|
async function getDirectorySize(dirPath: string): Promise<number> {
|
|
try {
|
|
await fs.access(dirPath)
|
|
} catch {
|
|
return 0
|
|
}
|
|
let totalSize = 0
|
|
const entries = await fs.readdir(dirPath, {
|
|
recursive: true,
|
|
withFileTypes: true,
|
|
})
|
|
for (const entry of entries) {
|
|
if (entry.isFile()) {
|
|
const filePath = path.join(entry.parentPath ?? entry.path, entry.name)
|
|
const stat = await fs.stat(filePath)
|
|
totalSize += stat.size
|
|
}
|
|
}
|
|
return totalSize
|
|
}
|
|
|
|
for (const cacheEnabled of [false, true]) {
|
|
// TODO(deploy-test-completion): Re-enable this suite in deploy mode.
|
|
// It likely mutates files in the isolated local fixture after setup.
|
|
// @force-gate !deploy
|
|
describe(`filesystem-caching with cache ${cacheEnabled ? 'enabled' : 'disabled'}`, () => {
|
|
beforeAll(() => {
|
|
process.env.NEXT_PUBLIC_ENV_VAR = 'hello world'
|
|
})
|
|
afterAll(() => {
|
|
delete process.env.NEXT_PUBLIC_ENV_VAR
|
|
})
|
|
|
|
const env = {
|
|
ENABLE_CACHING: cacheEnabled ? '1' : '',
|
|
// Make it easier to run in development, test directories are cleared between runs already so this is safe.
|
|
TURBO_ENGINE_IGNORE_DIRTY: '1',
|
|
// decrease the idle timeout to make the test more reliable
|
|
TURBO_ENGINE_SNAPSHOT_IDLE_TIMEOUT_MILLIS: '1000',
|
|
// persist even tiny snapshots so the test doesn't depend on the
|
|
// minimum-compilation-time threshold
|
|
TURBO_ENGINE_SNAPSHOT_MIN_ACTIVE_TIME_MILLIS: '0',
|
|
}
|
|
|
|
const { next, isTurbopack } = nextTestSetup({
|
|
files: __dirname,
|
|
overrideFiles: getPnpmRealpathWorkaround(),
|
|
// Pass the cache setting through every harness-managed build and restart.
|
|
env,
|
|
})
|
|
|
|
beforeAll(() => {
|
|
// We can skip the dev watch delay since this is not an HMR test
|
|
;(next as any).handleDevWatchDelayBeforeChange = () => {}
|
|
;(next as any).handleDevWatchDelayAfterChange = () => {}
|
|
})
|
|
|
|
async function restartCycle(): Promise<number> {
|
|
await stop()
|
|
const cacheSize = await getCacheSize()
|
|
await start()
|
|
return cacheSize
|
|
}
|
|
|
|
async function stop() {
|
|
if (isNextDev) {
|
|
// Give FileSystem Cache time to write to disk
|
|
// Turbopack is configured to wait 1s above.
|
|
// Webpack has an idle timeout (after large changes) of 1s
|
|
// and we give time a bit more to allow writing to disk
|
|
await waitFor(3000)
|
|
}
|
|
await next.stop()
|
|
}
|
|
|
|
async function start() {
|
|
await next.start()
|
|
}
|
|
|
|
async function getCacheSize(): Promise<number> {
|
|
return getDirectorySize(
|
|
path.join(next.testDir, '.next', 'cache', 'turbopack')
|
|
)
|
|
}
|
|
|
|
// Ensure each test starts with a fresh server and no test leaks a
|
|
// running server into the next one.
|
|
beforeEach(async () => {
|
|
// stop() is a no-op if already stopped; start() rebuilds + starts.
|
|
await stop()
|
|
await start()
|
|
})
|
|
afterEach(async () => {
|
|
await stop()
|
|
})
|
|
|
|
// Very flakey with Webpack enabled
|
|
;(process.env.IS_TURBOPACK_TEST ? it : it.skip)(
|
|
'should cache or not cache loaders',
|
|
async () => {
|
|
let appTimestamp, unchangedTimestamp, appClientTimestamp, pagesTimestamp
|
|
{
|
|
const browser = await next.browser('/')
|
|
appTimestamp = await browser.elementByCss('main').text()
|
|
expect(appTimestamp).toMatch(/Timestamp = \d+$/)
|
|
await browser.close()
|
|
}
|
|
{
|
|
const browser = await next.browser('/unchanged')
|
|
unchangedTimestamp = await browser.elementByCss('main').text()
|
|
expect(unchangedTimestamp).toMatch(/Timestamp = \d+$/)
|
|
await browser.close()
|
|
}
|
|
{
|
|
const browser = await next.browser('/client')
|
|
appClientTimestamp = await browser.elementByCss('main').text()
|
|
expect(appClientTimestamp).toMatch(/Timestamp = \d+$/)
|
|
await browser.close()
|
|
}
|
|
{
|
|
const browser = await next.browser('/pages')
|
|
pagesTimestamp = await browser.elementByCss('main').text()
|
|
expect(pagesTimestamp).toMatch(/Timestamp = \d+$/)
|
|
await browser.close()
|
|
}
|
|
const initialCacheSize = await restartCycle()
|
|
|
|
{
|
|
const browser = await next.browser('/')
|
|
const newTimestamp = await browser.elementByCss('main').text()
|
|
expect(newTimestamp).toMatch(/Timestamp = \d+$/)
|
|
if (cacheEnabled) {
|
|
expect(newTimestamp).toBe(appTimestamp)
|
|
} else {
|
|
expect(newTimestamp).not.toBe(appTimestamp)
|
|
}
|
|
await browser.close()
|
|
}
|
|
{
|
|
const browser = await next.browser('/unchanged')
|
|
const newTimestamp = await browser.elementByCss('main').text()
|
|
expect(newTimestamp).toMatch(/Timestamp = \d+$/)
|
|
if (cacheEnabled) {
|
|
expect(newTimestamp).toBe(unchangedTimestamp)
|
|
} else {
|
|
expect(newTimestamp).not.toBe(unchangedTimestamp)
|
|
}
|
|
await browser.close()
|
|
}
|
|
{
|
|
const browser = await next.browser('/client')
|
|
const newTimestamp = await browser.elementByCss('main').text()
|
|
expect(newTimestamp).toMatch(/Timestamp = \d+$/)
|
|
if (cacheEnabled) {
|
|
expect(newTimestamp).toBe(appClientTimestamp)
|
|
} else {
|
|
expect(newTimestamp).not.toBe(appClientTimestamp)
|
|
}
|
|
await browser.close()
|
|
}
|
|
{
|
|
const browser = await next.browser('/pages')
|
|
const newTimestamp = await browser.elementByCss('main').text()
|
|
expect(newTimestamp).toMatch(/Timestamp = \d+$/)
|
|
if (cacheEnabled) {
|
|
expect(newTimestamp).toBe(pagesTimestamp)
|
|
} else {
|
|
expect(newTimestamp).not.toBe(pagesTimestamp)
|
|
}
|
|
await browser.close()
|
|
}
|
|
|
|
if (cacheEnabled) {
|
|
const finalCacheSize = await restartCycle()
|
|
const increasePercent = (
|
|
(finalCacheSize / initialCacheSize - 1) *
|
|
100
|
|
).toFixed(2)
|
|
console.log(
|
|
`Cache size: ${(initialCacheSize / 1024 / 1024).toFixed(2)} MB -> ${(finalCacheSize / 1024 / 1024).toFixed(2)} MB (${increasePercent}%)`
|
|
)
|
|
expect(finalCacheSize).toBeLessThanOrEqual(initialCacheSize * 1.1)
|
|
}
|
|
}
|
|
)
|
|
|
|
function makeTextCheck(url: string, text: string) {
|
|
return textCheck.bind(null, url, text)
|
|
}
|
|
|
|
async function textCheck(url: string, text: string) {
|
|
const browser = await next.browser(url)
|
|
expect(await browser.elementByCss('p').text()).toBe(text)
|
|
await browser.close()
|
|
}
|
|
|
|
function makeFileEdit(file: string) {
|
|
return async (inner: () => Promise<void>) => {
|
|
await next.patchFile(
|
|
file,
|
|
(content) => {
|
|
return content.replace('hello world', 'hello filesystem cache')
|
|
},
|
|
inner
|
|
)
|
|
}
|
|
}
|
|
|
|
interface Change {
|
|
checkInitial(): Promise<void>
|
|
withChange(previous: () => Promise<void>): Promise<void>
|
|
checkChanged(): Promise<void>
|
|
fullInvalidation?: boolean
|
|
largeCacheIncrease?: boolean
|
|
}
|
|
const POTENTIAL_CHANGES: Record<string, Change> = {
|
|
'RSC change': {
|
|
checkInitial: makeTextCheck('/', 'hello world'),
|
|
withChange: makeFileEdit('app/page.tsx'),
|
|
checkChanged: makeTextCheck('/', 'hello filesystem cache'),
|
|
},
|
|
'RCC change': {
|
|
checkInitial: makeTextCheck('/client', 'hello world'),
|
|
withChange: makeFileEdit('app/client/page.tsx'),
|
|
checkChanged: makeTextCheck('/client', 'hello filesystem cache'),
|
|
},
|
|
'Pages change': {
|
|
checkInitial: makeTextCheck('/pages', 'hello world'),
|
|
withChange: makeFileEdit('pages/pages.tsx'),
|
|
checkChanged: makeTextCheck('/pages', 'hello filesystem cache'),
|
|
},
|
|
'rename app page': {
|
|
checkInitial: makeTextCheck('/remove-me', 'hello world'),
|
|
async withChange(inner) {
|
|
await next.renameFolder('app/remove-me', 'app/add-me')
|
|
try {
|
|
await inner()
|
|
} finally {
|
|
await next.renameFolder('app/add-me', 'app/remove-me')
|
|
}
|
|
},
|
|
checkChanged: makeTextCheck('/add-me', 'hello world'),
|
|
largeCacheIncrease: true,
|
|
},
|
|
// TODO fix this case with Turbopack
|
|
...(isTurbopack
|
|
? {}
|
|
: {
|
|
'loader change': {
|
|
async checkInitial() {
|
|
await textCheck('/loader', 'hello world')
|
|
await textCheck('/loader/client', 'hello world')
|
|
},
|
|
withChange: makeFileEdit('my-loader.js'),
|
|
async checkChanged() {
|
|
await textCheck('/loader', 'hello filesystem cache')
|
|
await textCheck('/loader/client', 'hello filesystem cache')
|
|
},
|
|
fullInvalidation: !isTurbopack,
|
|
},
|
|
}),
|
|
'next config change': {
|
|
async checkInitial() {
|
|
await textCheck('/next-config', 'hello world')
|
|
await textCheck('/next-config/client', 'hello world')
|
|
},
|
|
withChange: makeFileEdit('next.config.js'),
|
|
async checkChanged() {
|
|
await textCheck('/next-config', 'hello filesystem cache')
|
|
await textCheck('/next-config/client', 'hello filesystem cache')
|
|
},
|
|
fullInvalidation: !isTurbopack,
|
|
},
|
|
'env var change': {
|
|
async checkInitial() {
|
|
await textCheck('/env', 'hello world')
|
|
await textCheck('/env/client', 'hello world')
|
|
},
|
|
async withChange(inner) {
|
|
process.env.NEXT_PUBLIC_ENV_VAR = 'hello filesystem cache'
|
|
try {
|
|
await inner()
|
|
} finally {
|
|
process.env.NEXT_PUBLIC_ENV_VAR = 'hello world'
|
|
}
|
|
},
|
|
async checkChanged() {
|
|
await textCheck('/env', 'hello filesystem cache')
|
|
await textCheck('/env/client', 'hello filesystem cache')
|
|
},
|
|
},
|
|
} as const
|
|
|
|
// Checking only single change and all combined for performance reasons.
|
|
const combinations = Object.entries(POTENTIAL_CHANGES).map(([k, v]) => [
|
|
k,
|
|
[v],
|
|
]) as Array<[string, Array<Change>]>
|
|
combinations.push([
|
|
Object.keys(POTENTIAL_CHANGES).join(', '),
|
|
Object.values(POTENTIAL_CHANGES),
|
|
])
|
|
|
|
for (const [name, changes] of combinations) {
|
|
// Very flakey with Webpack enabled
|
|
;(process.env.IS_TURBOPACK_TEST ? it : it.skip)(
|
|
`should allow to change files while stopped (${name})`,
|
|
async () => {
|
|
let fullInvalidation = !cacheEnabled
|
|
let largeCacheIncrease = false
|
|
for (const change of changes) {
|
|
await change.checkInitial()
|
|
if (change.fullInvalidation) {
|
|
fullInvalidation = true
|
|
}
|
|
if (change.largeCacheIncrease) {
|
|
largeCacheIncrease = true
|
|
}
|
|
}
|
|
|
|
let unchangedTimestamp: string
|
|
if (!fullInvalidation) {
|
|
const browser = await next.browser('/unchanged')
|
|
unchangedTimestamp = await browser.elementByCss('main').text()
|
|
expect(unchangedTimestamp).toMatch(/Timestamp = \d+$/)
|
|
await browser.close()
|
|
}
|
|
|
|
async function checkChanged() {
|
|
for (const change of changes) {
|
|
await change.checkChanged()
|
|
}
|
|
|
|
if (!fullInvalidation) {
|
|
const browser = await next.browser('/unchanged')
|
|
const timestamp = await browser.elementByCss('main').text()
|
|
expect(unchangedTimestamp).toEqual(timestamp)
|
|
await browser.close()
|
|
}
|
|
}
|
|
|
|
await stop()
|
|
const initialCacheSize = await getCacheSize()
|
|
|
|
async function inner() {
|
|
await start()
|
|
await checkChanged()
|
|
// Some no-op change builds
|
|
for (let i = 0; i < 2; i++) {
|
|
await restartCycle()
|
|
await checkChanged()
|
|
}
|
|
await stop()
|
|
}
|
|
|
|
let current = inner
|
|
for (const change of changes) {
|
|
const prev = current
|
|
current = () => change.withChange(prev)
|
|
}
|
|
await current()
|
|
|
|
if (cacheEnabled) {
|
|
const finalCacheSize = await getCacheSize()
|
|
const maxIncrease = largeCacheIncrease ? 1.5 : 1.1
|
|
const increasePercent = (
|
|
(finalCacheSize / initialCacheSize - 1) *
|
|
100
|
|
).toFixed(2)
|
|
console.log(
|
|
`Cache size (${name}): ${(initialCacheSize / 1024 / 1024).toFixed(2)} MB -> ${(finalCacheSize / 1024 / 1024).toFixed(2)} MB (${increasePercent}%)`
|
|
)
|
|
expect(finalCacheSize).toBeLessThanOrEqual(
|
|
initialCacheSize * maxIncrease
|
|
)
|
|
}
|
|
|
|
await start()
|
|
for (const change of changes) {
|
|
await change.checkInitial()
|
|
}
|
|
|
|
if (!fullInvalidation) {
|
|
const browser = await next.browser('/unchanged')
|
|
const timestamp = await browser.elementByCss('main').text()
|
|
expect(unchangedTimestamp).toEqual(timestamp)
|
|
await browser.close()
|
|
}
|
|
},
|
|
200000
|
|
)
|
|
}
|
|
|
|
if (cacheEnabled) {
|
|
;(process.env.IS_TURBOPACK_TEST ? it : it.skip)(
|
|
'should emit turbopack-persistence trace spans',
|
|
async () => {
|
|
// Trigger a page load so persistence has data to write
|
|
const browser = await next.browser('/')
|
|
expect(await browser.elementByCss('main').text()).toMatch(
|
|
/Timestamp = \d+$/
|
|
)
|
|
await browser.close()
|
|
|
|
// Wait for persistence to write to disk
|
|
await stop()
|
|
|
|
const traceDir = isNextDev ? '.next/dev' : '.next'
|
|
const tracePath = path.join(next.testDir, traceDir, 'trace')
|
|
expect(existsSync(tracePath)).toBe(true)
|
|
|
|
const events = parseTraceEvents(tracePath)
|
|
const persistenceEvents = events.filter(
|
|
(e) => e.name === 'turbopack-persistence'
|
|
)
|
|
|
|
expect(persistenceEvents.length).toBeGreaterThan(0)
|
|
|
|
// Verify the persistence event has the expected attributes
|
|
const event = persistenceEvents[0]
|
|
expect(event.duration).toBeGreaterThanOrEqual(0)
|
|
expect(event.tags).toBeDefined()
|
|
const tags = event.tags as Record<string, unknown>
|
|
expect(tags.reason).toBeDefined()
|
|
expect(typeof tags.snapshot_duration_ms).toBe('number')
|
|
expect(typeof tags.persist_duration_ms).toBe('number')
|
|
expect(typeof tags.task_count).toBe('number')
|
|
}
|
|
)
|
|
}
|
|
})
|
|
}
|