next.js/test/e2e/app-dir/segment-cache/deployment-skew/deployment-skew.test.ts
deployment-skew.test.ts225 lines7.4 KB
import type * as Playwright from 'playwright'
import webdriver from 'next-webdriver'
import { createRouterAct } from 'router-act'
import { findPort, retry } from 'next-test-utils'
import { isNextDeploy, isNextDev, isNextStart, nextTestSetup } from 'e2e-utils'
import { build, start } from './servers.mjs'

describe('segment cache (deployment skew)', () => {
  if (isNextDev) {
    test('should not run during dev', () => {})
    return
  }

  // To debug these tests locally, first build the app:
  //   node build.mjs
  //
  // Then start:
  //   node start.mjs
  //
  // This will build two versions of the same app on different ports, then
  // start a proxy server that rewrites incoming requests to one or the other
  // based on the request information.

  if (isNextStart) {
    let cleanup: () => Promise<void>
    let port: number

    describe('with BUILD_ID', () => {
      beforeAll(async () => {
        build('BUILD_ID')
        const proxyPort = (port = await findPort())
        const nextPort1 = await findPort()
        const nextPort2 = await findPort()
        cleanup = await start(proxyPort, nextPort1, nextPort2, 'BUILD_ID')
      })

      afterAll(async () => {
        await cleanup()
      })

      runTests('BUILD_ID', () => port)
    })

    describe('with NEXT_DEPLOYMENT_ID', () => {
      beforeAll(async () => {
        build('DEPLOYMENT_ID')
        const proxyPort = (port = await findPort())
        const nextPort1 = await findPort()
        const nextPort2 = await findPort()
        cleanup = await start(proxyPort, nextPort1, nextPort2, 'DEPLOYMENT_ID')
      })

      afterAll(async () => {
        await cleanup()
      })

      runTests('DEPLOYMENT_ID', () => port)
    })
  }

  describe('header with deployment id', () => {
    const { next } = nextTestSetup({
      files: __dirname,
      env: {
        // rely on skew protection when deployed
        NEXT_DEPLOYMENT_ID: isNextDeploy ? undefined : 'test-deployment-id',
      },
      disableAutoSkewProtection: true,
    })

    // Deployment skew is hard to properly e2e deploy test, so this just checks for the header.
    it('header is set on RSC responses', async () => {
      for (const route of ['/dynamic-page', '/static-page']) {
        await next.fetch(route)
        let res = await next.fetch(`${route}?_rsc=`, {
          headers: { rsc: '1' },
        })

        expect(res.status).toBe(200)
        expect(res.headers.get('content-type')).toBe('text/x-component')
        expect(res.headers.get('x-nextjs-deployment-id')).toBeTruthy()
      }
    })
  })
})

function runTests(mode: 'BUILD_ID' | 'DEPLOYMENT_ID', getPort: () => number) {
  it(
    'does not crash when prefetching a dynamic, non-PPR page ' +
      'on a different deployment',
    async () => {
      // Reproduces a bug that occurred when prefetching a dynamic page
      // from a different deployment, when PPR is disabled. Once PPR is the
      // default, it's OK to rewrite this to use the latest APIs.
      let act
      const browser = await webdriver(getPort(), '/', {
        beforePageLoad(p: Playwright.Page) {
          act = createRouterAct(p)
        },
      })

      // Initiate a prefetch of link to a different deployment
      await act(async () => {
        const checkbox = await browser.elementByCss(
          '[data-link-accordion="/dynamic-page?deployment=2"]'
        )
        await checkbox.click()
      })

      // Navigate to the target page
      const link = await browser.elementByCss(
        'a[href="/dynamic-page?deployment=2"]'
      )
      await link.click()

      // Should have performed a full-page navigation to the new deployment.
      const buildId = await browser.elementById('build-id')
      expect(await buildId.text()).toBe('Build ID: 2')
    },
    60 * 1000
  )

  it(
    'does not crash when prefetching a static page on a different deployment',
    async () => {
      // Same as the previous test, but for a static page
      let act
      const browser = await webdriver(getPort(), '/', {
        beforePageLoad(p: Playwright.Page) {
          act = createRouterAct(p)
        },
      })

      // Initiate a prefetch of link to a different deployment
      await act(async () => {
        const checkbox = await browser.elementByCss(
          '[data-link-accordion="/static-page?deployment=2"]'
        )
        await checkbox.click()
      })

      // Navigate to the target page
      const link = await browser.elementByCss(
        'a[href="/static-page?deployment=2"]'
      )
      await link.click()

      // Should have performed a full-page navigation to the new deployment.
      const buildId = await browser.elementById('build-id')
      expect(await buildId.text()).toBe('Build ID: 2')
    },
    60 * 1000
  )

  it(
    'triggers MPA navigation when a server action redirects to a different deployment',
    async () => {
      // Verify that when a server action calls redirect() and the redirect
      // target is served by a different deployment (different build ID), the
      // client falls back to an MPA navigation instead of attempting to apply
      // the foreign RSC payload.
      const browser = await webdriver(getPort(), '/')
      await browser.eval('window.next.router.push("/action-redirect")')
      await browser.waitForElementByCss('#action-page')
      let sawActionRequest = false
      let sawRedirectActionResponse = false
      let actionResponseDeploymentId: string | undefined
      browser.on('request', (request: Playwright.Request) => {
        const headers = request.headers()
        if (request.method() === 'POST' && headers['next-action']) {
          sawActionRequest = true
        }
      })
      browser.on('response', async (response: Playwright.Response) => {
        const request = response.request()
        if (request.method() !== 'POST') {
          return
        }

        const headers = response.headers()
        if (headers['x-action-redirect']) {
          sawRedirectActionResponse = true
          actionResponseDeploymentId = headers['x-nextjs-deployment-id']
        }
      })

      // Verify we're on the action redirect page
      const heading = await browser.elementById('action-page')
      expect(await heading.text()).toBe('Action Redirect Page')

      // Click the button that triggers the server action redirect.
      // In deployment ID mode, the proxy injects a foreign
      // x-nextjs-deployment-id header to simulate skew. In build ID mode,
      // the response omits that header so the client falls back to the
      // build ID carried in the action Flight payload.
      const button = await browser.elementById('redirect-action-button')
      await button.click()

      await retry(async () => {
        expect(sawActionRequest).toBe(true)
      })

      await retry(async () => {
        expect(sawRedirectActionResponse).toBe(true)
      })

      if (mode === 'DEPLOYMENT_ID') {
        expect(actionResponseDeploymentId).toBe('foreign-deployment')
      } else {
        expect(actionResponseDeploymentId).toBeUndefined()
      }

      // Wait for the navigation to complete.
      // The client detects the mismatch in either the response header or
      // the fallback build ID field and discards the flight data,
      // triggering an MPA navigation (full page load) to the redirect
      // target. The redirect URL (/dynamic-page?deployment=2) goes through
      // the proxy to deployment 2.
      const buildId = await browser.waitForElementByCss('#build-id')
      expect(await buildId.text()).toBe('Build ID: 2')
    },
    60 * 1000
  )
}
Quest for Codev2.0.0
/
SIGN IN