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
2106 lines
59 KiB
TypeScript
2106 lines
59 KiB
TypeScript
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 Node’s 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}` : ''
|
||
},
|
||
}
|
||
}
|