Files
next.js/test/lib/next-test-utils.ts
Arian Tron 61f56f997c
Some checks failed
Test examples / Test Examples (20) (push) Has been cancelled
Test examples / Test Examples (22) (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Trigger Release / start (push) Has been cancelled
Stale issue handler / stale (push) Has been cancelled
Update Font Data / create-pull-request (push) Has been cancelled
build-and-deploy / deploy-target (push) Has been cancelled
build-and-deploy / build (push) Has been cancelled
build-and-deploy / stable - aarch64-unknown-linux-musl - node@16 (push) Has been cancelled
build-and-deploy / stable - x86_64-unknown-linux-musl - node@16 (push) Has been cancelled
build-and-deploy / stable - aarch64-unknown-linux-gnu - node@16 (push) Has been cancelled
build-and-deploy / stable - x86_64-unknown-linux-gnu - node@16 (push) Has been cancelled
build-and-deploy / stable - aarch64-pc-windows-msvc - node@16 (push) Has been cancelled
build-and-deploy / stable - x86_64-pc-windows-msvc - node@16 (push) Has been cancelled
build-and-deploy / stable - aarch64-apple-darwin - node@16 (push) Has been cancelled
build-and-deploy / stable - x86_64-apple-darwin - node@16 (push) Has been cancelled
build-and-deploy / build-wasm (nodejs) (push) Has been cancelled
build-and-deploy / build-wasm (web) (push) Has been cancelled
build-and-deploy / Deploy preview tarball (push) Has been cancelled
build-and-deploy / Potentially publish release (push) Has been cancelled
build-and-deploy / publish-turbopack-npm-packages (push) Has been cancelled
build-and-deploy / Deploy examples (push) Has been cancelled
build-and-deploy / thank you, build (push) Has been cancelled
build-and-deploy / Upload Turbopack Bytesize metrics to Datadog (push) Has been cancelled
Rspack Next.js development integration tests / Rspack integration tests (push) Has been cancelled
Rspack Next.js production integration tests / Rspack integration tests (push) Has been cancelled
Turbopack Next.js development integration tests / Next.js integration tests (push) Has been cancelled
Turbopack Next.js production integration tests / Next.js integration tests (push) Has been cancelled
Update Rspack test manifest / Update and upload Rspack development test manifest (push) Has been cancelled
Update Rspack test manifest / Update and upload Rspack production test manifest (push) Has been cancelled
Upload bundler test manifests to areweturboyet.com / Upload test results (push) Has been cancelled
Update React / create-pull-request (push) Has been cancelled
test-e2e-project-reset-cron / reset-test-project (push) Has been cancelled
Notify about the top 15 issues/PRs/feature requests (most reacted) in the last 90 days / run (push) Has been cancelled
first commit
2026-03-10 19:37:31 +03:30

2106 lines
59 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import express from 'express'
import {
existsSync,
readFileSync,
unlinkSync,
writeFileSync,
createReadStream,
} from 'fs'
import { inspect, promisify } from 'util'
import http from 'http'
import path from 'path'
import type cheerio from 'cheerio'
import spawn from 'cross-spawn'
import { writeFile } from 'fs-extra'
import getPort from 'get-port'
import { getRandomPort } from 'get-port-please'
import fetch from 'node-fetch'
import { nanoid } from 'nanoid'
import qs from 'querystring'
import treeKill from 'tree-kill'
import { once } from 'events'
import server from 'next/dist/server/next'
import _pkg from 'next/package.json'
import type { SpawnOptions, ChildProcess } from 'child_process'
import type { RequestInit, Response } from 'node-fetch'
import type { NextServer } from 'next/dist/server/next'
import { Playwright } from 'next-webdriver'
import { shouldUseTurbopack } from './turbo'
import stripAnsi from 'strip-ansi'
import escapeRegex from 'escape-string-regexp'
// TODO: Create dedicated Jest environment that sets up these matchers
// Edge Runtime unit tests fail with "EvalError: Code generation from strings disallowed for this context" if these matchers are imported in those tests.
import './add-redbox-matchers'
import { NextInstance } from 'e2e-utils'
import { ClientReferenceManifest } from 'next/dist/build/webpack/plugins/flight-manifest-plugin'
export { shouldUseTurbopack }
export const nextServer = server
export const pkg = _pkg
// This goes straight to Nodes stdout, avoiding Jest's verbose output:
export const debugPrint = (...args: unknown[]) => {
const prettyArgs = args
.map((arg) =>
typeof arg === 'string'
? arg
: inspect(arg, { colors: process.stdout.isTTY })
)
.join(' ')
const timestamp = new Date().toISOString().split('T')[1]
return process.stdout.write(`[${timestamp}] ${prettyArgs}\n`)
}
export function initNextServerScript(
scriptPath: string,
successRegexp: RegExp,
env: NodeJS.ProcessEnv,
failRegexp?: RegExp,
opts?: {
cwd?: string
nodeArgs?: string[]
onStdout?: (data: any) => void
onStderr?: (data: any) => void
// If true, the promise will reject if the process exits with a non-zero code
shouldRejectOnError?: boolean
}
): Promise<ChildProcess> {
return new Promise((resolve, reject) => {
const instance = spawn(
'node',
[...((opts && opts.nodeArgs) || []), '--no-deprecation', scriptPath],
{
env: { HOSTNAME: '::', ...env },
cwd: opts && opts.cwd,
}
)
function handleStdout(data) {
const message = data.toString()
if (successRegexp.test(message)) {
resolve(instance)
}
process.stdout.write(message)
if (opts && opts.onStdout) {
opts.onStdout(message.toString())
}
}
function handleStderr(data) {
const message = data.toString()
if (failRegexp && failRegexp.test(message)) {
instance.kill()
return reject(new Error('received failRegexp'))
}
process.stderr.write(message)
if (opts && opts.onStderr) {
opts.onStderr(message.toString())
}
}
if (opts?.shouldRejectOnError) {
instance.on('exit', (code) => {
if (code !== 0) {
reject(new Error('exited with code: ' + code))
}
})
}
instance.stdout!.on('data', handleStdout)
instance.stderr!.on('data', handleStderr)
instance.on('close', () => {
instance.stdout!.removeListener('data', handleStdout)
instance.stderr!.removeListener('data', handleStderr)
})
instance.on('error', (err) => {
reject(err)
})
})
}
export function getFullUrl(
appPortOrUrl: string | number,
url?: string,
hostname?: string
) {
let fullUrl =
typeof appPortOrUrl === 'string' && appPortOrUrl.startsWith('http')
? appPortOrUrl
: `http://${hostname ? hostname : 'localhost'}:${appPortOrUrl}${url}`
if (typeof appPortOrUrl === 'string' && url) {
const parsedUrl = new URL(fullUrl)
const parsedPathQuery = new URL(url, fullUrl)
parsedUrl.hash = parsedPathQuery.hash
parsedUrl.search = parsedPathQuery.search
parsedUrl.pathname = parsedPathQuery.pathname
if (hostname && parsedUrl.hostname === 'localhost') {
parsedUrl.hostname = hostname
}
fullUrl = parsedUrl.toString()
}
return fullUrl
}
/**
* Appends the querystring to the url
*
* @param pathname the pathname
* @param query the query object to add to the pathname
* @returns the pathname with the query
*/
export function withQuery(
pathname: string,
query: Record<string, any> | string
) {
const querystring = typeof query === 'string' ? query : qs.stringify(query)
if (querystring.length === 0) {
return pathname
}
// If there's a `?` between the pathname and the querystring already, then
// don't add another one.
if (querystring.startsWith('?') || pathname.endsWith('?')) {
return `${pathname}${querystring}`
}
return `${pathname}?${querystring}`
}
export function getFetchUrl(
appPort: string | number,
pathname: string,
query?: Record<string, any> | string | null | undefined
) {
const url = query ? withQuery(pathname, query) : pathname
return getFullUrl(appPort, url)
}
export function fetchViaHTTP(
appPort: string | number,
pathname: string,
query?: Record<string, any> | string | null | undefined,
opts?: RequestInit
): Promise<Response> {
const url = query ? withQuery(pathname, query) : pathname
return fetch(getFullUrl(appPort, url), opts)
}
export function expectVaryHeaderToContain(
varyHeader: string | null,
expectedFields: string[]
) {
const varyFields = new Set(
(varyHeader ?? '')
.split(',')
.map((field) => field.trim().toLowerCase())
.filter(Boolean)
)
for (const expectedField of expectedFields) {
expect(varyFields.has(expectedField.toLowerCase())).toBe(true)
}
}
/**
* Creates request options with a unique x-invocation-id header for testing
* cache deduplication in minimal mode. Use this when you need to ensure each
* request is treated as independent, or when multiple requests need to share
* the same invocation ID.
*
* @example
* // Independent requests (each gets its own invocation ID)
* const res1 = await fetchViaHTTP(appPort, '/page', undefined, withInvocationId())
* const res2 = await fetchViaHTTP(appPort, '/page', undefined, withInvocationId())
*
* @example
* // Grouped requests (share the same invocation ID for cache testing)
* const sharedOpts = withInvocationId()
* const res1 = await fetchViaHTTP(appPort, '/page', undefined, sharedOpts)
* const res2 = await fetchViaHTTP(appPort, '/_next/data/.../page.json', undefined, sharedOpts)
*
* @param opts - Optional existing RequestInit to merge with
* @returns RequestInit with x-invocation-id header added
*/
export function withInvocationId(opts?: RequestInit): RequestInit {
const invocationId = `test:${nanoid()}`
return {
...opts,
headers: {
...opts?.headers,
'x-invocation-id': invocationId,
},
}
}
export function renderViaHTTP(
appPort: string | number,
pathname: string,
query?: Record<string, any> | string | undefined,
opts?: RequestInit
) {
return fetchViaHTTP(appPort, pathname, query, opts).then((res) => res.text())
}
export function findPort() {
// [NOTE] What are we doing here?
// There are some flaky tests failures caused by `No available ports found` from 'get-port'.
// This may be related / fixed by upstream https://github.com/sindresorhus/get-port/pull/56,
// however it happened after get-port switched to pure esm which is not easy to adapt by bump.
// get-port-please seems to offer the feature parity so we'll try to use it, and leave get-port as fallback
// for a while until we are certain to switch to get-port-please entirely.
try {
return getRandomPort()
} catch (e) {
require('console').warn('get-port-please failed, falling back to get-port')
return getPort()
}
}
export interface NextOptions {
cwd?: string
env?: NodeJS.Dict<string>
nodeArgs?: string[]
spawnOptions?: SpawnOptions
instance?: (instance: ChildProcess) => void
stderr?: true | 'log'
stdout?: true | 'log'
ignoreFail?: boolean
disableAutoSkewProtection?: boolean
onStdout?: (data: any) => void
onStderr?: (data: any) => void
}
export function runNextCommand(
argv: string[],
options: NextOptions = {}
): Promise<{
code: number | null
signal: NodeJS.Signals | null
stdout: string
stderr: string
}> {
const nextDir = path.dirname(require.resolve('next/package'))
const nextBin = path.join(nextDir, 'dist/bin/next')
const cwd = options.cwd || nextDir
// Let Next.js decide the environment
const env: NodeJS.ProcessEnv = {
...process.env,
// @ts-ignore packages/next/types/global.d.ts should allow undefined NODE_ENV
NODE_ENV: undefined as NodeJS.ProcessEnv['NODE_ENV'],
__NEXT_TEST_MODE: 'true',
...options.env,
}
return new Promise((resolve, reject) => {
debugPrint(`Running command "next ${argv.join(' ')}"`)
const instance = spawn(
'node',
[...(options.nodeArgs || []), '--no-deprecation', nextBin, ...argv],
{
...options.spawnOptions,
cwd,
env,
stdio: ['ignore', 'pipe', 'pipe'],
}
)
if (typeof options.instance === 'function') {
options.instance(instance)
}
let mergedStdio = ''
let stderrOutput = ''
if (options.stderr || options.onStderr) {
instance.stderr!.on('data', function (chunk) {
mergedStdio += chunk
stderrOutput += chunk
if (options.stderr === 'log') {
debugPrint(chunk.toString())
}
if (typeof options.onStderr === 'function') {
options.onStderr(chunk.toString())
}
})
} else {
instance.stderr!.on('data', function (chunk) {
mergedStdio += chunk
})
}
let stdoutOutput = ''
if (options.stdout || options.onStdout) {
instance.stdout!.on('data', function (chunk) {
mergedStdio += chunk
stdoutOutput += chunk
if (options.stdout === 'log') {
debugPrint(chunk.toString())
}
if (typeof options.onStdout === 'function') {
options.onStdout(chunk.toString())
}
})
} else {
instance.stdout!.on('data', function (chunk) {
mergedStdio += chunk
})
}
instance.on('close', (code, signal) => {
if (
!options.stderr &&
!options.stdout &&
!options.ignoreFail &&
(code !== 0 || signal)
) {
return reject(
new Error(
`command failed with code ${code} signal ${signal}\n${mergedStdio}`
)
)
}
if (code || signal) {
console.error(`process exited with code ${code} and signal ${signal}`)
}
resolve({
code,
signal,
stdout: stdoutOutput,
stderr: stderrOutput,
})
})
instance.on('error', (err) => {
err['stdout'] = stdoutOutput
err['stderr'] = stderrOutput
reject(err)
})
})
}
export interface NextDevOptions {
cwd?: string
env?: NodeJS.Dict<string>
nodeArgs?: string[]
nextBin?: string
bootupMarker?: RegExp
nextStart?: boolean
turbo?: boolean
disableAutoSkewProtection?: boolean
stderr?: false
stdout?: false
onStdout?: (data: any) => void
onStderr?: (data: any) => void
}
export function runNextCommandDev(
argv: string[],
stdOut?: boolean,
opts: NextDevOptions = {}
): Promise<(typeof stdOut extends true ? string : ChildProcess) | undefined> {
const nextDir = path.dirname(require.resolve('next/package'))
const nextBin = opts.nextBin || path.join(nextDir, 'dist/bin/next')
const cwd = opts.cwd || nextDir
const env = {
...process.env,
// @ts-ignore packages/next/types/global.d.ts should allow undefined NODE_ENV
NODE_ENV: undefined as NodeJS.ProcessEnv['NODE_ENV'],
__NEXT_TEST_MODE: 'true',
...opts.env,
}
const nodeArgs = opts.nodeArgs || []
return new Promise((resolve, reject) => {
const instance = spawn(
'node',
[...nodeArgs, '--no-deprecation', nextBin, ...argv],
{
cwd,
env,
}
)
let didResolve = false
const bootType =
opts.nextStart || stdOut ? 'start' : opts?.turbo ? 'turbo' : 'dev'
function handleStdout(data) {
const message = data.toString()
const bootupMarkers = {
dev: /✓ ready/i,
turbo: /✓ ready/i,
start: /✓ ready/i,
}
const strippedMessage = stripAnsi(message) as any
if (
(opts.bootupMarker && opts.bootupMarker.test(strippedMessage)) ||
bootupMarkers[bootType].test(strippedMessage)
) {
if (!didResolve) {
didResolve = true
// Pass down the original message
resolve(stdOut ? message : instance)
}
}
if (typeof opts.onStdout === 'function') {
opts.onStdout(message)
}
if (opts.stdout !== false) {
process.stdout.write(message)
}
}
function handleStderr(data) {
const message = data.toString()
if (typeof opts.onStderr === 'function') {
opts.onStderr(message)
}
if (opts.stderr !== false) {
process.stderr.write(message)
}
}
instance.stderr!.on('data', handleStderr)
instance.stdout!.on('data', handleStdout)
instance.on('close', () => {
instance.stderr!.removeListener('data', handleStderr)
instance.stdout!.removeListener('data', handleStdout)
if (!didResolve) {
didResolve = true
resolve(undefined)
}
})
instance.on('error', (err) => {
reject(err)
})
})
}
// Launch the app in development mode.
export function launchApp(
dir: string,
port: string | number,
opts?: NextDevOptions
) {
const options = opts ?? {}
const useTurbo = shouldUseTurbopack()
return runNextCommandDev(
[
useTurbo ? '--turbopack' : undefined,
dir,
'-p',
port as string,
'--hostname',
'::',
].filter((flag: string | undefined): flag is string => Boolean(flag)),
undefined,
{ ...options, turbo: useTurbo }
)
}
export function nextBuild(
dir: string,
args: string[] = [],
opts: NextOptions = {}
) {
if (!opts.disableAutoSkewProtection && shouldUseTurbopack() && !opts.env) {
opts.env ??= {}
opts.env.NEXT_DEPLOYMENT_ID = 'test-dpl-id-1234'
opts.env.__NEXT_IMMUTABLE_ASSET_TOKEN = 'test-immutable-tkn-7890'
}
return runNextCommand(['build', dir, ...args], opts)
}
export function nextTest(
dir: string,
args: string[] = [],
opts: NextOptions = {}
) {
return runNextCommand(['experimental-test', dir, ...args], {
...opts,
env: {
JEST_WORKER_ID: undefined, // Playwright complains about being executed by Jest
...opts.env,
},
})
}
export function nextStart(
dir: string,
port: string | number,
opts: NextDevOptions = {}
) {
if (!opts.disableAutoSkewProtection && shouldUseTurbopack() && !opts.env) {
opts.env ??= {}
opts.env.NEXT_DEPLOYMENT_ID = 'test-dpl-id-1234'
opts.env.__NEXT_IMMUTABLE_ASSET_TOKEN = 'test-immutable-tkn-7890'
}
return runNextCommandDev(
['start', '-p', port as string, '--hostname', '::', dir],
undefined,
{ ...opts, nextStart: true }
)
}
export function buildTS(
args: string[] = [],
cwd?: string,
env?: any
): Promise<void> {
cwd = cwd || path.dirname(require.resolve('next/package'))
env = { ...process.env, NODE_ENV: undefined, ...env }
return new Promise((resolve, reject) => {
const instance = spawn(
'node',
['--no-deprecation', require.resolve('typescript/lib/tsc'), ...args],
{ cwd, env }
)
let output = ''
const handleData = (chunk) => {
output += chunk.toString()
}
instance.stdout!.on('data', handleData)
instance.stderr!.on('data', handleData)
instance.on('exit', (code) => {
if (code) {
return reject(new Error('exited with code: ' + code + '\n' + output))
}
resolve()
})
})
}
export async function killProcess(
pid: number,
signal: NodeJS.Signals | number = 'SIGTERM'
): Promise<void> {
return await new Promise((resolve, reject) => {
treeKill(pid, signal, (err) => {
if (err) {
if (
process.platform === 'win32' &&
typeof err.message === 'string' &&
(err.message.includes(`no running instance of the task`) ||
err.message.includes(`not found`))
) {
// Windows throws an error if the process is already dead
//
// Command failed: taskkill /pid 6924 /T /F
// ERROR: The process with PID 6924 (child process of PID 6736) could not be terminated.
// Reason: There is no running instance of the task.
return resolve()
}
return reject(err)
}
resolve()
})
})
}
// Kill a launched app
export async function killApp(
instance?: ChildProcess,
signal: NodeJS.Signals | number = 'SIGKILL'
) {
if (!instance) {
return
}
if (
instance?.pid &&
instance.exitCode === null &&
instance.signalCode === null
) {
const exitPromise = once(instance, 'exit')
await killProcess(instance.pid, signal)
await exitPromise
}
}
async function startListen(server: http.Server, port?: number) {
const listenerPromise = new Promise((resolve) => {
server['__socketSet'] = new Set()
const listener = server.listen(port, () => {
resolve(null)
})
listener.on('connection', function (socket) {
server['__socketSet'].add(socket)
socket.on('close', () => {
server['__socketSet'].delete(socket)
})
})
})
await listenerPromise
}
export async function startApp(app: NextServer) {
// force require usage instead of dynamic import in jest
// x-ref: https://github.com/nodejs/node/issues/35889
process.env.__NEXT_TEST_MODE = 'jest'
// TODO: tests that use this should be migrated to use
// the nextStart test function instead as it tests outside
// of jest's context
await app.prepare()
const handler = app.getRequestHandler()
const server = http.createServer(handler)
server['__app'] = app
await startListen(server)
return server
}
export async function stopApp(server: http.Server | undefined) {
if (!server) {
return
}
if (server['__app']) {
await server['__app'].close()
}
// Node.js's http::close() prevents new connections from being accepted,
// but doesn't close existing connections and if there are any leftover
// whole process teardown will wait until it's being closed.
// Instead, force close connections since this is teardown fn that we expect
// any connections to be closed already.
server['__socketSet']?.forEach(function (socket) {
if (!socket.closed && !socket.destroyed) {
socket.destroy()
}
})
await promisify(server.close).apply(server)
}
export async function waitFor(
millisOrCondition: number | (() => boolean)
): Promise<void> {
if (typeof millisOrCondition === 'number') {
return new Promise((resolve) => setTimeout(resolve, millisOrCondition))
}
return new Promise((resolve) => {
const interval = setInterval(() => {
if (millisOrCondition()) {
clearInterval(interval)
resolve()
}
}, 100)
})
}
export async function startStaticServer(
dir: string,
notFoundFile?: string,
fixedPort?: number
) {
const app = express()
const server = http.createServer(app)
app.use(express.static(dir))
if (notFoundFile) {
app.use((req, res) => {
createReadStream(notFoundFile).pipe(res)
})
}
await startListen(server, fixedPort)
return server
}
export async function startCleanStaticServer(dir: string) {
const app = express()
const server = http.createServer(app)
app.use(express.static(dir, { extensions: ['html'] }))
await startListen(server)
return server
}
/**
* Check for content in 1 second intervals timing out after 30 seconds.
* @deprecated use retry + expect instead
*/
export async function check(
contentFn: () => unknown | Promise<unknown>,
regex: boolean | number | string | RegExp
): Promise<boolean> {
let content: unknown
let lastErr: unknown
for (let tries = 0; tries < 30; tries++) {
try {
content = await contentFn()
if (typeof regex !== 'object') {
if (regex === content) {
return true
}
} else if (regex.test('' + content)) {
// found the content
return true
}
await waitFor(1000)
} catch (err) {
await waitFor(1000)
lastErr = err
}
}
console.error('TIMED OUT CHECK: ', { regex, content, lastErr })
throw new Error('TIMED OUT: ' + regex + '\n\n' + content + '\n\n' + lastErr)
}
export class File {
path: string
originalContent: string | null
constructor(path: string) {
this.path = path
this.originalContent = existsSync(this.path)
? readFileSync(this.path, 'utf8')
: null
}
write(content: string) {
if (!this.originalContent) {
this.originalContent = content
}
writeFileSync(this.path, content, 'utf8')
}
replace(pattern: RegExp | string, newValue: string) {
const currentContent = readFileSync(this.path, 'utf8')
if (pattern instanceof RegExp) {
if (!pattern.test(currentContent)) {
throw new Error(
`Failed to replace content.\n\nPattern: ${pattern.toString()}\n\nContent: ${currentContent}`
)
}
} else if (typeof pattern === 'string') {
if (!currentContent.includes(pattern)) {
throw new Error(
`Failed to replace content.\n\nPattern: ${pattern}\n\nContent: ${currentContent}`
)
}
} else {
throw new Error(`Unknown replacement attempt type: ${pattern}`)
}
const newContent = currentContent.replace(pattern, newValue)
this.write(newContent)
}
prepend(str: string) {
const content = readFileSync(this.path, 'utf8')
this.write(str + content)
}
delete() {
unlinkSync(this.path)
}
restore() {
this.write(this.originalContent!)
}
}
export async function retry<T>(
fn: () => T | Promise<T>,
duration: number = 3000,
interval: number = 500,
description: string = fn.name
): Promise<T> {
if (duration % interval !== 0) {
throw new Error(
`invalid duration ${duration} and interval ${interval} mix, duration must be evenly divisible by interval`
)
}
for (let i = duration; i >= 0; i -= interval) {
try {
return await fn()
} catch (err) {
if (i === 0) {
console.error(
`Failed to retry${
description ? ` ${description}` : ''
} within ${duration}ms`
)
throw err
}
debugPrint(
`Retrying${description ? ` ${description}` : ''} in ${interval}ms`
)
await waitFor(interval)
}
}
throw new Error('Duration cannot be less than 0.')
}
export async function waitForRedbox(browser: Playwright) {
const redbox = browser.locateRedbox()
try {
await redbox.waitFor({ timeout: 5000 })
} catch (errorCause) {
const error = new Error('Expected Redbox but found no visible one.')
Error.captureStackTrace(error, waitForRedbox)
throw error
}
try {
await redbox
.locator('[data-nextjs-error-suspended]')
.waitFor({ state: 'detached', timeout: 10000 })
} catch (cause) {
const error = new Error('Redbox still had suspended content after 10s', {
cause,
})
Error.captureStackTrace(error, waitForRedbox)
throw error
}
}
export async function waitForNoRedbox(
browser: Playwright,
{ waitInMs = 5000 }: { waitInMs?: number } = {}
) {
await waitFor(waitInMs)
const redbox = browser.locateRedbox()
if (await redbox.isVisible()) {
const [redboxHeader, redboxDescription, redboxSource] = await Promise.all([
getRedboxHeader(browser).catch(() => '<missing>'),
getRedboxDescription(browser).catch(() => '<missing>'),
getRedboxSource(browser).catch(() => '<missing>'),
])
const error = new Error(
'Expected no visible Redbox but found one\n' +
`header: ${redboxHeader}\n` +
`description: ${redboxDescription}\n` +
`source: ${redboxSource}`
)
Error.captureStackTrace(error, waitForNoRedbox)
throw error
}
}
export async function waitForNoErrorToast(
browser: Playwright,
{ waitInMs }: { waitInMs?: number } = {}
): Promise<void> {
let didOpenRedbox = false
try {
await browser.waitForElementByCss('[data-issues]', waitInMs).click()
didOpenRedbox = true
} catch {
// We expect this to fail.
}
if (didOpenRedbox) {
// If a redbox was opened unexpectedly, we use the `waitForNoRedbox` helper
// to print a useful error message containing the redbox contents.
await waitForNoRedbox(browser, {
// We already know the redbox is open, so we can skip waiting for it.
waitInMs: 0,
})
}
}
export async function hasErrorToast(browser: Playwright): Promise<boolean> {
return Boolean(
await browser.eval(() => {
const portal = [].slice
.call(document.querySelectorAll('nextjs-portal'))
.find((p) => p.shadowRoot.querySelector('[data-issues]'))
const root = portal?.shadowRoot
const node = root?.querySelector('[data-issues-count]')
return !!node
})
)
}
export async function getToastErrorCount(browser: Playwright): Promise<number> {
return parseInt(
(await browser.eval(() => {
const portal = [].slice
.call(document.querySelectorAll('nextjs-portal'))
.find((p) => p.shadowRoot.querySelector('[data-issues]'))
const root = portal?.shadowRoot
const node = root?.querySelector('[data-issues-count]')
return node?.innerText || '0'
})) ?? '0'
)
}
/**
* Has retried version of {@link hasErrorToast} built-in.
* Success implies {@link waitForRedbox}.
*/
export async function openRedbox(browser: Playwright): Promise<void> {
const redbox = browser.locateRedbox()
if (await redbox.isVisible()) {
const error = new Error(
'Redbox is already open. Use `waitForRedbox` instead.'
)
Error.captureStackTrace(error, openRedbox)
throw error
}
try {
await browser.waitForElementByCss('[data-issues]').click()
} catch (cause) {
const error = new Error('Redbox did not open.')
Error.captureStackTrace(error, openRedbox)
throw error
}
await waitForRedbox(browser)
}
export async function toggleDevToolsIndicatorPopover(
browser: Playwright
): Promise<void> {
const devToolsIndicator = await waitForDevToolsIndicator(browser)
try {
await devToolsIndicator.click()
} catch (cause) {
const error = new Error('No DevTools Indicator to toggle.', { cause })
Error.captureStackTrace(error, toggleDevToolsIndicatorPopover)
throw error
}
}
export async function getSegmentExplorerRoute(browser: Playwright) {
return await browser
.elementByCss('.segment-explorer-page-route-bar-path')
.text()
.catch(() => '<empty>')
}
export async function getSegmentExplorerContent(browser: Playwright) {
// open the devtool button
await toggleDevToolsIndicatorPopover(browser)
// open the segment explorer
await browser.elementByCss('[data-segment-explorer]').click()
// wait for the segment explorer to be visible
await browser.waitForElementByCss('[data-nextjs-devtool-segment-explorer]')
const rows = await browser.elementsByCss('.segment-explorer-item')
let result: string[] = []
for (const row of rows) {
// query filename of row: segment-explorer-filename
const segment = (
(await (await row.$('.segment-explorer-filename--path'))?.innerText()) ||
''
).trim()
const files = (
(await (await row.$('.segment-explorer-files'))?.innerText()) || ''
)
.split(/\n+/)
.map((file) => file.trim())
// line format: segment [files]
result.push(`${segment} [${files.join(', ')}]`)
}
return result.join('\n')
}
export async function hasDevToolsPanel(browser: Playwright) {
const result = await browser.eval(() => {
const portal = document.querySelector('nextjs-portal')
return (
portal?.shadowRoot?.querySelector('[data-nextjs-dialog-overlay]') != null
)
})
return result
}
export async function waitForDevToolsIndicator(browser: Playwright) {
const devToolsIndicator = browser.locateDevToolsIndicator()
try {
await devToolsIndicator.waitFor({ timeout: 5000 })
} catch (errorCause) {
const error = new Error(
'Expected DevTools Indicator but found no visible one.'
)
Error.captureStackTrace(error, waitForDevToolsIndicator)
throw error
}
return devToolsIndicator
}
export async function assertNoDevToolsIndicator(browser: Playwright) {
const devToolsIndicator = browser.locateDevToolsIndicator()
if (await devToolsIndicator.isVisible()) {
const error = new Error(
'Expected no visible DevTools Indicator but found one.'
)
Error.captureStackTrace(error, assertNoDevToolsIndicator)
throw error
}
}
export async function waitForStaticIndicator(
browser: Playwright,
expectedRouteType: 'Static' | 'Dynamic' | undefined
): Promise<void> {
await toggleDevToolsIndicatorPopover(browser)
await retry(async () => {
const routeType = await browser.eval(() => {
const portal = [].slice
.call(document.querySelectorAll('nextjs-portal'))
.find((p) => p.shadowRoot.querySelector('[data-nextjs-toast]'))
return (
portal?.shadowRoot
// 'Route\nStatic' || 'Route\nDynamic'
?.querySelector('[data-nextjs-route-type]')
?.innerText.split('\n')
.pop()
)
})
if (routeType !== expectedRouteType) {
if (expectedRouteType) {
throw new Error(
`Expected static indicator with route type ${expectedRouteType}, found ${routeType} instead.`
)
} else {
throw new Error(
`Expected no static indicator, found ${routeType} instead.`
)
}
}
})
}
export function getRedboxHeader(browser: Playwright): Promise<string | null> {
return browser.eval(() => {
const portal = [].slice
.call(document.querySelectorAll('nextjs-portal'))
.find((p) => p.shadowRoot.querySelector('[data-nextjs-dialog-header]'))
const root = portal?.shadowRoot
return root?.querySelector('[data-nextjs-dialog-header]')?.innerText ?? null
})
}
export async function getRedboxTotalErrorCount(
browser: Playwright
): Promise<number> {
const text = await browser.eval(() => {
const portal = [].slice
.call(document.querySelectorAll('nextjs-portal'))
.find((p) =>
p.shadowRoot.querySelector('[data-nextjs-dialog-header-total-count]')
)
const root = portal?.shadowRoot
return root?.querySelector('[data-nextjs-dialog-header-total-count]')
?.innerText
})
return parseInt(text || '-1')
}
export function getRedboxSource(browser: Playwright): Promise<string | null> {
return browser.eval(() => {
const portal = [].slice
.call(document.querySelectorAll('nextjs-portal'))
.find((p) =>
p.shadowRoot.querySelector(
'#nextjs__container_errors_label, #nextjs__container_errors_label'
)
)
const root = portal.shadowRoot
return (
root.querySelector('[data-nextjs-codeframe], [data-nextjs-terminal]')
?.innerText ?? null
)
})
}
export function getRedboxTitle(browser: Playwright): Promise<string | null> {
return browser.eval(() => {
const portal = [].slice
.call(document.querySelectorAll('nextjs-portal'))
.find((p) => p.shadowRoot.querySelector('[data-nextjs-dialog-header]'))
const root = portal.shadowRoot
return (
root.querySelector(
'[data-nextjs-dialog-header] .nextjs__container_errors__error_title'
)?.innerText ?? null
)
})
}
export function getRedboxLabel(browser: Playwright): Promise<string | null> {
return browser.eval(() => {
const portal = [].slice
.call(document.querySelectorAll('nextjs-portal'))
.find((p) => p.shadowRoot.querySelector('[data-nextjs-dialog-header]'))
const root = portal.shadowRoot
return (
root.querySelector('#nextjs__container_errors_label')?.innerText ?? null
)
})
}
export function getRedboxEnvironmentLabel(
browser: Playwright
): Promise<string | null> {
return browser.eval(() => {
const portal = [].slice
.call(document.querySelectorAll('nextjs-portal'))
.find((p) => p.shadowRoot.querySelector('[data-nextjs-dialog-header]'))
const root = portal.shadowRoot
return (
root.querySelector('[data-nextjs-environment-name-label]')?.innerText ??
null
)
})
}
export function getRedboxDescription(
browser: Playwright
): Promise<string | null> {
return browser.eval(() => {
const portal = [].slice
.call(document.querySelectorAll('nextjs-portal'))
.find((p) => p.shadowRoot.querySelector('[data-nextjs-dialog-header]'))
const root = portal.shadowRoot
return (
root.querySelector('#nextjs__container_errors_desc')?.innerText ?? null
)
})
}
export function getRedboxErrorCode(
browser: Playwright
): Promise<string | null> {
return browser.eval(() => {
const portal = [].slice
.call(document.querySelectorAll('nextjs-portal'))
.find((p) => p.shadowRoot.querySelector('[data-nextjs-dialog-header]'))
const root = portal.shadowRoot
return (
root
.querySelector('[data-nextjs-error-code]')
?.getAttribute('data-nextjs-error-code') ?? null
)
})
}
export function getRedboxDescriptionWarning(
browser: Playwright
): Promise<string | null> {
return browser.eval(() => {
const portal = [].slice
.call(document.querySelectorAll('nextjs-portal'))
.find((p) => p.shadowRoot.querySelector('[data-nextjs-dialog-header]'))
const root = portal.shadowRoot
return (
root.querySelector('#nextjs__container_errors__notes')?.innerText ?? null
)
})
}
export function getRedboxErrorLink(
browser: Playwright
): Promise<string | null> {
return browser.eval(() => {
const portal = [].slice
.call(document.querySelectorAll('nextjs-portal'))
.find((p) => p.shadowRoot.querySelector('[data-nextjs-dialog-header]'))
const root = portal.shadowRoot
return (
root.querySelector('#nextjs__container_errors__link')?.innerText ?? null
)
})
}
export function getBrowserBodyText(browser: Playwright) {
return browser.eval<string>(
'document.getElementsByTagName("body")[0].innerText'
)
}
export function normalizeRegEx(src: string) {
return new RegExp(src).source.replace(/\^\//g, '^\\/')
}
function readJson(path: string) {
return JSON.parse(readFileSync(path, 'utf-8'))
}
export function getBuildManifest(dir: string) {
return readJson(path.join(dir, getDistDir(), 'build-manifest.json'))
}
export function getImagesManifest(dir: string) {
return readJson(path.join(dir, getDistDir(), 'images-manifest.json'))
}
export function getPageFilesFromBuildManifest(dir: string, page: string) {
const buildManifest = getBuildManifest(dir)
const pageFiles = buildManifest.pages[page]
if (!pageFiles) {
throw new Error(`No files for page ${page}`)
}
return pageFiles
}
export function getContentOfPageFilesFromBuildManifest(
dir: string,
page: string
): string {
const pageFiles = getPageFilesFromBuildManifest(dir, page)
return pageFiles
.map((file) => readFileSync(path.join(dir, getDistDir(), file), 'utf8'))
.join('\n')
}
export function getPageFileFromBuildManifest(dir: string, page: string) {
const pageFiles = getPageFilesFromBuildManifest(dir, page)
const pageFile = pageFiles[pageFiles.length - 1]
expect(pageFile).toEndWith('.js')
if (!process.env.IS_TURBOPACK_TEST) {
expect(pageFile).toInclude(`pages${page === '' ? '/index' : page}`)
}
if (!pageFile) {
throw new Error(`No page file for page ${page}`)
}
return pageFile
}
export function readNextBuildClientPageFile(appDir: string, page: string) {
const pageFile = getPageFileFromBuildManifest(appDir, page)
return readFileSync(path.join(appDir, getDistDir(), pageFile), 'utf8')
}
export function getPagesManifest(dir: string) {
const serverFile = path.join(dir, getDistDir(), 'server/pages-manifest.json')
return readJson(serverFile)
}
export function updatePagesManifest(dir: string, content: any) {
const serverFile = path.join(dir, getDistDir(), 'server/pages-manifest.json')
return writeFile(serverFile, content)
}
export function getPageFileFromPagesManifest(dir: string, page: string) {
const pagesManifest = getPagesManifest(dir)
const pageFile = pagesManifest[page]
if (!pageFile) {
throw new Error(`No file for page ${page}`)
}
return pageFile
}
export function readNextBuildServerPageFile(appDir: string, page: string) {
const pageFile = getPageFileFromPagesManifest(appDir, page)
return readFileSync(
path.join(appDir, getDistDir(), 'server', pageFile),
'utf8'
)
}
export function getClientBuildManifest(dir: string) {
let buildId = readFileSync(path.join(dir, getDistDir(), 'BUILD_ID'), 'utf8')
let code = readFileSync(
path.join(dir, getDistDir(), 'static', buildId, '_buildManifest.js'),
'utf8'
)
// eslint-disable-next-line no-eval
let manifest = (0, eval)(`var self = global;${code};self.__BUILD_MANIFEST`)
return manifest
}
export function getClientBuildManifestLoaderChunkUrlPath(
dir: string,
page: string
) {
let manifest = getClientBuildManifest(dir)
let chunk: string[] | undefined = manifest[page]
if (chunk == null) {
throw new Error(`Couldn't find page "${page}" in _buildManifest.js`)
}
if (chunk.length !== 1) {
throw new Error(
`Expected a single chunk, but found ${chunk.length} for "${page}" in _buildManifest.js`
)
}
// Remove leading './' so that this can be used in a `url.contains(chunk)` check.
return encodeURI(chunk[0].replace(/^\.\//, ''))
}
function runSuite(
suiteName: string,
context: { env: 'prod' | 'dev'; appDir: string } & Partial<{
stderr: string
stdout: string
appPort: number
code: number | null
server: ChildProcess
}>,
options: {
beforeAll?: Function
afterAll?: Function
runTests: Function
} & NextDevOptions
) {
const { appDir, env } = context
describe(`${suiteName} ${env}`, () => {
beforeAll(async () => {
options.beforeAll?.(env)
context.stderr = ''
const onStderr = (msg) => {
context.stderr += msg
}
context.stdout = ''
const onStdout = (msg) => {
context.stdout += msg
}
if (env === 'prod') {
context.appPort = await findPort()
const { stdout, stderr, code } = await nextBuild(appDir, [], {
stderr: true,
stdout: true,
env: options.env || {},
nodeArgs: options.nodeArgs,
})
context.stdout = stdout
context.stderr = stderr
context.code = code
context.server = await nextStart(context.appDir, context.appPort, {
onStderr,
onStdout,
env: options.env || {},
nodeArgs: options.nodeArgs,
})
} else if (env === 'dev') {
context.appPort = await findPort()
context.server = await launchApp(context.appDir, context.appPort, {
onStderr,
onStdout,
env: options.env || {},
nodeArgs: options.nodeArgs,
})
}
})
afterAll(async () => {
options.afterAll?.(env)
if (context.server) {
await killApp(context.server)
}
})
options.runTests(context, env)
})
}
export function runDevSuite(
suiteName: string,
appDir: string,
options: {
beforeAll?: Function
afterAll?: Function
runTests: Function
env?: NodeJS.ProcessEnv
}
) {
return runSuite(suiteName, { appDir, env: 'dev' }, options)
}
export function runProdSuite(
suiteName: string,
appDir: string,
options: {
beforeAll?: Function
afterAll?: Function
runTests: Function
env?: NodeJS.ProcessEnv
}
) {
;(process.env.TURBOPACK_DEV ? describe.skip : describe)(
'production mode',
() => {
runSuite(suiteName, { appDir, env: 'prod' }, options)
}
)
}
/**
* Parse the output and return all entries that match the provided `eventName`
* @param {string} output output of the console
* @param {string} eventName
* @returns {Array<{}>}
*/
export function findAllTelemetryEvents(output: string, eventName: string) {
const regex = /\[telemetry\] ({.+?^})/gms
// Pop the last element of each entry to retrieve contents of the capturing group
const events = [...output.matchAll(regex)].map((entry) =>
JSON.parse(entry.pop()!)
)
return events.filter((e) => e.eventName === eventName).map((e) => e.payload)
}
type TestVariants = 'default' | 'turbo'
// WEB-168: There are some differences / incompletes in turbopack implementation enforces jest requires to update
// test snapshot when run against turbo. This fn returns describe, or describe.skip dependes on the running context
// to avoid force-snapshot update per each runs until turbopack update includes all the changes.
export function getSnapshotTestDescribe(variant: TestVariants) {
const runningEnv = variant ?? 'default'
if (runningEnv !== 'default' && runningEnv !== 'turbo') {
throw new Error(
`An invalid test env was passed: ${variant} (only "default" and "turbo" are valid options)`
)
}
const shouldRunTurboDev = shouldUseTurbopack()
const shouldSkip =
(runningEnv === 'turbo' && !shouldRunTurboDev) ||
(runningEnv === 'default' && shouldRunTurboDev)
return shouldSkip ? describe.skip : describe
}
const nextjsClientComponentNames = [
// Pages Router
'App',
'AppContainer',
'Container',
'Head',
'PagesDevOverlayBridge',
'PagesDevOverlayErrorBoundary',
'PathnameContextProviderAdapter',
// App Router
'ClientPageRoot',
'ClientSegmentRoot',
'HTTPAccessFallbackBoundary',
'HTTPAccessFallbackErrorBoundary',
'InnerLayoutRouter',
'InnerScrollAndFocusHandlerOld',
'InnerScrollHandlerNew',
'RedirectBoundary',
'RedirectErrorBoundary',
'RenderFromTemplateContext',
'Root',
'ScrollAndMaybeFocusHandler',
'SegmentViewNode',
'SegmentTrieNode',
// These are added due to user actions e.g. loading.js -> LoadingBoundary
// They may be relevant in some context in the future.
// Consider including them in different assertions.
'ErrorBoundary',
'LoadingBoundary',
]
const nextjsClientComponentStackFrame = new RegExp(
`^(\\s*)<(${nextjsClientComponentNames.join('|')})(>| )`
)
/**
* @returns `null` if there are no frames
*/
export async function getRedboxComponentStack(
browser: Playwright,
includeNextjsInternalComponents = false
): Promise<string | null> {
const componentStackTraceElements = await browser.elementsByCss(
'[data-nextjs-container-errors-pseudo-html] code'
)
if (componentStackTraceElements.length === 0) {
return null
}
const componentStackTrace = await componentStackTraceElements[0].innerText()
const componentStackFrames = componentStackTrace.split('\n')
return componentStackFrames
.map((componentStackFrame) => {
if (!includeNextjsInternalComponents) {
const componentStackFrameMatch = componentStackFrame.match(
nextjsClientComponentStackFrame
)
// React component stack frames aren't subject to ignore-listing.
// They're not relevant for our tests though.
// If you need to assert on Next.js internal component frames,
// use `getRedboxComponentStack(browser, true)` instead.
if (componentStackFrameMatch) {
return componentStackFrameMatch[1] + '<Next.js Internal Component>'
}
}
return componentStackFrame
})
.join('\n')
.trim()
}
export async function hasRedboxCallStack(browser: Playwright) {
return browser.eval(() => {
const portal = [].slice
.call(document.querySelectorAll('nextjs-portal'))
.find((p) => p.shadowRoot.querySelector('[data-nextjs-dialog-body]'))
const root = portal?.shadowRoot
return root?.querySelectorAll('[data-nextjs-call-stack-frame]').length > 0
})
}
export interface RedboxCauseEntry {
label: string | null
message: string | null
source: string | null
stack: string[]
}
export async function getRedboxCause(
browser: Playwright
): Promise<RedboxCauseEntry[] | null> {
return browser.eval(() => {
const portal = [].slice
.call(document.querySelectorAll('nextjs-portal'))
.find((p) => p.shadowRoot.querySelector('[data-nextjs-dialog-header]'))
const root = portal?.shadowRoot
const causeElements = root?.querySelectorAll('[data-nextjs-error-cause]')
if (!causeElements || causeElements.length === 0) return null
const causes: {
label: string | null
message: string | null
source: string | null
stack: string[]
}[] = []
for (const el of causeElements) {
const stackFrameElements = el.querySelectorAll(
':scope > [data-nextjs-call-stack-container] > [data-nextjs-call-stack-frame]'
)
const stack: string[] = []
for (const frameEl of stackFrameElements) {
stack.push(frameEl.innerText.replace(/\n+/g, ' '))
}
causes.push({
label: el.querySelector('.error-cause-label')?.innerText ?? null,
message: el.querySelector('.error-cause-message')?.innerText ?? null,
source:
el.querySelector(':scope > [data-nextjs-codeframe]')?.innerText ??
null,
stack,
})
}
return causes
})
}
export async function getRedboxCallStack(
browser: Playwright
): Promise<string[] | null> {
return browser.eval(() => {
const portal = [].slice
.call(document.querySelectorAll('nextjs-portal'))
.find((p) => p.shadowRoot.querySelector('[data-nextjs-call-stack-frame]'))
const root = portal?.shadowRoot
const frameElements = root?.querySelectorAll(
'[data-nextjs-call-stack-frame]'
)
const stack: string[] = []
if (frameElements !== undefined) {
let foundInternalFrame = false
for (const frameElement of frameElements) {
// Skip frames that belong to an Error.cause section
if (frameElement.closest('[data-nextjs-error-cause]')) {
continue
}
// `innerText` will be "${methodName}\n${location}".
// Ideally `innerText` would be "${methodName} ${location}"
// so that c&p automatically does the right thing.
const frame = frameElement.innerText.replace(/\n+/g, ' ')
// TODO: Special marker if source-mapping fails.
// Feel free to adjust this heuristic if it accidentally hides too much.
const isInternalFrame =
// likely https://linear.app/vercel/issue/NDX-464
// location starts with `./dist` e.g. "NotFoundBoundary ./dist/esm/[...]"
/ .\/dist\//.test(frame)
if (isInternalFrame) {
// We only add one of these frames.
// If we'd add all of them, the stack would change during refactorings which is annoying.
if (!foundInternalFrame) {
stack.push('<FIXME-internal-frame>')
}
foundInternalFrame = true
} else if (frame.includes('file://')) {
stack.push('<FIXME-file-protocol>')
} else if (frame.includes('.next/')) {
stack.push('<FIXME-next-dist-dir>')
} else {
stack.push(frame)
}
}
}
return stack
})
}
export async function getRedboxCallStackCollapsed(
browser: Playwright
): Promise<string> {
const callStackFrameElements = await browser.elementsByCss(
'.nextjs-container-errors-body > [data-nextjs-codeframe] > :first-child, ' +
'.nextjs-container-errors-body > [data-nextjs-call-stack-frame], ' +
'.nextjs-container-errors-body > [data-nextjs-collapsed-call-stack-details] > summary'
)
const callStackFrameTexts = await Promise.all(
callStackFrameElements.map((f) => f.innerText())
)
return callStackFrameTexts.join('\n---\n').trim()
}
export async function getVersionCheckerText(
browser: Playwright
): Promise<string> {
await browser.waitForElementByCss('[data-nextjs-version-checker]', 30000)
const versionCheckerElement = await browser.elementByCss(
'[data-nextjs-version-checker]'
)
const versionCheckerText = await versionCheckerElement.innerText()
return versionCheckerText.trim()
}
export function colorToRgb(color) {
switch (color) {
case 'blue':
return 'rgb(0, 0, 255)'
case 'red':
return 'rgb(255, 0, 0)'
case 'green':
return 'rgb(0, 128, 0)'
case 'yellow':
return 'rgb(255, 255, 0)'
case 'purple':
return 'rgb(128, 0, 128)'
case 'black':
return 'rgb(0, 0, 0)'
default:
throw new Error('Unknown color')
}
}
export function getUrlFromBackgroundImage(backgroundImage: string) {
const matches = backgroundImage.match(/url\("[^)]+"\)/g)!.map((match) => {
// Extract the URL part from each match. The match includes 'url("' and '"")', so we remove those.
return match.slice(5, -2)
})
return matches
}
export const getTitle = (browser: Playwright) =>
browser.elementByCss('title', { state: 'attached' }).text()
async function checkMeta(
browser: Playwright,
queryValue: string,
expected: RegExp | string | string[] | undefined | null,
queryKey: string = 'property',
tag: string = 'meta',
domAttributeField: string = 'content'
) {
const values = await browser.eval<(string | null)[]>(
`[...document.querySelectorAll('${tag}[${queryKey}="${queryValue}"]')].map((el) => el.getAttribute("${domAttributeField}"))`
)
if (expected instanceof RegExp) {
expect(values[0]).toMatch(expected)
} else {
if (Array.isArray(expected)) {
expect(values).toEqual(expected)
} else {
// If expected is undefined, then it should not exist.
// Otherwise, it should exist in the matched values.
if (expected === undefined) {
expect(values).not.toContain(undefined)
} else {
expect(values).toContain(expected)
}
}
}
}
export function createDomMatcher(browser: Playwright) {
/**
* @param tag - tag name, e.g. 'meta'
* @param query - query string, e.g. 'name="description"'
* @param expectedObject - expected object, e.g. { content: 'my description' }
* @returns {Promise<void>} - promise that resolves when the check is done
*
* @example
* const matchDom = createDomMatcher(browser)
* await matchDom('meta', 'name="description"', { content: 'description' })
*/
return async (
tag: string,
query: string,
expectedObject: Record<string, string | null | undefined>
) => {
const props = await browser.eval(`
const el = document.querySelector('${tag}[${query}]');
const res = {}
const keys = ${JSON.stringify(Object.keys(expectedObject))}
for (const k of keys) {
res[k] = el?.getAttribute(k)
}
res
`)
expect(props).toEqual(expectedObject)
}
}
export function createMultiHtmlMatcher($: ReturnType<typeof cheerio.load>) {
/**
* @param tag - tag name, e.g. 'meta'
* @param queryKey - query key, e.g. 'property'
* @param domAttributeField - dom attribute field, e.g. 'content'
* @param expected - expected object, e.g. { description: 'my description' }
* @returns {void} - void when the check is done
*
* @example
*
* const $ = await next.render$('html')
* const matchHtml = createMultiHtmlMatcher($)
* matchHtml('meta', 'name', 'property', {
* description: 'description',
* og: 'og:description'
* })
*
*/
return (
tag: string,
queryKey: string,
domAttributeField: string,
expected: Record<string, string | string[] | undefined>
) => {
const res = {}
for (const key of Object.keys(expected)) {
const el = $(`${tag}[${queryKey}="${key}"]`)
if (el.length > 1) {
res[key] = el.toArray().map((el) => el.attribs[domAttributeField])
} else {
res[key] = el.attr(domAttributeField)
}
}
expect(res).toEqual(expected)
}
}
export function createMultiDomMatcher(browser: Playwright) {
/**
* @param tag - tag name, e.g. 'meta'
* @param queryKey - query key, e.g. 'property'
* @param domAttributeField - dom attribute field, e.g. 'content'
* @param expected - expected object, e.g. { description: 'my description' }
* @returns {Promise<void>} - promise that resolves when the check is done
*
* @example
* const matchMultiDom = createMultiDomMatcher(browser)
* await matchMultiDom('meta', 'property', 'content', {
* description: 'description',
* 'og:title': 'title',
* 'twitter:title': 'title'
* })
*
*/
return async (
tag: string,
queryKey: string,
domAttributeField: string,
expected: Record<string, string | string[] | undefined | null>
) => {
await Promise.all(
Object.keys(expected).map(async (key) => {
return checkMeta(
browser,
key,
expected[key],
queryKey,
tag,
domAttributeField
)
})
)
}
}
export const checkMetaNameContentPair = (
browser: Playwright,
name: string,
content: string | string[]
) => checkMeta(browser, name, content, 'name')
export const checkLink = (
browser: Playwright,
rel: string,
content: string | string[]
) => checkMeta(browser, rel, content, 'rel', 'link', 'href')
export async function toggleCollapseCallStackFrames(browser: Playwright) {
const button = await browser.elementByCss(
'[data-nextjs-call-stack-ignored-list-toggle-button]'
)
const lastExpanded = await button.getAttribute(
'data-nextjs-call-stack-ignored-list-toggle-button'
)
await button.click()
await retry(async () => {
const currExpanded = await button.getAttribute(
'data-nextjs-call-stack-ignored-list-toggle-button'
)
expect(currExpanded).not.toBe(lastExpanded)
})
}
/**
* Encodes the params into a URLSearchParams object using the format that the
* now builder uses for route matches (adding the `nxtP` prefix to the keys).
*
* @param params - The params to encode.
* @param extraQueryParams - The extra query params to encode (without the `nxtP` prefix).
* @returns The encoded URLSearchParams object.
*/
export function createNowRouteMatches(
params: Record<string, string>,
extraQueryParams: Record<string, string> = {}
): URLSearchParams {
const urlSearchParams = new URLSearchParams()
for (const [key, value] of Object.entries(params)) {
urlSearchParams.append(`nxtP${key}`, value)
}
for (const [key, value] of Object.entries(extraQueryParams)) {
urlSearchParams.append(key, value)
}
return urlSearchParams
}
export async function assertNoConsoleErrors(browser: Playwright) {
const logs = await browser.log()
const warningsAndErrors = logs.filter((log) => {
return (
log.source === 'warning' ||
(log.source === 'error' &&
// These are expected when we visit 404 pages.
!log.message.startsWith(
'Failed to load resource: the server responded with a status of 404'
))
)
})
expect(warningsAndErrors).toEqual([])
}
export async function getHighlightedDiffLines(
browser: Playwright
): Promise<[string, string][]> {
const lines = await browser.elementsByCss(
'[data-nextjs-container-errors-pseudo-html--diff]'
)
return Promise.all(
lines.map(async (line) => [
(await line.getAttribute(
'data-nextjs-container-errors-pseudo-html--diff'
))!,
(await line.innerText())[0],
])
)
}
export function trimEndMultiline(str: string) {
return str
.split('\n')
.map((line) => line.trimEnd())
.join('\n')
}
/**
* Normalizes the manifest by applying the replacements to the manifest. This
* is useful for testing the manifest in a snapshot test.
*
* @param manifest - The manifest to normalize.
* @param replacements - The replacements to perform on the manifest.
* @returns The normalized manifest.
*/
export function normalizeManifest<T>(
manifest: unknown,
replacements: [search: string, replace: string][]
): T {
return JSON.parse(
replacements.reduce(
(acc, [search, replace]) =>
acc.replace(
new RegExp(
// We want to match the literal string, so we need to escape it
// again
escapeRegex(
JSON.stringify(
// The output has already been escaped, so we need to escape our
// search string.
escapeRegex(search)
)
// Remove the quotes added by the JSON.stringify call.
.replace(/^"(.*)"/, '$1')
),
'g'
),
replace
),
// We'll perform the replacements on the JSON stringified manifest.
JSON.stringify(manifest)
)
)
}
export function getDistDir(): '.next' | '.next/dev' {
// global.isNextDev is set in e2e/development/production tests.
// NEXT_TEST_MODE is set in CI or local test-* commands.
return (global as any).isNextDev || process.env.NEXT_TEST_MODE === 'dev'
? '.next/dev'
: '.next'
}
/**
* Loads and returns the client reference manifest for a given route
*/
export function getClientReferenceManifest(
next: NextInstance,
route: string
): ClientReferenceManifest {
const manifestPath = path.join(
next.testDir,
next.distDir,
`server/app${route}_client-reference-manifest.js`
)
const modulePath = require.resolve(manifestPath)
// Clear global
delete (globalThis as any).__RSC_MANIFEST
// Need to use jest.isolateModules because Jest messes with require.cache and `delete
// require.cache[modulePath]` doesn't actually work anymore
jest.isolateModules(() => {
// Load the manifest (it sets globalThis.__RSC_MANIFEST)
require(modulePath)
})
const manifest = (globalThis as any).__RSC_MANIFEST[
route
] as ClientReferenceManifest
// Sanity check
expect(
manifest.clientModules ||
manifest.ssrModuleMapping ||
manifest.rscModuleMapping
).toBeDefined()
return manifest
}
export const getCacheHeader = (curRes: Response) =>
// favor generic header
curRes.headers.get('x-nextjs-cache') || curRes.headers.get('x-vercel-cache')
export function getDeploymentId(appDir: string, isDev: boolean) {
let requiredServerFiles
if (!isDev) {
// File isn't written in dev, but it might still exist because it was created by a prior
// production build.
try {
requiredServerFiles = JSON.parse(
readFileSync(
path.join(appDir, getDistDir(), 'required-server-files.json'),
'utf8'
)
)
} catch {}
}
const deploymentId: string | undefined =
requiredServerFiles?.config?.deploymentId
const immutableAssetToken: string | undefined =
requiredServerFiles?.config?.experimental?.immutableAssetToken
const assetToken: string | undefined = immutableAssetToken || deploymentId
return {
deploymentId,
getDeploymentIdQuery(ampersand = false) {
return deploymentId ? `${ampersand ? '&' : '?'}dpl=${deploymentId}` : ''
},
assetToken,
getAssetQuery(ampersand = false) {
return assetToken ? `${ampersand ? '&' : '?'}dpl=${assetToken}` : ''
},
}
}