@exodus/test 1.0.0-rc.0
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/CHANGELOG.md +3 -0
- package/README.md +60 -0
- package/bin/index.js +124 -0
- package/bin/preload.js +3 -0
- package/package.json +44 -0
- package/src/index.js +119 -0
package/CHANGELOG.md
ADDED
package/README.md
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# @exodus/test
|
|
2
|
+
|
|
3
|
+
Most likely it will just work on your simple jest tests as as drop-in replacement
|
|
4
|
+
|
|
5
|
+
## Library
|
|
6
|
+
|
|
7
|
+
### Moving from jest
|
|
8
|
+
|
|
9
|
+
`import { describe, it, assert, jest, expect } from '@exodus/test'`
|
|
10
|
+
|
|
11
|
+
Replace `.calls` with `.callsArguments`, as the former one now contains more detailed information
|
|
12
|
+
|
|
13
|
+
### Moving from tap/tape
|
|
14
|
+
|
|
15
|
+
`import { tap as test } from '@exodus/test'`
|
|
16
|
+
|
|
17
|
+
Not all features might be supported
|
|
18
|
+
|
|
19
|
+
### Running tests asynchronously
|
|
20
|
+
|
|
21
|
+
Add `{ concurrency: true }`, like this: `describe('my testsuite', { concurrency: true }, () => {`
|
|
22
|
+
|
|
23
|
+
### List of exports
|
|
24
|
+
|
|
25
|
+
Adapters:
|
|
26
|
+
|
|
27
|
+
- `jest` -- jest mock adapter
|
|
28
|
+
- `tap` -- tap/tape adapter
|
|
29
|
+
- `mock`
|
|
30
|
+
|
|
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`
|
|
45
|
+
|
|
46
|
+
## Binary
|
|
47
|
+
|
|
48
|
+
Just use `"test: "exodus-test"`
|
|
49
|
+
|
|
50
|
+
### Options
|
|
51
|
+
|
|
52
|
+
- `--global` -- register all test helpers as global variables
|
|
53
|
+
|
|
54
|
+
- `--typescript` -- enable typescript support
|
|
55
|
+
|
|
56
|
+
- `--coverage` -- enable coverage, prints coverage output (varies by coverage engine)
|
|
57
|
+
|
|
58
|
+
- `--coverage-engine c8` -- use c8 coverage engine (default), also generates `./coverage/` dirs
|
|
59
|
+
|
|
60
|
+
- `--coverage-engine node` -- use Node.js builtint coverage engine
|
package/bin/index.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawn } from 'node:child_process'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
import { basename, dirname, resolve } from 'node:path'
|
|
6
|
+
import { createRequire } from 'node:module'
|
|
7
|
+
import assert from 'node:assert/strict'
|
|
8
|
+
import glob from 'fast-glob' // Only for Node.js <22 support
|
|
9
|
+
|
|
10
|
+
const bindir = dirname(fileURLToPath(import.meta.url))
|
|
11
|
+
|
|
12
|
+
const DEFAULT_PATTERNS = ['**/*.{test,spec}.?(c|m)js', '**/*.{test,spec}.ts']
|
|
13
|
+
|
|
14
|
+
function versionCheck() {
|
|
15
|
+
const [major, minor, patch] = process.versions.node.split('.').map(Number)
|
|
16
|
+
assert((major === 18 && minor >= 13) || major >= 20, 'Node.js version too old!')
|
|
17
|
+
assert(major !== 21, 'Node.js version deprecated!')
|
|
18
|
+
|
|
19
|
+
return { major, minor, patch }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function parseOptions() {
|
|
23
|
+
const options = {
|
|
24
|
+
global: false,
|
|
25
|
+
typescript: false,
|
|
26
|
+
coverage: false,
|
|
27
|
+
coverageEngine: 'c8', // c8 or node
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const args = [...process.argv]
|
|
31
|
+
|
|
32
|
+
// First argument should be node
|
|
33
|
+
assert.equal(basename(args.shift()), 'node')
|
|
34
|
+
assert.equal(basename(process.argv0), 'node')
|
|
35
|
+
|
|
36
|
+
// Second argument should be this script
|
|
37
|
+
const jsname = args.shift()
|
|
38
|
+
assert(basename(jsname) === 'exodus-test' || jsname === fileURLToPath(import.meta.url))
|
|
39
|
+
|
|
40
|
+
while (args[0]?.startsWith('--')) {
|
|
41
|
+
const option = args.shift()
|
|
42
|
+
switch (option) {
|
|
43
|
+
case '--global':
|
|
44
|
+
options.global = true
|
|
45
|
+
break
|
|
46
|
+
case '--typescript':
|
|
47
|
+
options.typescript = true
|
|
48
|
+
break
|
|
49
|
+
case '--coverage-engine':
|
|
50
|
+
options.coverageEngine = args.shift()
|
|
51
|
+
break
|
|
52
|
+
case '--coverage':
|
|
53
|
+
options.coverage = true
|
|
54
|
+
break
|
|
55
|
+
default:
|
|
56
|
+
throw new Error(`Unknown option: ${option}`)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
assert(
|
|
61
|
+
args.every((arg) => !arg.startsWith('--')),
|
|
62
|
+
'Options should come before patterns'
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
const patterns = [...args]
|
|
66
|
+
if (patterns.length === 0) patterns.push(...DEFAULT_PATTERNS) // defaults
|
|
67
|
+
|
|
68
|
+
return { options, patterns }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const { major, minor } = versionCheck()
|
|
72
|
+
const { options, patterns } = parseOptions()
|
|
73
|
+
|
|
74
|
+
let program = 'node'
|
|
75
|
+
|
|
76
|
+
const require = createRequire(import.meta.url)
|
|
77
|
+
const c8 = require.resolve('c8/bin/c8.js')
|
|
78
|
+
|
|
79
|
+
const args = ['--test', '--enable-source-maps']
|
|
80
|
+
if (options.coverage) {
|
|
81
|
+
if (options.coverageEngine === 'node') {
|
|
82
|
+
args.push('--experimental-test-coverage')
|
|
83
|
+
} else if (options.coverageEngine === 'c8') {
|
|
84
|
+
program = c8
|
|
85
|
+
args.unshift('node')
|
|
86
|
+
// perhaps use text-summary ?
|
|
87
|
+
args.unshift('-r', 'text', '-r', 'html')
|
|
88
|
+
} else {
|
|
89
|
+
throw new Error(`Unknown coverage engine: ${JSON.stringify(options.coverageEngine)}`)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (options.global) {
|
|
94
|
+
if (major >= 20 || (major === 18 && minor >= 18)) {
|
|
95
|
+
args.push('--import', resolve(bindir, 'preload.js'))
|
|
96
|
+
} else {
|
|
97
|
+
throw new Error('Option --global requires Node.js >= v18.18.0')
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
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')
|
|
104
|
+
} else {
|
|
105
|
+
throw new Error('Option --typescript requires Node.js >=20.6.0 || 18 >=18.18.0')
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
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')
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
assert(program && ['node', c8].includes(program))
|
|
120
|
+
const node = spawn(program, args, { stdio: 'inherit' })
|
|
121
|
+
|
|
122
|
+
node.on('close', (code) => {
|
|
123
|
+
process.exitCode = code
|
|
124
|
+
})
|
package/bin/preload.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@exodus/test",
|
|
3
|
+
"version": "1.0.0-rc.0",
|
|
4
|
+
"author": "Exodus Movement, Inc.",
|
|
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
|
+
},
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/ExodusMovement/exodus-hydra.git"
|
|
13
|
+
},
|
|
14
|
+
"keywords": [
|
|
15
|
+
"test",
|
|
16
|
+
"expect",
|
|
17
|
+
"jest",
|
|
18
|
+
"node-test"
|
|
19
|
+
],
|
|
20
|
+
"license": "UNLICENSED",
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "restricted"
|
|
23
|
+
},
|
|
24
|
+
"type": "module",
|
|
25
|
+
"bin": {
|
|
26
|
+
"exodus-test": "bin/index.js"
|
|
27
|
+
},
|
|
28
|
+
"main": "src/index.js",
|
|
29
|
+
"files": [
|
|
30
|
+
"bin/preload.js",
|
|
31
|
+
"!__tests__",
|
|
32
|
+
"CHANGELOG.md"
|
|
33
|
+
],
|
|
34
|
+
"scripts": {
|
|
35
|
+
"test": "exodus-test --global --typescript",
|
|
36
|
+
"lint": "run -T eslint ."
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@swc-node/register": "^1.8.0",
|
|
40
|
+
"c8": "^9.1.0",
|
|
41
|
+
"expect": "^29.7.0",
|
|
42
|
+
"fast-glob": "^3.2.11"
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
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'
|