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

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'
@@ -13,6 +14,14 @@ const bindir = dirname(fileURLToPath(import.meta.url))
13
14
  const EXTS = `.?([cm])[jt]s?(x)` // we differ from jest, allowing [cm] before everything
14
15
  const DEFAULT_PATTERNS = [`**/__tests__/**/*${EXTS}`, `**/?(*.)+(spec|test)${EXTS}`]
15
16
 
17
+ const ENGINES = new Map(
18
+ Object.entries({
19
+ 'node:test': { binary: 'node', pure: false, hasImportLoader: true },
20
+ 'node:pure': { binary: 'node', pure: true, hasImportLoader: true },
21
+ 'bun:pure': { binary: 'bun', pure: true, hasImportLoader: false },
22
+ })
23
+ )
24
+
16
25
  function parseOptions() {
17
26
  const options = {
18
27
  jest: false,
@@ -26,6 +35,12 @@ function parseOptions() {
26
35
  passWithNoTests: false,
27
36
  writeSnapshots: false,
28
37
  debug: { files: false },
38
+ ideaCompat: false,
39
+ engine: process.env.EXODUS_TEST_ENGINE ?? 'node:test',
40
+ // Engine options
41
+ binary: 'node',
42
+ pure: false,
43
+ hasImportLoader: false,
29
44
  }
30
45
 
31
46
  const args = [...process.argv]
@@ -40,6 +55,20 @@ function parseOptions() {
40
55
 
41
56
  while (args[0]?.startsWith('--')) {
42
57
  const option = args.shift()
58
+ if (options.ideaCompat) {
59
+ // Ignore some options IntelliJ IDEA is passing
60
+ switch (option) {
61
+ case '--reporters':
62
+ args.shift()
63
+ continue
64
+ case '--verbose':
65
+ case '--runTestsByPath':
66
+ case '--runInBand':
67
+ case '--testTimeout=7200000':
68
+ continue
69
+ }
70
+ }
71
+
43
72
  switch (option) {
44
73
  case '--global': // compat, will be removed in release
45
74
  case '--jest':
@@ -79,9 +108,23 @@ function parseOptions() {
79
108
  case '--forceExit':
80
109
  options.forceExit = true
81
110
  break
111
+ case '--engine':
112
+ options.engine = args.shift()
113
+ break
82
114
  case '--debug-files':
83
115
  options.debug.files = true
84
116
  break
117
+ case '--colors':
118
+ process.env.FORCE_COLOR = '1'
119
+ break
120
+ case '--no-colors':
121
+ process.env.FORCE_COLOR = '0'
122
+ process.env.NO_COLOR = '1'
123
+ process.env.NODE_DISABLE_COLORS = '1'
124
+ break
125
+ case '--idea-compat':
126
+ options.ideaCompat = true
127
+ break
85
128
  default:
86
129
  throw new Error(`Unknown option: ${option}`)
87
130
  }
@@ -99,46 +142,44 @@ function parseOptions() {
99
142
 
100
143
  const { options, patterns } = parseOptions()
101
144
 
102
- let program = 'node'
145
+ const engineOptions = ENGINES.get(options.engine)
146
+ assert(engineOptions, `Unknown engine: ${options.engine}`)
147
+ Object.assign(options, engineOptions)
103
148
 
104
149
  const require = createRequire(import.meta.url)
105
150
  const resolveRequire = (query) => require.resolve(query)
106
151
  const resolveImport = import.meta.resolve && ((query) => fileURLToPath(import.meta.resolve(query)))
107
152
 
108
- const c8 = resolveRequire('c8/bin/c8.js')
109
- if (resolveImport) assert.equal(c8, resolveImport('c8/bin/c8.js'))
110
-
111
- const args = ['--test', '--no-warnings=ExperimentalWarning', '--test-reporter=spec']
112
-
113
- if (haveModuleMocks) args.push('--experimental-test-module-mocks')
114
- if (haveSnapshots) args.push('--experimental-test-snapshots')
115
-
116
- if (options.writeSnapshots) {
117
- assert(haveSnapshots, 'For snapshots, use Node.js >=22.3.0')
118
- args.push('--test-update-snapshots')
119
- }
120
-
121
- if (options.forceExit) {
122
- assert(haveForceExit, 'For forceExit, use Node.js >= 20.14.0')
123
- args.push('--test-force-exit')
124
- }
153
+ const args = []
154
+ if (options.pure) {
155
+ const requiresNodeCoverage = options.coverage && options.coverageEngine === 'node'
156
+ assert(!requiresNodeCoverage, '"--coverage-engine node" requires "--engine node:test" (default)')
157
+ assert(!options.writeSnapshots, `Can not use write snapshots with ${options.engine} engine`)
158
+ assert(!options.forceExit, `Can not use --force-exit with ${options.engine} engine yet`) // TODO
159
+ assert(!options.watch, `Can not use --watch with with ${options.engine} engine`)
160
+ assert(!options.only, `Can not use --only with with ${options.engine} engine yet`) // TODO
161
+ } else if (options.engine === 'node:test') {
162
+ args.push('--test', '--no-warnings=ExperimentalWarning', '--test-reporter=spec')
163
+
164
+ if (haveModuleMocks) args.push('--experimental-test-module-mocks')
165
+ if (haveSnapshots) args.push('--experimental-test-snapshots')
166
+
167
+ if (options.writeSnapshots) {
168
+ assert(haveSnapshots, 'For snapshots, use Node.js >=22.3.0')
169
+ args.push('--test-update-snapshots')
170
+ }
125
171
 
126
- if (options.watch) args.push('--watch')
127
- if (options.only) args.push('--test-only')
172
+ if (options.forceExit) {
173
+ assert(haveForceExit, 'For forceExit, use Node.js >= 20.14.0')
174
+ args.push('--test-force-exit')
175
+ }
128
176
 
129
- args.push('--expose-internals') // this is unoptimal and hopefully temporary, see rationale in src/dark.cjs
177
+ if (options.watch) args.push('--watch')
178
+ if (options.only) args.push('--test-only')
130
179
 
131
- if (options.coverage) {
132
- if (options.coverageEngine === 'node') {
133
- args.push('--experimental-test-coverage')
134
- } else if (options.coverageEngine === 'c8') {
135
- program = c8
136
- args.unshift('node')
137
- // perhaps use text-summary ?
138
- args.unshift('-r', 'text', '-r', 'html')
139
- } else {
140
- throw new Error(`Unknown coverage engine: ${JSON.stringify(options.coverageEngine)}`)
141
- }
180
+ args.push('--expose-internals') // this is unoptimal and hopefully temporary, see rationale in src/dark.cjs
181
+ } else {
182
+ throw new Error('Unreachable')
142
183
  }
143
184
 
144
185
  const ignore = ['**/node_modules']
@@ -154,7 +195,7 @@ if (process.env.EXODUS_TEST_IGNORE) {
154
195
  if (options.jest) {
155
196
  const { loadJestConfig } = await import('../src/jest.config.js')
156
197
  const config = await loadJestConfig(process.cwd())
157
- args.push('--import', resolve(bindir, 'jest.js'))
198
+ args.push(options.hasImportLoader ? '--import' : '-r', resolve(bindir, 'jest.js'))
158
199
 
159
200
  if (config.testFailureExitCode !== undefined) {
160
201
  if (Number(config.testFailureExitCode) === 0) {
@@ -176,6 +217,7 @@ if (options.jest) {
176
217
  patterns.push(...(Array.isArray(config.testMatch) ? config.testMatch : [config.testMatch]))
177
218
  }
178
219
 
220
+ if (config.passWithNoTests) options.passWithNoTests = true
179
221
  const testRegex = config.testRegex ? new RegExp(config.testRegex, 'u') : null
180
222
  const ignoreRegexes = config.testPathIgnorePatterns.map((x) => new RegExp(x, 'u'))
181
223
  if (testRegex || ignoreRegexes.length > 0) {
@@ -189,7 +231,11 @@ if (options.jest) {
189
231
 
190
232
  if (options.esbuild) {
191
233
  assert(resolveImport)
192
- args.push('--import', resolveImport('tsx'))
234
+ if (options.hasImportLoader) {
235
+ args.push('--import', resolveImport('tsx'))
236
+ } else {
237
+ args.push('-r', resolveImport('tsx/cjs'))
238
+ }
193
239
  }
194
240
 
195
241
  if (options.babel) {
@@ -260,14 +306,54 @@ if (tsTests.length > 0 && !options.esbuild) {
260
306
  console.warn(`Flag --typescript has been used, but there were no TypeScript tests found!`)
261
307
  }
262
308
 
263
- assert(files.length > 0) // otherwise we can run recursively
264
- args.push(...files)
265
-
266
309
  if (!Object.hasOwn(process.env, 'NODE_ENV')) process.env.NODE_ENV = 'test'
310
+ process.env.EXODUS_TEST_PLATFORM = options.binary
267
311
 
268
- assert(program && ['node', c8].includes(program))
269
- const node = spawn(program, args, { stdio: 'inherit' })
312
+ const c8 = resolveRequire('c8/bin/c8.js')
313
+ if (resolveImport) assert.equal(c8, resolveImport('c8/bin/c8.js'))
270
314
 
271
- node.on('close', (code) => {
315
+ if (options.coverage) {
316
+ assert.equal(options.binary, 'node', 'Coverage is only supported with Node.js')
317
+ if (options.coverageEngine === 'node') {
318
+ args.push('--experimental-test-coverage')
319
+ } else if (options.coverageEngine === 'c8') {
320
+ args.unshift(options.binary)
321
+ options.binary = c8
322
+ // perhaps use text-summary ?
323
+ args.unshift('-r', 'text', '-r', 'html')
324
+ } else {
325
+ throw new Error(`Unknown coverage engine: ${JSON.stringify(options.coverageEngine)}`)
326
+ }
327
+ }
328
+
329
+ assert(files.length > 0) // otherwise we can run recursively
330
+ assert(options.binary && ['node', 'bun', c8].includes(options.binary))
331
+ process.env.EXODUS_TEST_EXECARGV = JSON.stringify(args)
332
+
333
+ if (options.pure) {
334
+ process.env.EXODUS_TEST_CONTEXT = 'pure'
335
+ console.warn(`\n${options.engine} engine is experimental and may not work an expected\n\n`)
336
+ const failures = []
337
+ for (const file of files) {
338
+ const node = spawn(options.binary, [...args, file], { stdio: 'inherit' })
339
+ const [code] = await once(node, 'close')
340
+ if (code !== 0) failures.push(file)
341
+ }
342
+
343
+ if (failures.length > 0) {
344
+ process.exitCode = 1
345
+ const [total, passed, failed] = [files.length, files.length - failures.length, failures.length]
346
+ console.log(`Test suites failed: ${failed} / ${total} (passed: ${passed} / ${total})`)
347
+ console.log('Failed test suites:')
348
+ for (const file of failures) console.log(` ${file}`) // joining with \n can get truncated, too big
349
+ } else {
350
+ console.log(`All ${files.length} test suites passed`)
351
+ }
352
+ } else {
353
+ assert(['node', c8].includes(options.binary), 'Native test engine is only supported with Node.js')
354
+ assert.equal(options.engine, 'node:test')
355
+ process.env.EXODUS_TEST_CONTEXT = 'node:test'
356
+ const node = spawn(options.binary, [...args, ...files], { stdio: 'inherit' })
357
+ const [code] = await once(node, 'close')
272
358
  process.exitCode = code
273
- })
359
+ }
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.26",
4
4
  "author": "Exodus Movement, Inc.",
5
5
  "description": "A test suite runner",
6
6
  "homepage": "https://github.com/ExodusMovement/test",
@@ -30,6 +30,7 @@
30
30
  },
31
31
  "exports": {
32
32
  "./jest": "./src/jest.js",
33
+ "./node": "./src/node.js",
33
34
  "./tape": "./src/tape.js"
34
35
  },
35
36
  "prettier": "@exodus/prettier",
@@ -37,13 +38,19 @@
37
38
  "bin/babel.cjs",
38
39
  "bin/jest.js",
39
40
  "src/dark.cjs",
41
+ "src/engine.js",
42
+ "src/engine.node.cjs",
43
+ "src/engine.pure.cjs",
44
+ "src/engine.select.cjs",
40
45
  "src/jest.js",
41
46
  "src/jest.config.js",
47
+ "src/jest.config.fs.js",
42
48
  "src/jest.environment.js",
43
49
  "src/jest.fn.js",
44
50
  "src/jest.mock.js",
45
51
  "src/jest.snapshot.js",
46
52
  "src/jest.timers.js",
53
+ "src/node.js",
47
54
  "src/tape.js",
48
55
  "src/version.js",
49
56
  "!__tests__",
@@ -51,9 +58,12 @@
51
58
  ],
52
59
  "scripts": {
53
60
  "test": "npm run test:jest",
54
- "test:all": "npm run test:jest && npm run test:tape",
61
+ "test:all": "npm run test:jest && npm run test:tape && npm run test:native",
62
+ "test:native": "EXODUS_TEST_IGNORE='{**/typescript/**,**/jest-repo/**/user.test.js}' ./bin/index.js --jest '__test__/**/*.test.js'",
55
63
  "test:jest": "./bin/index.js --jest --esbuild",
56
64
  "test:tape": "./bin/index.js --esbuild '__test__/tape/test/*.js' __test__/tape.test.js",
65
+ "test:pure": "EXODUS_TEST_ENGINE=node:pure npm run test",
66
+ "test:bun:pure": "EXODUS_TEST_ENGINE=bun:pure npm run test",
57
67
  "coverage": "./bin/index.js --jest --esbuild --coverage",
58
68
  "lint": "prettier --list-different . && eslint .",
59
69
  "lint:fix": "prettier --write . && eslint --fix ."
@@ -61,9 +71,11 @@
61
71
  "dependencies": {
62
72
  "@babel/register": "^7.0.0",
63
73
  "c8": "^9.1.0",
74
+ "esbuild": "~0.21.5",
64
75
  "expect": "^29.7.0",
65
76
  "fast-glob": "^3.2.11",
66
77
  "jest-extended": "^4.0.2",
78
+ "jsdom": "^24.1.0",
67
79
  "pretty-format": "^29.7.0",
68
80
  "tsx": "^4.16.2"
69
81
  },
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() {
@@ -107,9 +105,13 @@ function getTestNamePath(t) {
107
105
  return [t.name] // last resort
108
106
  }
109
107
 
108
+ const execArgv = process.env.EXODUS_TEST_EXECARGV
109
+ ? JSON.parse(process.env.EXODUS_TEST_EXECARGV)
110
+ : process.execArgv
111
+ const insideEsbuild = execArgv.some((x) => x.includes('node_modules/tsx/dist/'))
112
+
110
113
  function makeEsbuildMockable() {
111
- const usingTsx = process.execArgv.some((x) => x.endsWith('node_modules/tsx/dist/loader.mjs'))
112
- if (!usingTsx) return
114
+ if (!insideEsbuild) return
113
115
  // Hook into tsx/esbuild transpiled module conversion magic to make loaded modules mockable in runtime
114
116
  // We want all modules to be .configurable = true, so we can override them
115
117
  const defineProperty = Object.defineProperty
@@ -140,4 +142,4 @@ function makeEsbuildMockable() {
140
142
  }
141
143
  }
142
144
 
143
- module.exports = { createCallerLocationHook, getTestNamePath, makeEsbuildMockable }
145
+ module.exports = { createCallerLocationHook, getTestNamePath, insideEsbuild, makeEsbuildMockable }
package/src/engine.js ADDED
@@ -0,0 +1,22 @@
1
+ import engine from './engine.select.cjs' // need to be sync for non-preloaded imports into cjs
2
+
3
+ const { engine: name } = engine
4
+ export { name as engine }
5
+
6
+ const { assert, assertLoose } = engine
7
+ export { assert, assertLoose }
8
+
9
+ const { mock, describe, test, beforeEach, afterEach, before, after } = engine
10
+ export { mock, describe, test, beforeEach, afterEach, before, after }
11
+
12
+ const { builtinModules, syncBuiltinESMExports } = engine
13
+ export { builtinModules, syncBuiltinESMExports }
14
+
15
+ const { utilFormat, isPromise, nodeVersion } = engine
16
+ export { utilFormat, isPromise, nodeVersion }
17
+
18
+ const { baseFile, relativeRequire, isTopLevelESM } = engine
19
+ export { baseFile, relativeRequire, isTopLevelESM }
20
+
21
+ const { readSnapshot, setSnapshotSerializers, setSnapshotResolver } = engine
22
+ export { readSnapshot, setSnapshotSerializers, setSnapshotResolver }
@@ -0,0 +1,39 @@
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
+ engine: 'node:test',
32
+ ...{ assert, assertLoose },
33
+ ...{ mock, describe, test, beforeEach, afterEach, before, after },
34
+ ...{ builtinModules, syncBuiltinESMExports },
35
+ ...{ utilFormat, isPromise, nodeVersion },
36
+ ...{ baseFile, relativeRequire, isTopLevelESM },
37
+ ...{ readSnapshot, setSnapshotSerializers, setSnapshotResolver },
38
+ }
39
+ /* eslint-enable unicorn/no-useless-spread */