diff --git a/packages/driver/cypress/integration/cypress/command_queue_spec.ts b/packages/driver/cypress/integration/cypress/command_queue_spec.ts index 8e8eaca53ce..ae4c476613a 100644 --- a/packages/driver/cypress/integration/cypress/command_queue_spec.ts +++ b/packages/driver/cypress/integration/cypress/command_queue_spec.ts @@ -1,6 +1,6 @@ import _ from 'lodash' import $Command from '../../../src/cypress/command' -import $CommandQueue from '../../../src/cypress/command_queue' +import { CommandQueue } from '../../../src/cypress/command_queue' const createCommand = (props = {}) => { return $Command.create(_.extend({ @@ -23,14 +23,14 @@ const log = (props = {}) => { describe('src/cypress/command_queue', () => { let queue const state = () => {} - const timeouts = { timeout () {} } - const stability = { whenStable () {} } + const timeout = () => {} + const whenStable = () => {} const cleanup = () => {} const fail = () => {} const isCy = () => {} beforeEach(() => { - queue = $CommandQueue.create(state, timeouts, stability, cleanup, fail, isCy) + queue = new CommandQueue(state, timeout, whenStable, cleanup, fail, isCy) queue.add(createCommand({ name: 'get', diff --git a/packages/driver/cypress/integration/util/queue_spec.ts b/packages/driver/cypress/integration/util/queue_spec.ts index 72ad7c991f4..c23a23168f0 100644 --- a/packages/driver/cypress/integration/util/queue_spec.ts +++ b/packages/driver/cypress/integration/util/queue_spec.ts @@ -1,6 +1,6 @@ import Bluebird from 'bluebird' -import $Queue from '../../../src/util/queue' +import { Queue } from '../../../src/util/queue' const ids = (queueables) => queueables.map((q) => q.id) @@ -8,7 +8,7 @@ describe('src/util/queue', () => { let queue beforeEach(() => { - queue = $Queue.create([ + queue = new Queue([ { id: '1' }, { id: '2' }, { id: '3' }, diff --git a/packages/driver/src/cypress.ts b/packages/driver/src/cypress.ts index bc297ed89e6..f52954f5189 100644 --- a/packages/driver/src/cypress.ts +++ b/packages/driver/src/cypress.ts @@ -14,7 +14,7 @@ import browserInfo from './cypress/browser' import $scriptUtils from './cypress/script_utils' import $Commands from './cypress/commands' -import $Cy from './cypress/cy' +import { $Cy } from './cypress/cy' import $dom from './dom' import $Downloads from './cypress/downloads' import $errorMessages from './cypress/error_messages' @@ -209,12 +209,8 @@ class $Cypress { // or parsed. we have not received any custom commands // at this point onSpecWindow (specWindow, scripts) { - const logFn = (...args) => { - return this.log.apply(this, args) - } - // create cy and expose globally - this.cy = $Cy.create(specWindow, this, this.Cookies, this.state, this.config, logFn) + this.cy = new $Cy(specWindow, this, this.Cookies, this.state, this.config) window.cy = this.cy this.isCy = this.cy.isCy this.log = $Log.create(this, this.cy, this.state, this.config) diff --git a/packages/driver/src/cypress/command_queue.ts b/packages/driver/src/cypress/command_queue.ts index b84b1aa4143..95fcd4239ac 100644 --- a/packages/driver/src/cypress/command_queue.ts +++ b/packages/driver/src/cypress/command_queue.ts @@ -3,7 +3,7 @@ import $ from 'jquery' import Bluebird from 'bluebird' import Debug from 'debug' -import $queue from '../util/queue' +import { Queue } from '../util/queue' import $dom from '../dom' import $utils from './utils' import $errUtils from './error_utils' @@ -60,336 +60,322 @@ const commandRunningFailed = (Cypress, state, err) => { }) } -export default { - create: (state, timeout, whenStable, cleanup, fail, isCy) => { - const queue = $queue.create() - - const { get, slice, at, reset, clear, stop } = queue - - const logs = (filter) => { - let logs = _.flatten(_.invokeMap(queue.get(), 'get', 'logs')) +export class CommandQueue extends Queue { + state: any + timeout: any + whenStable: any + cleanup: any + fail: any + isCy: any + + constructor (state, timeout, whenStable, cleanup, fail, isCy) { + super() + this.state = state + this.timeout = timeout + this.whenStable = whenStable + this.cleanup = cleanup + this.fail = fail + this.isCy = isCy + } - if (filter) { - const matchesFilter = _.matches(filter) + logs (filter) { + let logs = _.flatten(_.invokeMap(this.get(), 'get', 'logs')) - logs = _.filter(logs, (log) => { - return matchesFilter(log.get()) - }) - } + if (filter) { + const matchesFilter = _.matches(filter) - return logs - } - - const names = () => { - return _.invokeMap(queue.get(), 'get', 'name') + logs = _.filter(logs, (log) => { + return matchesFilter(log.get()) + }) } - const add = (command) => { - queue.add(command) - } + return logs + } - const insert = (index: number, command: Command) => { - queue.insert(index, command) + names () { + return _.invokeMap(this.get(), 'get', 'name') + } - const prev = at(index - 1) as Command - const next = at(index + 1) as Command + insert (index: number, command: Command) { + super.insert(index, command) - if (prev) { - prev.set('next', command) - command.set('prev', prev) - } + const prev = this.at(index - 1) + const next = this.at(index + 1) - if (next) { - next.set('prev', command) - command.set('next', next) - } + if (prev) { + prev.set('next', command) + command.set('prev', prev) + } - return command + if (next) { + next.set('prev', command) + command.set('next', next) } - const find = (attrs) => { - const matchesAttrs = _.matches(attrs) + return command + } + + find (attrs) { + const matchesAttrs = _.matches(attrs) - return _.find(queue.get(), (command: Command) => { - return matchesAttrs(command.attributes) - }) + return _.find(this.get(), (command: Command) => { + return matchesAttrs(command.attributes) + }) + } + + private runCommand (command: 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 (this.stopped) { + return } - const runCommand = (command: 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 (queue.stopped) { - return - } + this.state('current', command) + this.state('chainerId', command.get('chainerId')) - state('current', command) - state('chainerId', command.get('chainerId')) + return this.whenStable(() => { + this.state('nestedIndex', this.state('index')) - return whenStable(() => { - 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 - return command.get('args') - }) - .then((args) => { - // store this if we enqueue new commands - // to check for promise violations - let ret - let enqueuedCmd - - const commandEnqueued = (obj) => { - return enqueuedCmd = obj - } + const commandEnqueued = (obj) => { + return enqueuedCmd = obj + } - // only check for command enqueuing 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 ($utils.noArgsAreAFunction(args)) { - Cypress.once('command:enqueued', commandEnqueued) - } + // only check for command enqueuing 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 ($utils.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) - } + // run the command's fn with runnable's context + try { + ret = __stackReplacementMarker(command.get('fn'), this.state('ctx'), args) + } catch (err) { + throw err + } finally { + // always remove this listener + Cypress.removeListener('command:enqueued', commandEnqueued) + } - state('commandIntermediateValue', ret) + this.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 - } + // we cannot pass our cypress instance or our chainer + // back into bluebird else it will create a thenable + // which is never resolved + if (this.isCy(ret)) { + return null + } - if (!(!enqueuedCmd || !$utils.isPromiseLike(ret))) { - return $errUtils.throwErrByPath( - 'miscellaneous.command_returned_promise_and_commands', { - args: { - current: command.get('name'), - called: enqueuedCmd.name, - }, + if (!(!enqueuedCmd || !$utils.isPromiseLike(ret))) { + $errUtils.throwErrByPath( + 'miscellaneous.command_returned_promise_and_commands', { + args: { + current: command.get('name'), + called: enqueuedCmd.name, }, - ) - } + }, + ) + } - if (!(!enqueuedCmd || !!_.isUndefined(ret))) { - 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, - }, + if (!(!enqueuedCmd || !!_.isUndefined(ret))) { + 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) - } + return ret + }).then((subject) => { + this.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 }) + command.set({ subject }) - // end / snapshot our logs if they need it - command.finishLogs() + // end / snapshot our logs if they need it + command.finishLogs() - // reset the nestedIndex back to null - state('nestedIndex', null) + // reset the nestedIndex back to null + this.state('nestedIndex', null) - // also reset recentlyReady back to null - state('recentlyReady', null) + // also reset recentlyReady back to null + this.state('recentlyReady', null) - // we're finished with the current command so set it back to null - state('current', null) + // we're finished with the current command so set it back to null + this.state('current', null) - state('subject', subject) + this.state('subject', subject) - return subject - }) - } - - const run = () => { - const next = () => { - // bail if we've been told to abort in case - // an old command continues to run after - if (queue.stopped) { - return - } - - // start at 0 index if we dont have one - let index = state('index') || state('index', 0) + return subject + }) + } - const command = at(index) as Command + // TypeScript doesn't allow overriding functions with different type signatures + // @ts-ignore + run () { + const next = () => { + // bail if we've been told to abort in case + // an old command continues to run after + if (this.stopped) { + return + } - // if the command should be skipped - // just bail and increment index - // and set the subject - if (command && command.get('skip')) { - // must set prev + next since other - // operations depend on this state being correct - command.set({ - prev: at(index - 1) as Command, - next: at(index + 1) as Command, - }) + // start at 0 index if we dont have one + let index = this.state('index') || this.state('index', 0) - state('index', index + 1) - state('subject', command.get('subject')) + const command = this.at(index) - return next() - } + // if the command should be skipped + // just bail and increment index + // and set the subject + if (command && command.get('skip')) { + // must set prev + next since other + // operations depend on this state being correct + command.set({ + prev: this.at(index - 1), + next: this.at(index + 1), + }) - // if we're at the very end - if (!command) { - // trigger queue is almost finished - Cypress.action('cy:command:queue:before:end') + this.state('index', index + 1) + this.state('subject', command.get('subject')) - // 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 whenStable(() => { - Cypress.action('cy:command:queue:end') + return next() + } - return null - }) - } + // if we're at the very end + if (!command) { + // trigger queue is almost finished + Cypress.action('cy:command:queue:before:end') - // store the previous timeout - const prevTimeout = timeout() + // 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 this.whenStable(() => { + Cypress.action('cy:command:queue:end') - // store the current runnable - const runnable = state('runnable') + return null + }) + } - Cypress.action('cy:command:start', command) + // store the previous timeout + const prevTimeout = this.timeout() - 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 + // store the current runnable + const runnable = this.state('runnable') - if (!runnable.state) { - timeout(prevTimeout) - } + Cypress.action('cy:command:start', command) - // 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 - index += 1 - state('index', index) + return this.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 - Cypress.action('cy:command:end', command) + if (!runnable.state) { + this.timeout(prevTimeout) + } - fn = state('onPaused') + // 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 + index += 1 + this.state('index', index) - if (fn) { - return new Bluebird((resolve) => { - return fn(resolve) - }).then(next) - } + Cypress.action('cy:command:end', command) - return next() - }) - } + fn = this.state('onPaused') - const onError = (err: Error | string) => { - if (state('onCommandFailed')) { - return state('onCommandFailed')(err, queue, next) + if (fn) { + return new Bluebird((resolve) => { + return fn(resolve) + }).then(next) } - debugErrors('caught error in promise chain: %o', err) + return next() + }) + } - // since this failed this means that a specific command failed - // and we should highlight it in red or insert a new command - // @ts-ignore - if (_.isObject(err) && !err.name) { - // @ts-ignore - err.name = 'CypressError' - } + const onError = (err: Error | string) => { + if (this.state('onCommandFailed')) { + return this.state('onCommandFailed')(err, this, next) + } - commandRunningFailed(Cypress, state, err) + debugErrors('caught error in promise chain: %o', err) - return fail(err) + // since this failed this means that a specific command failed + // and we should highlight it in red or insert a new command + // @ts-ignore + if (_.isObject(err) && !err.name) { + // @ts-ignore + err.name = 'CypressError' } - const { promise, reject, cancel } = queue.run({ - onRun: next, - onError, - onFinish: cleanup, - }) + commandRunningFailed(Cypress, this.state, err) - state('promise', promise) - state('reject', reject) - state('cancel', () => { - cancel() + return this.fail(err) + } - Cypress.action('cy:canceled') - }) + const { promise, reject, cancel } = super.run({ + onRun: next, + onError, + onFinish: this.cleanup, + }) - return promise - } + this.state('promise', promise) + this.state('reject', reject) + this.state('cancel', () => { + cancel() - return { - logs, - names, - add, - insert, - find, - run, - get, - slice, - at, - reset, - clear, - stop, - - get length () { - return queue.length - }, - - get stopped () { - return queue.stopped - }, - } - }, + Cypress.action('cy:canceled') + }) + + return promise + } } diff --git a/packages/driver/src/cypress/cy.ts b/packages/driver/src/cypress/cy.ts index 4d0659d52a6..38c753c830a 100644 --- a/packages/driver/src/cypress/cy.ts +++ b/packages/driver/src/cypress/cy.ts @@ -31,7 +31,7 @@ import { create as createStability, IStability } from '../cy/stability' import $selection from '../dom/selection' import { create as createSnapshots, ISnapshots } from '../cy/snapshots' import { $Command } from './command' -import $CommandQueue from './command_queue' +import { CommandQueue } from './command_queue' import { initVideoRecorder } from '../cy/video-recorder' import { TestConfigOverride } from '../cy/testConfigOverrides' @@ -51,10 +51,6 @@ const setWindowDocumentProps = function (contentWindow, state) { return state('document', contentWindow.document) } -const setRemoteIframeProps = ($autIframe, state) => { - return state('$autIframe', $autIframe) -} - function __stackReplacementMarker (fn, ctx, args) { return fn.apply(ctx, args) } @@ -119,16 +115,18 @@ const setTopOnError = function (Cypress, cy: $Cy) { top.__alreadySetErrorHandlers__ = true } -// NOTE: this makes the cy object an instance -// TODO: refactor the 'create' method below into this class -class $Cy implements ITimeouts, IStability, IAssertions, IRetries, IJQuery, ILocation, ITimer, IChai, IXhr, IAliases, IEnsures, ISnapshots, IFocused { +export class $Cy implements ITimeouts, IStability, IAssertions, IRetries, IJQuery, ILocation, ITimer, IChai, IXhr, IAliases, IEnsures, ISnapshots, IFocused { id: string + specWindow: any state: any config: any + Cypress: any + Cookies: any devices: { keyboard: Keyboard mouse: Mouse } + queue: CommandQueue timeout: ITimeouts['timeout'] clearTimeout: ITimeouts['clearTimeout'] @@ -198,14 +196,38 @@ class $Cy implements ITimeouts, IStability, IAssertions, IRetries, IJQuery, ILoc interceptFocus: ReturnType['interceptFocus'] interceptBlur: ReturnType['interceptBlur'] + private testConfigOverride: TestConfigOverride + private commandFns: Record = {} + constructor (specWindow, Cypress, Cookies, state, config) { + state('specWindow', specWindow) + + this.specWindow = specWindow this.id = _.uniqueId('cy') this.state = state this.config = config + this.Cypress = Cypress + this.Cookies = Cookies initVideoRecorder(Cypress) + this.testConfigOverride = new TestConfigOverride() + // bind methods this.$$ = this.$$.bind(this) + this.isCy = this.isCy.bind(this) + this.fail = this.fail.bind(this) + this.isStopped = this.isStopped.bind(this) + this.stop = this.stop.bind(this) + this.reset = this.reset.bind(this) + this.addCommandSync = this.addCommandSync.bind(this) + this.addChainer = this.addChainer.bind(this) + this.addCommand = this.addCommand.bind(this) + this.now = this.now.bind(this) + this.replayCommandsFrom = this.replayCommandsFrom.bind(this) + this.onBeforeAppWindowLoad = this.onBeforeAppWindowLoad.bind(this) + this.onUncaughtException = this.onUncaughtException.bind(this) + this.setRunnable = this.setRunnable.bind(this) + this.cleanup = this.cleanup.bind(this) // init traits @@ -311,6 +333,15 @@ class $Cy implements ITimeouts, IStability, IAssertions, IRetries, IJQuery, ILoc this.onCssModified = snapshots.onCssModified this.onBeforeWindowLoad = snapshots.onBeforeWindowLoad + + this.queue = new CommandQueue(state, this.timeout, this.whenStable, this.cleanup, this.fail, this.isCy) + + setTopOnError(Cypress, this) + + // make cy global in the specWindow + specWindow.cy = this + + $Events.extend(this) } $$ (selector, context) { @@ -321,906 +352,868 @@ class $Cy implements ITimeouts, IStability, IAssertions, IRetries, IJQuery, ILoc return $dom.query(selector, context) } - // private - wrapNativeMethods (contentWindow) { - try { - // return null to trick contentWindow into thinking - // its not been iframed if modifyObstructiveCode is true - if (this.config('modifyObstructiveCode')) { - Object.defineProperty(contentWindow, 'frameElement', { - get () { - return null - }, - }) - } + isCy (val) { + return (val === this) || $utils.isInstanceOf(val, $Chainer) + } - const cy = this + isStopped () { + return this.queue.stopped + } - contentWindow.HTMLElement.prototype.focus = function (focusOption) { - return cy.interceptFocus(this, contentWindow, focusOption) - } + fail (err, options = {}) { + // this means the error has already been through this handler and caught + // again. but we don't need to run it through again, so we can re-throw + // it and it will fail the test as-is + if (err && err.hasFailed) { + delete err.hasFailed - contentWindow.HTMLElement.prototype.blur = function () { - return cy.interceptBlur(this) - } + throw err + } - contentWindow.SVGElement.prototype.focus = function (focusOption) { - return cy.interceptFocus(this, contentWindow, focusOption) - } + options = _.defaults(options, { + async: false, + }) - contentWindow.SVGElement.prototype.blur = function () { - return cy.interceptBlur(this) - } + let rets - contentWindow.HTMLInputElement.prototype.select = function () { - return $selection.interceptSelect.call(this) - } + this.queue.stop() - contentWindow.document.hasFocus = function () { - return cy.documentHasFocus.call(this) - } + if (typeof err === 'string') { + err = new Error(err) + } - const cssModificationSpy = function (original, ...args) { - cy.onCssModified(this.href) + err.stack = $stackUtils.normalizedStack(err) - return original.apply(this, args) - } + err = $errUtils.enhanceStack({ + err, + userInvocationStack: $errUtils.getUserInvocationStack(err, this.state), + projectRoot: this.config('projectRoot'), + }) - const { insertRule } = contentWindow.CSSStyleSheet.prototype - const { deleteRule } = contentWindow.CSSStyleSheet.prototype + err = $errUtils.processErr(err, this.config) - contentWindow.CSSStyleSheet.prototype.insertRule = _.wrap(insertRule, cssModificationSpy) - contentWindow.CSSStyleSheet.prototype.deleteRule = _.wrap(deleteRule, cssModificationSpy) + err.hasFailed = true - if (this.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 - this.state('fetchPolyfilled', true) - } - } catch (error) { } // eslint-disable-line no-empty - } -} + // store the error on state now + this.state('error', err) -export default { - create (specWindow, Cypress, Cookies, state, config, log) { - let cy = new $Cy(specWindow, Cypress, Cookies, state, config) - const commandFns = {} + const cy = this - state('specWindow', specWindow) + const finish = function (err) { + // if the test has a (done) callback, we fail the test with that + const d = cy.state('done') - const warnMixingPromisesAndCommands = function () { - const title = state('runnable').fullTitle() + if (d) { + return d(err) + } - $errUtils.warnByPath('miscellaneous.mixing_promises_and_commands', { - args: { title }, - }) - } + // if this failure was asynchronously called (outside the promise chain) + // but the promise chain is still active, reject it. if we're inside + // the promise chain, this isn't necessary and will actually mess it up + const r = cy.state('reject') - const testConfigOverride = new TestConfigOverride() + if (options.async && r) { + return r(err) + } - const isStopped = () => { - return queue.stopped + // we're in the promise chain, so throw the error and it will + // get caught by mocha and fail the test + throw err } - const isCy = (val) => { - return (val === cy) || $utils.isInstanceOf(val, $Chainer) + // 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) } - const runnableCtx = function (name) { - cy.ensureRunnable(name) + // 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 - return state('runnable').ctx + // 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 = this.Cypress.action('cy:fail', err, this.state('runnable')) + } catch (cyFailErr) { + // and if any of these throw synchronously immediately error + cyFailErr.isCyFailErr = true + + return this.fail(cyFailErr) } - const urlNavigationEvent = (event) => { - return Cypress.action('app:navigation:changed', `page navigation event (${event})`) + // bail if we had callbacks attached + if (rets && rets.length) { + return } - const contentWindowListeners = function (contentWindow) { - $Listeners.bindTo(contentWindow, { - // eslint-disable-next-line @cypress/dev/arrow-body-multiline-braces - onError: (handlerType) => (event) => { - const { originalErr, err, promise } = $errUtils.errorFromUncaughtEvent(handlerType, event) - const handled = cy.onUncaughtException({ - err, - promise, - handlerType, - frameType: 'app', - }) + // else figure out how to finish this failure + return finish(err) + } - debugErrors('uncaught AUT error: %o', originalErr) + initialize ($autIframe) { + this.state('$autIframe', $autIframe) + + // dont need to worry about a try/catch here + // because this is during initialize and its + // impossible something is wrong here + setWindowDocumentProps(getContentWindow($autIframe), this.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 + this.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 + try { + setWindowDocumentProps(getContentWindow($autIframe), this.state) + + // we may need to update the url now + this.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 + this.contentWindowListeners(getContentWindow($autIframe)) + + cy.Cypress.action('app:window:load', this.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 this.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 + const onpl = this.state('onPageLoadErr') + + if (onpl) { + e = onpl(e) + } - $errUtils.logError(Cypress, handlerType, originalErr, handled) + // and now reject with it + const r = this.state('reject') - // return undefined so the browser does its default - // uncaught exception behavior (logging to console) - return undefined - }, - onSubmit (e) { - return Cypress.action('app:form:submitted', e) - }, - onBeforeUnload (e) { - cy.isStable(false, 'beforeunload') + if (r) { + return r(e) + } + } + }) + } - Cookies.setInitial() + stop () { + // don't do anything if we've already stopped + if (this.queue.stopped) { + return + } - cy.resetTimer() + return this.doneEarly() + } - Cypress.action('app:window:before:unload', e) + // reset is called before each test + reset (test) { + try { + const s = this.state() + + const backup = { + window: s.window, + document: s.document, + $autIframe: s.$autIframe, + specWindow: s.specWindow, + activeSessions: s.activeSessions, + } - // return undefined so our beforeunload handler - // doesn't 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) + // reset state back to empty object + this.state.reset() - // return false if ANY results are false - // else true - const ret = !_.some(results, returnedFalse) + // and then restore these backed up props + this.state(backup) - Cypress.action('app:window:confirmed', str, ret) + this.queue.reset() + this.queue.clear() + this.resetTimer() + this.testConfigOverride.restoreAndSetTestConfigOverrides(test, this.Cypress.config, this.Cypress.env) - return ret - }, - }) + this.removeAllListeners() + } catch (err) { + this.fail(err) } + } - const enqueue = function (obj) { - // if we have a nestedIndex it means we're processing - // nested commands and need to insert 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 insert it into the existing array. - let nestedIndex = state('nestedIndex') - - // if this is a number, then we know we're about to insert this - // into our commands and need to reset next + increment the index - if (_.isNumber(nestedIndex)) { - state('nestedIndex', (nestedIndex += 1)) - } + addCommandSync (name, fn) { + const cy = this - // we look at whether or not nestedIndex is a number, because if it - // is then we need to insert inside of our commands, else just push - // it onto the end of the queue - const index = _.isNumber(nestedIndex) ? nestedIndex : queue.length + cy[name] = function () { + return fn.apply(cy.runnableCtx(name), arguments) + } + } - queue.insert(index, $Command.create(obj)) + addChainer (name, fn) { + // add this function to our chainer class + return $Chainer.add(name, fn) + } - return Cypress.action('cy:command:enqueued', obj) - } + addCommand ({ name, fn, type, prevSubject }) { + const cy = this - const getCommandsUntilFirstParentOrValidSubject = function (command, memo = []) { - if (!command) { - return null - } + // TODO: prob don't need this anymore + this.commandFns[name] = fn - // push these onto the beginning of the commands array - memo.unshift(command) + const wrap = function (firstCall) { + fn = cy.commandFns[name] + const wrapped = wrapByType(fn, firstCall) - // break and return the memo - if ((command.get('type') === 'parent') || $dom.isAttached(command.get('subject'))) { - return memo - } + wrapped.originalFn = fn - return getCommandsUntilFirstParentOrValidSubject(command.get('prev'), memo) + return wrapped } - const removeSubject = () => { - return state('subject', undefined) + 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 = cy.pushSubjectAndValidate(name, args, firstCall, prevSubject) + + return fn.apply(cy.runnableCtx(name), args) + } } - const pushSubjectAndValidate = function (name, args, firstCall, prevSubject) { - if (firstCall) { - // if we have a prevSubject then error - // since we're invoking this improperly - let needle + cy[name] = function (...args) { + const userInvocationStack = $stackUtils.captureUserInvocationStack(cy.specWindow.Error) - if (prevSubject && ((needle = 'optional', ![].concat(prevSubject).includes(needle)))) { - const stringifiedArg = $utils.stringifyActual(args[0]) + cy.ensureRunnable(name) - $errUtils.throwErrByPath('miscellaneous.invoking_child_without_parent', { - args: { - cmd: name, - args: _.isString(args[0]) ? `\"${stringifiedArg}\"` : stringifiedArg, + // this is the first call on cypress + // so create a new chainer instance + const chain = $Chainer.create(name, userInvocationStack, cy.specWindow, args) + + // store the chain so we can access it later + cy.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 + const ret = cy.state('commandIntermediateValue') + + if (ret) { + const current = cy.state('current') + + // if this is a custom promise + if ($utils.isPromiseLike(ret) && $utils.noArgsAreAFunction(current.get('args'))) { + $errUtils.throwErrByPath( + 'miscellaneous.command_returned_promise_and_commands', { + args: { + current: current.get('name'), + called: name, + }, }, - }) + ) } - - // 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 we're the first call onto a cy + // command, then kick off the run + if (!cy.state('promise')) { + if (cy.state('returnedCustomPromise')) { + cy.warnMixingPromisesAndCommands() + } - if (prevSubject) { - // make sure our current subject is valid for - // what we expect in this command - cy.ensureSubjectByType(subject, prevSubject, name) + cy.queue.run() } - args.unshift(subject) + return chain + } - Cypress.action('cy:next:subject:prepared', subject, args, firstCall) + return this.addChainer(name, (chainer, userInvocationStack, args) => { + const { firstCall, chainerId } = chainer - return args - } + // dont enqueue / inject any new commands if + // onInjectCommand returns false + const onInjectCommand = cy.state('onInjectCommand') + const injected = _.isFunction(onInjectCommand) - const doneEarly = function () { - queue.stop() - - // 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')() + if (injected) { + if (onInjectCommand.call(cy, name, ...args) === false) { + return + } } - return cleanup() - } + cy.enqueue({ + name, + args, + type, + chainerId, + userInvocationStack, + injected, + fn: wrap(firstCall), + }) - 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() - } + return true + }) + } + + now (name, ...args) { + return Promise.resolve( + this.commandFns[name].apply(this, args), + ) + } - // if a command fails then after each commands - // could also fail unless we clear this out - state('commandIntermediateValue', undefined) + replayCommandsFrom (current) { + const cy = this - // reset the nestedIndex back to null - state('nestedIndex', null) + // reset each chainerId to the + // current value + const chainerId = this.state('chainerId') - // also reset recentlyReady back to null - state('recentlyReady', null) + const insert = function (command) { + command.set('chainerId', chainerId) - // 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) + // clone the command to prevent + // mutating its properties + return cy.enqueue(command.clone()) } - const fail = (err, options = {}) => { - // this means the error has already been through this handler and caught - // again. but we don't need to run it through again, so we can re-throw - // it and it will fail the test as-is - if (err && err.hasFailed) { - delete err.hasFailed + // - 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 - throw err - } + const commands = cy.getCommandsUntilFirstParentOrValidSubject(current) - options = _.defaults(options, { - async: false, - }) + if (commands) { + let initialCommand = commands.shift() - let rets + const commandsToInsert = _.reduce(commands, (memo, command, index) => { + const push = () => { + return memo.push(command) + } - queue.stop() + if (!(command.get('type') !== 'assertion')) { + // if we're an assertion and the prev command + // is in the memo, then push this one + if (memo.includes(command.get('prev'))) { + 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() + } - if (typeof err === 'string') { - err = new Error(err) + return memo + }, [initialCommand]) + + for (let c of commandsToInsert) { + insert(c) } + } - err.stack = $stackUtils.normalizedStack(err) + // prevent loop comprehension + return null + } - err = $errUtils.enhanceStack({ - err, - userInvocationStack: $errUtils.getUserInvocationStack(err, state), - projectRoot: config('projectRoot'), - }) + 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, this.state) - err = $errUtils.processErr(err, config) + this.urlNavigationEvent('before:load') - err.hasFailed = true + this.contentWindowListeners(contentWindow) - // store the error on state now - state('error', err) + this.wrapNativeMethods(contentWindow) - const finish = function (err) { - // if the test has a (done) callback, we fail the test with that - const d = state('done') + this.onBeforeWindowLoad() + } - if (d) { - return d(err) - } + onUncaughtException ({ handlerType, frameType, err, promise }) { + err = $errUtils.createUncaughtException({ + handlerType, + frameType, + state: this.state, + err, + }) - // if this failure was asynchronously called (outside the promise chain) - // but the promise chain is still active, reject it. if we're inside - // the promise chain, this isn't necessary and will actually mess it up - const r = state('reject') + const runnable = this.state('runnable') - if (options.async && r) { - return r(err) - } + // don't do anything if we don't have a current runnable + if (!runnable) return + + // uncaught exceptions should be only be catchable in the AUT (app) + // or if in component testing mode, since then the spec frame and + // AUT frame are the same + if (frameType === 'app' || this.config('componentTesting')) { + try { + const results = this.Cypress.action('app:uncaught:exception', err, runnable, promise) - // we're in the promise chain, so throw the error and it will - // get caught by mocha and fail the test - throw err + // dont do anything if any of our uncaught:exception + // listeners returned false + if (_.some(results, returnedFalse)) { + // return true to signal that the user handled this error + return true + } + } catch (uncaughtExceptionErr) { + err = $errUtils.createUncaughtException({ + err: uncaughtExceptionErr, + handlerType: 'error', + frameType: 'spec', + state: this.state, + }) } + } - // 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 + try { + this.fail(err) + } catch (failErr) { + const r = this.state('reject') - return finish(err) + if (r) { + r(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 + setRunnable (runnable, hookId) { + // when we're setting a new runnable + // prepare to run again! + this.queue.reset() - // 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 + // reset the promise again + this.state('promise', undefined) - return fail(cyFailErr) - } + this.state('hookId', hookId) - // bail if we had callbacks attached - if (rets && rets.length) { - return - } + this.state('runnable', runnable) - // else figure out how to finish this failure - return finish(err) - } + this.state('test', $utils.getTestFromRunnable(runnable)) - const queue = $CommandQueue.create(state, cy.timeout, cy.whenStable, cleanup, fail, isCy) - - _.extend(cy, { - // command queue instance - queue, - - // errors sync methods - fail, - - // is cy - isCy, - - isStopped, - - 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 cy.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) - } - } - }) - }, + this.state('ctx', runnable.ctx) - stop () { - // don't do anything if we've already stopped - if (queue.stopped) { - return - } + const { fn } = runnable - return doneEarly() - }, + const restore = () => { + return runnable.fn = fn + } - // reset is called before each test - reset (test) { - try { - const s = state() - - const backup = { - window: s.window, - document: s.document, - $autIframe: s.$autIframe, - specWindow: s.specWindow, - activeSessions: s.activeSessions, - } + const cy = this - // reset state back to empty object - state.reset() + runnable.fn = function () { + restore() - // and then restore these backed up props - state(backup) + const timeout = cy.config('defaultCommandTimeout') - queue.reset() - queue.clear() - cy.resetTimer() - testConfigOverride.restoreAndSetTestConfigOverrides(test, Cypress.config, Cypress.env) + // control timeouts on runnables ourselves + if (_.isFinite(timeout)) { + cy.timeout(timeout) + } - cy.removeAllListeners() - } catch (err) { - fail(err) - } - }, + // store the current length of our queue + // before we invoke the runnable.fn + const currentLength = cy.queue.length - addCommandSync (name, fn) { - cy[name] = function () { - return fn.apply(runnableCtx(name), arguments) - } - }, + 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 - addChainer (name, fn) { - // add this function to our chainer class - return $Chainer.add(name, fn) - }, + if (fn.length) { + const originalDone = arguments[0] - addCommand ({ name, fn, type, prevSubject }) { - // TODO: prob don't need this anymore - commandFns[name] = fn + arguments[0] = (done = function (err) { + // TODO: handle no longer error when ended early + cy.doneEarly() - const wrap = function (firstCall) { - fn = commandFns[name] - const wrapped = wrapByType(fn, firstCall) + originalDone(err) - wrapped.originalFn = fn + // 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 + }) - return wrapped + // store this done property + // for async tests + cy.state('done', done) } - 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) + let ret = __stackReplacementMarker(fn, this, arguments) + + // if we returned a value from fn + // and enqueued some new commands + // and the value isn't currently cy + // or a promise + if (ret && + cy.queue.length > currentLength && + !cy.isCy(ret) && + !$utils.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 }, + }) + } - return fn.apply(runnableCtx(name), args) - } + // 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) } - cy[name] = function (...args) { - const userInvocationStack = $stackUtils.captureUserInvocationStack(specWindow.Error) - - let ret - - cy.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 ($utils.isPromiseLike(ret) && $utils.noArgsAreAFunction(current.get('args'))) { - $errUtils.throwErrByPath( - 'miscellaneous.command_returned_promise_and_commands', { - args: { - current: current.get('name'), - called: name, - }, - }, - ) - } + // if we returned a promise like object + if (!cy.isCy(ret) && $utils.isPromiseLike(ret)) { + // indicate we've returned a custom promise + cy.state('returnedCustomPromise', true) + + // this means we instantiated a promise + // and we've already invoked multiple + // commands and should warn + if (cy.queue.length > currentLength) { + cy.warnMixingPromisesAndCommands() } - // if we're the first call onto a cy - // command, then kick off the run - if (!state('promise')) { - if (state('returnedCustomPromise')) { - warnMixingPromisesAndCommands() - } + return ret + } - queue.run() + // if we're cy or we've enqueued commands + if (cy.isCy(ret) || cy.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 } - return chain + // otherwise, return the 'queue promise', so mocha awaits it + return cy.state('promise') } - return cy.addChainer(name, (chainer, userInvocationStack, args) => { - const { firstCall, chainerId } = chainer + // 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 cy.fail(err) + } + } + } - // dont enqueue / inject any new commands if - // onInjectCommand returns false - const onInjectCommand = state('onInjectCommand') - const injected = _.isFunction(onInjectCommand) + private wrapNativeMethods (contentWindow) { + try { + // return null to trick contentWindow into thinking + // its not been iframed if modifyObstructiveCode is true + if (this.config('modifyObstructiveCode')) { + Object.defineProperty(contentWindow, 'frameElement', { + get () { + return null + }, + }) + } - if (injected) { - if (onInjectCommand.call(cy, name, ...args) === false) { - return - } - } + const cy = this - enqueue({ - name, - args, - type, - chainerId, - userInvocationStack, - injected, - fn: wrap(firstCall), - }) + contentWindow.HTMLElement.prototype.focus = function (focusOption) { + return cy.interceptFocus(this, contentWindow, focusOption) + } - return true - }) - }, + contentWindow.HTMLElement.prototype.blur = function () { + return cy.interceptBlur(this) + } - now (name, ...args) { - return Promise.resolve( - commandFns[name].apply(cy, args), - ) - }, + contentWindow.SVGElement.prototype.focus = function (focusOption) { + return cy.interceptFocus(this, contentWindow, focusOption) + } - replayCommandsFrom (current) { - // reset each chainerId to the - // current value - const chainerId = state('chainerId') + contentWindow.SVGElement.prototype.blur = function () { + return cy.interceptBlur(this) + } - const insert = function (command) { - command.set('chainerId', chainerId) + contentWindow.HTMLInputElement.prototype.select = function () { + return $selection.interceptSelect.call(this) + } - // clone the command to prevent - // mutating its properties - return enqueue(command.clone()) - } + contentWindow.document.hasFocus = function () { + return cy.documentHasFocus.call(this) + } - // - 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 - } + const cssModificationSpy = function (original, ...args) { + cy.onCssModified(this.href) - , [initialCommand]) + return original.apply(this, args) + } - for (let c of commandsToInsert) { - insert(c) - } - } + const { insertRule } = contentWindow.CSSStyleSheet.prototype + const { deleteRule } = contentWindow.CSSStyleSheet.prototype - // prevent loop comprehension - return null - }, + contentWindow.CSSStyleSheet.prototype.insertRule = _.wrap(insertRule, cssModificationSpy) + contentWindow.CSSStyleSheet.prototype.deleteRule = _.wrap(deleteRule, cssModificationSpy) - 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) + if (this.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 + this.state('fetchPolyfilled', true) + } + } catch (error) { } // eslint-disable-line no-empty + } - urlNavigationEvent('before:load') + private warnMixingPromisesAndCommands () { + const title = this.state('runnable').fullTitle() - contentWindowListeners(contentWindow) + $errUtils.warnByPath('miscellaneous.mixing_promises_and_commands', { + args: { title }, + }) + } - cy.wrapNativeMethods(contentWindow) + private runnableCtx (name) { + this.ensureRunnable(name) - cy.onBeforeWindowLoad() - }, + return this.state('runnable').ctx + } - onUncaughtException ({ handlerType, frameType, err, promise }) { - err = $errUtils.createUncaughtException({ - handlerType, - frameType, - state, + private urlNavigationEvent (event) { + return this.Cypress.action('app:navigation:changed', `page navigation event (${event})`) + } + + private cleanup () { + // cleanup could be called during a 'stop' event which + // could happen in between a runnable because they are async + if (this.state('runnable')) { + // make sure we reset the runnable's timeout now + this.state('runnable').resetTimeout() + } + + // if a command fails then after each commands + // could also fail unless we clear this out + this.state('commandIntermediateValue', undefined) + + // reset the nestedIndex back to null + this.state('nestedIndex', null) + + // also reset recentlyReady back to null + this.state('recentlyReady', null) + + // and forcibly move the index needle to the + // end in case we have after / afterEach hooks + // which need to run + return this.state('index', this.queue.length) + } + + private contentWindowListeners (contentWindow) { + const cy = this + + $Listeners.bindTo(contentWindow, { + // eslint-disable-next-line @cypress/dev/arrow-body-multiline-braces + onError: (handlerType) => (event) => { + const { originalErr, err, promise } = $errUtils.errorFromUncaughtEvent(handlerType, event) + const handled = cy.onUncaughtException({ err, + promise, + handlerType, + frameType: 'app', }) - const runnable = state('runnable') - - // don't do anything if we don't have a current runnable - if (!runnable) return - - // uncaught exceptions should be only be catchable in the AUT (app) - // or if in component testing mode, since then the spec frame and - // AUT frame are the same - if (frameType === 'app' || config('componentTesting')) { - try { - const results = Cypress.action('app:uncaught:exception', err, runnable, promise) - - // dont do anything if any of our uncaught:exception - // listeners returned false - if (_.some(results, returnedFalse)) { - // return true to signal that the user handled this error - return true - } - } catch (uncaughtExceptionErr) { - err = $errUtils.createUncaughtException({ - err: uncaughtExceptionErr, - handlerType: 'error', - frameType: 'spec', - state, - }) - } - } + debugErrors('uncaught AUT error: %o', originalErr) - try { - fail(err) - } catch (failErr) { - const r = state('reject') + $errUtils.logError(cy.Cypress, handlerType, originalErr, handled) - if (r) { - r(err) - } - } + // return undefined so the browser does its default + // uncaught exception behavior (logging to console) + return undefined }, + onSubmit (e) { + return cy.Cypress.action('app:form:submitted', e) + }, + onBeforeUnload (e) { + cy.isStable(false, 'beforeunload') - setRunnable (runnable, hookId) { - // when we're setting a new runnable - // prepare to run again! - queue.reset() + cy.Cookies.setInitial() - // reset the promise again - state('promise', undefined) + cy.resetTimer() - state('hookId', hookId) + cy.Cypress.action('app:window:before:unload', e) - state('runnable', runnable) + // return undefined so our beforeunload handler + // doesn't trigger a confirmation dialog + return undefined + }, + onUnload (e) { + return cy.Cypress.action('app:window:unload', e) + }, + onNavigation (...args) { + return cy.Cypress.action('app:navigation:changed', ...args) + }, + onAlert (str) { + return cy.Cypress.action('app:window:alert', str) + }, + onConfirm (str) { + const results = cy.Cypress.action('app:window:confirm', str) - state('test', $utils.getTestFromRunnable(runnable)) + // return false if ANY results are false + // else true + const ret = !_.some(results, returnedFalse) - state('ctx', runnable.ctx) + cy.Cypress.action('app:window:confirmed', str, ret) - const { fn } = runnable + return ret + }, + }) + } - const restore = () => { - return runnable.fn = fn - } + private enqueue (obj) { + // if we have a nestedIndex it means we're processing + // nested commands and need to insert 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 insert it into the existing array. + let nestedIndex = this.state('nestedIndex') + + // if this is a number, then we know we're about to insert this + // into our commands and need to reset next + increment the index + if (_.isNumber(nestedIndex)) { + this.state('nestedIndex', (nestedIndex += 1)) + } - runnable.fn = function () { - restore() + // we look at whether or not nestedIndex is a number, because if it + // is then we need to insert inside of our commands, else just push + // it onto the end of the queue + const index = _.isNumber(nestedIndex) ? nestedIndex : this.queue.length - const timeout = config('defaultCommandTimeout') + this.queue.insert(index, $Command.create(obj)) - // control timeouts on runnables ourselves - if (_.isFinite(timeout)) { - cy.timeout(timeout) - } + return this.Cypress.action('cy:command:enqueued', obj) + } - // 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 isn't currently cy - // or a promise - if (ret && - (queue.length > currentLength) && - (!isCy(ret)) && - (!$utils.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)) && $utils.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) - } - } - }, - }) + private getCommandsUntilFirstParentOrValidSubject (command, memo = []) { + if (!command) { + return null + } - setTopOnError(Cypress, cy) + // push these onto the beginning of the commands array + memo.unshift(command) - // make cy global in the specWindow - specWindow.cy = cy + // break and return the memo + if ((command.get('type') === 'parent') || $dom.isAttached(command.get('subject'))) { + return memo + } + + return this.getCommandsUntilFirstParentOrValidSubject(command.get('prev'), memo) + } + + private pushSubjectAndValidate (name, args, firstCall, prevSubject) { + if (firstCall) { + // if we have a prevSubject then error + // since we're invoking this improperly + if (prevSubject && ![].concat(prevSubject).includes('optional')) { + 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) + this.state('subject', undefined) + } - $Events.extend(cy) + const subject = this.state('subject') - return cy - }, + if (prevSubject) { + // make sure our current subject is valid for + // what we expect in this command + this.ensureSubjectByType(subject, prevSubject, name) + } + + args.unshift(subject) + + this.Cypress.action('cy:next:subject:prepared', subject, args, firstCall) + + return args + } + + private doneEarly () { + this.queue.stop() + + // 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 = this.state('promise') + + // if our outer promise is pending + // then cancel outer and inner + // and set canceled to be true + if (p && p.isPending()) { + this.state('canceled', true) + this.state('cancel')() + } + + return this.cleanup() + } } diff --git a/packages/driver/src/util/queue.ts b/packages/driver/src/util/queue.ts index 3e37016fa58..4a1564979d3 100644 --- a/packages/driver/src/util/queue.ts +++ b/packages/driver/src/util/queue.ts @@ -6,111 +6,102 @@ interface QueueRunProps { onFinish: () => void } -export default { - create: (queueables: T[] = []) => { - let stopped = false +export class Queue { + private queueables: T[] = [] + private _stopped = false - const get = (): T[] => { - return queueables - } + constructor (queueables: T[] = []) { + this.queueables = queueables + } - const add = (queueable: T) => { - queueables.push(queueable) - } + get (): T[] { + return this.queueables + } - const insert = (index: number, queueable: T) => { - if (index < 0 || index > queueables.length) { - throw new Error(`queue.insert must be called with a valid index - the index (${index}) is out of bounds`) - } + add (queueable: T) { + this.queueables.push(queueable) + } - queueables.splice(index, 0, queueable) - - return queueable + insert (index: number, queueable: T) { + if (index < 0 || index > this.queueables.length) { + throw new Error(`queue.insert must be called with a valid index - the index (${index}) is out of bounds`) } - const slice = (index: number) => { - return queueables.slice(index) - } - - const at = (index: number): T => { - return get()[index] - } - - const reset = () => { - stopped = false - } - - const clear = () => { - queueables.length = 0 - } - - const stop = () => { - stopped = true - } - - const run = ({ onRun, onError, onFinish }: QueueRunProps) => { - let inner - let rejectOuterAndCancelInner - - // this ends up being the parent promise wrapper - const promise = new Bluebird((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 - // the promise being defined - inner = Bluebird - .resolve(null) - .then(onRun) - .then(resolve) - .catch(reject) - - // can't use onCancel argument here because it's 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 - rejectOuterAndCancelInner = (err) => { - inner.cancel() - reject(err) - } - }) - .catch(onError) - .finally(onFinish) - - const cancel = () => { - promise.cancel() + this.queueables.splice(index, 0, queueable) + + return queueable + } + + slice (index: number) { + return this.queueables.slice(index) + } + + at (index: number): T { + return this.queueables[index] + } + + reset () { + this._stopped = false + } + + clear () { + this.queueables.length = 0 + } + + stop () { + this._stopped = true + } + + run ({ onRun, onError, onFinish }: QueueRunProps) { + let inner + let rejectOuterAndCancelInner + + // this ends up being the parent promise wrapper + const promise = new Bluebird((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 + // the promise being defined + inner = Bluebird + .resolve(null) + .then(onRun) + .then(resolve) + .catch(reject) + + // can't use onCancel argument here because it's 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 + rejectOuterAndCancelInner = (err) => { inner.cancel() + reject(err) } + }) + .catch(onError) + .finally(onFinish) - return { - promise, - cancel, - // wrapped to ensure `rejectOuterAndCancelInner` is assigned - // before reject is called - reject: (err) => rejectOuterAndCancelInner(err), - } + const cancel = () => { + promise.cancel() + inner.cancel() } return { - get, - add, - insert, - slice, - at, - reset, - clear, - stop, - run, - - get length () { - return queueables.length - }, - - get stopped () { - return stopped - }, + promise, + cancel, + // wrapped to ensure `rejectOuterAndCancelInner` is assigned + // before reject is called + reject: (err) => rejectOuterAndCancelInner(err), } - }, + } + + get length () { + return this.queueables.length + } + + get stopped () { + return this._stopped + } }