@exodus/test 1.0.0-rc.14 → 1.0.0-rc.16

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/test",
3
- "version": "1.0.0-rc.14",
3
+ "version": "1.0.0-rc.16",
4
4
  "author": "Exodus Movement, Inc.",
5
5
  "description": "A test suite runner",
6
6
  "homepage": "https://github.com/ExodusMovement/test",
package/src/dark.cjs CHANGED
@@ -1,5 +1,7 @@
1
1
  const { fileURLToPath } = require('node:url')
2
2
 
3
+ const mayBeUrlToPath = (str) => (str.startsWith('file://') ? fileURLToPath(str) : str)
4
+
3
5
  let locForNextTest
4
6
 
5
7
  let installLocationInNextTest = function (loc) {
@@ -9,9 +11,7 @@ let installLocationInNextTest = function (loc) {
9
11
  // WARNING
10
12
  // Do not refactor, do not wrap
11
13
  // This function has to be called unwrapped directly inside our test() impl
12
- let getCallerLocation = () => {}
13
-
14
- const mayBeUrlToPath = (str) => (str.startsWith('file://') ? fileURLToPath(str) : str)
14
+ let getCallerLocation
15
15
 
16
16
  // This is unoptimal
17
17
  // Ideally, an option for overriding file locations should be added to Node.js,
@@ -21,28 +21,90 @@ const mayBeUrlToPath = (str) => (str.startsWith('file://') ? fileURLToPath(str)
21
21
  // This whole logic is limited only to updating caller locations for reports
22
22
  // We don't do use exposed Node.js internas for anything else
23
23
 
24
- try {
25
- const { Test } = require('node:internal/test_runner/test')
26
- const locStorage = new Map()
27
- Object.defineProperty(Test.prototype, 'loc', {
28
- get() {
29
- return locStorage.get(this)
30
- },
31
- set(val) {
32
- locStorage.set(this, val)
33
- if (locForNextTest) {
34
- const loc = locForNextTest
35
- locForNextTest = undefined
36
- locStorage.set(this, { line: loc[0], column: loc[1], file: mayBeUrlToPath(loc[2]) })
24
+ function createCallerLocationHook() {
25
+ if (getCallerLocation) return { installLocationInNextTest, getCallerLocation }
26
+
27
+ try {
28
+ const { Test } = require('node:internal/test_runner/test')
29
+ const locStorage = new Map()
30
+ Object.defineProperty(Test.prototype, 'loc', {
31
+ get() {
32
+ return locStorage.get(this)
33
+ },
34
+ set(val) {
35
+ locStorage.set(this, val)
36
+ if (locForNextTest) {
37
+ const loc = locForNextTest
38
+ locForNextTest = undefined
39
+ locStorage.set(this, { line: loc[0], column: loc[1], file: mayBeUrlToPath(loc[2]) })
40
+ }
41
+ },
42
+ })
43
+
44
+ // We can replicate getCallerLocation() with public V8 Error CallSite API, but we won't
45
+ // need it anyway if we don't have a path for hook into internal Test implementation
46
+
47
+ const { internalBinding } = require('node:internal/test/binding')
48
+ getCallerLocation = internalBinding('util').getCallerLocation
49
+ } catch {
50
+ getCallerLocation = () => {}
51
+ }
52
+
53
+ return { installLocationInNextTest, getCallerLocation }
54
+ }
55
+
56
+ // Easy on Node.js >= 22.3.0, but we polyfill for the rest
57
+ function getTestNamePath(t) {
58
+ // No implementation in Node.js yet, will have to PR
59
+ if (t.fullName) return t.fullName.split(' > ')
60
+
61
+ // We are on Node.js < 22.3.0 where even t.fullName doesn't exist yet, polyfill
62
+ const namePath = Symbol('namePath')
63
+ const getNamePath = Symbol('getNamePath')
64
+ try {
65
+ if (t[namePath]) return t[namePath]
66
+
67
+ // Sigh, ok, whatever
68
+ const { Test } = require('node:internal/test_runner/test')
69
+
70
+ const usePathName = Symbol('usePathName')
71
+ const restoreName = Symbol('restoreName')
72
+ Test.prototype[getNamePath] = function () {
73
+ if (this === this.root) return []
74
+ return [...(this.parent?.[getNamePath]() || []), this.name]
75
+ }
76
+
77
+ const diagnostic = Test.prototype.diagnostic
78
+ Test.prototype.diagnostic = function (...args) {
79
+ if (args[0] === usePathName) {
80
+ this[restoreName] = this.name
81
+ this.name = this[getNamePath]()
82
+ return
37
83
  }
38
- },
39
- })
40
84
 
41
- // We can replicate getCallerLocation() with public V8 Error CallSite API, but we won't
42
- // need it anyway if we don't have a path for hook into internal Test implementation
85
+ if (args[0] === restoreName) {
86
+ this.name = this[restoreName]
87
+ delete this[restoreName]
88
+ return
89
+ }
43
90
 
44
- const { internalBinding } = require('node:internal/test/binding')
45
- getCallerLocation = internalBinding('util').getCallerLocation
46
- } catch {}
91
+ return diagnostic.apply(this, args)
92
+ }
93
+
94
+ const TestContextProto = Object.getPrototypeOf(t)
95
+ Object.defineProperty(TestContextProto, namePath, {
96
+ get() {
97
+ this.diagnostic(usePathName)
98
+ const result = this.name
99
+ this.diagnostic(restoreName)
100
+ return result
101
+ },
102
+ })
103
+
104
+ return t[namePath]
105
+ } catch {}
106
+
107
+ return [t.name] // last resort
108
+ }
47
109
 
48
- module.exports = { installLocationInNextTest, getCallerLocation }
110
+ module.exports = { createCallerLocationHook, getTestNamePath }
@@ -1,5 +1,6 @@
1
1
  import assert from 'node:assert/strict'
2
2
  import { readFile } from 'node:fs/promises'
3
+ import { existsSync } from 'node:fs'
3
4
  import path from 'node:path'
4
5
  import { createRequire } from 'node:module'
5
6
  import { specialEnvironments } from './jest.environment.js'
@@ -10,36 +11,39 @@ const baseDir = files.length === 1 ? path.dirname(path.resolve(files[0])) : unde
10
11
  async function getJestConfig(dir) {
11
12
  if (!dir) return
12
13
 
13
- try {
14
- const pkg = JSON.parse(await readFile(path.resolve(dir, 'package.json'), 'utf8'))
14
+ const configPath = (ext) => path.resolve(dir, `jest.config.${ext}`)
15
15
 
16
- // Only if package.json is found
17
- let dynamic
18
- for (const type of ['mjs', 'cjs', 'js']) {
19
- try {
20
- const { default: config } = await import(path.resolve(dir, `jest.config.${type}`))
21
- dynamic = config
22
- break
23
- } catch (e) {
24
- if (e.code !== 'ERR_MODULE_NOT_FOUND') throw e
25
- }
26
- }
16
+ assert(!existsSync(configPath('ts')), 'jest.config.ts is not supported yet with .ts extension')
27
17
 
28
- if (dynamic === undefined) {
29
- try {
30
- dynamic = JSON.parse(await readFile(path.resolve(dir, 'jest.config.json'), 'utf8'))
31
- } catch (e) {
32
- if (e.code !== 'ENOENT') throw e
18
+ const configs = []
19
+ for (const type of ['js', 'ts', 'mjs', 'cjs', 'json']) {
20
+ try {
21
+ if (type === 'json') {
22
+ configs.push(JSON.parse(await readFile(configPath('json'), 'utf8')))
23
+ } else {
24
+ const { default: config } = await import(configPath(type))
25
+ configs.push(config)
33
26
  }
27
+ } catch (e) {
28
+ if (!['ERR_MODULE_NOT_FOUND', 'ENOENT'].includes(e.code)) throw e
34
29
  }
30
+ }
31
+
32
+ try {
33
+ const pkg = JSON.parse(await readFile(path.resolve(dir, 'package.json'), 'utf8'))
34
+ assert(typeof pkg.jest !== 'string', 'String package.json["jest"] values are not supported yet')
35
+ if (pkg.jest) configs.push(pkg.jest)
36
+ } catch (e) {
37
+ if (e.code !== 'ENOENT') throw e
38
+ }
35
39
 
36
- // We don't deep merge (yet?)
37
- const conf = { ...pkg.jest, ...dynamic }
40
+ assert(configs.length < 2, `Multiple jest configs found in ${dir} dir, use only a single one`)
41
+
42
+ if (configs.length > 0) {
43
+ const conf = { ...configs[0] }
38
44
  assert(!conf.rootDir, 'Jest config.rootDir is not supported yet')
39
45
  conf.rootDir = dir
40
46
  return conf
41
- } catch (e) {
42
- if (e.code !== 'ENOENT') throw e
43
47
  }
44
48
 
45
49
  const parent = path.dirname(dir)
@@ -124,7 +128,9 @@ export async function installJestEnvironment(jestGlobals) {
124
128
  if (c.restoreMocks) beforeEach(() => jest.restoreAllMocks())
125
129
  if (c.resetModules) beforeEach(() => jest.resetModules())
126
130
 
127
- const require = createRequire(config.rootDir)
131
+ const require = config.rootDir
132
+ ? createRequire(path.resolve(config.rootDir, 'package.json'))
133
+ : () => assert.fail('Unreachable: requiring plugins without a rootDir')
128
134
 
129
135
  if (Object.hasOwn(specialEnvironments, c.testEnvironment)) {
130
136
  specialEnvironments[c.testEnvironment](require, jestGlobals, c.testEnvironmentOptions)
@@ -1,56 +1,4 @@
1
- // Shoult not import src/ stuff here, as this goes into runner too (to check config)
2
-
3
- function getTestNamePath(t, { require } = {}) {
4
- // No implementation in Node.js yet, will have to PR
5
- if (t.fullName) return t.fullName.split(' > ')
6
-
7
- // We are on Node.js < 22.3.0 where even t.fullName doesn't exist yet, polyfill
8
- const namePath = Symbol('namePath')
9
- try {
10
- if (t[namePath]) return t[namePath]
11
-
12
- // Sigh, ok, whatever
13
- const { Test } = require('node:internal/test_runner/test')
14
-
15
- const usePathName = Symbol('usePathName')
16
- const restoreName = Symbol('restoreName')
17
- Test.prototype.getNamePath = function () {
18
- if (this === this.root) return []
19
- return [...(this.parent?.getNamePath() || []), this.name]
20
- }
21
-
22
- const diagnostic = Test.prototype.diagnostic
23
- Test.prototype.diagnostic = function (...args) {
24
- if (args[0] === usePathName) {
25
- this[restoreName] = this.name
26
- this.name = this.getNamePath()
27
- return
28
- }
29
-
30
- if (args[0] === restoreName) {
31
- this.name = this[restoreName]
32
- delete this[restoreName]
33
- return
34
- }
35
-
36
- return diagnostic.apply(this, args)
37
- }
38
-
39
- const TestContextProto = Object.getPrototypeOf(t)
40
- Object.defineProperty(TestContextProto, namePath, {
41
- get() {
42
- this.diagnostic(usePathName)
43
- const result = this.name
44
- this.diagnostic(restoreName)
45
- return result
46
- },
47
- })
48
-
49
- return t[namePath]
50
- } catch {}
51
-
52
- return [t.name] // last resort
53
- }
1
+ import { getTestNamePath } from './dark.cjs'
54
2
 
55
3
  export const specialEnvironments = {
56
4
  __proto__: null,
@@ -80,7 +28,7 @@ export const specialEnvironments = {
80
28
 
81
29
  jestGlobals.beforeEach((t) => {
82
30
  if (!pollyGlobals.isPollyActive) return
83
- const name = getTestNamePath(t, { require }).join('/')
31
+ const name = getTestNamePath(t).join('/')
84
32
  pollyGlobals.pollyContext.polly = new Polly(name, pollyGlobals.pollyContext.options)
85
33
  })
86
34
 
package/src/jest.js CHANGED
@@ -6,10 +6,12 @@ import { jestfn, allMocks } from './jest.fn.js'
6
6
  import { jestmock, requireActual, requireMock, resetModules } from './jest.mock.js'
7
7
  import * as jestTimers from './jest.timers.js'
8
8
  import './jest.snapshot.js'
9
- import { getCallerLocation, installLocationInNextTest } from './dark.cjs'
9
+ import { createCallerLocationHook } from './dark.cjs'
10
10
  import { expect } from 'expect'
11
11
  import matchers from 'jest-extended'
12
12
 
13
+ const { getCallerLocation, installLocationInNextTest } = createCallerLocationHook()
14
+
13
15
  expect.extend(matchers)
14
16
 
15
17
  let defaultTimeout = jestConfig().testTimeout // overridable via jest.setTimeout()
@@ -3,16 +3,25 @@ import { createRequire } from 'node:module'
3
3
  import { expect } from 'expect'
4
4
  import { format } from 'pretty-format'
5
5
  import assert from 'node:assert/strict'
6
- import { basename, dirname, join } from 'node:path'
6
+ import { basename, dirname, join, normalize } from 'node:path'
7
+ import { readFileSync } from 'node:fs'
7
8
  import { jestConfig } from './jest.config.js'
8
9
  import { relativeRequire } from './jest.mock.js'
10
+ import { getTestNamePath } from './dark.cjs'
9
11
 
10
12
  const { snapshotFormat, snapshotSerializers } = jestConfig()
11
13
  const plugins = []
12
14
  const serialize = (val) => format(val, { ...snapshotFormat, plugins }).replaceAll(/\r\n|\r/gu, '\n')
15
+ const resolveSnapshot = (f) => join(dirname(f), '__snapshots__', `${basename(f)}.snap`)
13
16
 
14
17
  let serializersAreSetup = false
15
- let snapshotsAreJest = false
18
+ let snapshotsAreJest
19
+
20
+ // For manually loading the snapshot
21
+ const files = process.argv.slice(1)
22
+ const snapshotLocation = files.length === 1 ? resolveSnapshot(normalize(files[0])) : undefined
23
+ const nameCounts = new Map()
24
+ let snapshotText
16
25
 
17
26
  function maybeSetupSerializers() {
18
27
  if (serializersAreSetup) return
@@ -23,14 +32,20 @@ function maybeSetupSerializers() {
23
32
 
24
33
  // We want to setup snapshots to behave like jest only when first used from jest API
25
34
  function maybeSetupJestSnapshots() {
26
- if (snapshotsAreJest) return
27
- maybeSetupSerializers()
28
- const require = createRequire(import.meta.url)
29
- const { snapshot } = require('node:test') // attempt to load them, and we need to do that synchronously
30
- assert(snapshot, 'snapshots require Node.js >=22.3.0')
31
- snapshot.setDefaultSnapshotSerializers([serialize])
32
- snapshot.setResolveSnapshotPath((f) => join(dirname(f), '__snapshots__', `${basename(f)}.snap`))
33
- snapshotsAreJest = true
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
49
  }
35
50
 
36
51
  const wrap = (check) => {
@@ -91,22 +106,48 @@ const snapInline = (obj, inline) => {
91
106
  getAssert().strictEqual(serialize(obj).trim(), inline.trim())
92
107
  }
93
108
 
94
- const snapOnDisk = (obj) =>
95
- wrapContextName(() => {
96
- maybeSetupJestSnapshots()
109
+ const snapOnDisk = (obj) => {
110
+ const escape = (str) => str.replaceAll(/([\\`])/gu, '\\$1')
97
111
 
98
- if (!serialize(obj).includes('\n')) {
99
- // Node.js always wraps with newlines, while jest wraps only those that are already multiline
112
+ if (!maybeSetupJestSnapshots()) {
113
+ // We don't have native snapshots, polyfill reading
114
+ if (snapshotLocation && snapshotText !== null) {
100
115
  try {
101
- getAssert().snapshot(obj)
102
- } catch (e) {
103
- if (`\n${e.expected}\n` === e.actual) return
104
- throw e
116
+ snapshotText = `\n${readFileSync(snapshotLocation, 'utf8')}\n` // we'll search wrapped in \n
117
+ } catch {
118
+ snapshotText = null
105
119
  }
106
120
  }
107
121
 
108
- return getAssert().snapshot(obj)
109
- })
122
+ const addFail = `Adding new snapshots requires Node.js >=22.3.0`
123
+
124
+ // We don't support polyfilled snapshot generation here, only parsing
125
+ // Also be careful with assertion plan counters
126
+ if (!snapshotText) getAssert().fail(`Could not find snapshot file. ${addFail}`)
127
+
128
+ const name = getTestNamePath(context).join(' ')
129
+ const count = (nameCounts.get(name) || 0) + 1
130
+ nameCounts.set(name, count)
131
+ const escaped = escape(serialize(obj))
132
+ const key = `${name} ${count}`
133
+ const makeEntry = (x) => `\nexports[\`${escape(key)}\`] = \`${x}\`;\n`
134
+ const final = escaped.includes('\n') ? `\n${escaped}\n` : escaped
135
+ if (snapshotText.includes(makeEntry(final))) return
136
+ // Perhaps wrapped with newlines from Node.js snapshots?
137
+ if (!final.includes('\n') && snapshotText.includes(makeEntry(`\n${final}\n`))) return
138
+ return getAssert().fail(`Could not match "${key}" in snapshot. ${addFail}`)
139
+ }
140
+
141
+ // Node.js always wraps with newlines, while jest wraps only those that are already multiline
142
+ try {
143
+ wrapContextName(() => getAssert().snapshot(obj))
144
+ } catch (e) {
145
+ const escaped = escape(e.expected)
146
+ const final = escaped.includes('\n') ? escaped : `\n${escaped}\n`
147
+ if (final === e.actual) return
148
+ throw e
149
+ }
150
+ }
110
151
 
111
152
  expect.extend({
112
153
  toMatchInlineSnapshot: (obj, i) => wrap(() => snapInline(obj, i)),
package/src/tape.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import assert from 'node:assert/strict'
2
2
  import assertLoose from 'node:assert'
3
3
  import { test } from 'node:test'
4
- import { getCallerLocation, installLocationInNextTest } from './dark.cjs'
4
+ import { createCallerLocationHook } from './dark.cjs'
5
5
 
6
6
  const knownOptions = new Set(['skip', 'todo', 'concurrency', 'timeout'])
7
7
 
@@ -134,6 +134,8 @@ function tapeWrapAssert(t, callback) {
134
134
 
135
135
  const AsyncFunction = (async () => {}).constructor
136
136
 
137
+ const { getCallerLocation, installLocationInNextTest } = createCallerLocationHook()
138
+
137
139
  function tapeWrap(test) {
138
140
  const tap = (...args) => {
139
141
  const fn = args.pop()