'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
}