diff options
Diffstat (limited to 'testing/marionette/legacyaction.js')
-rw-r--r-- | testing/marionette/legacyaction.js | 630 |
1 files changed, 630 insertions, 0 deletions
diff --git a/testing/marionette/legacyaction.js b/testing/marionette/legacyaction.js new file mode 100644 index 0000000000..908c5929a7 --- /dev/null +++ b/testing/marionette/legacyaction.js @@ -0,0 +1,630 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* eslint-disable no-restricted-globals */ + +"use strict"; + +const EXPORTED_SYMBOLS = ["legacyaction"]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + Preferences: "resource://gre/modules/Preferences.jsm", + + accessibility: "chrome://marionette/content/accessibility.js", + element: "chrome://marionette/content/element.js", + error: "chrome://marionette/content/error.js", + evaluate: "chrome://marionette/content/evaluate.js", + event: "chrome://marionette/content/event.js", + Log: "chrome://marionette/content/log.js", + WebElement: "chrome://marionette/content/element.js", +}); + +XPCOMUtils.defineLazyGetter(this, "logger", () => Log.get()); + +const CONTEXT_MENU_DELAY_PREF = "ui.click_hold_context_menus.delay"; +const DEFAULT_CONTEXT_MENU_DELAY = 750; // ms + +/* global action */ +/** @namespace */ +this.legacyaction = this.action = {}; + +/** + * Functionality for (single finger) action chains. + */ +action.Chain = function() { + // for assigning unique ids to all touches + this.nextTouchId = 1000; + // keep track of active Touches + this.touchIds = {}; + // last touch for each fingerId + this.lastCoordinates = null; + this.isTap = false; + this.scrolling = false; + // whether to send mouse event + this.mouseEventsOnly = false; + this.checkTimer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + + // determines if we create touch events + this.inputSource = null; +}; + +/** + * Create a touch based event. + * + * @param {Element} elem + * The Element on which the touch event should be created. + * @param {Number} x + * x coordinate relative to the viewport. + * @param {Number} y + * y coordinate relative to the viewport. + * @param {Number} touchId + * Touch event id used by legacyactions. + */ +action.Chain.prototype.createATouch = function(elem, x, y, touchId) { + const doc = elem.ownerDocument; + const win = doc.defaultView; + const [ + clientX, + clientY, + pageX, + pageY, + screenX, + screenY, + ] = this.getCoordinateInfo(elem, x, y); + const atouch = doc.createTouch( + win, + elem, + touchId, + pageX, + pageY, + screenX, + screenY, + clientX, + clientY + ); + return atouch; +}; + +action.Chain.prototype.dispatchActions = function( + args, + touchId, + container, + seenEls +) { + this.seenEls = seenEls; + this.container = container; + let commandArray = evaluate.fromJSON(args, seenEls, container.frame); + + if (touchId == null) { + touchId = this.nextTouchId++; + } + + if (!container.frame.document.createTouch) { + this.mouseEventsOnly = true; + } + + let keyModifiers = { + shiftKey: false, + ctrlKey: false, + altKey: false, + metaKey: false, + }; + + return new Promise(resolve => { + this.actions(commandArray, touchId, 0, keyModifiers, resolve); + }).catch(this.resetValues.bind(this)); +}; + +/** + * This function emit mouse event. + * + * @param {Document} doc + * Current document. + * @param {string} type + * Type of event to dispatch. + * @param {number} clickCount + * Number of clicks, button notes the mouse button. + * @param {number} elClientX + * X coordinate of the mouse relative to the viewport. + * @param {number} elClientY + * Y coordinate of the mouse relative to the viewport. + * @param {Object} modifiers + * An object of modifier keys present. + */ +action.Chain.prototype.emitMouseEvent = function( + doc, + type, + elClientX, + elClientY, + button, + clickCount, + modifiers +) { + logger.debug( + `Emitting ${type} mouse event ` + + `at coordinates (${elClientX}, ${elClientY}) ` + + `relative to the viewport, ` + + `button: ${button}, ` + + `clickCount: ${clickCount}` + ); + + let win = doc.defaultView; + let domUtils = win.windowUtils; + + let mods; + if (typeof modifiers != "undefined") { + mods = event.parseModifiers_(modifiers); + } else { + mods = 0; + } + + domUtils.sendMouseEvent( + type, + elClientX, + elClientY, + button || 0, + clickCount || 1, + mods, + false, + 0, + this.inputSource + ); +}; + +action.Chain.prototype.emitTouchEvent = function(doc, type, touch) { + logger.info( + `Emitting Touch event of type ${type} ` + + `to element with id: ${touch.target.id} ` + + `and tag name: ${touch.target.tagName} ` + + `at coordinates (${touch.clientX}), ` + + `${touch.clientY}) relative to the viewport` + ); + + const win = doc.defaultView; + if (win.docShell.asyncPanZoomEnabled && this.scrolling) { + logger.debug( + `Cannot emit touch event with asyncPanZoomEnabled and legacyactions.scrolling` + ); + return; + } + + // we get here if we're not in asyncPacZoomEnabled land, or if we're + // the main process + win.windowUtils.sendTouchEvent( + type, + [touch.identifier], + [touch.clientX], + [touch.clientY], + [touch.radiusX], + [touch.radiusY], + [touch.rotationAngle], + [touch.force], + 0 + ); +}; + +/** + * Reset any persisted values after a command completes. + */ +action.Chain.prototype.resetValues = function() { + this.container = null; + this.seenEls = null; + this.mouseEventsOnly = false; +}; + +/** + * Function that performs a single tap. + */ +action.Chain.prototype.singleTap = async function( + el, + corx, + cory, + capabilities +) { + const doc = el.ownerDocument; + // after this block, the element will be scrolled into view + let visible = element.isVisible(el, corx, cory); + if (!visible) { + throw new error.ElementNotInteractableError( + "Element is not currently visible and may not be manipulated" + ); + } + + let a11y = accessibility.get(capabilities["moz:accessibilityChecks"]); + let acc = await a11y.getAccessible(el, true); + a11y.assertVisible(acc, el, visible); + a11y.assertActionable(acc, el); + if (!doc.createTouch) { + this.mouseEventsOnly = true; + } + let c = element.coordinates(el, corx, cory); + if (!this.mouseEventsOnly) { + let touchId = this.nextTouchId++; + let touch = this.createATouch(el, c.x, c.y, touchId); + this.emitTouchEvent(doc, "touchstart", touch); + this.emitTouchEvent(doc, "touchend", touch); + } + this.mouseTap(doc, c.x, c.y); +}; + +/** + * Emit events for each action in the provided chain. + * + * To emit touch events for each finger, one might send a [["press", id], + * ["wait", 5], ["release"]] chain. + * + * @param {Array.<Array<?>>} chain + * A multi-dimensional array of actions. + * @param {Object.<string, number>} touchId + * Represents the finger ID. + * @param {number} i + * Keeps track of the current action of the chain. + * @param {Object.<string, boolean>} keyModifiers + * Keeps track of keyDown/keyUp pairs through an action chain. + * @param {function(?)} cb + * Called on success. + * + * @return {Object.<string, number>} + * Last finger ID, or an empty object. + */ +action.Chain.prototype.actions = function(chain, touchId, i, keyModifiers, cb) { + if (i == chain.length) { + cb(touchId || null); + this.resetValues(); + return; + } + + let pack = chain[i]; + let command = pack[0]; + let webEl; + let el; + let c; + i++; + + if (!["press", "wait", "keyDown", "keyUp", "click"].includes(command)) { + // if mouseEventsOnly, then touchIds isn't used + if (!(touchId in this.touchIds) && !this.mouseEventsOnly) { + this.resetValues(); + throw new error.WebDriverError("Element has not been pressed"); + } + } + + switch (command) { + case "keyDown": + event.sendKeyDown(pack[1], keyModifiers, this.container.frame); + this.actions(chain, touchId, i, keyModifiers, cb); + break; + + case "keyUp": + event.sendKeyUp(pack[1], keyModifiers, this.container.frame); + this.actions(chain, touchId, i, keyModifiers, cb); + break; + + case "click": + webEl = WebElement.fromUUID(pack[1], "content"); + el = this.seenEls.get(webEl); + let button = pack[2]; + let clickCount = pack[3]; + c = element.coordinates(el); + this.mouseTap( + el.ownerDocument, + c.x, + c.y, + button, + clickCount, + keyModifiers + ); + if (button == 2) { + this.emitMouseEvent( + el.ownerDocument, + "contextmenu", + c.x, + c.y, + button, + clickCount, + keyModifiers + ); + } + this.actions(chain, touchId, i, keyModifiers, cb); + break; + + case "press": + if (this.lastCoordinates) { + this.generateEvents( + "cancel", + this.lastCoordinates[0], + this.lastCoordinates[1], + touchId, + null, + keyModifiers + ); + this.resetValues(); + throw new error.WebDriverError( + "Invalid Command: press cannot follow an active touch event" + ); + } + + // look ahead to check if we're scrolling, + // needed for APZ touch dispatching + if (i != chain.length && chain[i][0].includes("move")) { + this.scrolling = true; + } + webEl = WebElement.fromUUID(pack[1], "content"); + el = this.seenEls.get(webEl); + c = element.coordinates(el, pack[2], pack[3]); + touchId = this.generateEvents("press", c.x, c.y, null, el, keyModifiers); + this.actions(chain, touchId, i, keyModifiers, cb); + break; + + case "release": + this.generateEvents( + "release", + this.lastCoordinates[0], + this.lastCoordinates[1], + touchId, + null, + keyModifiers + ); + this.actions(chain, null, i, keyModifiers, cb); + this.scrolling = false; + break; + + case "move": + webEl = WebElement.fromUUID(pack[1], "content"); + el = this.seenEls.get(webEl); + c = element.coordinates(el); + this.generateEvents("move", c.x, c.y, touchId, null, keyModifiers); + this.actions(chain, touchId, i, keyModifiers, cb); + break; + + case "moveByOffset": + this.generateEvents( + "move", + this.lastCoordinates[0] + pack[1], + this.lastCoordinates[1] + pack[2], + touchId, + null, + keyModifiers + ); + this.actions(chain, touchId, i, keyModifiers, cb); + break; + + case "wait": + if (pack[1] != null) { + let time = pack[1] * 1000; + + // standard waiting time to fire contextmenu + let standard = Preferences.get( + CONTEXT_MENU_DELAY_PREF, + DEFAULT_CONTEXT_MENU_DELAY + ); + + if (time >= standard && this.isTap) { + chain.splice(i, 0, ["longPress"], ["wait", (time - standard) / 1000]); + time = standard; + } + this.checkTimer.initWithCallback( + () => this.actions(chain, touchId, i, keyModifiers, cb), + time, + Ci.nsITimer.TYPE_ONE_SHOT + ); + } else { + this.actions(chain, touchId, i, keyModifiers, cb); + } + break; + + case "cancel": + this.generateEvents( + "cancel", + this.lastCoordinates[0], + this.lastCoordinates[1], + touchId, + null, + keyModifiers + ); + this.actions(chain, touchId, i, keyModifiers, cb); + this.scrolling = false; + break; + + case "longPress": + this.generateEvents( + "contextmenu", + this.lastCoordinates[0], + this.lastCoordinates[1], + touchId, + null, + keyModifiers + ); + this.actions(chain, touchId, i, keyModifiers, cb); + break; + } +}; + +/** + * Given an element and a pair of coordinates, returns an array of the + * form [clientX, clientY, pageX, pageY, screenX, screenY]. + */ +action.Chain.prototype.getCoordinateInfo = function(el, corx, cory) { + let win = el.ownerGlobal; + return [ + corx, // clientX + cory, // clientY + corx + win.pageXOffset, // pageX + cory + win.pageYOffset, // pageY + corx + win.mozInnerScreenX, // screenX + cory + win.mozInnerScreenY, // screenY + ]; +}; + +/** + * @param {number} x + * X coordinate of the location to generate the event that is relative + * to the viewport. + * @param {number} y + * Y coordinate of the location to generate the event that is relative + * to the viewport. + */ +action.Chain.prototype.generateEvents = function( + type, + x, + y, + touchId, + target, + keyModifiers +) { + this.lastCoordinates = [x, y]; + let doc = this.container.frame.document; + + switch (type) { + case "tap": + if (this.mouseEventsOnly) { + let touch = this.createATouch(target, x, y, touchId); + this.mouseTap( + touch.target.ownerDocument, + touch.clientX, + touch.clientY, + null, + null, + keyModifiers + ); + } else { + touchId = this.nextTouchId++; + let touch = this.createATouch(target, x, y, touchId); + this.emitTouchEvent(doc, "touchstart", touch); + this.emitTouchEvent(doc, "touchend", touch); + this.mouseTap( + touch.target.ownerDocument, + touch.clientX, + touch.clientY, + null, + null, + keyModifiers + ); + } + this.lastCoordinates = null; + break; + + case "press": + this.isTap = true; + if (this.mouseEventsOnly) { + this.emitMouseEvent(doc, "mousemove", x, y, null, null, keyModifiers); + this.emitMouseEvent(doc, "mousedown", x, y, null, null, keyModifiers); + } else { + touchId = this.nextTouchId++; + let touch = this.createATouch(target, x, y, touchId); + this.emitTouchEvent(doc, "touchstart", touch); + this.touchIds[touchId] = touch; + return touchId; + } + break; + + case "release": + if (this.mouseEventsOnly) { + let [x, y] = this.lastCoordinates; + this.emitMouseEvent(doc, "mouseup", x, y, null, null, keyModifiers); + } else { + let touch = this.touchIds[touchId]; + let [x, y] = this.lastCoordinates; + + touch = this.createATouch(touch.target, x, y, touchId); + this.emitTouchEvent(doc, "touchend", touch); + + if (this.isTap) { + this.mouseTap( + touch.target.ownerDocument, + touch.clientX, + touch.clientY, + null, + null, + keyModifiers + ); + } + delete this.touchIds[touchId]; + } + + this.isTap = false; + this.lastCoordinates = null; + break; + + case "cancel": + this.isTap = false; + if (this.mouseEventsOnly) { + let [x, y] = this.lastCoordinates; + this.emitMouseEvent(doc, "mouseup", x, y, null, null, keyModifiers); + } else { + this.emitTouchEvent(doc, "touchcancel", this.touchIds[touchId]); + delete this.touchIds[touchId]; + } + this.lastCoordinates = null; + break; + + case "move": + this.isTap = false; + if (this.mouseEventsOnly) { + this.emitMouseEvent(doc, "mousemove", x, y, null, null, keyModifiers); + } else { + let touch = this.createATouch( + this.touchIds[touchId].target, + x, + y, + touchId + ); + this.touchIds[touchId] = touch; + this.emitTouchEvent(doc, "touchmove", touch); + } + break; + + case "contextmenu": + this.isTap = false; + let event = this.container.frame.document.createEvent("MouseEvents"); + if (this.mouseEventsOnly) { + target = doc.elementFromPoint( + this.lastCoordinates[0], + this.lastCoordinates[1] + ); + } else { + target = this.touchIds[touchId].target; + } + + let [clientX, clientY, , , screenX, screenY] = this.getCoordinateInfo( + target, + x, + y + ); + + event.initMouseEvent( + "contextmenu", + true, + true, + target.ownerGlobal, + 1, + screenX, + screenY, + clientX, + clientY, + false, + false, + false, + false, + 0, + null + ); + target.dispatchEvent(event); + break; + + default: + throw new error.WebDriverError("Unknown event type: " + type); + } + return null; +}; + +action.Chain.prototype.mouseTap = function(doc, x, y, button, count, mod) { + this.emitMouseEvent(doc, "mousemove", x, y, button, count, mod); + this.emitMouseEvent(doc, "mousedown", x, y, button, count, mod); + this.emitMouseEvent(doc, "mouseup", x, y, button, count, mod); +}; |