@exodus/test 1.0.0-rc.51 → 1.0.0-rc.52

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
@@ -2,91 +2,94 @@
2
2
 
3
3
  A runner for `node:test`, `jest`, and `tape` test suites on top of `node:test` (and any runtime)
4
4
 
5
- Most likely it will just work on your simple jest tests as as drop-in replacement
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
-
13
- ## Why?
14
-
15
- - Can run your tests on Node.js, Bun, Deno, JavaScriptCore and Hermes without extra churn
16
-
17
- - Unlike `jest`, it is fast
18
-
19
- - Unlike `node:test`, it is a drop-in replacement for `jest`
20
-
21
- - With `expect`, support for snapshots, mocks and matchers
22
-
23
- - `jest-when` and `jest-extended` are fully compatible and can just be used
24
-
25
- - Snapshots are compatible with Jest and can just be used both ways
26
-
27
- - Also compatible to `node:test`
28
-
29
- - Unlike `bun:test`, it runs all test files in isolated contexts
30
-
31
- Bun leaks globals / side effects between test files and has incompatible `test()` lifecycle / order
32
-
33
- - Can use Jest config
34
-
35
- - Native coverage support (enable via `--coverage`)
36
-
37
- - Can record / replay `fetch` and `WebSocket` sessions. And run them on all runtimes (including Hermes)
38
-
39
- - Automatic polyfills for JavaScriptCore / Hermes, including crypto
40
-
41
- - Hanging tests error by default (unlike `jest`)
42
-
43
- - Native ESM out of the box
5
+ ## Features
44
6
 
7
+ - Native ESM, including in Jest tests
45
8
  - Esbuild on the fly for babelified ESM interop (enable via `--esbuild`)
46
-
47
- - TypeScript support in both transform (enable via `--esbuild`) and typestrip (via `--typescript`) modes
48
-
49
- - Babel support, picks up your Babel config (enable via `--babel`)
50
-
9
+ - TypeScript support in both transform (through [tsx](https://tsx.is/), enable via `--esbuild`)
10
+ and typestrip (via `--typescript`) modes
11
+ - Runs on [node:test](https://nodejs.org/api/test.html), and (experimental) on bun, deno, d8, JSC and
12
+ [Hermes](https://hermesengine.dev)
13
+ - Testsuite-agnostic -- can run any file as long as it sets exit code based on test results
14
+ - Built-in [Jest](https://jestjs.io) compatibility (with `--jest`), including `jest.*` global
15
+ - Up to ~10x faster depending on the original setup
16
+ - Actual `expect` module, also `jest-extended` and `jest-when` just work on top
17
+ - Snapshots, including snapshot matchers
18
+ - Function and timer mocks
19
+ - [test.concurrent]()
20
+ - Module mocks (on top of Node.js runtime only), including for ESM modules
21
+ - Loads Jest configuration
22
+ - It works on Hermes too!
23
+ - Built-in network record/replay for offline tests, mocking `fetch` and `WebSocket` sessions
51
24
  - `--drop-network` support for guaranteed offline testing
52
-
53
- ## Library
54
-
55
- ### Using with `node:test` natively
56
-
57
- You can just use pure [`node:test`](https://nodejs.org/api/test.html) in your tests,
58
- this runner is fully compatible with that (and will set version-specific options for you)!
59
-
60
- ### Moving from jest
61
-
62
- ```js
63
- import {
64
- jest,
65
- expect,
66
- describe,
67
- it,
68
- beforeEach,
69
- afterEach,
70
- beforeAll,
71
- afterAll,
72
- } from '@exodus/test/jest'
73
- ```
74
-
75
- Or, run with [`--jest` option](#options) to register jest globals
76
-
77
- ### Moving from tap/tape
78
-
79
- ```js
80
- import test from '@exodus/test/tape'
25
+ - Native code coverage via v8 (Node.js or [c8](https://github.com/bcoe/c8)), with istanbul reporters
26
+ - GitHub reporter (auto-enabled by default)
27
+ - JSDOM env support
28
+ - Hanging tests error by default (unlike `jest`)
29
+ - Babel support, picks up your Babel config (enable via `--babel`)
30
+ - Unlike `bun:test`, it runs test files in isolated contexts \
31
+ Bun leaks globals / side effects between test files ([ref](https://github.com/oven-sh/bun/issues/6024)),
32
+ and has incompatible `test()` lifecycle / order
33
+ - Also features a tape API for drop-in replacement
34
+
35
+ ## Reporter samples
36
+
37
+ #### CLI (but uses colors when output supports them, e.g. in terminal):
38
+
39
+ ```console
40
+ # tests/jest/expect.mock.test.js
41
+ ✔ PASS drinkAll > drinks something lemon-flavoured (1.300417ms)
42
+ ✔ PASS drinkAll > does not drink something octopus-flavoured (0.191791ms)
43
+ ✔ PASS drinkAll (1.842959ms)
44
+ ✔ PASS drinkEach > drinkEach drinks each drink (0.360625ms)
45
+ PASS drinkEach (0.463416ms)
46
+ ✔ PASS toHaveBeenCalledWith > registration applies correctly to orange La Croix (0.53325ms)
47
+ ✔ PASS toHaveBeenCalledWith (0.564166ms)
48
+ PASS toHaveBeenLastCalledWith > applying to all flavors does mango last (0.380375ms)
49
+ ✔ PASS toHaveBeenLastCalledWith (0.473417ms)
50
+ # tests/jest/fn.invocationCallOrder.test.js
51
+ ✔ PASS mock.invocationCallOrder (4.221042ms)
81
52
  ```
82
53
 
83
- ### Running tests asynchronously
54
+ #### GitHub Actions collapses test results per-file, like this:
55
+
56
+ <details>
57
+ <summary>✅ <strong>tests/jest/lifecycle.test.js</strong></summary>
58
+ <pre>
59
+ ✔ PASS A > B > C (3.26166ms)
60
+ ✔ PASS A > B > D (1.699463ms)
61
+ ✔ PASS A > B (6.72719ms)
62
+ ✔ PASS A > E > F (1.117997ms)
63
+ ✔ PASS A > E > G > H (1.330904ms)
64
+ ✔ PASS A > E > G (1.94971ms)
65
+ ✔ PASS A > E (3.821825ms)
66
+ ✔ PASS A > I (0.533096ms)
67
+ ✔ PASS A (13.887889ms)
68
+ ✔ PASS J (0.373187ms)
69
+ ✔ PASS K > L (0.659852ms)
70
+ ✔ PASS K (1.143195ms)
71
+ </pre>
72
+ </details><details>
73
+ <summary>✅ <strong>tests/jest/timers.async.test.js</strong></summary>
74
+ <pre>
75
+ ✔ PASS advanceTimersByTime() does not let microtasks to pass (5.326604ms)
76
+ ✔ PASS advanceTimersByTime() does not let microtasks to pass even with await (1.336064ms)
77
+ ✔ PASS advanceTimersByTimeAsync() lets microtasks to pass (6.99526ms)
78
+ ✔ PASS advanceTimersByTimeAsync() lets microtasks to pass, chained (10.131664ms)
79
+ ✔ PASS advanceTimersByTimeAsync() lets microtasks to pass, longer chained (8.635472ms)
80
+ ✔ PASS advanceTimersByTimeAsync() lets microtasks to pass, async chain (56.937983ms)
81
+ </pre>
82
+ </details>
83
+
84
+ See live output in [CI](https://github.com/ExodusMovement/test/actions/workflows/checks.yaml)
84
85
 
85
- Add `{ concurrency: true }`, like this: `describe('my testsuite', { concurrency: true }, () => {`
86
+ ## Library
86
87
 
87
88
  ### List of exports
88
89
 
89
- - `@exodus/test/jest` -- `jest` mock
90
+ - `@exodus/test/node` -- `node:test` API, working under non-Node.js platforms
91
+
92
+ - `@exodus/test/jest` -- `jest` implementation
90
93
 
91
94
  - `@exodus/test/tape` -- `tape` mock (can also be helpful when moving from `tap`)
92
95
 
package/bin/index.js CHANGED
@@ -16,9 +16,7 @@ import glob from 'fast-glob'
16
16
  import { haveModuleMocks, haveSnapshots, haveForceExit } from '../src/version.js'
17
17
 
18
18
  const bindir = dirname(fileURLToPath(import.meta.url))
19
-
20
- const EXTS = `.?([cm])[jt]s?(x)` // we differ from jest, allowing [cm] before everything
21
- const DEFAULT_PATTERNS = [`**/__tests__/**/*${EXTS}`, `**/?(*.)+(spec|test)${EXTS}`]
19
+ const DEFAULT_PATTERNS = [`**/?(*.)+(spec|test).?([cm])[jt]s?(x)`] // do not trust magic dirs by default
22
20
 
23
21
  const bundleOptions = { pure: true, bundle: true, esbuild: true, ts: 'auto' }
24
22
  const hermesAv = ['-Og', '-Xmicrotask-queue']
@@ -191,10 +189,18 @@ const { options, patterns } = parseOptions()
191
189
  const warnHuman = isTTY && !isCI ? (...args) => console.warn(...args) : () => {}
192
190
  if (isCI) process.env.FORCE_COLOR = '1' // should support colored output even though not a TTY, overridable with --no-color
193
191
 
192
+ const setEnv = (name, value) => {
193
+ const env = process.env[name]
194
+ if (env && env !== value) throw new Error(`env conflict: ${name}="${env}", effective: "${value}"`)
195
+ process.env[name] = value
196
+ }
197
+
194
198
  const engineOptions = ENGINES.get(options.engine)
195
199
  assert(engineOptions, `Unknown engine: ${options.engine}`)
196
200
  Object.assign(options, engineOptions)
197
201
  options.platform = options.binary // binary can be overriden by c8
202
+ setEnv('EXODUS_TEST_ENGINE', options.engine) // e.g. 'hermes:bundle', 'node:bundle', 'node:test', 'node:pure'
203
+ setEnv('EXODUS_TEST_PLATFORM', options.binary) // e.g. 'hermes', 'node'
198
204
 
199
205
  const require = createRequire(import.meta.url)
200
206
  const resolveRequire = (query) => require.resolve(query)
@@ -406,15 +412,6 @@ if (!options.bundle) {
406
412
  }
407
413
 
408
414
  if (!Object.hasOwn(process.env, 'NODE_ENV')) process.env.NODE_ENV = 'test'
409
-
410
- const setEnv = (name, value) => {
411
- const env = process.env[name]
412
- if (env && env !== value) throw new Error(`env conflict: ${name}="${env}", effective: "${value}"`)
413
- process.env[name] = value
414
- }
415
-
416
- setEnv('EXODUS_TEST_PLATFORM', options.binary) // e.g. 'hermes', 'node'
417
- setEnv('EXODUS_TEST_ENGINE', options.engine) // e.g. 'hermes:bundle', 'node:bundle', 'node:test', 'node:pure'
418
415
  setEnv('EXODUS_TEST_ONLY', options.only ? '1' : '')
419
416
 
420
417
  const c8 = resolveRequire('c8/bin/c8.js')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/test",
3
- "version": "1.0.0-rc.51",
3
+ "version": "1.0.0-rc.52",
4
4
  "author": "Exodus Movement, Inc.",
5
5
  "description": "A test suite runner",
6
6
  "homepage": "https://github.com/ExodusMovement/test",
@@ -64,6 +64,7 @@
64
64
  "src/engine.js",
65
65
  "src/engine.node.cjs",
66
66
  "src/engine.pure.cjs",
67
+ "src/engine.pure.snapshot.cjs",
67
68
  "src/engine.select.cjs",
68
69
  "src/jest.js",
69
70
  "src/jest.config.js",
@@ -87,8 +88,9 @@
87
88
  "test:typescript": "./bin/index.js --jest --typescript tests/typescript.test.ts",
88
89
  "test:jest": "./bin/index.js --jest --esbuild",
89
90
  "test:tape": "./bin/index.js --esbuild 'tests/tape/tests/*.js' tests/tape.test.js",
90
- "test:pure": "EXODUS_TEST_ENGINE=node:pure npm run test",
91
- "test:bun:pure": "EXODUS_TEST_ENGINE=bun:pure npm run test",
91
+ "test:pure": "EXODUS_TEST_ENGINE=node:pure EXODUS_TEST_IGNORE='tests/jest-extended/**' npm run test",
92
+ "test:bun": "EXODUS_TEST_ENGINE=bun:pure EXODUS_TEST_IGNORE='tests/jest-extended/**' npm run test",
93
+ "test:hermes": "EXODUS_TEST_ENGINE=hermes:bundle EXODUS_TEST_IGNORE='tests/{{jest-extended,inband}/**,jest-when/when.test.*,jest/jest.{mock,resetModules}.*}' npm run test",
92
94
  "test:fetch": "./bin/index.js --jest --drop-network --engine node:pure tests/fetch.test.js tests/websocket.test.js",
93
95
  "coverage": "./bin/index.js --jest --esbuild --coverage",
94
96
  "lint": "prettier --list-different . && eslint .",
@@ -137,6 +139,7 @@
137
139
  "@types/jest-when": "^3.5.2",
138
140
  "@typescript-eslint/eslint-plugin": "^7.15.0",
139
141
  "eslint": "^8.44.0",
142
+ "hermes-engine-cli": "^0.12.0",
140
143
  "jest": "^29.7.0",
141
144
  "jest-matcher-utils": "^29.7.0",
142
145
  "jest-serializer-ansi-escapes": "^3.0.0",
@@ -1,5 +1,6 @@
1
1
  const assert = require('node:assert/strict')
2
2
  const assertLoose = require('node:assert')
3
+ const { matchSnapshot } = require('./engine.pure.snapshot.cjs')
3
4
 
4
5
  const { setTimeout, setInterval, setImmediate, Date } = globalThis
5
6
  const { clearTimeout, clearInterval, clearImmediate } = globalThis
@@ -28,17 +29,21 @@ class Context {
28
29
  children = []
29
30
  assert = { ...assertLoose, snapshot: undefined }
30
31
  hooks = { __proto__: null, before: [], after: [], beforeEach: [], afterEach: [] }
32
+ #fullName
31
33
 
32
34
  constructor(parent, name, options = {}) {
33
35
  Object.assign(this, { root: parent?.root, parent, name, options })
34
- this.fullName = parent && parent !== parent.root ? `${parent.fullName} > ${name}` : name
35
- if (this.fullName === name) this.fullName = this.fullName.replace(INBAND_PREFIX_REGEX, '')
36
+ this.#fullName = parent && parent !== parent.root ? `${parent.fullName} > ${name}` : name
37
+ if (this.#fullName === name) this.#fullName = this.#fullName.replace(INBAND_PREFIX_REGEX, '')
36
38
  if (this.root) {
37
39
  this.parent.children.push(this)
38
40
  } else {
39
41
  assert(this.name === '<root>' && !this.parent)
40
42
  this.root = this
41
43
  }
44
+
45
+ this.assert.snapshot = (obj) =>
46
+ matchSnapshot(readSnapshot, assert, this.fullName, serializeSnapshot(obj))
42
47
  }
43
48
 
44
49
  get onlySomewhere() {
@@ -48,6 +53,10 @@ class Context {
48
53
  get only() {
49
54
  return (this.options.only && !this.children.some((x) => x.onlySomewhere)) || this.parent?.only
50
55
  }
56
+
57
+ get fullName() {
58
+ return this.#fullName
59
+ }
51
60
  }
52
61
 
53
62
  function enterContext(name, options) {
@@ -466,7 +475,17 @@ if (process.env.EXODUS_TEST_ENVIRONMENT === 'bundle') {
466
475
 
467
476
  // eslint-disable-next-line no-undef
468
477
  let snapshotResolver = (dir, name) => [dir, `${name}.snapshot`] // default per Node.js docs
469
- const setSnapshotSerializers = () => {}
478
+ let snapshotSerializers = [(obj) => JSON.stringify(obj, null, 2)]
479
+ const serializeSnapshot = (obj) => {
480
+ let val = obj
481
+ for (const fn of snapshotSerializers) val = fn(val)
482
+ return val
483
+ }
484
+
485
+ const setSnapshotSerializers = ([...arr]) => {
486
+ snapshotSerializers = arr
487
+ }
488
+
470
489
  const setSnapshotResolver = (fn) => {
471
490
  snapshotResolver = fn
472
491
  }
@@ -0,0 +1,35 @@
1
+ const nameCounts = new Map()
2
+ let snapshotText
3
+
4
+ const escapeSnapshot = (str) => str.replaceAll(/([\\`])/gu, '\\$1')
5
+
6
+ function matchSnapshot(readSnapshot, assert, name, serialized) {
7
+ // We don't have native snapshots, polyfill reading
8
+ if (snapshotText !== null) {
9
+ try {
10
+ const snapshotRaw = readSnapshot()
11
+ snapshotText = snapshotRaw ? `\n${snapshotRaw}\n` : null // we'll search wrapped in \n
12
+ } catch {
13
+ snapshotText = null
14
+ }
15
+ }
16
+
17
+ const addFail = `Adding new snapshots requires Node.js >=22.3.0`
18
+
19
+ // We don't support polyfilled snapshot generation here, only parsing
20
+ // Also be careful with assertion plan counters
21
+ if (!snapshotText) assert.fail(`Could not find snapshot file. ${addFail}`)
22
+
23
+ const count = (nameCounts.get(name) || 0) + 1
24
+ nameCounts.set(name, count)
25
+ const escaped = escapeSnapshot(serialized)
26
+ const key = `${name} ${count}`
27
+ const makeEntry = (x) => `\nexports[\`${escapeSnapshot(key)}\`] = \`${x}\`;\n`
28
+ const final = escaped.includes('\n') ? `\n${escaped}\n` : escaped
29
+ if (snapshotText.includes(makeEntry(final))) return
30
+ // Perhaps wrapped with newlines from Node.js snapshots?
31
+ if (!final.includes('\n') && snapshotText.includes(makeEntry(`\n${final}\n`))) return
32
+ return assert.fail(`Could not match "${key}" in snapshot. ${addFail}`)
33
+ }
34
+
35
+ module.exports = { escapeSnapshot, matchSnapshot }
@@ -2,7 +2,9 @@
2
2
  import assert from 'node:assert/strict'
3
3
  import { specialEnvironments } from './jest.environment.js'
4
4
 
5
+ const EXTS = `.?([cm])[jt]s?(x)` // we differ from jest, allowing [cm] before everything
5
6
  const normalizeJestConfig = (config) => ({
7
+ testMatch: [`**/__tests__/**/*${EXTS}`, `**/?(*.)+(spec|test)${EXTS}`],
6
8
  testEnvironment: 'node',
7
9
  testTimeout: 5000,
8
10
  testPathIgnorePatterns: [],
@@ -11,6 +11,7 @@ import { format, plugins as builtinPlugins } from 'pretty-format'
11
11
  import { jestConfig } from './jest.config.js'
12
12
  import { getTestNamePath } from './dark.cjs'
13
13
  import { haveSnapshotsReportUnescaped } from './version.js'
14
+ import { matchSnapshot, escapeSnapshot } from './engine.pure.snapshot.cjs'
14
15
 
15
16
  const { snapshotFormat, snapshotSerializers } = jestConfig()
16
17
  const plugins = Object.values(builtinPlugins)
@@ -19,10 +20,6 @@ const serialize = (val) => format(val, { ...snapshotFormat, plugins }).replaceAl
19
20
  let serializersAreSetup = false
20
21
  let snapshotsAreJest = false
21
22
 
22
- // For manually loading the snapshot
23
- const nameCounts = new Map()
24
- let snapshotText
25
-
26
23
  function maybeSetupSerializers() {
27
24
  if (serializersAreSetup) return
28
25
  // empty require and serializers should not let this fail, non-empty serializers and empty require should
@@ -48,6 +45,7 @@ const wrap = (check) => {
48
45
  }
49
46
  }
50
47
 
48
+ // Older Node.js versions do not have context.assert, which we want to prefer for counting
51
49
  let context
52
50
  beforeEach((t) => (context = t))
53
51
  const getAssert = () => context?.assert ?? assert // do not use non-strict comparisons on this!
@@ -123,47 +121,20 @@ const snapOnDisk = (orig, matcher) => {
123
121
  }
124
122
 
125
123
  const obj = matcher ? deepMerge(orig, matcher) : orig
126
- const escape = (str) => str.replaceAll(/([\\`])/gu, '\\$1')
127
124
 
128
125
  maybeSetupJestSnapshots()
129
126
 
130
127
  if (!context?.assert?.snapshot) {
131
- // We don't have native snapshots, polyfill reading
132
- if (snapshotText !== null) {
133
- try {
134
- const snapshotRaw = readSnapshot()
135
- snapshotText = snapshotRaw ? `\n${snapshotRaw}\n` : null // we'll search wrapped in \n
136
- } catch {
137
- snapshotText = null
138
- }
139
- }
140
-
141
- const addFail = `Adding new snapshots requires Node.js >=22.3.0`
142
-
143
- // We don't support polyfilled snapshot generation here, only parsing
144
- // Also be careful with assertion plan counters
145
- if (!snapshotText) getAssert().fail(`Could not find snapshot file. ${addFail}`)
146
-
147
128
  const namePath = getTestNamePath(context).map((x) => (x === '<anonymous>' ? '' : x))
148
- const name = namePath.join(' ')
149
- const count = (nameCounts.get(name) || 0) + 1
150
- nameCounts.set(name, count)
151
- const escaped = escape(serialize(obj))
152
- const key = `${name} ${count}`
153
- const makeEntry = (x) => `\nexports[\`${escape(key)}\`] = \`${x}\`;\n`
154
- const final = escaped.includes('\n') ? `\n${escaped}\n` : escaped
155
- if (snapshotText.includes(makeEntry(final))) return
156
- // Perhaps wrapped with newlines from Node.js snapshots?
157
- if (!final.includes('\n') && snapshotText.includes(makeEntry(`\n${final}\n`))) return
158
- return getAssert().fail(`Could not match "${key}" in snapshot. ${addFail}`)
129
+ return matchSnapshot(readSnapshot, getAssert(), namePath.join(' '), serialize(obj))
159
130
  }
160
131
 
161
132
  // Node.js always wraps with newlines, while jest wraps only those that are already multiline
162
133
  try {
163
- wrapContextName(() => getAssert().snapshot(obj))
134
+ wrapContextName(() => context.assert.snapshot(obj))
164
135
  } catch (e) {
165
136
  if (typeof e.expected === 'string') {
166
- const escaped = haveSnapshotsReportUnescaped ? e.expected : escape(e.expected)
137
+ const escaped = haveSnapshotsReportUnescaped ? e.expected : escapeSnapshot(e.expected)
167
138
  const final = escaped.includes('\n') ? escaped : `\n${escaped}\n`
168
139
  if (final === e.actual) return
169
140
  }