summaryrefslogtreecommitdiffstats
path: root/remote/shared/webdriver
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 19:33:14 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 19:33:14 +0000
commit36d22d82aa202bb199967e9512281e9a53db42c9 (patch)
tree105e8c98ddea1c1e4784a60a5a6410fa416be2de /remote/shared/webdriver
parentInitial commit. (diff)
downloadfirefox-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')
-rw-r--r--remote/shared/webdriver/Actions.sys.mjs2137
-rw-r--r--remote/shared/webdriver/Assert.sys.mjs489
-rw-r--r--remote/shared/webdriver/Capabilities.sys.mjs737
-rw-r--r--remote/shared/webdriver/Errors.sys.mjs796
-rw-r--r--remote/shared/webdriver/KeyData.sys.mjs340
-rw-r--r--remote/shared/webdriver/NodeCache.sys.mjs169
-rw-r--r--remote/shared/webdriver/Session.sys.mjs344
-rw-r--r--remote/shared/webdriver/process-actors/WebDriverProcessDataChild.sys.mjs95
-rw-r--r--remote/shared/webdriver/process-actors/WebDriverProcessDataParent.sys.mjs39
-rw-r--r--remote/shared/webdriver/test/xpcshell/head.js15
-rw-r--r--remote/shared/webdriver/test/xpcshell/test_Actions.js706
-rw-r--r--remote/shared/webdriver/test/xpcshell/test_Assert.js183
-rw-r--r--remote/shared/webdriver/test/xpcshell/test_Capabilities.js585
-rw-r--r--remote/shared/webdriver/test/xpcshell/test_Errors.js509
-rw-r--r--remote/shared/webdriver/test/xpcshell/test_NodeCache.js240
-rw-r--r--remote/shared/webdriver/test/xpcshell/test_Session.js49
-rw-r--r--remote/shared/webdriver/test/xpcshell/xpcshell.ini13
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]