UNPKG

@revoloo/cypress6

Version:

Cypress.io end to end testing tool

606 lines (488 loc) 16.9 kB
const _ = require('lodash') const Promise = require('bluebird') const $dom = require('../../dom') const $utils = require('../../cypress/utils') const $errUtils = require('../../cypress/error_utils') const returnFalseIfThenable = (key, ...args) => { if ((key === 'then') && _.isFunction(args[0]) && _.isFunction(args[1])) { // https://github.com/cypress-io/cypress/issues/111 // if we're inside of a promise then the promise lib will naturally // pass (at least) two functions to another cy.then // this works similar to the way mocha handles thenables. for instance // in coffeescript when we pass cypress commands within a Promise's // .then() because the value is the cypress instance means that // the Promise lib will attach a new .then internally. it would never // resolve unless we invoked it immediately, so we invoke it and // return false then ensuring the command is not queued args[0]() return false } } const primitiveToObject = (memo) => { if (_.isString(memo)) { return new String(memo) } if (_.isNumber(memo)) { return new Number(memo) } return memo } const getFormattedElement = ($el) => { if ($dom.isElement($el)) { return $dom.getElements($el) } return $el } module.exports = function (Commands, Cypress, cy, state) { // thens can return more "thenables" which are not resolved // until they're 'really' resolved, so naturally this API // supports nesting promises const thenFn = function (subject, userOptions, fn) { const ctx = state('ctx') if (_.isFunction(userOptions)) { fn = userOptions userOptions = {} } const options = _.defaults({}, userOptions, { timeout: cy.timeout(), }) // clear the timeout since we are handling // it ourselves cy.clearTimeout() // TODO: use subject from state("subject") const remoteSubject = cy.getRemotejQueryInstance(subject) let hasSpreadArray try { hasSpreadArray = subject && subject._spreadArray } catch (error) {} // eslint-disable-line no-empty let args = remoteSubject || subject args = hasSpreadArray ? args : [args] // name could be invoke or its! const name = state('current').get('name') const cleanup = () => { state('onInjectCommand', undefined) cy.removeListener('command:enqueued', enqueuedCommand) return null } let invokedCyCommand = false const enqueuedCommand = () => { invokedCyCommand = true return invokedCyCommand } state('onInjectCommand', returnFalseIfThenable) cy.once('command:enqueued', enqueuedCommand) // this code helps juggle subjects forward // the same way that promises work const current = state('current') const next = current.get('next') // TODO: this code may no longer be necessary // if the next command is chained to us then when it eventually // runs we need to reset the subject to be the return value of the // previous command so the subject is continuously juggled forward if (next && next.get('chainerId') === current.get('chainerId')) { const checkSubject = (newSubject, args) => { if (state('current') !== next) { return } // get whatever the previous commands return // value is. this likely does not match the 'var current' // command in the case of nested cy commands const s = next.get('prev').get('subject') // find the new subject and splice it out // with our existing subject const index = _.indexOf(args, newSubject) if (index > -1) { args.splice(index, 1, s) } return cy.removeListener('next:subject:prepared', checkSubject) } cy.on('next:subject:prepared', checkSubject) } const getRet = () => { let ret = fn.apply(ctx, args) if (cy.isCy(ret)) { ret = undefined } if (ret && invokedCyCommand && !ret.then) { $errUtils.throwErrByPath('then.callback_mixes_sync_and_async', { onFail: options._log, args: { value: $utils.stringify(ret) }, }) } return ret } return Promise .try(getRet) .timeout(options.timeout) .then((ret) => { // if ret is undefined then // resolve with the existing subject if (_.isUndefined(ret)) { return subject } return ret }).catch(Promise.TimeoutError, () => { return $errUtils.throwErrByPath('invoke_its.timed_out', { onFail: options._log, args: { cmd: name, timeout: options.timeout, func: fn.toString(), }, }) }) .finally(cleanup) } const invokeItsFn = (subject, str, userOptions, ...args) => { return invokeBaseFn(userOptions || { log: true }, subject, str, ...args) } const invokeFn = (subject, userOptionsOrStr, ...args) => { const userOptionsPassed = _.isObject(userOptionsOrStr) && !_.isFunction(userOptionsOrStr) let userOptions = null let str = null if (!userOptionsPassed) { str = userOptionsOrStr userOptions = { log: true } } else { userOptions = userOptionsOrStr if (args.length > 0) { str = args[0] args = args.slice(1) } } return invokeBaseFn(userOptions, subject, str, ...args) } const invokeBaseFn = (userOptions, subject, str, ...args) => { const name = state('current').get('name') const isCmdIts = name === 'its' const isCmdInvoke = name === 'invoke' const getMessage = () => { if (isCmdIts) { return `.${str}` } return `.${str}(${$utils.stringify(args)})` } // to allow the falsy value 0 to be used const isProp = (str) => { return !!str || (str === 0) } const message = getMessage() let traversalErr = null // copy userOptions because _log is added below. const options = _.extend({}, userOptions) if (options.log) { options._log = Cypress.log({ message, $el: $dom.isElement(subject) ? subject : null, timeout: options.timeout, consoleProps () { return { Subject: subject } }, }) } // check for false positive (negative?) with 0 given as index if (!isProp(str)) { $errUtils.throwErrByPath('invoke_its.null_or_undefined_property_name', { onFail: options._log, args: { cmd: name, identifier: isCmdIts ? 'property' : 'function' }, }) } if (!_.isString(str) && !_.isNumber(str)) { $errUtils.throwErrByPath('invoke_its.invalid_prop_name_arg', { onFail: options._log, args: { cmd: name, identifier: isCmdIts ? 'property' : 'function' }, }) } if (!_.isObject(userOptions) || _.isFunction(userOptions)) { $errUtils.throwErrByPath('invoke_its.invalid_options_arg', { onFail: options._log, args: { cmd: name }, }) } if (isCmdIts && args && args.length > 0) { $errUtils.throwErrByPath('invoke_its.invalid_num_of_args', { onFail: options._log, args: { cmd: name }, }) } const propertyNotOnSubjectErr = (prop) => { return $errUtils.cypressErrByPath('invoke_its.nonexistent_prop', { args: { prop, cmd: name, }, }) } const propertyValueNullOrUndefinedErr = (prop, value) => { const errMessagePath = isCmdIts ? 'its' : 'invoke' return $errUtils.cypressErrByPath(`${errMessagePath}.null_or_undefined_prop_value`, { args: { prop, value, }, cmd: name, }) } const subjectNullOrUndefinedErr = (prop, value) => { const errMessagePath = isCmdIts ? 'its' : 'invoke' return $errUtils.cypressErrByPath(`${errMessagePath}.subject_null_or_undefined`, { args: { prop, cmd: name, value, }, }) } const propertyNotOnPreviousNullOrUndefinedValueErr = (prop, value, previousProp) => { return $errUtils.cypressErrByPath('invoke_its.previous_prop_null_or_undefined', { args: { prop, value, previousProp, cmd: name, }, }) } const traverseObjectAtPath = (acc, pathsArray, index = 0) => { // traverse at this depth const prop = pathsArray[index] const previousProp = pathsArray[index - 1] const valIsNullOrUndefined = _.isNil(acc) // if we're attempting to tunnel into // a null or undefined object... if (isProp(prop) && valIsNullOrUndefined) { if (index === 0) { // give an error stating the current subject is nil traversalErr = subjectNullOrUndefinedErr(prop, acc) } else { // else refer to the previous property so users know which prop // caused us to hit this dead end traversalErr = propertyNotOnPreviousNullOrUndefinedValueErr(prop, acc, previousProp) } return acc } // if we have no more properties to traverse if (!isProp(prop)) { if (valIsNullOrUndefined) { // set traversal error that the final value is null or undefined traversalErr = propertyValueNullOrUndefinedErr(previousProp, acc) } // finally return the reduced traversed accumulator here return acc } // attempt to lookup this property on the acc // if our property does not exist then allow // undefined to pass through but set the traversalErr // since if we don't have any assertions we want to // provide a very specific error message and not the // generic existence one if (!(prop in primitiveToObject(acc))) { traversalErr = propertyNotOnSubjectErr(prop) return undefined } // if we succeeded then continue to traverse return traverseObjectAtPath(acc[prop], pathsArray, index + 1) } const getSettledValue = (value, subject, propAtLastPath) => { if (isCmdIts) { return value } if (_.isFunction(value)) { return value.apply(subject, args) } // TODO: this logic should likely be part of // traverseObjectAtPath(...) rather be further // away from the handling of traversals. this // causes us to need to separately handle // the 'propAtLastPath' argument since we're // outside of the reduced accumulator. // if we're not a function and we have a traversal // error then throw it now - since that provide a // more specific error regarding non-existant // properties or null or undefined values if (traversalErr) { throw traversalErr } // else throw that prop isn't a function $errUtils.throwErrByPath('invoke.prop_not_a_function', { onFail: options._log, args: { prop: propAtLastPath, type: $utils.stringifyFriendlyTypeof(value), }, }) } const getValue = () => { // reset this on each go around so previous errors // don't leak into new failures or upcoming assertion errors traversalErr = null const remoteSubject = cy.getRemotejQueryInstance(subject) const actualSubject = remoteSubject || subject let paths = _.isString(str) ? str.split('.') : [str] const prop = traverseObjectAtPath(actualSubject, paths) const value = getSettledValue(prop, actualSubject, _.last(paths)) if (options._log) { options._log.set({ consoleProps () { const obj = {} if (isCmdInvoke) { obj['Function'] = message if (args.length) { obj['With Arguments'] = args } } else { obj['Property'] = message } _.extend(obj, { Subject: getFormattedElement(actualSubject), Yielded: getFormattedElement(value), }) return obj }, }) } return value } // by default we want to only add the default assertion // of ensuring existence for cy.its() not cy.invoke() because // invoking a function can legitimately return null or undefined const ensureExistenceFor = isCmdIts ? 'subject' : false // wrap retrying into its own // separate function const retryValue = () => { return Promise .try(getValue) .catch((err) => { options.error = err return cy.retry(retryValue, options) }) } const resolveValue = () => { return Promise .try(retryValue) .then((value) => { return cy.verifyUpcomingAssertions(value, options, { ensureExistenceFor, onRetry: resolveValue, onFail () { // if we failed our upcoming assertions and also // exited early out of getting the value of our // subject then reset the error to this one if (traversalErr) { options.error = traversalErr } }, }) }) } return resolveValue() } Commands.addAll({ prevSubject: true }, { spread (subject, options, fn) { // if this isnt an array-like blow up right here if (!_.isArrayLike(subject)) { $errUtils.throwErrByPath('spread.invalid_type') } subject._spreadArray = true return thenFn(subject, options, fn) }, each (subject, options, fn) { let userOptions = options const ctx = this if (_.isUndefined(fn)) { fn = userOptions userOptions = {} } if (!_.isFunction(fn)) { $errUtils.throwErrByPath('each.invalid_argument') } const nonArray = () => { return $errUtils.throwErrByPath('each.non_array', { args: { subject: $utils.stringify(subject) }, }) } try { if (!('length' in subject)) { nonArray() } } catch (e) { nonArray() } if (subject.length === 0) { return subject } // if we have a next command then we need to // slice in this existing subject as its subject // due to the way we queue promises const next = state('current').get('next') if (next) { const checkSubject = (newSubject, args, firstCall) => { if (state('current') !== next) { return } // https://github.com/cypress-io/cypress/issues/4921 // When dual commands like contains() is used as the firstCall (cy.contains() style), // we should not prepend subject. if (!firstCall) { // find the new subject and splice it out // with our existing subject const index = _.indexOf(args, newSubject) if (index > -1) { args.splice(index, 1, subject) } } return cy.removeListener('next:subject:prepared', checkSubject) } cy.on('next:subject:prepared', checkSubject) } let endEarly = false const yieldItem = (el, index) => { if (endEarly) { return } if ($dom.isElement(el)) { el = $dom.wrap(el) } const callback = () => { const ret = fn.call(ctx, el, index, subject) // if the return value is false then return early if (ret === false) { endEarly = true } return ret } return thenFn(el, userOptions, callback, state) } // generate a real array since bluebird is finicky and // doesnt want an 'array-like' structure like jquery instances // need to take into account regular arrays here by first checking // if its an array instance return Promise .each(_.toArray(subject), yieldItem) .return(subject) }, }) // temporarily keeping this as a dual command // but it will move to a child command once // cy.resolve + cy.wrap are upgraded to handle // promises Commands.addAll({ prevSubject: 'optional' }, { then () { // eslint-disable-next-line prefer-rest-params return thenFn.apply(this, arguments) }, }) Commands.addAll({ prevSubject: true }, { // making this a dual command due to child commands // automatically returning their subject when their // return values are undefined. prob should rethink // this and investigate why that is the default behavior // of child commands invoke (subject, optionsOrStr, ...args) { return invokeFn.apply(this, [subject, optionsOrStr, ...args]) }, its (subject, str, options, ...args) { return invokeItsFn.apply(this, [subject, str, options, ...args]) }, }) }