@revoloo/cypress6
Version:
Cypress.io end to end testing tool
529 lines (419 loc) • 13.6 kB
JavaScript
const _ = require('lodash')
const Promise = require('bluebird')
const $dom = require('../dom')
const $errUtils = require('../cypress/error_utils')
// TODO
// bTagOpen + bTagClosed
// are duplicated in assertions.coffee
const butRe = /,? but\b/
const bTagOpen = /\*\*/g
const bTagClosed = /\*\*/g
const stackTracesRe = / at .*\n/gm
const IS_DOM_TYPES = [$dom.isElement, $dom.isDocument, $dom.isWindow]
const invokeWith = (value) => {
return (fn) => {
return fn(value)
}
}
const functionHadArguments = (current) => {
const fn = current && current.get('args') && current.get('args')[0]
return fn && _.isFunction(fn) && (fn.length > 0)
}
const isAssertionType = (cmd) => {
return cmd && cmd.is('assertion')
}
const isDomSubjectAndMatchesValue = (value, subject) => {
const allElsAreTheSame = () => {
const els1 = $dom.getElements(value)
const els2 = $dom.getElements(subject)
// no difference
return _.difference(els1, els2).length === 0
}
// iterate through each dom type until we
// find the function for this particular value
const isDomTypeFn = _.find(IS_DOM_TYPES, invokeWith(value))
if (isDomTypeFn) {
// then check that subject also matches this
// and that all the els are the same
return isDomTypeFn(subject) && allElsAreTheSame()
}
}
// Rules:
// 1. always remove value
// 2. if value is a jquery object set a subject
// 3. if actual is undefined or its not expected remove both actual + expected
const parseValueActualAndExpected = (value, actual, expected) => {
const obj = { actual, expected }
if ($dom.isJquery(value)) {
obj.subject = value
if (_.isUndefined(actual) || (actual !== expected)) {
delete obj.actual
delete obj.expected
}
}
return obj
}
const create = function (Cypress, cy) {
const getUpcomingAssertions = () => {
const index = cy.state('index') + 1
const assertions = []
// grab the rest of the queue'd commands
for (let cmd of cy.queue.slice(index).get()) {
// don't break on utilities, just skip over them
if (cmd.is('utility')) {
continue
}
// grab all of the queued commands which are
// assertions and match our current chainerId
if (cmd.is('assertion')) {
assertions.push(cmd)
} else {
break
}
}
return assertions
}
const injectAssertionFns = (cmds) => {
return _.map(cmds, injectAssertion)
}
const injectAssertion = (cmd) => {
return ((subject) => {
// set assertions to itself or empty array
if (!cmd.get('assertions')) {
cmd.set('assertions', [])
}
// reset the assertion index back to 0
// so we can track assertions and merge
// them up with existing ones
cmd.set('assertionIndex', 0)
if (cy.state('current') != null) {
cy.state('current').set('currentAssertionCommand', cmd)
}
return cmd.get('fn').originalFn.apply(
cy.state('ctx'),
[subject].concat(cmd.get('args')),
)
})
}
const finishAssertions = (assertions) => {
return _.each(assertions, (log) => {
log.snapshot()
const e = log.get('_error')
if (e) {
return log.error(e)
}
return log.end()
})
}
const verifyUpcomingAssertions = function (subject, options = {}, callbacks = {}) {
const cmds = getUpcomingAssertions()
cy.state('upcomingAssertions', cmds)
// we're applying the default assertion in the
// case where there are no upcoming assertion commands
const isDefaultAssertionErr = cmds.length === 0
if (options.assertions == null) {
options.assertions = []
}
_.defaults(callbacks, {
ensureExistenceFor: 'dom',
})
const ensureExistence = () => {
// by default, ensure existence for dom subjects,
// but not non-dom subjects
switch (callbacks.ensureExistenceFor) {
case 'dom': {
const $el = determineEl(options.$el, subject)
if (!$dom.isJquery($el)) {
return
}
return cy.ensureElExistence($el)
}
case 'subject':
return cy.ensureExistence(subject)
default:
return
}
}
const determineEl = ($el, subject) => {
// prefer $el unless it is strickly undefined
if (!_.isUndefined($el)) {
return $el
}
return subject
}
const onPassFn = () => {
if (_.isFunction(callbacks.onPass)) {
return callbacks.onPass.call(this, cmds, options.assertions)
}
return subject
}
const onFailFn = (err) => {
// when we fail for whatever reason we need to
// check to see if we would firstly fail if
// we don't have an el in existence. what this
// catches are assertions downstream about an
// elements existence but the element never
// exists in the first place. this will probably
// ensure the error is about existence not about
// the downstream assertion.
try {
ensureExistence()
} catch (e2) {
err = e2
}
err.isDefaultAssertionErr = isDefaultAssertionErr
options.error = err
if (err.retry === false) {
throw err
}
const { onFail, onRetry } = callbacks
if (!onFail && !onRetry) {
throw err
}
// if our onFail throws then capture it
// and finish the assertions and then throw
// it again
try {
if (_.isFunction(onFail)) {
// pass in the err and the upcoming assertion commands
onFail.call(this, err, isDefaultAssertionErr, cmds)
}
} catch (e3) {
finishAssertions(options.assertions)
throw e3
}
if (_.isFunction(onRetry)) {
return cy.retry(onRetry, options)
}
}
// bail if we have no assertions and apply
// the default assertions if applicable
if (!cmds.length) {
return Promise
.try(ensureExistence)
.then(onPassFn)
.catch(onFailFn)
}
let i = 0
const cmdHasFunctionArg = (cmd) => {
return _.isFunction(cmd.get('args')[0])
}
const overrideAssert = function (...args) {
let cmd = cmds[i]
const setCommandLog = (log) => {
// our next log may not be an assertion
// due to page events so make sure we wait
// until we see page events
if (log.get('name') !== 'assert') {
return
}
// when we do immediately unbind this function
cy.state('onBeforeLog', null)
const insertNewLog = (log) => {
cmd.log(log)
return options.assertions.push(log)
}
// its possible a single 'should' will assert multiple
// things such as the case with have.property. we can
// detect when that is happening because cmd will be null.
// if thats the case then just set cmd to be the previous
// cmd and do not increase 'i'
// this will prevent 2 logs from ever showing up but still
// provide errors when the 1st assertion fails.
if (!cmd) {
cmd = cmds[i - 1]
} else {
i += 1
}
// if our command has a function argument
// then we know it may contain multiple
// assertions
if (cmdHasFunctionArg(cmd)) {
let index = cmd.get('assertionIndex')
let assertions = cmd.get('assertions')
// https://github.com/cypress-io/cypress/issues/4981
// `assertions` is undefined because assertions added by
// `should` command are not handled yet.
// So, don't increase i and go back to the last command.
if (!assertions) {
i -= 1
cmd = cmds[i - 1]
index = cmd.get('assertionIndex')
assertions = cmd.get('assertions')
}
// always increase the assertionIndex
// so our next assertion matches up
// to the correct index
const incrementIndex = () => {
return cmd.set('assertionIndex', index += 1)
}
// if we dont have an assertion at this
// index then insert a new log
const assertion = assertions[index]
if (!assertion) {
assertions.push(log)
incrementIndex()
return insertNewLog(log)
}
// else just merge this log
// into the previous assertion log
incrementIndex()
assertion.merge(log)
// dont output a new log
return false
}
// if we already have a log
// then just update its attrs from
// the new log
const l = cmd.getLastLog()
if (l) {
l.merge(log)
// and make sure we return false
// which prevents a new log from
// being added
return false
}
return insertNewLog(log)
}
cy.state('onBeforeLog', setCommandLog)
// send verify=true as the last arg
return assertFn.apply(this, args.concat(true))
}
const fns = injectAssertionFns(cmds)
const subjects = []
// iterate through each subject
// and force the assertion to return
// this value so it does not get
// invoked again
const setSubjectAndSkip = () => {
subjects.forEach((subject, i) => {
const cmd = cmds[i]
cmd.set('subject', subject)
cmd.skip()
})
return cmds
}
const assertions = (memo, fn, i) => {
// HACK: bluebird .reduce will not call the callback
// if given an undefined initial value, so in order to
// support undefined subjects, we wrap the initial value
// in an Array and unwrap it if index = 0
if (i === 0) {
memo = memo[0]
}
return fn(memo).then((subject) => {
return subjects[i] = subject
})
}
const restore = () => {
cy.state('upcomingAssertions', [])
// no matter what we need to
// restore the assert fn
return cy.state('overrideAssert', undefined)
}
// store this in case our test ends early
// and we reset between tests
cy.state('overrideAssert', overrideAssert)
return Promise
.reduce(fns, assertions, [subject])
.then(() => {
restore()
setSubjectAndSkip()
finishAssertions(options.assertions)
return onPassFn()
})
.catch((err) => {
restore()
// when we're told not to retry
if (err.retry === false) {
// finish the assertions
finishAssertions(options.assertions)
// and then push our command into this err
try {
$errUtils.throwErr(err, { onFail: options._log })
} catch (e) {
err = e
}
}
throw err
})
.catch(onFailFn)
}
const assertFn = (passed, message, value, actual, expected, error, verifying = false) => {
// slice off everything after a ', but' or ' but ' for passing assertions, because
// otherwise it doesn't make sense:
// "expected <div> to have a an attribute 'href', but it was 'href'"
if (message && passed && butRe.test(message)) {
message = message.substring(0, message.search(butRe))
}
if (value && value.isSinonProxy) {
message = message.replace(stackTracesRe, '\n')
}
let obj = parseValueActualAndExpected(value, actual, expected)
if ($dom.isElement(value)) {
obj.$el = $dom.wrap(value)
}
// if we are simply verifying the upcoming
// assertions then do not immediately end or snapshot
// else do
if (verifying) {
obj._error = error
} else {
obj.end = true
obj.snapshot = true
obj.error = error
}
const isChildLike = (subject, current) => {
return (value === subject) ||
isDomSubjectAndMatchesValue(value, subject) ||
// if our current command is an assertion type
isAssertionType(current) ||
// are we currently verifying assertions?
(cy.state('upcomingAssertions') && cy.state('upcomingAssertions').length > 0) ||
// did the function have arguments
functionHadArguments(current)
}
_.extend(obj, {
name: 'assert',
// end: true
// snapshot: true
message,
passed,
selector: value ? value.selector : undefined,
timeout: 0,
type (current, subject) {
// if our current command has arguments assume
// we are an assertion that's involving the current
// subject or our value is the current subject
return isChildLike(subject, current) ? 'child' : 'parent'
},
consoleProps: () => {
obj = { Command: 'assert' }
_.extend(obj, parseValueActualAndExpected(value, actual, expected))
return _.extend(obj,
{ Message: message.replace(bTagOpen, '').replace(bTagClosed, '') })
},
})
// think about completely gutting the whole object toString
// which chai does by default, its so ugly and worthless
if (error) {
error.onFail = (err) => { }
}
Cypress.log(obj)
return null
}
const assert = function (...args) {
// if we've temporarily overriden assertions
// then just bail early with this function
const fn = cy.state('overrideAssert') || assertFn
return fn.apply(this, args)
}
return {
finishAssertions,
verifyUpcomingAssertions,
assert,
}
}
module.exports = {
create,
}