/* eslint-disable jest/no-standalone-expect */ import { nextTestSetup, isNextDev } from 'e2e-utils' import { waitFor } from 'next-test-utils' import fs from 'fs/promises' import { readFileSync, existsSync } from 'fs' import path from 'path' import type { TraceEvent } from 'next/dist/trace' function parseTraceFile(tracePath: string): TraceEvent[] { const traceContent = readFileSync(tracePath, 'utf8') const traceLines = traceContent .trim() .split('\n') .filter((line) => line.trim()) const allEvents: TraceEvent[] = [] for (const line of traceLines) { const events = JSON.parse(line) as TraceEvent[] allEvents.push(...events) } return allEvents } async function getDirectorySize(dirPath: string): Promise { 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]) { 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 }) let envVars = [ `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`, ].join(' ') const { skipped, next, isTurbopack } = nextTestSetup({ files: __dirname, skipDeployment: true, packageJson: { scripts: { build: `${envVars} next build`, dev: `${envVars} next dev`, start: 'next start', }, }, // We need to use npm here as pnpms symlinks trigger a weird bug (kernel bug?) installCommand: 'npm i', // Next is always started with caching, but this can disable it for the followup restarts buildCommand: `npm run build`, startCommand: isNextDev ? 'npm run dev' : 'npm run start', }) if (skipped) { return } 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 { 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 { 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) => { await next.patchFile( file, (content) => { return content.replace('hello world', 'hello filesystem cache') }, inner ) } } interface Change { checkInitial(): Promise withChange(previous: () => Promise): Promise checkChanged(): Promise fullInvalidation?: boolean largeCacheIncrease?: boolean } const POTENTIAL_CHANGES: Record = { '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]> 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 = parseTraceFile(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 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') } ) } }) }