@exodus/test 1.0.0-rc.17 → 1.0.0-rc.19

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
@@ -6,20 +6,13 @@ import { basename, dirname, resolve } from 'node:path'
6
6
  import { createRequire } from 'node:module'
7
7
  import assert from 'node:assert/strict'
8
8
  import glob from 'fast-glob'
9
+ import { haveModuleMocks, haveSnapshots, haveForceExit } from '../src/version.js'
9
10
 
10
11
  const bindir = dirname(fileURLToPath(import.meta.url))
11
12
 
12
- const EXTS = `.?([cm])[jt]s?(x)` // we differt from jest, allowing [cm] before everything
13
+ const EXTS = `.?([cm])[jt]s?(x)` // we differ from jest, allowing [cm] before everything
13
14
  const DEFAULT_PATTERNS = [`**/__tests__/**/*${EXTS}`, `**/?(*.)+(spec|test)${EXTS}`]
14
15
 
15
- function versionCheck() {
16
- const [major, minor, patch] = process.versions.node.split('.').map(Number)
17
- assert((major === 18 && minor >= 13) || major >= 20, 'Node.js version too old!')
18
- assert(major !== 21, 'Node.js version deprecated!')
19
-
20
- return { major, minor, patch }
21
- }
22
-
23
16
  function parseOptions() {
24
17
  const options = {
25
18
  jest: false,
@@ -100,7 +93,6 @@ function parseOptions() {
100
93
  return { options, patterns }
101
94
  }
102
95
 
103
- const { major, minor } = versionCheck()
104
96
  const { options, patterns } = parseOptions()
105
97
 
106
98
  let program = 'node'
@@ -114,10 +106,7 @@ if (resolveImport) assert.equal(c8, resolveImport('c8/bin/c8.js'))
114
106
 
115
107
  const args = ['--test', '--no-warnings=ExperimentalWarning']
116
108
 
117
- const haveModuleMocks = major > 22 || (major === 22 && minor >= 3)
118
109
  if (haveModuleMocks) args.push('--experimental-test-module-mocks')
119
-
120
- const haveSnapshots = major > 22 || (major === 22 && minor >= 3)
121
110
  if (haveSnapshots) args.push('--experimental-test-snapshots')
122
111
 
123
112
  if (options.writeSnapshots) {
@@ -126,14 +115,11 @@ if (options.writeSnapshots) {
126
115
  }
127
116
 
128
117
  if (options.forceExit) {
129
- assert((major === 20 && minor > 13) || major >= 22, 'For forceExit, use Node.js >= 20.14.0')
118
+ assert(haveForceExit, 'For forceExit, use Node.js >= 20.14.0')
130
119
  args.push('--test-force-exit')
131
120
  }
132
121
 
133
- if (options.watch) {
134
- assert((major === 18 && minor > 13) || major >= 20, 'For watch mode, use Node.js >= 18.13.0')
135
- args.push('--watch')
136
- }
122
+ if (options.watch) args.push('--watch')
137
123
 
138
124
  args.push('--expose-internals') // this is unoptimal and hopefully temporary, see rationale in src/dark.cjs
139
125
 
@@ -151,12 +137,8 @@ if (options.coverage) {
151
137
  }
152
138
 
153
139
  if (options.esbuild) {
154
- if (major >= 22 || (major === 20 && minor >= 6) || (major === 18 && minor >= 18)) {
155
- assert(resolveImport)
156
- args.push('--import', resolveImport('tsx'))
157
- } else {
158
- args.push('-r', resolveRequire('tsx/cjs'))
159
- }
140
+ assert(resolveImport)
141
+ args.push('--import', resolveImport('tsx'))
160
142
  }
161
143
 
162
144
  if (options.babel) {
@@ -176,11 +158,7 @@ if (process.env.EXODUS_TEST_IGNORE) {
176
158
  if (options.jest) {
177
159
  const { loadJestConfig } = await import('../src/jest.config.js')
178
160
  const config = await loadJestConfig(process.cwd())
179
- if (major >= 20 || (major === 18 && minor >= 18)) {
180
- args.push('--import', resolve(bindir, 'jest.js'))
181
- } else {
182
- throw new Error('Option --jest requires Node.js >= v18.18.0')
183
- }
161
+ args.push('--import', resolve(bindir, 'jest.js'))
184
162
 
185
163
  if (config.testFailureExitCode !== undefined) {
186
164
  if (Number(config.testFailureExitCode) === 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/test",
3
- "version": "1.0.0-rc.17",
3
+ "version": "1.0.0-rc.19",
4
4
  "author": "Exodus Movement, Inc.",
5
5
  "description": "A test suite runner",
6
6
  "homepage": "https://github.com/ExodusMovement/test",
@@ -42,6 +42,7 @@
42
42
  "src/jest.snapshot.js",
43
43
  "src/jest.timers.js",
44
44
  "src/tape.js",
45
+ "src/version.js",
45
46
  "!__tests__",
46
47
  "CHANGELOG.md"
47
48
  ],
@@ -68,6 +69,7 @@
68
69
  "@types/jest-when": "^3.5.2",
69
70
  "@typescript-eslint/eslint-plugin": "^7.15.0",
70
71
  "eslint": "^8.44.0",
72
+ "jest": "^29.7.0",
71
73
  "jest-matcher-utils": "^29.7.0",
72
74
  "jest-serializer-ansi-escapes": "^3.0.0",
73
75
  "jest-when": "^3.6.0"
package/src/jest.fn.js CHANGED
@@ -61,7 +61,6 @@ export const jestfn = (baseimpl, parent, property) => {
61
61
 
62
62
  const queuedMock = (impl) => {
63
63
  mockimpl = impl || noop
64
- onceStack.length = 0
65
64
  }
66
65
 
67
66
  // getMockImplementation() is undocumented and is changed only in real mockImplementation() call
package/src/jest.js CHANGED
@@ -7,8 +7,10 @@ import { jestmock, requireActual, requireMock, resetModules } from './jest.mock.
7
7
  import * as jestTimers from './jest.timers.js'
8
8
  import './jest.snapshot.js'
9
9
  import { createCallerLocationHook } from './dark.cjs'
10
+ import './version.js'
10
11
  import { expect } from 'expect'
11
12
  import matchers from 'jest-extended'
13
+ import { format as prettyFormat } from 'pretty-format'
12
14
 
13
15
  const { getCallerLocation, installLocationInNextTest } = createCallerLocationHook()
14
16
 
@@ -46,8 +48,10 @@ const makeEach =
46
48
  // Hack for common testing with simple arrow functions, until we can disable esbuild minification
47
49
  const formatArg = (x) => (x && x instanceof Function && `${x}` === '()=>{}' ? '() => {}' : x)
48
50
  // better than nothing
49
- const protos = new Set([null, Array.prototype, Object.prototype])
50
- const printed = (x) => (x && protos.has(Object.getPrototypeOf(x)) ? JSON.stringify(x) : `${x}`)
51
+ const printed = (x) =>
52
+ x && [null, Array.prototype, Object.prototype].includes(Object.getPrototypeOf(x))
53
+ ? prettyFormat(x, { min: true })
54
+ : `${x}`
51
55
 
52
56
  const args = parseArgs(list, rest)
53
57
  const wrapped = args.every((x) => Array.isArray(x))
@@ -81,9 +85,11 @@ const makeEach =
81
85
  const forceExit = process.execArgv.map((x) => x.replaceAll('_', '-')).includes('--test-force-exit')
82
86
 
83
87
  const inConcurrent = []
88
+ const inDescribe = []
84
89
  const concurrent = []
85
90
  const describe = (...args) => {
86
91
  const fn = args.pop()
92
+ inDescribe.push(fn)
87
93
  const optionsConcurrent = args?.at(-1)?.concurrency > 1
88
94
  if (optionsConcurrent) inConcurrent.push(fn)
89
95
  const result = nodeDescribe(...args, async () => {
@@ -105,6 +111,7 @@ const describe = (...args) => {
105
111
  return res
106
112
  })
107
113
  if (optionsConcurrent) inConcurrent.pop()
114
+ inDescribe.pop()
108
115
  return result
109
116
  }
110
117
 
@@ -129,8 +136,12 @@ const test = (...args) => testRaw(getCallerLocation(), ...args)
129
136
 
130
137
  describe.each = makeEach(describe)
131
138
  test.each = makeEach(test)
132
- test.concurrent = (...args) =>
133
- inConcurrent.length > 0 ? test(...args) : concurrent.push([args, getCallerLocation()])
139
+ test.concurrent = (...args) => {
140
+ assert(inDescribe.length > 0, 'test.concurrent is supported only within a describe block')
141
+ if (inConcurrent.length > 0) return test(...args)
142
+ concurrent.push([args, getCallerLocation()])
143
+ }
144
+
134
145
  test.concurrent.each = makeEach(test.concurrent)
135
146
  describe.skip = (...args) => nodeDescribe.skip(...args)
136
147
  test.skip = (...args) => nodeTest.skip(...args)
package/src/jest.mock.js CHANGED
@@ -16,6 +16,8 @@ export const relativeRequire = require
16
16
 
17
17
  export function resolveModule(name) {
18
18
  assert(baseUrl || /^[@a-zA-Z]/u.test(name), 'Mocking relative paths is not possible')
19
+ const unprefixed = name.replace(/^node:/, '')
20
+ if (builtinModules.includes(unprefixed)) return unprefixed
19
21
  return require.resolve(name)
20
22
  }
21
23
 
@@ -70,7 +72,7 @@ function mockClone(root) {
70
72
  if (obj instanceof RegExp) return [new RegExp(), false] // this is what jest does apparently
71
73
  if (seen.has(obj)) return [seen.get(obj), !simple.has(obj)]
72
74
  if (obj instanceof Function) {
73
- seen.set(obj, jestfn(obj))
75
+ seen.set(obj, jestfn())
74
76
  return [seen.get(obj), true]
75
77
  }
76
78
 
@@ -96,8 +98,6 @@ function mockClone(root) {
96
98
  }
97
99
 
98
100
  export function jestmock(name, mocker) {
99
- assert(mock.module, 'ESM module mocks are available only on Node.js >=22.3')
100
-
101
101
  // Loaded ESM: isn't mocked
102
102
  // Loaded CJS: mocked via object overriding
103
103
  // Loaded built-ins: mocked via object overriding where possible
@@ -119,18 +119,20 @@ export function jestmock(name, mocker) {
119
119
  const value = mocker ? { ...mocker() } : mockClone(mapActual.get(resolved))
120
120
  mapMocks.set(resolved, value)
121
121
 
122
- // fall through when e.g. this module doesn't exist or is ESM
123
122
  if (Object.hasOwn(require.cache, resolved)) {
124
123
  assert.equal(mapActual.get(resolved), require.cache[resolved].exports)
125
124
  // If we did't have this prior but have now, it means we just loaded it and there are no leaked instances
126
125
  if (havePrior) override(resolved)
127
126
  require.cache[resolved].exports = value
128
- } else if (builtinModules.includes(resolved.replace(/^node:/, ''))) {
127
+ } else if (builtinModules.includes(resolved)) {
129
128
  override(resolved, true) // Override builtin modules
130
129
  syncBuiltinESMExports()
130
+ } else {
131
+ // The module doesn't exist or is ESM
132
+ assert(mock.module, 'ESM module mocks are available only on Node.js >=22.3')
131
133
  }
132
134
 
133
- mock.module(name, { defaultExport: value.default, namedExports: value })
135
+ mock.module?.(name, { defaultExport: value.default ?? value, namedExports: value })
134
136
 
135
137
  return this
136
138
  }
@@ -1,7 +1,7 @@
1
1
  import { beforeEach } from 'node:test'
2
2
  import { createRequire } from 'node:module'
3
3
  import { expect } from 'expect'
4
- import { format } from 'pretty-format'
4
+ import { format, plugins as builtinPlugins } from 'pretty-format'
5
5
  import assert from 'node:assert/strict'
6
6
  import { basename, dirname, join, normalize } from 'node:path'
7
7
  import { readFileSync } from 'node:fs'
@@ -10,7 +10,7 @@ import { relativeRequire } from './jest.mock.js'
10
10
  import { getTestNamePath } from './dark.cjs'
11
11
 
12
12
  const { snapshotFormat, snapshotSerializers } = jestConfig()
13
- const plugins = []
13
+ const plugins = Object.values(builtinPlugins)
14
14
  const serialize = (val) => format(val, { ...snapshotFormat, plugins }).replaceAll(/\r\n|\r/gu, '\n')
15
15
  const resolveSnapshot = (f) => join(dirname(f), '__snapshots__', `${basename(f)}.snap`)
16
16
 
@@ -106,7 +106,16 @@ const snapInline = (obj, inline) => {
106
106
  getAssert().strictEqual(serialize(obj).trim(), inline.trim())
107
107
  }
108
108
 
109
- const snapOnDisk = (obj) => {
109
+ const snapOnDisk = (orig, matcher) => {
110
+ if (matcher) {
111
+ expect(orig).toMatchObject(matcher)
112
+ // If we passed, make appear that the above call never happened
113
+ const state = expect.getState()
114
+ state.assertionCalls--
115
+ state.numPassingAsserts--
116
+ }
117
+
118
+ const obj = matcher ? { ...orig, ...matcher } : orig
110
119
  const escape = (str) => str.replaceAll(/([\\`])/gu, '\\$1')
111
120
 
112
121
  if (!maybeSetupJestSnapshots()) {
@@ -142,16 +151,19 @@ const snapOnDisk = (obj) => {
142
151
  try {
143
152
  wrapContextName(() => getAssert().snapshot(obj))
144
153
  } catch (e) {
145
- const escaped = escape(e.expected)
146
- const final = escaped.includes('\n') ? escaped : `\n${escaped}\n`
147
- if (final === e.actual) return
154
+ if (typeof e.expected === 'string') {
155
+ const escaped = escape(e.expected)
156
+ const final = escaped.includes('\n') ? escaped : `\n${escaped}\n`
157
+ if (final === e.actual) return
158
+ }
159
+
148
160
  throw e
149
161
  }
150
162
  }
151
163
 
152
164
  expect.extend({
153
165
  toMatchInlineSnapshot: (obj, i) => wrap(() => snapInline(obj, i)),
154
- toMatchSnapshot: (obj) => wrap(() => snapOnDisk(obj)),
166
+ toMatchSnapshot: (obj, matcher) => wrap(() => snapOnDisk(obj, matcher)),
155
167
  toThrowErrorMatchingInlineSnapshot: (...a) => wrap(() => throws(a, (m) => snapInline(m, a[1]))),
156
168
  toThrowErrorMatchingSnapshot: (...a) => wrap(() => throws(a, (m) => snapOnDisk(m))),
157
169
  })
@@ -1,18 +1,16 @@
1
1
  import assert from 'node:assert/strict'
2
2
  import { mock } from 'node:test'
3
3
  import { jestConfig } from './jest.config.js'
4
-
5
- const [major, minor] = process.versions.node.split('.').map(Number)
4
+ import { haveValidTimers, haveNoTimerInfiniteLoopBug } from './version.js'
6
5
 
7
6
  const assertHaveTimers = () =>
8
7
  assert(mock.timers, 'Timer mocking requires Node.js >=20.4.0 || 18 >=18.19.0')
9
8
 
10
9
  let timersWarned = false
11
10
  const warnOldTimers = () => {
12
- if (timersWarned) return
11
+ if (haveValidTimers || timersWarned) return
13
12
  timersWarned = true
14
- const ok = major >= 22 || (major === 20 && minor >= 11)
15
- if (!ok) console.warn('Warning: timer mocks are known to be glitchy before Node.js >=20.11.0')
13
+ console.warn('Warning: timer mocks are known to be glitchy before Node.js >=20.11.0')
16
14
  }
17
15
 
18
16
  export function useRealTimers() {
@@ -47,8 +45,7 @@ export function runAllTimers() {
47
45
  }
48
46
 
49
47
  export function runOnlyPendingTimers() {
50
- const noInfiniteLoopBug = major >= 22 || (major === 20 && minor >= 11)
51
- assert(noInfiniteLoopBug, 'runOnlyPendingTimers requires Node.js >=20.11.0')
48
+ assert(haveNoTimerInfiniteLoopBug, 'runOnlyPendingTimers requires Node.js >=20.11.0')
52
49
  mock.timers.runAll()
53
50
  return this
54
51
  }
package/src/tape.js CHANGED
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict'
2
2
  import assertLoose from 'node:assert'
3
3
  import { test } from 'node:test'
4
4
  import { createCallerLocationHook } from './dark.cjs'
5
+ import './version.js'
5
6
 
6
7
  const knownOptions = new Set(['skip', 'todo', 'concurrency', 'timeout'])
7
8
 
package/src/version.js ADDED
@@ -0,0 +1,16 @@
1
+ import assert from 'node:assert/strict'
2
+
3
+ const [major, minor, patch] = process.versions.node.split('.').map(Number)
4
+ assert(major !== 21, 'Node.js 21.x is deprecated!') // reached EOL, no reason to even test
5
+ // older versions are glitchy with before/after on top-level, which is a deal-breaker
6
+ const ok = (major === 18 && minor >= 19) || (major === 20 && minor >= 7) || major >= 22
7
+ assert(ok, 'Node.js version too old or glitchy with node:test, use ^18.19.0 || ^20.7.0 || >=22.0.0')
8
+ assert(major !== 22 || minor !== 3, 'Refusing to run on Node.js 22.3.0 specifically, do not use it') // safe-guard
9
+
10
+ export { major, minor, patch }
11
+
12
+ export const haveModuleMocks = (major === 22 && minor >= 3) || major > 22
13
+ export const haveSnapshots = (major === 22 && minor >= 3) || major > 22
14
+ export const haveForceExit = (major === 20 && minor > 13) || major >= 22
15
+ export const haveValidTimers = (major === 20 && minor >= 11) || major >= 22 // older glitch in various ways / stop executing
16
+ export const haveNoTimerInfiniteLoopBug = (major === 20 && minor >= 11) || major >= 22 // mock.timers.runAll() can get into infinite recursion