/* 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/. */ import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { clearTimeout: "resource://gre/modules/Timer.sys.mjs", setTimeout: "resource://gre/modules/Timer.sys.mjs", AppInfo: "chrome://remote/content/shared/AppInfo.sys.mjs", assert: "chrome://remote/content/shared/webdriver/Assert.sys.mjs", AsyncQueue: "chrome://remote/content/shared/AsyncQueue.sys.mjs", error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs", event: "chrome://remote/content/shared/webdriver/Event.sys.mjs", keyData: "chrome://remote/content/shared/webdriver/KeyData.sys.mjs", Log: "chrome://remote/content/shared/Log.sys.mjs", pprint: "chrome://remote/content/shared/Format.sys.mjs", Sleep: "chrome://remote/content/marionette/sync.sys.mjs", }); ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get()); // TODO? With ES 2016 and Symbol you can make a safer approximation // to an enum e.g. https://gist.github.com/xmlking/e86e4f15ec32b12c4689 /** * Implements WebDriver Actions API: a low-level interface for providing * virtualized device input to the web browser. * * Typical usage is to construct an action chain and then dispatch it: * const state = new actions.State(); * const chain = await actions.Chain.fromJSON(state, protocolData); * await chain.dispatch(state, window); * * @namespace */ export const actions = {}; // Max interval between two clicks that should result in a dblclick or a tripleclick (in ms) export const CLICK_INTERVAL = 640; /** Map from normalized key value to UI Events modifier key name */ const MODIFIER_NAME_LOOKUP = { Alt: "alt", Shift: "shift", Control: "ctrl", Meta: "meta", }; // Flag, that indicates if an async widget event should be used when dispatching a wheel scroll event. XPCOMUtils.defineLazyPreferenceGetter( actions, "useAsyncWheelEvents", "remote.events.async.wheel.enabled", false ); /** * Object containing various callback functions to be used when deserializing * action sequences and dispatching these. * * @typedef {object} ActionsOptions * @property {Function} isElementOrigin * Function to check if it's a valid origin element. * @property {Function} getElementOrigin * Function to retrieve the element reference for an element origin. * @property {Function} assertInViewPort * Function to check if the coordinates [x, y] are in the visible viewport. * @property {Function} dispatchEvent * Function to use for dispatching events. * @property {Function} getClientRects * Function that retrieves the client rects for an element. * @property {Function} getInViewCentrePoint * Function that calculates the in-view center point for the given * coordinates [x, y]. */ /** * State associated with actions. * * Typically each top-level navigable in a WebDriver session should have a * single State object. */ actions.State = class { #actionsQueue; /** * Creates a new {@link State} instance. */ constructor() { // A queue that ensures that access to the input state is serialized. this.#actionsQueue = new lazy.AsyncQueue(); // Tracker for mouse button clicks. this.clickTracker = new ClickTracker(); /** * A map between input ID and the device state for that input * source, with one entry for each active input source. * * Maps string => InputSource */ this.inputStateMap = new Map(); /** * List of {@link Action} associated with current session. Used to * manage dispatching events when resetting the state of the input sources. * Reset operations are assumed to be idempotent. */ this.inputsToCancel = new TickActions(); // Map between string input id and numeric pointer id. this.pointerIdMap = new Map(); } /** * Returns the list of inputs to cancel when releasing the actions. * * @returns {TickActions} * The inputs to cancel. */ get inputCancelList() { return this.inputsToCancel; } toString() { return `[object ${this.constructor.name} ${JSON.stringify(this)}]`; } /** * Enqueue a new action task. * * @param {Function} task * The task to queue. * * @returns {Promise} * Promise that resolves when the task is completed, with the resolved * value being the result of the task. */ enqueueAction(task) { return this.#actionsQueue.enqueue(task); } /** * Get the state for a given input source. * * @param {string} id * Id of the input source. * * @returns {InputSource} * State of the input source. */ getInputSource(id) { return this.inputStateMap.get(id); } /** * Find or add state for an input source. * * The caller should verify that the returned state is the expected type. * * @param {string} id * Id of the input source. * @param {InputSource} newInputSource * State of the input source. * * @returns {InputSource} * The input source. */ getOrAddInputSource(id, newInputSource) { let inputSource = this.getInputSource(id); if (inputSource === undefined) { this.inputStateMap.set(id, newInputSource); inputSource = newInputSource; } return inputSource; } /** * Iterate over all input states of a given type. * * @param {string} type * Type name of the input source (e.g. "pointer"). * * @returns {Iterator} * Iterator over id and input source. */ *inputSourcesByType(type) { for (const [id, inputSource] of this.inputStateMap) { if (inputSource.type === type) { yield [id, inputSource]; } } } /** * Get a numerical pointer id for a given pointer. * * Pointer ids are positive integers. Mouse pointers are typically * ids 0 or 1. Non-mouse pointers never get assigned id < 2. Each * pointer gets a unique id. * * @param {string} id * Id of the pointer. * @param {string} type * Type of the pointer. * * @returns {number} * The numerical pointer id. */ getPointerId(id, type) { let pointerId = this.pointerIdMap.get(id); if (pointerId === undefined) { // Reserve pointer ids 0 and 1 for mouse pointers const idValues = Array.from(this.pointerIdMap.values()); if (type === "mouse") { for (const mouseId of [0, 1]) { if (!idValues.includes(mouseId)) { pointerId = mouseId; break; } } } if (pointerId === undefined) { pointerId = Math.max(1, ...idValues) + 1; } this.pointerIdMap.set(id, pointerId); } return pointerId; } }; /** * Tracker for mouse button clicks. */ export class ClickTracker { #count; #lastButtonClicked; #timer; /** * Creates a new {@link ClickTracker} instance. */ constructor() { this.#count = 0; this.#lastButtonClicked = null; } get count() { return this.#count; } #cancelTimer() { lazy.clearTimeout(this.#timer); } #startTimer() { this.#timer = lazy.setTimeout(this.reset.bind(this), CLICK_INTERVAL); } /** * Reset tracking mouse click counter. */ reset() { this.#cancelTimer(); this.#count = 0; this.#lastButtonClicked = null; } /** * Track |button| click to identify possible double or triple click. * * @param {number} button * A positive integer that refers to a mouse button. */ setClick(button) { this.#cancelTimer(); if ( this.#lastButtonClicked === null || this.#lastButtonClicked === button ) { this.#count++; } else { this.#count = 1; } this.#lastButtonClicked = button; this.#startTimer(); } } /** * Device state for an input source. */ class InputSource { #id; static type = null; /** * Creates a new {@link InputSource} instance. * * @param {string} id * Id of {@link InputSource}. */ constructor(id) { this.#id = id; this.type = this.constructor.type; } toString() { return `[object ${this.constructor.name} id: ${this.#id} type: ${ this.type }]`; } /** * Unmarshals a JSON Object to an {@link InputSource}. * * @see https://w3c.github.io/webdriver/#dfn-get-or-create-an-input-source * * @param {State} actionState * Actions state. * @param {Sequence} actionSequence * Actions for a specific input source. * * @returns {InputSource} * An {@link InputSource} object for the type of the * action {@link Sequence}. * * @throws {InvalidArgumentError} * If the actionSequence is invalid. */ static fromJSON(actionState, actionSequence) { const { id, type } = actionSequence; lazy.assert.string( id, lazy.pprint`Expected "id" to be a string, got ${id}` ); const cls = inputSourceTypes.get(type); if (cls === undefined) { throw new lazy.error.InvalidArgumentError( lazy.pprint`Expected known action type, got ${type}` ); } const sequenceInputSource = cls.fromJSON(actionState, actionSequence); const inputSource = actionState.getOrAddInputSource( id, sequenceInputSource ); if (inputSource.type !== type) { throw new lazy.error.InvalidArgumentError( lazy.pprint`Expected input source ${id} to be ` + `type ${inputSource.type}, got ${type}` ); } } } /** * Input state not associated with a specific physical device. */ class NullInputSource extends InputSource { static type = "none"; /** * Unmarshals a JSON Object to a {@link NullInputSource}. * * @param {State} actionState * Actions state. * @param {Sequence} actionSequence * Actions for a specific input source. * * @returns {NullInputSource} * A {@link NullInputSource} object for the type of the * action {@link Sequence}. * * @throws {InvalidArgumentError} * If the actionSequence is invalid. */ static fromJSON(actionState, actionSequence) { const { id } = actionSequence; return new this(id); } } /** * Input state associated with a keyboard-type device. */ class KeyInputSource extends InputSource { static type = "key"; /** * Creates a new {@link KeyInputSource} instance. * * @param {string} id * Id of {@link InputSource}. */ constructor(id) { super(id); this.pressed = new Set(); this.alt = false; this.shift = false; this.ctrl = false; this.meta = false; } /** * Unmarshals a JSON Object to a {@link KeyInputSource}. * * @param {State} actionState * Actions state. * @param {Sequence} actionSequence * Actions for a specific input source. * * @returns {KeyInputSource} * A {@link KeyInputSource} object for the type of the * action {@link Sequence}. * * @throws {InvalidArgumentError} * If the actionSequence is invalid. */ static fromJSON(actionState, actionSequence) { const { id } = actionSequence; return new this(id); } /** * Update modifier state according to |key|. * * @param {string} key * Normalized key value of a modifier key. * @param {boolean} value * Value to set the modifier attribute to. * * @throws {InvalidArgumentError} * If |key| is not a modifier. */ setModState(key, value) { if (key in MODIFIER_NAME_LOOKUP) { this[MODIFIER_NAME_LOOKUP[key]] = value; } else { throw new lazy.error.InvalidArgumentError( lazy.pprint`Expected "key" to be one of ${Object.keys( MODIFIER_NAME_LOOKUP )}, got ${key}` ); } } /** * Check whether |key| is pressed. * * @param {string} key * Normalized key value. * * @returns {boolean} * True if |key| is in set of pressed keys. */ isPressed(key) { return this.pressed.has(key); } /** * Add |key| to the set of pressed keys. * * @param {string} key * Normalized key value. * * @returns {boolean} * True if |key| is in list of pressed keys. */ press(key) { return this.pressed.add(key); } /** * Remove |key| from the set of pressed keys. * * @param {string} key * Normalized key value. * * @returns {boolean} * True if |key| was present before removal, false otherwise. */ release(key) { return this.pressed.delete(key); } } /** * Input state associated with a pointer-type device. */ class PointerInputSource extends InputSource { static type = "pointer"; /** * Creates a new {@link PointerInputSource} instance. * * @param {string} id * Id of {@link InputSource}. * @param {Pointer} pointer * The specific {@link Pointer} type associated with this input source. */ constructor(id, pointer) { super(id); this.pointer = pointer; this.x = 0; this.y = 0; this.pressed = new Set(); } /** * Check whether |button| is pressed. * * @param {number} button * Positive integer that refers to a mouse button. * * @returns {boolean} * True if |button| is in set of pressed buttons. */ isPressed(button) { lazy.assert.positiveInteger( button, lazy.pprint`Expected "button" to be a positive integer, got ${button}` ); return this.pressed.has(button); } /** * Add |button| to the set of pressed keys. * * @param {number} button * Positive integer that refers to a mouse button. */ press(button) { lazy.assert.positiveInteger( button, lazy.pprint`Expected "button" to be a positive integer, got ${button}` ); this.pressed.add(button); } /** * Remove |button| from the set of pressed buttons. * * @param {number} button * A positive integer that refers to a mouse button. * * @returns {boolean} * True if |button| was present before removals, false otherwise. */ release(button) { lazy.assert.positiveInteger( button, lazy.pprint`Expected "button" to be a positive integer, got ${button}` ); return this.pressed.delete(button); } /** * Unmarshals a JSON Object to a {@link PointerInputSource}. * * @param {State} actionState * Actions state. * @param {Sequence} actionSequence * Actions for a specific input source. * * @returns {PointerInputSource} * A {@link PointerInputSource} object for the type of the * action {@link Sequence}. * * @throws {InvalidArgumentError} * If the actionSequence is invalid. */ static fromJSON(actionState, actionSequence) { const { id, parameters } = actionSequence; let pointerType = "mouse"; if (parameters !== undefined) { lazy.assert.object( parameters, lazy.pprint`Expected "parameters" to be an object, got ${parameters}` ); if (parameters.pointerType !== undefined) { pointerType = lazy.assert.string( parameters.pointerType, lazy.pprint( `Expected "pointerType" to be a string, got ${parameters.pointerType}` ) ); if (!["mouse", "pen", "touch"].includes(pointerType)) { throw new lazy.error.InvalidArgumentError( lazy.pprint`Expected "pointerType" to be one of "mouse", "pen", or "touch"` ); } } } const pointerId = actionState.getPointerId(id, pointerType); const pointer = Pointer.fromJSON(pointerId, pointerType); return new this(id, pointer); } } /** * Input state associated with a wheel-type device. */ class WheelInputSource extends InputSource { static type = "wheel"; /** * Unmarshals a JSON Object to a {@link WheelInputSource}. * * @param {State} actionState * Actions state. * @param {Sequence} actionSequence * Actions for a specific input source. * * @returns {WheelInputSource} * A {@link WheelInputSource} object for the type of the * action {@link Sequence}. * * @throws {InvalidArgumentError} * If the actionSequence is invalid. */ static fromJSON(actionState, actionSequence) { const { id } = actionSequence; return new this(id); } } const inputSourceTypes = new Map(); for (const cls of [ NullInputSource, KeyInputSource, PointerInputSource, WheelInputSource, ]) { inputSourceTypes.set(cls.type, cls); } /** * Representation of a coordinate origin */ class Origin { /** * Viewport coordinates of the origin of this coordinate system. * * This is overridden in subclasses to provide a class-specific origin. */ getOriginCoordinates() { throw new Error( `originCoordinates not defined for ${this.constructor.name}` ); } /** * Convert [x, y] coordinates to viewport coordinates. * * @param {InputSource} inputSource * State of the current input device * @param {Array} coords * Coordinates [x, y] of the target relative to the origin. * @param {ActionsOptions} options * Configuration of actions dispatch. * * @returns {Array} * Viewport coordinates [x, y]. */ async getTargetCoordinates(inputSource, coords, options) { const [x, y] = coords; const origin = await this.getOriginCoordinates(inputSource, options); return [origin.x + x, origin.y + y]; } /** * Unmarshals a JSON Object to an {@link Origin}. * * @param {string|Element=} origin * Type of origin, one of "viewport", "pointer", {@link Element} * or undefined. * @param {ActionsOptions} options * Configuration for actions. * * @returns {Promise} * Promise that resolves to an {@link Origin} object * representing the origin. * * @throws {InvalidArgumentError} * If origin isn't a valid origin. */ static async fromJSON(origin, options) { const { context, getElementOrigin, isElementOrigin } = options; if (origin === undefined || origin === "viewport") { return new ViewportOrigin(); } if (origin === "pointer") { return new PointerOrigin(); } if (isElementOrigin(origin)) { const element = await getElementOrigin(origin, context); return new ElementOrigin(element); } throw new lazy.error.InvalidArgumentError( `Expected "origin" to be undefined, "viewport", "pointer", ` + lazy.pprint`or an element, got: ${origin}` ); } } class ViewportOrigin extends Origin { getOriginCoordinates() { return { x: 0, y: 0 }; } } class PointerOrigin extends Origin { getOriginCoordinates(inputSource) { return { x: inputSource.x, y: inputSource.y }; } } /** * Representation of an element origin. */ class ElementOrigin extends Origin { /** * Creates a new {@link ElementOrigin} instance. * * @param {Element} element * The element providing the coordinate origin. */ constructor(element) { super(); this.element = element; } /** * Retrieve the coordinates of the origin's in-view center point. * * @param {InputSource} _inputSource * [unused] Current input device. * @param {ActionsOptions} options * * @returns {Promise>} * Promise that resolves to the coordinates [x, y]. */ async getOriginCoordinates(_inputSource, options) { const { context, getClientRects, getInViewCentrePoint } = options; const clientRects = await getClientRects(this.element, context); // The spec doesn't handle this case: https://github.com/w3c/webdriver/issues/1642 if (!clientRects.length) { throw new lazy.error.MoveTargetOutOfBoundsError( lazy.pprint`Origin element ${this.element} is not displayed` ); } return getInViewCentrePoint(clientRects[0], context); } } /** * Represents the behavior of a single input source at a single * point in time. */ class Action { /** Type of the input source associated with this action */ static type = null; /** Type of action specific to the input source */ static subtype = null; /** Whether this kind of action affects the overall duration of a tick */ affectsWallClockTime = false; /** * Creates a new {@link Action} instance. * * @param {string} id * Id of {@link InputSource}. */ constructor(id) { this.id = id; this.type = this.constructor.type; this.subtype = this.constructor.subtype; } toString() { return `[${this.constructor.name} ${this.type}:${this.subtype}]`; } /** * Dispatch the action to the relevant window. * * This is overridden by subclasses to implement the type-specific * dispatch of the action. * * @returns {Promise} * Promise that is resolved once the action is complete. */ dispatch() { throw new Error( `Action subclass ${this.constructor.name} must override dispatch()` ); } /** * Unmarshals a JSON Object to an {@link Action}. * * @param {string} type * Type of {@link InputSource}. * @param {string} id * Id of {@link InputSource}. * @param {object} actionItem * Object representing a single action. * @param {ActionsOptions} options * Configuration for actions. * * @returns {Promise} * Promise that resolves to an action that can be dispatched. * * @throws {InvalidArgumentError} * If the actionItem attribute is invalid. */ static fromJSON(type, id, actionItem, options) { lazy.assert.object( actionItem, lazy.pprint`Expected "action" to be an object, got ${actionItem}` ); const subtype = actionItem.type; const subtypeMap = actionTypes.get(type); if (subtypeMap === undefined) { throw new lazy.error.InvalidArgumentError( lazy.pprint`Expected known action type, got ${type}` ); } let cls = subtypeMap.get(subtype); // Non-device specific actions can happen for any action type if (cls === undefined) { cls = actionTypes.get("none").get(subtype); } if (cls === undefined) { throw new lazy.error.InvalidArgumentError( lazy.pprint`Expected known subtype for type ${type}, got ${subtype}` ); } return cls.fromJSON(id, actionItem, options); } } /** * Action not associated with a specific input device. */ class NullAction extends Action { static type = "none"; } /** * Action that waits for a given duration. */ class PauseAction extends NullAction { static subtype = "pause"; affectsWallClockTime = true; /** * Creates a new {@link PauseAction} instance. * * @param {string} id * Id of {@link InputSource}. * @param {object} options * @param {number} options.duration * Time to pause, in ms. */ constructor(id, options) { super(id); const { duration } = options; this.duration = duration; } /** * Dispatch pause action. * * @param {State} state * The {@link State} of the action. * @param {InputSource} inputSource * Current input device. * @param {number} tickDuration * Length of the current tick, in ms. * * @returns {Promise} * Promise that is resolved once the action is complete. */ dispatch(state, inputSource, tickDuration) { const ms = this.duration ?? tickDuration; lazy.logger.trace( ` Dispatch ${this.constructor.name} with ${this.id} ${ms}` ); return lazy.Sleep(ms); } /** * Unmarshals a JSON Object to a {@link PauseAction}. * * @see https://w3c.github.io/webdriver/#dfn-process-a-null-action * * @param {string} id * Id of {@link InputSource}. * @param {object} actionItem * Object representing a single action. * * @returns {PauseAction} * A pause action that can be dispatched. * * @throws {InvalidArgumentError} * If the actionItem attribute is invalid. */ static fromJSON(id, actionItem) { const { duration } = actionItem; if (duration !== undefined) { lazy.assert.positiveInteger( duration, lazy.pprint`Expected "duration" to be a positive integer, got ${duration}` ); } return new this(id, { duration }); } } /** * Action associated with a keyboard input device */ class KeyAction extends Action { static type = "key"; /** * Creates a new {@link KeyAction} instance. * * @param {string} id * Id of {@link InputSource}. * @param {object} options * @param {string} options.value * The key character. */ constructor(id, options) { super(id); const { value } = options; this.value = value; } getEventData(inputSource) { let value = this.value; if (inputSource.shift) { value = lazy.keyData.getShiftedKey(value); } return new KeyEventData(value); } /** * Unmarshals a JSON Object to a {@link KeyAction}. * * @see https://w3c.github.io/webdriver/#dfn-process-a-key-action * * @param {string} id * Id of {@link InputSource}. * @param {object} actionItem * Object representing a single action. * * @returns {KeyAction} * A key action that can be dispatched. * * @throws {InvalidArgumentError} * If the actionItem attribute is invalid. */ static fromJSON(id, actionItem) { const { value } = actionItem; lazy.assert.string( value, 'Expected "value" to be a string that represents single code point ' + lazy.pprint`or grapheme cluster, got ${value}` ); let segmenter = new Intl.Segmenter(); lazy.assert.that(v => { let graphemeIterator = segmenter.segment(v)[Symbol.iterator](); // We should have exactly one grapheme cluster, so the first iterator // value must be defined, but the second one must be undefined return ( graphemeIterator.next().value !== undefined && graphemeIterator.next().value === undefined ); }, `Expected "value" to be a string that represents single code point or grapheme cluster, got "${value}"`)( value ); return new this(id, { value }); } } /** * Action equivalent to pressing a key on a keyboard. * * @param {string} id * Id of {@link InputSource}. * @param {object} options * @param {string} options.value * The key character. */ class KeyDownAction extends KeyAction { static subtype = "keyDown"; /** * Dispatch a keydown action. * * @param {State} state * The {@link State} of the action. * @param {InputSource} inputSource * Current input device. * @param {number} tickDuration * [unused] Length of the current tick, in ms. * @param {ActionsOptions} options * Configuration of actions dispatch. * * @returns {Promise} * Promise that is resolved once the action is complete. */ async dispatch(state, inputSource, tickDuration, options) { const { context, dispatchEvent } = options; lazy.logger.trace( ` Dispatch ${this.constructor.name} with ${this.id} ${this.value}` ); const keyEvent = this.getEventData(inputSource); keyEvent.repeat = inputSource.isPressed(keyEvent.key); inputSource.press(keyEvent.key); if (keyEvent.key in MODIFIER_NAME_LOOKUP) { inputSource.setModState(keyEvent.key, true); } keyEvent.update(state, inputSource); await dispatchEvent("synthesizeKeyDown", context, { x: inputSource.x, y: inputSource.y, eventData: keyEvent, }); // Append a copy of |this| with keyUp subtype if event dispatched state.inputsToCancel.push(new KeyUpAction(this.id, this)); } } /** * Action equivalent to releasing a key on a keyboard. * * @param {string} id * Id of {@link InputSource}. * @param {object} options * @param {string} options.value * The key character. */ class KeyUpAction extends KeyAction { static subtype = "keyUp"; /** * Dispatch a keyup action. * * @param {State} state * The {@link State} of the action. * @param {InputSource} inputSource * Current input device. * @param {number} tickDuration * [unused] Length of the current tick, in ms. * @param {ActionsOptions} options * Configuration of actions dispatch. * * @returns {Promise} * Promise that is resolved once the action is complete. */ async dispatch(state, inputSource, tickDuration, options) { const { context, dispatchEvent } = options; lazy.logger.trace( ` Dispatch ${this.constructor.name} with ${this.id} ${this.value}` ); const keyEvent = this.getEventData(inputSource); if (!inputSource.isPressed(keyEvent.key)) { return; } if (keyEvent.key in MODIFIER_NAME_LOOKUP) { inputSource.setModState(keyEvent.key, false); } inputSource.release(keyEvent.key); keyEvent.update(state, inputSource); await dispatchEvent("synthesizeKeyUp", context, { x: inputSource.x, y: inputSource.y, eventData: keyEvent, }); } } /** * Action associated with a pointer input device */ class PointerAction extends Action { static type = "pointer"; /** * Creates a new {@link PointerAction} instance. * * @param {string} id * Id of {@link InputSource}. * @param {object} options * @param {number=} options.width * Width of pointer in pixels. * @param {number=} options.height * Height of pointer in pixels. * @param {number=} options.pressure * Pressure of pointer. * @param {number=} options.tangentialPressure * Tangential pressure of pointer. * @param {number=} options.tiltX * X tilt angle of pointer. * @param {number=} options.tiltY * Y tilt angle of pointer. * @param {number=} options.twist * Twist angle of pointer. * @param {number=} options.altitudeAngle * Altitude angle of pointer. * @param {number=} options.azimuthAngle * Azimuth angle of pointer. */ constructor(id, options) { super(id); const { width, height, pressure, tangentialPressure, tiltX, tiltY, twist, altitudeAngle, azimuthAngle, } = options; this.width = width; this.height = height; this.pressure = pressure; this.tangentialPressure = tangentialPressure; this.tiltX = tiltX; this.tiltY = tiltY; this.twist = twist; this.altitudeAngle = altitudeAngle; this.azimuthAngle = azimuthAngle; } /** * Validate properties common to all pointer types. * * @param {object} actionItem * Object representing a single pointer action. * * @returns {object} * Properties of the pointer action; contains `width`, `height`, * `pressure`, `tangentialPressure`, `tiltX`, `tiltY`, `twist`, * `altitudeAngle`, and `azimuthAngle`. */ static validateCommon(actionItem) { const { width, height, pressure, tangentialPressure, tiltX, tiltY, twist, altitudeAngle, azimuthAngle, } = actionItem; if (width !== undefined) { lazy.assert.positiveInteger( width, lazy.pprint`Expected "width" to be a positive integer, got ${width}` ); } if (height !== undefined) { lazy.assert.positiveInteger( height, lazy.pprint`Expected "height" to be a positive integer, got ${height}` ); } if (pressure !== undefined) { lazy.assert.numberInRange( pressure, [0, 1], lazy.pprint`Expected "pressure" to be in range 0 to 1, got ${pressure}` ); } if (tangentialPressure !== undefined) { lazy.assert.numberInRange( tangentialPressure, [-1, 1], 'Expected "tangentialPressure" to be in range -1 to 1, ' + lazy.pprint`got ${tangentialPressure}` ); } if (tiltX !== undefined) { lazy.assert.integerInRange( tiltX, [-90, 90], lazy.pprint`Expected "tiltX" to be in range -90 to 90, got ${tiltX}` ); } if (tiltY !== undefined) { lazy.assert.integerInRange( tiltY, [-90, 90], lazy.pprint`Expected "tiltY" to be in range -90 to 90, got ${tiltY}` ); } if (twist !== undefined) { lazy.assert.integerInRange( twist, [0, 359], lazy.pprint`Expected "twist" to be in range 0 to 359, got ${twist}` ); } if (altitudeAngle !== undefined) { lazy.assert.numberInRange( altitudeAngle, [0, Math.PI / 2], 'Expected "altitudeAngle" to be in range 0 to ${Math.PI / 2}, ' + lazy.pprint`got ${altitudeAngle}` ); } if (azimuthAngle !== undefined) { lazy.assert.numberInRange( azimuthAngle, [0, 2 * Math.PI], 'Expected "azimuthAngle" to be in range 0 to ${2 * Math.PI}, ' + lazy.pprint`got ${azimuthAngle}` ); } return { width, height, pressure, tangentialPressure, tiltX, tiltY, twist, altitudeAngle, azimuthAngle, }; } } /** * Action associated with a pointer input device being depressed. */ class PointerDownAction extends PointerAction { static subtype = "pointerDown"; /** * Creates a new {@link PointerAction} instance. * * @param {string} id * Id of {@link InputSource}. * @param {object} options * @param {number} options.button * Button being pressed. For devices without buttons (e.g. touch), * this should be 0. * @param {number=} options.width * Width of pointer in pixels. * @param {number=} options.height * Height of pointer in pixels. * @param {number=} options.pressure * Pressure of pointer. * @param {number=} options.tangentialPressure * Tangential pressure of pointer. * @param {number=} options.tiltX * X tilt angle of pointer. * @param {number=} options.tiltY * Y tilt angle of pointer. * @param {number=} options.twist * Twist angle of pointer. * @param {number=} options.altitudeAngle * Altitude angle of pointer. * @param {number=} options.azimuthAngle * Azimuth angle of pointer. */ constructor(id, options) { super(id, options); const { button } = options; this.button = button; } /** * Dispatch a pointerdown action. * * @param {State} state * The {@link State} of the action. * @param {InputSource} inputSource * Current input device. * @param {number} tickDuration * [unused] Length of the current tick, in ms. * @param {ActionsOptions} options * Configuration of actions dispatch. * * @returns {Promise} * Promise that is resolved once the action is complete. */ async dispatch(state, inputSource, tickDuration, options) { lazy.logger.trace( `Dispatch ${this.constructor.name} ${inputSource.pointer.type} with id: ${this.id} button: ${this.button}` ); if (inputSource.isPressed(this.button)) { return; } inputSource.press(this.button); await inputSource.pointer.pointerDown(state, inputSource, this, options); // Append a copy of |this| with pointerUp subtype if event dispatched state.inputsToCancel.push(new PointerUpAction(this.id, this)); } /** * Unmarshals a JSON Object to a {@link PointerDownAction}. * * @see https://w3c.github.io/webdriver/#dfn-process-a-pointer-up-or-pointer-down-action * * @param {string} id * Id of {@link InputSource}. * @param {object} actionItem * Object representing a single action. * * @returns {PointerDownAction} * A pointer down action that can be dispatched. * * @throws {InvalidArgumentError} * If the actionItem attribute is invalid. */ static fromJSON(id, actionItem) { const { button } = actionItem; const props = PointerAction.validateCommon(actionItem); lazy.assert.positiveInteger( button, lazy.pprint`Expected "button" to be a positive integer, got ${button}` ); props.button = button; return new this(id, props); } } /** * Action associated with a pointer input device being released. */ class PointerUpAction extends PointerAction { static subtype = "pointerUp"; /** * Creates a new {@link PointerUpAction} instance. * * @param {string} id * Id of {@link InputSource}. * @param {object} options * @param {number} options.button * Button being pressed. For devices without buttons (e.g. touch), * this should be 0. * @param {number=} options.width * Width of pointer in pixels. * @param {number=} options.height * Height of pointer in pixels. * @param {number=} options.pressure * Pressure of pointer. * @param {number=} options.tangentialPressure * Tangential pressure of pointer. * @param {number=} options.tiltX * X tilt angle of pointer. * @param {number=} options.tiltY * Y tilt angle of pointer. * @param {number=} options.twist * Twist angle of pointer. * @param {number=} options.altitudeAngle * Altitude angle of pointer. * @param {number=} options.azimuthAngle * Azimuth angle of pointer. */ constructor(id, options) { super(id, options); const { button } = options; this.button = button; } /** * Dispatch a pointerup action. * * @param {State} state * The {@link State} of the action. * @param {InputSource} inputSource * Current input device. * @param {number} tickDuration * [unused] Length of the current tick, in ms. * @param {ActionsOptions} options * Configuration of actions dispatch. * * @returns {Promise} * Promise that is resolved once the action is complete. */ async dispatch(state, inputSource, tickDuration, options) { lazy.logger.trace( `Dispatch ${this.constructor.name} ${inputSource.pointer.type} with id: ${this.id} button: ${this.button}` ); if (!inputSource.isPressed(this.button)) { return; } inputSource.release(this.button); await inputSource.pointer.pointerUp(state, inputSource, this, options); } /** * Unmarshals a JSON Object to a {@link PointerUpAction}. * * @see https://w3c.github.io/webdriver/#dfn-process-a-pointer-up-or-pointer-down-action * * @param {string} id * Id of {@link InputSource}. * @param {object} actionItem * Object representing a single action. * * @returns {PointerUpAction} * A pointer up action that can be dispatched. * * @throws {InvalidArgumentError} * If the actionItem attribute is invalid. */ static fromJSON(id, actionItem) { const { button } = actionItem; const props = PointerAction.validateCommon(actionItem); lazy.assert.positiveInteger( button, lazy.pprint`Expected "button" to be a positive integer, got ${button}` ); props.button = button; return new this(id, props); } } /** * Action associated with a pointer input device being moved. */ class PointerMoveAction extends PointerAction { static subtype = "pointerMove"; affectsWallClockTime = true; /** * Creates a new {@link PointerMoveAction} instance. * * @param {string} id * Id of {@link InputSource}. * @param {object} options * @param {number} options.origin * {@link Origin} of target coordinates. * @param {number} options.x * X value of scroll coordinates. * @param {number} options.y * Y value of scroll coordinates. * @param {number=} options.width * Width of pointer in pixels. * @param {number=} options.height * Height of pointer in pixels. * @param {number=} options.pressure * Pressure of pointer. * @param {number=} options.tangentialPressure * Tangential pressure of pointer. * @param {number=} options.tiltX * X tilt angle of pointer. * @param {number=} options.tiltY * Y tilt angle of pointer. * @param {number=} options.twist * Twist angle of pointer. * @param {number=} options.altitudeAngle * Altitude angle of pointer. * @param {number=} options.azimuthAngle * Azimuth angle of pointer. */ constructor(id, options) { super(id, options); const { duration, origin, x, y } = options; this.duration = duration; this.origin = origin; this.x = x; this.y = y; } /** * Dispatch a pointermove action. * * @param {State} state * The {@link State} of the action. * @param {InputSource} inputSource * Current input device. * @param {number} tickDuration * [unused] Length of the current tick, in ms. * @param {ActionsOptions} options * Configuration of actions dispatch. * * @returns {Promise} * Promise that is resolved once the action is complete. */ async dispatch(state, inputSource, tickDuration, options) { const { assertInViewPort, context } = options; lazy.logger.trace( `Dispatch ${this.constructor.name} ${inputSource.pointer.type} with id: ${this.id} x: ${this.x} y: ${this.y}` ); const target = await this.origin.getTargetCoordinates( inputSource, [this.x, this.y], options ); await assertInViewPort(target, context); return moveOverTime( [[inputSource.x, inputSource.y]], [target], this.duration ?? tickDuration, async _target => await this.performPointerMoveStep(state, inputSource, _target, options) ); } /** * Perform one part of a pointer move corresponding to a specific emitted event. * * @param {State} state * The {@link State} of actions. * @param {InputSource} inputSource * Current input device. * @param {Array>} targets * Array of [x, y] arrays specifying the viewport coordinates to move to. * @param {ActionsOptions} options * Configuration of actions dispatch. * * @returns {Promise} */ async performPointerMoveStep(state, inputSource, targets, options) { if (targets.length !== 1) { throw new Error( "PointerMoveAction.performPointerMoveStep requires a single target" ); } const target = targets[0]; lazy.logger.trace( `PointerMoveAction.performPointerMoveStep ${JSON.stringify(target)}` ); if (target[0] == inputSource.x && target[1] == inputSource.y) { return; } await inputSource.pointer.pointerMove( state, inputSource, this, target[0], target[1], options ); inputSource.x = target[0]; inputSource.y = target[1]; } /** * Unmarshals a JSON Object to a {@link PointerMoveAction}. * * @see https://w3c.github.io/webdriver/#dfn-process-a-pointer-move-action * * @param {string} id * Id of {@link InputSource}. * @param {object} actionItem * Object representing a single action. * @param {ActionsOptions} options * Configuration for actions. * * @returns {Promise} * A pointer move action that can be dispatched. * * @throws {InvalidArgumentError} * If the actionItem attribute is invalid. */ static async fromJSON(id, actionItem, options) { const { duration, origin, x, y } = actionItem; if (duration !== undefined) { lazy.assert.positiveInteger( duration, lazy.pprint`Expected "duration" to be a positive integer, got ${duration}` ); } const originObject = await Origin.fromJSON(origin, options); lazy.assert.number( x, lazy.pprint`Expected "x" to be a finite number, got ${x}` ); lazy.assert.number( y, lazy.pprint`Expected "y" to be a finite number, got ${y}` ); const props = PointerAction.validateCommon(actionItem); props.duration = duration; props.origin = originObject; props.x = x; props.y = y; return new this(id, props); } } /** * Action associated with a wheel input device. */ class WheelAction extends Action { static type = "wheel"; } /** * Action associated with scrolling a scroll wheel */ class WheelScrollAction extends WheelAction { static subtype = "scroll"; affectsWallClockTime = true; /** * Creates a new {@link WheelScrollAction} instance. * * @param {number} id * Id of {@link InputSource}. * @param {object} options * @param {Origin} options.origin * {@link Origin} of target coordinates. * @param {number} options.x * X value of scroll coordinates. * @param {number} options.y * Y value of scroll coordinates. * @param {number} options.deltaX * Number of CSS pixels to scroll in X direction. * @param {number} options.deltaY * Number of CSS pixels to scroll in Y direction. */ constructor(id, options) { super(id); const { duration, origin, x, y, deltaX, deltaY } = options; this.duration = duration; this.origin = origin; this.x = x; this.y = y; this.deltaX = deltaX; this.deltaY = deltaY; } /** * Unmarshals a JSON Object to a {@link WheelScrollAction}. * * @param {string} id * Id of {@link InputSource}. * @param {object} actionItem * Object representing a single action. * @param {ActionsOptions} options * Configuration for actions. * * @returns {Promise} * Promise that resolves to a wheel scroll action * that can be dispatched. * * @throws {InvalidArgumentError} * If the actionItem attribute is invalid. */ static async fromJSON(id, actionItem, options) { const { duration, origin, x, y, deltaX, deltaY } = actionItem; if (duration !== undefined) { lazy.assert.positiveInteger( duration, lazy.pprint`Expected "duration" to be a positive integer, got ${duration}` ); } const originObject = await Origin.fromJSON(origin, options); if (originObject instanceof PointerOrigin) { throw new lazy.error.InvalidArgumentError( `"pointer" origin not supported for "wheel" input source.` ); } lazy.assert.integer( x, lazy.pprint`Expected "x" to be an Integer, got ${x}` ); lazy.assert.integer( y, lazy.pprint`Expected "y" to be an Integer, got ${y}` ); lazy.assert.integer( deltaX, lazy.pprint`Expected "deltaX" to be an Integer, got ${deltaX}` ); lazy.assert.integer( deltaY, lazy.pprint`Expected "deltaY" to be an Integer, got ${deltaY}` ); return new this(id, { duration, origin: originObject, x, y, deltaX, deltaY, }); } /** * Dispatch a wheel scroll action. * * @param {State} state * The {@link State} of the action. * @param {InputSource} inputSource * Current input device. * @param {number} tickDuration * [unused] Length of the current tick, in ms. * @param {ActionsOptions} options * Configuration of actions dispatch. * * @returns {Promise} * Promise that is resolved once the action is complete. */ async dispatch(state, inputSource, tickDuration, options) { const { assertInViewPort, context, toBrowserWindowCoordinates } = options; let scrollCoordinates = await this.origin.getTargetCoordinates( inputSource, [this.x, this.y], options ); await assertInViewPort(scrollCoordinates, context); lazy.logger.trace( `Dispatch ${this.constructor.name} with id: ${this.id} ` + `pageX: ${scrollCoordinates[0]} pageY: ${scrollCoordinates[1]} ` + `deltaX: ${this.deltaX} deltaY: ${this.deltaY} ` + `async: ${actions.useAsyncWheelEvents}` ); // Only convert coordinates if those are for a content process if (context.isContent && actions.useAsyncWheelEvents) { scrollCoordinates = await toBrowserWindowCoordinates( scrollCoordinates, context ); } const startX = 0; const startY = 0; // This is an action-local state that holds the amount of scroll completed const deltaPosition = [startX, startY]; return moveOverTime( [[startX, startY]], [[this.deltaX, this.deltaY]], this.duration ?? tickDuration, async deltaTarget => await this.performOneWheelScroll( state, scrollCoordinates, deltaPosition, deltaTarget, options ) ); } /** * Perform one part of a wheel scroll corresponding to a specific emitted event. * * @param {State} state * The {@link State} of actions. * @param {Array} scrollCoordinates * The viewport coordinates [x, y] of the scroll action. * @param {Array} deltaPosition * [deltaX, deltaY] coordinates of the scroll before this event. * @param {Array>} deltaTargets * Array of [deltaX, deltaY] coordinates to scroll to. * @param {ActionsOptions} options * Configuration of actions dispatch. * * @returns {Promise} */ async performOneWheelScroll( state, scrollCoordinates, deltaPosition, deltaTargets, options ) { const { context, dispatchEvent } = options; if (deltaTargets.length !== 1) { throw new Error("Can only scroll one wheel at a time"); } if (deltaPosition[0] == this.deltaX && deltaPosition[1] == this.deltaY) { return; } const deltaTarget = deltaTargets[0]; const deltaX = deltaTarget[0] - deltaPosition[0]; const deltaY = deltaTarget[1] - deltaPosition[1]; const eventData = new WheelEventData({ deltaX, deltaY, deltaZ: 0, }); eventData.update(state); lazy.logger.trace( `WheelScrollAction.performOneWheelScrollStep [${deltaX},${deltaY}]` ); await dispatchEvent("synthesizeWheelAtPoint", context, { x: scrollCoordinates[0], y: scrollCoordinates[1], eventData, }); // Update the current scroll position for the caller deltaPosition[0] = deltaTarget[0]; deltaPosition[1] = deltaTarget[1]; } } /** * Group of actions representing behavior of all touch pointers during * a single tick. * * For touch pointers, we need to call into the platform once with all * the actions so that they are regarded as simultaneous. This means * we don't use the `dispatch()` method on the underlying actions, but * instead use one on this group object. */ class TouchActionGroup { static type = null; /** * Creates a new {@link TouchActionGroup} instance. */ constructor() { this.type = this.constructor.type; this.actions = new Map(); } static forType(type) { const cls = touchActionGroupTypes.get(type); return new cls(); } /** * Add action corresponding to a specific pointer to the group. * * @param {InputSource} inputSource * Current input device. * @param {Action} action * Action to add to the group. */ addPointer(inputSource, action) { if (action.subtype !== this.type) { throw new Error( `Added action of unexpected type, got ${action.subtype}, expected ${this.type}` ); } this.actions.set(action.id, [inputSource, action]); } /** * Dispatch the action group to the relevant window. * * This is overridden by subclasses to implement the type-specific * dispatch of the action. * * @returns {Promise} * Promise that is resolved once the action is complete. */ dispatch() { throw new Error( "TouchActionGroup subclass missing dispatch implementation" ); } } /** * Group of actions representing behavior of all touch pointers * depressed during a single tick. */ class PointerDownTouchActionGroup extends TouchActionGroup { static type = "pointerDown"; /** * Dispatch a pointerdown touch action. * * @param {State} state * The {@link State} of the action. * @param {InputSource} inputSource * Current input device. * @param {number} tickDuration * [unused] Length of the current tick, in ms. * @param {ActionsOptions} options * Configuration of actions dispatch. * * @returns {Promise} * Promise that is resolved once the action is complete. */ async dispatch(state, inputSource, tickDuration, options) { const { context, dispatchEvent } = options; lazy.logger.trace( `Dispatch ${this.constructor.name} with ${Array.from( this.actions.values() ).map(x => x[1].id)}` ); if (inputSource !== null) { throw new Error( "Expected null inputSource for PointerDownTouchActionGroup.dispatch" ); } // Only include pointers that are not already depressed const filteredActions = Array.from(this.actions.values()).filter( ([actionInputSource, action]) => !actionInputSource.isPressed(action.button) ); if (filteredActions.length) { const eventData = new MultiTouchEventData("touchstart"); for (const [actionInputSource, action] of filteredActions) { eventData.addPointerEventData(actionInputSource, action); actionInputSource.press(action.button); eventData.update(state, actionInputSource); } // Touch start events must include all depressed touch pointers for (const [id, pointerInputSource] of state.inputSourcesByType( "pointer" )) { if ( pointerInputSource.pointer.type === "touch" && !this.actions.has(id) && pointerInputSource.isPressed(0) ) { eventData.addPointerEventData(pointerInputSource, {}); eventData.update(state, pointerInputSource); } } await dispatchEvent("synthesizeMultiTouch", context, { eventData }); for (const [, action] of filteredActions) { // Append a copy of |action| with pointerUp subtype if event dispatched state.inputsToCancel.push(new PointerUpAction(action.id, action)); } } } } /** * Group of actions representing behavior of all touch pointers * released during a single tick. */ class PointerUpTouchActionGroup extends TouchActionGroup { static type = "pointerUp"; /** * Dispatch a pointerup touch action. * * @param {State} state * The {@link State} of the action. * @param {InputSource} inputSource * Current input device. * @param {number} tickDuration * [unused] Length of the current tick, in ms. * @param {ActionsOptions} options * Configuration of actions dispatch. * * @returns {Promise} * Promise that is resolved once the action is complete. */ async dispatch(state, inputSource, tickDuration, options) { const { context, dispatchEvent } = options; lazy.logger.trace( `Dispatch ${this.constructor.name} with ${Array.from( this.actions.values() ).map(x => x[1].id)}` ); if (inputSource !== null) { throw new Error( "Expected null inputSource for PointerUpTouchActionGroup.dispatch" ); } // Only include pointers that are not already depressed const filteredActions = Array.from(this.actions.values()).filter( ([actionInputSource, action]) => actionInputSource.isPressed(action.button) ); if (filteredActions.length) { const eventData = new MultiTouchEventData("touchend"); for (const [actionInputSource, action] of filteredActions) { eventData.addPointerEventData(actionInputSource, action); actionInputSource.release(action.button); eventData.update(state, actionInputSource); } await dispatchEvent("synthesizeMultiTouch", context, { eventData }); } } } /** * Group of actions representing behavior of all touch pointers * moved during a single tick. */ class PointerMoveTouchActionGroup extends TouchActionGroup { static type = "pointerMove"; /** * Dispatch a pointermove touch action. * * @param {State} state * The {@link State} of the action. * @param {InputSource} inputSource * Current input device. * @param {number} tickDuration * [unused] Length of the current tick, in ms. * @param {ActionsOptions} options * Configuration of actions dispatch. * * @returns {Promise} * Promise that is resolved once the action is complete. */ async dispatch(state, inputSource, tickDuration, options) { const { assertInViewPort, context } = options; lazy.logger.trace( `Dispatch ${this.constructor.name} with ${Array.from(this.actions).map( x => x[1].id )}` ); if (inputSource !== null) { throw new Error( "Expected null inputSource for PointerMoveTouchActionGroup.dispatch" ); } let startCoords = []; let targetCoords = []; for (const [actionInputSource, action] of this.actions.values()) { const target = await action.origin.getTargetCoordinates( actionInputSource, [action.x, action.y], options ); await assertInViewPort(target, context); startCoords.push([actionInputSource.x, actionInputSource.y]); targetCoords.push(target); } // Touch move events must include all depressed touch pointers, even if they are static // This can end up generating pointermove events even for static pointers, but Gecko // seems to generate a lot of pointermove events anyway, so this seems like the lesser // problem. // See https://bugzilla.mozilla.org/show_bug.cgi?id=1779206 const staticTouchPointers = []; for (const [id, pointerInputSource] of state.inputSourcesByType( "pointer" )) { if ( pointerInputSource.pointer.type === "touch" && !this.actions.has(id) && pointerInputSource.isPressed(0) ) { staticTouchPointers.push(pointerInputSource); } } return moveOverTime( startCoords, targetCoords, this.duration ?? tickDuration, async currentTargetCoords => await this.performPointerMoveStep( state, staticTouchPointers, currentTargetCoords, options ) ); } /** * Perform one part of a pointer move corresponding to a specific emitted event. * * @param {State} state * The {@link State} of actions. * @param {Array} staticTouchPointers * Array of PointerInputSource objects for pointers that aren't * involved in the touch move. * @param {Array>} targetCoords * Array of [x, y] arrays specifying the viewport coordinates to move to. * @param {ActionsOptions} options * Configuration of actions dispatch. */ async performPointerMoveStep( state, staticTouchPointers, targetCoords, options ) { const { context, dispatchEvent } = options; if (targetCoords.length !== this.actions.size) { throw new Error("Expected one target per pointer"); } const perPointerData = Array.from(this.actions.values()).map( ([inputSource, action], i) => { const target = targetCoords[i]; return [inputSource, action, target]; } ); const reachedTarget = perPointerData.every( ([inputSource, , target]) => target[0] === inputSource.x && target[1] === inputSource.y ); if (reachedTarget) { return; } const eventData = new MultiTouchEventData("touchmove"); for (const [inputSource, action, target] of perPointerData) { inputSource.x = target[0]; inputSource.y = target[1]; eventData.addPointerEventData(inputSource, action); eventData.update(state, inputSource); } for (const inputSource of staticTouchPointers) { eventData.addPointerEventData(inputSource, {}); eventData.update(state, inputSource); } await dispatchEvent("synthesizeMultiTouch", context, { eventData }); } } const touchActionGroupTypes = new Map(); for (const cls of [ PointerDownTouchActionGroup, PointerUpTouchActionGroup, PointerMoveTouchActionGroup, ]) { touchActionGroupTypes.set(cls.type, cls); } /** * Split a transition from startCoord to targetCoord linearly over duration. * * startCoords and targetCoords are lists of [x,y] positions in some space * (e.g. screen position or scroll delta). This function will linearly * interpolate intermediate positions, sending out roughly one event * per frame to simulate moving between startCoord and targetCoord in * a time of tickDuration milliseconds. The callback function is * responsible for actually emitting the event, given the current * position in the coordinate space. * * @param {Array} startCoords * Array of initial [x, y] coordinates for each input source involved * in the move. * @param {Array>} targetCoords * Array of target [x, y] coordinates for each input source involved * in the move. * @param {number} duration * Time in ms the move will take. * @param {Function} callback * Function that actually performs the move. This takes a single parameter * which is an array of [x, y] coordinates corresponding to the move * targets. */ async function moveOverTime(startCoords, targetCoords, duration, callback) { lazy.logger.trace( `moveOverTime start: ${startCoords} target: ${targetCoords} duration: ${duration}` ); if (startCoords.length !== targetCoords.length) { throw new Error( "Expected equal number of start coordinates and target coordinates" ); } if ( !startCoords.every(item => item.length == 2) || !targetCoords.every(item => item.length == 2) ) { throw new Error( "Expected start coordinates target coordinates to be Array of multiple [x,y] coordinates." ); } if (duration === 0) { // transition to destination in one step await callback(targetCoords); return; } const timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); // interval between transitions in ms, based on common vsync const fps60 = 17; const distances = targetCoords.map((targetCoord, i) => { const startCoord = startCoords[i]; return [targetCoord[0] - startCoord[0], targetCoord[1] - startCoord[1]]; }); const ONE_SHOT = Ci.nsITimer.TYPE_ONE_SHOT; const startTime = Date.now(); const transitions = (async () => { // wait |fps60| ms before performing first incremental transition await new Promise(resolveTimer => timer.initWithCallback(resolveTimer, fps60, ONE_SHOT) ); let durationRatio = Math.floor(Date.now() - startTime) / duration; const epsilon = fps60 / duration / 10; while (1 - durationRatio > epsilon) { const intermediateTargets = startCoords.map((startCoord, i) => { let distance = distances[i]; return [ Math.floor(durationRatio * distance[0] + startCoord[0]), Math.floor(durationRatio * distance[1] + startCoord[1]), ]; }); await Promise.all([ callback(intermediateTargets), // wait |fps60| ms before performing next transition new Promise(resolveTimer => timer.initWithCallback(resolveTimer, fps60, ONE_SHOT) ), ]); durationRatio = Math.floor(Date.now() - startTime) / duration; } })(); await transitions; // perform last transition after all incremental moves are resolved and // durationRatio is close enough to 1 await callback(targetCoords); } const actionTypes = new Map(); for (const cls of [ KeyDownAction, KeyUpAction, PauseAction, PointerDownAction, PointerUpAction, PointerMoveAction, WheelScrollAction, ]) { if (!actionTypes.has(cls.type)) { actionTypes.set(cls.type, new Map()); } actionTypes.get(cls.type).set(cls.subtype, cls); } /** * Implementation of the behavior of a specific type of pointer. * * @abstract */ class Pointer { /** Type of pointer */ static type = null; /** * Creates a new {@link Pointer} instance. * * @param {number} id * Numeric pointer id. */ constructor(id) { this.id = id; this.type = this.constructor.type; } /** * Implementation of depressing the pointer. */ pointerDown() { throw new Error(`Unimplemented pointerDown for pointerType ${this.type}`); } /** * Implementation of releasing the pointer. */ pointerUp() { throw new Error(`Unimplemented pointerUp for pointerType ${this.type}`); } /** * Implementation of moving the pointer. */ pointerMove() { throw new Error(`Unimplemented pointerMove for pointerType ${this.type}`); } /** * Unmarshals a JSON Object to a {@link Pointer}. * * @param {number} pointerId * Numeric pointer id. * @param {string} pointerType * Pointer type. * * @returns {Pointer} * An instance of the Pointer class for {@link pointerType}. * * @throws {InvalidArgumentError} * If {@link pointerType} is not a valid pointer type. */ static fromJSON(pointerId, pointerType) { const cls = pointerTypes.get(pointerType); if (cls === undefined) { throw new lazy.error.InvalidArgumentError( 'Expected "pointerType" type to be one of ' + lazy.pprint`${pointerTypes}, got ${pointerType}` ); } return new cls(pointerId); } } /** * Implementation of mouse pointer behavior. */ class MousePointer extends Pointer { static type = "mouse"; /** * Emits a pointer down event. * * @param {State} state * The {@link State} of the action. * @param {InputSource} inputSource * Current input device. * @param {PointerDownAction} action * The pointer down action to perform. * @param {ActionsOptions} options * Configuration of actions dispatch. * * @returns {Promise} * Promise that resolves when the event has been dispatched. */ async pointerDown(state, inputSource, action, options) { const { context, dispatchEvent } = options; const mouseEvent = new MouseEventData("mousedown", { button: action.button, }); mouseEvent.update(state, inputSource); if (mouseEvent.ctrlKey) { if (lazy.AppInfo.isMac) { mouseEvent.button = 2; state.clickTracker.reset(); } } else { mouseEvent.clickCount = state.clickTracker.count + 1; } await dispatchEvent("synthesizeMouseAtPoint", context, { x: inputSource.x, y: inputSource.y, eventData: mouseEvent, }); if ( lazy.event.MouseButton.isSecondary(mouseEvent.button) || (mouseEvent.ctrlKey && lazy.AppInfo.isMac) ) { const contextMenuEvent = { ...mouseEvent, type: "contextmenu" }; await dispatchEvent("synthesizeMouseAtPoint", context, { x: inputSource.x, y: inputSource.y, eventData: contextMenuEvent, }); } } /** * Emits a pointer up event. * * @param {State} state * The {@link State} of the action. * @param {InputSource} inputSource * Current input device. * @param {PointerUpAction} action * The pointer up action to perform. * @param {ActionsOptions} options * Configuration of actions dispatch. * * @returns {Promise} * Promise that resolves when the event has been dispatched. */ async pointerUp(state, inputSource, action, options) { const { context, dispatchEvent } = options; const mouseEvent = new MouseEventData("mouseup", { button: action.button, }); mouseEvent.update(state, inputSource); state.clickTracker.setClick(action.button); mouseEvent.clickCount = state.clickTracker.count; await dispatchEvent("synthesizeMouseAtPoint", context, { x: inputSource.x, y: inputSource.y, eventData: mouseEvent, }); } /** * Emits a pointer down event. * * @param {State} state * The {@link State} of the action. * @param {InputSource} inputSource * Current input device. * @param {PointerMoveAction} action * The pointer down action to perform. * @param {number} targetX * Target x position to move the pointer to. * @param {number} targetY * Target y position to move the pointer to. * @param {ActionsOptions} options * Configuration of actions dispatch. * * @returns {Promise} * Promise that resolves when the event has been dispatched. */ async pointerMove(state, inputSource, action, targetX, targetY, options) { const { context, dispatchEvent } = options; const mouseEvent = new MouseEventData("mousemove"); mouseEvent.update(state, inputSource); await dispatchEvent("synthesizeMouseAtPoint", context, { x: targetX, y: targetY, eventData: mouseEvent, }); state.clickTracker.reset(); } } /* * The implementation here is empty because touch actions have to go via the * TouchActionGroup. So if we end up calling these methods that's a bug in * the code. */ class TouchPointer extends Pointer { static type = "touch"; } /* * Placeholder for future pen type pointer support. */ class PenPointer extends Pointer { static type = "pen"; } const pointerTypes = new Map(); for (const cls of [MousePointer, TouchPointer, PenPointer]) { pointerTypes.set(cls.type, cls); } /** * Represents a series of ticks, specifying which actions to perform at * each tick. */ actions.Chain = class extends Array { toString() { return `[chain ${super.toString()}]`; } /** * Dispatch the action chain to the relevant window. * * @param {State} state * The {@link State} of actions. * @param {ActionsOptions} options * Configuration of actions dispatch. * * @returns {Promise} * Promise that is resolved once the action chain is complete. */ dispatch(state, options) { let i = 1; const chainEvents = (async () => { for (const tickActions of this) { lazy.logger.trace(`Dispatching tick ${i++}/${this.length}`); await tickActions.dispatch(state, options); } })(); // Reset the current click tracker counter. We shouldn't be able to simulate // a double click with multiple action chains. state.clickTracker.reset(); return chainEvents; } /* eslint-disable no-shadow */ // Shadowing is intentional for `actions`. /** * * Unmarshals a JSON Object to a {@link Chain}. * * @see https://w3c.github.io/webdriver/#dfn-extract-an-action-sequence * * @param {State} actionState * The {@link State} of actions. * @param {Array} actions * Array of objects that each represent an action sequence. * @param {ActionsOptions} options * Configuration for actions. * * @returns {Promise} * Promise resolving to an object that allows dispatching * a chain of actions. * * @throws {InvalidArgumentError} * If actions doesn't correspond to a valid action chain. */ static async fromJSON(actionState, actions, options) { lazy.assert.array( actions, lazy.pprint`Expected "actions" to be an array, got ${actions}` ); const actionsByTick = new this(); for (const actionSequence of actions) { lazy.assert.object( actionSequence, 'Expected "actions" item to be an object, ' + lazy.pprint`got ${actionSequence}` ); const inputSourceActions = await Sequence.fromJSON( actionState, actionSequence, options ); for (let i = 0; i < inputSourceActions.length; i++) { // new tick if (actionsByTick.length < i + 1) { actionsByTick.push(new TickActions()); } actionsByTick[i].push(inputSourceActions[i]); } } return actionsByTick; } /* eslint-enable no-shadow */ }; /** * Represents the action for each input device to perform in a single tick. */ class TickActions extends Array { /** * Tick duration in milliseconds. * * @returns {number} * Longest action duration in |tickActions| if any, or 0. */ getDuration() { let max = 0; for (const action of this) { if (action.affectsWallClockTime && action.duration) { max = Math.max(action.duration, max); } } return max; } /** * Dispatch sequence of actions for this tick. * * This creates a Promise for one tick that resolves once the Promise * for each tick-action is resolved, which takes at least |tickDuration| * milliseconds. The resolved set of events for each tick is followed by * firing of pending DOM events. * * Note that the tick-actions are dispatched in order, but they may have * different durations and therefore may not end in the same order. * * @param {State} state * The {@link State} of actions. * @param {ActionsOptions} options * Configuration of actions dispatch. * * @returns {Promise} * Promise that resolves when tick is complete. */ dispatch(state, options) { const tickDuration = this.getDuration(); const tickActions = this.groupTickActions(state); const pendingEvents = tickActions.map(([inputSource, action]) => action.dispatch(state, inputSource, tickDuration, options) ); return Promise.all(pendingEvents); } /** * Group together actions from input sources that have to be * dispatched together. * * The actual transformation here is to group together touch pointer * actions into {@link TouchActionGroup} instances. * * @param {State} state * The {@link State} of actions. * * @returns {Array>} * Array of pairs. For ungrouped actions each element is * [InputSource, Action] For touch actions there are multiple * pointers handled at once, so the first item of the array is * null, meaning the group has to perform its own handling of the * relevant state, and the second element is a TouchActionGroup. */ groupTickActions(state) { const touchActions = new Map(); const groupedActions = []; for (const action of this) { const inputSource = state.getInputSource(action.id); if (action.type == "pointer" && inputSource.pointer.type === "touch") { lazy.logger.debug( `Grouping action ${action.type} ${action.id} ${action.subtype}` ); let group = touchActions.get(action.subtype); if (group === undefined) { group = TouchActionGroup.forType(action.subtype); touchActions.set(action.subtype, group); groupedActions.push([null, group]); } group.addPointer(inputSource, action); } else { groupedActions.push([inputSource, action]); } } return groupedActions; } } /** * Represents one input source action sequence; this is essentially an * |Array|. * * This is a temporary object only used when constructing an {@link * action.Chain}. */ class Sequence extends Array { toString() { return `[sequence ${super.toString()}]`; } /** * Unmarshals a JSON Object to a {@link Sequence}. * * @see https://w3c.github.io/webdriver/#dfn-process-an-input-source-action-sequence * * @param {State} actionState * The {@link State} of actions. * @param {object} actionSequence * Protocol representation of the actions for a specific input source. * @param {ActionsOptions} options * Configuration for actions. * * @returns {Promise>>} * Promise that resolves to an object that allows dispatching a * sequence of actions. * * @throws {InvalidArgumentError} * If the actionSequence doesn't correspond to a valid action sequence. */ static async fromJSON(actionState, actionSequence, options) { // used here to validate 'type' in addition to InputSource type below const { actions: actionsFromSequence, id, type } = actionSequence; // type and id get validated in InputSource.fromJSON lazy.assert.array( actionsFromSequence, 'Expected "actionSequence.actions" to be an array, ' + lazy.pprint`got ${actionSequence.actions}` ); // This sets the input state in the global state map, if it's new. InputSource.fromJSON(actionState, actionSequence); const sequence = new this(); for (const actionItem of actionsFromSequence) { sequence.push(await Action.fromJSON(type, id, actionItem, options)); } return sequence; } } /** * Representation of an input event. * * @param {object} [options={}] * @param {boolean} [options.altKey] - If set to `true`, the Alt key will be * considered pressed. * @param {boolean} [options.ctrlKey] - If set to `true`, the Ctrl key will be * considered pressed. * @param {boolean} [options.metaKey] - If set to `true`, the Meta key will be * considered pressed. * @param {boolean} [options.shiftKey] - If set to `true`, the Shift key will be * considered pressed. */ class InputEventData { constructor(options = {}) { const { altKey, ctrlKey, metaKey, shiftKey } = options; this.altKey = altKey; this.ctrlKey = ctrlKey; this.metaKey = metaKey; this.shiftKey = shiftKey; } /** * Update the input data based on global and input state */ update(state) { for (const [, otherInputSource] of state.inputSourcesByType("key")) { // set modifier properties based on whether any corresponding keys are // pressed on any key input source this.altKey = otherInputSource.alt || this.altKey; this.ctrlKey = otherInputSource.ctrl || this.ctrlKey; this.metaKey = otherInputSource.meta || this.metaKey; this.shiftKey = otherInputSource.shift || this.shiftKey; } } toString() { return `${this.constructor.name} ${JSON.stringify(this)}`; } } /** * Representation of a key input event. */ class KeyEventData extends InputEventData { /** * Creates a new {@link KeyEventData} instance. * * @param {string} rawKey * The key value. */ constructor(rawKey) { super(); const { key, code, location, printable } = lazy.keyData.getData(rawKey); this.key = key; this.code = code; this.location = location; this.printable = printable; this.repeat = false; // keyCode will be computed by event.sendKeyDown } update(state, inputSource) { this.altKey = inputSource.alt; this.shiftKey = inputSource.shift; this.ctrlKey = inputSource.ctrl; this.metaKey = inputSource.meta; } } /** * Representation of a pointer input event. */ class PointerEventData extends InputEventData { /** * Creates a new {@link PointerEventData} instance. * * @param {string} type * The event type. */ constructor(type) { super(); this.type = type; this.buttons = 0; } update(state, inputSource) { // set modifier properties based on whether any corresponding keys are // pressed on any key input source for (const [, otherInputSource] of state.inputSourcesByType("key")) { this.altKey = otherInputSource.alt || this.altKey; this.ctrlKey = otherInputSource.ctrl || this.ctrlKey; this.metaKey = otherInputSource.meta || this.metaKey; this.shiftKey = otherInputSource.shift || this.shiftKey; } const allButtons = Array.from(inputSource.pressed); this.buttons = allButtons.reduce( (a, i) => a + PointerEventData.getButtonFlag(i), 0 ); } /** * Return a flag for buttons which indicates a button is pressed. * * @param {integer} button * The mouse button number. */ static getButtonFlag(button) { switch (button) { case 1: return 4; case 2: return 2; default: return Math.pow(2, button); } } } /** * Representation of a mouse input event. */ class MouseEventData extends PointerEventData { /** * Creates a new {@link MouseEventData} instance. * * @param {string} type * The event type. * @param {object=} options * @param {number=} options.button * The number of the mouse button. Defaults to 0. */ constructor(type, options = {}) { super(type); const { button = 0 } = options; this.button = button; this.buttons = 0; // Some WPTs try to synthesize DnD only with mouse events. However, // Gecko waits DnD events directly and non-WPT-tests use Gecko specific // test API to synthesize DnD. Therefore, we want new path only for // synthesized events coming from the webdriver. this.allowToHandleDragDrop = true; } update(state, inputSource) { super.update(state, inputSource); this.id = inputSource.pointer.id; } } /** * Representation of a wheel input event. */ class WheelEventData extends InputEventData { /** * Creates a new {@link WheelEventData} instance. * * @param {object} [options={}] * @param {number} [options.deltaX=0] - Floating-point value in CSS pixels to * scroll in the x direction. * @param {number} [options.deltaY=0] - Floating-point value in CSS pixels to * scroll in the y direction. * * @see event.synthesizeWheelAtPoint * @see InputEventData */ constructor(options) { super(options); const { deltaX, deltaY } = options; this.deltaX = deltaX; this.deltaY = deltaY; this.deltaZ = 0; } } /** * Representation of a multi touch event. */ class MultiTouchEventData extends PointerEventData { #setGlobalState; /** * Creates a new {@link MultiTouchEventData} instance. * * @param {string} type * The event type. */ constructor(type) { super(type); this.id = []; this.x = []; this.y = []; this.rx = []; this.ry = []; this.angle = []; this.force = []; this.tiltx = []; this.tilty = []; this.twist = []; this.#setGlobalState = false; } /** * Add the data from one pointer to the event. * * @param {InputSource} inputSource * The state of the pointer. * @param {PointerAction} action * Action for the pointer. */ addPointerEventData(inputSource, action) { this.x.push(inputSource.x); this.y.push(inputSource.y); this.id.push(inputSource.pointer.id); this.rx.push(action.width || 1); this.ry.push(action.height || 1); this.angle.push(0); this.force.push(action.pressure || (this.type === "touchend" ? 0 : 1)); this.tiltx.push(action.tiltX || 0); this.tilty.push(action.tiltY || 0); this.twist.push(action.twist || 0); } update(state, inputSource) { // We call update once per input source, but only want to update global state once. // Instead of introducing a new lifecycle method, or changing the API to allow multiple // input sources in a single call, use a small bit of state to avoid repeatedly setting // global state. if (!this.#setGlobalState) { // set modifier properties based on whether any corresponding keys are // pressed on any key input source for (const [, otherInputSource] of state.inputSourcesByType("key")) { this.altKey = otherInputSource.alt || this.altKey; this.ctrlKey = otherInputSource.ctrl || this.ctrlKey; this.metaKey = otherInputSource.meta || this.metaKey; this.shiftKey = otherInputSource.shift || this.shiftKey; } this.#setGlobalState = true; } // Note that we currently emit Touch events that don't have this property // but pointer events should have a `buttons` property, so we'll compute it // anyway. const allButtons = Array.from(inputSource.pressed); this.buttons = this.buttons | allButtons.reduce((a, i) => a + PointerEventData.getButtonFlag(i), 0); } } // Helpers /** * Assert that target is in the viewport of win. * * @param {Array} target * Coordinates [x, y] of the target relative to the viewport. * @param {WindowProxy} win * The target window. * * @throws {MoveTargetOutOfBoundsError} * If target is outside the viewport. */ export function assertTargetInViewPort(target, win) { const [x, y] = target; lazy.assert.number( x, lazy.pprint`Expected "x" to be finite number, got ${x}` ); lazy.assert.number( y, lazy.pprint`Expected "y" to be finite number, got ${y}` ); // Viewport includes scrollbars if rendered. if (x < 0 || y < 0 || x > win.innerWidth || y > win.innerHeight) { throw new lazy.error.MoveTargetOutOfBoundsError( `Move target (${x}, ${y}) is out of bounds of viewport dimensions ` + `(${win.innerWidth}, ${win.innerHeight})` ); } }