@exodus/test 1.0.0-rc.10 → 1.0.0-rc.101

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +171 -50
  3. package/bin/browsers.js +137 -0
  4. package/bin/color.js +14 -0
  5. package/bin/electron.js +71 -0
  6. package/bin/electron.preload.cjs +2 -0
  7. package/bin/find-binary.js +102 -0
  8. package/bin/inband.js +14 -0
  9. package/bin/index.js +638 -92
  10. package/bin/reporter.js +194 -0
  11. package/expect.cjs +1 -0
  12. package/jest.js +1 -0
  13. package/{bin → loader}/babel.cjs +0 -2
  14. package/loader/deno-import-map.json +9 -0
  15. package/loader/esbuild.js +1 -0
  16. package/loader/esbuild.optional.js +6 -0
  17. package/loader/flow.js +27 -0
  18. package/loader/jest.js +2 -0
  19. package/loader/node-test.js +14 -0
  20. package/loader/typescript.js +3 -0
  21. package/loader/typescript.loader.js +36 -0
  22. package/node.js +1 -0
  23. package/package.json +130 -17
  24. package/src/dark.cjs +129 -27
  25. package/src/engine.js +22 -0
  26. package/src/engine.node.cjs +46 -0
  27. package/src/engine.pure.cjs +592 -0
  28. package/src/engine.pure.snapshot.cjs +37 -0
  29. package/src/engine.select.cjs +5 -0
  30. package/src/exodus.js +51 -0
  31. package/src/expect.cjs +180 -0
  32. package/src/glob.cjs +13 -0
  33. package/src/jest.config.fs.js +55 -0
  34. package/src/jest.config.js +233 -0
  35. package/src/jest.environment.js +34 -0
  36. package/src/jest.fn.js +23 -21
  37. package/src/jest.js +252 -52
  38. package/src/jest.mock.js +385 -67
  39. package/src/jest.setup.js +7 -0
  40. package/src/jest.snapshot.js +104 -40
  41. package/src/jest.timers.js +101 -20
  42. package/src/node.js +10 -0
  43. package/src/pretty-format.cjs +25 -0
  44. package/src/replay.js +106 -0
  45. package/src/tape.cjs +15 -0
  46. package/src/tape.js +11 -8
  47. package/src/timers-track.js +89 -0
  48. package/src/version.js +21 -0
  49. package/tape.js +1 -0
  50. package/bin/jest.js +0 -13
package/bin/index.js CHANGED
@@ -1,49 +1,144 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { spawn } from 'node:child_process'
3
+ import { spawn, execFile as execFileCallback } from 'node:child_process'
4
+ import { promisify } from 'node:util'
5
+ import { once } from 'node:events'
4
6
  import { fileURLToPath } from 'node:url'
5
- import { basename, dirname, resolve } from 'node:path'
6
- import { createRequire } from 'node:module'
7
+ import { basename, join } from 'node:path'
8
+ import { randomUUID } from 'node:crypto'
9
+ import { existsSync, rmSync, realpathSync } from 'node:fs'
10
+ import { unlink } from 'node:fs/promises'
11
+ import { tmpdir, availableParallelism, homedir } from 'node:os'
7
12
  import assert from 'node:assert/strict'
8
- import glob from 'fast-glob'
9
-
10
- const bindir = dirname(fileURLToPath(import.meta.url))
11
-
12
- const DEFAULT_PATTERNS = ['**/*.{test,spec}.{js,cjs,mjs,ts}', '**/{test,spec}.{js,cjs,mjs,ts}']
13
-
14
- function versionCheck() {
15
- const [major, minor, patch] = process.versions.node.split('.').map(Number)
16
- assert((major === 18 && minor >= 13) || major >= 20, 'Node.js version too old!')
17
- assert(major !== 21, 'Node.js version deprecated!')
13
+ // The following make sense only when we run the code in the same Node.js version, i.e. engineOptions.haveIsOk
14
+ import * as have from '../src/version.js'
15
+ import { findBinary } from './find-binary.js'
16
+ import * as browsers from './browsers.js'
17
+ import { glob as globImplementation } from '../src/glob.cjs'
18
+
19
+ const DEFAULT_PATTERNS = [`**/?(*.)+(spec|test).?([cm])[jt]s?(x)`] // do not trust magic dirs by default
20
+ const bundleOpts = { pure: true, bundle: true, esbuild: true, ts: 'auto' }
21
+ const bareboneOpts = { ...bundleOpts, barebone: true }
22
+ const hermesA = ['-Og', '-Xmicrotask-queue']
23
+ const denoA = ['run', '--allow-all'] // also will set DENO_COMPAT=1 env flag below
24
+ const ENGINES = new Map(
25
+ Object.entries({
26
+ 'node:test': { binary: 'node', pure: false, loader: '--import', ts: 'flag', haveIsOk: true },
27
+ 'node:pure': { binary: 'node', pure: true, loader: '--import', ts: 'flag', haveIsOk: true },
28
+ 'node:bundle': { binary: 'node', ...bundleOpts },
29
+ 'bun:test': { binary: 'bun', ts: 'auto' },
30
+ 'bun:pure': { binary: 'bun', pure: true, ts: 'auto' },
31
+ 'bun:bundle': { binary: 'bun', ...bundleOpts },
32
+ 'electron-as-node:test': { binary: 'electron', pure: false, loader: '--import', ts: 'flag' },
33
+ 'electron-as-node:pure': { binary: 'electron', pure: true, loader: '--import', ts: 'flag' },
34
+ 'electron-as-node:bundle': { binary: 'electron', ...bundleOpts },
35
+ 'electron:bundle': { binary: 'electron', electron: true, ...bundleOpts },
36
+ 'deno:test': { binary: 'deno', pure: false, loader: '--preload', ts: 'auto' },
37
+ 'deno:pure': { binary: 'deno', binaryArgs: denoA, pure: true, loader: '--preload', ts: 'auto' },
38
+ 'deno:bundle': { binary: 'deno', binaryArgs: ['run'], target: 'deno1', ...bundleOpts },
39
+ // Barebone engines
40
+ 'd8:bundle': { binary: 'd8', ...bareboneOpts },
41
+ 'jsc:bundle': { binary: 'jsc', target: 'safari13', ...bareboneOpts },
42
+ 'hermes:bundle': { binary: 'hermes', binaryArgs: hermesA, target: 'es2018', ...bareboneOpts },
43
+ 'spidermonkey:bundle': { binary: 'spidermonkey', ...bareboneOpts },
44
+ 'engine262:bundle': { binary: 'engine262', ...bareboneOpts },
45
+ 'quickjs:bundle': { binary: 'quickjs', binaryArgs: ['--std'], ...bareboneOpts },
46
+ 'xs:bundle': { binary: 'xs', ...bareboneOpts },
47
+ 'graaljs:bundle': { binary: 'graaljs', ...bareboneOpts },
48
+ 'escargot:bundle': { binary: 'escargot', ...bareboneOpts },
49
+ 'boa:bundle': { binary: 'boa', binaryArgs: ['-m'], ...bareboneOpts },
50
+ // Browser engines
51
+ 'chrome:puppeteer': { binary: 'chrome', browsers: 'puppeteer', ...bundleOpts },
52
+ 'firefox:puppeteer': { binary: 'firefox', browsers: 'puppeteer', ...bundleOpts },
53
+ 'brave:puppeteer': { binary: 'brave', browsers: 'puppeteer', ...bundleOpts },
54
+ 'msedge:puppeteer': { binary: 'msedge', browsers: 'puppeteer', ...bundleOpts },
55
+ 'chromium:playwright': { binary: 'chromium', browsers: 'playwright', ...bundleOpts },
56
+ 'firefox:playwright': { binary: 'firefox', browsers: 'playwright', ...bundleOpts },
57
+ 'webkit:playwright': { binary: 'webkit', browsers: 'playwright', ...bundleOpts },
58
+ 'chrome:playwright': { binary: 'chrome', browsers: 'playwright', ...bundleOpts },
59
+ 'msedge:playwright': { binary: 'msedge', browsers: 'playwright', ...bundleOpts },
60
+ })
61
+ )
62
+ const barebonesOk = ['d8', 'spidermonkey', 'quickjs', 'xs', 'hermes']
63
+ const barebonesUnhandled = ['jsc', 'escargot', 'boa', 'graaljs', 'engine262']
64
+
65
+ const getEnvFlag = (name) => {
66
+ if (!Object.hasOwn(process.env, name)) return
67
+ if ([undefined, '', '0', '1'].includes(process.env[name])) return process.env[name] === '1'
68
+ throw new Error(`Unexpected ${name} env value, expected '', '0', or '1'`)
69
+ }
18
70
 
19
- return { major, minor, patch }
71
+ function getNumber(arg) {
72
+ assert.equal(`${arg}`, `${Number(arg)}`)
73
+ return Number(arg)
20
74
  }
21
75
 
22
76
  function parseOptions() {
23
77
  const options = {
78
+ concurrency: undefined, // undefined means unset (can read from config), 0 means auto
24
79
  jest: false,
25
80
  typescript: false,
81
+ flow: false,
26
82
  esbuild: false,
27
83
  babel: false,
28
- coverage: false,
29
- coverageEngine: 'c8', // c8 or node
84
+ coverage: getEnvFlag('EXODUS_TEST_COVERAGE'),
85
+ coverageEngine: process.platform === 'win32' ? 'node' : 'c8', // c8 or node. TODO: can we use c8 on win?
30
86
  watch: false,
87
+ only: false,
31
88
  passWithNoTests: false,
32
89
  writeSnapshots: false,
90
+ devtools: false,
91
+ debug: { files: false },
92
+ dropNetwork: getEnvFlag('EXODUS_TEST_DROP_NETWORK'),
93
+ ideaCompat: false,
94
+ engine: process.env.EXODUS_TEST_ENGINE ?? 'node:test',
95
+ flagEngine: false, // Option combination error reporting differs when engine is passed by flag or env
96
+ entropySize: 5 * 1024,
97
+ require: [],
98
+ testNamePattern: [],
99
+ testTimeout: undefined,
33
100
  }
34
101
 
35
102
  const args = [...process.argv]
36
103
 
37
104
  // First argument should be node
38
- assert.equal(basename(args.shift()), 'node')
39
- assert.equal(basename(process.argv0), 'node')
105
+ assert(['node', 'node.exe'].includes(basename(args.shift())))
106
+ assert(['node', 'node.exe'].includes(basename(process.argv0)))
40
107
 
41
108
  // Second argument should be this script
42
109
  const jsname = args.shift()
43
- assert(basename(jsname) === 'exodus-test' || jsname === fileURLToPath(import.meta.url))
110
+ const pathsEqual = (a, b) => a === b || (existsSync(a) && realpathSync(a) === b) // resolve symlinks
111
+ assert(basename(jsname) === 'exodus-test' || pathsEqual(jsname, fileURLToPath(import.meta.url)))
44
112
 
45
- while (args[0]?.startsWith('--')) {
113
+ if (args[0] === '--playwright') {
114
+ const res = browsers.runPlaywrightCommand(args.slice(1))
115
+ process.exitCode = res.status ?? 1
116
+ process.exit(0)
117
+ }
118
+
119
+ class OptionValue extends String {}
120
+
121
+ while (args[0]?.startsWith('-')) {
46
122
  const option = args.shift()
123
+ if (option.includes('=')) {
124
+ const [optionName, ...rest] = option.split('=')
125
+ args.unshift(optionName, new OptionValue(rest.join('=')))
126
+ continue
127
+ }
128
+
129
+ if (options.ideaCompat) {
130
+ // Ignore some options IntelliJ IDEA is passing
131
+ switch (option) {
132
+ case '--reporters':
133
+ args.shift()
134
+ continue
135
+ case '--verbose':
136
+ case '--runTestsByPath':
137
+ case '--runInBand':
138
+ continue
139
+ }
140
+ }
141
+
47
142
  switch (option) {
48
143
  case '--global': // compat, will be removed in release
49
144
  case '--jest':
@@ -52,21 +147,34 @@ function parseOptions() {
52
147
  case '--typescript':
53
148
  options.typescript = true
54
149
  break
150
+ case '--flow':
151
+ options.flow = true
152
+ break
55
153
  case '--esbuild':
56
- options.esbuild = true
154
+ options.esbuild = args[0] instanceof OptionValue ? String(args.shift()) : '*'
57
155
  break
58
156
  case '--babel':
59
157
  options.babel = true
60
158
  break
159
+ case '--require':
160
+ options.require.push(String(args.shift()))
161
+ break
61
162
  case '--coverage-engine':
62
- options.coverageEngine = args.shift()
163
+ options.coverageEngine = String(args.shift())
63
164
  break
64
165
  case '--coverage':
65
166
  options.coverage = true
66
167
  break
168
+ case '--no-coverage':
169
+ options.coverage = false
170
+ break
67
171
  case '--watch':
68
172
  options.watch = true
69
173
  break
174
+ case '--test-only':
175
+ case '--only':
176
+ options.only = true
177
+ break
70
178
  case '--passWithNoTests':
71
179
  options.passWithNoTests = true
72
180
  break
@@ -78,117 +186,313 @@ function parseOptions() {
78
186
  case '--forceExit':
79
187
  options.forceExit = true
80
188
  break
189
+ case '--engine':
190
+ options.engine = String(args.shift())
191
+ options.flagEngine = true
192
+ break
193
+ case '--devtools':
194
+ case '--inspect-brk':
195
+ options.devtools = '--inspect-brk'
196
+ break
197
+ case '--inspect-wait':
198
+ if (options.devtools !== '--inspect-brk') options.devtools = '--inspect-wait'
199
+ break
200
+ case '--inspect':
201
+ if (!options.devtools) options.devtools = '--inspect'
202
+ break
203
+ case '--debug-files':
204
+ options.debug.files = true
205
+ break
206
+ case '--colors':
207
+ process.env.FORCE_COLOR = '1'
208
+ break
209
+ case '--no-colors':
210
+ process.env.FORCE_COLOR = '0'
211
+ process.env.NO_COLOR = '1'
212
+ process.env.NODE_DISABLE_COLORS = '1'
213
+ break
214
+ case '--drop-network':
215
+ options.dropNetwork = true
216
+ break
217
+ case '--idea-compat':
218
+ options.ideaCompat = true
219
+ break
220
+ case '--throttle-cpu':
221
+ options.throttle = getNumber(args.shift())
222
+ assert(Number.isInteger(options.throttle) && options.throttle > 0) // throttle x times, 1 is no throttle, 2 is 2x slowdown
223
+ break
224
+ case '--debug-timers':
225
+ setEnv('EXODUS_TEST_TIMERS_TRACK', '1')
226
+ break
227
+ case '--concurrency':
228
+ options.concurrency = getNumber(args.shift())
229
+ assert(Number.isInteger(options.concurrency) && options.concurrency >= 0)
230
+ break
231
+ case '--bundle-entropy-size':
232
+ options.entropySize = Number(args.shift())
233
+ break
234
+ case '-t':
235
+ case '--test-name-pattern':
236
+ case '--testNamePattern':
237
+ options.testNamePattern.push(String(args.shift()))
238
+ break
239
+ case '--testTimeout':
240
+ options.testTimeout = Number(args.shift())
241
+ break
81
242
  default:
82
243
  throw new Error(`Unknown option: ${option}`)
83
244
  }
84
245
  }
85
246
 
86
- assert(
87
- args.every((arg) => !arg.startsWith('--')),
88
- 'Options should come before patterns'
89
- )
247
+ const argsArePlainStrings = args.every((arg) => typeof arg === 'string' && !arg.startsWith('--'))
248
+ assert(argsArePlainStrings, 'Options should come before patterns')
90
249
 
91
250
  const patterns = [...args]
92
- if (patterns.length === 0) patterns.push(...DEFAULT_PATTERNS) // defaults
93
251
 
94
252
  return { options, patterns }
95
253
  }
96
254
 
97
- const { major, minor } = versionCheck()
255
+ const isTTY = process.stdout.isTTY
256
+ const isCI = process.env.CI
257
+ const warnHuman = isTTY && !isCI ? (...args) => console.warn(...args) : () => {}
258
+ if (isCI) process.env.FORCE_COLOR = '1' // should support colored output even though not a TTY, overridable with --no-color
259
+
260
+ const setEnv = (name, value) => {
261
+ const env = process.env[name]
262
+ if (env && env !== value) throw new Error(`env conflict: ${name}="${env}", effective: "${value}"`)
263
+ process.env[name] = value === undefined ? '' : value
264
+ }
265
+
98
266
  const { options, patterns } = parseOptions()
99
267
 
100
- let program = 'node'
268
+ const engineName = `${options.engine} engine` // used for warnings to user
269
+ const engineFlagError = (flag) => `${engineName} does not support --${flag}`
270
+ const engineOptions = ENGINES.get(options.engine)
271
+ assert(engineOptions, `Unknown engine: ${options.engine}`)
272
+ Object.assign(options, engineOptions)
273
+ options.platform = options.binary // binary can be overriden by c8 or electron
274
+ const isBrowserLike = options.browsers || options.electron
275
+ setEnv('EXODUS_TEST_ENGINE', options.engine) // e.g. 'hermes:bundle', 'node:bundle', 'node:test', 'node:pure'
276
+ setEnv('EXODUS_TEST_PLATFORM', options.binary) // e.g. 'hermes', 'node'
277
+ setEnv('EXODUS_TEST_TIMEOUT', options.testTimeout)
278
+ setEnv('EXODUS_TEST_DEVTOOLS', options.devtools ? '1' : '')
279
+ setEnv('EXODUS_TEST_IS_BROWSER', isBrowserLike ? '1' : '')
280
+ setEnv('EXODUS_TEST_IS_BAREBONE', options.barebone ? '1' : '')
281
+ setEnv('EXODUS_TEST_ENVIRONMENT', options.bundle ? 'bundle' : '') // perhaps switch to _IS_BUNDLED?
282
+ if (['deno:pure', 'deno:test'].includes(options.engine)) setEnv('DENO_COMPAT', '1') // https://deno.com/blog/v2.4#deno_compat1
283
+
284
+ assert(!options.devtools || isBrowserLike || !options.pure, engineFlagError('devtools'))
285
+ assert(!options.throttle || options.browsers, engineFlagError('throttle-cpu'))
286
+
287
+ const args = []
288
+
289
+ if (have.haveModuleMocks && engineOptions.haveIsOk) {
290
+ args.push('--experimental-test-module-mocks')
291
+ }
101
292
 
102
- const require = createRequire(import.meta.url)
103
- const resolveRequire = (query) => require.resolve(query)
104
- const resolveImport = import.meta.resolve && ((query) => fileURLToPath(import.meta.resolve(query)))
293
+ if (options.pure) {
294
+ if (options.bundle) {
295
+ assert(!options.coverage, `Can not use --coverage with ${engineName}`)
296
+ assert(!options.babel, `Can not use --babel with ${engineName}`) // TODO?
297
+ }
105
298
 
106
- const c8 = resolveRequire('c8/bin/c8.js')
107
- if (resolveImport) assert.equal(c8, resolveImport('c8/bin/c8.js'))
299
+ const requiresNodeCoverage = options.coverage && options.coverageEngine === 'node'
300
+ assert(!requiresNodeCoverage, '"--coverage-engine node" requires "--engine node:test" (default)')
301
+ assert(!options.writeSnapshots, `Can not use write snapshots with ${engineName}`)
302
+ assert(!options.forceExit, `Can not use --force-exit with ${engineName} yet`) // TODO
303
+ assert(!options.watch, `Can not use --watch with with ${engineName}`)
304
+ assert(options.testNamePattern.length === 0, '--test-name-pattern requires node:test engine now')
305
+ // eslint-disable-next-line unicorn/prefer-switch
306
+ } else if (options.engine === 'node:test' || options.engine === 'electron-as-node:test') {
307
+ const reporter = import.meta.resolve('./reporter.js')
308
+ args.push('--test', '--no-warnings=ExperimentalWarning', '--test-reporter', reporter)
309
+
310
+ if (have.haveSnapshots && engineOptions.haveIsOk) args.push('--experimental-test-snapshots')
311
+
312
+ if (options.writeSnapshots) {
313
+ assert(have.haveSnapshots && engineOptions.haveIsOk, 'For snapshots, use Node.js >=22.3.0')
314
+ args.push('--test-update-snapshots')
315
+ }
108
316
 
109
- const args = ['--test', '--no-warnings=ExperimentalWarning']
317
+ if (options.forceExit) {
318
+ assert(have.haveForceExit && engineOptions.haveIsOk, 'For forceExit, use Node.js >= 20.14.0')
319
+ args.push('--test-force-exit')
320
+ }
110
321
 
111
- const haveModuleMocks = major > 22 || (major === 22 && minor >= 3)
112
- if (haveModuleMocks) args.push('--experimental-test-module-mocks')
322
+ if (options.watch) args.push('--watch')
323
+ if (options.only) args.push('--test-only')
113
324
 
114
- const haveSnapshots = major > 22 || (major === 22 && minor >= 3)
115
- if (haveSnapshots) args.push('--experimental-test-snapshots')
325
+ for (const pattern of options.testNamePattern) args.push('--test-name-pattern', pattern)
116
326
 
117
- if (options.writeSnapshots) {
118
- assert(haveSnapshots, 'For snapshots, use Node.js >=22.3.0')
119
- args.push('--test-update-snapshots')
327
+ args.push('--expose-internals') // this is unoptimal and hopefully temporary, see rationale in src/dark.cjs
328
+ } else if (options.engine === 'deno:test') {
329
+ args.push('test', '--allow-all')
330
+ assert(!options.jest, 'deno:test engine does not support --jest yet')
331
+ } else if (options.engine === 'bun:test') {
332
+ args.push('test')
333
+ throw new Error('bun:test is unavailable because Bun test runner has many bugs and does not work')
334
+ } else {
335
+ throw new Error('Unreachable')
120
336
  }
121
337
 
122
- if (options.forceExit) {
123
- assert((major === 20 && minor > 13) || major >= 22, 'For forceExit, use Node.js >= 20.14.0')
124
- args.push('--test-force-exit')
338
+ const ignore = ['**/node_modules']
339
+ let filter
340
+ if (process.env.EXODUS_TEST_IGNORE) {
341
+ // fast-glob treats negative ignore patterns exactly the same as positive, let's not cause a confusion
342
+ assert(!process.env.EXODUS_TEST_IGNORE.startsWith('!'), 'Ignore pattern should not be negative')
343
+ ignore.push(process.env.EXODUS_TEST_IGNORE)
125
344
  }
126
345
 
127
- if (options.watch) {
128
- assert((major === 18 && minor > 13) || major >= 20, 'For watch mode, use Node.js >= 18.13.0')
129
- args.push('--watch')
346
+ // This might be used in presets, so has to be loaded before jest
347
+ if (options.flow && !options.bundle) args.push('--import', import.meta.resolve('../loader/flow.js'))
348
+ if (['node:test', 'electron-as-node:test', 'deno:test'].includes(options.engine)) {
349
+ // Do not need node:test override
350
+ } else if (options.engine === 'deno:pure') {
351
+ args.push('--import-map', import.meta.resolve('../loader/deno-import-map.json'))
352
+ } else if (!options.bundle) {
353
+ args.push(options.loader ?? '-r', import.meta.resolve('../loader/node-test.js'))
130
354
  }
131
355
 
132
- args.push('--expose-internals') // this is unoptimal and hopefully temporary, see rationale in src/dark.cjs
133
-
134
- if (options.coverage) {
135
- if (options.coverageEngine === 'node') {
136
- args.push('--experimental-test-coverage')
137
- } else if (options.coverageEngine === 'c8') {
138
- program = c8
139
- args.unshift('node')
140
- // perhaps use text-summary ?
141
- args.unshift('-r', 'text', '-r', 'html')
356
+ // The comment below is disabled, we don't auto-mock @jest/globals anymore, and having our loader first is faster
357
+ // [Disabled] Our loader should be last, as enabling module mocks confuses other loaders
358
+ let jestConfig = null
359
+ let globalTeardown
360
+ if (options.jest) {
361
+ const { loadJestConfig } = await import('../src/jest.config.js')
362
+ const config = await loadJestConfig(process.cwd())
363
+ jestConfig = config
364
+ if (options.bundle) {
365
+ setEnv('EXODUS_TEST_JEST_CONFIG', JSON.stringify(jestConfig))
142
366
  } else {
143
- throw new Error(`Unknown coverage engine: ${JSON.stringify(options.coverageEngine)}`)
367
+ args.push(options.loader ?? '-r', import.meta.resolve('../loader/jest.js'))
368
+ }
369
+
370
+ if (config.testFailureExitCode !== undefined) {
371
+ if (Number(config.testFailureExitCode) === 0) {
372
+ console.warn('Jest is configured to succeed with exit code 0 on test failures!')
373
+ }
374
+
375
+ process.on('exit', (code) => {
376
+ if (code !== 0) process.exitCode = config.testFailureExitCode
377
+ })
378
+ }
379
+
380
+ if (patterns.length > 0) {
381
+ // skip, we already have patterns via argv
382
+ } else if (config.testRegex) {
383
+ assert(typeof config.testRegex === 'string', `config.testRegex should be a string`)
384
+ assert(!config.testMatch, 'config.testRegex can not be used together with config.testMatch')
385
+ patterns.push('**/*')
386
+ } else if (config.testMatch) {
387
+ patterns.push(...(Array.isArray(config.testMatch) ? config.testMatch : [config.testMatch]))
388
+ }
389
+
390
+ if (config.passWithNoTests) options.passWithNoTests = true
391
+ const testRegex = config.testRegex ? new RegExp(config.testRegex, 'u') : null
392
+ const ignoreRegexes = config.testPathIgnorePatterns.map((x) => new RegExp(x, 'u'))
393
+ if (testRegex || ignoreRegexes.length > 0) {
394
+ filter = (x) => {
395
+ const resolved = `<rootDir>/${x}` // don't actually include cwd, that should be irrelevant
396
+ if (testRegex && !testRegex.test(resolved)) return false
397
+ return !ignoreRegexes.some((r) => r.test(resolved))
398
+ }
399
+ }
400
+
401
+ if (config.collectCoverage && options.coverage === undefined) options.coverage = true
402
+ if (config.maxWorkers && options.concurrency === undefined) {
403
+ options.concurrency = config.maxWorkers
404
+ }
405
+
406
+ for (const key of ['globalSetup', 'globalTeardown']) {
407
+ if (!config[key]) continue
408
+ const { default: method } = await import(config[key])
409
+ assert(method, `config.${key} does not export a default method`)
410
+ assert(method.length === 0, `Arguments for config.${key} are not supported yet`)
411
+ if (key === 'globalTeardown') {
412
+ globalTeardown = method
413
+ } else {
414
+ await method() // globalSetup
415
+ }
144
416
  }
145
417
  }
146
418
 
147
- if (options.typescript || options.esbuild) {
148
- if (major >= 22 || (major === 20 && minor >= 6) || (major === 18 && minor >= 18)) {
149
- assert(resolveImport)
150
- args.push('--import', resolveImport('tsx'))
419
+ if (options.concurrency) {
420
+ const raw = options.concurrency
421
+ let concurrency = raw
422
+ if (typeof raw === 'string') {
423
+ if (/^\d{1,15}%$/u.test(raw)) {
424
+ const perc = Number(raw.slice(0, -1))
425
+ concurrency = Math.max(1, Math.round((perc * availableParallelism()) / 100))
426
+ } else {
427
+ assert(/^\d{1,15}$/u.test(raw), `Wrong concurrency: ${raw}`)
428
+ concurrency = Number(raw)
429
+ }
430
+ }
431
+
432
+ assert(Number.isSafeInteger(concurrency) && concurrency >= 1, `Wrong concurrency: ${raw}`)
433
+ options.concurrency = concurrency
434
+ }
435
+
436
+ if (options.esbuild && !options.bundle) {
437
+ setEnv('EXODUS_TEST_ESBUILD', options.esbuild)
438
+ if (options.loader === '--import') {
439
+ const optional = options.esbuild === '*' ? '' : '.optional'
440
+ args.push('--import', import.meta.resolve(`../loader/esbuild${optional}.js`))
441
+ } else if (options.flagEngine === false) {
442
+ // Engine is set via env, --esbuild set via flag. Allow but warn
443
+ console.warn(`Warning: ${engineName} does not support --esbuild option`)
151
444
  } else {
152
- args.push('-r', resolveRequire('tsx/cjs'))
445
+ console.error(`Error: ${engineName} does not support --esbuild option`)
446
+ process.exit(1)
153
447
  }
154
448
  }
155
449
 
156
450
  if (options.babel) {
157
- assert(!options.typescript, 'Options --babel and --typescript are mutually exclusive')
158
- args.push('-r', resolveRequire('./babel.cjs'))
451
+ assert(!options.esbuild, 'Options --babel and --esbuild are mutually exclusive')
452
+ args.push('-r', import.meta.resolve('../loader/babel.cjs'))
159
453
  }
160
454
 
161
- // Our loader should be last, as enabling module mocks confuses other loaders
162
- if (options.jest) {
163
- if (major >= 20 || (major === 18 && minor >= 18)) {
164
- args.push('--import', resolve(bindir, 'jest.js'))
165
- } else {
166
- throw new Error('Option --jest requires Node.js >= v18.18.0')
455
+ if (options.typescript) {
456
+ assert(!options.esbuild, 'Options --typescript and --esbuild are mutually exclusive')
457
+ assert(!options.babel, 'Options --typescript and --babel are mutually exclusive')
458
+
459
+ if (options.ts === 'flag') {
460
+ assert(options.loader === '--import')
461
+ // TODO: switch to native --experimental-strip-types where available
462
+ args.push('--import', import.meta.resolve('../loader/typescript.js'))
463
+ } else if (options.ts !== 'auto') {
464
+ throw new Error(`Processing --typescript is not possible with ${engineName}`)
167
465
  }
168
466
  }
169
467
 
170
- const ignore = ['node_modules']
171
- if (process.env.EXODUS_TEST_IGNORE) {
172
- // fast-glob treats negative ignore patterns exactly the same as positive, let's not cause a confusion
173
- assert(!process.env.EXODUS_TEST_IGNORE.startsWith('!'), 'Ignore pattern should not be negative')
174
- ignore.push(process.env.EXODUS_TEST_IGNORE)
468
+ for (const r of options.require) {
469
+ assert(!options.bundle, 'Can not use -r with *:bundle engines')
470
+ args.push('-r', r)
175
471
  }
176
472
 
177
- const allfiles = await glob(patterns, { ignore })
473
+ async function glob(patterns, { ignore, cwd }) {
474
+ const patternsY = patterns.filter((x) => !x.startsWith('!'))
475
+ const patternsN = patterns.filter((x) => x.startsWith('!')).map((x) => x.slice(1))
476
+ return globImplementation(patternsY, { exclude: [...ignore, ...patternsN], cwd })
477
+ }
478
+
479
+ if (patterns.length === 0) patterns.push(...DEFAULT_PATTERNS) // defaults
480
+ const globbed = await glob(patterns, { ignore })
481
+ const allfiles = filter ? globbed.filter(filter) : globbed
178
482
 
179
483
  if (allfiles.length === 0) {
180
484
  if (options.passWithNoTests) {
181
- console.warn('No tests files found, but passing due to --passWithNoTests')
485
+ console.warn('No test files found, but passing due to --passWithNoTests')
182
486
  process.exit(0)
183
487
  }
184
488
 
185
- console.error('No tests files found!')
489
+ console.error('No test files found!')
186
490
  process.exit(1)
187
491
  }
188
492
 
189
493
  let subfiles // must be a strict subset of allfiles
190
494
  if (process.env.EXODUS_TEST_SELECT) {
191
- const subfiles = await glob(process.env.EXODUS_TEST_SELECT, { ignore })
495
+ subfiles = await glob(process.env.EXODUS_TEST_SELECT, { ignore })
192
496
 
193
497
  const allSet = new Set(allfiles)
194
498
  const stray = subfiles.filter((file) => !allSet.has(file))
@@ -198,29 +502,271 @@ if (process.env.EXODUS_TEST_SELECT) {
198
502
  }
199
503
 
200
504
  if (subfiles.length === 0) {
201
- console.error('No tests files selected due to EXODUS_TEST_SELECT, passing')
505
+ console.error('No test files selected due to EXODUS_TEST_SELECT, passing')
202
506
  process.exit(0)
203
507
  }
204
508
  }
205
509
 
206
510
  const files = subfiles ?? allfiles
207
511
 
208
- const tsTests = files.filter((file) => file.endsWith('.ts'))
209
- if (tsTests.length > 0 && !options.typescript) {
210
- console.error(`Some tests require --typescript flag:\n ${tsTests.join('\n ')}`)
512
+ files.sort((a, b) => {
513
+ const [al, bl] = [a.split('/'), b.split('/')]
514
+ while (al[0] === bl[0]) {
515
+ al.shift()
516
+ bl.shift()
517
+ }
518
+
519
+ // First process each file in dir, then subdirs
520
+ if (al.length < 2) return -1
521
+ if (bl.length < 2) return 1
522
+ // Prefer example/ over example-something/
523
+ const [an, bn] = [al, bl].map((list) => list.join(String.fromCodePoint(0)))
524
+ if (an < bn) return -1
525
+ if (an > bn) return 1
526
+ throw new Error('Unreachable')
527
+ })
528
+
529
+ if (options.debug.files) {
530
+ for (const f of files) console.log(f) // joining with \n can get truncated, too big
531
+ process.exit(1) // do not succeed!
532
+ }
533
+
534
+ const tsTests = files.filter((file) => /\.[mc]?tsx?$/u.test(file))
535
+ const tsSupport = options.ts === 'auto' || options.esbuild || options.typescript || options.babel
536
+ if (tsTests.length > 0 && !tsSupport) {
537
+ console.error(`Some tests require --typescript or --esbuild flag:\n ${tsTests.join('\n ')}`)
211
538
  process.exit(1)
212
- } else if (!allfiles.some((file) => file.endsWith('.ts')) && options.typescript) {
539
+ } else if (!allfiles.some((file) => /\.[cm]?ts$/.test(file)) && options.typescript) {
213
540
  console.warn(`Flag --typescript has been used, but there were no TypeScript tests found!`)
214
541
  }
215
542
 
216
- assert(files.length > 0) // otherwise we can run recursively
217
- args.push(...files)
543
+ if (!options.bundle) {
544
+ // uses top-level await, :bundle doesn't have that
545
+ const inband = new Set(files.filter((f) => basename(f).includes('.inband.')))
546
+ if (inband.size > 0) {
547
+ process.env.EXODUS_TEST_INBAND = JSON.stringify([...inband])
548
+ const remaning = files.filter((f) => !inband.has(f))
549
+ files.length = 0
550
+ files.push(fileURLToPath(import.meta.resolve('./inband.js')), ...remaning)
551
+ }
552
+ }
218
553
 
219
554
  if (!Object.hasOwn(process.env, 'NODE_ENV')) process.env.NODE_ENV = 'test'
555
+ setEnv('EXODUS_TEST_ONLY', options.only ? '1' : '')
556
+
557
+ let c8
558
+ if (options.coverage) {
559
+ assert.equal(options.binary, 'node', 'Coverage is only supported with Node.js')
560
+ if (options.coverageEngine === 'node') {
561
+ args.push('--experimental-test-coverage')
562
+ if (have.haveCoverExclude && engineOptions.haveIsOk) {
563
+ args.push(
564
+ `--test-coverage-exclude=**/@exodus/test/src/**`,
565
+ `--test-coverage-exclude=${DEFAULT_PATTERNS[0]}`
566
+ )
567
+ }
568
+ } else if (options.coverageEngine === 'c8') {
569
+ c8 = findBinary('c8')
570
+ assert.equal(c8, fileURLToPath(import.meta.resolve('c8/bin/c8.js')))
571
+ args.unshift(options.binary)
572
+ options.binary = c8
573
+ // perhaps use text-summary ?
574
+ args.unshift('-r', 'text', '-r', 'html', '-r', 'lcov', '-r', 'json-summary')
575
+ } else {
576
+ throw new Error(`Unknown coverage engine: ${JSON.stringify(options.coverageEngine)}`)
577
+ }
578
+ }
579
+
580
+ if (options.binary === 'electron') {
581
+ if (isBrowserLike) {
582
+ assert(!options.binaryArgs)
583
+ options.binaryArgs = [fileURLToPath(import.meta.resolve('./electron.js'))]
584
+ } else {
585
+ setEnv('ELECTRON_RUN_AS_NODE', '1')
586
+ }
587
+ }
588
+
589
+ if (options.barebone || options.binary === 'electron') {
590
+ options.binary = findBinary(options.binary)
591
+ options.binaryCanBeAbsolute = true
592
+ }
593
+
594
+ const makeTitle = () => {
595
+ let title = options.browsers === 'puppeteer' ? findBinary(options.binary) : options.binary
596
+ if (options.browsers === 'playwright') return `${title} (Playwright-managed)`
597
+ if (basename(title) === title) return title
598
+ const dir = { '~': `${process.cwd()}/`, '.': `${homedir()}/` }
599
+ if (title.startsWith(dir['~']) && dir['~'].length > 1) title = `./${title.slice(dir['~'].length)}`
600
+ if (title.startsWith(dir['.']) && dir['.'].length > 1) title = `~/${title.slice(dir['.'].length)}`
601
+ return /\s/u.test(title) ? JSON.stringify(title) : title
602
+ }
603
+
604
+ const { color } = await import('./color.js') // can't load before env flags are set
605
+ console.info(color(`Engine: ${options.engine}, running on ${makeTitle()}`, 'green'))
606
+
607
+ const assertBinary = (binary, allowed) => {
608
+ if (allowed.includes(binary)) return
609
+ if (existsSync(binary)) {
610
+ const name = basename(binary.toLowerCase()).replace(/\.exe$/u, '')
611
+ if ((c8 && binary === c8) || (options.binaryCanBeAbsolute && allowed.includes(name))) return
612
+ }
613
+
614
+ throw new Error(`Unexpected binary: ${binary}`)
615
+ }
616
+
617
+ setEnv('EXODUS_TEST_EXECARGV', JSON.stringify(args))
618
+ let buildFile
619
+
620
+ if (options.bundle) {
621
+ const outdir = join(tmpdir(), `exodus-test-${randomUUID().slice(0, 8)}`)
622
+ process.on('exit', () => rmSync(outdir, { recursive: true, force: true }))
623
+ assert.deepEqual(args, [])
624
+
625
+ if (options.binary === 'node') args.unshift('--enable-source-maps') // FIXME
626
+
627
+ const bundle = await import('@exodus/test-bundler/bundle')
628
+ bundle.setResolver((file) => fileURLToPath(import.meta.resolve(`../src/${file}`)))
629
+ await bundle.init({ ...options, outdir, jestConfig })
630
+ buildFile = (file) => bundle.build(file)
631
+ }
632
+
633
+ if (options.dropNetwork) warnHuman('--drop-network is a test helper, not a security mechanism')
634
+
635
+ const execFile = promisify(execFileCallback)
636
+
637
+ async function launch(binary, args, opts = {}, buffering = false) {
638
+ if (options.browsers) {
639
+ assert(buffering, 'Unexpected non-buffered browser run')
640
+ const { timeout } = opts
641
+ const { browsers: runner, devtools, dropNetwork, throttle } = options
642
+ return browsers.run(runner, args, { binary, devtools, dropNetwork, timeout, throttle })
643
+ }
644
+
645
+ const barebones = [...barebonesOk, ...barebonesUnhandled]
646
+ assertBinary(binary, ['node', 'bun', 'deno', 'electron', ...barebones, 'v8']) // v8 is an alias to d8
647
+ if (binary === c8 && process.platform === 'win32') {
648
+ ;[binary, args] = ['node', [binary, ...args]]
649
+ }
650
+
651
+ if (options.dropNetwork) {
652
+ switch (process.platform) {
653
+ case 'darwin':
654
+ ;[binary, args] = ['sandbox-exec', ['-n', 'no-network', binary, ...args]]
655
+ break
656
+ case 'linux':
657
+ ;[binary, args] = ['unshare', ['-n', '-r', binary, ...args]]
658
+ break
659
+ default:
660
+ assert.fail(`--drop-network is not implemented on platform: ${process.platform}`)
661
+ }
662
+ }
663
+
664
+ if (buffering) return execFile(binary, args, { maxBuffer: 5 * 1024 * 1024, ...opts }) // 5 MiB just in case
665
+ const child = spawn(binary, args, { stdio: 'inherit', ...opts })
666
+ const [code] = await once(child, 'close')
667
+ return { code }
668
+ }
669
+
670
+ if (options.pure) {
671
+ if (!process.env.FORCE_COLOR && process.stdout.hasColors?.() && process.stderr.hasColors?.()) {
672
+ setEnv('FORCE_COLOR', '1') // Default to color output for subprocesses if our stream supports it
673
+ }
674
+
675
+ setEnv('EXODUS_TEST_CONTEXT', 'pure')
676
+ warnHuman(`${engineName} is experimental and may not work an expected`)
677
+ const missUnhandled = barebonesUnhandled.includes(options.platform) || isBrowserLike
678
+ if (missUnhandled) warnHuman(`Warning: ${engineName} does not have unhandled rejections tracking`)
679
+
680
+ const runOne = async (inputFile) => {
681
+ const bundled = buildFile ? await buildFile(inputFile) : undefined
682
+ if (buildFile) assert(bundled.file)
683
+ const file = buildFile ? bundled.file : inputFile
684
+ if (bundled?.errors.length > 0) return { ok: false, output: bundled.errors }
685
+
686
+ const failedBare = 'EXODUS_TEST_FAILED_EXIT_CODE_1'
687
+ const cleanOut = (out) => out.replaceAll(`\n${failedBare}\n`, '\n').replaceAll(failedBare, '')
688
+ const { binaryArgs = [] } = options
689
+ // Timeout is fallback if timeout in script hangs, 50x as it can be adjusted per-script inside them
690
+ // Do we want to extract timeouts from script code instead? Also, hermes might be slower, so makes sense to increase
691
+ const timeout = (options.testTimeout || jestConfig?.testTimeout || 5000) * 50
692
+ const start = process.hrtime.bigint()
693
+ try {
694
+ const fullArgs = [...binaryArgs, ...args, file]
695
+ const { code = 0, stdout, stderr } = await launch(options.binary, fullArgs, { timeout }, true)
696
+ const ms = Number(process.hrtime.bigint() - start) / 1e6
697
+ if (stdout.includes(failedBare)) return { ok: false, output: [cleanOut(stdout), stderr], ms }
698
+ const ok = code === 0 && !/^(✖ FAIL|‼ FATAL) /mu.test(stdout)
699
+ return { ok, output: [stdout, stderr], ms }
700
+ } catch (err) {
701
+ const ms = Number(process.hrtime.bigint() - start) / 1e6
702
+ const { code, stderr = '', signal, killed } = err
703
+ const stdout = cleanOut(err.stdout || '')
704
+ if (code === null) {
705
+ assert(signal)
706
+ const message = ` ${signal}${killed ? ' (killed)' : ''}`
707
+ const comment = killed && signal === 'SIGTERM' ? ' Most likely due to timeout reached' : ''
708
+ return { ok: false, output: [stdout, stderr, message, comment], ms }
709
+ }
710
+
711
+ if (Number.isInteger(code) && code > 0) return { ok: false, output: [stdout, stderr], ms } // Expected, test error
712
+
713
+ throw err // Internal test runner error, e.g. launch() failed
714
+ } finally {
715
+ if (bundled) await unlink(bundled.file)
716
+ }
717
+ }
220
718
 
221
- assert(program && ['node', c8].includes(program))
222
- const node = spawn(program, args, { stdio: 'inherit' })
719
+ const { Queue } = await import('@chalker/queue')
720
+ const queue = new Queue(options.concurrency || availableParallelism() - 1)
721
+ const runConcurrent = async (file) => {
722
+ await queue.claim()
723
+ try {
724
+ // need to await here
725
+ return await runOne(file)
726
+ } finally {
727
+ queue.release()
728
+ }
729
+ }
223
730
 
224
- node.on('close', (code) => {
731
+ const { format, head, middle, tail, timeLabel, summary } = await import('./reporter.js')
732
+
733
+ const failures = []
734
+ const tasks = files.map((file) => ({ file, task: runConcurrent(file) }))
735
+ console.time(timeLabel)
736
+ for (const { file, task } of tasks) {
737
+ head(file)
738
+ const { ok, output, ms } = await task
739
+ middle(file, ok, ms)
740
+ for (const chunk of output.filter((x) => x.trim())) console.log(format(chunk).trimEnd())
741
+ tail(file)
742
+ if (!ok) failures.push(file)
743
+ }
744
+
745
+ if (failures.length > 0) process.exitCode = 1
746
+ summary(files, failures)
747
+
748
+ if (options.browsers) await browsers.close()
749
+ console.timeEnd(timeLabel)
750
+ } else {
751
+ assert(!buildFile)
752
+ assertBinary(options.binary, ['node', 'electron', 'deno', 'bun'])
753
+ assert(['node:test', 'electron-as-node:test', 'deno:test', 'bun:test'].includes(options.engine))
754
+ setEnv('EXODUS_TEST_CONTEXT', 'node:test') // The context is always node:test in this branch
755
+ assert(files.length > 0) // otherwise we can run recursively
756
+ assert(!options.binaryArgs)
757
+ if (options.concurrency) args.push('--test-concurrency', options.concurrency)
758
+ if (['--inspect', '--inspect-brk', '--inspect-wait'].includes(options.devtools)) {
759
+ args.push(options.devtools)
760
+ if (have.haveNetworkInspection) args.push('--experimental-network-inspection')
761
+ console.warn(
762
+ ['--inspect-brk', '--inspect-wait'].includes(options.devtools)
763
+ ? 'Open chrome://inspect/ to connect devtools, waiting'
764
+ : 'Open chrome://inspect/ to connect devtools\nUse --inspect-brk to wait for inspector'
765
+ )
766
+ }
767
+
768
+ const { code } = await launch(options.binary, [...args, ...files])
225
769
  process.exitCode = code
226
- })
770
+ }
771
+
772
+ if (globalTeardown) await globalTeardown()