Skip to content

Commit

Permalink
feat: support snapshot matching in node:test implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
ChALkeR committed Sep 9, 2024
1 parent 720e38e commit 952f88e
Show file tree
Hide file tree
Showing 4 changed files with 61 additions and 36 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
"src/engine.js",
"src/engine.node.cjs",
"src/engine.pure.cjs",
"src/engine.pure.snapshot.cjs",
"src/engine.select.cjs",
"src/jest.js",
"src/jest.config.js",
Expand Down
25 changes: 22 additions & 3 deletions src/engine.pure.cjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
const assert = require('node:assert/strict')
const assertLoose = require('node:assert')
const { matchSnapshot } = require('./engine.pure.snapshot.cjs')

const { setTimeout, setInterval, setImmediate, Date } = globalThis
const { clearTimeout, clearInterval, clearImmediate } = globalThis
Expand Down Expand Up @@ -28,17 +29,21 @@ class Context {
children = []
assert = { ...assertLoose, snapshot: undefined }
hooks = { __proto__: null, before: [], after: [], beforeEach: [], afterEach: [] }
#fullName

constructor(parent, name, options = {}) {
Object.assign(this, { root: parent?.root, parent, name, options })
this.fullName = parent && parent !== parent.root ? `${parent.fullName} > ${name}` : name
if (this.fullName === name) this.fullName = this.fullName.replace(INBAND_PREFIX_REGEX, '')
this.#fullName = parent && parent !== parent.root ? `${parent.fullName} > ${name}` : name
if (this.#fullName === name) this.#fullName = this.#fullName.replace(INBAND_PREFIX_REGEX, '')
if (this.root) {
this.parent.children.push(this)
} else {
assert(this.name === '<root>' && !this.parent)
this.root = this
}

this.assert.snapshot = (obj) =>
matchSnapshot(readSnapshot, assert, this.fullName, serializeSnapshot(obj))
}

get onlySomewhere() {
Expand All @@ -48,6 +53,10 @@ class Context {
get only() {
return (this.options.only && !this.children.some((x) => x.onlySomewhere)) || this.parent?.only
}

get fullName() {
return this.#fullName
}
}

function enterContext(name, options) {
Expand Down Expand Up @@ -466,7 +475,17 @@ if (process.env.EXODUS_TEST_ENVIRONMENT === 'bundle') {

// eslint-disable-next-line no-undef
let snapshotResolver = (dir, name) => [dir, `${name}.snapshot`] // default per Node.js docs
const setSnapshotSerializers = () => {}
let snapshotSerializers = [(obj) => JSON.stringify(obj, null, 2)]
const serializeSnapshot = (obj) => {
let val = obj
for (const fn of snapshotSerializers) val = fn(val)
return val
}

const setSnapshotSerializers = ([...arr]) => {
snapshotSerializers = arr
}

const setSnapshotResolver = (fn) => {
snapshotResolver = fn
}
Expand Down
35 changes: 35 additions & 0 deletions src/engine.pure.snapshot.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
const nameCounts = new Map()
let snapshotText

const escapeSnapshot = (str) => str.replaceAll(/([\\`])/gu, '\\$1')

function matchSnapshot(readSnapshot, assert, name, serialized) {
// We don't have native snapshots, polyfill reading
if (snapshotText !== null) {
try {
const snapshotRaw = readSnapshot()
snapshotText = snapshotRaw ? `\n${snapshotRaw}\n` : null // we'll search wrapped in \n
} catch {
snapshotText = null
}
}

const addFail = `Adding new snapshots requires Node.js >=22.3.0`

// We don't support polyfilled snapshot generation here, only parsing
// Also be careful with assertion plan counters
if (!snapshotText) assert.fail(`Could not find snapshot file. ${addFail}`)

const count = (nameCounts.get(name) || 0) + 1
nameCounts.set(name, count)
const escaped = escapeSnapshot(serialized)
const key = `${name} ${count}`
const makeEntry = (x) => `\nexports[\`${escapeSnapshot(key)}\`] = \`${x}\`;\n`
const final = escaped.includes('\n') ? `\n${escaped}\n` : escaped
if (snapshotText.includes(makeEntry(final))) return
// Perhaps wrapped with newlines from Node.js snapshots?
if (!final.includes('\n') && snapshotText.includes(makeEntry(`\n${final}\n`))) return
return assert.fail(`Could not match "${key}" in snapshot. ${addFail}`)
}

module.exports = { escapeSnapshot, matchSnapshot }
36 changes: 3 additions & 33 deletions src/jest.snapshot.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { format, plugins as builtinPlugins } from 'pretty-format'
import { jestConfig } from './jest.config.js'
import { getTestNamePath } from './dark.cjs'
import { haveSnapshotsReportUnescaped } from './version.js'
import { matchSnapshot, escapeSnapshot } from './engine.pure.snapshot.cjs'

const { snapshotFormat, snapshotSerializers } = jestConfig()
const plugins = Object.values(builtinPlugins)
Expand All @@ -19,10 +20,6 @@ const serialize = (val) => format(val, { ...snapshotFormat, plugins }).replaceAl
let serializersAreSetup = false
let snapshotsAreJest = false

// For manually loading the snapshot
const nameCounts = new Map()
let snapshotText

function maybeSetupSerializers() {
if (serializersAreSetup) return
// empty require and serializers should not let this fail, non-empty serializers and empty require should
Expand Down Expand Up @@ -124,47 +121,20 @@ const snapOnDisk = (orig, matcher) => {
}

const obj = matcher ? deepMerge(orig, matcher) : orig
const escape = (str) => str.replaceAll(/([\\`])/gu, '\\$1')

maybeSetupJestSnapshots()

if (!context?.assert?.snapshot) {
// We don't have native snapshots, polyfill reading
if (snapshotText !== null) {
try {
const snapshotRaw = readSnapshot()
snapshotText = snapshotRaw ? `\n${snapshotRaw}\n` : null // we'll search wrapped in \n
} catch {
snapshotText = null
}
}

const addFail = `Adding new snapshots requires Node.js >=22.3.0`

// We don't support polyfilled snapshot generation here, only parsing
// Also be careful with assertion plan counters
if (!snapshotText) getAssert().fail(`Could not find snapshot file. ${addFail}`)

const namePath = getTestNamePath(context).map((x) => (x === '<anonymous>' ? '' : x))
const name = namePath.join(' ')
const count = (nameCounts.get(name) || 0) + 1
nameCounts.set(name, count)
const escaped = escape(serialize(obj))
const key = `${name} ${count}`
const makeEntry = (x) => `\nexports[\`${escape(key)}\`] = \`${x}\`;\n`
const final = escaped.includes('\n') ? `\n${escaped}\n` : escaped
if (snapshotText.includes(makeEntry(final))) return
// Perhaps wrapped with newlines from Node.js snapshots?
if (!final.includes('\n') && snapshotText.includes(makeEntry(`\n${final}\n`))) return
return getAssert().fail(`Could not match "${key}" in snapshot. ${addFail}`)
return matchSnapshot(readSnapshot, getAssert(), namePath.join(' '), serialize(obj))
}

// Node.js always wraps with newlines, while jest wraps only those that are already multiline
try {
wrapContextName(() => context.assert.snapshot(obj))
} catch (e) {
if (typeof e.expected === 'string') {
const escaped = haveSnapshotsReportUnescaped ? e.expected : escape(e.expected)
const escaped = haveSnapshotsReportUnescaped ? e.expected : escapeSnapshot(e.expected)
const final = escaped.includes('\n') ? escaped : `\n${escaped}\n`
if (final === e.actual) return
}
Expand Down

0 comments on commit 952f88e

Please sign in to comment.