import { nextTestSetup } from 'e2e-utils' import { retry } from 'next-test-utils' import * as nodePath from 'node:path' import type { Playwright } from '../../../lib/next-webdriver' describe.each([ { description: 'without runtime prefetch configs', hasRuntimePrefetch: false, fixturePath: 'fixtures/without-prefetch-config', }, { description: 'with runtime prefetch configs', hasRuntimePrefetch: true, fixturePath: 'fixtures/with-prefetch-config', }, ])( 'cache-components-dev-warmup - $description', ({ fixturePath, hasRuntimePrefetch }) => { const { next, isTurbopack } = nextTestSetup({ files: nodePath.join(__dirname, fixturePath), }) // Restart the dev server for each test to clear the in-memory cache. // We're testing cache-warming behavior here, so we don't want tests to interfere with each other. let isFirstTest = true beforeEach(async () => { if (isFirstTest) { // There's no point restarting if this is the first test. isFirstTest = false return } await next.stop() await next.clean() await next.start() }) function assertLog( logs: Array<{ source: string; message: string }>, message: string, expectedEnvironment: string ) { // Match logs that contain the message, with any environment. const logPattern = new RegExp( `^(?=.*\\b${message}\\b)(?=.*\\b(Cache|Prerender|Prefetch|Prefetchable|Server)\\b).*` ) const logMessages = logs.map((log) => log.message) const messages = logMessages.filter((message) => logPattern.test(message)) // If there's zero or more than one logs that match, the test is not set up correctly. if (messages.length === 0) { throw new Error( `Found no logs matching '${message}':\n\n${logMessages.map((s, i) => `${i}. ${s}`).join('\n')}}` ) } if (messages.length > 1) { throw new Error( `Found multiple logs matching '${message}':\n\n${messages.map((s, i) => `${i}. ${s}`).join('\n')}` ) } // The message should have the expected environment. const actualMessageText = messages[0] const [, actualEnvironment] = actualMessageText.match(logPattern)! expect([actualEnvironment, actualMessageText]).toEqual([ expectedEnvironment, expect.stringContaining(message), ]) } async function testInitialLoad( path: string, assertLogs: (browser: Playwright) => Promise ) { const browser = await next.browser(path) // Initial load. await retry(() => assertLogs(browser)) // We should not see any errors related to the aborted render. expect(next.cliOutput).not.toContain( 'AbortError: This operation was aborted' ) // After another load (with warm caches) the logs should be the same. await browser.loadPage(next.url + path) // clears old logs await retry(() => assertLogs(browser)) expect(next.cliOutput).not.toContain( 'AbortError: This operation was aborted' ) if (isTurbopack) { // FIXME: // In Turbopack, requests to the /revalidate route seem to occasionally crash // due to some HMR or compilation issue. `revalidatePath` throws this error: // // Invariant: static generation store missing in revalidatePath // // This is unrelated to the logic being tested here, so for now, we skip the assertions // that require us to revalidate. console.log('WARNING: skipping revalidation assertions in turbopack') return } // After a revalidation the subsequent warmup render must discard stale // cache entries. // This should not affect the environment labels. await revalidatePath(path) await browser.loadPage(next.url + path) // clears old logs await retry(() => assertLogs(browser)) // We should not see any errors related to the aborted render. expect(next.cliOutput).not.toContain( 'AbortError: This operation was aborted' ) } async function testNavigation( path: string, assertLogs: (browser: Playwright) => Promise ) { const browser = await next.browser('/') // Initial nav (first time loading the page) await browser.elementByCss(`a[href="${path}"]`).click() await retry(() => assertLogs(browser)) // We should not see any errors related to the aborted render. expect(next.cliOutput).not.toContain( 'AbortError: This operation was aborted' ) // Reload, and perform another nav (with warm caches). the logs should be the same. await browser.loadPage(next.url + '/') // clears old logs await browser.elementByCss(`a[href="${path}"]`).click() await retry(() => assertLogs(browser)) expect(next.cliOutput).not.toContain( 'AbortError: This operation was aborted' ) if (isTurbopack) { // FIXME: // In Turbopack, requests to the /revalidate route seem to occasionally crash // due to some HMR or compilation issue. `revalidatePath` throws this error: // // Invariant: static generation store missing in revalidatePath // // This is unrelated to the logic being tested here, so for now, we skip the assertions // that require us to revalidate. console.log('WARNING: skipping revalidation assertions in turbopack') return } // After a revalidation the subsequent warmup render must discard stale // cache entries. // This should not affect the environment labels. await revalidatePath(path) await browser.loadPage(next.url + '/') // clears old logs await browser.elementByCss(`a[href="${path}"]`).click() await retry(() => assertLogs(browser)) expect(next.cliOutput).not.toContain( 'AbortError: This operation was aborted' ) } async function revalidatePath(path: string) { const response = await next.fetch( `/revalidate?path=${encodeURIComponent(path)}` ) if (!response.ok) { throw new Error( `Failed to revalidate path: '${path}' - server responded with status ${response.status}` ) } } const RUNTIME_ENV = hasRuntimePrefetch ? 'Prefetch' : 'Prefetchable' describe.each([ { description: 'initial load', isInitialLoad: true }, { description: 'navigation', isInitialLoad: false }, ])('$description', ({ isInitialLoad }) => { describe('cached data resolves in the correct phase', () => { it('cached data + cached fetch', async () => { const path = '/simple' const assertLogs = async (browser: Playwright) => { const logs = await browser.log() assertLog(logs, 'after cache read - layout', 'Prerender') assertLog(logs, 'after cache read - page', 'Prerender') assertLog(logs, 'after successive cache reads - page', 'Prerender') assertLog(logs, 'after cached fetch - layout', 'Prerender') assertLog(logs, 'after cached fetch - page', 'Prerender') assertLog(logs, 'after uncached fetch - layout', 'Server') assertLog(logs, 'after uncached fetch - page', 'Server') } if (isInitialLoad) { await testInitialLoad(path, assertLogs) } else { await testNavigation(path, assertLogs) } }) it('cached data + private cache', async () => { const path = '/private-cache' const assertLogs = async (browser: Playwright) => { const logs = await browser.log() assertLog(logs, 'after cache read - layout', 'Prerender') assertLog(logs, 'after cache read - page', 'Prerender') // Private caches are dynamic holes in static prerenders, // so they shouldn't resolve in the static stage. assertLog(logs, 'after private cache read - page', RUNTIME_ENV) assertLog(logs, 'after private cache read - layout', RUNTIME_ENV) assertLog( logs, 'after successive private cache reads - page', RUNTIME_ENV ) assertLog(logs, 'after uncached fetch - layout', 'Server') assertLog(logs, 'after uncached fetch - page', 'Server') } if (isInitialLoad) { await testInitialLoad(path, assertLogs) } else { await testNavigation(path, assertLogs) } }) it('cached data + short-lived cached data', async () => { const path = '/short-lived-cache' const assertLogs = async (browser: Playwright) => { const logs = await browser.log() assertLog(logs, 'after cache read - layout', 'Prerender') assertLog(logs, 'after cache read - page', 'Prerender') // Short lived caches are dynamic holes in static prerenders, // so they shouldn't resolve in the static stage. assertLog(logs, 'after short-lived cache read - page', RUNTIME_ENV) assertLog( logs, 'after short-lived cache read - layout', RUNTIME_ENV ) assertLog(logs, 'after uncached fetch - layout', 'Server') assertLog(logs, 'after uncached fetch - page', 'Server') } if (isInitialLoad) { await testInitialLoad(path, assertLogs) } else { await testNavigation(path, assertLogs) } }) it('cache reads that reveal more components with more caches', async () => { const path = '/successive-caches' const assertLogs = async (browser: Playwright) => { const logs = await browser.log() // No matter how deeply we nest the component tree, // if all the IO is cached, it should be labeled as Prerender. assertLog(logs, 'after cache 1', 'Prerender') assertLog(logs, 'after cache 2', 'Prerender') assertLog(logs, 'after caches 1 and 2', 'Prerender') assertLog(logs, 'after cache 3', 'Prerender') } if (isInitialLoad) { await testInitialLoad(path, assertLogs) } else { await testNavigation(path, assertLogs) } }) }) it('request APIs resolve in the correct phase', async () => { const path = '/apis/123' const assertLogs = async (browser: Playwright) => { const logs = await browser.log() assertLog(logs, 'after cache read - page', 'Prerender') // TODO: we should only label this as "Prefetch" if there's a prefetch config. assertLog(logs, `after cookies`, RUNTIME_ENV) assertLog(logs, `after headers`, RUNTIME_ENV) assertLog(logs, `after params`, RUNTIME_ENV) assertLog(logs, `after searchParams`, RUNTIME_ENV) assertLog(logs, 'after connection', 'Server') } if (isInitialLoad) { await testInitialLoad(path, assertLogs) } else { await testNavigation(path, assertLogs) } }) // FIXME: it seems like in Turbopack we sometimes get two instances of `workUnitAsyncStorage` -- // `app-render` gets a second, newer instance, different from `io()`. // Thus, `io()` gets an undefined `workUnitStore` and does nothing, so sync IO does not get tracked at all. // This is likely caused by the same bug that breaks `/revalidate` (see other FIXME above), // where a route crashes due to a missing `workStore`. if (!isTurbopack) { it('sync IO in the static phase', async () => { const path = '/sync-io/static' const assertLogs = async (browser: Playwright) => { const logs = await browser.log() assertLog(logs, 'after first cache', 'Prerender') // sync IO in the static stage errors and advances to Server. assertLog(logs, 'after sync io', 'Server') assertLog(logs, 'after cache read - page', 'Server') } if (isInitialLoad) { await testInitialLoad(path, assertLogs) } else { await testNavigation(path, assertLogs) } }) it('sync IO in the runtime phase', async () => { const path = '/sync-io/runtime' const assertLogs = async (browser: Playwright) => { const logs = await browser.log() assertLog(logs, 'after first cache', 'Prerender') assertLog(logs, 'after cookies', RUNTIME_ENV) if (hasRuntimePrefetch) { // if runtime prefetching is on, sync IO in the runtime stage errors and advances to Server. assertLog(logs, 'after sync io', 'Server') assertLog(logs, 'after cache read - page', 'Server') } else { // if runtime prefetching is not on, sync IO in the runtime stage does nothing. assertLog(logs, 'after sync io', RUNTIME_ENV) assertLog(logs, 'after cache read - page', RUNTIME_ENV) } } if (isInitialLoad) { await testInitialLoad(path, assertLogs) } else { await testNavigation(path, assertLogs) } }) } }) } )