diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
commit | 36d22d82aa202bb199967e9512281e9a53db42c9 (patch) | |
tree | 105e8c98ddea1c1e4784a60a5a6410fa416be2de /remote/shared/webdriver | |
parent | Initial commit. (diff) | |
download | firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.tar.xz firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.zip |
Adding upstream version 115.7.0esr.upstream/115.7.0esr
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'remote/shared/webdriver')
17 files changed, 7446 insertions, 0 deletions
diff --git a/remote/shared/webdriver/Actions.sys.mjs b/remote/shared/webdriver/Actions.sys.mjs new file mode 100644 index 0000000000..e0d96a1ece --- /dev/null +++ b/remote/shared/webdriver/Actions.sys.mjs @@ -0,0 +1,2137 @@ +/* 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 no-dupe-keys:off */ +/* eslint-disable no-restricted-globals */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AppInfo: "chrome://remote/content/shared/AppInfo.sys.mjs", + assert: "chrome://remote/content/shared/webdriver/Assert.sys.mjs", + element: "chrome://remote/content/marionette/element.sys.mjs", + error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs", + event: "chrome://remote/content/marionette/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", +}); + +XPCOMUtils.defineLazyGetter(lazy, "logger", () => + lazy.Log.get(lazy.Log.TYPES.MARIONETTE) +); + +// 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 + * virtualised device input to the web browser. + * + * Typical usage is to construct an action chain and then dispatch it: + * const state = new action.State(); + * const chain = new action.Chain.fromJSON(state, protocolData); + * await chain.dispatch(state, window); + * + * @namespace + */ +export const action = {}; + +/** Map from normalized key value to UI Events modifier key name */ +const MODIFIER_NAME_LOOKUP = { + Alt: "alt", + Shift: "shift", + Control: "ctrl", + Meta: "meta", +}; + +/** + * State associated with actions + * + * Typically each top-level browsing context in a session should have a single State object + */ +action.State = class { + constructor(options = {}) { + const { specCompatPointerOrigin = true } = options; + + /** Flag for WebDriver spec conforming pointer origin calculation. */ + this.specCompatPointerOrigin = specCompatPointerOrigin; + + /** + * 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(); + } + + toString() { + return `[object ${this.constructor.name} ${JSON.stringify(this)}]`; + } + + /** + * Reset state stored in this object. + * It is an error to use the State object after calling release(). + * + * @param {WindowProxy} win Current window global. + */ + async release(win) { + this.inputsToCancel.reverse(); + await this.inputsToCancel.dispatch(this, win); + lazy.event.DoubleClickTracker.resetClick(); + } + + /** + * Get the state for a given input source. + * + * @param {string} id Input source id. + * @returns {InputSource} Input source state. + */ + 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 Input source id. + * @param {InputSource} newInputSource Input source state. + */ + 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 Input source type name (e.g. "pointer"). + * @returns {Iterator} Iterator over [id, 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 Pointer id. + * @param {string} type Pointer type. + * @returns {number} 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; + } +}; + +/** + * Device state for an input source. + */ +class InputSource { + #id; + static type = null; + + constructor(id) { + this.#id = id; + this.type = this.constructor.type; + } + + toString() { + return `[object ${this.constructor.name} id: ${this.#id} type: ${ + this.type + }]`; + } + + /** + * @param {State} state Actions state. + * @param {Sequence} actionSequence Actions for a specific input source. + * + * @returns {InputSource} + * An {@link InputSource} object for the type of the + * {@link actionSequence}. + * + * @throws {InvalidArgumentError} + * If {@link actionSequence.type} is not valid. + */ + static fromJSON(state, 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`Unknown action type: ${type}` + ); + } + + const sequenceInputSource = cls.fromJSON(state, actionSequence); + const inputSource = state.getOrAddInputSource(id, sequenceInputSource); + if (inputSource.type !== type) { + throw new lazy.error.InvalidArgumentError( + `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"; + + static fromJSON(state, actionSequence) { + const { id } = actionSequence; + return new this(id); + } +} + +/** + * Input state associated with a keyboard-type device. + */ +class KeyInputSource extends InputSource { + static type = "key"; + + constructor(id) { + super(id); + this.pressed = new Set(); + this.alt = false; + this.shift = false; + this.ctrl = false; + this.meta = false; + } + + static fromJSON(state, 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( + "Expected 'key' to be one of " + + Object.keys(MODIFIER_NAME_LOOKUP) + + lazy.pprint`, 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"; + + /** + * @param {string} id InputSource id. + * @param {Pointer} pointer Object representing the specific 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); + return this.pressed.has(button); + } + + /** + * Add |button| to the set of pressed keys. + * + * @param {number} button + * Positive integer that refers to a mouse button. + * + * @returns {Set} + * Set of pressed buttons. + */ + press(button) { + lazy.assert.positiveInteger(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); + return this.pressed.delete(button); + } + + static fromJSON(state, actionSequence) { + const { id, parameters } = actionSequence; + + const pointerType = parameters?.pointerType ?? "mouse"; + const pointerId = state.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"; + + static fromJSON(state, 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. + * + * @param {State} state - Actions state. + * @param {InputSource} inputSource - State of current input device. + * @param {WindowProxy} win - Current window global + */ + getOriginCoordinates(state, inputSource, win) { + throw new Error( + `originCoordinates not defined for ${this.constructor.name}` + ); + } + + /** + * Convert [x, y] coordinates to viewport coordinates + * + * @param {State} state - Actions state + * @param {InputSource} inputSource - State of the current input device + * @param {Array<number>} coords - [x, y] coordinate of target relative to origin + * @param {WindowProxy} win - Current window global + */ + getTargetCoordinates(state, inputSource, coords, win) { + const [x, y] = coords; + const origin = this.getOriginCoordinates(state, inputSource, win); + return [origin.x + x, origin.y + y]; + } + + /** + * @param {Element|string=} origin - Type of orgin, one of "viewport", "pointer", element or undefined. + * + * @returns {Origin} - An origin object representing the origin. + * + * @throws {InvalidArgumentError} + * If <code>origin</code> isn't a valid origin. + */ + static fromJSON(origin) { + if (origin === undefined || origin === "viewport") { + return new ViewportOrigin(); + } + if (origin === "pointer") { + return new PointerOrigin(); + } + if (lazy.element.isElement(origin)) { + return new ElementOrigin(origin); + } + + throw new lazy.error.InvalidArgumentError( + `Expected 'origin' to be undefined, "viewport", "pointer", ` + + lazy.pprint`or an element, got: ${origin}` + ); + } +} + +class ViewportOrigin extends Origin { + getOriginCoordinates(state, inputSource, win) { + return { x: 0, y: 0 }; + } +} + +class PointerOrigin extends Origin { + getOriginCoordinates(state, inputSource, win) { + return { x: inputSource.x, y: inputSource.y }; + } +} + +class ElementOrigin extends Origin { + /** + * @param {Element} element - The element providing the coordinate origin. + */ + constructor(element) { + super(); + this.element = element; + } + + getOriginCoordinates(state, inputSource, win) { + if (state.specCompatPointerOrigin) { + const clientRects = this.element.getClientRects(); + // The spec doesn't handle this case; https://github.com/w3c/webdriver/issues/1642 + if (!clientRects.length) { + throw new lazy.error.MoveTargetOutOfBoundsError( + `Origin element is not displayed` + ); + } + return lazy.element.getInViewCentrePoint(clientRects[0], win); + } + return lazy.element.coordinates(this.element); + } +} + +/** + * Repesents the behaviour of a single input source at a single + * point in time. + * + * @param {string} id - Input source ID. + */ +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; + + 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. + * + * @param {State} state - Actions state. + * @param {InputSource} inputSource - State of the current input device. + * @param {number} tickDuration - Length of the current tick, in ms. + * @param {WindowProxy} win - Current window global. + * @returns {Promise} - Promise that is resolved once the action is complete. + */ + dispatch(state, inputSource, tickDuration, win) { + throw new Error( + `Action subclass ${this.constructor.name} must override dispatch()` + ); + } + + /** + * @param {string} type - Input source type. + * @param {string} id - Input source id. + * @param {object} actionItem - Object representing a single action. + * + * @returns {Action} - An action that can be dispatched. + * + * @throws {InvalidArgumentError} + * If any <code>actionSequence</code> or <code>actionItem</code> + * attributes are invalid. + */ + static fromJSON(type, id, actionItem) { + const subtype = actionItem.type; + const subtypeMap = actionTypes.get(type); + if (subtypeMap === undefined) { + throw new lazy.error.InvalidArgumentError(`Unknown action type: ${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( + `Unknown subtype ${subtype} for type ${type}` + ); + } + return cls.fromJSON(id, actionItem); + } +} + +/** + * Action not associated with a specific input device. + */ +class NullAction extends Action { + static type = "none"; +} + +/** + * Action that waits for a given duration. + * + * @param {string} id - Input source ID. + * @param {object} options - Named arguments. + * @param {number} options.duration - Time to pause, in ms. + */ +class PauseAction extends NullAction { + static subtype = "pause"; + affectsWallClockTime = true; + + constructor(id, options) { + super(id); + const { duration } = options; + this.duration = duration; + } + + /** + * Dispatch pause action + * + * @param {State} state - Actions state. + * @param {InputSource} inputSource - State of the current input device. + * @param {number} tickDuration - Length of the current tick, in ms. + * @param {WindowProxy} win - Current window global. + * @returns {Promise} - Promise that is resolved once the action is complete. + */ + dispatch(state, inputSource, tickDuration, win) { + const ms = this.duration ?? tickDuration; + lazy.logger.trace( + ` Dispatch ${this.constructor.name} with ${this.id} ${ms}` + ); + return lazy.Sleep(ms); + } + + static fromJSON(id, actionItem) { + const { duration } = actionItem; + if (duration !== undefined) { + lazy.assert.positiveInteger( + duration, + lazy.pprint`Expected 'duration' (${duration}) to be >= 0` + ); + } + return new this(id, { duration }); + } +} + +/** + * Action associated with a keyboard input device + * + * @param {string} id - Input source ID. + * @param {object} options - Named arguments. + * @param {string} options.value - Key character. + */ +class KeyAction extends Action { + static type = "key"; + + 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); + } + + static fromJSON(id, actionItem) { + // TODO countGraphemes + // TODO key.value could be a single code point like "\uE012" + // (see rawKey) or "grapheme cluster" + // https://bugzilla.mozilla.org/show_bug.cgi?id=1496323 + const value = actionItem.value; + lazy.assert.string( + value, + "Expected 'value' to be a string that represents single code point " + + lazy.pprint`or grapheme cluster, got ${value}` + ); + return new this(id, { value }); + } +} + +/** + * Action equivalent to pressing a key on a keyboard. + * + * @param {string} id - Input source ID. + * @param {string} value - Key character. + */ +class KeyDownAction extends KeyAction { + static subtype = "keyDown"; + + dispatch(state, inputSource, tickDuration, win) { + lazy.logger.trace( + `Dispatch ${this.constructor.name} with ${this.id} ${this.value}` + ); + return new Promise(resolve => { + 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); + } + + // Append a copy of |a| with keyUp subtype + state.inputsToCancel.push(new KeyUpAction(this.id, this)); + keyEvent.update(state, inputSource); + lazy.event.sendKeyDown(keyEvent, win); + + resolve(); + }); + } +} + +/** + * Action equivalent to releasing a key on a keyboard. + * + * @param {string} id - Input source ID. + * @param {string} value - Key character. + */ +class KeyUpAction extends KeyAction { + static subtype = "keyUp"; + + dispatch(state, inputSource, tickDuration, win) { + lazy.logger.trace( + `Dispatch ${this.constructor.name} with ${this.id} ${this.value}` + ); + return new Promise(resolve => { + const keyEvent = this.getEventData(inputSource); + if (!inputSource.isPressed(keyEvent.key)) { + resolve(); + return; + } + if (keyEvent.key in MODIFIER_NAME_LOOKUP) { + inputSource.setModState(keyEvent.key, false); + } + inputSource.release(keyEvent.key); + keyEvent.update(state, inputSource); + + lazy.event.sendKeyUp(keyEvent, win); + resolve(); + }); + } +} + +/** + * Action associated with a pointer input device + * + * @param {string} id - Input source ID. + * @param {object} options - Named arguments. + * @param {number=} options.width - Pointer width in pixels. + * @param {number=} options.height - Pointer height in pixels. + * @param {number=} options.pressure - Pointer pressure. + * @param {number=} options.tangentialPressure - Pointer tangential pressure. + * @param {number=} options.tiltX - Pointer X tilt angle. + * @param {number=} options.tiltX - Pointer Y tilt angle. + * @param {number=} options.twist - Pointer twist angle. + * @param {number=} options.altitudeAngle - Pointer altitude angle. + * @param {number=} options.azimuthAngle - Pointer azimuth angle. + */ +class PointerAction extends Action { + static type = "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 action. + */ + 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' (${width}) to be >= 0` + ); + } + if (height !== undefined) { + lazy.assert.positiveInteger( + height, + lazy.pprint`Expected 'height' (${height}) to be >= 0` + ); + } + if (pressure !== undefined) { + lazy.assert.numberInRange( + pressure, + [0, 1], + lazy.pprint`Expected 'pressure' (${pressure}) to be in range 0 to 1` + ); + } + if (tangentialPressure !== undefined) { + lazy.assert.numberInRange( + tangentialPressure, + [-1, 1], + lazy.pprint`Expected 'tangentialPressure' (${tangentialPressure}) to be in range -1 to 1` + ); + } + if (tiltX !== undefined) { + lazy.assert.integerInRange( + tiltX, + [-90, 90], + lazy.pprint`Expected 'tiltX' (${tiltX}) to be in range -90 to 90` + ); + } + if (tiltY !== undefined) { + lazy.assert.integerInRange( + tiltY, + [-90, 90], + lazy.pprint`Expected 'tiltY' (${tiltY}) to be in range -90 to 90` + ); + } + if (twist !== undefined) { + lazy.assert.integerInRange( + twist, + [0, 359], + lazy.pprint`Expected 'twist' (${twist}) to be in range 0 to 359` + ); + } + if (altitudeAngle !== undefined) { + lazy.assert.numberInRange( + altitudeAngle, + [0, Math.PI / 2], + lazy.pprint`Expected 'altitudeAngle' (${altitudeAngle}) to be in range 0 to ${ + Math.PI / 2 + }` + ); + } + if (azimuthAngle !== undefined) { + lazy.assert.numberInRange( + azimuthAngle, + [0, 2 * Math.PI], + lazy.pprint`Expected 'azimuthAngle' (${azimuthAngle}) to be in range 0 to ${ + 2 * Math.PI + }` + ); + } + return { + width, + height, + pressure, + tangentialPressure, + tiltX, + tiltY, + twist, + altitudeAngle, + azimuthAngle, + }; + } +} + +/** + * Action associated with a pointer input device being depressed. + * + * @param {string} id - Input source ID. + * @param {object} options - Named arguments. + * @param {number} options.button - Button being pressed. For devices without buttons (e.g. touch), this should be 0. + * @param {number=} options.width - Pointer width in pixels. + * @param {number=} options.height - Pointer height in pixels. + * @param {number=} options.pressure - Pointer pressure. + * @param {number=} options.tangentialPressure - Pointer tangential pressure. + * @param {number=} options.tiltX - Pointer X tilt angle. + * @param {number=} options.tiltX - Pointer Y tilt angle. + * @param {number=} options.twist - Pointer twist angle. + * @param {number=} options.altitudeAngle - Pointer altitude angle. + * @param {number=} options.azimuthAngle - Pointer azimuth angle. + */ +class PointerDownAction extends PointerAction { + static subtype = "pointerDown"; + + constructor(id, options) { + super(id, options); + const { button } = options; + this.button = button; + } + + dispatch(state, inputSource, tickDuration, win) { + lazy.logger.trace( + `Dispatch ${this.constructor.name} ${inputSource.pointer.type} with id: ${this.id} button: ${this.button}` + ); + return new Promise(resolve => { + if (inputSource.isPressed(this.button)) { + resolve(); + return; + } + + inputSource.press(this.button); + // Append a copy of |a| with pointerUp subtype + state.inputsToCancel.push(new PointerUpAction(this.id, this)); + inputSource.pointer.pointerDown(state, inputSource, this, win); + resolve(); + }); + } + + static fromJSON(id, actionItem) { + const props = PointerAction.validateCommon(actionItem); + const { button } = actionItem; + lazy.assert.positiveInteger( + button, + lazy.pprint`Expected 'button' (${button}) to be >= 0` + ); + props.button = button; + return new this(id, props); + } +} + +/** + * Action associated with a pointer input device being released. + * + * @param {string} id - Input source ID. + * @param {object} options - Named arguments. + * @param {number} options.button - Button being released. For devices without buttons (e.g. touch), this should be 0. + * @param {number=} options.width - Pointer width in pixels. + * @param {number=} options.height - Pointer height in pixels. + * @param {number=} options.pressure - Pointer pressure. + * @param {number=} options.tangentialPressure - Pointer tangential pressure. + * @param {number=} options.tiltX - Pointer X tilt angle. + * @param {number=} options.tiltX - Pointer Y tilt angle. + * @param {number=} options.twist - Pointer twist angle. + * @param {number=} options.altitudeAngle - Pointer altitude angle. + * @param {number=} options.azimuthAngle - Pointer azimuth angle. + */ +class PointerUpAction extends PointerAction { + static subtype = "pointerUp"; + + constructor(id, options) { + super(id, options); + const { button } = options; + this.button = button; + } + + dispatch(state, inputSource, tickDuration, win) { + lazy.logger.trace( + `Dispatch ${this.constructor.name} ${inputSource.pointer.type} with id: ${this.id} button: ${this.button}` + ); + return new Promise(resolve => { + if (!inputSource.isPressed(this.button)) { + resolve(); + return; + } + + inputSource.release(this.button); + inputSource.pointer.pointerUp(state, inputSource, this, win); + + resolve(); + }); + } + + static fromJSON(id, actionItem) { + const props = PointerAction.validateCommon(actionItem); + const { button } = actionItem; + lazy.assert.positiveInteger( + button, + lazy.pprint`Expected 'button' (${button}) to be >= 0` + ); + props.button = button; + return new this(id, props); + } +} + +/** + * Action associated with a pointer input device being moved. + * + * @param {string} id - Input source ID. + * @param {object} options - Named arguments. + * @param {number=} options.width - Pointer width in pixels. + * @param {number=} options.height - Pointer height in pixels. + * @param {number=} options.pressure - Pointer pressure. + * @param {number=} options.tangentialPressure - Pointer tangential pressure. + * @param {number=} options.tiltX - Pointer X tilt angle. + * @param {number=} options.tiltX - Pointer Y tilt angle. + * @param {number=} options.twist - Pointer twist angle. + * @param {number=} options.altitudeAngle - Pointer altitude angle. + * @param {number=} options.azimuthAngle - Pointer azimuth angle. + * @param {number=} options.duration - Duration of move in ms. + * @param {Origin} options.origin - Origin of target coordinates. + * @param {number} options.x - X value of target coordinates. + * @param {number} options.y - Y value of target coordinates. + */ +class PointerMoveAction extends PointerAction { + static subtype = "pointerMove"; + affectsWallClockTime = true; + + 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(state, inputSource, tickDuration, win) { + lazy.logger.trace( + `Dispatch ${this.constructor.name} ${inputSource.pointer.type} with id: ${this.id} x: ${this.x} y: ${this.y}` + ); + const target = this.origin.getTargetCoordinates( + state, + inputSource, + [this.x, this.y], + win + ); + + assertInViewPort(target, win); + + return moveOverTime( + [[inputSource.x, inputSource.y]], + [target], + this.duration ?? tickDuration, + target => this.performPointerMoveStep(state, inputSource, target, win) + ); + } + + /** + * Perform one part of a pointer move corresponding to a specific emitted event. + * + * @param {State} state - Actions state. + * @param {InputSource} inputSource - State of the current input device. + * @param {Array<Array<number>>} targets - Array of [x, y] arrays + * specifying the viewport coordinates to move to. + * @param {WindowProxy} win - Current window global. + */ + performPointerMoveStep(state, inputSource, targets, win) { + 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; + } + + inputSource.pointer.pointerMove( + state, + inputSource, + this, + target[0], + target[1], + win + ); + + inputSource.x = target[0]; + inputSource.y = target[1]; + } + + static fromJSON(id, actionItem) { + const props = PointerAction.validateCommon(actionItem); + const { duration, origin, x, y } = actionItem; + if (duration !== undefined) { + lazy.assert.positiveInteger( + duration, + lazy.pprint`Expected 'duration' (${duration}) to be >= 0` + ); + } + const originObject = Origin.fromJSON(origin); + lazy.assert.integer(x, lazy.pprint`Expected 'x' (${x}) to be an Integer`); + lazy.assert.integer(y, lazy.pprint`Expected 'y' (${y}) to be an Integer`); + 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 + * + * @param {number} duration - Duration of scroll in ms. + * @param {Origin} origin - Origin of target coordinates. + * @param {number} x - X value of scroll coordinates. + * @param {number} y - Y value of scroll coordinates. + * @param {number} deltaX - Number of CSS pixels to scroll in X direction. + * @param {number} deltaY - Number of CSS pixels to scroll in Y direction + */ +class WheelScrollAction extends WheelAction { + static subtype = "scroll"; + affectsWallClockTime = true; + + constructor(id, { duration, origin, x, y, deltaX, deltaY }) { + super(id); + this.duration = duration; + this.origin = origin; + this.x = x; + this.y = y; + this.deltaX = deltaX; + this.deltaY = deltaY; + } + + static fromJSON(id, actionItem) { + const { duration, origin, x, y, deltaX, deltaY } = actionItem; + if (duration !== undefined) { + lazy.assert.positiveInteger( + duration, + lazy.pprint`Expected 'duration' (${duration}) to be >= 0` + ); + } + const originObject = Origin.fromJSON(origin); + lazy.assert.integer(x, lazy.pprint`Expected 'x' (${x}) to be an Integer`); + lazy.assert.integer(y, lazy.pprint`Expected 'y' (${y}) to be an Integer`); + lazy.assert.integer( + deltaX, + lazy.pprint`Expected 'deltaX' (${deltaX}) to be an Integer` + ); + lazy.assert.integer( + deltaY, + lazy.pprint`Expected 'deltaY' (${deltaY}) to be an Integer` + ); + + return new this(id, { + duration, + origin: originObject, + x, + y, + deltaX, + deltaY, + }); + } + + async dispatch(state, inputSource, tickDuration, win) { + lazy.logger.trace( + `Dispatch ${this.constructor.name} with id: ${this.id} deltaX: ${this.deltaX} deltaY: ${this.deltaY}` + ); + const scrollCoordinates = this.origin.getTargetCoordinates( + state, + inputSource, + [this.x, this.y], + win + ); + assertInViewPort(scrollCoordinates, win); + + const startX = 0; + const startY = 0; + // This is an action-local state that holds the amount of scroll completed + const deltaPosition = [startX, startY]; + await moveOverTime( + [[startX, startY]], + [[this.deltaX, this.deltaY]], + this.duration ?? tickDuration, + deltaTarget => + this.performOneWheelScroll( + scrollCoordinates, + deltaPosition, + deltaTarget, + win + ) + ); + } + + /** + * Perform one part of a wheel scroll corresponding to a specific emitted event. + * + * @param {Array<number>} scrollCoordinates - [x, y] viewport coordinates of the scroll. + * @param {Array<number>} deltaPosition - [deltaX, deltaY] coordinates of the scroll before this event. + * @param {Array<Array<number>>} deltaTargets - Array of [deltaX, deltaY] coordinates to scroll to. + * @param {WindowProxy} win - Current window global. + */ + performOneWheelScroll(scrollCoordinates, deltaPosition, deltaTargets, win) { + 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, + }); + lazy.event.synthesizeWheelAtPoint( + scrollCoordinates[0], + scrollCoordinates[1], + eventData, + win + ); + + // Update the current scroll position for the caller + deltaPosition[0] = deltaTarget[0]; + deltaPosition[1] = deltaTarget[1]; + } +} + +/** + * Group of actions representing behaviour 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; + + 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 - State of the 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. + * + * @param {State} state - Actions state. + * @param {null} inputSource + * This is always null; the argument only exists for compatibility + * with {@link Action.dispatch}. + * @param {number} tickDuration - Length of the current tick, in ms. + * @param {WindowProxy} win - Current window global. + * @returns {Promise} - Promise that is resolved once the action is complete. + */ + dispatch(state, inputSource, tickDuration, win) { + throw new Error( + "TouchActionGroup subclass missing dispatch implementation" + ); + } +} + +/** + * Group of actions representing behaviour of all touch pointers + * depressed during a single tick. + */ +class PointerDownTouchActionGroup extends TouchActionGroup { + static type = "pointerDown"; + + dispatch(state, inputSource, tickDuration, win) { + lazy.logger.trace( + `Dispatch ${this.constructor.name} with ${Array.from( + this.actions.values() + ).map(x => x[1].id)}` + ); + return new Promise(resolve => { + if (inputSource !== null) { + throw new Error( + "Expected null inputSource for PointerDownTouchActionGroup.dispatch" + ); + } + + // Only include pointers that are not already depressed + const actions = Array.from(this.actions.values()).filter( + ([actionInputSource, action]) => + !actionInputSource.isPressed(action.button) + ); + if (actions.length) { + const eventData = new MultiTouchEventData("touchstart"); + for (const [actionInputSource, action] of actions) { + // Skip if already pressed + eventData.addPointerEventData(actionInputSource, action); + actionInputSource.press(action.button); + // Append a copy of |action| with pointerUp subtype + state.inputsToCancel.push(new PointerUpAction(action.id, action)); + 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); + } + } + lazy.event.synthesizeMultiTouch(eventData, win); + } + resolve(); + }); + } +} + +/** + * Group of actions representing behaviour of all touch pointers + * released during a single tick. + */ +class PointerUpTouchActionGroup extends TouchActionGroup { + static type = "pointerUp"; + + dispatch(state, inputSource, tickDuration, win) { + lazy.logger.trace( + `Dispatch ${this.constructor.name} with ${Array.from( + this.actions.values() + ).map(x => x[1].id)}` + ); + return new Promise(resolve => { + if (inputSource !== null) { + throw new Error( + "Expected null inputSource for PointerUpTouchActionGroup.dispatch" + ); + } + + // Only include pointers that are not already depressed + const actions = Array.from(this.actions.values()).filter( + ([actionInputSource, action]) => + actionInputSource.isPressed(action.button) + ); + if (actions.length) { + const eventData = new MultiTouchEventData("touchend"); + for (const [actionInputSource, action] of actions) { + eventData.addPointerEventData(actionInputSource, action); + actionInputSource.release(action.button); + eventData.update(state, actionInputSource); + } + lazy.event.synthesizeMultiTouch(eventData, win); + } + resolve(); + }); + } +} + +/** + * Group of actions representing behaviour of all touch pointers + * moved during a single tick. + */ +class PointerMoveTouchActionGroup extends TouchActionGroup { + static type = "pointerMove"; + + dispatch(state, inputSource, tickDuration, win) { + 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 = action.origin.getTargetCoordinates( + state, + actionInputSource, + [action.x, action.y], + win + ); + assertInViewPort(target, win); + 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, + currentTargetCoords => + this.performPointerMoveStep( + state, + staticTouchPointers, + currentTargetCoords, + win + ) + ); + } + + /** + * Perform one part of a pointer move corresponding to a specific emitted event. + * + * @param {State} state - Actions state. + * @param {Array.<PointerInputSource>} staticTouchPointers + * Array of PointerInputSource objects for pointers that aren't involved in + * the touch move. + * @param {Array.<Array>} targetCoords + * Array of [x, y] arrays specifying the viewport coordinates to move to. + * @param {WindowProxy} win - Current window global. + */ + performPointerMoveStep(state, staticTouchPointers, targetCoords, win) { + 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, action, 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); + } + lazy.event.synthesizeMultiTouch(eventData, win); + } +} + +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.<Array>} startCoords + * Array of initial [x, y] coordinates for each input source involved + * in the move. + * @param {Array.<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 + 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]), + ]; + }); + callback(intermediateTargets); + // wait |fps60| ms before performing next transition + await new Promise(resolveTimer => + timer.initWithCallback(resolveTimer, fps60, ONE_SHOT) + ); + + durationRatio = Math.floor(Date.now() - startTime) / duration; + } + })(); + + await transitions; + + // perform last transitionafter all incremental moves are resolved and + // durationRatio is close enough to 1 + 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 behaviour of a specific type of pointer + */ +class Pointer { + /** Type of pointer */ + static type = null; + + constructor(id) { + this.id = id; + this.type = this.constructor.type; + } + + /** + * Implementation of depressing the pointer. + * + * @param {State} state - Actions state. + * @param {InputSource} inputSource - State of the current input device. + * @param {Action} action - The Action object invoking the pointer + * @param {WindowProxy} win - Current window global. + */ + pointerDown(state, inputSource, action, win) { + throw new Error(`Unimplemented pointerDown for pointerType ${this.type}`); + } + + /** + * Implementation of releasing the pointer. + * + * @param {State} state - Actions state. + * @param {InputSource} inputSource - State of the current input device. + * @param {Action} action - The Action object invoking the pointer + * @param {WindowProxy} win - Current window global. + */ + pointerUp(state, inputSource, action, win) { + throw new Error(`Unimplemented pointerUp for pointerType ${this.type}`); + } + + /** + * Implementation of moving the pointer. + * + * @param {State} state - Actions state. + * @param {InputSource} inputSource - State of the current input device. + * @param {number} targetX - Target X coordinate of the pointer move + * @param {number} targetY - Target Y coordinate of the pointer move + * @param {WindowProxy} win - Current window global. + */ + pointerMove(state, inputSource, targetX, targetY, win) { + throw new Error(`Unimplemented pointerMove for pointerType ${this.type}`); + } + + /** + * @param {number} pointerId - Numeric pointer id. + * @param {string} pointerType - Pointer type. + * @returns {Pointer} - 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( + `Unknown pointerType: ${pointerType}` + ); + } + return new cls(pointerId); + } +} + +/** + * Implementation of mouse pointer behaviour + */ +class MousePointer extends Pointer { + static type = "mouse"; + + pointerDown(state, inputSource, action, win) { + const mouseEvent = new MouseEventData("mousedown", { + button: action.button, + }); + mouseEvent.update(state, inputSource); + if (mouseEvent.ctrlKey) { + if (lazy.AppInfo.isMac) { + mouseEvent.button = 2; + lazy.event.DoubleClickTracker.resetClick(); + } + } else if (lazy.event.DoubleClickTracker.isClicked()) { + mouseEvent.clickCount = 2; + } + lazy.event.synthesizeMouseAtPoint( + inputSource.x, + inputSource.y, + mouseEvent, + win + ); + if ( + lazy.event.MouseButton.isSecondary(mouseEvent.button) || + (mouseEvent.ctrlKey && lazy.AppInfo.isMac) + ) { + const contextMenuEvent = { ...mouseEvent, type: "contextmenu" }; + lazy.event.synthesizeMouseAtPoint( + inputSource.x, + inputSource.y, + contextMenuEvent, + win + ); + } + } + + pointerUp(state, inputSource, action, win) { + const mouseEvent = new MouseEventData("mouseup", { + button: action.button, + }); + mouseEvent.update(state, inputSource); + if (lazy.event.DoubleClickTracker.isClicked()) { + mouseEvent.clickCount = 2; + } + lazy.event.synthesizeMouseAtPoint( + inputSource.x, + inputSource.y, + mouseEvent, + win + ); + } + + pointerMove(state, inputSource, action, targetX, targetY, win) { + const mouseEvent = new MouseEventData("mousemove"); + mouseEvent.update(state, inputSource); + lazy.event.synthesizeMouseAtPoint(targetX, targetY, mouseEvent, win); + } +} + +/* + * 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. + */ +action.Chain = class extends Array { + toString() { + return `[chain ${super.toString()}]`; + } + + /** + * Dispatch the action chain to the relevant window. + * + * @param {State} state - Actions state. + * @param {WindowProxy} win - Current window global. + * @returns {Promise} - Promise that is resolved once the action + * chain is complete. + */ + dispatch(state, win) { + let i = 1; + const chainEvents = (async () => { + for (const tickActions of this) { + lazy.logger.trace(`Dispatching tick ${i++}/${this.length}`); + await tickActions.dispatch(state, win); + } + })(); + return chainEvents; + } + + /** + * @param {State} state - Actions state. + * @param {Array.<object>} actions - Array of objects that each + * represent an action sequence. + * @returns {action.Chain} - Object that allows dispatching a chain + * of actions. + * @throws {InvalidArgumentError} - If actions doesn't correspond to + * a valid action chain. + */ + static fromJSON(state, actions) { + lazy.assert.array( + actions, + lazy.pprint`Expected 'actions' to be an array, got ${actions}` + ); + + const actionsByTick = new this(); + for (const actionSequence of actions) { + const inputSourceActions = Sequence.fromJSON(state, actionSequence); + 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; + } +}; + +/** + * 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 - Actions state. + * @param {WindowProxy} win - Current window global. + * + * @returns {Promise} - Promise that resolves when tick is complete. + */ + dispatch(state, win) { + const tickDuration = this.getDuration(); + const tickActions = this.groupTickActions(state); + const pendingEvents = tickActions.map(([inputSource, action]) => + action.dispatch(state, inputSource, tickDuration, win) + ); + 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 - Actions state. + * @returns {Array.<Array.<InputSource?,Action|TouchActionGroup>>} + * 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 TouuchActionGroup. + */ + groupTickActions(state) { + const touchActions = new Map(); + const actions = []; + 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); + actions.push([null, group]); + } + group.addPointer(inputSource, action); + } else { + actions.push([inputSource, action]); + } + } + return actions; + } +} + +/** + * Represents one input source action sequence; this is essentially an + * |Array.<Action>|. + * + * This is a temporary object only used when constructing an {@link + * action.Chain}. + */ +class Sequence extends Array { + toString() { + return `[sequence ${super.toString()}]`; + } + + /** + * @param {State} state - Actions state. + * @param {object} actionSequence + * Protocol representation of the actions for a specific input source. + * @returns {Array.<Array>} - Array of [InputSource?,Action|TouchActionGroup] + */ + static fromJSON(state, actionSequence) { + // used here to validate 'type' in addition to InputSource type below + const { id, type, actions } = actionSequence; + + // type and id get validated in InputSource.fromJSON + lazy.assert.array( + actions, + "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(state, actionSequence); + + const sequence = new this(); + for (const actionItem of actions) { + sequence.push(Action.fromJSON(type, id, actionItem)); + } + + return sequence; + } +} + +/** + * Representation of an input event + */ +class InputEventData { + constructor() { + this.altKey = false; + this.shiftKey = false; + this.ctrlKey = false; + this.metaKey = false; + } + + /** + * Update the input data based on global and input state + * + * @param {State} state - Actions state. + * @param {InputSource} inputSource - State of the current input device. + */ + update(state, inputSource) {} + + toString() { + return `${this.constructor.name} ${JSON.stringify(this)}`; + } +} + +/** + * Representation of a key input event + * + * @param {string} rawKey - Key value. + */ +class KeyEventData extends InputEventData { + 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 + * + * @param {string} type - Event type. + */ +class PointerEventData extends InputEventData { + 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 + Math.pow(2, i), 0); + } +} + +/** + * Representation of a mouse input event + * + * @param {string} type - Event type. + * @param {number} button - Mouse button number. + */ +class MouseEventData extends PointerEventData { + constructor(type, options = {}) { + super(type); + const { button = 0 } = options; + lazy.assert.positiveInteger(button); + this.button = button; + this.buttons = 0; + } + + update(state, inputSource) { + super.update(state, inputSource); + this.id = inputSource.pointer.id; + } +} + +/** + * Representation of a wheel scroll event + * + * @param {object} options - Named arguments. + * @param {number} options.deltaX - Scroll delta X. + * @param {number} options.deltaY - Scroll delta Y. + * @param {number} options.deltaY - Scroll delta Z (current always 0). + * @param {number=} deltaMode - Scroll delta mode (current always 0). + */ +class WheelEventData extends InputEventData { + constructor(options) { + super(); + const { deltaX, deltaY, deltaZ, deltaMode = 0 } = options; + this.deltaX = deltaX; + this.deltaY = deltaY; + this.deltaZ = deltaZ; + this.deltaMode = deltaMode; + } +} + +/** + * Representation of a multitouch event + * + * @param {string} type - Event type. + */ +class MultiTouchEventData extends PointerEventData { + #setGlobalState; + + 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 - 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 + Math.pow(2, i), 0); + } +} + +// helpers + +/** + * Assert that target is in the viewport of win. + * + * @param {Array.<number>} target - [x, y] coordinates of target + * relative to viewport. + * @param {WindowProxy} win - target window. + * @throws {MoveTargetOutOfBoundsError} - If target is outside the + * viewport. + */ +function assertInViewPort(target, win) { + const [x, y] = target; + lazy.assert.number(x, `Expected x to be finite number`); + lazy.assert.number(y, `Expected y to be finite number`); + // Viewport includes scrollbars if rendered. + if (x < 0 || y < 0 || x > win.innerWidth || y > win.innerHeight) { + throw new lazy.error.MoveTargetOutOfBoundsError( + `(${x}, ${y}) is out of bounds of viewport ` + + `width (${win.innerWidth}) ` + + `and height (${win.innerHeight})` + ); + } +} diff --git a/remote/shared/webdriver/Assert.sys.mjs b/remote/shared/webdriver/Assert.sys.mjs new file mode 100644 index 0000000000..6c254173aa --- /dev/null +++ b/remote/shared/webdriver/Assert.sys.mjs @@ -0,0 +1,489 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AppInfo: "chrome://remote/content/shared/AppInfo.sys.mjs", + error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs", + pprint: "chrome://remote/content/shared/Format.sys.mjs", +}); + +/** + * Shorthands for common assertions made in WebDriver. + * + * @namespace + */ +export const assert = {}; + +/** + * Asserts that WebDriver has an active session. + * + * @param {WebDriverSession} session + * WebDriver session instance. + * @param {string=} msg + * Custom error message. + * + * @throws {InvalidSessionIDError} + * If session does not exist, or has an invalid id. + */ +assert.session = function (session, msg = "") { + msg = msg || "WebDriver session does not exist, or is not active"; + assert.that( + session => session && typeof session.id == "string", + msg, + lazy.error.InvalidSessionIDError + )(session); +}; + +/** + * Asserts that the current browser is Firefox Desktop. + * + * @param {string=} msg + * Custom error message. + * + * @throws {UnsupportedOperationError} + * If current browser is not Firefox. + */ +assert.firefox = function (msg = "") { + msg = msg || "Only supported in Firefox"; + assert.that( + isFirefox => isFirefox, + msg, + lazy.error.UnsupportedOperationError + )(lazy.AppInfo.isFirefox); +}; + +/** + * Asserts that the current application is Firefox Desktop or Thunderbird. + * + * @param {string=} msg + * Custom error message. + * + * @throws {UnsupportedOperationError} + * If current application is not running on desktop. + */ +assert.desktop = function (msg = "") { + msg = msg || "Only supported in desktop applications"; + assert.that( + isDesktop => isDesktop, + msg, + lazy.error.UnsupportedOperationError + )(!lazy.AppInfo.isAndroid); +}; + +/** + * Asserts that the current application runs on Android. + * + * @param {string=} msg + * Custom error message. + * + * @throws {UnsupportedOperationError} + * If current application is not running on Android. + */ +assert.mobile = function (msg = "") { + msg = msg || "Only supported on Android"; + assert.that( + isAndroid => isAndroid, + msg, + lazy.error.UnsupportedOperationError + )(lazy.AppInfo.isAndroid); +}; + +/** + * Asserts that the current <var>context</var> is content. + * + * @param {string} context + * Context to test. + * @param {string=} msg + * Custom error message. + * + * @returns {string} + * <var>context</var> is returned unaltered. + * + * @throws {UnsupportedOperationError} + * If <var>context</var> is not content. + */ +assert.content = function (context, msg = "") { + msg = msg || "Only supported in content context"; + assert.that( + c => c.toString() == "content", + msg, + lazy.error.UnsupportedOperationError + )(context); +}; + +/** + * Asserts that the {@link CanonicalBrowsingContext} is open. + * + * @param {CanonicalBrowsingContext} browsingContext + * Canonical browsing context to check. + * @param {string=} msg + * Custom error message. + * + * @returns {CanonicalBrowsingContext} + * <var>browsingContext</var> is returned unaltered. + * + * @throws {NoSuchWindowError} + * If <var>browsingContext</var> is no longer open. + */ +assert.open = function (browsingContext, msg = "") { + msg = msg || "Browsing context has been discarded"; + return assert.that( + browsingContext => { + if (!browsingContext?.currentWindowGlobal) { + return false; + } + + if (browsingContext.isContent && !browsingContext.top.embedderElement) { + return false; + } + + return true; + }, + msg, + lazy.error.NoSuchWindowError + )(browsingContext); +}; + +/** + * Asserts that there is no current user prompt. + * + * @param {modal.Dialog} dialog + * Reference to current dialogue. + * @param {string=} msg + * Custom error message. + * + * @throws {UnexpectedAlertOpenError} + * If there is a user prompt. + */ +assert.noUserPrompt = function (dialog, msg = "") { + assert.that( + d => d === null || typeof d == "undefined", + msg, + lazy.error.UnexpectedAlertOpenError + )(dialog); +}; + +/** + * Asserts that <var>obj</var> is defined. + * + * @param {?} obj + * Value to test. + * @param {string=} msg + * Custom error message. + * + * @returns {?} + * <var>obj</var> is returned unaltered. + * + * @throws {InvalidArgumentError} + * If <var>obj</var> is not defined. + */ +assert.defined = function (obj, msg = "") { + msg = msg || lazy.pprint`Expected ${obj} to be defined`; + return assert.that(o => typeof o != "undefined", msg)(obj); +}; + +/** + * Asserts that <var>obj</var> is a finite number. + * + * @param {?} obj + * Value to test. + * @param {string=} msg + * Custom error message. + * + * @returns {number} + * <var>obj</var> is returned unaltered. + * + * @throws {InvalidArgumentError} + * If <var>obj</var> is not a number. + */ +assert.number = function (obj, msg = "") { + msg = msg || lazy.pprint`Expected ${obj} to be finite number`; + return assert.that(Number.isFinite, msg)(obj); +}; + +/** + * Asserts that <var>obj</var> is a positive number. + * + * @param {?} obj + * Value to test. + * @param {string=} msg + * Custom error message. + * + * @returns {number} + * <var>obj</var> is returned unaltered. + * + * @throws {InvalidArgumentError} + * If <var>obj</var> is not a positive integer. + */ +assert.positiveNumber = function (obj, msg = "") { + assert.number(obj, msg); + msg = msg || lazy.pprint`Expected ${obj} to be >= 0`; + return assert.that(n => n >= 0, msg)(obj); +}; + +/** + * Asserts that <var>obj</var> is a number in the inclusive range <var>lower</var> to <var>upper</var>. + * + * @param {?} obj + * Value to test. + * @param {Array<number>} range + * Array range [lower, upper] + * @param {string=} msg + * Custom error message. + * + * @returns {number} + * <var>obj</var> is returned unaltered. + * + * @throws {InvalidArgumentError} + * If <var>obj</var> is not a number in the specified range. + */ +assert.numberInRange = function (obj, range, msg = "") { + const [lower, upper] = range; + assert.number(obj, msg); + msg = msg || lazy.pprint`Expected ${obj} to be >= ${lower} and <= ${upper}`; + return assert.that(n => n >= lower && n <= upper, msg)(obj); +}; + +/** + * Asserts that <var>obj</var> is callable. + * + * @param {?} obj + * Value to test. + * @param {string=} msg + * Custom error message. + * + * @returns {Function} + * <var>obj</var> is returned unaltered. + * + * @throws {InvalidArgumentError} + * If <var>obj</var> is not callable. + */ +assert.callable = function (obj, msg = "") { + msg = msg || lazy.pprint`${obj} is not callable`; + return assert.that(o => typeof o == "function", msg)(obj); +}; + +/** + * Asserts that <var>obj</var> is an unsigned short number. + * + * @param {?} obj + * Value to test. + * @param {string=} msg + * Custom error message. + * + * @returns {number} + * <var>obj</var> is returned unaltered. + * + * @throws {InvalidArgumentError} + * If <var>obj</var> is not an unsigned short. + */ +assert.unsignedShort = function (obj, msg = "") { + msg = msg || lazy.pprint`Expected ${obj} to be >= 0 and < 65536`; + return assert.that(n => n >= 0 && n < 65536, msg)(obj); +}; + +/** + * Asserts that <var>obj</var> is an integer. + * + * @param {?} obj + * Value to test. + * @param {string=} msg + * Custom error message. + * + * @returns {number} + * <var>obj</var> is returned unaltered. + * + * @throws {InvalidArgumentError} + * If <var>obj</var> is not an integer. + */ +assert.integer = function (obj, msg = "") { + msg = msg || lazy.pprint`Expected ${obj} to be an integer`; + return assert.that(Number.isSafeInteger, msg)(obj); +}; + +/** + * Asserts that <var>obj</var> is a positive integer. + * + * @param {?} obj + * Value to test. + * @param {string=} msg + * Custom error message. + * + * @returns {number} + * <var>obj</var> is returned unaltered. + * + * @throws {InvalidArgumentError} + * If <var>obj</var> is not a positive integer. + */ +assert.positiveInteger = function (obj, msg = "") { + assert.integer(obj, msg); + msg = msg || lazy.pprint`Expected ${obj} to be >= 0`; + return assert.that(n => n >= 0, msg)(obj); +}; + +/** + * Asserts that <var>obj</var> is an integer in the inclusive range <var>lower</var> to <var>upper</var>. + * + * @param {?} obj + * Value to test. + * @param {Array<number>} range + * Array range [lower, upper] + * @param {string=} msg + * Custom error message. + * + * @returns {number} + * <var>obj</var> is returned unaltered. + * + * @throws {InvalidArgumentError} + * If <var>obj</var> is not a number in the specified range. + */ +assert.integerInRange = function (obj, range, msg = "") { + const [lower, upper] = range; + assert.integer(obj, msg); + msg = msg || lazy.pprint`Expected ${obj} to be >= ${lower} and <= ${upper}`; + return assert.that(n => n >= lower && n <= upper, msg)(obj); +}; + +/** + * Asserts that <var>obj</var> is a boolean. + * + * @param {?} obj + * Value to test. + * @param {string=} msg + * Custom error message. + * + * @returns {boolean} + * <var>obj</var> is returned unaltered. + * + * @throws {InvalidArgumentError} + * If <var>obj</var> is not a boolean. + */ +assert.boolean = function (obj, msg = "") { + msg = msg || lazy.pprint`Expected ${obj} to be boolean`; + return assert.that(b => typeof b == "boolean", msg)(obj); +}; + +/** + * Asserts that <var>obj</var> is a string. + * + * @param {?} obj + * Value to test. + * @param {string=} msg + * Custom error message. + * + * @returns {string} + * <var>obj</var> is returned unaltered. + * + * @throws {InvalidArgumentError} + * If <var>obj</var> is not a string. + */ +assert.string = function (obj, msg = "") { + msg = msg || lazy.pprint`Expected ${obj} to be a string`; + return assert.that(s => typeof s == "string", msg)(obj); +}; + +/** + * Asserts that <var>obj</var> is an object. + * + * @param {?} obj + * Value to test. + * @param {string=} msg + * Custom error message. + * + * @returns {object} + * obj| is returned unaltered. + * + * @throws {InvalidArgumentError} + * If <var>obj</var> is not an object. + */ +assert.object = function (obj, msg = "") { + msg = msg || lazy.pprint`Expected ${obj} to be an object`; + return assert.that(o => { + // unable to use instanceof because LHS and RHS may come from + // different globals + let s = Object.prototype.toString.call(o); + return s == "[object Object]" || s == "[object nsJSIID]"; + }, msg)(obj); +}; + +/** + * Asserts that <var>prop</var> is in <var>obj</var>. + * + * @param {?} prop + * An array element or own property to test if is in <var>obj</var>. + * @param {?} obj + * An array or an Object that is being tested. + * @param {string=} msg + * Custom error message. + * + * @returns {?} + * The array element, or the value of <var>obj</var>'s own property + * <var>prop</var>. + * + * @throws {InvalidArgumentError} + * If the <var>obj</var> was an array and did not contain <var>prop</var>. + * Otherwise if <var>prop</var> is not in <var>obj</var>, or <var>obj</var> + * is not an object. + */ +assert.in = function (prop, obj, msg = "") { + if (Array.isArray(obj)) { + assert.that(p => obj.includes(p), msg)(prop); + return prop; + } + assert.object(obj, msg); + msg = msg || lazy.pprint`Expected ${prop} in ${obj}`; + assert.that(p => obj.hasOwnProperty(p), msg)(prop); + return obj[prop]; +}; + +/** + * Asserts that <var>obj</var> is an Array. + * + * @param {?} obj + * Value to test. + * @param {string=} msg + * Custom error message. + * + * @returns {object} + * <var>obj</var> is returned unaltered. + * + * @throws {InvalidArgumentError} + * If <var>obj</var> is not an Array. + */ +assert.array = function (obj, msg = "") { + msg = msg || lazy.pprint`Expected ${obj} to be an Array`; + return assert.that(Array.isArray, msg)(obj); +}; + +/** + * Returns a function that is used to assert the |predicate|. + * + * @param {function(?): boolean} predicate + * Evaluated on calling the return value of this function. If its + * return value of the inner function is false, <var>error</var> + * is thrown with <var>message</var>. + * @param {string=} message + * Custom error message. + * @param {Error=} err + * Custom error type by its class. + * + * @returns {function(?): ?} + * Function that takes and returns the passed in value unaltered, + * and which may throw <var>error</var> with <var>message</var> + * if <var>predicate</var> evaluates to false. + */ +assert.that = function ( + predicate, + message = "", + err = lazy.error.InvalidArgumentError +) { + return obj => { + if (!predicate(obj)) { + throw new err(message); + } + return obj; + }; +}; diff --git a/remote/shared/webdriver/Capabilities.sys.mjs b/remote/shared/webdriver/Capabilities.sys.mjs new file mode 100644 index 0000000000..0a41826784 --- /dev/null +++ b/remote/shared/webdriver/Capabilities.sys.mjs @@ -0,0 +1,737 @@ +/* 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, { + Preferences: "resource://gre/modules/Preferences.sys.mjs", + + AppInfo: "chrome://remote/content/shared/AppInfo.sys.mjs", + assert: "chrome://remote/content/shared/webdriver/Assert.sys.mjs", + error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs", + pprint: "chrome://remote/content/shared/Format.sys.mjs", + RemoteAgent: "chrome://remote/content/components/RemoteAgent.sys.mjs", +}); + +XPCOMUtils.defineLazyGetter(lazy, "remoteAgent", () => { + return Cc["@mozilla.org/remote/agent;1"].createInstance(Ci.nsIRemoteAgent); +}); + +/** Representation of WebDriver session timeouts. */ +export class Timeouts { + constructor() { + // disabled + this.implicit = 0; + // five minutes + this.pageLoad = 300000; + // 30 seconds + this.script = 30000; + } + + toString() { + return "[object Timeouts]"; + } + + /** Marshals timeout durations to a JSON Object. */ + toJSON() { + return { + implicit: this.implicit, + pageLoad: this.pageLoad, + script: this.script, + }; + } + + static fromJSON(json) { + lazy.assert.object( + json, + lazy.pprint`Expected "timeouts" to be an object, got ${json}` + ); + let t = new Timeouts(); + + for (let [type, ms] of Object.entries(json)) { + switch (type) { + case "implicit": + t.implicit = lazy.assert.positiveInteger( + ms, + lazy.pprint`Expected ${type} to be a positive integer, got ${ms}` + ); + break; + + case "script": + if (ms !== null) { + lazy.assert.positiveInteger( + ms, + lazy.pprint`Expected ${type} to be a positive integer, got ${ms}` + ); + } + t.script = ms; + break; + + case "pageLoad": + t.pageLoad = lazy.assert.positiveInteger( + ms, + lazy.pprint`Expected ${type} to be a positive integer, got ${ms}` + ); + break; + + default: + throw new lazy.error.InvalidArgumentError( + "Unrecognised timeout: " + type + ); + } + } + + return t; + } +} + +/** + * Enum of page loading strategies. + * + * @enum + */ +export const PageLoadStrategy = { + /** No page load strategy. Navigation will return immediately. */ + None: "none", + /** + * Eager, causing navigation to complete when the document reaches + * the <code>interactive</code> ready state. + */ + Eager: "eager", + /** + * Normal, causing navigation to return when the document reaches the + * <code>complete</code> ready state. + */ + Normal: "normal", +}; + +/** Proxy configuration object representation. */ +export class Proxy { + /** @class */ + constructor() { + this.proxyType = null; + this.httpProxy = null; + this.httpProxyPort = null; + this.noProxy = null; + this.sslProxy = null; + this.sslProxyPort = null; + this.socksProxy = null; + this.socksProxyPort = null; + this.socksVersion = null; + this.proxyAutoconfigUrl = null; + } + + /** + * Sets Firefox proxy settings. + * + * @returns {boolean} + * True if proxy settings were updated as a result of calling this + * function, or false indicating that this function acted as + * a no-op. + */ + init() { + switch (this.proxyType) { + case "autodetect": + lazy.Preferences.set("network.proxy.type", 4); + return true; + + case "direct": + lazy.Preferences.set("network.proxy.type", 0); + return true; + + case "manual": + lazy.Preferences.set("network.proxy.type", 1); + + if (this.httpProxy) { + lazy.Preferences.set("network.proxy.http", this.httpProxy); + if (Number.isInteger(this.httpProxyPort)) { + lazy.Preferences.set("network.proxy.http_port", this.httpProxyPort); + } + } + + if (this.sslProxy) { + lazy.Preferences.set("network.proxy.ssl", this.sslProxy); + if (Number.isInteger(this.sslProxyPort)) { + lazy.Preferences.set("network.proxy.ssl_port", this.sslProxyPort); + } + } + + if (this.socksProxy) { + lazy.Preferences.set("network.proxy.socks", this.socksProxy); + if (Number.isInteger(this.socksProxyPort)) { + lazy.Preferences.set( + "network.proxy.socks_port", + this.socksProxyPort + ); + } + if (this.socksVersion) { + lazy.Preferences.set( + "network.proxy.socks_version", + this.socksVersion + ); + } + } + + if (this.noProxy) { + lazy.Preferences.set( + "network.proxy.no_proxies_on", + this.noProxy.join(", ") + ); + } + return true; + + case "pac": + lazy.Preferences.set("network.proxy.type", 2); + lazy.Preferences.set( + "network.proxy.autoconfig_url", + this.proxyAutoconfigUrl + ); + return true; + + case "system": + lazy.Preferences.set("network.proxy.type", 5); + return true; + + default: + return false; + } + } + + /** + * @param {Object<string, ?>} json + * JSON Object to unmarshal. + * + * @throws {InvalidArgumentError} + * When proxy configuration is invalid. + */ + static fromJSON(json) { + function stripBracketsFromIpv6Hostname(hostname) { + return hostname.includes(":") + ? hostname.replace(/[\[\]]/g, "") + : hostname; + } + + // Parse hostname and optional port from host + function fromHost(scheme, host) { + lazy.assert.string( + host, + lazy.pprint`Expected proxy "host" to be a string, got ${host}` + ); + + if (host.includes("://")) { + throw new lazy.error.InvalidArgumentError(`${host} contains a scheme`); + } + + let url; + try { + // To parse the host a scheme has to be added temporarily. + // If the returned value for the port is an empty string it + // could mean no port or the default port for this scheme was + // specified. In such a case parse again with a different + // scheme to ensure we filter out the default port. + url = new URL("http://" + host); + if (url.port == "") { + url = new URL("https://" + host); + } + } catch (e) { + throw new lazy.error.InvalidArgumentError(e.message); + } + + let hostname = stripBracketsFromIpv6Hostname(url.hostname); + + // If the port hasn't been set, use the default port of + // the selected scheme (except for socks which doesn't have one). + let port = parseInt(url.port); + if (!Number.isInteger(port)) { + if (scheme === "socks") { + port = null; + } else { + port = Services.io.getDefaultPort(scheme); + } + } + + if ( + url.username != "" || + url.password != "" || + url.pathname != "/" || + url.search != "" || + url.hash != "" + ) { + throw new lazy.error.InvalidArgumentError( + `${host} was not of the form host[:port]` + ); + } + + return [hostname, port]; + } + + let p = new Proxy(); + if (typeof json == "undefined" || json === null) { + return p; + } + + lazy.assert.object( + json, + lazy.pprint`Expected "proxy" to be an object, got ${json}` + ); + + lazy.assert.in( + "proxyType", + json, + lazy.pprint`Expected "proxyType" in "proxy" object, got ${json}` + ); + p.proxyType = lazy.assert.string( + json.proxyType, + lazy.pprint`Expected "proxyType" to be a string, got ${json.proxyType}` + ); + + switch (p.proxyType) { + case "autodetect": + case "direct": + case "system": + break; + + case "pac": + p.proxyAutoconfigUrl = lazy.assert.string( + json.proxyAutoconfigUrl, + `Expected "proxyAutoconfigUrl" to be a string, ` + + lazy.pprint`got ${json.proxyAutoconfigUrl}` + ); + break; + + case "manual": + if (typeof json.ftpProxy != "undefined") { + throw new lazy.error.InvalidArgumentError( + "Since Firefox 90 'ftpProxy' is no longer supported" + ); + } + if (typeof json.httpProxy != "undefined") { + [p.httpProxy, p.httpProxyPort] = fromHost("http", json.httpProxy); + } + if (typeof json.sslProxy != "undefined") { + [p.sslProxy, p.sslProxyPort] = fromHost("https", json.sslProxy); + } + if (typeof json.socksProxy != "undefined") { + [p.socksProxy, p.socksProxyPort] = fromHost("socks", json.socksProxy); + p.socksVersion = lazy.assert.positiveInteger( + json.socksVersion, + lazy.pprint`Expected "socksVersion" to be a positive integer, got ${json.socksVersion}` + ); + } + if (typeof json.noProxy != "undefined") { + let entries = lazy.assert.array( + json.noProxy, + lazy.pprint`Expected "noProxy" to be an array, got ${json.noProxy}` + ); + p.noProxy = entries.map(entry => { + lazy.assert.string( + entry, + lazy.pprint`Expected "noProxy" entry to be a string, got ${entry}` + ); + return stripBracketsFromIpv6Hostname(entry); + }); + } + break; + + default: + throw new lazy.error.InvalidArgumentError( + `Invalid type of proxy: ${p.proxyType}` + ); + } + + return p; + } + + /** + * @returns {Object<string, (number | string)>} + * JSON serialisation of proxy object. + */ + toJSON() { + function addBracketsToIpv6Hostname(hostname) { + return hostname.includes(":") ? `[${hostname}]` : hostname; + } + + function toHost(hostname, port) { + if (!hostname) { + return null; + } + + // Add brackets around IPv6 addresses + hostname = addBracketsToIpv6Hostname(hostname); + + if (port != null) { + return `${hostname}:${port}`; + } + + return hostname; + } + + let excludes = this.noProxy; + if (excludes) { + excludes = excludes.map(addBracketsToIpv6Hostname); + } + + return marshal({ + proxyType: this.proxyType, + httpProxy: toHost(this.httpProxy, this.httpProxyPort), + noProxy: excludes, + sslProxy: toHost(this.sslProxy, this.sslProxyPort), + socksProxy: toHost(this.socksProxy, this.socksProxyPort), + socksVersion: this.socksVersion, + proxyAutoconfigUrl: this.proxyAutoconfigUrl, + }); + } + + toString() { + return "[object Proxy]"; + } +} + +/** + * Enum of unhandled prompt behavior. + * + * @enum + */ +export const UnhandledPromptBehavior = { + /** All simple dialogs encountered should be accepted. */ + Accept: "accept", + /** + * All simple dialogs encountered should be accepted, and an error + * returned that the dialog was handled. + */ + AcceptAndNotify: "accept and notify", + /** All simple dialogs encountered should be dismissed. */ + Dismiss: "dismiss", + /** + * All simple dialogs encountered should be dismissed, and an error + * returned that the dialog was handled. + */ + DismissAndNotify: "dismiss and notify", + /** All simple dialogs encountered should be left to the user to handle. */ + Ignore: "ignore", +}; + +/** WebDriver session capabilities representation. */ +export class Capabilities extends Map { + /** @class */ + constructor() { + super([ + // webdriver + ["browserName", getWebDriverBrowserName()], + ["browserVersion", lazy.AppInfo.version], + ["platformName", getWebDriverPlatformName()], + ["acceptInsecureCerts", false], + ["pageLoadStrategy", PageLoadStrategy.Normal], + ["proxy", new Proxy()], + ["setWindowRect", !lazy.AppInfo.isAndroid], + ["timeouts", new Timeouts()], + ["strictFileInteractability", false], + ["unhandledPromptBehavior", UnhandledPromptBehavior.DismissAndNotify], + ["webSocketUrl", null], + + // proprietary + ["moz:accessibilityChecks", false], + ["moz:buildID", lazy.AppInfo.appBuildID], + [ + "moz:debuggerAddress", + // With bug 1715481 fixed always use the Remote Agent instance + lazy.RemoteAgent.running && lazy.RemoteAgent.cdp + ? lazy.remoteAgent.debuggerAddress + : null, + ], + [ + "moz:headless", + Cc["@mozilla.org/gfx/info;1"].getService(Ci.nsIGfxInfo).isHeadless, + ], + ["moz:platformVersion", Services.sysinfo.getProperty("version")], + ["moz:processID", lazy.AppInfo.processID], + ["moz:profile", maybeProfile()], + [ + "moz:shutdownTimeout", + Services.prefs.getIntPref("toolkit.asyncshutdown.crash_timeout"), + ], + ["moz:useNonSpecCompliantPointerOrigin", false], + ["moz:webdriverClick", true], + ["moz:windowless", false], + ]); + } + + /** + * @param {string} key + * Capability key. + * @param {(string|number|boolean)} value + * JSON-safe capability value. + */ + set(key, value) { + if (key === "timeouts" && !(value instanceof Timeouts)) { + throw new TypeError(); + } else if (key === "proxy" && !(value instanceof Proxy)) { + throw new TypeError(); + } + + return super.set(key, value); + } + + toString() { + return "[object Capabilities]"; + } + + /** + * JSON serialisation of capabilities object. + * + * @returns {Object<string, ?>} + */ + toJSON() { + let marshalled = marshal(this); + + // Always return the proxy capability even if it's empty + if (!("proxy" in marshalled)) { + marshalled.proxy = {}; + } + + marshalled.timeouts = super.get("timeouts"); + + return marshalled; + } + + /** + * Unmarshal a JSON object representation of WebDriver capabilities. + * + * @param {Object<string, *>=} json + * WebDriver capabilities. + * + * @returns {Capabilities} + * Internal representation of WebDriver capabilities. + */ + static fromJSON(json) { + if (typeof json == "undefined" || json === null) { + json = {}; + } + lazy.assert.object( + json, + lazy.pprint`Expected "capabilities" to be an object, got ${json}"` + ); + + return Capabilities.match_(json); + } + + // Matches capabilities as described by WebDriver. + static match_(json = {}) { + let matched = new Capabilities(); + + for (let [k, v] of Object.entries(json)) { + switch (k) { + case "acceptInsecureCerts": + lazy.assert.boolean( + v, + lazy.pprint`Expected ${k} to be a boolean, got ${v}` + ); + break; + + case "pageLoadStrategy": + lazy.assert.string( + v, + lazy.pprint`Expected ${k} to be a string, got ${v}` + ); + if (!Object.values(PageLoadStrategy).includes(v)) { + throw new lazy.error.InvalidArgumentError( + "Unknown page load strategy: " + v + ); + } + break; + + case "proxy": + v = Proxy.fromJSON(v); + break; + + case "setWindowRect": + lazy.assert.boolean( + v, + lazy.pprint`Expected ${k} to be boolean, got ${v}` + ); + if (!lazy.AppInfo.isAndroid && !v) { + throw new lazy.error.InvalidArgumentError( + "setWindowRect cannot be disabled" + ); + } else if (lazy.AppInfo.isAndroid && v) { + throw new lazy.error.InvalidArgumentError( + "setWindowRect is only supported on desktop" + ); + } + break; + + case "timeouts": + v = Timeouts.fromJSON(v); + break; + + case "strictFileInteractability": + v = lazy.assert.boolean(v); + break; + + case "unhandledPromptBehavior": + lazy.assert.string( + v, + lazy.pprint`Expected ${k} to be a string, got ${v}` + ); + if (!Object.values(UnhandledPromptBehavior).includes(v)) { + throw new lazy.error.InvalidArgumentError( + `Unknown unhandled prompt behavior: ${v}` + ); + } + break; + + case "webSocketUrl": + lazy.assert.boolean( + v, + lazy.pprint`Expected ${k} to be boolean, got ${v}` + ); + + if (!v) { + throw new lazy.error.InvalidArgumentError( + lazy.pprint`Expected ${k} to be true, got ${v}` + ); + } + break; + + case "moz:accessibilityChecks": + lazy.assert.boolean( + v, + lazy.pprint`Expected ${k} to be boolean, got ${v}` + ); + break; + + // Don't set the value because it's only used to return the address + // of the Remote Agent's debugger (HTTP server). + case "moz:debuggerAddress": + continue; + + case "moz:useNonSpecCompliantPointerOrigin": + lazy.assert.boolean( + v, + lazy.pprint`Expected ${k} to be boolean, got ${v}` + ); + break; + + case "moz:webdriverClick": + lazy.assert.boolean( + v, + lazy.pprint`Expected ${k} to be boolean, got ${v}` + ); + break; + + case "moz:windowless": + lazy.assert.boolean( + v, + lazy.pprint`Expected ${k} to be boolean, got ${v}` + ); + + // Only supported on MacOS + if (v && !lazy.AppInfo.isMac) { + throw new lazy.error.InvalidArgumentError( + "moz:windowless only supported on MacOS" + ); + } + break; + } + + matched.set(k, v); + } + + return matched; + } +} + +function getWebDriverBrowserName() { + // Similar to chromedriver which reports "chrome" as browser name for all + // WebView apps, we will report "firefox" for all GeckoView apps. + if (lazy.AppInfo.isAndroid) { + return "firefox"; + } + + return lazy.AppInfo.name?.toLowerCase(); +} + +function getWebDriverPlatformName() { + let name = Services.sysinfo.getProperty("name"); + + if (lazy.AppInfo.isAndroid) { + return "android"; + } + + switch (name) { + case "Windows_NT": + return "windows"; + + case "Darwin": + return "mac"; + + default: + return name.toLowerCase(); + } +} + +// Specialisation of |JSON.stringify| that produces JSON-safe object +// literals, dropping empty objects and entries which values are undefined +// or null. Objects are allowed to produce their own JSON representations +// by implementing a |toJSON| function. +function marshal(obj) { + let rv = Object.create(null); + + function* iter(mapOrObject) { + if (mapOrObject instanceof Map) { + for (const [k, v] of mapOrObject) { + yield [k, v]; + } + } else { + for (const k of Object.keys(mapOrObject)) { + yield [k, mapOrObject[k]]; + } + } + } + + for (let [k, v] of iter(obj)) { + // Skip empty values when serialising to JSON. + if (typeof v == "undefined" || v === null) { + continue; + } + + // Recursively marshal objects that are able to produce their own + // JSON representation. + if (typeof v.toJSON == "function") { + v = marshal(v.toJSON()); + + // Or do the same for object literals. + } else if (isObject(v)) { + v = marshal(v); + } + + // And finally drop (possibly marshaled) objects which have no + // entries. + if (!isObjectEmpty(v)) { + rv[k] = v; + } + } + + return rv; +} + +function isObject(obj) { + return Object.prototype.toString.call(obj) == "[object Object]"; +} + +function isObjectEmpty(obj) { + return isObject(obj) && Object.keys(obj).length === 0; +} + +// Services.dirsvc is not accessible from JSWindowActor child, +// but we should not panic about that. +function maybeProfile() { + try { + return Services.dirsvc.get("ProfD", Ci.nsIFile).path; + } catch (e) { + return "<protected>"; + } +} diff --git a/remote/shared/webdriver/Errors.sys.mjs b/remote/shared/webdriver/Errors.sys.mjs new file mode 100644 index 0000000000..65c2f0b689 --- /dev/null +++ b/remote/shared/webdriver/Errors.sys.mjs @@ -0,0 +1,796 @@ +/* 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 { RemoteError } from "chrome://remote/content/shared/RemoteError.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + pprint: "chrome://remote/content/shared/Format.sys.mjs", +}); + +const ERRORS = new Set([ + "DetachedShadowRootError", + "ElementClickInterceptedError", + "ElementNotAccessibleError", + "ElementNotInteractableError", + "InsecureCertificateError", + "InvalidArgumentError", + "InvalidCookieDomainError", + "InvalidElementStateError", + "InvalidSelectorError", + "InvalidSessionIDError", + "JavaScriptError", + "MoveTargetOutOfBoundsError", + "NoSuchAlertError", + "NoSuchElementError", + "NoSuchFrameError", + "NoSuchHandleError", + "NoSuchNodeError", + "NoSuchScriptError", + "NoSuchShadowRootError", + "NoSuchWindowError", + "ScriptTimeoutError", + "SessionNotCreatedError", + "StaleElementReferenceError", + "TimeoutError", + "UnableToSetCookieError", + "UnexpectedAlertOpenError", + "UnknownCommandError", + "UnknownError", + "UnsupportedOperationError", + "WebDriverError", +]); + +const BUILTIN_ERRORS = new Set([ + "Error", + "EvalError", + "InternalError", + "RangeError", + "ReferenceError", + "SyntaxError", + "TypeError", + "URIError", +]); + +/** @namespace */ +export const error = { + /** + * Check if ``val`` is an instance of the ``Error`` prototype. + * + * Because error objects may originate from different globals, comparing + * the prototype of the left hand side with the prototype property from + * the right hand side, which is what ``instanceof`` does, will not work. + * If the LHS and RHS come from different globals, this check will always + * fail because the two objects will not have the same identity. + * + * Therefore it is not safe to use ``instanceof`` in any multi-global + * situation, e.g. in content across multiple ``Window`` objects or anywhere + * in chrome scope. + * + * This function also contains a special check if ``val`` is an XPCOM + * ``nsIException`` because they are special snowflakes and may indeed + * cause Firefox to crash if used with ``instanceof``. + * + * @param {*} val + * Any value that should be undergo the test for errorness. + * @returns {boolean} + * True if error, false otherwise. + */ + isError(val) { + if (val === null || typeof val != "object") { + return false; + } else if (val instanceof Ci.nsIException) { + return true; + } + + // DOMRectList errors on string comparison + try { + let proto = Object.getPrototypeOf(val); + return BUILTIN_ERRORS.has(proto.toString()); + } catch (e) { + return false; + } + }, + + /** + * Checks if ``obj`` is an object in the :js:class:`WebDriverError` + * prototypal chain. + * + * @param {*} obj + * Arbitrary object to test. + * + * @returns {boolean} + * True if ``obj`` is of the WebDriverError prototype chain, + * false otherwise. + */ + isWebDriverError(obj) { + // Don't use "instanceof" to compare error objects because of possible + // problems when the other instance was created in a different global and + // as such won't have the same prototype object. + return error.isError(obj) && "name" in obj && ERRORS.has(obj.name); + }, + + /** + * Ensures error instance is a :js:class:`WebDriverError`. + * + * If the given error is already in the WebDriverError prototype + * chain, ``err`` is returned unmodified. If it is not, it is wrapped + * in :js:class:`UnknownError`. + * + * @param {Error} err + * Error to conditionally turn into a WebDriverError. + * + * @returns {WebDriverError} + * If ``err`` is a WebDriverError, it is returned unmodified. + * Otherwise an UnknownError type is returned. + */ + wrap(err) { + if (error.isWebDriverError(err)) { + return err; + } + return new UnknownError(err); + }, + + /** + * Unhandled error reporter. Dumps the error and its stacktrace to console, + * and reports error to the Browser Console. + */ + report(err) { + let msg = "Marionette threw an error: " + error.stringify(err); + dump(msg + "\n"); + console.error(msg); + }, + + /** + * Prettifies an instance of Error and its stacktrace to a string. + */ + stringify(err) { + try { + let s = err.toString(); + if ("stack" in err) { + s += "\n" + err.stack; + } + return s; + } catch (e) { + return "<unprintable error>"; + } + }, + + /** Create a stacktrace to the current line in the program. */ + stack() { + let trace = new Error().stack; + let sa = trace.split("\n"); + sa = sa.slice(1); + let rv = "stacktrace:\n" + sa.join("\n"); + return rv.trimEnd(); + }, +}; + +/** + * WebDriverError is the prototypal parent of all WebDriver errors. + * It should not be used directly, as it does not correspond to a real + * error in the specification. + */ +class WebDriverError extends RemoteError { + /** + * Base error for WebDriver protocols. + * + * @param {(string|Error)=} obj + * Optional string describing error situation or Error instance + * to propagate. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ + constructor(obj, data = {}) { + super(obj); + + this.name = this.constructor.name; + this.status = "webdriver error"; + this.data = data; + + // Error's ctor does not preserve x' stack + if (error.isError(obj)) { + this.stack = obj.stack; + } + + if (error.isWebDriverError(obj)) { + this.message = obj.message; + this.data = obj.data; + } + } + + /** + * @returns {Object<string, string>} + * JSON serialisation of error prototype. + */ + toJSON() { + const result = { + error: this.status, + message: this.message || "", + stacktrace: this.stack || "", + }; + + // Only add the field if additional data has been specified. + if (Object.keys(this.data).length) { + result.data = this.data; + } + + return result; + } + + /** + * Unmarshals a JSON error representation to the appropriate Marionette + * error type. + * + * @param {Object<string, string>} json + * Error object. + * + * @returns {Error} + * Error prototype. + */ + static fromJSON(json) { + if (typeof json.error == "undefined") { + let s = JSON.stringify(json); + throw new TypeError("Undeserialisable error type: " + s); + } + if (!STATUSES.has(json.error)) { + throw new TypeError("Not of WebDriverError descent: " + json.error); + } + + let cls = STATUSES.get(json.error); + let err = new cls(); + if ("message" in json) { + err.message = json.message; + } + if ("stacktrace" in json) { + err.stack = json.stacktrace; + } + if ("data" in json) { + err.data = json.data; + } + + return err; + } +} + +/** + * The Gecko a11y API indicates that the element is not accessible. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class ElementNotAccessibleError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "element not accessible"; + } +} + +/** + * An element click could not be completed because the element receiving + * the events is obscuring the element that was requested clicked. + * + * @param {string=} message + * Optional string describing error situation. Will be replaced if both + * `data.obscuredEl` and `data.coords` are provided. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + * @param {Element=} obscuredEl + * Element obscuring the element receiving the click. Providing this + * is not required, but will produce a nicer error message. + * @param {Map.<string, number>=} coords + * Original click location. Providing this is not required, but + * will produce a nicer error message. + */ +class ElementClickInterceptedError extends WebDriverError { + constructor(message, data = {}, obscuredEl = undefined, coords = undefined) { + let obscuredElDetails = null; + let overlayingElDetails = null; + + if (obscuredEl && coords) { + const doc = obscuredEl.ownerDocument; + const overlayingEl = doc.elementFromPoint(coords.x, coords.y); + + obscuredElDetails = lazy.pprint`${obscuredEl}`; + overlayingElDetails = lazy.pprint`${overlayingEl}`; + + switch (obscuredEl.style.pointerEvents) { + case "none": + message = + `Element ${obscuredElDetails} is not clickable ` + + `at point (${coords.x},${coords.y}) ` + + `because it does not have pointer events enabled, ` + + `and element ${overlayingElDetails} ` + + `would receive the click instead`; + break; + + default: + message = + `Element ${obscuredElDetails} is not clickable ` + + `at point (${coords.x},${coords.y}) ` + + `because another element ${overlayingElDetails} ` + + `obscures it`; + break; + } + } + + if (coords) { + data.coords = coords; + } + if (obscuredElDetails) { + data.obscuredElement = obscuredElDetails; + } + if (overlayingElDetails) { + data.overlayingElement = overlayingElDetails; + } + + super(message, data); + this.status = "element click intercepted"; + } +} + +/** + * A command could not be completed because the element is not pointer- + * or keyboard interactable. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class ElementNotInteractableError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "element not interactable"; + } +} + +/** + * Navigation caused the user agent to hit a certificate warning, which + * is usually the result of an expired or invalid TLS certificate. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class InsecureCertificateError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "insecure certificate"; + } +} + +/** + * The arguments passed to a command are either invalid or malformed. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class InvalidArgumentError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "invalid argument"; + } +} + +/** + * An illegal attempt was made to set a cookie under a different + * domain than the current page. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class InvalidCookieDomainError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "invalid cookie domain"; + } +} + +/** + * A command could not be completed because the element is in an + * invalid state, e.g. attempting to clear an element that isn't both + * editable and resettable. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class InvalidElementStateError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "invalid element state"; + } +} + +/** + * Argument was an invalid selector. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class InvalidSelectorError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "invalid selector"; + } +} + +/** + * Occurs if the given session ID is not in the list of active sessions, + * meaning the session either does not exist or that it's not active. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class InvalidSessionIDError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "invalid session id"; + } +} + +/** + * An error occurred whilst executing JavaScript supplied by the user. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class JavaScriptError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "javascript error"; + } +} + +/** + * The target for mouse interaction is not in the browser's viewport + * and cannot be brought into that viewport. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class MoveTargetOutOfBoundsError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "move target out of bounds"; + } +} + +/** + * An attempt was made to operate on a modal dialog when one was + * not open. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class NoSuchAlertError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "no such alert"; + } +} + +/** + * An element could not be located on the page using the given + * search parameters. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class NoSuchElementError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "no such element"; + } +} + +/** + * A command tried to remove an unknown preload script. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class NoSuchScriptError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "no such script"; + } +} + +/** + * A shadow root was not attached to the element. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class NoSuchShadowRootError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "no such shadow root"; + } +} + +/** + * A shadow root is no longer attached to the document. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class DetachedShadowRootError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "detached shadow root"; + } +} + +/** + * A command to switch to a frame could not be satisfied because + * the frame could not be found. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class NoSuchFrameError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "no such frame"; + } +} + +/** + * The handle of a strong object reference could not be found. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class NoSuchHandleError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "no such handle"; + } +} + +/** + * A node as given by its unique shared id could not be found within the cache + * of known nodes. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class NoSuchNodeError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "no such node"; + } +} + +/** + * A command to switch to a window could not be satisfied because + * the window could not be found. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class NoSuchWindowError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "no such window"; + } +} + +/** + * A script did not complete before its timeout expired. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class ScriptTimeoutError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "script timeout"; + } +} + +/** + * A new session could not be created. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class SessionNotCreatedError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "session not created"; + } +} + +/** + * A command failed because the referenced element is no longer + * attached to the DOM. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class StaleElementReferenceError extends WebDriverError { + constructor(message, options = {}) { + super(message, options); + this.status = "stale element reference"; + } +} + +/** + * An operation did not complete before its timeout expired. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class TimeoutError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "timeout"; + } +} + +/** + * A command to set a cookie's value could not be satisfied. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class UnableToSetCookieError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "unable to set cookie"; + } +} + +/** + * A modal dialog was open, blocking this operation. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class UnexpectedAlertOpenError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "unexpected alert open"; + } +} + +/** + * A command could not be executed because the remote end is not + * aware of it. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class UnknownCommandError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "unknown command"; + } +} + +/** + * An unknown error occurred in the remote end while processing + * the command. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class UnknownError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "unknown error"; + } +} + +/** + * Indicates that a command that should have executed properly + * cannot be supported for some reason. + * + * @param {string=} message + * Optional string describing error situation. + * @param {object=} data + * Additional error data helpful in diagnosing the error. + */ +class UnsupportedOperationError extends WebDriverError { + constructor(message, data = {}) { + super(message, data); + this.status = "unsupported operation"; + } +} + +const STATUSES = new Map([ + ["detached shadow root", DetachedShadowRootError], + ["element click intercepted", ElementClickInterceptedError], + ["element not accessible", ElementNotAccessibleError], + ["element not interactable", ElementNotInteractableError], + ["insecure certificate", InsecureCertificateError], + ["invalid argument", InvalidArgumentError], + ["invalid cookie domain", InvalidCookieDomainError], + ["invalid element state", InvalidElementStateError], + ["invalid selector", InvalidSelectorError], + ["invalid session id", InvalidSessionIDError], + ["javascript error", JavaScriptError], + ["move target out of bounds", MoveTargetOutOfBoundsError], + ["no such alert", NoSuchAlertError], + ["no such element", NoSuchElementError], + ["no such frame", NoSuchFrameError], + ["no such handle", NoSuchHandleError], + ["no such node", NoSuchNodeError], + ["no such script", NoSuchScriptError], + ["no such shadow root", NoSuchShadowRootError], + ["no such window", NoSuchWindowError], + ["script timeout", ScriptTimeoutError], + ["session not created", SessionNotCreatedError], + ["stale element reference", StaleElementReferenceError], + ["timeout", TimeoutError], + ["unable to set cookie", UnableToSetCookieError], + ["unexpected alert open", UnexpectedAlertOpenError], + ["unknown command", UnknownCommandError], + ["unknown error", UnknownError], + ["unsupported operation", UnsupportedOperationError], + ["webdriver error", WebDriverError], +]); + +// Errors must be expored on the local this scope so that the +// EXPORTED_SYMBOLS and the ChromeUtils.import("foo") machinery sees them. +// We could assign each error definition directly to |this|, but +// because they are Error prototypes this would mess up their names. +for (let cls of STATUSES.values()) { + error[cls.name] = cls; +} diff --git a/remote/shared/webdriver/KeyData.sys.mjs b/remote/shared/webdriver/KeyData.sys.mjs new file mode 100644 index 0000000000..13ec1f5bd8 --- /dev/null +++ b/remote/shared/webdriver/KeyData.sys.mjs @@ -0,0 +1,340 @@ +/* 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 KEY_DATA = { + " ": { code: "Space" }, + "!": { code: "Digit1", shifted: true }, + "#": { code: "Digit3", shifted: true }, + $: { code: "Digit4", shifted: true }, + "%": { code: "Digit5", shifted: true }, + "&": { code: "Digit7", shifted: true }, + "'": { code: "Quote" }, + "(": { code: "Digit9", shifted: true }, + ")": { code: "Digit0", shifted: true }, + "*": { code: "Digit8", shifted: true }, + "+": { code: "Equal", shifted: true }, + ",": { code: "Comma" }, + "-": { code: "Minus" }, + ".": { code: "Period" }, + "/": { code: "Slash" }, + 0: { code: "Digit0" }, + 1: { code: "Digit1" }, + 2: { code: "Digit2" }, + 3: { code: "Digit3" }, + 4: { code: "Digit4" }, + 5: { code: "Digit5" }, + 6: { code: "Digit6" }, + 7: { code: "Digit7" }, + 8: { code: "Digit8" }, + 9: { code: "Digit9" }, + ":": { code: "Semicolon", shifted: true }, + ";": { code: "Semicolon" }, + "<": { code: "Comma", shifted: true }, + "=": { code: "Equal" }, + ">": { code: "Period", shifted: true }, + "?": { code: "Slash", shifted: true }, + "@": { code: "Digit2", shifted: true }, + A: { code: "KeyA", shifted: true }, + B: { code: "KeyB", shifted: true }, + C: { code: "KeyC", shifted: true }, + D: { code: "KeyD", shifted: true }, + E: { code: "KeyE", shifted: true }, + F: { code: "KeyF", shifted: true }, + G: { code: "KeyG", shifted: true }, + H: { code: "KeyH", shifted: true }, + I: { code: "KeyI", shifted: true }, + J: { code: "KeyJ", shifted: true }, + K: { code: "KeyK", shifted: true }, + L: { code: "KeyL", shifted: true }, + M: { code: "KeyM", shifted: true }, + N: { code: "KeyN", shifted: true }, + O: { code: "KeyO", shifted: true }, + P: { code: "KeyP", shifted: true }, + Q: { code: "KeyQ", shifted: true }, + R: { code: "KeyR", shifted: true }, + S: { code: "KeyS", shifted: true }, + T: { code: "KeyT", shifted: true }, + U: { code: "KeyU", shifted: true }, + V: { code: "KeyV", shifted: true }, + W: { code: "KeyW", shifted: true }, + X: { code: "KeyX", shifted: true }, + Y: { code: "KeyY", shifted: true }, + Z: { code: "KeyZ", shifted: true }, + "[": { code: "BracketLeft" }, + '"': { code: "Quote", shifted: true }, + "\\": { code: "Backslash" }, + "]": { code: "BracketRight" }, + "^": { code: "Digit6", shifted: true }, + _: { code: "Minus", shifted: true }, + "`": { code: "Backquote" }, + a: { code: "KeyA" }, + b: { code: "KeyB" }, + c: { code: "KeyC" }, + d: { code: "KeyD" }, + e: { code: "KeyE" }, + f: { code: "KeyF" }, + g: { code: "KeyG" }, + h: { code: "KeyH" }, + i: { code: "KeyI" }, + j: { code: "KeyJ" }, + k: { code: "KeyK" }, + l: { code: "KeyL" }, + m: { code: "KeyM" }, + n: { code: "KeyN" }, + o: { code: "KeyO" }, + p: { code: "KeyP" }, + q: { code: "KeyQ" }, + r: { code: "KeyR" }, + s: { code: "KeyS" }, + t: { code: "KeyT" }, + u: { code: "KeyU" }, + v: { code: "KeyV" }, + w: { code: "KeyW" }, + x: { code: "KeyX" }, + y: { code: "KeyY" }, + z: { code: "KeyZ" }, + "{": { code: "BracketLeft", shifted: true }, + "|": { code: "Backslash", shifted: true }, + "}": { code: "BracketRight", shifted: true }, + "~": { code: "Backquote", shifted: true }, + "\uE000": { key: "Unidentified", printable: false }, + "\uE001": { key: "Cancel", printable: false }, + "\uE002": { code: "Help", key: "Help", printable: false }, + "\uE003": { code: "Backspace", key: "Backspace", printable: false }, + "\uE004": { code: "Tab", key: "Tab", printable: false }, + "\uE005": { code: "", key: "Clear", printable: false }, + "\uE006": { code: "Enter", key: "Enter", printable: false }, + "\uE007": { + code: "NumpadEnter", + key: "Enter", + location: 1, + printable: false, + }, + "\uE008": { + code: "ShiftLeft", + key: "Shift", + location: 1, + modifier: "shiftKey", + printable: false, + }, + "\uE009": { + code: "ControlLeft", + key: "Control", + location: 1, + modifier: "ctrlKey", + printable: false, + }, + "\uE00A": { + code: "AltLeft", + key: "Alt", + location: 1, + modifier: "altKey", + printable: false, + }, + "\uE00B": { code: "", key: "Pause", printable: false }, + "\uE00C": { code: "Escape", key: "Escape", printable: false }, + "\uE00D": { code: "Space", key: " ", shifted: true }, + "\uE00E": { code: "PageUp", key: "PageUp", printable: false }, + "\uE00F": { code: "PageDown", key: "PageDown", printable: false }, + "\uE010": { code: "End", key: "End", printable: false }, + "\uE011": { code: "Home", key: "Home", printable: false }, + "\uE012": { code: "ArrowLeft", key: "ArrowLeft", printable: false }, + "\uE013": { code: "ArrowUp", key: "ArrowUp", printable: false }, + "\uE014": { code: "ArrowRight", key: "ArrowRight", printable: false }, + "\uE015": { code: "ArrowDown", key: "ArrowDown", printable: false }, + "\uE016": { code: "Insert", key: "Insert", printable: false }, + "\uE017": { code: "Delete", key: "Delete", printable: false }, + "\uE018": { code: "", key: ";" }, + "\uE019": { code: "", key: "=" }, + "\uE01A": { code: "Numpad0", key: "0", location: 3 }, + "\uE01B": { code: "Numpad1", key: "1", location: 3 }, + "\uE01C": { code: "Numpad2", key: "2", location: 3 }, + "\uE01D": { code: "Numpad3", key: "3", location: 3 }, + "\uE01E": { code: "Numpad4", key: "4", location: 3 }, + "\uE01F": { code: "Numpad5", key: "5", location: 3 }, + "\uE020": { code: "Numpad6", key: "6", location: 3 }, + "\uE021": { code: "Numpad7", key: "7", location: 3 }, + "\uE022": { code: "Numpad8", key: "8", location: 3 }, + "\uE023": { code: "Numpad9", key: "9", location: 3 }, + "\uE024": { code: "NumpadMultiply", key: "*", location: 3 }, + "\uE025": { code: "NumpadAdd", key: "+", location: 3 }, + "\uE026": { code: "NumpadComma", key: ",", location: 3 }, + "\uE027": { code: "NumpadSubtract", key: "-", location: 3 }, + "\uE028": { code: "NumpadDecimal", key: ".", location: 3 }, + "\uE029": { code: "NumpadDivide", key: "/", location: 3 }, + "\uE031": { code: "F1", key: "F1", printable: false }, + "\uE032": { code: "F2", key: "F2", printable: false }, + "\uE033": { code: "F3", key: "F3", printable: false }, + "\uE034": { code: "F4", key: "F4", printable: false }, + "\uE035": { code: "F5", key: "F5", printable: false }, + "\uE036": { code: "F6", key: "F6", printable: false }, + "\uE037": { code: "F7", key: "F7", printable: false }, + "\uE038": { code: "F8", key: "F8", printable: false }, + "\uE039": { code: "F9", key: "F9", printable: false }, + "\uE03A": { code: "F10", key: "F10", printable: false }, + "\uE03B": { code: "F11", key: "F11", printable: false }, + "\uE03C": { code: "F12", key: "F12", printable: false }, + "\uE03D": { + code: "OSLeft", + key: "Meta", + location: 1, + modifier: "metaKey", + printable: false, + }, + "\uE040": { code: "", key: "ZenkakuHankaku", printable: false }, + "\uE050": { + code: "ShiftRight", + key: "Shift", + location: 2, + modifier: "shiftKey", + printable: false, + }, + "\uE051": { + code: "ControlRight", + key: "Control", + location: 2, + modifier: "ctrlKey", + printable: false, + }, + "\uE052": { + code: "AltRight", + key: "Alt", + location: 2, + modifier: "altKey", + printable: false, + }, + "\uE053": { + code: "OSRight", + key: "Meta", + location: 2, + modifier: "metaKey", + printable: false, + }, + "\uE054": { + code: "Numpad9", + key: "PageUp", + location: 3, + printable: false, + shifted: true, + }, + "\uE055": { + code: "Numpad3", + key: "PageDown", + location: 3, + printable: false, + shifted: true, + }, + "\uE056": { + code: "Numpad1", + key: "End", + location: 3, + printable: false, + shifted: true, + }, + "\uE057": { + code: "Numpad7", + key: "Home", + location: 3, + printable: false, + shifted: true, + }, + "\uE058": { + code: "Numpad4", + key: "ArrowLeft", + location: 3, + printable: false, + shifted: true, + }, + "\uE059": { + code: "Numpad8", + key: "ArrowUp", + location: 3, + printable: false, + shifted: true, + }, + "\uE05A": { + code: "Numpad6", + key: "ArrowRight", + location: 3, + printable: false, + shifted: true, + }, + "\uE05B": { + code: "Numpad2", + key: "ArrowDown", + location: 3, + printable: false, + shifted: true, + }, + "\uE05C": { + code: "Numpad0", + key: "Insert", + location: 3, + printable: false, + shifted: true, + }, + "\uE05D": { + code: "NumpadDecimal", + key: "Delete", + location: 3, + printable: false, + shifted: true, + }, +}; + +const lazy = {}; + +XPCOMUtils.defineLazyGetter(lazy, "SHIFT_DATA", () => { + // Initalize the shift mapping + const shiftData = new Map(); + const byCode = new Map(); + for (let [key, props] of Object.entries(KEY_DATA)) { + if (props.code) { + if (!byCode.has(props.code)) { + byCode.set(props.code, [null, null]); + } + byCode.get(props.code)[props.shifted ? 1 : 0] = key; + } + } + for (let [unshifted, shifted] of byCode.values()) { + if (unshifted !== null && shifted !== null) { + shiftData.set(unshifted, shifted); + } + } + return shiftData; +}); + +export const keyData = { + /** + * Get key event data for a given key character. + * + * @param {string} rawKey + * Key for which to get data. This can either be the key codepoint + * itself or one of the codepoints in the range U+E000-U+E05D that + * WebDriver uses to represent keys not corresponding directly to + * a codepoint. + * @returns {object} Key event data object. + */ + getData(rawKey) { + let keyData = { key: rawKey, location: 0, printable: true, shifted: false }; + if (KEY_DATA.hasOwnProperty(rawKey)) { + keyData = { ...keyData, ...KEY_DATA[rawKey] }; + } + return keyData; + }, + + /** + * Get shifted key character for a given key character. + * + * For characters unaffected by the shift key, this returns the input. + * + * @param {string} rawKey Key for which to get shifted key. + * @returns {string} Key string to use when the shift modifier is set. + */ + getShiftedKey(rawKey) { + return lazy.SHIFT_DATA.get(rawKey) ?? rawKey; + }, +}; diff --git a/remote/shared/webdriver/NodeCache.sys.mjs b/remote/shared/webdriver/NodeCache.sys.mjs new file mode 100644 index 0000000000..5290726a8a --- /dev/null +++ b/remote/shared/webdriver/NodeCache.sys.mjs @@ -0,0 +1,169 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + generateUUID: "chrome://remote/content/shared/UUID.sys.mjs", +}); + +/** + * @typedef {object} NodeReferenceDetails + * @property {number} browserId + * @property {number} browsingContextGroupId + * @property {number} browsingContextId + * @property {boolean} isTopBrowsingContext + * @property {WeakRef} nodeWeakRef + */ + +/** + * The class provides a mapping between DOM nodes and a unique node references. + * Supported types of nodes are Element and ShadowRoot. + */ +export class NodeCache { + #nodeIdMap; + #seenNodesMap; + + constructor() { + // node => node id + this.#nodeIdMap = new WeakMap(); + + // Reverse map for faster lookup requests of node references. Values do + // not only contain the resolved DOM node but also further details like + // browsing context information. + // + // node id => node details + this.#seenNodesMap = new Map(); + } + + /** + * Get the number of nodes in the cache. + */ + get size() { + return this.#seenNodesMap.size; + } + + /** + * Get or if not yet existent create a unique reference for an Element or + * ShadowRoot node. + * + * @param {Node} node + * The node to be added. + * + * @returns {string} + * The unique node reference for the DOM node. + */ + getOrCreateNodeReference(node) { + if (!Node.isInstance(node)) { + throw new TypeError(`Failed to create node reference for ${node}`); + } + + let nodeId; + if (this.#nodeIdMap.has(node)) { + // For already known nodes return the cached node id. + nodeId = this.#nodeIdMap.get(node); + } else { + // Bug 1820734: For some Node types like `CDATA` no `ownerGlobal` + // property is available, and as such they cannot be deserialized + // right now. + const browsingContext = node.ownerGlobal?.browsingContext; + + // For not yet cached nodes generate a unique id without curly braces. + nodeId = lazy.generateUUID(); + + const details = { + browserId: browsingContext?.browserId, + browsingContextGroupId: browsingContext?.group.id, + browsingContextId: browsingContext?.id, + isTopBrowsingContext: browsingContext?.parent === null, + nodeWeakRef: Cu.getWeakReference(node), + }; + + this.#nodeIdMap.set(node, nodeId); + this.#seenNodesMap.set(nodeId, details); + } + + return nodeId; + } + + /** + * Clear known DOM nodes. + * + * @param {object=} options + * @param {boolean=} options.all + * Clear all references from any browsing context. Defaults to false. + * @param {BrowsingContext=} options.browsingContext + * Clear all references living in that browsing context. + */ + clear(options = {}) { + const { all = false, browsingContext } = options; + + if (all) { + this.#nodeIdMap = new WeakMap(); + this.#seenNodesMap.clear(); + return; + } + + if (browsingContext) { + for (const [nodeId, identifier] of this.#seenNodesMap.entries()) { + const { browsingContextId, nodeWeakRef } = identifier; + const node = nodeWeakRef.get(); + + if (browsingContextId === browsingContext.id) { + this.#nodeIdMap.delete(node); + this.#seenNodesMap.delete(nodeId); + } + } + + return; + } + + throw new Error(`Requires "browsingContext" or "all" to be set.`); + } + + /** + * Get a DOM node by its unique reference. + * + * @param {BrowsingContext} browsingContext + * The browsing context the node should be part of. + * @param {string} nodeId + * The unique node reference of the DOM node. + * + * @returns {Node|null} + * The DOM node that the unique identifier was generated for or + * `null` if the node does not exist anymore. + */ + getNode(browsingContext, nodeId) { + const nodeDetails = this.getReferenceDetails(nodeId); + + // Check that the node reference is known, and is associated with a + // browsing context that shares the same browsing context group. + if ( + nodeDetails === null || + nodeDetails.browsingContextGroupId !== browsingContext.group.id + ) { + return null; + } + + if (nodeDetails.nodeWeakRef) { + return nodeDetails.nodeWeakRef.get(); + } + + return null; + } + + /** + * Get detailed information for the node reference. + * + * @param {string} nodeId + * + * @returns {NodeReferenceDetails} + * Node details like: browsingContextId + */ + getReferenceDetails(nodeId) { + const details = this.#seenNodesMap.get(nodeId); + + return details !== undefined ? details : null; + } +} diff --git a/remote/shared/webdriver/Session.sys.mjs b/remote/shared/webdriver/Session.sys.mjs new file mode 100644 index 0000000000..d09ad47e79 --- /dev/null +++ b/remote/shared/webdriver/Session.sys.mjs @@ -0,0 +1,344 @@ +/* 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, { + accessibility: "chrome://remote/content/marionette/accessibility.sys.mjs", + allowAllCerts: "chrome://remote/content/marionette/cert.sys.mjs", + Capabilities: "chrome://remote/content/shared/webdriver/Capabilities.sys.mjs", + error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs", + generateUUID: "chrome://remote/content/shared/UUID.sys.mjs", + Log: "chrome://remote/content/shared/Log.sys.mjs", + registerProcessDataActor: + "chrome://remote/content/shared/webdriver/process-actors/WebDriverProcessDataParent.sys.mjs", + RootMessageHandler: + "chrome://remote/content/shared/messagehandler/RootMessageHandler.sys.mjs", + RootMessageHandlerRegistry: + "chrome://remote/content/shared/messagehandler/RootMessageHandlerRegistry.sys.mjs", + unregisterProcessDataActor: + "chrome://remote/content/shared/webdriver/process-actors/WebDriverProcessDataParent.sys.mjs", + WebDriverBiDiConnection: + "chrome://remote/content/webdriver-bidi/WebDriverBiDiConnection.sys.mjs", + WebSocketHandshake: + "chrome://remote/content/server/WebSocketHandshake.sys.mjs", +}); + +XPCOMUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get()); + +/** + * Representation of WebDriver session. + */ +export class WebDriverSession { + /** + * Construct a new WebDriver session. + * + * It is expected that the caller performs the necessary checks on + * the requested capabilities to be WebDriver conforming. The WebDriver + * service offered by Marionette does not match or negotiate capabilities + * beyond type- and bounds checks. + * + * <h3>Capabilities</h3> + * + * <dl> + * <dt><code>acceptInsecureCerts</code> (boolean) + * <dd>Indicates whether untrusted and self-signed TLS certificates + * are implicitly trusted on navigation for the duration of the session. + * + * <dt><code>pageLoadStrategy</code> (string) + * <dd>The page load strategy to use for the current session. Must be + * one of "<tt>none</tt>", "<tt>eager</tt>", and "<tt>normal</tt>". + * + * <dt><code>proxy</code> (Proxy object) + * <dd>Defines the proxy configuration. + * + * <dt><code>setWindowRect</code> (boolean) + * <dd>Indicates whether the remote end supports all of the resizing + * and repositioning commands. + * + * <dt><code>timeouts</code> (Timeouts object) + * <dd>Describes the timeouts imposed on certian session operations. + * + * <dt><code>strictFileInteractability</code> (boolean) + * <dd>Defines the current session’s strict file interactability. + * + * <dt><code>unhandledPromptBehavior</code> (string) + * <dd>Describes the current session’s user prompt handler. Must be one of + * "<tt>accept</tt>", "<tt>accept and notify</tt>", "<tt>dismiss</tt>", + * "<tt>dismiss and notify</tt>", and "<tt>ignore</tt>". Defaults to the + * "<tt>dismiss and notify</tt>" state. + * + * <dt><code>moz:accessibilityChecks</code> (boolean) + * <dd>Run a11y checks when clicking elements. + * + * <dt><code>moz:debuggerAddress</code> (boolean) + * <dd>Indicate that the Chrome DevTools Protocol (CDP) has to be enabled. + * + * <dt><code>moz:useNonSpecCompliantPointerOrigin</code> (boolean) + * <dd>Use the not WebDriver conforming calculation of the pointer origin + * when the origin is an element, and the element center point is used. + * + * <dt><code>moz:webdriverClick</code> (boolean) + * <dd>Use a WebDriver conforming <i>WebDriver::ElementClick</i>. + * </dl> + * + * <h4>Timeouts object</h4> + * + * <dl> + * <dt><code>script</code> (number) + * <dd>Determines when to interrupt a script that is being evaluates. + * + * <dt><code>pageLoad</code> (number) + * <dd>Provides the timeout limit used to interrupt navigation of the + * browsing context. + * + * <dt><code>implicit</code> (number) + * <dd>Gives the timeout of when to abort when locating an element. + * </dl> + * + * <h4>Proxy object</h4> + * + * <dl> + * <dt><code>proxyType</code> (string) + * <dd>Indicates the type of proxy configuration. Must be one + * of "<tt>pac</tt>", "<tt>direct</tt>", "<tt>autodetect</tt>", + * "<tt>system</tt>", or "<tt>manual</tt>". + * + * <dt><code>proxyAutoconfigUrl</code> (string) + * <dd>Defines the URL for a proxy auto-config file if + * <code>proxyType</code> is equal to "<tt>pac</tt>". + * + * <dt><code>httpProxy</code> (string) + * <dd>Defines the proxy host for HTTP traffic when the + * <code>proxyType</code> is "<tt>manual</tt>". + * + * <dt><code>noProxy</code> (string) + * <dd>Lists the adress for which the proxy should be bypassed when + * the <code>proxyType</code> is "<tt>manual</tt>". Must be a JSON + * List containing any number of any of domains, IPv4 addresses, or IPv6 + * addresses. + * + * <dt><code>sslProxy</code> (string) + * <dd>Defines the proxy host for encrypted TLS traffic when the + * <code>proxyType</code> is "<tt>manual</tt>". + * + * <dt><code>socksProxy</code> (string) + * <dd>Defines the proxy host for a SOCKS proxy traffic when the + * <code>proxyType</code> is "<tt>manual</tt>". + * + * <dt><code>socksVersion</code> (string) + * <dd>Defines the SOCKS proxy version when the <code>proxyType</code> is + * "<tt>manual</tt>". It must be any integer between 0 and 255 + * inclusive. + * </dl> + * + * <h3>Example</h3> + * + * Input: + * + * <pre><code> + * {"capabilities": {"acceptInsecureCerts": true}} + * </code></pre> + * + * @param {Object<string, *>=} capabilities + * JSON Object containing any of the recognised capabilities listed + * above. + * + * @param {WebDriverBiDiConnection=} connection + * An optional existing WebDriver BiDi connection to associate with the + * new session. + * + * @throws {SessionNotCreatedError} + * If, for whatever reason, a session could not be created. + */ + constructor(capabilities, connection) { + // WebSocket connections that use this session. This also accounts for + // possible disconnects due to network outages, which require clients + // to reconnect. + this._connections = new Set(); + + this.id = lazy.generateUUID(); + + // Define the HTTP path to query this session via WebDriver BiDi + this.path = `/session/${this.id}`; + + try { + this.capabilities = lazy.Capabilities.fromJSON(capabilities, this.path); + } catch (e) { + throw new lazy.error.SessionNotCreatedError(e); + } + + if (this.capabilities.get("acceptInsecureCerts")) { + lazy.logger.warn( + "TLS certificate errors will be ignored for this session" + ); + lazy.allowAllCerts.enable(); + } + + if (this.proxy.init()) { + lazy.logger.info( + `Proxy settings initialised: ${JSON.stringify(this.proxy)}` + ); + } + + // If we are testing accessibility with marionette, start a11y service in + // chrome first. This will ensure that we do not have any content-only + // services hanging around. + if (this.a11yChecks && lazy.accessibility.service) { + lazy.logger.info("Preemptively starting accessibility service in Chrome"); + } + + // If a connection without an associated session has been specified + // immediately register the newly created session for it. + if (connection) { + connection.registerSession(this); + this._connections.add(connection); + } + + lazy.registerProcessDataActor(); + } + + destroy() { + lazy.allowAllCerts.disable(); + + // Close all open connections which unregister themselves. + this._connections.forEach(connection => connection.close()); + if (this._connections.size > 0) { + lazy.logger.warn( + `Failed to close ${this._connections.size} WebSocket connections` + ); + } + + // Destroy the dedicated MessageHandler instance if we created one. + if (this._messageHandler) { + this._messageHandler.off( + "message-handler-protocol-event", + this._onMessageHandlerProtocolEvent + ); + this._messageHandler.destroy(); + } + + lazy.unregisterProcessDataActor(); + } + + async execute(module, command, params) { + // XXX: At the moment, commands do not describe consistently their destination, + // so we will need a translation step based on a specific command and its params + // in order to extract a destination that can be understood by the MessageHandler. + // + // For now, an option is to send all commands to ROOT, and all BiDi MessageHandler + // modules will therefore need to implement this translation step in the root + // implementation of their module. + const destination = { + type: lazy.RootMessageHandler.type, + }; + if (!this.messageHandler.supportsCommand(module, command, destination)) { + throw new lazy.error.UnknownCommandError(`${module}.${command}`); + } + + return this.messageHandler.handleCommand({ + moduleName: module, + commandName: command, + params, + destination, + }); + } + + get a11yChecks() { + return this.capabilities.get("moz:accessibilityChecks"); + } + + get messageHandler() { + if (!this._messageHandler) { + this._messageHandler = + lazy.RootMessageHandlerRegistry.getOrCreateMessageHandler(this.id); + this._onMessageHandlerProtocolEvent = + this._onMessageHandlerProtocolEvent.bind(this); + this._messageHandler.on( + "message-handler-protocol-event", + this._onMessageHandlerProtocolEvent + ); + } + + return this._messageHandler; + } + + get pageLoadStrategy() { + return this.capabilities.get("pageLoadStrategy"); + } + + get proxy() { + return this.capabilities.get("proxy"); + } + + get strictFileInteractability() { + return this.capabilities.get("strictFileInteractability"); + } + + get timeouts() { + return this.capabilities.get("timeouts"); + } + + set timeouts(timeouts) { + this.capabilities.set("timeouts", timeouts); + } + + get unhandledPromptBehavior() { + return this.capabilities.get("unhandledPromptBehavior"); + } + + /** + * Remove the specified WebDriver BiDi connection. + * + * @param {WebDriverBiDiConnection} connection + */ + removeConnection(connection) { + if (this._connections.has(connection)) { + this._connections.delete(connection); + } else { + lazy.logger.warn("Trying to remove a connection that doesn't exist."); + } + } + + toString() { + return `[object ${this.constructor.name} ${this.id}]`; + } + + // nsIHttpRequestHandler + + /** + * Handle new WebSocket connection requests. + * + * WebSocket clients will attempt to connect to this session at + * `/session/:id`. Hereby a WebSocket upgrade will automatically + * be performed. + * + * @param {Request} request + * HTTP request (httpd.js) + * @param {Response} response + * Response to an HTTP request (httpd.js) + */ + async handle(request, response) { + const webSocket = await lazy.WebSocketHandshake.upgrade(request, response); + const conn = new lazy.WebDriverBiDiConnection( + webSocket, + response._connection + ); + conn.registerSession(this); + this._connections.add(conn); + } + + _onMessageHandlerProtocolEvent(eventName, messageHandlerEvent) { + const { name, data } = messageHandlerEvent; + this._connections.forEach(connection => connection.sendEvent(name, data)); + } + + // XPCOM + + get QueryInterface() { + return ChromeUtils.generateQI(["nsIHttpRequestHandler"]); + } +} diff --git a/remote/shared/webdriver/process-actors/WebDriverProcessDataChild.sys.mjs b/remote/shared/webdriver/process-actors/WebDriverProcessDataChild.sys.mjs new file mode 100644 index 0000000000..47e6429035 --- /dev/null +++ b/remote/shared/webdriver/process-actors/WebDriverProcessDataChild.sys.mjs @@ -0,0 +1,95 @@ +/* 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, { + Log: "chrome://remote/content/shared/Log.sys.mjs", + NodeCache: "chrome://remote/content/shared/webdriver/NodeCache.sys.mjs", +}); + +XPCOMUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get()); + +// Observer to clean-up element references for closed browsing contexts. +class BrowsingContextObserver { + constructor(actor) { + this.actor = actor; + } + + async observe(subject, topic, data) { + if (topic === "browsing-context-discarded") { + this.actor.cleanUp({ browsingContext: subject }); + } + } +} + +export class WebDriverProcessDataChild extends JSProcessActorChild { + #browsingContextObserver; + #nodeCache; + + constructor() { + super(); + + // For now have a single reference store only. Once multiple WebDriver + // sessions are supported, it needs to be hashed by the session id. + this.#nodeCache = new lazy.NodeCache(); + + // Register observer to cleanup element references when a browsing context + // gets destroyed. + this.#browsingContextObserver = new BrowsingContextObserver(this); + Services.obs.addObserver( + this.#browsingContextObserver, + "browsing-context-discarded" + ); + } + + actorCreated() { + lazy.logger.trace( + `WebDriverProcessData actor created for PID ${Services.appinfo.processID}` + ); + } + + didDestroy() { + Services.obs.removeObserver( + this.#browsingContextObserver, + "browsing-context-discarded" + ); + } + + /** + * Clean up all the process specific data. + * + * @param {object=} options + * @param {BrowsingContext=} options.browsingContext + * If specified only clear data living in that browsing context. + */ + cleanUp(options = {}) { + const { browsingContext = null } = options; + + this.#nodeCache.clear({ browsingContext }); + } + + /** + * Get the node cache. + * + * @returns {NodeCache} + * The cache containing DOM node references. + */ + getNodeCache() { + return this.#nodeCache; + } + + async receiveMessage(msg) { + switch (msg.name) { + case "WebDriverProcessDataParent:CleanUp": + return this.cleanUp(msg.data); + default: + return Promise.reject( + new Error(`Unexpected message received: ${msg.name}`) + ); + } + } +} diff --git a/remote/shared/webdriver/process-actors/WebDriverProcessDataParent.sys.mjs b/remote/shared/webdriver/process-actors/WebDriverProcessDataParent.sys.mjs new file mode 100644 index 0000000000..43c48fbec4 --- /dev/null +++ b/remote/shared/webdriver/process-actors/WebDriverProcessDataParent.sys.mjs @@ -0,0 +1,39 @@ +/* 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, { + Log: "chrome://remote/content/shared/Log.sys.mjs", +}); + +XPCOMUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get()); + +/** + * Register the WebDriverProcessData actor that holds session data. + */ +export function registerProcessDataActor() { + try { + ChromeUtils.registerProcessActor("WebDriverProcessData", { + kind: "JSProcessActor", + child: { + esModuleURI: + "chrome://remote/content/shared/webdriver/process-actors/WebDriverProcessDataChild.sys.mjs", + }, + includeParent: true, + }); + } catch (e) { + if (e.name === "NotSupportedError") { + lazy.logger.warn(`WebDriverProcessData actor is already registered!`); + } else { + throw e; + } + } +} + +export function unregisterProcessDataActor() { + ChromeUtils.unregisterProcessActor("WebDriverProcessData"); +} diff --git a/remote/shared/webdriver/test/xpcshell/head.js b/remote/shared/webdriver/test/xpcshell/head.js new file mode 100644 index 0000000000..ddc5573d78 --- /dev/null +++ b/remote/shared/webdriver/test/xpcshell/head.js @@ -0,0 +1,15 @@ +async function doGC() { + // Run GC and CC a few times to make sure that as much as possible is freed. + const numCycles = 3; + for (let i = 0; i < numCycles; i++) { + Cu.forceGC(); + Cu.forceCC(); + await new Promise(resolve => Cu.schedulePreciseShrinkingGC(resolve)); + } + + const MemoryReporter = Cc[ + "@mozilla.org/memory-reporter-manager;1" + ].getService(Ci.nsIMemoryReporterManager); + + await new Promise(resolve => MemoryReporter.minimizeMemoryUsage(resolve)); +} diff --git a/remote/shared/webdriver/test/xpcshell/test_Actions.js b/remote/shared/webdriver/test/xpcshell/test_Actions.js new file mode 100644 index 0000000000..01e345e253 --- /dev/null +++ b/remote/shared/webdriver/test/xpcshell/test_Actions.js @@ -0,0 +1,706 @@ +/* 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/. */ + +"use strict"; + +const { action } = ChromeUtils.importESModule( + "chrome://remote/content/shared/webdriver/Actions.sys.mjs" +); + +const XHTMLNS = "http://www.w3.org/1999/xhtml"; + +const domEl = { + nodeType: 1, + ELEMENT_NODE: 1, + namespaceURI: XHTMLNS, +}; + +add_task(function test_createInputState() { + for (let type of ["none", "key", "pointer" /*"wheel"*/]) { + const state = new action.State(); + const id = "device"; + const actionSequence = { + type, + id, + actions: [], + }; + action.Chain.fromJSON(state, [actionSequence]); + equal(state.inputStateMap.size, 1); + equal(state.inputStateMap.get(id).constructor.type, type); + } +}); + +add_task(function test_defaultPointerParameters() { + let state = new action.State(); + const inputTickActions = [ + { type: "pointer", subtype: "pointerDown", button: 0 }, + ]; + const chain = action.Chain.fromJSON(state, chainForTick(inputTickActions)); + const pointerAction = chain[0][0]; + equal( + state.getInputSource(pointerAction.id).pointer.constructor.type, + "mouse" + ); +}); + +add_task(function test_processPointerParameters() { + for (let subtype of ["pointerDown", "pointerUp"]) { + for (let pointerType of ["foo", "", "get", "Get", 2, {}]) { + const inputTickActions = [ + { + type: "pointer", + parameters: { pointerType }, + subtype, + button: 0, + }, + ]; + let message = `Action sequence with parameters: {pointerType: ${pointerType} subtype: ${subtype}}`; + checkFromJSONErrors(inputTickActions, /Unknown pointerType/, message); + } + } + + for (let pointerType of ["mouse" /*"touch"*/]) { + let state = new action.State(); + const inputTickActions = [ + { + type: "pointer", + parameters: { pointerType }, + subtype: "pointerDown", + button: 0, + }, + ]; + const chain = action.Chain.fromJSON(state, chainForTick(inputTickActions)); + const pointerAction = chain[0][0]; + equal( + state.getInputSource(pointerAction.id).pointer.constructor.type, + pointerType + ); + } +}); + +add_task(function test_processPointerDownAction() { + for (let button of [-1, "a"]) { + const inputTickActions = [ + { type: "pointer", subtype: "pointerDown", button }, + ]; + checkFromJSONErrors( + inputTickActions, + /Expected 'button' .* to be >= 0/, + `pointerDown with {button: ${button}}` + ); + } + let state = new action.State(); + const inputTickActions = [ + { type: "pointer", subtype: "pointerDown", button: 5 }, + ]; + const chain = action.Chain.fromJSON(state, chainForTick(inputTickActions)); + equal(chain[0][0].button, 5); +}); + +add_task(function test_validateActionDurationAndCoordinates() { + for (let [type, subtype] of [ + ["none", "pause"], + ["pointer", "pointerMove"], + ]) { + for (let duration of [-1, "a"]) { + const inputTickActions = [{ type, subtype, duration }]; + checkFromJSONErrors( + inputTickActions, + /Expected 'duration' .* to be >= 0/, + `{subtype} with {duration: ${duration}}` + ); + } + } + for (let name of ["x", "y"]) { + const actionItem = { + type: "pointer", + subtype: "pointerMove", + duration: 5000, + }; + actionItem[name] = "a"; + checkFromJSONErrors( + [actionItem], + /Expected '.*' \(.*\) to be an Integer/, + `${name}: "a", subtype: pointerMove` + ); + } +}); + +add_task(function test_processPointerMoveActionOriginValidation() { + for (let origin of [-1, { a: "blah" }, []]) { + const inputTickActions = [ + { type: "pointer", duration: 5000, subtype: "pointerMove", origin }, + ]; + checkFromJSONErrors( + inputTickActions, + /Expected \'origin\' to be undefined, "viewport", "pointer", or an element/, + `actionItem.origin: (${getTypeString(origin)})` + ); + } +}); + +add_task(function test_processPointerMoveActionOriginStringValidation() { + for (let origin of ["a", "", "get", "Get"]) { + const inputTickActions = [ + { type: "pointer", duration: 5000, subtype: "pointerMove", origin }, + ]; + checkFromJSONErrors( + inputTickActions, + /Expected 'origin' to be undefined, "viewport", "pointer", or an element/, + `actionItem.origin: ${origin}` + ); + } +}); + +add_task(function test_processPointerMoveActionElementOrigin() { + let state = new action.State(); + const inputTickActions = [ + { + type: "pointer", + duration: 5000, + subtype: "pointerMove", + origin: domEl, + x: 0, + y: 0, + }, + ]; + const chain = action.Chain.fromJSON(state, chainForTick(inputTickActions)); + deepEqual(chain[0][0].origin.element, domEl); +}); + +add_task(function test_processPointerMoveActionDefaultOrigin() { + let state = new action.State(); + const inputTickActions = [ + { type: "pointer", duration: 5000, subtype: "pointerMove", x: 0, y: 0 }, + ]; + const chain = action.Chain.fromJSON(state, chainForTick(inputTickActions)); + // The default is viewport coordinates which have an origin at [0,0] and don't depend on inputSource + deepEqual(chain[0][0].origin.getOriginCoordinates(state, null, null), { + x: 0, + y: 0, + }); +}); + +add_task(function test_processPointerMoveAction() { + let state = new action.State(); + const actionItems = [ + { + duration: 5000, + type: "pointerMove", + origin: undefined, + x: 0, + y: 0, + }, + { + duration: undefined, + type: "pointerMove", + origin: domEl, + x: 0, + y: 0, + }, + { + duration: 5000, + type: "pointerMove", + x: 1, + y: 2, + origin: undefined, + }, + ]; + const actionSequence = { + id: "some_id", + type: "pointer", + actions: actionItems, + }; + let chain = action.Chain.fromJSON(state, [actionSequence]); + equal(chain.length, actionItems.length); + for (let i = 0; i < actionItems.length; i++) { + let actual = chain[i][0]; + let expected = actionItems[i]; + equal(actual.duration, expected.duration); + equal(actual.x, expected.x); + equal(actual.y, expected.y); + + let originClass; + if (expected.origin === undefined || expected.origin == "viewport") { + originClass = "ViewportOrigin"; + } else if (expected.origin === "pointer") { + originClass = "PointerOrigin"; + } else { + originClass = "ElementOrigin"; + } + deepEqual(actual.origin.constructor.name, originClass); + } +}); + +add_task(function test_computePointerDestinationViewport() { + const state = new action.State(); + const inputTickActions = [ + { + type: "pointer", + subtype: "pointerMove", + x: 100, + y: 200, + origin: "viewport", + }, + ]; + const chain = action.Chain.fromJSON(state, chainForTick(inputTickActions)); + const actionItem = chain[0][0]; + const inputSource = state.getInputSource(actionItem.id); + // these values should not affect the outcome + inputSource.x = "99"; + inputSource.y = "10"; + const target = actionItem.origin.getTargetCoordinates( + state, + inputSource, + [actionItem.x, actionItem.y], + null + ); + equal(actionItem.x, target[0]); + equal(actionItem.y, target[1]); +}); + +add_task(function test_computePointerDestinationPointer() { + const state = new action.State(); + const inputTickActions = [ + { + type: "pointer", + subtype: "pointerMove", + x: 100, + y: 200, + origin: "pointer", + }, + ]; + const chain = action.Chain.fromJSON(state, chainForTick(inputTickActions)); + const actionItem = chain[0][0]; + const inputSource = state.getInputSource(actionItem.id); + inputSource.x = 10; + inputSource.y = 99; + const target = actionItem.origin.getTargetCoordinates( + state, + inputSource, + [actionItem.x, actionItem.y], + null + ); + equal(actionItem.x + inputSource.x, target[0]); + equal(actionItem.y + inputSource.y, target[1]); +}); + +add_task(function test_processPointerAction() { + for (let pointerType of ["mouse", "touch"]) { + const actionItems = [ + { + duration: 2000, + type: "pause", + }, + { + type: "pointerMove", + duration: 2000, + x: 0, + y: 0, + }, + { + type: "pointerUp", + button: 1, + }, + ]; + let actionSequence = { + type: "pointer", + id: "some_id", + parameters: { + pointerType, + }, + actions: actionItems, + }; + const state = new action.State(); + const chain = action.Chain.fromJSON(state, [actionSequence]); + equal(chain.length, actionItems.length); + for (let i = 0; i < actionItems.length; i++) { + const actual = chain[i][0]; + const expected = actionItems[i]; + equal(actual.type, expected.type === "pause" ? "none" : "pointer"); + equal(actual.subtype, expected.type); + equal(actual.id, actionSequence.id); + if (expected.type === "pointerUp") { + equal(actual.button, expected.button); + } else { + equal(actual.duration, expected.duration); + } + if (expected.type !== "pause") { + equal( + state.getInputSource(actual.id).pointer.constructor.type, + pointerType + ); + } + } + } +}); + +add_task(function test_processPauseAction() { + for (let type of ["none", "key", "pointer"]) { + const state = new action.State(); + const actionSequence = { + type, + id: "some_id", + actions: [{ type: "pause", duration: 5000 }], + }; + const actionItem = action.Chain.fromJSON(state, [actionSequence])[0][0]; + equal(actionItem.type, "none"); + equal(actionItem.subtype, "pause"); + equal(actionItem.id, "some_id"); + equal(actionItem.duration, 5000); + } + const state = new action.State(); + const actionSequence = { + type: "none", + id: "some_id", + actions: [{ type: "pause" }], + }; + const actionItem = action.Chain.fromJSON(state, [actionSequence])[0][0]; + equal(actionItem.duration, undefined); +}); + +add_task(function test_processActionSubtypeValidation() { + for (let type of ["none", "key", "pointer"]) { + const message = `type: ${type}, subtype: dancing`; + const inputTickActions = [{ type, subtype: "dancing" }]; + checkFromJSONErrors( + inputTickActions, + new RegExp(`Unknown subtype dancing for type ${type}`), + message + ); + } +}); + +add_task(function test_processKeyActionDown() { + for (let value of [-1, undefined, [], ["a"], { length: 1 }, null]) { + const inputTickActions = [{ type: "key", subtype: "keyDown", value }]; + const message = `actionItem.value: (${getTypeString(value)})`; + checkFromJSONErrors( + inputTickActions, + /Expected 'value' to be a string that represents single code point/, + message + ); + } + + const state = new action.State(); + const actionSequence = { + type: "key", + id: "keyboard", + actions: [{ type: "keyDown", value: "\uE004" }], + }; + const actionItem = action.Chain.fromJSON(state, [actionSequence])[0][0]; + + equal(actionItem.type, "key"); + equal(actionItem.id, "keyboard"); + equal(actionItem.subtype, "keyDown"); + equal(actionItem.value, "\ue004"); +}); + +add_task(function test_processInputSourceActionSequenceValidation() { + checkFromJSONErrors( + [{ type: "swim", subtype: "pause", id: "some id" }], + /Unknown action type/, + "actionSequence type: swim" + ); + + checkFromJSONErrors( + [{ type: "none", subtype: "pause", id: -1 }], + /Expected 'id' to be a string/, + "actionSequence id: -1" + ); + + checkFromJSONErrors( + [{ type: "none", subtype: "pause", id: undefined }], + /Expected 'id' to be a string/, + "actionSequence id: undefined" + ); + + const state = new action.State(); + const actionSequence = [ + { type: "none", subtype: "pause", id: "some_id", actions: -1 }, + ]; + const errorRegex = /Expected 'actionSequence.actions' to be an array/; + const message = "actionSequence actions: -1"; + + Assert.throws( + () => action.Chain.fromJSON(state, actionSequence), + /InvalidArgumentError/, + message + ); + Assert.throws( + () => action.Chain.fromJSON(state, actionSequence), + errorRegex, + message + ); +}); + +add_task(function test_processInputSourceActionSequence() { + const state = new action.State(); + const actionItem = { type: "pause", duration: 5 }; + const actionSequence = { + type: "none", + id: "some id", + actions: [actionItem], + }; + const chain = action.Chain.fromJSON(state, [actionSequence]); + equal(chain.length, 1); + const tickActions = chain[0]; + equal(tickActions.length, 1); + equal(tickActions[0].type, "none"); + equal(tickActions[0].subtype, "pause"); + equal(tickActions[0].duration, 5); + equal(tickActions[0].id, "some id"); +}); + +add_task(function test_processInputSourceActionSequencePointer() { + const state = new action.State(); + const actionItem = { type: "pointerDown", button: 1 }; + const actionSequence = { + type: "pointer", + id: "9", + actions: [actionItem], + parameters: { + pointerType: "mouse", // TODO "pen" + }, + }; + const chain = action.Chain.fromJSON(state, [actionSequence]); + equal(chain.length, 1); + const tickActions = chain[0]; + equal(tickActions.length, 1); + equal(tickActions[0].type, "pointer"); + equal(tickActions[0].subtype, "pointerDown"); + equal(tickActions[0].button, 1); + equal(tickActions[0].id, "9"); + const inputSource = state.getInputSource(tickActions[0].id); + equal(inputSource.constructor.type, "pointer"); + equal(inputSource.pointer.constructor.type, "mouse"); +}); + +add_task(function test_processInputSourceActionSequenceKey() { + const state = new action.State(); + const actionItem = { type: "keyUp", value: "a" }; + const actionSequence = { + type: "key", + id: "9", + actions: [actionItem], + }; + const chain = action.Chain.fromJSON(state, [actionSequence]); + equal(chain.length, 1); + const tickActions = chain[0]; + equal(tickActions.length, 1); + equal(tickActions[0].type, "key"); + equal(tickActions[0].subtype, "keyUp"); + equal(tickActions[0].value, "a"); + equal(tickActions[0].id, "9"); +}); + +add_task(function test_processInputSourceActionSequenceInputStateMap() { + const state = new action.State(); + const id = "1"; + const actionItem = { type: "pause", duration: 5000 }; + const actionSequence = { + type: "key", + id, + actions: [actionItem], + }; + action.Chain.fromJSON(state, [actionSequence]); + equal(state.inputStateMap.size, 1); + equal(state.inputStateMap.get(id).constructor.type, "key"); + + // Construct a different state with the same input id + const state1 = new action.State(); + const actionItem1 = { type: "pointerDown", button: 0 }; + const actionSequence1 = { + type: "pointer", + id, + actions: [actionItem1], + }; + action.Chain.fromJSON(state1, [actionSequence1]); + equal(state1.inputStateMap.size, 1); + + // Overwrite the state in the initial map with one of a different type + state.inputStateMap.set(id, state1.inputStateMap.get(id)); + equal(state.inputStateMap.get(id).constructor.type, "pointer"); + + const message = "Wrong state for input id type"; + Assert.throws( + () => action.Chain.fromJSON(state, [actionSequence]), + /InvalidArgumentError/, + message + ); + Assert.throws( + () => action.Chain.fromJSON(state, [actionSequence]), + /Expected input source 1 to be type pointer, got key/, + message + ); +}); + +add_task(function test_extractActionChainValidation() { + for (let actions of [-1, "a", undefined, null]) { + const state = new action.State(); + let message = `actions: ${getTypeString(actions)}`; + Assert.throws( + () => action.Chain.fromJSON(state, actions), + /InvalidArgumentError/, + message + ); + Assert.throws( + () => action.Chain.fromJSON(state, actions), + /Expected 'actions' to be an array/, + message + ); + } +}); + +add_task(function test_extractActionChainEmpty() { + const state = new action.State(); + deepEqual(action.Chain.fromJSON(state, []), []); +}); + +add_task(function test_extractActionChain_oneTickOneInput() { + const state = new action.State(); + const actionItem = { type: "pause", duration: 5000 }; + const actionSequence = { + type: "none", + id: "some id", + actions: [actionItem], + }; + const actionsByTick = action.Chain.fromJSON(state, [actionSequence]); + equal(1, actionsByTick.length); + equal(1, actionsByTick[0].length); + equal(actionsByTick[0][0].id, actionSequence.id); + equal(actionsByTick[0][0].type, "none"); + equal(actionsByTick[0][0].subtype, "pause"); + equal(actionsByTick[0][0].duration, actionItem.duration); +}); + +add_task(function test_extractActionChain_twoAndThreeTicks() { + const state = new action.State(); + const mouseActionItems = [ + { + type: "pointerDown", + button: 2, + }, + { + type: "pointerUp", + button: 2, + }, + ]; + const mouseActionSequence = { + type: "pointer", + id: "7", + actions: mouseActionItems, + parameters: { + pointerType: "mouse", + }, + }; + const keyActionItems = [ + { + type: "keyDown", + value: "a", + }, + { + type: "pause", + duration: 4, + }, + { + type: "keyUp", + value: "a", + }, + ]; + let keyActionSequence = { + type: "key", + id: "1", + actions: keyActionItems, + }; + let actionsByTick = action.Chain.fromJSON(state, [ + keyActionSequence, + mouseActionSequence, + ]); + // number of ticks is same as longest action sequence + equal(keyActionItems.length, actionsByTick.length); + equal(2, actionsByTick[0].length); + equal(2, actionsByTick[1].length); + equal(1, actionsByTick[2].length); + + equal(actionsByTick[2][0].id, keyActionSequence.id); + equal(actionsByTick[2][0].type, "key"); + equal(actionsByTick[2][0].subtype, "keyUp"); +}); + +add_task(function test_computeTickDuration() { + const state = new action.State(); + const expected = 8000; + const inputTickActions = [ + { type: "none", subtype: "pause", duration: 5000 }, + { type: "key", subtype: "pause", duration: 1000 }, + { type: "pointer", subtype: "pointerMove", duration: 6000, x: 0, y: 0 }, + // invalid because keyDown should not have duration, so duration should be ignored. + { type: "key", subtype: "keyDown", duration: 100000, value: "a" }, + { type: "pointer", subtype: "pause", duration: expected }, + { type: "pointer", subtype: "pointerUp", button: 0 }, + ]; + const chain = action.Chain.fromJSON(state, chainForTick(inputTickActions)); + equal(1, chain.length); + const tickActions = chain[0]; + equal(expected, tickActions.getDuration()); +}); + +add_task(function test_computeTickDuration_noDurations() { + const state = new action.State(); + const inputTickActions = [ + // invalid because keyDown should not have duration, so duration should be ignored. + { type: "key", subtype: "keyDown", duration: 100000, value: "a" }, + // undefined duration permitted + { type: "none", subtype: "pause" }, + { type: "pointer", subtype: "pointerMove", button: 0, x: 0, y: 0 }, + { type: "pointer", subtype: "pointerDown", button: 0 }, + { type: "key", subtype: "keyUp", value: "a" }, + ]; + const chain = action.Chain.fromJSON(state, chainForTick(inputTickActions)); + equal(0, chain[0].getDuration()); +}); + +// helpers +function getTypeString(obj) { + return Object.prototype.toString.call(obj); +} + +function checkFromJSONErrors(inputTickActions, regex, message) { + const state = new action.State(); + + if (typeof message == "undefined") { + message = `fromJSON`; + } + Assert.throws( + () => action.Chain.fromJSON(state, chainForTick(inputTickActions)), + /InvalidArgumentError/, + message + ); + Assert.throws( + () => action.Chain.fromJSON(state, chainForTick(inputTickActions)), + regex, + message + ); +} + +function chainForTick(tickActions) { + const actions = []; + let lastId = 0; + for (let { type, subtype, parameters, ...props } of tickActions) { + let id; + if (!props.hasOwnProperty("id")) { + id = `${type}_${lastId++}`; + } else { + id = props.id; + delete props.id; + } + const inputAction = { type, id, actions: [{ type: subtype, ...props }] }; + if (parameters !== undefined) { + inputAction.parameters = parameters; + } + actions.push(inputAction); + } + return actions; +} diff --git a/remote/shared/webdriver/test/xpcshell/test_Assert.js b/remote/shared/webdriver/test/xpcshell/test_Assert.js new file mode 100644 index 0000000000..1673b645f4 --- /dev/null +++ b/remote/shared/webdriver/test/xpcshell/test_Assert.js @@ -0,0 +1,183 @@ +/* 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/. */ + +"use strict"; +/* eslint-disable no-array-constructor, no-new-object */ + +const { assert } = ChromeUtils.importESModule( + "chrome://remote/content/shared/webdriver/Assert.sys.mjs" +); +const { error } = ChromeUtils.importESModule( + "chrome://remote/content/shared/webdriver/Errors.sys.mjs" +); + +add_task(function test_session() { + assert.session({ id: "foo" }); + + const invalidTypes = [ + null, + undefined, + [], + {}, + { id: undefined }, + { id: null }, + { id: true }, + { id: 1 }, + { id: [] }, + { id: {} }, + ]; + + for (const invalidType of invalidTypes) { + Assert.throws(() => assert.session(invalidType), /InvalidSessionIDError/); + } + + Assert.throws(() => assert.session({ id: null }, "custom"), /custom/); +}); + +add_task(function test_platforms() { + // at least one will fail + let raised; + for (let fn of [assert.desktop, assert.mobile]) { + try { + fn(); + } catch (e) { + raised = e; + } + } + ok(raised instanceof error.UnsupportedOperationError); +}); + +add_task(function test_noUserPrompt() { + assert.noUserPrompt(null); + assert.noUserPrompt(undefined); + Assert.throws(() => assert.noUserPrompt({}), /UnexpectedAlertOpenError/); + Assert.throws(() => assert.noUserPrompt({}, "custom"), /custom/); +}); + +add_task(function test_defined() { + assert.defined({}); + Assert.throws(() => assert.defined(undefined), /InvalidArgumentError/); + Assert.throws(() => assert.noUserPrompt({}, "custom"), /custom/); +}); + +add_task(function test_number() { + assert.number(1); + assert.number(0); + assert.number(-1); + assert.number(1.2); + for (let i of ["foo", "1", {}, [], NaN, Infinity, undefined]) { + Assert.throws(() => assert.number(i), /InvalidArgumentError/); + } + + Assert.throws(() => assert.number("foo", "custom"), /custom/); +}); + +add_task(function test_callable() { + assert.callable(function () {}); + assert.callable(() => {}); + + for (let typ of [undefined, "", true, {}, []]) { + Assert.throws(() => assert.callable(typ), /InvalidArgumentError/); + } + + Assert.throws(() => assert.callable("foo", "custom"), /custom/); +}); + +add_task(function test_integer() { + assert.integer(1); + assert.integer(0); + assert.integer(-1); + Assert.throws(() => assert.integer("foo"), /InvalidArgumentError/); + Assert.throws(() => assert.integer(1.2), /InvalidArgumentError/); + + Assert.throws(() => assert.integer("foo", "custom"), /custom/); +}); + +add_task(function test_positiveInteger() { + assert.positiveInteger(1); + assert.positiveInteger(0); + Assert.throws(() => assert.positiveInteger(-1), /InvalidArgumentError/); + Assert.throws(() => assert.positiveInteger("foo"), /InvalidArgumentError/); + Assert.throws(() => assert.positiveInteger("foo", "custom"), /custom/); +}); + +add_task(function test_positiveNumber() { + assert.positiveNumber(1); + assert.positiveNumber(0); + assert.positiveNumber(1.1); + assert.positiveNumber(Number.MAX_VALUE); + // eslint-disable-next-line no-loss-of-precision + Assert.throws(() => assert.positiveNumber(1.8e308), /InvalidArgumentError/); + Assert.throws(() => assert.positiveNumber(-1), /InvalidArgumentError/); + Assert.throws(() => assert.positiveNumber(Infinity), /InvalidArgumentError/); + Assert.throws(() => assert.positiveNumber("foo"), /InvalidArgumentError/); + Assert.throws(() => assert.positiveNumber("foo", "custom"), /custom/); +}); + +add_task(function test_boolean() { + assert.boolean(true); + assert.boolean(false); + Assert.throws(() => assert.boolean("false"), /InvalidArgumentError/); + Assert.throws(() => assert.boolean(undefined), /InvalidArgumentError/); + Assert.throws(() => assert.boolean(undefined, "custom"), /custom/); +}); + +add_task(function test_string() { + assert.string("foo"); + assert.string(`bar`); + Assert.throws(() => assert.string(42), /InvalidArgumentError/); + Assert.throws(() => assert.string(42, "custom"), /custom/); +}); + +add_task(function test_open() { + assert.open({ currentWindowGlobal: {} }); + + for (let typ of [null, undefined, { currentWindowGlobal: null }]) { + Assert.throws(() => assert.open(typ), /NoSuchWindowError/); + } + + Assert.throws(() => assert.open(null, "custom"), /custom/); +}); + +add_task(function test_object() { + assert.object({}); + assert.object(new Object()); + for (let typ of [42, "foo", true, null, undefined]) { + Assert.throws(() => assert.object(typ), /InvalidArgumentError/); + } + + Assert.throws(() => assert.object(null, "custom"), /custom/); +}); + +add_task(function test_in() { + assert.in("foo", { foo: 42 }); + for (let typ of [{}, 42, true, null, undefined]) { + Assert.throws(() => assert.in("foo", typ), /InvalidArgumentError/); + } + + Assert.throws(() => assert.in("foo", { bar: 42 }, "custom"), /custom/); +}); + +add_task(function test_array() { + assert.array([]); + assert.array(new Array()); + Assert.throws(() => assert.array(42), /InvalidArgumentError/); + Assert.throws(() => assert.array({}), /InvalidArgumentError/); + + Assert.throws(() => assert.array(42, "custom"), /custom/); +}); + +add_task(function test_that() { + equal(1, assert.that(n => n + 1)(1)); + Assert.throws(() => assert.that(() => false)(), /InvalidArgumentError/); + Assert.throws(() => assert.that(val => val)(false), /InvalidArgumentError/); + Assert.throws( + () => assert.that(val => val, "foo", error.SessionNotCreatedError)(false), + /SessionNotCreatedError/ + ); + + Assert.throws(() => assert.that(() => false, "custom")(), /custom/); +}); + +/* eslint-enable no-array-constructor, no-new-object */ diff --git a/remote/shared/webdriver/test/xpcshell/test_Capabilities.js b/remote/shared/webdriver/test/xpcshell/test_Capabilities.js new file mode 100644 index 0000000000..e88c0126c9 --- /dev/null +++ b/remote/shared/webdriver/test/xpcshell/test_Capabilities.js @@ -0,0 +1,585 @@ +/* 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/. */ + +"use strict"; + +const { Preferences } = ChromeUtils.importESModule( + "resource://gre/modules/Preferences.sys.mjs" +); + +const { AppInfo } = ChromeUtils.importESModule( + "chrome://remote/content/shared/AppInfo.sys.mjs" +); +const { error } = ChromeUtils.importESModule( + "chrome://remote/content/shared/webdriver/Errors.sys.mjs" +); +const { + Capabilities, + PageLoadStrategy, + Proxy, + Timeouts, + UnhandledPromptBehavior, +} = ChromeUtils.importESModule( + "chrome://remote/content/shared/webdriver/Capabilities.sys.mjs" +); + +add_task(function test_Timeouts_ctor() { + let ts = new Timeouts(); + equal(ts.implicit, 0); + equal(ts.pageLoad, 300000); + equal(ts.script, 30000); +}); + +add_task(function test_Timeouts_toString() { + equal(new Timeouts().toString(), "[object Timeouts]"); +}); + +add_task(function test_Timeouts_toJSON() { + let ts = new Timeouts(); + deepEqual(ts.toJSON(), { implicit: 0, pageLoad: 300000, script: 30000 }); +}); + +add_task(function test_Timeouts_fromJSON() { + let json = { + implicit: 0, + pageLoad: 2.0, + script: Number.MAX_SAFE_INTEGER, + }; + let ts = Timeouts.fromJSON(json); + equal(ts.implicit, json.implicit); + equal(ts.pageLoad, json.pageLoad); + equal(ts.script, json.script); +}); + +add_task(function test_Timeouts_fromJSON_unrecognised_field() { + let json = { + sessionId: "foobar", + }; + try { + Timeouts.fromJSON(json); + } catch (e) { + equal(e.name, error.InvalidArgumentError.name); + equal(e.message, "Unrecognised timeout: sessionId"); + } +}); + +add_task(function test_Timeouts_fromJSON_invalid_types() { + for (let value of [null, [], {}, false, "10", 2.5]) { + Assert.throws( + () => Timeouts.fromJSON({ implicit: value }), + /InvalidArgumentError/ + ); + } +}); + +add_task(function test_Timeouts_fromJSON_bounds() { + for (let value of [-1, Number.MAX_SAFE_INTEGER + 1]) { + Assert.throws( + () => Timeouts.fromJSON({ script: value }), + /InvalidArgumentError/ + ); + } +}); + +add_task(function test_PageLoadStrategy() { + equal(PageLoadStrategy.None, "none"); + equal(PageLoadStrategy.Eager, "eager"); + equal(PageLoadStrategy.Normal, "normal"); +}); + +add_task(function test_Proxy_ctor() { + let p = new Proxy(); + let props = [ + "proxyType", + "httpProxy", + "sslProxy", + "socksProxy", + "socksVersion", + "proxyAutoconfigUrl", + ]; + for (let prop of props) { + ok(prop in p, `${prop} in ${JSON.stringify(props)}`); + equal(p[prop], null); + } +}); + +add_task(function test_Proxy_init() { + let p = new Proxy(); + + // no changed made, and 5 (system) is default + equal(p.init(), false); + equal(Preferences.get("network.proxy.type"), 5); + + // pac + p.proxyType = "pac"; + p.proxyAutoconfigUrl = "http://localhost:1234"; + ok(p.init()); + + equal(Preferences.get("network.proxy.type"), 2); + equal( + Preferences.get("network.proxy.autoconfig_url"), + "http://localhost:1234" + ); + + // direct + p = new Proxy(); + p.proxyType = "direct"; + ok(p.init()); + equal(Preferences.get("network.proxy.type"), 0); + + // autodetect + p = new Proxy(); + p.proxyType = "autodetect"; + ok(p.init()); + equal(Preferences.get("network.proxy.type"), 4); + + // system + p = new Proxy(); + p.proxyType = "system"; + ok(p.init()); + equal(Preferences.get("network.proxy.type"), 5); + + // manual + for (let proxy of ["http", "ssl", "socks"]) { + p = new Proxy(); + p.proxyType = "manual"; + p.noProxy = ["foo", "bar"]; + p[`${proxy}Proxy`] = "foo"; + p[`${proxy}ProxyPort`] = 42; + if (proxy === "socks") { + p[`${proxy}Version`] = 4; + } + + ok(p.init()); + equal(Preferences.get("network.proxy.type"), 1); + equal(Preferences.get("network.proxy.no_proxies_on"), "foo, bar"); + equal(Preferences.get(`network.proxy.${proxy}`), "foo"); + equal(Preferences.get(`network.proxy.${proxy}_port`), 42); + if (proxy === "socks") { + equal(Preferences.get(`network.proxy.${proxy}_version`), 4); + } + } + + // empty no proxy should reset default exclustions + p = new Proxy(); + p.proxyType = "manual"; + p.noProxy = []; + ok(p.init()); + equal(Preferences.get("network.proxy.no_proxies_on"), ""); +}); + +add_task(function test_Proxy_toString() { + equal(new Proxy().toString(), "[object Proxy]"); +}); + +add_task(function test_Proxy_toJSON() { + let p = new Proxy(); + deepEqual(p.toJSON(), {}); + + // autoconfig url + p = new Proxy(); + p.proxyType = "pac"; + p.proxyAutoconfigUrl = "foo"; + deepEqual(p.toJSON(), { proxyType: "pac", proxyAutoconfigUrl: "foo" }); + + // manual proxy + p = new Proxy(); + p.proxyType = "manual"; + deepEqual(p.toJSON(), { proxyType: "manual" }); + + for (let proxy of ["httpProxy", "sslProxy", "socksProxy"]) { + let expected = { proxyType: "manual" }; + + p = new Proxy(); + p.proxyType = "manual"; + + if (proxy == "socksProxy") { + p.socksVersion = 5; + expected.socksVersion = 5; + } + + // without port + p[proxy] = "foo"; + expected[proxy] = "foo"; + deepEqual(p.toJSON(), expected); + + // with port + p[proxy] = "foo"; + p[`${proxy}Port`] = 0; + expected[proxy] = "foo:0"; + deepEqual(p.toJSON(), expected); + + p[`${proxy}Port`] = 42; + expected[proxy] = "foo:42"; + deepEqual(p.toJSON(), expected); + + // add brackets for IPv6 address as proxy hostname + p[proxy] = "2001:db8::1"; + p[`${proxy}Port`] = 42; + expected[proxy] = "foo:42"; + expected[proxy] = "[2001:db8::1]:42"; + deepEqual(p.toJSON(), expected); + } + + // noProxy: add brackets for IPv6 address + p = new Proxy(); + p.proxyType = "manual"; + p.noProxy = ["2001:db8::1"]; + let expected = { proxyType: "manual", noProxy: "[2001:db8::1]" }; + deepEqual(p.toJSON(), expected); +}); + +add_task(function test_Proxy_fromJSON() { + let p = new Proxy(); + deepEqual(p, Proxy.fromJSON(undefined)); + deepEqual(p, Proxy.fromJSON(null)); + + for (let typ of [true, 42, "foo", []]) { + Assert.throws(() => Proxy.fromJSON(typ), /InvalidArgumentError/); + } + + // must contain a valid proxyType + Assert.throws(() => Proxy.fromJSON({}), /InvalidArgumentError/); + Assert.throws( + () => Proxy.fromJSON({ proxyType: "foo" }), + /InvalidArgumentError/ + ); + + // autoconfig url + for (let url of [true, 42, [], {}]) { + Assert.throws( + () => Proxy.fromJSON({ proxyType: "pac", proxyAutoconfigUrl: url }), + /InvalidArgumentError/ + ); + } + + p = new Proxy(); + p.proxyType = "pac"; + p.proxyAutoconfigUrl = "foo"; + deepEqual(p, Proxy.fromJSON({ proxyType: "pac", proxyAutoconfigUrl: "foo" })); + + // manual proxy + p = new Proxy(); + p.proxyType = "manual"; + deepEqual(p, Proxy.fromJSON({ proxyType: "manual" })); + + for (let proxy of ["httpProxy", "sslProxy", "socksProxy"]) { + let manual = { proxyType: "manual" }; + + // invalid hosts + for (let host of [ + true, + 42, + [], + {}, + null, + "http://foo", + "foo:-1", + "foo:65536", + "foo/test", + "foo#42", + "foo?foo=bar", + "2001:db8::1", + ]) { + manual[proxy] = host; + Assert.throws(() => Proxy.fromJSON(manual), /InvalidArgumentError/); + } + + p = new Proxy(); + p.proxyType = "manual"; + if (proxy == "socksProxy") { + manual.socksVersion = 5; + p.socksVersion = 5; + } + + let host_map = { + "foo:1": { hostname: "foo", port: 1 }, + "foo:21": { hostname: "foo", port: 21 }, + "foo:80": { hostname: "foo", port: 80 }, + "foo:443": { hostname: "foo", port: 443 }, + "foo:65535": { hostname: "foo", port: 65535 }, + "127.0.0.1:42": { hostname: "127.0.0.1", port: 42 }, + "[2001:db8::1]:42": { hostname: "2001:db8::1", port: "42" }, + }; + + // valid proxy hosts with port + for (let host in host_map) { + manual[proxy] = host; + + p[`${proxy}`] = host_map[host].hostname; + p[`${proxy}Port`] = host_map[host].port; + + deepEqual(p, Proxy.fromJSON(manual)); + } + + // Without a port the default port of the scheme is used + for (let host of ["foo", "foo:"]) { + manual[proxy] = host; + + // For socks no default port is available + p[proxy] = `foo`; + if (proxy === "socksProxy") { + p[`${proxy}Port`] = null; + } else { + let default_ports = { httpProxy: 80, sslProxy: 443 }; + + p[`${proxy}Port`] = default_ports[proxy]; + } + + deepEqual(p, Proxy.fromJSON(manual)); + } + } + + // missing required socks version + Assert.throws( + () => Proxy.fromJSON({ proxyType: "manual", socksProxy: "foo:1234" }), + /InvalidArgumentError/ + ); + + // Bug 1703805: Since Firefox 90 ftpProxy is no longer supported + Assert.throws( + () => Proxy.fromJSON({ proxyType: "manual", ftpProxy: "foo:21" }), + /InvalidArgumentError/ + ); + + // noProxy: invalid settings + for (let noProxy of [true, 42, {}, null, "foo", [true], [42], [{}], [null]]) { + Assert.throws( + () => Proxy.fromJSON({ proxyType: "manual", noProxy }), + /InvalidArgumentError/ + ); + } + + // noProxy: valid settings + p = new Proxy(); + p.proxyType = "manual"; + for (let noProxy of [[], ["foo"], ["foo", "bar"], ["127.0.0.1"]]) { + let manual = { proxyType: "manual", noProxy }; + p.noProxy = noProxy; + deepEqual(p, Proxy.fromJSON(manual)); + } + + // noProxy: IPv6 needs brackets removed + p = new Proxy(); + p.proxyType = "manual"; + p.noProxy = ["2001:db8::1"]; + let manual = { proxyType: "manual", noProxy: ["[2001:db8::1]"] }; + deepEqual(p, Proxy.fromJSON(manual)); +}); + +add_task(function test_UnhandledPromptBehavior() { + equal(UnhandledPromptBehavior.Accept, "accept"); + equal(UnhandledPromptBehavior.AcceptAndNotify, "accept and notify"); + equal(UnhandledPromptBehavior.Dismiss, "dismiss"); + equal(UnhandledPromptBehavior.DismissAndNotify, "dismiss and notify"); + equal(UnhandledPromptBehavior.Ignore, "ignore"); +}); + +add_task(function test_Capabilities_ctor() { + let caps = new Capabilities(); + ok(caps.has("browserName")); + ok(caps.has("browserVersion")); + ok(caps.has("platformName")); + ok(["linux", "mac", "windows", "android"].includes(caps.get("platformName"))); + equal(PageLoadStrategy.Normal, caps.get("pageLoadStrategy")); + equal(false, caps.get("acceptInsecureCerts")); + ok(caps.get("timeouts") instanceof Timeouts); + ok(caps.get("proxy") instanceof Proxy); + equal(caps.get("setWindowRect"), !AppInfo.isAndroid); + equal(caps.get("strictFileInteractability"), false); + equal(caps.get("webSocketUrl"), null); + + equal(false, caps.get("moz:accessibilityChecks")); + ok(caps.has("moz:buildID")); + ok(caps.has("moz:debuggerAddress")); + ok(caps.has("moz:platformVersion")); + ok(caps.has("moz:processID")); + ok(caps.has("moz:profile")); + equal(false, caps.get("moz:useNonSpecCompliantPointerOrigin")); + equal(true, caps.get("moz:webdriverClick")); +}); + +add_task(function test_Capabilities_toString() { + equal("[object Capabilities]", new Capabilities().toString()); +}); + +add_task(function test_Capabilities_toJSON() { + let caps = new Capabilities(); + let json = caps.toJSON(); + + equal(caps.get("browserName"), json.browserName); + equal(caps.get("browserVersion"), json.browserVersion); + equal(caps.get("platformName"), json.platformName); + equal(caps.get("pageLoadStrategy"), json.pageLoadStrategy); + equal(caps.get("acceptInsecureCerts"), json.acceptInsecureCerts); + deepEqual(caps.get("proxy").toJSON(), json.proxy); + deepEqual(caps.get("timeouts").toJSON(), json.timeouts); + equal(caps.get("setWindowRect"), json.setWindowRect); + equal(caps.get("strictFileInteractability"), json.strictFileInteractability); + equal(caps.get("webSocketUrl"), json.webSocketUrl); + + equal(caps.get("moz:accessibilityChecks"), json["moz:accessibilityChecks"]); + equal(caps.get("moz:buildID"), json["moz:buildID"]); + equal(caps.get("moz:debuggerAddress"), json["moz:debuggerAddress"]); + equal(caps.get("moz:platformVersion"), json["moz:platformVersion"]); + equal(caps.get("moz:processID"), json["moz:processID"]); + equal(caps.get("moz:profile"), json["moz:profile"]); + equal( + caps.get("moz:useNonSpecCompliantPointerOrigin"), + json["moz:useNonSpecCompliantPointerOrigin"] + ); + equal(caps.get("moz:webdriverClick"), json["moz:webdriverClick"]); +}); + +add_task(function test_Capabilities_fromJSON() { + const { fromJSON } = Capabilities; + + // plain + for (let typ of [{}, null, undefined]) { + ok(fromJSON(typ).has("browserName")); + } + for (let typ of [true, 42, "foo", []]) { + Assert.throws(() => fromJSON(typ), /InvalidArgumentError/); + } + + // matching + let caps = new Capabilities(); + + caps = fromJSON({ acceptInsecureCerts: true }); + equal(true, caps.get("acceptInsecureCerts")); + caps = fromJSON({ acceptInsecureCerts: false }); + equal(false, caps.get("acceptInsecureCerts")); + Assert.throws( + () => fromJSON({ acceptInsecureCerts: "foo" }), + /InvalidArgumentError/ + ); + + for (let strategy of Object.values(PageLoadStrategy)) { + caps = fromJSON({ pageLoadStrategy: strategy }); + equal(strategy, caps.get("pageLoadStrategy")); + } + Assert.throws( + () => fromJSON({ pageLoadStrategy: "foo" }), + /InvalidArgumentError/ + ); + Assert.throws( + () => fromJSON({ pageLoadStrategy: null }), + /InvalidArgumentError/ + ); + + let proxyConfig = { proxyType: "manual" }; + caps = fromJSON({ proxy: proxyConfig }); + equal("manual", caps.get("proxy").proxyType); + + let timeoutsConfig = { implicit: 123 }; + caps = fromJSON({ timeouts: timeoutsConfig }); + equal(123, caps.get("timeouts").implicit); + + if (!AppInfo.isAndroid) { + caps = fromJSON({ setWindowRect: true }); + equal(true, caps.get("setWindowRect")); + Assert.throws( + () => fromJSON({ setWindowRect: false }), + /InvalidArgumentError/ + ); + } else { + Assert.throws( + () => fromJSON({ setWindowRect: true }), + /InvalidArgumentError/ + ); + } + + caps = fromJSON({ strictFileInteractability: false }); + equal(false, caps.get("strictFileInteractability")); + caps = fromJSON({ strictFileInteractability: true }); + equal(true, caps.get("strictFileInteractability")); + + caps = fromJSON({ webSocketUrl: true }); + equal(true, caps.get("webSocketUrl")); + Assert.throws( + () => fromJSON({ webSocketUrl: false }), + /InvalidArgumentError/ + ); + Assert.throws( + () => fromJSON({ webSocketUrl: "foo" }), + /InvalidArgumentError/ + ); + + caps = fromJSON({ "moz:accessibilityChecks": true }); + equal(true, caps.get("moz:accessibilityChecks")); + caps = fromJSON({ "moz:accessibilityChecks": false }); + equal(false, caps.get("moz:accessibilityChecks")); + Assert.throws( + () => fromJSON({ "moz:accessibilityChecks": "foo" }), + /InvalidArgumentError/ + ); + Assert.throws( + () => fromJSON({ "moz:accessibilityChecks": 1 }), + /InvalidArgumentError/ + ); + + // capability is always populated with null if remote agent is not listening + caps = fromJSON({}); + equal(null, caps.get("moz:debuggerAddress")); + caps = fromJSON({ "moz:debuggerAddress": "foo" }); + equal(null, caps.get("moz:debuggerAddress")); + caps = fromJSON({ "moz:debuggerAddress": true }); + equal(null, caps.get("moz:debuggerAddress")); + + caps = fromJSON({ "moz:useNonSpecCompliantPointerOrigin": false }); + equal(false, caps.get("moz:useNonSpecCompliantPointerOrigin")); + caps = fromJSON({ "moz:useNonSpecCompliantPointerOrigin": true }); + equal(true, caps.get("moz:useNonSpecCompliantPointerOrigin")); + Assert.throws( + () => fromJSON({ "moz:useNonSpecCompliantPointerOrigin": "foo" }), + /InvalidArgumentError/ + ); + Assert.throws( + () => fromJSON({ "moz:useNonSpecCompliantPointerOrigin": 1 }), + /InvalidArgumentError/ + ); + + caps = fromJSON({ "moz:webdriverClick": true }); + equal(true, caps.get("moz:webdriverClick")); + caps = fromJSON({ "moz:webdriverClick": false }); + equal(false, caps.get("moz:webdriverClick")); + Assert.throws( + () => fromJSON({ "moz:webdriverClick": "foo" }), + /InvalidArgumentError/ + ); + Assert.throws( + () => fromJSON({ "moz:webdriverClick": 1 }), + /InvalidArgumentError/ + ); +}); + +// use Proxy.toJSON to test marshal +add_task(function test_marshal() { + let proxy = new Proxy(); + + // drop empty fields + deepEqual({}, proxy.toJSON()); + proxy.proxyType = "manual"; + deepEqual({ proxyType: "manual" }, proxy.toJSON()); + proxy.proxyType = null; + deepEqual({}, proxy.toJSON()); + proxy.proxyType = undefined; + deepEqual({}, proxy.toJSON()); + + // iterate over object literals + proxy.proxyType = { foo: "bar" }; + deepEqual({ proxyType: { foo: "bar" } }, proxy.toJSON()); + + // iterate over complex object that implement toJSON + proxy.proxyType = new Proxy(); + deepEqual({}, proxy.toJSON()); + proxy.proxyType.proxyType = "manual"; + deepEqual({ proxyType: { proxyType: "manual" } }, proxy.toJSON()); + + // drop objects with no entries + proxy.proxyType = { foo: {} }; + deepEqual({}, proxy.toJSON()); + proxy.proxyType = { foo: new Proxy() }; + deepEqual({}, proxy.toJSON()); +}); diff --git a/remote/shared/webdriver/test/xpcshell/test_Errors.js b/remote/shared/webdriver/test/xpcshell/test_Errors.js new file mode 100644 index 0000000000..e688d529ca --- /dev/null +++ b/remote/shared/webdriver/test/xpcshell/test_Errors.js @@ -0,0 +1,509 @@ +/* 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/. */ + +const { error } = ChromeUtils.importESModule( + "chrome://remote/content/shared/webdriver/Errors.sys.mjs" +); + +const errors = [ + error.WebDriverError, + + error.DetachedShadowRootError, + error.ElementClickInterceptedError, + error.ElementNotAccessibleError, + error.ElementNotInteractableError, + error.InsecureCertificateError, + error.InvalidArgumentError, + error.InvalidCookieDomainError, + error.InvalidElementStateError, + error.InvalidSelectorError, + error.InvalidSessionIDError, + error.JavaScriptError, + error.MoveTargetOutOfBoundsError, + error.NoSuchAlertError, + error.NoSuchElementError, + error.NoSuchFrameError, + error.NoSuchHandleError, + error.NoSuchNodeError, + error.NoSuchScriptError, + error.NoSuchShadowRootError, + error.NoSuchWindowError, + error.ScriptTimeoutError, + error.SessionNotCreatedError, + error.StaleElementReferenceError, + error.TimeoutError, + error.UnableToSetCookieError, + error.UnexpectedAlertOpenError, + error.UnknownCommandError, + error.UnknownError, + error.UnsupportedOperationError, +]; + +function notok(condition) { + ok(!condition); +} + +add_task(function test_isError() { + notok(error.isError(null)); + notok(error.isError([])); + notok(error.isError(new Date())); + + ok(error.isError(new Components.Exception())); + ok(error.isError(new Error())); + ok(error.isError(new EvalError())); + ok(error.isError(new InternalError())); + ok(error.isError(new RangeError())); + ok(error.isError(new ReferenceError())); + ok(error.isError(new SyntaxError())); + ok(error.isError(new TypeError())); + ok(error.isError(new URIError())); + + errors.forEach(err => ok(error.isError(new err()))); +}); + +add_task(function test_isWebDriverError() { + notok(error.isWebDriverError(new Components.Exception())); + notok(error.isWebDriverError(new Error())); + notok(error.isWebDriverError(new EvalError())); + notok(error.isWebDriverError(new InternalError())); + notok(error.isWebDriverError(new RangeError())); + notok(error.isWebDriverError(new ReferenceError())); + notok(error.isWebDriverError(new SyntaxError())); + notok(error.isWebDriverError(new TypeError())); + notok(error.isWebDriverError(new URIError())); + + errors.forEach(err => ok(error.isWebDriverError(new err()))); +}); + +add_task(function test_wrap() { + // webdriver-derived errors should not be wrapped + errors.forEach(err => { + const unwrappedError = new err("foo"); + const wrappedError = error.wrap(unwrappedError); + + ok(wrappedError instanceof error.WebDriverError); + ok(wrappedError instanceof err); + equal(wrappedError.name, unwrappedError.name); + equal(wrappedError.status, unwrappedError.status); + equal(wrappedError.message, "foo"); + }); + + // JS errors should be wrapped in UnknownError and retain their type + // as part of the message field. + const jsErrors = [ + Error, + EvalError, + InternalError, + RangeError, + ReferenceError, + SyntaxError, + TypeError, + URIError, + ]; + + jsErrors.forEach(err => { + const originalError = new err("foo"); + const wrappedError = error.wrap(originalError); + + ok(wrappedError instanceof error.UnknownError); + equal(wrappedError.name, "UnknownError"); + equal(wrappedError.status, "unknown error"); + equal(wrappedError.message, `${originalError.name}: foo`); + }); +}); + +add_task(function test_stringify() { + equal("<unprintable error>", error.stringify()); + equal("<unprintable error>", error.stringify("foo")); + equal("[object Object]", error.stringify({})); + equal("[object Object]\nfoo", error.stringify({ stack: "foo" })); + equal("Error: foo", error.stringify(new Error("foo")).split("\n")[0]); + + errors.forEach(err => { + const e = new err("foo"); + + equal(`${e.name}: foo`, error.stringify(e).split("\n")[0]); + }); +}); + +add_task(function test_constructor_from_error() { + const data = { a: 3, b: "bar" }; + const origError = new error.WebDriverError("foo", data); + + errors.forEach(err => { + const newError = new err(origError); + + ok(newError instanceof err); + equal(newError.message, origError.message); + equal(newError.stack, origError.stack); + equal(newError.data, origError.data); + }); +}); + +add_task(function test_stack() { + equal("string", typeof error.stack()); + ok(error.stack().includes("test_stack")); + ok(!error.stack().includes("add_task")); +}); + +add_task(function test_toJSON() { + errors.forEach(err => { + const e0 = new err(); + const e0_json = e0.toJSON(); + equal(e0_json.error, e0.status); + equal(e0_json.message, ""); + equal(e0_json.stacktrace, e0.stack); + equal(e0_json.data, undefined); + + // message property + const e1 = new err("a"); + const e1_json = e1.toJSON(); + + equal(e1_json.message, e1.message); + equal(e1_json.stacktrace, e1.stack); + equal(e1_json.data, undefined); + + // message and optional data property + const data = { a: 3, b: "bar" }; + const e2 = new err("foo", data); + const e2_json = e2.toJSON(); + + equal(e2.status, e2_json.error); + equal(e2.message, e2_json.message); + equal(e2_json.data, data); + }); +}); + +add_task(function test_fromJSON() { + errors.forEach(err => { + Assert.throws( + () => err.fromJSON({ error: "foo" }), + /Not of WebDriverError descent/ + ); + Assert.throws( + () => err.fromJSON({ error: "Error" }), + /Not of WebDriverError descent/ + ); + Assert.throws(() => err.fromJSON({}), /Undeserialisable error type/); + Assert.throws(() => err.fromJSON(undefined), /TypeError/); + + // message and stack + const e1 = new err("1"); + const e1_json = { error: e1.status, message: "3", stacktrace: "4" }; + const e1_fromJSON = error.WebDriverError.fromJSON(e1_json); + + ok(e1_fromJSON instanceof error.WebDriverError); + ok(e1_fromJSON instanceof err); + equal(e1_fromJSON.name, e1.name); + equal(e1_fromJSON.status, e1_json.error); + equal(e1_fromJSON.message, e1_json.message); + equal(e1_fromJSON.stack, e1_json.stacktrace); + + // message and optional data + const e2_data = { a: 3, b: "bar" }; + const e2 = new err("1", e2_data); + const e2_json = { error: e1.status, message: "3", data: e2_data }; + const e2_fromJSON = error.WebDriverError.fromJSON(e2_json); + + ok(e2_fromJSON instanceof error.WebDriverError); + ok(e2_fromJSON instanceof err); + equal(e2_fromJSON.name, e2.name); + equal(e2_fromJSON.status, e2_json.error); + equal(e2_fromJSON.message, e2_json.message); + equal(e2_fromJSON.data, e2_json.data); + + // parity with toJSON + const e3_data = { a: 3, b: "bar" }; + const e3 = new err("1", e3_data); + const e3_json = e3.toJSON(); + const e3_fromJSON = error.WebDriverError.fromJSON(e3_json); + + equal(e3_json.error, e3_fromJSON.status); + equal(e3_json.message, e3_fromJSON.message); + equal(e3_json.stacktrace, e3_fromJSON.stack); + }); +}); + +add_task(function test_WebDriverError() { + let err = new error.WebDriverError("foo"); + equal("WebDriverError", err.name); + equal("foo", err.message); + equal("webdriver error", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_DetachedShadowRootError() { + let err = new error.DetachedShadowRootError("foo"); + equal("DetachedShadowRootError", err.name); + equal("foo", err.message); + equal("detached shadow root", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_ElementClickInterceptedError() { + let otherEl = { + hasAttribute: attr => attr in otherEl, + getAttribute: attr => (attr in otherEl ? otherEl[attr] : null), + nodeType: 1, + localName: "a", + }; + let obscuredEl = { + hasAttribute: attr => attr in obscuredEl, + getAttribute: attr => (attr in obscuredEl ? obscuredEl[attr] : null), + nodeType: 1, + localName: "b", + ownerDocument: { + elementFromPoint() { + return otherEl; + }, + }, + style: { + pointerEvents: "auto", + }, + }; + + let err1 = new error.ElementClickInterceptedError( + undefined, + undefined, + obscuredEl, + { x: 1, y: 2 } + ); + equal("ElementClickInterceptedError", err1.name); + equal( + "Element <b> is not clickable at point (1,2) " + + "because another element <a> obscures it", + err1.message + ); + equal("element click intercepted", err1.status); + ok(err1 instanceof error.WebDriverError); + + obscuredEl.style.pointerEvents = "none"; + let err2 = new error.ElementClickInterceptedError( + undefined, + undefined, + obscuredEl, + { x: 1, y: 2 } + ); + equal( + "Element <b> is not clickable at point (1,2) " + + "because it does not have pointer events enabled, " + + "and element <a> would receive the click instead", + err2.message + ); +}); + +add_task(function test_ElementNotAccessibleError() { + let err = new error.ElementNotAccessibleError("foo"); + equal("ElementNotAccessibleError", err.name); + equal("foo", err.message); + equal("element not accessible", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_ElementNotInteractableError() { + let err = new error.ElementNotInteractableError("foo"); + equal("ElementNotInteractableError", err.name); + equal("foo", err.message); + equal("element not interactable", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_InsecureCertificateError() { + let err = new error.InsecureCertificateError("foo"); + equal("InsecureCertificateError", err.name); + equal("foo", err.message); + equal("insecure certificate", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_InvalidArgumentError() { + let err = new error.InvalidArgumentError("foo"); + equal("InvalidArgumentError", err.name); + equal("foo", err.message); + equal("invalid argument", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_InvalidCookieDomainError() { + let err = new error.InvalidCookieDomainError("foo"); + equal("InvalidCookieDomainError", err.name); + equal("foo", err.message); + equal("invalid cookie domain", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_InvalidElementStateError() { + let err = new error.InvalidElementStateError("foo"); + equal("InvalidElementStateError", err.name); + equal("foo", err.message); + equal("invalid element state", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_InvalidSelectorError() { + let err = new error.InvalidSelectorError("foo"); + equal("InvalidSelectorError", err.name); + equal("foo", err.message); + equal("invalid selector", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_InvalidSessionIDError() { + let err = new error.InvalidSessionIDError("foo"); + equal("InvalidSessionIDError", err.name); + equal("foo", err.message); + equal("invalid session id", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_JavaScriptError() { + let err = new error.JavaScriptError("foo"); + equal("JavaScriptError", err.name); + equal("foo", err.message); + equal("javascript error", err.status); + ok(err instanceof error.WebDriverError); + + equal("", new error.JavaScriptError(undefined).message); + + let superErr = new RangeError("foo"); + let inheritedErr = new error.JavaScriptError(superErr); + equal("RangeError: foo", inheritedErr.message); + equal(superErr.stack, inheritedErr.stack); +}); + +add_task(function test_MoveTargetOutOfBoundsError() { + let err = new error.MoveTargetOutOfBoundsError("foo"); + equal("MoveTargetOutOfBoundsError", err.name); + equal("foo", err.message); + equal("move target out of bounds", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_NoSuchAlertError() { + let err = new error.NoSuchAlertError("foo"); + equal("NoSuchAlertError", err.name); + equal("foo", err.message); + equal("no such alert", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_NoSuchElementError() { + let err = new error.NoSuchElementError("foo"); + equal("NoSuchElementError", err.name); + equal("foo", err.message); + equal("no such element", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_NoSuchFrameError() { + let err = new error.NoSuchFrameError("foo"); + equal("NoSuchFrameError", err.name); + equal("foo", err.message); + equal("no such frame", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_NoSuchNodeError() { + let err = new error.NoSuchNodeError("foo"); + equal("NoSuchNodeError", err.name); + equal("foo", err.message); + equal("no such node", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_NoSuchScriptError() { + let err = new error.NoSuchScriptError("foo"); + equal("NoSuchScriptError", err.name); + equal("foo", err.message); + equal("no such script", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_NoSuchShadowRootError() { + let err = new error.NoSuchShadowRootError("foo"); + equal("NoSuchShadowRootError", err.name); + equal("foo", err.message); + equal("no such shadow root", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_NoSuchWindowError() { + let err = new error.NoSuchWindowError("foo"); + equal("NoSuchWindowError", err.name); + equal("foo", err.message); + equal("no such window", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_ScriptTimeoutError() { + let err = new error.ScriptTimeoutError("foo"); + equal("ScriptTimeoutError", err.name); + equal("foo", err.message); + equal("script timeout", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_SessionNotCreatedError() { + let err = new error.SessionNotCreatedError("foo"); + equal("SessionNotCreatedError", err.name); + equal("foo", err.message); + equal("session not created", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_StaleElementReferenceError() { + let err = new error.StaleElementReferenceError("foo"); + equal("StaleElementReferenceError", err.name); + equal("foo", err.message); + equal("stale element reference", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_TimeoutError() { + let err = new error.TimeoutError("foo"); + equal("TimeoutError", err.name); + equal("foo", err.message); + equal("timeout", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_UnableToSetCookieError() { + let err = new error.UnableToSetCookieError("foo"); + equal("UnableToSetCookieError", err.name); + equal("foo", err.message); + equal("unable to set cookie", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_UnexpectedAlertOpenError() { + let err = new error.UnexpectedAlertOpenError("foo"); + equal("UnexpectedAlertOpenError", err.name); + equal("foo", err.message); + equal("unexpected alert open", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_UnknownCommandError() { + let err = new error.UnknownCommandError("foo"); + equal("UnknownCommandError", err.name); + equal("foo", err.message); + equal("unknown command", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_UnknownError() { + let err = new error.UnknownError("foo"); + equal("UnknownError", err.name); + equal("foo", err.message); + equal("unknown error", err.status); + ok(err instanceof error.WebDriverError); +}); + +add_task(function test_UnsupportedOperationError() { + let err = new error.UnsupportedOperationError("foo"); + equal("UnsupportedOperationError", err.name); + equal("foo", err.message); + equal("unsupported operation", err.status); + ok(err instanceof error.WebDriverError); +}); diff --git a/remote/shared/webdriver/test/xpcshell/test_NodeCache.js b/remote/shared/webdriver/test/xpcshell/test_NodeCache.js new file mode 100644 index 0000000000..961de2bcf5 --- /dev/null +++ b/remote/shared/webdriver/test/xpcshell/test_NodeCache.js @@ -0,0 +1,240 @@ +const { NodeCache } = ChromeUtils.importESModule( + "chrome://remote/content/shared/webdriver/NodeCache.sys.mjs" +); + +function setupTest() { + const browser = Services.appShell.createWindowlessBrowser(false); + + browser.document.body.innerHTML = ` + <div id="foo" style="margin: 50px"> + <iframe></iframe> + <video></video> + <svg xmlns="http://www.w3.org/2000/svg"></svg> + <textarea></textarea> + </div> + <div id="with-comment"><!-- Comment --></div> + `; + + const divEl = browser.document.querySelector("div"); + const svgEl = browser.document.querySelector("svg"); + const textareaEl = browser.document.querySelector("textarea"); + const videoEl = browser.document.querySelector("video"); + + const iframeEl = browser.document.querySelector("iframe"); + const childEl = iframeEl.contentDocument.createElement("div"); + iframeEl.contentDocument.body.appendChild(childEl); + + const shadowRoot = videoEl.openOrClosedShadowRoot; + + return { + browser, + nodeCache: new NodeCache(), + childEl, + divEl, + iframeEl, + shadowRoot, + svgEl, + textareaEl, + videoEl, + }; +} + +add_task(function getOrCreateNodeReference_invalid() { + const { nodeCache } = setupTest(); + + const invalidValues = [null, undefined, "foo", 42, true, [], {}]; + + for (const value of invalidValues) { + info(`Testing value: ${value}`); + Assert.throws(() => nodeCache.getOrCreateNodeReference(value), /TypeError/); + } +}); + +add_task(function getOrCreateNodeReference_supportedNodeTypes() { + const { browser, divEl, nodeCache } = setupTest(); + + const xmlDocument = new DOMParser().parseFromString( + "<xml></xml>", + "application/xml" + ); + + const values = [ + { node: divEl, type: Node.ELEMENT_NODE }, + { node: divEl.attributes[0], type: Node.ATTRIBUTE_NODE }, + { node: browser.document.createTextNode("foo"), type: Node.TEXT_NODE }, + { + node: xmlDocument.createCDATASection("foo"), + type: Node.CDATA_SECTION_NODE, + }, + { + node: browser.document.createProcessingInstruction( + "xml-stylesheet", + "href='foo.css'" + ), + type: Node.PROCESSING_INSTRUCTION_NODE_NODE, + }, + { node: browser.document.createComment("foo"), type: Node.COMMENT_NODE }, + { node: browser.document, type: Node.Document_NODE }, + { + node: browser.document.implementation.createDocumentType( + "foo", + "bar", + "dtd" + ), + type: Node.DOCUMENT_TYPE_NODE_NODE, + }, + { + node: browser.document.createDocumentFragment(), + type: Node.DOCUMENT_FRAGMENT_NODE, + }, + ]; + + values.forEach((value, index) => { + info(`Testing value: ${value.type}`); + const nodeRef = nodeCache.getOrCreateNodeReference(value.node); + equal(nodeCache.size, index + 1); + equal(typeof nodeRef, "string"); + }); +}); + +add_task(function getOrCreateNodeReference_referenceAlreadyCreated() { + const { divEl, nodeCache } = setupTest(); + + const divElRef = nodeCache.getOrCreateNodeReference(divEl); + const divElRefOther = nodeCache.getOrCreateNodeReference(divEl); + equal(nodeCache.size, 1); + equal(divElRefOther, divElRef); +}); + +add_task(function getOrCreateNodeReference_differentReference() { + const { divEl, nodeCache, shadowRoot } = setupTest(); + + const divElRef = nodeCache.getOrCreateNodeReference(divEl); + equal(nodeCache.size, 1); + + const shadowRootRef = nodeCache.getOrCreateNodeReference(shadowRoot); + equal(nodeCache.size, 2); + + notEqual(divElRef, shadowRootRef); +}); + +add_task(function getOrCreateNodeReference_differentReferencePerNodeCache() { + const { browser, divEl, nodeCache } = setupTest(); + const nodeCache2 = new NodeCache(); + + const divElRef1 = nodeCache.getOrCreateNodeReference(divEl); + const divElRef2 = nodeCache2.getOrCreateNodeReference(divEl); + + notEqual(divElRef1, divElRef2); + equal( + nodeCache.getNode(browser.browsingContext, divElRef1), + nodeCache2.getNode(browser.browsingContext, divElRef2) + ); + + equal(nodeCache.getNode(browser.browsingContext, divElRef2), null); +}); + +add_task(function clear() { + const { browser, divEl, nodeCache, svgEl } = setupTest(); + + nodeCache.getOrCreateNodeReference(divEl); + nodeCache.getOrCreateNodeReference(svgEl); + equal(nodeCache.size, 2); + + // Clear requires explicit arguments. + Assert.throws(() => nodeCache.clear(), /Error/); + + // Clear references for a different browsing context + const browser2 = Services.appShell.createWindowlessBrowser(false); + const imgEl = browser2.document.createElement("img"); + const imgElRef = nodeCache.getOrCreateNodeReference(imgEl); + equal(nodeCache.size, 3); + + nodeCache.clear({ browsingContext: browser.browsingContext }); + equal(nodeCache.size, 1); + equal(nodeCache.getNode(browser2.browsingContext, imgElRef), imgEl); + + // Clear all references + nodeCache.getOrCreateNodeReference(divEl); + equal(nodeCache.size, 2); + + nodeCache.clear({ all: true }); + equal(nodeCache.size, 0); +}); + +add_task(function getNode_multiple_nodes() { + const { browser, divEl, nodeCache, svgEl } = setupTest(); + + const divElRef = nodeCache.getOrCreateNodeReference(divEl); + const svgElRef = nodeCache.getOrCreateNodeReference(svgEl); + + equal(nodeCache.getNode(browser.browsingContext, svgElRef), svgEl); + equal(nodeCache.getNode(browser.browsingContext, divElRef), divEl); +}); + +add_task(function getNode_differentBrowsingContextInSameGroup() { + const { iframeEl, divEl, nodeCache } = setupTest(); + + const divElRef = nodeCache.getOrCreateNodeReference(divEl); + equal(nodeCache.size, 1); + + equal( + nodeCache.getNode(iframeEl.contentWindow.browsingContext, divElRef), + divEl + ); +}); + +add_task(function getNode_differentBrowsingContextInOtherGroup() { + const { divEl, nodeCache } = setupTest(); + + const divElRef = nodeCache.getOrCreateNodeReference(divEl); + equal(nodeCache.size, 1); + + const browser2 = Services.appShell.createWindowlessBrowser(false); + equal(nodeCache.getNode(browser2.browsingContext, divElRef), null); +}); + +add_task(async function getNode_nodeDeleted() { + const { browser, nodeCache } = setupTest(); + let el = browser.document.createElement("div"); + + const elRef = nodeCache.getOrCreateNodeReference(el); + + // Delete element and force a garbage collection + el = null; + + await doGC(); + + equal(nodeCache.getNode(browser.browsingContext, elRef), null); +}); + +add_task(function getNodeDetails_forTopBrowsingContext() { + const { browser, divEl, nodeCache } = setupTest(); + + const divElRef = nodeCache.getOrCreateNodeReference(divEl); + + const nodeDetails = nodeCache.getReferenceDetails(divElRef); + equal(nodeDetails.browserId, browser.browsingContext.browserId); + equal(nodeDetails.browsingContextGroupId, browser.browsingContext.group.id); + equal(nodeDetails.browsingContextId, browser.browsingContext.id); + ok(nodeDetails.isTopBrowsingContext); + ok(nodeDetails.nodeWeakRef); + equal(nodeDetails.nodeWeakRef.get(), divEl); +}); + +add_task(async function getNodeDetails_forChildBrowsingContext() { + const { browser, iframeEl, childEl, nodeCache } = setupTest(); + + const childElRef = nodeCache.getOrCreateNodeReference(childEl); + + const nodeDetails = nodeCache.getReferenceDetails(childElRef); + equal(nodeDetails.browserId, browser.browsingContext.browserId); + equal(nodeDetails.browsingContextGroupId, browser.browsingContext.group.id); + equal( + nodeDetails.browsingContextId, + iframeEl.contentWindow.browsingContext.id + ); + ok(!nodeDetails.isTopBrowsingContext); + ok(nodeDetails.nodeWeakRef); + equal(nodeDetails.nodeWeakRef.get(), childEl); +}); diff --git a/remote/shared/webdriver/test/xpcshell/test_Session.js b/remote/shared/webdriver/test/xpcshell/test_Session.js new file mode 100644 index 0000000000..c7d40c7720 --- /dev/null +++ b/remote/shared/webdriver/test/xpcshell/test_Session.js @@ -0,0 +1,49 @@ +/* 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/. */ + +"use strict"; + +const { Capabilities, Timeouts } = ChromeUtils.importESModule( + "chrome://remote/content/shared/webdriver/Capabilities.sys.mjs" +); +const { WebDriverSession } = ChromeUtils.importESModule( + "chrome://remote/content/shared/webdriver/Session.sys.mjs" +); + +add_task(function test_WebDriverSession_ctor() { + const session = new WebDriverSession(); + + equal(typeof session.id, "string"); + ok(session.capabilities instanceof Capabilities); +}); + +add_task(function test_WebDriverSession_getters() { + const session = new WebDriverSession(); + + equal( + session.a11yChecks, + session.capabilities.get("moz:accessibilityChecks") + ); + equal(session.pageLoadStrategy, session.capabilities.get("pageLoadStrategy")); + equal(session.proxy, session.capabilities.get("proxy")); + equal( + session.strictFileInteractability, + session.capabilities.get("strictFileInteractability") + ); + equal(session.timeouts, session.capabilities.get("timeouts")); + equal( + session.unhandledPromptBehavior, + session.capabilities.get("unhandledPromptBehavior") + ); +}); + +add_task(function test_WebDriverSession_setters() { + const session = new WebDriverSession(); + + const timeouts = new Timeouts(); + timeouts.pageLoad = 45; + + session.timeouts = timeouts; + equal(session.timeouts, session.capabilities.get("timeouts")); +}); diff --git a/remote/shared/webdriver/test/xpcshell/xpcshell.ini b/remote/shared/webdriver/test/xpcshell/xpcshell.ini new file mode 100644 index 0000000000..c52f25c605 --- /dev/null +++ b/remote/shared/webdriver/test/xpcshell/xpcshell.ini @@ -0,0 +1,13 @@ +# 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/. + +[DEFAULT] +head = head.js + +[test_Actions.js] +[test_Assert.js] +[test_Capabilities.js] +[test_Errors.js] +[test_NodeCache.js] +[test_Session.js] |