mirror of
https://github.com/hanzoai/next.js.git
synced 2026-09-24 17:00:51 +00:00
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.
1178 lines
36 KiB
TypeScript
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.
|
|
}
|
|
}
|