Files
vercel__next.js/test/e2e/filesystem-cache/filesystem-cache.test.ts
Tobias Koppers 357b2b3c4c Use pnpm for isolated test installs (#98425)
### 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>
2026-09-15 20:19:48 +00:00

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')
}
)
}
})
}