diff --git a/cli/types/index.d.ts b/cli/types/index.d.ts index 020b76e007c5..a17c341f7a0a 100644 --- a/cli/types/index.d.ts +++ b/cli/types/index.d.ts @@ -620,7 +620,14 @@ declare namespace Cypress { * * @see https://on.cypress.io/dblclick */ - dblclick(options?: Partial): Chainable + dblclick(options?: Partial): Chainable + + /** + * Right-click a DOM element. + * + * @see https://on.cypress.io/rightclick + */ + rightclick(options?: Partial): Chainable /** * Set a debugger and log what the previous command yields. diff --git a/cli/types/tests/chainer-examples.ts b/cli/types/tests/chainer-examples.ts index 020b447e7e0f..4825f497c032 100644 --- a/cli/types/tests/chainer-examples.ts +++ b/cli/types/tests/chainer-examples.ts @@ -452,3 +452,7 @@ cy.writeFile('../file.path', '', { flag: 'a+', encoding: 'utf-8' }) + +cy.get('foo').click() +cy.get('foo').rightclick() +cy.get('foo').dblclick() diff --git a/packages/driver/src/cy/actionability.coffee b/packages/driver/src/cy/actionability.coffee index fce7ac3952a1..6a195c636a51 100644 --- a/packages/driver/src/cy/actionability.coffee +++ b/packages/driver/src/cy/actionability.coffee @@ -36,19 +36,19 @@ getPositionFromArguments = (positionOrX, y, options) -> return {options, position, x, y} -ensureElIsNotCovered = (cy, win, $el, fromViewport, options, log, onScroll) -> +ensureElIsNotCovered = (cy, win, $el, fromElViewport, options, log, onScroll) -> $elAtCoords = null - getElementAtPointFromViewport = (fromViewport) -> + getElementAtPointFromViewport = (fromElViewport) -> ## get the element at point from the viewport based ## on the desired x/y normalized coordinations - if elAtCoords = $dom.getElementAtPointFromViewport(win.document, fromViewport.x, fromViewport.y) + if elAtCoords = $dom.getElementAtPointFromViewport(win.document, fromElViewport.x, fromElViewport.y) $elAtCoords = $dom.wrap(elAtCoords) - ensureDescendents = (fromViewport) -> + ensureDescendents = (fromElViewport) -> ## figure out the deepest element we are about to interact ## with at these coordinates - $elAtCoords = getElementAtPointFromViewport(fromViewport) + $elAtCoords = getElementAtPointFromViewport(fromElViewport) cy.ensureElDoesNotHaveCSS($el, 'pointer-events', 'none', log) cy.ensureDescendents($el, $elAtCoords, log) @@ -57,8 +57,8 @@ ensureElIsNotCovered = (cy, win, $el, fromViewport, options, log, onScroll) -> ensureDescendentsAndScroll = -> try - ## use the initial coords fromViewport - ensureDescendents(fromViewport) + ## use the initial coords fromElViewport + ensureDescendents(fromElViewport) catch err ## if we're being covered by a fixed position element then ## we're going to attempt to continously scroll the element @@ -146,16 +146,16 @@ ensureElIsNotCovered = (cy, win, $el, fromViewport, options, log, onScroll) -> ## now that we've changed scroll positions ## we must recalculate whether this element is covered ## since the element's top / left positions change. - fromViewport = getCoordinatesForEl(cy, $el, options).fromViewport + fromElViewport = getCoordinatesForEl(cy, $el, options).fromElViewport ## this is a relative calculation based on the viewport ## so these are the only coordinates we care about - ensureDescendents(fromViewport) + ensureDescendents(fromElViewport) catch err ## we failed here, but before scrolling the next container ## we need to first verify that the element covering up ## is the same one as before our scroll - if $elAtCoords = getElementAtPointFromViewport(fromViewport) + if $elAtCoords = getElementAtPointFromViewport(fromElViewport) ## get the fixed element again $fixed = getFixedOrStickyEl($elAtCoords) @@ -201,7 +201,7 @@ ensureNotAnimating = (cy, $el, coordsHistory, animationDistanceThreshold) -> ## if we dont have at least 2 points ## then automatically retry if coordsHistory.length < 2 - throw new Error("coordsHistory must be at least 2 sets of coords") + throw $utils.cypressErr("coordsHistory must be at least 2 sets of coords") ## verify that our element is not currently animating ## by verifying it is still at the same coordinates within @@ -277,7 +277,7 @@ verify = (cy, $el, options, callbacks) -> ## (see https://github.com/cypress-io/cypress/pull/1478) sticky = !!getStickyEl($el) - coordsHistory.push(if sticky then coords.fromViewport else coords.fromWindow) + coordsHistory.push(if sticky then coords.fromElViewport else coords.fromElWindow) ## then we ensure the element isnt animating ensureNotAnimating(cy, $el, coordsHistory, options.animationDistanceThreshold) @@ -285,8 +285,8 @@ verify = (cy, $el, options, callbacks) -> ## now that we know our element isn't animating its time ## to figure out if its being covered by another element. ## this calculation is relative from the viewport so we - ## only care about fromViewport coords - $elAtCoords = options.ensure.notCovered && ensureElIsNotCovered(cy, win, $el, coords.fromViewport, options, _log, onScroll) + ## only care about fromElViewport coords + $elAtCoords = options.ensure.notCovered && ensureElIsNotCovered(cy, win, $el, coords.fromElViewport, options, _log, onScroll) ## pass our final object into onReady finalEl = $elAtCoords ? $el diff --git a/packages/driver/src/cy/commands/actions/click.js b/packages/driver/src/cy/commands/actions/click.js index 42a4efa4ca77..cd83ad7cbd14 100644 --- a/packages/driver/src/cy/commands/actions/click.js +++ b/packages/driver/src/cy/commands/actions/click.js @@ -1,303 +1,339 @@ const _ = require('lodash') +const $ = require('jquery') const Promise = require('bluebird') const $dom = require('../../../dom') const $utils = require('../../../cypress/utils') -const $elements = require('../../../dom/elements') -const $selection = require('../../../dom/selection') const $actionability = require('../../actionability') -module.exports = (Commands, Cypress, cy, state, config) => { - const { mouse } = cy.devices - - return Commands.addAll({ prevSubject: 'element' }, { - click (subject, positionOrX, y, options = {}) { - //# TODO handle pointer-events: none - //# http://caniuse.com/#feat=pointer-events - - let position; let x; - - ({ options, position, x, y } = $actionability.getPositionFromArguments(positionOrX, y, options)) - - _.defaults(options, { - $el: subject, - log: true, - verify: true, - force: false, - multiple: false, - position, - x, - y, - errorOnSelect: true, - waitForAnimations: config('waitForAnimations'), - animationDistanceThreshold: config('animationDistanceThreshold'), - }) +const formatMoveEventsTable = (events) => { + return { + name: `Mouse Move Events${events ? '' : ' (skipped)'}`, + data: _.map(events, (obj) => { + const key = _.keys(obj)[0] + const val = obj[_.keys(obj)[0]] + + if (val.skipped) { + const reason = val.skipped + + // no modifiers can be present + // on move events + return { + 'Event Name': key, + 'Target Element': reason, + 'Prevented Default?': null, + 'Stopped Propagation?': null, + } + } - //# throw if we're trying to click multiple elements - //# and we did not pass the multiple flag - if ((options.multiple === false) && (options.$el.length > 1)) { - $utils.throwErrByPath('click.multiple_elements', { - args: { num: options.$el.length }, - }) + // no modifiers can be present + // on move events + return { + 'Event Name': key, + 'Target Element': val.el, + 'Prevented Default?': val.preventedDefault, + 'Stopped Propagation?': val.stoppedPropagation, } + }), + } +} - state('window') +const formatMouseEvents = (events) => { + return _.map(events, (val, key) => { + if (val.skipped) { - const click = (el) => { - let deltaOptions - const $el = $dom.wrap(el) + const reason = val.skipped - const domEvents = {} + return { + 'Event Name': key.slice(0, -5), + 'Target Element': reason, + 'Prevented Default?': null, + 'Stopped Propagation?': null, + 'Modifiers': null, + } + } + + return { + 'Event Name': key.slice(0, -5), + 'Target Element': val.el, + 'Prevented Default?': val.preventedDefault, + 'Stopped Propagation?': val.stoppedPropagation, + 'Modifiers': val.modifiers ? val.modifiers : null, + } + }) +} - if (options.log) { - //# figure out the options which actually change the behavior of clicks - deltaOptions = $utils.filterOutOptions(options) +module.exports = (Commands, Cypress, cy, state, config) => { + const { mouse } = cy.devices - options._log = Cypress.log({ - message: deltaOptions, - $el, - }) + const mouseAction = (eventName, { subject, positionOrX, y, options, onReady, onTable }) => { + let position + let x + + ({ options, position, x, y } = $actionability.getPositionFromArguments(positionOrX, y, options)) + + _.defaults(options, { + $el: subject, + log: true, + verify: true, + force: false, + multiple: false, + position, + x, + y, + errorOnSelect: true, + waitForAnimations: config('waitForAnimations'), + animationDistanceThreshold: config('animationDistanceThreshold'), + }) + + // throw if we're trying to click multiple elements + // and we did not pass the multiple flag + if ((options.multiple === false) && (options.$el.length > 1)) { + $utils.throwErrByPath('click.multiple_elements', { + args: { cmd: eventName, num: options.$el.length }, + }) + } - options._log.snapshot('before', { next: 'after' }) - } + const perform = (el) => { + let deltaOptions + const $el = $dom.wrap(el) - if (options.errorOnSelect && $el.is('select')) { - $utils.throwErrByPath('click.on_select_element', { onFail: options._log }) - } + if (options.log) { + // figure out the options which actually change the behavior of clicks + deltaOptions = $utils.filterOutOptions(options) - const afterMouseDown = function ($elToClick, coords) { - //# we need to use both of these - let consoleObj - const { fromWindow, fromViewport } = coords + options._log = Cypress.log({ + message: deltaOptions, + $el, + }) - //# handle mouse events removing DOM elements - //# https://www.w3.org/TR/uievents/#event-type-click (scroll up slightly) + options._log.snapshot('before', { next: 'after' }) + } - if ($dom.isAttached($elToClick)) { - domEvents.mouseUp = mouse.mouseUp($elToClick, fromViewport) - } + if (options.errorOnSelect && $el.is('select')) { + $utils.throwErrByPath('click.on_select_element', { + args: { cmd: eventName }, + onFail: options._log, + }) + } - if ($dom.isAttached($elToClick)) { - domEvents.click = mouse.click($elToClick, fromViewport) - } + // we want to add this delay delta to our + // runnables timeout so we prevent it from + // timing out from multiple clicks + cy.timeout($actionability.delay, true, eventName) - if (options._log) { - consoleObj = options._log.invoke('consoleProps') - } + const createLog = (domEvents, fromElWindow, fromAutWindow) => { + let consoleObj - const consoleProps = function () { - consoleObj = _.defaults(consoleObj != null ? consoleObj : {}, { - 'Applied To': $dom.getElements($el), - 'Elements': $el.length, - 'Coords': _.pick(fromWindow, 'x', 'y'), //# always absolute - 'Options': deltaOptions, - }) - - if ($el.get(0) !== $elToClick.get(0)) { - //# only do this if $elToClick isnt $el - consoleObj['Actual Element Clicked'] = $dom.getElements($elToClick) - } - - consoleObj.groups = function () { - const groups = [{ - name: 'MouseDown', - items: _.pick(domEvents.mouseDown, 'preventedDefault', 'stoppedPropagation', 'modifiers'), - }] - - if (domEvents.mouseUp) { - groups.push({ - name: 'MouseUp', - items: _.pick(domEvents.mouseUp, 'preventedDefault', 'stoppedPropagation', 'modifiers'), - }) - } + const elClicked = domEvents.moveEvents.el - if (domEvents.click) { - groups.push({ - name: 'Click', - items: _.pick(domEvents.click, 'preventedDefault', 'stoppedPropagation', 'modifiers'), - }) - } + if (options._log) { + consoleObj = options._log.invoke('consoleProps') + } - return groups - } + const consoleProps = function () { + consoleObj = _.defaults(consoleObj != null ? consoleObj : {}, { + 'Applied To': $dom.getElements(options.$el), + 'Elements': options.$el.length, + 'Coords': _.pick(fromElWindow, 'x', 'y'), // always absolute + 'Options': deltaOptions, + }) - return consoleObj + if (options.$el.get(0) !== elClicked) { + // only do this if $elToClick isnt $el + consoleObj['Actual Element Clicked'] = $dom.getElements($(elClicked)) } - return Promise - .delay($actionability.delay, 'click') - .then(() => { - //# display the red dot at these coords - if (options._log) { - //# because we snapshot and output a command per click - //# we need to manually snapshot + end them - options._log.set({ coords: fromWindow, consoleProps }) - } - - //# we need to split this up because we want the coordinates - //# to mutate our passed in options._log but we dont necessary - //# want to snapshot and end our command if we're a different - //# action like (cy.type) and we're borrowing the click action - if (options._log && options.log) { - return options._log.snapshot().end() - } - }).return(null) + consoleObj.table = _.extend((consoleObj.table || {}), onTable(domEvents)) + + return consoleObj } - //# we want to add this delay delta to our - //# runnables timeout so we prevent it from - //# timing out from multiple clicks - cy.timeout($actionability.delay, true, 'click') - - //# must use callbacks here instead of .then() - //# because we're issuing the clicks synchonrously - //# once we establish the coordinates and the element - //# passes all of the internal checks - return $actionability.verify(cy, $el, options, { - onScroll ($el, type) { - return Cypress.action('cy:scrolled', $el, type) - }, + return Promise + .delay($actionability.delay, 'click') + .then(() => { + // display the red dot at these coords + if (options._log) { + // because we snapshot and output a command per click + // we need to manually snapshot + end them + options._log.set({ coords: fromAutWindow, consoleProps }) + } - onReady ($elToClick, coords) { - //# record the previously focused element before - //# issuing the mousedown because browsers may - //# automatically shift the focus to the element - //# without firing the focus event - const $previouslyFocused = cy.getFocused() + // we need to split this up because we want the coordinates + // to mutate our passed in options._log but we dont necessary + // want to snapshot and end our command if we're a different + // action like (cy.type) and we're borrowing the click action + if (options._log && options.log) { + return options._log.snapshot().end() + } + }) + .return(null) + } - el = $elToClick.get(0) + // must use callbacks here instead of .then() + // because we're issuing the clicks synchonrously + // once we establish the coordinates and the element + // passes all of the internal checks + return $actionability.verify(cy, $el, options, { + onScroll ($el, type) { + return Cypress.action('cy:scrolled', $el, type) + }, - domEvents.mouseDown = mouse.mouseDown($elToClick, coords.fromViewport) + onReady ($elToClick, coords) { + const { fromElViewport, fromElWindow, fromAutWindow } = coords - //# if mousedown was canceled then or caused - //# our element to be removed from the DOM - //# just resolve after mouse down and dont - //# send a focus event - if (domEvents.mouseDown.preventedDefault || !$dom.isAttached($elToClick)) { - return afterMouseDown($elToClick, coords) - } + const forceEl = options.force && $elToClick.get(0) - if ($elements.isInput(el) || $elements.isTextarea(el) || $elements.isContentEditable(el)) { - if (!$elements.isNeedSingleValueChangeInputElement(el)) { - $selection.moveSelectionToEnd(el) - } - } - - //# retrieve the first focusable $el in our parent chain - const $elToFocus = $elements.getFirstFocusableEl($elToClick) - - if (cy.needsFocus($elToFocus, $previouslyFocused)) { - if ($dom.isWindow($elToFocus)) { - // if the first focusable element from the click - // is the window, then we can skip the focus event - // since the user has clicked a non-focusable element - const $focused = cy.getFocused() - - if ($focused) { - cy.fireBlur($focused.get(0)) - } - } else { - // the user clicked inside a focusable element - cy.fireFocus($elToFocus.get(0)) - } - } + const moveEvents = mouse.move(fromElViewport, forceEl) - return afterMouseDown($elToClick, coords) + const onReadyProps = onReady(fromElViewport, forceEl) + return createLog({ + moveEvents, + ...onReadyProps, }, - }) - .catch((err) => { - //# snapshot only on click failure - err.onFail = function () { - if (options._log) { - return options._log.snapshot() - } + fromElWindow, + fromAutWindow) + }, + }) + .catch((err) => { + // snapshot only on click failure + err.onFail = function () { + if (options._log) { + return options._log.snapshot() } - - //# if we give up on waiting for actionability then - //# lets throw this error and log the command - return $utils.throwErr(err, { onFail: options._log }) - }) - } - - return Promise - .each(options.$el.toArray(), click) - .then(() => { - let verifyAssertions - - if (options.verify === false) { - return options.$el } - return (verifyAssertions = () => { - return cy.verifyUpcomingAssertions(options.$el, options, { - onRetry: verifyAssertions, - }) - })() + // if we give up on waiting for actionability then + // lets throw this error and log the command + return $utils.throwErr(err, { onFail: options._log }) }) - }, + } - //# update dblclick to use the click - //# logic and just swap out the event details? - dblclick (subject, options = {}) { - _.defaults(options, - { log: true }) - - const dblclicks = [] + return Promise + .each(options.$el.toArray(), perform) + .then(() => { + if (options.verify === false) { + return options.$el + } - const dblclick = (el) => { - let log - const $el = $dom.wrap(el) + const verifyAssertions = () => { + return cy.verifyUpcomingAssertions(options.$el, options, { + onRetry: verifyAssertions, + }) + } - //# we want to add this delay delta to our - //# runnables timeout so we prevent it from - //# timing out from multiple clicks - cy.timeout($actionability.delay, true, 'dblclick') + return verifyAssertions() + }) + } - if (options.log) { - log = Cypress.log({ - $el, - consoleProps () { + return Commands.addAll({ prevSubject: 'element' }, { + click (subject, positionOrX, y, options = {}) { + return mouseAction('click', { + y, + subject, + options, + positionOrX, + onReady (fromElViewport, forceEl) { + const clickEvents = mouse.click(fromElViewport, forceEl) + + return { + clickEvents, + } + }, + onTable (domEvents) { + return { + 1: () => { + return formatMoveEventsTable(domEvents.moveEvents.events) + }, + 2: () => { return { - 'Applied To': $dom.getElements($el), - 'Elements': $el.length, + name: 'Mouse Click Events', + data: formatMouseEvents(domEvents.clickEvents), } }, - }) - } - - cy.ensureVisibility($el, log) - - const p = cy.now('focus', $el, { $el, error: false, verify: false, log: false }).then(() => { - const event = new MouseEvent('dblclick', { - bubbles: true, - cancelable: true, - }) - - el.dispatchEvent(event) - - // $el.cySimulate("dblclick") - - // log.snapshot() if log - - //# need to return null here to prevent - //# chaining thenable promises - return null - }).delay($actionability.delay, 'dblclick') - - dblclicks.push(p) + } + }, + }) + }, - return p - } + dblclick (subject, positionOrX, y, options = {}) { + // TODO: 4.0 make this false by default + options.multiple = true - //# create a new promise and chain off of it using reduce to insert - //# the artificial delays. we have to set this as cancelable for it - //# to propogate since this is an "inner" promise + return mouseAction('dblclick', { + y, + subject, + options, + positionOrX, + onReady (fromElViewport, forceEl) { + const { clickEvents1, clickEvents2, dblclickProps } = mouse.dblclick(fromElViewport, forceEl) + + return { + dblclickProps, + clickEvents: [clickEvents1, clickEvents2], + } + }, + onTable (domEvents) { + return { + 1: () => { + return formatMoveEventsTable(domEvents.moveEvents.events) + }, + 2: () => { + return { + name: 'Mouse Click Events', + data: _.concat( + formatMouseEvents(domEvents.clickEvents[0], formatMouseEvents), + formatMouseEvents(domEvents.clickEvents[1], formatMouseEvents) + ), + } + }, + 3: () => { + return { + name: 'Mouse Double Click Event', + data: formatMouseEvents({ + dblclickProps: domEvents.dblclickProps, + }), + } + }, + } + }, + }) + }, - //# return our original subject when our promise resolves - return Promise - .resolve(subject.toArray()) - .each(dblclick) - .return(subject) + rightclick (subject, positionOrX, y, options = {}) { + return mouseAction('rightclick', { + y, + subject, + options, + positionOrX, + onReady (fromElViewport, forceEl) { + const { clickEvents, contextmenuEvent } = mouse.rightclick(fromElViewport, forceEl) + + return { + clickEvents, + contextmenuEvent, + } + }, + onTable (domEvents) { + return { + 1: () => { + return formatMoveEventsTable(domEvents.moveEvents.events) + }, + 2: () => { + return { + name: 'Mouse Click Events', + data: formatMouseEvents(domEvents.clickEvents, formatMouseEvents), + } + }, + 3: () => { + return { + name: 'Mouse Right Click Event', + data: formatMouseEvents(domEvents.contextmenuEvent), + } + }, + } + }, + }) }, }) } diff --git a/packages/driver/src/cy/commands/actions/trigger.coffee b/packages/driver/src/cy/commands/actions/trigger.coffee index 5696fb998f8d..b27b4e7cc34e 100644 --- a/packages/driver/src/cy/commands/actions/trigger.coffee +++ b/packages/driver/src/cy/commands/actions/trigger.coffee @@ -2,6 +2,8 @@ _ = require("lodash") Promise = require("bluebird") $dom = require("../../../dom") +$elements = require("../../../dom/elements") +$window = require("../../../dom/window") $utils = require("../../../cypress/utils") $actionability = require("../../actionability") @@ -82,19 +84,21 @@ module.exports = (Commands, Cypress, cy, state, config) -> Cypress.action("cy:scrolled", $el, type) onReady: ($elToClick, coords) -> - { fromWindow, fromViewport } = coords + { fromElWindow, fromElViewport, fromAutWindow } = coords if options._log ## display the red dot at these coords options._log.set({ - coords: fromWindow + coords: fromAutWindow }) eventOptions = _.extend({ - clientX: fromViewport.x - clientY: fromViewport.y - pageX: fromWindow.x - pageY: fromWindow.y + clientX: fromElViewport.x + clientY: fromElViewport.y + screenX: fromElViewport.x + screenY: fromElViewport.y + pageX: fromElWindow.x + pageY: fromElWindow.y }, eventOptions) dispatch($elToClick.get(0), eventName, eventOptions) diff --git a/packages/driver/src/cy/commands/actions/type.js b/packages/driver/src/cy/commands/actions/type.js index 7f553181fa5f..a5da732b2b59 100644 --- a/packages/driver/src/cy/commands/actions/type.js +++ b/packages/driver/src/cy/commands/actions/type.js @@ -7,6 +7,7 @@ const $elements = require('../../../dom/elements') const $selection = require('../../../dom/selection') const $utils = require('../../../cypress/utils') const $actionability = require('../../actionability') +const $Keyboard = require('../../../cy/keyboard') const inputEvents = 'textInput input'.split(' ') @@ -51,10 +52,10 @@ module.exports = function (Commands, Cypress, cy, state, config) { let obj table[id] = (obj = {}) - const modifiers = keyboard.getActiveModifiersArray() + const modifiers = $Keyboard.modifiersToString(keyboard.getActiveModifiers()) - if (modifiers.length) { - obj.modifiers = modifiers.join(', ') + if (modifiers) { + obj.modifiers = modifiers } if (key) { @@ -91,12 +92,16 @@ module.exports = function (Commands, Cypress, cy, state, config) { 'Typed': chars, 'Applied To': $dom.getElements(options.$el), 'Options': deltaOptions, - 'table' () { - return { - name: 'Key Events Table', - data: getTableData(), - columns: ['typed', 'which', 'keydown', 'keypress', 'textInput', 'input', 'keyup', 'change', 'modifiers'], - } + 'table': { + // mouse events tables will take up slots 1 and 2 if they're present + // this preserves the order of the tables + 3: () => { + return { + name: 'Keyboard Events', + data: getTableData(), + columns: ['typed', 'which', 'keydown', 'keypress', 'textInput', 'input', 'keyup', 'change', 'modifiers'], + } + }, }, } }, diff --git a/packages/driver/src/cy/commands/screenshot.coffee b/packages/driver/src/cy/commands/screenshot.coffee index 24e4cb77b150..d5fdb5b25a2c 100644 --- a/packages/driver/src/cy/commands/screenshot.coffee +++ b/packages/driver/src/cy/commands/screenshot.coffee @@ -101,8 +101,7 @@ takeScrollingScreenshots = (scrolls, win, state, automationOptions) -> Promise .mapSeries(scrolls, scrollAndTake) - .then (results) -> - _.last(results) + .then(_.last) takeFullPageScreenshot = (state, automationOptions) -> win = state("window") @@ -143,13 +142,13 @@ applyPaddingToElementPositioning = (elPosition, automationOptions) -> return { width: elPosition.width + paddingLeft + paddingRight height: elPosition.height + paddingTop + paddingBottom - fromViewport: { - top: elPosition.fromViewport.top - paddingTop - left: elPosition.fromViewport.left - paddingLeft - bottom: elPosition.fromViewport.bottom + paddingBottom + fromElViewport: { + top: elPosition.fromElViewport.top - paddingTop + left: elPosition.fromElViewport.left - paddingLeft + bottom: elPosition.fromElViewport.bottom + paddingBottom } - fromWindow: { - top: elPosition.fromWindow.top - paddingTop + fromElWindow: { + top: elPosition.fromElWindow.top - paddingTop } } @@ -170,40 +169,42 @@ takeElementScreenshot = ($el, state, automationOptions) -> validateNumScreenshots(numScreenshots, automationOptions) scrolls = _.map _.times(numScreenshots), (index) -> - y = elPosition.fromWindow.top + (viewportHeight * index) + y = elPosition.fromElWindow.top + (viewportHeight * index) + afterScroll = -> elPosition = applyPaddingToElementPositioning( $dom.getElementPositioning($el), automationOptions ) - x = Math.min(viewportWidth, elPosition.fromViewport.left) + x = Math.min(viewportWidth, elPosition.fromElViewport.left) width = Math.min(viewportWidth - x, elPosition.width) if numScreenshots is 1 return { x: x - y: elPosition.fromViewport.top + y: elPosition.fromElViewport.top width: width height: elPosition.height } if index + 1 is numScreenshots - overlap = (numScreenshots - 1) * viewportHeight + elPosition.fromViewport.top - heightLeft = elPosition.fromViewport.bottom - overlap - { + overlap = (numScreenshots - 1) * viewportHeight + elPosition.fromElViewport.top + heightLeft = elPosition.fromElViewport.bottom - overlap + + return { x: x y: overlap width: width height: heightLeft } - else - { - x: x - y: Math.max(0, elPosition.fromViewport.top) - width: width - ## TODO: try simplifying to just 'viewportHeight' - height: Math.min(viewportHeight, elPosition.fromViewport.top + elPosition.height) - } + + return { + x: x + y: Math.max(0, elPosition.fromElViewport.top) + width: width + ## TODO: try simplifying to just 'viewportHeight' + height: Math.min(viewportHeight, elPosition.fromElViewport.top + elPosition.height) + } { y, afterScroll } diff --git a/packages/driver/src/cy/keyboard.js b/packages/driver/src/cy/keyboard.js index 26d489917075..60c703916758 100644 --- a/packages/driver/src/cy/keyboard.js +++ b/packages/driver/src/cy/keyboard.js @@ -2,6 +2,7 @@ const _ = require('lodash') const Promise = require('bluebird') const $elements = require('../dom/elements') const $selection = require('../dom/selection') +const $document = require('../dom/document') const isSingleDigitRe = /^\d$/ const isStartingDigitRe = /^\d/ @@ -37,6 +38,27 @@ const initialModifiers = { shift: false, } +const toModifiersEventOptions = (modifiers) => { + return { + altKey: modifiers.alt, + ctrlKey: modifiers.ctrl, + metaKey: modifiers.meta, + shiftKey: modifiers.shift, + } +} + +const fromModifierEventOptions = (eventOptions) => { + return _.pickBy({ + alt: eventOptions.altKey, + ctrl: eventOptions.ctrlKey, + meta: eventOptions.metaKey, + shift: eventOptions.shiftKey, + + }, Boolean) +} + +const modifiersToString = (modifiers) => _.keys(_.pickBy(modifiers, Boolean)).join(', ') + const create = (state) => { const kb = { getActiveModifiers () { @@ -103,7 +125,6 @@ const create = (state) => { options.setKey = '{del}' return kb.ensureKey(el, null, options, () => { - $selection.getSelectionBounds(el) if ($selection.isCollapsed(el)) { // if there's no text selected, delete the prev char @@ -342,10 +363,6 @@ const create = (state) => { '{shift}': 'shift', }, - boundsAreEqual (bounds) { - return bounds[0] === bounds[1] - }, - type (options = {}) { _.defaults(options, { delay: 10, @@ -383,7 +400,7 @@ const create = (state) => { return kb.typeChars(el, key, options) }).then(() => { if (options.release !== false) { - return kb.resetModifiers(el, options.window) + return kb.resetModifiers($document.getDocumentFromElement(el)) } }) }, @@ -452,16 +469,6 @@ const create = (state) => { return code }, - expectedValueDoesNotMatchCurrentValue (expected, rng) { - return expected !== rng.all() - }, - - moveCaretToEnd (rng) { - const len = rng.length() - - return rng.bounds([len, len]) - }, - simulateKey (el, eventType, key, options) { // bail if we've said not to fire this specific event // in our options @@ -527,7 +534,7 @@ const create = (state) => { repeat: false, }) - kb.mixinModifiers(event) + _.extend(event, toModifiersEventOptions(kb.getActiveModifiers())) } if (keys) { @@ -658,9 +665,7 @@ const create = (state) => { }, isSpecialChar (chars) { - let needle - - return (needle = chars, _.keys(kb.specialChars).includes(needle)) + return _.includes(_.keys(kb.specialChars), chars) }, handleSpecialChars (el, chars, options) { @@ -670,9 +675,7 @@ const create = (state) => { }, isModifier (chars) { - let needle - - return (needle = chars, _.keys(kb.modifierChars).includes(needle)) + return _.includes(_.keys(kb.modifierChars), chars) }, handleModifier (el, chars, options) { @@ -699,56 +702,33 @@ const create = (state) => { })) }, - mixinModifiers (event) { - const activeModifiers = kb.getActiveModifiers() + resetModifiers (doc) { - return _.extend(event, { - altKey: activeModifiers.alt, - ctrlKey: activeModifiers.ctrl, - metaKey: activeModifiers.meta, - shiftKey: activeModifiers.shift, - }) - }, + const activeEl = $elements.getActiveElByDocument(doc) + const activeModifiers = kb.getActiveModifiers(state) + + for (let modifier in activeModifiers) { + const isActivated = activeModifiers[modifier] - getActiveModifiersArray () { - return _.reduce(kb.getActiveModifiers(), (memo, isActivated, modifier) => { + activeModifiers[modifier] = false + state('keyboardModifiers', _.clone(activeModifiers)) if (isActivated) { - memo.push(modifier) + kb.simulateModifier(activeEl, 'keyup', modifier, { + window, + onBeforeEvent () { }, + onEvent () { }, + }) } - - return memo } - , []) - }, - - resetModifiers (el, window) { - return (() => { - const result = [] - - const activeModifiers = kb.getActiveModifiers() - - for (let modifier in activeModifiers) { - const isActivated = activeModifiers[modifier] - - activeModifiers[modifier] = false - state('keyboardModifiers', activeModifiers) - if (isActivated) { - result.push(kb.simulateModifier(el, 'keyup', modifier, { - window, - onBeforeEvent () {}, - onEvent () {}, - })) - } else { - result.push(undefined) - } - } - - return result - })() }, } return kb } -module.exports = { create } +module.exports = { + create, + toModifiersEventOptions, + modifiersToString, + fromModifierEventOptions, +} diff --git a/packages/driver/src/cy/mouse.js b/packages/driver/src/cy/mouse.js index aafc45e5324c..ae5e5313313b 100644 --- a/packages/driver/src/cy/mouse.js +++ b/packages/driver/src/cy/mouse.js @@ -1,143 +1,680 @@ +const $ = require('jquery') +const _ = require('lodash') const $dom = require('../dom') +const $elements = require('../dom/elements') +const $Keyboard = require('./keyboard') +const $selection = require('../dom/selection') +const debug = require('debug')('cypress:driver:mouse') + +/** + * @typedef Coords + * @property {number} x + * @property {number} y + * @property {Document} doc + */ + +const getLastHoveredEl = (state) => { + let lastHoveredEl = state('mouseLastHoveredEl') + const lastHoveredElAttached = lastHoveredEl && $elements.isAttachedEl(lastHoveredEl) + + if (!lastHoveredElAttached) { + lastHoveredEl = null + state('mouseLastHoveredEl', lastHoveredEl) + } -const { stopPropagation } = window.MouseEvent.prototype + return lastHoveredEl +} + +const defaultPointerDownUpOptions = { + pointerType: 'mouse', + pointerId: 1, + isPrimary: true, + detail: 0, + // pressure 0.5 is default for mouse that doesn't support pressure + // https://developer.mozilla.org/en-US/docs/Web/API/PointerEvent/pressure + pressure: 0.5, +} -const create = (state, keyboard) => { +const getMouseCoords = (state) => { + return state('mouseCoords') +} + +const shouldFireMouseMoveEvents = (targetEl, lastHoveredEl, fromElViewport, coords) => { + // not the same element, fire mouse move events + if (lastHoveredEl !== targetEl) { + return true + } + + const xy = (obj) => { + return _.pick(obj, 'x', 'y') + } + + // if we have the same element, but the xy coords are different + // then fire mouse move events... + return !_.isEqual(xy(fromElViewport), xy(coords)) +} + +const create = (state, keyboard, focused) => { const mouse = { - mouseDown ($elToClick, fromViewport) { - const el = $elToClick.get(0) + _getDefaultMouseOptions (x, y, win) { + const _activeModifiers = keyboard.getActiveModifiers() + const modifiersEventOptions = $Keyboard.toModifiersEventOptions(_activeModifiers) + const coordsEventOptions = toCoordsEventOptions(x, y, win) + + return _.extend({ + view: win, + // allow propagation out of root of shadow-dom + // https://developer.mozilla.org/en-US/docs/Web/API/Event/composed + composed: true, + // only for events involving moving cursor + relatedTarget: null, + }, modifiersEventOptions, coordsEventOptions) + }, + + /** + * @param {Coords} coords + * @param {HTMLElement} forceEl + */ + move (fromElViewport, forceEl) { + debug('mouse.move', fromElViewport) + + const lastHoveredEl = getLastHoveredEl(state) + + const targetEl = forceEl || mouse.getElAtCoords(fromElViewport) + // if the element is already hovered and our coords for firing the events + // already match our existing state coords, then bail early and don't fire + // any mouse move events + if (!shouldFireMouseMoveEvents(targetEl, lastHoveredEl, fromElViewport, getMouseCoords(state))) { + return { el: targetEl } + } + + const events = mouse._moveEvents(targetEl, fromElViewport) + + const resultEl = forceEl || mouse.getElAtCoords(fromElViewport) + + return { el: resultEl, fromEl: lastHoveredEl, events } + }, + + /** + * @param {HTMLElement} el + * @param {Coords} coords + * Steps to perform mouse move: + * - send out events to elLastHovered (bubbles) + * - send leave events to all Elements until commonAncestor + * - send over events to elToHover (bubbles) + * - send enter events to all elements from commonAncestor + * - send move events to elToHover (bubbles) + * - elLastHovered = elToHover + */ + _moveEvents (el, coords) { + // events are not fired on disabled elements, so we don't have to take that into account const win = $dom.getWindowByElement(el) + const { x, y } = coords - const mdownEvtProps = keyboard.mixinModifiers({ - bubbles: true, - cancelable: true, - view: win, - clientX: fromViewport.x, - clientY: fromViewport.y, - buttons: 1, - detail: 1, + const defaultOptions = mouse._getDefaultMouseOptions(x, y, win) + const defaultMouseOptions = _.extend({}, defaultOptions, { + button: 0, + which: 0, + buttons: 0, }) - const mdownEvt = new window.MouseEvent('mousedown', mdownEvtProps) + const defaultPointerOptions = _.extend({}, defaultOptions, { + button: -1, + which: 0, + buttons: 0, + pointerId: 1, + pointerType: 'mouse', + isPrimary: true, + }) - //# ensure this property exists on older chromium versions - if (mdownEvt.buttons == null) { - mdownEvt.buttons = 1 + const notFired = () => { + return { + skipped: formatReasonNotFired('Already on Coordinates'), + } } + let pointerout = _.noop + let pointerleave = _.noop + let pointerover = notFired + let pointerenter = _.noop + let mouseout = _.noop + let mouseleave = _.noop + let mouseover = notFired + let mouseenter = _.noop + let pointermove = notFired + let mousemove = notFired + + const lastHoveredEl = getLastHoveredEl(state) + + const hoveredElChanged = el !== lastHoveredEl + let commonAncestor = null + + if (hoveredElChanged && lastHoveredEl) { + commonAncestor = $elements.getFirstCommonAncestor(el, lastHoveredEl) + pointerout = () => { + sendPointerout(lastHoveredEl, _.extend({}, defaultPointerOptions, { relatedTarget: el })) + } + + mouseout = () => { + sendMouseout(lastHoveredEl, _.extend({}, defaultMouseOptions, { relatedTarget: el })) + } + + let curParent = lastHoveredEl + + const elsToSendMouseleave = [] + + while (curParent && curParent !== commonAncestor) { + elsToSendMouseleave.push(curParent) + curParent = curParent.parentNode + } + + pointerleave = () => { + elsToSendMouseleave.forEach((elToSend) => { + sendPointerleave(elToSend, _.extend({}, defaultPointerOptions, { relatedTarget: el })) + }) + } + + mouseleave = () => { + elsToSendMouseleave.forEach((elToSend) => { + sendMouseleave(elToSend, _.extend({}, defaultMouseOptions, { relatedTarget: el })) + }) + } - mdownEvt.stopPropagation = function (...args) { - this._hasStoppedPropagation = true - - return stopPropagation.apply(this, args) } - const canceled = !el.dispatchEvent(mdownEvt) + if (hoveredElChanged) { + if (el && $elements.isAttachedEl(el)) { + + mouseover = () => { + return sendMouseover(el, _.extend({}, defaultMouseOptions, { relatedTarget: lastHoveredEl })) + } + + pointerover = () => { + return sendPointerover(el, _.extend({}, defaultPointerOptions, { relatedTarget: lastHoveredEl })) + } + + let curParent = el + const elsToSendMouseenter = [] + + while (curParent && curParent.ownerDocument && curParent !== commonAncestor) { + elsToSendMouseenter.push(curParent) + curParent = curParent.parentNode + } + + elsToSendMouseenter.reverse() + + pointerenter = () => { + return elsToSendMouseenter.forEach((elToSend) => { + sendPointerenter(elToSend, _.extend({}, defaultPointerOptions, { relatedTarget: lastHoveredEl })) + }) + } + + mouseenter = () => { + return elsToSendMouseenter.forEach((elToSend) => { + sendMouseenter(elToSend, _.extend({}, defaultMouseOptions, { relatedTarget: lastHoveredEl })) + }) + } + } - const props = { - preventedDefault: canceled, - stoppedPropagation: !!mdownEvt._hasStoppedPropagation, } - const modifiers = keyboard.getActiveModifiersArray() + pointermove = () => { + return sendPointermove(el, defaultPointerOptions) + } - if (modifiers.length) { - props.modifiers = modifiers.join(', ') + mousemove = () => { + return sendMousemove(el, defaultMouseOptions) } - return props + const events = [] + + pointerout() + pointerleave() + events.push({ pointerover: pointerover() }) + pointerenter() + mouseout() + mouseleave() + events.push({ mouseover: mouseover() }) + mouseenter() + state('mouseLastHoveredEl', $elements.isAttachedEl(el) ? el : null) + state('mouseCoords', { x, y }) + events.push({ pointermove: pointermove() }) + events.push({ mousemove: mousemove() }) + + return events + }, + + /** + * + * @param {Coords} coords + * @returns {HTMLElement} + */ + getElAtCoords ({ x, y, doc }) { + const el = doc.elementFromPoint(x, y) + + return el }, - mouseUp ($elToClick, fromViewport) { - const el = $elToClick.get(0) + /** + * + * @param {Coords} coords + */ + moveToCoords (coords) { + const { el } = mouse.move(coords) + + return el + }, + + /** + * @param {Coords} coords + * @param {HTMLElement} forceEl + */ + _downEvents (coords, forceEl, pointerEvtOptionsExtend = {}, mouseEvtOptionsExtend = {}) { + + const { x, y } = coords + const el = forceEl || mouse.moveToCoords(coords) const win = $dom.getWindowByElement(el) - const mupEvtProps = keyboard.mixinModifiers({ - bubbles: true, - cancelable: true, - view: win, - clientX: fromViewport.x, - clientY: fromViewport.y, - buttons: 0, - detail: 1, - }) + const defaultOptions = mouse._getDefaultMouseOptions(x, y, win) - const mupEvt = new MouseEvent('mouseup', mupEvtProps) + const pointerEvtOptions = _.extend({}, defaultOptions, { + ...defaultPointerDownUpOptions, + button: 0, + which: 1, + buttons: 1, + relatedTarget: null, + }, pointerEvtOptionsExtend) - //# ensure this property exists on older chromium versions - if (mupEvt.buttons == null) { - mupEvt.buttons = 0 + const mouseEvtOptions = _.extend({}, defaultOptions, { + button: 0, + which: 1, + buttons: 1, + detail: 1, + }, mouseEvtOptionsExtend) + + // TODO: pointer events should have fractional coordinates, not rounded + let pointerdownProps = sendPointerdown( + el, + pointerEvtOptions + ) + + const pointerdownPrevented = pointerdownProps.preventedDefault + const elIsDetached = $elements.isDetachedEl(el) + + if (pointerdownPrevented || elIsDetached) { + let reason = 'pointerdown was cancelled' + + if (elIsDetached) { + reason = 'Element was detached' + } + + return { + pointerdownProps, + mousedownProps: { + skipped: formatReasonNotFired(reason), + }, + } } - mupEvt.stopPropagation = function (...args) { - this._hasStoppedPropagation = true + let mousedownProps = sendMousedown(el, mouseEvtOptions) - return stopPropagation.apply(this, args) + return { + pointerdownProps, + mousedownProps, } - const canceled = !el.dispatchEvent(mupEvt) + }, + + down (fromElViewport, forceEl, pointerEvtOptionsExtend = {}, mouseEvtOptionsExtend = {}) { + const $previouslyFocused = focused.getFocused() + + const mouseDownEvents = mouse._downEvents(fromElViewport, forceEl, pointerEvtOptionsExtend, mouseEvtOptionsExtend) - const props = { - preventedDefault: canceled, - stoppedPropagation: !!mupEvt._hasStoppedPropagation, + // el we just send pointerdown + const el = mouseDownEvents.pointerdownProps.el + + if (mouseDownEvents.pointerdownProps.preventedDefault || mouseDownEvents.mousedownProps.preventedDefault || !$elements.isAttachedEl(el)) { + return mouseDownEvents + } + + if ($elements.isInput(el) || $elements.isTextarea(el) || $elements.isContentEditable(el)) { + if (!$elements.isNeedSingleValueChangeInputElement(el)) { + $selection.moveSelectionToEnd(el) + } } - const modifiers = keyboard.getActiveModifiersArray() + //# retrieve the first focusable $el in our parent chain + const $elToFocus = $elements.getFirstFocusableEl($(el)) + + if (focused.needsFocus($elToFocus, $previouslyFocused)) { + if ($dom.isWindow($elToFocus)) { + // if the first focusable element from the click + // is the window, then we can skip the focus event + // since the user has clicked a non-focusable element + const $focused = focused.getFocused() + + if ($focused) { + focused.fireBlur($focused.get(0)) + } + } else { + // the user clicked inside a focusable element + focused.fireFocus($elToFocus.get(0)) + } - if (modifiers.length) { - props.modifiers = modifiers.join(', ') } - return props + return mouseDownEvents }, - click ($elToClick, fromViewport) { - const el = $elToClick.get(0) + /** + * @param {HTMLElement} el + * @param {Window} win + * @param {Coords} fromElViewport + * @param {HTMLElement} forceEl + */ + up (fromElViewport, forceEl, skipMouseEvent, pointerEvtOptionsExtend = {}, mouseEvtOptionsExtend = {}) { + debug('mouse.up', { fromElViewport, forceEl, skipMouseEvent }) - const win = $dom.getWindowByElement(el) + return mouse._upEvents(fromElViewport, forceEl, skipMouseEvent, pointerEvtOptionsExtend, mouseEvtOptionsExtend) + }, - const clickEvtProps = keyboard.mixinModifiers({ - bubbles: true, - cancelable: true, - view: win, - clientX: fromViewport.x, - clientY: fromViewport.y, + /** + * + * Steps to perform a click: + * + * moveToCoordsOrNoop = (coords) => { + * elAtPoint = getElementFromPoint(coords) + * if (elAtPoint !== elLastHovered) + * sendMouseMoveEvents({to: elAtPoint, from: elLastHovered}) + * elLastHovered = elAtPoint + * return getElementFromPoint(coords) + * } + * + * coords = getCoords(elSubject) + * el1 = moveToCoordsOrNoop(coords) + * sendMousedown(el1) + * el2 = moveToCoordsOrNoop(coords) + * sendMouseup(el2) + * el3 = moveToCoordsOrNoop(coords) + * if (notDetached(el1)) + * sendClick(el3) + */ + click (fromElViewport, forceEl, pointerEvtOptionsExtend = {}, mouseEvtOptionsExtend = {}) { + debug('mouse.click', { fromElViewport, forceEl }) + + const mouseDownEvents = mouse.down(fromElViewport, forceEl, pointerEvtOptionsExtend, mouseEvtOptionsExtend) + + const skipMouseupEvent = mouseDownEvents.pointerdownProps.skipped || mouseDownEvents.pointerdownProps.preventedDefault + + const mouseUpEvents = mouse.up(fromElViewport, forceEl, skipMouseupEvent, pointerEvtOptionsExtend, mouseEvtOptionsExtend) + + const skipClickEvent = $elements.isDetachedEl(mouseDownEvents.pointerdownProps.el) + + const mouseClickEvents = mouse._mouseClickEvents(fromElViewport, forceEl, skipClickEvent, mouseEvtOptionsExtend) + + return _.extend({}, mouseDownEvents, mouseUpEvents, mouseClickEvents) + + }, + + /** + * @param {Coords} fromElViewport + * @param {HTMLElement} el + * @param {HTMLElement} forceEl + * @param {Window} win + */ + _upEvents (fromElViewport, forceEl, skipMouseEvent, pointerEvtOptionsExtend = {}, mouseEvtOptionsExtend = {}) { + + const win = state('window') + + let defaultOptions = mouse._getDefaultMouseOptions(fromElViewport.x, fromElViewport.y, win) + + const pointerEvtOptions = _.extend({}, defaultOptions, { + ...defaultPointerDownUpOptions, + buttons: 0, + }, pointerEvtOptionsExtend) + + let mouseEvtOptions = _.extend({}, defaultOptions, { buttons: 0, detail: 1, - }) + }, mouseEvtOptionsExtend) - const clickEvt = new MouseEvent('click', clickEvtProps) + const el = forceEl || mouse.moveToCoords(fromElViewport) - //# ensure this property exists on older chromium versions - if (clickEvt.buttons == null) { - clickEvt.buttons = 0 + let pointerupProps = sendPointerup(el, pointerEvtOptions) + + if (skipMouseEvent || $elements.isDetachedEl($(el))) { + return { + pointerupProps, + mouseupProps: { + skipped: formatReasonNotFired('Previous event cancelled'), + }, + } } - clickEvt.stopPropagation = function (...args) { - this._hasStoppedPropagation = true + let mouseupProps = sendMouseup(el, mouseEvtOptions) - return stopPropagation.apply(this, args) + return { + pointerupProps, + mouseupProps, } - const canceled = !el.dispatchEvent(clickEvt) + }, - const props = { - preventedDefault: canceled, - stoppedPropagation: !!clickEvt._hasStoppedPropagation, + _mouseClickEvents (fromElViewport, forceEl, skipClickEvent, mouseEvtOptionsExtend = {}) { + const el = forceEl || mouse.moveToCoords(fromElViewport) + + const win = $dom.getWindowByElement(el) + + const defaultOptions = mouse._getDefaultMouseOptions(fromElViewport.x, fromElViewport.y, win) + + const clickEventOptions = _.extend({}, defaultOptions, { + buttons: 0, + detail: 1, + }, mouseEvtOptionsExtend) + + if (skipClickEvent) { + return { + clickProps: { + skipped: formatReasonNotFired('Element was detached'), + }, + } + } + + let clickProps = sendClick(el, clickEventOptions) + + return { clickProps } + }, + + _contextmenuEvent (fromElViewport, forceEl, mouseEvtOptionsExtend) { + const el = forceEl || mouse.moveToCoords(fromElViewport) + + const win = $dom.getWindowByElement(el) + const defaultOptions = mouse._getDefaultMouseOptions(fromElViewport.x, fromElViewport.y, win) + + const mouseEvtOptions = _.extend({}, defaultOptions, { + button: 2, + buttons: 2, + detail: 0, + which: 3, + }, mouseEvtOptionsExtend) + + let contextmenuProps = sendContextmenu(el, mouseEvtOptions) + + return { contextmenuProps } + }, + + dblclick (fromElViewport, forceEl, mouseEvtOptionsExtend = {}) { + const click = (clickNum) => { + const clickEvents = mouse.click(fromElViewport, forceEl, {}, { detail: clickNum }) + + return clickEvents } - const modifiers = keyboard.getActiveModifiersArray() + const clickEvents1 = click(1) + const clickEvents2 = click(2) - if (modifiers.length) { - props.modifiers = modifiers.join(', ') + const el = forceEl || mouse.moveToCoords(fromElViewport) + const win = $dom.getWindowByElement(el) + + const dblclickEvtProps = _.extend(mouse._getDefaultMouseOptions(fromElViewport.x, fromElViewport.y, win), { + buttons: 0, + detail: 2, + }, mouseEvtOptionsExtend) + + let dblclickProps = sendDblclick(el, dblclickEvtProps) + + return { clickEvents1, clickEvents2, dblclickProps } + }, + + rightclick (fromElViewport, forceEl) { + const pointerEvtOptionsExtend = { + button: 2, + buttons: 2, + which: 3, + } + const mouseEvtOptionsExtend = { + button: 2, + buttons: 2, + which: 3, } - return props + const mouseDownEvents = mouse.down(fromElViewport, forceEl, pointerEvtOptionsExtend, mouseEvtOptionsExtend) + + const contextmenuEvent = mouse._contextmenuEvent(fromElViewport, forceEl) + + const skipMouseupEvent = mouseDownEvents.pointerdownProps.skipped || mouseDownEvents.pointerdownProps.preventedDefault + + const mouseUpEvents = mouse.up(fromElViewport, forceEl, skipMouseupEvent, pointerEvtOptionsExtend, mouseEvtOptionsExtend) + + const clickEvents = _.extend({}, mouseDownEvents, mouseUpEvents) + + return _.extend({}, { clickEvents, contextmenuEvent }) }, } return mouse } -module.exports = { create } +const { stopPropagation } = window.MouseEvent.prototype + +const sendEvent = (evtName, el, evtOptions, bubbles = false, cancelable = false, Constructor) => { + evtOptions = _.extend({}, evtOptions, { bubbles, cancelable }) + const _eventModifiers = $Keyboard.fromModifierEventOptions(evtOptions) + const modifiers = $Keyboard.modifiersToString(_eventModifiers) + + const evt = new Constructor(evtName, _.extend({}, evtOptions, { bubbles, cancelable })) + + if (bubbles) { + evt.stopPropagation = function (...args) { + evt._hasStoppedPropagation = true + + return stopPropagation.apply(this, ...args) + } + } + + debug('event:', evtName, el) + + const preventedDefault = !el.dispatchEvent(evt) + + return { + stoppedPropagation: !!evt._hasStoppedPropagation, + preventedDefault, + el, + modifiers, + } + +} + +const sendPointerEvent = (el, evtOptions, evtName, bubbles = false, cancelable = false) => { + const Constructor = el.ownerDocument.defaultView.PointerEvent + + return sendEvent(evtName, el, evtOptions, bubbles, cancelable, Constructor) +} +const sendMouseEvent = (el, evtOptions, evtName, bubbles = false, cancelable = false) => { + // TODO: IE doesn't have event constructors, so you should use document.createEvent('mouseevent') + // https://dom.spec.whatwg.org/#dom-document-createevent + const Constructor = el.ownerDocument.defaultView.MouseEvent + + return sendEvent(evtName, el, evtOptions, bubbles, cancelable, Constructor) +} + +const sendPointerup = (el, evtOptions) => { + return sendPointerEvent(el, evtOptions, 'pointerup', true, true) +} +const sendPointerdown = (el, evtOptions) => { + return sendPointerEvent(el, evtOptions, 'pointerdown', true, true) +} +const sendPointermove = (el, evtOptions) => { + return sendPointerEvent(el, evtOptions, 'pointermove', true, true) +} +const sendPointerover = (el, evtOptions) => { + return sendPointerEvent(el, evtOptions, 'pointerover', true, true) +} +const sendPointerenter = (el, evtOptions) => { + return sendPointerEvent(el, evtOptions, 'pointerenter', false, false) +} +const sendPointerleave = (el, evtOptions) => { + return sendPointerEvent(el, evtOptions, 'pointerleave', false, false) +} +const sendPointerout = (el, evtOptions) => { + return sendPointerEvent(el, evtOptions, 'pointerout', true, true) +} + +const sendMouseup = (el, evtOptions) => { + return sendMouseEvent(el, evtOptions, 'mouseup', true, true) +} +const sendMousedown = (el, evtOptions) => { + return sendMouseEvent(el, evtOptions, 'mousedown', true, true) +} +const sendMousemove = (el, evtOptions) => { + return sendMouseEvent(el, evtOptions, 'mousemove', true, true) +} +const sendMouseover = (el, evtOptions) => { + return sendMouseEvent(el, evtOptions, 'mouseover', true, true) +} +const sendMouseenter = (el, evtOptions) => { + return sendMouseEvent(el, evtOptions, 'mouseenter', false, false) +} +const sendMouseleave = (el, evtOptions) => { + return sendMouseEvent(el, evtOptions, 'mouseleave', false, false) +} +const sendMouseout = (el, evtOptions) => { + return sendMouseEvent(el, evtOptions, 'mouseout', true, true) +} +const sendClick = (el, evtOptions) => { + return sendMouseEvent(el, evtOptions, 'click', true, true) +} +const sendDblclick = (el, evtOptions) => { + return sendMouseEvent(el, evtOptions, 'dblclick', true, true) +} +const sendContextmenu = (el, evtOptions) => { + return sendMouseEvent(el, evtOptions, 'contextmenu', true, true) +} + +const formatReasonNotFired = (reason) => { + return `⚠️ not fired (${reason})` +} + +const toCoordsEventOptions = (x, y, win) => { + // these are the coords from the element's window, + // ignoring scroll position + const { scrollX, scrollY } = win + + return { + x, + y, + clientX: x, + clientY: y, + screenX: x, + screenY: y, + pageX: x + scrollX, + pageY: x + scrollY, + layerX: x + scrollX, + layerY: x + scrollY, + } +} + +module.exports = { + create, +} diff --git a/packages/driver/src/cy/retries.coffee b/packages/driver/src/cy/retries.coffee index 33f6fe346f29..ca7959720302 100644 --- a/packages/driver/src/cy/retries.coffee +++ b/packages/driver/src/cy/retries.coffee @@ -1,7 +1,8 @@ _ = require("lodash") Promise = require("bluebird") - +debug = require('debug')('cypress:driver:retries') $utils = require("../cypress/utils") +{ CypressErrorRe } = require("../cypress/log") create = (Cypress, state, timeout, clearTimeout, whenStable, finishAssertions) -> return { @@ -28,6 +29,15 @@ create = (Cypress, state, timeout, clearTimeout, whenStable, finishAssertions) - _name: current?.get("name") }) + { error } = options + + ## TODO: remove this once the codeframe PR is in since that + ## correctly handles not rewrapping errors so that stack + ## traces are correctly displayed + if debug.enabled and error and not CypressErrorRe.test(error.name) + debug('retrying due to caught error...') + console.error(error) + interval = options.interval ? options._interval ## we calculate the total time we've been retrying diff --git a/packages/driver/src/cypress/cy.coffee b/packages/driver/src/cypress/cy.coffee index 48ee269332a1..027f6bf70349 100644 --- a/packages/driver/src/cypress/cy.coffee +++ b/packages/driver/src/cypress/cy.coffee @@ -83,7 +83,7 @@ create = (specWindow, Cypress, Cookies, state, config, log) -> location = $Location.create(state) focused = $Focused.create(state) keyboard = $Keyboard.create(state) - mouse = $Mouse.create(state, keyboard) + mouse = $Mouse.create(state, keyboard, focused) timers = $Timers.create() { expect } = $Chai.create(specWindow, assertions.assert) diff --git a/packages/driver/src/cypress/error_messages.coffee b/packages/driver/src/cypress/error_messages.coffee index 388be1c40d35..b0b27101bd8b 100644 --- a/packages/driver/src/cypress/error_messages.coffee +++ b/packages/driver/src/cypress/error_messages.coffee @@ -127,8 +127,8 @@ module.exports = { invalid_argument: "#{cmd('clearLocalStorage')} must be called with either a string or regular expression." click: - multiple_elements: "#{cmd('click')} can only be called on a single element. Your subject contained {{num}} elements. Pass { multiple: true } if you want to serially click each element." - on_select_element: "#{cmd('click')} cannot be called on a element. Use #{cmd('select')} command instead to change the value." clock: already_created: "#{cmd('clock')} can only be called once per test. Use the clock returned from the previous call." diff --git a/packages/driver/src/cypress/log.coffee b/packages/driver/src/cypress/log.coffee index a1c3674ad01b..1353edb925c9 100644 --- a/packages/driver/src/cypress/log.coffee +++ b/packages/driver/src/cypress/log.coffee @@ -502,6 +502,8 @@ create = (Cypress, cy, state, config) -> return logFn module.exports = { + CypressErrorRe + reduceMemory toSerializedJSON diff --git a/packages/driver/src/dom/coordinates.js b/packages/driver/src/dom/coordinates.js index fffc727c918a..c855a5ba7d20 100644 --- a/packages/driver/src/dom/coordinates.js +++ b/packages/driver/src/dom/coordinates.js @@ -1,13 +1,18 @@ const $window = require('./window') +const $elements = require('./elements') const getElementAtPointFromViewport = (doc, x, y) => { return doc.elementFromPoint(x, y) } +const isAutIframe = (win) => !$elements.getNativeProp(win.parent, 'frameElement') + +/** + * @param {JQuery} $el + */ const getElementPositioning = ($el) => { - /** - * @type {HTMLElement} - */ + let autFrame + const el = $el[0] const win = $window.getWindowByElement(el) @@ -15,26 +20,71 @@ const getElementPositioning = ($el) => { // properties except for width / height // are relative to the top left of the viewport - // we use the first of getClientRects in order to account for inline elements - // that span multiple lines. Which would cause us to click in the center and thus miss - // This should be the same as using getBoundingClientRect() - // for elements with a single rect - // const rect = el.getBoundingClientRect() + // we use the first of getClientRects in order to account for inline + // elements that span multiple lines. Which would cause us to click + // click in the center and thus miss... + // + // however we have a fallback to getBoundingClientRect() such as + // when the element is hidden or detached from the DOM. getClientRects() + // returns a zero length DOMRectList in that case, which becomes undefined. + // so we fallback to getBoundingClientRect() so that we get an actual DOMRect + // with all properties 0'd out const rect = el.getClientRects()[0] || el.getBoundingClientRect() - const center = getCenterCoordinates(rect) + // we want to return the coordinates from the autWindow to the element + // which handles a situation in which the element is inside of a nested + // iframe. we use these "absolute" coordinates from the autWindow to draw + // things like the red hitbox - since the hitbox layer is placed on the + // autWindow instead of the window the element is actually within + const getRectFromAutIframe = (rect) => { + let x = 0 //rect.left + let y = 0 //rect.top + let curWindow = win + let frame + + // walk up from a nested iframe so we continually add the x + y values + while (!isAutIframe(curWindow) && curWindow.parent !== curWindow) { + frame = $elements.getNativeProp(curWindow, 'frameElement') + + if (curWindow && frame) { + const frameRect = frame.getBoundingClientRect() + + x += frameRect.left + y += frameRect.top + } + + curWindow = curWindow.parent + } + + autFrame = curWindow + + return { + left: x + rect.left, + top: y + rect.top, + right: x + rect.right, + bottom: y + rect.top, + width: rect.width, + height: rect.height, + } + } + + const rectFromAut = getRectFromAutIframe(rect) + const rectFromAutCenter = getCenterCoordinates(rectFromAut) // add the center coordinates // because its useful to any caller - const topCenter = center.y - const leftCenter = center.x + const rectCenter = getCenterCoordinates(rect) + + const topCenter = rectCenter.y + const leftCenter = rectCenter.x return { scrollTop: el.scrollTop, scrollLeft: el.scrollLeft, width: rect.width, height: rect.height, - fromViewport: { + fromElViewport: { + doc: win.document, top: rect.top, left: rect.left, right: rect.right, @@ -42,11 +92,17 @@ const getElementPositioning = ($el) => { topCenter, leftCenter, }, - fromWindow: { - top: rect.top + win.pageYOffset, - left: rect.left + win.pageXOffset, - topCenter: topCenter + win.pageYOffset, - leftCenter: leftCenter + win.pageXOffset, + fromElWindow: { + top: rect.top + win.scrollY, + left: rect.left + win.scrollX, + topCenter: topCenter + win.scrollY, + leftCenter: leftCenter + win.scrollX, + }, + fromAutWindow: { + top: rectFromAut.top + autFrame.scrollY, + left: rectFromAut.left + autFrame.scrollX, + topCenter: rectFromAutCenter.y + autFrame.scrollY, + leftCenter: rectFromAutCenter.x + autFrame.scrollX, }, } } @@ -147,22 +203,22 @@ const getBottomRightCoordinates = (rect) => { const getElementCoordinatesByPositionRelativeToXY = ($el, x, y) => { const positionProps = getElementPositioning($el) - const { fromViewport, fromWindow } = positionProps + const { fromElViewport, fromElWindow } = positionProps - fromViewport.left += x - fromViewport.top += y + fromElViewport.left += x + fromElViewport.top += y - fromWindow.left += x - fromWindow.top += y + fromElWindow.left += x + fromElWindow.top += y - const viewportTargetCoords = getTopLeftCoordinates(fromViewport) - const windowTargetCoords = getTopLeftCoordinates(fromWindow) + const viewportTargetCoords = getTopLeftCoordinates(fromElViewport) + const windowTargetCoords = getTopLeftCoordinates(fromElWindow) - fromViewport.x = viewportTargetCoords.x - fromViewport.y = viewportTargetCoords.y + fromElViewport.x = viewportTargetCoords.x + fromElViewport.y = viewportTargetCoords.y - fromWindow.x = windowTargetCoords.x - fromWindow.y = windowTargetCoords.y + fromElWindow.x = windowTargetCoords.x + fromElWindow.y = windowTargetCoords.y return positionProps } @@ -176,7 +232,7 @@ const getElementCoordinatesByPosition = ($el, position) => { // but also from the viewport so // whoever is calling us can use it // however they'd like - const { width, height, fromViewport, fromWindow } = positionProps + const { width, height, fromElViewport, fromElWindow, fromAutWindow } = positionProps // dynamically call the by transforming the nam=> e // bottom -> getBottomCoordinates @@ -192,8 +248,8 @@ const getElementCoordinatesByPosition = ($el, position) => { const viewportTargetCoords = fn({ width, height, - top: fromViewport.top, - left: fromViewport.left, + top: fromElViewport.top, + left: fromElViewport.left, }) // get the desired x/y coords based on @@ -201,24 +257,31 @@ const getElementCoordinatesByPosition = ($el, position) => { const windowTargetCoords = fn({ width, height, - top: fromWindow.top, - left: fromWindow.left, + top: fromElWindow.top, + left: fromElWindow.left, }) - fromViewport.x = viewportTargetCoords.x - fromViewport.y = viewportTargetCoords.y + fromElViewport.x = viewportTargetCoords.x + fromElViewport.y = viewportTargetCoords.y + + fromElWindow.x = windowTargetCoords.x + fromElWindow.y = windowTargetCoords.y + + const autTargetCoords = fn({ + width, + height, + top: fromAutWindow.top, + left: fromAutWindow.left, + }) - fromWindow.x = windowTargetCoords.x - fromWindow.y = windowTargetCoords.y + fromAutWindow.x = autTargetCoords.x + fromAutWindow.y = autTargetCoords.y // return an object with both sets // of normalized coordinates for both // the window and the viewport return { - width, - height, - fromViewport, - fromWindow, + ...positionProps, } } diff --git a/packages/driver/src/dom/elements.js b/packages/driver/src/dom/elements.js index ea56bfc2d0a8..3228dce8fd06 100644 --- a/packages/driver/src/dom/elements.js +++ b/packages/driver/src/dom/elements.js @@ -172,6 +172,9 @@ const nativeGetters = { selectionStart: _getSelectionStart, selectionEnd: _getSelectionEnd, type: _getType, + activeElement: descriptor('Document', 'activeElement').get, + body: descriptor('Document', 'body').get, + frameElement: Object.getOwnPropertyDescriptor(window, 'frameElement').get, } const nativeSetters = { @@ -314,6 +317,10 @@ const isBody = (el) => { return getTagName(el) === 'body' } +const isIframe = (el) => { + return getTagName(el) === 'iframe' +} + const isHTML = (el) => { return getTagName(el) === 'html' } @@ -421,6 +428,34 @@ const isAncestor = ($el, $maybeAncestor) => { return $el.parents().index($maybeAncestor) >= 0 } +const getFirstCommonAncestor = (el1, el2) => { + const el1Ancestors = [el1].concat(getAllParents(el1)) + let curEl = el2 + + while (curEl) { + if (el1Ancestors.indexOf(curEl) !== -1) { + return curEl + } + + curEl = curEl.parentNode + } + + return curEl +} + +const getAllParents = (el) => { + let curEl = el.parentNode + const allParents = [] + + while (curEl) { + allParents.push(curEl) + curEl = curEl.parentNode + } + + return allParents + +} + const isChild = ($el, $maybeChild) => { return $el.children().index($maybeChild) >= 0 } @@ -476,6 +511,20 @@ const isAttached = function ($el) { return $document.hasActiveWindow(doc) && _.every(els, isIn) } +/** + * @param {HTMLElement} el + */ +const isDetachedEl = (el) => { + return !isAttachedEl(el) +} + +/** + * @param {HTMLElement} el + */ +const isAttachedEl = function (el) { + return isAttached($(el)) +} + const isSame = function ($el1, $el2) { const el1 = $jquery.unwrap($el1) const el2 = $jquery.unwrap($el2) @@ -637,6 +686,14 @@ const getFirstFocusableEl = ($el) => { return getFirstFocusableEl($el.parent()) } +const getActiveElByDocument = (doc) => { + const activeEl = getNativeProp(doc, 'activeElement') + + if (activeEl) return activeEl + + return getNativeProp(doc, 'body') +} + const getFirstParentWithTagName = ($el, tagName) => { // return null if we're at body/html/document // cuz that means nothing has fixed position @@ -868,6 +925,10 @@ _.extend(module.exports, { isDetached, + isAttachedEl, + + isDetachedEl, + isAncestor, isChild, @@ -892,6 +953,8 @@ _.extend(module.exports, { isInput, + isIframe, + isTextarea, isType, @@ -922,10 +985,14 @@ _.extend(module.exports, { getFirstFocusableEl, + getActiveElByDocument, + getContainsSelector, getFirstDeepestElement, + getFirstCommonAncestor, + getFirstParentWithTagName, getFirstFixedOrStickyPositionParent, diff --git a/packages/driver/src/dom/visibility.js b/packages/driver/src/dom/visibility.js index 84eec0621a57..70b873f60c1a 100644 --- a/packages/driver/src/dom/visibility.js +++ b/packages/driver/src/dom/visibility.js @@ -179,7 +179,7 @@ const elAtCenterPoint = function ($el) { const doc = $document.getDocumentFromElement($el.get(0)) const elProps = $coordinates.getElementPositioning($el) - const { topCenter, leftCenter } = elProps.fromViewport + const { topCenter, leftCenter } = elProps.fromElViewport const el = $coordinates.getElementAtPointFromViewport(doc, leftCenter, topCenter) @@ -232,16 +232,16 @@ const elIsOutOfBoundsOfAncestorsOverflow = function ($el, $ancestor = $el.parent // target el is out of bounds if ( // target el is to the right of the ancestor's visible area - (elProps.fromWindow.left > (ancestorProps.width + ancestorProps.fromWindow.left)) || + (elProps.fromElWindow.left > (ancestorProps.width + ancestorProps.fromElWindow.left)) || // target el is to the left of the ancestor's visible area - ((elProps.fromWindow.left + elProps.width) < ancestorProps.fromWindow.left) || + ((elProps.fromElWindow.left + elProps.width) < ancestorProps.fromElWindow.left) || // target el is under the ancestor's visible area - (elProps.fromWindow.top > (ancestorProps.height + ancestorProps.fromWindow.top)) || + (elProps.fromElWindow.top > (ancestorProps.height + ancestorProps.fromElWindow.top)) || // target el is above the ancestor's visible area - ((elProps.fromWindow.top + elProps.height) < ancestorProps.fromWindow.top) + ((elProps.fromElWindow.top + elProps.height) < ancestorProps.fromElWindow.top) ) { return true } diff --git a/packages/driver/test/cypress/fixtures/dom.html b/packages/driver/test/cypress/fixtures/dom.html index 5af94e176d29..fec644b076d9 100644 --- a/packages/driver/test/cypress/fixtures/dom.html +++ b/packages/driver/test/cypress/fixtures/dom.html @@ -6,6 +6,12 @@
@@ -138,13 +148,6 @@ -
- Sakura - Naruto - - -
-
@@ -562,6 +565,12 @@
+
+ Sakura + Naruto + + +
iframe:
diff --git a/packages/driver/test/cypress/fixtures/issue-2956.html b/packages/driver/test/cypress/fixtures/issue-2956.html new file mode 100644 index 000000000000..a0edfbfce47e --- /dev/null +++ b/packages/driver/test/cypress/fixtures/issue-2956.html @@ -0,0 +1,131 @@ + + + + + + + + + + +
+
+ +
+
+
+
+
+ +
+ + diff --git a/packages/driver/test/cypress/integration/commands/actions/check_spec.coffee b/packages/driver/test/cypress/integration/commands/actions/check_spec.coffee index 15bafe51ce51..e0f5ed522588 100644 --- a/packages/driver/test/cypress/integration/commands/actions/check_spec.coffee +++ b/packages/driver/test/cypress/integration/commands/actions/check_spec.coffee @@ -432,8 +432,8 @@ describe "src/cy/commands/actions/check", -> it "passes in coords", -> cy.get("[name=colors][value=blue]").check().then ($input) -> lastLog = @lastLog - { fromWindow }= Cypress.dom.getElementCoordinatesByPosition($input) - expect(lastLog.get("coords")).to.deep.eq(fromWindow) + { fromElWindow }= Cypress.dom.getElementCoordinatesByPosition($input) + expect(lastLog.get("coords")).to.deep.eq(fromElWindow) it "ends command when checkbox is already checked", -> cy.get("[name=colors][value=blue]").check().check().then -> @@ -445,13 +445,13 @@ describe "src/cy/commands/actions/check", -> cy.get("[name=colors][value=blue]").check().then ($input) -> lastLog = @lastLog - { fromWindow }= Cypress.dom.getElementCoordinatesByPosition($input) + { fromElWindow }= Cypress.dom.getElementCoordinatesByPosition($input) console = lastLog.invoke("consoleProps") expect(console.Command).to.eq "check" expect(console["Applied To"]).to.eq lastLog.get("$el").get(0) expect(console.Elements).to.eq 1 expect(console.Coords).to.deep.eq( - _.pick(fromWindow, "x", "y") + _.pick(fromElWindow, "x", "y") ) it "#consoleProps when checkbox is already checked", -> @@ -836,13 +836,13 @@ describe "src/cy/commands/actions/check", -> cy.get("[name=colors][value=blue]").uncheck().then ($input) -> lastLog = @lastLog - { fromWindow } = Cypress.dom.getElementCoordinatesByPosition($input) + { fromElWindow } = Cypress.dom.getElementCoordinatesByPosition($input) console = lastLog.invoke("consoleProps") expect(console.Command).to.eq "uncheck" expect(console["Applied To"]).to.eq lastLog.get("$el").get(0) expect(console.Elements).to.eq(1) expect(console.Coords).to.deep.eq( - _.pick(fromWindow, "x", "y") + _.pick(fromElWindow, "x", "y") ) it "#consoleProps when checkbox is already unchecked", -> diff --git a/packages/driver/test/cypress/integration/commands/actions/click_spec.js b/packages/driver/test/cypress/integration/commands/actions/click_spec.js index ca6914863e78..2f6a049c9df9 100644 --- a/packages/driver/test/cypress/integration/commands/actions/click_spec.js +++ b/packages/driver/test/cypress/integration/commands/actions/click_spec.js @@ -1,11 +1,66 @@ const $ = Cypress.$.bind(Cypress) const { _ } = Cypress const { Promise } = Cypress +const chaiSubset = require('chai-subset') +const { getCommandLogWithText, findReactInstance, withMutableReporterState, clickCommandLog } = require('../../../support/utils') + +chai.use(chaiSubset) const fail = function (str) { throw new Error(str) } +const mouseClickEvents = ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click'] +const mouseHoverEvents = [ + 'pointerout', + 'pointerleave', + 'pointerover', + 'pointerenter', + 'mouseout', + 'mouseleave', + 'mouseover', + 'mouseenter', + 'pointermove', + 'mousemove', +] +const focusEvents = ['focus', 'focusin'] + +const attachListeners = (listenerArr) => { + return (els) => { + _.each(els, (el, elName) => { + return listenerArr.forEach((evtName) => { + el.on(evtName, cy.stub().as(`${elName}:${evtName}`)) + }) + }) + } +} + +const attachFocusListeners = attachListeners(focusEvents) +const attachMouseClickListeners = attachListeners(mouseClickEvents) +const attachMouseHoverListeners = attachListeners(mouseHoverEvents) +const attachMouseDblclickListeners = attachListeners(['dblclick']) +const attachContextmenuListeners = attachListeners(['contextmenu']) + +const getAllFn = (...aliases) => { + if (aliases.length > 1) { + return getAllFn((_.isArray(aliases[1]) ? aliases[1] : aliases[1].split(' ')).map((alias) => `@${aliases[0]}:${alias}`).join(' ')) + } + + return Cypress.Promise.all( + aliases[0].split(' ').map((alias) => { + return cy.now('get', alias) + }) + ) +} + +Cypress.Commands.add('getAll', getAllFn) + +const wrapped = (obj) => cy.wrap(obj, { log: false }) +const shouldBeCalled = (stub) => wrapped(stub).should('be.called') +const shouldBeCalledOnce = (stub) => wrapped(stub).should('be.calledOnce') +const shouldBeCalledWithCount = (num) => (stub) => wrapped(stub).should('have.callCount', num) +const shouldNotBeCalled = (stub) => wrapped(stub).should('not.be.called') + describe('src/cy/commands/actions/click', () => { beforeEach(() => { cy.visit('/fixtures/dom.html') @@ -16,7 +71,7 @@ describe('src/cy/commands/actions/click', () => { const $btn = cy.$$('#button') $btn.on('click', (e) => { - const { fromViewport } = Cypress.dom.getElementCoordinatesByPosition($btn) + const { fromElViewport } = Cypress.dom.getElementCoordinatesByPosition($btn) const obj = _.pick(e.originalEvent, 'bubbles', 'cancelable', 'view', 'button', 'buttons', 'which', 'relatedTarget', 'altKey', 'ctrlKey', 'shiftKey', 'metaKey', 'detail', 'type') @@ -36,8 +91,8 @@ describe('src/cy/commands/actions/click', () => { type: 'click', }) - expect(e.clientX).to.be.closeTo(fromViewport.x, 1) - expect(e.clientY).to.be.closeTo(fromViewport.y, 1) + expect(e.clientX).to.be.closeTo(fromElViewport.x, 1) + expect(e.clientY).to.be.closeTo(fromElViewport.y, 1) done() }) @@ -64,7 +119,7 @@ describe('src/cy/commands/actions/click', () => { $btn.get(0).addEventListener('mousedown', (e) => { // calculate after scrolling - const { fromViewport } = Cypress.dom.getElementCoordinatesByPosition($btn) + const { fromElViewport } = Cypress.dom.getElementCoordinatesByPosition($btn) const obj = _.pick(e, 'bubbles', 'cancelable', 'view', 'button', 'buttons', 'which', 'relatedTarget', 'altKey', 'ctrlKey', 'shiftKey', 'metaKey', 'detail', 'type') @@ -84,8 +139,8 @@ describe('src/cy/commands/actions/click', () => { type: 'mousedown', }) - expect(e.clientX).to.be.closeTo(fromViewport.x, 1) - expect(e.clientY).to.be.closeTo(fromViewport.y, 1) + expect(e.clientX).to.be.closeTo(fromElViewport.x, 1) + expect(e.clientY).to.be.closeTo(fromElViewport.y, 1) done() }) @@ -99,7 +154,7 @@ describe('src/cy/commands/actions/click', () => { const win = cy.state('window') $btn.get(0).addEventListener('mouseup', (e) => { - const { fromViewport } = Cypress.dom.getElementCoordinatesByPosition($btn) + const { fromElViewport } = Cypress.dom.getElementCoordinatesByPosition($btn) const obj = _.pick(e, 'bubbles', 'cancelable', 'view', 'button', 'buttons', 'which', 'relatedTarget', 'altKey', 'ctrlKey', 'shiftKey', 'metaKey', 'detail', 'type') @@ -119,8 +174,8 @@ describe('src/cy/commands/actions/click', () => { type: 'mouseup', }) - expect(e.clientX).to.be.closeTo(fromViewport.x, 1) - expect(e.clientY).to.be.closeTo(fromViewport.y, 1) + expect(e.clientX).to.be.closeTo(fromElViewport.x, 1) + expect(e.clientY).to.be.closeTo(fromElViewport.y, 1) done() }) @@ -134,8 +189,8 @@ describe('src/cy/commands/actions/click', () => { const $btn = cy.$$('#button') _.each('mousedown mouseup click'.split(' '), (event) => { - return $btn.get(0).addEventListener(event, () => { - return events.push(event) + $btn.get(0).addEventListener(event, () => { + events.push(event) }) }) @@ -144,94 +199,31 @@ describe('src/cy/commands/actions/click', () => { }) }) - describe('pointer-events:none', () => { - beforeEach(function () { - cy.$$('
behind #ptrNone
').appendTo(cy.$$('#dom')) - this.ptrNone = cy.$$('
#ptrNone
').appendTo(cy.$$('#dom')) - cy.$$('
#ptrNone > div
').appendTo(this.ptrNone) - - this.logs = [] - cy.on('log:added', (attrs, log) => { - this.lastLog = log - - this.logs.push(log) - }) - }) - - it('element behind pointer-events:none should still get click', () => { - cy.get('#ptr').click() // should pass with flying colors - }) - - it('should be able to force on pointer-events:none with force:true', () => { - cy.get('#ptrNone').click({ timeout: 300, force: true }) - }) - - it('should error with message about pointer-events', function () { - const onError = cy.stub().callsFake((err) => { - const { lastLog } = this - - expect(err.message).to.contain('has CSS \'pointer-events: none\'') - expect(err.message).to.not.contain('inherited from') - const consoleProps = lastLog.invoke('consoleProps') - - expect(_.keys(consoleProps)).deep.eq([ - 'Command', - 'Tried to Click', - 'But it has CSS', - 'Error', - ]) - - expect(consoleProps['But it has CSS']).to.eq('pointer-events: none') - }) - - cy.once('fail', onError) + it('sends pointer and mouse events in order', () => { + const events = [] + const $btn = cy.$$('#button') - cy.get('#ptrNone').click({ timeout: 300 }) - .then(() => { - expect(onError).calledOnce + _.each('pointerdown mousedown pointerup mouseup click'.split(' '), (event) => { + $btn.get(0).addEventListener(event, () => { + events.push(event) }) }) - it('should error with message about pointer-events and include inheritance', function () { - const onError = cy.stub().callsFake((err) => { - const { lastLog } = this - - expect(err.message).to.contain('has CSS \'pointer-events: none\', inherited from this element:') - expect(err.message).to.contain('
{ - expect(onError).calledOnce - }) + cy.get('#button').click().then(() => { + expect(events).to.deep.eq(['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click']) }) }) it('records correct clientX when el scrolled', (done) => { - const $btn = $('').appendTo(cy.$$('body')) + const $btn = $(``).appendTo(cy.$$('body')) const win = cy.state('window') $btn.get(0).addEventListener('click', (e) => { - const { fromViewport } = Cypress.dom.getElementCoordinatesByPosition($btn) + const { fromElViewport } = Cypress.dom.getElementCoordinatesByPosition($btn) - expect(win.pageXOffset).to.be.gt(0) - expect(e.clientX).to.be.closeTo(fromViewport.x, 1) + expect(win.scrollX).to.be.gt(0) + expect(e.clientX).to.be.closeTo(fromElViewport.x, 1) done() }) @@ -240,15 +232,15 @@ describe('src/cy/commands/actions/click', () => { }) it('records correct clientY when el scrolled', (done) => { - const $btn = $('').appendTo(cy.$$('body')) + const $btn = $(``).appendTo(cy.$$('body')) const win = cy.state('window') $btn.get(0).addEventListener('click', (e) => { - const { fromViewport } = Cypress.dom.getElementCoordinatesByPosition($btn) + const { fromElViewport } = Cypress.dom.getElementCoordinatesByPosition($btn) - expect(win.pageYOffset).to.be.gt(0) - expect(e.clientY).to.be.closeTo(fromViewport.y, 1) + expect(win.scrollY).to.be.gt(0) + expect(e.clientY).to.be.closeTo(fromElViewport.y, 1) done() }) @@ -257,25 +249,37 @@ describe('src/cy/commands/actions/click', () => { }) it('will send all events even mousedown is defaultPrevented', () => { - const events = [] const $btn = cy.$$('#button') $btn.get(0).addEventListener('mousedown', (e) => { e.preventDefault() - expect(e.defaultPrevented).to.be.true }) - _.each('mouseup click'.split(' '), (event) => { - return $btn.get(0).addEventListener(event, () => { - return events.push(event) - }) - }) + attachMouseClickListeners({ $btn }) - cy.get('#button').click().then(() => { - expect(events).to.deep.eq(['mouseup', 'click']) + cy.get('#button').click().should('not.have.focus') + + cy.getAll('$btn', 'pointerdown mousedown pointerup mouseup click').each(shouldBeCalled) + }) + + it('will not send mouseEvents/focus if pointerdown is defaultPrevented', () => { + const $btn = cy.$$('#button') + + $btn.get(0).addEventListener('pointerdown', (e) => { + e.preventDefault() + + expect(e.defaultPrevented).to.be.true }) + + attachMouseClickListeners({ $btn }) + + cy.get('#button').click().should('not.have.focus') + + cy.getAll('$btn', 'pointerdown pointerup click').each(shouldBeCalledOnce) + cy.getAll('$btn', 'mousedown mouseup').each(shouldNotBeCalled) + }) it('sends a click event', (done) => { @@ -294,14 +298,15 @@ describe('src/cy/commands/actions/click', () => { }) }) - it('causes focusable elements to receive focus', (done) => { - const $text = cy.$$(':text:first') + it('causes focusable elements to receive focus', () => { - $text.focus(() => { - done() - }) + const el = cy.$$(':text:first') + + attachFocusListeners({ el }) - cy.get(':text:first').click() + cy.get(':text:first').click().should('have.focus') + + cy.getAll('el', 'focus focusin').each(shouldBeCalledOnce) }) it('does not fire a focus, mouseup, or click event when element has been removed on mousedown', () => { @@ -309,26 +314,71 @@ describe('src/cy/commands/actions/click', () => { $btn.on('mousedown', function () { // synchronously remove this button - return $(this).remove() + $(this).remove() }) $btn.on('focus', () => { - return fail('should not have gotten focus') + fail('should not have gotten focus') }) $btn.on('focusin', () => { - return fail('should not have gotten focusin') + fail('should not have gotten focusin') }) $btn.on('mouseup', () => { - return fail('should not have gotten mouseup') + fail('should not have gotten mouseup') }) $btn.on('click', () => { - return fail('should not have gotten click') + fail('should not have gotten click') + }) + + cy.contains('button').click() + }) + + it('events when element removed on pointerdown', () => { + const btn = cy.$$('button:first') + const div = cy.$$('div#tabindex') + + attachFocusListeners({ btn }) + attachMouseClickListeners({ btn, div }) + attachMouseHoverListeners({ btn, div }) + + btn.on('pointerdown', () => { + // synchronously remove this button + btn.remove() + }) + + cy.contains('button').click() + + cy.getAll('btn', 'pointerdown').each(shouldBeCalled) + cy.getAll('btn', 'mousedown mouseup').each(shouldNotBeCalled) + + // the browser is in control of whether or not the pointerdown event + // so this test *may* not necessarily pass in all browsers, but it's + // worth adding to help specify the current expected behavior + cy.getAll('div', 'pointerdown').each(shouldNotBeCalled) + cy.getAll('div', 'pointerover pointerenter mouseover mouseenter pointerup mouseup').each(shouldBeCalled) + }) + + it('events when element removed on pointerover', () => { + const btn = cy.$$('button:first') + const div = cy.$$('div#tabindex') + + // attachFocusListeners({ btn }) + attachMouseClickListeners({ btn, div }) + attachMouseHoverListeners({ btn, div }) + + btn.on('pointerover', () => { + // synchronously remove this button + btn.remove() }) cy.contains('button').click() + + cy.getAll('btn', 'pointerover pointerenter').each(shouldBeCalled) + cy.getAll('btn', 'pointerdown mousedown mouseover mouseenter').each(shouldNotBeCalled) + cy.getAll('div', 'pointerover pointerenter pointerdown mousedown pointerup mouseup click').each(shouldBeCalled) }) it('does not fire a click when element has been removed on mouseup', () => { @@ -336,19 +386,53 @@ describe('src/cy/commands/actions/click', () => { $btn.on('mouseup', function () { // synchronously remove this button - return $(this).remove() + $(this).remove() }) $btn.on('click', () => { - return fail('should not have gotten click') + fail('should not have gotten click') }) cy.contains('button').click() }) - it('silences errors on unfocusable elements', () => { - cy.$$('div:first') + it('does not fire a click or mouseup when element has been removed on pointerup', () => { + const $btn = cy.$$('button:first') + $btn.on('pointerup', function () { + // synchronously remove this button + $(this).remove() + }) + + ;['mouseup', 'click'].forEach((eventName) => { + $btn.on(eventName, () => { + fail(`should not have gotten ${eventName}`) + }) + }) + + cy.contains('button').click() + }) + + it('sends modifiers', () => { + const btn = cy.$$('button:first') + + attachMouseClickListeners({ btn }) + + cy.get('input:first').type('{ctrl}{shift}', { release: false }) + cy.get('button:first').click() + + cy.getAll('btn', 'pointerdown mousedown pointerup mouseup click').each((stub) => { + expect(stub).to.be.calledWithMatch({ + shiftKey: true, + ctrlKey: true, + metaKey: false, + altKey: false, + }) + + }) + }) + + it('silences errors on unfocusable elements', () => { cy.get('div:first').click({ force: true }) }) @@ -356,7 +440,7 @@ describe('src/cy/commands/actions/click', () => { let blurred = false cy.$$('input:first').blur(() => { - return blurred = true + blurred = true }) cy @@ -413,7 +497,7 @@ describe('src/cy/commands/actions/click', () => { }) const clicked = cy.spy(() => { - return stop() + stop() }) const $anchors = cy.$$('#sequential-clicks a') @@ -429,7 +513,7 @@ describe('src/cy/commands/actions/click', () => { // is called const timeout = cy.spy(cy.timeout) - return _.delay(() => { + _.delay(() => { // and we should have stopped clicking after 3 expect(clicked.callCount).to.eq(3) @@ -444,24 +528,23 @@ describe('src/cy/commands/actions/click', () => { }) it('serially clicks a collection', () => { - let clicks = 0 + const throttled = cy.stub().as('clickcount') // create a throttled click function // which proves we are clicking serially - const throttled = _.throttle(() => { - return clicks += 1 - } - , 5, { leading: false }) + const handleClick = cy.stub() + .callsFake(_.throttle(throttled, 0, { leading: false })) + .as('handleClick') - const anchors = cy.$$('#sequential-clicks a') + const $anchors = cy.$$('#sequential-clicks a') - anchors.click(throttled) + $anchors.on('click', handleClick) - // make sure we're clicking multiple anchors - expect(anchors.length).to.be.gt(1) + // make sure we're clicking multiple $anchors + expect($anchors.length).to.be.gt(1) - cy.get('#sequential-clicks a').click({ multiple: true }).then(($anchors) => { - expect($anchors.length).to.eq(clicks) + cy.get('#sequential-clicks a').click({ multiple: true }).then(($els) => { + expect($els).to.have.length(throttled.callCount) }) }) @@ -612,92 +695,262 @@ describe('src/cy/commands/actions/click', () => { }) }) - describe('actionability', () => { - it('can click on inline elements that wrap lines', () => { - cy.get('#overflow-link').find('.wrapped').click() - }) + describe('pointer-events:none', () => { + beforeEach(function () { + cy.$$('
behind #ptrNone
').appendTo(cy.$$('#dom')) + this.ptrNone = cy.$$(`
#ptrNone
`).appendTo(cy.$$('#dom')) + cy.$$('
#ptrNone > div
').appendTo(this.ptrNone) - // readonly should only limit typing, not clicking - it('can click on readonly inputs', () => { - cy.get('#readonly-attr').click() + this.logs = [] + cy.on('log:added', (attrs, log) => { + this.lastLog = log + + this.logs.push(log) + }) }) - it('can click on readonly submit inputs', () => { - cy.get('#readonly-submit').click() + it('element behind pointer-events:none should still get click', () => { + cy.get('#ptr').click() // should pass with flying colors }) - it('can click elements which are hidden until scrolled within parent container', () => { - cy.get('#overflow-auto-container').contains('quux').click() + it('should be able to force on pointer-events:none with force:true', () => { + cy.get('#ptrNone').click({ timeout: 300, force: true }) }) - it('does not scroll when being forced', () => { - const scrolled = [] + it('should error with message about pointer-events', function () { + const onError = cy.stub().callsFake((err) => { + const { lastLog } = this - cy.on('scrolled', ($el, type) => { - return scrolled.push(type) + expect(err.message).to.contain(`has CSS 'pointer-events: none'`) + expect(err.message).to.not.contain('inherited from') + const consoleProps = lastLog.invoke('consoleProps') + + expect(_.keys(consoleProps)).deep.eq([ + 'Command', + 'Tried to Click', + 'But it has CSS', + 'Error', + ]) + + expect(consoleProps['But it has CSS']).to.eq('pointer-events: none') }) - cy - .get('button:last').click({ force: true }) + cy.once('fail', onError) + + cy.get('#ptrNone').click({ timeout: 300 }) .then(() => { - expect(scrolled).to.be.empty + expect(onError).calledOnce }) }) - it('does not scroll when position sticky and display flex', () => { - const scrolled = [] - - cy.on('scrolled', ($el, type) => { - return scrolled.push(type) - }) + it('should error with message about pointer-events and include inheritance', function () { + const onError = cy.stub().callsFake((err) => { + const { lastLog } = this - cy.viewport(1000, 660) + expect(err.message).to.contain(`has CSS 'pointer-events: none', inherited from this element:`) + expect(err.message).to.contain('
') - .attr('id', 'flex-wrap') - .css({ - display: 'flex', + expect(consoleProps['Inherited From']).to.eq(this.ptrNone.get(0)) }) - .prependTo($body) - $(`\ -\ -`) - .attr('id', 'nav') - .css({ - position: 'sticky', - top: 0, - height: '100vh', - width: '200px', - background: '#f0f0f0', - borderRight: '1px solid silver', - padding: '20px', - }) - .appendTo($wrap) + cy.once('fail', onError) - const $content = $('

Hello

') - .attr('id', 'content') - .css({ - padding: '20px', - flex: 1, + cy.get('#ptrNoneChild').click({ timeout: 300 }) + .then(() => { + expect(onError).calledOnce }) - .appendTo($wrap) + }) + }) - $('
Long block 1
') - .attr('id', 'long-block-1') - .css({ - height: '500px', - border: '1px solid red', - marginTop: '10px', + describe('pointer-events:none', () => { + beforeEach(function () { + cy.$$('
behind #ptrNone
').appendTo(cy.$$('#dom')) + this.ptrNone = cy.$$('
#ptrNone
').appendTo(cy.$$('#dom')) + cy.$$('
#ptrNone > div
').appendTo(this.ptrNone) + + this.logs = [] + cy.on('log:added', (attrs, log) => { + this.lastLog = log + + this.logs.push(log) + }) + }) + + it('element behind pointer-events:none should still get click', () => { + cy.get('#ptr').click() // should pass with flying colors + }) + + it('should be able to force on pointer-events:none with force:true', () => { + cy.get('#ptrNone').click({ timeout: 300, force: true }) + }) + + it('should error with message about pointer-events', function () { + const onError = cy.stub().callsFake((err) => { + const { lastLog } = this + + expect(err.message).to.contain('has CSS \'pointer-events: none\'') + expect(err.message).to.not.contain('inherited from') + const consoleProps = lastLog.invoke('consoleProps') + + expect(_.keys(consoleProps)).deep.eq([ + 'Command', + 'Tried to Click', + 'But it has CSS', + 'Error', + ]) + + expect(consoleProps['But it has CSS']).to.eq('pointer-events: none') + }) + + cy.once('fail', onError) + + cy.get('#ptrNone').click({ timeout: 300 }) + .then(() => { + expect(onError).calledOnce + }) + }) + + it('should error with message about pointer-events and include inheritance', function () { + const onError = cy.stub().callsFake((err) => { + const { lastLog } = this + + expect(err.message).to.contain('has CSS \'pointer-events: none\', inherited from this element:') + expect(err.message).to.contain('
{ + expect(onError).calledOnce + }) + }) + }) + + describe('actionability', () => { + it('can click on inline elements that wrap lines', () => { + cy.get('#overflow-link').find('.wrapped').click() + }) + + // readonly should only limit typing, not clicking + it('can click on readonly inputs', () => { + cy.get('#readonly-attr').click() + }) + + it('can click on readonly submit inputs', () => { + cy.get('#readonly-submit').click() + }) + + it('can click on checkbox inputs', () => { + cy.get(':checkbox:first').click() + .then(($el) => { + expect($el).to.be.checked + }) + }) + + it('can force click on disabled checkbox inputs', () => { + cy.get(':checkbox:first') + .then(($el) => { + $el[0].disabled = true + }) + .click({ force: true }) + .then(($el) => { + expect($el).to.be.checked + }) + }) + + it('can click elements which are hidden until scrolled within parent container', () => { + cy.get('#overflow-auto-container').contains('quux').click() + }) + + it('does not scroll when being forced', () => { + const scrolled = [] + + cy.on('scrolled', ($el, type) => { + scrolled.push(type) + }) + + cy + .get('button:last').click({ force: true }) + .then(() => { + expect(scrolled).to.be.empty + }) + }) + + it('does not scroll when position sticky and display flex', () => { + const scrolled = [] + + cy.on('scrolled', ($el, type) => { + scrolled.push(type) + }) + + cy.viewport(1000, 660) + + const $body = cy.$$('body') + + $body.children().remove() + + const $wrap = $('
') + .attr('id', 'flex-wrap') + .css({ + display: 'flex', + }) + .prependTo($body) + + $(`\ + `) + .attr('id', 'nav') + .css({ + position: 'sticky', + top: 0, + height: '100vh', + width: '200px', + background: '#f0f0f0', + borderRight: '1px solid silver', + padding: '20px', + }) + .appendTo($wrap) + + const $content = $('

Hello

') + .attr('id', 'content') + .css({ + padding: '20px', + flex: 1, + }) + .appendTo($wrap) + + $('
Long block 1
') + .attr('id', 'long-block-1') + .css({ + height: '500px', + border: '1px solid red', + marginTop: '10px', width: '100%', }).appendTo($content) @@ -762,15 +1015,15 @@ describe('src/cy/commands/actions/click', () => { let clicked = false cy.on('scrolled', ($el, type) => { - return scrolled.push(type) + scrolled.push(type) }) cy.on('command:retry', () => { - return retried = true + retried = true }) $btn.on('click', () => { - return clicked = true + clicked = true }) cy.get('#button-covered-in-span').click({ force: true }).then(() => { @@ -799,7 +1052,7 @@ describe('src/cy/commands/actions/click', () => { let retried = false cy.on('scrolled', ($el, type) => { - return scrolled.push(type) + scrolled.push(type) }) cy.on('command:retry', _.after(3, () => { @@ -820,9 +1073,16 @@ describe('src/cy/commands/actions/click', () => { }) it('scrolls the window past a fixed position element when being covered', () => { + const spy = cy.spy().as('mousedown') + $('') .attr('id', 'button-covered-in-nav') + .css({ + width: 120, + height: 20, + }) .appendTo(cy.$$('#fixed-nav-test')) + .mousedown(spy) $('').css({ position: 'fixed', @@ -836,14 +1096,27 @@ describe('src/cy/commands/actions/click', () => { const scrolled = [] cy.on('scrolled', ($el, type) => { - return scrolled.push(type) + scrolled.push(type) }) // - element scrollIntoView // - element scrollIntoView (retry animation coords) // - window - cy.get('#button-covered-in-nav').click().then(() => { + cy + .get('#button-covered-in-nav').click() + .then(($btn) => { + const rect = $btn.get(0).getBoundingClientRect() + const { fromElViewport } = Cypress.dom.getElementCoordinatesByPosition($btn) + + // this button should be 120 pixels wide + expect(rect.width).to.eq(120) + + const obj = spy.firstCall.args[0] + + // clientX + clientY are relative to the document expect(scrolled).to.deep.eq(['element', 'element', 'window']) + expect(obj).property('clientX').closeTo(fromElViewport.leftCenter, 1) + expect(obj).property('clientY').closeTo(fromElViewport.topCenter, 1) }) }) @@ -873,7 +1146,7 @@ describe('src/cy/commands/actions/click', () => { const scrolled = [] cy.on('scrolled', ($el, type) => { - return scrolled.push(type) + scrolled.push(type) }) // - element scrollIntoView @@ -930,7 +1203,7 @@ describe('src/cy/commands/actions/click', () => { const scrolled = [] cy.on('scrolled', ($el, type) => { - return scrolled.push(type) + scrolled.push(type) }) // - element scrollIntoView @@ -964,7 +1237,7 @@ describe('src/cy/commands/actions/click', () => { let clicks = 0 $btn.on('click', () => { - return clicks += 1 + clicks += 1 }) cy.on('command:retry', _.after(3, () => { @@ -983,7 +1256,7 @@ describe('src/cy/commands/actions/click', () => { let retries = 0 cy.on('command:retry', () => { - return retries += 1 + retries += 1 }) cy.stub(cy, 'ensureElementIsNotAnimating') @@ -1020,14 +1293,14 @@ describe('src/cy/commands/actions/click', () => { it('passes options.animationDistanceThreshold to cy.ensureElementIsNotAnimating', () => { const $btn = cy.$$('button:first') - const { fromWindow } = Cypress.dom.getElementCoordinatesByPosition($btn) + const { fromElWindow } = Cypress.dom.getElementCoordinatesByPosition($btn) cy.spy(cy, 'ensureElementIsNotAnimating') cy.get('button:first').click({ animationDistanceThreshold: 1000 }).then(() => { const { args } = cy.ensureElementIsNotAnimating.firstCall - expect(args[1]).to.deep.eq([fromWindow, fromWindow]) + expect(args[1]).to.deep.eq([fromElWindow, fromElWindow]) expect(args[2]).to.eq(1000) }) @@ -1038,14 +1311,14 @@ describe('src/cy/commands/actions/click', () => { const $btn = cy.$$('button:first') - const { fromWindow } = Cypress.dom.getElementCoordinatesByPosition($btn) + const { fromElWindow } = Cypress.dom.getElementCoordinatesByPosition($btn) cy.spy(cy, 'ensureElementIsNotAnimating') cy.get('button:first').click().then(() => { const { args } = cy.ensureElementIsNotAnimating.firstCall - expect(args[1]).to.deep.eq([fromWindow, fromWindow]) + expect(args[1]).to.deep.eq([fromElWindow, fromElWindow]) expect(args[2]).to.eq(animationDistanceThreshold) }) @@ -1060,13 +1333,13 @@ describe('src/cy/commands/actions/click', () => { } }) - return null + null }) it('eventually passes the assertion', () => { cy.$$('button:first').click(function () { _.delay(() => { - return $(this).addClass('clicked') + $(this).addClass('clicked') } , 50) @@ -1086,7 +1359,7 @@ describe('src/cy/commands/actions/click', () => { it('eventually passes the assertion on multiple buttons', () => { cy.$$('button').click(function () { _.delay(() => { - return $(this).addClass('clicked') + $(this).addClass('clicked') } , 50) @@ -1246,8 +1519,6 @@ describe('src/cy/commands/actions/click', () => { it('can pass options along with position', (done) => { const $btn = $('').attr('id', 'button-covered-in-span').css({ height: 100, width: 100 }).prependTo(cy.$$('body')) - $('span').css({ position: 'absolute', left: $btn.offset().left + 80, top: $btn.offset().top + 80, padding: 5, display: 'inline-block', backgroundColor: 'yellow' }).appendTo(cy.$$('body')) - $btn.on('click', () => { done() }) @@ -1280,8 +1551,6 @@ describe('src/cy/commands/actions/click', () => { it('can pass options along with x, y', (done) => { const $btn = $('').attr('id', 'button-covered-in-span').css({ height: 100, width: 100 }).prependTo(cy.$$('body')) - $('span').css({ position: 'absolute', left: $btn.offset().left + 50, top: $btn.offset().top + 65, padding: 5, display: 'inline-block', backgroundColor: 'yellow' }).appendTo(cy.$$('body')) - $btn.on('click', () => { done() }) @@ -1346,8 +1615,8 @@ describe('src/cy/commands/actions/click', () => { const input = cy.$$('input:first') _.each('focus focusin mousedown mouseup click'.split(' '), (event) => { - return input.get(0).addEventListener(event, () => { - return events.push(event) + input.get(0).addEventListener(event, () => { + events.push(event) }) }) @@ -1401,27 +1670,17 @@ describe('src/cy/commands/actions/click', () => { expect(onFocus).not.to.be.called }) }) - }) - // it "events", -> - // $btn = cy.$$("button") - // win = $(cy.state("window")) - - // _.each {"btn": btn, "win": win}, (type, key) -> - // _.each "focus mousedown mouseup click".split(" "), (event) -> - // # _.each "focus focusin focusout mousedown mouseup click".split(" "), (event) -> - // type.get(0).addEventListener event, (e) -> - // if key is "btn" - // # e.preventDefault() - // e.stopPropagation() - - // console.log "#{key} #{event}", e - - // $btn.on "mousedown", (e) -> - // console.log("btn mousedown") - // e.preventDefault() - - // win.on "mousedown", -> console.log("win mousedown") + it('will fire pointerdown event', () => { + // cy.get('input').eq(1).click() + // cy.get('input').eq(2).click() + // cy.get('input').eq(4).click() + cy.get('textarea:first').click() + // cy.get('input').eq(3).click() + cy.get('input:first').click() + // cy.get('input').eq(1).click() + }) + }) describe('errors', () => { beforeEach(function () { @@ -1432,10 +1691,10 @@ describe('src/cy/commands/actions/click', () => { cy.on('log:added', (attrs, log) => { this.lastLog = log - return this.logs.push(log) + this.logs.push(log) }) - return null + null }) it('throws when not a dom subject', (done) => { @@ -1447,15 +1706,14 @@ describe('src/cy/commands/actions/click', () => { }) it('throws when attempting to click multiple elements', (done) => { - const num = cy.$$('button').length cy.on('fail', (err) => { - expect(err.message).to.eq(`cy.click() can only be called on a single element. Your subject contained ${num} elements. Pass { multiple: true } if you want to serially click each element.`) + expect(err.message).to.eq('cy.click() can only be called on a single element. Your subject contained 4 elements. Pass { multiple: true } if you want to serially click each element.') done() }) - cy.get('button').click() + cy.get('.badge-multi').click() }) it('throws when subject is not in the document', (done) => { @@ -1509,16 +1767,23 @@ describe('src/cy/commands/actions/click', () => { cy.click() }) + // Array(1).fill().map(()=> it('throws when any member of the subject isnt visible', function (done) { - cy.timeout(250) + + // sometimes the command will timeout early with + // Error: coordsHistory must be at least 2 sets of coords + cy.timeout(300) cy.$$('#three-buttons button').show().last().hide() cy.on('fail', (err) => { - const { lastLog } = this + const { lastLog, logs } = this + const logsArr = logs.map((log) => { + return log.get().consoleProps() + }) - expect(this.logs.length).to.eq(4) + expect(logsArr).to.have.length(4) expect(lastLog.get('error')).to.eq(err) expect(err.message).to.include('cy.click() failed because this element is not visible') @@ -1633,7 +1898,7 @@ describe('src/cy/commands/actions/click', () => { expect(lastLog.get('snapshots')[1].name).to.eq('after') expect(err.message).to.include('cy.click() failed because this element is not visible:') expect(err.message).to.include('>button ...') - expect(err.message).to.include('\'\' is not visible because it has CSS property: \'position: fixed\' and its being covered') + expect(err.message).to.include(`'' is not visible because it has CSS property: 'position: fixed' and its being covered`) expect(err.message).to.include('>span on...') const console = lastLog.invoke('consoleProps') @@ -1678,7 +1943,7 @@ describe('src/cy/commands/actions/click', () => { it('throws when provided invalid position', function (done) { cy.on('fail', (err) => { expect(this.logs.length).to.eq(2) - expect(err.message).to.eq('Invalid position argument: \'foo\'. Position may only be topLeft, top, topRight, left, center, right, bottomLeft, bottom, bottomRight.') + expect(err.message).to.eq(`Invalid position argument: 'foo'. Position may only be topLeft, top, topRight, left, center, right, bottomLeft, bottom, bottomRight.`) done() }) @@ -1693,7 +1958,7 @@ describe('src/cy/commands/actions/click', () => { let clicks = 0 cy.$$('button:first').on('click', () => { - return clicks += 1 + clicks += 1 }) cy.on('fail', (err) => { @@ -1714,7 +1979,7 @@ describe('src/cy/commands/actions/click', () => { expect(err.message).not.to.include('undefined') expect(lastLog.get('name')).to.eq('assert') expect(lastLog.get('state')).to.eq('failed') - expect(lastLog.get('error')).to.be.an.instanceof(chai.AssertionError) + expect(lastLog.get('error')).to.be.an.instanceof(window.chai.AssertionError) done() }) @@ -1740,10 +2005,8 @@ describe('src/cy/commands/actions/click', () => { cy.on('log:added', (attrs, log) => { this.lastLog = log - return this.logs.push(log) + this.logs.push(log) }) - - return null }) it('logs immediately before resolving', (done) => { @@ -1791,14 +2054,14 @@ describe('src/cy/commands/actions/click', () => { // append two buttons const button = () => { - return $('') + return $(``) } cy.$$('body').append(button()).append(button()) cy.on('log:added', (attrs, log) => { if (log.get('name') === 'click') { - return clicks.push(log) + clicks.push(log) } }) @@ -1815,7 +2078,7 @@ describe('src/cy/commands/actions/click', () => { cy.on('log:added', (attrs, log) => { if (log.get('name') === 'click') { - return logs.push(log) + logs.push(log) } }) @@ -1829,9 +2092,9 @@ describe('src/cy/commands/actions/click', () => { const { lastLog } = this $btn.blur() // blur which removes focus styles which would change coords - const { fromWindow } = Cypress.dom.getElementCoordinatesByPosition($btn) + const { fromElWindow } = Cypress.dom.getElementCoordinatesByPosition($btn) - expect(lastLog.get('coords')).to.deep.eq(fromWindow) + expect(lastLog.get('coords')).to.deep.eq(fromElWindow) }) }) @@ -1840,12 +2103,12 @@ describe('src/cy/commands/actions/click', () => { cy.on('log:added', (attrs, log) => { if (log.get('name') === 'click') { - return logs.push(log) + logs.push(log) } }) cy.get('#three-buttons button').click({ multiple: true }).then(() => { - return _.each(logs, (log) => { + _.each(logs, (log) => { expect(log.get('state')).to.eq('passed') expect(log.get('ended')).to.be.true @@ -1864,23 +2127,26 @@ describe('src/cy/commands/actions/click', () => { }) it('#consoleProps', () => { - cy.get('button').first().click().then(function ($button) { + cy.get('button').first().click().then(function ($btn) { const { lastLog } = this - const console = lastLog.invoke('consoleProps') - const { fromWindow } = Cypress.dom.getElementCoordinatesByPosition($button) - const logCoords = lastLog.get('coords') + const rect = $btn.get(0).getBoundingClientRect() + const consoleProps = lastLog.invoke('consoleProps') + const { fromElWindow } = Cypress.dom.getElementCoordinatesByPosition($btn) + + // this button should be 60 pixels wide + expect(rect.width).to.eq(60) - expect(logCoords.x).to.be.closeTo(fromWindow.x, 1) // ensure we are within 1 - expect(logCoords.y).to.be.closeTo(fromWindow.y, 1) // ensure we are within 1 - expect(console.Command).to.eq('click') - expect(console['Applied To']).to.eq(lastLog.get('$el').get(0)) - expect(console.Elements).to.eq(1) - expect(console.Coords.x).to.be.closeTo(fromWindow.x, 1) // ensure we are within 1 + expect(consoleProps.Coords.x).to.be.closeTo(fromElWindow.x, 1) // ensure we are within 1 + expect(consoleProps.Coords.y).to.be.closeTo(fromElWindow.y, 1) // ensure we are within 1 - expect(console.Coords.y).to.be.closeTo(fromWindow.y, 1) + expect(consoleProps).to.containSubset({ + 'Command': 'click', + 'Applied To': lastLog.get('$el').get(0), + 'Elements': 1, + }) }) - }) // ensure we are within 1 + }) it('#consoleProps actual element clicked', () => { const $btn = $('`) + .css({ + float: 'left', + display: 'block', + width: 250, + height: 30, + }) + .appendTo(cy.$$('body')) + + attachMouseHoverListeners({ btn }) + attachMouseClickListeners({ btn }) + + btn.on('pointerover', () => { + btn.attr('disabled', true) + }) + + cy.get('#btn').click() + + cy.getAll('btn', 'pointerover pointerenter pointerdown pointerup').each((stub) => { + expect(stub).to.be.calledOnce + }) + + cy.getAll('btn', 'mouseover mouseenter mousedown mouseup click').each((stub) => { + expect(stub).to.not.be.called + }) + }) + + it('handles disabled attr added on mousedown', () => { + const btn = cy.$$(/*html*/``) + .css({ + float: 'left', + display: 'block', + width: 250, + height: 30, + }) + .appendTo(cy.$$('body')) + + attachMouseHoverListeners({ btn }) + attachMouseClickListeners({ btn }) + + btn.on('mousedown', () => { + btn.attr('disabled', true) + }) + + cy.get('#btn').click() + + cy.getAll('btn', 'pointerdown mousedown pointerup').each((stub) => { + expect(stub).to.be.calledOnce + }) + + cy.getAll('btn', 'mouseup click').each((stub) => { + expect(stub).to.not.be.calledOnce + }) + }) + + it('can click new element after mousemove sequence', () => { + const btn = cy.$$(/*html*/``) + .css({ + float: 'left', + display: 'block', + width: 250, + height: 30, + }) + .appendTo(cy.$$('body')) + + const cover = cy.$$(/*html*/`
`).css({ + backgroundColor: 'blue', + position: 'relative', + height: 50, + width: 300, + }) + .appendTo(btn.parent()) + + cover.on('mousemove', () => { + cover.hide() + }) + + attachMouseHoverListeners({ btn, cover }) + attachMouseClickListeners({ btn, cover }) + + cy.get('#cover').click() + + cy.getAll('cover', 'pointerdown mousedown pointerup mouseup click').each((stub) => { + expect(stub).to.not.be.called + }) + + cy.getAll('btn', 'pointerdown mousedown mouseup pointerup click').each((stub) => { + expect(stub).to.be.calledOnce + }) + }) + + it('can click new element after mousemove sequence [disabled]', () => { + const btn = cy.$$(/*html*/``) + .css({ + float: 'left', + display: 'block', + width: 250, + height: 30, + }) + .appendTo(cy.$$('body')) + + const cover = cy.$$(/*html*/`
`).css({ + backgroundColor: 'blue', + position: 'relative', + height: 50, + width: 300, + }) + .appendTo(btn.parent()) + + cover.on('mousemove', () => { + cover.hide() + }) + + attachMouseHoverListeners({ btn, cover }) + attachMouseClickListeners({ btn, cover }) + + btn.attr('disabled', true) + + cover.on('mousemove', () => { + cover.hide() + }) + + attachMouseHoverListeners({ btn, cover }) + attachMouseClickListeners({ btn, cover }) + + cy.get('#cover').click() + + cy.getAll('btn', 'mousedown mouseup click').each((stub) => { + expect(stub).to.not.be.called + }) + + // on disabled inputs, pointer events are still fired + cy.getAll('btn', 'pointerdown pointerup').each((stub) => { + expect(stub).to.be.called + }) + }) + + it('can target new element after mousedown sequence', () => { + const btn = cy.$$(/*html*/``) + .css({ + float: 'left', + display: 'block', + width: 250, + height: 30, + }) + .appendTo(cy.$$('body')) + + const cover = cy.$$(/*html*/`
`).css({ + backgroundColor: 'blue', + position: 'relative', + height: 50, + width: 300, + }) + .appendTo(btn.parent()) + + cover.on('mousedown', () => { + cover.hide() + }) + + attachMouseHoverListeners({ btn, cover }) + attachMouseClickListeners({ btn, cover }) + + btn.on('mouseup', () => { + btn.attr('disabled', true) + }) + + cy.get('#cover').click() + + cy.getAll('btn', 'mouseup pointerup').each((stub) => { + expect(stub).to.be.calledOnce + }) + }) + + it('can target new element after mouseup sequence', () => { + const btn = cy.$$(/*html*/``) + .css({ + float: 'left', + display: 'block', + width: 250, + height: 30, + }) + .appendTo(cy.$$('body')) + + const cover = cy.$$(/*html*/`
`).css({ + backgroundColor: 'blue', + position: 'relative', + height: 50, + width: 300, + }) + .appendTo(btn.parent()) + + cover.on('mouseup', () => { + cover.hide() + }) + + attachMouseHoverListeners({ btn, cover }) + attachMouseClickListeners({ btn, cover }) + + btn.on('mouseup', () => { + btn.attr('disabled', true) + }) + + cy.get('#cover').click() + + cy.getAll('cover', 'click').each((stub) => { + expect(stub).to.not.be.called + }) + + cy.getAll('cover', 'pointerdown mousedown mouseup').each((stub) => { + expect(stub).to.be.calledOnce + }) + }) + + it('responds to changes in move handlers', () => { + const btn = cy.$$(/*html*/``) + .css({ + float: 'left', + display: 'block', + width: 250, + height: 30, + }) + .appendTo(cy.$$('body')) + + const cover = cy.$$(/*html*/`
`).css({ + backgroundColor: 'blue', + position: 'relative', + height: 50, + width: 300, + }) + .appendTo(btn.parent()) + + cover.on('mouseover', () => { + cover.hide() + }) + + attachMouseHoverListeners({ btn, cover }) + attachMouseClickListeners({ btn, cover }) + + cy.get('#cover').click() + + cy.getAll('cover', 'mousedown').each((stub) => { + expect(stub).to.not.be.called + }) + + cy.getAll('btn', 'pointerdown mousedown mouseup pointerup click').each((stub) => { + expect(stub).to.be.calledOnce + }) + }) + + }) + + describe('user experience', () => { + + beforeEach(() => { + cy.visit('/fixtures/dom.html') + }) + + // https://github.com/cypress-io/cypress/issues/4347 + it('can render element highlight inside iframe', () => { + + cy.get('iframe:first') + .should(($iframe) => { + // wait for iframe to load + expect($iframe.first().contents().find('body').html()).ok + }) + .then(($iframe) => { + // cypress does not wrap this as a DOM element (does not wrap in jquery) + return cy.wrap($iframe.first().contents().find('body')) + }) + .within(() => { + cy.get('a#hashchange') + .click() + }) + .then(($body) => { + expect($body[0].ownerDocument.defaultView.location.hash).eq('#hashchange') + }) + + clickCommandLog('click') + .then(() => { + cy.get('.__cypress-highlight').then(($target) => { + const targetRect = $target[0].getBoundingClientRect() + const iframeRect = cy.$$('iframe')[0].getBoundingClientRect() + + expect(targetRect.top).gt(iframeRect.top) + expect(targetRect.bottom).lt(iframeRect.bottom) + }) + }) + }) + + it('can print table of keys on click', () => { + const spyTableName = cy.spy(top.console, 'groupCollapsed') + const spyTableData = cy.spy(top.console, 'table') + + cy.get('input:first').click() + + cy.wrap(null) + .should(() => { + spyTableName.reset() + spyTableData.reset() + + return withMutableReporterState(() => { + const commandLogEl = getCommandLogWithText('click') + + const reactCommandInstance = findReactInstance(commandLogEl.get(0)) + + reactCommandInstance.props.appState.isRunning = false + + commandLogEl.find('.command-wrapper').click() + + expect(spyTableName).calledWith('Mouse Move Events') + expect(spyTableName).calledWith('Mouse Click Events') + expect(spyTableData).calledTwice + }) + }) + }) + }) + }) diff --git a/packages/driver/test/cypress/integration/commands/actions/scroll_spec.coffee b/packages/driver/test/cypress/integration/commands/actions/scroll_spec.coffee index 52d1cb85b082..256ca891f032 100644 --- a/packages/driver/test/cypress/integration/commands/actions/scroll_spec.coffee +++ b/packages/driver/test/cypress/integration/commands/actions/scroll_spec.coffee @@ -47,7 +47,7 @@ describe "src/cy/commands/actions/scroll", -> it "can use window", -> cy.window().scrollTo("10px").then (win) -> - expect(win.pageXOffset).to.eq(10) + expect(win.scrollX).to.eq(10) it "can handle window w/length > 1 as a subject", -> cy.visit('/fixtures/dom.html') @@ -479,31 +479,31 @@ describe "src/cy/commands/actions/scroll", -> expect($div).to.match div it "scrolls x axis of window to element", -> - expect(@win.pageYOffset).to.eq(0) - expect(@win.pageXOffset).to.eq(0) + expect(@win.scrollY).to.eq(0) + expect(@win.scrollX).to.eq(0) cy.get("#scroll-into-view-win-horizontal div").scrollIntoView() cy.window().then (win) -> - expect(win.pageYOffset).to.eq(0) - expect(win.pageXOffset).not.to.eq(0) + expect(win.scrollY).to.eq(0) + expect(win.scrollX).not.to.eq(0) it "scrolls y axis of window to element", -> - expect(@win.pageYOffset).to.eq(0) - expect(@win.pageXOffset).to.eq(0) + expect(@win.scrollY).to.eq(0) + expect(@win.scrollX).to.eq(0) cy.get("#scroll-into-view-win-vertical div").scrollIntoView() cy.window().then (win) -> - expect(win.pageYOffset).not.to.eq(0) - expect(win.pageXOffset).to.eq(200) + expect(win.scrollY).not.to.eq(0) + expect(win.scrollX).to.eq(200) it "scrolls both axes of window to element", -> - expect(@win.pageYOffset).to.eq(0) - expect(@win.pageXOffset).to.eq(0) + expect(@win.scrollY).to.eq(0) + expect(@win.scrollX).to.eq(0) cy.get("#scroll-into-view-win-both div").scrollIntoView() cy.window().then (win) -> - expect(win.pageYOffset).not.to.eq(0) - expect(win.pageXOffset).not.to.eq(0) + expect(win.scrollY).not.to.eq(0) + expect(win.scrollX).not.to.eq(0) it "scrolls x axis of container to element", -> expect(@scrollHoriz.get(0).scrollTop).to.eq(0) diff --git a/packages/driver/test/cypress/integration/commands/actions/select_spec.coffee b/packages/driver/test/cypress/integration/commands/actions/select_spec.coffee index 1988a2bf6dc0..dbfcadd7cae1 100644 --- a/packages/driver/test/cypress/integration/commands/actions/select_spec.coffee +++ b/packages/driver/test/cypress/integration/commands/actions/select_spec.coffee @@ -374,13 +374,13 @@ describe "src/cy/commands/actions/select", -> it "#consoleProps", -> cy.get("#select-maps").select("de_dust2").then ($select) -> - { fromWindow } = Cypress.dom.getElementCoordinatesByPosition($select) + { fromElWindow } = Cypress.dom.getElementCoordinatesByPosition($select) console = @lastLog.invoke("consoleProps") expect(console.Command).to.eq("select") expect(console.Selected).to.deep.eq ["de_dust2"] expect(console["Applied To"]).to.eq $select.get(0) - expect(console.Coords.x).to.be.closeTo(fromWindow.x, 10) - expect(console.Coords.y).to.be.closeTo(fromWindow.y, 10) + expect(console.Coords.x).to.be.closeTo(fromElWindow.x, 10) + expect(console.Coords.y).to.be.closeTo(fromElWindow.y, 10) it "logs only one select event", -> types = [] diff --git a/packages/driver/test/cypress/integration/commands/actions/trigger_spec.coffee b/packages/driver/test/cypress/integration/commands/actions/trigger_spec.coffee index cb37cfda03a9..342f4e6ad48c 100644 --- a/packages/driver/test/cypress/integration/commands/actions/trigger_spec.coffee +++ b/packages/driver/test/cypress/integration/commands/actions/trigger_spec.coffee @@ -18,7 +18,7 @@ describe "src/cy/commands/actions/trigger", -> $btn = cy.$$("#button") $btn.on "mouseover", (e) => - { fromViewport } = Cypress.dom.getElementCoordinatesByPosition($btn) + { fromElViewport } = Cypress.dom.getElementCoordinatesByPosition($btn) obj = _.pick(e.originalEvent, "bubbles", "cancelable", "target", "type") expect(obj).to.deep.eq { @@ -28,8 +28,8 @@ describe "src/cy/commands/actions/trigger", -> type: "mouseover" } - expect(e.clientX).to.be.closeTo(fromViewport.x, 1) - expect(e.clientY).to.be.closeTo(fromViewport.y, 1) + expect(e.clientX).to.be.closeTo(fromElViewport.x, 1) + expect(e.clientY).to.be.closeTo(fromElViewport.y, 1) done() cy.get("#button").trigger("mouseover") @@ -90,10 +90,10 @@ describe "src/cy/commands/actions/trigger", -> win = cy.state("window") $btn.on "mouseover", (e) => - { fromViewport } = Cypress.dom.getElementCoordinatesByPosition($btn) + { fromElViewport } = Cypress.dom.getElementCoordinatesByPosition($btn) - expect(win.pageXOffset).to.be.gt(0) - expect(e.clientX).to.be.closeTo(fromViewport.x, 1) + expect(win.scrollX).to.be.gt(0) + expect(e.clientX).to.be.closeTo(fromElViewport.x, 1) done() cy.get("#scrolledBtn").trigger("mouseover") @@ -104,10 +104,24 @@ describe "src/cy/commands/actions/trigger", -> win = cy.state("window") $btn.on "mouseover", (e) => - { fromViewport } = Cypress.dom.getElementCoordinatesByPosition($btn) + { fromElViewport } = Cypress.dom.getElementCoordinatesByPosition($btn) - expect(win.pageXOffset).to.be.gt(0) - expect(e.clientY).to.be.closeTo(fromViewport.y, 1) + expect(win.scrollX).to.be.gt(0) + expect(e.clientY).to.be.closeTo(fromElViewport.y, 1) + done() + + cy.get("#scrolledBtn").trigger("mouseover") + + it "records correct pageX and pageY el scrolled", (done) -> + $btn = $("").appendTo cy.$$("body") + + win = cy.state("window") + + $btn.on "mouseover", (e) => + { fromElViewport } = Cypress.dom.getElementCoordinatesByPosition($btn) + + expect(e.pageX).to.be.closeTo(win.scrollX + e.clientX, 1) + expect(e.pageY).to.be.closeTo(win.scrollY + e.clientY, 1) done() cy.get("#scrolledBtn").trigger("mouseover") @@ -438,14 +452,14 @@ describe "src/cy/commands/actions/trigger", -> it "passes options.animationDistanceThreshold to cy.ensureElementIsNotAnimating", -> $btn = cy.$$("button:first") - { fromWindow } = Cypress.dom.getElementCoordinatesByPosition($btn) + { fromElWindow } = Cypress.dom.getElementCoordinatesByPosition($btn) cy.spy(cy, "ensureElementIsNotAnimating") cy.get("button:first").trigger("tap", {animationDistanceThreshold: 1000}).then -> args = cy.ensureElementIsNotAnimating.firstCall.args - expect(args[1]).to.deep.eq([fromWindow, fromWindow]) + expect(args[1]).to.deep.eq([fromElWindow, fromElWindow]) expect(args[2]).to.eq(1000) it "passes config.animationDistanceThreshold to cy.ensureElementIsNotAnimating", -> @@ -453,14 +467,14 @@ describe "src/cy/commands/actions/trigger", -> $btn = cy.$$("button:first") - { fromWindow } = Cypress.dom.getElementCoordinatesByPosition($btn) + { fromElWindow } = Cypress.dom.getElementCoordinatesByPosition($btn) cy.spy(cy, "ensureElementIsNotAnimating") cy.get("button:first").trigger("mouseover").then -> args = cy.ensureElementIsNotAnimating.firstCall.args - expect(args[1]).to.deep.eq([fromWindow, fromWindow]) + expect(args[1]).to.deep.eq([fromElWindow, fromElWindow]) expect(args[2]).to.eq(animationDistanceThreshold) describe "assertion verification", -> @@ -773,17 +787,17 @@ describe "src/cy/commands/actions/trigger", -> cy.get("button:first").trigger("mouseover").then ($btn) -> lastLog = @lastLog - { fromWindow } = Cypress.dom.getElementCoordinatesByPosition($btn) - expect(lastLog.get("coords")).to.deep.eq(fromWindow, "x", "y") + { fromElWindow } = Cypress.dom.getElementCoordinatesByPosition($btn) + expect(lastLog.get("coords")).to.deep.eq(fromElWindow, "x", "y") it "#consoleProps", -> cy.get("button:first").trigger("mouseover").then ($button) => consoleProps = @lastLog.invoke("consoleProps") - { fromWindow } = Cypress.dom.getElementCoordinatesByPosition($button) + { fromElWindow } = Cypress.dom.getElementCoordinatesByPosition($button) logCoords = @lastLog.get("coords") eventOptions = consoleProps["Event options"] - expect(logCoords.x).to.be.closeTo(fromWindow.x, 1) ## ensure we are within 1 - expect(logCoords.y).to.be.closeTo(fromWindow.y, 1) ## ensure we are within 1 + expect(logCoords.x).to.be.closeTo(fromElWindow.x, 1) ## ensure we are within 1 + expect(logCoords.y).to.be.closeTo(fromElWindow.y, 1) ## ensure we are within 1 expect(consoleProps.Command).to.eq "trigger" expect(eventOptions.bubbles).to.be.true expect(eventOptions.cancelable).to.be.true @@ -791,3 +805,5 @@ describe "src/cy/commands/actions/trigger", -> expect(eventOptions.clientY).to.be.be.a("number") expect(eventOptions.pageX).to.be.be.a("number") expect(eventOptions.pageY).to.be.be.a("number") + expect(eventOptions.screenX).to.be.be.a("number").and.eq(eventOptions.clientX) + expect(eventOptions.screenY).to.be.be.a("number").and.eq(eventOptions.clientY) diff --git a/packages/driver/test/cypress/integration/commands/actions/type_spec.js b/packages/driver/test/cypress/integration/commands/actions/type_spec.js index f13894679d83..b86ed25b71ef 100644 --- a/packages/driver/test/cypress/integration/commands/actions/type_spec.js +++ b/packages/driver/test/cypress/integration/commands/actions/type_spec.js @@ -2,6 +2,7 @@ const $ = Cypress.$.bind(Cypress) const { _ } = Cypress const { Promise } = Cypress const $selection = require('../../../../../src/dom/selection') +const { getCommandLogWithText, findReactInstance, withMutableReporterState } = require('../../../support/utils') // trim new lines at the end of innerText // due to changing browser versions implementing @@ -318,14 +319,14 @@ describe('src/cy/commands/actions/type', () => { it('passes options.animationDistanceThreshold to cy.ensureElementIsNotAnimating', () => { const $txt = cy.$$(':text:first') - const { fromWindow } = Cypress.dom.getElementCoordinatesByPosition($txt) + const { fromElWindow } = Cypress.dom.getElementCoordinatesByPosition($txt) cy.spy(cy, 'ensureElementIsNotAnimating') cy.get(':text:first').type('foo', { animationDistanceThreshold: 1000 }).then(() => { const { args } = cy.ensureElementIsNotAnimating.firstCall - expect(args[1]).to.deep.eq([fromWindow, fromWindow]) + expect(args[1]).to.deep.eq([fromElWindow, fromElWindow]) expect(args[2]).to.eq(1000) }) @@ -336,14 +337,14 @@ describe('src/cy/commands/actions/type', () => { const $txt = cy.$$(':text:first') - const { fromWindow } = Cypress.dom.getElementCoordinatesByPosition($txt) + const { fromElWindow } = Cypress.dom.getElementCoordinatesByPosition($txt) cy.spy(cy, 'ensureElementIsNotAnimating') cy.get(':text:first').type('foo').then(() => { const { args } = cy.ensureElementIsNotAnimating.firstCall - expect(args[1]).to.deep.eq([fromWindow, fromWindow]) + expect(args[1]).to.deep.eq([fromElWindow, fromElWindow]) expect(args[2]).to.eq(animationDistanceThreshold) }) @@ -4092,30 +4093,30 @@ describe('src/cy/commands/actions/type', () => { context('#consoleProps', () => { it('has all of the regular options', () => { cy.get('input:first').type('foobar').then(function ($input) { - const { fromWindow } = Cypress.dom.getElementCoordinatesByPosition($input) + const { fromElWindow } = Cypress.dom.getElementCoordinatesByPosition($input) const console = this.lastLog.invoke('consoleProps') expect(console.Command).to.eq('type') expect(console.Typed).to.eq('foobar') expect(console['Applied To']).to.eq($input.get(0)) - expect(console.Coords.x).to.be.closeTo(fromWindow.x, 1) + expect(console.Coords.x).to.be.closeTo(fromElWindow.x, 1) - expect(console.Coords.y).to.be.closeTo(fromWindow.y, 1) + expect(console.Coords.y).to.be.closeTo(fromElWindow.y, 1) }) }) it('has a table of keys', () => { - cy.get(':text:first').type('{cmd}{option}foo{enter}b{leftarrow}{del}{enter}').then(function () { - const table = this.lastLog.invoke('consoleProps').table() + cy.get(':text:first').type('{cmd}{option}foo{enter}b{leftarrow}{del}{enter}') + .then(function () { + const table = this.lastLog.invoke('consoleProps').table[3]() // eslint-disable-next-line console.table(table.data, table.columns) - expect(table.columns).to.deep.eq([ 'typed', 'which', 'keydown', 'keypress', 'textInput', 'input', 'keyup', 'change', 'modifiers', ]) - expect(table.name).to.eq('Key Events Table') + expect(table.name).to.eq('Keyboard Events') const expectedTable = { 1: { typed: '', which: 91, keydown: true, modifiers: 'meta' }, 2: { typed: '', which: 18, keydown: true, modifiers: 'alt, meta' }, @@ -4129,20 +4130,13 @@ describe('src/cy/commands/actions/type', () => { 10: { typed: '{enter}', which: 13, keydown: true, keypress: true, keyup: true, modifiers: 'alt, meta' }, } - return [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((i) => { - expect(table.data[i]).to.deep.eq(expectedTable[i]) - }) + expect(table.data).to.deep.eq(expectedTable) }) }) - // table.data.forEach (item, i) -> - // expect(item).to.deep.eq(expectedTable[i]) - - // expect(table.data).to.deep.eq(expectedTable) - it('has no modifiers when there are none activated', () => { cy.get(':text:first').type('f').then(function () { - const table = this.lastLog.invoke('consoleProps').table() + const table = this.lastLog.invoke('consoleProps').table[3]() expect(table.data).to.deep.eq({ 1: { typed: 'f', which: 70, keydown: true, keypress: true, textInput: true, input: true, keyup: true }, @@ -4156,7 +4150,7 @@ describe('src/cy/commands/actions/type', () => { }) cy.get(':text:first').type('f').then(function () { - const table = this.lastLog.invoke('consoleProps').table() + const table = this.lastLog.invoke('consoleProps').table[3]() // eslint-disable-next-line console.table(table.data, table.columns) @@ -5075,4 +5069,30 @@ https://on.cypress.io/type`) }) }) }) + + describe('user experience', () => { + it('can print table of keys on click', () => { + cy.get('input:first').type('foo') + + .then(() => { + return withMutableReporterState(() => { + const spyTableName = cy.spy(top.console, 'groupCollapsed') + const spyTableData = cy.spy(top.console, 'table') + + const commandLogEl = getCommandLogWithText('foo') + + const reactCommandInstance = findReactInstance(commandLogEl[0]) + + reactCommandInstance.props.appState.isRunning = false + + $(commandLogEl).find('.command-wrapper').click() + + expect(spyTableName.firstCall).calledWith('Mouse Move Events') + expect(spyTableName.secondCall).calledWith('Mouse Click Events') + expect(spyTableName.thirdCall).calledWith('Keyboard Events') + expect(spyTableData).calledThrice + }) + }) + }) + }) }) diff --git a/packages/driver/test/cypress/integration/commands/screenshot_spec.coffee b/packages/driver/test/cypress/integration/commands/screenshot_spec.coffee index 1bb6c2dc7479..e92266fb90cb 100644 --- a/packages/driver/test/cypress/integration/commands/screenshot_spec.coffee +++ b/packages/driver/test/cypress/integration/commands/screenshot_spec.coffee @@ -33,6 +33,10 @@ describe "src/cy/commands/screenshot", -> context "runnable:after:run:async", -> it "is noop when not isTextTerminal", -> + ## backup this property so we set it back to whatever + ## is correct based on what mode we're currently in + isTextTerminal = Cypress.config("isTextTerminal") + Cypress.config("isTextTerminal", false) cy.spy(Cypress, "action").log(false) @@ -48,7 +52,7 @@ describe "src/cy/commands/screenshot", -> expect(Cypress.action).not.to.be.calledWith("cy:test:set:state") expect(Cypress.automation).not.to.be.called .finally -> - Cypress.config("isTextTerminal", true) + Cypress.config("isTextTerminal", isTextTerminal) it "is noop when no test.err", -> Cypress.config("isInteractive", false) diff --git a/packages/driver/test/cypress/integration/dom/coordinates_spec.js b/packages/driver/test/cypress/integration/dom/coordinates_spec.js index 03dccf139f88..74a2098f233b 100644 --- a/packages/driver/test/cypress/integration/dom/coordinates_spec.js +++ b/packages/driver/test/cypress/integration/dom/coordinates_spec.js @@ -24,14 +24,14 @@ describe('src/dom/coordinates', () => { it('returns the leftCenter and topCenter normalized', function () { const win = Cypress.dom.getWindowByElement(this.$button.get(0)) - const pageYOffset = Object.getOwnPropertyDescriptor(win, 'pageYOffset') - const pageXOffset = Object.getOwnPropertyDescriptor(win, 'pageXOffset') + const scrollY = Object.getOwnPropertyDescriptor(win, 'scrollY') + const scrollX = Object.getOwnPropertyDescriptor(win, 'scrollX') - Object.defineProperty(win, 'pageYOffset', { + Object.defineProperty(win, 'scrollY', { value: 10, }) - Object.defineProperty(win, 'pageXOffset', { + Object.defineProperty(win, 'scrollX', { value: 20, }) @@ -42,16 +42,16 @@ describe('src/dom/coordinates', () => { height: 40, }]) - const { fromViewport, fromWindow } = Cypress.dom.getElementPositioning(this.$button) + const { fromElViewport, fromElWindow } = Cypress.dom.getElementPositioning(this.$button) - expect(fromViewport.topCenter).to.eq(120) - expect(fromViewport.leftCenter).to.eq(85) + expect(fromElViewport.topCenter).to.eq(120) + expect(fromElViewport.leftCenter).to.eq(85) - expect(fromWindow.topCenter).to.eq(130) - expect(fromWindow.leftCenter).to.eq(105) + expect(fromElWindow.topCenter).to.eq(130) + expect(fromElWindow.leftCenter).to.eq(105) - Object.defineProperty(win, 'pageYOffset', pageYOffset) - Object.defineProperty(win, 'pageXOffset', pageXOffset) + Object.defineProperty(win, 'scrollY', scrollY) + Object.defineProperty(win, 'scrollX', scrollX) }) }) @@ -79,15 +79,15 @@ describe('src/dom/coordinates', () => { context('.getElementCoordinatesByPosition', () => { beforeEach(function () { - this.fromWindowPos = (pos) => { + this.fromElWindowPos = (pos) => { return Cypress.dom.getElementCoordinatesByPosition(this.$button, pos) - .fromWindow + .fromElWindow } }) describe('topLeft', () => { it('returns top left x/y including padding + border', function () { - const obj = this.fromWindowPos('topLeft') + const obj = this.fromElWindowPos('topLeft') // padding is added to the line-height but width includes the padding expect(obj.x).to.eq(60) @@ -97,7 +97,7 @@ describe('src/dom/coordinates', () => { describe('top', () => { it('returns top center x/y including padding + border', function () { - const obj = this.fromWindowPos('top') + const obj = this.fromElWindowPos('top') // padding is added to the line-height but width includes the padding expect(obj.x).to.eq(110) @@ -107,7 +107,7 @@ describe('src/dom/coordinates', () => { describe('topRight', () => { it('returns top right x/y including padding + border', function () { - const obj = this.fromWindowPos('topRight') + const obj = this.fromElWindowPos('topRight') // padding is added to the line-height but width includes the padding expect(obj.x).to.eq(159) @@ -117,7 +117,7 @@ describe('src/dom/coordinates', () => { describe('left', () => { it('returns center left x/y including padding + border', function () { - const obj = this.fromWindowPos('left') + const obj = this.fromElWindowPos('left') // padding is added to the line-height but width includes the padding expect(obj.x).to.eq(60) @@ -127,7 +127,7 @@ describe('src/dom/coordinates', () => { describe('center', () => { it('returns center x/y including padding + border', function () { - const obj = this.fromWindowPos() + const obj = this.fromElWindowPos() // padding is added to the line-height but width includes the padding expect(obj.x).to.eq(110) @@ -140,7 +140,7 @@ describe('src/dom/coordinates', () => { // calculation would be wrong. using getBoundingClientRect passes this test this.$button.css({ transform: 'rotate(90deg)' }) - const obj = this.fromWindowPos() + const obj = this.fromElWindowPos() // padding is added to the line-height but width includes the padding expect(obj.x).to.eq(110) @@ -150,7 +150,7 @@ describe('src/dom/coordinates', () => { describe('right', () => { it('returns center right x/y including padding + border', function () { - const obj = this.fromWindowPos('right') + const obj = this.fromElWindowPos('right') // padding is added to the line-height but width includes the padding expect(obj.x).to.eq(159) @@ -160,7 +160,7 @@ describe('src/dom/coordinates', () => { describe('bottomLeft', () => { it('returns bottom left x/y including padding + border', function () { - const obj = this.fromWindowPos('bottomLeft') + const obj = this.fromElWindowPos('bottomLeft') // padding is added to the line-height but width includes the padding expect(obj.x).to.eq(60) @@ -170,7 +170,7 @@ describe('src/dom/coordinates', () => { context('bottom', () => { it('returns bottom center x/y including padding + border', function () { - const obj = this.fromWindowPos('bottom') + const obj = this.fromElWindowPos('bottom') // padding is added to the line-height but width includes the padding expect(obj.x).to.eq(110) @@ -180,7 +180,7 @@ describe('src/dom/coordinates', () => { context('bottomRight', () => { it('returns bottom right x/y including padding + border', function () { - const obj = this.fromWindowPos('bottomRight') + const obj = this.fromElWindowPos('bottomRight') // padding is added to the line-height but width includes the padding expect(obj.x).to.eq(159) @@ -217,7 +217,7 @@ this is some long text with a single ] }).as('getClientRects') - const obj = Cypress.dom.getElementCoordinatesByPosition($el, 'center').fromViewport + const obj = Cypress.dom.getElementCoordinatesByPosition($el, 'center').fromElViewport expect({ x: obj.x, y: obj.y }).to.deep.eq({ x: 125, y: 120 }) }) diff --git a/packages/driver/test/cypress/integration/e2e/dom_hitbox.spec.js b/packages/driver/test/cypress/integration/e2e/dom_hitbox.spec.js index 60bd45a8dee5..df4627a47387 100644 --- a/packages/driver/test/cypress/integration/e2e/dom_hitbox.spec.js +++ b/packages/driver/test/cypress/integration/e2e/dom_hitbox.spec.js @@ -1,5 +1,4 @@ -const { withMutableReporterState, findReactInstance, getCommandLogWithText } = require('../../support/utils') -const { $ } = Cypress +const { clickCommandLog } = require('../../support/utils') // https://github.com/cypress-io/cypress/pull/5299/files describe('rect highlight', () => { @@ -52,30 +51,6 @@ describe('rect highlight', () => { }) }) -const getAndPin = (sel) => { - cy.get(sel) - - // TODO: fix me @brian-mann plz halp ;-( - // arbitrary wait to allow clicking and pinning command - // if reduced, test is flakey - cy.wait(10) - .should(() => { - withMutableReporterState(() => { - - const commandLogEl = getCommandLogWithText(sel) - - const reactCommandInstance = findReactInstance(commandLogEl) - - reactCommandInstance.props.appState.isRunning = false - - $(commandLogEl).find('.command-wrapper').click() - - // make sure command was pinned, otherwise throw a better error message - expect(cy.$$('.command-pin:visible', top.document).length, 'command should be pinned').ok - }) - }) -} - const ensureCorrectHighlightPositions = (sel) => { return cy.wrap(null, { timeout: 400 }).should(() => { const dims = { @@ -93,6 +68,12 @@ const ensureCorrectHighlightPositions = (sel) => { }) } +const getAndPin = (sel) => { + cy.get(sel) + + clickCommandLog(sel) +} + const expectToBeEqual = (rect1, rect2, mes = 'rect to be equal to rect') => { try { expect(rect1.width, 'width').to.be.closeTo(rect2.width, 1) diff --git a/packages/driver/test/cypress/screenshots/commands/screenshot_spec.coffee/srccycommandsscreenshot -- #screenshot -- can handle window wlength 1 as a subject (1).png b/packages/driver/test/cypress/screenshots/commands/screenshot_spec.coffee/srccycommandsscreenshot -- #screenshot -- can handle window wlength 1 as a subject (1).png new file mode 100644 index 000000000000..8d80a1814815 Binary files /dev/null and b/packages/driver/test/cypress/screenshots/commands/screenshot_spec.coffee/srccycommandsscreenshot -- #screenshot -- can handle window wlength 1 as a subject (1).png differ diff --git a/packages/driver/test/cypress/screenshots/commands/screenshot_spec.coffee/srccycommandsscreenshot -- #screenshot -- can handle window wlength 1 as a subject (2).png b/packages/driver/test/cypress/screenshots/commands/screenshot_spec.coffee/srccycommandsscreenshot -- #screenshot -- can handle window wlength 1 as a subject (2).png new file mode 100644 index 000000000000..8d80a1814815 Binary files /dev/null and b/packages/driver/test/cypress/screenshots/commands/screenshot_spec.coffee/srccycommandsscreenshot -- #screenshot -- can handle window wlength 1 as a subject (2).png differ diff --git a/packages/driver/test/cypress/screenshots/commands/screenshot_spec.coffee/srccycommandsscreenshot -- #screenshot -- can handle window wlength 1 as a subject (3).png b/packages/driver/test/cypress/screenshots/commands/screenshot_spec.coffee/srccycommandsscreenshot -- #screenshot -- can handle window wlength 1 as a subject (3).png new file mode 100644 index 000000000000..8d80a1814815 Binary files /dev/null and b/packages/driver/test/cypress/screenshots/commands/screenshot_spec.coffee/srccycommandsscreenshot -- #screenshot -- can handle window wlength 1 as a subject (3).png differ diff --git a/packages/driver/test/cypress/screenshots/commands/screenshot_spec.coffee/srccycommandsscreenshot -- #screenshot -- can handle window wlength 1 as a subject (4).png b/packages/driver/test/cypress/screenshots/commands/screenshot_spec.coffee/srccycommandsscreenshot -- #screenshot -- can handle window wlength 1 as a subject (4).png new file mode 100644 index 000000000000..8d80a1814815 Binary files /dev/null and b/packages/driver/test/cypress/screenshots/commands/screenshot_spec.coffee/srccycommandsscreenshot -- #screenshot -- can handle window wlength 1 as a subject (4).png differ diff --git a/packages/driver/test/cypress/screenshots/commands/screenshot_spec.coffee/srccycommandsscreenshot -- #screenshot -- can handle window wlength 1 as a subject.png b/packages/driver/test/cypress/screenshots/commands/screenshot_spec.coffee/srccycommandsscreenshot -- #screenshot -- can handle window wlength 1 as a subject.png new file mode 100644 index 000000000000..8d80a1814815 Binary files /dev/null and b/packages/driver/test/cypress/screenshots/commands/screenshot_spec.coffee/srccycommandsscreenshot -- #screenshot -- can handle window wlength 1 as a subject.png differ diff --git a/packages/driver/test/cypress/support/utils.js b/packages/driver/test/cypress/support/utils.js index 97e9d0153a68..fd869bfc0a2f 100644 --- a/packages/driver/test/cypress/support/utils.js +++ b/packages/driver/test/cypress/support/utils.js @@ -1,4 +1,12 @@ -export const getCommandLogWithText = (text) => cy.$$(`.runnable-active .command-wrapper:contains(${text}):visible`, top.document).parentsUntil('li').last().parent()[0] +const { $ } = Cypress + +export const getCommandLogWithText = (text) => { + return cy + .$$(`.runnable-active .command-wrapper:contains(${text}):visible`, top.document) + .parentsUntil('li') + .last() + .parent() +} export const findReactInstance = function (dom) { let key = Object.keys(dom).find((key) => key.startsWith('__reactInternalInstance$')) @@ -12,6 +20,29 @@ export const findReactInstance = function (dom) { } +export const clickCommandLog = (sel) => { + return cy.wait(10) + .then(() => { + withMutableReporterState(() => { + + const commandLogEl = getCommandLogWithText(sel) + + const reactCommandInstance = findReactInstance(commandLogEl[0]) + + if (!reactCommandInstance) { + assert(false, 'failed to get command log React instance') + } + + reactCommandInstance.props.appState.isRunning = false + + $(commandLogEl).find('.command-wrapper').click() + + // make sure command was pinned, otherwise throw a better error message + expect(cy.$$('.command-pin:visible', top.document).length, 'command should be pinned').ok + }) + }) +} + export const withMutableReporterState = (fn) => { top.Runner.configureMobx({ enforceActions: 'never' }) @@ -19,9 +50,8 @@ export const withMutableReporterState = (fn) => { currentTestLog.props.model.isOpen = true - return Cypress.Promise.try(() => { - return fn() - }).then(() => { + return Cypress.Promise.try(fn) + .then(() => { top.Runner.configureMobx({ enforceActions: 'strict' }) }) diff --git a/packages/runner/src/lib/logger.js b/packages/runner/src/lib/logger.js index d39f0c711ccf..0cd7546691ad 100644 --- a/packages/runner/src/lib/logger.js +++ b/packages/runner/src/lib/logger.js @@ -76,8 +76,6 @@ export default { if (!groups) return - delete consoleProps.groups - return _.map(groups, (group) => { group.items = this._formatted(group.items) @@ -86,6 +84,18 @@ export default { }, _logTable (consoleProps) { + + if (isMultiEntryTable(consoleProps.table)) { + _.each( + _.sortBy(consoleProps.table, (val, key) => key), + (table) => { + return this._logTable({ table }) + } + ) + + return + } + const table = this._getTable(consoleProps) if (!table) return @@ -104,8 +114,8 @@ export default { if (!table) return - delete consoleProps.table - return table }, } + +const isMultiEntryTable = (table) => !_.isFunction(table) && !_.some(_.keys(table).map(isNaN).filter(Boolean), true)