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

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.
package/bin/index.js CHANGED
@@ -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]
@@ -63,7 +65,7 @@ function parseOptions() {
63
65
  const jsname = args.shift()
64
66
  assert(basename(jsname) === 'exodus-test' || jsname === fileURLToPath(import.meta.url))
65
67
 
66
- while (args[0]?.startsWith('--')) {
68
+ while (args[0]?.startsWith('-')) {
67
69
  const option = args.shift()
68
70
  if (options.ideaCompat) {
69
71
  // Ignore some options IntelliJ IDEA is passing
@@ -96,6 +98,9 @@ function parseOptions() {
96
98
  case '--babel':
97
99
  options.babel = true
98
100
  break
101
+ case '--require':
102
+ options.require.push(args.shift())
103
+ break
99
104
  case '--coverage-engine':
100
105
  options.coverageEngine = args.shift()
101
106
  break
@@ -284,6 +289,11 @@ if (options.typescript) {
284
289
  }
285
290
  }
286
291
 
292
+ for (const r of options.require) {
293
+ assert(!options.bundle, 'Can not use -r with *:bundle engines')
294
+ args.push('-r', r)
295
+ }
296
+
287
297
  if (patterns.length === 0) patterns.push(...DEFAULT_PATTERNS) // defaults
288
298
  const globbed = await glob(patterns, { ignore })
289
299
  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.38",
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) {