| | import spawn from 'cross-spawn' |
| | import { Span } from 'next/dist/trace' |
| | import { NextInstance } from './base' |
| | import { retry, waitFor } from 'next-test-utils' |
| | import stripAnsi from 'strip-ansi' |
| | import { quote as shellQuote } from 'shell-quote' |
| |
|
| | export class NextDevInstance extends NextInstance { |
| | private _cliOutput: string = '' |
| |
|
| | public get buildId() { |
| | return 'development' |
| | } |
| |
|
| | public async setup(parentSpan: Span) { |
| | super.setup(parentSpan) |
| | await super.createTestDir({ parentSpan }) |
| | } |
| |
|
| | public get cliOutput() { |
| | return this._cliOutput || '' |
| | } |
| |
|
| | private handleStdio = (childProcess) => { |
| | childProcess.stdout.on('data', (chunk) => { |
| | const msg = chunk.toString() |
| | process.stdout.write(chunk) |
| | this._cliOutput += msg |
| | this.emit('stdout', [msg]) |
| | }) |
| | childProcess.stderr.on('data', (chunk) => { |
| | const msg = chunk.toString() |
| | process.stderr.write(chunk) |
| | this._cliOutput += msg |
| | this.emit('stderr', [msg]) |
| | }) |
| | } |
| |
|
| | private getBuildArgs(args?: string[]) { |
| | let buildArgs = ['pnpm', 'next', 'build'] |
| |
|
| | if (this.buildCommand) { |
| | buildArgs = this.buildCommand.split(' ') |
| | } |
| |
|
| | if (this.buildArgs) { |
| | buildArgs.push(...this.buildArgs) |
| | } |
| |
|
| | if (args) { |
| | buildArgs.push(...args) |
| | } |
| |
|
| | if (process.env.NEXT_SKIP_ISOLATE) { |
| | |
| | if (buildArgs[0] === 'yarn') { |
| | buildArgs[0] = 'pnpm' |
| | } |
| | } |
| |
|
| | return buildArgs |
| | } |
| |
|
| | private getSpawnOpts( |
| | env?: Record<string, string> |
| | ): import('child_process').SpawnOptions { |
| | return { |
| | cwd: this.testDir, |
| | stdio: ['ignore', 'pipe', 'pipe'], |
| | shell: false, |
| | env: { |
| | ...process.env, |
| | ...this.env, |
| | ...env, |
| | NODE_ENV: this.env.NODE_ENV || ('' as any), |
| | PORT: this.forcedPort || '0', |
| | __NEXT_TEST_MODE: 'e2e', |
| | }, |
| | } |
| | } |
| |
|
| | public async build( |
| | options: { env?: Record<string, string>; args?: string[] } = {} |
| | ) { |
| | if (this.childProcess) { |
| | throw new Error( |
| | `can not run build while server is running, use next.stop() first` |
| | ) |
| | } |
| |
|
| | return new Promise<{ |
| | exitCode: NodeJS.Signals | number | null |
| | cliOutput: string |
| | }>((resolve) => { |
| | const curOutput = this._cliOutput.length |
| | const spawnOpts = this.getSpawnOpts(options.env) |
| | const buildArgs = this.getBuildArgs(options.args) |
| |
|
| | console.log('running', shellQuote(buildArgs)) |
| |
|
| | this.childProcess = spawn(buildArgs[0], buildArgs.slice(1), spawnOpts) |
| | this.handleStdio(this.childProcess) |
| |
|
| | this.childProcess.on('error', (error) => { |
| | this.childProcess = undefined |
| | resolve({ |
| | exitCode: 1, |
| | cliOutput: |
| | this.cliOutput.slice(curOutput) + '\nSpawn error: ' + error.message, |
| | }) |
| | }) |
| |
|
| | this.childProcess.on('exit', (code, signal) => { |
| | this.childProcess = undefined |
| | resolve({ |
| | exitCode: signal || code, |
| | cliOutput: this.cliOutput.slice(curOutput), |
| | }) |
| | }) |
| | }) |
| | } |
| |
|
| | public async start() { |
| | if (this.childProcess) { |
| | throw new Error('next already started') |
| | } |
| |
|
| | const useTurbo = |
| | !process.env.NEXT_TEST_WASM && |
| | ((this as any).turbo || (this as any).experimentalTurbo) |
| |
|
| | let startArgs = [ |
| | 'pnpm', |
| | 'next', |
| | useTurbo ? '--turbopack' : undefined, |
| | ].filter(Boolean) as string[] |
| |
|
| | if (this.startCommand) { |
| | startArgs = this.startCommand.split(' ') |
| | } |
| |
|
| | if (this.startArgs) { |
| | startArgs.push(...this.startArgs) |
| | } |
| |
|
| | if (process.env.NEXT_SKIP_ISOLATE) { |
| | |
| | if (startArgs[0] === 'yarn') { |
| | startArgs[0] = 'pnpm' |
| | } |
| | } |
| |
|
| | console.log('running', shellQuote(startArgs)) |
| | await new Promise<void>((resolve, reject) => { |
| | try { |
| | this.childProcess = spawn(startArgs[0], startArgs.slice(1), { |
| | cwd: this.testDir, |
| | stdio: ['ignore', 'pipe', 'pipe'], |
| | shell: false, |
| | env: { |
| | ...process.env, |
| | ...this.env, |
| | NODE_ENV: this.env.NODE_ENV || ('' as any), |
| | PORT: this.forcedPort || '0', |
| | __NEXT_TEST_MODE: 'e2e', |
| | }, |
| | }) |
| |
|
| | this._cliOutput = '' |
| |
|
| | this.childProcess.stdout!.on('data', (chunk) => { |
| | const msg = chunk.toString() |
| | process.stdout.write(chunk) |
| | this._cliOutput += msg |
| | this.emit('stdout', [msg]) |
| | }) |
| | this.childProcess.stderr!.on('data', (chunk) => { |
| | const msg = chunk.toString() |
| | process.stderr.write(chunk) |
| | this._cliOutput += msg |
| | this.emit('stderr', [msg]) |
| | }) |
| |
|
| | const serverReadyTimeoutId = this.setServerReadyTimeout( |
| | reject, |
| | this.startServerTimeout |
| | ) |
| |
|
| | this.childProcess.on('close', (code, signal) => { |
| | if (this.isStopping) return |
| | if (code || signal) { |
| | this.childProcess = undefined |
| | const error = new Error( |
| | `next dev exited unexpectedly with code/signal ${code || signal}` |
| | ) |
| | clearTimeout(serverReadyTimeoutId) |
| | require('console').error(error) |
| | reject(error) |
| | } |
| | }) |
| |
|
| | const readyCb = (msg) => { |
| | const resolveServer = () => { |
| | clearTimeout(serverReadyTimeoutId) |
| | try { |
| | this._parsedUrl = new URL(this._url) |
| | } catch (err) { |
| | reject({ |
| | err, |
| | msg, |
| | }) |
| | } |
| | |
| | resolve() |
| | } |
| |
|
| | const colorStrippedMsg = stripAnsi(msg) |
| | if (colorStrippedMsg.includes('- Local:')) { |
| | this._url = msg |
| | .split('\n') |
| | .find((line) => line.includes('- Local:')) |
| | .split(/\s*- Local:/) |
| | .pop() |
| | .trim() |
| | } |
| |
|
| | if (this.serverReadyPattern.test(colorStrippedMsg)) { |
| | resolveServer() |
| | } |
| | } |
| | this.on('stdout', readyCb) |
| | } catch (err) { |
| | require('console').error(`Failed to run ${shellQuote(startArgs)}`, err) |
| | setTimeout(() => process.exit(1), 0) |
| | } |
| | }) |
| | } |
| |
|
| | private async handleDevWatchDelayBeforeChange(filename: string) { |
| | |
| | |
| | |
| | if (process.env.IS_TURBOPACK_TEST) { |
| | require('console').log('fs dev delay before', filename) |
| | await waitFor(500) |
| | } |
| | } |
| |
|
| | private async handleDevWatchDelayAfterChange(filename: string) { |
| | |
| | |
| | |
| | |
| | |
| | if (filename.startsWith('app/') || filename.startsWith('pages/')) { |
| | require('console').log('fs dev delay', filename) |
| | await new Promise((resolve) => setTimeout(resolve, 500)) |
| | } |
| | } |
| |
|
| | public override async patchFile( |
| | filename: string, |
| | content: string | ((content: string) => string), |
| | runWithTempContent?: (context: { newFile: boolean }) => Promise<void> |
| | ) { |
| | await this.handleDevWatchDelayBeforeChange(filename) |
| | try { |
| | let cliOutputLength = this.cliOutput.length |
| | const isServerRunning = this.childProcess && !this.isStopping |
| |
|
| | const detectServerRestart = async () => { |
| | await retry(async () => { |
| | const isServerReady = this.serverReadyPattern.test( |
| | this.cliOutput.slice(cliOutputLength) |
| | ) |
| | if (isServerRunning && !isServerReady) { |
| | throw new Error('Server has not finished restarting.') |
| | } |
| | }, 5000) |
| | } |
| |
|
| | const waitServerToBeReadyAfterPatchFile = async () => { |
| | if (!isServerRunning) { |
| | return |
| | } |
| |
|
| | |
| | if (filename.startsWith('next.config')) { |
| | await detectServerRestart() |
| | return |
| | } |
| |
|
| | if (this.patchFileDelay > 0) { |
| | console.warn( |
| | `Applying patch delay of ${this.patchFileDelay}ms. Note: Introducing artificial delays is generally discouraged, as it may affect test reliability. However, this delay is configurable on a per-test basis.` |
| | ) |
| | await waitFor(this.patchFileDelay) |
| | return |
| | } |
| | } |
| |
|
| | try { |
| | return await super.patchFile( |
| | filename, |
| | content, |
| | runWithTempContent |
| | ? async (...args) => { |
| | await waitServerToBeReadyAfterPatchFile() |
| | cliOutputLength = this.cliOutput.length |
| |
|
| | return runWithTempContent(...args) |
| | } |
| | : undefined |
| | ) |
| | } finally { |
| | |
| | |
| |
|
| | await waitServerToBeReadyAfterPatchFile() |
| | } |
| | } finally { |
| | await this.handleDevWatchDelayAfterChange(filename) |
| | } |
| | } |
| |
|
| | public override async renameFile(filename: string, newFilename: string) { |
| | await this.handleDevWatchDelayBeforeChange(filename) |
| | await super.renameFile(filename, newFilename) |
| | await this.handleDevWatchDelayAfterChange(filename) |
| | } |
| |
|
| | public override async renameFolder( |
| | foldername: string, |
| | newFoldername: string |
| | ) { |
| | await this.handleDevWatchDelayBeforeChange(foldername) |
| | await super.renameFolder(foldername, newFoldername) |
| | await this.handleDevWatchDelayAfterChange(foldername) |
| | } |
| |
|
| | public override async deleteFile(filename: string) { |
| | await this.handleDevWatchDelayBeforeChange(filename) |
| | await super.deleteFile(filename) |
| | await this.handleDevWatchDelayAfterChange(filename) |
| | } |
| | } |
| |
|