Skip to content

Commit af44d32

Browse files
committed
fix: support node:test snapshot corner-cases
1 parent 860b0d3 commit af44d32

5 files changed

Lines changed: 610 additions & 9 deletions

File tree

.github/workflows/checks.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ jobs:
3434
strategy:
3535
fail-fast: false
3636
matrix:
37-
node-version: ['25', '24', '24.0.0', '22.22.0', '22.12.0', '20', '20.19.0']
37+
node-version: ['25', '24', '24.0.0', '22.22.0', '22.12.0', '22.13.0', '20', '20.19.0']
3838
steps:
3939
- uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29
4040
- uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda
@@ -61,7 +61,7 @@ jobs:
6161
strategy:
6262
fail-fast: false
6363
matrix:
64-
node-version: ['25', '24', '24.0.0', '22.22.0', '22.12.0', '20', '20.19.0']
64+
node-version: ['25', '24', '24.0.0', '22.22.0', '22.12.0', '22.13.0', '20', '20.19.0']
6565
steps:
6666
- uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29
6767
- uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda

src/engine.pure.cjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -564,7 +564,7 @@ if (process.env.EXODUS_TEST_ENVIRONMENT === 'bundle') {
564564

565565
// eslint-disable-next-line no-undef
566566
let snapshotResolver = (dir, name) => [dir, `${name}.snapshot`] // default per Node.js docs
567-
let snapshotSerializers = [(obj) => JSON.stringify(obj, null, 2)]
567+
let snapshotSerializers = [(obj) => (obj === undefined ? `${obj}` : JSON.stringify(obj, null, 2))]
568568
const serializeSnapshot = (obj) => {
569569
let val = obj
570570
for (const fn of snapshotSerializers) val = fn(val)

src/engine.pure.snapshot.cjs

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ const nameCounts = new Map()
22
let snapshotText, snapshotTextClean
33

44
const escapeSnapshot = (str) => str.replaceAll(/([\\`]|\$\{)/gu, '\\$1')
5+
const escapeSnapshotKey = (s) => escapeSnapshot(s).replaceAll('\n', '\\n').replaceAll('"', '\\"')
56

67
function matchSnapshot(readSnapshot, assert, name, serialized) {
78
// We don't have native snapshots, polyfill reading
@@ -25,12 +26,16 @@ function matchSnapshot(readSnapshot, assert, name, serialized) {
2526
nameCounts.set(name, count)
2627
const escaped = escapeSnapshot(serialized)
2728
const key = `${name} ${count}`
28-
const makeEntry = (x) => `\nexports[\`${escapeSnapshot(key)}\`] = \`${x}\`;\n`
29-
const fixedText = escaped.includes('\r') ? snapshotText : snapshotTextClean // well, if we expect \r let's preserve them
30-
const final = escaped.includes('\n') ? `\n${escaped}\n` : escaped
31-
if (fixedText.includes(makeEntry(final))) return
32-
// Perhaps wrapped with newlines from Node.js snapshots?
33-
if (!final.includes('\n') && fixedText.includes(makeEntry(`\n${final}\n`))) return
29+
// Node.js and jest escape keys differently, both result to same strings, accept both
30+
for (const keyfun of [escapeSnapshot, escapeSnapshotKey]) {
31+
const makeEntry = (x) => `\nexports[\`${keyfun(key)}\`] = \`${x}\`;\n`
32+
const fixedText = escaped.includes('\r') ? snapshotText : snapshotTextClean // well, if we expect \r let's preserve them
33+
const final = escaped.includes('\n') ? `\n${escaped}\n` : escaped
34+
if (fixedText.includes(makeEntry(final))) return
35+
// Perhaps wrapped with newlines from Node.js snapshots?
36+
if (!final.includes('\n') && fixedText.includes(makeEntry(`\n${final}\n`))) return
37+
}
38+
3439
return assert.fail(`Could not match "${key}" in snapshot. ${addFail}`)
3540
}
3641

tests/node/snapshot.test.js

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import { describe, it } from 'node:test'
2+
import * as nodeTest from 'node:test'
3+
4+
const skip = !nodeTest.snapshot // Node.js 20.x and 22.12 (22.13 has it)
5+
6+
it('simple', { skip }, (t) => {
7+
t.assert.snapshot(10)
8+
t.assert.snapshot(null)
9+
t.assert.snapshot()
10+
t.assert.snapshot([])
11+
t.assert.snapshot(/xx/)
12+
t.assert.snapshot(Infinity)
13+
t.assert.snapshot(false)
14+
t.assert.snapshot(true)
15+
t.assert.snapshot({})
16+
})
17+
18+
it('complex', { skip }, (t) => {
19+
t.assert.snapshot([10])
20+
t.assert.snapshot([{ a: 10 }])
21+
t.assert.snapshot({ a: 10 })
22+
t.assert.snapshot({ a: 10, b: 20 })
23+
t.assert.snapshot(Buffer.from(''))
24+
})
25+
26+
// repeat test name
27+
it('simple', { skip }, (t) => {
28+
t.assert.snapshot(/hello/)
29+
t.assert.snapshot(true)
30+
t.assert.snapshot(NaN)
31+
t.assert.snapshot({})
32+
t.assert.snapshot(42)
33+
t.assert.snapshot([])
34+
t.assert.snapshot(-Infinity)
35+
})
36+
37+
it('mixed', { skip }, (t) => {
38+
t.assert.snapshot(true)
39+
t.assert.snapshot([1, 2, 3])
40+
t.assert.snapshot({ foo: 'bar' })
41+
t.assert.snapshot(43)
42+
t.assert.snapshot({})
43+
t.assert.snapshot([])
44+
})
45+
46+
// repeat test name
47+
it('mixed', { skip }, (t) => {
48+
t.assert.snapshot([5, 4, 3])
49+
t.assert.snapshot([])
50+
t.assert.snapshot(false)
51+
t.assert.snapshot(41)
52+
t.assert.snapshot({ bar: 'buz' })
53+
t.assert.snapshot({})
54+
})
55+
56+
it('escape', { skip }, (t) => {
57+
t.assert.snapshot('\\')
58+
t.assert.snapshot('${')
59+
t.assert.snapshot('$$\\${')
60+
})
61+
62+
const TEST_ONE = { a: 20, d: Buffer.from('foo'), b: [1, 2, 'bar', 5], e: { foo: 'bar' } }
63+
// eslint-disable-next-line no-sparse-arrays
64+
const TEST_TWO = { ['__proto__']: [], b: [1, 2, , , 5], e: { foo: 'bar' }, f: -Infinity }
65+
const TEST_THREE = [new Error('?!'), new TypeError('bar'), new Uint16Array([4, 2, 65_123]), null]
66+
67+
// Test names repeat on a purpose!
68+
69+
it('test A', { skip }, (t) => {
70+
t.assert.snapshot(TEST_ONE)
71+
})
72+
73+
it('test B', { skip }, (t) => {
74+
t.assert.snapshot(TEST_TWO)
75+
})
76+
77+
it('test B', { skip }, (t) => {
78+
t.assert.snapshot(TEST_THREE)
79+
})
80+
81+
// Repeat name
82+
it('test B', { skip }, (t) => {
83+
t.assert.snapshot({ x: 1337 })
84+
})
85+
86+
describe('nested test', { skip }, () => {
87+
it('test A', (t) => {
88+
t.assert.snapshot(TEST_TWO)
89+
})
90+
91+
it('test A', (t) => {
92+
t.assert.snapshot(TEST_THREE)
93+
})
94+
95+
it('nested test one', (t) => {
96+
t.assert.snapshot(TEST_ONE)
97+
})
98+
})
99+
100+
it('errors', { skip }, (t) => {
101+
t.assert.snapshot(new TypeError('Whoops\nI\nFailed'))
102+
t.assert.snapshot(new RangeError('Out of something'))
103+
})
104+
105+
describe('weird names', { skip }, () => {
106+
const ascii = Array.from({ length: 128 })
107+
.fill()
108+
.map((a, i) => i)
109+
.slice(0x20)
110+
.map((i) => String.fromCodePoint(i))
111+
.join('')
112+
for (const key of [
113+
'\n',
114+
'{}',
115+
'$',
116+
'`',
117+
'>',
118+
'` ` `',
119+
'` `\n `',
120+
'\\',
121+
'\\\n`\n\\``',
122+
'${',
123+
ascii,
124+
]) {
125+
it(key, (t) => {
126+
t.assert.snapshot(key)
127+
})
128+
}
129+
130+
it('multi\nline', { skip }, (t) => {
131+
t.assert.snapshot(0)
132+
})
133+
134+
it('with `', { skip }, (t) => {
135+
t.assert.snapshot(42)
136+
})
137+
})
138+
139+
describe('', { skip }, () => {
140+
it('', (t) => {
141+
t.assert.snapshot('empty names test')
142+
t.assert.snapshot(t.fullName)
143+
t.assert.snapshot(t.name)
144+
})
145+
146+
it((t) => {
147+
t.assert.snapshot('no name test')
148+
t.assert.snapshot(t.fullName)
149+
t.assert.snapshot(t.name)
150+
})
151+
152+
// non-string names
153+
for (const name of [false, true, 0, 1, null, undefined, {}, ['arr'], 'str']) {
154+
it(name, (t) => {
155+
t.assert.snapshot(t.fullName)
156+
t.assert.snapshot(t.name)
157+
})
158+
}
159+
})

0 commit comments

Comments
 (0)