next.js/test/lib/next-modes/next-dev.ts
next-dev.ts348 lines10.0 KB
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) {
      // without isolation yarn can't be used and pnpm must be used instead
      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 &&
      !process.env.NEXT_TEST_WASM_AFTER_JEST &&
      ((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) {
      // without isolation yarn can't be used and pnpm must be used instead
      if (startArgs[0] === 'yarn') {
        startArgs[0] = 'pnpm'
      }
    }

    require('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,
              })
            }
            // server might reload so we keep listening
            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) {
    // This is a temporary workaround for turbopack starting watching too late.
    // So we delay file changes by 500ms to give it some time
    // to connect the WebSocket and start watching.
    if (process.env.IS_TURBOPACK_TEST) {
      require('console').log('fs dev delay before', filename)
      await waitFor(500)
    }
  }

  private async handleDevWatchDelayAfterChange(filename: string) {
    // to help alleviate flakiness with tests that create
    // dynamic routes // and then request it we give a buffer
    // of 500ms to allow WatchPack to detect the changed files
    // TODO: replace this with an event directly from WatchPack inside
    // router-server for better accuracy
    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 the patch file is a next.config.js, we ignore the delay and wait server restart
        if (filename.startsWith('next.config')) {
          await detectServerRestart()
          return
        }

        if (this.patchFileDelay > 0) {
          require('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 {
        // It's intentional: when runWithTempContent is defined, we wait twice: once for the patch,
        // and once for the restore of the original file

        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)
  }
}
Quest for Codev2.0.0
/
SIGN IN