UNPKG

@revoloo/cypress6

Version:

Cypress.io end to end testing tool

1,424 lines (1,126 loc) 41.8 kB
/* eslint-disable prefer-rest-params */ const _ = require('lodash') const $ = require('jquery') const Promise = require('bluebird') const $dom = require('../dom') const $utils = require('./utils') const $errUtils = require('./error_utils') const $stackUtils = require('./stack_utils') const $Chai = require('../cy/chai') const $Xhrs = require('../cy/xhrs') const $jQuery = require('../cy/jquery') const $Aliases = require('../cy/aliases') const $Events = require('./events') const $Errors = require('../cy/errors') const $Ensures = require('../cy/ensures') const $Focused = require('../cy/focused') const $Mouse = require('../cy/mouse') const $Keyboard = require('../cy/keyboard') const $Location = require('../cy/location') const $Assertions = require('../cy/assertions') const $Listeners = require('../cy/listeners') const $Chainer = require('./chainer') const $Timers = require('../cy/timers') const $Timeouts = require('../cy/timeouts') const $Retries = require('../cy/retries') const $Stability = require('../cy/stability') const $selection = require('../dom/selection') const $Snapshots = require('../cy/snapshots') const $CommandQueue = require('./command_queue') const $VideoRecorder = require('../cy/video-recorder') const $TestConfigOverrides = require('../cy/testConfigOverrides') const { registerFetch } = require('unfetch') const noArgsAreAFunction = (args) => { return !_.some(args, _.isFunction) } const isPromiseLike = (ret) => { return ret && _.isFunction(ret.then) } const returnedFalse = (result) => { return result === false } const getContentWindow = ($autIframe) => { return $autIframe.prop('contentWindow') } const setWindowDocumentProps = function (contentWindow, state) { state('window', contentWindow) return state('document', contentWindow.document) } const setRemoteIframeProps = ($autIframe, state) => { return state('$autIframe', $autIframe) } function __stackReplacementMarker (fn, ctx, args) { return fn.apply(ctx, args) } // We only set top.onerror once since we make it configurable:false // but we update cy instance every run (page reload or rerun button) let curCy = null const setTopOnError = function (cy) { if (curCy) { curCy = cy return } curCy = cy // prevent overriding top.onerror twice when loading more than one // instance of test runner. if (top.onerror && top.onerror.isCypressHandler) { return } const onTopError = function () { return curCy.onUncaughtException.apply(curCy, arguments) } onTopError.isCypressHandler = true top.onerror = onTopError // Prevent Mocha from setting top.onerror which would override our handler // Since the setter will change which event handler gets invoked, we make it a noop return Object.defineProperty(top, 'onerror', { set () {}, get () { return onTopError }, configurable: false, enumerable: true, }) } // NOTE: this makes the cy object an instance // TODO: refactor the 'create' method below into this class class $Cy {} const create = function (specWindow, Cypress, Cookies, state, config, log) { let cy = new $Cy() let stopped = false const commandFns = {} const isStopped = () => { return stopped } const onFinishAssertions = function () { return assertions.finishAssertions.apply(window, arguments) } const warnMixingPromisesAndCommands = function () { const title = state('runnable').fullTitle() $errUtils.warnByPath('miscellaneous.mixing_promises_and_commands', { args: { title }, }) } const $$ = function (selector, context) { if (context == null) { context = state('document') } return $dom.query(selector, context) } const queue = $CommandQueue.create() $VideoRecorder.create(Cypress) const timeouts = $Timeouts.create(state) const stability = $Stability.create(Cypress, state) const retries = $Retries.create(Cypress, state, timeouts.timeout, timeouts.clearTimeout, stability.whenStable, onFinishAssertions) const assertions = $Assertions.create(Cypress, cy) const jquery = $jQuery.create(state) const location = $Location.create(state) const focused = $Focused.create(state) const keyboard = $Keyboard.create(Cypress, state) const mouse = $Mouse.create(state, keyboard, focused, Cypress) const timers = $Timers.create() const { expect } = $Chai.create(specWindow, state, assertions.assert) const xhrs = $Xhrs.create(state) const aliases = $Aliases.create(cy) const errors = $Errors.create(state, config, log) const ensures = $Ensures.create(state, expect) const snapshots = $Snapshots.create($$, state) const testConfigOverrides = $TestConfigOverrides.create() const isCy = (val) => { return (val === cy) || $utils.isInstanceOf(val, $Chainer) } const runnableCtx = function (name) { ensures.ensureRunnable(name) return state('runnable').ctx } const urlNavigationEvent = (event) => { return Cypress.action('app:navigation:changed', `page navigation event (${event})`) } const contentWindowListeners = function (contentWindow) { return $Listeners.bindTo(contentWindow, { onError () { // use a function callback here instead of direct // reference so our users can override this function // if need be return cy.onUncaughtException.apply(cy, arguments) }, onSubmit (e) { return Cypress.action('app:form:submitted', e) }, onBeforeUnload (e) { stability.isStable(false, 'beforeunload') Cookies.setInitial() timers.reset() Cypress.action('app:window:before:unload', e) // return undefined so our beforeunload handler // doesnt trigger a confirmation dialog return undefined }, onUnload (e) { return Cypress.action('app:window:unload', e) }, onNavigation (...args) { return Cypress.action('app:navigation:changed', ...args) }, onAlert (str) { return Cypress.action('app:window:alert', str) }, onConfirm (str) { const results = Cypress.action('app:window:confirm', str) // return false if ANY results are false // else true const ret = !_.some(results, returnedFalse) Cypress.action('app:window:confirmed', str, ret) return ret }, }) } const wrapNativeMethods = function (contentWindow) { try { // return null to trick contentWindow into thinking // its not been iframed if modifyObstructiveCode is true if (config('modifyObstructiveCode')) { Object.defineProperty(contentWindow, 'frameElement', { get () { return null }, }) } contentWindow.HTMLElement.prototype.focus = function (focusOption) { return focused.interceptFocus(this, contentWindow, focusOption) } contentWindow.HTMLElement.prototype.blur = function () { return focused.interceptBlur(this) } contentWindow.SVGElement.prototype.focus = function (focusOption) { return focused.interceptFocus(this, contentWindow, focusOption) } contentWindow.SVGElement.prototype.blur = function () { return focused.interceptBlur(this) } contentWindow.HTMLInputElement.prototype.select = function () { return $selection.interceptSelect.call(this) } contentWindow.document.hasFocus = function () { return focused.documentHasFocus.call(this) } const cssModificationSpy = function (original, ...args) { snapshots.onCssModified(this.href) return original.apply(this, args) } const { insertRule } = contentWindow.CSSStyleSheet.prototype const { deleteRule } = contentWindow.CSSStyleSheet.prototype contentWindow.CSSStyleSheet.prototype.insertRule = _.wrap(insertRule, cssModificationSpy) contentWindow.CSSStyleSheet.prototype.deleteRule = _.wrap(deleteRule, cssModificationSpy) if (config('experimentalFetchPolyfill')) { // drop "fetch" polyfill that replaces it with XMLHttpRequest // from the app iframe that we wrap for network stubbing contentWindow.fetch = registerFetch(contentWindow) // flag the polyfill to test this experimental feature easier state('fetchPolyfilled', true) } } catch (error) {} // eslint-disable-line no-empty } const enqueue = function (obj) { // if we have a nestedIndex it means we're processing // nested commands and need to splice them into the // index past the current index as opposed to // pushing them to the end we also dont want to // reset the run defer because splicing means we're // already in a run loop and dont want to create another! // we also reset the .next property to properly reference // our new obj // we had a bug that would bomb on custom commands when it was the // first command. this was due to nestedIndex being undefined at that // time. so we have to ensure to check that its any kind of number (even 0) // in order to know to splice into the existing array. let nestedIndex = state('nestedIndex') // if this is a number then we know // we're about to splice this into our commands // and need to reset next + increment the index if (_.isNumber(nestedIndex)) { state('nestedIndex', (nestedIndex += 1)) } // we look at whether or not nestedIndex is a number, because if it // is then we need to splice inside of our commands, else just push // it onto the end of the queu const index = _.isNumber(nestedIndex) ? nestedIndex : queue.length queue.splice(index, 0, obj) return Cypress.action('cy:command:enqueued', obj) } const getCommandsUntilFirstParentOrValidSubject = function (command, memo = []) { if (!command) { return null } // push these onto the beginning of the commands array memo.unshift(command) // break and return the memo if ((command.get('type') === 'parent') || $dom.isAttached(command.get('subject'))) { return memo } return getCommandsUntilFirstParentOrValidSubject(command.get('prev'), memo) } const runCommand = function (command) { // bail here prior to creating a new promise // because we could have stopped / canceled // prior to ever making it through our first // command if (stopped) { return } state('current', command) state('chainerId', command.get('chainerId')) return stability.whenStable(() => { // TODO: handle this event // @trigger "invoke:start", command state('nestedIndex', state('index')) return command.get('args') }) .then((args) => { // store this if we enqueue new commands // to check for promise violations let ret let enqueuedCmd = null const commandEnqueued = (obj) => { return enqueuedCmd = obj } // only check for command enqueing when none // of our args are functions else commands // like cy.then or cy.each would always fail // since they return promises and queue more // new commands if (noArgsAreAFunction(args)) { Cypress.once('command:enqueued', commandEnqueued) } // run the command's fn with runnable's context try { ret = __stackReplacementMarker(command.get('fn'), state('ctx'), args) } catch (err) { throw err } finally { // always remove this listener Cypress.removeListener('command:enqueued', commandEnqueued) } state('commandIntermediateValue', ret) // we cannot pass our cypress instance or our chainer // back into bluebird else it will create a thenable // which is never resolved if (isCy(ret)) { return null } if (!(!enqueuedCmd || !isPromiseLike(ret))) { return $errUtils.throwErrByPath( 'miscellaneous.command_returned_promise_and_commands', { args: { current: command.get('name'), called: enqueuedCmd.name, }, }, ) } if (!(!enqueuedCmd || !!_.isUndefined(ret))) { // TODO: clean this up in the utility function // to conditionally stringify functions ret = _.isFunction(ret) ? ret.toString() : $utils.stringify(ret) // if we got a return value and we enqueued // a new command and we didn't return cy // or an undefined value then throw return $errUtils.throwErrByPath( 'miscellaneous.returned_value_and_commands_from_custom_command', { args: { current: command.get('name'), returned: ret, }, }, ) } return ret }).then((subject) => { state('commandIntermediateValue', undefined) // we may be given a regular array here so // we need to re-wrap the array in jquery // if that's the case if the first item // in this subject is a jquery element. // we want to do this because in 3.1.2 there // was a regression when wrapping an array of elements const firstSubject = $utils.unwrapFirst(subject) // if ret is a DOM element and its not an instance of our own jQuery if (subject && $dom.isElement(firstSubject) && !$utils.isInstanceOf(subject, $)) { // set it back to our own jquery object // to prevent it from being passed downstream // TODO: enable turning this off // wrapSubjectsInJquery: false // which will just pass subjects downstream // without modifying them subject = $dom.wrap(subject) } command.set({ subject }) // end / snapshot our logs // if they need it command.finishLogs() // reset the nestedIndex back to null state('nestedIndex', null) // also reset recentlyReady back to null state('recentlyReady', null) // we're finished with the current command // so set it back to null state('current', null) state('subject', subject) return subject }) } const run = function () { const next = function () { // bail if we've been told to abort in case // an old command continues to run after if (stopped) { return } // start at 0 index if we dont have one let index = state('index') || state('index', 0) const command = queue.at(index) // if the command should be skipped // just bail and increment index // and set the subject // TODO DRY THIS LOGIC UP if (command && command.get('skip')) { // must set prev + next since other // operations depend on this state being correct command.set({ prev: queue.at(index - 1), next: queue.at(index + 1) }) state('index', index + 1) state('subject', command.get('subject')) return next() } // if we're at the very end if (!command) { // trigger queue is almost finished Cypress.action('cy:command:queue:before:end') // we need to wait after all commands have // finished running if the application under // test is no longer stable because we cannot // move onto the next test until its finished return stability.whenStable(() => { Cypress.action('cy:command:queue:end') return null }) } // store the previous timeout const prevTimeout = timeouts.timeout() // store the current runnable const runnable = state('runnable') Cypress.action('cy:command:start', command) return runCommand(command) .then(() => { // each successful command invocation should // always reset the timeout for the current runnable // unless it already has a state. if it has a state // and we reset the timeout again, it will always // cause a timeout later no matter what. by this time // mocha expects the test to be done let fn if (!runnable.state) { timeouts.timeout(prevTimeout) } // mutate index by incrementing it // this allows us to keep the proper index // in between different hooks like before + beforeEach // else run will be called again and index would start // over at 0 state('index', (index += 1)) Cypress.action('cy:command:end', command) fn = state('onPaused') if (fn) { return new Promise((resolve) => { return fn(resolve) }).then(next) } return next() }) } let inner = null // this ends up being the parent promise wrapper const promise = new Promise((resolve, reject) => { // bubble out the inner promise // we must use a resolve(null) here // so the outer promise is first defined // else this will kick off the 'next' call // too soon and end up running commands prior // to promise being defined inner = Promise .resolve(null) .then(next) .then(resolve) .catch(reject) // can't use onCancel argument here because // its called asynchronously // when we manually reject our outer promise we // have to immediately cancel the inner one else // it won't be notified and its callbacks will // continue to be invoked // normally we don't have to do this because rejections // come from the inner promise and bubble out to our outer // // but when we manually reject the outer promise we // have to go in the opposite direction from outer -> inner const rejectOuterAndCancelInner = function (err) { inner.cancel() return reject(err) } state('resolve', resolve) state('reject', rejectOuterAndCancelInner) }) .catch((err) => { // since this failed this means that a // specific command failed and we should // highlight it in red or insert a new command err.name = err.name || 'CypressError' errors.commandRunningFailed(err) return fail(err, state('runnable')) }) .finally(cleanup) // cancel both promises const cancel = function () { promise.cancel() inner.cancel() // notify the world return Cypress.action('cy:canceled') } state('cancel', cancel) state('promise', promise) // return this outer bluebird promise return promise } const removeSubject = () => { return state('subject', undefined) } const pushSubjectAndValidate = function (name, args, firstCall, prevSubject) { if (firstCall) { // if we have a prevSubject then error // since we're invoking this improperly let needle if (prevSubject && ((needle = 'optional', ![].concat(prevSubject).includes(needle)))) { const stringifiedArg = $utils.stringifyActual(args[0]) $errUtils.throwErrByPath('miscellaneous.invoking_child_without_parent', { args: { cmd: name, args: _.isString(args[0]) ? `\"${stringifiedArg}\"` : stringifiedArg, }, }) } // else if this is the very first call // on the chainer then make the first // argument undefined (we have no subject) removeSubject() } const subject = state('subject') if (prevSubject) { // make sure our current subject is valid for // what we expect in this command ensures.ensureSubjectByType(subject, prevSubject, name) } args.unshift(subject) Cypress.action('cy:next:subject:prepared', subject, args, firstCall) return args } const doneEarly = function () { stopped = true // we only need to worry about doneEarly when // it comes from a manual event such as stopping // Cypress or when we yield a (done) callback // and could arbitrarily call it whenever we want const p = state('promise') // if our outer promise is pending // then cancel outer and inner // and set canceled to be true if (p && p.isPending()) { state('canceled', true) state('cancel')() } return cleanup() } const cleanup = function () { // cleanup could be called during a 'stop' event which // could happen in between a runnable because they are async if (state('runnable')) { // make sure we reset the runnable's timeout now state('runnable').resetTimeout() } // if a command fails then after each commands // could also fail unless we clear this out state('commandIntermediateValue', undefined) // reset the nestedIndex back to null state('nestedIndex', null) // also reset recentlyReady back to null state('recentlyReady', null) // and forcibly move the index needle to the // end in case we have after / afterEach hooks // which need to run return state('index', queue.length) } const getUserInvocationStack = (err) => { const current = state('current') const currentAssertionCommand = current?.get('currentAssertionCommand') const withInvocationStack = currentAssertionCommand || current // user assertion errors (expect().to, etc) get their invocation stack // attached to the error thrown from chai // command errors and command assertion errors (default assertion or cy.should) // have the invocation stack attached to the current command // prefer err.userInvocation stack if it's been set let userInvocationStack = $errUtils.getUserInvocationStack(err) || state('currentAssertionUserInvocationStack') // if there is no user invocation stack from an assertion or it is the default // assertion, meaning it came from a command (e.g. cy.get), prefer the // command's user invocation stack so the code frame points to the command. // `should` callbacks are tricky because the `currentAssertionUserInvocationStack` // points to the `cy.should`, but the error came from inside the callback, // so we need to prefer that. if ( !userInvocationStack || err.isDefaultAssertionErr || (currentAssertionCommand && !current?.get('followedByShouldCallback')) ) { userInvocationStack = withInvocationStack?.get('userInvocationStack') } if (!userInvocationStack) return if ( $errUtils.isCypressErr(err) || $errUtils.isAssertionErr(err) || $errUtils.isChaiValidationErr(err) ) { return userInvocationStack } } const fail = (err) => { let rets stopped = true if (typeof err === 'string') { err = new Error(err) } err.stack = $stackUtils.normalizedStack(err) err = $errUtils.enhanceStack({ err, userInvocationStack: getUserInvocationStack(err), projectRoot: config('projectRoot'), }) err = $errUtils.processErr(err, config) // store the error on state now state('error', err) const finish = function (err) { // if we have an async done callback // we have an explicit (done) callback and // we aren't attached to the cypress command queue // promise chain and throwing the error would only // result in an unhandled rejection let d d = state('done') if (d) { // invoke it with err return d(err) } // else we're connected to the promise chain // and need to throw so this bubbles up throw err } // this means the error came from a 'fail' handler, so don't send // 'cy:fail' action again, just finish up if (err.isCyFailErr) { delete err.isCyFailErr return finish(err) } // if we have a "fail" handler // 1. catch any errors it throws and fail the test // 2. otherwise swallow any errors // 3. but if the test is not ended with a done() // then it should fail // 4. and tests without a done will pass // if we dont have a "fail" handler // 1. callback with state("done") when async // 2. throw the error for the promise chain try { // collect all of the callbacks for 'fail' rets = Cypress.action('cy:fail', err, state('runnable')) } catch (cyFailErr) { // and if any of these throw synchronously immediately error cyFailErr.isCyFailErr = true return fail(cyFailErr) } // bail if we had callbacks attached if (rets && rets.length) { return } // else figure out how to finisht this failure return finish(err) } _.extend(cy, { id: _.uniqueId('cy'), // synchrounous querying $$, state, // command queue instance queue, // errors sync methods fail, // chai expect sync methods expect, // is cy isCy, isStopped, // timeout sync methods timeout: timeouts.timeout, clearTimeout: timeouts.clearTimeout, // stability sync methods isStable: stability.isStable, whenStable: stability.whenStable, // xhr sync methods getRequestsByAlias: xhrs.getRequestsByAlias, getIndexedXhrByAlias: xhrs.getIndexedXhrByAlias, // alias sync methods getAlias: aliases.getAlias, addAlias: aliases.addAlias, validateAlias: aliases.validateAlias, getNextAlias: aliases.getNextAlias, aliasNotFoundFor: aliases.aliasNotFoundFor, getXhrTypeByAlias: aliases.getXhrTypeByAlias, // location sync methods getRemoteLocation: location.getRemoteLocation, // jquery sync methods getRemotejQueryInstance: jquery.getRemotejQueryInstance, // focused sync methods getFocused: focused.getFocused, needsFocus: focused.needsFocus, fireFocus: focused.fireFocus, fireBlur: focused.fireBlur, devices: { mouse, keyboard, }, // timer sync methods pauseTimers: timers.pauseTimers, // snapshots sync methods createSnapshot: snapshots.createSnapshot, // retry sync methods retry: retries.retry, // assertions sync methods assert: assertions.assert, verifyUpcomingAssertions: assertions.verifyUpcomingAssertions, // ensure sync methods ensureWindow: ensures.ensureWindow, ensureElement: ensures.ensureElement, ensureDocument: ensures.ensureDocument, ensureAttached: ensures.ensureAttached, ensureExistence: ensures.ensureExistence, ensureElExistence: ensures.ensureElExistence, ensureElDoesNotHaveCSS: ensures.ensureElDoesNotHaveCSS, ensureVisibility: ensures.ensureVisibility, ensureDescendents: ensures.ensureDescendents, ensureNotReadonly: ensures.ensureNotReadonly, ensureNotDisabled: ensures.ensureNotDisabled, ensureValidPosition: ensures.ensureValidPosition, ensureScrollability: ensures.ensureScrollability, ensureElementIsNotAnimating: ensures.ensureElementIsNotAnimating, initialize ($autIframe) { setRemoteIframeProps($autIframe, state) // dont need to worry about a try/catch here // because this is during initialize and its // impossible something is wrong here setWindowDocumentProps(getContentWindow($autIframe), state) // initially set the content window listeners too // so we can tap into all the normal flow of events // like before:unload, navigation events, etc contentWindowListeners(getContentWindow($autIframe)) // the load event comes from the autIframe anytime any window // inside of it loads. // when this happens we need to check for cross origin errors // by trying to talk to the contentWindow document to see if // its accessible. // when we find ourselves in a cross origin situation, then our // proxy has not injected Cypress.action('window:before:load') // so Cypress.onBeforeAppWindowLoad() was never called return $autIframe.on('load', () => { // if setting these props failed // then we know we're in a cross origin failure let onpl; let r try { setWindowDocumentProps(getContentWindow($autIframe), state) // we may need to update the url now urlNavigationEvent('load') // we normally DONT need to reapply contentWindow listeners // because they would have been automatically applied during // onBeforeAppWindowLoad, but in the case where we visited // about:blank in a visit, we do need these contentWindowListeners(getContentWindow($autIframe)) Cypress.action('app:window:load', state('window')) // we are now stable again which is purposefully // the last event we call here, to give our event // listeners time to be invoked prior to moving on return stability.isStable(true, 'load') } catch (err) { let e = err // we failed setting the remote window props // which means we're in a cross domain failure // check first to see if you have a callback function // defined and let the page load change the error onpl = state('onPageLoadErr') if (onpl) { e = onpl(e) } // and now reject with it r = state('reject') if (r) { return r(e) } } }) }, stop () { // don't do anything if we've already stopped if (stopped) { return } return doneEarly() }, reset (attrs, test) { stopped = false const s = state() const backup = { window: s.window, document: s.document, $autIframe: s.$autIframe, } // reset state back to empty object state.reset() // and then restore these backed up props state(backup) queue.reset() timers.reset() testConfigOverrides.restoreAndSetTestConfigOverrides(test, Cypress.config, Cypress.env) return cy.removeAllListeners() }, addCommandSync (name, fn) { cy[name] = function () { return fn.apply(runnableCtx(name), arguments) } }, addChainer (name, fn) { // add this function to our chainer class return $Chainer.add(name, fn) }, addCommand ({ name, fn, type, prevSubject }) { // TODO: prob don't need this anymore commandFns[name] = fn const wrap = function (firstCall) { fn = commandFns[name] const wrapped = wrapByType(fn, firstCall) wrapped.originalFn = fn return wrapped } const wrapByType = function (fn, firstCall) { if (type === 'parent') { return fn } // child, dual, assertion, utility command // pushes the previous subject into them // after verifying its of the correct type return function (...args) { // push the subject into the args args = pushSubjectAndValidate(name, args, firstCall, prevSubject) return fn.apply(runnableCtx(name), args) } } cy[name] = function (...args) { const userInvocationStack = $stackUtils.captureUserInvocationStack(specWindow.Error) let ret ensures.ensureRunnable(name) // this is the first call on cypress // so create a new chainer instance const chain = $Chainer.create(name, userInvocationStack, specWindow, args) // store the chain so we can access it later state('chain', chain) // if we are in the middle of a command // and its return value is a promise // that means we are attempting to invoke // a cypress command within another cypress // command and we should error ret = state('commandIntermediateValue') if (ret) { const current = state('current') // if this is a custom promise if (isPromiseLike(ret) && noArgsAreAFunction(current.get('args'))) { $errUtils.throwErrByPath( 'miscellaneous.command_returned_promise_and_commands', { args: { current: current.get('name'), called: name, }, }, ) } } // if we're the first call onto a cy // command, then kick off the run if (!state('promise')) { if (state('returnedCustomPromise')) { warnMixingPromisesAndCommands() } run() } return chain } return cy.addChainer(name, (chainer, userInvocationStack, args) => { const { firstCall, chainerId } = chainer // dont enqueue / inject any new commands if // onInjectCommand returns false const onInjectCommand = state('onInjectCommand') const injected = _.isFunction(onInjectCommand) if (injected) { if (onInjectCommand.call(cy, name, ...args) === false) { return } } enqueue({ name, args, type, chainerId, userInvocationStack, injected, fn: wrap(firstCall), }) return true }) }, now (name, ...args) { return Promise.resolve( commandFns[name].apply(cy, args), ) }, replayCommandsFrom (current) { // reset each chainerId to the // current value const chainerId = state('chainerId') const insert = function (command) { command.set('chainerId', chainerId) // clone the command to prevent // mutating its properties return enqueue(command.clone()) } // - starting with the aliased command // - walk up to each prev command // - until you reach a parent command // - or until the subject is in the DOM // - from that command walk down inserting // every command which changed the subject // - coming upon an assertion should only be // inserted if the previous command should // be replayed const commands = getCommandsUntilFirstParentOrValidSubject(current) if (commands) { let initialCommand = commands.shift() const commandsToInsert = _.reduce(commands, (memo, command, index) => { let needle const push = () => { return memo.push(command) } if (!(command.get('type') !== 'assertion')) { // if we're an assertion and the prev command // is in the memo, then push this one if ((needle = command.get('prev'), memo.includes(needle))) { push() } } else if (!(command.get('subject') === initialCommand.get('subject'))) { // when our subjects dont match then // reset the initialCommand to this command // so the next commands can compare against // this one to figure out the changing subjects initialCommand = command push() } return memo } , [initialCommand]) for (let c of commandsToInsert) { insert(c) } } // prevent loop comprehension return null }, onBeforeAppWindowLoad (contentWindow) { // we set window / document props before the window load event // so that we properly handle events coming from the application // from the time that happens BEFORE the load event occurs setWindowDocumentProps(contentWindow, state) urlNavigationEvent('before:load') contentWindowListeners(contentWindow) wrapNativeMethods(contentWindow) snapshots.onBeforeWindowLoad() return timers.wrap(contentWindow) }, onSpecWindowUncaughtException () { // create the special uncaught exception err const err = errors.createUncaughtException('spec', arguments) const runnable = state('runnable') if (!runnable) return err try { fail(err) } catch (failErr) { const r = state('reject') if (r) { return r(err) } return failErr } }, onUncaughtException () { let r const runnable = state('runnable') // don't do anything if we don't have a current runnable if (!runnable) { return } // create the special uncaught exception err const err = errors.createUncaughtException('app', arguments) const results = Cypress.action('app:uncaught:exception', err, runnable) // dont do anything if any of our uncaught:exception // listeners returned false if (_.some(results, returnedFalse)) { return } // do all the normal fail stuff and promise cancelation // but dont re-throw the error r = state('reject') if (r) { r(err) } // per the onerror docs we need to return true here // https://developer.mozilla.org/en-US/docs/Web/API/GlobalEventHandlers/onerror // When the function returns true, this prevents the firing of the default event handler. return true }, detachDom (...args) { return snapshots.detachDom(...args) }, getStyles (...args) { return snapshots.getStyles(...args) }, setRunnable (runnable, hookId) { // when we're setting a new runnable // prepare to run again! stopped = false // reset the promise again state('promise', undefined) state('hookId', hookId) state('runnable', runnable) state('test', $utils.getTestFromRunnable(runnable)) state('ctx', runnable.ctx) const { fn } = runnable const restore = () => { return runnable.fn = fn } runnable.fn = function () { restore() const timeout = config('defaultCommandTimeout') // control timeouts on runnables ourselves if (_.isFinite(timeout)) { timeouts.timeout(timeout) } // store the current length of our queue // before we invoke the runnable.fn const currentLength = queue.length try { // if we have a fn.length that means we // are accepting a done callback and need // to change the semantics around how we // attach the run queue let done if (fn.length) { const originalDone = arguments[0] arguments[0] = (done = function (err) { // TODO: handle no longer error // when ended early doneEarly() originalDone(err) // return null else we there are situations // where returning a regular bluebird promise // results in a warning about promise being created // in a handler but not returned return null }) // store this done property // for async tests state('done', done) } let ret = __stackReplacementMarker(fn, this, arguments) // if we returned a value from fn // and enqueued some new commands // and the value isnt currently cy // or a promise if (ret && (queue.length > currentLength) && (!isCy(ret)) && (!isPromiseLike(ret))) { // TODO: clean this up in the utility function // to conditionally stringify functions ret = _.isFunction(ret) ? ret.toString() : $utils.stringify(ret) $errUtils.throwErrByPath('miscellaneous.returned_value_and_commands', { args: { returned: ret }, }) } // if we attached a done callback // and returned a promise then we // need to automatically bind to // .catch() and return done(err) // TODO: this has gone away in mocha 3.x.x // due to overspecifying a resolution. // in those cases we need to remove // returning a promise if (fn.length && ret && ret.catch) { ret = ret.catch(done) } // if we returned a promise like object if ((!isCy(ret)) && isPromiseLike(ret)) { // indicate we've returned a custom promise state('returnedCustomPromise', true) // this means we instantiated a promise // and we've already invoked multiple // commands and should warn if (queue.length > currentLength) { warnMixingPromisesAndCommands() } return ret } // if we're cy or we've enqueued commands if (isCy(ret) || (queue.length > currentLength)) { if (fn.length) { // if user has passed done callback don't return anything // so we don't get an 'overspecified' error from mocha return } // otherwise, return the 'queue promise', so mocha awaits it return state('promise') } // else just return ret return ret } catch (err) { // if runnable.fn threw synchronously, then it didnt fail from // a cypress command, but we should still teardown and handle // the error return fail(err, runnable) } } }, }) setTopOnError(cy) // make cy global in the specWindow specWindow.cy = cy $Events.extend(cy) return cy } module.exports = { create, }