next.js/test/development/app-dir/cache-components-dev-warmup/cache-components.dev-warmup.test.ts
cache-components.dev-warmup.test.ts366 lines13.3 KB
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<void>
    ) {
      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 <path>
        //
        // 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<void>
    ) {
      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 <path>
        //
        // 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)
          }
        })
      }
    })
  }
)
Quest for Codev2.0.0
/
SIGN IN