@exodus/test 1.0.0-rc.25 → 1.0.0-rc.27
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 +88 -41
- package/package.json +11 -2
- package/src/dark.cjs +7 -3
- package/src/engine.js +5 -2
- package/src/engine.node.cjs +2 -1
- package/src/engine.pure.cjs +342 -0
- package/src/engine.select.cjs +5 -1
- package/src/jest.config.fs.js +54 -0
- package/src/jest.config.js +22 -56
- package/src/jest.environment.js +28 -5
- package/src/jest.js +34 -23
- package/src/jest.mock.js +90 -23
- package/src/jest.snapshot.js +3 -2
- package/src/node.js +10 -0
package/bin/index.js
CHANGED
|
@@ -14,6 +14,14 @@ const bindir = dirname(fileURLToPath(import.meta.url))
|
|
|
14
14
|
const EXTS = `.?([cm])[jt]s?(x)` // we differ from jest, allowing [cm] before everything
|
|
15
15
|
const DEFAULT_PATTERNS = [`**/__tests__/**/*${EXTS}`, `**/?(*.)+(spec|test)${EXTS}`]
|
|
16
16
|
|
|
17
|
+
const ENGINES = new Map(
|
|
18
|
+
Object.entries({
|
|
19
|
+
'node:test': { binary: 'node', pure: false, hasImportLoader: true },
|
|
20
|
+
'node:pure': { binary: 'node', pure: true, hasImportLoader: true },
|
|
21
|
+
'bun:pure': { binary: 'bun', pure: true, hasImportLoader: false },
|
|
22
|
+
})
|
|
23
|
+
)
|
|
24
|
+
|
|
17
25
|
function parseOptions() {
|
|
18
26
|
const options = {
|
|
19
27
|
jest: false,
|
|
@@ -26,8 +34,9 @@ function parseOptions() {
|
|
|
26
34
|
only: false,
|
|
27
35
|
passWithNoTests: false,
|
|
28
36
|
writeSnapshots: false,
|
|
29
|
-
pure: false,
|
|
30
37
|
debug: { files: false },
|
|
38
|
+
ideaCompat: false,
|
|
39
|
+
engine: process.env.EXODUS_TEST_ENGINE ?? 'node:test',
|
|
31
40
|
}
|
|
32
41
|
|
|
33
42
|
const args = [...process.argv]
|
|
@@ -42,6 +51,20 @@ function parseOptions() {
|
|
|
42
51
|
|
|
43
52
|
while (args[0]?.startsWith('--')) {
|
|
44
53
|
const option = args.shift()
|
|
54
|
+
if (options.ideaCompat) {
|
|
55
|
+
// Ignore some options IntelliJ IDEA is passing
|
|
56
|
+
switch (option) {
|
|
57
|
+
case '--reporters':
|
|
58
|
+
args.shift()
|
|
59
|
+
continue
|
|
60
|
+
case '--verbose':
|
|
61
|
+
case '--runTestsByPath':
|
|
62
|
+
case '--runInBand':
|
|
63
|
+
case '--testTimeout=7200000':
|
|
64
|
+
continue
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
45
68
|
switch (option) {
|
|
46
69
|
case '--global': // compat, will be removed in release
|
|
47
70
|
case '--jest':
|
|
@@ -81,12 +104,23 @@ function parseOptions() {
|
|
|
81
104
|
case '--forceExit':
|
|
82
105
|
options.forceExit = true
|
|
83
106
|
break
|
|
84
|
-
case '--
|
|
85
|
-
options.
|
|
107
|
+
case '--engine':
|
|
108
|
+
options.engine = args.shift()
|
|
86
109
|
break
|
|
87
110
|
case '--debug-files':
|
|
88
111
|
options.debug.files = true
|
|
89
112
|
break
|
|
113
|
+
case '--colors':
|
|
114
|
+
process.env.FORCE_COLOR = '1'
|
|
115
|
+
break
|
|
116
|
+
case '--no-colors':
|
|
117
|
+
process.env.FORCE_COLOR = '0'
|
|
118
|
+
process.env.NO_COLOR = '1'
|
|
119
|
+
process.env.NODE_DISABLE_COLORS = '1'
|
|
120
|
+
break
|
|
121
|
+
case '--idea-compat':
|
|
122
|
+
options.ideaCompat = true
|
|
123
|
+
break
|
|
90
124
|
default:
|
|
91
125
|
throw new Error(`Unknown option: ${option}`)
|
|
92
126
|
}
|
|
@@ -104,24 +138,23 @@ function parseOptions() {
|
|
|
104
138
|
|
|
105
139
|
const { options, patterns } = parseOptions()
|
|
106
140
|
|
|
107
|
-
|
|
141
|
+
const engineOptions = ENGINES.get(options.engine)
|
|
142
|
+
assert(engineOptions, `Unknown engine: ${options.engine}`)
|
|
143
|
+
Object.assign(options, engineOptions)
|
|
108
144
|
|
|
109
145
|
const require = createRequire(import.meta.url)
|
|
110
146
|
const resolveRequire = (query) => require.resolve(query)
|
|
111
147
|
const resolveImport = import.meta.resolve && ((query) => fileURLToPath(import.meta.resolve(query)))
|
|
112
148
|
|
|
113
|
-
const c8 = resolveRequire('c8/bin/c8.js')
|
|
114
|
-
if (resolveImport) assert.equal(c8, resolveImport('c8/bin/c8.js'))
|
|
115
|
-
|
|
116
149
|
const args = []
|
|
117
150
|
if (options.pure) {
|
|
118
151
|
const requiresNodeCoverage = options.coverage && options.coverageEngine === 'node'
|
|
119
|
-
assert(!requiresNodeCoverage, '
|
|
120
|
-
assert(!options.writeSnapshots,
|
|
121
|
-
assert(!options.forceExit,
|
|
122
|
-
assert(!options.watch,
|
|
123
|
-
assert(!options.only,
|
|
124
|
-
} else {
|
|
152
|
+
assert(!requiresNodeCoverage, '"--coverage-engine node" requires "--engine node:test" (default)')
|
|
153
|
+
assert(!options.writeSnapshots, `Can not use write snapshots with ${options.engine} engine`)
|
|
154
|
+
assert(!options.forceExit, `Can not use --force-exit with ${options.engine} engine yet`) // TODO
|
|
155
|
+
assert(!options.watch, `Can not use --watch with with ${options.engine} engine`)
|
|
156
|
+
assert(!options.only, `Can not use --only with with ${options.engine} engine yet`) // TODO
|
|
157
|
+
} else if (options.engine === 'node:test') {
|
|
125
158
|
args.push('--test', '--no-warnings=ExperimentalWarning', '--test-reporter=spec')
|
|
126
159
|
|
|
127
160
|
if (haveModuleMocks) args.push('--experimental-test-module-mocks')
|
|
@@ -141,19 +174,8 @@ if (options.pure) {
|
|
|
141
174
|
if (options.only) args.push('--test-only')
|
|
142
175
|
|
|
143
176
|
args.push('--expose-internals') // this is unoptimal and hopefully temporary, see rationale in src/dark.cjs
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
if (options.coverage) {
|
|
147
|
-
if (options.coverageEngine === 'node') {
|
|
148
|
-
args.push('--experimental-test-coverage')
|
|
149
|
-
} else if (options.coverageEngine === 'c8') {
|
|
150
|
-
program = c8
|
|
151
|
-
args.unshift('node')
|
|
152
|
-
// perhaps use text-summary ?
|
|
153
|
-
args.unshift('-r', 'text', '-r', 'html')
|
|
154
|
-
} else {
|
|
155
|
-
throw new Error(`Unknown coverage engine: ${JSON.stringify(options.coverageEngine)}`)
|
|
156
|
-
}
|
|
177
|
+
} else {
|
|
178
|
+
throw new Error('Unreachable')
|
|
157
179
|
}
|
|
158
180
|
|
|
159
181
|
const ignore = ['**/node_modules']
|
|
@@ -169,7 +191,7 @@ if (process.env.EXODUS_TEST_IGNORE) {
|
|
|
169
191
|
if (options.jest) {
|
|
170
192
|
const { loadJestConfig } = await import('../src/jest.config.js')
|
|
171
193
|
const config = await loadJestConfig(process.cwd())
|
|
172
|
-
args.push('--import', resolve(bindir, 'jest.js'))
|
|
194
|
+
args.push(options.hasImportLoader ? '--import' : '-r', resolve(bindir, 'jest.js'))
|
|
173
195
|
|
|
174
196
|
if (config.testFailureExitCode !== undefined) {
|
|
175
197
|
if (Number(config.testFailureExitCode) === 0) {
|
|
@@ -205,7 +227,14 @@ if (options.jest) {
|
|
|
205
227
|
|
|
206
228
|
if (options.esbuild) {
|
|
207
229
|
assert(resolveImport)
|
|
208
|
-
|
|
230
|
+
if (options.hasImportLoader) {
|
|
231
|
+
args.push('--import', resolveImport('tsx'))
|
|
232
|
+
} else if (options.engine === process.env.EXODUS_TEST_ENGINE) {
|
|
233
|
+
console.warn(`Warning: ${options.engine} engine does not support --esbuild option`)
|
|
234
|
+
} else {
|
|
235
|
+
console.error(`Error: ${options.engine} engine does not support --esbuild option`)
|
|
236
|
+
process.exit(1)
|
|
237
|
+
}
|
|
209
238
|
}
|
|
210
239
|
|
|
211
240
|
if (options.babel) {
|
|
@@ -277,35 +306,53 @@ if (tsTests.length > 0 && !options.esbuild) {
|
|
|
277
306
|
}
|
|
278
307
|
|
|
279
308
|
if (!Object.hasOwn(process.env, 'NODE_ENV')) process.env.NODE_ENV = 'test'
|
|
309
|
+
process.env.EXODUS_TEST_PLATFORM = options.binary
|
|
310
|
+
|
|
311
|
+
const c8 = resolveRequire('c8/bin/c8.js')
|
|
312
|
+
if (resolveImport) assert.equal(c8, resolveImport('c8/bin/c8.js'))
|
|
313
|
+
|
|
314
|
+
if (options.coverage) {
|
|
315
|
+
assert.equal(options.binary, 'node', 'Coverage is only supported with Node.js')
|
|
316
|
+
if (options.coverageEngine === 'node') {
|
|
317
|
+
args.push('--experimental-test-coverage')
|
|
318
|
+
} else if (options.coverageEngine === 'c8') {
|
|
319
|
+
args.unshift(options.binary)
|
|
320
|
+
options.binary = c8
|
|
321
|
+
// perhaps use text-summary ?
|
|
322
|
+
args.unshift('-r', 'text', '-r', 'html')
|
|
323
|
+
} else {
|
|
324
|
+
throw new Error(`Unknown coverage engine: ${JSON.stringify(options.coverageEngine)}`)
|
|
325
|
+
}
|
|
326
|
+
}
|
|
280
327
|
|
|
281
328
|
assert(files.length > 0) // otherwise we can run recursively
|
|
282
|
-
assert(
|
|
329
|
+
assert(options.binary && ['node', 'bun', c8].includes(options.binary))
|
|
330
|
+
process.env.EXODUS_TEST_EXECARGV = JSON.stringify(args)
|
|
283
331
|
|
|
284
332
|
if (options.pure) {
|
|
285
333
|
process.env.EXODUS_TEST_CONTEXT = 'pure'
|
|
286
|
-
console.warn(
|
|
287
|
-
'--pure mode is experimental and may not work an expected / might be removed at any time'
|
|
288
|
-
)
|
|
334
|
+
console.warn(`\n${options.engine} engine is experimental and may not work an expected\n\n`)
|
|
289
335
|
const failures = []
|
|
290
336
|
for (const file of files) {
|
|
291
|
-
const node = spawn(
|
|
337
|
+
const node = spawn(options.binary, [...args, file], { stdio: 'inherit' })
|
|
292
338
|
const [code] = await once(node, 'close')
|
|
293
339
|
if (code !== 0) failures.push(file)
|
|
294
340
|
}
|
|
295
341
|
|
|
296
|
-
if (failures.length > 0) process.exitCode = 1
|
|
297
|
-
console.log(
|
|
298
|
-
failures === 0
|
|
299
|
-
? `All ${files.length} test suites passed`
|
|
300
|
-
: `Test suites failed: ${failures.length} / ${files.length}`
|
|
301
|
-
)
|
|
302
342
|
if (failures.length > 0) {
|
|
343
|
+
process.exitCode = 1
|
|
344
|
+
const [total, passed, failed] = [files.length, files.length - failures.length, failures.length]
|
|
345
|
+
console.log(`Test suites failed: ${failed} / ${total} (passed: ${passed} / ${total})`)
|
|
303
346
|
console.log('Failed test suites:')
|
|
304
347
|
for (const file of failures) console.log(` ${file}`) // joining with \n can get truncated, too big
|
|
348
|
+
} else {
|
|
349
|
+
console.log(`All ${files.length} test suites passed`)
|
|
305
350
|
}
|
|
306
351
|
} else {
|
|
307
|
-
|
|
308
|
-
|
|
352
|
+
assert(['node', c8].includes(options.binary), `Unexpected native engine: ${options.binary}`)
|
|
353
|
+
assert(['node:test'].includes(options.engine))
|
|
354
|
+
process.env.EXODUS_TEST_CONTEXT = options.engine
|
|
355
|
+
const node = spawn(options.binary, [...args, ...files], { stdio: 'inherit' })
|
|
309
356
|
const [code] = await once(node, 'close')
|
|
310
357
|
process.exitCode = code
|
|
311
358
|
}
|
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.27",
|
|
4
4
|
"author": "Exodus Movement, Inc.",
|
|
5
5
|
"description": "A test suite runner",
|
|
6
6
|
"homepage": "https://github.com/ExodusMovement/test",
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
},
|
|
31
31
|
"exports": {
|
|
32
32
|
"./jest": "./src/jest.js",
|
|
33
|
+
"./node": "./src/node.js",
|
|
33
34
|
"./tape": "./src/tape.js"
|
|
34
35
|
},
|
|
35
36
|
"prettier": "@exodus/prettier",
|
|
@@ -39,14 +40,17 @@
|
|
|
39
40
|
"src/dark.cjs",
|
|
40
41
|
"src/engine.js",
|
|
41
42
|
"src/engine.node.cjs",
|
|
43
|
+
"src/engine.pure.cjs",
|
|
42
44
|
"src/engine.select.cjs",
|
|
43
45
|
"src/jest.js",
|
|
44
46
|
"src/jest.config.js",
|
|
47
|
+
"src/jest.config.fs.js",
|
|
45
48
|
"src/jest.environment.js",
|
|
46
49
|
"src/jest.fn.js",
|
|
47
50
|
"src/jest.mock.js",
|
|
48
51
|
"src/jest.snapshot.js",
|
|
49
52
|
"src/jest.timers.js",
|
|
53
|
+
"src/node.js",
|
|
50
54
|
"src/tape.js",
|
|
51
55
|
"src/version.js",
|
|
52
56
|
"!__tests__",
|
|
@@ -54,9 +58,12 @@
|
|
|
54
58
|
],
|
|
55
59
|
"scripts": {
|
|
56
60
|
"test": "npm run test:jest",
|
|
57
|
-
"test:all": "npm run test:jest && npm run test:tape",
|
|
61
|
+
"test:all": "npm run test:jest && npm run test:tape && npm run test:native",
|
|
62
|
+
"test:native": "EXODUS_TEST_IGNORE='{**/typescript/**,**/jest-repo/**/user.test.js}' ./bin/index.js --jest '__test__/**/*.test.{js,cjs,mjs}'",
|
|
58
63
|
"test:jest": "./bin/index.js --jest --esbuild",
|
|
59
64
|
"test:tape": "./bin/index.js --esbuild '__test__/tape/test/*.js' __test__/tape.test.js",
|
|
65
|
+
"test:pure": "EXODUS_TEST_ENGINE=node:pure npm run test",
|
|
66
|
+
"test:bun:pure": "EXODUS_TEST_ENGINE=bun:pure npm run test",
|
|
60
67
|
"coverage": "./bin/index.js --jest --esbuild --coverage",
|
|
61
68
|
"lint": "prettier --list-different . && eslint .",
|
|
62
69
|
"lint:fix": "prettier --write . && eslint --fix ."
|
|
@@ -64,9 +71,11 @@
|
|
|
64
71
|
"dependencies": {
|
|
65
72
|
"@babel/register": "^7.0.0",
|
|
66
73
|
"c8": "^9.1.0",
|
|
74
|
+
"esbuild": "~0.21.5",
|
|
67
75
|
"expect": "^29.7.0",
|
|
68
76
|
"fast-glob": "^3.2.11",
|
|
69
77
|
"jest-extended": "^4.0.2",
|
|
78
|
+
"jsdom": "^24.1.0",
|
|
70
79
|
"pretty-format": "^29.7.0",
|
|
71
80
|
"tsx": "^4.16.2"
|
|
72
81
|
},
|
package/src/dark.cjs
CHANGED
|
@@ -105,9 +105,13 @@ function getTestNamePath(t) {
|
|
|
105
105
|
return [t.name] // last resort
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
const execArgv = process.env.EXODUS_TEST_EXECARGV
|
|
109
|
+
? JSON.parse(process.env.EXODUS_TEST_EXECARGV)
|
|
110
|
+
: process.execArgv
|
|
111
|
+
const insideEsbuild = execArgv.some((x) => x.endsWith('node_modules/tsx/dist/loader.mjs'))
|
|
112
|
+
|
|
108
113
|
function makeEsbuildMockable() {
|
|
109
|
-
|
|
110
|
-
if (!usingTsx) return
|
|
114
|
+
if (!insideEsbuild) return
|
|
111
115
|
// Hook into tsx/esbuild transpiled module conversion magic to make loaded modules mockable in runtime
|
|
112
116
|
// We want all modules to be .configurable = true, so we can override them
|
|
113
117
|
const defineProperty = Object.defineProperty
|
|
@@ -138,4 +142,4 @@ function makeEsbuildMockable() {
|
|
|
138
142
|
}
|
|
139
143
|
}
|
|
140
144
|
|
|
141
|
-
module.exports = { createCallerLocationHook, getTestNamePath, makeEsbuildMockable }
|
|
145
|
+
module.exports = { createCallerLocationHook, getTestNamePath, insideEsbuild, makeEsbuildMockable }
|
package/src/engine.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import engine from './engine.select.cjs' // need to be sync for non-preloaded imports into cjs
|
|
2
2
|
|
|
3
|
+
const { engine: name } = engine
|
|
4
|
+
export { name as engine }
|
|
5
|
+
|
|
3
6
|
const { assert, assertLoose } = engine
|
|
4
7
|
export { assert, assertLoose }
|
|
5
8
|
|
|
@@ -15,5 +18,5 @@ export { utilFormat, isPromise, nodeVersion }
|
|
|
15
18
|
const { baseFile, relativeRequire, isTopLevelESM } = engine
|
|
16
19
|
export { baseFile, relativeRequire, isTopLevelESM }
|
|
17
20
|
|
|
18
|
-
const {
|
|
19
|
-
export {
|
|
21
|
+
const { readSnapshot, setSnapshotSerializers, setSnapshotResolver } = engine
|
|
22
|
+
export { readSnapshot, setSnapshotSerializers, setSnapshotResolver }
|
package/src/engine.node.cjs
CHANGED
|
@@ -28,11 +28,12 @@ const setSnapshotResolver = (fn) => {
|
|
|
28
28
|
|
|
29
29
|
/* eslint-disable unicorn/no-useless-spread */
|
|
30
30
|
module.exports = {
|
|
31
|
+
engine: 'node:test',
|
|
31
32
|
...{ assert, assertLoose },
|
|
32
33
|
...{ mock, describe, test, beforeEach, afterEach, before, after },
|
|
33
34
|
...{ builtinModules, syncBuiltinESMExports },
|
|
34
35
|
...{ utilFormat, isPromise, nodeVersion },
|
|
35
36
|
...{ baseFile, relativeRequire, isTopLevelESM },
|
|
36
|
-
...{
|
|
37
|
+
...{ readSnapshot, setSnapshotSerializers, setSnapshotResolver },
|
|
37
38
|
}
|
|
38
39
|
/* eslint-enable unicorn/no-useless-spread */
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
const assert = require('node:assert/strict')
|
|
2
|
+
const assertLoose = require('node:assert')
|
|
3
|
+
const { existsSync, readFileSync } = require('node:fs')
|
|
4
|
+
const { normalize, basename, dirname, join: pathJoin } = require('node:path')
|
|
5
|
+
const { format: utilFormat } = require('node:util')
|
|
6
|
+
const {
|
|
7
|
+
createRequire,
|
|
8
|
+
builtinModules,
|
|
9
|
+
syncBuiltinESMExports,
|
|
10
|
+
syncBuiltinExports, // bun has it under a different name (also a no-op and always synced atm)
|
|
11
|
+
} = require('node:module')
|
|
12
|
+
|
|
13
|
+
const { setTimeout, setInterval, setImmediate, Date } = globalThis
|
|
14
|
+
const { clearTimeout, clearInterval, clearImmediate } = globalThis
|
|
15
|
+
|
|
16
|
+
Error.stackTraceLimit = 100
|
|
17
|
+
|
|
18
|
+
let context
|
|
19
|
+
let running
|
|
20
|
+
let willstart
|
|
21
|
+
|
|
22
|
+
function parseArgs(args) {
|
|
23
|
+
assert(args.length <= 3)
|
|
24
|
+
const name = typeof args[0] === 'string' ? args.shift() : 'test'
|
|
25
|
+
const fn = args.pop()
|
|
26
|
+
const options = args.pop() || {}
|
|
27
|
+
return { name, options, fn }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function enterContext(name, options = {}) {
|
|
31
|
+
assert(!running)
|
|
32
|
+
if (willstart) clearTimeout(willstart) // have to he accurate for engines like Hermes
|
|
33
|
+
context = {
|
|
34
|
+
root: context?.root,
|
|
35
|
+
parent: context,
|
|
36
|
+
name,
|
|
37
|
+
options,
|
|
38
|
+
fullName: context && context !== context.root ? `${context.fullName} > ${name}` : name,
|
|
39
|
+
assert: { ...assertLoose, snapshot: undefined },
|
|
40
|
+
hooks: { __proto__: null, before: [], after: [], beforeEach: [], afterEach: [] },
|
|
41
|
+
test, // todo: bind to context
|
|
42
|
+
describe, // todo: bind to context
|
|
43
|
+
children: [],
|
|
44
|
+
}
|
|
45
|
+
if (context.root) {
|
|
46
|
+
context.parent.children.push(context)
|
|
47
|
+
} else {
|
|
48
|
+
assert((context.name = '<root>'))
|
|
49
|
+
assert(!context.parent)
|
|
50
|
+
context.root = context
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function exitContext() {
|
|
55
|
+
assert(context !== context.root)
|
|
56
|
+
context = context.parent
|
|
57
|
+
if (context === context.root) willstart = setTimeout(run, 0)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function runFunction(fn, context) {
|
|
61
|
+
if (fn.length < 2) return fn(context)
|
|
62
|
+
return new Promise((resolve, reject) => fn(context, (err) => (err ? reject(err) : resolve())))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function runContext(context) {
|
|
66
|
+
const { options, children, hooks, fn } = context
|
|
67
|
+
assert(!context.running, 'Can not run twice')
|
|
68
|
+
// eslint-disable-next-line @exodus/mutable/no-param-reassign-prop-only
|
|
69
|
+
context.running = true
|
|
70
|
+
assert(children.length === 0 || !fn)
|
|
71
|
+
if (options.skip) return console.log('⏭ SKIP', context.fullName)
|
|
72
|
+
if (context.fn) {
|
|
73
|
+
let error
|
|
74
|
+
const stack = [context]
|
|
75
|
+
while (stack[0].parent) stack.unshift(stack[0].parent)
|
|
76
|
+
|
|
77
|
+
// TODO: try/catch for hooks?
|
|
78
|
+
for (const c of stack) for (const hook of c.hooks.beforeEach) await runFunction(hook, context)
|
|
79
|
+
try {
|
|
80
|
+
await runFunction(fn, context)
|
|
81
|
+
} catch (e) {
|
|
82
|
+
error = e ?? 'Unknown error'
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
stack.reverse()
|
|
86
|
+
for (const c of stack) for (const hook of c.hooks.afterEach) await runFunction(hook, context)
|
|
87
|
+
|
|
88
|
+
console.log(error === undefined ? '✔ PASS' : '✖ FAIL', context.fullName)
|
|
89
|
+
if (error) {
|
|
90
|
+
console.log(' ', error)
|
|
91
|
+
if (globalThis.process) globalThis.process.exitCode = 1
|
|
92
|
+
}
|
|
93
|
+
} else {
|
|
94
|
+
// if (context !== context.root) console.log(`▶ ${context.fullName}`)
|
|
95
|
+
// TODO: try/catch for hooks?
|
|
96
|
+
// TODO: flatten recursion before running?
|
|
97
|
+
for (const hook of hooks.before) await runFunction(hook, context)
|
|
98
|
+
for (const child of children) await runContext(child)
|
|
99
|
+
for (const hook of hooks.after) await runFunction(hook, context)
|
|
100
|
+
// if (context !== context.root) console.log(`▶ ${context.fullName}`)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function run() {
|
|
105
|
+
assert(!running)
|
|
106
|
+
running = true
|
|
107
|
+
assert(context === context.root)
|
|
108
|
+
runContext(context).catch((error) => {
|
|
109
|
+
// Should not throw under regular circumstances
|
|
110
|
+
console.log('Fatal: ', error)
|
|
111
|
+
if (globalThis.process) globalThis.process.exitCode = 1
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function describe(...args) {
|
|
116
|
+
const { name, options, fn } = parseArgs(args)
|
|
117
|
+
enterContext(name, options)
|
|
118
|
+
context.options = options
|
|
119
|
+
if (!options.skip) fn(context) // todo: callback
|
|
120
|
+
exitContext()
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
describe.skip = (...args) => {
|
|
124
|
+
const { name, options, fn } = parseArgs(args)
|
|
125
|
+
return describe(name, { ...options, skip: true }, fn)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function test(...args) {
|
|
129
|
+
const { name, options, fn } = parseArgs(args)
|
|
130
|
+
enterContext(name, options)
|
|
131
|
+
context.fn = fn
|
|
132
|
+
exitContext()
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
test.skip = (...args) => {
|
|
136
|
+
const { name, options, fn } = parseArgs(args)
|
|
137
|
+
return test(name, { ...options, skip: true }, fn)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
class MockTimers {
|
|
141
|
+
#enabled = false
|
|
142
|
+
#base = 0
|
|
143
|
+
#elapsed = 0
|
|
144
|
+
#queue = []
|
|
145
|
+
enable({ now = 0, apis = ['setInterval', 'setTimeout', 'setImmediate', 'Date'] } = {}) {
|
|
146
|
+
assert(!this.#enabled, 'MockTimers is already enabled!')
|
|
147
|
+
this.#base = +now
|
|
148
|
+
this.#elapsed = 0
|
|
149
|
+
if (apis.includes('setInterval')) {
|
|
150
|
+
globalThis.setInterval = this.#setInterval.bind(this)
|
|
151
|
+
globalThis.clearInterval = this.#clearInterval.bind(this)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (apis.includes('setTimeout')) {
|
|
155
|
+
globalThis.setTimeout = this.#setTimeout.bind(this)
|
|
156
|
+
globalThis.clearTimeout = this.#clearTimeout.bind(this)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (apis.includes('setImmediate')) {
|
|
160
|
+
globalThis.setImmediate = this.#setImmediate.bind(this)
|
|
161
|
+
globalThis.clearImmediate = this.#clearImmediate.bind(this)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const OrigDate = Date
|
|
165
|
+
if (apis.includes('Date')) {
|
|
166
|
+
const now = () => this.#base + this.#elapsed
|
|
167
|
+
globalThis.Date = class Date extends OrigDate {
|
|
168
|
+
static now = () => now()
|
|
169
|
+
constructor(first = globalThis.Date.now(), ...rest) {
|
|
170
|
+
super(first, ...rest)
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
reset() {
|
|
177
|
+
this.#enabled = false
|
|
178
|
+
Object.assign(globalThis, { setTimeout, setInterval, setImmediate, Date })
|
|
179
|
+
Object.assign(globalThis, { clearTimeout, clearInterval, clearImmediate })
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
[Symbol.dispose]() {
|
|
183
|
+
this.reset()
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
tick(milliseconds = 1) {
|
|
187
|
+
this.#elapsed += milliseconds
|
|
188
|
+
while (true) {
|
|
189
|
+
const next =
|
|
190
|
+
this.#queue.find((x) => x.type === 'immediate') ||
|
|
191
|
+
this.#queue.find((x) => x.at <= this.#elapsed)
|
|
192
|
+
if (!next) break
|
|
193
|
+
if (next.type === 'interval') {
|
|
194
|
+
next.at += next.interval
|
|
195
|
+
} else {
|
|
196
|
+
this.#queue = this.#queue.filter((x) => x !== next)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
next.fn(...next.args)
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
runAll() {
|
|
204
|
+
this.tick(Math.max(0, ...this.#queue.map((x) => x.at)))
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
setTime(milliseconds) {
|
|
208
|
+
this.#base = milliseconds
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
#setTimeout(fn, delay, ...args) {
|
|
212
|
+
this.#queue.push({ type: 'timeout', fn, at: delay + this.#elapsed, args })
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
#setInterval(fn, delay, ...args) {
|
|
216
|
+
this.#queue.push({ type: 'interval', fn, at: delay + this.#elapsed, interval: delay, args })
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
#setImmediate(fn, ...args) {
|
|
220
|
+
this.#queue.push({ type: 'immediate', fn, args })
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
#clearTimeout(id) {
|
|
224
|
+
this.#queue = this.#queue.filter((x) => x !== id)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
#clearInterval(id) {
|
|
228
|
+
this.#clearTimeout(id)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
#clearImmediate(id) {
|
|
232
|
+
this.#clearTimeout(id)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
class MockedFunction extends Function {
|
|
237
|
+
get mock() {
|
|
238
|
+
return this._mock
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const mock = {
|
|
243
|
+
module: undefined,
|
|
244
|
+
timers: new MockTimers(),
|
|
245
|
+
fn: (original = () => {}, implementation = original) => {
|
|
246
|
+
let impl = implementation
|
|
247
|
+
const mocked = function (...args) {
|
|
248
|
+
const call = {
|
|
249
|
+
arguments: args,
|
|
250
|
+
// eslint-disable-next-line unicorn/error-message
|
|
251
|
+
stack: new Error(), // todo: recheck location
|
|
252
|
+
target: undefined,
|
|
253
|
+
this: this,
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
mocked.mock.calls.push(call)
|
|
257
|
+
|
|
258
|
+
try {
|
|
259
|
+
// todo: what's if it a promise
|
|
260
|
+
if (this instanceof mocked) {
|
|
261
|
+
impl.apply(this, args)
|
|
262
|
+
call.result = call.target = this
|
|
263
|
+
} else {
|
|
264
|
+
call.result = impl.apply(this, args)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
call.error = undefined
|
|
268
|
+
} catch (err) {
|
|
269
|
+
call.result = undefined
|
|
270
|
+
call.error = err
|
|
271
|
+
throw err
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return call.result
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
Object.setPrototypeOf(mocked, MockedFunction.prototype)
|
|
278
|
+
|
|
279
|
+
mocked._mock = {
|
|
280
|
+
calls: [],
|
|
281
|
+
get callCount() {
|
|
282
|
+
return this.calls.length
|
|
283
|
+
},
|
|
284
|
+
mockImplementation: (fn) => {
|
|
285
|
+
impl = fn
|
|
286
|
+
},
|
|
287
|
+
mockImplementationOnce: (fn) => {
|
|
288
|
+
const prev = impl
|
|
289
|
+
impl = (...args) => {
|
|
290
|
+
impl = prev
|
|
291
|
+
return fn.apply(this, args)
|
|
292
|
+
}
|
|
293
|
+
},
|
|
294
|
+
resetCalls: () => {
|
|
295
|
+
mocked._mock.calls.length = 0
|
|
296
|
+
},
|
|
297
|
+
restore: () => {
|
|
298
|
+
impl = original
|
|
299
|
+
},
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return mocked
|
|
303
|
+
},
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const beforeEach = (fn) => context.hooks.beforeEach.push(fn)
|
|
307
|
+
const afterEach = (fn) => context.hooks.afterEach.push(fn)
|
|
308
|
+
const before = (fn) => context.hooks.before.push(fn)
|
|
309
|
+
const after = (fn) => context.hooks.after.push(fn)
|
|
310
|
+
|
|
311
|
+
const isPromise = (x) => Boolean(x && x.then && x.catch && x.finally)
|
|
312
|
+
const nodeVersion = '9999.99.99'
|
|
313
|
+
|
|
314
|
+
const files = process.argv.slice(1)
|
|
315
|
+
const baseFile = files.length === 1 && existsSync(files[0]) ? normalize(files[0]) : undefined
|
|
316
|
+
const relativeRequire = baseFile ? createRequire(baseFile) : require
|
|
317
|
+
const isTopLevelESM = () =>
|
|
318
|
+
!baseFile || // assume ESM otherwise
|
|
319
|
+
!Object.hasOwn(relativeRequire.cache, baseFile) || // node esm
|
|
320
|
+
relativeRequire.cache[baseFile].exports[Symbol.toStringTag] === 'Module' // bun esm
|
|
321
|
+
|
|
322
|
+
let snapshotResolver = (dir, name) => [dir, `${name}.snapshot`] // default per Node.js docs
|
|
323
|
+
const resolveSnapshot = (f) => pathJoin(...snapshotResolver(dirname(f), basename(f)))
|
|
324
|
+
const readSnapshot = (f = baseFile) => (f ? readFileSync(resolveSnapshot(f), 'utf8') : null)
|
|
325
|
+
const setSnapshotSerializers = () => {}
|
|
326
|
+
const setSnapshotResolver = (fn) => {
|
|
327
|
+
snapshotResolver = fn
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
enterContext('<root>')
|
|
331
|
+
|
|
332
|
+
/* eslint-disable unicorn/no-useless-spread */
|
|
333
|
+
module.exports = {
|
|
334
|
+
engine: 'pure',
|
|
335
|
+
...{ assert, assertLoose },
|
|
336
|
+
...{ mock, describe, test, beforeEach, afterEach, before, after },
|
|
337
|
+
...{ builtinModules, syncBuiltinESMExports: syncBuiltinESMExports || syncBuiltinExports },
|
|
338
|
+
...{ utilFormat, isPromise, nodeVersion },
|
|
339
|
+
...{ baseFile, relativeRequire, isTopLevelESM },
|
|
340
|
+
...{ readSnapshot, setSnapshotSerializers, setSnapshotResolver },
|
|
341
|
+
}
|
|
342
|
+
/* eslint-enable unicorn/no-useless-spread */
|
package/src/engine.select.cjs
CHANGED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { readFile } from 'node:fs/promises'
|
|
3
|
+
import { existsSync } from 'node:fs'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
|
|
6
|
+
async function getJestConfig(dir) {
|
|
7
|
+
if (!dir) return
|
|
8
|
+
|
|
9
|
+
const configPath = (ext) => path.resolve(dir, `jest.config.${ext}`)
|
|
10
|
+
|
|
11
|
+
assert(!existsSync(configPath('ts')), 'jest.config.ts is not supported yet with .ts extension')
|
|
12
|
+
|
|
13
|
+
const configs = []
|
|
14
|
+
for (const type of ['js', 'ts', 'mjs', 'cjs', 'json']) {
|
|
15
|
+
try {
|
|
16
|
+
if (type === 'json') {
|
|
17
|
+
configs.push(JSON.parse(await readFile(configPath('json'), 'utf8')))
|
|
18
|
+
} else {
|
|
19
|
+
const { default: config } = await import(configPath(type))
|
|
20
|
+
configs.push(config)
|
|
21
|
+
}
|
|
22
|
+
} catch (e) {
|
|
23
|
+
if (!['ERR_MODULE_NOT_FOUND', 'ENOENT'].includes(e.code)) throw e
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
const pkg = JSON.parse(await readFile(path.resolve(dir, 'package.json'), 'utf8'))
|
|
29
|
+
assert(typeof pkg.jest !== 'string', 'String package.json["jest"] values are not supported yet')
|
|
30
|
+
if (pkg.jest) configs.push(pkg.jest)
|
|
31
|
+
} catch (e) {
|
|
32
|
+
if (e.code !== 'ENOENT') throw e
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
assert(configs.length < 2, `Multiple jest configs found in ${dir} dir, use only a single one`)
|
|
36
|
+
|
|
37
|
+
if (configs.length > 0) {
|
|
38
|
+
const conf = { ...configs[0] }
|
|
39
|
+
if (conf.rootDir && ['.', './'].includes(conf.rootDir)) {
|
|
40
|
+
assert.equal(path.resolve(dir, conf.rootDir), dir, 'Jest config.rootDir is not supported yet')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
conf.rootDir = dir
|
|
44
|
+
return conf
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const parent = path.dirname(dir)
|
|
48
|
+
return parent === dir ? undefined : getJestConfig(parent)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const files = process.argv.slice(1)
|
|
52
|
+
const baseDir = files.length === 1 ? path.dirname(path.resolve(files[0])) : undefined
|
|
53
|
+
|
|
54
|
+
export const readJestConfig = async (dir = baseDir) => getJestConfig(dir)
|
package/src/jest.config.js
CHANGED
|
@@ -1,56 +1,9 @@
|
|
|
1
1
|
// Not using ./engine.js yet, might pass / embed already loaded config instead
|
|
2
2
|
import assert from 'node:assert/strict'
|
|
3
|
-
import { readFile } from 'node:fs/promises'
|
|
4
|
-
import { existsSync } from 'node:fs'
|
|
5
3
|
import path from 'node:path'
|
|
6
4
|
import { createRequire } from 'node:module'
|
|
7
5
|
import { specialEnvironments } from './jest.environment.js'
|
|
8
6
|
|
|
9
|
-
const files = process.argv.slice(1)
|
|
10
|
-
const baseDir = files.length === 1 ? path.dirname(path.resolve(files[0])) : undefined
|
|
11
|
-
|
|
12
|
-
async function getJestConfig(dir) {
|
|
13
|
-
if (!dir) return
|
|
14
|
-
|
|
15
|
-
const configPath = (ext) => path.resolve(dir, `jest.config.${ext}`)
|
|
16
|
-
|
|
17
|
-
assert(!existsSync(configPath('ts')), 'jest.config.ts is not supported yet with .ts extension')
|
|
18
|
-
|
|
19
|
-
const configs = []
|
|
20
|
-
for (const type of ['js', 'ts', 'mjs', 'cjs', 'json']) {
|
|
21
|
-
try {
|
|
22
|
-
if (type === 'json') {
|
|
23
|
-
configs.push(JSON.parse(await readFile(configPath('json'), 'utf8')))
|
|
24
|
-
} else {
|
|
25
|
-
const { default: config } = await import(configPath(type))
|
|
26
|
-
configs.push(config)
|
|
27
|
-
}
|
|
28
|
-
} catch (e) {
|
|
29
|
-
if (!['ERR_MODULE_NOT_FOUND', 'ENOENT'].includes(e.code)) throw e
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
try {
|
|
34
|
-
const pkg = JSON.parse(await readFile(path.resolve(dir, 'package.json'), 'utf8'))
|
|
35
|
-
assert(typeof pkg.jest !== 'string', 'String package.json["jest"] values are not supported yet')
|
|
36
|
-
if (pkg.jest) configs.push(pkg.jest)
|
|
37
|
-
} catch (e) {
|
|
38
|
-
if (e.code !== 'ENOENT') throw e
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
assert(configs.length < 2, `Multiple jest configs found in ${dir} dir, use only a single one`)
|
|
42
|
-
|
|
43
|
-
if (configs.length > 0) {
|
|
44
|
-
const conf = { ...configs[0] }
|
|
45
|
-
assert(!conf.rootDir, 'Jest config.rootDir is not supported yet')
|
|
46
|
-
conf.rootDir = dir
|
|
47
|
-
return conf
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
const parent = path.dirname(dir)
|
|
51
|
-
return parent === dir ? undefined : getJestConfig(parent)
|
|
52
|
-
}
|
|
53
|
-
|
|
54
7
|
const normalizeJestConfig = (config) => ({
|
|
55
8
|
testEnvironment: 'node',
|
|
56
9
|
testTimeout: 5000,
|
|
@@ -78,8 +31,7 @@ const normalizeJestConfig = (config) => ({
|
|
|
78
31
|
function verifyJestConfig(c) {
|
|
79
32
|
assert(!configUsed, 'Can not apply new config as the current one was already used')
|
|
80
33
|
|
|
81
|
-
|
|
82
|
-
if (!Object.hasOwn(specialEnvironments, c.testEnvironment) && !nodeEnvs.has(c.testEnvironment)) {
|
|
34
|
+
if (!Object.hasOwn(specialEnvironments, c.testEnvironment)) {
|
|
83
35
|
assert.equal(c.testEnvironment, 'node', 'Only "node" testEnvironment is supported')
|
|
84
36
|
}
|
|
85
37
|
|
|
@@ -87,12 +39,14 @@ function verifyJestConfig(c) {
|
|
|
87
39
|
assert.deepEqual(environmentOptions, {}, 'Jest config.testEnvironmentOptions is not supported')
|
|
88
40
|
|
|
89
41
|
assert(!c.automock, 'Automocking all modules is not currently supported (config.automock)')
|
|
42
|
+
assert(!c.moduleNameMapper, 'Jest config.moduleNameMapper is not supported')
|
|
90
43
|
if (c.moduleDirectories) {
|
|
91
44
|
const valid = ['node_modules']
|
|
92
45
|
assert.deepEqual(c.moduleDirectories, valid, 'Jest config.moduleDirectories is not supported')
|
|
93
46
|
}
|
|
94
47
|
|
|
95
|
-
|
|
48
|
+
const pre = new Set(['ts-jest'])
|
|
49
|
+
assert(!c.preset || pre.has(c.preset.split('/')[0]), 'Jest config.preset is not supported')
|
|
96
50
|
|
|
97
51
|
// TODO
|
|
98
52
|
const TODO = ['globalSetup', 'globalTeardown', 'randomize', 'projects', 'roots']
|
|
@@ -110,14 +64,25 @@ export const jestConfig = () => {
|
|
|
110
64
|
|
|
111
65
|
// Methods loadJestConfig() and installJestEnvironment() below are for --jest flag
|
|
112
66
|
|
|
113
|
-
export async function loadJestConfig(
|
|
114
|
-
|
|
67
|
+
export async function loadJestConfig(...args) {
|
|
68
|
+
let rawConfig
|
|
69
|
+
if (process.env.EXODUS_TEST_JEST_CONFIG) {
|
|
70
|
+
rawConfig = JSON.parse(process.env.EXODUS_TEST_JEST_CONFIG)
|
|
71
|
+
} else {
|
|
72
|
+
const { readJestConfig } = await import('./jest.config.fs.js')
|
|
73
|
+
rawConfig = await readJestConfig(...args)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
config = normalizeJestConfig(rawConfig)
|
|
115
77
|
verifyJestConfig(config)
|
|
116
78
|
return config
|
|
117
79
|
}
|
|
118
80
|
|
|
119
81
|
export async function installJestEnvironment(jestGlobals) {
|
|
120
|
-
const
|
|
82
|
+
const engine = await import('./engine.js')
|
|
83
|
+
|
|
84
|
+
const { beforeEach } = engine
|
|
85
|
+
const { jest } = jestGlobals
|
|
121
86
|
const c = config
|
|
122
87
|
|
|
123
88
|
Error.stackTraceLimit = 100
|
|
@@ -135,11 +100,12 @@ export async function installJestEnvironment(jestGlobals) {
|
|
|
135
100
|
: () => assert.fail('Unreachable: requiring plugins without a rootDir')
|
|
136
101
|
|
|
137
102
|
if (Object.hasOwn(specialEnvironments, c.testEnvironment)) {
|
|
138
|
-
specialEnvironments[c.testEnvironment](require, jestGlobals, c.testEnvironmentOptions)
|
|
103
|
+
specialEnvironments[c.testEnvironment](require, engine, jestGlobals, c.testEnvironmentOptions)
|
|
139
104
|
}
|
|
140
105
|
|
|
141
|
-
|
|
142
|
-
for (const file of c.
|
|
106
|
+
// require is already relative to rootDir
|
|
107
|
+
for (const file of c.setupFiles || []) require(file.replace(/^<rootDir>\//g, './'))
|
|
108
|
+
for (const file of c.setupFilesAfterEnv || []) require(file.replace(/^<rootDir>\//g, './'))
|
|
143
109
|
|
|
144
110
|
// @jest/globals import auto-mocking is disabled until https://github.com/nodejs/node/issues/53807 is resolved
|
|
145
111
|
/*
|
package/src/jest.environment.js
CHANGED
|
@@ -3,8 +3,31 @@ import { getTestNamePath } from './dark.cjs'
|
|
|
3
3
|
export const specialEnvironments = {
|
|
4
4
|
__proto__: null,
|
|
5
5
|
|
|
6
|
+
jsdom: (require) => {
|
|
7
|
+
const { JSDOM, VirtualConsole } = require('jsdom')
|
|
8
|
+
const virtualConsole = new VirtualConsole()
|
|
9
|
+
const { window } = new JSDOM('<!DOCTYPE html>', {
|
|
10
|
+
url: 'http://localhost/',
|
|
11
|
+
pretendToBeVisual: true,
|
|
12
|
+
runScripts: 'dangerously',
|
|
13
|
+
virtualConsole,
|
|
14
|
+
})
|
|
15
|
+
virtualConsole.sendTo(console, { omitJSDOMErrors: true })
|
|
16
|
+
virtualConsole.on('jsdomError', (error) => {
|
|
17
|
+
throw error
|
|
18
|
+
})
|
|
19
|
+
const assignMissing = (target, source) => {
|
|
20
|
+
const entries = Object.entries(source).filter(([key]) => !Object.hasOwn(target, key))
|
|
21
|
+
Object.assign(target, Object.fromEntries(entries))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
assignMissing(globalThis, window)
|
|
25
|
+
assignMissing(console, window.console)
|
|
26
|
+
Object.setPrototypeOf(global, Object.getPrototypeOf(window))
|
|
27
|
+
},
|
|
28
|
+
|
|
6
29
|
// Reproduces setup-polly-jest/jest-environment-node ad hacks into 'setup-polly-jest'.pollyJest
|
|
7
|
-
'setup-polly-jest/jest-environment-node': (require,
|
|
30
|
+
'setup-polly-jest/jest-environment-node': (require, engine) => {
|
|
8
31
|
const { Polly } = require('@pollyjs/core')
|
|
9
32
|
const pollyJest = require('setup-polly-jest')
|
|
10
33
|
const { JestPollyGlobals, createPollyContextAccessor } = require('setup-polly-jest/lib/common')
|
|
@@ -13,12 +36,12 @@ export const specialEnvironments = {
|
|
|
13
36
|
pollyJest.setupPolly = (options) => {
|
|
14
37
|
if (!pollyGlobals.isJestPollyEnvironment) return
|
|
15
38
|
|
|
16
|
-
|
|
39
|
+
engine.before(() => {
|
|
17
40
|
pollyGlobals.isPollyActive = true
|
|
18
41
|
pollyGlobals.pollyContext.options = options
|
|
19
42
|
})
|
|
20
43
|
|
|
21
|
-
|
|
44
|
+
engine.after(() => {
|
|
22
45
|
pollyGlobals.isPollyActive = false
|
|
23
46
|
pollyGlobals.pollyContext.options = null
|
|
24
47
|
})
|
|
@@ -26,13 +49,13 @@ export const specialEnvironments = {
|
|
|
26
49
|
return createPollyContextAccessor(pollyGlobals)
|
|
27
50
|
}
|
|
28
51
|
|
|
29
|
-
|
|
52
|
+
engine.beforeEach((t) => {
|
|
30
53
|
if (!pollyGlobals.isPollyActive) return
|
|
31
54
|
const name = getTestNamePath(t).join('/')
|
|
32
55
|
pollyGlobals.pollyContext.polly = new Polly(name, pollyGlobals.pollyContext.options)
|
|
33
56
|
})
|
|
34
57
|
|
|
35
|
-
|
|
58
|
+
engine.afterEach(async () => {
|
|
36
59
|
if (!pollyGlobals.pollyContext.polly) return
|
|
37
60
|
await pollyGlobals.pollyContext.polly.stop()
|
|
38
61
|
pollyGlobals.pollyContext.polly = null
|
package/src/jest.js
CHANGED
|
@@ -1,19 +1,12 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
test as nodeTest,
|
|
4
|
-
afterEach,
|
|
5
|
-
after,
|
|
6
|
-
assert,
|
|
7
|
-
utilFormat,
|
|
8
|
-
isPromise,
|
|
9
|
-
} from './engine.js'
|
|
1
|
+
import { assert, utilFormat, isPromise, mock } from './engine.js'
|
|
2
|
+
import * as node from './engine.js'
|
|
10
3
|
import { jestConfig } from './jest.config.js'
|
|
11
4
|
import { jestFunctionMocks } from './jest.fn.js'
|
|
12
5
|
import { jestModuleMocks } from './jest.mock.js'
|
|
13
6
|
import * as jestTimers from './jest.timers.js'
|
|
14
7
|
import './jest.snapshot.js'
|
|
15
|
-
import { createCallerLocationHook } from './dark.cjs'
|
|
16
|
-
import './version.js'
|
|
8
|
+
import { createCallerLocationHook, insideEsbuild } from './dark.cjs'
|
|
9
|
+
import { haveValidTimers } from './version.js'
|
|
17
10
|
import { expect } from 'expect'
|
|
18
11
|
import matchers from 'jest-extended'
|
|
19
12
|
import { format as prettyFormat } from 'pretty-format'
|
|
@@ -98,7 +91,7 @@ const describe = (...args) => {
|
|
|
98
91
|
inDescribe.push(fn)
|
|
99
92
|
const optionsConcurrent = args?.at(-1)?.concurrency > 1
|
|
100
93
|
if (optionsConcurrent) inConcurrent.push(fn)
|
|
101
|
-
const result =
|
|
94
|
+
const result = node.describe(...args, async () => {
|
|
102
95
|
const res = fn()
|
|
103
96
|
|
|
104
97
|
// We do only block-level concurrency, not file-level
|
|
@@ -137,26 +130,26 @@ Also, using expect.assertions() to ensure the planned number of assertions is be
|
|
|
137
130
|
})
|
|
138
131
|
}
|
|
139
132
|
|
|
140
|
-
const test = (...args) => testRaw(getCallerLocation(),
|
|
141
|
-
test.only = (...args) => testRaw(getCallerLocation(),
|
|
133
|
+
const test = (...args) => testRaw(getCallerLocation(), node.test, ...args)
|
|
134
|
+
test.only = (...args) => testRaw(getCallerLocation(), node.test.only, ...args)
|
|
142
135
|
|
|
143
136
|
describe.each = makeEach(describe)
|
|
144
137
|
test.each = makeEach(test) // TODO: pass caller location
|
|
145
138
|
test.concurrent = (...args) => {
|
|
146
139
|
assert(inDescribe.length > 0, 'test.concurrent is supported only within a describe block')
|
|
147
140
|
if (inConcurrent.length > 0) return test(...args)
|
|
148
|
-
concurrent.push([getCallerLocation(),
|
|
141
|
+
concurrent.push([getCallerLocation(), node.test, ...args])
|
|
149
142
|
}
|
|
150
143
|
|
|
151
144
|
test.concurrent.each = makeEach(test.concurrent)
|
|
152
|
-
describe.skip = (...args) =>
|
|
153
|
-
test.skip = (...args) =>
|
|
145
|
+
describe.skip = (...args) => node.describe.skip(...args)
|
|
146
|
+
test.skip = (...args) => node.test.skip(...args)
|
|
154
147
|
|
|
155
|
-
afterEach(() => {
|
|
148
|
+
node.afterEach(() => {
|
|
156
149
|
for (const { error } of expect.extractExpectedAssertionsErrors()) throw error
|
|
157
150
|
})
|
|
158
151
|
|
|
159
|
-
after(() => {
|
|
152
|
+
node.after(() => {
|
|
160
153
|
jestTimers.useRealTimers()
|
|
161
154
|
const prefix = `Tests completed, but still have asynchronous activity after`
|
|
162
155
|
|
|
@@ -176,8 +169,20 @@ after(() => {
|
|
|
176
169
|
}
|
|
177
170
|
})
|
|
178
171
|
|
|
179
|
-
const jest = {
|
|
180
|
-
exodus:
|
|
172
|
+
export const jest = {
|
|
173
|
+
exodus: {
|
|
174
|
+
__proto__: null,
|
|
175
|
+
features: {
|
|
176
|
+
__proto__: null,
|
|
177
|
+
platform: String(process.env.EXODUS_TEST_PLATFORM),
|
|
178
|
+
engine: String(node.engine),
|
|
179
|
+
timers: Boolean(mock.timers && haveValidTimers),
|
|
180
|
+
esmMocks: Boolean(mock.module), // full support for ESM mocks
|
|
181
|
+
esmInterop: Boolean(insideEsbuild), // loading/using ESM as CJS, ESM mocks creation without a mocker function
|
|
182
|
+
esmNamedBuiltinMocks: Boolean(mock.module || insideEsbuild), // support for named ESM imports from builtin module mocks
|
|
183
|
+
concurrency: node.engine !== 'pure', // pure engine doesn't support concurrency
|
|
184
|
+
},
|
|
185
|
+
},
|
|
181
186
|
setTimeout: (x) => {
|
|
182
187
|
assert.equal(typeof x, 'number')
|
|
183
188
|
defaultTimeout = x
|
|
@@ -188,6 +193,12 @@ const jest = {
|
|
|
188
193
|
...jestTimers,
|
|
189
194
|
}
|
|
190
195
|
|
|
191
|
-
|
|
196
|
+
const wrapCallback = (fn) => (fn.length > 0 ? (t, c) => fn(c) : () => fn())
|
|
197
|
+
|
|
198
|
+
export const beforeEach = (fn) => node.beforeEach(wrapCallback(fn))
|
|
199
|
+
export const afterEach = (fn) => node.afterEach(wrapCallback(fn))
|
|
200
|
+
export const beforeAll = (fn) => node.before(wrapCallback(fn))
|
|
201
|
+
export const afterAll = (fn) => node.after(wrapCallback(fn))
|
|
202
|
+
|
|
203
|
+
export { describe, test, test as it }
|
|
192
204
|
export { expect } from 'expect'
|
|
193
|
-
export { beforeEach, afterEach, before as beforeAll, after as afterAll } from './engine.js'
|
package/src/jest.mock.js
CHANGED
|
@@ -8,13 +8,25 @@ import {
|
|
|
8
8
|
syncBuiltinESMExports,
|
|
9
9
|
} from './engine.js'
|
|
10
10
|
import { jestfn } from './jest.fn.js'
|
|
11
|
-
import { makeEsbuildMockable } from './dark.cjs'
|
|
11
|
+
import { makeEsbuildMockable, insideEsbuild } from './dark.cjs'
|
|
12
12
|
|
|
13
13
|
const mapMocks = new Map()
|
|
14
14
|
const mapActual = new Map()
|
|
15
|
+
const nodeMocks = new Map()
|
|
16
|
+
const overridenBuiltins = new Set()
|
|
17
|
+
|
|
18
|
+
function wrap(impl) {
|
|
19
|
+
return function (...args) {
|
|
20
|
+
impl(...args)
|
|
21
|
+
return this
|
|
22
|
+
}
|
|
23
|
+
}
|
|
15
24
|
|
|
16
25
|
export const jestModuleMocks = {
|
|
17
|
-
mock: jestmock,
|
|
26
|
+
mock: wrap((name, mock) => jestmock(name, mock, { override: true })),
|
|
27
|
+
doMock: wrap((name, mock) => jestmock(name, mock)),
|
|
28
|
+
unmock: wrap(unmock),
|
|
29
|
+
dontMock: wrap(unmock),
|
|
18
30
|
createMockFromModule: (name) => mockClone(requireActual(name)),
|
|
19
31
|
requireMock,
|
|
20
32
|
requireActual,
|
|
@@ -42,13 +54,33 @@ export function requireMock(name) {
|
|
|
42
54
|
}
|
|
43
55
|
|
|
44
56
|
export function resetModules() {
|
|
45
|
-
|
|
46
|
-
|
|
57
|
+
for (const [, ctx] of nodeMocks) {
|
|
58
|
+
if (mock.module) ctx.restore()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
for (const resolved of Object.keys(require.cache)) {
|
|
62
|
+
delete require.cache[resolved]
|
|
63
|
+
mapMocks.delete(resolved)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function unmock(name) {
|
|
68
|
+
const resolved = resolveModule(name)
|
|
69
|
+
assert(mapMocks.has(resolved), 'Module is not mocked')
|
|
70
|
+
if (mock.module) nodeMocks.get(resolved).restore()
|
|
71
|
+
delete require.cache[resolved]
|
|
72
|
+
delete require.cache[`node:${resolved}`]
|
|
73
|
+
mapMocks.delete(resolved)
|
|
74
|
+
nodeMocks.delete(resolved)
|
|
75
|
+
assert(
|
|
76
|
+
!overridenBuiltins.has(resolved),
|
|
77
|
+
'Built-in modules mocked with jest.mock can not be unmocked, use jest.doMock'
|
|
78
|
+
)
|
|
47
79
|
}
|
|
48
80
|
|
|
49
|
-
const isObject = (obj) => [Object.prototype, null].includes(Object.getPrototypeOf(obj))
|
|
81
|
+
const isObject = (obj) => obj && [Object.prototype, null].includes(Object.getPrototypeOf(obj))
|
|
50
82
|
|
|
51
|
-
function
|
|
83
|
+
function overrideModule(resolved, lax = false) {
|
|
52
84
|
const value = mapMocks.get(resolved)
|
|
53
85
|
const current = mapActual.get(resolved)
|
|
54
86
|
if (current === value) return
|
|
@@ -100,7 +132,9 @@ function mockCloneItem(obj, cache) {
|
|
|
100
132
|
// Special path, as .default might be a getter and we want to unwrap it
|
|
101
133
|
if (obj.__esModule === true) {
|
|
102
134
|
const { __esModule, default: def, ...rest } = obj
|
|
103
|
-
|
|
135
|
+
const proto = Object.getPrototypeOf(obj)
|
|
136
|
+
const toClone = proto?.[Symbol.toStringTag] === 'Module' ? proto : { default: def, ...rest } // unwrap bun modules for proper cloning
|
|
137
|
+
return { __esModule, ...mockClone(toClone, cache) }
|
|
104
138
|
}
|
|
105
139
|
|
|
106
140
|
const prototype = Object.getPrototypeOf(obj)
|
|
@@ -137,7 +171,7 @@ function mockCloneItem(obj, cache) {
|
|
|
137
171
|
return null
|
|
138
172
|
}
|
|
139
173
|
|
|
140
|
-
export function jestmock(name, mocker) {
|
|
174
|
+
export function jestmock(name, mocker, { override = false } = {}) {
|
|
141
175
|
// Loaded ESM: isn't mocked
|
|
142
176
|
// Loaded CJS: mocked via object overriding
|
|
143
177
|
// Loaded built-ins: mocked via object overriding where possible
|
|
@@ -147,6 +181,10 @@ export function jestmock(name, mocker) {
|
|
|
147
181
|
|
|
148
182
|
const resolved = resolveModule(name)
|
|
149
183
|
assert(!mapMocks.has(resolved), 'Re-mocking the same module is not supported')
|
|
184
|
+
assert(
|
|
185
|
+
!overridenBuiltins.has(resolved),
|
|
186
|
+
'Built-in modules mocked with jest.mock can not be remocked, use jest.doMock'
|
|
187
|
+
)
|
|
150
188
|
|
|
151
189
|
// Attempt to load it
|
|
152
190
|
// Jest also loads modules on mock
|
|
@@ -154,41 +192,70 @@ export function jestmock(name, mocker) {
|
|
|
154
192
|
const havePrior = Object.hasOwn(require.cache, resolved)
|
|
155
193
|
try {
|
|
156
194
|
mapActual.set(resolved, require(resolved))
|
|
157
|
-
} catch {
|
|
195
|
+
} catch {
|
|
196
|
+
assert(mocker, 'Can not auto-clone a native ESM module without --esbuild or newer Node.js')
|
|
197
|
+
}
|
|
158
198
|
|
|
159
199
|
const value = mocker ? { ...mocker() } : mockClone(mapActual.get(resolved))
|
|
160
200
|
mapMocks.set(resolved, value)
|
|
161
201
|
|
|
162
202
|
let likelyESM = false
|
|
203
|
+
let okFromESM = false
|
|
163
204
|
const isBuiltIn = builtinModules.includes(resolved)
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
205
|
+
const isNodeCache = (x) => x && x.id && x.path && x.filename && x.children && x.paths && x.loaded
|
|
206
|
+
if (isBuiltIn && !isNodeCache(require.cache[resolved])) {
|
|
207
|
+
if (!value.default && !value.__esModule) {
|
|
208
|
+
value.__esModule = true // allows esbuild to unwrap it to named mocks
|
|
209
|
+
value.default = value
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (override) {
|
|
213
|
+
overridenBuiltins.add(resolved)
|
|
214
|
+
overrideModule(resolved, true) // Override builtin modules
|
|
215
|
+
if (syncBuiltinESMExports) {
|
|
216
|
+
syncBuiltinESMExports()
|
|
217
|
+
okFromESM = true
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
require.cache[resolved] = require.cache[`node:${resolved}`] = { exports: value }
|
|
222
|
+
} else if (Object.hasOwn(require.cache, resolved)) {
|
|
223
|
+
if (isNodeCache(require.cache[resolved]) || !require.cache[resolved].exports?.__esModule) {
|
|
224
|
+
assert.equal(mapActual.get(resolved), require.cache[resolved].exports)
|
|
225
|
+
// If we did't have this prior but have now, it means we just loaded it and there are no leaked instances
|
|
226
|
+
if (havePrior && override) overrideModule(resolved)
|
|
227
|
+
require.cache[resolved].exports = value
|
|
228
|
+
} else {
|
|
229
|
+
// If it's non-Node.js and has __esModule tag, assume it's ESM
|
|
230
|
+
likelyESM = true
|
|
231
|
+
}
|
|
172
232
|
} else {
|
|
173
233
|
// The module doesn't exist or is ESM
|
|
174
234
|
likelyESM = true
|
|
175
235
|
}
|
|
176
236
|
|
|
177
|
-
if (!
|
|
237
|
+
if (likelyESM || (!okFromESM && isTopLevelESM())) {
|
|
178
238
|
// Native module mocks is required if loading ESM or __from__ ESM
|
|
179
239
|
// No good way to check the locations that import the module, but we can check top-level file
|
|
180
240
|
// Built-in modules are fine though
|
|
181
|
-
assert(
|
|
241
|
+
assert(mock.module, 'ESM module mocks are available only on Node.js >=22.3')
|
|
242
|
+
} else if (isBuiltIn && name.startsWith('node:') && !override) {
|
|
243
|
+
assert(mock.module, 'Native non-overriding node:* mocks are available only on Node.js >=22.3')
|
|
182
244
|
}
|
|
183
245
|
|
|
184
|
-
|
|
246
|
+
const obj = { defaultExport: value }
|
|
247
|
+
if (isBuiltIn && isObject(value)) obj.namedExports = value
|
|
248
|
+
if (insideEsbuild) {
|
|
249
|
+
// esbuild handles unwrapping just default exports for us
|
|
250
|
+
assert(!likelyESM) // should not be reachable
|
|
251
|
+
} else if (likelyESM && isObject(value) && value.__esModule === true) {
|
|
185
252
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
186
253
|
const { default: defaultExport, __esModule, ...namedExports } = value
|
|
187
|
-
|
|
188
|
-
} else {
|
|
189
|
-
mock.module?.(resolved, { defaultExport: value })
|
|
254
|
+
Object.assign(obj, { defaultExport, namedExports })
|
|
190
255
|
}
|
|
191
256
|
|
|
257
|
+
nodeMocks.set(resolved, mock.module?.(resolved, obj))
|
|
258
|
+
|
|
192
259
|
return this
|
|
193
260
|
}
|
|
194
261
|
|
package/src/jest.snapshot.js
CHANGED
|
@@ -63,7 +63,7 @@ function wrapContextName(fn) {
|
|
|
63
63
|
configurable: true,
|
|
64
64
|
get() {
|
|
65
65
|
assert.equal(this, context)
|
|
66
|
-
return value.replaceAll(' > ', ' ')
|
|
66
|
+
return value.replaceAll(' > ', ' ').replaceAll('<anonymous>', '')
|
|
67
67
|
},
|
|
68
68
|
})
|
|
69
69
|
try {
|
|
@@ -127,7 +127,8 @@ const snapOnDisk = (orig, matcher) => {
|
|
|
127
127
|
// Also be careful with assertion plan counters
|
|
128
128
|
if (!snapshotText) getAssert().fail(`Could not find snapshot file. ${addFail}`)
|
|
129
129
|
|
|
130
|
-
const
|
|
130
|
+
const namePath = getTestNamePath(context).map((x) => (x === '<anonymous>' ? '' : x))
|
|
131
|
+
const name = namePath.join(' ')
|
|
131
132
|
const count = (nameCounts.get(name) || 0) + 1
|
|
132
133
|
nameCounts.set(name, count)
|
|
133
134
|
const escaped = escape(serialize(obj))
|
package/src/node.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { setSnapshotSerializers as setDefaultSnapshotSerializers } from './engine.js'
|
|
2
|
+
|
|
3
|
+
function setResolveSnapshotPath() {
|
|
4
|
+
// TODO: might want to test it and allow if it's pure / doesn't depend on fs
|
|
5
|
+
throw new Error('Unsupported due to possible environment differences')
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export const snapshot = { setDefaultSnapshotSerializers, setResolveSnapshotPath }
|
|
9
|
+
|
|
10
|
+
export { mock, describe, test, beforeEach, afterEach, before, after } from './engine.js'
|