next.js/test/lib/create-next-install.js
create-next-install.js249 lines7.9 KB
const os = require('os')
const path = require('path')
const execa = require('execa')
const fs = require('fs-extra')
const childProcess = require('child_process')
const { randomBytes } = require('crypto')
const { linkPackages } =
  require('../../.github/actions/next-stats-action/src/prepare/repo-setup')()

const PREFER_OFFLINE = process.env.NEXT_TEST_PREFER_OFFLINE === '1'
const useRspack = process.env.NEXT_TEST_USE_RSPACK === '1'

async function installDependencies(cwd, tmpDir) {
  const args = [
    'install',
    '--strict-peer-dependencies=false',
    '--no-frozen-lockfile',
    `--config.cacheDir=${tmpDir}`,
  ]

  if (PREFER_OFFLINE) {
    args.push('--prefer-offline')
  }

  await execa('pnpm', args, {
    cwd,
    stdio: ['ignore', 'inherit', 'inherit'],
    env: {
      ...process.env,
      // pnpm reads this despite claims it ignores `npm_config_*` env variables.
      // This isn't set in CI but some local environments set this from the
      // pnpm-workspace.yaml for unknown reasons.
      // minimumReleaseAgeExclude is not propagated with environment variables
      // so some installs would just fail.
      // TODO: ideally every test fixture would run with minimumReleaseAgeExclude but
      // that requires some work in monorepo test suites.
      npm_config_minimum_release_age: undefined,
    },
  })
}

/**
 *
 * @param {object} param0
 * @param {import('@next/telemetry').Span} param0.parentSpan
 * @param {object} [param0.dependencies]
 * @param {object | null} [param0.resolutions]
 * @param { ((ctx: { dependencies: { [key: string]: string } }) => string) | string | null} [param0.installCommand]
 * @param {object} [param0.packageJson]
 * @param {string} [param0.subDir]
 * @param {(span: import('@next/telemetry').Span, installDir: string) => Promise<void>} [param0.beforeInstall]
 * @returns {Promise<{installDir: string, pkgPaths: Map<string, string>}>}
 */
async function createNextInstall({
  parentSpan,
  dependencies = {},
  resolutions = null,
  installCommand = null,
  packageJson = {},
  subDir = '',
  beforeInstall,
}) {
  const tmpDir = await fs.realpath(process.env.NEXT_TEST_DIR || os.tmpdir())

  return await parentSpan
    .traceChild('createNextInstall')
    .traceAsyncFn(async (rootSpan) => {
      const origRepoDir = path.join(__dirname, '../../')
      const installDir = path.join(
        tmpDir,
        `next-install-${randomBytes(32).toString('hex')}`,
        subDir
      )
      require('console').log('Creating next instance in:')
      require('console').log(installDir)

      const pkgPathsEnv = process.env.NEXT_TEST_PKG_PATHS
      let pkgPaths

      if (pkgPathsEnv) {
        pkgPaths = new Map(JSON.parse(pkgPathsEnv))
        require('console').log('using provided pkg paths')
      } else {
        await rootSpan.traceChild('turbo-run-pack').traceAsyncFn(() =>
          execa(
            'pnpm',
            [
              'turbo',
              'run',
              'pack-for-isolated-tests',
              '--output-logs',
              'new-only',
              // Jest tui can't handle Turborepo tui. But we're cutting off stdin
              // so Turborepo's tui isn't interactive anyway.
              '--ui',
              'stream',
            ],
            {
              cwd: origRepoDir,
              stdio: ['ignore', 'inherit', 'inherit'],
            }
          )
        )

        if (process.env.NEXT_TEST_WASM) {
          const wasmPath = path.join(origRepoDir, 'crates', 'wasm', 'pkg')
          const hasWasmBinary = fs.existsSync(
            path.join(wasmPath, 'package.json')
          )
          if (hasWasmBinary) {
            process.env.NEXT_TEST_WASM_DIR = wasmPath
          }
        } else {
          const nativePath = path.join(origRepoDir, 'packages/next-swc/native')
          const hasNativeBinary = fs.existsSync(nativePath)
            ? fs.readdirSync(nativePath).some((item) => item.endsWith('.node'))
            : false

          if (hasNativeBinary) {
            process.env.NEXT_TEST_NATIVE_DIR = nativePath
          } else {
            const swcDirectory = fs
              .readdirSync(path.join(origRepoDir, 'node_modules/@next'))
              .find((directory) => directory.startsWith('swc-'))
            process.env.NEXT_TEST_NATIVE_DIR = path.join(
              origRepoDir,
              'node_modules/@next',
              swcDirectory
            )
          }
        }

        require('console').log({
          swcNativeDirectory: process.env.NEXT_TEST_NATIVE_DIR,
          swcWasmDirectory: process.env.NEXT_TEST_WASM_DIR,
        })

        pkgPaths = await rootSpan.traceChild('linkPackages').traceAsyncFn(() =>
          linkPackages({
            repoDir: origRepoDir,
          })
        )
      }

      const combinedDependencies = {
        next: pkgPaths.get('next'),
        ...Object.keys(dependencies).reduce((prev, pkg) => {
          const pkgPath = pkgPaths.get(pkg)
          prev[pkg] = pkgPath || dependencies[pkg]
          return prev
        }, {}),
      }

      if (useRspack) {
        combinedDependencies['next-rspack'] = pkgPaths.get('next-rspack')
      }

      // Build overrides to resolve transitive workspace deps from local
      // tarballs. Write all three formats so npm, pnpm, and yarn all work.
      const workspacePkgOverrides = {}
      for (const [name, tarballPath] of pkgPaths.entries()) {
        if (!combinedDependencies[name]) {
          workspacePkgOverrides[name] = tarballPath
        }
      }

      const scripts = {
        debug: `NEXT_PRIVATE_SKIP_CANARY_CHECK=1 NEXT_TELEMETRY_DISABLED=1 NEXT_TEST_NATIVE_DIR=${process.env.NEXT_TEST_NATIVE_DIR} node --inspect --trace-deprecation --enable-source-maps node_modules/next/dist/bin/next`,
        'debug-brk': `NEXT_PRIVATE_SKIP_CANARY_CHECK=1 NEXT_TELEMETRY_DISABLED=1 NEXT_TEST_NATIVE_DIR=${process.env.NEXT_TEST_NATIVE_DIR} node --inspect-brk --trace-deprecation --enable-source-maps node_modules/next/dist/bin/next`,
        ...packageJson.scripts,
      }

      await fs.ensureDir(installDir)
      await fs.writeFile(
        path.join(installDir, 'package.json'),
        JSON.stringify(
          {
            ...packageJson,
            scripts,
            dependencies: combinedDependencies,
            private: true,
            overrides: {
              ...workspacePkgOverrides,
              ...(packageJson.overrides || {}),
            },
            pnpm: {
              ...(packageJson.pnpm || {}),
              overrides: {
                ...workspacePkgOverrides,
                ...(packageJson.pnpm?.overrides || {}),
              },
            },
            resolutions: {
              ...workspacePkgOverrides,
              ...(resolutions || {}),
            },
          },
          null,
          2
        )
      )

      if (beforeInstall !== undefined) {
        await rootSpan
          .traceChild('beforeInstall')
          .traceAsyncFn(async (span) => {
            await beforeInstall(span, installDir)
          })
      }

      if (installCommand) {
        const installString =
          typeof installCommand === 'function'
            ? installCommand({
                dependencies: combinedDependencies,
                resolutions,
              })
            : installCommand

        console.log('running install command', installString)
        rootSpan.traceChild('run custom install').traceFn(() => {
          childProcess.execSync(installString, {
            cwd: installDir,
            stdio: ['ignore', 'inherit', 'inherit'],
          })
        })
      } else {
        await rootSpan
          .traceChild('run generic install command', combinedDependencies)
          .traceAsyncFn(() => installDependencies(installDir, tmpDir))
      }

      if (useRspack) {
        process.env.NEXT_RSPACK = 'true'
        process.env.RSPACK_CONFIG_VALIDATE = 'loose-silent'
      }

      return {
        installDir,
        pkgPaths,
      }
    })
}

module.exports = {
  createNextInstall,
  getPkgPaths: linkPackages,
}
Quest for Codev2.0.0
/
SIGN IN