@exodus/test 1.0.0-rc.70 → 1.0.0-rc.71

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/bundler/bundle.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import assert from 'node:assert/strict'
2
- import { readFile, readdir } from 'node:fs/promises'
2
+ import { readFile, writeFile, readdir } from 'node:fs/promises'
3
3
  import { existsSync } from 'node:fs'
4
4
  import { fileURLToPath, pathToFileURL } from 'node:url'
5
5
  import { basename, dirname, extname, resolve, join, relative } from 'node:path'
@@ -11,6 +11,8 @@ import glob from 'fast-glob'
11
11
  const require = createRequire(import.meta.url)
12
12
  const resolveRequire = (query) => require.resolve(query)
13
13
  const resolveImport = import.meta.resolve && ((query) => fileURLToPath(import.meta.resolve(query)))
14
+ const cjsMockRegex = /\.exodus-test-mock\.cjs$/u
15
+ const cjsMockFallback = `throw new Error('Mocking loaded ESM modules in not possible in bundles')`
14
16
 
15
17
  const readSnapshots = async (files, resolvers) => {
16
18
  const snapshots = []
@@ -28,12 +30,42 @@ const readSnapshots = async (files, resolvers) => {
28
30
  return snapshots
29
31
  }
30
32
 
33
+ const stringify = (x) => ([undefined, null].includes(x) ? `${x}` : JSON.stringify(x))
31
34
  const loadPipeline = [
32
35
  function (source, filepath) {
33
- return source
36
+ let res = source
34
37
  .replace(/\bimport\.meta\.url\b/g, JSON.stringify(pathToFileURL(filepath)))
35
38
  .replace(/\b(__dirname|import\.meta\.dirname)\b/g, JSON.stringify(dirname(filepath)))
36
39
  .replace(/\b(__filename|import\.meta\.filename)\b/g, JSON.stringify(filepath))
40
+
41
+ // Unneded polyfills
42
+ for (const [a, b] of Object.entries({
43
+ 'is-nan': 'Number.isNaN', // https://www.npmjs.com/package/is-nan description: ES2015-compliant shim for Number.isNaN
44
+ 'is-nan/polyfill': '() => Number.isNaN',
45
+ 'object.assign': 'Object.assign',
46
+ 'object.assign/polyfill': '() => Object.assign',
47
+ 'object-is': 'Object.is',
48
+ 'object-is/polyfill': '() => Object.is',
49
+ hasown: 'Object.hasOwn',
50
+ gopd: 'Object.getOwnPropertyDescriptor',
51
+ 'has-property-descriptors': '() => true',
52
+ 'has-symbols': '() => true',
53
+ 'has-symbols/shams': '() => true',
54
+ 'has-tostringtag': "() => typeof Symbol.toStringTag === 'symbol'",
55
+ 'has-tostringtag/shams': '() => !!Symbol.toStringTag',
56
+ 'es-define-property': 'Object.defineProperty',
57
+ 'es-errors': 'Error',
58
+ 'es-errors/eval': 'EvalError',
59
+ 'es-errors/range': 'RangeError',
60
+ 'es-errors/ref': 'ReferenceError',
61
+ 'es-errors/syntax': 'SyntaxError',
62
+ 'es-errors/type': 'TypeError',
63
+ 'es-errors/uri': 'URIError',
64
+ })) {
65
+ res = res.replaceAll(`require('${a}')`, `(${b})`).replaceAll(`require("${a}")`, `(${b})`) // Assumes well-formed names/code
66
+ }
67
+
68
+ return res
37
69
  },
38
70
  ]
39
71
 
@@ -98,7 +130,7 @@ const loadCache = new Map()
98
130
  const loadSourceFileBase = async (filepath) => {
99
131
  if (!loadCache.has(filepath)) {
100
132
  const load = async () => {
101
- let contents = await readFile(filepath, 'utf8')
133
+ let contents = await readFile(filepath.replace(cjsMockRegex, ''), 'utf8')
102
134
  for (const transform of loadPipeline) contents = await transform(contents, filepath)
103
135
  return contents
104
136
  }
@@ -124,7 +156,6 @@ export const build = async (...files) => {
124
156
  const input = []
125
157
  const importSource = async (file) => input.push(await loadSourceFile(resolveRequire(file)))
126
158
  const importFile = (...args) => input.push(`await import(${JSON.stringify(resolve(...args))});`)
127
- const stringify = (x) => ([undefined, null].includes(x) ? `${x}` : JSON.stringify(x))
128
159
 
129
160
  if (!['node', 'electron'].includes(options.platform)) {
130
161
  if (process.env.EXODUS_TEST_IS_BAREBONE) {
@@ -182,7 +213,7 @@ export const build = async (...files) => {
182
213
  const fsFilesDirs = new Map()
183
214
  const cwd = process.cwd()
184
215
  const fixturesRegex = /(fixtures|samples)/u
185
- const aggressiveExtensions = /\.(json|txt|hex)$/u // These are bundled when just used in path.join and by wildcard from fixtures/
216
+ const aggressiveExtensions = /\.(json|txt|hex|wasm)$/u // These are bundled when just used in path.join and by wildcard from fixtures/
186
217
  const fileAllowed = (f) =>
187
218
  f && f.startsWith(`${cwd}/`) && resolve(f) === f && /^[a-z0-9@_./-]+$/iu.test(relative(cwd, f))
188
219
 
@@ -241,7 +272,28 @@ export const build = async (...files) => {
241
272
  if (/(readdir|readFile|exists)Sync/u.test(source)) await fsFilesBundleFixtures('fs')
242
273
  if (fixturesRegex.test(source)) await fsFilesBundleFixtures('fixtures')
243
274
 
244
- return source
275
+ // Resolve require.resolve and bundle those files for fixture or json extensions (e.g. package.json)
276
+ let filepathRequire
277
+ const toAdd = []
278
+ const res = source.replace(
279
+ /\b(require|import\.meta)\.resolve\(\s*(?:"([^"\\]+)"|'([^'\\]+)')\s*\)/gu,
280
+ (orig, cause, a, b) => {
281
+ if (!filepathRequire) filepathRequire = createRequire(filepath)
282
+ try {
283
+ const file = filepathRequire.resolve(a || b)
284
+ if (aggressiveExtensions.test(file)) toAdd.push(file) // load resolved files for specific extensions
285
+ if (cause === 'require') return `(${stringify(file)})`
286
+ // Do not replace import.meta.resolve for non-fixture extensions, might cause misresolutions
287
+ return aggressiveExtensions.test(file) ? `(${stringify(pathToFileURL(file))})` : orig
288
+ } catch {
289
+ return orig
290
+ }
291
+ }
292
+ )
293
+
294
+ for (const file of toAdd) await fsFilesAdd(file)
295
+
296
+ return res
245
297
  })
246
298
 
247
299
  if (files.length === 1) {
@@ -266,6 +318,7 @@ export const build = async (...files) => {
266
318
  'fs/promises': api('fs-promises.cjs'),
267
319
  http: api('http.cjs'),
268
320
  https: api('https.cjs'),
321
+ module: api('module.cjs'),
269
322
  os: resolveRequire('os-browserify/browser.js'), // 'main' entry point is noop, we want browser entry
270
323
  path: resolveRequire('path-browserify'),
271
324
  querystring: resolveRequire('querystring-es3'),
@@ -273,7 +326,7 @@ export const build = async (...files) => {
273
326
  timers: resolveRequire('timers-browserify'),
274
327
  tty: api('tty.cjs'),
275
328
  url: api('url.cjs'),
276
- util: dirname(resolveRequire('util/')),
329
+ util: api('util.cjs'),
277
330
  zlib: resolveRequire('browserify-zlib'),
278
331
  }
279
332
 
@@ -294,6 +347,7 @@ export const build = async (...files) => {
294
347
  'process.stderr': 'undefined',
295
348
  'process.stdout': 'undefined',
296
349
  'process.type': 'undefined',
350
+ 'process.platform': 'undefined',
297
351
  'process.version': stringify('v22.5.1'), // shouldn't depend on currently used Node.js version
298
352
  'process.versions.node': stringify('22.5.1'), // see line above
299
353
  EXODUS_TEST_PROCESS_CWD: stringify(process.cwd()),
@@ -318,6 +372,7 @@ export const build = async (...files) => {
318
372
  ...nodeUnprefixed,
319
373
  // Needed for polyfills but name conflicts with Node.js modules
320
374
  'url/url.js': resolveRequire('url/url.js'),
375
+ 'util/util.js': resolveRequire('util/util.js'),
321
376
  // expect-related deps
322
377
  'ansi-styles': api('ansi-styles.cjs'),
323
378
  'jest-util': api('jest-util.js'),
@@ -339,7 +394,12 @@ export const build = async (...files) => {
339
394
  plugins: [
340
395
  {
341
396
  name: 'exodus-test.bundle',
342
- setup({ onLoad }) {
397
+ setup({ onResolve, onLoad }) {
398
+ onResolve({ filter: /\.[cm]?[jt]sx?$/ }, (args) => {
399
+ if (shouldInstallMocks && cjsMockRegex.test(args.path)) {
400
+ return { path: args.path, namespace: 'file' }
401
+ }
402
+ })
343
403
  onLoad({ filter: /\.[cm]?[jt]sx?$/, namespace: 'file' }, async (args) => {
344
404
  let filepath = args.path
345
405
  // Resolve .native versions
@@ -359,6 +419,46 @@ export const build = async (...files) => {
359
419
  ],
360
420
  }
361
421
 
422
+ let shouldInstallMocks = false
423
+ const mocked = new Set()
424
+ specificLoadPipeline.push(async (source, filepath) => {
425
+ if (shouldInstallMocks) {
426
+ if (cjsMockRegex.test(filepath)) return cjsMockFallback
427
+ if (mocked.has(filepath) && !filepath.endsWith('.cjs') && /^export\b/mu.test(source)) {
428
+ const mock = stringify(`${filepath}.exodus-test-mock.cjs`)
429
+ const def = 'x.__esModule ? x.default : (x.default ?? x)'
430
+ return `export * from ${mock}\nvar x = require(${mock})\nexport default ${def}`
431
+ }
432
+ }
433
+
434
+ // 'await import' is replaced only in files with mocks (likely toplevel there)
435
+ // Otherwise we don't patch module system at all
436
+ if (!source.includes('jest.doMock') && !source.includes('jest.mock')) return source
437
+ shouldInstallMocks = true
438
+ const filepathRequire = createRequire(filepath)
439
+ return source
440
+ .replaceAll(/\bawait (import\((?:"[^"\\]+"|'[^'\\]+')\))/gu, 'EXODUS_TEST_SYNC_IMPORT($1)')
441
+ .replaceAll(
442
+ /\bjest\.(doMock|mock|requireActual|requireMock)\(\s*("[^"\\]+"|'[^'\\]+')/gu,
443
+ (_, method, raw) => {
444
+ try {
445
+ const arg = JSON.parse(raw[0] === "'" ? raw.replaceAll("'", '"') : raw) // fine because it doesn't have quotes or \
446
+ const { alias } = config
447
+ const file = Object.hasOwn(alias, arg) ? alias[arg] : filepathRequire.resolve(arg) // throws when not resolved
448
+ assert(existsSync(file), `File ${file} does not exist`)
449
+ const builtin = stringify(Object.hasOwn(alias, arg) ? arg.replace(/^node:/, '') : null)
450
+ const id = `bundle:${relative(cwd, file)}`
451
+ if (method.startsWith('require')) return `jest.${method}(${stringify(id)}`
452
+ mocked.add(file)
453
+ return `jest.__${method}Bundle(${stringify(id)},${builtin},()=>require(${raw})`
454
+ } catch (err) {
455
+ console.error(err)
456
+ throw new Error(`Failed to mock ${raw}: not resolved`, { cause: err })
457
+ }
458
+ }
459
+ )
460
+ })
461
+
362
462
  if (files.length === 1) {
363
463
  config.define['process.argv'] = stringify(['exodus-test', resolve(files[0])])
364
464
  }
@@ -379,6 +479,24 @@ export const build = async (...files) => {
379
479
  assert.equal(res instanceof Error, res.errors.length > 0)
380
480
  }
381
481
 
482
+ if (res.errors.length === 0 && shouldInstallMocks) {
483
+ const code = await readFile(outfile, 'utf8')
484
+ const heads = {
485
+ esm: /(var __esm = (?:function)?\((fn[\d]*), res[\d]*\)\s*(?:=>|\{\s*return)\s*)(function __init[\d]*\(\) \{)/u,
486
+ cjs: /(var __commonJS = (?:function)?\((cb[\d]*), mod[\d]*\)\s*(?:=>|\{\s*return)\s*)(function __require[\d]*\(\) \{)/u,
487
+ }
488
+ const k = '__getOwnPropNames($2)[0]'
489
+ const mock = (p, l, v) =>
490
+ `var ${p}=new Set(),${l}=new Set(),${v}=new Map();$1${p}.add(${k}) && $3;{const k=${k};${l}.add(k);if (${v}.has(k))return ${v}.get(k)};`
491
+ assert(heads.esm.test(code) && heads.cjs.test(code), 'Failed to match for module mocks')
492
+ const patched = code
493
+ .replace(heads.esm, mock('__mocksESMPossible', '__mocksESMLoaded', '__mocksESM')) // __mocksESM actually doesn't work
494
+ .replace(heads.cjs, mock('__mocksCJSPossible', '__mocksCJSLoaded', '__mocksCJS'))
495
+ .replaceAll('EXODUS_TEST_SYNC_IMPORT(Promise.resolve().then(', '((f=>f())(')
496
+ assert(!patched.includes('EXODUS_TEST_SYNC_IMPORT'), "Failed to fix 'await import'")
497
+ await writeFile(outfile, patched)
498
+ }
499
+
382
500
  // if (res.errors.length === 0) require('fs').copyFileSync(outfile, 'tempout.cjs') // DEBUG
383
501
 
384
502
  // We treat warnings as errors, so just merge all them
@@ -5,6 +5,7 @@ if (!globalThis.global) globalThis.global = globalThis
5
5
  if (!globalThis.Buffer) globalThis.Buffer = require('buffer').Buffer
6
6
 
7
7
  const consoleKeys = ['log', 'error', 'warn', 'info', 'debug', 'trace']
8
+ const { print } = globalThis
8
9
  if (!globalThis.console) globalThis.console = Object.fromEntries(consoleKeys.map((k) => [k, print])) // eslint-disable-line no-undef
9
10
 
10
11
  // In browsers e.g. errors (and some other objects) are hard to unwrap via the API
@@ -12,6 +13,7 @@ if (!globalThis.console) globalThis.console = Object.fromEntries(consoleKeys.map
12
13
  // In barebone, we don't want console.log({x:10}) to print "[Object object]"", we want "{ x: 10 }"
13
14
  if (process.env.EXODUS_TEST_IS_BROWSER || process.env.EXODUS_TEST_IS_BAREBONE) {
14
15
  const utilFormat = require('exodus-test:util-format')
16
+ if (print) globalThis.print = (...args) => print(utilFormat(...args))
15
17
  for (const type of consoleKeys) {
16
18
  if (!Object.hasOwn(console, type)) continue
17
19
  const orig = console[type].bind(console)
@@ -49,10 +51,12 @@ if (!globalThis.WebSocket) {
49
51
  }
50
52
 
51
53
  if (!Array.prototype.at) {
52
- // eslint-disable-next-line no-extend-native
53
- Array.prototype.at = function (i) {
54
+ const at = function (i) {
54
55
  return this[i < 0 ? this.length + i : i]
55
56
  }
57
+
58
+ // eslint-disable-next-line no-extend-native
59
+ Object.defineProperty(Array.prototype, 'at', { configurable: true, writable: true, value: at })
56
60
  }
57
61
 
58
62
  if (process.env.EXODUS_TEST_PLATFORM === 'hermes') {
@@ -66,6 +70,43 @@ if (process.env.EXODUS_TEST_PLATFORM === 'hermes') {
66
70
  )
67
71
  Promise.allSettled = (iterable) => Promise.all([...iterable].map((element) => wrap(element)))
68
72
  }
73
+
74
+ // Refs: https://github.com/facebook/hermes/commit/e97db61b49bd0c065a3ce7da46f074bc39b80c6a
75
+ if (!Promise.any) {
76
+ const AggregateError =
77
+ globalThis.AggregateError ||
78
+ class AggregateError extends Error {
79
+ constructor(errors, message) {
80
+ super(message)
81
+ this.name = 'AggregateError'
82
+ this.errors = errors
83
+ }
84
+ }
85
+
86
+ const errmsg = 'All promises were rejected'
87
+ Promise.any = function (values) {
88
+ const promises = [...values]
89
+ const errors = []
90
+ if (promises.length === 0) return Promise.reject(new AggregateError(errors, errmsg))
91
+ let resolved = false
92
+ return new Promise((resolve, reject) => {
93
+ const oneResolve = (value) => {
94
+ if (resolved) return
95
+ resolved = true
96
+ errors.length = 0
97
+ resolve(value)
98
+ }
99
+
100
+ const oneReject = (error) => {
101
+ if (resolved) return
102
+ errors.push(error)
103
+ if (errors.length === promises.length) reject(new AggregateError(errors, errmsg))
104
+ }
105
+
106
+ promises.forEach((promise) => Promise.resolve(promise).then(oneResolve, oneReject))
107
+ })
108
+ }
109
+ }
69
110
  }
70
111
 
71
112
  if (globalThis.describe) delete globalThis.describe
@@ -0,0 +1,16 @@
1
+ const ids = 'Module,SourceMap,builtinModules,findSourceMap,globalPaths,isBuiltin,runMain'.split(',')
2
+
3
+ const makeMethod = (key) => {
4
+ // Not an arrow as there are classes that can be called with new
5
+ return function () {
6
+ throw new Error(`module.${key} unsupported in bundled mode`)
7
+ }
8
+ }
9
+
10
+ const createRequire = (filename) => (file) => {
11
+ const clean = file.replace(/^node:/, '')
12
+ if (globalThis.EXODUS_TEST_MOCK_BUILTINS?.has(clean)) return EXODUS_TEST_MOCK_BUILTINS.get(clean) // eslint-disable-line no-undef
13
+ throw new Error(`module.createRequire is unsupported in bundled mode (origin: ${filename})`)
14
+ }
15
+
16
+ module.exports = { ...Object.fromEntries(ids.map((key) => [key, makeMethod(key)])), createRequire }
@@ -1,4 +1,4 @@
1
- const { inspect: inspectOrig, isString, isNull, isObject } = require('util/') // dep
1
+ const { inspect: inspectOrig, isString, isNull, isObject } = require('util/util.js') // dep
2
2
 
3
3
  // Print errors without square brackets
4
4
  const trim = (x) => x.trim()
@@ -0,0 +1,4 @@
1
+ const util = require('util/util.js')
2
+ const format = require('./util-format.cjs')
3
+ const { TextEncoder, TextDecoder } = globalThis
4
+ module.exports = { ...util, format, TextEncoder, TextDecoder }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/test",
3
- "version": "1.0.0-rc.70",
3
+ "version": "1.0.0-rc.71",
4
4
  "author": "Exodus Movement, Inc.",
5
5
  "description": "A test suite runner",
6
6
  "homepage": "https://github.com/ExodusMovement/test",
@@ -58,10 +58,12 @@
58
58
  "bundler/modules/globals.cjs",
59
59
  "bundler/modules/jest-message-util.js",
60
60
  "bundler/modules/jest-util.js",
61
+ "bundler/modules/module.cjs",
61
62
  "bundler/modules/node-buffer.cjs",
62
63
  "bundler/modules/text-encoding-utf.cjs",
63
64
  "bundler/modules/tty.cjs",
64
65
  "bundler/modules/url.cjs",
66
+ "bundler/modules/util.cjs",
65
67
  "bundler/modules/util-format.cjs",
66
68
  "bundler/modules/ws.cjs",
67
69
  "loaders/babel.cjs",
@@ -96,8 +98,8 @@
96
98
  ],
97
99
  "scripts": {
98
100
  "test:_pure": "EXODUS_TEST_IGNORE='tests/jest-extended/**' npm run test",
99
- "test:_bundle": "EXODUS_TEST_IGNORE='tests/{{jest-extended,inband}/**,jest-when/when.test.*,jest/jest.resetModules.*,{jest,jest-superset}/mock/jest.mock.*}' npm run test",
100
- "test": "npm run test:jest",
101
+ "test:_bundle": "EXODUS_TEST_IGNORE='tests/{{jest-extended,inband}/**,jest-when/when.test.*,jest/jest.resetModules.*}' npm run test --",
102
+ "test": "npm run test:jest --",
101
103
  "test:all": "npm run test:jest && npm run test:tape && npm run test:native && npm run test:pure && npm run test:typescript && npm run test:fetch && npm run test:jsdom",
102
104
  "test:native": "EXODUS_TEST_IGNORE='{**/typescript/**,**/jest-repo/**/user.test.js}' ./bin/index.js --jest 'tests/**/*.test.{js,cjs,mjs}'",
103
105
  "test:typescript": "./bin/index.js --jest --typescript tests/typescript.test.ts",
@@ -113,7 +115,10 @@
113
115
  "test:chromium:playwright": "EXODUS_TEST_ENGINE=chromium:playwright npm run test:_bundle --",
114
116
  "test:firefox:playwright": "EXODUS_TEST_ENGINE=firefox:playwright npm run test:_bundle --",
115
117
  "test:webkit:playwright": "EXODUS_TEST_ENGINE=webkit:playwright npm run test:_bundle --",
116
- "test:hermes": "EXODUS_TEST_ENGINE=hermes:bundle npm run test:_bundle",
118
+ "test:bundle": "EXODUS_TEST_ENGINE=node:bundle npm run test:_bundle --",
119
+ "test:d8": "EXODUS_TEST_ENGINE=d8:bundle npm run test:_bundle --",
120
+ "test:jsc": "EXODUS_TEST_ENGINE=jsc:bundle npm run test:_bundle --",
121
+ "test:hermes": "EXODUS_TEST_ENGINE=hermes:bundle npm run test:_bundle --",
117
122
  "test:fetch": "./bin/index.js --jest --drop-network --engine node:pure tests/fetch.test.js tests/websocket.test.js",
118
123
  "test:jsdom": "EXODUS_TEST_JEST_CONFIG='{\"testMatch\":[\"**/*.jsdom-test.js\"],\"testEnvironment\":\"jsdom\", \"rootDir\": \".\"}' ./bin/index.js --jest",
119
124
  "coverage": "./bin/index.js --jest --esbuild --coverage",
package/src/dark.cjs CHANGED
@@ -19,33 +19,34 @@ let getCallerLocation
19
19
 
20
20
  function createCallerLocationHook() {
21
21
  if (getCallerLocation) return { installLocationInNextTest, getCallerLocation }
22
-
23
- try {
24
- const { Test } = require('node:internal/test_runner/test')
25
- const { fileURLToPath } = require('node:url')
26
- const mayBeUrlToPath = (str) => (str.startsWith('file://') ? fileURLToPath(str) : str)
27
- const locStorage = new Map()
28
- Object.defineProperty(Test.prototype, 'loc', {
29
- get() {
30
- return locStorage.get(this)
31
- },
32
- set(val) {
33
- locStorage.set(this, val)
34
- if (locForNextTest) {
35
- const loc = locForNextTest
36
- locForNextTest = undefined
37
- locStorage.set(this, { line: loc[0], column: loc[1], file: mayBeUrlToPath(loc[2]) })
38
- }
39
- },
40
- })
41
-
42
- // We can replicate getCallerLocation() with public V8 Error CallSite API, but we won't
43
- // need it anyway if we don't have a path for hook into internal Test implementation
44
-
45
- const { internalBinding } = require('node:internal/test/binding')
46
- getCallerLocation = internalBinding('util').getCallerLocation
47
- } catch {
48
- getCallerLocation = () => {}
22
+ getCallerLocation = () => {}
23
+
24
+ if (process.env.EXODUS_TEST_ENGINE === 'node:test') {
25
+ try {
26
+ const { Test } = require('node:internal/test_runner/test')
27
+ const { fileURLToPath } = require('node:url')
28
+ const mayBeUrlToPath = (str) => (str.startsWith('file://') ? fileURLToPath(str) : str)
29
+ const locStorage = new Map()
30
+ Object.defineProperty(Test.prototype, 'loc', {
31
+ get() {
32
+ return locStorage.get(this)
33
+ },
34
+ set(val) {
35
+ locStorage.set(this, val)
36
+ if (locForNextTest) {
37
+ const loc = locForNextTest
38
+ locForNextTest = undefined
39
+ locStorage.set(this, { line: loc[0], column: loc[1], file: mayBeUrlToPath(loc[2]) })
40
+ }
41
+ },
42
+ })
43
+
44
+ // We can replicate getCallerLocation() with public V8 Error CallSite API, but we won't
45
+ // need it anyway if we don't have a path for hook into internal Test implementation
46
+
47
+ const { internalBinding } = require('node:internal/test/binding')
48
+ getCallerLocation = internalBinding('util').getCallerLocation
49
+ } catch {}
49
50
  }
50
51
 
51
52
  return { installLocationInNextTest, getCallerLocation }
@@ -56,51 +57,53 @@ function getTestNamePath(t) {
56
57
  // No implementation in Node.js yet, will have to PR
57
58
  if (t.fullName) return t.fullName.split(' > ')
58
59
 
59
- // We are on Node.js < 22.3.0 where even t.fullName doesn't exist yet, polyfill
60
- const namePath = Symbol('namePath')
61
- const getNamePath = Symbol('getNamePath')
62
- try {
63
- if (t[namePath]) return t[namePath]
64
-
65
- // Sigh, ok, whatever
66
- const { Test } = require('node:internal/test_runner/test')
60
+ if (process.env.EXODUS_TEST_ENGINE === 'node:test') {
61
+ // We are on Node.js < 22.3.0 where even t.fullName doesn't exist yet, polyfill
62
+ const namePath = Symbol('namePath')
63
+ const getNamePath = Symbol('getNamePath')
64
+ try {
65
+ if (t[namePath]) return t[namePath]
66
+
67
+ // Sigh, ok, whatever
68
+ const { Test } = require('node:internal/test_runner/test')
69
+
70
+ const usePathName = Symbol('usePathName')
71
+ const restoreName = Symbol('restoreName')
72
+ Test.prototype[getNamePath] = function () {
73
+ if (this === this.root) return []
74
+ return [...(this.parent?.[getNamePath]() || []), this.name]
75
+ }
67
76
 
68
- const usePathName = Symbol('usePathName')
69
- const restoreName = Symbol('restoreName')
70
- Test.prototype[getNamePath] = function () {
71
- if (this === this.root) return []
72
- return [...(this.parent?.[getNamePath]() || []), this.name]
73
- }
77
+ const diagnostic = Test.prototype.diagnostic
78
+ Test.prototype.diagnostic = function (...args) {
79
+ if (args[0] === usePathName) {
80
+ this[restoreName] = this.name
81
+ this.name = this[getNamePath]()
82
+ return
83
+ }
74
84
 
75
- const diagnostic = Test.prototype.diagnostic
76
- Test.prototype.diagnostic = function (...args) {
77
- if (args[0] === usePathName) {
78
- this[restoreName] = this.name
79
- this.name = this[getNamePath]()
80
- return
81
- }
85
+ if (args[0] === restoreName) {
86
+ this.name = this[restoreName]
87
+ delete this[restoreName]
88
+ return
89
+ }
82
90
 
83
- if (args[0] === restoreName) {
84
- this.name = this[restoreName]
85
- delete this[restoreName]
86
- return
91
+ return diagnostic.apply(this, args)
87
92
  }
88
93
 
89
- return diagnostic.apply(this, args)
90
- }
91
-
92
- const TestContextProto = Object.getPrototypeOf(t)
93
- Object.defineProperty(TestContextProto, namePath, {
94
- get() {
95
- this.diagnostic(usePathName)
96
- const result = this.name
97
- this.diagnostic(restoreName)
98
- return result
99
- },
100
- })
101
-
102
- return t[namePath]
103
- } catch {}
94
+ const TestContextProto = Object.getPrototypeOf(t)
95
+ Object.defineProperty(TestContextProto, namePath, {
96
+ get() {
97
+ this.diagnostic(usePathName)
98
+ const result = this.name
99
+ this.diagnostic(restoreName)
100
+ return result
101
+ },
102
+ })
103
+
104
+ return t[namePath]
105
+ } catch {}
106
+ }
104
107
 
105
108
  return [t.name] // last resort
106
109
  }
package/src/jest.js CHANGED
@@ -222,9 +222,10 @@ export const jest = {
222
222
  features: {
223
223
  __proto__: null,
224
224
  timers: Boolean(mock.timers && haveValidTimers),
225
- esmMocks: Boolean(mock.module && !isBundle), // full support for ESM mocks
225
+ dynamicRequire: Boolean(!isBundle), // require(non-literal-non-glob), createRequire()(non-builtin)
226
+ esmMocks: Boolean(mock.module || isBundle), // support for ESM mocks
226
227
  esmInterop: Boolean(insideEsbuild && !isBundle), // loading/using ESM as CJS, ESM mocks creation without a mocker function
227
- esmNamedBuiltinMocks: Boolean(mock.module || (insideEsbuild && !isBundle)), // support for named ESM imports from builtin module mocks
228
+ esmNamedBuiltinMocks: Boolean(mock.module || insideEsbuild || isBundle), // support for named ESM imports from builtin module mocks
228
229
  concurrency: node.engine !== 'pure', // pure engine doesn't support concurrency
229
230
  },
230
231
  mock: {
package/src/jest.mock.js CHANGED
@@ -34,7 +34,38 @@ export const jestModuleMocks = {
34
34
  resetModules,
35
35
  }
36
36
 
37
+ if (process.env.EXODUS_TEST_ENVIRONMENT === 'bundle') {
38
+ globalThis.EXODUS_TEST_MOCK_BUILTINS = new Map()
39
+ Object.assign(jestModuleMocks, {
40
+ __mockBundle: wrap((name, builtin, actual, mock) =>
41
+ jestmock(name, mock, { actual, builtin, override: true })
42
+ ),
43
+ __doMockBundle: wrap((name, builtin, actual, mock) =>
44
+ jestmock(name, mock, { actual, builtin })
45
+ ),
46
+ })
47
+ }
48
+
49
+ // For bundles
50
+ const cjsSet = typeof __mocksCJSPossible === 'undefined' ? null : __mocksCJSPossible // eslint-disable-line no-undef
51
+ const esmSet = typeof __mocksESMPossible === 'undefined' ? null : __mocksESMPossible // eslint-disable-line no-undef
52
+
37
53
  function resolveModule(name) {
54
+ if (process.env.EXODUS_TEST_ENVIRONMENT === 'bundle') {
55
+ assert(name.startsWith('bundle:'), `Can't mock unresolved ${name} in bundle, use static syntax`)
56
+ assert(cjsSet && esmSet, 'Module mocking not installed correctly in bundle')
57
+ const id = name.replace(/^bundle:/u, '')
58
+ assert(!cjsSet?.has(id) || !esmSet?.has(id), 'CJS/ESM conflict in bundle mock')
59
+ assert(cjsSet?.has(id) || esmSet?.has(id), `Mock: can not find ${id} in bundle. Unused mock?`)
60
+ const cjs = `${id}.exodus-test-mock.cjs`
61
+ if (esmSet.has(id) && cjsSet.has(cjs)) {
62
+ assert(!esmSet.has(cjs))
63
+ return cjs
64
+ }
65
+
66
+ return id
67
+ }
68
+
38
69
  assert(requireIsRelative || /^[@a-zA-Z]/u.test(name), 'Mocking relative paths is not possible')
39
70
  const unprefixed = name.replace(/^node:/, '')
40
71
  if (builtinModules.includes(unprefixed)) return unprefixed
@@ -182,15 +213,16 @@ function mockCloneItem(obj, cache) {
182
213
  return null
183
214
  }
184
215
 
185
- function jestmock(name, mocker, { override = false } = {}) {
186
- assert(process.env.EXODUS_TEST_ENVIRONMENT !== 'bundle', 'module mocks unsupported from bundle') // TODO: can we do something?
187
-
216
+ function jestmock(name, mocker, { override = false, actual, builtin } = {}) {
188
217
  // Loaded ESM: isn't mocked
189
218
  // Loaded CJS: mocked via object overriding
190
219
  // Loaded built-ins: mocked via object overriding where possible
191
- // New CJS: mocked via mock.module + require.cache
192
- // New ESM: mocked via mock.module
220
+ // New CJS, doMock CJS: mocked via mock.module + require.cache
221
+ // New ESM, doMock ESM: mocked via mock.module
193
222
  // New built-ins: mocked via mock.module
223
+ // [Bundled] New CJS, doMock CJS: mocked via bundle hook
224
+ // [Bundled] New ESM, doMock ESM: isn't mocked
225
+ // [Bundled] New built-ins: mocked via bundle hook
194
226
 
195
227
  const resolved = resolveModule(name)
196
228
  assert(!mapMocks.has(resolved), 'Re-mocking the same module is not supported')
@@ -199,14 +231,24 @@ function jestmock(name, mocker, { override = false } = {}) {
199
231
  'Built-in modules mocked with jest.mock can not be remocked, use jest.doMock'
200
232
  )
201
233
 
234
+ let havePrior
235
+ if (process.env.EXODUS_TEST_ENVIRONMENT === 'bundle') {
236
+ havePrior = __mocksCJSLoaded.has(resolved) || __mocksESMLoaded.has(resolved) // eslint-disable-line no-undef
237
+ assert(actual)
238
+ } else {
239
+ havePrior = Object.hasOwn(require.cache, resolved)
240
+ assert(!actual && !builtin)
241
+ }
242
+
202
243
  // Attempt to load it
203
244
  // Jest also loads modules on mock
204
245
  // Can be ESM, so let it fail silently
205
- const havePrior = Object.hasOwn(require.cache, resolved)
206
246
  try {
207
- mapActual.set(resolved, require(resolved))
247
+ assert(!resolved.endsWith('.exodus-test-mock.cjs')) // actual() would attempt to load non-wrapped ESM here
248
+ mapActual.set(resolved, actual ? actual() : require(resolved))
208
249
  } catch {
209
- assert(mocker, 'Can not auto-clone a native ESM module without --esbuild or newer Node.js')
250
+ const reason = actual ? 'in bundle' : 'without --esbuild or newer Node.js'
251
+ assert(mocker, `Can not auto-clone a native ESM module ${reason}`)
210
252
  }
211
253
 
212
254
  const expand = (obj) => (isObject(obj) ? { ...obj } : obj)
@@ -214,6 +256,22 @@ function jestmock(name, mocker, { override = false } = {}) {
214
256
  mapMocks.set(resolved, value)
215
257
 
216
258
  loadExpect() // we need to do this as we don't want mocks affecting expect
259
+
260
+ if (process.env.EXODUS_TEST_ENVIRONMENT === 'bundle') {
261
+ if (builtin) globalThis.EXODUS_TEST_MOCK_BUILTINS.set(builtin, value)
262
+ if (havePrior && override) overrideModule(resolved) // This won't work on ESM
263
+
264
+ if (cjsSet?.has(resolved)) {
265
+ __mocksCJS.set(resolved, value) // eslint-disable-line no-undef
266
+ } else if (esmSet?.has(resolved)) {
267
+ throw new Error('ESM module mocks are not supported from bundle') // TODO: can we do something?
268
+ } else {
269
+ throw new Error('unreachable')
270
+ }
271
+
272
+ return this
273
+ }
274
+
217
275
  const topLevelESM = isTopLevelESM()
218
276
  let likelyESM = topLevelESM && !insideEsbuild && ![null, resolved].includes(resolveImport(name))
219
277
  let okFromESM = false