@exodus/test 1.0.0-rc.33 → 1.0.0-rc.35

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/bundle.js CHANGED
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict'
2
2
  import { readFile } from 'node:fs/promises'
3
3
  import { existsSync } from 'node:fs'
4
4
  import { fileURLToPath, pathToFileURL } from 'node:url'
5
- import { basename, dirname, resolve, join } from 'node:path'
5
+ import { basename, dirname, extname, resolve, join } from 'node:path'
6
6
  import { createRequire } from 'node:module'
7
7
  import { randomUUID as uuid, randomBytes } from 'node:crypto'
8
8
  import * as esbuild from 'esbuild'
@@ -56,8 +56,14 @@ const loadPipeline = [
56
56
 
57
57
  const options = {}
58
58
 
59
- export const init = async ({ platform, jest, target, jestConfig, outdir }) => {
60
- Object.assign(options, { platform, jest, target, jestConfig, outdir })
59
+ export const init = async ({ platform, jest, flow, target, jestConfig, outdir }) => {
60
+ Object.assign(options, { platform, jest, flow, target, jestConfig, outdir })
61
+
62
+ if (options.flow) {
63
+ const { default: flowRemoveTypes } = await import('flow-remove-types')
64
+ loadPipeline.unshift((source) => flowRemoveTypes(source, { pretty: true }).toString())
65
+ }
66
+
61
67
  if (options.platform === 'hermes') {
62
68
  const babel = await import('./babel-worker.cjs')
63
69
  loadPipeline.push(async (source) => {
@@ -182,10 +188,12 @@ export const build = async (...files) => {
182
188
  'process.env.NO_COLOR': stringify('1'),
183
189
  'process.env.NODE_ENV': stringify(process.env.NODE_ENV),
184
190
  'process.env.EXODUS_TEST_CONTEXT': stringify('pure'),
185
- 'process.env.EXODUS_TEST_ENVIRONMENT': stringify('bundle'),
186
- 'process.env.EXODUS_TEST_PLATFORM': stringify(process.env.EXODUS_TEST_PLATFORM),
191
+ 'process.env.EXODUS_TEST_ENVIRONMENT': stringify('bundle'), // always 'bundle'
192
+ 'process.env.EXODUS_TEST_PLATFORM': stringify(process.env.EXODUS_TEST_PLATFORM), // e.g. 'hermes', 'node'
193
+ 'process.env.EXODUS_TEST_ENGINE': stringify(process.env.EXODUS_TEST_ENGINE), // e.g. 'hermes:bundle', 'node:bundle'
187
194
  'process.env.EXODUS_TEST_JEST_CONFIG': stringify(JSON.stringify(options.jestConfig)),
188
195
  'process.env.EXODUS_TEST_EXECARGV': stringify(process.env.EXODUS_TEST_EXECARGV),
196
+ 'process.env.EXODUS_TEST_ONLY': stringify(process.env.EXODUS_TEST_ONLY),
189
197
  'process.env.NODE_DEBUG': stringify(),
190
198
  'process.env.DEBUG': stringify(),
191
199
  'process.env.READABLE_STREAM': stringify(),
@@ -254,17 +262,19 @@ export const build = async (...files) => {
254
262
  {
255
263
  name: 'exodus-test.bundle',
256
264
  setup({ onLoad }) {
257
- onLoad({ filter: /\.[cm]?[jt]s$/, namespace: 'file' }, async (args) => {
265
+ onLoad({ filter: /\.[cm]?[jt]sx?$/, namespace: 'file' }, async (args) => {
258
266
  let filepath = args.path
259
267
  // Resolve .native versions
260
268
  // TODO: move flag to engine options
261
269
  // TODO: maybe follow package.json for this
262
270
  if (['jsc', 'hermes'].includes(options.platform)) {
263
- const maybeNative = filepath.replace(/(\.[cm]?[jt]s)$/u, '.native$1')
271
+ const maybeNative = filepath.replace(/(\.[cm]?[jt]sx?)$/u, '.native$1')
264
272
  if (existsSync(maybeNative)) filepath = maybeNative
265
273
  }
266
274
 
267
- const loader = /\.[cm]?ts$/.test(filepath) ? 'ts' : 'js'
275
+ const loader = extname(filepath).replace(/^\.[cm]?/, '') // TODO: a flag to force jsx/tsx perhaps
276
+ assert(['js', 'ts', 'jsx', 'tx'].includes(loader))
277
+
268
278
  return { contents: await loadSourceFile(filepath), loader }
269
279
  })
270
280
  },
package/bin/index.js CHANGED
@@ -38,6 +38,7 @@ function parseOptions() {
38
38
  const options = {
39
39
  jest: false,
40
40
  typescript: false,
41
+ flow: false,
41
42
  esbuild: false,
42
43
  babel: false,
43
44
  coverage: false,
@@ -47,6 +48,7 @@ function parseOptions() {
47
48
  passWithNoTests: false,
48
49
  writeSnapshots: false,
49
50
  debug: { files: false },
51
+ dropNetwork: ![undefined, '', '0'].includes(process.env.EXODUS_TEST_DROP_NETWORK),
50
52
  ideaCompat: false,
51
53
  engine: process.env.EXODUS_TEST_ENGINE ?? 'node:test',
52
54
  }
@@ -85,6 +87,9 @@ function parseOptions() {
85
87
  case '--typescript':
86
88
  options.typescript = true
87
89
  break
90
+ case '--flow':
91
+ options.flow = true
92
+ break
88
93
  case '--esbuild':
89
94
  options.esbuild = true
90
95
  break
@@ -100,6 +105,7 @@ function parseOptions() {
100
105
  case '--watch':
101
106
  options.watch = true
102
107
  break
108
+ case '--test-only':
103
109
  case '--only':
104
110
  options.only = true
105
111
  break
@@ -128,6 +134,9 @@ function parseOptions() {
128
134
  process.env.NO_COLOR = '1'
129
135
  process.env.NODE_DISABLE_COLORS = '1'
130
136
  break
137
+ case '--drop-network':
138
+ options.dropNetwork = true
139
+ break
131
140
  case '--idea-compat':
132
141
  options.ideaCompat = true
133
142
  break
@@ -169,7 +178,6 @@ if (options.pure) {
169
178
  assert(!options.writeSnapshots, `Can not use write snapshots with ${options.engine} engine`)
170
179
  assert(!options.forceExit, `Can not use --force-exit with ${options.engine} engine yet`) // TODO
171
180
  assert(!options.watch, `Can not use --watch with with ${options.engine} engine`)
172
- assert(!options.only, `Can not use --only with with ${options.engine} engine yet`) // TODO
173
181
  } else if (options.engine === 'node:test') {
174
182
  args.push('--test', '--no-warnings=ExperimentalWarning', '--test-reporter=spec')
175
183
 
@@ -340,7 +348,9 @@ if (tsTests.length > 0 && !options.esbuild && !options.typescript) {
340
348
  }
341
349
 
342
350
  if (!Object.hasOwn(process.env, 'NODE_ENV')) process.env.NODE_ENV = 'test'
343
- process.env.EXODUS_TEST_PLATFORM = options.binary
351
+ process.env.EXODUS_TEST_PLATFORM = options.binary // e.g. 'hermes', 'node'
352
+ process.env.EXODUS_TEST_ENGINE = options.engine // e.g. 'hermes:bundle', 'node:bundle', 'node:test', 'node:pure'
353
+ process.env.EXODUS_TEST_ONLY = options.only ? '1' : ''
344
354
 
345
355
  const c8 = resolveRequire('c8/bin/c8.js')
346
356
  if (resolveImport) assert.equal(c8, resolveImport('c8/bin/c8.js'))
@@ -353,7 +363,7 @@ if (options.coverage) {
353
363
  args.unshift(options.binary)
354
364
  options.binary = c8
355
365
  // perhaps use text-summary ?
356
- args.unshift('-r', 'text', '-r', 'html', '-r', 'lcov')
366
+ args.unshift('-r', 'text', '-r', 'html', '-r', 'lcov', '-r', 'json-summary')
357
367
  } else {
358
368
  throw new Error(`Unknown coverage engine: ${JSON.stringify(options.coverageEngine)}`)
359
369
  }
@@ -374,7 +384,32 @@ if (options.bundle) {
374
384
  buildFile = (file) => bundle.build(file)
375
385
  }
376
386
 
377
- assert(options.binary && ['node', 'bun', 'deno', 'jsc', 'hermes', c8].includes(options.binary))
387
+ if (options.dropNetwork) {
388
+ console.warn(`--drop-network is experimental and is a test helper, not a security mechanism`)
389
+ }
390
+
391
+ const execFile = promisify(execFileCallback)
392
+
393
+ async function launch(binary, args, opts = {}, buffering = false) {
394
+ assert(binary && ['node', 'bun', 'deno', 'jsc', 'hermes', c8].includes(binary))
395
+ if (options.dropNetwork) {
396
+ switch (process.platform) {
397
+ case 'darwin':
398
+ ;[binary, args] = ['sandbox-exec', ['-n', 'no-network', binary, ...args]]
399
+ break
400
+ case 'linux':
401
+ ;[binary, args] = ['unshare', ['-n', '-r', binary, ...args]]
402
+ break
403
+ default:
404
+ assert.fail(`--drop-network is not implemented on platform: ${process.platform}`)
405
+ }
406
+ }
407
+
408
+ if (buffering) return execFile(binary, args, { maxBuffer: 5 * 1024 * 1024, ...opts }) // 5 MiB just in case
409
+ const child = spawn(binary, args, { stdio: 'inherit', ...opts })
410
+ const [code] = await once(child, 'close')
411
+ return { code }
412
+ }
378
413
 
379
414
  if (options.pure) {
380
415
  if (options.binary === 'hermes') {
@@ -405,8 +440,6 @@ if (options.pure) {
405
440
  process.env.EXODUS_TEST_CONTEXT = 'pure'
406
441
  console.warn(`\n${options.engine} engine is experimental and may not work an expected\n`)
407
442
 
408
- const execFile = promisify(execFileCallback)
409
-
410
443
  const runOne = async (inputFile) => {
411
444
  const bundled = buildFile ? await buildFile(inputFile) : undefined
412
445
  if (buildFile) assert(bundled.file)
@@ -414,12 +447,12 @@ if (options.pure) {
414
447
  if (bundled?.errors.length > 0) return { ok: false, output: bundled.errors }
415
448
 
416
449
  const { binaryArgs = [] } = options
417
- // 5 MiB just in case, timeout is fallback if timeout in script hangs, 50x as it can be adjusted per-script inside them
450
+ // Timeout is fallback if timeout in script hangs, 50x as it can be adjusted per-script inside them
418
451
  // Do we want to extract timeouts from script code instead? Also, hermes might be slower, so makes sense to increase
419
- const execOpts = { maxBuffer: 5 * 1024 * 1024, timeout: (jestConfig?.testTimeout || 5000) * 50 }
452
+ const timeout = (jestConfig?.testTimeout || 5000) * 50
420
453
  try {
421
454
  const fullArgs = [...binaryArgs, ...args, file]
422
- const { code = 0, stdout, stderr } = await execFile(options.binary, fullArgs, execOpts)
455
+ const { code = 0, stdout, stderr } = await launch(options.binary, fullArgs, { timeout }, true)
423
456
  return { ok: code === 0, output: [stdout, stderr] }
424
457
  } catch (err) {
425
458
  const { code, stdout = '', stderr = '', signal, killed } = err
@@ -463,6 +496,7 @@ if (options.pure) {
463
496
  .replaceAll(/^✔ PASS /gmu, color('✔ PASS ', 'green'))
464
497
  .replaceAll(/^⏭ SKIP /gmu, color('⏭ SKIP ', 'dim'))
465
498
  .replaceAll(/^✖ FAIL /gmu, color('✖ FAIL ', 'red'))
499
+ .replaceAll(/^⚠ WARN /gmu, color('⚠ WARN ', 'blue'))
466
500
  .replaceAll(/^‼ FATAL /gmu, `${color('‼', 'red')} ${color(' FATAL ', 'bgRed')} `)
467
501
  }
468
502
 
@@ -498,7 +532,6 @@ if (options.pure) {
498
532
  process.env.EXODUS_TEST_CONTEXT = options.engine
499
533
  assert(files.length > 0) // otherwise we can run recursively
500
534
  assert(!options.binaryArgs)
501
- const node = spawn(options.binary, [...args, ...files], { stdio: 'inherit' })
502
- const [code] = await once(node, 'close')
535
+ const { code } = await launch(options.binary, [...args, ...files])
503
536
  process.exitCode = code
504
537
  }
@@ -1,17 +1,13 @@
1
- import { dirname, resolve as pathResolve } from 'node:path'
2
- import { createRequire } from 'node:module'
3
1
  import { readFile } from 'node:fs/promises'
2
+ import { transformSync } from 'amaro'
4
3
 
5
- const require = createRequire(import.meta.url)
6
- const amaroDir = dirname(require.resolve('amaro/package.json'))
7
- const amaro = await import(pathResolve(amaroDir, 'dist/index.js'))
8
4
  const extensionsRegex = /\.ts$|\.mts$/
9
5
 
10
6
  export async function load(url, context, nextLoad) {
11
7
  if (extensionsRegex.test(url) && !url.includes('/node_modules/')) {
12
8
  const sourceBuf = await readFile(new URL(url))
13
9
  const source = sourceBuf.toString('utf8')
14
- const transformed = amaro.transformSync(source, { isModule: true }).code
10
+ const { code: transformed } = transformSync(source, { isModule: true })
15
11
  const transformedBuf = Buffer.from(transformed)
16
12
  if (sourceBuf.length !== transformed.length) throw new Error('length mismatch')
17
13
  // eslint-disable-next-line unicorn/no-for-loop
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/test",
3
- "version": "1.0.0-rc.33",
3
+ "version": "1.0.0-rc.35",
4
4
  "author": "Exodus Movement, Inc.",
5
5
  "description": "A test suite runner",
6
6
  "homepage": "https://github.com/ExodusMovement/test",
@@ -31,7 +31,10 @@
31
31
  "exports": {
32
32
  "./jest": "./src/jest.js",
33
33
  "./node": "./src/node.js",
34
- "./tape": "./src/tape.js"
34
+ "./tape": {
35
+ "import": "./src/tape.js",
36
+ "require": "./src/tape.cjs"
37
+ }
35
38
  },
36
39
  "prettier": "@exodus/prettier",
37
40
  "files": [
@@ -100,7 +103,7 @@
100
103
  "@babel/register": "^7.0.0",
101
104
  "@chalker/queue": "^1.0.0",
102
105
  "@ungap/url-search-params": "^0.2.2",
103
- "amaro": "^0.0.4",
106
+ "amaro": "^0.0.5",
104
107
  "assert": "^2.1.0",
105
108
  "browserify-zlib": "^0.2.0",
106
109
  "buffer": "^6.0.3",
@@ -111,6 +114,7 @@
111
114
  "events": "^3.3.0",
112
115
  "expect": "^29.7.0",
113
116
  "fast-glob": "^3.2.11",
117
+ "flow-remove-types": "^2.242.0",
114
118
  "jest-extended": "^4.0.2",
115
119
  "jsdom": "^24.1.0",
116
120
  "os-browserify": "^0.3.0",
@@ -20,28 +20,37 @@ function parseArgs(args) {
20
20
  return { name, options, fn }
21
21
  }
22
22
 
23
- function enterContext(name, options = {}) {
24
- assert(!running)
25
- if (willstart) clearTimeout(willstart) // have to he accurate for engines like Hermes
26
- context = {
27
- root: context?.root,
28
- parent: context,
29
- name,
30
- options,
31
- fullName: context && context !== context.root ? `${context.fullName} > ${name}` : name,
32
- assert: { ...assertLoose, snapshot: undefined },
33
- hooks: { __proto__: null, before: [], after: [], beforeEach: [], afterEach: [] },
34
- test, // todo: bind to context
35
- describe, // todo: bind to context
36
- children: [],
23
+ class Context {
24
+ test = test // todo: bind to context
25
+ describe = describe // todo: bind to context
26
+ children = []
27
+ assert = { ...assertLoose, snapshot: undefined }
28
+ hooks = { __proto__: null, before: [], after: [], beforeEach: [], afterEach: [] }
29
+
30
+ constructor(parent, name, options = {}) {
31
+ Object.assign(this, { root: parent?.root, parent, name, options })
32
+ this.fullName = parent && parent !== parent.root ? `${parent.fullName} > ${name}` : name
33
+ if (this.root) {
34
+ this.parent.children.push(this)
35
+ } else {
36
+ assert(this.name === '<root>' && !this.parent)
37
+ this.root = this
38
+ }
37
39
  }
38
- if (context.root) {
39
- context.parent.children.push(context)
40
- } else {
41
- assert((context.name = '<root>'))
42
- assert(!context.parent)
43
- context.root = context
40
+
41
+ get onlySomewhere() {
42
+ return this.options.only || this.children.some((x) => x.onlySomewhere)
44
43
  }
44
+
45
+ get only() {
46
+ return (this.options.only && !this.children.some((x) => x.onlySomewhere)) || this.parent?.only
47
+ }
48
+ }
49
+
50
+ function enterContext(name, options) {
51
+ assert(!running)
52
+ if (willstart) clearTimeout(willstart) // have to he accurate for engines like Hermes
53
+ context = new Context(context, name, options)
45
54
  }
46
55
 
47
56
  function exitContext() {
@@ -55,6 +64,8 @@ async function runFunction(fn, context) {
55
64
  return new Promise((resolve, reject) => fn(context, (err) => (err ? reject(err) : resolve())))
56
65
  }
57
66
 
67
+ const runOnly = process.env.EXODUS_TEST_ONLY === '1'
68
+
58
69
  async function runContext(context) {
59
70
  const { options, children, hooks, fn } = context
60
71
  assert(!context.running, 'Can not run twice')
@@ -63,6 +74,12 @@ async function runContext(context) {
63
74
  assert(children.length === 0 || !fn)
64
75
  if (options.skip) return console.log('⏭ SKIP', context.fullName)
65
76
  if (context.fn) {
77
+ if (runOnly) {
78
+ if (!context.only) return console.log('⏭ SKIP', context.fullName)
79
+ } else if (options.only) {
80
+ console.log(`⚠ WARN test.only requires the --only command-line option`)
81
+ }
82
+
66
83
  let error
67
84
  const stack = [context]
68
85
  while (stack[0].parent) stack.unshift(stack[0].parent)
@@ -93,6 +110,10 @@ async function runContext(context) {
93
110
  abstractProcess.exitCode = 1
94
111
  }
95
112
  } else {
113
+ if (options.only && !runOnly) {
114
+ console.log(`⚠ WARN describe.only requires the --only command-line option`)
115
+ }
116
+
96
117
  // if (context !== context.root) console.log(`▶ ${context.fullName}`)
97
118
  // TODO: try/catch for hooks?
98
119
  // TODO: flatten recursion before running?
@@ -118,7 +139,6 @@ async function run() {
118
139
  async function describe(...args) {
119
140
  const { name, options, fn } = parseArgs(args)
120
141
  enterContext(name, options)
121
- context.options = options
122
142
  // todo: callback support?
123
143
  if (!options.skip) {
124
144
  try {
@@ -140,6 +160,11 @@ describe.skip = (...args) => {
140
160
  return describe(name, { ...options, skip: true }, fn)
141
161
  }
142
162
 
163
+ describe.only = (...args) => {
164
+ const { name, options, fn } = parseArgs(args)
165
+ return describe(name, { ...options, only: true }, fn)
166
+ }
167
+
143
168
  function test(...args) {
144
169
  const { name, options, fn } = parseArgs(args)
145
170
  enterContext(name, options)
@@ -152,6 +177,11 @@ test.skip = (...args) => {
152
177
  return test(name, { ...options, skip: true }, fn)
153
178
  }
154
179
 
180
+ test.only = (...args) => {
181
+ const { name, options, fn } = parseArgs(args)
182
+ return test(name, { ...options, only: true }, fn)
183
+ }
184
+
155
185
  class MockTimers {
156
186
  #enabled = false
157
187
  #base = 0
package/src/jest.js CHANGED
@@ -89,12 +89,13 @@ const forceExit = execArgv.map((x) => x.replaceAll('_', '-')).includes('--test-f
89
89
  const inConcurrent = []
90
90
  const inDescribe = []
91
91
  const concurrent = []
92
- const describe = (...args) => {
92
+
93
+ const describeRaw = (nodeDescribe, ...args) => {
93
94
  const fn = args.pop()
94
95
  inDescribe.push(fn)
95
96
  const optionsConcurrent = args?.at(-1)?.concurrency > 1
96
97
  if (optionsConcurrent) inConcurrent.push(fn)
97
- const result = node.describe(...args, () => {
98
+ const result = nodeDescribe(...args, () => {
98
99
  const res = fn()
99
100
 
100
101
  // We do only block-level concurrency, not file-level
@@ -104,7 +105,7 @@ const describe = (...args) => {
104
105
  } else if (concurrent.length > 0) {
105
106
  const queue = [...concurrent]
106
107
  concurrent.length = 0
107
- describe('concurrent', { concurrency: defaultConcurrency }, () => {
108
+ nodeDescribe('concurrent', { concurrency: defaultConcurrency }, () => {
108
109
  for (const args of queue) testRaw(...args)
109
110
  })
110
111
  }
@@ -133,6 +134,9 @@ Also, using expect.assertions() to ensure the planned number of assertions is be
133
134
  })
134
135
  }
135
136
 
137
+ const describe = (...args) => describeRaw(node.describe, ...args)
138
+ describe.only = (...args) => describeRaw(node.describe.only, ...args)
139
+
136
140
  const test = (...args) => testRaw(getCallerLocation(), node.test, ...args)
137
141
  test.only = (...args) => testRaw(getCallerLocation(), node.test.only, ...args)
138
142
 
@@ -178,10 +182,11 @@ const isBundle = process.env.EXODUS_TEST_ENVIRONMENT === 'bundle' // TODO: impro
178
182
  export const jest = {
179
183
  exodus: {
180
184
  __proto__: null,
185
+ platform: String(process.env.EXODUS_TEST_PLATFORM), // e.g. 'hermes', 'node'
186
+ engine: String(process.env.EXODUS_TEST_ENGINE), // e.g. 'hermes:bundle', 'node:bundle', 'node:test', 'node:pure'
187
+ implementation: String(node.engine), // aka process.env.EXODUS_TEST_CONTEXT, e.g. 'node:test' or 'pure'
181
188
  features: {
182
189
  __proto__: null,
183
- platform: String(process.env.EXODUS_TEST_PLATFORM),
184
- engine: String(node.engine),
185
190
  timers: Boolean(mock.timers && haveValidTimers),
186
191
  esmMocks: Boolean(mock.module && !isBundle), // full support for ESM mocks
187
192
  esmInterop: Boolean(insideEsbuild && !isBundle), // loading/using ESM as CJS, ESM mocks creation without a mocker function
@@ -10,6 +10,7 @@ import { expect } from 'expect'
10
10
  import { format, plugins as builtinPlugins } from 'pretty-format'
11
11
  import { jestConfig } from './jest.config.js'
12
12
  import { getTestNamePath } from './dark.cjs'
13
+ import { haveSnapshotsReportUnescaped } from './version.js'
13
14
 
14
15
  const { snapshotFormat, snapshotSerializers } = jestConfig()
15
16
  const plugins = Object.values(builtinPlugins)
@@ -160,7 +161,7 @@ const snapOnDisk = (orig, matcher) => {
160
161
  wrapContextName(() => getAssert().snapshot(obj))
161
162
  } catch (e) {
162
163
  if (typeof e.expected === 'string') {
163
- const escaped = escape(e.expected)
164
+ const escaped = haveSnapshotsReportUnescaped ? e.expected : escape(e.expected)
164
165
  const final = escaped.includes('\n') ? escaped : `\n${escaped}\n`
165
166
  if (final === e.actual) return
166
167
  }
package/src/version.js CHANGED
@@ -12,6 +12,7 @@ export { major, minor, patch }
12
12
 
13
13
  export const haveModuleMocks = (major === 22 && minor >= 3) || major > 22
14
14
  export const haveSnapshots = (major === 22 && minor >= 3) || major > 22
15
+ export const haveSnapshotsReportUnescaped = (major === 22 && minor >= 5) || major > 22
15
16
  export const haveForceExit = (major === 20 && minor > 13) || major >= 22
16
17
  export const haveValidTimers = (major === 20 && minor >= 11) || major >= 22 // older glitch in various ways / stop executing
17
18
  export const haveNoTimerInfiniteLoopBug = (major === 20 && minor >= 11) || major >= 22 // mock.timers.runAll() can get into infinite recursion