@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 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 '--pure':
85
- options.pure = true
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
- let program = 'node'
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, 'Can not use "node" coverage engine with --pure')
120
- assert(!options.writeSnapshots, 'Can not use write snapshots with --pure')
121
- assert(!options.forceExit, 'Can not use --force-exit with --pure') // TODO
122
- assert(!options.watch, 'Can not use --watch with --pure')
123
- assert(!options.only, 'Can not use --only with --pure') // TODO
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
- args.push('--import', resolveImport('tsx'))
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(program && ['node', c8].includes(program))
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(program, [...args, file], { stdio: 'inherit' })
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
- process.env.EXODUS_TEST_CONTEXT = 'node --test'
308
- const node = spawn(program, [...args, ...files], { stdio: 'inherit' })
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.25",
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
- const usingTsx = process.execArgv.some((x) => x.endsWith('node_modules/tsx/dist/loader.mjs'))
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 { snapshot, readSnapshot, setSnapshotSerializers, setSnapshotResolver } = engine
19
- export { snapshot, readSnapshot, setSnapshotSerializers, setSnapshotResolver }
21
+ const { readSnapshot, setSnapshotSerializers, setSnapshotResolver } = engine
22
+ export { readSnapshot, setSnapshotSerializers, setSnapshotResolver }
@@ -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
- ...{ snapshot, readSnapshot, setSnapshotSerializers, setSnapshotResolver },
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 */
@@ -1 +1,5 @@
1
- module.exports = require('./engine.node.cjs')
1
+ // Needs to be inlined for bundler to optimize it out correctly
2
+ module.exports =
3
+ process.env.EXODUS_TEST_CONTEXT === 'pure'
4
+ ? require('./engine.pure.cjs')
5
+ : require('./engine.node.cjs')
@@ -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)
@@ -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
- const nodeEnvs = new Set(['ts-jest', 'ts-jest/presets/js-with-ts'])
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
- assert(!c.preset, 'Jest config.preset is not supported')
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(dir = baseDir) {
114
- config = normalizeJestConfig(await getJestConfig(dir))
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 { jest, beforeEach } = jestGlobals
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
- for (const file of c.setupFiles || []) require(file)
142
- for (const file of c.setupFilesAfterEnv || []) require(file)
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
  /*
@@ -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, jestGlobals) => {
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
- jestGlobals.beforeAll(() => {
39
+ engine.before(() => {
17
40
  pollyGlobals.isPollyActive = true
18
41
  pollyGlobals.pollyContext.options = options
19
42
  })
20
43
 
21
- jestGlobals.afterAll(() => {
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
- jestGlobals.beforeEach((t) => {
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
- jestGlobals.afterEach(async () => {
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
- describe as nodeDescribe,
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 = nodeDescribe(...args, async () => {
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(), nodeTest, ...args)
141
- test.only = (...args) => testRaw(getCallerLocation(), nodeTest.only, ...args)
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(), nodeTest, ...args])
141
+ concurrent.push([getCallerLocation(), node.test, ...args])
149
142
  }
150
143
 
151
144
  test.concurrent.each = makeEach(test.concurrent)
152
- describe.skip = (...args) => nodeDescribe.skip(...args)
153
- test.skip = (...args) => nodeTest.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: Object.create(null), // declare ourselves
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
- export { jest, describe, test, test as it }
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
- // Caveat: only resets CJS modules, not ESM
46
- for (const key of Object.keys(require.cache)) delete require.cache[key]
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 override(resolved, lax = false) {
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
- return { __esModule, ...mockClone({ default: def, ...rest }, cache) }
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
- if (Object.hasOwn(require.cache, resolved)) {
165
- assert.equal(mapActual.get(resolved), require.cache[resolved].exports)
166
- // If we did't have this prior but have now, it means we just loaded it and there are no leaked instances
167
- if (havePrior) override(resolved)
168
- require.cache[resolved].exports = value
169
- } else if (isBuiltIn) {
170
- override(resolved, true) // Override builtin modules
171
- syncBuiltinESMExports()
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 (!mock.module && !isBuiltIn) {
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(!likelyESM && !isTopLevelESM(), 'ESM module mocks are available only on Node.js >=22.3')
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
- if (likelyESM && isObject(value) && value.__esModule === true) {
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
- mock.module?.(resolved, { defaultExport, namedExports })
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
 
@@ -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 name = getTestNamePath(context).join(' ')
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'