/* 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.>} chain * A multi-dimensional array of actions. * @param {Object.} touchId * Represents the finger ID. * @param {number} i * Keeps track of the current action of the chain. * @param {Object.} keyModifiers * Keeps track of keyDown/keyUp pairs through an action chain. * @param {function(?)} cb * Called on success. * * @return {Object.} * 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); };