@barelyhuman/node-snapshot
Version: 
snapshot testing for node:test
167 lines (147 loc) • 4.4 kB
JavaScript
import { diffTrimmedLines } from 'diff'
import k from 'kleur'
import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { dirname, extname, join, relative } from 'node:path'
import { format, plugins as prettyFormatPlugins } from 'pretty-format'
const ADDED = k.green
const REMOVED = k.red
const require = createRequire(import.meta.url)
function getFileName() {
  const err = new Error()
  const stackArr = err.stack.split('\n')
  const snapshotLineIndex = stackArr.findIndex(
    l => l.trim().indexOf('at snapshot') > -1
  )
  const matched = stackArr[snapshotLineIndex + 1].match(/\((.+)\)$/)
  if (!matched) {
    return
  }
  const filePath = matched[1]
  const pathSplits = filePath.split(':')
  let lineNumber, col
  pathSplits.reverse().forEach((i, ind) => {
    if (ind === 0 && !isNaN(+i)) {
      col = i
    }
    if (ind === 1 && !isNaN(+i)) {
      lineNumber = i
    }
  })
  const sanitizedFilePath = filePath
    .replace(`:${lineNumber}:${col}`, '')
    .replace('file://', '')
  return {
    filename: sanitizedFilePath,
    line: lineNumber,
    col: col,
  }
}
let fileTestCounter = new Map()
function getFileCounterKey(filename, testName) {
  return `${filename}:${testName}`
}
export function snapshot(
  test,
  currentValue,
  errorMsg = 'Snapshot does not match'
) {
  const hasFileDetails = getFileName()
  const shouldUpdate = () => Number(process.env.UPDATE_SNAPSHOTS) === 1
  if (!hasFileDetails) return
  const { filename } = hasFileDetails
  const snapshotFileName = join(
    'snapshots',
    relative(process.cwd(), filename.replace(extname(filename), '.snap.cjs'))
  )
  let snapshotName = test.name
  if (test.fullName) {
    snapshotName = test.fullName
  } else if (test.name) {
    snapshotName = test.name
  }
  const fileCounterKey = getFileCounterKey(filename, test.fullName ?? test.name)
  if (!fileTestCounter.has(fileCounterKey)) {
    fileTestCounter.set(fileCounterKey, 0)
  }
  const currentCount = fileTestCounter.get(fileCounterKey)
  snapshotName += ' ' + (Number(currentCount) + 1)
  fileTestCounter.set(fileCounterKey, Number(currentCount) + 1)
  if (shouldUpdate()) {
    writeSnapshot(currentValue, snapshotFileName, snapshotName)
    return
  }
  if (existsSync(snapshotFileName)) {
    const module = require(join(process.cwd(), snapshotFileName))
    if (module[snapshotName]) {
      const _diff = diffTrimmedLines(
        formatValue(currentValue).replace(/\\\`/g, '`'),
        module[snapshotName]
      )
      const hasChanges = _diff.filter(d => d.added || d.removed)
      if (hasChanges.length) {
        let changeText = k.reset('\n')
        _diff.forEach(d => {
          if (d.added) {
            changeText += `${ADDED('expected')} ${d.value}`.trimEnd() + '\n'
          } else if (d.removed) {
            changeText += `${REMOVED('received')} ${d.value}`.trimEnd() + '\n'
          } else {
            changeText += d.value.trimEnd() + '\n'
          }
        })
        test.diagnostic(changeText)
        throw new Error(errorMsg)
      }
    } else {
      throw new Error(
        `Snapshot doesn't exist for \`${snapshotName}\`, please run the test command with UPDATE_SNAPSHOTS=1`
      )
    }
  }
}
function writeSnapshot(value, file, name) {
  if (!existsSync(file)) {
    let data = ''
    data += '\n\n'
    data += `exports[${JSON.stringify(name)}] = \`${formatValue(value)}\``
    mkdirSync(dirname(file), { recursive: true })
    writeFileSync(file, data, 'utf8')
    return
  }
  const module = require(join(process.cwd(), file))
  module[name] = formatValue(value)
  let newContent = ''
  Object.keys(module).forEach(exp => {
    newContent += `exports[${JSON.stringify(exp)}] = \`${module[exp]}\`\n\n`
  })
  writeFileSync(file, newContent, 'utf8')
}
function formatValue(value) {
  const {
    DOMCollection,
    DOMElement,
    Immutable,
    ReactElement,
    ReactTestComponent,
    AsymmetricMatcher,
  } = prettyFormatPlugins
  return normalizeNewlines(
    format(value, {
      escapeRegex: true,
      indent: 2,
      escapeString: false,
      plugins: [
        DOMCollection,
        DOMElement,
        Immutable,
        ReactElement,
        ReactTestComponent,
        AsymmetricMatcher,
      ],
    })
  ).replaceAll(/[`]/g, '\\`')
}
function normalizeNewlines(str) {
  return str.replaceAll(/\r\n|\r/g, '\n')
}