Files
Benjamin WoodruffandGitHub e630d81822 [ci] Enforce minimumReleaseAge in e2e tests that install external dependencies (#95628)
We also mitigate the security impact of e2e tests by running everything
in VMs, and by not reading caches in release jobs, but we should still
be using `minimumReleaseAge` in e2e tests.

Ideally we'd also use lockfiles here too, and I'll work on that next,
but that is likely a lot more complicated because we probably need some
way to merge lockfiles, since we can't pin the next.js and react
versions in every test, and we need a reasonable/efficient way to
bulk-update all the lockfiles for tests that need it.

Depends on https://github.com/vercel/next.js/pull/95668 for a bunch of
windows path representation fixes that this ends up exposing in CI,
because adding workspace files causes pnpm to change the absolute path
representation used in the Windows junction points that it creates.
2026-07-21 14:54:01 -07:00

1178 lines
36 KiB
TypeScript

import os from 'os'
import path from 'path'
import { existsSync, promises as fs, rmSync, readFileSync } from 'fs'
import treeKill from 'tree-kill'
import type { NextConfig } from 'next'
import { FileRef, isNextDeploy, PatchedFileRef } from '../e2e-utils'
import { ChildProcess } from 'child_process'
import spawn from 'cross-spawn'
import { quote as shellQuote } from 'shell-quote'
import { createNextInstall } from '../create-next-install'
import { Span } from 'next/dist/trace'
import webdriver from '../next-webdriver'
import {
renderViaHTTP,
fetchViaHTTP,
findPort,
getDistDir,
} from 'next-test-utils'
import cheerio from 'cheerio'
import { once } from 'events'
import type { Playwright } from '../browsers/playwright'
import escapeStringRegexp from 'escape-string-regexp'
import { Page, Response } from 'playwright'
type Event = 'stdout' | 'stderr' | 'error' | 'destroy'
export type InstallCommand =
| string
| ((ctx: { dependencies: { [key: string]: string } }) => string)
export type PackageJson = {
dependencies?: { [key: string]: string }
[key: string]: unknown
}
type ResolvedFileConfig =
| FileRef
| PatchedFileRef
| { [filename: string]: string | FileRef | PatchedFileRef }
type FilesConfig = ResolvedFileConfig | string
export interface NextInstanceOpts {
files: FilesConfig
overrideFiles?: FilesConfig
dependencies?: { [name: string]: string }
resolutions?: { [name: string]: string }
packageJson?: PackageJson
nextConfig?: NextConfig
installCommand?: InstallCommand
buildCommand?: string
buildArgs?: string[]
startCommand?: string
startArgs?: string[]
env?: Record<string, string>
subDir?: string
turbo?: boolean
forcedPort?: string
serverReadyPattern?: RegExp
patchFileDelay?: number
startServerTimeout?: number
disableAutoSkewProtection?: boolean
/**
* Delete the `pnpm-workspace.yaml` that `createNextInstall` writes for
* supply-chain gating of installs. For tests that assert on Next.js
* workspace-root detection, which the file affects.
*/
deleteWorkspaceFile?: boolean
}
/**
* Omit the first argument of a function
*/
type OmitFirstArgument<F> = F extends (
firstArgument: any,
...args: infer P
) => infer R
? (...args: P) => R
: never
// Do not rename or format. sync-react script relies on this line.
// prettier-ignore
const nextjsReactPeerVersion = "19.2.8";
const ROOT_PACKAGE_MANAGER: string =
require('../../../package.json').packageManager
export class NextInstance {
protected files: ResolvedFileConfig
protected overrideFiles: ResolvedFileConfig
protected nextConfig?: NextConfig
protected installCommand?: InstallCommand
public buildCommand?: string
public buildArgs?: string[]
protected startCommand?: string
protected startArgs?: string[]
protected dependencies?: PackageJson['dependencies'] = {}
protected resolutions?: PackageJson['resolutions']
protected events: { [eventName: string]: Set<any> } = {}
public testDir: string
public distDir: string
protected isStopping: Error | null = null
protected isDestroyed: Error | null = null
protected childProcess?: ChildProcess
protected _url: string
protected _parsedUrl: URL
protected packageJson: PackageJson = {}
protected basePath?: string
public env: Record<string, string>
public forcedPort?: string
public subDir: string = ''
public startServerTimeout: number = 10_000 // 10 seconds
public serverReadyPattern: RegExp = /✓ Ready in /
patchFileDelay: number = 0
public deleteWorkspaceFile: boolean = false
constructor(opts: NextInstanceOpts) {
this.env = {}
Object.assign(this, opts)
const nextTestWasm =
process.env.NEXT_TEST_WASM ?? process.env.NEXT_TEST_WASM_AFTER_JEST
if (nextTestWasm) {
this.env.NEXT_TEST_WASM = nextTestWasm
}
if (!isNextDeploy) {
this.env = {
...this.env,
// remove node_modules/.bin repo path from env
// to match CI $PATH value and isolate further
PATH: process.env
.PATH!.split(path.delimiter)
.filter((part) => {
return !part.includes(path.join('node_modules', '.bin'))
})
.join(path.delimiter),
}
}
}
private async writeFiles(filesConfig: FilesConfig, testDir: string) {
// Handle case where files is a directory string
const files =
typeof filesConfig === 'string' ? new FileRef(filesConfig) : filesConfig
if (files instanceof FileRef) {
// if a FileRef is passed directly to `files` we copy the
// entire folder to the test directory
const stats = await fs.stat(files.fsPath)
if (!stats.isDirectory()) {
throw new Error(
`FileRef passed to "files" in "createNext" is not a directory ${files.fsPath}`
)
}
const skippedRelativePaths = new Set([
'package.json',
'.next',
'.next-profiles',
'.DS_Store',
])
await fs.cp(files.fsPath, testDir, {
recursive: true,
// By default Node.js turns relative symlinks into absolute symlinks.
// We don't want absolute symlinks because the test directory is isolated
// and the symlink would turn into a path to the Next.js repo original file.
// Setting this option to `true` will keep the symlink relative. Ensuring it's isolated.
// See https://nodejs.org/api/fs.html#fscpsrc-dest-options-callback
verbatimSymlinks: true,
filter(source) {
const topLevel = path
.relative(files.fsPath, source)
.split(path.sep)[0]
return !skippedRelativePaths.has(topLevel)
},
})
} else {
for (const filename of Object.keys(files)) {
const item = files[filename]
const outputFilename = path.join(testDir, filename)
if (typeof item === 'string') {
await fs.mkdir(path.dirname(outputFilename), { recursive: true })
await fs.writeFile(outputFilename, item)
} else if (item instanceof FileRef) {
try {
const existingStat = await fs.lstat(outputFilename)
if (existingStat.isFile() || existingStat.isSymbolicLink()) {
await fs.unlink(outputFilename)
}
} catch {
// file might not exist or can't be unliked. carry on
}
await fs.cp(item.fsPath, outputFilename, { recursive: true })
} else if (item instanceof PatchedFileRef) {
try {
const existingStat = await fs.lstat(outputFilename)
if (existingStat.isFile() || existingStat.isSymbolicLink()) {
await fs.unlink(outputFilename)
}
} catch {
// file might not exist or can't be unliked. carry on
}
await fs.writeFile(
outputFilename,
item.cb(await fs.readFile(item.fsPath, 'utf8'))
)
}
}
}
}
protected async writeInitialFiles() {
return this.writeFiles(this.files, this.testDir)
}
protected async writeOverrideFiles() {
if (this.overrideFiles) {
return this.writeFiles(this.overrideFiles, this.testDir)
}
}
protected async beforeInstall(parentSpan: Span) {
await parentSpan.traceChild('writeInitialFiles').traceAsyncFn(async () => {
await this.writeInitialFiles()
})
await parentSpan.traceChild('writeOverrideFiles').traceAsyncFn(async () => {
await this.writeOverrideFiles()
})
}
protected async createTestDir({
skipInstall = false,
parentSpan,
}: {
skipInstall?: boolean
parentSpan: Span
}) {
if (this.isDestroyed) {
throw new Error('next instance already destroyed')
}
await parentSpan
.traceChild('createTestDir')
.traceAsyncFn(async (rootSpan) => {
const skipIsolatedNext = !!process.env.NEXT_SKIP_ISOLATE
if (!skipIsolatedNext) {
require('console').log(
`Creating test directory with isolated next... (use NEXT_SKIP_ISOLATE=1 to opt-out)`
)
}
const tmpDir = skipIsolatedNext
? path.join(__dirname, '../../tmp')
: process.env.NEXT_TEST_DIR || (await fs.realpath(os.tmpdir()))
this.testDir = path.join(
tmpDir,
`next-test-${Date.now()}-${(Math.random() * 1000) | 0}`,
this.subDir
)
this.distDir = getDistDir()
const reactVersion =
process.env.NEXT_TEST_REACT_VERSION || nextjsReactPeerVersion
const finalDependencies = {
react: reactVersion,
'react-dom': reactVersion,
'@types/react': '19.2.2',
'@types/react-dom': '19.2.1',
typescript: '6.0.3',
'@types/node': '26.1.0',
...this.dependencies,
...this.packageJson?.dependencies,
}
if (
process.env.__NEXT_ENABLE_REACT_COMPILER === 'true' &&
!finalDependencies['babel-plugin-react-compiler']
) {
finalDependencies['babel-plugin-react-compiler'] =
'0.0.0-experimental-3fde738-20250918'
}
if (skipInstall || skipIsolatedNext) {
const pkgScripts = (this.packageJson['scripts'] as {}) || {}
// Pin the same pnpm version the repo uses so corepack resolves a
// consistent pnpm across isolated test dirs. Mirrors the logic in
// `create-next-install.js` so skipInstall / skipIsolatedNext test
// dirs behave the same as regular ones.
const rootPackageManager = require(
path.join(__dirname, '../../../package.json')
).packageManager
const packageManagerField =
(this.packageJson as { packageManager?: string }).packageManager ||
rootPackageManager
await fs.mkdir(this.testDir, { recursive: true })
await fs.writeFile(
path.join(this.testDir, 'package.json'),
JSON.stringify(
{
// Pin packageManager so corepack doesn't auto-inject a reference
// to the latest version (and rewrite this file mid-test).
// Callers can override via packageJson.packageManager.
packageManager: ROOT_PACKAGE_MANAGER,
...this.packageJson,
...(packageManagerField && {
packageManager: packageManagerField,
}),
dependencies: {
...finalDependencies,
next:
process.env.NEXT_TEST_VERSION ||
require('next/package.json').version,
},
...(this.resolutions ? { resolutions: this.resolutions } : {}),
scripts: {
...(isNextDeploy
? // since we can't get the build id as a build artifact,
// add it in build logs
{
'post-build': `node -e 'console.log("BUILD" + "_ID: " + fs.readFileSync("${this.distDir}/BUILD_ID") + "\\nDEPLOYMENT" + "_ID: " + process.env.NEXT_DEPLOYMENT_ID + "\\nNEXT_SUPPORTS_IMMUTABLE" + "_ASSETS: " + ((process.env.VERCEL_IMMUTABLE_STATIC_FILES_ENABLED && process.env.NEXT_ENABLE_ADAPTER==="1") ? 1 : 0))'`,
}
: {}),
...pkgScripts,
build:
(pkgScripts['build'] || this.buildCommand || 'next build') +
(this.buildArgs?.length
? ` ${this.buildArgs.join(' ')}`
: '') +
' && pnpm post-build',
},
},
null,
2
)
)
await this.beforeInstall(parentSpan)
} else {
if (
process.env.NEXT_TEST_STARTER &&
!this.dependencies &&
!this.installCommand &&
!this.packageJson &&
!isNextDeploy
) {
await fs.cp(process.env.NEXT_TEST_STARTER, this.testDir, {
recursive: true,
})
require('console').log(
'created next.js install, writing test files'
)
await this.beforeInstall(parentSpan)
} else {
await createNextInstall({
parentSpan: rootSpan,
dependencies: finalDependencies,
resolutions: this.resolutions ?? null,
installCommand: this.installCommand,
packageJson: this.packageJson,
subDir: this.subDir,
beforeInstall: async (span, installDir) => {
this.testDir = installDir
require('console').log(
'created next.js install, writing test files'
)
await this.beforeInstall(span)
},
})
}
}
if (this.deleteWorkspaceFile) {
await fs.rm(path.join(this.testDir, 'pnpm-workspace.yaml'), {
force: true,
})
}
const testDirFiles = await fs.readdir(this.testDir)
let nextConfigFile = testDirFiles.find((file) =>
file.startsWith('next.config.')
)
if (nextConfigFile && this.nextConfig) {
throw new Error(
`nextConfig provided on "nextTestSetup()" and as a file "${nextConfigFile}", use one or the other to continue`
)
}
if (this.nextConfig?.distDir) {
this.distDir = this.nextConfig.distDir
}
// Same logic as we get the basePath in isNextDeploy
if (nextConfigFile) {
const content = await fs.readFile(
path.join(this.testDir, nextConfigFile),
'utf8'
)
if (content.includes('distDir')) {
const match = content.match(
/['"`]?distDir['"`]?:.*?['"`](.*?)['"`]/
)?.[1]
if (match) {
this.distDir = match
}
}
}
if (this.nextConfig || (isNextDeploy && !nextConfigFile)) {
const functions: string[] = []
const exportDeclare =
this.packageJson?.type === 'module'
? 'export default'
: 'module.exports = '
await fs.writeFile(
path.join(this.testDir, 'next.config.js'),
exportDeclare +
JSON.stringify(
{
...this.nextConfig,
} as NextConfig,
(key, val: unknown) => {
if (typeof val === 'function') {
functions.push(
val
.toString()
.replace(
new RegExp(`${val.name}[\\s]{0,}\\(`),
'function('
)
)
return `__func_${functions.length - 1}`
}
return val
},
2
).replace(/"__func_[\d]{1,}"/g, function (str) {
return functions.shift()!
})
)
}
const tsConfigTestFile = testDirFiles.find(
(file) => file === 'tsconfig.test.json'
)
if (tsConfigTestFile) {
require('console').log(
'tsconfig.test.json found, using it for this test'
)
await fs.copyFile(
path.join(this.testDir, 'tsconfig.test.json'),
path.join(this.testDir, 'tsconfig.json')
)
}
if (isNextDeploy) {
const fileName = path.join(
this.testDir,
nextConfigFile || 'next.config.js'
)
const content = await fs.readFile(fileName, 'utf8')
if (content.includes('basePath')) {
this.basePath =
content.match(/['"`]?basePath['"`]?:.*?['"`](.*?)['"`]/)?.[1] ||
''
}
await fs.writeFile(
fileName,
`${content}\n` +
`
// alias __NEXT_TEST_MODE for next-deploy as "_" is not a valid
// env variable during deploy
if (process.env.NEXT_PRIVATE_TEST_MODE) {
process.env.__NEXT_TEST_MODE = process.env.NEXT_PRIVATE_TEST_MODE
}
// alias experimental feature flags for deployment compatibility
if (process.env.NEXT_PRIVATE_EXPERIMENTAL_CACHE_COMPONENTS) {
process.env.__NEXT_CACHE_COMPONENTS = process.env.NEXT_PRIVATE_EXPERIMENTAL_CACHE_COMPONENTS
}
if (process.env.NEXT_PRIVATE_EXPERIMENTAL_CACHED_NAVIGATIONS) {
process.env.__NEXT_EXPERIMENTAL_CACHED_NAVIGATIONS = process.env.NEXT_PRIVATE_EXPERIMENTAL_CACHED_NAVIGATIONS
}
if (process.env.NEXT_PRIVATE_EXPERIMENTAL_APP_NEW_SCROLL_HANDLER) {
process.env.__NEXT_EXPERIMENTAL_APP_NEW_SCROLL_HANDLER = process.env.NEXT_PRIVATE_EXPERIMENTAL_APP_NEW_SCROLL_HANDLER
}
`
)
if (
testDirFiles.includes('node_modules') &&
!testDirFiles.includes('vercel.json')
) {
// Tests that include a patched node_modules dir won't automatically be uploaded to Vercel.
// We need to ensure node_modules is not excluded from the deploy files, and tweak the
// start + build commands to handle copying the patched node modules into the final.
// To be extra safe, we only do this if the test directory doesn't already have a custom vercel.json
require('console').log(
'Detected node_modules in the test directory, writing `vercel.json` and `.vercelignore` to ensure its included.'
)
await fs.writeFile(
path.join(this.testDir, 'vercel.json'),
JSON.stringify({
installCommand:
'mv node_modules node_modules.bak && npm i && cp -r node_modules.bak/* node_modules',
})
)
await fs.writeFile(
path.join(this.testDir, '.vercelignore'),
'!node_modules'
)
}
}
})
}
protected setServerReadyTimeout(
reject: (reason?: unknown) => void,
ms: number
): NodeJS.Timeout {
return setTimeout(() => {
reject(
new Error(
`Failed to start server after ${ms}ms, waiting for this log pattern: ${this.serverReadyPattern}`
)
)
}, ms)
}
// normalize snapshots or stack traces being tested
// to a consistent test dir value since it's random
public normalizeTestDirContent(content) {
content = content.replace(
new RegExp(escapeStringRegexp(this.testDir), 'g'),
'TEST_DIR'
)
return content
}
public async clean() {
if (this.childProcess) {
throw new Error(`stop() must be called before cleaning`)
}
const keptFiles = [
'node_modules',
'package.json',
'yarn.lock',
'pnpm-lock.yaml',
]
for (const file of await fs.readdir(this.testDir)) {
if (!keptFiles.includes(file)) {
await fs.rm(path.join(this.testDir, file), {
recursive: true,
force: true,
})
}
}
await this.writeInitialFiles()
}
public async build(options?: {
env?: Record<string, string>
args?: string[]
}): Promise<{
exitCode: NodeJS.Signals | number | null
cliOutput: string
}> {
throw new Error('Not implemented')
}
/**
* Run an arbitrary Next.js CLI command in the isolated test directory.
*
* Unlike `build()`/`start()`, this does not start or manage the persistent
* server lifecycle, so it is suitable for commands like `next info`,
* `next telemetry`, `next --help`, `next --version`, `next export`, etc.
*
* When testing long-running commands (e.g. `next dev` for CLI-only
* assertions), pass `instance` to capture the `ChildProcess` and kill it
* externally from the test.
*
* Use in conjunction with `skipStart: true` in `nextTestSetup()` so the
* helper does not spawn an internal build/start first.
*/
public async runCommand(
args: string[],
options: {
env?: Record<string, string>
cwd?: string
onStdout?: (msg: string) => void
onStderr?: (msg: string) => void
/**
* Receives the spawned ChildProcess while it is running, allowing the
* caller to kill it externally (e.g. to test signal handling or
* long-running commands like `next dev`).
*/
instance?: (childProcess: ChildProcess) => void
/**
* If true, a non-zero exit code will not reject the promise.
* Defaults to true since callers typically want to assert on the exit
* code / stderr themselves.
*/
ignoreFail?: boolean
/**
* Abort signal to terminate the child process early.
*/
signal?: AbortSignal
} = {}
): Promise<{
exitCode: NodeJS.Signals | number | null
code: number | null
signal: NodeJS.Signals | null
stdout: string
stderr: string
cliOutput: string
}> {
const {
env,
cwd,
onStdout,
onStderr,
instance,
ignoreFail = true,
signal,
} = options
// Resolve the `next` binary from within the isolated test directory so
// that it uses the locally installed version (matching the peer React
// version etc.). Spawning the binary directly (rather than via `pnpm`)
// also means signals sent to the child are delivered to the Next.js
// process without an intermediate wrapper.
//
// When running with NEXT_SKIP_ISOLATE there is no isolated install, so
// fall back to the workspace-level next binary instead (which also
// avoids a pnpm wrapper swallowing signals).
const localNextBin = path.join(
this.testDir,
'node_modules',
'next',
'dist',
'bin',
'next'
)
const workspaceNextBin = path.join(
__dirname,
'..',
'..',
'..',
'node_modules',
'next',
'dist',
'bin',
'next'
)
const nextBin = existsSync(localNextBin) ? localNextBin : workspaceNextBin
const spawnArgs = ['node', '--no-deprecation', nextBin, ...args]
const spawnOpts: import('child_process').SpawnOptions = {
cwd: cwd ?? this.testDir,
stdio: ['ignore', 'pipe', 'pipe'],
shell: false,
env: {
...process.env,
...this.env,
...env,
NODE_ENV: (env?.NODE_ENV ?? this.env.NODE_ENV ?? '') as any,
__NEXT_TEST_MODE: 'e2e',
},
}
require('console').log('running', shellQuote(spawnArgs))
return new Promise((resolve, reject) => {
const child = spawn(spawnArgs[0], spawnArgs.slice(1), spawnOpts)
let stdout = ''
let stderr = ''
let cliOutput = ''
child.stdout!.on('data', (chunk) => {
const msg = chunk.toString()
stdout += msg
cliOutput += msg
onStdout?.(msg)
})
child.stderr!.on('data', (chunk) => {
const msg = chunk.toString()
stderr += msg
cliOutput += msg
onStderr?.(msg)
})
let aborted = false
const onAbort = () => {
aborted = true
try {
if (child.pid != null) {
treeKill(child.pid, 'SIGKILL', () => {})
}
} catch {}
}
if (signal) {
if (signal.aborted) {
onAbort()
} else {
signal.addEventListener('abort', onAbort, { once: true })
}
}
child.on('error', (err) => {
if (signal) signal.removeEventListener('abort', onAbort)
if (ignoreFail) {
resolve({
exitCode: 1,
code: 1,
signal: null,
stdout,
stderr: stderr + '\nSpawn error: ' + err.message,
cliOutput: cliOutput + '\nSpawn error: ' + err.message,
})
} else {
reject(err)
}
})
child.on('exit', (code, exitSignal) => {
if (signal) signal.removeEventListener('abort', onAbort)
const result = {
exitCode: exitSignal ?? code,
code,
signal: exitSignal,
stdout,
stderr,
cliOutput,
}
if (!ignoreFail && !aborted && code !== 0) {
const err = new Error(
`\`${shellQuote(spawnArgs)}\` exited with code ${code}${
exitSignal ? ` (signal ${exitSignal})` : ''
}`
)
;(err as any).result = result
reject(err)
return
}
resolve(result)
})
instance?.(child)
})
}
public async setup(parentSpan: Span): Promise<void> {
if (this.forcedPort === 'random') {
this.forcedPort = (await findPort()) + ''
console.log('Forced random port:', this.forcedPort)
}
}
public async start(options?: {
skipBuild?: boolean
env?: Record<string, string>
}): Promise<void> {}
public async stop(
signal: 'SIGINT' | 'SIGTERM' | 'SIGKILL' = 'SIGKILL'
): Promise<void> {
if (this.childProcess) {
if (this.isStopping !== null) {
// warn for debugging, but don't prevent sending two signals in succession
// (e.g. SIGINT and then SIGKILL)
require('console').error(
`Next server is already being stopped (received signal: ${signal}): `,
this.isStopping
)
}
this.isStopping = Error()
Error.captureStackTrace(this.isStopping, this.stop)
const closePromise = once(this.childProcess, 'close')
await new Promise<void>((resolve) => {
treeKill(this.childProcess!.pid!, signal, (err) => {
if (err) {
require('console').error('tree-kill', err)
}
resolve()
})
})
this.childProcess.kill(signal)
await closePromise
this.childProcess = undefined
this.isStopping = null
require('console').log(`Stopped next server`)
}
}
public async destroy(): Promise<void> {
try {
require('console').time('destroyed next instance')
if (this.isDestroyed) {
throw new Error(`next instance already destroyed`, {
cause: this.isDestroyed,
})
}
this.isDestroyed = Error()
Error.captureStackTrace(this.isDestroyed, this.destroy)
this.emit('destroy', [])
await this.stop().catch(console.error)
if (process.env.TRACE_PLAYWRIGHT) {
await fs
.cp(
path.join(this.testDir, this.distDir, 'trace'),
path.join(
__dirname,
'../../traces',
`${path
.relative(
path.join(__dirname, '../../'),
process.env.TEST_FILE_PATH!
)
.replace(/\//g, '-')}`,
`next-trace`
),
{ recursive: true }
)
.catch((e) => {
require('console').error(e)
})
}
if (!process.env.NEXT_TEST_SKIP_CLEANUP) {
// Faster than `await fs.rm`. Benchmark before change.
rmSync(this.testDir, { recursive: true, force: true })
}
require('console').timeEnd(`destroyed next instance`)
} catch (err) {
require('console').error('Error while destroying', err)
}
}
public get url() {
return this._url
}
public get appPort() {
return this._parsedUrl.port
}
public get buildId(): string {
return ''
}
public get deploymentId(): string | undefined {
return undefined
}
public getDeploymentIdQuery(ampersand: boolean = false): string | undefined {
const prefix = ampersand ? '&' : '?'
return this.deploymentId ? `${prefix}dpl=${this.deploymentId}` : ''
}
public get supportsImmutableAssets(): boolean {
return false
}
public get assetToken(): string | undefined {
return this.supportsImmutableAssets ? undefined : this.deploymentId
}
public getAssetQuery(ampersand: boolean = false): string | undefined {
const prefix = ampersand ? '&' : '?'
return this.assetToken ? `${prefix}dpl=${this.assetToken}` : ''
}
public get cliOutput(): string {
return ''
}
protected throwIfUnavailable(): void | never {
if (this.isStopping !== null) {
throw new Error('Next.js is no longer available.', {
cause: this.isStopping,
})
}
if (this.isDestroyed !== null) {
throw new Error('Next.js is no longer available.', {
cause: this.isDestroyed,
})
}
if (this.childProcess === undefined) {
throw new Error('No child process available')
}
}
// TODO: block these in deploy mode
public async hasFile(filename: string) {
return existsSync(path.join(this.testDir, filename))
}
public async readFile(filename: string) {
return fs.readFile(path.join(this.testDir, filename), 'utf8')
}
public async readFileBuffer(
filename: string
): Promise<Buffer<ArrayBufferLike>> {
return fs.readFile(path.join(this.testDir, filename))
}
public async writeFileBuffer(filename: string, data: Buffer): Promise<void> {
return fs.writeFile(path.join(this.testDir, filename), data)
}
public async readFiles(
dirname: string,
predicate: (filename: string) => boolean
) {
const absoluteDirname = path.join(this.testDir, dirname)
const filenames = await fs.readdir(absoluteDirname, 'utf-8')
return Promise.all(
filenames
.filter(predicate)
.map((filename) =>
fs.readFile(path.join(absoluteDirname, filename), 'utf8')
)
)
}
public readFileSync(filename: string) {
return readFileSync(path.join(this.testDir, filename), 'utf8')
}
public async readJSON(filename: string) {
return JSON.parse(
await fs.readFile(path.join(this.testDir, filename), 'utf-8')
)
}
public async remove(fileOrDirPath: string) {
await fs.rm(path.join(this.testDir, fileOrDirPath), {
recursive: true,
force: true,
})
}
public async patchFile(
filename: string,
content: string | ((content: string | undefined) => string),
runWithTempContent?: (context: { newFile: boolean }) => Promise<void>
): Promise<{ newFile: boolean }> {
const outputPath = path.join(this.testDir, filename)
const newFile = !existsSync(outputPath)
await fs.mkdir(path.dirname(outputPath), { recursive: true })
const previousContent = newFile ? undefined : await this.readFile(filename)
await fs.writeFile(
outputPath,
typeof content === 'function' ? content(previousContent) : content,
{
flush: true,
}
)
if (runWithTempContent) {
try {
await runWithTempContent({ newFile })
} finally {
if (previousContent === undefined) {
await fs.rm(outputPath)
} else {
await fs.writeFile(outputPath, previousContent, {
flush: true,
})
}
}
}
return { newFile }
}
public async renameFile(filename: string, newFilename: string) {
await fs.rename(
path.join(this.testDir, filename),
path.join(this.testDir, newFilename)
)
}
/**
* Makes `linkFilename` point to `targetFilename`.
*
* Performs an atomic update to the symlink:
* https://blog.moertel.com/posts/2005-08-22-how-to-change-symlinks-atomically.html
*/
public async symlink(targetFilename: string, linkFilename: string) {
const tmpLinkPath = path.join(this.testDir, linkFilename + '.tmp')
try {
await fs.symlink(path.join(this.testDir, targetFilename), tmpLinkPath)
await fs.rename(tmpLinkPath, path.join(this.testDir, linkFilename))
} catch (e) {
await fs.unlink(tmpLinkPath)
throw e
}
}
public async renameFolder(foldername: string, newFoldername: string) {
await fs.rename(
path.join(this.testDir, foldername),
path.join(this.testDir, newFoldername)
)
}
public async deleteFile(filename: string) {
await fs.rm(path.join(this.testDir, filename), {
recursive: true,
force: true,
})
}
/**
* Create a new browser window for the Next.js app.
*/
public async browser(
...args: Parameters<OmitFirstArgument<typeof webdriver>>
): Promise<Playwright> {
// When `baseUrl` is provided the test is driving a separate server (proxy,
// static-export server, etc.), so we don't require the Next.js server to
// be running.
const baseUrl = args[1]?.baseUrl
if (baseUrl === undefined) {
try {
this.throwIfUnavailable()
} catch (error) {
Error.captureStackTrace(error, this.browser)
throw error
}
}
return webdriver(this.url, ...args)
}
/**
* Create a new browser window for the Next.js app, and also return the page's
* response.
*/
public async browserWithResponse(
...args: Parameters<OmitFirstArgument<typeof webdriver>>
): Promise<{ browser: Playwright; response: Response }> {
try {
this.throwIfUnavailable()
} catch (error) {
Error.captureStackTrace(error, this.browserWithResponse)
throw error
}
const [url, options = {}] = args
let resolveResponse: (response: Response) => void
const responsePromise = new Promise<Response>((resolve, reject) => {
const timer = setTimeout(() => {
reject(`Timed out waiting for the response of ${url}`)
}, 30_000)
resolveResponse = (response: Response) => {
clearTimeout(timer)
resolve(response)
}
})
const absoluteUrl = new URL(url, this.url).href
const [browser, response] = await Promise.all([
webdriver(this.url, url, {
...options,
async beforePageLoad(page: Page) {
await options.beforePageLoad?.(page)
page.on('response', async (response) => {
if (response.url() === absoluteUrl) {
resolveResponse(response)
}
})
},
}),
responsePromise,
])
return { browser, response }
}
/**
* Fetch the HTML for the provided page. This is a shortcut for `renderViaHTTP().then(html => cheerio.load(html))`.
*/
public async render$(
...args: Parameters<OmitFirstArgument<typeof renderViaHTTP>>
): Promise<ReturnType<typeof cheerio.load>> {
try {
this.throwIfUnavailable()
} catch (error) {
Error.captureStackTrace(error, this.render$)
throw error
}
const html = await renderViaHTTP(this.url, ...args)
return cheerio.load(html)
}
/**
* Fetch the HTML for the provided page. This is a shortcut for `fetchViaHTTP().then(res => res.text())`.
*/
public async render(
...args: Parameters<OmitFirstArgument<typeof renderViaHTTP>>
) {
try {
this.throwIfUnavailable()
} catch (error) {
Error.captureStackTrace(error, this.render)
throw error
}
return renderViaHTTP(this.url, ...args)
}
/**
* Performs a fetch request to the NextInstance with the options provided.
*
* @param pathname the pathname on the NextInstance to fetch
* @param opts the optional options to pass to the underlying fetch
* @returns the fetch response
*/
public async fetch(
pathname: string,
opts?: import('node-fetch').RequestInit
) {
try {
this.throwIfUnavailable()
} catch (error) {
Error.captureStackTrace(error, this.fetch)
throw error
}
return fetchViaHTTP(this.url, pathname, null, opts)
}
public on(event: Event, cb: (...args: any[]) => any) {
if (!this.events[event]) {
this.events[event] = new Set()
}
this.events[event].add(cb)
}
public off(event: Event, cb: (...args: any[]) => any) {
this.events[event]?.delete(cb)
}
protected emit(event: Event, args: any[]) {
this.events[event]?.forEach((cb) => {
cb(...args)
})
}
public getCliOutputFromHere() {
const length = this.cliOutput.length
return () => {
return this.cliOutput.slice(length)
}
}
public async waitForMinPrerenderAge(minAgeMS: number): Promise<void> {
// For tests we usually have a low revalidate time.
// We assume the prerender is old enough by default for those small revalidation times.
}
}