current commit
260814.1 · fix(test): skip cache-tests when submodule is missing (#5661)
'use strict'

import { parseArgs, styleText } from 'node:util'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { exit } from 'node:process'
import { existsSync } from 'node:fs'
import { fork } from 'node:child_process'
import { runtimeFeatures } from '../../lib/util/runtime-features.js'

// CITGM / npm-pack installs don't include git submodules. Skip cleanly when the
// http-tests/cache-tests checkout is missing rather than failing the whole suite
// (https://github.com/nodejs/undici/issues/5642).
const CACHE_TESTS_RUNNER = join(
  import.meta.dirname,
  '../fixtures/cache-tests/test-engine/client/runner.mjs'
)
if (!existsSync(CACHE_TESTS_RUNNER)) {
  console.warn(
    'Skipping cache-tests: test/fixtures/cache-tests submodule is not checked out. ' +
    'Run `git submodule update --init test/fixtures/cache-tests` to enable them.'
  )
  exit(0)
}

/**
 * @typedef {import('../../types/cache-interceptor.d.ts').default.CacheOptions} CacheOptions
 *
 * @typedef {{
 *  opts: CacheOptions,
 *  ignoredTests?: string[],
 *  cacheStore?: 'MemoryCacheStore' | 'SqliteCacheStore'
 * }} TestEnvironment
 *
 * @typedef {{
 *  total: number,
 *  skipped: number,
 *  passed: number,
 *  failed: number,
 *  optionalFailed: number,
 *  setupFailed: number,
 *  testHarnessFailed: number,
 *  dependencyFailed: number,
 *  retried: number
 * }} TestStats
 *
 * @typedef {{
 *  code: number | null,
 *  signal: NodeJS.Signals | null,
 *  stdout: Buffer[],
 *  environment: TestEnvironment,
 *  port: number
 * }} WorkerResult
 */

const CLI_OPTIONS = parseArgs({
  options: {
    // Cache type(s) to test
    type: {
      type: 'string',
      multiple: true,
      short: 't'
    },
    // Cache store(s) to test
    store: {
      type: 'string',
      multiple: true,
      short: 's'
    },
    // Only shows errors
    ci: {
      type: 'boolean'
    }
  }
})

/**
 * @type {TestEnvironment}
 */
const BASE_TEST_ENVIRONMENT = {
  opts: { methods: ['GET', 'HEAD'] },
  ignoredTests: [
    // Tests for invalid etags, goes against the spec
    'conditional-etag-forward-unquoted',
    'conditional-etag-strong-generate-unquoted',

    // Responses with no-cache can be reused if they're revalidated (which is
    //  what we're doing)
    'cc-resp-no-cache',
    'cc-resp-no-cache-case-insensitive',

    // We're not caching 304s currently
    '304-etag-update-response-Cache-Control',
    '304-etag-update-response-Content-Foo',
    '304-etag-update-response-Test-Header',
    '304-etag-update-response-X-Content-Foo',
    '304-etag-update-response-X-Test-Header',

    // The hardening treats malformed or duplicate Age values as stale instead
    // of ignoring them or using the first parseable value.
    'age-parse-nonnumeric',
    'age-parse-negative',
    'age-parse-float',
    'age-parse-prefix',
    'age-parse-prefix-twoline',
    'age-parse-dup-0',
    'age-parse-dup-0-twoline',
    'age-parse-dup-old',

    // Broken?
    'head-200-update',
    'head-200-retain',
    'head-410-update',
    'stale-close-must-revalidate',
    'stale-close-no-cache'
  ]
}

/**
 * @type {TestEnvironment[]}
 */
const CACHE_TYPES = [
  {
    opts: { type: 'shared' },
    ignoredTests: [
      'freshness-max-age-s-maxage-private',
      'freshness-max-age-s-maxage-private-multiple'
    ]
  },
  {
    opts: { type: 'private' }
  }
]

/**
 * @type {TestEnvironment[]}
 */
const CACHE_STORES = [
  { opts: {}, cacheStore: 'MemoryCacheStore' }
]

if (runtimeFeatures.has('sqlite')) {
  CACHE_STORES.push({ opts: {}, cacheStore: 'SqliteCacheStore' })
} else {
  console.warn('Skipping SqliteCacheStore, node:sqlite not present')
}

const PROTOCOL = 'http'
const PORT = 8000

const testEnvironments = filterEnvironments(
  buildTestEnvironments(0, [CACHE_TYPES, CACHE_STORES])
)

// Windows runners intermittently terminate cache-test workers with 0xC0000409
// when all environments run at the same time. Keep environment workers serial
// there; each worker still runs the upstream cache-tests concurrently.
const WORKER_CONCURRENCY = Math.max(1, Number(process.env.CACHE_TEST_WORKER_CONCURRENCY) || (
  process.platform === 'win32' ? 1 : testEnvironments.length
))

console.log(`Testing ${testEnvironments.length} environments\n`)
console.log(`PROTOCOL: ${styleText('gray', PROTOCOL)}`)
console.log(`WORKERS: ${styleText('gray', `${WORKER_CONCURRENCY}`)}`)
console.log('')

/**
 * @type {Array<() => Promise<WorkerResult>>}
 */
const jobs = []

// Run all the tests in child processes because the test runner is a bit finicky
for (let i = 0; i < testEnvironments.length; i++) {
  const environment = testEnvironments[i]
  const port = PORT + i

  const runWorker = () => {
    const { promise, resolve } = Promise.withResolvers()
    const cacheTestsWorkerProcess = fork(join(import.meta.dirname, 'cache-tests-worker.mjs'), {
      stdio: 'pipe',
      env: {
        NODE_OPTIONS: process.env.NODE_OPTIONS,
        NODE_V8_COVERAGE: process.env.NODE_V8_COVERAGE,
        TEST_ENVIRONMENT: JSON.stringify(environment),
        BASE_URL: `${PROTOCOL}://localhost:${port}`,
        CI: CLI_OPTIONS.values.ci ? 'true' : undefined,
        npm_config_protocol: PROTOCOL,
        npm_config_port: `${port}`,
        npm_config_pidfile: join(tmpdir(), `http-cache-test-server-${i}.pid`)
      }
    })

    const stdout = []
    cacheTestsWorkerProcess.stdout.on('data', chunk => {
      stdout.push(chunk)
    })

    cacheTestsWorkerProcess.stderr.on('data', chunk => {
      stdout.push(chunk)
    })

    cacheTestsWorkerProcess.on('error', err => {
      stdout.push(Buffer.from(`${err.stack ?? err.message}\n`))
    })

    cacheTestsWorkerProcess.on('close', (code, signal) => {
      resolve({
        code,
        signal,
        stdout,
        environment,
        port
      })
    })

    return promise
  }

  jobs.push(async () => {
    const result = await runWorker()
    if (result.signal === null && (result.code === 0 || result.code === 1)) {
      return result
    }

    const retryResult = await runWorker()
    return {
      ...retryResult,
      stdout: [
        ...result.stdout,
        Buffer.from(`cache-tests worker crashed, retrying once: port=${port} store=${environment.cacheStore ?? 'default'} type=${environment.opts.type ?? 'default'} code=${result.code ?? 'null'} signal=${result.signal ?? 'null'}\n`),
        ...retryResult.stdout
      ]
    }
  })
}

// Status code so we can fail CI jobs if we need
let exitCode = 0

// Print the results of all the results in the order that they exist
for (const result of await runJobs(jobs, WORKER_CONCURRENCY)) {
  if (result.code !== 0 || result.signal !== null) {
    exitCode = result.code ?? 1
  }

  for (const line of result.stdout) {
    process.stdout.write(line)
  }

  if (result.code !== 0 || result.signal !== null) {
    console.error(`cache-tests worker failed: port=${result.port} store=${result.environment.cacheStore ?? 'default'} type=${result.environment.opts.type ?? 'default'} code=${result.code ?? 'null'} signal=${result.signal ?? 'null'}`)
  }

  console.log('')
}

exit(exitCode)

/**
 * @param {number} idx
 * @param  {TestEnvironment[][]} testOptions
 * @returns {TestEnvironment[]}
 */
function buildTestEnvironments (idx, testOptions) {
  let baseEnvironments = testOptions[idx]

  if (idx === 0) {
    // We're at the beginning
    baseEnvironments = baseEnvironments.map(
      environment => joinEnvironments(BASE_TEST_ENVIRONMENT, environment))
  }

  if (idx + 1 >= testOptions.length) {
    // We're at the end, nothing more to make a matrix out of
    return baseEnvironments
  }

  /**
   * @type {TestEnvironment[]}
   */
  const environments = []

  // Get all of the environments below us
  const subEnvironments = buildTestEnvironments(idx + 1, testOptions)

  for (const baseEnvironment of baseEnvironments) {
    const combinedEnvironments = subEnvironments.map(
      subEnvironment => joinEnvironments(baseEnvironment, subEnvironment))

    environments.push(...combinedEnvironments)
  }

  return environments
}

/**
 * @param {Array<() => Promise<WorkerResult>>} jobs
 * @param {number} concurrency
 * @returns {Promise<WorkerResult[]>}
 */
async function runJobs (jobs, concurrency) {
  const results = new Array(jobs.length)
  let next = 0

  async function worker () {
    while (next < jobs.length) {
      const idx = next++
      results[idx] = await jobs[idx]()
    }
  }

  await Promise.all(
    Array.from({ length: Math.min(concurrency, jobs.length) }, worker)
  )

  return results
}

/**
 * @param {TestEnvironment} base
 * @param {TestEnvironment} sub
 * @returns {TestEnvironment}
 */
function joinEnvironments (base, sub) {
  const ignoredTests = base.ignoredTests ?? []
  if (sub.ignoredTests) {
    ignoredTests.push(...sub.ignoredTests)
  }

  return {
    opts: {
      ...base.opts,
      ...sub.opts
    },
    ignoredTests: ignoredTests.length > 0 ? ignoredTests : undefined,
    cacheStore: sub.cacheStore
  }
}

/**
 * @param {TestEnvironment[]} environments
 * @returns {TestEnvironment[]}
 */
function filterEnvironments (environments) {
  const { values } = CLI_OPTIONS

  if (values.type) {
    environments = environments.filter(env =>
      env.opts.type === undefined ||
      values.type?.includes(env.opts.type)
    )
  }

  if (values.store) {
    environments = environments.filter(({ cacheStore }) => {
      if (cacheStore === undefined) {
        return false
      }

      const storeName = cacheStore
      for (const allowedStore of values.store) {
        if (storeName.match(allowedStore)) {
          return true
        }
      }

      return false
    })
  }

  return environments
}