snap-shot-it
Version:
Smarter snapshot utility for Mocha and BDD test runners
368 lines (320 loc) • 10.4 kB
JavaScript
const debug = require('debug')('snap-shot-it')
const { core, restore, prune } = require('snap-shot-core')
const { isDataDriven, dataDriven } = require('@bahmutov/data-driven')
const { isNamedSnapshotArguments } = require('./named-snapshots')
const utils = require('./utils')
const R = require('ramda')
const { hasOnly, hasFailed } = require('has-only')
const pluralize = require('pluralize')
const la = require('lazy-ass')
const is = require('check-more-types')
const { stripIndent } = require('common-tags')
const path = require('path')
const itsName = require('its-name')
// save current directory right away to avoid any surprises later
// when some random tests change it
const cwd = process.cwd()
const relativeToCwd = path.relative.bind(null, cwd)
debug('loading snap-shot-it')
const EXTENSION = '.js'
const noop = () => {}
/**
* all tests we have seen so we can prune later
* for each seen spec we keep just an object
* { file, key }
* where key is the full name of the saved snapshot
* so in the list there might be multiple key
*/
const seenSpecs = []
function _pruneSnapshots () {
debug('pruning snapshots')
debug('seen %s', pluralize('spec', seenSpecs.length, true))
debug(seenSpecs)
prune({ tests: seenSpecs, ext: EXTENSION }, pruneSnapshotsOptions)
// eslint-disable-next-line immutable/no-mutation
seenSpecs.length = 0
}
// eslint-disable-next-line immutable/no-let
let pruneSnapshots = noop
let pruneSnapshotsOptions
/**
* Returns true if the object has information enough
* to prune snapshots later.
*/
const isPruneInfo = is.schema({
specFile: is.unemptyString,
key: is.unemptyString,
testTitle: is.unemptyString,
titleParts: is.strings,
allowDuplicate: is.bool
})
function addToPrune (info) {
la(isPruneInfo(info), 'wrong info for pruning snapshot', info)
const prevInfo = findExistingSnapshotKey(info)
if (prevInfo && !info.allowDuplicate) {
debug('add to prune: found duplicate snapshot name: %s', prevInfo.key)
debug('%o', info)
throwDuplicateSnapshotKeyError(info, prevInfo)
}
seenSpecs.push(info)
}
const findExistingSnapshotKey = info => {
la(isPruneInfo(info), 'wrong snapshot prune info', info)
return R.find(R.propEq('key', info.key))(seenSpecs)
}
const throwDuplicateSnapshotKeyError = (current, previous) => {
la(isPruneInfo(current), 'wrong current snapshot info', current)
la(isPruneInfo(previous), 'wrong previous snapshot info', previous)
throw new Error(stripIndent`
Duplicate snapshot key "${current.key}"
in spec file: ${relativeToCwd(current.specFile)}
current test title: "${current.testTitle}"
current test title in parts: ${current.titleParts.join(' - ')}
previous test title in parts: ${previous.titleParts.join(' - ')}
Please change the snapshot name to ensure uniqueness.
`)
}
// eslint-disable-next-line immutable/no-let
let currentTest
function setTest (t) {
if (!t) {
throw new Error('Expected test object')
}
currentTest = t
}
/**
* Returns full title of the test.
* Usually it is just a single string, concatenated from the root
* suite all the way to the test title.
*
```
describe('foo', () => {
context('bar', () => {
it('works', () => {
// test title is "foo bar works"
})
})
})
```
*/
const getTestTitle = test => test.fullTitle().trim()
/**
* Returns individual parts of the test title, starting from the outer suite
*/
const getTestTitleParts = itsName
const getTestInfo = test => {
return {
file: test.file,
specName: getTestTitle(test)
}
}
function clearCurrentTest () {
if (currentTest) {
const fullTitle = getTestTitle(currentTest)
debug('clearing current test "%s"', fullTitle)
restore(getTestInfo(currentTest))
currentTest = null
}
}
global.beforeEach(function () {
/* eslint-disable immutable/no-this */
if (hasOnly(this)) {
debug('skip pruning snapshots because found .only')
pruneSnapshots = noop
} else if (utils.isPruningDisabled()) {
debug('skip pruning snapshots by env variable')
pruneSnapshots = noop
} else {
debug('will prune snapshots because no .only')
pruneSnapshots = _pruneSnapshots
}
/* eslint-enable immutable/no-this */
})
global.beforeEach(function () {
/* eslint-disable immutable/no-this */
if (this.currentTest) {
setTest(this.currentTest)
}
/* eslint-enable immutable/no-this */
})
global.afterEach(clearCurrentTest)
function snapshot (value) {
if (!currentTest) {
throw new Error('Missing current test, cannot make snapshot')
}
const fullTitle = getTestTitle(currentTest)
la(
is.unemptyString(fullTitle),
'could not get full title from test',
currentTest
)
debug('snapshot in test "%s"', fullTitle)
debug('from file "%s"', currentTest.file)
const fullTitleParts = getTestTitleParts(currentTest)
la(
is.strings(fullTitleParts),
'could not get test title strings',
currentTest
)
debug('full title in parts %o', fullTitleParts)
// eslint-disable-next-line immutable/no-let
let savedTestTitle = fullTitle
let snapshotOptions = {}
if (isDataDriven(arguments)) {
// value is a function
debug('data-driven test for %s', value.name)
value = dataDriven(value, Array.from(arguments).slice(1))
savedTestTitle += ' ' + value.name
debug('extended save name to include function name')
debug('snapshot name "%s"', savedTestTitle)
} else if (isNamedSnapshotArguments(arguments)) {
savedTestTitle = arguments[0]
value = arguments[1]
// and there could be additional arguments for named snapshots
snapshotOptions = arguments[2] || {}
debug('named snapshots "%s"', savedTestTitle)
} else {
debug('snapshot value %j', value)
}
// grab options from environment variables
const envOptions = {
// TODO cast '0' as false id:6
// - <https://github.com/bahmutov/snap-shot-it/issues/376>
// Gleb Bahmutov
// gleb.bahmutov@gmail.com
show: Boolean(process.env.SNAPSHOT_SHOW),
dryRun: Boolean(process.env.SNAPSHOT_DRY),
update: utils.isUpdatingSnapshots(),
ci: Boolean(process.env.CI)
}
if ('SNAPSHOT_SORT' in process.env) {
if (process.env.SNAPSHOT_SORT === '0') {
envOptions.sortSnapshots = false
} else {
envOptions.sortSnapshots = Boolean(process.env.SNAPSHOT_SORT)
}
}
// note: we need to provide default values for ALL keys
const defaultOptions = {
show: false,
dryRun: false,
update: false,
ci: false,
sortSnapshots: false,
useRelativePath: false,
// callback functions that will be set later
'pre-compare': R.noop,
compare: R.noop,
store: R.noop
}
const packageConfigOptions = utils.getPackageConfigOptions(cwd)
const opts = utils.mergeConfigOptions(
defaultOptions,
packageConfigOptions,
envOptions
)
debug('environment options %o', envOptions)
debug('package config options %o', packageConfigOptions)
debug('merged options %o', opts)
// for pruning we only need a few options
pruneSnapshotsOptions = R.pick(['sortSnapshots', 'useRelativePath'], opts)
debug('prune options %o', pruneSnapshotsOptions)
const compare = opts.compare
? utils.load(cwd, opts.compare)
: require('snap-shot-compare')
const store = opts.store ? utils.load(cwd, opts.store) : null
const preCompare = opts['pre-compare']
? utils.load(cwd, opts['pre-compare'])
: R.identity
const what = preCompare(value)
const snap = {
what,
file: currentTest.file,
ext: EXTENSION,
compare,
store,
opts
}
if (isNamedSnapshotArguments(arguments)) {
// eslint-disable-next-line immutable/no-mutation
snap.exactSpecName = savedTestTitle
} else {
// eslint-disable-next-line immutable/no-mutation
snap.specName = savedTestTitle
}
const snapshotInfo = {
specFile: currentTest.file,
testTitle: fullTitle, // a single string like "suite test"
titleParts: fullTitleParts // list of titles like ["suite", "test"]
// just missing "key" which is determined in the snap-shot-core
}
let coreResult
try {
debug('about to compare')
coreResult = core(snap)
debug('core result %o', coreResult)
// there should be value and snapshot name (key)
la('value' in coreResult, 'core result should have value', coreResult)
la('key' in coreResult, 'core result should have key', coreResult)
const pruneInfo = R.assoc('key', coreResult.key, snapshotInfo)
pruneInfo.allowDuplicate = Boolean(snapshotOptions.allowSharedSnapshot)
debug('prune info %o', pruneInfo)
addToPrune(pruneInfo)
} catch (e) {
if (e.key) {
debug('value comparison exception')
// maybe it is due to a duplicate snapshot key?
// maybe two different tests save snapshots with same name like
//
// it('a', () => {
// snapshot('foo', 1)
// })
// it('b', () => {
// snapshot('foo', 42)
// })
//
// check if we have a collision
// the code is duplicate from above to get just the key collision error
const info = R.assoc('key', e.key, snapshotInfo)
info.allowDuplicate = Boolean(snapshotOptions.allowSharedSnapshot)
debug('current snapshot info %o', info)
const prevInfo = findExistingSnapshotKey(info)
if (prevInfo) {
debug('found duplicate snapshot name: %s', prevInfo.key)
console.error(
'Snapshot error was caused by the duplicate snapshot name'
)
throwDuplicateSnapshotKeyError(info, prevInfo)
}
}
debug('not a snapshot key collision')
throw e
}
return coreResult
}
/* eslint-disable immutable/no-mutation */
module.exports = snapshot
/* eslint-enable immutable/no-mutation */
global.after(function () {
/* eslint-disable immutable/no-this */
if (!hasFailed(this)) {
debug('the test run was a success')
la(
is.fn(pruneSnapshots),
'expected pruneSnapshots to be a function',
pruneSnapshots
)
pruneSnapshots.call(this)
} else {
debug('not attempting to prune snapshots because the test run has failed')
}
/* eslint-enable immutable/no-this */
})
function deleteFromCache () {
// to work with transpiled code, need to force
// re-evaluating this module again on watch
debug('deleting snap-shot-it from cache')
delete require.cache[__filename]
}
global.after(deleteFromCache)