@exodus/test 1.0.0-rc.24 → 1.0.0-rc.25

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/bin/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { spawn } from 'node:child_process'
4
+ import { once } from 'node:events'
4
5
  import { fileURLToPath } from 'node:url'
5
6
  import { basename, dirname, resolve } from 'node:path'
6
7
  import { createRequire } from 'node:module'
@@ -25,6 +26,7 @@ function parseOptions() {
25
26
  only: false,
26
27
  passWithNoTests: false,
27
28
  writeSnapshots: false,
29
+ pure: false,
28
30
  debug: { files: false },
29
31
  }
30
32
 
@@ -79,6 +81,9 @@ function parseOptions() {
79
81
  case '--forceExit':
80
82
  options.forceExit = true
81
83
  break
84
+ case '--pure':
85
+ options.pure = true
86
+ break
82
87
  case '--debug-files':
83
88
  options.debug.files = true
84
89
  break
@@ -108,26 +113,36 @@ const resolveImport = import.meta.resolve && ((query) => fileURLToPath(import.me
108
113
  const c8 = resolveRequire('c8/bin/c8.js')
109
114
  if (resolveImport) assert.equal(c8, resolveImport('c8/bin/c8.js'))
110
115
 
111
- const args = ['--test', '--no-warnings=ExperimentalWarning', '--test-reporter=spec']
116
+ const args = []
117
+ if (options.pure) {
118
+ const requiresNodeCoverage = options.coverage && options.coverageEngine === 'node'
119
+ assert(!requiresNodeCoverage, 'Can not use "node" coverage engine with --pure')
120
+ assert(!options.writeSnapshots, 'Can not use write snapshots with --pure')
121
+ assert(!options.forceExit, 'Can not use --force-exit with --pure') // TODO
122
+ assert(!options.watch, 'Can not use --watch with --pure')
123
+ assert(!options.only, 'Can not use --only with --pure') // TODO
124
+ } else {
125
+ args.push('--test', '--no-warnings=ExperimentalWarning', '--test-reporter=spec')
126
+
127
+ if (haveModuleMocks) args.push('--experimental-test-module-mocks')
128
+ if (haveSnapshots) args.push('--experimental-test-snapshots')
129
+
130
+ if (options.writeSnapshots) {
131
+ assert(haveSnapshots, 'For snapshots, use Node.js >=22.3.0')
132
+ args.push('--test-update-snapshots')
133
+ }
112
134
 
113
- if (haveModuleMocks) args.push('--experimental-test-module-mocks')
114
- if (haveSnapshots) args.push('--experimental-test-snapshots')
135
+ if (options.forceExit) {
136
+ assert(haveForceExit, 'For forceExit, use Node.js >= 20.14.0')
137
+ args.push('--test-force-exit')
138
+ }
115
139
 
116
- if (options.writeSnapshots) {
117
- assert(haveSnapshots, 'For snapshots, use Node.js >=22.3.0')
118
- args.push('--test-update-snapshots')
119
- }
140
+ if (options.watch) args.push('--watch')
141
+ if (options.only) args.push('--test-only')
120
142
 
121
- if (options.forceExit) {
122
- assert(haveForceExit, 'For forceExit, use Node.js >= 20.14.0')
123
- args.push('--test-force-exit')
143
+ args.push('--expose-internals') // this is unoptimal and hopefully temporary, see rationale in src/dark.cjs
124
144
  }
125
145
 
126
- if (options.watch) args.push('--watch')
127
- if (options.only) args.push('--test-only')
128
-
129
- args.push('--expose-internals') // this is unoptimal and hopefully temporary, see rationale in src/dark.cjs
130
-
131
146
  if (options.coverage) {
132
147
  if (options.coverageEngine === 'node') {
133
148
  args.push('--experimental-test-coverage')
@@ -176,6 +191,7 @@ if (options.jest) {
176
191
  patterns.push(...(Array.isArray(config.testMatch) ? config.testMatch : [config.testMatch]))
177
192
  }
178
193
 
194
+ if (config.passWithNoTests) options.passWithNoTests = true
179
195
  const testRegex = config.testRegex ? new RegExp(config.testRegex, 'u') : null
180
196
  const ignoreRegexes = config.testPathIgnorePatterns.map((x) => new RegExp(x, 'u'))
181
197
  if (testRegex || ignoreRegexes.length > 0) {
@@ -260,14 +276,36 @@ if (tsTests.length > 0 && !options.esbuild) {
260
276
  console.warn(`Flag --typescript has been used, but there were no TypeScript tests found!`)
261
277
  }
262
278
 
263
- assert(files.length > 0) // otherwise we can run recursively
264
- args.push(...files)
265
-
266
279
  if (!Object.hasOwn(process.env, 'NODE_ENV')) process.env.NODE_ENV = 'test'
267
280
 
281
+ assert(files.length > 0) // otherwise we can run recursively
268
282
  assert(program && ['node', c8].includes(program))
269
- const node = spawn(program, args, { stdio: 'inherit' })
270
283
 
271
- node.on('close', (code) => {
284
+ if (options.pure) {
285
+ process.env.EXODUS_TEST_CONTEXT = 'pure'
286
+ console.warn(
287
+ '--pure mode is experimental and may not work an expected / might be removed at any time'
288
+ )
289
+ const failures = []
290
+ for (const file of files) {
291
+ const node = spawn(program, [...args, file], { stdio: 'inherit' })
292
+ const [code] = await once(node, 'close')
293
+ if (code !== 0) failures.push(file)
294
+ }
295
+
296
+ if (failures.length > 0) process.exitCode = 1
297
+ console.log(
298
+ failures === 0
299
+ ? `All ${files.length} test suites passed`
300
+ : `Test suites failed: ${failures.length} / ${files.length}`
301
+ )
302
+ if (failures.length > 0) {
303
+ console.log('Failed test suites:')
304
+ for (const file of failures) console.log(` ${file}`) // joining with \n can get truncated, too big
305
+ }
306
+ } else {
307
+ process.env.EXODUS_TEST_CONTEXT = 'node --test'
308
+ const node = spawn(program, [...args, ...files], { stdio: 'inherit' })
309
+ const [code] = await once(node, 'close')
272
310
  process.exitCode = code
273
- })
311
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/test",
3
- "version": "1.0.0-rc.24",
3
+ "version": "1.0.0-rc.25",
4
4
  "author": "Exodus Movement, Inc.",
5
5
  "description": "A test suite runner",
6
6
  "homepage": "https://github.com/ExodusMovement/test",
@@ -37,6 +37,9 @@
37
37
  "bin/babel.cjs",
38
38
  "bin/jest.js",
39
39
  "src/dark.cjs",
40
+ "src/engine.js",
41
+ "src/engine.node.cjs",
42
+ "src/engine.select.cjs",
40
43
  "src/jest.js",
41
44
  "src/jest.config.js",
42
45
  "src/jest.environment.js",
package/src/dark.cjs CHANGED
@@ -1,7 +1,3 @@
1
- const { fileURLToPath } = require('node:url')
2
-
3
- const mayBeUrlToPath = (str) => (str.startsWith('file://') ? fileURLToPath(str) : str)
4
-
5
1
  let locForNextTest
6
2
 
7
3
  const installLocationInNextTest = function (loc) {
@@ -26,6 +22,8 @@ function createCallerLocationHook() {
26
22
 
27
23
  try {
28
24
  const { Test } = require('node:internal/test_runner/test')
25
+ const { fileURLToPath } = require('node:url')
26
+ const mayBeUrlToPath = (str) => (str.startsWith('file://') ? fileURLToPath(str) : str)
29
27
  const locStorage = new Map()
30
28
  Object.defineProperty(Test.prototype, 'loc', {
31
29
  get() {
package/src/engine.js ADDED
@@ -0,0 +1,19 @@
1
+ import engine from './engine.select.cjs' // need to be sync for non-preloaded imports into cjs
2
+
3
+ const { assert, assertLoose } = engine
4
+ export { assert, assertLoose }
5
+
6
+ const { mock, describe, test, beforeEach, afterEach, before, after } = engine
7
+ export { mock, describe, test, beforeEach, afterEach, before, after }
8
+
9
+ const { builtinModules, syncBuiltinESMExports } = engine
10
+ export { builtinModules, syncBuiltinESMExports }
11
+
12
+ const { utilFormat, isPromise, nodeVersion } = engine
13
+ export { utilFormat, isPromise, nodeVersion }
14
+
15
+ const { baseFile, relativeRequire, isTopLevelESM } = engine
16
+ export { baseFile, relativeRequire, isTopLevelESM }
17
+
18
+ const { snapshot, readSnapshot, setSnapshotSerializers, setSnapshotResolver } = engine
19
+ export { snapshot, readSnapshot, setSnapshotSerializers, setSnapshotResolver }
@@ -0,0 +1,38 @@
1
+ const assert = require('node:assert/strict')
2
+ const assertLoose = require('node:assert')
3
+ const { types, format: utilFormat } = require('node:util')
4
+ const { existsSync, readFileSync } = require('node:fs')
5
+ const { normalize, basename, dirname, join: pathJoin } = require('node:path')
6
+ const { createRequire, builtinModules, syncBuiltinESMExports } = require('node:module')
7
+ const nodeTest = require('node:test')
8
+
9
+ const { mock, describe, test, beforeEach, afterEach, before, after } = nodeTest
10
+
11
+ const isPromise = types.isPromise
12
+ const nodeVersion = process.versions.node
13
+
14
+ const files = process.argv.slice(1)
15
+ const baseFile = files.length === 1 && existsSync(files[0]) ? normalize(files[0]) : undefined
16
+ const relativeRequire = baseFile ? createRequire(baseFile) : require
17
+ const isTopLevelESM = () => !baseFile || !Object.hasOwn(relativeRequire.cache, baseFile) // assume ESM otherwise
18
+
19
+ const snapshot = nodeTest.snapshot
20
+ let snapshotResolver = (dir, name) => [dir, `${name}.snapshot`] // default per Node.js docs
21
+ const resolveSnapshot = (f) => pathJoin(...snapshotResolver(dirname(f), basename(f)))
22
+ const readSnapshot = (f = baseFile) => (f ? readFileSync(resolveSnapshot(f), 'utf8') : null)
23
+ const setSnapshotSerializers = (list) => snapshot?.setDefaultSnapshotSerializers(list)
24
+ const setSnapshotResolver = (fn) => {
25
+ snapshotResolver = fn
26
+ snapshot?.setResolveSnapshotPath(resolveSnapshot)
27
+ }
28
+
29
+ /* eslint-disable unicorn/no-useless-spread */
30
+ module.exports = {
31
+ ...{ assert, assertLoose },
32
+ ...{ mock, describe, test, beforeEach, afterEach, before, after },
33
+ ...{ builtinModules, syncBuiltinESMExports },
34
+ ...{ utilFormat, isPromise, nodeVersion },
35
+ ...{ baseFile, relativeRequire, isTopLevelESM },
36
+ ...{ snapshot, readSnapshot, setSnapshotSerializers, setSnapshotResolver },
37
+ }
38
+ /* eslint-enable unicorn/no-useless-spread */
@@ -0,0 +1 @@
1
+ module.exports = require('./engine.node.cjs')
@@ -1,3 +1,4 @@
1
+ // Not using ./engine.js yet, might pass / embed already loaded config instead
1
2
  import assert from 'node:assert/strict'
2
3
  import { readFile } from 'node:fs/promises'
3
4
  import { existsSync } from 'node:fs'
@@ -54,6 +55,7 @@ const normalizeJestConfig = (config) => ({
54
55
  testEnvironment: 'node',
55
56
  testTimeout: 5000,
56
57
  testPathIgnorePatterns: [],
58
+ passWithNoTests: false,
57
59
  snapshotSerializers: [],
58
60
  injectGlobals: true,
59
61
  maxConcurrency: 5,
@@ -76,7 +78,8 @@ const normalizeJestConfig = (config) => ({
76
78
  function verifyJestConfig(c) {
77
79
  assert(!configUsed, 'Can not apply new config as the current one was already used')
78
80
 
79
- if (!Object.hasOwn(specialEnvironments, c.testEnvironment)) {
81
+ const nodeEnvs = new Set(['ts-jest', 'ts-jest/presets/js-with-ts'])
82
+ if (!Object.hasOwn(specialEnvironments, c.testEnvironment) && !nodeEnvs.has(c.testEnvironment)) {
80
83
  assert.equal(c.testEnvironment, 'node', 'Only "node" testEnvironment is supported')
81
84
  }
82
85
 
package/src/jest.fn.js CHANGED
@@ -1,5 +1,4 @@
1
- import { mock } from 'node:test'
2
- import assert from 'node:assert/strict'
1
+ import { mock, assert } from './engine.js'
3
2
 
4
3
  const registry = new Set()
5
4
  let callId = 0
package/src/jest.js CHANGED
@@ -1,6 +1,12 @@
1
- import assert from 'node:assert/strict'
2
- import { describe as nodeDescribe, test as nodeTest, afterEach, after } from 'node:test'
3
- import { format, types } from 'node:util'
1
+ import {
2
+ describe as nodeDescribe,
3
+ test as nodeTest,
4
+ afterEach,
5
+ after,
6
+ assert,
7
+ utilFormat,
8
+ isPromise,
9
+ } from './engine.js'
4
10
  import { jestConfig } from './jest.config.js'
5
11
  import { jestFunctionMocks } from './jest.fn.js'
6
12
  import { jestModuleMocks } from './jest.mock.js'
@@ -75,7 +81,7 @@ const makeEach =
75
81
 
76
82
  if (Array.isArray(args)) {
77
83
  const length = [...name.replaceAll('%%', '').matchAll(/%[psdifjo]/gu)].length
78
- if (length > 0) name = format(name, ...args.slice(0, length).map(formatArg))
84
+ if (length > 0) name = utilFormat(name, ...args.slice(0, length).map(formatArg))
79
85
  }
80
86
 
81
87
  impl(name, () => (Array.isArray(args) ? fn(...args) : fn(args)))
@@ -122,7 +128,7 @@ const testRaw = (callerLocation, testBase, name, fn, testTimeout) => {
122
128
  return testBase(name, { timeout }, async (t) => {
123
129
  const res = fn()
124
130
  assert(
125
- types.isPromise(res),
131
+ isPromise(res),
126
132
  `Test "${t.fullName}" did not return a Promise or supply a callback, which is required in force-exit mode.
127
133
  For tests to not end abruptly, use either async functions (recommended), Promises, or specify callbacks to test() / it().
128
134
  Also, using expect.assertions() to ensure the planned number of assertions is being called is advised for async code.`
@@ -184,4 +190,4 @@ const jest = {
184
190
 
185
191
  export { jest, describe, test, test as it }
186
192
  export { expect } from 'expect'
187
- export { beforeEach, afterEach, before as beforeAll, after as afterAll } from 'node:test'
193
+ export { beforeEach, afterEach, before as beforeAll, after as afterAll } from './engine.js'
package/src/jest.mock.js CHANGED
@@ -1,19 +1,18 @@
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'
1
+ import {
2
+ mock,
3
+ assert,
4
+ baseFile,
5
+ relativeRequire as require,
6
+ isTopLevelESM,
7
+ builtinModules,
8
+ syncBuiltinESMExports,
9
+ } from './engine.js'
6
10
  import { jestfn } from './jest.fn.js'
7
11
  import { makeEsbuildMockable } from './dark.cjs'
8
12
 
9
- const files = process.argv.slice(1)
10
- const baseUrl = files.length === 1 && existsSync(files[0]) ? normalize(files[0]) : undefined
11
13
  const mapMocks = new Map()
12
14
  const mapActual = new Map()
13
15
 
14
- const require = createRequire(baseUrl || import.meta.url)
15
- const isTopLevelESM = () => !baseUrl || !Object.hasOwn(require.cache, baseUrl) // assume ESM otherwise
16
-
17
16
  export const jestModuleMocks = {
18
17
  mock: jestmock,
19
18
  createMockFromModule: (name) => mockClone(requireActual(name)),
@@ -22,10 +21,8 @@ export const jestModuleMocks = {
22
21
  resetModules,
23
22
  }
24
23
 
25
- export const relativeRequire = require
26
-
27
24
  export function resolveModule(name) {
28
- assert(baseUrl || /^[@a-zA-Z]/u.test(name), 'Mocking relative paths is not possible')
25
+ assert(baseFile || /^[@a-zA-Z]/u.test(name), 'Mocking relative paths is not possible')
29
26
  const unprefixed = name.replace(/^node:/, '')
30
27
  if (builtinModules.includes(unprefixed)) return unprefixed
31
28
  return require.resolve(name)
@@ -1,25 +1,24 @@
1
- import { beforeEach } from 'node:test'
2
- import { createRequire } from 'node:module'
1
+ import {
2
+ beforeEach,
3
+ assert,
4
+ setSnapshotResolver,
5
+ setSnapshotSerializers,
6
+ readSnapshot,
7
+ relativeRequire,
8
+ } from './engine.js'
3
9
  import { expect } from 'expect'
4
10
  import { format, plugins as builtinPlugins } from 'pretty-format'
5
- import assert from 'node:assert/strict'
6
- import { basename, dirname, join, normalize } from 'node:path'
7
- import { readFileSync } from 'node:fs'
8
11
  import { jestConfig } from './jest.config.js'
9
- import { relativeRequire } from './jest.mock.js'
10
12
  import { getTestNamePath } from './dark.cjs'
11
13
 
12
14
  const { snapshotFormat, snapshotSerializers } = jestConfig()
13
15
  const plugins = Object.values(builtinPlugins)
14
16
  const serialize = (val) => format(val, { ...snapshotFormat, plugins }).replaceAll(/\r\n|\r/gu, '\n')
15
- const resolveSnapshot = (f) => join(dirname(f), '__snapshots__', `${basename(f)}.snap`)
16
17
 
17
18
  let serializersAreSetup = false
18
- let snapshotsAreJest
19
+ let snapshotsAreJest = false
19
20
 
20
21
  // For manually loading the snapshot
21
- const files = process.argv.slice(1)
22
- const snapshotLocation = files.length === 1 ? resolveSnapshot(normalize(files[0])) : undefined
23
22
  const nameCounts = new Map()
24
23
  let snapshotText
25
24
 
@@ -32,20 +31,11 @@ function maybeSetupSerializers() {
32
31
 
33
32
  // We want to setup snapshots to behave like jest only when first used from jest API
34
33
  function maybeSetupJestSnapshots() {
35
- if (snapshotsAreJest !== undefined) return snapshotsAreJest
36
- try {
37
- maybeSetupSerializers()
38
- const require = createRequire(import.meta.url)
39
- const { snapshot } = require('node:test') // attempt to load them, and we need to do that synchronously
40
- assert(snapshot, 'snapshots require Node.js >=22.3.0')
41
- snapshot.setDefaultSnapshotSerializers([serialize])
42
- snapshot.setResolveSnapshotPath(resolveSnapshot)
43
- snapshotsAreJest = true
44
- } catch {
45
- snapshotsAreJest = false
46
- }
47
-
48
- return snapshotsAreJest
34
+ if (snapshotsAreJest) return
35
+ setSnapshotResolver((dir, name) => [dir, '__snapshots__', `${name}.snap`])
36
+ setSnapshotSerializers([serialize])
37
+ maybeSetupSerializers()
38
+ snapshotsAreJest = true
49
39
  }
50
40
 
51
41
  const wrap = (check) => {
@@ -118,11 +108,14 @@ const snapOnDisk = (orig, matcher) => {
118
108
  const obj = matcher ? { ...orig, ...matcher } : orig
119
109
  const escape = (str) => str.replaceAll(/([\\`])/gu, '\\$1')
120
110
 
121
- if (!maybeSetupJestSnapshots()) {
111
+ maybeSetupJestSnapshots()
112
+
113
+ if (!context?.assert?.snapshot) {
122
114
  // We don't have native snapshots, polyfill reading
123
- if (snapshotLocation && snapshotText !== null) {
115
+ if (snapshotText !== null) {
124
116
  try {
125
- snapshotText = `\n${readFileSync(snapshotLocation, 'utf8')}\n` // we'll search wrapped in \n
117
+ const snapshotRaw = readSnapshot()
118
+ snapshotText = snapshotRaw ? `\n${snapshotRaw}\n` : null // we'll search wrapped in \n
126
119
  } catch {
127
120
  snapshotText = null
128
121
  }
@@ -1,5 +1,4 @@
1
- import assert from 'node:assert/strict'
2
- import { mock } from 'node:test'
1
+ import { mock, assert } from './engine.js'
3
2
  import { jestConfig } from './jest.config.js'
4
3
  import { haveValidTimers, haveNoTimerInfiniteLoopBug } from './version.js'
5
4
 
package/src/tape.js CHANGED
@@ -1,6 +1,4 @@
1
- import assert from 'node:assert/strict'
2
- import assertLoose from 'node:assert'
3
- import { test } from 'node:test'
1
+ import { assert, assertLoose, test } from './engine.js'
4
2
  import { createCallerLocationHook } from './dark.cjs'
5
3
  import './version.js'
6
4
 
package/src/version.js CHANGED
@@ -1,6 +1,6 @@
1
- import assert from 'node:assert/strict'
1
+ import { assert, nodeVersion } from './engine.js'
2
2
 
3
- const [major, minor, patch] = process.versions.node.split('.').map(Number)
3
+ const [major, minor, patch] = nodeVersion.split('.').map(Number)
4
4
  assert(major !== 21, 'Node.js 21.x is deprecated!') // reached EOL, no reason to even test
5
5
  // older versions are glitchy with before/after on top-level, which is a deal-breaker
6
6
  // 20.7.0 is fine for node:test but broken with tsx, so we bump to 20.8.0