next.js/test/e2e/filesystem-cache/filesystem-cache.test.ts
filesystem-cache.test.ts449 lines14.9 KB
/* 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'

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]) {
  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<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')
        }
      )
    }
  })
}
Quest for Codev2.0.0
/
SIGN IN