@revoloo/cypress6
Version:
Cypress.io end to end testing tool
444 lines (354 loc) • 10.6 kB
JavaScript
const _ = require('lodash')
// if we're in Cypress, we'll need to swap this with Cypress.sinon later
let sinon = require('sinon')
const Debug = require('debug')
const chalk = require('chalk')
const stripAnsi = require('strip-ansi')
const { stripIndent } = require('common-tags')
const { printVar, stringifyShort, isObject, addPluginButton, fmt, typeColors } = require('./snapshotUtils')
const debug = Debug('plugin:snapshot')
/**
* prints nice assertion error in command log with modified error message
*/
function throwErr (e, message, exp, ctx) {
try {
ctx.assert(false, message, 'sdf', exp, e.act, true)
} catch (err) {
err.message += `\n\n**- expected + actual:**\n${e.message}`
throw err
}
}
function getMatchDeepMessage (act, exp) {
return `Expected **${chai.util.objDisplay(act)}** to deep match: **${chai.util.objDisplay(exp)}**`
}
function saveSnapshot (ctx, exactSpecName, file, exp, act) {
const message = !exp ? 'new snapshot saved' : 'snapshot updated'
ctx.assert(true, `📸 ${message}: **${exactSpecName}**`, '', exp, act)
return cy.task('saveSnapshot', {
file,
what: act,
exactSpecName,
}, { log: false })
}
const registerInCypress = () => {
// need to use correct sinon version for matcher.isMatcher to work
sinon = Cypress.sinon
const $ = Cypress.$
let snapshotIndex = {}
chai = window.chai
chai.Assertion.addMethod('matchDeep', matchDeepCypress)
chai.Assertion.addMethod('matchSnapshot', matchSnapshotCypress)
after(() => {
snapshotIndex = {}
})
before(() => {
addPluginButton($, 'toggle-snapshot-update', '', {
render () {
const btnIcon = $(this).children().first()
return btnIcon.text(top.SNAPSHOT_UPDATE ? 'snapshot\nupdate\non' : 'snapshot\nupdate\noff')
.css({ 'font-size': '10px', 'line-height': '0.9' })
.html(btnIcon.html().replace(/\n/g, '<br/>'))
},
click () {
top.SNAPSHOT_UPDATE = !top.SNAPSHOT_UPDATE
},
})
})
function matchDeepCypress (...args) {
const exp = args[1] || args[0]
const ctx = this
try {
const res = matchDeep.apply(this, [args[0], args[1], { Cypress, expectedOnly: true }])
const message = getMatchDeepMessage(res.act, exp)
ctx.assert(true, message)
Cypress.log({
name: 'assert',
message,
state: 'passed',
consoleProps: () => {
return {
Actual: res.act,
}
},
})
} catch (e) {
throwErr(
e,
getMatchDeepMessage(e.act, args[1] || args[0]),
exp,
ctx,
)
}
}
function matchSnapshotCypress (m, snapshotName) {
const ctx = this
const file = Cypress.spec.name
const testName = Cypress.mocha.getRunner().test.fullTitle()
return cy.then(() => {
snapshotIndex[testName] = (snapshotIndex[testName] || 1)
const exactSpecName = snapshotName || `${testName} #${snapshotIndex[testName]}`
return cy.task('getSnapshot', {
file,
exactSpecName,
}, { log: false })
.then(function (exp) {
try {
snapshotIndex[testName] = snapshotIndex[testName] + 1
const res = matchDeep.call(ctx, m, exp, { message: 'to match snapshot', Cypress, isSnapshot: true, sinon })
ctx.assert(true, `snapshot matched: **${exactSpecName}**`, res.act)
} catch (e) {
if (!e.known) {
throw e
}
// save snapshot if env var or no previously saved snapshot (and no failed matcher assertions)
if ((top.SNAPSHOT_UPDATE || !exp) && !e.failedMatcher && e.act) {
return saveSnapshot(ctx, exactSpecName, file, exp, e.act)
}
throwErr(e, `**snapshot failed to match**: ${exactSpecName}`, exp, ctx)
}
})
})
}
}
const matcherStringToObj = (mes) => {
const res = mes.replace(/typeOf\("(\w+)"\)/, '$1')
const ret = {}
ret.toString = () => {
return `${res}`
}
ret.toJSON = () => {
return `match.${res}`
}
return ret
}
const matchDeep = function (matchers, exp, optsArg) {
let m = matchers
if (exp === undefined) {
exp = m
m = {}
}
const opts = _.defaults(optsArg, {
message: 'to match',
Cypress: false,
diff: true,
expectedOnly: false,
sinon: null,
})
if (!opts.sinon) {
opts.sinon = sinon
}
const match = opts.sinon.match
const isAnsi = !opts.Cypress
const act = this._obj
m = _.map(m, (val, key) => {
return [key.split('.'), val]
})
const diffStr = withMatchers(m, match, opts.expectedOnly)(exp, act)
if (diffStr.changed) {
let e = _.extend(new Error(), { known: true, act: diffStr.act, failedMatcher: diffStr.opts.failedMatcher })
e.message = isAnsi ? `\n${diffStr.text}` : stripAnsi(diffStr.text)
if (_.isString(act)) {
e.message = `\n${stripIndent`
SnapshotError: Failed to match snapshot
Expected:\n---\n${printVar(exp)}\n---
Actual:\n---\n${printVar(diffStr.act)}\n---
`}`
}
throw e
}
return diffStr
}
const parseMatcherFromString = (matcher) => {
const regex = /match\.(.*)/
if (_.isString(matcher)) {
const parsed = regex.exec(matcher)
if (parsed) {
return parsed[1]
}
}
}
function parseMatcherFromObj (obj, match) {
if (match.isMatcher(obj)) {
return obj
}
const objStr = (_.isString(obj) && obj) || (obj && obj.toJSON && obj.toJSON())
if (objStr) {
const parsed = parseMatcherFromString(objStr)
if (parsed) {
return match[parsed]
}
}
return obj
}
function setReplacement (act, val, path) {
if (_.isFunction(val)) {
return val(act, path)
}
return val
}
const withMatchers = (matchers, match, expectedOnly = false) => {
const getReplacementFor = (path = [], m) => {
for (let rep of m) {
const wildCards = _.keys(_.pickBy(rep[0], (value) => {
return value === '*'
}))
const _path = _.map(path, (value, key) => {
if (_.includes(wildCards, `${key}`)) {
return '*'
}
return value
})
const matched = _path.join('.').endsWith(rep[0].join('.'))
if (matched) {
return rep[1]
}
}
return NO_REPLACEMENT
}
const testValue = (matcher, value) => {
if (matcher.test(value)) {
return true
}
return false
}
const NO_REPLACEMENT = {}
/**
* diffing function that produces human-readable diff output.
* unfortunately it is also unreadable code in itself.
*/
const diff = (exp, act, path = ['^'], optsArg) => {
const opts = _.defaults({}, optsArg, {
expectedOnly,
})
if (path.length > 15) {
throw new Error(`exceeded max depth on ${path.slice(0, 4)} ... ${path.slice(-4)}`)
}
let text = ''
let changed = false
let itemDiff
let keys
let subOutput = ''
let replacement = getReplacementFor(path, matchers)
if (replacement !== NO_REPLACEMENT) {
if (match.isMatcher(replacement)) {
if (testValue(replacement, act)) {
act = matcherStringToObj(replacement.message).toJSON()
} else {
opts.failedMatcher = true
if (!_.isFunction(act)) {
act = _.clone(act)
}
exp = replacement
}
} else {
act = setReplacement(act, replacement, path)
}
} else {
if (!_.isFunction(act) && !_.isFunction(_.get(act, 'toJSON'))) {
act = _.clone(act)
}
exp = parseMatcherFromObj(exp, match)
if (match.isMatcher(exp)) {
if (testValue(exp, act)) {
act = matcherStringToObj(exp.message).toJSON()
return {
text: '',
changed: false,
act,
}
}
return {
text: fmt.wrap('failed', `${chalk.green(printVar(act))} ⛔ ${matcherStringToObj(exp.message).toJSON()}`),
changed: true,
act,
}
}
}
if (_.isFunction(_.get(act, 'toJSON'))) {
act = act.toJSON()
}
if (isObject(exp) && isObject(act) && !match.isMatcher(exp)) {
keys = _.keysIn(exp)
let actObj = _.extend({}, act)
let key
if (_.isArray(exp)) {
keys.sort((a, b) => +a - +b)
} else {
keys.sort()
}
for (let i = 0; i < keys.length; i++) {
key = keys[i]
const isUndef = exp[key] === undefined
if (_.hasIn(act, key) || isUndef) {
itemDiff = diff(exp[key], act[key], path.concat([key]))
_.defaults(opts, itemDiff.opts)
act[key] = itemDiff.act
if (itemDiff.changed) {
subOutput += fmt.keyChanged(key, itemDiff.text)
changed = true
}
} else {
subOutput += fmt.keyRemoved(key, exp[key])
changed = true
}
delete actObj[key]
}
let addedKeys = _.keysIn(actObj)
if (!opts.expectedOnly) {
for (let i = 0; i < addedKeys.length; i++) {
const key = addedKeys[i]
const val = act[key]
const addDiff = diff(val, val, path.concat([key]))
_.defaults(opts, addDiff.opts)
act[key] = addDiff.act
if (act[key] === undefined) continue
if (opts.failedMatcher) {
subOutput += addDiff.text
} else {
subOutput += fmt.keyAdded(key, act[key])
}
changed = true
}
}
if (changed) {
text = fmt.wrapObjectLike(exp, act, subOutput)
}
} else if (match.isMatcher(exp)) {
debug('is matcher')
if (!testValue(exp, act)) {
text = fmt.wrap('failed', `${chalk.green(printVar(act))} ⛔ ${matcherStringToObj(exp.message).toJSON()}`)
changed = true
}
} else if (isObject(act)) {
debug('only act is obj')
const addDiff = diff({}, act, path, { expectedOnly: false })
_.defaults(opts, addDiff.opts)
return _.extend({},
addDiff, {
changed: true,
text: fmt.wrap('removed', `${printVar(exp)}\n${fmt.wrap('added', addDiff.text)}`),
})
} else {
debug('neither is obj')
exp = printVar(exp)
act = printVar(act)
if (exp !== act) {
text = fmt.wrap('modified', `${exp} ${typeColors['normal']('⮕')} ${act}`)
changed = true
}
}
return {
changed,
text,
act,
opts,
}
}
return diff
}
module.exports = {
registerInCypress,
matchDeep,
stringifyShort,
parseMatcherFromString,
}