@exodus/test
Version:
A test suite runner
777 lines (685 loc) • 31.5 kB
JavaScript
import { spawn, execFile as execFileCallback } from 'node:child_process'
import { promisify } from 'node:util'
import { once } from 'node:events'
import { fileURLToPath } from 'node:url'
import { basename, join } from 'node:path'
import { randomUUID } from 'node:crypto'
import { existsSync, rmSync, realpathSync } from 'node:fs'
import { unlink } from 'node:fs/promises'
import { tmpdir, availableParallelism, homedir } from 'node:os'
import assert from 'node:assert/strict'
// The following make sense only when we run the code in the same Node.js version, i.e. engineOptions.haveIsOk
import * as have from '../src/version.js'
import { findBinary } from './find-binary.js'
import * as browsers from './browsers.js'
import { glob as globImplementation } from '../src/glob.cjs'
const DEFAULT_PATTERNS = [`**/?(*.)+(spec|test).?([cm])[jt]s?(x)`] // do not trust magic dirs by default
const bundleOpts = { pure: true, bundle: true, esbuild: true, ts: 'auto' }
const bareboneOpts = { ...bundleOpts, barebone: true }
const hermesA = ['-w', '-Xmicrotask-queue'] // -Xes6-class fails with -O0 / -Og, --block-scoping fails in default, any of that is bad
const denoA = ['run', '--allow-all'] // also will set DENO_COMPAT=1 env flag below
const denoT = ['test', '--allow-all']
const nodeTS = process.features.typescript ? 'auto' : 'flag'
const ENGINES = new Map(
Object.entries({
'node:test': { binary: 'node', loader: '--import', ts: nodeTS, haveIsOk: true },
'node:pure': { binary: 'node', pure: true, loader: '--import', ts: nodeTS, haveIsOk: true },
'node:bundle': { binary: 'node', binaryArgs: ['--expose-gc'], ...bundleOpts },
'bun:test': { binary: 'bun', ts: 'auto' },
'bun:pure': { binary: 'bun', pure: true, ts: 'auto' },
'bun:bundle': { binary: 'bun', ...bundleOpts },
'electron-as-node:test': { binary: 'electron', loader: '--import', ts: 'flag' },
'electron-as-node:pure': { binary: 'electron', pure: true, loader: '--import', ts: 'flag' },
'electron-as-node:bundle': { binary: 'electron', binaryArgs: ['--expose-gc'], ...bundleOpts },
'electron:bundle': { binary: 'electron', electron: true, ...bundleOpts },
'deno:test': { binary: 'deno', binaryArgs: denoT, loader: '--preload', ts: 'auto' },
'deno:pure': { binary: 'deno', binaryArgs: denoA, pure: true, loader: '--preload', ts: 'auto' },
'deno:bundle': { binary: 'deno', binaryArgs: ['run'], target: 'deno1', ...bundleOpts },
// Barebone engines
'v8:bundle': { binary: 'd8', binaryArgs: ['--expose-gc'], ...bareboneOpts },
'jsc:bundle': { binary: 'jsc', target: 'safari13', ...bareboneOpts },
'hermes:bundle': { binary: 'hermes', binaryArgs: hermesA, target: 'es2018', ...bareboneOpts },
'spidermonkey:bundle': { binary: 'spidermonkey', ...bareboneOpts },
'engine262:bundle': { binary: 'engine262', ...bareboneOpts },
'quickjs:bundle': { binary: 'quickjs', binaryArgs: ['--std'], ...bareboneOpts },
'xs:bundle': { binary: 'xs', ...bareboneOpts },
'graaljs:bundle': { binary: 'graaljs', ...bareboneOpts },
'escargot:bundle': { binary: 'escargot', ...bareboneOpts },
'boa:bundle': { binary: 'boa', binaryArgs: ['-m'], ...bareboneOpts },
'jerryscript:bundle': { binary: 'jerryscript', ...bareboneOpts },
// Browser engines
'chrome:puppeteer': { binary: 'chrome', browsers: 'puppeteer', ...bundleOpts },
'firefox:puppeteer': { binary: 'firefox', browsers: 'puppeteer', ...bundleOpts },
'brave:puppeteer': { binary: 'brave', browsers: 'puppeteer', ...bundleOpts },
'msedge:puppeteer': { binary: 'msedge', browsers: 'puppeteer', ...bundleOpts },
'chromium:playwright': { binary: 'chromium', browsers: 'playwright', ...bundleOpts },
'firefox:playwright': { binary: 'firefox', browsers: 'playwright', ...bundleOpts },
'webkit:playwright': { binary: 'webkit', browsers: 'playwright', ...bundleOpts },
'chrome:playwright': { binary: 'chrome', browsers: 'playwright', ...bundleOpts },
'msedge:playwright': { binary: 'msedge', browsers: 'playwright', ...bundleOpts },
})
)
const barebonesOk = ['v8', 'd8', 'spidermonkey', 'quickjs', 'xs', 'hermes']
const barebonesUnhandled = ['jsc', 'escargot', 'boa', 'graaljs', 'jerry', 'engine262']
const getEnvFlag = (name) => {
if (!Object.hasOwn(process.env, name)) return
if ([undefined, '', '0', '1'].includes(process.env[name])) return process.env[name] === '1'
throw new Error(`Unexpected ${name} env value, expected '', '0', or '1'`)
}
function getNumber(arg) {
assert.equal(`${arg}`, `${Number(arg)}`)
return Number(arg)
}
function parseOptions() {
const options = {
concurrency: undefined, // undefined means unset (can read from config), 0 means auto
jest: false,
typescript: false,
flow: false,
esbuild: false,
babel: false,
coverage: getEnvFlag('EXODUS_TEST_COVERAGE'),
coverageEngine: process.platform === 'win32' ? 'node' : 'c8', // c8 or node. TODO: can we use c8 on win?
watch: false,
only: false,
passWithNoTests: false,
writeSnapshots: false,
devtools: false,
debug: { files: false },
dropNetwork: getEnvFlag('EXODUS_TEST_DROP_NETWORK'),
ideaCompat: false,
engine: process.env.EXODUS_TEST_ENGINE ?? 'node:test',
flagEngine: false, // Option combination error reporting differs when engine is passed by flag or env
entropySize: 5 * 1024,
require: [],
testNamePattern: [],
testTimeout: undefined,
reporter: undefined,
}
const args = [...process.argv]
// First argument should be node
assert(['node', 'node.exe'].includes(basename(args.shift())))
assert(['node', 'node.exe'].includes(basename(process.argv0)))
// Second argument should be this script
const jsname = args.shift()
const pathsEqual = (a, b) => a === b || (existsSync(a) && realpathSync(a) === b) // resolve symlinks
assert(basename(jsname) === 'exodus-test' || pathsEqual(jsname, fileURLToPath(import.meta.url)))
if (args[0] === '--playwright') {
const res = browsers.runPlaywrightCommand(args.slice(1))
process.exitCode = res.status ?? 1
process.exit(0)
}
class OptionValue extends String {}
while (args[0]?.startsWith('-')) {
const option = args.shift()
if (option.includes('=')) {
const [optionName, ...rest] = option.split('=')
args.unshift(optionName, new OptionValue(rest.join('=')))
continue
}
if (options.ideaCompat) {
// Ignore some options IntelliJ IDEA is passing
switch (option) {
case '--reporters':
args.shift()
continue
case '--verbose':
case '--runTestsByPath':
case '--runInBand':
continue
}
}
switch (option) {
case '--global': // compat, will be removed in release
case '--jest':
options.jest = true
break
case '--typescript':
options.typescript = true
break
case '--flow':
options.flow = true
break
case '--esbuild':
options.esbuild = args[0] instanceof OptionValue ? String(args.shift()) : '*'
break
case '--babel':
options.babel = true
break
case '--require':
options.require.push(String(args.shift()))
break
case '--coverage-engine':
options.coverageEngine = String(args.shift())
break
case '--coverage':
options.coverage = true
break
case '--no-coverage':
options.coverage = false
break
case '--watch':
options.watch = true
break
case '--test-only':
case '--only':
options.only = true
break
case '--passWithNoTests':
options.passWithNoTests = true
break
case '--test-update-snapshots': // Node.js name for this, might get suggested in errors
case '--write-snapshots':
options.writeSnapshots = true
break
case '--test-force-exit':
case '--forceExit':
options.forceExit = true
break
case '--engine':
options.engine = String(args.shift())
options.flagEngine = true
break
case '--devtools':
case '--inspect-brk':
options.devtools = '--inspect-brk'
break
case '--inspect-wait':
if (options.devtools !== '--inspect-brk') options.devtools = '--inspect-wait'
break
case '--inspect':
if (!options.devtools) options.devtools = '--inspect'
break
case '--debug-files':
options.debug.files = true
break
case '--colors':
process.env.FORCE_COLOR = '1'
break
case '--no-colors':
process.env.FORCE_COLOR = '0'
process.env.NO_COLOR = '1'
process.env.NODE_DISABLE_COLORS = '1'
break
case '--drop-network':
options.dropNetwork = true
break
case '--idea-compat':
options.ideaCompat = true
break
case '--throttle-cpu':
options.throttle = getNumber(args.shift())
assert(Number.isInteger(options.throttle) && options.throttle > 0) // throttle x times, 1 is no throttle, 2 is 2x slowdown
break
case '--debug-timers':
setEnv('EXODUS_TEST_TIMERS_TRACK', '1')
break
case '--concurrency':
options.concurrency = getNumber(args.shift())
assert(Number.isInteger(options.concurrency) && options.concurrency >= 0)
break
case '--bundle-entropy-size':
options.entropySize = Number(args.shift())
break
case '-t':
case '--test-name-pattern':
case '--testNamePattern':
options.testNamePattern.push(String(args.shift()))
break
case '--testTimeout':
options.testTimeout = Number(args.shift())
break
case '--reporter':
case '--test-reporter':
options.reporter = String(args.shift())
break
default:
throw new Error(`Unknown option: ${option}`)
}
}
const argsArePlainStrings = args.every((arg) => typeof arg === 'string' && !arg.startsWith('--'))
assert(argsArePlainStrings, 'Options should come before patterns')
const patterns = [...args]
return { options, patterns }
}
const isTTY = process.stdout.isTTY
const isCI = process.env.CI
const warnHuman = isTTY && !isCI ? (...args) => console.warn(...args) : () => {}
if (isCI) process.env.FORCE_COLOR = '1' // should support colored output even though not a TTY, overridable with --no-color
const setEnv = (name, value) => {
const env = process.env[name]
if (env && env !== value) throw new Error(`env conflict: ${name}="${env}", effective: "${value}"`)
process.env[name] = value === undefined ? '' : value
}
const { options, patterns } = parseOptions()
const engineName = `${options.engine} engine` // used for warnings to user
const engineFlagError = (flag) => `${engineName} does not support --${flag}`
if (options.engine === 'd8:bundle') options.engine = 'v8:bundle' // compat
const engineOptions = ENGINES.get(options.engine)
assert(engineOptions, `Unknown engine: ${options.engine}`)
Object.assign(options, engineOptions)
options.platform = options.binary // binary can be overriden by c8 or electron
const isBrowserLike = options.browsers || options.electron
setEnv('EXODUS_TEST_ENGINE', options.engine) // e.g. 'hermes:bundle', 'node:bundle', 'node:test', 'node:pure'
setEnv('EXODUS_TEST_PLATFORM', options.binary) // e.g. 'hermes', 'node'
setEnv('EXODUS_TEST_TIMEOUT', options.testTimeout)
setEnv('EXODUS_TEST_DEVTOOLS', options.devtools ? '1' : '')
setEnv('EXODUS_TEST_IS_BROWSER', isBrowserLike ? '1' : '')
setEnv('EXODUS_TEST_IS_BAREBONE', options.barebone ? '1' : '')
setEnv('EXODUS_TEST_ENVIRONMENT', options.bundle ? 'bundle' : '') // perhaps switch to _IS_BUNDLED?
if (['deno:pure', 'deno:test'].includes(options.engine)) setEnv('DENO_COMPAT', '1') // https://deno.com/blog/v2.4#deno_compat1
assert(!options.devtools || isBrowserLike || !options.pure, engineFlagError('devtools'))
assert(!options.throttle || options.browsers, engineFlagError('throttle-cpu'))
const args = []
if (engineOptions.haveIsOk) args.push('--experimental-test-module-mocks')
if (options.pure) {
if (options.bundle) {
assert(!options.coverage, `Can not use --coverage with ${engineName}`)
assert(!options.babel, `Can not use --babel with ${engineName}`) // TODO?
}
const requiresNodeCoverage = options.coverage && options.coverageEngine === 'node'
assert(!requiresNodeCoverage, '"--coverage-engine node" requires "--engine node:test" (default)')
assert(!options.writeSnapshots, `Can not use write snapshots with ${engineName}`)
assert(!options.forceExit, `Can not use --force-exit with ${engineName} yet`) // TODO
assert(!options.watch, `Can not use --watch with with ${engineName}`)
assert(options.testNamePattern.length === 0, '--test-name-pattern requires node:test engine now')
// eslint-disable-next-line unicorn/prefer-switch
} else if (options.engine === 'node:test' || options.engine === 'electron-as-node:test') {
const reporter = options.reporter ?? import.meta.resolve('./reporter.js')
args.push('--test', '--no-warnings=ExperimentalWarning', '--test-reporter', reporter)
if (have.haveSnapshots && engineOptions.haveIsOk) args.push('--experimental-test-snapshots')
if (options.writeSnapshots) {
assert(have.haveSnapshots && engineOptions.haveIsOk, 'For snapshots, use Node.js >=22.3.0')
args.push('--test-update-snapshots')
}
if (options.forceExit) args.push('--test-force-exit')
if (options.watch) args.push('--watch')
if (options.only) args.push('--test-only')
for (const pattern of options.testNamePattern) args.push('--test-name-pattern', pattern)
args.push('--expose-internals') // this is unoptimal and hopefully temporary, see rationale in src/dark.cjs
} else if (options.engine === 'deno:test') {
assert(!options.jest, 'deno:test engine does not support --jest yet')
} else if (options.engine === 'bun:test') {
args.push('test')
throw new Error('bun:test is unavailable because Bun test runner has many bugs and does not work')
} else {
throw new Error('Unreachable')
}
if (!options.bundle && ['node', 'electron'].includes(options.platform)) args.push('--expose-gc') // for benchmarks
const ignore = ['**/node_modules']
let filter
if (process.env.EXODUS_TEST_IGNORE) {
// fast-glob treats negative ignore patterns exactly the same as positive, let's not cause a confusion
assert(!process.env.EXODUS_TEST_IGNORE.startsWith('!'), 'Ignore pattern should not be negative')
ignore.push(process.env.EXODUS_TEST_IGNORE)
}
// This might be used in presets, so has to be loaded before jest
if (options.flow && !options.bundle) args.push('--import', import.meta.resolve('../loader/flow.js'))
if (['node:test', 'electron-as-node:test', 'deno:test'].includes(options.engine)) {
// Do not need node:test override
} else if (options.engine === 'deno:pure') {
args.push('--import-map', import.meta.resolve('../loader/deno-import-map.json'))
} else if (!options.bundle) {
args.push(options.loader ?? '-r', import.meta.resolve('../loader/node-test.js'))
}
// The comment below is disabled, we don't auto-mock @jest/globals anymore, and having our loader first is faster
// [Disabled] Our loader should be last, as enabling module mocks confuses other loaders
let jestConfig = null
let globalTeardown
if (options.jest) {
const { loadJestConfig } = await import('../src/jest.config.js')
const config = await loadJestConfig(process.cwd())
jestConfig = config
if (options.bundle) {
setEnv('EXODUS_TEST_JEST_CONFIG', JSON.stringify(jestConfig))
} else {
args.push(options.loader ?? '-r', import.meta.resolve('../loader/jest.js'))
}
if (config.testFailureExitCode !== undefined) {
if (Number(config.testFailureExitCode) === 0) {
console.warn('Jest is configured to succeed with exit code 0 on test failures!')
}
process.on('exit', (code) => {
if (code !== 0) process.exitCode = config.testFailureExitCode
})
}
if (patterns.length > 0) {
// skip, we already have patterns via argv
} else if (config.testRegex) {
assert(typeof config.testRegex === 'string', `config.testRegex should be a string`)
assert(!config.testMatch, 'config.testRegex can not be used together with config.testMatch')
patterns.push('**/*')
} else if (config.testMatch) {
patterns.push(...(Array.isArray(config.testMatch) ? config.testMatch : [config.testMatch]))
}
if (config.passWithNoTests) options.passWithNoTests = true
const testRegex = config.testRegex ? new RegExp(config.testRegex, 'u') : null
const ignoreRegexes = config.testPathIgnorePatterns.map((x) => new RegExp(x, 'u'))
if (testRegex || ignoreRegexes.length > 0) {
filter = (x) => {
const resolved = `<rootDir>/${x}` // don't actually include cwd, that should be irrelevant
if (testRegex && !testRegex.test(resolved)) return false
return !ignoreRegexes.some((r) => r.test(resolved))
}
}
if (config.collectCoverage && options.coverage === undefined) options.coverage = true
if (config.maxWorkers && options.concurrency === undefined) {
options.concurrency = config.maxWorkers
}
for (const key of ['globalSetup', 'globalTeardown']) {
if (!config[key]) continue
const { default: method } = await import(config[key])
assert(method, `config.${key} does not export a default method`)
assert(method.length === 0, `Arguments for config.${key} are not supported yet`)
if (key === 'globalTeardown') {
globalTeardown = method
} else {
await method() // globalSetup
}
}
}
const cpus = availableParallelism()
// increase from default cpus - 1 on default GH CI runners, but not on browsers which already use other processes
if (!options.concurrency && isCI && !isBrowserLike && cpus === 2) options.concurrency = cpus
if (options.concurrency) {
const raw = options.concurrency
let concurrency = raw
if (typeof raw === 'string') {
if (/^\d{1,15}%$/u.test(raw)) {
const perc = Number(raw.slice(0, -1))
concurrency = Math.max(1, Math.round((perc * cpus) / 100))
} else {
assert(/^\d{1,15}$/u.test(raw), `Wrong concurrency: ${raw}`)
concurrency = Number(raw)
}
}
assert(Number.isSafeInteger(concurrency) && concurrency >= 1, `Wrong concurrency: ${raw}`)
options.concurrency = concurrency
}
if (options.esbuild && !options.bundle) {
setEnv('EXODUS_TEST_ESBUILD', options.esbuild)
if (options.loader === '--import') {
const optional = options.esbuild === '*' ? '' : '.optional'
args.push('--import', import.meta.resolve(`../loader/esbuild${optional}.js`))
} else if (options.flagEngine === false) {
// Engine is set via env, --esbuild set via flag. Allow but warn
console.warn(`Warning: ${engineName} does not support --esbuild option`)
} else {
console.error(`Error: ${engineName} does not support --esbuild option`)
process.exit(1)
}
}
if (options.babel) {
assert(!options.esbuild, 'Options --babel and --esbuild are mutually exclusive')
args.push('-r', import.meta.resolve('../loader/babel.cjs'))
}
if (options.typescript) {
assert(!options.esbuild, 'Options --typescript and --esbuild are mutually exclusive')
assert(!options.babel, 'Options --typescript and --babel are mutually exclusive')
if (options.ts === 'flag') {
assert(options.loader === '--import')
// TODO: switch to native --experimental-strip-types where available
args.push('--import', import.meta.resolve('../loader/typescript.js'))
} else if (options.ts !== 'auto') {
throw new Error(`Processing --typescript is not possible with ${engineName}`)
}
}
for (const r of options.require) {
assert(!options.bundle, 'Can not use -r with *:bundle engines')
args.push('-r', r)
}
async function glob(patterns, { ignore, cwd }) {
const patternsY = patterns.filter((x) => !x.startsWith('!'))
const patternsN = patterns.filter((x) => x.startsWith('!')).map((x) => x.slice(1))
return globImplementation(patternsY, { exclude: [...ignore, ...patternsN], cwd })
}
if (patterns.length === 0) patterns.push(...DEFAULT_PATTERNS) // defaults
const globbed = await glob(patterns, { ignore })
const allfiles = filter ? globbed.filter(filter) : globbed
if (allfiles.length === 0) {
if (options.passWithNoTests) {
console.warn('No test files found, but passing due to --passWithNoTests')
process.exit(0)
}
console.error('No test files found!')
process.exit(1)
}
let subfiles // must be a strict subset of allfiles
if (process.env.EXODUS_TEST_SELECT) {
subfiles = await glob(process.env.EXODUS_TEST_SELECT, { ignore })
const allSet = new Set(allfiles)
const stray = subfiles.filter((file) => !allSet.has(file))
if (stray.length > 0) {
console.error(`Selected tests should be a subset of all tests:\n ${stray.join('\n ')}`)
process.exit(1)
}
if (subfiles.length === 0) {
console.error('No test files selected due to EXODUS_TEST_SELECT, passing')
process.exit(0)
}
}
const files = subfiles ?? allfiles
files.sort((a, b) => {
const [al, bl] = [a.split('/'), b.split('/')]
while (al[0] === bl[0]) {
al.shift()
bl.shift()
}
// First process each file in dir, then subdirs
if (al.length < 2) return -1
if (bl.length < 2) return 1
// Prefer example/ over example-something/
const [an, bn] = [al, bl].map((list) => list.join(String.fromCodePoint(0)))
if (an < bn) return -1
if (an > bn) return 1
throw new Error('Unreachable')
})
if (options.debug.files) {
for (const f of files) console.log(f) // joining with \n can get truncated, too big
process.exit(1) // do not succeed!
}
const tsTests = files.filter((file) => /\.[mc]?tsx?$/u.test(file))
const tsSupport = options.ts === 'auto' || options.esbuild || options.typescript || options.babel
if (tsTests.length > 0 && !tsSupport) {
console.error(`Some tests require --typescript or --esbuild flag:\n ${tsTests.join('\n ')}`)
process.exit(1)
} else if (!allfiles.some((file) => /\.[cm]?ts$/.test(file)) && options.typescript) {
console.warn(`Flag --typescript has been used, but there were no TypeScript tests found!`)
}
if (!options.bundle) {
// uses top-level await, :bundle doesn't have that
const inband = new Set(files.filter((f) => basename(f).includes('.inband.')))
if (inband.size > 0) {
process.env.EXODUS_TEST_INBAND = JSON.stringify([...inband])
const remaning = files.filter((f) => !inband.has(f))
files.length = 0
files.push(fileURLToPath(import.meta.resolve('./inband.js')), ...remaning)
}
}
if (!Object.hasOwn(process.env, 'NODE_ENV')) process.env.NODE_ENV = 'test'
setEnv('EXODUS_TEST_ONLY', options.only ? '1' : '')
let c8
if (options.coverage) {
assert.equal(options.binary, 'node', 'Coverage is only supported with Node.js')
if (options.coverageEngine === 'node') {
args.push('--experimental-test-coverage')
if (have.haveCoverExclude && engineOptions.haveIsOk) {
args.push(
`--test-coverage-exclude=**/@exodus/test/src/**`,
`--test-coverage-exclude=${DEFAULT_PATTERNS[0]}`
)
}
} else if (options.coverageEngine === 'c8') {
c8 = findBinary('c8')
assert.equal(c8, fileURLToPath(import.meta.resolve('c8/bin/c8.js')))
args.unshift(options.binary)
options.binary = c8
// perhaps use text-summary ?
args.unshift('-r', 'text', '-r', 'html', '-r', 'lcov', '-r', 'json-summary')
} else {
throw new Error(`Unknown coverage engine: ${JSON.stringify(options.coverageEngine)}`)
}
}
if (options.binary === 'electron') {
if (isBrowserLike) {
assert(!options.binaryArgs)
options.binaryArgs = [fileURLToPath(import.meta.resolve('./electron.js'))]
} else {
setEnv('ELECTRON_RUN_AS_NODE', '1')
}
}
if (options.barebone || ['electron', 'workerd'].includes(options.binary)) {
options.binary = findBinary(options.binary)
options.binaryCanBeAbsolute = true
}
const makeTitle = () => {
let title = options.browsers === 'puppeteer' ? findBinary(options.binary) : options.binary
if (options.browsers === 'playwright') return `${title} (Playwright-managed)`
if (basename(title) === title) return title
const dir = { '~': `${process.cwd()}/`, '.': `${homedir()}/` }
if (title.startsWith(dir['~']) && dir['~'].length > 1) title = `./${title.slice(dir['~'].length)}`
if (title.startsWith(dir['.']) && dir['.'].length > 1) title = `~/${title.slice(dir['.'].length)}`
return /\s/u.test(title) ? JSON.stringify(title) : title
}
const { color } = await import('./color.js') // can't load before env flags are set
console.info(color(`Engine: ${options.engine}, running on ${makeTitle()}`, 'green'))
const assertBinary = (binary, allowed) => {
if (allowed.includes(binary)) return
if (existsSync(binary)) {
const name = basename(binary.toLowerCase()).replace(/\.exe$/u, '')
if ((c8 && binary === c8) || (options.binaryCanBeAbsolute && allowed.includes(name))) return
}
throw new Error(`Unexpected binary: ${binary}`)
}
setEnv('EXODUS_TEST_EXECARGV', JSON.stringify(args))
let buildFile
if (options.bundle) {
const outdir = join(tmpdir(), `exodus-test-${randomUUID().slice(0, 8)}`)
process.on('exit', () => rmSync(outdir, { recursive: true, force: true }))
assert.deepEqual(args, [])
if (options.binary === 'node') args.unshift('--enable-source-maps') // FIXME
const bundle = await import('@exodus/test-bundler/bundle')
bundle.setResolver((file) => fileURLToPath(import.meta.resolve(`../src/${file}`)))
await bundle.init({ ...options, outdir, jestConfig })
buildFile = (file) => bundle.build(file)
}
if (options.dropNetwork) warnHuman('--drop-network is a test helper, not a security mechanism')
const execFile = promisify(execFileCallback)
async function launch(binary, args, opts = {}, buffering = false) {
if (options.browsers) {
assert(buffering, 'Unexpected non-buffered browser run')
const { timeout } = opts
const { browsers: runner, devtools, dropNetwork, throttle } = options
return browsers.run(runner, args, { binary, devtools, dropNetwork, timeout, throttle })
}
const barebones = [...barebonesOk, ...barebonesUnhandled]
assertBinary(binary, ['node', 'bun', 'deno', 'electron', 'workerd', ...barebones])
if (binary === c8 && process.platform === 'win32') {
;[binary, args] = ['node', [binary, ...args]]
}
if (options.dropNetwork) {
switch (process.platform) {
case 'darwin':
;[binary, args] = ['sandbox-exec', ['-n', 'no-network', binary, ...args]]
break
case 'linux':
;[binary, args] = ['unshare', ['-n', '-r', binary, ...args]]
break
default:
assert.fail(`--drop-network is not implemented on platform: ${process.platform}`)
}
}
if (buffering) return execFile(binary, args, { maxBuffer: 5 * 1024 * 1024, ...opts }) // 5 MiB just in case
const child = spawn(binary, args, { stdio: 'inherit', ...opts })
const [code] = await once(child, 'close')
return { code }
}
if (options.pure) {
if (!process.env.FORCE_COLOR && process.stdout.hasColors?.() && process.stderr.hasColors?.()) {
setEnv('FORCE_COLOR', '1') // Default to color output for subprocesses if our stream supports it
}
setEnv('EXODUS_TEST_CONTEXT', 'pure')
warnHuman(`${engineName} is experimental and may not work an expected`)
const missUnhandled = barebonesUnhandled.includes(options.platform) || isBrowserLike
if (missUnhandled) warnHuman(`Warning: ${engineName} does not have unhandled rejections tracking`)
const runOne = async (inputFile) => {
const bundled = buildFile ? await buildFile(inputFile) : undefined
if (buildFile) assert(bundled.file)
const file = buildFile ? bundled.file : inputFile
if (bundled?.errors.length > 0) return { ok: false, output: bundled.errors }
const failedBare = 'EXODUS_TEST_FAILED_EXIT_CODE_1'
const cleanOut = (out) => out.replaceAll(`\n${failedBare}\n`, '\n').replaceAll(failedBare, '')
// Timeout is fallback if timeout in script hangs, 50x as it can be adjusted per-script inside them
// Do we want to extract timeouts from script code instead? Also, hermes might be slower, so makes sense to increase
const timeout = (options.testTimeout || jestConfig?.testTimeout || 5000) * 50
const start = process.hrtime.bigint()
try {
const fullArgs = [...(options.binaryArgs ?? []), ...args, file]
const { code = 0, stdout, stderr } = await launch(options.binary, fullArgs, { timeout }, true)
const ms = Number(process.hrtime.bigint() - start) / 1e6
if (stdout.includes(failedBare)) return { ok: false, output: [cleanOut(stdout), stderr], ms }
const ok = code === 0 && !/^(✖ FAIL|‼ FATAL) /mu.test(stdout)
return { ok, output: [stdout, stderr], ms }
} catch (err) {
const ms = Number(process.hrtime.bigint() - start) / 1e6
const { code, stderr = '', signal, killed } = err
const stdout = cleanOut(err.stdout || '')
if (code === null) {
assert(signal)
const message = ` ${signal}${killed ? ' (killed)' : ''}`
const comment = killed && signal === 'SIGTERM' ? ' Most likely due to timeout reached' : ''
return { ok: false, output: [stdout, stderr, message, comment], ms }
}
if (Number.isInteger(code) && code > 0) return { ok: false, output: [stdout, stderr], ms } // Expected, test error
throw err // Internal test runner error, e.g. launch() failed
} finally {
if (bundled) await unlink(bundled.file)
}
}
const { Queue } = await import('@chalker/queue')
const queue = new Queue(options.concurrency || cpus - 1)
const runConcurrent = async (file) => {
await queue.claim()
try {
// need to await here
return await runOne(file)
} finally {
queue.release()
}
}
const { format, head, middle, tail, timeLabel, summary } = await import('./reporter.js')
const failures = []
const tasks = files.map((file) => ({ file, task: runConcurrent(file) }))
console.time(timeLabel)
for (const { file, task } of tasks) {
head(file)
const { ok, output, ms } = await task
middle(file, ok, ms)
for (const chunk of output.filter((x) => x.trim())) console.log(format(chunk).trimEnd())
tail(file)
if (!ok) failures.push(file)
}
if (failures.length > 0) process.exitCode = 1
summary(files, failures)
if (options.browsers) await browsers.close()
console.timeEnd(timeLabel)
} else {
assert(!buildFile)
assertBinary(options.binary, ['node', 'electron', 'deno', 'bun'])
assert(['node:test', 'electron-as-node:test', 'deno:test', 'bun:test'].includes(options.engine))
setEnv('EXODUS_TEST_CONTEXT', 'node:test') // The context is always node:test in this branch
assert(files.length > 0) // otherwise we can run recursively
if (options.concurrency) args.push('--test-concurrency', options.concurrency)
if (['--inspect', '--inspect-brk', '--inspect-wait'].includes(options.devtools)) {
args.push(options.devtools, '--experimental-network-inspection')
console.warn(
['--inspect-brk', '--inspect-wait'].includes(options.devtools)
? 'Open chrome://inspect/ to connect devtools, waiting'
: 'Open chrome://inspect/ to connect devtools\nUse --inspect-brk to wait for inspector'
)
}
const { code } = await launch(options.binary, [...(options.binaryArgs ?? []), ...args, ...files])
process.exitCode = code
}
if (globalTeardown) await globalTeardown()