@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 ADDED
@@ -0,0 +1,3 @@
1
+ ## 1.0.0
2
+
3
+ Initial release
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
@@ -0,0 +1,3 @@
1
+ import * as testUtils from '../src/index.js'
2
+
3
+ Object.assign(globalThis, testUtils)
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'