@exodus/test 1.0.0-rc.37 → 1.0.0-rc.39

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.
@@ -36,8 +36,20 @@ if (isMainThread) {
36
36
  module.exports = { transformAsync }
37
37
  } else {
38
38
  const babel = require('@babel/core')
39
+ const tryLoadPlugin = (name) => {
40
+ // Try unwrapping plugin names, as otherwise Babel tries to require them from the wrong dir,
41
+ // which breaks strict directory structure under pnpm in some setups
42
+ try {
43
+ if (typeof name === 'string' && name.startsWith('@babel/plugin-')) return require(name)
44
+ } catch {}
45
+
46
+ return name
47
+ }
48
+
39
49
  parentPort.on('message', ({ port, code: input, options }) => {
40
50
  try {
51
+ // eslint-disable-next-line @exodus/mutable/no-param-reassign-prop-only
52
+ if (options.plugins) options.plugins = options.plugins.map((name) => tryLoadPlugin(name))
41
53
  const { code, sourcetype, map } = babel.transformSync(input, options) // async here is useless and slower
42
54
  // additional properties are deleted as we don't want to transfer e.g. Plugin instances
43
55
  port.postMessage({ result: { code, sourcetype, map } })
package/bin/bundle.js CHANGED
@@ -139,7 +139,6 @@ export const build = async (...files) => {
139
139
 
140
140
  if (options.jest) {
141
141
  const { jestConfig } = options
142
- assert(jestConfig.rootDir)
143
142
  const preload = [...(jestConfig.setupFiles || []), ...(jestConfig.setupFilesAfterEnv || [])]
144
143
  if (jestConfig.testEnvironment && jestConfig.testEnvironment !== 'node') {
145
144
  const { specialEnvironments } = await import('../src/jest.environment.js')
@@ -147,9 +146,15 @@ export const build = async (...files) => {
147
146
  preload.push(...(specialEnvironments[jestConfig.testEnvironment].dependencies || []))
148
147
  }
149
148
 
150
- const local = createRequire(resolve(jestConfig.rootDir, 'package.json'))
151
- const w = (f) => `[${stringify(f)}, () => require(${stringify(local.resolve(f))})]`
152
- input.push(`globalThis.EXODUS_TEST_PRELOADED = [${preload.map((f) => w(f)).join(', ')}]`)
149
+ if (preload.length === 0) {
150
+ input.push(`globalThis.EXODUS_TEST_PRELOADED = []`)
151
+ } else {
152
+ assert(jestConfig.rootDir)
153
+ const local = createRequire(resolve(jestConfig.rootDir, 'package.json'))
154
+ const w = (f) => `[${stringify(f)}, () => require(${stringify(local.resolve(f))})]`
155
+ input.push(`globalThis.EXODUS_TEST_PRELOADED = [${preload.map((f) => w(f)).join(', ')}]`)
156
+ }
157
+
153
158
  await importSource('./jest.js')
154
159
  }
155
160
 
package/bin/index.js CHANGED
@@ -7,7 +7,7 @@ import { fileURLToPath } from 'node:url'
7
7
  import { basename, dirname, resolve, join } from 'node:path'
8
8
  import { createRequire } from 'node:module'
9
9
  import { randomUUID } from 'node:crypto'
10
- import { existsSync, rmSync } from 'node:fs'
10
+ import { existsSync, rmSync, realpathSync } from 'node:fs'
11
11
  import { unlink } from 'node:fs/promises'
12
12
  import { tmpdir, availableParallelism } from 'node:os'
13
13
  import assert from 'node:assert/strict'
@@ -21,6 +21,7 @@ const EXTS = `.?([cm])[jt]s?(x)` // we differ from jest, allowing [cm] before ev
21
21
  const DEFAULT_PATTERNS = [`**/__tests__/**/*${EXTS}`, `**/?(*.)+(spec|test)${EXTS}`]
22
22
 
23
23
  const bundleOptions = { pure: true, bundle: true, esbuild: true, ts: 'auto' }
24
+ const hermesAv = ['-Og', '-Xmicrotask-queue']
24
25
  const ENGINES = new Map(
25
26
  Object.entries({
26
27
  'node:test': { binary: 'node', pure: false, hasImportLoader: true, ts: 'flag' },
@@ -30,7 +31,7 @@ const ENGINES = new Map(
30
31
  'bun:bundle': { binary: 'bun', ...bundleOptions },
31
32
  'deno:bundle': { binary: 'deno', binaryArgs: ['run'], target: 'deno1', ...bundleOptions },
32
33
  'jsc:bundle': { binary: 'jsc', ...bundleOptions, target: 'safari13' },
33
- 'hermes:bundle': { binary: 'hermes', binaryArgs: ['-Og'], target: 'es2018', ...bundleOptions },
34
+ 'hermes:bundle': { binary: 'hermes', binaryArgs: hermesAv, target: 'es2018', ...bundleOptions },
34
35
  })
35
36
  )
36
37
 
@@ -51,6 +52,7 @@ function parseOptions() {
51
52
  dropNetwork: ![undefined, '', '0'].includes(process.env.EXODUS_TEST_DROP_NETWORK),
52
53
  ideaCompat: false,
53
54
  engine: process.env.EXODUS_TEST_ENGINE ?? 'node:test',
55
+ require: [],
54
56
  }
55
57
 
56
58
  const args = [...process.argv]
@@ -61,9 +63,10 @@ function parseOptions() {
61
63
 
62
64
  // Second argument should be this script
63
65
  const jsname = args.shift()
64
- assert(basename(jsname) === 'exodus-test' || jsname === fileURLToPath(import.meta.url))
66
+ const pathsEqual = (a, b) => a === b || (existsSync(a) && realpathSync(a) === b) // resolve symlinks
67
+ assert(basename(jsname) === 'exodus-test' || pathsEqual(jsname, fileURLToPath(import.meta.url)))
65
68
 
66
- while (args[0]?.startsWith('--')) {
69
+ while (args[0]?.startsWith('-')) {
67
70
  const option = args.shift()
68
71
  if (options.ideaCompat) {
69
72
  // Ignore some options IntelliJ IDEA is passing
@@ -96,6 +99,9 @@ function parseOptions() {
96
99
  case '--babel':
97
100
  options.babel = true
98
101
  break
102
+ case '--require':
103
+ options.require.push(args.shift())
104
+ break
99
105
  case '--coverage-engine':
100
106
  options.coverageEngine = args.shift()
101
107
  break
@@ -284,6 +290,11 @@ if (options.typescript) {
284
290
  }
285
291
  }
286
292
 
293
+ for (const r of options.require) {
294
+ assert(!options.bundle, 'Can not use -r with *:bundle engines')
295
+ args.push('-r', r)
296
+ }
297
+
287
298
  if (patterns.length === 0) patterns.push(...DEFAULT_PATTERNS) // defaults
288
299
  const globbed = await glob(patterns, { ignore })
289
300
  const allfiles = filter ? globbed.filter(filter) : globbed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/test",
3
- "version": "1.0.0-rc.37",
3
+ "version": "1.0.0-rc.39",
4
4
  "author": "Exodus Movement, Inc.",
5
5
  "description": "A test suite runner",
6
6
  "homepage": "https://github.com/ExodusMovement/test",
@@ -104,7 +104,7 @@
104
104
  "@babel/plugin-transform-private-methods": "^7.0.0",
105
105
  "@babel/register": "^7.0.0",
106
106
  "@chalker/queue": "^1.0.0",
107
- "@exodus/replay": "1.0.0-rc.1",
107
+ "@exodus/replay": "1.0.0-rc.3",
108
108
  "@ungap/url-search-params": "^0.2.2",
109
109
  "amaro": "^0.0.5",
110
110
  "assert": "^2.1.0",
@@ -76,18 +76,19 @@ if (typeof process === 'undefined') {
76
76
  }
77
77
 
78
78
  if (process.env.EXODUS_TEST_PLATFORM === 'hermes') {
79
+ const print = console.log.bind(console) // we don not want overrides
79
80
  let headerLogged = false
80
81
  globalThis.HermesInternal?.enablePromiseRejectionTracker({
81
82
  allRejections: true,
82
83
  onUnhandled: (i, err) => {
83
84
  globalThis.EXODUS_TEST_PROCESS.exitCode = 1
84
85
  if (!headerLogged) {
85
- console.log(`‼ FATAL Tests generated asynchronous activity after they ended.
86
+ print(`‼ FATAL Tests generated asynchronous activity after they ended.
86
87
  This activity created errors and would have caused tests to fail, but instead triggered unhandledRejection events`)
87
88
  headerLogged = true
88
89
  }
89
90
 
90
- console.log(`Uncaught error #${i}: ${err}`)
91
+ print(`Uncaught error #${i}: ${err}`)
91
92
  globalThis.EXODUS_TEST_PROCESS._maybeProcessExitCode()
92
93
  },
93
94
  })
package/src/engine.js CHANGED
@@ -12,8 +12,8 @@ export { mock, describe, test, beforeEach, afterEach, before, after }
12
12
  const { builtinModules, syncBuiltinESMExports } = engine
13
13
  export { builtinModules, syncBuiltinESMExports }
14
14
 
15
- const { utilFormat, isPromise, nodeVersion } = engine
16
- export { utilFormat, isPromise, nodeVersion }
15
+ const { utilFormat, isPromise, nodeVersion, awaitForMicrotaskQueue } = engine
16
+ export { utilFormat, isPromise, nodeVersion, awaitForMicrotaskQueue }
17
17
 
18
18
  const { requireIsRelative, relativeRequire, isTopLevelESM } = engine
19
19
  export { requireIsRelative, relativeRequire, isTopLevelESM }
@@ -10,6 +10,7 @@ const { mock, describe, test, beforeEach, afterEach, before, after } = nodeTest
10
10
 
11
11
  const isPromise = types.isPromise
12
12
  const nodeVersion = process.versions.node
13
+ const awaitForMicrotaskQueue = () => new Promise((resolve) => process.nextTick(resolve))
13
14
 
14
15
  const files = process.argv.slice(1)
15
16
  const baseFile = files.length === 1 && existsSync(files[0]) ? normalize(files[0]) : undefined
@@ -33,7 +34,7 @@ module.exports = {
33
34
  ...{ assert, assertLoose },
34
35
  ...{ mock, describe, test, beforeEach, afterEach, before, after },
35
36
  ...{ builtinModules, syncBuiltinESMExports },
36
- ...{ utilFormat, isPromise, nodeVersion },
37
+ ...{ utilFormat, isPromise, nodeVersion, awaitForMicrotaskQueue },
37
38
  ...{ requireIsRelative, relativeRequire, isTopLevelESM },
38
39
  ...{ readSnapshot, setSnapshotSerializers, setSnapshotResolver },
39
40
  }
@@ -4,6 +4,7 @@ const assertLoose = require('node:assert')
4
4
  const { setTimeout, setInterval, setImmediate, Date } = globalThis
5
5
  const { clearTimeout, clearInterval, clearImmediate } = globalThis
6
6
 
7
+ const print = console.log.bind(console) // we don not want overrides
7
8
  Error.stackTraceLimit = 100
8
9
 
9
10
  let context
@@ -72,12 +73,12 @@ async function runContext(context) {
72
73
  // eslint-disable-next-line @exodus/mutable/no-param-reassign-prop-only
73
74
  context.running = true
74
75
  assert(children.length === 0 || !fn)
75
- if (options.skip) return console.log('⏭ SKIP', context.fullName)
76
+ if (options.skip) return print('⏭ SKIP', context.fullName)
76
77
  if (context.fn) {
77
78
  if (runOnly) {
78
- if (!context.only) return console.log('⏭ SKIP', context.fullName)
79
+ if (!context.only) return print('⏭ SKIP', context.fullName)
79
80
  } else if (options.only) {
80
- console.log(`⚠ WARN test.only requires the --only command-line option`)
81
+ print(`⚠ WARN test.only requires the --only command-line option`)
81
82
  }
82
83
 
83
84
  let error
@@ -104,23 +105,23 @@ async function runContext(context) {
104
105
  stack.reverse()
105
106
  for (const c of stack) for (const hook of c.hooks.afterEach) await runFunction(hook, context)
106
107
 
107
- console.log(error === undefined ? '✔ PASS' : '✖ FAIL', context.fullName)
108
+ print(error === undefined ? '✔ PASS' : '✖ FAIL', context.fullName)
108
109
  if (error) {
109
- console.log(' ', error)
110
+ print(' ', error)
110
111
  abstractProcess.exitCode = 1
111
112
  }
112
113
  } else {
113
114
  if (options.only && !runOnly) {
114
- console.log(`⚠ WARN describe.only requires the --only command-line option`)
115
+ print(`⚠ WARN describe.only requires the --only command-line option`)
115
116
  }
116
117
 
117
- // if (context !== context.root) console.log(`▶ ${context.fullName}`)
118
+ // if (context !== context.root) print(`▶ ${context.fullName}`)
118
119
  // TODO: try/catch for hooks?
119
120
  // TODO: flatten recursion before running?
120
121
  for (const hook of hooks.before) await runFunction(hook, context)
121
122
  for (const child of children) await runContext(child)
122
123
  for (const hook of hooks.after) await runFunction(hook, context)
123
- // if (context !== context.root) console.log(`▶ ${context.fullName}`)
124
+ // if (context !== context.root) print(`▶ ${context.fullName}`)
124
125
  }
125
126
  }
126
127
 
@@ -130,7 +131,7 @@ async function run() {
130
131
  assert(context === context.root)
131
132
  await runContext(context).catch((error) => {
132
133
  // Should not throw under regular circumstances
133
- console.log('‼ FATAL', error)
134
+ print('‼ FATAL', error)
134
135
  abstractProcess.exitCode = 1
135
136
  })
136
137
  abstractProcess._maybeProcessExitCode?.()
@@ -146,8 +147,8 @@ async function describe(...args) {
146
147
  // we don't need to be async if fn is sync
147
148
  if (isPromise(res)) await res
148
149
  } catch (error) {
149
- console.log('✖ FAIL', context.fullName)
150
- console.log(' describe() body threw an error:', error)
150
+ print('✖ FAIL', context.fullName)
151
+ print(' describe() body threw an error:', error)
151
152
  abstractProcess.exitCode = 1
152
153
  }
153
154
  }
@@ -242,19 +243,31 @@ class MockTimers {
242
243
 
243
244
  tick(milliseconds = 1) {
244
245
  this.#elapsed += milliseconds
245
- while (true) {
246
- const next =
247
- this.#queue.find((x) => x.type === 'immediate') ||
248
- this.#queue.find((x) => x.at <= this.#elapsed)
249
- if (!next) break
250
- if (next.type === 'interval') {
251
- next.at += next.interval
252
- } else {
253
- this.#queue = this.#queue.filter((x) => x !== next)
254
- }
246
+ while (this.#microtick() !== null);
247
+ }
248
+
249
+ async tickAsync(milliseconds = 1) {
250
+ let shouldAwait = true
251
+ for (let i = 0; i < milliseconds; i++) {
252
+ if (shouldAwait) await awaitForMicrotaskQueue()
253
+ this.#elapsed += 1
254
+ shouldAwait = this.#microtick() !== null
255
+ if (shouldAwait) while (this.#microtick() !== null);
256
+ }
257
+ }
255
258
 
256
- next.fn(...next.args)
259
+ #microtick() {
260
+ const next =
261
+ this.#queue.find((x) => x.type === 'immediate') ||
262
+ this.#queue.find((x) => x.at <= this.#elapsed)
263
+ if (!next) return null
264
+ if (next.type === 'interval') {
265
+ next.at += next.interval
266
+ } else {
267
+ this.#queue = this.#queue.filter((x) => x !== next)
257
268
  }
269
+
270
+ next.fn(...next.args)
258
271
  }
259
272
 
260
273
  runAll() {
@@ -385,6 +398,22 @@ const after = (fn) => context.hooks.after.push(fn)
385
398
 
386
399
  const isPromise = (x) => Boolean(x && x.then && x.catch && x.finally)
387
400
  const nodeVersion = '9999.99.99'
401
+ const awaitForMicrotaskQueue = async () => {
402
+ if (globalThis?.process?.nextTick) {
403
+ // We are in microtasks, awaiting for "next" tick will get us out of here
404
+ return new Promise((resolve) => globalThis.process.nextTick(resolve))
405
+ }
406
+
407
+ // If that is not available, we can wait for the actual next cycle
408
+ // For Hermes, we use -Xmicrotask-queue for this to act not like just a Promise.resolve().then(
409
+ // TODO: recheck if setImmediate is not faked with setTimeout if we enable a polyfill for it for JSC?
410
+ if (setImmediate) return new Promise((resolve) => setImmediate(resolve))
411
+
412
+ // Do not rely on setTimeout here! it will tick actual time and is terribly slow (i.e. timers no longer fake)
413
+ // 100_000 should be enough to flush everything that's going on in the microtask queue
414
+ // Only JSC hits this currently
415
+ for (let i = 0; i < 100_000; i++) await Promise.resolve()
416
+ }
388
417
 
389
418
  let builtinModules = []
390
419
  let requireIsRelative = false
@@ -433,7 +462,7 @@ module.exports = {
433
462
  ...{ assert, assertLoose },
434
463
  ...{ mock, describe, test, beforeEach, afterEach, before, after },
435
464
  ...{ builtinModules, syncBuiltinESMExports },
436
- ...{ utilFormat, isPromise, nodeVersion },
465
+ ...{ utilFormat, isPromise, nodeVersion, awaitForMicrotaskQueue },
437
466
  ...{ requireIsRelative, relativeRequire, isTopLevelESM },
438
467
  ...{ readSnapshot, setSnapshotSerializers, setSnapshotResolver },
439
468
  }
@@ -26,6 +26,10 @@ const normalizeJestConfig = (config) => ({
26
26
  },
27
27
  })
28
28
 
29
+ function checkModuleNameMapper(mapper) {
30
+ return !mapper || JSON.stringify(mapper) === '{"^(\\\\.{1,2}/.*)\\\\.js$":"$1"}' // extension stripping is fine
31
+ }
32
+
29
33
  function verifyJestConfig(c) {
30
34
  assert(!configUsed, 'Can not apply new config as the current one was already used')
31
35
 
@@ -37,7 +41,7 @@ function verifyJestConfig(c) {
37
41
  assert.deepEqual(environmentOptions, {}, 'Jest config.testEnvironmentOptions is not supported')
38
42
 
39
43
  assert(!c.automock, 'Automocking all modules is not currently supported (config.automock)')
40
- assert(!c.moduleNameMapper, 'Jest config.moduleNameMapper is not supported')
44
+ assert(checkModuleNameMapper(c.moduleNameMapper), 'Jest config.moduleNameMapper is not supported')
41
45
  if (c.moduleDirectories) {
42
46
  const valid = ['node_modules']
43
47
  assert.deepEqual(c.moduleDirectories, valid, 'Jest config.moduleDirectories is not supported')
@@ -1,4 +1,4 @@
1
- import { mock, assert } from './engine.js'
1
+ import { mock, assert, awaitForMicrotaskQueue } from './engine.js'
2
2
  import { jestConfig } from './jest.config.js'
3
3
  import { haveValidTimers, haveNoTimerInfiniteLoopBug } from './version.js'
4
4
 
@@ -65,7 +65,19 @@ export function advanceTimersByTime(time) {
65
65
  }
66
66
 
67
67
  export async function advanceTimersByTimeAsync(time) {
68
- return this.advanceTimersByTime(time)
68
+ assertHaveTimers()
69
+ warnOldTimers()
70
+
71
+ if (mock.timers.tickAsync) {
72
+ await mock.timers.tickAsync(time)
73
+ } else {
74
+ for (let i = 0; i < time; i++) {
75
+ await awaitForMicrotaskQueue()
76
+ mock.timers.tick(1)
77
+ }
78
+ }
79
+
80
+ return this
69
81
  }
70
82
 
71
83
  export function setSystemTime(time) {