@exodus/test 1.0.0-rc.26 → 1.0.0-rc.28

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
@@ -2,9 +2,11 @@
2
2
 
3
3
  import { spawn } from 'node:child_process'
4
4
  import { once } from 'node:events'
5
- import { fileURLToPath } from 'node:url'
6
- import { basename, dirname, resolve } from 'node:path'
5
+ import { fileURLToPath, pathToFileURL } from 'node:url'
6
+ import { basename, dirname, resolve, join } from 'node:path'
7
7
  import { createRequire } from 'node:module'
8
+ import { randomUUID } from 'node:crypto'
9
+ import { existsSync } from 'node:fs'
8
10
  import assert from 'node:assert/strict'
9
11
  import glob from 'fast-glob'
10
12
  import { haveModuleMocks, haveSnapshots, haveForceExit } from '../src/version.js'
@@ -14,11 +16,17 @@ const bindir = dirname(fileURLToPath(import.meta.url))
14
16
  const EXTS = `.?([cm])[jt]s?(x)` // we differ from jest, allowing [cm] before everything
15
17
  const DEFAULT_PATTERNS = [`**/__tests__/**/*${EXTS}`, `**/?(*.)+(spec|test)${EXTS}`]
16
18
 
19
+ const bundleOptions = { pure: true, bundle: true, esbuild: true }
17
20
  const ENGINES = new Map(
18
21
  Object.entries({
19
22
  'node:test': { binary: 'node', pure: false, hasImportLoader: true },
20
23
  'node:pure': { binary: 'node', pure: true, hasImportLoader: true },
24
+ 'node:bundle': { binary: 'node', ...bundleOptions },
21
25
  'bun:pure': { binary: 'bun', pure: true, hasImportLoader: false },
26
+ 'bun:bundle': { binary: 'bun', ...bundleOptions },
27
+ 'deno:bundle': { binary: 'deno', binaryArgs: ['run'], target: 'deno1', ...bundleOptions },
28
+ 'jsc:bundle': { binary: 'jsc', ...bundleOptions, target: 'safari11' },
29
+ 'hermes:bundle': { binary: 'hermes', binaryArgs: ['-Og'], target: 'es2018', ...bundleOptions },
22
30
  })
23
31
  )
24
32
 
@@ -37,10 +45,6 @@ function parseOptions() {
37
45
  debug: { files: false },
38
46
  ideaCompat: false,
39
47
  engine: process.env.EXODUS_TEST_ENGINE ?? 'node:test',
40
- // Engine options
41
- binary: 'node',
42
- pure: false,
43
- hasImportLoader: false,
44
48
  }
45
49
 
46
50
  const args = [...process.argv]
@@ -152,6 +156,11 @@ const resolveImport = import.meta.resolve && ((query) => fileURLToPath(import.me
152
156
 
153
157
  const args = []
154
158
  if (options.pure) {
159
+ if (options.bundle) {
160
+ assert(!options.coverage, `Can not use --coverage with ${options.engine} engine`)
161
+ assert(!options.babel, `Can not use --babel with ${options.engine} engine`) // TODO?
162
+ }
163
+
155
164
  const requiresNodeCoverage = options.coverage && options.coverageEngine === 'node'
156
165
  assert(!requiresNodeCoverage, '"--coverage-engine node" requires "--engine node:test" (default)')
157
166
  assert(!options.writeSnapshots, `Can not use write snapshots with ${options.engine} engine`)
@@ -192,10 +201,14 @@ if (process.env.EXODUS_TEST_IGNORE) {
192
201
 
193
202
  // The comment below is disabled, we don't auto-mock @jest/globals anymore, and having our loader first is faster
194
203
  // [Disabled] Our loader should be last, as enabling module mocks confuses other loaders
204
+ let jestConfig = null
195
205
  if (options.jest) {
196
206
  const { loadJestConfig } = await import('../src/jest.config.js')
197
207
  const config = await loadJestConfig(process.cwd())
198
- args.push(options.hasImportLoader ? '--import' : '-r', resolve(bindir, 'jest.js'))
208
+ jestConfig = config
209
+ if (!options.bundle) {
210
+ args.push(options.hasImportLoader ? '--import' : '-r', resolve(bindir, 'jest.js'))
211
+ }
199
212
 
200
213
  if (config.testFailureExitCode !== undefined) {
201
214
  if (Number(config.testFailureExitCode) === 0) {
@@ -229,12 +242,15 @@ if (options.jest) {
229
242
  }
230
243
  }
231
244
 
232
- if (options.esbuild) {
245
+ if (options.esbuild && !options.bundle) {
233
246
  assert(resolveImport)
234
247
  if (options.hasImportLoader) {
235
248
  args.push('--import', resolveImport('tsx'))
249
+ } else if (options.engine === process.env.EXODUS_TEST_ENGINE) {
250
+ console.warn(`Warning: ${options.engine} engine does not support --esbuild option`)
236
251
  } else {
237
- args.push('-r', resolveImport('tsx/cjs'))
252
+ console.error(`Error: ${options.engine} engine does not support --esbuild option`)
253
+ process.exit(1)
238
254
  }
239
255
  }
240
256
 
@@ -326,18 +342,223 @@ if (options.coverage) {
326
342
  }
327
343
  }
328
344
 
329
- assert(files.length > 0) // otherwise we can run recursively
330
- assert(options.binary && ['node', 'bun', c8].includes(options.binary))
331
345
  process.env.EXODUS_TEST_EXECARGV = JSON.stringify(args)
346
+ const inputs = files.map((file) => ({ source: file, file }))
347
+
348
+ if (options.bundle) {
349
+ const esbuild = await import('esbuild')
350
+ const { readFile, writeFile } = await import('node:fs/promises')
351
+ const { rmSync } = await import('node:fs')
352
+ const os = await import('node:os')
353
+ const outdir = join(os.tmpdir(), `exodus-test-${randomUUID().slice(0, 8)}`)
354
+ process.on('beforeExit', async () => rmSync(outdir, { recursive: true, force: true }))
355
+ assert.deepEqual(args, [])
356
+ if (options.binary === 'node') args.unshift('--enable-source-maps') // FIXME
357
+
358
+ const readSnapshots = async (ifiles) => {
359
+ const snapshots = []
360
+ for (const file of ifiles) {
361
+ for (const resolver of [
362
+ (dir, name) => [dir, `${name}.snapshot`], // node:test
363
+ (dir, name) => [dir, '__snapshots__', `${name}.snap`], // jest
364
+ ]) {
365
+ const snapshotFile = join(...resolver(dirname(file), basename(file)))
366
+ try {
367
+ snapshots.push([snapshotFile, await readFile(snapshotFile, 'utf8')])
368
+ } catch (e) {
369
+ if (e.code !== 'ENOENT') throw e
370
+ }
371
+ }
372
+ }
373
+
374
+ return snapshots
375
+ }
376
+
377
+ const loadPipeline = [
378
+ function (source, args) {
379
+ return source
380
+ .replace(/\bimport\.meta\.url\b/g, JSON.stringify(pathToFileURL(args.path)))
381
+ .replace(/\bimport\.meta\.dirname\b/g, JSON.stringify(dirname(args.path)))
382
+ .replace(/\bimport\.meta\.filename\b/g, JSON.stringify(basename(args.path)))
383
+ },
384
+ ]
385
+
386
+ const writePipeline = []
387
+ if (options.binary === 'hermes') {
388
+ const babel = await import('@babel/core')
389
+ writePipeline.push((source) => {
390
+ const result = babel.transformSync(source, {
391
+ compact: false,
392
+ plugins: [
393
+ '@babel/plugin-transform-arrow-functions',
394
+ '@babel/plugin-transform-class-properties',
395
+ '@babel/plugin-transform-classes',
396
+ '@babel/plugin-transform-block-scoping',
397
+ ],
398
+ })
399
+ return result.code
400
+ })
401
+ }
402
+
403
+ const buildOne = async (...ifiles) => {
404
+ const input = []
405
+ const importSource = async (file) => input.push(await readFile(resolveRequire(file), 'utf8'))
406
+ const importFile = (...args) => input.push(`await import(${JSON.stringify(resolve(...args))});`)
407
+
408
+ if (!['node', c8].includes(options.binary)) await importSource('../src/bundle-apis/globals.cjs')
409
+
410
+ if (options.jest) {
411
+ assert(jestConfig.rootDir)
412
+ const preload = [...(jestConfig.setupFiles || []), ...(jestConfig.setupFilesAfterEnv || [])]
413
+ if (jestConfig.testEnvironment && jestConfig.testEnvironment !== 'node') {
414
+ const { specialEnvironments } = await import('../src/jest.environment.js')
415
+ assert(Object.hasOwn(specialEnvironments, jestConfig.testEnvironment))
416
+ preload.push(...(specialEnvironments[jestConfig.testEnvironment].dependencies || []))
417
+ }
418
+
419
+ const local = createRequire(resolve(jestConfig.rootDir, 'package.json'))
420
+ const w = (f) => `[${JSON.stringify(f)}, () => require(${JSON.stringify(local.resolve(f))})]`
421
+ input.push(`globalThis.EXODUS_TEST_PRELOADED = [${preload.map((f) => w(f)).join(', ')}]`)
422
+ await importSource('./jest.js')
423
+ }
424
+
425
+ for (const file of ifiles) importFile(file)
426
+ const filename =
427
+ ifiles.length === 1 ? `${ifiles[0]}-${randomUUID().slice(0, 8)}` : `bundle-${randomUUID()}`
428
+ const outfile = `${join(outdir, filename)}.js`
429
+ const EXODUS_TEST_SNAPSHOTS = await readSnapshots(ifiles)
430
+ const build = async (opts) => esbuild.build(opts).catch((err) => ({ errors: [err] }))
431
+ let main = input.join('\n')
432
+ if (['jsc', 'hermes'].includes(options.binary)) {
433
+ main = `try {\n${main}\n} catch (err) { print(err); throw err }` // TODO: fix reporting
434
+ }
435
+
436
+ const stringify = (x) => ([undefined, null].includes(x) ? `${x}` : JSON.stringify(x))
437
+ const res = await build({
438
+ stdin: {
439
+ contents: `(async function () {\n${main}\n})()`,
440
+ resolveDir: bindir,
441
+ },
442
+ bundle: true,
443
+ outdir,
444
+ entryNames: filename,
445
+ platform: 'neutral',
446
+ mainFields: ['browser', 'module', 'main'],
447
+ define: {
448
+ 'process.env.FORCE_COLOR': stringify('0'),
449
+ 'process.env.NO_COLOR': stringify('1'),
450
+ 'process.env.NODE_ENV': stringify(process.env.NODE_ENV),
451
+ 'process.env.EXODUS_TEST_CONTEXT': stringify('pure'),
452
+ 'process.env.EXODUS_TEST_ENVIRONMENT': stringify('bundle'),
453
+ 'process.env.EXODUS_TEST_PLATFORM': stringify(process.env.EXODUS_TEST_PLATFORM),
454
+ 'process.env.EXODUS_TEST_JEST_CONFIG': stringify(JSON.stringify(jestConfig)),
455
+ 'process.env.EXODUS_TEST_EXECARGV': stringify(process.env.EXODUS_TEST_EXECARGV),
456
+ 'process.env.NODE_DEBUG': stringify(),
457
+ 'process.env.READABLE_STREAM': stringify(),
458
+ 'process.env.CI': stringify(process.env.CI),
459
+ 'process.env.CI_ENABLE_VERBOSE_LOGS': stringify(process.env.CI_ENABLE_VERBOSE_LOGS),
460
+ 'process.browser': stringify(true),
461
+ 'process.emitWarning': 'undefined',
462
+ 'process.stderr': 'undefined',
463
+ 'process.stdout': 'undefined',
464
+ EXODUS_TEST_FILES: stringify(ifiles.map((f) => [dirname(f), basename(f)])),
465
+ EXODUS_TEST_SNAPSHOTS: stringify(EXODUS_TEST_SNAPSHOTS),
466
+ },
467
+ alias: {
468
+ // Node browserify
469
+ 'node:assert': resolveRequire('assert'),
470
+ 'node:assert/strict': resolveRequire('../src/bundle-apis/assert-strict.cjs'),
471
+ assert: resolveRequire('assert'),
472
+ buffer: resolveRequire('buffer'),
473
+ crypto: resolveRequire('crypto-browserify'),
474
+ events: resolveRequire('events'),
475
+ os: resolveRequire('os-browserify'),
476
+ path: resolveRequire('path-browserify'),
477
+ stream: resolveRequire('stream-browserify'),
478
+ util: resolveRequire('util'),
479
+ // expect-related deps
480
+ 'ansi-styles': resolveRequire('../src/bundle-apis/ansi-styles.cjs'),
481
+ 'jest-util': resolveRequire('../src/bundle-apis/jest-util.js'),
482
+ 'jest-message-util': resolveRequire('../src/bundle-apis/jest-message-util.js'),
483
+ },
484
+ sourcemap: writePipeline.length > 0 ? 'inline' : 'linked',
485
+ sourcesContent: false,
486
+ keepNames: true,
487
+ format: 'iife',
488
+ target: options.target || `node${process.versions.node}`,
489
+ supported: {
490
+ bigint: true,
491
+ },
492
+ plugins: [
493
+ {
494
+ name: 'exodus-test.bundle',
495
+ setup({ onLoad }) {
496
+ onLoad({ filter: /\.m?js$/, namespace: 'file' }, async (args) => {
497
+ let contents = await readFile(args.path, 'utf8')
498
+ for (const transform of loadPipeline) contents = await transform(contents, args)
499
+ return { contents }
500
+ })
501
+ },
502
+ },
503
+ ],
504
+ })
505
+
506
+ if (writePipeline.length > 0 && res.errors.length === 0) {
507
+ let contents = await readFile(outfile, 'utf8')
508
+ for (const transform of writePipeline) contents = await transform(contents)
509
+ await writeFile(outfile, contents)
510
+ }
511
+
512
+ // require('fs').copyFileSync(outfile, 'tempout.cjs') // DEBUG
513
+ return { file: outfile, errors: res.errors, warnings: res.warnings }
514
+ }
515
+
516
+ for (const input of inputs) Object.assign(input, await buildOne(input.file)) // TODO: queued concurrency
517
+ }
518
+
519
+ assert.equal(inputs.length, files.length)
520
+ assert(options.binary && ['node', 'bun', 'deno', 'jsc', 'hermes', c8].includes(options.binary))
332
521
 
333
522
  if (options.pure) {
523
+ if (options.binary === 'hermes') {
524
+ const dir = dirname(require.resolve('hermes-engine-cli/package.json'))
525
+ switch (process.platform) {
526
+ case 'darwin':
527
+ process.env.PATH = `${join(dir, 'osx-bin')}:${process.env.PATH}`
528
+ break
529
+ case 'linux':
530
+ process.env.PATH = `${join(dir, 'linux64-bin')}:${process.env.PATH}`
531
+ break
532
+ case 'win32':
533
+ process.env.PATH = `${join(dir, 'win64-bin')}:${process.env.PATH}`
534
+ break
535
+ default:
536
+ assert.fail(`Unexpected platform: ${process.platform}`)
537
+ }
538
+ } else if (options.binary === 'jsc' && process.platform === 'darwin') {
539
+ const prefix = '/System/Library/Frameworks/JavaScriptCore.framework/Versions/A'
540
+ for (const dir of [`${prefix}/Helpers`, `${prefix}/Resources`]) {
541
+ if (existsSync(join(dir, 'jsc'))) {
542
+ process.env.PATH = `${dir}:${process.env.PATH}`
543
+ break
544
+ }
545
+ }
546
+ }
547
+
334
548
  process.env.EXODUS_TEST_CONTEXT = 'pure'
335
549
  console.warn(`\n${options.engine} engine is experimental and may not work an expected\n\n`)
336
550
  const failures = []
337
- for (const file of files) {
338
- const node = spawn(options.binary, [...args, file], { stdio: 'inherit' })
551
+ for (const input of inputs) {
552
+ if (input.errors?.length > 0 || input.warnings?.length > 0) {
553
+ failures.push(input.source)
554
+ continue
555
+ }
556
+
557
+ const { binaryArgs = [] } = options
558
+ console.log(`# ${input.source}`)
559
+ const node = spawn(options.binary, [...binaryArgs, ...args, input.file], { stdio: 'inherit' })
339
560
  const [code] = await once(node, 'close')
340
- if (code !== 0) failures.push(file)
561
+ if (code !== 0) failures.push(input.source)
341
562
  }
342
563
 
343
564
  if (failures.length > 0) {
@@ -350,9 +571,11 @@ if (options.pure) {
350
571
  console.log(`All ${files.length} test suites passed`)
351
572
  }
352
573
  } else {
353
- assert(['node', c8].includes(options.binary), 'Native test engine is only supported with Node.js')
354
- assert.equal(options.engine, 'node:test')
355
- process.env.EXODUS_TEST_CONTEXT = 'node:test'
574
+ assert(['node', c8].includes(options.binary), `Unexpected native engine: ${options.binary}`)
575
+ assert(['node:test'].includes(options.engine))
576
+ process.env.EXODUS_TEST_CONTEXT = options.engine
577
+ assert(files.length > 0) // otherwise we can run recursively
578
+ assert(!options.binaryArgs)
356
579
  const node = spawn(options.binary, [...args, ...files], { stdio: 'inherit' })
357
580
  const [code] = await once(node, 'close')
358
581
  process.exitCode = code
package/bin/jest.js CHANGED
@@ -1,5 +1,4 @@
1
- import { loadJestConfig, installJestEnvironment } from '../src/jest.config.js'
2
-
1
+ const { loadJestConfig, installJestEnvironment } = await import('../src/jest.config.js')
3
2
  await loadJestConfig()
4
3
  const jestGlobals = await import('../src/jest.js')
5
4
  await installJestEnvironment(jestGlobals)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/test",
3
- "version": "1.0.0-rc.26",
3
+ "version": "1.0.0-rc.28",
4
4
  "author": "Exodus Movement, Inc.",
5
5
  "description": "A test suite runner",
6
6
  "homepage": "https://github.com/ExodusMovement/test",
@@ -37,6 +37,12 @@
37
37
  "files": [
38
38
  "bin/babel.cjs",
39
39
  "bin/jest.js",
40
+ "src/bundle-apis/ansi-styles.cjs",
41
+ "src/bundle-apis/assert-strict.cjs",
42
+ "src/bundle-apis/globals.cjs",
43
+ "src/bundle-apis/jest-message-util.js",
44
+ "src/bundle-apis/jest-util.js",
45
+ "src/bundle-apis/util-format.js",
40
46
  "src/dark.cjs",
41
47
  "src/engine.js",
42
48
  "src/engine.node.cjs",
@@ -58,8 +64,8 @@
58
64
  ],
59
65
  "scripts": {
60
66
  "test": "npm run test:jest",
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'",
67
+ "test:all": "npm run test:jest && npm run test:tape && npm run test:native && npm run test:pure",
68
+ "test:native": "EXODUS_TEST_IGNORE='{**/typescript/**,**/jest-repo/**/user.test.js}' ./bin/index.js --jest '__test__/**/*.test.{js,cjs,mjs}'",
63
69
  "test:jest": "./bin/index.js --jest --esbuild",
64
70
  "test:tape": "./bin/index.js --esbuild '__test__/tape/test/*.js' __test__/tape.test.js",
65
71
  "test:pure": "EXODUS_TEST_ENGINE=node:pure npm run test",
@@ -69,15 +75,28 @@
69
75
  "lint:fix": "prettier --write . && eslint --fix ."
70
76
  },
71
77
  "dependencies": {
78
+ "@babel/core": "^7.24.9",
79
+ "@babel/plugin-transform-arrow-functions": "^7.24.7",
80
+ "@babel/plugin-transform-block-scoping": "^7.24.7",
81
+ "@babel/plugin-transform-class-properties": "^7.24.7",
82
+ "@babel/plugin-transform-classes": "^7.24.8",
72
83
  "@babel/register": "^7.0.0",
84
+ "assert": "^2.1.0",
85
+ "buffer": "^6.0.3",
73
86
  "c8": "^9.1.0",
87
+ "crypto-browserify": "^3.12.0",
74
88
  "esbuild": "~0.21.5",
89
+ "events": "^3.3.0",
75
90
  "expect": "^29.7.0",
76
91
  "fast-glob": "^3.2.11",
77
92
  "jest-extended": "^4.0.2",
78
93
  "jsdom": "^24.1.0",
94
+ "os-browserify": "^0.3.0",
95
+ "path-browserify": "^1.0.1",
79
96
  "pretty-format": "^29.7.0",
80
- "tsx": "^4.16.2"
97
+ "stream-browserify": "^3.0.0",
98
+ "tsx": "^4.16.2",
99
+ "util": "^0.12.5"
81
100
  },
82
101
  "devDependencies": {
83
102
  "@exodus/eslint-config": "^5.24.0",
@@ -91,5 +110,13 @@
91
110
  "jest-serializer-ansi-escapes": "^3.0.0",
92
111
  "jest-when": "^3.6.0"
93
112
  },
113
+ "peerDependencies": {
114
+ "hermes-engine-cli": "^0.12.0"
115
+ },
116
+ "peerDependenciesMeta": {
117
+ "hermes-engine-cli": {
118
+ "optional": true
119
+ }
120
+ },
94
121
  "packageManager": "pnpm@9.4.0+sha1.9217c800d4ab947a7aee520242a7b70d64fc7638"
95
122
  }
@@ -0,0 +1,49 @@
1
+ const colors = [
2
+ 'reset',
3
+ 'bold',
4
+ 'dim',
5
+ 'italic',
6
+ 'underline',
7
+ 'overline',
8
+ 'inverse',
9
+ 'hidden',
10
+ 'strikethrough',
11
+ 'black',
12
+ 'red',
13
+ 'green',
14
+ 'yellow',
15
+ 'blue',
16
+ 'magenta',
17
+ 'cyan',
18
+ 'white',
19
+ 'blackBright',
20
+ 'redBright',
21
+ 'greenBright',
22
+ 'yellowBright',
23
+ 'blueBright',
24
+ 'magentaBright',
25
+ 'cyanBright',
26
+ 'whiteBright',
27
+ 'gray',
28
+ 'grey',
29
+ 'bgBlack',
30
+ 'bgRed',
31
+ 'bgGreen',
32
+ 'bgYellow',
33
+ 'bgBlue',
34
+ 'bgMagenta',
35
+ 'bgCyan',
36
+ 'bgWhite',
37
+ 'bgBlackBright',
38
+ 'bgRedBright',
39
+ 'bgGreenBright',
40
+ 'bgYellowBright',
41
+ 'bgBlueBright',
42
+ 'bgMagentaBright',
43
+ 'bgCyanBright',
44
+ 'bgWhiteBright',
45
+ 'bgGray',
46
+ 'bgGrey',
47
+ ]
48
+
49
+ for (const key of colors) exports[key] = { open: '', close: '' }
@@ -0,0 +1 @@
1
+ module.exports = require('node:assert').strict
@@ -0,0 +1,58 @@
1
+ if (!globalThis.global) globalThis.global = globalThis
2
+ if (!globalThis.Buffer) globalThis.Buffer = require('buffer').Buffer
3
+ if (!globalThis.console) {
4
+ // eslint-disable-next-line no-undef
5
+ globalThis.console = { log: print, error: print, warn: print, info: print, debug: print }
6
+ }
7
+
8
+ if (!globalThis.fetch) {
9
+ globalThis.fetch = () => {
10
+ throw new Error('Fetch not supported')
11
+ }
12
+ }
13
+
14
+ if (!globalThis.WebSocket) {
15
+ globalThis.WebSocket = () => {
16
+ throw new Error('WebSocket not supported')
17
+ }
18
+ }
19
+
20
+ if (!Array.prototype.at) {
21
+ // eslint-disable-next-line no-extend-native
22
+ Array.prototype.at = function (i) {
23
+ return this[i < 0 ? this.length + i : i]
24
+ }
25
+ }
26
+
27
+ if (globalThis.describe) delete globalThis.describe
28
+
29
+ if (process.env.EXODUS_TEST_PLATFORM === 'hermes') {
30
+ // Ok, we have broken timers, let's hack them around
31
+ let i = 0
32
+ const timers = new Map()
33
+ const { setTimeout, clearTimeout } = globalThis
34
+ const dateNow = Date.now
35
+ globalThis.setTimeout = (fn, time) => {
36
+ const id = `ht${i++}`
37
+ const now = dateNow()
38
+ const tick = () => {
39
+ if (!timers.has(id)) return
40
+ const remaining = now + time - dateNow()
41
+ if (remaining < 0) {
42
+ timers.delete(id)
43
+ fn()
44
+ } else {
45
+ timers.set(id, setTimeout(tick, remaining))
46
+ }
47
+ }
48
+
49
+ timers.set(id, setTimeout(tick, time))
50
+ }
51
+
52
+ globalThis.clearTimeout = (id) => {
53
+ if (!timers.has(id)) return
54
+ clearTimeout(timers.get(id))
55
+ timers.delete(id)
56
+ }
57
+ // TODO: setInterval, clearInterval
58
+ }
@@ -0,0 +1,5 @@
1
+ // Used in expect toThrowMatchers
2
+ // TODO: figure out if a better impl is actually needed
3
+
4
+ export const formatStackTrace = (stack) => stack
5
+ export const separateMessageFromStack = (content) => ({ stack: content })
@@ -0,0 +1,22 @@
1
+ // APIs used in expect()
2
+
3
+ export { isPromise } from '../engine.js'
4
+
5
+ var NUMS = [
6
+ 'zero',
7
+ 'one',
8
+ 'two',
9
+ 'three',
10
+ 'four',
11
+ 'five',
12
+ 'six',
13
+ 'seven',
14
+ 'eight',
15
+ 'nine',
16
+ 'ten',
17
+ 'eleven',
18
+ 'twelve',
19
+ 'thirteen',
20
+ ]
21
+
22
+ export const pluralize = (word, count) => `${NUMS[count] || count} ${word}${count === 1 ? '' : 's'}`
package/src/dark.cjs CHANGED
@@ -51,7 +51,7 @@ function createCallerLocationHook() {
51
51
  return { installLocationInNextTest, getCallerLocation }
52
52
  }
53
53
 
54
- // Easy on Node.js >= 22.3.0, but we polyfill for the rest
54
+ // Easy on Node.js >= 22.3.0 || ^20.16.0, but we polyfill for the rest
55
55
  function getTestNamePath(t) {
56
56
  // No implementation in Node.js yet, will have to PR
57
57
  if (t.fullName) return t.fullName.split(' > ')
@@ -108,7 +108,7 @@ function getTestNamePath(t) {
108
108
  const execArgv = process.env.EXODUS_TEST_EXECARGV
109
109
  ? JSON.parse(process.env.EXODUS_TEST_EXECARGV)
110
110
  : process.execArgv
111
- const insideEsbuild = execArgv.some((x) => x.includes('node_modules/tsx/dist/'))
111
+ const insideEsbuild = execArgv.some((x) => x.endsWith('node_modules/tsx/dist/loader.mjs'))
112
112
 
113
113
  function makeEsbuildMockable() {
114
114
  if (!insideEsbuild) return
package/src/engine.js CHANGED
@@ -15,8 +15,8 @@ export { builtinModules, syncBuiltinESMExports }
15
15
  const { utilFormat, isPromise, nodeVersion } = engine
16
16
  export { utilFormat, isPromise, nodeVersion }
17
17
 
18
- const { baseFile, relativeRequire, isTopLevelESM } = engine
19
- export { baseFile, relativeRequire, isTopLevelESM }
18
+ const { requireIsRelative, relativeRequire, isTopLevelESM } = engine
19
+ export { requireIsRelative, relativeRequire, isTopLevelESM }
20
20
 
21
21
  const { readSnapshot, setSnapshotSerializers, setSnapshotResolver } = engine
22
22
  export { readSnapshot, setSnapshotSerializers, setSnapshotResolver }
@@ -14,6 +14,7 @@ const nodeVersion = process.versions.node
14
14
  const files = process.argv.slice(1)
15
15
  const baseFile = files.length === 1 && existsSync(files[0]) ? normalize(files[0]) : undefined
16
16
  const relativeRequire = baseFile ? createRequire(baseFile) : require
17
+ const requireIsRelative = Boolean(baseFile)
17
18
  const isTopLevelESM = () => !baseFile || !Object.hasOwn(relativeRequire.cache, baseFile) // assume ESM otherwise
18
19
 
19
20
  const snapshot = nodeTest.snapshot
@@ -33,7 +34,7 @@ module.exports = {
33
34
  ...{ mock, describe, test, beforeEach, afterEach, before, after },
34
35
  ...{ builtinModules, syncBuiltinESMExports },
35
36
  ...{ utilFormat, isPromise, nodeVersion },
36
- ...{ baseFile, relativeRequire, isTopLevelESM },
37
+ ...{ requireIsRelative, relativeRequire, isTopLevelESM },
37
38
  ...{ readSnapshot, setSnapshotSerializers, setSnapshotResolver },
38
39
  }
39
40
  /* eslint-enable unicorn/no-useless-spread */
@@ -1,9 +1,5 @@
1
1
  const assert = require('node:assert/strict')
2
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 { createRequire, builtinModules, syncBuiltinESMExports } = require('node:module')
7
3
 
8
4
  const { setTimeout, setInterval, setImmediate, Date } = globalThis
9
5
  const { clearTimeout, clearInterval, clearImmediate } = globalThis
@@ -57,6 +53,14 @@ async function runFunction(fn, context) {
57
53
  return new Promise((resolve, reject) => fn(context, (err) => (err ? reject(err) : resolve())))
58
54
  }
59
55
 
56
+ let fallbackExitCode = 0
57
+ const failExitCode = (...args) => {
58
+ console.log(...args)
59
+ if (globalThis.process) return (globalThis.process.exitCode = 1)
60
+ if (globalThis.Deno) return (globalThis.Deno.exitCode = 1)
61
+ return (fallbackExitCode = 1)
62
+ }
63
+
60
64
  async function runContext(context) {
61
65
  const { options, children, hooks, fn } = context
62
66
  assert(!context.running, 'Can not run twice')
@@ -81,10 +85,7 @@ async function runContext(context) {
81
85
  for (const c of stack) for (const hook of c.hooks.afterEach) await runFunction(hook, context)
82
86
 
83
87
  console.log(error === undefined ? '✔ PASS' : '✖ FAIL', context.fullName)
84
- if (error) {
85
- console.log(' ', error)
86
- if (globalThis.process) globalThis.process.exitCode = 1
87
- }
88
+ if (error) failExitCode(' ', error)
88
89
  } else {
89
90
  // if (context !== context.root) console.log(`▶ ${context.fullName}`)
90
91
  // TODO: try/catch for hooks?
@@ -100,11 +101,15 @@ async function run() {
100
101
  assert(!running)
101
102
  running = true
102
103
  assert(context === context.root)
103
- runContext(context).catch((error) => {
104
+ await runContext(context).catch((error) => {
104
105
  // Should not throw under regular circumstances
105
- console.log('Fatal: ', error)
106
- if (globalThis.process) globalThis.process.exitCode = 1
106
+ failExitCode('Fatal: ', error)
107
107
  })
108
+ if (fallbackExitCode !== 0) {
109
+ setTimeout(() => {
110
+ throw new Error('Test failed')
111
+ }, 0)
112
+ }
108
113
  }
109
114
 
110
115
  function describe(...args) {
@@ -152,7 +157,19 @@ class MockTimers {
152
157
  }
153
158
 
154
159
  if (apis.includes('setImmediate')) {
155
- globalThis.setImmediate = this.#setImmediate.bind(this)
160
+ if (process.env.EXODUS_TEST_PLATFORM === 'hermes') {
161
+ // Sigh, these are used internally
162
+ const isInternal = (x) =>
163
+ x.includes('at handleResolved ') || x.includes('/InternalBytecode/InternalBytecode')
164
+ globalThis.setImmediate = (...args) => {
165
+ const { stack } = new Error() // eslint-disable-line unicorn/error-message
166
+ if (isInternal(stack.split('\n')[2])) return setImmediate(...args)
167
+ return this.#setImmediate(...args)
168
+ }
169
+ } else {
170
+ globalThis.setImmediate = this.#setImmediate.bind(this)
171
+ }
172
+
156
173
  globalThis.clearImmediate = this.#clearImmediate.bind(this)
157
174
  }
158
175
 
@@ -306,17 +323,40 @@ const after = (fn) => context.hooks.after.push(fn)
306
323
  const isPromise = (x) => Boolean(x && x.then && x.catch && x.finally)
307
324
  const nodeVersion = '9999.99.99'
308
325
 
309
- const files = process.argv.slice(1)
310
- const baseFile = files.length === 1 && existsSync(files[0]) ? normalize(files[0]) : undefined
311
- const relativeRequire = baseFile ? createRequire(baseFile) : require
312
- const isTopLevelESM = () =>
313
- !baseFile || // assume ESM otherwise
314
- !Object.hasOwn(relativeRequire.cache, baseFile) || // node esm
315
- relativeRequire.cache[baseFile].exports[Symbol.toStringTag] === 'Module' // bun esm
326
+ let builtinModules = []
327
+ let requireIsRelative = false
328
+ let relativeRequire, isTopLevelESM, syncBuiltinESMExports, readSnapshot, utilFormat
329
+ if (process.env.EXODUS_TEST_ENVIRONMENT === 'bundle') {
330
+ // eslint-disable-next-line no-undef
331
+ const files = EXODUS_TEST_FILES
332
+ const baseFile = files.length === 1 ? files[0] : undefined
333
+ isTopLevelESM = () => false
334
+ // eslint-disable-next-line no-undef
335
+ const bundleSnaps = typeof EXODUS_TEST_SNAPSHOTS !== 'undefined' && new Map(EXODUS_TEST_SNAPSHOTS)
336
+ const resolveSnapshot = (f) => snapshotResolver(f[0], f[1]).join('/')
337
+ readSnapshot = (f = baseFile) => (f ? bundleSnaps.get(resolveSnapshot(f)) : null)
338
+ utilFormat = require('./bundle-apis/util-format.cjs')
339
+ } else {
340
+ const { existsSync, readFileSync } = require('node:fs')
341
+ const { dirname, basename, normalize, join } = require('node:path')
342
+ const nodeModule = require('node:module')
343
+ const files = process.argv.slice(1)
344
+ const baseFile = files.length === 1 && existsSync(files[0]) ? normalize(files[0]) : undefined
345
+ requireIsRelative = Boolean(baseFile)
346
+ relativeRequire = baseFile ? nodeModule.createRequire(baseFile) : require
347
+ isTopLevelESM = () =>
348
+ !baseFile || // assume ESM otherwise
349
+ !Object.hasOwn(relativeRequire.cache, baseFile) || // node esm
350
+ relativeRequire.cache[baseFile].exports[Symbol.toStringTag] === 'Module' // bun esm
351
+ const resolveSnapshot = (f) => join(...snapshotResolver(dirname(f), basename(f)))
352
+ readSnapshot = (f = baseFile) => (f ? readFileSync(resolveSnapshot(f), 'utf8') : null)
353
+ builtinModules = nodeModule.builtinModules
354
+ syncBuiltinESMExports = nodeModule.syncBuiltinESMExports || nodeModule.syncBuiltinExports // bun has it under a different name (also a no-op and always synced atm)
355
+ utilFormat = require('node:util').format
356
+ }
316
357
 
358
+ // eslint-disable-next-line no-undef
317
359
  let snapshotResolver = (dir, name) => [dir, `${name}.snapshot`] // default per Node.js docs
318
- const resolveSnapshot = (f) => pathJoin(...snapshotResolver(dirname(f), basename(f)))
319
- const readSnapshot = (f = baseFile) => (f ? readFileSync(resolveSnapshot(f), 'utf8') : null)
320
360
  const setSnapshotSerializers = () => {}
321
361
  const setSnapshotResolver = (fn) => {
322
362
  snapshotResolver = fn
@@ -331,7 +371,7 @@ module.exports = {
331
371
  ...{ mock, describe, test, beforeEach, afterEach, before, after },
332
372
  ...{ builtinModules, syncBuiltinESMExports },
333
373
  ...{ utilFormat, isPromise, nodeVersion },
334
- ...{ baseFile, relativeRequire, isTopLevelESM },
374
+ ...{ requireIsRelative, relativeRequire, isTopLevelESM },
335
375
  ...{ readSnapshot, setSnapshotSerializers, setSnapshotResolver },
336
376
  }
337
377
  /* eslint-enable unicorn/no-useless-spread */
@@ -1,7 +1,5 @@
1
1
  // Not using ./engine.js yet, might pass / embed already loaded config instead
2
2
  import assert from 'node:assert/strict'
3
- import path from 'node:path'
4
- import { createRequire } from 'node:module'
5
3
  import { specialEnvironments } from './jest.environment.js'
6
4
 
7
5
  const normalizeJestConfig = (config) => ({
@@ -75,6 +73,12 @@ export async function loadJestConfig(...args) {
75
73
 
76
74
  config = normalizeJestConfig(rawConfig)
77
75
  verifyJestConfig(config)
76
+
77
+ // require is already relative to rootDir
78
+ const cleanFile = (file) => file.replace(/^<rootDir>\//g, './')
79
+ config.setupFiles = config.setupFiles?.map((f) => cleanFile(f))
80
+ config.setupFilesAfterEnv = config.setupFilesAfterEnv?.map((f) => cleanFile(f))
81
+
78
82
  return config
79
83
  }
80
84
 
@@ -95,17 +99,29 @@ export async function installJestEnvironment(jestGlobals) {
95
99
  if (c.restoreMocks) beforeEach(() => jest.restoreAllMocks())
96
100
  if (c.resetModules) beforeEach(() => jest.resetModules())
97
101
 
98
- const require = config.rootDir
99
- ? createRequire(path.resolve(config.rootDir, 'package.json'))
100
- : () => assert.fail('Unreachable: requiring plugins without a rootDir')
102
+ let require
103
+ if (process.env.EXODUS_TEST_ENVIRONMENT === 'bundle') {
104
+ const preloaded = new Map(EXODUS_TEST_PRELOADED) // eslint-disable-line no-undef
105
+ require = (name) => {
106
+ if (preloaded.has(name)) return preloaded.get(name)()
107
+ assert.fail('Requiring non-bundled plugins from bundle is unsupported')
108
+ }
109
+ } else if (config.rootDir) {
110
+ const { resolve } = await import('node:path')
111
+ const { createRequire } = await import('node:module')
112
+ require = createRequire(resolve(config.rootDir, 'package.json'))
113
+ } else {
114
+ require = () => assert.fail('Unreachable: requiring plugins without a rootDir')
115
+ }
116
+
117
+ for (const file of c.setupFiles || []) require(file)
101
118
 
102
119
  if (Object.hasOwn(specialEnvironments, c.testEnvironment)) {
103
- specialEnvironments[c.testEnvironment](require, engine, jestGlobals, c.testEnvironmentOptions)
120
+ const { setup } = specialEnvironments[c.testEnvironment]
121
+ await setup(require, engine, jestGlobals, c.testEnvironmentOptions)
104
122
  }
105
123
 
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, './'))
124
+ for (const file of c.setupFilesAfterEnv || []) require(file)
109
125
 
110
126
  // @jest/globals import auto-mocking is disabled until https://github.com/nodejs/node/issues/53807 is resolved
111
127
  /*
@@ -1,64 +1,72 @@
1
- import { getTestNamePath } from './dark.cjs'
2
-
3
1
  export const specialEnvironments = {
4
2
  __proto__: null,
5
3
 
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
- }
4
+ jsdom: {
5
+ dependencies: ['jsdom'],
6
+ setup: (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
23
 
24
- assignMissing(globalThis, window)
25
- assignMissing(console, window.console)
26
- Object.setPrototypeOf(global, Object.getPrototypeOf(window))
24
+ assignMissing(globalThis, window)
25
+ assignMissing(console, window.console)
26
+ Object.setPrototypeOf(global, Object.getPrototypeOf(window))
27
+ },
27
28
  },
28
29
 
29
30
  // Reproduces setup-polly-jest/jest-environment-node ad hacks into 'setup-polly-jest'.pollyJest
30
- 'setup-polly-jest/jest-environment-node': (require, engine) => {
31
- const { Polly } = require('@pollyjs/core')
32
- const pollyJest = require('setup-polly-jest')
33
- const { JestPollyGlobals, createPollyContextAccessor } = require('setup-polly-jest/lib/common')
34
- const pollyGlobals = new JestPollyGlobals(globalThis)
35
- pollyGlobals.isJestPollyEnvironment = true
36
- pollyJest.setupPolly = (options) => {
37
- if (!pollyGlobals.isJestPollyEnvironment) return
31
+ 'setup-polly-jest/jest-environment-node': {
32
+ dependencies: ['@pollyjs/core', 'setup-polly-jest', 'setup-polly-jest/lib/common'],
33
+ setup: async (require, engine) => {
34
+ const { getTestNamePath } = await import('./dark.cjs')
35
+ const { Polly } = require('@pollyjs/core')
36
+ const pollyJest = require('setup-polly-jest')
37
+ const {
38
+ JestPollyGlobals,
39
+ createPollyContextAccessor,
40
+ } = require('setup-polly-jest/lib/common')
41
+ const pollyGlobals = new JestPollyGlobals(globalThis)
42
+ pollyGlobals.isJestPollyEnvironment = true
43
+ pollyJest.setupPolly = (options) => {
44
+ if (!pollyGlobals.isJestPollyEnvironment) return
38
45
 
39
- engine.before(() => {
40
- pollyGlobals.isPollyActive = true
41
- pollyGlobals.pollyContext.options = options
42
- })
46
+ engine.before(() => {
47
+ pollyGlobals.isPollyActive = true
48
+ pollyGlobals.pollyContext.options = options
49
+ })
43
50
 
44
- engine.after(() => {
45
- pollyGlobals.isPollyActive = false
46
- pollyGlobals.pollyContext.options = null
47
- })
51
+ engine.after(() => {
52
+ pollyGlobals.isPollyActive = false
53
+ pollyGlobals.pollyContext.options = null
54
+ })
48
55
 
49
- return createPollyContextAccessor(pollyGlobals)
50
- }
56
+ return createPollyContextAccessor(pollyGlobals)
57
+ }
51
58
 
52
- engine.beforeEach((t) => {
53
- if (!pollyGlobals.isPollyActive) return
54
- const name = getTestNamePath(t).join('/')
55
- pollyGlobals.pollyContext.polly = new Polly(name, pollyGlobals.pollyContext.options)
56
- })
59
+ engine.beforeEach((t) => {
60
+ if (!pollyGlobals.isPollyActive) return
61
+ const name = getTestNamePath(t).join('/')
62
+ pollyGlobals.pollyContext.polly = new Polly(name, pollyGlobals.pollyContext.options)
63
+ })
57
64
 
58
- engine.afterEach(async () => {
59
- if (!pollyGlobals.pollyContext.polly) return
60
- await pollyGlobals.pollyContext.polly.stop()
61
- pollyGlobals.pollyContext.polly = null
62
- })
65
+ engine.afterEach(async () => {
66
+ if (!pollyGlobals.pollyContext.polly) return
67
+ await pollyGlobals.pollyContext.polly.stop()
68
+ pollyGlobals.pollyContext.polly = null
69
+ })
70
+ },
63
71
  },
64
72
  }
package/src/jest.js CHANGED
@@ -81,7 +81,10 @@ const makeEach =
81
81
  }
82
82
  }
83
83
 
84
- const forceExit = process.execArgv.map((x) => x.replaceAll('_', '-')).includes('--test-force-exit')
84
+ const execArgv = process.env.EXODUS_TEST_EXECARGV
85
+ ? JSON.parse(process.env.EXODUS_TEST_EXECARGV)
86
+ : process.execArgv
87
+ const forceExit = execArgv.map((x) => x.replaceAll('_', '-')).includes('--test-force-exit')
85
88
 
86
89
  const inConcurrent = []
87
90
  const inDescribe = []
@@ -149,26 +152,29 @@ node.afterEach(() => {
149
152
  for (const { error } of expect.extractExpectedAssertionsErrors()) throw error
150
153
  })
151
154
 
152
- node.after(() => {
153
- jestTimers.useRealTimers()
154
- const prefix = `Tests completed, but still have asynchronous activity after`
155
+ if (globalThis.process) {
156
+ node.after(() => {
157
+ jestTimers.useRealTimers()
158
+ const prefix = `Tests completed, but still have asynchronous activity after`
155
159
 
156
- // give everything additional (configurable) defaultTimeout time to finish, otherwide fail
157
- const timeout = defaultTimeout
158
- setTimeout(() => {
159
- console.error(`${prefix} additional ${timeout}ms. Terminating with a failure...`)
160
- process.exit(1)
161
- }, timeout).unref()
162
-
163
- // Warn after 5s that something is going on
164
- const warnTimeout = 5000
165
- if (warnTimeout < timeout + 1000) {
160
+ // give everything additional (configurable) defaultTimeout time to finish, otherwide fail
161
+ const timeout = defaultTimeout
166
162
  setTimeout(() => {
167
- console.warn(`${prefix} ${warnTimeout}ms. Waiting for ${timeout}ms to pass to finish...`)
168
- }, warnTimeout).unref()
169
- }
170
- })
163
+ console.error(`${prefix} additional ${timeout}ms. Terminating with a failure...`)
164
+ process.exit(1)
165
+ }, timeout).unref()
166
+
167
+ // Warn after 5s that something is going on
168
+ const warnTimeout = 5000
169
+ if (warnTimeout < timeout + 1000) {
170
+ setTimeout(() => {
171
+ console.warn(`${prefix} ${warnTimeout}ms. Waiting for ${timeout}ms to pass to finish...`)
172
+ }, warnTimeout).unref()
173
+ }
174
+ })
175
+ }
171
176
 
177
+ const isBundle = process.env.EXODUS_TEST_ENVIRONMENT === 'bundle' // TODO: improve mocking from bundle
172
178
  export const jest = {
173
179
  exodus: {
174
180
  __proto__: null,
@@ -177,9 +183,9 @@ export const jest = {
177
183
  platform: String(process.env.EXODUS_TEST_PLATFORM),
178
184
  engine: String(node.engine),
179
185
  timers: Boolean(mock.timers && haveValidTimers),
180
- esmMocks: Boolean(mock.module), // full support for ESM mocks
181
- esmClone: Boolean(insideEsbuild), // support for ESM mocks creation without a mocker function
182
- esmNamedBuiltinMocks: Boolean(mock.module || insideEsbuild), // support for named ESM imports from builtin module mocks
186
+ esmMocks: Boolean(mock.module && !isBundle), // full support for ESM mocks
187
+ esmInterop: Boolean(insideEsbuild && !isBundle), // loading/using ESM as CJS, ESM mocks creation without a mocker function
188
+ esmNamedBuiltinMocks: Boolean(mock.module || (insideEsbuild && !isBundle)), // support for named ESM imports from builtin module mocks
183
189
  concurrency: node.engine !== 'pure', // pure engine doesn't support concurrency
184
190
  },
185
191
  },
package/src/jest.mock.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  mock,
3
3
  assert,
4
- baseFile,
4
+ requireIsRelative,
5
5
  relativeRequire as require,
6
6
  isTopLevelESM,
7
7
  builtinModules,
@@ -34,7 +34,7 @@ export const jestModuleMocks = {
34
34
  }
35
35
 
36
36
  export function resolveModule(name) {
37
- assert(baseFile || /^[@a-zA-Z]/u.test(name), 'Mocking relative paths is not possible')
37
+ assert(requireIsRelative || /^[@a-zA-Z]/u.test(name), 'Mocking relative paths is not possible')
38
38
  const unprefixed = name.replace(/^node:/, '')
39
39
  if (builtinModules.includes(unprefixed)) return unprefixed
40
40
  return require.resolve(name)
@@ -58,6 +58,7 @@ export function resetModules() {
58
58
  if (mock.module) ctx.restore()
59
59
  }
60
60
 
61
+ assert(process.env.EXODUS_TEST_ENVIRONMENT !== 'bundle', 'resetModules() unsupported from bundle')
61
62
  for (const resolved of Object.keys(require.cache)) {
62
63
  delete require.cache[resolved]
63
64
  mapMocks.delete(resolved)
@@ -132,7 +133,9 @@ function mockCloneItem(obj, cache) {
132
133
  // Special path, as .default might be a getter and we want to unwrap it
133
134
  if (obj.__esModule === true) {
134
135
  const { __esModule, default: def, ...rest } = obj
135
- return { __esModule, ...mockClone({ default: def, ...rest }, cache) }
136
+ const proto = Object.getPrototypeOf(obj)
137
+ const toClone = proto?.[Symbol.toStringTag] === 'Module' ? proto : { default: def, ...rest } // unwrap bun modules for proper cloning
138
+ return { __esModule, ...mockClone(toClone, cache) }
136
139
  }
137
140
 
138
141
  const prototype = Object.getPrototypeOf(obj)
@@ -170,6 +173,8 @@ function mockCloneItem(obj, cache) {
170
173
  }
171
174
 
172
175
  export function jestmock(name, mocker, { override = false } = {}) {
176
+ assert(process.env.EXODUS_TEST_ENVIRONMENT !== 'bundle', 'module mocks unsupported from bundle') // TODO: can we do something?
177
+
173
178
  // Loaded ESM: isn't mocked
174
179
  // Loaded CJS: mocked via object overriding
175
180
  // Loaded built-ins: mocked via object overriding where possible