@exodus/test 1.0.0-rc.104 → 1.0.0-rc.106
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/README.md +10 -4
- package/benchmark.js +1 -0
- package/bin/find-binary.js +7 -5
- package/bin/index.js +6 -12
- package/mock.js +1 -0
- package/package.json +12 -7
- package/src/benchmark.js +16 -9
- package/src/dark.cjs +50 -44
- package/src/engine.node.cjs +8 -1
- package/src/engine.pure.cjs +1 -1
- package/src/engine.pure.snapshot.cjs +1 -1
- package/src/exodus.js +17 -44
- package/src/expect.cjs +19 -13
- package/src/jest.config.js +73 -61
- package/src/jest.fn.js +1 -0
- package/src/jest.js +15 -5
- package/src/jest.mock.js +6 -2
- package/src/jest.snapshot.js +2 -4
- package/src/jest.timers.js +2 -18
- package/src/mock.js +25 -0
- package/src/replay.js +29 -16
- package/src/version.js +5 -14
package/README.md
CHANGED
|
@@ -168,11 +168,17 @@ Just use `"test": "exodus-test"`
|
|
|
168
168
|
|
|
169
169
|
- `--test-force-exit` — force exit after tests are done
|
|
170
170
|
|
|
171
|
-
##
|
|
171
|
+
## Module mocking in ESM
|
|
172
172
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
173
|
+
Module mocks in ESM is a common source of confusion, as Jest in most old setups does not run real ESM,
|
|
174
|
+
and instead uses Babel to transform ESM into CJS, and then hoists mocks on top of `require()` calls.
|
|
175
|
+
|
|
176
|
+
That hoisting is not possible in ESM world, as static import statements are always resolved before
|
|
177
|
+
any other code.
|
|
178
|
+
|
|
179
|
+
Also see [Jest documentation](https://jestjs.io/docs/ecmascript-modules#module-mocking-in-esm) on that.
|
|
180
|
+
|
|
181
|
+
To port code from CJS or Babel, e.g. the following approach with dynamic imports can be used:
|
|
176
182
|
|
|
177
183
|
```js
|
|
178
184
|
jest.doMock('./hogwarts.js', () => ({
|
package/benchmark.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './src/benchmark.js'
|
package/bin/find-binary.js
CHANGED
|
@@ -4,7 +4,7 @@ import { join } from 'node:path'
|
|
|
4
4
|
import { createRequire } from 'node:module'
|
|
5
5
|
|
|
6
6
|
const require = createRequire(import.meta.url)
|
|
7
|
-
const nvm = process.env.NVM_BIN ? (x) => join(process.env.NVM_BIN, '../lib/node_modules', x) : null
|
|
7
|
+
// const nvm = process.env.NVM_BIN ? (x) => join(process.env.NVM_BIN, '../lib/node_modules', x) : null
|
|
8
8
|
const jsvu = (x) => join(homedir(), '.jsvu/bin', x)
|
|
9
9
|
const esvu = (x) => join(homedir(), '.esvu/bin', x)
|
|
10
10
|
|
|
@@ -38,10 +38,9 @@ function findBinaryOnce(name) {
|
|
|
38
38
|
const flavor = Object.hasOwn(flavors, process.platform) ? flavors[process.platform] : null
|
|
39
39
|
return findFile([
|
|
40
40
|
(bin) => flavor && require.resolve(`react-native/sdks/hermesc/${flavor}/${bin}`), // 1. Locally installed react-native dep (works only for osx)
|
|
41
|
-
(bin) =>
|
|
42
|
-
(bin) =>
|
|
43
|
-
|
|
44
|
-
]) // 5. hermes installed in the system
|
|
41
|
+
(bin) => jsvu(bin), // 2. jsvu
|
|
42
|
+
(bin) => esvu(bin), // 3. esvu
|
|
43
|
+
]) // 4. hermes installed in the system
|
|
45
44
|
}
|
|
46
45
|
|
|
47
46
|
case 'jsc':
|
|
@@ -67,6 +66,9 @@ function findBinaryOnce(name) {
|
|
|
67
66
|
return require('electron')
|
|
68
67
|
case 'workerd':
|
|
69
68
|
return require.resolve('workerd/bin/workerd')
|
|
69
|
+
case 'jerryscript':
|
|
70
|
+
name = 'jerry' // look under this name, including in global
|
|
71
|
+
return findFile([jsvu, esvu])
|
|
70
72
|
case 'c8':
|
|
71
73
|
return require.resolve('c8/bin/c8.js')
|
|
72
74
|
case 'chrome':
|
package/bin/index.js
CHANGED
|
@@ -19,7 +19,7 @@ import { glob as globImplementation } from '../src/glob.cjs'
|
|
|
19
19
|
const DEFAULT_PATTERNS = [`**/?(*.)+(spec|test).?([cm])[jt]s?(x)`] // do not trust magic dirs by default
|
|
20
20
|
const bundleOpts = { pure: true, bundle: true, esbuild: true, ts: 'auto' }
|
|
21
21
|
const bareboneOpts = { ...bundleOpts, barebone: true }
|
|
22
|
-
const hermesA = ['-
|
|
22
|
+
const hermesA = ['-w', '-Xmicrotask-queue'] // -Xes6-class fails with -O0 / -Og, --block-scoping fails in default, any of that is bad
|
|
23
23
|
const denoA = ['run', '--allow-all'] // also will set DENO_COMPAT=1 env flag below
|
|
24
24
|
const denoT = ['test', '--allow-all']
|
|
25
25
|
const nodeTS = process.features.typescript ? 'auto' : 'flag'
|
|
@@ -49,6 +49,7 @@ const ENGINES = new Map(
|
|
|
49
49
|
'graaljs:bundle': { binary: 'graaljs', ...bareboneOpts },
|
|
50
50
|
'escargot:bundle': { binary: 'escargot', ...bareboneOpts },
|
|
51
51
|
'boa:bundle': { binary: 'boa', binaryArgs: ['-m'], ...bareboneOpts },
|
|
52
|
+
'jerryscript:bundle': { binary: 'jerryscript', ...bareboneOpts },
|
|
52
53
|
// Browser engines
|
|
53
54
|
'chrome:puppeteer': { binary: 'chrome', browsers: 'puppeteer', ...bundleOpts },
|
|
54
55
|
'firefox:puppeteer': { binary: 'firefox', browsers: 'puppeteer', ...bundleOpts },
|
|
@@ -62,7 +63,7 @@ const ENGINES = new Map(
|
|
|
62
63
|
})
|
|
63
64
|
)
|
|
64
65
|
const barebonesOk = ['v8', 'd8', 'spidermonkey', 'quickjs', 'xs', 'hermes']
|
|
65
|
-
const barebonesUnhandled = ['jsc', 'escargot', 'boa', 'graaljs', 'engine262']
|
|
66
|
+
const barebonesUnhandled = ['jsc', 'escargot', 'boa', 'graaljs', 'jerry', 'engine262']
|
|
66
67
|
|
|
67
68
|
const getEnvFlag = (name) => {
|
|
68
69
|
if (!Object.hasOwn(process.env, name)) return
|
|
@@ -294,9 +295,7 @@ assert(!options.throttle || options.browsers, engineFlagError('throttle-cpu'))
|
|
|
294
295
|
|
|
295
296
|
const args = []
|
|
296
297
|
|
|
297
|
-
if (
|
|
298
|
-
args.push('--experimental-test-module-mocks')
|
|
299
|
-
}
|
|
298
|
+
if (engineOptions.haveIsOk) args.push('--experimental-test-module-mocks')
|
|
300
299
|
|
|
301
300
|
if (options.pure) {
|
|
302
301
|
if (options.bundle) {
|
|
@@ -322,11 +321,7 @@ if (options.pure) {
|
|
|
322
321
|
args.push('--test-update-snapshots')
|
|
323
322
|
}
|
|
324
323
|
|
|
325
|
-
if (options.forceExit)
|
|
326
|
-
assert(have.haveForceExit && engineOptions.haveIsOk, 'For forceExit, use Node.js >= 20.14.0')
|
|
327
|
-
args.push('--test-force-exit')
|
|
328
|
-
}
|
|
329
|
-
|
|
324
|
+
if (options.forceExit) args.push('--test-force-exit')
|
|
330
325
|
if (options.watch) args.push('--watch')
|
|
331
326
|
if (options.only) args.push('--test-only')
|
|
332
327
|
|
|
@@ -766,8 +761,7 @@ if (options.pure) {
|
|
|
766
761
|
assert(files.length > 0) // otherwise we can run recursively
|
|
767
762
|
if (options.concurrency) args.push('--test-concurrency', options.concurrency)
|
|
768
763
|
if (['--inspect', '--inspect-brk', '--inspect-wait'].includes(options.devtools)) {
|
|
769
|
-
args.push(options.devtools)
|
|
770
|
-
if (have.haveNetworkInspection) args.push('--experimental-network-inspection')
|
|
764
|
+
args.push(options.devtools, '--experimental-network-inspection')
|
|
771
765
|
console.warn(
|
|
772
766
|
['--inspect-brk', '--inspect-wait'].includes(options.devtools)
|
|
773
767
|
? 'Open chrome://inspect/ to connect devtools, waiting'
|
package/mock.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './src/mock.js'
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@exodus/test",
|
|
3
|
-
"version": "1.0.0-rc.
|
|
3
|
+
"version": "1.0.0-rc.106",
|
|
4
4
|
"author": "Exodus Movement, Inc.",
|
|
5
5
|
"description": "A test suite runner",
|
|
6
6
|
"homepage": "https://github.com/ExodusMovement/test",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
],
|
|
20
20
|
"license": "MIT",
|
|
21
21
|
"engines": {
|
|
22
|
-
"node": "^
|
|
22
|
+
"node": "^20.18.0 || >=22.6.0"
|
|
23
23
|
},
|
|
24
24
|
"type": "module",
|
|
25
25
|
"bin": {
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
"./benchmark": "./src/benchmark.js",
|
|
32
32
|
"./expect": "./src/expect.cjs",
|
|
33
33
|
"./jest": "./src/jest.js",
|
|
34
|
+
"./mock": "./src/mock.js",
|
|
34
35
|
"./node": "./src/node.js",
|
|
35
36
|
"./tape": {
|
|
36
37
|
"import": "./src/tape.js",
|
|
@@ -74,6 +75,7 @@
|
|
|
74
75
|
"src/jest.setup.js",
|
|
75
76
|
"src/jest.snapshot.js",
|
|
76
77
|
"src/jest.timers.js",
|
|
78
|
+
"src/mock.js",
|
|
77
79
|
"src/node.js",
|
|
78
80
|
"src/pretty-format.cjs",
|
|
79
81
|
"src/replay.js",
|
|
@@ -81,8 +83,10 @@
|
|
|
81
83
|
"src/tape.cjs",
|
|
82
84
|
"src/timers-track.js",
|
|
83
85
|
"src/version.js",
|
|
86
|
+
"benchmark.js",
|
|
84
87
|
"expect.cjs",
|
|
85
88
|
"jest.js",
|
|
89
|
+
"mock.js",
|
|
86
90
|
"node.js",
|
|
87
91
|
"tape.js",
|
|
88
92
|
"CHANGELOG.md"
|
|
@@ -125,6 +129,7 @@
|
|
|
125
129
|
"test:graaljs": "EXODUS_TEST_ENGINE=graaljs:bundle npm run test:_bundle --",
|
|
126
130
|
"test:escargot": "EXODUS_TEST_ENGINE=escargot:bundle npm run test:_bundle --",
|
|
127
131
|
"test:boa": "EXODUS_TEST_ENGINE=boa:bundle npm run test:_bundle --",
|
|
132
|
+
"test:jerryscript": "EXODUS_TEST_ENGINE=jerryscript:bundle npm run test:_bundle -- --bundle-entropy-size 128",
|
|
128
133
|
"test:fetch": "node ./bin/index.js --jest --drop-network --engine node:pure 'tests/replay/*.test.js'",
|
|
129
134
|
"test:jsdom": "EXODUS_TEST_JEST_CONFIG='{\"testMatch\":[\"**/*.jsdom-test.js\"],\"testEnvironment\":\"jsdom\", \"rootDir\": \".\"}' ./bin/index.js --jest",
|
|
130
135
|
"coverage": "node ./bin/index.js --jest --esbuild --coverage",
|
|
@@ -138,15 +143,15 @@
|
|
|
138
143
|
"optionalDependencies": {
|
|
139
144
|
"@chalker/queue": "^1.0.1",
|
|
140
145
|
"@exodus/replay": "^1.0.0-rc.9",
|
|
141
|
-
"@exodus/test-bundler": "1.0.0-rc.
|
|
146
|
+
"@exodus/test-bundler": "1.0.0-rc.7",
|
|
142
147
|
"c8": "^9.1.0",
|
|
143
|
-
"expect": "^
|
|
148
|
+
"expect": "^30.2.0",
|
|
144
149
|
"fast-glob": "^3.2.11",
|
|
145
150
|
"jest-extended": "^4.0.2",
|
|
146
151
|
"playwright-core": "^1.52.0",
|
|
147
|
-
"pretty-format": "^
|
|
148
|
-
"puppeteer-core": "^24.
|
|
149
|
-
"tsx": "^4.20.
|
|
152
|
+
"pretty-format": "^30.2.0",
|
|
153
|
+
"puppeteer-core": "^24.14.0",
|
|
154
|
+
"tsx": "^4.20.6"
|
|
150
155
|
},
|
|
151
156
|
"devDependencies": {
|
|
152
157
|
"@exodus/eslint-config": "^5.24.0",
|
package/src/benchmark.js
CHANGED
|
@@ -10,9 +10,10 @@ const fTime = (ns) => {
|
|
|
10
10
|
return min < 5n ? `${s}s` : `${min}min`
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
const { performance, scheduler, process, requestAnimationFrame, gc } = globalThis
|
|
13
14
|
const getTime = (() => {
|
|
14
|
-
if (
|
|
15
|
-
if (
|
|
15
|
+
if (process) return () => process.hrtime.bigint()
|
|
16
|
+
if (performance) return () => BigInt(Math.round(performance.now() * 1e6))
|
|
16
17
|
return () => BigInt(Math.round(Date.now() * 1e6))
|
|
17
18
|
})()
|
|
18
19
|
|
|
@@ -22,9 +23,13 @@ export async function benchmark(name, options, fn) {
|
|
|
22
23
|
if (options?.skip) return
|
|
23
24
|
const { args, timeout = 1000 } = options ?? {}
|
|
24
25
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
// This will pause us for a bit, but we don't care - having a non-busy process is more important
|
|
27
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
28
|
+
if (requestAnimationFrame) await new Promise((resolve) => requestAnimationFrame(resolve))
|
|
29
|
+
if (scheduler?.yield) await scheduler.yield()
|
|
30
|
+
|
|
31
|
+
if (gc) for (let i = 0; i < 4; i++) gc()
|
|
32
|
+
if (!gc && !gcWarned) {
|
|
28
33
|
gcWarned = true
|
|
29
34
|
console.log('Warning: no gc() available\n')
|
|
30
35
|
}
|
|
@@ -43,12 +48,14 @@ export async function benchmark(name, options, fn) {
|
|
|
43
48
|
total += diff
|
|
44
49
|
if (min === undefined || min > diff) min = diff
|
|
45
50
|
if (max === undefined || max < diff) max = diff
|
|
46
|
-
if (total >= BigInt(timeout)) break
|
|
51
|
+
if (total >= BigInt(timeout) * 10n ** 6n) break
|
|
47
52
|
}
|
|
48
53
|
|
|
49
54
|
const mean = total / BigInt(count)
|
|
50
|
-
|
|
51
|
-
|
|
55
|
+
let res = `${name} x ${fRps(1e9 / Number(mean))} ops/sec @ ${fTime(mean)}/op`
|
|
56
|
+
if (fTime(min) !== fTime(max)) res += ` (${fTime(min)}..${fTime(max)})`
|
|
57
|
+
console.log(res)
|
|
52
58
|
|
|
53
|
-
if (
|
|
59
|
+
if (gc) for (let i = 0; i < 4; i++) gc()
|
|
60
|
+
return { total, count, mean, min, max }
|
|
54
61
|
}
|
package/src/dark.cjs
CHANGED
|
@@ -52,57 +52,63 @@ function createCallerLocationHook() {
|
|
|
52
52
|
return { installLocationInNextTest, getCallerLocation }
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
//
|
|
56
|
-
function
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
55
|
+
// Optimized out in 'bundle' env
|
|
56
|
+
function getTestNamePathFromNode(t) {
|
|
57
|
+
// We are on Node.js < 22.3.0 where even t.fullName doesn't exist yet, polyfill
|
|
58
|
+
const namePath = Symbol('namePath')
|
|
59
|
+
const getNamePath = Symbol('getNamePath')
|
|
60
|
+
try {
|
|
61
|
+
if (t[namePath]) return t[namePath]
|
|
62
|
+
|
|
63
|
+
// Sigh, ok, whatever
|
|
64
|
+
const { Test } = require('node:internal/test_runner/test')
|
|
65
|
+
|
|
66
|
+
const usePathName = Symbol('usePathName')
|
|
67
|
+
const restoreName = Symbol('restoreName')
|
|
68
|
+
Test.prototype[getNamePath] = function () {
|
|
69
|
+
if (this === this.root) return []
|
|
70
|
+
return [...(this.parent?.[getNamePath]() || []), this.name]
|
|
71
|
+
}
|
|
69
72
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
73
|
+
const diagnostic = Test.prototype.diagnostic
|
|
74
|
+
Test.prototype.diagnostic = function (...args) {
|
|
75
|
+
if (args[0] === usePathName) {
|
|
76
|
+
this[restoreName] = this.name
|
|
77
|
+
this.name = this[getNamePath]()
|
|
78
|
+
return
|
|
75
79
|
}
|
|
76
80
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
return
|
|
83
|
-
}
|
|
81
|
+
if (args[0] === restoreName) {
|
|
82
|
+
this.name = this[restoreName]
|
|
83
|
+
delete this[restoreName]
|
|
84
|
+
return
|
|
85
|
+
}
|
|
84
86
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
delete this[restoreName]
|
|
88
|
-
return
|
|
89
|
-
}
|
|
87
|
+
return diagnostic.apply(this, args)
|
|
88
|
+
}
|
|
90
89
|
|
|
91
|
-
|
|
92
|
-
|
|
90
|
+
const TestContextProto = Object.getPrototypeOf(t)
|
|
91
|
+
Object.defineProperty(TestContextProto, namePath, {
|
|
92
|
+
get() {
|
|
93
|
+
this.diagnostic(usePathName)
|
|
94
|
+
const result = this.name
|
|
95
|
+
this.diagnostic(restoreName)
|
|
96
|
+
return result
|
|
97
|
+
},
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
return t[namePath]
|
|
101
|
+
} catch {}
|
|
102
|
+
}
|
|
93
103
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
const result = this.name
|
|
99
|
-
this.diagnostic(restoreName)
|
|
100
|
-
return result
|
|
101
|
-
},
|
|
102
|
-
})
|
|
104
|
+
// Easy on Node.js >= 22.3.0 || ^20.16.0, but we polyfill for the rest
|
|
105
|
+
function getTestNamePath(t) {
|
|
106
|
+
// No implementation in Node.js yet, will have to PR
|
|
107
|
+
if (t.fullName) return t.fullName.split(' > ')
|
|
103
108
|
|
|
104
|
-
|
|
105
|
-
|
|
109
|
+
if (process.env.EXODUS_TEST_ENGINE === 'node:test') {
|
|
110
|
+
const names = getTestNamePathFromNode(t)
|
|
111
|
+
if (names) return names
|
|
106
112
|
}
|
|
107
113
|
|
|
108
114
|
return [t.name] // last resort
|
package/src/engine.node.cjs
CHANGED
|
@@ -30,7 +30,14 @@ const setSnapshotResolver = (fn) => {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
const mockModule = mock?.module
|
|
33
|
-
? (t, o) =>
|
|
33
|
+
? (t, o) => {
|
|
34
|
+
try {
|
|
35
|
+
// resolve windows-looking paths, fails on old 20.x/22.x, but non-url works there, hence in try-catch
|
|
36
|
+
if (t.includes('\\')) return mock.module(pathToFileURL(t), o)
|
|
37
|
+
} catch {}
|
|
38
|
+
|
|
39
|
+
return mock.module(t, o)
|
|
40
|
+
}
|
|
34
41
|
: undefined
|
|
35
42
|
|
|
36
43
|
/* eslint-disable unicorn/no-useless-spread */
|
package/src/engine.pure.cjs
CHANGED
|
@@ -539,7 +539,7 @@ if (process.env.EXODUS_TEST_ENVIRONMENT === 'bundle') {
|
|
|
539
539
|
// eslint-disable-next-line no-undef
|
|
540
540
|
const bundleSnaps = typeof EXODUS_TEST_SNAPSHOTS !== 'undefined' && new Map(EXODUS_TEST_SNAPSHOTS)
|
|
541
541
|
const resolveSnapshot = (f) => snapshotResolver(f[0], f[1]).join('/')
|
|
542
|
-
readSnapshot = (f = baseFile) => (f
|
|
542
|
+
readSnapshot = (f = baseFile) => (f && bundleSnaps?.get(resolveSnapshot(f))) || null
|
|
543
543
|
utilFormat = require('exodus-test:util-format')
|
|
544
544
|
} else {
|
|
545
545
|
const { existsSync, readFileSync } = require('node:fs')
|
package/src/exodus.js
CHANGED
|
@@ -1,51 +1,24 @@
|
|
|
1
1
|
import { mock } from './engine.js'
|
|
2
2
|
import * as node from './engine.js'
|
|
3
|
-
import { fetchReplay, fetchRecord, websocketRecord, websocketReplay } from './replay.js'
|
|
4
|
-
import { timersTrack, timersList, timersDebug, timersAssert } from './timers-track.js'
|
|
5
|
-
import { insideEsbuild } from './dark.cjs'
|
|
6
|
-
import { haveValidTimers } from './version.js'
|
|
7
3
|
|
|
8
|
-
|
|
9
|
-
if (!(typeof rate === 'number' && rate > 0)) throw new TypeError('Expected a positive rate')
|
|
10
|
-
const { setTimeout, setInterval, Date: OrigDate } = globalThis
|
|
11
|
-
for (const api of apis) {
|
|
12
|
-
// eslint-disable-next-line unicorn/prefer-switch
|
|
13
|
-
if (api === 'setTimeout') {
|
|
14
|
-
globalThis.setTimeout = (fn, ms, ...args) => setTimeout(fn, Math.ceil(ms / rate), ...args)
|
|
15
|
-
} else if (api === 'setInterval') {
|
|
16
|
-
globalThis.setInterval = (fn, ms, ...args) => setInterval(fn, Math.ceil(ms / rate), ...args)
|
|
17
|
-
} else if (api === 'Date') {
|
|
18
|
-
const base = OrigDate.now()
|
|
19
|
-
globalThis.Date = class Date extends OrigDate {
|
|
20
|
-
static now = () => base + Math.floor((OrigDate.now() - base) * rate)
|
|
21
|
-
constructor(first = globalThis.Date.now(), ...rest) {
|
|
22
|
-
super(first, ...rest)
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
} else {
|
|
26
|
-
throw new Error(`Unknown or unsupported API in timersSpeedup(): ${api}`)
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
}
|
|
4
|
+
import { insideEsbuild } from './dark.cjs'
|
|
30
5
|
|
|
31
6
|
const isBundle = process.env.EXODUS_TEST_ENVIRONMENT === 'bundle' // TODO: improve mocking from bundle
|
|
32
|
-
|
|
7
|
+
|
|
8
|
+
export const platform = String(process.env.EXODUS_TEST_PLATFORM) // e.g. 'hermes', 'node'
|
|
9
|
+
export const engine = String(process.env.EXODUS_TEST_ENGINE) // e.g. 'hermes:bundle', 'node:bundle', 'node:test', 'node:pure'
|
|
10
|
+
export const implementation = String(node.engine) // aka process.env.EXODUS_TEST_CONTEXT, e.g. 'node:test' or 'pure'
|
|
11
|
+
|
|
12
|
+
/* eslint-disable jsdoc/check-tag-names */
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @experimental API might change
|
|
16
|
+
*/
|
|
17
|
+
export const features = {
|
|
33
18
|
__proto__: null,
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
timers: Boolean(mock.timers && haveValidTimers),
|
|
40
|
-
dynamicRequire: Boolean(!isBundle), // require(non-literal-non-glob), createRequire()(non-builtin)
|
|
41
|
-
esmMocks: Boolean(mock.module || isBundle), // support for ESM mocks
|
|
42
|
-
esmNamedBuiltinMocks: Boolean(mock.module || isBundle || insideEsbuild()), // support for named ESM imports from builtin module mocks: also fine in --esbuild
|
|
43
|
-
esmInterop: Boolean(insideEsbuild() && !isBundle), // loading/using ESM as CJS, ESM mocks creation without a mocker function
|
|
44
|
-
concurrency: node.engine !== 'pure', // pure engine doesn't support concurrency
|
|
45
|
-
},
|
|
46
|
-
mock: {
|
|
47
|
-
...{ timersTrack, timersList, timersDebug, timersAssert, timersSpeedup }, // eslint-disable-line unicorn/no-useless-spread
|
|
48
|
-
...{ fetchRecord, fetchReplay }, // eslint-disable-line unicorn/no-useless-spread
|
|
49
|
-
...{ websocketRecord, websocketReplay }, // eslint-disable-line unicorn/no-useless-spread
|
|
50
|
-
},
|
|
19
|
+
dynamicRequire: Boolean(!isBundle), // require(non-literal-non-glob), createRequire()(non-builtin)
|
|
20
|
+
esmMocks: Boolean(mock.module || isBundle), // support for ESM mocks
|
|
21
|
+
esmNamedBuiltinMocks: Boolean(mock.module || isBundle || insideEsbuild()), // support for named ESM imports from builtin module mocks: also fine in --esbuild
|
|
22
|
+
esmInterop: Boolean(insideEsbuild() && !isBundle), // loading/using ESM as CJS, ESM mocks creation without a mocker function
|
|
23
|
+
concurrency: node.engine !== 'pure', // pure engine doesn't support concurrency
|
|
51
24
|
}
|
package/src/expect.cjs
CHANGED
|
@@ -13,21 +13,27 @@ function fixupAssertions() {
|
|
|
13
13
|
|
|
14
14
|
function loadExpect(loadReason) {
|
|
15
15
|
if (expect) return expect
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
throw new Error(
|
|
20
|
-
}
|
|
16
|
+
// eslint-disable-next-line no-undef
|
|
17
|
+
if (typeof EXODUS_TEST_LOAD_EXPECT !== 'undefined' && EXODUS_TEST_LOAD_EXPECT === false) {
|
|
18
|
+
if (loadReason === 'jest.mock') return // allow that and ignore if there is no usage
|
|
19
|
+
throw new Error('FATAL: expect() was optimized out')
|
|
20
|
+
} else {
|
|
21
|
+
try {
|
|
22
|
+
expect = require('expect').expect
|
|
23
|
+
} catch {
|
|
24
|
+
throw new Error(`Failed to load 'expect', required for ${loadReason}`)
|
|
25
|
+
}
|
|
21
26
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
27
|
+
// console.log('expect load reason:', loadReason)
|
|
28
|
+
try {
|
|
29
|
+
expect.extend(require('jest-extended'))
|
|
30
|
+
} catch {}
|
|
26
31
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
32
|
+
for (const x of extend) expect.extend(...x)
|
|
33
|
+
for (const [key, value] of set) expect[key] = value
|
|
34
|
+
fixupAssertions()
|
|
35
|
+
return expect
|
|
36
|
+
}
|
|
31
37
|
}
|
|
32
38
|
|
|
33
39
|
const areNumeric = (...args) => args.every((a) => typeof a === 'number' || typeof a === 'bigint')
|
package/src/jest.config.js
CHANGED
|
@@ -4,6 +4,7 @@ import { specialEnvironments } from './jest.environment.js'
|
|
|
4
4
|
|
|
5
5
|
const skipPreset = new Set(['ts-jest'])
|
|
6
6
|
const EXTS = `.?([cm])[jt]s?(x)` // we differ from jest, allowing [cm] before everything
|
|
7
|
+
const needPreset = ({ preset } = {}) => preset && !skipPreset.has(preset)
|
|
7
8
|
const normalizeJestConfig = (config) => ({
|
|
8
9
|
testMatch: [`**/__tests__/**/*${EXTS}`, `**/?(*.)+(spec|test)${EXTS}`],
|
|
9
10
|
testEnvironment: 'node',
|
|
@@ -65,6 +66,67 @@ export const jestConfig = () => {
|
|
|
65
66
|
|
|
66
67
|
// Methods loadJestConfig() and installJestEnvironment() below are for --jest flag
|
|
67
68
|
|
|
69
|
+
// Optimized out in 'bundle' env
|
|
70
|
+
async function loadConfigParts(rawConfig) {
|
|
71
|
+
const presetExtension = /\.([cm]?js|json)$/u
|
|
72
|
+
const suffixes = ['/jest-preset.json', '/jest-preset.js', '/jest-preset.cjs', '/jest-preset.mjs']
|
|
73
|
+
const resolveGlobalSetup = (config, req) => {
|
|
74
|
+
if (config.globalSetup) config.globalSetup = req.resolve(config.globalSetup) // eslint-disable-line @exodus/mutable/no-param-reassign-prop-only
|
|
75
|
+
if (config.globalTeardown) config.globalTeardown = req.resolve(config.globalTeardown) // eslint-disable-line @exodus/mutable/no-param-reassign-prop-only
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
assert(rawConfig.rootDir)
|
|
79
|
+
const { resolve } = await import('node:path')
|
|
80
|
+
const { createRequire } = await import('node:module')
|
|
81
|
+
const { pathToFileURL } = await import('node:url')
|
|
82
|
+
let requireConfig = createRequire(resolve(rawConfig.rootDir, 'package.json'))
|
|
83
|
+
resolveGlobalSetup(rawConfig, requireConfig)
|
|
84
|
+
while (needPreset(rawConfig)) {
|
|
85
|
+
let baseConfig
|
|
86
|
+
|
|
87
|
+
const attemptLoad = async (file) => {
|
|
88
|
+
try {
|
|
89
|
+
const resolved = requireConfig.resolve(file)
|
|
90
|
+
// FIXME: fix linter to allow this
|
|
91
|
+
// const meta = resolved.toLowerCase().endsWith('.json') ? { with: { type: 'json' } } : undefined
|
|
92
|
+
// const presetModule = await import(pathToFileURL(resolved), meta)
|
|
93
|
+
const presetModule = await import(pathToFileURL(resolved))
|
|
94
|
+
requireConfig = createRequire(resolved)
|
|
95
|
+
baseConfig = presetModule.default
|
|
96
|
+
} catch {}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Even if it is relative, it could be a path to module
|
|
100
|
+
for (const suffix of suffixes) {
|
|
101
|
+
if (!baseConfig) await attemptLoad(`${rawConfig.preset}${suffix}`)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// If it's a path to a file
|
|
105
|
+
if (!baseConfig && rawConfig.preset[0] === '.' && presetExtension.test(rawConfig.preset)) {
|
|
106
|
+
const { statSync } = await import('node:fs')
|
|
107
|
+
if (statSync(rawConfig.preset).isFile()) await attemptLoad(rawConfig.preset)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
assert(baseConfig, `Could not load preset: ${rawConfig.preset} `)
|
|
111
|
+
resolveGlobalSetup(baseConfig, requireConfig)
|
|
112
|
+
rawConfig = {
|
|
113
|
+
...baseConfig,
|
|
114
|
+
...rawConfig,
|
|
115
|
+
preset: baseConfig.preset,
|
|
116
|
+
setupFiles: [
|
|
117
|
+
...(baseConfig.setupFiles || []).map((file) => requireConfig.resolve(file)),
|
|
118
|
+
...(rawConfig.setupFiles || []),
|
|
119
|
+
],
|
|
120
|
+
setupFilesAfterEnv: [
|
|
121
|
+
...(baseConfig.setupFilesAfterEnv || []).map((file) => requireConfig.resolve(file)),
|
|
122
|
+
...(rawConfig.setupFilesAfterEnv || []),
|
|
123
|
+
],
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return rawConfig
|
|
128
|
+
}
|
|
129
|
+
|
|
68
130
|
export async function loadJestConfig(...args) {
|
|
69
131
|
let rawConfig
|
|
70
132
|
if (process.env.EXODUS_TEST_JEST_CONFIG === undefined) {
|
|
@@ -75,67 +137,12 @@ export async function loadJestConfig(...args) {
|
|
|
75
137
|
}
|
|
76
138
|
|
|
77
139
|
const cleanFile = (file) => file.replace(/^<rootDir>\//g, './') // require is already relative to rootDir
|
|
78
|
-
const needPreset = ({ preset } = {}) => preset && !skipPreset.has(preset)
|
|
79
|
-
const resolveGlobalSetup = (config, req) => {
|
|
80
|
-
if (config.globalSetup) config.globalSetup = req.resolve(config.globalSetup) // eslint-disable-line @exodus/mutable/no-param-reassign-prop-only
|
|
81
|
-
if (config.globalTeardown) config.globalTeardown = req.resolve(config.globalTeardown) // eslint-disable-line @exodus/mutable/no-param-reassign-prop-only
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
const presetExtension = /\.([cm]?js|json)$/u
|
|
85
|
-
const suffixes = ['/jest-preset.json', '/jest-preset.js', '/jest-preset.cjs', '/jest-preset.mjs']
|
|
86
140
|
if (needPreset(rawConfig) || rawConfig?.globalSetup || rawConfig?.globalTeardown) {
|
|
87
141
|
rawConfig.preset = cleanFile(rawConfig.preset) // relative to root dir only at top level, presets shouldn't use <rootDir>
|
|
88
142
|
if (process.env.EXODUS_TEST_ENVIRONMENT === 'bundle') {
|
|
89
143
|
throw new Error('jest preset and globalSetup/Teardown not yet supported in bundles')
|
|
90
144
|
} else {
|
|
91
|
-
|
|
92
|
-
const { resolve } = await import('node:path')
|
|
93
|
-
const { createRequire } = await import('node:module')
|
|
94
|
-
const { pathToFileURL } = await import('node:url')
|
|
95
|
-
let requireConfig = createRequire(resolve(rawConfig.rootDir, 'package.json'))
|
|
96
|
-
resolveGlobalSetup(rawConfig, requireConfig)
|
|
97
|
-
while (needPreset(rawConfig)) {
|
|
98
|
-
let baseConfig
|
|
99
|
-
|
|
100
|
-
const attemptLoad = async (file) => {
|
|
101
|
-
try {
|
|
102
|
-
const resolved = requireConfig.resolve(file)
|
|
103
|
-
// FIXME: fix linter to allow this
|
|
104
|
-
// const meta = resolved.toLowerCase().endsWith('.json') ? { with: { type: 'json' } } : undefined
|
|
105
|
-
// const presetModule = await import(pathToFileURL(resolved), meta)
|
|
106
|
-
const presetModule = await import(pathToFileURL(resolved))
|
|
107
|
-
requireConfig = createRequire(resolved)
|
|
108
|
-
baseConfig = presetModule.default
|
|
109
|
-
} catch {}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
// Even if it is relative, it could be a path to module
|
|
113
|
-
for (const suffix of suffixes) {
|
|
114
|
-
if (!baseConfig) await attemptLoad(`${rawConfig.preset}${suffix}`)
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// If it's a path to a file
|
|
118
|
-
if (!baseConfig && rawConfig.preset[0] === '.' && presetExtension.test(rawConfig.preset)) {
|
|
119
|
-
const { statSync } = await import('node:fs')
|
|
120
|
-
if (statSync(rawConfig.preset).isFile()) await attemptLoad(rawConfig.preset)
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
assert(baseConfig, `Could not load preset: ${rawConfig.preset} `)
|
|
124
|
-
resolveGlobalSetup(baseConfig, requireConfig)
|
|
125
|
-
rawConfig = {
|
|
126
|
-
...baseConfig,
|
|
127
|
-
...rawConfig,
|
|
128
|
-
preset: baseConfig.preset,
|
|
129
|
-
setupFiles: [
|
|
130
|
-
...(baseConfig.setupFiles || []).map((file) => requireConfig.resolve(file)),
|
|
131
|
-
...(rawConfig.setupFiles || []),
|
|
132
|
-
],
|
|
133
|
-
setupFilesAfterEnv: [
|
|
134
|
-
...(baseConfig.setupFilesAfterEnv || []).map((file) => requireConfig.resolve(file)),
|
|
135
|
-
...(rawConfig.setupFilesAfterEnv || []),
|
|
136
|
-
],
|
|
137
|
-
}
|
|
138
|
-
}
|
|
145
|
+
rawConfig = await loadConfigParts(rawConfig)
|
|
139
146
|
}
|
|
140
147
|
}
|
|
141
148
|
|
|
@@ -148,6 +155,15 @@ export async function loadJestConfig(...args) {
|
|
|
148
155
|
return config
|
|
149
156
|
}
|
|
150
157
|
|
|
158
|
+
// Optimized out in 'bundle' env
|
|
159
|
+
async function makeDynamicImport(rootDir) {
|
|
160
|
+
const { resolve } = await import('node:path')
|
|
161
|
+
const { createRequire } = await import('node:module')
|
|
162
|
+
const { pathToFileURL } = await import('node:url')
|
|
163
|
+
const require = createRequire(resolve(rootDir, 'package.json'))
|
|
164
|
+
return (path) => import(pathToFileURL(require.resolve(path))) // does not need json imports
|
|
165
|
+
}
|
|
166
|
+
|
|
151
167
|
export async function installJestEnvironment(jestGlobals) {
|
|
152
168
|
const engine = await import('./engine.js')
|
|
153
169
|
|
|
@@ -173,11 +189,7 @@ export async function installJestEnvironment(jestGlobals) {
|
|
|
173
189
|
assert.fail('Requiring non-bundled plugins from bundle is unsupported')
|
|
174
190
|
}
|
|
175
191
|
} else if (config.rootDir) {
|
|
176
|
-
|
|
177
|
-
const { createRequire } = await import('node:module')
|
|
178
|
-
const { pathToFileURL } = await import('node:url')
|
|
179
|
-
const require = createRequire(resolve(config.rootDir, 'package.json'))
|
|
180
|
-
dynamicImport = (path) => import(pathToFileURL(require.resolve(path))) // does not need json imports
|
|
192
|
+
dynamicImport = await makeDynamicImport(config.rootDir)
|
|
181
193
|
} else {
|
|
182
194
|
dynamicImport = async () => assert.fail('Unreachable: importing plugins without a rootDir')
|
|
183
195
|
}
|
package/src/jest.fn.js
CHANGED
|
@@ -23,6 +23,7 @@ export const jestFunctionMocks = {
|
|
|
23
23
|
if (desc?.get && !desc.set && desc.configurable && desc.enumerable) delete obj[name] // e.g. a wrapped module
|
|
24
24
|
// eslint-disable-next-line @exodus/mutable/no-param-reassign-prop-only
|
|
25
25
|
obj[name] = fn
|
|
26
|
+
if (Symbol.dispose) fn[Symbol.dispose] = () => fn.mockRestore()
|
|
26
27
|
return fn
|
|
27
28
|
},
|
|
28
29
|
clearAllMocks: applyAllWrap('mockClear'),
|
package/src/jest.js
CHANGED
|
@@ -6,7 +6,8 @@ import { jestModuleMocks } from './jest.mock.js'
|
|
|
6
6
|
import * as jestTimers from './jest.timers.js'
|
|
7
7
|
import { setupSnapshots } from './jest.snapshot.js'
|
|
8
8
|
import { createCallerLocationHook } from './dark.cjs'
|
|
9
|
-
import
|
|
9
|
+
import * as exodus from './exodus.js'
|
|
10
|
+
import * as mock from './mock.js'
|
|
10
11
|
import { expect } from './expect.cjs'
|
|
11
12
|
import { format as prettyFormat } from './pretty-format.cjs'
|
|
12
13
|
import { timersTrack, timersDebug } from './timers-track.js'
|
|
@@ -247,12 +248,13 @@ if (process.env.EXODUS_TEST_PLATFORM !== 'deno' && globalThis.process) {
|
|
|
247
248
|
}
|
|
248
249
|
}
|
|
249
250
|
|
|
250
|
-
|
|
251
|
-
|
|
251
|
+
// jest.exodus extension
|
|
252
|
+
function makeJestExodus() {
|
|
253
|
+
return {
|
|
252
254
|
__proto__: null,
|
|
253
255
|
...exodus,
|
|
254
256
|
mock: {
|
|
255
|
-
...
|
|
257
|
+
...mock,
|
|
256
258
|
fetchNoop: () => {
|
|
257
259
|
// We can't use pure noop, it will break chained fetch().then(), so let's reject
|
|
258
260
|
const fetch = () => Promise.reject(new Error('fetch is disabled by mock.fetchNoop()'))
|
|
@@ -264,7 +266,15 @@ export const jest = {
|
|
|
264
266
|
return globalThis.WebSocket
|
|
265
267
|
},
|
|
266
268
|
},
|
|
267
|
-
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export const jest = {
|
|
273
|
+
exodus:
|
|
274
|
+
// eslint-disable-next-line no-undef
|
|
275
|
+
typeof EXODUS_TEST_LOAD_JESTEXODUS === 'undefined' || EXODUS_TEST_LOAD_JESTEXODUS !== false
|
|
276
|
+
? makeJestExodus()
|
|
277
|
+
: undefined,
|
|
268
278
|
setTimeout: (x) => {
|
|
269
279
|
assert.equal(typeof x, 'number')
|
|
270
280
|
defaultTimeout = x
|
package/src/jest.mock.js
CHANGED
|
@@ -253,7 +253,9 @@ function mockCloneItem(obj, cache) {
|
|
|
253
253
|
|
|
254
254
|
// TODO: implement for bundles or add a guard against bundles if __mocks__ dir exists
|
|
255
255
|
let loadMocksDirMock
|
|
256
|
-
|
|
256
|
+
|
|
257
|
+
// Optimized out in 'bundle' env
|
|
258
|
+
function installMockDirs() {
|
|
257
259
|
const { existsSync, readdirSync, statSync } = require('node:fs')
|
|
258
260
|
const { dirname, join, extname } = require('node:path')
|
|
259
261
|
const dirs = []
|
|
@@ -290,7 +292,7 @@ if (process.env.EXODUS_TEST_ENVIRONMENT !== 'bundle') {
|
|
|
290
292
|
}
|
|
291
293
|
}
|
|
292
294
|
|
|
293
|
-
// Automock does't work on import() in jest anyway, so it's ok to let that require manual jest.mock
|
|
295
|
+
// Automock does't work on import() in jest anyway, so it's ok to let that require manual jest.mock
|
|
294
296
|
if (shouldAutoMock.size > 0) {
|
|
295
297
|
const { Module } = require('node:module')
|
|
296
298
|
const _require = Module.prototype.require
|
|
@@ -305,6 +307,8 @@ if (process.env.EXODUS_TEST_ENVIRONMENT !== 'bundle') {
|
|
|
305
307
|
}
|
|
306
308
|
}
|
|
307
309
|
|
|
310
|
+
if (process.env.EXODUS_TEST_ENVIRONMENT !== 'bundle') installMockDirs()
|
|
311
|
+
|
|
308
312
|
function jestmock(name, mocker, { override = false, actual, builtin, loc } = {}) {
|
|
309
313
|
// Loaded ESM: isn't mocked
|
|
310
314
|
// Loaded CJS: mocked via object overriding
|
package/src/jest.snapshot.js
CHANGED
|
@@ -9,8 +9,7 @@ import {
|
|
|
9
9
|
import { formatWithAllPlugins } from './pretty-format.cjs'
|
|
10
10
|
import { jestConfig } from './jest.config.js'
|
|
11
11
|
import { getTestNamePath } from './dark.cjs'
|
|
12
|
-
import {
|
|
13
|
-
import { matchSnapshot, escapeSnapshot } from './engine.pure.snapshot.cjs'
|
|
12
|
+
import { matchSnapshot } from './engine.pure.snapshot.cjs'
|
|
14
13
|
|
|
15
14
|
const { snapshotFormat, snapshotSerializers } = jestConfig()
|
|
16
15
|
const plugins = []
|
|
@@ -140,8 +139,7 @@ const snapOnDisk = (expect, orig, matcherOrSnapshotName, snapshotName) => {
|
|
|
140
139
|
wrapContextName(() => context.assert.snapshot(obj), name)
|
|
141
140
|
} catch (e) {
|
|
142
141
|
if (typeof e.expected === 'string') {
|
|
143
|
-
const
|
|
144
|
-
const final = escaped.includes('\n') ? escaped : `\n${escaped}\n`
|
|
142
|
+
const final = e.expected.includes('\n') ? e.expected : `\n${e.expected}\n`
|
|
145
143
|
if (final === e.actual) return
|
|
146
144
|
}
|
|
147
145
|
|
package/src/jest.timers.js
CHANGED
|
@@ -1,20 +1,8 @@
|
|
|
1
1
|
import { mock, assert, awaitForMicrotaskQueue } from './engine.js'
|
|
2
2
|
import { jestConfig } from './jest.config.js'
|
|
3
|
-
import { haveValidTimers, haveNoTimerInfiniteLoopBug } from './version.js'
|
|
4
|
-
|
|
5
|
-
const assertHaveTimers = () =>
|
|
6
|
-
assert(mock.timers, 'Timer mocking requires Node.js >=20.4.0 || 18 >=18.19.0')
|
|
7
|
-
|
|
8
|
-
let timersWarned = false
|
|
9
|
-
const warnOldTimers = () => {
|
|
10
|
-
if (haveValidTimers || timersWarned) return
|
|
11
|
-
timersWarned = true
|
|
12
|
-
console.warn('Warning: timer mocks are known to be glitchy before Node.js >=20.11.0')
|
|
13
|
-
}
|
|
14
3
|
|
|
15
4
|
let enabled = false
|
|
16
5
|
const assertEnabledTimers = () => {
|
|
17
|
-
assertHaveTimers()
|
|
18
6
|
assert(enabled, 'You should enable MockTimers first by calling useFakeTimers()')
|
|
19
7
|
}
|
|
20
8
|
|
|
@@ -27,15 +15,12 @@ export function useRealTimers() {
|
|
|
27
15
|
const doNotFakeDefault = jestConfig().fakeTimers?.doNotFake ?? []
|
|
28
16
|
|
|
29
17
|
export function useFakeTimers({ doNotFake = doNotFakeDefault, ...rest } = {}) {
|
|
30
|
-
assertHaveTimers()
|
|
31
|
-
warnOldTimers()
|
|
32
18
|
assert.deepEqual(rest, {}, 'Unsupported options')
|
|
33
|
-
const allApis = ['setInterval', 'setTimeout', 'setImmediate']
|
|
34
|
-
if (haveValidTimers) allApis.push('Date') // vas not supported in older versions
|
|
19
|
+
const allApis = ['setInterval', 'setTimeout', 'setImmediate', 'Date']
|
|
35
20
|
for (const name of doNotFake) assert(allApis.includes(name), `Unknown API: ${name}`)
|
|
36
21
|
const apis = allApis.filter((name) => !doNotFake.includes(name))
|
|
37
22
|
try {
|
|
38
|
-
mock.timers.enable(
|
|
23
|
+
mock.timers.enable({ apis })
|
|
39
24
|
} catch (e) {
|
|
40
25
|
// We allow calling this multiple times and swallow the "MockTimers is already enabled!" error
|
|
41
26
|
if (e.code !== 'ERR_INVALID_STATE') throw e
|
|
@@ -62,7 +47,6 @@ export function runAllTimers() {
|
|
|
62
47
|
|
|
63
48
|
export function runOnlyPendingTimers() {
|
|
64
49
|
assertEnabledTimers()
|
|
65
|
-
assert(haveNoTimerInfiniteLoopBug, 'runOnlyPendingTimers requires Node.js >=20.11.0')
|
|
66
50
|
mock.timers.runAll()
|
|
67
51
|
return this
|
|
68
52
|
}
|
package/src/mock.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export { fetchReplay, websocketRecord, fetchRecord, websocketReplay } from './replay.js'
|
|
2
|
+
export { timersTrack, timersDebug, timersList, timersAssert } from './timers-track.js'
|
|
3
|
+
|
|
4
|
+
export function timersSpeedup(rate, { apis = ['setTimeout', 'setInterval', 'Date'] } = {}) {
|
|
5
|
+
if (!(typeof rate === 'number' && rate > 0)) throw new TypeError('Expected a positive rate')
|
|
6
|
+
const { setTimeout, setInterval, Date: OrigDate } = globalThis
|
|
7
|
+
for (const api of apis) {
|
|
8
|
+
// eslint-disable-next-line unicorn/prefer-switch
|
|
9
|
+
if (api === 'setTimeout') {
|
|
10
|
+
globalThis.setTimeout = (fn, ms, ...args) => setTimeout(fn, Math.ceil(ms / rate), ...args)
|
|
11
|
+
} else if (api === 'setInterval') {
|
|
12
|
+
globalThis.setInterval = (fn, ms, ...args) => setInterval(fn, Math.ceil(ms / rate), ...args)
|
|
13
|
+
} else if (api === 'Date') {
|
|
14
|
+
const base = OrigDate.now()
|
|
15
|
+
globalThis.Date = class Date extends OrigDate {
|
|
16
|
+
static now = () => base + Math.floor((OrigDate.now() - base) * rate)
|
|
17
|
+
constructor(first = globalThis.Date.now(), ...rest) {
|
|
18
|
+
super(first, ...rest)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
} else {
|
|
22
|
+
throw new Error(`Unknown or unsupported API in timersSpeedup(): ${api}`)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
package/src/replay.js
CHANGED
|
@@ -3,18 +3,20 @@ const recordingResolver = (type) => (dir, name) => [dir, '__recordings__', type,
|
|
|
3
3
|
let replay
|
|
4
4
|
let readRecordingRaw, writeRecording
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
6
|
+
function loadReplayBundle() {
|
|
7
|
+
// TODO: also under process.features.require_module
|
|
8
|
+
if (process.env.EXODUS_TEST_ENVIRONMENT === 'bundle') {
|
|
9
|
+
replay = require('@exodus/replay') // synchronous
|
|
10
|
+
} else if (!replay) {
|
|
11
|
+
throw new Error('Failed to load @exodus/replay')
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Optimized out in 'bundle' env
|
|
16
|
+
async function loadNonBundle() {
|
|
17
|
+
// Preload if synchronous lazy-loading is unavailable
|
|
18
|
+
// TODO: not under process?.features?.require_module
|
|
16
19
|
try {
|
|
17
|
-
// TODO: we can synchronously lazy-load it under process.features.require_module
|
|
18
20
|
replay = await import('@exodus/replay')
|
|
19
21
|
} catch {}
|
|
20
22
|
|
|
@@ -56,8 +58,19 @@ if (process.env.EXODUS_TEST_ENVIRONMENT === 'bundle') {
|
|
|
56
58
|
}
|
|
57
59
|
}
|
|
58
60
|
|
|
61
|
+
if (process.env.EXODUS_TEST_ENVIRONMENT === 'bundle') {
|
|
62
|
+
// eslint-disable-next-line no-undef
|
|
63
|
+
const files = EXODUS_TEST_FILES
|
|
64
|
+
const baseFile = files.length === 1 ? files[0] : undefined
|
|
65
|
+
// eslint-disable-next-line no-undef
|
|
66
|
+
const map = new Map(typeof EXODUS_TEST_RECORDINGS === 'undefined' ? [] : EXODUS_TEST_RECORDINGS)
|
|
67
|
+
const resolveRecording = (resolver, f) => resolver(f[0], f[1]).join('/')
|
|
68
|
+
readRecordingRaw = (resolver) => (baseFile ? map.get(resolveRecording(resolver, baseFile)) : null)
|
|
69
|
+
} else {
|
|
70
|
+
await loadNonBundle()
|
|
71
|
+
}
|
|
72
|
+
|
|
59
73
|
function readRecording(resolver) {
|
|
60
|
-
if (!readRecordingRaw) throw new Error('Replaying recordings is not supported in this engine')
|
|
61
74
|
const data = readRecordingRaw(resolver)
|
|
62
75
|
if (typeof data !== 'string') throw new Error('Can not read recording')
|
|
63
76
|
return JSON.parse(data)
|
|
@@ -66,7 +79,7 @@ function readRecording(resolver) {
|
|
|
66
79
|
const log = { websocket: undefined, fetch: undefined }
|
|
67
80
|
|
|
68
81
|
export function fetchRecord(options) {
|
|
69
|
-
|
|
82
|
+
loadReplayBundle()
|
|
70
83
|
if (log.fetch) throw new Error('Can not record again: already recording or replaying!')
|
|
71
84
|
if (!writeRecording) throw new Error('Writing fetch log is not supported on this engine')
|
|
72
85
|
log.fetch = []
|
|
@@ -77,7 +90,7 @@ export function fetchRecord(options) {
|
|
|
77
90
|
}
|
|
78
91
|
|
|
79
92
|
export function fetchReplay() {
|
|
80
|
-
|
|
93
|
+
loadReplayBundle()
|
|
81
94
|
if (log.fetch) throw new Error('Can not replay: already recording or replaying!')
|
|
82
95
|
log.fetch = readRecording(recordingResolver('fetch')) // Re-initialized from start on each call
|
|
83
96
|
const fetch = replay.fetchReplayer(log.fetch)
|
|
@@ -86,7 +99,7 @@ export function fetchReplay() {
|
|
|
86
99
|
}
|
|
87
100
|
|
|
88
101
|
export function websocketRecord(options) {
|
|
89
|
-
|
|
102
|
+
loadReplayBundle()
|
|
90
103
|
if (log.websocket) throw new Error('Can not record: already recording or replaying!')
|
|
91
104
|
if (!writeRecording) throw new Error('Writing WebSocket log is not supported on this engine')
|
|
92
105
|
log.websocket = []
|
|
@@ -97,7 +110,7 @@ export function websocketRecord(options) {
|
|
|
97
110
|
}
|
|
98
111
|
|
|
99
112
|
export function websocketReplay(options) {
|
|
100
|
-
|
|
113
|
+
loadReplayBundle()
|
|
101
114
|
if (log.websocket) throw new Error('Can not replay: already recording or replaying!')
|
|
102
115
|
log.websocket = readRecording(recordingResolver('websocket')) // Re-initialized from start on each call
|
|
103
116
|
const WebSocket = replay.WebSocketReplayer(log.websocket, options)
|
package/src/version.js
CHANGED
|
@@ -1,21 +1,12 @@
|
|
|
1
1
|
import { assert, nodeVersion } from './engine.js'
|
|
2
2
|
|
|
3
3
|
const [major, minor, patch] = nodeVersion.split('.').map(Number)
|
|
4
|
-
//
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
assert(ok, 'Node.js version too old or glitchy with node:test, use ^18.19.0 || ^20.8.0 || >=22.0.0')
|
|
8
|
-
assert(major !== 22 || minor !== 3, 'Refusing to run on Node.js 22.3.0 specifically, do not use it') // safe-guard
|
|
4
|
+
// Before 20.18, there are no module mocks
|
|
5
|
+
const ok = (major === 20 && minor >= 18) || (major === 22 && minor >= 6) || major > 22
|
|
6
|
+
assert(ok, 'Node.js version too old, use ^20.18.0 || >=22.6.0')
|
|
9
7
|
|
|
10
8
|
export { major, minor, patch }
|
|
11
9
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
export const haveSnapshots = (major === 22 && minor >= 3) || major > 22
|
|
15
|
-
export const haveSnapshotsReportUnescaped = (major === 22 && minor >= 5) || major > 22
|
|
16
|
-
export const haveForceExit = (major === 20 && minor > 13) || major >= 22
|
|
17
|
-
export const haveValidTimers = (major === 20 && minor >= 11) || major >= 22 // older glitch in various ways / stop executing
|
|
18
|
-
export const haveNoTimerInfiniteLoopBug = (major === 20 && minor >= 11) || major >= 22 // mock.timers.runAll() can get into infinite recursion
|
|
10
|
+
// actually 22.3, but prior to 22.5 escaping is wrong. We don't support 22.3-22.5 anyway
|
|
11
|
+
export const haveSnapshots = (major === 22 && minor >= 5) || major > 22
|
|
19
12
|
export const haveCoverExclude = (major === 22 && minor >= 5) || major > 22
|
|
20
|
-
export const haveNetworkInspection =
|
|
21
|
-
(major === 20 && minor >= 18) || (major === 22 && minor >= 6) || major > 22
|