@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 +47 -28
- package/bin/babel.cjs +8 -0
- package/bin/index.js +125 -23
- package/bin/jest.js +13 -0
- package/package.json +41 -13
- package/src/dark.cjs +48 -0
- package/src/jest.fn.js +169 -0
- package/src/jest.js +89 -0
- package/src/jest.mock.js +133 -0
- package/src/jest.snapshot.js +98 -0
- package/src/jest.timers.js +67 -0
- package/src/tape.js +159 -0
- package/bin/preload.js +0 -3
- package/src/index.js +0 -119
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
|
-
###
|
|
15
|
+
### Using with `node:test` natively
|
|
8
16
|
|
|
9
|
-
|
|
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
|
-
|
|
20
|
+
### Moving from jest
|
|
12
21
|
|
|
13
|
-
|
|
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
|
-
|
|
37
|
+
### Moving from tap/tape
|
|
16
38
|
|
|
17
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
- `jest` -- jest mock adapter
|
|
28
|
-
- `tap` -- tap/tape adapter
|
|
29
|
-
- `mock`
|
|
49
|
+
- `@exodus/test/jest` -- `jest` mock
|
|
30
50
|
|
|
31
|
-
|
|
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
|
-
- `--
|
|
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
|
-
- `--
|
|
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
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'
|
|
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}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
94
|
-
if (major >= 20 || (major === 18 && minor >= 18)) {
|
|
95
|
-
|
|
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
|
-
|
|
152
|
+
args.push('-r', resolveRequire('tsx/cjs'))
|
|
98
153
|
}
|
|
99
154
|
}
|
|
100
155
|
|
|
101
|
-
if (options.
|
|
102
|
-
|
|
103
|
-
|
|
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 --
|
|
166
|
+
throw new Error('Option --jest requires Node.js >= v18.18.0')
|
|
106
167
|
}
|
|
107
168
|
}
|
|
108
169
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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.
|
|
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/
|
|
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": "
|
|
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
|
-
"
|
|
28
|
+
"exports": {
|
|
29
|
+
"./jest": "./src/jest.js",
|
|
30
|
+
"./tape": "./src/tape.js"
|
|
31
|
+
},
|
|
32
|
+
"prettier": "@exodus/prettier",
|
|
29
33
|
"files": [
|
|
30
|
-
"bin/
|
|
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": "
|
|
36
|
-
"
|
|
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
|
-
"@
|
|
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'
|
package/src/jest.mock.js
ADDED
|
@@ -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
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'
|