@exodus/test 1.0.0-rc.0 → 1.0.0-rc.10

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
@@ -1,20 +1,44 @@
1
1
  # @exodus/test
2
2
 
3
+ A runner for `node:test`, `jest`, and `tape` test suites on top of `node:test`
4
+
3
5
  Most likely it will just work on your simple jest tests as as drop-in replacement
4
6
 
7
+ Comes with typescript support, optional esm/cjs interop, and also loading babel transforms!
8
+
9
+ Use `--coverage` to generate coverage output
10
+
11
+ Default `NODE_ENV` value is "test", use `NODE_ENV=` to override (e.g. to empty)
12
+
5
13
  ## Library
6
14
 
7
- ### Moving from jest
15
+ ### Using with `node:test` natively
8
16
 
9
- `import { describe, it, assert, jest, expect } from '@exodus/test'`
17
+ You can just use pure [`node:test`](https://nodejs.org/api/test.html) in your tests,
18
+ this runner is fully compatible with that (and will set version-specific options for you)!
10
19
 
11
- Replace `.calls` with `.callsArguments`, as the former one now contains more detailed information
20
+ ### Moving from jest
12
21
 
13
- ### Moving from tap/tape
22
+ ```js
23
+ import {
24
+ jest,
25
+ expect,
26
+ describe,
27
+ it,
28
+ beforeEach,
29
+ afterEach,
30
+ beforeAll,
31
+ afterAll,
32
+ } from '@exodus/test/jest'
33
+ ```
34
+
35
+ Or, run with [`--jest` option](#options) to register jest globals
14
36
 
15
- `import { tap as test } from '@exodus/test'`
37
+ ### Moving from tap/tape
16
38
 
17
- Not all features might be supported
39
+ ```js
40
+ import test from '@exodus/test/tap'
41
+ ```
18
42
 
19
43
  ### Running tests asynchronously
20
44
 
@@ -22,39 +46,34 @@ Add `{ concurrency: true }`, like this: `describe('my testsuite', { concurrency:
22
46
 
23
47
  ### List of exports
24
48
 
25
- Adapters:
26
-
27
- - `jest` -- jest mock adapter
28
- - `tap` -- tap/tape adapter
29
- - `mock`
49
+ - `@exodus/test/jest` -- `jest` mock
30
50
 
31
- Assertions:
32
-
33
- - `assert` -- alias for `node:assert/strict`
34
- - `expect` -- expect with additional features for function mocks
35
-
36
- Suite:
37
-
38
- - `describe`
39
- - `test`
40
- - `it` -- alias for `test`
41
- - `beforeEach`
42
- - `afterEach`
43
- - `before` -- alias for `beforeAll`
44
- - `after` -- alias for `afterAll`
51
+ - `@exodus/test/tape` -- `tape` mock (can also be helpful when moving from `tap`)
45
52
 
46
53
  ## Binary
47
54
 
48
- Just use `"test: "exodus-test"`
55
+ Just use `"test": "exodus-test"`
49
56
 
50
57
  ### Options
51
58
 
52
- - `--global` -- register all test helpers as global variables
59
+ - `--jest` -- register jest test helpers as global variables
60
+
61
+ - `--typescript` -- use typescript loader (which also compiles esm to cjs where needed)
62
+
63
+ - `--esbuild` -- use esbuild loader (currently an alias for `--typescript`)
53
64
 
54
- - `--typescript` -- enable typescript support
65
+ - `--babel` -- use babel loader (slower than `--esbuild`, makes sense if you have a special config)
55
66
 
56
67
  - `--coverage` -- enable coverage, prints coverage output (varies by coverage engine)
57
68
 
58
69
  - `--coverage-engine c8` -- use c8 coverage engine (default), also generates `./coverage/` dirs
59
70
 
60
71
  - `--coverage-engine node` -- use Node.js builtint coverage engine
72
+
73
+ - `--watch` -- operate in watch mode and re-run tests on file changes
74
+
75
+ - `--passWithNoTests` -- do not error when no test files were found
76
+
77
+ - `--write-snapshots` -- write snapshots instead of verifying them (has `--test-update-snapshots` alias)
78
+
79
+ - `--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/babel.cjs ADDED
@@ -0,0 +1,8 @@
1
+ const register = require('@babel/register')
2
+
3
+ register({
4
+ compact: false,
5
+ babelrc: false,
6
+ plugins: ['@babel/plugin-transform-modules-commonjs'],
7
+ ignore: [], // do not ignore node_modules
8
+ })
package/bin/index.js CHANGED
@@ -5,11 +5,11 @@ import { fileURLToPath } from 'node:url'
5
5
  import { basename, dirname, resolve } from 'node:path'
6
6
  import { createRequire } from 'node:module'
7
7
  import assert from 'node:assert/strict'
8
- import glob from 'fast-glob' // Only for Node.js <22 support
8
+ import glob from 'fast-glob'
9
9
 
10
10
  const bindir = dirname(fileURLToPath(import.meta.url))
11
11
 
12
- const DEFAULT_PATTERNS = ['**/*.{test,spec}.?(c|m)js', '**/*.{test,spec}.ts']
12
+ const DEFAULT_PATTERNS = ['**/*.{test,spec}.{js,cjs,mjs,ts}', '**/{test,spec}.{js,cjs,mjs,ts}']
13
13
 
14
14
  function versionCheck() {
15
15
  const [major, minor, patch] = process.versions.node.split('.').map(Number)
@@ -21,10 +21,15 @@ function versionCheck() {
21
21
 
22
22
  function parseOptions() {
23
23
  const options = {
24
- global: false,
24
+ jest: false,
25
25
  typescript: false,
26
+ esbuild: false,
27
+ babel: false,
26
28
  coverage: false,
27
29
  coverageEngine: 'c8', // c8 or node
30
+ watch: false,
31
+ passWithNoTests: false,
32
+ writeSnapshots: false,
28
33
  }
29
34
 
30
35
  const args = [...process.argv]
@@ -40,18 +45,39 @@ function parseOptions() {
40
45
  while (args[0]?.startsWith('--')) {
41
46
  const option = args.shift()
42
47
  switch (option) {
43
- case '--global':
44
- options.global = true
48
+ case '--global': // compat, will be removed in release
49
+ case '--jest':
50
+ options.jest = true
45
51
  break
46
52
  case '--typescript':
47
53
  options.typescript = true
48
54
  break
55
+ case '--esbuild':
56
+ options.esbuild = true
57
+ break
58
+ case '--babel':
59
+ options.babel = true
60
+ break
49
61
  case '--coverage-engine':
50
62
  options.coverageEngine = args.shift()
51
63
  break
52
64
  case '--coverage':
53
65
  options.coverage = true
54
66
  break
67
+ case '--watch':
68
+ options.watch = true
69
+ break
70
+ case '--passWithNoTests':
71
+ options.passWithNoTests = true
72
+ break
73
+ case '--test-update-snapshots': // Node.js name for this, might get suggested in errors
74
+ case '--write-snapshots':
75
+ options.writeSnapshots = true
76
+ break
77
+ case '--test-force-exit':
78
+ case '--forceExit':
79
+ options.forceExit = true
80
+ break
55
81
  default:
56
82
  throw new Error(`Unknown option: ${option}`)
57
83
  }
@@ -74,9 +100,37 @@ const { options, patterns } = parseOptions()
74
100
  let program = 'node'
75
101
 
76
102
  const require = createRequire(import.meta.url)
77
- const c8 = require.resolve('c8/bin/c8.js')
103
+ const resolveRequire = (query) => require.resolve(query)
104
+ const resolveImport = import.meta.resolve && ((query) => fileURLToPath(import.meta.resolve(query)))
105
+
106
+ const c8 = resolveRequire('c8/bin/c8.js')
107
+ if (resolveImport) assert.equal(c8, resolveImport('c8/bin/c8.js'))
108
+
109
+ const args = ['--test', '--no-warnings=ExperimentalWarning']
110
+
111
+ const haveModuleMocks = major > 22 || (major === 22 && minor >= 3)
112
+ if (haveModuleMocks) args.push('--experimental-test-module-mocks')
113
+
114
+ const haveSnapshots = major > 22 || (major === 22 && minor >= 3)
115
+ if (haveSnapshots) args.push('--experimental-test-snapshots')
116
+
117
+ if (options.writeSnapshots) {
118
+ assert(haveSnapshots, 'For snapshots, use Node.js >=22.3.0')
119
+ args.push('--test-update-snapshots')
120
+ }
121
+
122
+ if (options.forceExit) {
123
+ assert((major === 20 && minor > 13) || major >= 22, 'For forceExit, use Node.js >= 20.14.0')
124
+ args.push('--test-force-exit')
125
+ }
126
+
127
+ if (options.watch) {
128
+ assert((major === 18 && minor > 13) || major >= 20, 'For watch mode, use Node.js >= 18.13.0')
129
+ args.push('--watch')
130
+ }
131
+
132
+ args.push('--expose-internals') // this is unoptimal and hopefully temporary, see rationale in src/dark.cjs
78
133
 
79
- const args = ['--test', '--enable-source-maps']
80
134
  if (options.coverage) {
81
135
  if (options.coverageEngine === 'node') {
82
136
  args.push('--experimental-test-coverage')
@@ -90,32 +144,80 @@ if (options.coverage) {
90
144
  }
91
145
  }
92
146
 
93
- if (options.global) {
94
- if (major >= 20 || (major === 18 && minor >= 18)) {
95
- args.push('--import', resolve(bindir, 'preload.js'))
147
+ if (options.typescript || options.esbuild) {
148
+ if (major >= 22 || (major === 20 && minor >= 6) || (major === 18 && minor >= 18)) {
149
+ assert(resolveImport)
150
+ args.push('--import', resolveImport('tsx'))
96
151
  } else {
97
- throw new Error('Option --global requires Node.js >= v18.18.0')
152
+ args.push('-r', resolveRequire('tsx/cjs'))
98
153
  }
99
154
  }
100
155
 
101
- if (options.typescript) {
102
- if (major >= 22 || (major === 20 && minor >= 6) || (major === 18 && minor >= 18)) {
103
- args.push('--import', '@swc-node/register/esm-register')
156
+ if (options.babel) {
157
+ assert(!options.typescript, 'Options --babel and --typescript are mutually exclusive')
158
+ args.push('-r', resolveRequire('./babel.cjs'))
159
+ }
160
+
161
+ // Our loader should be last, as enabling module mocks confuses other loaders
162
+ if (options.jest) {
163
+ if (major >= 20 || (major === 18 && minor >= 18)) {
164
+ args.push('--import', resolve(bindir, 'jest.js'))
104
165
  } else {
105
- throw new Error('Option --typescript requires Node.js >=20.6.0 || 18 >=18.18.0')
166
+ throw new Error('Option --jest requires Node.js >= v18.18.0')
106
167
  }
107
168
  }
108
169
 
109
- if (major === 18 || major === 20) {
110
- // We need to expand glob patterns for these
111
- args.push(...(await glob(patterns)))
112
- } else if (major >= 22) {
113
- // Yay we have native glob support
114
- args.push(...patterns)
115
- } else {
116
- throw new Error('Unreachable')
170
+ const ignore = ['node_modules']
171
+ if (process.env.EXODUS_TEST_IGNORE) {
172
+ // fast-glob treats negative ignore patterns exactly the same as positive, let's not cause a confusion
173
+ assert(!process.env.EXODUS_TEST_IGNORE.startsWith('!'), 'Ignore pattern should not be negative')
174
+ ignore.push(process.env.EXODUS_TEST_IGNORE)
175
+ }
176
+
177
+ const allfiles = await glob(patterns, { ignore })
178
+
179
+ if (allfiles.length === 0) {
180
+ if (options.passWithNoTests) {
181
+ console.warn('No tests files found, but passing due to --passWithNoTests')
182
+ process.exit(0)
183
+ }
184
+
185
+ console.error('No tests files found!')
186
+ process.exit(1)
117
187
  }
118
188
 
189
+ let subfiles // must be a strict subset of allfiles
190
+ if (process.env.EXODUS_TEST_SELECT) {
191
+ const subfiles = await glob(process.env.EXODUS_TEST_SELECT, { ignore })
192
+
193
+ const allSet = new Set(allfiles)
194
+ const stray = subfiles.filter((file) => !allSet.has(file))
195
+ if (stray.length > 0) {
196
+ console.error(`Selected tests should be a subset of all tests:\n ${stray.join('\n ')}`)
197
+ process.exit(1)
198
+ }
199
+
200
+ if (subfiles.length === 0) {
201
+ console.error('No tests files selected due to EXODUS_TEST_SELECT, passing')
202
+ process.exit(0)
203
+ }
204
+ }
205
+
206
+ const files = subfiles ?? allfiles
207
+
208
+ const tsTests = files.filter((file) => file.endsWith('.ts'))
209
+ if (tsTests.length > 0 && !options.typescript) {
210
+ console.error(`Some tests require --typescript flag:\n ${tsTests.join('\n ')}`)
211
+ process.exit(1)
212
+ } else if (!allfiles.some((file) => file.endsWith('.ts')) && options.typescript) {
213
+ console.warn(`Flag --typescript has been used, but there were no TypeScript tests found!`)
214
+ }
215
+
216
+ assert(files.length > 0) // otherwise we can run recursively
217
+ args.push(...files)
218
+
219
+ if (!Object.hasOwn(process.env, 'NODE_ENV')) process.env.NODE_ENV = 'test'
220
+
119
221
  assert(program && ['node', c8].includes(program))
120
222
  const node = spawn(program, args, { stdio: 'inherit' })
121
223
 
package/bin/jest.js ADDED
@@ -0,0 +1,13 @@
1
+ import * as globals from '../src/jest.js'
2
+ // import { resolveModule } from '../src/jest.mock.js'
3
+ // import { mock } from 'node:test'
4
+
5
+ Object.assign(globalThis, globals)
6
+
7
+ // @jest/globals import auto-mocking is disabled until https://github.com/nodejs/node/issues/53807 is resolved
8
+ /*
9
+ try {
10
+ const resolved = resolveModule('@jest/globals')
11
+ if (mock.module) mock.module(resolved, { defaultExport: globals, namedExports: globals })
12
+ } catch {}
13
+ */
package/package.json CHANGED
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "name": "@exodus/test",
3
- "version": "1.0.0-rc.0",
3
+ "version": "1.0.0-rc.10",
4
4
  "author": "Exodus Movement, Inc.",
5
5
  "description": "A test suite runner",
6
- "homepage": "https://github.com/ExodusMovement/exodus-hydra/tree/master/libraries/test",
7
- "bugs": {
8
- "url": "https://github.com/ExodusMovement/exodus-hydra/issues?q=is%3Aissue+is%3Aopen+label%3Aexodus-test"
9
- },
6
+ "homepage": "https://github.com/ExodusMovement/test",
10
7
  "repository": {
11
8
  "type": "git",
12
- "url": "git+https://github.com/ExodusMovement/exodus-hydra.git"
9
+ "url": "https://github.com/ExodusMovement/test.git"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/ExodusMovement/test/issues"
13
13
  },
14
14
  "keywords": [
15
15
  "test",
@@ -25,20 +25,48 @@
25
25
  "bin": {
26
26
  "exodus-test": "bin/index.js"
27
27
  },
28
- "main": "src/index.js",
28
+ "exports": {
29
+ "./jest": "./src/jest.js",
30
+ "./tape": "./src/tape.js"
31
+ },
32
+ "prettier": "@exodus/prettier",
29
33
  "files": [
30
- "bin/preload.js",
34
+ "bin/babel.cjs",
35
+ "bin/jest.js",
36
+ "src/dark.cjs",
37
+ "src/jest.js",
38
+ "src/jest.fn.js",
39
+ "src/jest.mock.js",
40
+ "src/jest.snapshot.js",
41
+ "src/jest.timers.js",
42
+ "src/tape.js",
31
43
  "!__tests__",
32
44
  "CHANGELOG.md"
33
45
  ],
34
46
  "scripts": {
35
- "test": "exodus-test --global --typescript",
36
- "lint": "run -T eslint ."
47
+ "test": "./bin/index.js --jest --typescript",
48
+ "test:tape": "./bin/index.js --esbuild '__test__/tape/test/*.js' __test__/tape.test.js",
49
+ "coverage": "./bin/index.js --jest --typescript --coverage",
50
+ "lint": "prettier --list-different . && eslint .",
51
+ "lint:fix": "prettier --write . && eslint --fix ."
37
52
  },
38
53
  "dependencies": {
39
- "@swc-node/register": "^1.8.0",
54
+ "@babel/register": "^7.0.0",
40
55
  "c8": "^9.1.0",
41
56
  "expect": "^29.7.0",
42
- "fast-glob": "^3.2.11"
43
- }
57
+ "fast-glob": "^3.2.11",
58
+ "jest-extended": "^4.0.2",
59
+ "pretty-format": "^29.7.0",
60
+ "tsx": "^4.16.2"
61
+ },
62
+ "devDependencies": {
63
+ "@exodus/eslint-config": "^5.24.0",
64
+ "@exodus/prettier": "^1.0.0",
65
+ "@jest/globals": "^29.7.0",
66
+ "@types/jest-when": "^3.5.2",
67
+ "@typescript-eslint/eslint-plugin": "^7.15.0",
68
+ "eslint": "^8.44.0",
69
+ "jest-when": "^3.6.0"
70
+ },
71
+ "packageManager": "pnpm@9.4.0+sha1.9217c800d4ab947a7aee520242a7b70d64fc7638"
44
72
  }
package/src/dark.cjs ADDED
@@ -0,0 +1,48 @@
1
+ const { fileURLToPath } = require('node:url')
2
+
3
+ let locForNextTest
4
+
5
+ let installLocationInNextTest = function (loc) {
6
+ locForNextTest = loc
7
+ }
8
+
9
+ // WARNING
10
+ // Do not refactor, do not wrap
11
+ // This function has to be called unwrapped directly inside our test() impl
12
+ let getCallerLocation = () => {}
13
+
14
+ const mayBeUrlToPath = (str) => (str.startsWith('file://') ? fileURLToPath(str) : str)
15
+
16
+ // This is unoptimal
17
+ // Ideally, an option for overriding file locations should be added to Node.js,
18
+ // instead of relying on the call location of the original test() impl
19
+ // That could be even hardened by a simple option of how many frames up to look
20
+
21
+ // This whole logic is limited only to updating caller locations for reports
22
+ // We don't do use exposed Node.js internas for anything else
23
+
24
+ try {
25
+ const { Test } = require('node:internal/test_runner/test')
26
+ const locStorage = new Map()
27
+ Object.defineProperty(Test.prototype, 'loc', {
28
+ get() {
29
+ return locStorage.get(this)
30
+ },
31
+ set(val) {
32
+ locStorage.set(this, val)
33
+ if (locForNextTest) {
34
+ const loc = locForNextTest
35
+ locForNextTest = undefined
36
+ locStorage.set(this, { line: loc[0], column: loc[1], file: mayBeUrlToPath(loc[2]) })
37
+ }
38
+ },
39
+ })
40
+
41
+ // We can replicate getCallerLocation() with public V8 Error CallSite API, but we won't
42
+ // need it anyway if we don't have a path for hook into internal Test implementation
43
+
44
+ const { internalBinding } = require('node:internal/test/binding')
45
+ getCallerLocation = internalBinding('util').getCallerLocation
46
+ } catch {}
47
+
48
+ module.exports = { installLocationInNextTest, getCallerLocation }
package/src/jest.fn.js ADDED
@@ -0,0 +1,169 @@
1
+ import { mock } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ const registry = new Set()
5
+
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
+ }
12
+
13
+ export const allMocks = {
14
+ clearAllMocks: applyAllWrap('mockClear'),
15
+ resetAllMocks: applyAllWrap('mockReset'),
16
+ restoreAllMocks: applyAllWrap('mockRestore'),
17
+ }
18
+
19
+ // We need parent and property for jest.spyOn and mockfn.mockRestore()
20
+ export const jestfn = (baseimpl, parent, property) => {
21
+ // not an arrow as might be used as a constructor
22
+ // also, should be isolated between jest.fn calls
23
+ const noop = function () {}
24
+
25
+ let mockname
26
+ let mockimpl = baseimpl || noop
27
+ let reportedmockimpl = baseimpl || undefined
28
+ const onceStack = []
29
+
30
+ const fn = mock.fn(mockimpl)
31
+ const fnmock = fn.mock
32
+
33
+ const queuedMockClear = () => fnmock.resetCalls()
34
+ const queuedMockReset = () => {
35
+ queuedMockClear()
36
+ onceStack.length = 0
37
+ mockimpl = noop
38
+ mockname = undefined
39
+ reportedmockimpl = undefined
40
+ fnmock.mockImplementation(mockimpl)
41
+ }
42
+
43
+ const queuedMockRestore = () => {
44
+ queuedMockReset()
45
+ // mocked function resets to noop, the original resets to baseimpl
46
+ if (parent && property) {
47
+ assert(property in parent && !(property in {}) && !(property in Object.prototype))
48
+ if (parent[property] === fnproxy) {
49
+ // we need to handle the case when that came from prototype
50
+ // eslint-disable-next-line @exodus/mutable/no-param-reassign-prop-only
51
+ delete parent[property]
52
+ // eslint-disable-next-line @exodus/mutable/no-param-reassign-prop-only
53
+ if (parent[property] !== baseimpl) parent[property] = baseimpl
54
+ }
55
+ }
56
+ }
57
+
58
+ const queuedMock = (impl) => {
59
+ mockimpl = impl || noop
60
+ onceStack.length = 0
61
+ fnmock.mockImplementation(mockimpl)
62
+ }
63
+
64
+ // getMockImplementation() is undocumented and is changed only in real mockImplementation() call
65
+ const queuedMockReported = (impl) => {
66
+ queuedMock(impl)
67
+ reportedmockimpl = impl
68
+ }
69
+
70
+ const queuedMockOnce = (impl) => {
71
+ onceStack.push(impl)
72
+ fnmock.mockImplementation(queueImplementation)
73
+ }
74
+
75
+ const queueImplementation = function (...args) {
76
+ try {
77
+ const impl = onceStack.shift() || mockimpl
78
+ return impl.call(this, ...args)
79
+ } finally {
80
+ // load fast path if we are done with the queue
81
+ if (onceStack.length === 0) {
82
+ assert(mockimpl)
83
+ fnmock.mockImplementation(mockimpl)
84
+ }
85
+ }
86
+ }
87
+
88
+ const jestfnmock = {
89
+ get calls() {
90
+ return fnmock.calls.map((call) => call.arguments)
91
+ },
92
+ get results() {
93
+ return fnmock.calls.map((call) =>
94
+ call.error ? { type: 'throw', value: call.error } : { type: 'return', value: call.result }
95
+ )
96
+ },
97
+ get instances() {
98
+ return fnmock.calls.map((call) => {
99
+ // only return valid instances
100
+ assert(call.result && call.result === call.this)
101
+ return call.this
102
+ })
103
+ },
104
+ get contexts() {
105
+ return fnmock.calls.map((call) => call.this)
106
+ },
107
+ get lastCall() {
108
+ return fnmock.calls.at(-1)?.arguments
109
+ },
110
+ }
111
+
112
+ const fnProxyGet = (obj, key) => {
113
+ const wrap =
114
+ (body) =>
115
+ (...args) => {
116
+ body(...args)
117
+ return fnproxy
118
+ }
119
+
120
+ if (Object.hasOwn(obj, key)) return obj[key]
121
+
122
+ switch (key) {
123
+ case 'bind':
124
+ // No need to add this to the registy as we already have the base instance
125
+ return (...args) => new Proxy(obj.bind(...args), { get: fnProxyGet })
126
+ case 'mock':
127
+ return jestfnmock
128
+ case '_isMockFunction':
129
+ return true
130
+ case 'getMockName':
131
+ return () => mockname ?? 'jest.fn()'
132
+ case 'mockName':
133
+ return wrap((name) => {
134
+ mockname = name
135
+ })
136
+ case 'getMockImplementation':
137
+ return () => reportedmockimpl
138
+ case 'mockClear':
139
+ return wrap(() => queuedMockClear())
140
+ case 'mockReset':
141
+ return wrap(() => queuedMockReset())
142
+ case 'mockRestore':
143
+ return wrap(() => queuedMockRestore())
144
+ case 'mockImplementation':
145
+ return wrap((impl) => queuedMockReported(impl))
146
+ case 'mockImplementationOnce':
147
+ return wrap((impl) => queuedMockOnce(impl))
148
+ case 'mockReturnValue':
149
+ return wrap((val) => queuedMock(() => val))
150
+ case 'mockReturnValueOnce':
151
+ return wrap((val) => queuedMockOnce(() => val))
152
+ case 'mockResolvedValue':
153
+ return wrap((val) => queuedMock(() => Promise.resolve(val)))
154
+ case 'mockResolvedValueOnce':
155
+ return wrap((val) => queuedMockOnce(() => Promise.resolve(val)))
156
+ case 'mockRejectedValue':
157
+ return wrap((val) => queuedMock(() => Promise.reject(val)))
158
+ case 'mockRejectedValueOnce':
159
+ return wrap((val) => queuedMockOnce(() => Promise.reject(val)))
160
+ }
161
+
162
+ return obj[key]
163
+ }
164
+
165
+ const fnproxy = new Proxy(fn, { get: fnProxyGet })
166
+ registry.add(fnproxy)
167
+
168
+ return fnproxy
169
+ }
package/src/jest.js ADDED
@@ -0,0 +1,89 @@
1
+ import assert from 'node:assert/strict'
2
+ import { describe as nodeDescribe, test as nodeTest, afterEach } from 'node:test'
3
+ import { format, types } from 'node:util'
4
+ import { jestfn, allMocks } from './jest.fn.js'
5
+ import { jestmock, requireActual, requireMock, resetModules } from './jest.mock.js'
6
+ import * as jestTimers from './jest.timers.js'
7
+ import './jest.snapshot.js'
8
+ import { getCallerLocation, installLocationInNextTest } from './dark.cjs'
9
+ import { expect } from 'expect'
10
+ import matchers from 'jest-extended'
11
+
12
+ expect.extend(matchers)
13
+
14
+ let defaultTimeout = 5000
15
+
16
+ const makeEach = (impl) => (list) => (template, fn) => {
17
+ for (const arg of list) {
18
+ let name = template
19
+
20
+ const args = !arg || typeof arg !== 'object' ? [arg] : arg
21
+
22
+ for (const [key, value] of Object.entries(args)) {
23
+ name = name.replace(`$${key}`, value) // can collide but we don't care much yet
24
+ }
25
+
26
+ if (Array.isArray(args)) {
27
+ const length = [...name.replaceAll('%%', '').matchAll(/%./gu)].length
28
+ if (length > 0) name = format(name, ...args.slice(0, length))
29
+ }
30
+
31
+ impl(name, () => (Array.isArray(args) ? fn(...args) : fn(args)))
32
+ }
33
+ }
34
+
35
+ const forceExit = process.execArgv.map((x) => x.replaceAll('_', '-')).includes('--test-force-exit')
36
+
37
+ const describe = (...args) => nodeDescribe(...args)
38
+ const test = (name, fn, testTimeout) => {
39
+ const timeout = testTimeout ?? defaultTimeout
40
+ installLocationInNextTest(getCallerLocation())
41
+ if (fn.length > 0) return nodeTest(name, (t, c) => fn(c))
42
+ if (!forceExit) return nodeTest(name, fn)
43
+ return nodeTest(name, { timeout }, async (t) => {
44
+ const res = fn()
45
+ assert(
46
+ types.isPromise(res),
47
+ `Test "${t.fullName}" did not return a Promise or supply a callback, which is required in force-exit mode.
48
+ For tests to not end abruptly, use either async functions (recommended), Promises, or specify callbacks to test() / it().
49
+ Also, using expect.assertions() to ensure the planned number of assertions is being called is advised for async code.`
50
+ )
51
+ return res
52
+ })
53
+ }
54
+
55
+ describe.each = makeEach(describe)
56
+ test.each = makeEach(test)
57
+ describe.skip = (...args) => nodeDescribe.skip(...args)
58
+ test.skip = (...args) => nodeTest.skip(...args)
59
+
60
+ afterEach(() => {
61
+ for (const { error } of expect.extractExpectedAssertionsErrors()) throw error
62
+ })
63
+
64
+ const jest = {
65
+ fn: (impl) => jestfn(impl), // hide extra arguments
66
+ ...allMocks,
67
+ spyOn: (obj, name, accessType) => {
68
+ assert(!accessType, `accessType "${accessType}" is not supported`)
69
+ assert(obj && name && name in obj && !(name in {}) && !(name in Object.prototype))
70
+ const fn = jestfn(obj[name], obj, name)
71
+ // eslint-disable-next-line @exodus/mutable/no-param-reassign-prop-only
72
+ obj[name] = fn
73
+ return fn
74
+ },
75
+ setTimeout: (x) => {
76
+ assert.equal(typeof x, 'number')
77
+ defaultTimeout = x
78
+ return this
79
+ },
80
+ mock: jestmock,
81
+ requireMock,
82
+ requireActual,
83
+ resetModules,
84
+ ...jestTimers,
85
+ }
86
+
87
+ export { jest, describe, test, test as it }
88
+ export { expect } from 'expect'
89
+ export { beforeEach, afterEach, before as beforeAll, after as afterAll } from 'node:test'
@@ -0,0 +1,133 @@
1
+ import assert from 'node:assert/strict'
2
+ import { createRequire, builtinModules, syncBuiltinESMExports } from 'node:module'
3
+ import { existsSync } from 'node:fs'
4
+ import { normalize } from 'node:path'
5
+ import { mock } from 'node:test'
6
+ import { jestfn } from './jest.fn.js'
7
+
8
+ const files = process.argv.slice(1)
9
+ const baseUrl = files.length === 1 && existsSync(files[0]) ? normalize(files[0]) : undefined
10
+ const require = createRequire(baseUrl || import.meta.url)
11
+ const mapMocks = new Map()
12
+ const mapActual = new Map()
13
+
14
+ export function resolveModule(name) {
15
+ assert(baseUrl || /^[@a-zA-Z]/u.test(name), 'Mocking relative paths is not possible')
16
+ return require.resolve(name)
17
+ }
18
+
19
+ export function requireActual(name) {
20
+ const resolved = resolveModule(name)
21
+ if (mapActual.has(resolved)) return mapActual.get(resolved)
22
+ if (!mapMocks.has(resolved)) return require(resolved)
23
+ throw new Error('Module can not been loaded')
24
+ }
25
+
26
+ export function requireMock(name) {
27
+ const resolved = resolveModule(name)
28
+ assert(mapMocks.has(resolved), 'Module is not mocked')
29
+ return mapMocks.get(resolved)
30
+ }
31
+
32
+ export function resetModules() {
33
+ // Caveat: only resets CJS modules, not ESM
34
+ for (const key of Object.keys(require.cache)) delete require.cache[key]
35
+ }
36
+
37
+ const isObject = (obj) => [Object.prototype, null].includes(Object.getPrototypeOf(obj))
38
+
39
+ function override(resolved, lax = false) {
40
+ const value = mapMocks.get(resolved)
41
+ const current = mapActual.get(resolved)
42
+ assert(isObject(current), 'Modules that export a default non-object can not be mocked')
43
+ assert(isObject(value), 'Overriding loaded or internal modules is possible with objects only')
44
+ mapActual.set(resolved, { ...current })
45
+ for (const key of Object.keys(current)) {
46
+ try {
47
+ delete current[key]
48
+ } catch {}
49
+ }
50
+
51
+ // We want to skip overriding frozen properties that already match, e.g. fs.constants
52
+ const filtered = Object.entries(value).filter(([k, v]) => !(k in {}) && current[k] !== v)
53
+ const access = { configurable: true, enumerable: true, writable: true }
54
+ const definitions = Object.fromEntries(filtered.map(([k, value]) => [k, { value, ...access }]))
55
+ Object.defineProperties(current, definitions)
56
+ if (!lax) assert.deepEqual({ ...current }, value)
57
+ }
58
+
59
+ function mockClone(root) {
60
+ assert(isObject(root), 'Can not do a full mock on a non-object module')
61
+ const seen = new Map()
62
+ const simple = new Set()
63
+ const TypedArray = Object.getPrototypeOf(Int8Array)
64
+ const walk = (obj) => {
65
+ if (!obj || ['number', 'boolean', 'string', 'bigint'].includes(typeof obj)) return [obj, false]
66
+ if (Array.isArray(obj) || obj instanceof TypedArray) return [[], false] // this is what jest does apparently
67
+ if (obj instanceof RegExp) return [new RegExp(), false] // this is what jest does apparently
68
+ if (seen.has(obj)) return [seen.get(obj), !simple.has(obj)]
69
+ if (obj instanceof Function) {
70
+ seen.set(obj, jestfn(obj))
71
+ return [seen.get(obj), true]
72
+ }
73
+
74
+ if (isObject(obj)) {
75
+ const clone = Object.create(Object.getPrototypeOf(obj))
76
+ seen.set(obj, clone)
77
+ let modified = false
78
+ for (const [k, v] of Object.entries(obj)) {
79
+ const res = walk(v)
80
+ if (!res && !(k in clone)) continue
81
+ clone[k] = res[0]
82
+ modified ||= res[1]
83
+ }
84
+
85
+ if (modified) simple.add(obj)
86
+ return [modified ? clone : obj, modified]
87
+ }
88
+
89
+ return null
90
+ }
91
+
92
+ return walk(root)[0]
93
+ }
94
+
95
+ export function jestmock(name, mocker) {
96
+ assert(mock.module, 'ESM module mocks are available only on Node.js >=22.3')
97
+
98
+ // Loaded ESM: isn't mocked
99
+ // Loaded CJS: mocked via object overriding
100
+ // Loaded built-ins: mocked via object overriding where possible
101
+ // New CJS: mocked via mock.module + require.cache
102
+ // New ESM: mocked via mock.module
103
+ // New built-ins: mocked via mock.module
104
+
105
+ const resolved = resolveModule(name)
106
+ assert(!mapMocks.has(resolved), 'Re-mocking the same module is not supported')
107
+
108
+ // Attempt to load it
109
+ // Jest also loads modules on mock
110
+ // Can be ESM, so let it fail silently
111
+ const havePrior = Object.hasOwn(require.cache, resolved)
112
+ try {
113
+ mapActual.set(resolved, require(resolved))
114
+ } catch {}
115
+
116
+ const value = mocker ? { ...mocker() } : mockClone(mapActual.get(resolved))
117
+ mapMocks.set(resolved, value)
118
+
119
+ // fall through when e.g. this module doesn't exist or is ESM
120
+ if (Object.hasOwn(require.cache, resolved)) {
121
+ assert.equal(mapActual.get(resolved), require.cache[resolved].exports)
122
+ // If we did't have this prior but have now, it means we just loaded it and there are no leaked instances
123
+ if (havePrior) override(resolved)
124
+ require.cache[resolved].exports = value
125
+ } else if (builtinModules.includes(resolved.replace(/^node:/, ''))) {
126
+ override(resolved, true) // Override builtin modules
127
+ syncBuiltinESMExports()
128
+ }
129
+
130
+ mock.module(name, { defaultExport: value.default, namedExports: value })
131
+
132
+ return this
133
+ }
@@ -0,0 +1,98 @@
1
+ import { beforeEach } from 'node:test'
2
+ import { createRequire } from 'node:module'
3
+ import { expect } from 'expect'
4
+ import { format } from 'pretty-format'
5
+ import assert from 'node:assert/strict'
6
+ import { basename, dirname, join } from 'node:path'
7
+
8
+ const plugins = []
9
+ const opts = { indent: 2, escapeRegex: true, printFunctionName: false, printBasicPrototype: false }
10
+ const serialize = (val) => format(val, { ...opts, plugins }).replaceAll(/\r\n|\r/gu, '\n')
11
+
12
+ let snapshotsAreJest = false
13
+
14
+ // We want to setup snapshots to behave like jest only when first used from jest API
15
+ function maybeSetupJestSnapshots() {
16
+ if (snapshotsAreJest) return
17
+ const require = createRequire(import.meta.url)
18
+ const { snapshot } = require('node:test') // attempt to load them, and we need to do that synchronously
19
+ assert(snapshot, 'snapshots require Node.js >=22.3.0')
20
+ snapshot.setDefaultSnapshotSerializers([serialize])
21
+ snapshot.setResolveSnapshotPath((f) => join(dirname(f), '__snapshots__', `${basename(f)}.snap`))
22
+ snapshotsAreJest = true
23
+ }
24
+
25
+ const wrap = (check) => {
26
+ try {
27
+ check()
28
+ return { pass: true }
29
+ } catch (e) {
30
+ return { pass: false, message: () => e.message }
31
+ }
32
+ }
33
+
34
+ let context
35
+ beforeEach((t) => (context = t))
36
+ const getAssert = () => context?.assert ?? assert // do not use non-strict comparisons on this!
37
+
38
+ // Wrap reported context.fullName so that snapshots are placed/looked for under jest-compatible keys
39
+ function wrapContextName(fn) {
40
+ if (context.fullName === context.name) return fn() // fast path
41
+ const value = context.fullName
42
+ assert(typeof value === 'string' && value.endsWith(` > ${context.name}`))
43
+ const SuiteContext = Object.getPrototypeOf(context)
44
+ const fullNameDescriptor = Object.getOwnPropertyDescriptor(SuiteContext, 'fullName')
45
+ assert(fullNameDescriptor && fullNameDescriptor.configurable)
46
+ Object.defineProperty(context, 'fullName', {
47
+ configurable: true,
48
+ get() {
49
+ assert.equal(this, context)
50
+ return value.replaceAll(' > ', ' ')
51
+ },
52
+ })
53
+ try {
54
+ return fn()
55
+ } finally {
56
+ assert.notEqual(context.fullName, value)
57
+ delete context.fullName
58
+ assert.equal(context.fullName, value)
59
+ }
60
+ }
61
+
62
+ const throws = (fn, check) =>
63
+ getAssert().throws(fn, (e) => {
64
+ check(e.message) // jest stores only messages for errors
65
+ return true
66
+ })
67
+
68
+ const snapInline = (obj, inline) => {
69
+ assert(inline !== undefined, 'Inline Snapshots generation is not supported')
70
+ assert(typeof inline === 'string')
71
+ getAssert().strictEqual(serialize(obj).trim(), inline.trim())
72
+ }
73
+
74
+ const snapOnDisk = (obj) =>
75
+ wrapContextName(() => {
76
+ maybeSetupJestSnapshots()
77
+
78
+ if (!serialize(obj).includes('\n')) {
79
+ // Node.js always wraps with newlines, while jest wraps only those that are already multiline
80
+ try {
81
+ getAssert().snapshot(obj)
82
+ } catch (e) {
83
+ if (`\n${e.expected}\n` === e.actual) return
84
+ throw e
85
+ }
86
+ }
87
+
88
+ return getAssert().snapshot(obj)
89
+ })
90
+
91
+ expect.extend({
92
+ toMatchInlineSnapshot: (obj, i) => wrap(() => snapInline(obj, i)),
93
+ toMatchSnapshot: (obj) => wrap(() => snapOnDisk(obj)),
94
+ toThrowErrorMatchingInlineSnapshot: (f, i) => wrap(() => throws(f, (msg) => snapInline(msg, i))),
95
+ toThrowErrorMatchingSnapshot: (f) => wrap(() => throws(f, (msg) => snapOnDisk(msg))),
96
+ })
97
+
98
+ expect.addSnapshotSerializer = (plugin) => plugins.push(plugin)
@@ -0,0 +1,67 @@
1
+ import assert from 'node:assert/strict'
2
+ import { mock } from 'node:test'
3
+
4
+ const [major, minor] = process.versions.node.split('.').map(Number)
5
+
6
+ const assertHaveTimers = () =>
7
+ assert(mock.timers, 'Timer mocking requires Node.js >=20.4.0 || 18 >=18.19.0')
8
+
9
+ let timersWarned = false
10
+ const warnOldTimers = () => {
11
+ if (timersWarned) return
12
+ timersWarned = true
13
+ const ok = major >= 22 || (major === 20 && minor >= 11)
14
+ if (!ok) console.warn('Warning: timer mocks are known to be glitchy before Node.js >=20.11.0')
15
+ }
16
+
17
+ export function useRealTimers() {
18
+ mock.timers.reset()
19
+ return this
20
+ }
21
+
22
+ export function useFakeTimers({ doNotFake = [], ...rest } = {}) {
23
+ assertHaveTimers()
24
+ warnOldTimers()
25
+ assert.deepEqual(rest, {}, 'Unsupported options')
26
+ const allApis = ['setInterval', 'setTimeout', 'setImmediate', 'Date']
27
+ for (const name of doNotFake) assert(allApis.includes(name), `Unknown API: ${name}`)
28
+ const apis = allApis.filter((name) => !doNotFake.includes(name))
29
+ try {
30
+ mock.timers.enable({ apis })
31
+ } catch (e) {
32
+ // We allow calling this multiple times and swallow the "MockTimers is already enabled!" error
33
+ if (e.code !== 'ERR_INVALID_STATE') throw e
34
+ }
35
+
36
+ return this
37
+ }
38
+
39
+ export function runAllTimers() {
40
+ assertHaveTimers()
41
+ warnOldTimers()
42
+ mock.timers.tick(100_000_000_000) // > 3 years
43
+ return this
44
+ }
45
+
46
+ export function runOnlyPendingTimers() {
47
+ const noInfiniteLoopBug = major >= 22 || (major === 20 && minor >= 11)
48
+ assert(noInfiniteLoopBug, 'runOnlyPendingTimers requires Node.js >=20.11.0')
49
+ mock.timers.runAll()
50
+ return this
51
+ }
52
+
53
+ export function advanceTimersByTime(time) {
54
+ assertHaveTimers()
55
+ warnOldTimers()
56
+ mock.timers.tick(time)
57
+ return this
58
+ }
59
+
60
+ export async function advanceTimersByTimeAsync(time) {
61
+ return this.advanceTimersByTime(time)
62
+ }
63
+
64
+ export function setSystemTime(time) {
65
+ mock.timers.setTime(+time)
66
+ return this
67
+ }
package/src/tape.js ADDED
@@ -0,0 +1,159 @@
1
+ import assert from 'node:assert/strict'
2
+ import assertLoose from 'node:assert'
3
+ import { test } from 'node:test'
4
+ import { getCallerLocation, installLocationInNextTest } from './dark.cjs'
5
+
6
+ const knownOptions = new Set(['skip', 'todo', 'concurrency', 'timeout'])
7
+
8
+ function verifyOptions(options) {
9
+ for (const key of Object.keys(options)) {
10
+ assert(knownOptions.has(key), `Unknown option: ${key}`)
11
+ }
12
+ }
13
+
14
+ // We don't seem to need it for jest, so let's let it live in this file
15
+ function cleanAssertError(e, where) {
16
+ // eslint-disable-next-line @exodus/mutable/no-param-reassign-prop-only
17
+ e.stack = e.stack
18
+ .split('\n')
19
+ .filter((x) => !x.startsWith(`at ${where}:`))
20
+ .filter((x) => !x.includes(` (${where}:`))
21
+ .filter((x) => !x.includes(` (node:async_hooks:`))
22
+ .filter((x) => !x.includes(` (node:internal/test_runner`))
23
+ .join('\n')
24
+ }
25
+
26
+ // key: assert method, values: names after which it'll be available
27
+ // note that it's not available by the original key unless explicitly listed in names
28
+ // e.g. we are strict by default
29
+ // We follow tape, not tap, i.e. same/notSame aliases are strict, and there are no special strict versions
30
+ // Most are taken from the doc: https://www.npmjs.com/package/tape#methods
31
+ const aliases = {
32
+ __proto__: null,
33
+
34
+ ok: ['ok', 'true', 'assert'],
35
+ strictEqual: ['equal', 'equals', 'isEqual', 'strictEqual', 'strictEquals', 'is'],
36
+ notStrictEqual: [
37
+ 'notEqual',
38
+ 'notEquals',
39
+ 'isNotEqual',
40
+ 'doesNotEqual',
41
+ 'isInequal',
42
+ 'notStrictEqual',
43
+ 'notStrictEquals',
44
+ 'isNot',
45
+ 'not',
46
+ ],
47
+ equal: ['looseEqual', 'looseEquals'],
48
+ notEqual: ['notLooseEqual', 'notLooseEquals'],
49
+ deepStrictEqual: ['deepEqual', 'deepEquals', 'isEquivalent', 'same'],
50
+ notDeepStrictEqual: [
51
+ 'notDeepEqual',
52
+ 'notDeepEquals',
53
+ 'notEquivalent',
54
+ 'notDeeply',
55
+ 'notSame',
56
+ 'isNotDeepEqual',
57
+ 'isNotDeeply',
58
+ 'isNotEquivalent',
59
+ 'isInequivalent',
60
+ ],
61
+ deepEqual: ['deepLooseEqual'],
62
+ notDeepEqual: ['notDeepLooseEqual'],
63
+ throws: ['throws'],
64
+ doesNotThrow: ['doesNotThrow'],
65
+ fail: ['fail'],
66
+ rejects: ['rejects'],
67
+ doesNotReject: ['resolves'],
68
+
69
+ // specially handled ones as do not exist in t.assert / assert
70
+ notOk: ['notOk', 'false', 'notok'],
71
+ pass: ['pass'],
72
+ error: ['error', 'ifError', 'ifErr', 'iferror'], // tape
73
+ assertion: ['assertion'], // tape
74
+
75
+ // match/notMatch are confusing as operate on strings in some impls and objs in others. we skip them
76
+ }
77
+
78
+ function tapeWrapAssert(t, callback) {
79
+ // Auto-call api.end() on planned test count reaching zero
80
+ let plan = null
81
+ let count = 0
82
+ const track = (...calls) => {
83
+ count += calls.length
84
+ if (plan === count) api.end()
85
+ if (plan !== null) assert(plan >= count, `plan (${plan}) < count (${count})`)
86
+ }
87
+
88
+ const plannedAssert = () => (plan !== null && t.assert) || assertLoose // t.assert is cached and affected by t.plan
89
+
90
+ // Note: we must use plannedAssert instead of assert everywhere on user calls as we have t.plan
91
+ const api = {
92
+ test: tapeWrap(t.test.bind(t)),
93
+ plan: (total) => {
94
+ assert.equal(typeof total, 'number')
95
+ plan = total
96
+ assert(plan >= count, `plan (${plan}) < count (${count})`)
97
+ if (t.plan) t.plan(plan - count) // plan the remaining tests through node
98
+ track()
99
+ },
100
+ skip: (...r) => t.skip(...r),
101
+ todo: (...r) => t.todo(...r),
102
+ comment: (...r) => t.diagnostic(...r),
103
+ end: () => {
104
+ if (plan !== null) assert.equal(plan, count, `plan (${plan}) !== count (${count})`)
105
+ if (callback) callback()
106
+ api.end = () => {}
107
+ },
108
+ }
109
+
110
+ // Copy implementations from here if they exist, preferring over plannedAssert
111
+ const base = {
112
+ pass: (...r) => plannedAssert().ok(true, ...r),
113
+ notOk: (x, ...r) => plannedAssert().ok(!x, ...r),
114
+ error: (err, msg) => plannedAssert().ok(!err, msg || err?.message),
115
+ assertion: (fn, ...args) => fn.apply(plannedAssert(), args),
116
+ }
117
+
118
+ for (const [key, names] of Object.entries(aliases)) {
119
+ const impl = Object.hasOwn(base, key) ? base[key] : (...r) => plannedAssert()[key](...r)
120
+ const wrap = (...r) => {
121
+ try {
122
+ return impl(...r)
123
+ } catch (e) {
124
+ cleanAssertError(e, import.meta.url)
125
+ throw e
126
+ }
127
+ }
128
+
129
+ Object.assign(api, Object.fromEntries(names.map((name) => [name, (...r) => track(wrap(...r))])))
130
+ }
131
+
132
+ return api
133
+ }
134
+
135
+ const AsyncFunction = (async () => {}).constructor
136
+
137
+ function tapeWrap(test) {
138
+ const tap = (...args) => {
139
+ const fn = args.pop()
140
+ const name = args.shift() || 'test'
141
+ assert(args.length <= 1)
142
+ const [opts = {}] = args
143
+ verifyOptions(opts)
144
+ assert(fn instanceof Function)
145
+ installLocationInNextTest(getCallerLocation())
146
+ if (fn instanceof AsyncFunction) {
147
+ test(name, opts, (t) => fn(tapeWrapAssert(t)))
148
+ } else {
149
+ test(name, opts, (t, callback) => fn(tapeWrapAssert(t, callback)))
150
+ }
151
+ }
152
+
153
+ tap.skip = (...args) => test.skip(...args)
154
+ if (test.only) tap.only = tapeWrap(test.only)
155
+ return tap
156
+ }
157
+
158
+ export const tape = tapeWrap(test)
159
+ export default tape
package/bin/preload.js DELETED
@@ -1,3 +0,0 @@
1
- import * as testUtils from '../src/index.js'
2
-
3
- Object.assign(globalThis, testUtils)
package/src/index.js DELETED
@@ -1,119 +0,0 @@
1
- import { mock, describe, test, it } from 'node:test'
2
- import { expect } from 'expect'
3
- import assert from 'node:assert/strict'
4
-
5
- const MockFunctionContext = mock.fn().mock.constructor
6
-
7
- Object.defineProperty(MockFunctionContext.prototype, 'callsArguments', {
8
- get() {
9
- return this.calls.map((call) => call.arguments)
10
- },
11
- })
12
-
13
- const mockImplementationOrig = MockFunctionContext.prototype.mockImplementation
14
- MockFunctionContext.prototype.mockImplementation = function (...args) {
15
- mockImplementationOrig.call(this, ...args)
16
- return this
17
- }
18
-
19
- MockFunctionContext.prototype.mockRestore = MockFunctionContext.prototype.restore
20
-
21
- const makeEach = (impl) => (list) => (template, fn) => {
22
- for (const args of list) {
23
- let name = template
24
-
25
- if (!args || typeof args === 'string' || typeof args === 'number') {
26
- name = name.replace('%s', args)
27
- } else {
28
- for (const [key, value] of Object.entries(args)) {
29
- name = name.replace(`$${key}`, value) // can collide but we don't care much yet
30
- }
31
-
32
- if (Array.isArray(args)) {
33
- for (const arg of args) name = name.replace('%s', arg)
34
- }
35
- }
36
-
37
- impl(name, () => (Array.isArray(args) ? fn(...args) : fn(args)))
38
- }
39
- }
40
-
41
- describe.each = makeEach(describe)
42
- test.each = makeEach(test)
43
- it.each = makeEach(it)
44
-
45
- const jest = {
46
- fn: (...args) => mock.fn(...args),
47
- spyOn: (obj, name) => {
48
- assert(Object.hasOwn(obj, name))
49
- // eslint-disable-next-line @exodus/mutable/no-param-reassign-prop-only
50
- obj[name] = mock.fn(obj[name])
51
- return obj[name].mock
52
- },
53
- useFakeTimers: () => {
54
- mock.timers.enable()
55
- },
56
- runAllTimers: () => {
57
- mock.timers.tick(100_000_000_000) // > 3 years
58
- },
59
- advanceTimersByTime: (time) => {
60
- mock.timers.tick(time)
61
- },
62
- }
63
-
64
- expect.extend({
65
- toHaveBeenCalled: (fn) => {
66
- assert.equal(fn?.mock?.constructor, MockFunctionContext)
67
- return { pass: fn.mock.callCount() > 0 }
68
- },
69
- toHaveBeenCalledTimes: (fn, count) => {
70
- assert.equal(fn?.mock?.constructor, MockFunctionContext)
71
- return { pass: fn.mock.callCount() === count }
72
- },
73
- toHaveBeenCalledWith: (fn, ...expected) => {
74
- assert.equal(fn?.mock?.constructor, MockFunctionContext)
75
- for (const call of fn.mock.calls) {
76
- try {
77
- expect(call.arguments).toEqual(expected)
78
- return { pass: true }
79
- } catch {}
80
- }
81
-
82
- return { pass: false }
83
- },
84
- toHaveBeenLastCalledWith: (fn, ...expected) => {
85
- assert.equal(fn?.mock?.constructor, MockFunctionContext)
86
- try {
87
- expect(fn.mock.calls.at(-1).arguments).toEqual(expected)
88
- return { pass: true }
89
- } catch (e) {
90
- return { pass: false, message: () => e.message }
91
- }
92
- },
93
- })
94
-
95
- function tap(name, fn) {
96
- test(name, () =>
97
- fn({
98
- ...assert,
99
- end: () => {},
100
- })
101
- )
102
- }
103
-
104
- export { tap, jest }
105
- export { expect } from 'expect'
106
- export {
107
- mock,
108
- beforeEach,
109
- before,
110
- afterEach,
111
- after,
112
- before as beforeAll,
113
- after as afterAll,
114
- describe,
115
- test,
116
- it,
117
- } from 'node:test'
118
-
119
- export { default as assert } from 'node:assert/strict'