@revoloo/cypress6
Version:
Cypress.io end to end testing tool
762 lines (611 loc) • 23 kB
JavaScript
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)
}
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 getMouseCoords = (state) => {
return state('mouseCoords')
}
const create = (state, keyboard, focused, Cypress) => {
const isFirefox = Cypress.browser.family === 'firefox'
const sendPointerEvent = (el, evtOptions, evtName, bubbles = false, cancelable = false) => {
const constructor = el.ownerDocument.defaultView.PointerEvent
return sendEvent(evtName, el, evtOptions, bubbles, cancelable, constructor, true)
}
const sendMouseEvent = (el, evtOptions, evtName, bubbles = false, cancelable = false) => {
// 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, true)
}
const sendPointerup = (el, evtOptions) => {
if (isFirefox && el.disabled) {
return {}
}
return sendPointerEvent(el, evtOptions, 'pointerup', true, true)
}
const sendPointerdown = (el, evtOptions) => {
if (isFirefox && el.disabled) {
return {}
}
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) => {
if (isFirefox && el.disabled) {
return {}
}
return sendMouseEvent(el, evtOptions, 'mouseup', true, true)
}
const sendMousedown = (el, evtOptions) => {
if (isFirefox && el.disabled) {
return {}
}
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, opts = {}) => {
// send the click event if firefox and force (needed for force check checkbox)
if (!opts.force && isFirefox && el.disabled) {
return {}
}
return sendMouseEvent(el, evtOptions, 'click', true, true)
}
const sendDblclick = (el, evtOptions) => {
if (isFirefox && el.disabled) {
return {}
}
return sendMouseEvent(el, evtOptions, 'dblclick', true, true)
}
const sendContextmenu = (el, evtOptions) => {
if (isFirefox && el.disabled) {
return {}
}
return sendMouseEvent(el, evtOptions, 'contextmenu', true, true)
}
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 shouldMoveCursorToEndAfterMousedown = (el) => {
const _debug = debug.extend(':shouldMoveCursorToEnd')
_debug('shouldMoveToEnd?', el)
if (!$elements.isElement(el)) {
_debug('false: not element')
return false
}
if (!($elements.isInput(el) || $elements.isTextarea(el) || $elements.isContentEditable(el))) {
_debug('false: not input/textarea/contentedtable')
return false
}
if ($elements.isNeedSingleValueChangeInputElement(el)) {
_debug('false: is single value change input')
return false
}
_debug('true: should move to end')
return true
}
const mouse = {
_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 defaultOptions = mouse._getDefaultMouseOptions(x, y, win)
const defaultMouseOptions = _.extend({}, defaultOptions, {
button: 0,
which: 0,
buttons: 0,
})
const defaultPointerOptions = _.extend({}, defaultOptions, {
button: -1,
which: 0,
buttons: 0,
pointerId: 1,
pointerType: 'mouse',
isPrimary: true,
})
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.ownerDocument && 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 }))
})
}
}
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 }))
})
}
}
}
pointermove = () => {
return sendPointermove(el, defaultPointerOptions)
}
mousemove = () => {
return sendMousemove(el, defaultMouseOptions)
}
const events = []
pointerout()
pointerleave()
events.push({ type: 'pointerover', ...pointerover() })
pointerenter()
mouseout()
mouseleave()
events.push({ type: 'mouseover', ...mouseover() })
mouseenter()
state('mouseLastHoveredEl', $elements.isAttachedEl(el) ? el : null)
state('mouseCoords', { x, y })
events.push({ type: 'pointermove', ...pointermove() })
events.push({ type: 'mousemove', ...mousemove() })
return events
},
/**
*
* @param {Coords} coords
* @returns {HTMLElement}
*/
getElAtCoords ({ x, y, doc }) {
const el = $dom.elementFromPoint(doc, x, y)
return el
},
/**
*
* @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 defaultOptions = mouse._getDefaultMouseOptions(x, y, win)
const pointerEvtOptions = _.extend({}, defaultOptions, {
...defaultPointerDownUpOptions,
button: 0,
which: 1,
buttons: 1,
relatedTarget: null,
}, pointerEvtOptionsExtend)
const mouseEvtOptions = _.extend({}, defaultOptions, {
button: 0,
which: 1,
buttons: 1,
detail: 1,
}, mouseEvtOptionsExtend)
// TODO: pointer events should have fractional coordinates, not rounded
let pointerdown = sendPointerdown(
el,
pointerEvtOptions,
)
const pointerdownPrevented = pointerdown.preventedDefault
const elIsDetached = $elements.isDetachedEl(el)
if (pointerdownPrevented || elIsDetached) {
let reason = 'pointerdown was cancelled'
if (elIsDetached) {
reason = 'Element was detached'
}
return {
targetEl: el,
events: {
pointerdown,
mousedown: {
skipped: formatReasonNotFired(reason),
},
},
}
}
let mousedown = sendMousedown(el, mouseEvtOptions)
return {
targetEl: el,
events: {
pointerdown,
mousedown,
},
}
},
down (fromElViewport, forceEl, pointerEvtOptionsExtend = {}, mouseEvtOptionsExtend = {}) {
const $previouslyFocused = focused.getFocused()
const mouseDownPhase = mouse._downEvents(fromElViewport, forceEl, pointerEvtOptionsExtend, mouseEvtOptionsExtend)
// el we just send pointerdown
const el = mouseDownPhase.targetEl
if (mouseDownPhase.events.pointerdown.preventedDefault || mouseDownPhase.events.mousedown.preventedDefault || !$elements.isAttachedEl(el)) {
return mouseDownPhase
}
//# retrieve the first focusable $el in our parent chain
const $elToFocus = $elements.getFirstFocusableEl($(el))
debug('elToFocus:', $elToFocus[0])
if (focused.needsFocus($elToFocus, $previouslyFocused)) {
debug('el needs focus')
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), { preventScroll: true })
}
}
if (shouldMoveCursorToEndAfterMousedown(el)) {
debug('moveSelectionToEnd due to click', el)
$selection.moveSelectionToEnd(el, { onlyIfEmptySelection: true })
}
return mouseDownPhase
},
/**
* @param {HTMLElement} el
* @param {Window} win
* @param {Coords} fromElViewport
* @param {HTMLElement} forceEl
*/
up (fromElViewport, forceEl, skipMouseEvent, pointerEvtOptionsExtend = {}, mouseEvtOptionsExtend = {}) {
debug('mouse.up', { fromElViewport, forceEl, skipMouseEvent })
return mouse._upEvents(fromElViewport, forceEl, skipMouseEvent, pointerEvtOptionsExtend, mouseEvtOptionsExtend)
},
/**
*
* 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(ancestorOf(el1, el2))
*/
click (fromElViewport, forceEl, pointerEvtOptionsExtend = {}, mouseEvtOptionsExtend = {}) {
debug('mouse.click', { fromElViewport, forceEl })
const mouseDownPhase = mouse.down(fromElViewport, forceEl, pointerEvtOptionsExtend, mouseEvtOptionsExtend)
const skipMouseupEvent = mouseDownPhase.events.pointerdown.skipped || mouseDownPhase.events.pointerdown.preventedDefault
const mouseUpPhase = mouse.up(fromElViewport, forceEl, skipMouseupEvent, pointerEvtOptionsExtend, mouseEvtOptionsExtend)
const getElementToClick = () => {
// Never skip the click event when force:true
if (forceEl) {
return { elToClick: forceEl }
}
// Only send click event if mousedown element is not detached.
if ($elements.isDetachedEl(mouseDownPhase.targetEl) || $elements.isDetached(mouseUpPhase.targetEl)) {
return { skipClickEventReason: 'element was detached' }
}
const commonAncestor = mouseUpPhase.targetEl &&
mouseDownPhase.targetEl &&
$elements.getFirstCommonAncestor(mouseUpPhase.targetEl, mouseDownPhase.targetEl)
return { elToClick: commonAncestor }
}
const { skipClickEventReason, elToClick } = getElementToClick()
const mouseClickEvents = mouse._mouseClickEvents(fromElViewport, elToClick, forceEl, skipClickEventReason, mouseEvtOptionsExtend)
return _.extend({}, mouseDownPhase.events, mouseUpPhase.events, 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 el = forceEl || mouse.moveToCoords(fromElViewport)
let pointerup = sendPointerup(el, pointerEvtOptions)
if (skipMouseEvent || $elements.isDetachedEl($(el))) {
return {
targetEl: el,
events: {
pointerup,
mouseup: {
skipped: formatReasonNotFired('Previous event cancelled'),
},
},
}
}
let mouseup = sendMouseup(el, mouseEvtOptions)
return {
targetEl: el,
events: {
pointerup,
mouseup,
},
}
},
_mouseClickEvents (fromElViewport, el, forceEl, skipClickEvent, mouseEvtOptionsExtend = {}) {
if (skipClickEvent) {
return {
click: {
skipped: formatReasonNotFired(skipClickEvent),
},
}
}
if (!forceEl) {
mouse.moveToCoords(fromElViewport)
}
el = forceEl || el
const win = $dom.getWindowByElement(el)
const defaultOptions = mouse._getDefaultMouseOptions(fromElViewport.x, fromElViewport.y, win)
const clickEventOptions = _.extend({}, defaultOptions, {
buttons: 0,
detail: 1,
}, mouseEvtOptionsExtend)
let click = sendClick(el, clickEventOptions, { force: !!forceEl })
return { click }
},
_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 contextmenu = sendContextmenu(el, mouseEvtOptions)
return { contextmenu }
},
dblclick (fromElViewport, forceEl, mouseEvtOptionsExtend = {}) {
const click = (clickNum) => {
const clickEvents = mouse.click(fromElViewport, forceEl, {}, { detail: clickNum })
return clickEvents
}
const clickEvents1 = click(1)
const clickEvents2 = click(2)
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 dblclick = sendDblclick(el, dblclickEvtProps)
return { clickEvents1, clickEvents2, dblclick }
},
rightclick (fromElViewport, forceEl) {
const pointerEvtOptionsExtend = {
button: 2,
buttons: 2,
which: 3,
}
const mouseEvtOptionsExtend = {
button: 2,
buttons: 2,
which: 3,
}
const mouseDownPhase = mouse.down(fromElViewport, forceEl, pointerEvtOptionsExtend, mouseEvtOptionsExtend)
const contextmenuEvent = mouse._contextmenuEvent(fromElViewport, forceEl)
const skipMouseupEvent = mouseDownPhase.events.pointerdown.skipped || mouseDownPhase.events.pointerdown.preventedDefault
const mouseUpPhase = mouse.up(fromElViewport, forceEl, skipMouseupEvent, pointerEvtOptionsExtend, mouseEvtOptionsExtend)
const clickEvents = _.extend({}, mouseDownPhase.events, mouseUpPhase.events)
return _.extend({}, { clickEvents, contextmenuEvent })
},
}
return mouse
}
const { stopPropagation } = window.MouseEvent.prototype
const sendEvent = (evtName, el, evtOptions, bubbles = false, cancelable = false, Constructor, composed = false) => {
evtOptions = _.extend({}, evtOptions, { bubbles, cancelable })
const _eventModifiers = $Keyboard.fromModifierEventOptions(evtOptions)
const modifiers = $Keyboard.modifiersToString(_eventModifiers)
const evt = new Constructor(evtName, _.extend({}, evtOptions, { bubbles, cancelable, composed }))
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 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,
}