@exodus/test 1.0.0-rc.5 → 1.0.0-rc.6

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/README.md CHANGED
@@ -71,3 +71,5 @@ Just use `"test": "exodus-test"`
71
71
  - `--passWithNoTests` -- do not error when no test files were found
72
72
 
73
73
  - `--write-snapshots` -- write snapshots instead of verifying them (has `--test-update-snapshots` alias)
74
+
75
+ - `--test-force-exit` -- force exit after tests are done (useful in integration tests where it could be unfeasible to resolve all open handles)
package/bin/index.js CHANGED
@@ -70,6 +70,10 @@ function parseOptions() {
70
70
  case '--write-snapshots':
71
71
  options.writeSnapshots = true
72
72
  break
73
+ case '--test-force-exit':
74
+ case '--forceExit':
75
+ options.forceExit = true
76
+ break
73
77
  default:
74
78
  throw new Error(`Unknown option: ${option}`)
75
79
  }
@@ -111,6 +115,11 @@ if (options.writeSnapshots) {
111
115
  args.push('--test-update-snapshots')
112
116
  }
113
117
 
118
+ if (options.forceExit) {
119
+ assert((major === 20 && minor > 13) || major >= 22, 'For forceExit, use Node.js >= 20.14.0')
120
+ args.push('--test-force-exit')
121
+ }
122
+
114
123
  if (options.coverage) {
115
124
  if (options.coverageEngine === 'node') {
116
125
  args.push('--experimental-test-coverage')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/test",
3
- "version": "1.0.0-rc.5",
3
+ "version": "1.0.0-rc.6",
4
4
  "author": "Exodus Movement, Inc.",
5
5
  "description": "A test suite runner",
6
6
  "homepage": "https://github.com/ExodusMovement/test",
package/src/jest.fn.js CHANGED
@@ -3,15 +3,17 @@ import assert from 'node:assert/strict'
3
3
 
4
4
  const registry = new Set()
5
5
 
6
- function applyAll(method) {
7
- assert(['mockClear', 'mockReset', 'mockRestore'].includes(method))
8
- for (const obj of registry) obj[method]()
9
- }
6
+ const applyAllWrap = (method) =>
7
+ function () {
8
+ assert(['mockClear', 'mockReset', 'mockRestore'].includes(method))
9
+ for (const obj of registry) obj[method]()
10
+ return this
11
+ }
10
12
 
11
13
  export const allMocks = {
12
- mockClear: () => applyAll('mockClear'),
13
- mockReset: () => applyAll('mockReset'),
14
- mockRestore: () => applyAll('mockRestore'),
14
+ clearAllMocks: applyAllWrap('mockClear'),
15
+ resetAllMocks: applyAllWrap('mockReset'),
16
+ restoreAllMocks: applyAllWrap('mockRestore'),
15
17
  }
16
18
 
17
19
  // We need parent and property for jest.spyOn and mockfn.mockRestore()
package/src/jest.js CHANGED
@@ -1,10 +1,11 @@
1
1
  import assert from 'node:assert/strict'
2
- import { describe, test, it } from 'node:test'
3
- import { format } from 'node:util'
2
+ import { describe as nodeDescribe, test as nodeTest, afterEach } from 'node:test'
3
+ import { format, types } from 'node:util'
4
4
  import { jestfn, allMocks } from './jest.fn.js'
5
5
  import { jestmock, requireActual, requireMock } from './jest.mock.js'
6
6
  import * as jestTimers from './jest.timers.js'
7
7
  import './jest.snapshot.js'
8
+ import { expect } from 'expect'
8
9
 
9
10
  const makeEach = (impl) => (list) => (template, fn) => {
10
11
  for (const args of list) {
@@ -26,15 +27,36 @@ const makeEach = (impl) => (list) => (template, fn) => {
26
27
  }
27
28
  }
28
29
 
30
+ const forceExit = process.execArgv.map((x) => x.replaceAll('_', '-')).includes('--test-force-exit')
31
+
32
+ const describe = (...args) => nodeDescribe(...args)
33
+ const test = (name, fn) => {
34
+ if (fn.length > 0) return nodeTest(name, (t, c) => fn(c))
35
+ if (!forceExit) return nodeTest(name, fn)
36
+ return nodeTest(name, async (t) => {
37
+ const res = fn()
38
+ assert(
39
+ types.isPromise(res),
40
+ `Test "${t.fullName}" did not return a Promise or supply a callback, which is required in force-exit mode.
41
+ For tests to not end abruptly, use either async functions (recommended), Promises, or specify callbacks to test() / it().
42
+ Also, using expect.assertions() to ensure the planned number of assertions is being called is advised for async code.`
43
+ )
44
+ return res
45
+ })
46
+ }
47
+
29
48
  describe.each = makeEach(describe)
30
49
  test.each = makeEach(test)
31
- it.each = makeEach(it)
50
+ describe.skip = (...args) => nodeDescribe.skip(...args)
51
+ test.skip = (...args) => nodeTest.skip(...args)
52
+
53
+ afterEach(() => {
54
+ for (const { error } of expect.extractExpectedAssertionsErrors()) throw error
55
+ })
32
56
 
33
57
  const jest = {
34
58
  fn: (impl) => jestfn(impl), // hide extra arguments
35
- clearAllMocks: () => allMocks.mockClear(),
36
- resetAllMocks: () => allMocks.mockReset(),
37
- restoreAllMocks: () => allMocks.mockRestore(),
59
+ ...allMocks,
38
60
  spyOn: (obj, name) => {
39
61
  assert(obj && name && name in obj && !(name in {}) && !(name in Object.prototype))
40
62
  const fn = jestfn(obj[name], obj, name)
@@ -48,14 +70,6 @@ const jest = {
48
70
  ...jestTimers,
49
71
  }
50
72
 
51
- export { jest }
73
+ export { jest, describe, test, test as it }
52
74
  export { expect } from 'expect'
53
- export {
54
- beforeEach,
55
- afterEach,
56
- before as beforeAll,
57
- after as afterAll,
58
- describe,
59
- test,
60
- it,
61
- } from 'node:test'
75
+ export { beforeEach, afterEach, before as beforeAll, after as afterAll } from 'node:test'
package/src/jest.mock.js CHANGED
@@ -86,7 +86,7 @@ export function jestmock(name, mocker) {
86
86
 
87
87
  // Loaded ESM: isn't mocked
88
88
  // Loaded CJS: mocked via object overriding
89
- // Loaded built-ins: not mocked for now (!!!)
89
+ // Loaded built-ins: mocked via object overriding where possible
90
90
  // New CJS: mocked via mock.module + require.cache
91
91
  // New ESM: mocked via mock.module
92
92
  // New built-ins: mocked via mock.module
@@ -108,15 +108,14 @@ export function jestmock(name, mocker) {
108
108
  // fall through when e.g. this module doesn't exist or is ESM
109
109
  if (Object.hasOwn(require.cache, resolved)) {
110
110
  assert.equal(mapActual.get(resolved), require.cache[resolved].exports)
111
- // If we did't have this prior but have now, it means we just loaded it and there are not leaked instances
111
+ // If we did't have this prior but have now, it means we just loaded it and there are no leaked instances
112
112
  if (havePrior) override(resolved)
113
113
  require.cache[resolved].exports = value
114
114
  } else if (builtinModules.includes(resolved.replace(/^node:/, ''))) {
115
115
  override(resolved, true) // Override builtin modules
116
116
  }
117
117
 
118
- mock.module(name, {
119
- defaultExport: value.default,
120
- namedExports: value,
121
- })
118
+ mock.module(name, { defaultExport: value.default, namedExports: value })
119
+
120
+ return this
122
121
  }
@@ -58,39 +58,36 @@ function wrapContextName(fn) {
58
58
  }
59
59
  }
60
60
 
61
- function expectError(fn) {
62
- let err
63
- expect(() => {
64
- try {
65
- fn()
66
- } catch (e) {
67
- err = e
68
- throw e
69
- }
70
- }).toThrow()
71
- return expect(err.message) // jest just checks error message, not error obj
61
+ const throws = (fn, check) =>
62
+ context.assert.throws(fn, (e) => {
63
+ check(e.message) // jest stores only messages for errors
64
+ return true
65
+ })
66
+
67
+ const snapInline = (obj, inline) => {
68
+ assert(inline !== undefined, 'Inline Snapshots generation is not supported')
69
+ assert(typeof inline === 'string')
70
+ context.assert.equal(serialize(obj).trim(), inline.trim())
72
71
  }
73
72
 
74
- expect.extend({
75
- toMatchInlineSnapshot: (obj, inline) => {
76
- assert(inline !== undefined, 'Inline Snapshots generation is not supported')
77
- assert(typeof inline === 'string')
78
- return wrap(() => expect(serialize(obj).trim()).toEqual(inline.trim()))
79
- },
80
- toMatchSnapshot: (obj) => {
73
+ const snapOnDisk = (obj) =>
74
+ wrapContextName(() => {
81
75
  const str = serialize(obj)
82
76
  if (!str.includes('\n')) {
83
77
  // Node.js always wraps with newlines, while jest wraps only those that are already multiline
84
78
  // Hopefully, for simple objects there is no need to use snapshots and those can be just compared directly
85
- const msg = `Snapshotting primitives or empty objects/arrays is not supported yet: ${str}`
86
- return { pass: false, message: () => msg }
79
+ throw new Error(`Snapshots of primitives or empty objects/arrays is not supported: ${str}`)
87
80
  }
88
81
 
89
82
  maybeSetupJestSnapshots()
90
- return wrap(() => wrapContextName(() => context.assert.snapshot(obj)))
91
- },
92
- toThrowErrorMatchingInlineSnapshot: (f, i) => wrap(() => expectError(f).toMatchInlineSnapshot(i)),
93
- toThrowErrorMatchingSnapshot: (f) => wrap(() => expectError(f).toMatchSnapshot()),
83
+ return context.assert.snapshot(obj)
84
+ })
85
+
86
+ expect.extend({
87
+ toMatchInlineSnapshot: (obj, i) => wrap(() => snapInline(obj, i)),
88
+ toMatchSnapshot: (obj) => wrap(() => snapOnDisk(obj)),
89
+ toThrowErrorMatchingInlineSnapshot: (f, i) => wrap(() => throws(f, (msg) => snapInline(msg, i))),
90
+ toThrowErrorMatchingSnapshot: (f) => wrap(() => throws(f, (msg) => snapOnDisk(msg))),
94
91
  })
95
92
 
96
93
  expect.addSnapshotSerializer = (plugin) => plugins.push(plugin)
@@ -14,12 +14,12 @@ const warnOldTimers = () => {
14
14
  if (!ok) console.warn('Warning: timer mocks are known to be glitchy before Node.js >=20.11.0')
15
15
  }
16
16
 
17
- export const useRealTimers = () => {
17
+ export function useRealTimers() {
18
18
  mock.timers.reset()
19
- return jest
19
+ return this
20
20
  }
21
21
 
22
- export const useFakeTimers = ({ doNotFake = [], ...rest } = {}) => {
22
+ export function useFakeTimers({ doNotFake = [], ...rest } = {}) {
23
23
  assertHaveTimers()
24
24
  warnOldTimers()
25
25
  assert.deepEqual(rest, {}, 'Unsupported options')
@@ -33,33 +33,35 @@ export const useFakeTimers = ({ doNotFake = [], ...rest } = {}) => {
33
33
  if (e.code !== 'ERR_INVALID_STATE') throw e
34
34
  }
35
35
 
36
- return jest
36
+ return this
37
37
  }
38
38
 
39
- export const runAllTimers = () => {
39
+ export function runAllTimers() {
40
40
  assertHaveTimers()
41
41
  warnOldTimers()
42
42
  mock.timers.tick(100_000_000_000) // > 3 years
43
- return jest
43
+ return this
44
44
  }
45
45
 
46
- export const runOnlyPendingTimers = () => {
46
+ export function runOnlyPendingTimers() {
47
47
  const noInfiniteLoopBug = major >= 22 || (major === 20 && minor >= 11)
48
48
  assert(noInfiniteLoopBug, 'runOnlyPendingTimers requires Node.js >=20.11.0')
49
49
  mock.timers.runAll()
50
- return jest
50
+ return this
51
51
  }
52
52
 
53
- export const advanceTimersByTime = (time) => {
53
+ export function advanceTimersByTime(time) {
54
54
  assertHaveTimers()
55
55
  warnOldTimers()
56
56
  mock.timers.tick(time)
57
- return jest
57
+ return this
58
58
  }
59
59
 
60
- export const advanceTimersByTimeAsync = async (time) => jest.advanceTimersByTime(time)
60
+ export async function advanceTimersByTimeAsync(time) {
61
+ return this.advanceTimersByTime(time)
62
+ }
61
63
 
62
- export const setSystemTime = (time) => {
64
+ export function setSystemTime(time) {
63
65
  mock.timers.setTime(+time)
64
- return jest
66
+ return this
65
67
  }
package/src/tape.js CHANGED
@@ -52,7 +52,7 @@ const aliases = {
52
52
  rejects: ['rejects'],
53
53
  doesNotReject: ['resolves'],
54
54
 
55
- // specially handled ones as do not exist in t.assert
55
+ // specially handled ones as do not exist in t.assert / assert
56
56
  notOk: ['notOk', 'false', 'notok'],
57
57
  pass: ['pass'],
58
58
  error: ['error', 'ifError', 'ifErr', 'iferror'], // tape
@@ -61,41 +61,68 @@ const aliases = {
61
61
  // match/notMatch are confusing as operate on strings in some impls and objs in others. we skip them
62
62
  }
63
63
 
64
- function tapeWrapAssert(t) {
65
- // Note: we must use t.assert instead of assert everywhere as we have t.plan
64
+ function tapeWrapAssert(t, callback) {
65
+ // Auto-call api.end() on planned test count reaching zero
66
+ let plan = null
67
+ let count = 0
68
+ const track = (...calls) => {
69
+ count += calls.length
70
+ if (plan === count) api.end()
71
+ if (plan !== null) assert(plan >= count, `plan (${plan}) < count (${count})`)
72
+ }
73
+
74
+ const plannedAssert = () => (plan !== null && t.assert) || assert // t.assert is cached and affected by t.plan
66
75
 
76
+ // Note: we must use plannedAssert instead of assert everywhere on user calls as we have t.plan
67
77
  const api = {
68
78
  test: tapeWrap(t.test.bind(t)),
69
- plan: (...r) => t.plan(...r),
79
+ plan: (total) => {
80
+ assert.equal(typeof total, 'number')
81
+ plan = total
82
+ assert(plan >= count, `plan (${plan}) < count (${count})`)
83
+ if (t.plan) t.plan(plan - count) // plan the remaining tests through node
84
+ track()
85
+ },
70
86
  skip: (...r) => t.skip(...r),
71
87
  todo: (...r) => t.todo(...r),
72
88
  comment: (...r) => t.diagnostic(...r),
73
- end: () => {},
89
+ end: () => {
90
+ if (plan !== null) assert.equal(plan, count, `plan (${plan}) !== count (${count})`)
91
+ if (callback) callback()
92
+ api.end = () => {}
93
+ },
74
94
  }
75
95
 
76
- // Copy implementations from here if they exist, preferring over t.assert
96
+ // Copy implementations from here if they exist, preferring over plannedAssert
77
97
  const base = {
78
- pass: (...r) => t.assert.ok(true, ...r),
79
- notOk: (x, ...r) => t.assert.ok(!x, ...r),
80
- error: (err, msg) => t.assert.ok(!err, msg || err?.message),
81
- assertion: (fn, ...args) => fn.apply(t.assert, args),
98
+ pass: (...r) => plannedAssert().ok(true, ...r),
99
+ notOk: (x, ...r) => plannedAssert().ok(!x, ...r),
100
+ error: (err, msg) => plannedAssert().ok(!err, msg || err?.message),
101
+ assertion: (fn, ...args) => fn.apply(plannedAssert(), args),
82
102
  }
83
103
 
84
104
  for (const [key, names] of Object.entries(aliases)) {
85
- const impl = Object.hasOwn(base, key) ? base[key] : (...r) => t.assert[key](...r)
86
- Object.assign(api, Object.fromEntries(names.map((name) => [name, impl])))
105
+ const impl = Object.hasOwn(base, key) ? base[key] : (...r) => plannedAssert()[key](...r)
106
+ Object.assign(api, Object.fromEntries(names.map((name) => [name, (...r) => track(impl(...r))])))
87
107
  }
88
108
 
89
109
  return api
90
110
  }
91
111
 
112
+ const AsyncFunction = (async () => {}).constructor
113
+
92
114
  function tapeWrap(test) {
93
115
  const tap = (name, ...args) => {
94
116
  const fn = args.pop()
95
117
  assert(args.length <= 1)
96
118
  const [opts = {}] = args
97
119
  verifyOptions(opts)
98
- test(name, opts, (t) => fn(tapeWrapAssert(t)))
120
+ assert(fn instanceof Function)
121
+ if (fn instanceof AsyncFunction) {
122
+ test(name, opts, (t) => fn(tapeWrapAssert(t)))
123
+ } else {
124
+ test(name, opts, (t, callback) => fn(tapeWrapAssert(t, callback)))
125
+ }
99
126
  }
100
127
 
101
128
  tap.skip = (...args) => test.skip(...args)