next.js/test/lib/e2e-utils/instant-validation.ts
instant-validation.ts158 lines4.9 KB
import { retry } from '../next-test-utils'
import { getDeterministicOutput } from '../../e2e/app-dir/cache-components-errors/utils'

export type ValidationEvent =
  | { type: 'validation_start'; requestId: string; url: string }
  | { type: 'validation_end'; requestId: string; url: string }

export function parseValidationMessages(output: string): ValidationEvent[] {
  const messageRe = /<VALIDATION_MESSAGE>(.*?)<\/VALIDATION_MESSAGE>/g
  const events: ValidationEvent[] = []
  let match: RegExpExecArray | null
  while ((match = messageRe.exec(output)) !== null) {
    try {
      events.push(JSON.parse(match[1]))
    } catch (err) {
      throw new Error(`Failed to parse message '${match[1]}'`, {
        cause: err,
      })
    }
  }
  return events
}

export function extractBuildValidationError(
  cliOutput: string,
  { isMinified = true } = {}
): string {
  const markerRe = /<VALIDATION_MESSAGE>(.*?)<\/VALIDATION_MESSAGE>/g

  // Find all marker positions and their content
  const markers: {
    index: number
    endIndex: number
    data: ValidationEvent
  }[] = []
  let m: RegExpExecArray | null
  while ((m = markerRe.exec(cliOutput)) !== null) {
    // JSON.parse must succeed — if it throws, let the error propagate
    const data: ValidationEvent = JSON.parse(m[1])
    markers.push({
      index: m.index,
      endIndex: m.index + m[0].length,
      data,
    })
  }

  // Expect exactly two markers: one validation_start and one validation_end
  if (markers.length !== 2) {
    throw new Error(
      `Expected exactly 2 validation markers, found ${markers.length}.\n` +
        `CLI output:\n${cliOutput}`
    )
  }

  const [start, end] = markers
  if (
    start.data.type !== 'validation_start' ||
    end.data.type !== 'validation_end'
  ) {
    throw new Error(
      `Expected [validation_start, validation_end] markers, got [${start.data.type}, ${end.data.type}].\n` +
        `CLI output:\n${cliOutput}`
    )
  }
  if (start.data.requestId !== end.data.requestId) {
    throw new Error(
      `Expected [validation_start, validation_end] markers to come from the same request`
    )
  }

  const output = cliOutput.slice(start.endIndex, end.index).trim()
  return getDeterministicOutput(output, { isMinified })
}

export function normalizeValidationUrl(url: string): string {
  // RSC requests include ?_rsc=... in the URL. Strip it so the event URL
  // matches what browser.url() returns (which has no _rsc param).
  const parsed = new URL(url, 'http://n')
  parsed.searchParams.delete('_rsc')
  return parsed.pathname + parsed.search + parsed.hash
}

export async function waitForValidationStart(
  targetUrl: string,
  getOutput: () => string
): Promise<string> {
  const parsedTargetUrl = new URL(targetUrl)
  const relativeTargetUrl =
    parsedTargetUrl.pathname + parsedTargetUrl.search + parsedTargetUrl.hash

  const requestId = await retry(
    async () => {
      const events = parseValidationMessages(getOutput())
      const start = events.find(
        (e) =>
          e.type === 'validation_start' &&
          normalizeValidationUrl(e.url) === relativeTargetUrl
      )
      expect(start).toBeDefined()
      return start!.requestId
    },
    undefined,
    undefined,
    `wait for validation of '${relativeTargetUrl}' to start`
  )
  return requestId
}

export async function waitForValidationEnd(
  requestId: string,
  getOutput: () => string
): Promise<void> {
  await retry(
    async () => {
      const events = parseValidationMessages(getOutput())
      const end = events.find(
        (e) => e.type === 'validation_end' && e.requestId === requestId
      )
      expect(end).toBeDefined()
    },
    undefined,
    undefined,
    'wait for validation to end'
  )
}

export async function waitForValidation(
  url: string,
  getOutput: () => string
): Promise<void> {
  const requestId = await waitForValidationStart(url, getOutput)
  await waitForValidationEnd(requestId, getOutput)
}

type PrerenderResult = {
  cliOutput: string
  exitCode: number | NodeJS.Signals
}

export function expectNoBuildValidationErrors(result: PrerenderResult) {
  // Check the logs before checking the error code.
  // If it fails, the logs are more likely to show a useful reason than an error code.
  expect(result.cliOutput).not.toContain('Build-time instant validation failed')
  // As a sanity check, parse the log and make sure that instant validation actually ran.
  expect(extractBuildValidationError(result.cliOutput)).not.toContain(
    'Build-time instant validation failed'
  )
  expect(result.exitCode).toBe(0)
}

export function expectBuildValidationSkipped(result: PrerenderResult) {
  // Check the logs before checking the error code.
  // If it fails, the logs are more likely to show a useful reason than an error code.
  expect(result.cliOutput).not.toContain('Build-time instant validation failed')
  expect(parseValidationMessages(result.cliOutput)).toHaveLength(0)
  expect(result.exitCode).toBe(0)
}
Quest for Codev2.0.0
/
SIGN IN