next.js/test/lib/next-modes/next-deploy.ts
next-deploy.ts556 lines16.7 KB
import os from 'os'
import path from 'path'
import execa from 'execa'
import fs from 'fs-extra'
import { NextInstance } from './base'
import * as projectEnv from '../../../scripts/reset-project.mjs'
import { Span } from 'next/dist/trace'

export class NextDeployInstance extends NextInstance {
  private _cliOutput: string
  private _buildId: string
  private _deploymentId: string | undefined
  private _supportsImmutableAssets: boolean = false
  private _writtenHostsLine: string | null = null

  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) {
      // deploy tests don't have access to the process
    }
  }

  public get buildId() {
    // get deployment ID via fetch since we can't access
    // build artifacts directly
    return this._buildId
  }

  public get deploymentId() {
    return this._deploymentId
  }

  public get supportsImmutableAssets() {
    return process.env.IS_TURBOPACK_TEST ? this._supportsImmutableAssets : false
  }

  private async deployUsingCustomScript(): Promise<{ url: string }> {
    const deployScriptPath = process.env.NEXT_TEST_DEPLOY_SCRIPT_PATH!

    require('console').log(
      `Deploying project using custom script: ${deployScriptPath}`
    )

    // Prepare environment variables to pass to the deploy script
    const scriptEnv = {
      ...process.env,
      // Pass the test directory to the script
      NEXT_TEST_DIR: this.testDir,
      // Pass test-specific env vars
      ...this.env,
    }

    const deployRes = await execa(deployScriptPath, [], {
      cwd: this.testDir,
      env: scriptEnv,
      reject: false,
      stderr: 'inherit',
    })

    if (deployRes.exitCode !== 0) {
      throw new Error(
        `Custom deploy script failed: ${deployRes.stdout} ${deployRes.stderr} (${deployRes.exitCode})`
      )
    }

    // The script should output the deployment URL to stdout
    const url = deployRes.stdout.trim()
    if (!url) {
      throw new Error(
        'Custom deploy script did not return a deployment URL on stdout'
      )
    }

    // Validate it's a proper URL
    try {
      new URL(url)
    } catch (err) {
      throw new Error(`Custom deploy script returned invalid URL: ${url}`, {
        cause: err,
      })
    }

    return { url }
  }

  private async fetchBuildLogsUsingCustomScript(): Promise<string> {
    const logsScriptPath = process.env.NEXT_TEST_DEPLOY_LOGS_SCRIPT_PATH!

    require('console').log(
      `Fetching build logs using custom script: ${logsScriptPath}`
    )

    const scriptEnv = {
      ...process.env,
      NEXT_TEST_DIR: this.testDir,
      // Pass the deployment URL to the logs script
      NEXT_TEST_DEPLOY_URL: this._url,
      ...this.env,
    }

    const logsRes = await execa(logsScriptPath, [], {
      cwd: this.testDir,
      env: scriptEnv,
      reject: false,
    })

    if (logsRes.exitCode !== 0) {
      throw new Error(
        `Custom deploy logs script failed: ${logsRes.stdout} ${logsRes.stderr} (${logsRes.exitCode})`
      )
    }

    // The script should output the build logs to stdout
    return logsRes.stdout + logsRes.stderr
  }

  private async cleanupUsingCustomScript(): Promise<void> {
    const cleanupScriptPath = process.env.NEXT_TEST_CLEANUP_SCRIPT_PATH!

    require('console').log(
      `Running cleanup using custom script: ${cleanupScriptPath}`
    )

    const scriptEnv = {
      ...process.env,
      NEXT_TEST_DIR: this.testDir,
      NEXT_TEST_DEPLOY_URL: this._url,
      ...this.env,
    }

    const cleanupChild = execa(cleanupScriptPath, [], {
      cwd: this.testDir,
      env: scriptEnv,
      reject: false,
      stderr: 'inherit',
    })

    cleanupChild.stdout?.pipe(process.stdout)
    cleanupChild.stderr?.pipe(process.stderr)

    const { exitCode } = await cleanupChild

    if (exitCode !== 0) {
      throw new Error(
        `Custom cleanup script failed with exit code: ${exitCode}`
      )
    }
  }

  private parseIdsFromCliOuput(): void {
    const buildId = this._cliOutput.match(/BUILD_ID: (.+)/)?.[1]?.trim()
    if (!buildId) {
      throw new Error(`Failed to get buildId from logs ${this._cliOutput}`)
    }
    this._buildId = buildId
    const deploymentId = this._cliOutput
      .match(/DEPLOYMENT_ID: (.+)/)?.[1]
      ?.trim()
    if (!deploymentId) {
      throw new Error(`Failed to get deploymentId from logs ${this._cliOutput}`)
    }
    this._deploymentId = deploymentId
    const supportsImmutableAssets = this._cliOutput
      .match(/NEXT_SUPPORTS_IMMUTABLE_ASSETS: (.+)/)?.[1]
      ?.trim()
    if (!supportsImmutableAssets) {
      throw new Error(
        `Failed to get supportsImmutableAssets from logs ${this._cliOutput}`
      )
    }
    this._supportsImmutableAssets =
      supportsImmutableAssets === '1' ? true : false

    require('console').log(
      `Got buildId: ${this._buildId}, deploymentId: ${this._deploymentId}, supportsImmutableAssets: ${this._supportsImmutableAssets}`
    )
  }

  public async setup(parentSpan: Span) {
    super.setup(parentSpan)
    await super.createTestDir({ parentSpan, skipInstall: true })

    const existingDeployUrl = process.env.NEXT_TEST_DEPLOY_URL?.trim()
    const customDeployScriptPath =
      process.env.NEXT_TEST_DEPLOY_SCRIPT_PATH?.trim()
    const customLogsScriptPath =
      process.env.NEXT_TEST_DEPLOY_LOGS_SCRIPT_PATH?.trim()

    // Check if using an existing deployment URL (takes priority)
    if (existingDeployUrl) {
      try {
        this._url = new URL(existingDeployUrl).toString()
      } catch (err) {
        throw new Error(
          `Invalid NEXT_TEST_DEPLOY_URL value: ${existingDeployUrl}`,
          { cause: err }
        )
      }
      require('console').log(`Using existing deployment URL: ${this._url}`)

      this._parsedUrl = new URL(this._url)

      // Configure proxy address if needed
      await this.configureProxyAddress()

      // Use custom logs script if provided, otherwise use Vercel CLI
      if (customLogsScriptPath) {
        this._cliOutput = await this.fetchBuildLogsUsingCustomScript()
      } else {
        // Use vercel inspect to get logs for existing deployment
        const buildLogs = await execa(
          'vercel',
          ['inspect', '--logs', this._url],
          {
            env: process.env,
            reject: false,
          }
        )
        if (buildLogs.exitCode !== 0) {
          throw new Error(
            `Failed to get build output logs: ${buildLogs.stderr}`
          )
        }
        this._cliOutput = buildLogs.stdout + buildLogs.stderr
      }

      this.parseIdsFromCliOuput()
      return
    }

    // Check if using custom deploy script
    if (customDeployScriptPath) {
      if (!customLogsScriptPath) {
        throw new Error(
          'NEXT_TEST_DEPLOY_LOGS_SCRIPT_PATH is required when using NEXT_TEST_DEPLOY_SCRIPT_PATH'
        )
      }

      const { url } = await this.deployUsingCustomScript()
      this._url = url

      this._parsedUrl = new URL(this._url)

      // Configure proxy address if needed
      await this.configureProxyAddress()

      require('console').log(`Deployment URL: ${this._url}`)

      // Use the custom logs script to get build logs and extract buildId
      this._cliOutput = await this.fetchBuildLogsUsingCustomScript()
      this.parseIdsFromCliOuput()
      return
    }

    // Original Vercel CLI deployment logic
    // ensure Vercel CLI is installed
    try {
      const res = await execa('vercel', ['--version'])
      require('console').log(`Using Vercel CLI version:`, res.stdout)
    } catch (_) {
      require('console').log(`Installing Vercel CLI`)
      await execa('npm', ['i', '-g', 'vercel@latest'], {
        stdio: 'inherit',
      })
    }

    const vercelFlags: string[] = []
    const NEXT_ENABLE_ADAPTER = process.env.NEXT_ENABLE_ADAPTER
    const IS_TURBOPACK_TEST = process.env.IS_TURBOPACK_TEST

    const TEST_TEAM_NAME = NEXT_ENABLE_ADAPTER
      ? projectEnv.ADAPTER_TEST_TEAM_NAME
      : IS_TURBOPACK_TEST
        ? projectEnv.TURBOPACK_TEST_TEAM_NAME
        : projectEnv.TEST_TEAM_NAME

    const TEST_TOKEN = NEXT_ENABLE_ADAPTER
      ? projectEnv.ADAPTER_TEST_TOKEN
      : IS_TURBOPACK_TEST
        ? projectEnv.TURBOPACK_TEST_TOKEN
        : projectEnv.TEST_TOKEN

    // If the team name is available in the environment, use it as the scope.
    if (TEST_TEAM_NAME) {
      vercelFlags.push('--scope', TEST_TEAM_NAME)
    }
    const vercelEnv = { ...process.env }

    // The Vercel CLI uses @vercel/detect-agent to detect when it's running
    // under an AI coding agent (Claude Code, Cursor, Codex, …) and, when it
    // does, switches `vercel deploy` stdout from a plain URL to a JSON
    // manifest intended for AI consumption — which breaks
    // `new URL(deployRes.stdout)` below. The CLI honors an explicit
    // `--non-interactive=false` as an override of the agent default, and the
    // JSON-vs-plain decision keys off `client.nonInteractive`, so passing
    // the flag is enough to force plain-URL output for both link and deploy.
    vercelFlags.push('--non-interactive=false')

    // If the token is available in the environment, use it as the token in the
    // environment.
    if (TEST_TOKEN) {
      vercelEnv.TOKEN = TEST_TOKEN
    }

    // create auth file in CI
    if (process.env.NEXT_TEST_JOB) {
      if (!TEST_TOKEN && !TEST_TEAM_NAME) {
        throw new Error(
          'Missing TEST_TOKEN and TEST_TEAM_NAME environment variables for CI'
        )
      }

      const vcConfigDir = path.join(os.homedir(), '.vercel')
      await fs.ensureDir(vcConfigDir)
      await fs.writeFile(
        path.join(vcConfigDir, 'auth.json'),
        JSON.stringify({ token: TEST_TOKEN })
      )
      vercelFlags.push('--global-config', vcConfigDir)
    }

    require('console').log(`Linking project at ${this.testDir}`)

    // link the project
    const linkRes = await execa(
      'vercel',
      ['link', '-p', projectEnv.TEST_PROJECT_NAME, '--yes', ...vercelFlags],
      {
        cwd: this.testDir,
        env: vercelEnv,
        reject: false,
      }
    )

    if (linkRes.exitCode !== 0) {
      throw new Error(
        `Failed to link project ${linkRes.stdout} ${linkRes.stderr} (${linkRes.exitCode})`
      )
    }
    require('console').log(`Deploying project at ${this.testDir}`)

    const additionalEnv: string[] = []

    for (const key of Object.keys(this.env || {})) {
      additionalEnv.push(`${key}=${this.env[key]}`)
    }

    additionalEnv.push(
      `VERCEL_CLI_VERSION=${process.env.VERCEL_CLI_VERSION || 'vercel@latest'}`
    )

    // Add experimental feature flags

    if (process.env.__NEXT_CACHE_COMPONENTS) {
      additionalEnv.push(
        `NEXT_PRIVATE_EXPERIMENTAL_CACHE_COMPONENTS=${process.env.__NEXT_CACHE_COMPONENTS}`
      )
    }
    if (process.env.__NEXT_EXPERIMENTAL_CACHED_NAVIGATIONS) {
      additionalEnv.push(
        `NEXT_PRIVATE_EXPERIMENTAL_CACHED_NAVIGATIONS=${process.env.__NEXT_EXPERIMENTAL_CACHED_NAVIGATIONS}`
      )
    }
    if (process.env.__NEXT_EXPERIMENTAL_APP_NEW_SCROLL_HANDLER) {
      additionalEnv.push(
        `NEXT_PRIVATE_EXPERIMENTAL_APP_NEW_SCROLL_HANDLER=${process.env.__NEXT_EXPERIMENTAL_APP_NEW_SCROLL_HANDLER}`
      )
    }
    if (process.env.IS_TURBOPACK_TEST) {
      additionalEnv.push(`IS_TURBOPACK_TEST=1`)
    }
    if (process.env.IS_WEBPACK_TEST) {
      additionalEnv.push(`IS_WEBPACK_TEST=1`)
    }
    if (process.env.NEXT_ENABLE_ADAPTER) {
      additionalEnv.push(`NEXT_ENABLE_ADAPTER=1`)
    } else {
      additionalEnv.push(`NEXT_ENABLE_ADAPTER=0`)
    }

    const deployRes = await execa(
      'vercel',
      [
        'deploy',
        '--build-env',
        'NEXT_PRIVATE_TEST_MODE=e2e',
        '--build-env',
        'NEXT_TELEMETRY_DISABLED=1',
        '--build-env',
        'VERCEL_NEXT_BUNDLED_SERVER=1',
        ...additionalEnv.flatMap((pair) => [
          '--env',
          pair,
          '--build-env',
          pair,
        ]),
        '--force',
        ...vercelFlags,
      ],
      {
        cwd: this.testDir,
        env: vercelEnv,
        reject: false,
        // This will print deployment information earlier to the console so we
        // don't have to wait until the deployment is complete to get the
        // inspect URL.
        stderr: 'inherit',
      }
    )

    if (deployRes.exitCode !== 0) {
      throw new Error(
        `Failed to deploy project ${deployRes.stdout} ${deployRes.stderr} (${deployRes.exitCode})`
      )
    }

    // the CLI gives just the deployment URL back when not a TTY
    this._url = deployRes.stdout
    this._parsedUrl = new URL(this._url)

    // Configure proxy address if needed
    await this.configureProxyAddress()

    require('console').log(`Deployment URL: ${this._url}`)

    // Use the vercel inspect command to get the CLI output from the build.
    const buildLogs = await execa(
      'vercel',
      ['inspect', '--logs', this._url, ...vercelFlags],
      {
        env: vercelEnv,
        reject: false,
      }
    )
    if (buildLogs.exitCode !== 0) {
      throw new Error(`Failed to get build output logs: ${buildLogs.stderr}`)
    }
    // TODO: Combine with runtime logs (via `vercel logs`)
    // Build logs seem to be piped to stderr, so we'll combine them to make sure we get all the logs.
    this._cliOutput = buildLogs.stdout + buildLogs.stderr

    this.parseIdsFromCliOuput()
    // Use the stdout from the logs command as the CLI output. The CLI will
    // output other unrelated logs to stderr.
  }

  private async configureProxyAddress(): Promise<void> {
    // If configured, we should configure the `/etc/hosts` file to point the
    // deployment domain to the specified proxy address.
    if (
      process.env.NEXT_TEST_PROXY_ADDRESS &&
      // Validate that the proxy address is a valid IP address.
      /^\d+\.\d+\.\d+\.\d+$/.test(process.env.NEXT_TEST_PROXY_ADDRESS)
    ) {
      this._writtenHostsLine = `${process.env.NEXT_TEST_PROXY_ADDRESS}\t${this._parsedUrl.hostname}\n`

      require('console').log(
        `Writing proxy address to hosts file: ${this._writtenHostsLine.trim()}`
      )

      // Using a child process, we'll use sudo to tee the hosts file to add the
      // proxy address to the target domain.
      await execa('sudo', ['tee', '-a', '/etc/hosts'], {
        input: this._writtenHostsLine,
        stdout: 'inherit',
        shell: true,
      })

      // Verify that the proxy address was written to the hosts file.
      const hostsFile = await fs.readFile('/etc/hosts', 'utf8')
      if (!hostsFile.includes(this._writtenHostsLine)) {
        throw new Error('Proxy address not found in hosts file after writing')
      }

      require('console').log(`Proxy address written to hosts file`)
    }
  }

  public async destroy() {
    // Run custom cleanup script if provided
    const customCleanupScriptPath =
      process.env.NEXT_TEST_CLEANUP_SCRIPT_PATH?.trim()
    if (customCleanupScriptPath) {
      await this.cleanupUsingCustomScript().catch((err) => {
        require('console').error(
          'Error running custom cleanup script, continuing with destroy:',
          err
        )
      })
    }

    // If configured, we should remove the proxy address from the hosts file.
    if (this._writtenHostsLine) {
      const trimmed = this._writtenHostsLine.trim()

      require('console').log(
        `Removing proxy address from hosts file: ${this._writtenHostsLine.trim()}`
      )

      const hostsFile = await fs.readFile('/etc/hosts', 'utf8')

      const cleanedHostsFile = hostsFile
        .split('\n')
        .filter((line) => line.trim() !== trimmed)
        .join('\n')

      await execa('sudo', ['tee', '/etc/hosts'], {
        input: cleanedHostsFile,
        stdout: 'inherit',
        shell: true,
      })

      require('console').log(`Removed proxy address from hosts file`)
    }

    // Run the super destroy to clean up the test directory.
    return super.destroy()
  }

  public get cliOutput() {
    return this._cliOutput || ''
  }

  public async start() {
    // no-op as the deployment is created during setup()
  }

  public async patchFile(
    filename: string,
    content: string
  ): Promise<{ newFile: boolean }> {
    throw new Error('patchFile is not available in deploy test mode')
  }
  public async readFile(filename: string): Promise<string> {
    throw new Error('readFile is not available in deploy test mode')
  }
  public async deleteFile(filename: string): Promise<void> {
    throw new Error('deleteFile is not available in deploy test mode')
  }
  public async renameFile(
    filename: string,
    newFilename: string
  ): Promise<void> {
    throw new Error('renameFile is not available in deploy test mode')
  }
}
Quest for Codev2.0.0
/
SIGN IN