summaryrefslogtreecommitdiffstats
path: root/remote/marionette
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 09:22:09 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 09:22:09 +0000
commit43a97878ce14b72f0981164f87f2e35e14151312 (patch)
tree620249daf56c0258faa40cbdcf9cfba06de2a846 /remote/marionette
parentInitial commit. (diff)
downloadfirefox-upstream.tar.xz
firefox-upstream.zip
Adding upstream version 110.0.1.upstream/110.0.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'remote/marionette')
-rw-r--r--remote/marionette/.eslintrc.js14
-rw-r--r--remote/marionette/README20
-rw-r--r--remote/marionette/accessibility.sys.mjs457
-rw-r--r--remote/marionette/action.sys.mjs2119
-rw-r--r--remote/marionette/actors/MarionetteCommandsChild.sys.mjs579
-rw-r--r--remote/marionette/actors/MarionetteCommandsParent.sys.mjs369
-rw-r--r--remote/marionette/actors/MarionetteEventsChild.sys.mjs84
-rw-r--r--remote/marionette/actors/MarionetteEventsParent.sys.mjs115
-rw-r--r--remote/marionette/actors/MarionetteReftestChild.sys.mjs236
-rw-r--r--remote/marionette/actors/MarionetteReftestParent.sys.mjs85
-rw-r--r--remote/marionette/addon.sys.mjs139
-rw-r--r--remote/marionette/atom.sys.mjs305
-rw-r--r--remote/marionette/browser.sys.mjs384
-rw-r--r--remote/marionette/cert.sys.mjs61
-rw-r--r--remote/marionette/chrome/reftest.xhtml6
-rw-r--r--remote/marionette/chrome/test.xhtml27
-rw-r--r--remote/marionette/chrome/test2.xhtml20
-rw-r--r--remote/marionette/chrome/test_dialog.dtd7
-rw-r--r--remote/marionette/chrome/test_dialog.properties7
-rw-r--r--remote/marionette/chrome/test_dialog.xhtml37
-rw-r--r--remote/marionette/chrome/test_menupopup.xhtml30
-rw-r--r--remote/marionette/chrome/test_nested_iframe.xhtml9
-rw-r--r--remote/marionette/chrome/test_no_xul.xhtml31
-rw-r--r--remote/marionette/cookie.sys.mjs295
-rw-r--r--remote/marionette/dom.sys.mjs208
-rw-r--r--remote/marionette/driver.sys.mjs3242
-rw-r--r--remote/marionette/element.sys.mjs1524
-rw-r--r--remote/marionette/evaluate.sys.mjs354
-rw-r--r--remote/marionette/event.sys.mjs312
-rw-r--r--remote/marionette/interaction.sys.mjs774
-rw-r--r--remote/marionette/jar.mn54
-rw-r--r--remote/marionette/json.sys.mjs218
-rw-r--r--remote/marionette/l10n.sys.mjs103
-rw-r--r--remote/marionette/legacyaction.sys.mjs632
-rw-r--r--remote/marionette/message.sys.mjs329
-rw-r--r--remote/marionette/modal.sys.mjs377
-rw-r--r--remote/marionette/moz.build10
-rw-r--r--remote/marionette/navigate.sys.mjs427
-rw-r--r--remote/marionette/packets.sys.mjs425
-rw-r--r--remote/marionette/permissions.sys.mjs60
-rw-r--r--remote/marionette/prefs.sys.mjs180
-rw-r--r--remote/marionette/reftest-content.js65
-rw-r--r--remote/marionette/reftest.sys.mjs900
-rw-r--r--remote/marionette/server.sys.mjs410
-rw-r--r--remote/marionette/stream-utils.sys.mjs256
-rw-r--r--remote/marionette/sync.sys.mjs497
-rw-r--r--remote/marionette/test/README1
-rw-r--r--remote/marionette/test/xpcshell/.eslintrc.js7
-rw-r--r--remote/marionette/test/xpcshell/README16
-rw-r--r--remote/marionette/test/xpcshell/head.js7
-rw-r--r--remote/marionette/test/xpcshell/test_action.js745
-rw-r--r--remote/marionette/test/xpcshell/test_actors.js61
-rw-r--r--remote/marionette/test/xpcshell/test_browser.js25
-rw-r--r--remote/marionette/test/xpcshell/test_cookie.js370
-rw-r--r--remote/marionette/test/xpcshell/test_dom.js277
-rw-r--r--remote/marionette/test/xpcshell/test_element.js571
-rw-r--r--remote/marionette/test/xpcshell/test_json.js251
-rw-r--r--remote/marionette/test/xpcshell/test_message.js279
-rw-r--r--remote/marionette/test/xpcshell/test_modal.js119
-rw-r--r--remote/marionette/test/xpcshell/test_navigate.js96
-rw-r--r--remote/marionette/test/xpcshell/test_prefs.js115
-rw-r--r--remote/marionette/test/xpcshell/test_sync.js400
-rw-r--r--remote/marionette/test/xpcshell/xpcshell.ini20
-rw-r--r--remote/marionette/transport.sys.mjs529
64 files changed, 20682 insertions, 0 deletions
diff --git a/remote/marionette/.eslintrc.js b/remote/marionette/.eslintrc.js
new file mode 100644
index 0000000000..64a8883c43
--- /dev/null
+++ b/remote/marionette/.eslintrc.js
@@ -0,0 +1,14 @@
+/* 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";
+
+// inherits from ../../tools/lint/eslint/eslint-plugin-mozilla/lib/configs/recommended.js
+
+module.exports = {
+ rules: {
+ camelcase: ["error", { properties: "never" }],
+ "no-var": "error",
+ },
+};
diff --git a/remote/marionette/README b/remote/marionette/README
new file mode 100644
index 0000000000..d077a5136c
--- /dev/null
+++ b/remote/marionette/README
@@ -0,0 +1,20 @@
+Marionette [ ˌmarɪəˈnɛt] is
+
+ * a puppet worked by strings: the bird bobs up and down like
+ a marionette;
+
+ * a person who is easily manipulated or controlled: many officers
+ dismissed him as the mayor’s marionette;
+
+ * the remote protocol that lets out-of-process programs communicate
+ with, instrument, and control Gecko-based browsers.
+
+Marionette provides interfaces for interacting with both the internal
+JavaScript runtime and UI elements of Gecko-based browsers, such
+as Firefox on desktop and mobile. It can control both the chrome- and content
+documents, giving a high level of control and ability to replicate,
+or emulate, user interaction.
+
+Head on to the Marionette documentation to find out more:
+
+ https://firefox-source-docs.mozilla.org/testing/marionette/
diff --git a/remote/marionette/accessibility.sys.mjs b/remote/marionette/accessibility.sys.mjs
new file mode 100644
index 0000000000..2bcdb9bcc0
--- /dev/null
+++ b/remote/marionette/accessibility.sys.mjs
@@ -0,0 +1,457 @@
+/* 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, {
+ error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs",
+ Log: "chrome://remote/content/shared/Log.sys.mjs",
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "logger", () =>
+ lazy.Log.get(lazy.Log.TYPES.MARIONETTE)
+);
+
+XPCOMUtils.defineLazyGetter(lazy, "service", () => {
+ try {
+ return Cc["@mozilla.org/accessibilityService;1"].getService(
+ Ci.nsIAccessibilityService
+ );
+ } catch (e) {
+ lazy.logger.warn("Accessibility module is not present");
+ return undefined;
+ }
+});
+
+/** @namespace */
+export const accessibility = {
+ get service() {
+ return lazy.service;
+ },
+};
+
+/**
+ * Accessible states used to check element"s state from the accessiblity API
+ * perspective.
+ *
+ * Note: if gecko is built with --disable-accessibility, the interfaces
+ * are not defined. This is why we use getters instead to be able to use
+ * these statically.
+ */
+accessibility.State = {
+ get Unavailable() {
+ return Ci.nsIAccessibleStates.STATE_UNAVAILABLE;
+ },
+ get Focusable() {
+ return Ci.nsIAccessibleStates.STATE_FOCUSABLE;
+ },
+ get Selectable() {
+ return Ci.nsIAccessibleStates.STATE_SELECTABLE;
+ },
+ get Selected() {
+ return Ci.nsIAccessibleStates.STATE_SELECTED;
+ },
+};
+
+/**
+ * Accessible object roles that support some action.
+ */
+accessibility.ActionableRoles = new Set([
+ "checkbutton",
+ "check menu item",
+ "check rich option",
+ "combobox",
+ "combobox option",
+ "entry",
+ "key",
+ "link",
+ "listbox option",
+ "listbox rich option",
+ "menuitem",
+ "option",
+ "outlineitem",
+ "pagetab",
+ "pushbutton",
+ "radiobutton",
+ "radio menu item",
+ "rowheader",
+ "slider",
+ "spinbutton",
+ "switch",
+]);
+
+/**
+ * Factory function that constructs a new {@code accessibility.Checks}
+ * object with enforced strictness or not.
+ */
+accessibility.get = function(strict = false) {
+ return new accessibility.Checks(!!strict);
+};
+
+/**
+ * Component responsible for interacting with platform accessibility
+ * API.
+ *
+ * Its methods serve as wrappers for testing content and chrome
+ * accessibility as well as accessibility of user interactions.
+ */
+accessibility.Checks = class {
+ /**
+ * @param {boolean} strict
+ * Flag indicating whether the accessibility issue should be logged
+ * or cause an error to be thrown. Default is to log to stdout.
+ */
+ constructor(strict) {
+ this.strict = strict;
+ }
+
+ /**
+ * Get an accessible object for an element.
+ *
+ * @param {DOMElement|XULElement} element
+ * Element to get the accessible object for.
+ * @param {boolean=} mustHaveAccessible
+ * Flag indicating that the element must have an accessible object.
+ * Defaults to not require this.
+ *
+ * @return {Promise.<nsIAccessible>}
+ * Promise with an accessibility object for the given element.
+ */
+ getAccessible(element, mustHaveAccessible = false) {
+ if (!this.strict) {
+ return Promise.resolve();
+ }
+
+ return new Promise((resolve, reject) => {
+ if (!accessibility.service) {
+ reject();
+ return;
+ }
+
+ // First, check if accessibility is ready.
+ let docAcc = accessibility.service.getAccessibleFor(
+ element.ownerDocument
+ );
+ let state = {};
+ docAcc.getState(state, {});
+ if ((state.value & Ci.nsIAccessibleStates.STATE_BUSY) == 0) {
+ // Accessibility is ready, resolve immediately.
+ let acc = accessibility.service.getAccessibleFor(element);
+ if (mustHaveAccessible && !acc) {
+ reject();
+ } else {
+ resolve(acc);
+ }
+ return;
+ }
+ // Accessibility for the doc is busy, so wait for the state to change.
+ let eventObserver = {
+ observe(subject, topic) {
+ if (topic !== "accessible-event") {
+ return;
+ }
+
+ // If event type does not match expected type, skip the event.
+ let event = subject.QueryInterface(Ci.nsIAccessibleEvent);
+ if (event.eventType !== Ci.nsIAccessibleEvent.EVENT_STATE_CHANGE) {
+ return;
+ }
+
+ // If event's accessible does not match expected accessible,
+ // skip the event.
+ if (event.accessible !== docAcc) {
+ return;
+ }
+
+ Services.obs.removeObserver(this, "accessible-event");
+ let acc = accessibility.service.getAccessibleFor(element);
+ if (mustHaveAccessible && !acc) {
+ reject();
+ } else {
+ resolve(acc);
+ }
+ },
+ };
+ Services.obs.addObserver(eventObserver, "accessible-event");
+ }).catch(() =>
+ this.error("Element does not have an accessible object", element)
+ );
+ }
+
+ /**
+ * Test if the accessible has a role that supports some arbitrary
+ * action.
+ *
+ * @param {nsIAccessible} accessible
+ * Accessible object.
+ *
+ * @return {boolean}
+ * True if an actionable role is found on the accessible, false
+ * otherwise.
+ */
+ isActionableRole(accessible) {
+ return accessibility.ActionableRoles.has(
+ accessibility.service.getStringRole(accessible.role)
+ );
+ }
+
+ /**
+ * Test if an accessible has at least one action that it supports.
+ *
+ * @param {nsIAccessible} accessible
+ * Accessible object.
+ *
+ * @return {boolean}
+ * True if the accessible has at least one supported action,
+ * false otherwise.
+ */
+ hasActionCount(accessible) {
+ return accessible.actionCount > 0;
+ }
+
+ /**
+ * Test if an accessible has a valid name.
+ *
+ * @param {nsIAccessible} accessible
+ * Accessible object.
+ *
+ * @return {boolean}
+ * True if the accessible has a non-empty valid name, or false if
+ * this is not the case.
+ */
+ hasValidName(accessible) {
+ return accessible.name && accessible.name.trim();
+ }
+
+ /**
+ * Test if an accessible has a {@code hidden} attribute.
+ *
+ * @param {nsIAccessible} accessible
+ * Accessible object.
+ *
+ * @return {boolean}
+ * True if the accessible object has a {@code hidden} attribute,
+ * false otherwise.
+ */
+ hasHiddenAttribute(accessible) {
+ let hidden = false;
+ try {
+ hidden = accessible.attributes.getStringProperty("hidden");
+ } catch (e) {}
+ // if the property is missing, error will be thrown
+ return hidden && hidden === "true";
+ }
+
+ /**
+ * Verify if an accessible has a given state.
+ * Test if an accessible has a given state.
+ *
+ * @param {nsIAccessible} accessible
+ * Accessible object to test.
+ * @param {number} stateToMatch
+ * State to match.
+ *
+ * @return {boolean}
+ * True if |accessible| has |stateToMatch|, false otherwise.
+ */
+ matchState(accessible, stateToMatch) {
+ let state = {};
+ accessible.getState(state, {});
+ return !!(state.value & stateToMatch);
+ }
+
+ /**
+ * Test if an accessible is hidden from the user.
+ *
+ * @param {nsIAccessible} accessible
+ * Accessible object.
+ *
+ * @return {boolean}
+ * True if element is hidden from user, false otherwise.
+ */
+ isHidden(accessible) {
+ if (!accessible) {
+ return true;
+ }
+
+ while (accessible) {
+ if (this.hasHiddenAttribute(accessible)) {
+ return true;
+ }
+ accessible = accessible.parent;
+ }
+ return false;
+ }
+
+ /**
+ * Test if the element's visible state corresponds to its accessibility
+ * API visibility.
+ *
+ * @param {nsIAccessible} accessible
+ * Accessible object.
+ * @param {DOMElement|XULElement} element
+ * Element associated with |accessible|.
+ * @param {boolean} visible
+ * Visibility state of |element|.
+ *
+ * @throws ElementNotAccessibleError
+ * If |element|'s visibility state does not correspond to
+ * |accessible|'s.
+ */
+ assertVisible(accessible, element, visible) {
+ let hiddenAccessibility = this.isHidden(accessible);
+
+ let message;
+ if (visible && hiddenAccessibility) {
+ message =
+ "Element is not currently visible via the accessibility API " +
+ "and may not be manipulated by it";
+ } else if (!visible && !hiddenAccessibility) {
+ message =
+ "Element is currently only visible via the accessibility API " +
+ "and can be manipulated by it";
+ }
+ this.error(message, element);
+ }
+
+ /**
+ * Test if the element's unavailable accessibility state matches the
+ * enabled state.
+ *
+ * @param {nsIAccessible} accessible
+ * Accessible object.
+ * @param {DOMElement|XULElement} element
+ * Element associated with |accessible|.
+ * @param {boolean} enabled
+ * Enabled state of |element|.
+ *
+ * @throws ElementNotAccessibleError
+ * If |element|'s enabled state does not match |accessible|'s.
+ */
+ assertEnabled(accessible, element, enabled) {
+ if (!accessible) {
+ return;
+ }
+
+ let win = element.ownerGlobal;
+ let disabledAccessibility = this.matchState(
+ accessible,
+ accessibility.State.Unavailable
+ );
+ let explorable =
+ win.getComputedStyle(element).getPropertyValue("pointer-events") !==
+ "none";
+
+ let message;
+ if (!explorable && !disabledAccessibility) {
+ message =
+ "Element is enabled but is not explorable via the " +
+ "accessibility API";
+ } else if (enabled && disabledAccessibility) {
+ message = "Element is enabled but disabled via the accessibility API";
+ } else if (!enabled && !disabledAccessibility) {
+ message = "Element is disabled but enabled via the accessibility API";
+ }
+ this.error(message, element);
+ }
+
+ /**
+ * Test if it is possible to activate an element with the accessibility
+ * API.
+ *
+ * @param {nsIAccessible} accessible
+ * Accessible object.
+ * @param {DOMElement|XULElement} element
+ * Element associated with |accessible|.
+ *
+ * @throws ElementNotAccessibleError
+ * If it is impossible to activate |element| with |accessible|.
+ */
+ assertActionable(accessible, element) {
+ if (!accessible) {
+ return;
+ }
+
+ let message;
+ if (!this.hasActionCount(accessible)) {
+ message = "Element does not support any accessible actions";
+ } else if (!this.isActionableRole(accessible)) {
+ message =
+ "Element does not have a correct accessibility role " +
+ "and may not be manipulated via the accessibility API";
+ } else if (!this.hasValidName(accessible)) {
+ message = "Element is missing an accessible name";
+ } else if (!this.matchState(accessible, accessibility.State.Focusable)) {
+ message = "Element is not focusable via the accessibility API";
+ }
+
+ this.error(message, element);
+ }
+
+ /**
+ * Test that an element's selected state corresponds to its
+ * accessibility API selected state.
+ *
+ * @param {nsIAccessible} accessible
+ * Accessible object.
+ * @param {DOMElement|XULElement}
+ * Element associated with |accessible|.
+ * @param {boolean} selected
+ * The |element|s selected state.
+ *
+ * @throws ElementNotAccessibleError
+ * If |element|'s selected state does not correspond to
+ * |accessible|'s.
+ */
+ assertSelected(accessible, element, selected) {
+ if (!accessible) {
+ return;
+ }
+
+ // element is not selectable via the accessibility API
+ if (!this.matchState(accessible, accessibility.State.Selectable)) {
+ return;
+ }
+
+ let selectedAccessibility = this.matchState(
+ accessible,
+ accessibility.State.Selected
+ );
+
+ let message;
+ if (selected && !selectedAccessibility) {
+ message =
+ "Element is selected but not selected via the accessibility API";
+ } else if (!selected && selectedAccessibility) {
+ message =
+ "Element is not selected but selected via the accessibility API";
+ }
+ this.error(message, element);
+ }
+
+ /**
+ * Throw an error if strict accessibility checks are enforced and log
+ * the error to the log.
+ *
+ * @param {string} message
+ * @param {DOMElement|XULElement} element
+ * Element that caused an error.
+ *
+ * @throws ElementNotAccessibleError
+ * If |strict| is true.
+ */
+ error(message, element) {
+ if (!message || !this.strict) {
+ return;
+ }
+ if (element) {
+ let { id, tagName, className } = element;
+ message += `: id: ${id}, tagName: ${tagName}, className: ${className}`;
+ }
+
+ throw new lazy.error.ElementNotAccessibleError(message);
+ }
+};
diff --git a/remote/marionette/action.sys.mjs b/remote/marionette/action.sys.mjs
new file mode 100644
index 0000000000..75c33710e8
--- /dev/null
+++ b/remote/marionette/action.sys.mjs
@@ -0,0 +1,2119 @@
+/* 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)}]`;
+ }
+
+ /**
+ * Get the state for a given input source.
+ *
+ * @param {string} id Input source id.
+ * @return {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").
+ * @return {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} id Pointer type.
+ * @return {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.
+ *
+ * @return {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.
+ *
+ * @return {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.
+ *
+ * @return {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.
+ *
+ * @return {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 {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.
+ *
+ * @return {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.
+ *
+ * @return {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.
+ *
+ * @return {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.
+ *
+ * @return {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.
+ * @return {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} type - Input source id.
+ * @param {Object} actionItem - Object representing a single action.
+ *
+ * @return {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.
+ * @return {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.
+ * @return {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 {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.
+ * @return {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.
+ * @return {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 {Array.<Object>} actions - Array of objects that each
+ * represent an action sequence.
+ * @return {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.
+ *
+ * @return {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.
+ *
+ * @return {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.
+ * @return {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.
+ * @return {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 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/marionette/actors/MarionetteCommandsChild.sys.mjs b/remote/marionette/actors/MarionetteCommandsChild.sys.mjs
new file mode 100644
index 0000000000..e805d6f9ef
--- /dev/null
+++ b/remote/marionette/actors/MarionetteCommandsChild.sys.mjs
@@ -0,0 +1,579 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* eslint-disable no-restricted-globals */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ action: "chrome://remote/content/marionette/action.sys.mjs",
+ atom: "chrome://remote/content/marionette/atom.sys.mjs",
+ element: "chrome://remote/content/marionette/element.sys.mjs",
+ error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs",
+ evaluate: "chrome://remote/content/marionette/evaluate.sys.mjs",
+ event: "chrome://remote/content/marionette/event.sys.mjs",
+ interaction: "chrome://remote/content/marionette/interaction.sys.mjs",
+ json: "chrome://remote/content/marionette/json.sys.mjs",
+ legacyaction: "chrome://remote/content/marionette/legacyaction.sys.mjs",
+ Log: "chrome://remote/content/shared/Log.sys.mjs",
+ sandbox: "chrome://remote/content/marionette/evaluate.sys.mjs",
+ Sandboxes: "chrome://remote/content/marionette/evaluate.sys.mjs",
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "logger", () =>
+ lazy.Log.get(lazy.Log.TYPES.MARIONETTE)
+);
+
+export class MarionetteCommandsChild extends JSWindowActorChild {
+ #processActor;
+
+ constructor() {
+ super();
+
+ this.#processActor = ChromeUtils.domProcessChild.getActor(
+ "WebDriverProcessData"
+ );
+
+ // sandbox storage and name of the current sandbox
+ this.sandboxes = new lazy.Sandboxes(() => this.document.defaultView);
+ // State of the input actions. This is specific to contexts and sessions
+ this.actionState = null;
+ }
+
+ get innerWindowId() {
+ return this.manager.innerWindowId;
+ }
+
+ /**
+ * Lazy getter to create a legacyaction Chain instance for touch events.
+ */
+ get legacyactions() {
+ if (!this._legacyactions) {
+ this._legacyactions = new lazy.legacyaction.Chain();
+ }
+
+ return this._legacyactions;
+ }
+
+ actorCreated() {
+ lazy.logger.trace(
+ `[${this.browsingContext.id}] MarionetteCommands actor created ` +
+ `for window id ${this.innerWindowId}`
+ );
+ }
+
+ didDestroy() {
+ lazy.logger.trace(
+ `[${this.browsingContext.id}] MarionetteCommands actor destroyed ` +
+ `for window id ${this.innerWindowId}`
+ );
+ }
+
+ async receiveMessage(msg) {
+ if (!this.contentWindow) {
+ throw new DOMException("Actor is no longer active", "InactiveActor");
+ }
+
+ try {
+ let result;
+ let waitForNextTick = false;
+
+ const { name, data: serializedData } = msg;
+ const data = lazy.json.deserialize(
+ serializedData,
+ this.#processActor.getNodeCache(),
+ this.contentWindow
+ );
+
+ switch (name) {
+ case "MarionetteCommandsParent:clearElement":
+ this.clearElement(data);
+ waitForNextTick = true;
+ break;
+ case "MarionetteCommandsParent:clickElement":
+ result = await this.clickElement(data);
+ waitForNextTick = true;
+ break;
+ case "MarionetteCommandsParent:executeScript":
+ result = await this.executeScript(data);
+ waitForNextTick = true;
+ break;
+ case "MarionetteCommandsParent:findElement":
+ result = await this.findElement(data);
+ break;
+ case "MarionetteCommandsParent:findElements":
+ result = await this.findElements(data);
+ break;
+ case "MarionetteCommandsParent:getActiveElement":
+ result = await this.getActiveElement();
+ break;
+ case "MarionetteCommandsParent:getElementAttribute":
+ result = await this.getElementAttribute(data);
+ break;
+ case "MarionetteCommandsParent:getElementProperty":
+ result = await this.getElementProperty(data);
+ break;
+ case "MarionetteCommandsParent:getElementRect":
+ result = await this.getElementRect(data);
+ break;
+ case "MarionetteCommandsParent:getElementTagName":
+ result = await this.getElementTagName(data);
+ break;
+ case "MarionetteCommandsParent:getElementText":
+ result = await this.getElementText(data);
+ break;
+ case "MarionetteCommandsParent:getElementValueOfCssProperty":
+ result = await this.getElementValueOfCssProperty(data);
+ break;
+ case "MarionetteCommandsParent:getPageSource":
+ result = await this.getPageSource();
+ break;
+ case "MarionetteCommandsParent:getScreenshotRect":
+ result = await this.getScreenshotRect(data);
+ break;
+ case "MarionetteCommandsParent:getShadowRoot":
+ result = await this.getShadowRoot(data);
+ break;
+ case "MarionetteCommandsParent:isElementDisplayed":
+ result = await this.isElementDisplayed(data);
+ break;
+ case "MarionetteCommandsParent:isElementEnabled":
+ result = await this.isElementEnabled(data);
+ break;
+ case "MarionetteCommandsParent:isElementSelected":
+ result = await this.isElementSelected(data);
+ break;
+ case "MarionetteCommandsParent:performActions":
+ result = await this.performActions(data);
+ waitForNextTick = true;
+ break;
+ case "MarionetteCommandsParent:releaseActions":
+ result = await this.releaseActions();
+ break;
+ case "MarionetteCommandsParent:sendKeysToElement":
+ result = await this.sendKeysToElement(data);
+ waitForNextTick = true;
+ break;
+ case "MarionetteCommandsParent:singleTap":
+ result = await this.singleTap(data);
+ waitForNextTick = true;
+ break;
+ case "MarionetteCommandsParent:switchToFrame":
+ result = await this.switchToFrame(data);
+ waitForNextTick = true;
+ break;
+ case "MarionetteCommandsParent:switchToParentFrame":
+ result = await this.switchToParentFrame();
+ waitForNextTick = true;
+ break;
+ }
+
+ // Inform the content process that the command has completed. It allows
+ // it to process async follow-up tasks before the reply is sent.
+ if (waitForNextTick) {
+ await new Promise(resolve => Services.tm.dispatchToMainThread(resolve));
+ }
+
+ return {
+ data: lazy.json.clone(result, this.#processActor.getNodeCache()),
+ };
+ } catch (e) {
+ // Always wrap errors as WebDriverError
+ return { error: lazy.error.wrap(e).toJSON() };
+ }
+ }
+
+ // Implementation of WebDriver commands
+
+ /** Clear the text of an element.
+ *
+ * @param {Object} options
+ * @param {Element} options.elem
+ */
+ clearElement(options = {}) {
+ const { elem } = options;
+
+ lazy.interaction.clearElement(elem);
+ }
+
+ /**
+ * Click an element.
+ */
+ async clickElement(options = {}) {
+ const { capabilities, elem } = options;
+
+ return lazy.interaction.clickElement(
+ elem,
+ capabilities["moz:accessibilityChecks"],
+ capabilities["moz:webdriverClick"]
+ );
+ }
+
+ /**
+ * Executes a JavaScript function.
+ */
+ async executeScript(options = {}) {
+ const { args, opts = {}, script } = options;
+
+ let sb;
+ if (opts.sandboxName) {
+ sb = this.sandboxes.get(opts.sandboxName, opts.newSandbox);
+ } else {
+ sb = lazy.sandbox.createMutable(this.document.defaultView);
+ }
+
+ return lazy.evaluate.sandbox(sb, script, args, opts);
+ }
+
+ /**
+ * Find an element in the current browsing context's document using the
+ * given search strategy.
+ *
+ * @param {Object} options
+ * @param {Object} options.opts
+ * @param {Element} opts.startNode
+ * @param {string} opts.strategy
+ * @param {string} opts.selector
+ *
+ */
+ async findElement(options = {}) {
+ const { strategy, selector, opts } = options;
+
+ opts.all = false;
+
+ const container = { frame: this.document.defaultView };
+ return lazy.element.find(container, strategy, selector, opts);
+ }
+
+ /**
+ * Find elements in the current browsing context's document using the
+ * given search strategy.
+ *
+ * @param {Object} options
+ * @param {Object} options.opts
+ * @param {Element} opts.startNode
+ * @param {string} opts.strategy
+ * @param {string} opts.selector
+ *
+ */
+ async findElements(options = {}) {
+ const { strategy, selector, opts } = options;
+
+ opts.all = true;
+
+ const container = { frame: this.document.defaultView };
+ return lazy.element.find(container, strategy, selector, opts);
+ }
+
+ /**
+ * Return the active element in the document.
+ */
+ async getActiveElement() {
+ let elem = this.document.activeElement;
+ if (!elem) {
+ throw new lazy.error.NoSuchElementError();
+ }
+
+ return elem;
+ }
+
+ /**
+ * Get the value of an attribute for the given element.
+ */
+ async getElementAttribute(options = {}) {
+ const { name, elem } = options;
+
+ if (lazy.element.isBooleanAttribute(elem, name)) {
+ if (elem.hasAttribute(name)) {
+ return "true";
+ }
+ return null;
+ }
+ return elem.getAttribute(name);
+ }
+
+ /**
+ * Get the value of a property for the given element.
+ */
+ async getElementProperty(options = {}) {
+ const { name, elem } = options;
+
+ // Waive Xrays to get unfiltered access to the untrusted element.
+ const el = Cu.waiveXrays(elem);
+ return typeof el[name] != "undefined" ? el[name] : null;
+ }
+
+ /**
+ * Get the position and dimensions of the element.
+ */
+ async getElementRect(options = {}) {
+ const { elem } = options;
+
+ const rect = elem.getBoundingClientRect();
+ return {
+ x: rect.x + this.document.defaultView.pageXOffset,
+ y: rect.y + this.document.defaultView.pageYOffset,
+ width: rect.width,
+ height: rect.height,
+ };
+ }
+
+ /**
+ * Get the tagName for the given element.
+ */
+ async getElementTagName(options = {}) {
+ const { elem } = options;
+
+ return elem.tagName.toLowerCase();
+ }
+
+ /**
+ * Get the text content for the given element.
+ */
+ async getElementText(options = {}) {
+ const { elem } = options;
+
+ try {
+ return lazy.atom.getElementText(elem, this.document.defaultView);
+ } catch (e) {
+ lazy.logger.warn(`Atom getElementText failed: "${e.message}"`);
+
+ // Fallback in case the atom implementation is broken.
+ // As known so far this only happens for XML documents (bug 1794099).
+ return elem.textContent;
+ }
+ }
+
+ /**
+ * Get the value of a css property for the given element.
+ */
+ async getElementValueOfCssProperty(options = {}) {
+ const { name, elem } = options;
+
+ const style = this.document.defaultView.getComputedStyle(elem);
+ return style.getPropertyValue(name);
+ }
+
+ /**
+ * Get the source of the current browsing context's document.
+ */
+ async getPageSource() {
+ return this.document.documentElement.outerHTML;
+ }
+
+ /**
+ * Returns the rect of the element to screenshot.
+ *
+ * Because the screen capture takes place in the parent process the dimensions
+ * for the screenshot have to be determined in the appropriate child process.
+ *
+ * Also it takes care of scrolling an element into view if requested.
+ *
+ * @param {Object} options
+ * @param {Element} options.elem
+ * Optional element to take a screenshot of.
+ * @param {boolean=} options.full
+ * True to take a screenshot of the entire document element.
+ * Defaults to true.
+ * @param {boolean=} options.scroll
+ * When <var>elem</var> is given, scroll it into view.
+ * Defaults to true.
+ *
+ * @return {DOMRect}
+ * The area to take a snapshot from.
+ */
+ async getScreenshotRect(options = {}) {
+ const { elem, full = true, scroll = true } = options;
+ const win = elem
+ ? this.document.defaultView
+ : this.browsingContext.top.window;
+
+ let rect;
+
+ if (elem) {
+ if (scroll) {
+ lazy.element.scrollIntoView(elem);
+ }
+ rect = this.getElementRect({ elem });
+ } else if (full) {
+ const docEl = win.document.documentElement;
+ rect = new DOMRect(0, 0, docEl.scrollWidth, docEl.scrollHeight);
+ } else {
+ // viewport
+ rect = new DOMRect(
+ win.pageXOffset,
+ win.pageYOffset,
+ win.innerWidth,
+ win.innerHeight
+ );
+ }
+
+ return rect;
+ }
+
+ /**
+ * Return the shadowRoot attached to an element
+ */
+ async getShadowRoot(options = {}) {
+ const { elem } = options;
+
+ return lazy.element.getShadowRoot(elem);
+ }
+
+ /**
+ * Determine the element displayedness of the given web element.
+ */
+ async isElementDisplayed(options = {}) {
+ const { capabilities, elem } = options;
+
+ return lazy.interaction.isElementDisplayed(
+ elem,
+ capabilities["moz:accessibilityChecks"]
+ );
+ }
+
+ /**
+ * Check if element is enabled.
+ */
+ async isElementEnabled(options = {}) {
+ const { capabilities, elem } = options;
+
+ return lazy.interaction.isElementEnabled(
+ elem,
+ capabilities["moz:accessibilityChecks"]
+ );
+ }
+
+ /**
+ * Determine whether the referenced element is selected or not.
+ */
+ async isElementSelected(options = {}) {
+ const { capabilities, elem } = options;
+
+ return lazy.interaction.isElementSelected(
+ elem,
+ capabilities["moz:accessibilityChecks"]
+ );
+ }
+
+ /**
+ * Perform a series of grouped actions at the specified points in time.
+ *
+ * @param {Object} options
+ * @param {Object} options.actions
+ * Array of objects with each representing an action sequence.
+ * @param {Object} options.capabilities
+ * Object with a list of WebDriver session capabilities.
+ */
+ async performActions(options = {}) {
+ const { actions, capabilities } = options;
+ if (this.actionState === null) {
+ this.actionState = new lazy.action.State({
+ specCompatPointerOrigin: !capabilities[
+ "moz:useNonSpecCompliantPointerOrigin"
+ ],
+ });
+ }
+ let actionChain = lazy.action.Chain.fromJSON(this.actionState, actions);
+
+ await actionChain.dispatch(this.actionState, this.document.defaultView);
+ }
+
+ /**
+ * The release actions command is used to release all the keys and pointer
+ * buttons that are currently depressed. This causes events to be fired
+ * as if the state was released by an explicit series of actions. It also
+ * clears all the internal state of the virtual devices.
+ */
+ async releaseActions() {
+ if (this.actionState === null) {
+ return;
+ }
+ this.actionState.inputsToCancel.reverse();
+ await this.actionState.inputsToCancel.dispatch(
+ this.actionState,
+ this.document.defaultView
+ );
+ this.actionState = null;
+ lazy.event.DoubleClickTracker.resetClick();
+ }
+
+ /*
+ * Send key presses to element after focusing on it.
+ */
+ async sendKeysToElement(options = {}) {
+ const { capabilities, elem, text } = options;
+
+ const opts = {
+ strictFileInteractability: capabilities.strictFileInteractability,
+ accessibilityChecks: capabilities["moz:accessibilityChecks"],
+ webdriverClick: capabilities["moz:webdriverClick"],
+ };
+
+ return lazy.interaction.sendKeysToElement(elem, text, opts);
+ }
+
+ /**
+ * Perform a single tap.
+ */
+ async singleTap(options = {}) {
+ const { capabilities, elem, x, y } = options;
+ return this.legacyactions.singleTap(elem, x, y, capabilities);
+ }
+
+ /**
+ * Switch to the specified frame.
+ *
+ * @param {Object=} options
+ * @param {(number|Element)=} options.id
+ * If it's a number treat it as the index for all the existing frames.
+ * If it's an Element switch to this specific frame.
+ * If not specified or `null` switch to the top-level browsing context.
+ */
+ async switchToFrame(options = {}) {
+ const { id } = options;
+
+ const childContexts = this.browsingContext.children;
+ let browsingContext;
+
+ if (id == null) {
+ browsingContext = this.browsingContext.top;
+ } else if (typeof id == "number") {
+ if (id < 0 || id >= childContexts.length) {
+ throw new lazy.error.NoSuchFrameError(
+ `Unable to locate frame with index: ${id}`
+ );
+ }
+ browsingContext = childContexts[id];
+ } else {
+ const context = childContexts.find(context => {
+ return context.embedderElement === id;
+ });
+ if (!context) {
+ throw new lazy.error.NoSuchFrameError(
+ `Unable to locate frame for element: ${id}`
+ );
+ }
+ browsingContext = context;
+ }
+
+ // For in-process iframes the window global is lazy-loaded for optimization
+ // reasons. As such force the currentWindowGlobal to be created so we always
+ // have a window (bug 1691348).
+ browsingContext.window;
+
+ return { browsingContextId: browsingContext.id };
+ }
+
+ /**
+ * Switch to the parent frame.
+ */
+ async switchToParentFrame() {
+ const browsingContext = this.browsingContext.parent || this.browsingContext;
+
+ return { browsingContextId: browsingContext.id };
+ }
+}
diff --git a/remote/marionette/actors/MarionetteCommandsParent.sys.mjs b/remote/marionette/actors/MarionetteCommandsParent.sys.mjs
new file mode 100644
index 0000000000..fed8a4e7e4
--- /dev/null
+++ b/remote/marionette/actors/MarionetteCommandsParent.sys.mjs
@@ -0,0 +1,369 @@
+/* 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, {
+ capture: "chrome://remote/content/shared/Capture.sys.mjs",
+ error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs",
+ Log: "chrome://remote/content/shared/Log.sys.mjs",
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "logger", () =>
+ lazy.Log.get(lazy.Log.TYPES.MARIONETTE)
+);
+
+export class MarionetteCommandsParent extends JSWindowActorParent {
+ actorCreated() {
+ this._resolveDialogOpened = null;
+ }
+
+ dialogOpenedPromise() {
+ return new Promise(resolve => {
+ this._resolveDialogOpened = resolve;
+ });
+ }
+
+ async sendQuery(name, data) {
+ // return early if a dialog is opened
+ const result = await Promise.race([
+ super.sendQuery(name, data),
+ this.dialogOpenedPromise(),
+ ]).finally(() => {
+ this._resolveDialogOpened = null;
+ });
+
+ if ("error" in result) {
+ throw lazy.error.WebDriverError.fromJSON(result.error);
+ } else {
+ return result.data;
+ }
+ }
+
+ notifyDialogOpened() {
+ if (this._resolveDialogOpened) {
+ this._resolveDialogOpened({ data: null });
+ }
+ }
+
+ // Proxying methods for WebDriver commands
+
+ clearElement(webEl) {
+ return this.sendQuery("MarionetteCommandsParent:clearElement", {
+ elem: webEl,
+ });
+ }
+
+ clickElement(webEl, capabilities) {
+ return this.sendQuery("MarionetteCommandsParent:clickElement", {
+ elem: webEl,
+ capabilities: capabilities.toJSON(),
+ });
+ }
+
+ async executeScript(script, args, opts) {
+ return this.sendQuery("MarionetteCommandsParent:executeScript", {
+ script,
+ args,
+ opts,
+ });
+ }
+
+ findElement(strategy, selector, opts) {
+ return this.sendQuery("MarionetteCommandsParent:findElement", {
+ strategy,
+ selector,
+ opts,
+ });
+ }
+
+ findElements(strategy, selector, opts) {
+ return this.sendQuery("MarionetteCommandsParent:findElements", {
+ strategy,
+ selector,
+ opts,
+ });
+ }
+
+ async getShadowRoot(webEl) {
+ return this.sendQuery("MarionetteCommandsParent:getShadowRoot", {
+ elem: webEl,
+ });
+ }
+
+ async getActiveElement() {
+ return this.sendQuery("MarionetteCommandsParent:getActiveElement");
+ }
+
+ async getElementAttribute(webEl, name) {
+ return this.sendQuery("MarionetteCommandsParent:getElementAttribute", {
+ elem: webEl,
+ name,
+ });
+ }
+
+ async getElementProperty(webEl, name) {
+ return this.sendQuery("MarionetteCommandsParent:getElementProperty", {
+ elem: webEl,
+ name,
+ });
+ }
+
+ async getElementRect(webEl) {
+ return this.sendQuery("MarionetteCommandsParent:getElementRect", {
+ elem: webEl,
+ });
+ }
+
+ async getElementTagName(webEl) {
+ return this.sendQuery("MarionetteCommandsParent:getElementTagName", {
+ elem: webEl,
+ });
+ }
+
+ async getElementText(webEl) {
+ return this.sendQuery("MarionetteCommandsParent:getElementText", {
+ elem: webEl,
+ });
+ }
+
+ async getElementValueOfCssProperty(webEl, name) {
+ return this.sendQuery(
+ "MarionetteCommandsParent:getElementValueOfCssProperty",
+ {
+ elem: webEl,
+ name,
+ }
+ );
+ }
+
+ async getPageSource() {
+ return this.sendQuery("MarionetteCommandsParent:getPageSource");
+ }
+
+ async isElementDisplayed(webEl, capabilities) {
+ return this.sendQuery("MarionetteCommandsParent:isElementDisplayed", {
+ capabilities: capabilities.toJSON(),
+ elem: webEl,
+ });
+ }
+
+ async isElementEnabled(webEl, capabilities) {
+ return this.sendQuery("MarionetteCommandsParent:isElementEnabled", {
+ capabilities: capabilities.toJSON(),
+ elem: webEl,
+ });
+ }
+
+ async isElementSelected(webEl, capabilities) {
+ return this.sendQuery("MarionetteCommandsParent:isElementSelected", {
+ capabilities: capabilities.toJSON(),
+ elem: webEl,
+ });
+ }
+
+ async sendKeysToElement(webEl, text, capabilities) {
+ return this.sendQuery("MarionetteCommandsParent:sendKeysToElement", {
+ capabilities: capabilities.toJSON(),
+ elem: webEl,
+ text,
+ });
+ }
+
+ async performActions(actions, capabilities) {
+ return this.sendQuery("MarionetteCommandsParent:performActions", {
+ actions,
+ capabilities: capabilities.toJSON(),
+ });
+ }
+
+ async releaseActions() {
+ return this.sendQuery("MarionetteCommandsParent:releaseActions");
+ }
+
+ async singleTap(webEl, x, y, capabilities) {
+ return this.sendQuery("MarionetteCommandsParent:singleTap", {
+ capabilities: capabilities.toJSON(),
+ elem: webEl,
+ x,
+ y,
+ });
+ }
+
+ async switchToFrame(id) {
+ const {
+ browsingContextId,
+ } = await this.sendQuery("MarionetteCommandsParent:switchToFrame", { id });
+
+ return {
+ browsingContext: BrowsingContext.get(browsingContextId),
+ };
+ }
+
+ async switchToParentFrame() {
+ const { browsingContextId } = await this.sendQuery(
+ "MarionetteCommandsParent:switchToParentFrame"
+ );
+
+ return {
+ browsingContext: BrowsingContext.get(browsingContextId),
+ };
+ }
+
+ async takeScreenshot(webEl, format, full, scroll) {
+ const rect = await this.sendQuery(
+ "MarionetteCommandsParent:getScreenshotRect",
+ {
+ elem: webEl,
+ full,
+ scroll,
+ }
+ );
+
+ // If no element has been specified use the top-level browsing context.
+ // Otherwise use the browsing context from the currently selected frame.
+ const browsingContext = webEl
+ ? this.browsingContext
+ : this.browsingContext.top;
+
+ let canvas = await lazy.capture.canvas(
+ browsingContext.topChromeWindow,
+ browsingContext,
+ rect.x,
+ rect.y,
+ rect.width,
+ rect.height
+ );
+
+ switch (format) {
+ case lazy.capture.Format.Hash:
+ return lazy.capture.toHash(canvas);
+
+ case lazy.capture.Format.Base64:
+ return lazy.capture.toBase64(canvas);
+
+ default:
+ throw new TypeError(`Invalid capture format: ${format}`);
+ }
+ }
+}
+
+/**
+ * Proxy that will dynamically create MarionetteCommands actors for a dynamically
+ * provided browsing context until the method can be fully executed by the
+ * JSWindowActor pair.
+ *
+ * @param {function(): BrowsingContext} browsingContextFn
+ * A function that returns the reference to the browsing context for which
+ * the query should run.
+ */
+export function getMarionetteCommandsActorProxy(browsingContextFn) {
+ const MAX_ATTEMPTS = 10;
+
+ /**
+ * Methods which modify the content page cannot be retried safely.
+ * See Bug 1673345.
+ */
+ const NO_RETRY_METHODS = [
+ "clickElement",
+ "executeScript",
+ "performActions",
+ "releaseActions",
+ "sendKeysToElement",
+ "singleTap",
+ ];
+
+ return new Proxy(
+ {},
+ {
+ get(target, methodName) {
+ return async (...args) => {
+ let attempts = 0;
+ while (true) {
+ try {
+ const browsingContext = browsingContextFn();
+ if (!browsingContext) {
+ throw new DOMException(
+ "No BrowsingContext found",
+ "NoBrowsingContext"
+ );
+ }
+
+ // TODO: Scenarios where the window/tab got closed and
+ // currentWindowGlobal is null will be handled in Bug 1662808.
+ const actor = browsingContext.currentWindowGlobal.getActor(
+ "MarionetteCommands"
+ );
+
+ const result = await actor[methodName](...args);
+ return result;
+ } catch (e) {
+ if (!["AbortError", "InactiveActor"].includes(e.name)) {
+ // Only retry when the JSWindowActor pair gets destroyed, or
+ // gets inactive eg. when the page is moved into bfcache.
+ throw e;
+ }
+
+ if (NO_RETRY_METHODS.includes(methodName)) {
+ const browsingContextId = browsingContextFn()?.id;
+ lazy.logger.trace(
+ `[${browsingContextId}] Querying "${methodName}" failed with` +
+ ` ${e.name}, returning "null" as fallback`
+ );
+ return null;
+ }
+
+ if (++attempts > MAX_ATTEMPTS) {
+ const browsingContextId = browsingContextFn()?.id;
+ lazy.logger.trace(
+ `[${browsingContextId}] Querying "${methodName} "` +
+ `reached the limit of retry attempts (${MAX_ATTEMPTS})`
+ );
+ throw e;
+ }
+
+ lazy.logger.trace(
+ `Retrying "${methodName}", attempt: ${attempts}`
+ );
+ }
+ }
+ };
+ },
+ }
+ );
+}
+
+/**
+ * Register the MarionetteCommands actor that holds all the commands.
+ */
+export function registerCommandsActor() {
+ try {
+ ChromeUtils.registerWindowActor("MarionetteCommands", {
+ kind: "JSWindowActor",
+ parent: {
+ esModuleURI:
+ "chrome://remote/content/marionette/actors/MarionetteCommandsParent.sys.mjs",
+ },
+ child: {
+ esModuleURI:
+ "chrome://remote/content/marionette/actors/MarionetteCommandsChild.sys.mjs",
+ },
+
+ allFrames: true,
+ includeChrome: true,
+ });
+ } catch (e) {
+ if (e.name === "NotSupportedError") {
+ lazy.logger.warn(`MarionetteCommands actor is already registered!`);
+ } else {
+ throw e;
+ }
+ }
+}
+
+export function unregisterCommandsActor() {
+ ChromeUtils.unregisterWindowActor("MarionetteCommands");
+}
diff --git a/remote/marionette/actors/MarionetteEventsChild.sys.mjs b/remote/marionette/actors/MarionetteEventsChild.sys.mjs
new file mode 100644
index 0000000000..2cf5afac65
--- /dev/null
+++ b/remote/marionette/actors/MarionetteEventsChild.sys.mjs
@@ -0,0 +1,84 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* eslint-disable no-restricted-globals */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ event: "chrome://remote/content/marionette/event.sys.mjs",
+ Log: "chrome://remote/content/shared/Log.sys.mjs",
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "logger", () =>
+ lazy.Log.get(lazy.Log.TYPES.MARIONETTE)
+);
+
+export class MarionetteEventsChild extends JSWindowActorChild {
+ get innerWindowId() {
+ return this.manager.innerWindowId;
+ }
+
+ actorCreated() {
+ // Prevent the logger from being created if the current log level
+ // isn't set to 'trace'. This is important for a faster content process
+ // creation when Marionette is running.
+ if (lazy.Log.isTraceLevel) {
+ lazy.logger.trace(
+ `[${this.browsingContext.id}] MarionetteEvents actor created ` +
+ `for window id ${this.innerWindowId}`
+ );
+ }
+ }
+
+ handleEvent({ target, type }) {
+ if (!Services.cpmm.sharedData.get("MARIONETTE_EVENTS_ENABLED")) {
+ // The parent process will set MARIONETTE_EVENTS_ENABLED to false when
+ // the Marionette session ends to avoid unnecessary inter process
+ // communications
+ return;
+ }
+
+ // Ignore invalid combinations of load events and document's readyState.
+ if (
+ (type === "DOMContentLoaded" && target.readyState != "interactive") ||
+ (type === "pageshow" && target.readyState != "complete")
+ ) {
+ lazy.logger.warn(
+ `Ignoring event '${type}' because document has an invalid ` +
+ `readyState of '${target.readyState}'.`
+ );
+ return;
+ }
+
+ switch (type) {
+ case "beforeunload":
+ case "DOMContentLoaded":
+ case "hashchange":
+ case "pagehide":
+ case "pageshow":
+ case "popstate":
+ this.sendAsyncMessage("MarionetteEventsChild:PageLoadEvent", {
+ browsingContext: this.browsingContext,
+ documentURI: target.documentURI,
+ readyState: target.readyState,
+ type,
+ windowId: this.innerWindowId,
+ });
+ break;
+
+ // Listen for click event to indicate one click has happened, so actions
+ // code can send dblclick event
+ case "click":
+ lazy.event.DoubleClickTracker.setClick();
+ break;
+ case "dblclick":
+ case "unload":
+ lazy.event.DoubleClickTracker.resetClick();
+ break;
+ }
+ }
+}
diff --git a/remote/marionette/actors/MarionetteEventsParent.sys.mjs b/remote/marionette/actors/MarionetteEventsParent.sys.mjs
new file mode 100644
index 0000000000..4211f99e59
--- /dev/null
+++ b/remote/marionette/actors/MarionetteEventsParent.sys.mjs
@@ -0,0 +1,115 @@
+/* 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, {
+ EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs",
+
+ Log: "chrome://remote/content/shared/Log.sys.mjs",
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "logger", () =>
+ lazy.Log.get(lazy.Log.TYPES.MARIONETTE)
+);
+
+// Singleton to allow forwarding events to registered listeners.
+export const EventDispatcher = {
+ init() {
+ lazy.EventEmitter.decorate(this);
+ },
+};
+
+EventDispatcher.init();
+
+export class MarionetteEventsParent extends JSWindowActorParent {
+ async receiveMessage(msg) {
+ const { name, data } = msg;
+
+ let rv;
+ switch (name) {
+ case "MarionetteEventsChild:PageLoadEvent":
+ EventDispatcher.emit("page-load", data);
+ break;
+ }
+
+ return rv;
+ }
+}
+
+// Flag to check if the MarionetteEvents actors have already been registed.
+let eventsActorRegistered = false;
+
+/**
+ * Register Events actors to listen for page load events via EventDispatcher.
+ */
+function registerEventsActor() {
+ if (eventsActorRegistered) {
+ return;
+ }
+
+ try {
+ // Register the JSWindowActor pair for events as used by Marionette
+ ChromeUtils.registerWindowActor("MarionetteEvents", {
+ kind: "JSWindowActor",
+ parent: {
+ esModuleURI:
+ "chrome://remote/content/marionette/actors/MarionetteEventsParent.sys.mjs",
+ },
+ child: {
+ esModuleURI:
+ "chrome://remote/content/marionette/actors/MarionetteEventsChild.sys.mjs",
+ events: {
+ beforeunload: { capture: true },
+ DOMContentLoaded: { mozSystemGroup: true },
+ hashchange: { mozSystemGroup: true },
+ pagehide: { mozSystemGroup: true },
+ pageshow: { mozSystemGroup: true },
+ // popstate doesn't bubble, as such use capturing phase
+ popstate: { capture: true, mozSystemGroup: true },
+
+ click: {},
+ dblclick: {},
+ unload: { capture: true, createActor: false },
+ },
+ },
+
+ allFrames: true,
+ includeChrome: true,
+ });
+
+ eventsActorRegistered = true;
+ } catch (e) {
+ if (e.name === "NotSupportedError") {
+ lazy.logger.warn(`MarionetteEvents actor is already registered!`);
+ } else {
+ throw e;
+ }
+ }
+}
+
+/**
+ * Enable MarionetteEvents actors to start forwarding page load events from the
+ * child actor to the parent actor. Register the MarionetteEvents actor if necessary.
+ */
+export function enableEventsActor() {
+ // sharedData is replicated across processes and will be checked by
+ // MarionetteEventsChild before forward events to the parent actor.
+ Services.ppmm.sharedData.set("MARIONETTE_EVENTS_ENABLED", true);
+ // Request to immediately flush the data to the content processes to avoid races.
+ Services.ppmm.sharedData.flush();
+
+ registerEventsActor();
+}
+
+/**
+ * Disable MarionetteEvents actors to stop forwarding page load events from the
+ * child actor to the parent actor.
+ */
+export function disableEventsActor() {
+ Services.ppmm.sharedData.set("MARIONETTE_EVENTS_ENABLED", false);
+ Services.ppmm.sharedData.flush();
+}
diff --git a/remote/marionette/actors/MarionetteReftestChild.sys.mjs b/remote/marionette/actors/MarionetteReftestChild.sys.mjs
new file mode 100644
index 0000000000..e1a9918af2
--- /dev/null
+++ b/remote/marionette/actors/MarionetteReftestChild.sys.mjs
@@ -0,0 +1,236 @@
+/* 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(lazy.Log.TYPES.MARIONETTE)
+);
+
+/**
+ * Child JSWindowActor to handle navigation for reftests relying on marionette.
+ */
+export class MarionetteReftestChild extends JSWindowActorChild {
+ constructor() {
+ super();
+
+ // This promise will resolve with the URL recorded in the "load" event
+ // handler. This URL will not be impacted by any hash modification that
+ // might be performed by the test script.
+ // The harness should be loaded before loading any test page, so the actors
+ // should be registered before the "load" event is received for a test page.
+ this._loadedURLPromise = new Promise(
+ r => (this._resolveLoadedURLPromise = r)
+ );
+ }
+
+ handleEvent(event) {
+ if (event.type == "load") {
+ const url = event.target.location.href;
+ lazy.logger.debug(`Handle load event with URL ${url}`);
+ this._resolveLoadedURLPromise(url);
+ }
+ }
+
+ actorCreated() {
+ lazy.logger.trace(
+ `[${this.browsingContext.id}] Reftest actor created ` +
+ `for window id ${this.manager.innerWindowId}`
+ );
+ }
+
+ async receiveMessage(msg) {
+ const { name, data } = msg;
+
+ let result;
+ switch (name) {
+ case "MarionetteReftestParent:flushRendering":
+ result = await this.flushRendering(data);
+ break;
+ case "MarionetteReftestParent:reftestWait":
+ result = await this.reftestWait(data);
+ break;
+ }
+ return result;
+ }
+
+ /**
+ * Wait for a reftest page to be ready for screenshots:
+ * - wait for the loadedURL to be available (see handleEvent)
+ * - check if the URL matches the expected URL
+ * - if present, wait for the "reftest-wait" classname to be removed from the
+ * document element
+ *
+ * @param {Object} options
+ * @param {String} options.url
+ * The expected test page URL
+ * @param {Boolean} options.useRemote
+ * True when using e10s
+ * @return {Boolean}
+ * Returns true when the correct page is loaded and ready for
+ * screenshots. Returns false if the page loaded bug does not have the
+ * expected URL.
+ */
+ async reftestWait(options = {}) {
+ const { url, useRemote } = options;
+ const loadedURL = await this._loadedURLPromise;
+ if (loadedURL !== url) {
+ lazy.logger.debug(
+ `Window URL does not match the expected URL "${loadedURL}" !== "${url}"`
+ );
+ return false;
+ }
+
+ const documentElement = this.document.documentElement;
+ const hasReftestWait = documentElement.classList.contains("reftest-wait");
+
+ lazy.logger.debug("Waiting for event loop to spin");
+ await new Promise(resolve =>
+ this.document.defaultView.setTimeout(resolve, 0)
+ );
+
+ await this.paintComplete({ useRemote, ignoreThrottledAnimations: true });
+
+ if (hasReftestWait) {
+ const event = new this.document.defaultView.Event("TestRendered", {
+ bubbles: true,
+ });
+ documentElement.dispatchEvent(event);
+ lazy.logger.info("Emitted TestRendered event");
+ await this.reftestWaitRemoved();
+ await this.paintComplete({ useRemote, ignoreThrottledAnimations: false });
+ }
+ if (
+ this.document.defaultView.innerWidth < documentElement.scrollWidth ||
+ this.document.defaultView.innerHeight < documentElement.scrollHeight
+ ) {
+ lazy.logger.warn(
+ `${url} overflows viewport (width: ${documentElement.scrollWidth}, height: ${documentElement.scrollHeight})`
+ );
+ }
+ return true;
+ }
+
+ paintComplete({ useRemote, ignoreThrottledAnimations }) {
+ lazy.logger.debug("Waiting for rendering");
+ let windowUtils = this.document.defaultView.windowUtils;
+ return new Promise(resolve => {
+ let maybeResolve = () => {
+ this.flushRendering({ ignoreThrottledAnimations });
+ if (useRemote) {
+ // Flush display (paint)
+ lazy.logger.debug("Force update of layer tree");
+ windowUtils.updateLayerTree();
+ }
+
+ if (windowUtils.isMozAfterPaintPending) {
+ lazy.logger.debug("isMozAfterPaintPending: true");
+ this.document.defaultView.addEventListener(
+ "MozAfterPaint",
+ maybeResolve,
+ {
+ once: true,
+ }
+ );
+ } else {
+ // resolve at the start of the next frame in case of leftover paints
+ lazy.logger.debug("isMozAfterPaintPending: false");
+ this.document.defaultView.requestAnimationFrame(() => {
+ this.document.defaultView.requestAnimationFrame(resolve);
+ });
+ }
+ };
+ maybeResolve();
+ });
+ }
+
+ reftestWaitRemoved() {
+ lazy.logger.debug("Waiting for reftest-wait removal");
+ return new Promise(resolve => {
+ const documentElement = this.document.documentElement;
+ let observer = new this.document.defaultView.MutationObserver(() => {
+ if (!documentElement.classList.contains("reftest-wait")) {
+ observer.disconnect();
+ lazy.logger.debug("reftest-wait removed");
+ this.document.defaultView.setTimeout(resolve, 0);
+ }
+ });
+ if (documentElement.classList.contains("reftest-wait")) {
+ observer.observe(documentElement, { attributes: true });
+ } else {
+ this.document.defaultView.setTimeout(resolve, 0);
+ }
+ });
+ }
+
+ /**
+ * Ensure layout is flushed in each frame
+ *
+ * @param {Object} options
+ * @param {Boolean} options.ignoreThrottledAnimations Don't flush
+ * the layout of throttled animations. We can end up in a
+ * situation where flushing a throttled animation causes
+ * mozAfterPaint events even when all rendering we care about
+ * should have ceased. See
+ * https://searchfox.org/mozilla-central/rev/d58860eb739af613774c942c3bb61754123e449b/layout/tools/reftest/reftest-content.js#723-729
+ * for more detail.
+ */
+ flushRendering(options = {}) {
+ let { ignoreThrottledAnimations } = options;
+ lazy.logger.debug(
+ `flushRendering ignoreThrottledAnimations:${ignoreThrottledAnimations}`
+ );
+ let anyPendingPaintsGeneratedInDescendants = false;
+
+ let windowUtils = this.document.defaultView.windowUtils;
+
+ function flushWindow(win) {
+ let utils = win.windowUtils;
+ let afterPaintWasPending = utils.isMozAfterPaintPending;
+
+ let root = win.document.documentElement;
+ if (root) {
+ try {
+ if (ignoreThrottledAnimations) {
+ utils.flushLayoutWithoutThrottledAnimations();
+ } else {
+ root.getBoundingClientRect();
+ }
+ } catch (e) {
+ lazy.logger.error("flushWindow failed", e);
+ }
+ }
+
+ if (!afterPaintWasPending && utils.isMozAfterPaintPending) {
+ anyPendingPaintsGeneratedInDescendants = true;
+ }
+
+ for (let i = 0; i < win.frames.length; ++i) {
+ // Skip remote frames, flushRendering will be called on their individual
+ // MarionetteReftest actor via _recursiveFlushRendering performed from
+ // the topmost MarionetteReftest actor.
+ if (!Cu.isRemoteProxy(win.frames[i])) {
+ flushWindow(win.frames[i]);
+ }
+ }
+ }
+ flushWindow(this.document.defaultView);
+
+ if (
+ anyPendingPaintsGeneratedInDescendants &&
+ !windowUtils.isMozAfterPaintPending
+ ) {
+ lazy.logger.error(
+ "Descendant frame generated a MozAfterPaint event, " +
+ "but the root document doesn't have one!"
+ );
+ }
+ }
+}
diff --git a/remote/marionette/actors/MarionetteReftestParent.sys.mjs b/remote/marionette/actors/MarionetteReftestParent.sys.mjs
new file mode 100644
index 0000000000..f6d79f04d3
--- /dev/null
+++ b/remote/marionette/actors/MarionetteReftestParent.sys.mjs
@@ -0,0 +1,85 @@
+/* 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/. */
+
+/**
+ * Parent JSWindowActor to handle navigation for reftests relying on marionette.
+ */
+export class MarionetteReftestParent extends JSWindowActorParent {
+ /**
+ * Wait for the expected URL to be loaded.
+ *
+ * @param {String} url
+ * The expected url.
+ * @param {Boolean} useRemote
+ * True if tests are running with e10s.
+ * @return {Boolean} true if the page is fully loaded with the expected url,
+ * false otherwise.
+ */
+ async reftestWait(url, useRemote) {
+ try {
+ const isCorrectUrl = await this.sendQuery(
+ "MarionetteReftestParent:reftestWait",
+ {
+ url,
+ useRemote,
+ }
+ );
+
+ if (isCorrectUrl) {
+ // Trigger flush rendering for all remote frames.
+ await this._flushRenderingInSubtree({
+ ignoreThrottledAnimations: false,
+ });
+ }
+
+ return isCorrectUrl;
+ } catch (e) {
+ if (e.name === "AbortError") {
+ // If the query is aborted, the window global is being destroyed, most
+ // likely because a navigation happened.
+ return false;
+ }
+
+ // Other errors should not be swallowed.
+ throw e;
+ }
+ }
+
+ /**
+ * Call flushRendering on all browsing contexts in the subtree.
+ * Each actor will flush rendering in all the same process frames.
+ */
+ async _flushRenderingInSubtree({ ignoreThrottledAnimations }) {
+ const browsingContext = this.manager.browsingContext;
+ const contexts = browsingContext.getAllBrowsingContextsInSubtree();
+
+ await Promise.all(
+ contexts.map(async context => {
+ if (context === browsingContext) {
+ // Skip the top browsing context, for which flushRendering is
+ // already performed via the initial reftestWait call.
+ return;
+ }
+
+ const windowGlobal = context.currentWindowGlobal;
+ if (!windowGlobal) {
+ // Bail out if there is no window attached to the current context.
+ return;
+ }
+
+ if (!windowGlobal.isProcessRoot) {
+ // Bail out if this window global is not a process root.
+ // MarionetteReftestChild::flushRendering will flush all same process
+ // frames, so we only need to call flushRendering on process roots.
+ return;
+ }
+
+ const reftestActor = windowGlobal.getActor("MarionetteReftest");
+ await reftestActor.sendQuery("MarionetteReftestParent:flushRendering", {
+ ignoreThrottledAnimations,
+ });
+ })
+ );
+ }
+}
diff --git a/remote/marionette/addon.sys.mjs b/remote/marionette/addon.sys.mjs
new file mode 100644
index 0000000000..5ba1143e28
--- /dev/null
+++ b/remote/marionette/addon.sys.mjs
@@ -0,0 +1,139 @@
+/* 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, {
+ FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
+
+ error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ AddonManager: "resource://gre/modules/AddonManager.jsm",
+});
+
+// from https://developer.mozilla.org/en-US/Add-ons/Add-on_Manager/AddonManager#AddonInstall_errors
+const ERRORS = {
+ [-1]: "ERROR_NETWORK_FAILURE: A network error occured.",
+ [-2]: "ERROR_INCORECT_HASH: The downloaded file did not match the expected hash.",
+ [-3]: "ERROR_CORRUPT_FILE: The file appears to be corrupt.",
+ [-4]: "ERROR_FILE_ACCESS: There was an error accessing the filesystem.",
+ [-5]: "ERROR_SIGNEDSTATE_REQUIRED: The addon must be signed and isn't.",
+};
+
+async function installAddon(file) {
+ let install = await lazy.AddonManager.getInstallForFile(file, null, {
+ source: "internal",
+ });
+
+ if (install.error) {
+ throw new lazy.error.UnknownError(ERRORS[install.error]);
+ }
+
+ return install.install().catch(err => {
+ throw new lazy.error.UnknownError(ERRORS[install.error]);
+ });
+}
+
+/** Installs addons by path and uninstalls by ID. */
+export class Addon {
+ /**
+ * Install a Firefox addon.
+ *
+ * If the addon is restartless, it can be used right away. Otherwise a
+ * restart is required.
+ *
+ * Temporary addons will automatically be uninstalled on shutdown and
+ * do not need to be signed, though they must be restartless.
+ *
+ * @param {string} path
+ * Full path to the extension package archive.
+ * @param {boolean=} temporary
+ * True to install the addon temporarily, false (default) otherwise.
+ *
+ * @return {Promise.<string>}
+ * Addon ID.
+ *
+ * @throws {UnknownError}
+ * If there is a problem installing the addon.
+ */
+ static async install(path, temporary = false) {
+ let addon;
+ let file;
+
+ try {
+ file = new lazy.FileUtils.File(path);
+ } catch (e) {
+ throw new lazy.error.UnknownError(`Expected absolute path: ${e}`, e);
+ }
+
+ if (!file.exists()) {
+ throw new lazy.error.UnknownError(`No such file or directory: ${path}`);
+ }
+
+ try {
+ if (temporary) {
+ addon = await lazy.AddonManager.installTemporaryAddon(file);
+ } else {
+ addon = await installAddon(file);
+ }
+ } catch (e) {
+ throw new lazy.error.UnknownError(
+ `Could not install add-on: ${path}: ${e.message}`,
+ e
+ );
+ }
+
+ return addon.id;
+ }
+
+ /**
+ * Uninstall a Firefox addon.
+ *
+ * If the addon is restartless it will be uninstalled right away.
+ * Otherwise, Firefox must be restarted for the change to take effect.
+ *
+ * @param {string} id
+ * ID of the addon to uninstall.
+ *
+ * @return {Promise}
+ *
+ * @throws {UnknownError}
+ * If there is a problem uninstalling the addon.
+ */
+ static async uninstall(id) {
+ let candidate = await lazy.AddonManager.getAddonByID(id);
+ if (candidate === null) {
+ // `AddonManager.getAddonByID` never rejects but instead
+ // returns `null` if the requested addon cannot be found.
+ throw new lazy.error.UnknownError(`Addon ${id} is not installed`);
+ }
+
+ return new Promise(resolve => {
+ let listener = {
+ onOperationCancelled: addon => {
+ if (addon.id === candidate.id) {
+ lazy.AddonManager.removeAddonListener(listener);
+ throw new lazy.error.UnknownError(
+ `Uninstall of ${candidate.id} has been canceled`
+ );
+ }
+ },
+
+ onUninstalled: addon => {
+ if (addon.id === candidate.id) {
+ lazy.AddonManager.removeAddonListener(listener);
+ resolve();
+ }
+ },
+ };
+
+ lazy.AddonManager.addAddonListener(listener);
+ candidate.uninstall();
+ });
+ }
+}
diff --git a/remote/marionette/atom.sys.mjs b/remote/marionette/atom.sys.mjs
new file mode 100644
index 0000000000..70aa53b91c
--- /dev/null
+++ b/remote/marionette/atom.sys.mjs
@@ -0,0 +1,305 @@
+// Copyright 2011-2017 Software Freedom Conservancy
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+/** @namespace */
+export const atom = {};
+
+// Follow the instructions to export all the atoms:
+// https://firefox-source-docs.mozilla.org/testing/marionette/SeleniumAtoms.html
+//
+// Built from SHA1: a6b161a159c3d581b130f03a2e6e35f577f38dec
+
+atom.getElementText = function(element, window){return (function(){var k=this||self;function aa(a){return"string"==typeof a}function ba(a,b){a=a.split(".");var c=k;a[0]in c||"undefined"==typeof c.execScript||c.execScript("var "+a[0]);for(var d;a.length&&(d=a.shift());)a.length||void 0===b?c[d]&&c[d]!==Object.prototype[d]?c=c[d]:c=c[d]={}:c[d]=b}
+function ca(a){var b=typeof a;if("object"==b)if(a){if(a instanceof Array)return"array";if(a instanceof Object)return b;var c=Object.prototype.toString.call(a);if("[object Window]"==c)return"object";if("[object Array]"==c||"number"==typeof a.length&&"undefined"!=typeof a.splice&&"undefined"!=typeof a.propertyIsEnumerable&&!a.propertyIsEnumerable("splice"))return"array";if("[object Function]"==c||"undefined"!=typeof a.call&&"undefined"!=typeof a.propertyIsEnumerable&&!a.propertyIsEnumerable("call"))return"function"}else return"null";
+else if("function"==b&&"undefined"==typeof a.call)return"object";return b}function da(a,b,c){return a.call.apply(a.bind,arguments)}function ea(a,b,c){if(!a)throw Error();if(2<arguments.length){var d=Array.prototype.slice.call(arguments,2);return function(){var e=Array.prototype.slice.call(arguments);Array.prototype.unshift.apply(e,d);return a.apply(b,e)}}return function(){return a.apply(b,arguments)}}
+function fa(a,b,c){Function.prototype.bind&&-1!=Function.prototype.bind.toString().indexOf("native code")?fa=da:fa=ea;return fa.apply(null,arguments)}function ha(a,b){var c=Array.prototype.slice.call(arguments,1);return function(){var d=c.slice();d.push.apply(d,arguments);return a.apply(this,d)}}function m(a,b){function c(){}c.prototype=b.prototype;a.prototype=new c;a.prototype.constructor=a};/*
+
+ The MIT License
+
+ Copyright (c) 2007 Cybozu Labs, Inc.
+ Copyright (c) 2012 Google Inc.
+
+ Permission is hereby granted, free of charge, to any person obtaining a copy
+ of this software and associated documentation files (the "Software"), to
+ deal in the Software without restriction, including without limitation the
+ rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+ sell copies of the Software, and to permit persons to whom the Software is
+ furnished to do so, subject to the following conditions:
+
+ The above copyright notice and this permission notice shall be included in
+ all copies or substantial portions of the Software.
+
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+ IN THE SOFTWARE.
+*/
+function ia(a,b,c){this.a=a;this.b=b||1;this.f=c||1};var ja=Array.prototype.indexOf?function(a,b){return Array.prototype.indexOf.call(a,b,void 0)}:function(a,b){if("string"===typeof a)return"string"!==typeof b||1!=b.length?-1:a.indexOf(b,0);for(var c=0;c<a.length;c++)if(c in a&&a[c]===b)return c;return-1},p=Array.prototype.forEach?function(a,b){Array.prototype.forEach.call(a,b,void 0)}:function(a,b){for(var c=a.length,d="string"===typeof a?a.split(""):a,e=0;e<c;e++)e in d&&b.call(void 0,d[e],e,a)},ka=Array.prototype.filter?function(a,b){return Array.prototype.filter.call(a,
+b,void 0)}:function(a,b){for(var c=a.length,d=[],e=0,f="string"===typeof a?a.split(""):a,g=0;g<c;g++)if(g in f){var h=f[g];b.call(void 0,h,g,a)&&(d[e++]=h)}return d},la=Array.prototype.map?function(a,b){return Array.prototype.map.call(a,b,void 0)}:function(a,b){for(var c=a.length,d=Array(c),e="string"===typeof a?a.split(""):a,f=0;f<c;f++)f in e&&(d[f]=b.call(void 0,e[f],f,a));return d},ma=Array.prototype.reduce?function(a,b,c){return Array.prototype.reduce.call(a,b,c)}:function(a,b,c){var d=c;p(a,
+function(e,f){d=b.call(void 0,d,e,f,a)});return d},na=Array.prototype.some?function(a,b){return Array.prototype.some.call(a,b,void 0)}:function(a,b){for(var c=a.length,d="string"===typeof a?a.split(""):a,e=0;e<c;e++)if(e in d&&b.call(void 0,d[e],e,a))return!0;return!1},oa=Array.prototype.every?function(a,b){return Array.prototype.every.call(a,b,void 0)}:function(a,b){for(var c=a.length,d="string"===typeof a?a.split(""):a,e=0;e<c;e++)if(e in d&&!b.call(void 0,d[e],e,a))return!1;return!0};
+function pa(a,b){a:{for(var c=a.length,d="string"===typeof a?a.split(""):a,e=0;e<c;e++)if(e in d&&b.call(void 0,d[e],e,a)){b=e;break a}b=-1}return 0>b?null:"string"===typeof a?a.charAt(b):a[b]}function qa(a){return Array.prototype.concat.apply([],arguments)}function ra(a,b,c){return 2>=arguments.length?Array.prototype.slice.call(a,b):Array.prototype.slice.call(a,b,c)};function sa(a){var b=a.length-1;return 0<=b&&a.indexOf(" ",b)==b}var ta=String.prototype.trim?function(a){return a.trim()}:function(a){return/^[\s\xa0]*([\s\S]*?)[\s\xa0]*$/.exec(a)[1]};function ua(a,b){return a<b?-1:a>b?1:0};var r;a:{var va=k.navigator;if(va){var wa=va.userAgent;if(wa){r=wa;break a}}r=""}function u(a){return-1!=r.indexOf(a)};function xa(){return u("Firefox")||u("FxiOS")}function ya(){return(u("Chrome")||u("CriOS"))&&!u("Edge")};function za(a){return String(a).replace(/\-([a-z])/g,function(b,c){return c.toUpperCase()})};function Aa(){return u("iPhone")&&!u("iPod")&&!u("iPad")};function Ba(a,b){var c=Ca;return Object.prototype.hasOwnProperty.call(c,a)?c[a]:c[a]=b(a)};var Da=u("Opera"),w=u("Trident")||u("MSIE"),Ea=u("Edge"),Fa=u("Gecko")&&!(-1!=r.toLowerCase().indexOf("webkit")&&!u("Edge"))&&!(u("Trident")||u("MSIE"))&&!u("Edge"),Ga=-1!=r.toLowerCase().indexOf("webkit")&&!u("Edge");function Ha(){var a=k.document;return a?a.documentMode:void 0}var Ia;
+a:{var Ja="",Ka=function(){var a=r;if(Fa)return/rv:([^\);]+)(\)|;)/.exec(a);if(Ea)return/Edge\/([\d\.]+)/.exec(a);if(w)return/\b(?:MSIE|rv)[: ]([^\);]+)(\)|;)/.exec(a);if(Ga)return/WebKit\/(\S+)/.exec(a);if(Da)return/(?:Version)[ \/]?(\S+)/.exec(a)}();Ka&&(Ja=Ka?Ka[1]:"");if(w){var La=Ha();if(null!=La&&La>parseFloat(Ja)){Ia=String(La);break a}}Ia=Ja}var Ca={};
+function Ma(a){return Ba(a,function(){for(var b=0,c=ta(String(Ia)).split("."),d=ta(String(a)).split("."),e=Math.max(c.length,d.length),f=0;0==b&&f<e;f++){var g=c[f]||"",h=d[f]||"";do{g=/(\d*)(\D*)(.*)/.exec(g)||["","","",""];h=/(\d*)(\D*)(.*)/.exec(h)||["","","",""];if(0==g[0].length&&0==h[0].length)break;b=ua(0==g[1].length?0:parseInt(g[1],10),0==h[1].length?0:parseInt(h[1],10))||ua(0==g[2].length,0==h[2].length)||ua(g[2],h[2]);g=g[3];h=h[3]}while(0==b)}return 0<=b})}var Na;
+Na=k.document&&w?Ha():void 0;var x=w&&!(9<=Number(Na)),Oa=w&&!(8<=Number(Na));function Pa(a,b,c,d){this.a=a;this.nodeName=c;this.nodeValue=d;this.nodeType=2;this.parentNode=this.ownerElement=b}function Qa(a,b){var c=Oa&&"href"==b.nodeName?a.getAttribute(b.nodeName,2):b.nodeValue;return new Pa(b,a,b.nodeName,c)};function Ra(a){this.b=a;this.a=0}function Sa(a){a=a.match(Ta);for(var b=0;b<a.length;b++)Ua.test(a[b])&&a.splice(b,1);return new Ra(a)}var Ta=/\$?(?:(?![0-9-\.])(?:\*|[\w-\.]+):)?(?![0-9-\.])(?:\*|[\w-\.]+)|\/\/|\.\.|::|\d+(?:\.\d*)?|\.\d+|"[^"]*"|'[^']*'|[!<>]=|\s+|./g,Ua=/^\s/;function y(a,b){return a.b[a.a+(b||0)]}function z(a){return a.b[a.a++]}function Va(a){return a.b.length<=a.a};function Wa(a,b){this.x=void 0!==a?a:0;this.y=void 0!==b?b:0}Wa.prototype.ceil=function(){this.x=Math.ceil(this.x);this.y=Math.ceil(this.y);return this};Wa.prototype.floor=function(){this.x=Math.floor(this.x);this.y=Math.floor(this.y);return this};Wa.prototype.round=function(){this.x=Math.round(this.x);this.y=Math.round(this.y);return this};function Xa(a,b){this.width=a;this.height=b}Xa.prototype.aspectRatio=function(){return this.width/this.height};Xa.prototype.ceil=function(){this.width=Math.ceil(this.width);this.height=Math.ceil(this.height);return this};Xa.prototype.floor=function(){this.width=Math.floor(this.width);this.height=Math.floor(this.height);return this};Xa.prototype.round=function(){this.width=Math.round(this.width);this.height=Math.round(this.height);return this};function Ya(a){for(;a&&1!=a.nodeType;)a=a.previousSibling;return a}function Za(a,b){if(!a||!b)return!1;if(a.contains&&1==b.nodeType)return a==b||a.contains(b);if("undefined"!=typeof a.compareDocumentPosition)return a==b||!!(a.compareDocumentPosition(b)&16);for(;b&&a!=b;)b=b.parentNode;return b==a}
+function $a(a,b){if(a==b)return 0;if(a.compareDocumentPosition)return a.compareDocumentPosition(b)&2?1:-1;if(w&&!(9<=Number(Na))){if(9==a.nodeType)return-1;if(9==b.nodeType)return 1}if("sourceIndex"in a||a.parentNode&&"sourceIndex"in a.parentNode){var c=1==a.nodeType,d=1==b.nodeType;if(c&&d)return a.sourceIndex-b.sourceIndex;var e=a.parentNode,f=b.parentNode;return e==f?ab(a,b):!c&&Za(e,b)?-1*bb(a,b):!d&&Za(f,a)?bb(b,a):(c?a.sourceIndex:e.sourceIndex)-(d?b.sourceIndex:f.sourceIndex)}d=A(a);c=d.createRange();
+c.selectNode(a);c.collapse(!0);a=d.createRange();a.selectNode(b);a.collapse(!0);return c.compareBoundaryPoints(k.Range.START_TO_END,a)}function bb(a,b){var c=a.parentNode;if(c==b)return-1;for(;b.parentNode!=c;)b=b.parentNode;return ab(b,a)}function ab(a,b){for(;b=b.previousSibling;)if(b==a)return-1;return 1}function A(a){return 9==a.nodeType?a:a.ownerDocument||a.document}function cb(a,b){a&&(a=a.parentNode);for(var c=0;a;){if(b(a))return a;a=a.parentNode;c++}return null}
+function db(a){this.a=a||k.document||document}db.prototype.getElementsByTagName=function(a,b){return(b||this.a).getElementsByTagName(String(a))};function B(a){var b=null,c=a.nodeType;1==c&&(b=a.textContent,b=void 0==b||null==b?a.innerText:b,b=void 0==b||null==b?"":b);if("string"!=typeof b)if(x&&"title"==a.nodeName.toLowerCase()&&1==c)b=a.text;else if(9==c||1==c){a=9==c?a.documentElement:a.firstChild;c=0;var d=[];for(b="";a;){do 1!=a.nodeType&&(b+=a.nodeValue),x&&"title"==a.nodeName.toLowerCase()&&(b+=a.text),d[c++]=a;while(a=a.firstChild);for(;c&&!(a=d[--c].nextSibling););}}else b=a.nodeValue;return b}
+function C(a,b,c){if(null===b)return!0;try{if(!a.getAttribute)return!1}catch(d){return!1}Oa&&"class"==b&&(b="className");return null==c?!!a.getAttribute(b):a.getAttribute(b,2)==c}function eb(a,b,c,d,e){return(x?fb:gb).call(null,a,b,aa(c)?c:null,aa(d)?d:null,e||new E)}
+function fb(a,b,c,d,e){if(a instanceof F||8==a.b||c&&null===a.b){var f=b.all;if(!f)return e;a=ib(a);if("*"!=a&&(f=b.getElementsByTagName(a),!f))return e;if(c){for(var g=[],h=0;b=f[h++];)C(b,c,d)&&g.push(b);f=g}for(h=0;b=f[h++];)"*"==a&&"!"==b.tagName||e.add(b);return e}jb(a,b,c,d,e);return e}
+function gb(a,b,c,d,e){b.getElementsByName&&d&&"name"==c&&!w?(b=b.getElementsByName(d),p(b,function(f){a.a(f)&&e.add(f)})):b.getElementsByClassName&&d&&"class"==c?(b=b.getElementsByClassName(d),p(b,function(f){f.className==d&&a.a(f)&&e.add(f)})):a instanceof G?jb(a,b,c,d,e):b.getElementsByTagName&&(b=b.getElementsByTagName(a.f()),p(b,function(f){C(f,c,d)&&e.add(f)}));return e}
+function kb(a,b,c,d,e){var f;if((a instanceof F||8==a.b||c&&null===a.b)&&(f=b.childNodes)){var g=ib(a);if("*"!=g&&(f=ka(f,function(h){return h.tagName&&h.tagName.toLowerCase()==g}),!f))return e;c&&(f=ka(f,function(h){return C(h,c,d)}));p(f,function(h){"*"==g&&("!"==h.tagName||"*"==g&&1!=h.nodeType)||e.add(h)});return e}return lb(a,b,c,d,e)}function lb(a,b,c,d,e){for(b=b.firstChild;b;b=b.nextSibling)C(b,c,d)&&a.a(b)&&e.add(b);return e}
+function jb(a,b,c,d,e){for(b=b.firstChild;b;b=b.nextSibling)C(b,c,d)&&a.a(b)&&e.add(b),jb(a,b,c,d,e)}function ib(a){if(a instanceof G){if(8==a.b)return"!";if(null===a.b)return"*"}return a.f()};function E(){this.b=this.a=null;this.l=0}function mb(a){this.f=a;this.a=this.b=null}function nb(a,b){if(!a.a)return b;if(!b.a)return a;var c=a.a;b=b.a;for(var d=null,e,f=0;c&&b;){e=c.f;var g=b.f;e==g||e instanceof Pa&&g instanceof Pa&&e.a==g.a?(e=c,c=c.a,b=b.a):0<$a(c.f,b.f)?(e=b,b=b.a):(e=c,c=c.a);(e.b=d)?d.a=e:a.a=e;d=e;f++}for(e=c||b;e;)e.b=d,d=d.a=e,f++,e=e.a;a.b=d;a.l=f;return a}function ob(a,b){b=new mb(b);b.a=a.a;a.b?a.a.b=b:a.a=a.b=b;a.a=b;a.l++}
+E.prototype.add=function(a){a=new mb(a);a.b=this.b;this.a?this.b.a=a:this.a=this.b=a;this.b=a;this.l++};function pb(a){return(a=a.a)?a.f:null}function qb(a){return(a=pb(a))?B(a):""}function H(a,b){return new rb(a,!!b)}function rb(a,b){this.f=a;this.b=(this.s=b)?a.b:a.a;this.a=null}function I(a){var b=a.b;if(null==b)return null;var c=a.a=b;a.b=a.s?b.b:b.a;return c.f};function J(a){this.i=a;this.b=this.g=!1;this.f=null}function K(a){return"\n "+a.toString().split("\n").join("\n ")}function sb(a,b){a.g=b}function tb(a,b){a.b=b}function L(a,b){a=a.a(b);return a instanceof E?+qb(a):+a}function O(a,b){a=a.a(b);return a instanceof E?qb(a):""+a}function ub(a,b){a=a.a(b);return a instanceof E?!!a.l:!!a};function vb(a,b,c){J.call(this,a.i);this.c=a;this.h=b;this.o=c;this.g=b.g||c.g;this.b=b.b||c.b;this.c==wb&&(c.b||c.g||4==c.i||0==c.i||!b.f?b.b||b.g||4==b.i||0==b.i||!c.f||(this.f={name:c.f.name,u:b}):this.f={name:b.f.name,u:c})}m(vb,J);
+function xb(a,b,c,d,e){b=b.a(d);c=c.a(d);var f;if(b instanceof E&&c instanceof E){b=H(b);for(d=I(b);d;d=I(b))for(e=H(c),f=I(e);f;f=I(e))if(a(B(d),B(f)))return!0;return!1}if(b instanceof E||c instanceof E){b instanceof E?(e=b,d=c):(e=c,d=b);f=H(e);for(var g=typeof d,h=I(f);h;h=I(f)){switch(g){case "number":h=+B(h);break;case "boolean":h=!!B(h);break;case "string":h=B(h);break;default:throw Error("Illegal primitive type for comparison.");}if(e==b&&a(h,d)||e==c&&a(d,h))return!0}return!1}return e?"boolean"==
+typeof b||"boolean"==typeof c?a(!!b,!!c):"number"==typeof b||"number"==typeof c?a(+b,+c):a(b,c):a(+b,+c)}vb.prototype.a=function(a){return this.c.m(this.h,this.o,a)};vb.prototype.toString=function(){var a="Binary Expression: "+this.c;a+=K(this.h);return a+=K(this.o)};function yb(a,b,c,d){this.I=a;this.D=b;this.i=c;this.m=d}yb.prototype.toString=function(){return this.I};var zb={};
+function P(a,b,c,d){if(zb.hasOwnProperty(a))throw Error("Binary operator already created: "+a);a=new yb(a,b,c,d);return zb[a.toString()]=a}P("div",6,1,function(a,b,c){return L(a,c)/L(b,c)});P("mod",6,1,function(a,b,c){return L(a,c)%L(b,c)});P("*",6,1,function(a,b,c){return L(a,c)*L(b,c)});P("+",5,1,function(a,b,c){return L(a,c)+L(b,c)});P("-",5,1,function(a,b,c){return L(a,c)-L(b,c)});P("<",4,2,function(a,b,c){return xb(function(d,e){return d<e},a,b,c)});
+P(">",4,2,function(a,b,c){return xb(function(d,e){return d>e},a,b,c)});P("<=",4,2,function(a,b,c){return xb(function(d,e){return d<=e},a,b,c)});P(">=",4,2,function(a,b,c){return xb(function(d,e){return d>=e},a,b,c)});var wb=P("=",3,2,function(a,b,c){return xb(function(d,e){return d==e},a,b,c,!0)});P("!=",3,2,function(a,b,c){return xb(function(d,e){return d!=e},a,b,c,!0)});P("and",2,2,function(a,b,c){return ub(a,c)&&ub(b,c)});P("or",1,2,function(a,b,c){return ub(a,c)||ub(b,c)});function Ab(a,b){if(b.a.length&&4!=a.i)throw Error("Primary expression must evaluate to nodeset if filter has predicate(s).");J.call(this,a.i);this.c=a;this.h=b;this.g=a.g;this.b=a.b}m(Ab,J);Ab.prototype.a=function(a){a=this.c.a(a);return Bb(this.h,a)};Ab.prototype.toString=function(){var a="Filter:"+K(this.c);return a+=K(this.h)};function Cb(a,b){if(b.length<a.C)throw Error("Function "+a.j+" expects at least"+a.C+" arguments, "+b.length+" given");if(null!==a.B&&b.length>a.B)throw Error("Function "+a.j+" expects at most "+a.B+" arguments, "+b.length+" given");a.H&&p(b,function(c,d){if(4!=c.i)throw Error("Argument "+d+" to function "+a.j+" is not of type Nodeset: "+c);});J.call(this,a.i);this.v=a;this.c=b;sb(this,a.g||na(b,function(c){return c.g}));tb(this,a.G&&!b.length||a.F&&!!b.length||na(b,function(c){return c.b}))}
+m(Cb,J);Cb.prototype.a=function(a){return this.v.m.apply(null,qa(a,this.c))};Cb.prototype.toString=function(){var a="Function: "+this.v;if(this.c.length){var b=ma(this.c,function(c,d){return c+K(d)},"Arguments:");a+=K(b)}return a};function Db(a,b,c,d,e,f,g,h){this.j=a;this.i=b;this.g=c;this.G=d;this.F=!1;this.m=e;this.C=f;this.B=void 0!==g?g:f;this.H=!!h}Db.prototype.toString=function(){return this.j};var Eb={};
+function Q(a,b,c,d,e,f,g,h){if(Eb.hasOwnProperty(a))throw Error("Function already created: "+a+".");Eb[a]=new Db(a,b,c,d,e,f,g,h)}Q("boolean",2,!1,!1,function(a,b){return ub(b,a)},1);Q("ceiling",1,!1,!1,function(a,b){return Math.ceil(L(b,a))},1);Q("concat",3,!1,!1,function(a,b){return ma(ra(arguments,1),function(c,d){return c+O(d,a)},"")},2,null);Q("contains",2,!1,!1,function(a,b,c){b=O(b,a);a=O(c,a);return-1!=b.indexOf(a)},2);Q("count",1,!1,!1,function(a,b){return b.a(a).l},1,1,!0);
+Q("false",2,!1,!1,function(){return!1},0);Q("floor",1,!1,!1,function(a,b){return Math.floor(L(b,a))},1);Q("id",4,!1,!1,function(a,b){function c(h){if(x){var l=e.all[h];if(l){if(l.nodeType&&h==l.id)return l;if(l.length)return pa(l,function(v){return h==v.id})}return null}return e.getElementById(h)}var d=a.a,e=9==d.nodeType?d:d.ownerDocument;a=O(b,a).split(/\s+/);var f=[];p(a,function(h){h=c(h);!h||0<=ja(f,h)||f.push(h)});f.sort($a);var g=new E;p(f,function(h){g.add(h)});return g},1);
+Q("lang",2,!1,!1,function(){return!1},1);Q("last",1,!0,!1,function(a){if(1!=arguments.length)throw Error("Function last expects ()");return a.f},0);Q("local-name",3,!1,!0,function(a,b){return(a=b?pb(b.a(a)):a.a)?a.localName||a.nodeName.toLowerCase():""},0,1,!0);Q("name",3,!1,!0,function(a,b){return(a=b?pb(b.a(a)):a.a)?a.nodeName.toLowerCase():""},0,1,!0);Q("namespace-uri",3,!0,!1,function(){return""},0,1,!0);
+Q("normalize-space",3,!1,!0,function(a,b){return(b?O(b,a):B(a.a)).replace(/[\s\xa0]+/g," ").replace(/^\s+|\s+$/g,"")},0,1);Q("not",2,!1,!1,function(a,b){return!ub(b,a)},1);Q("number",1,!1,!0,function(a,b){return b?L(b,a):+B(a.a)},0,1);Q("position",1,!0,!1,function(a){return a.b},0);Q("round",1,!1,!1,function(a,b){return Math.round(L(b,a))},1);Q("starts-with",2,!1,!1,function(a,b,c){b=O(b,a);a=O(c,a);return 0==b.lastIndexOf(a,0)},2);Q("string",3,!1,!0,function(a,b){return b?O(b,a):B(a.a)},0,1);
+Q("string-length",1,!1,!0,function(a,b){return(b?O(b,a):B(a.a)).length},0,1);Q("substring",3,!1,!1,function(a,b,c,d){c=L(c,a);if(isNaN(c)||Infinity==c||-Infinity==c)return"";d=d?L(d,a):Infinity;if(isNaN(d)||-Infinity===d)return"";c=Math.round(c)-1;var e=Math.max(c,0);a=O(b,a);return Infinity==d?a.substring(e):a.substring(e,c+Math.round(d))},2,3);Q("substring-after",3,!1,!1,function(a,b,c){b=O(b,a);a=O(c,a);c=b.indexOf(a);return-1==c?"":b.substring(c+a.length)},2);
+Q("substring-before",3,!1,!1,function(a,b,c){b=O(b,a);a=O(c,a);a=b.indexOf(a);return-1==a?"":b.substring(0,a)},2);Q("sum",1,!1,!1,function(a,b){a=H(b.a(a));b=0;for(var c=I(a);c;c=I(a))b+=+B(c);return b},1,1,!0);Q("translate",3,!1,!1,function(a,b,c,d){b=O(b,a);c=O(c,a);var e=O(d,a);a={};for(d=0;d<c.length;d++){var f=c.charAt(d);f in a||(a[f]=e.charAt(d))}c="";for(d=0;d<b.length;d++)f=b.charAt(d),c+=f in a?a[f]:f;return c},3);Q("true",2,!1,!1,function(){return!0},0);function G(a,b){this.h=a;this.c=void 0!==b?b:null;this.b=null;switch(a){case "comment":this.b=8;break;case "text":this.b=3;break;case "processing-instruction":this.b=7;break;case "node":break;default:throw Error("Unexpected argument");}}function Fb(a){return"comment"==a||"text"==a||"processing-instruction"==a||"node"==a}G.prototype.a=function(a){return null===this.b||this.b==a.nodeType};G.prototype.f=function(){return this.h};
+G.prototype.toString=function(){var a="Kind Test: "+this.h;null===this.c||(a+=K(this.c));return a};function Gb(a){J.call(this,3);this.c=a.substring(1,a.length-1)}m(Gb,J);Gb.prototype.a=function(){return this.c};Gb.prototype.toString=function(){return"Literal: "+this.c};function F(a,b){this.j=a.toLowerCase();a="*"==this.j?"*":"http://www.w3.org/1999/xhtml";this.c=b?b.toLowerCase():a}F.prototype.a=function(a){var b=a.nodeType;if(1!=b&&2!=b)return!1;b=void 0!==a.localName?a.localName:a.nodeName;return"*"!=this.j&&this.j!=b.toLowerCase()?!1:"*"==this.c?!0:this.c==(a.namespaceURI?a.namespaceURI.toLowerCase():"http://www.w3.org/1999/xhtml")};F.prototype.f=function(){return this.j};
+F.prototype.toString=function(){return"Name Test: "+("http://www.w3.org/1999/xhtml"==this.c?"":this.c+":")+this.j};function Hb(a){J.call(this,1);this.c=a}m(Hb,J);Hb.prototype.a=function(){return this.c};Hb.prototype.toString=function(){return"Number: "+this.c};function Ib(a,b){J.call(this,a.i);this.h=a;this.c=b;this.g=a.g;this.b=a.b;1==this.c.length&&(a=this.c[0],a.A||a.c!=Jb||(a=a.o,"*"!=a.f()&&(this.f={name:a.f(),u:null})))}m(Ib,J);function Kb(){J.call(this,4)}m(Kb,J);Kb.prototype.a=function(a){var b=new E;a=a.a;9==a.nodeType?b.add(a):b.add(a.ownerDocument);return b};Kb.prototype.toString=function(){return"Root Helper Expression"};function Lb(){J.call(this,4)}m(Lb,J);Lb.prototype.a=function(a){var b=new E;b.add(a.a);return b};Lb.prototype.toString=function(){return"Context Helper Expression"};
+function Mb(a){return"/"==a||"//"==a}Ib.prototype.a=function(a){var b=this.h.a(a);if(!(b instanceof E))throw Error("Filter expression must evaluate to nodeset.");a=this.c;for(var c=0,d=a.length;c<d&&b.l;c++){var e=a[c],f=H(b,e.c.s);if(e.g||e.c!=Nb)if(e.g||e.c!=Ob){var g=I(f);for(b=e.a(new ia(g));null!=(g=I(f));)g=e.a(new ia(g)),b=nb(b,g)}else g=I(f),b=e.a(new ia(g));else{for(g=I(f);(b=I(f))&&(!g.contains||g.contains(b))&&b.compareDocumentPosition(g)&8;g=b);b=e.a(new ia(g))}}return b};
+Ib.prototype.toString=function(){var a="Path Expression:"+K(this.h);if(this.c.length){var b=ma(this.c,function(c,d){return c+K(d)},"Steps:");a+=K(b)}return a};function Pb(a,b){this.a=a;this.s=!!b}
+function Bb(a,b,c){for(c=c||0;c<a.a.length;c++)for(var d=a.a[c],e=H(b),f=b.l,g,h=0;g=I(e);h++){var l=a.s?f-h:h+1;g=d.a(new ia(g,l,f));if("number"==typeof g)l=l==g;else if("string"==typeof g||"boolean"==typeof g)l=!!g;else if(g instanceof E)l=0<g.l;else throw Error("Predicate.evaluate returned an unexpected type.");if(!l){l=e;g=l.f;var v=l.a;if(!v)throw Error("Next must be called at least once before remove.");var n=v.b;v=v.a;n?n.a=v:g.a=v;v?v.b=n:g.b=n;g.l--;l.a=null}}return b}
+Pb.prototype.toString=function(){return ma(this.a,function(a,b){return a+K(b)},"Predicates:")};function R(a,b,c,d){J.call(this,4);this.c=a;this.o=b;this.h=c||new Pb([]);this.A=!!d;b=this.h;b=0<b.a.length?b.a[0].f:null;a.J&&b&&(a=b.name,a=x?a.toLowerCase():a,this.f={name:a,u:b.u});a:{a=this.h;for(b=0;b<a.a.length;b++)if(c=a.a[b],c.g||1==c.i||0==c.i){a=!0;break a}a=!1}this.g=a}m(R,J);
+R.prototype.a=function(a){var b=a.a,c=this.f,d=null,e=null,f=0;c&&(d=c.name,e=c.u?O(c.u,a):null,f=1);if(this.A)if(this.g||this.c!=Qb)if(b=H((new R(Rb,new G("node"))).a(a)),c=I(b))for(a=this.m(c,d,e,f);null!=(c=I(b));)a=nb(a,this.m(c,d,e,f));else a=new E;else a=eb(this.o,b,d,e),a=Bb(this.h,a,f);else a=this.m(a.a,d,e,f);return a};R.prototype.m=function(a,b,c,d){a=this.c.v(this.o,a,b,c);return a=Bb(this.h,a,d)};
+R.prototype.toString=function(){var a="Step:"+K("Operator: "+(this.A?"//":"/"));this.c.j&&(a+=K("Axis: "+this.c));a+=K(this.o);if(this.h.a.length){var b=ma(this.h.a,function(c,d){return c+K(d)},"Predicates:");a+=K(b)}return a};function Sb(a,b,c,d){this.j=a;this.v=b;this.s=c;this.J=d}Sb.prototype.toString=function(){return this.j};var Tb={};function S(a,b,c,d){if(Tb.hasOwnProperty(a))throw Error("Axis already created: "+a);b=new Sb(a,b,c,!!d);return Tb[a]=b}
+S("ancestor",function(a,b){for(var c=new E;b=b.parentNode;)a.a(b)&&ob(c,b);return c},!0);S("ancestor-or-self",function(a,b){var c=new E;do a.a(b)&&ob(c,b);while(b=b.parentNode);return c},!0);
+var Jb=S("attribute",function(a,b){var c=new E,d=a.f();if("style"==d&&x&&b.style)return c.add(new Pa(b.style,b,"style",b.style.cssText)),c;var e=b.attributes;if(e)if(a instanceof G&&null===a.b||"*"==d)for(a=0;d=e[a];a++)x?d.nodeValue&&c.add(Qa(b,d)):c.add(d);else(d=e.getNamedItem(d))&&(x?d.nodeValue&&c.add(Qa(b,d)):c.add(d));return c},!1),Qb=S("child",function(a,b,c,d,e){return(x?kb:lb).call(null,a,b,aa(c)?c:null,aa(d)?d:null,e||new E)},!1,!0);S("descendant",eb,!1,!0);
+var Rb=S("descendant-or-self",function(a,b,c,d){var e=new E;C(b,c,d)&&a.a(b)&&e.add(b);return eb(a,b,c,d,e)},!1,!0),Nb=S("following",function(a,b,c,d){var e=new E;do for(var f=b;f=f.nextSibling;)C(f,c,d)&&a.a(f)&&e.add(f),e=eb(a,f,c,d,e);while(b=b.parentNode);return e},!1,!0);S("following-sibling",function(a,b){for(var c=new E;b=b.nextSibling;)a.a(b)&&c.add(b);return c},!1);S("namespace",function(){return new E},!1);
+var Ub=S("parent",function(a,b){var c=new E;if(9==b.nodeType)return c;if(2==b.nodeType)return c.add(b.ownerElement),c;b=b.parentNode;a.a(b)&&c.add(b);return c},!1),Ob=S("preceding",function(a,b,c,d){var e=new E,f=[];do f.unshift(b);while(b=b.parentNode);for(var g=1,h=f.length;g<h;g++){var l=[];for(b=f[g];b=b.previousSibling;)l.unshift(b);for(var v=0,n=l.length;v<n;v++)b=l[v],C(b,c,d)&&a.a(b)&&e.add(b),e=eb(a,b,c,d,e)}return e},!0,!0);
+S("preceding-sibling",function(a,b){for(var c=new E;b=b.previousSibling;)a.a(b)&&ob(c,b);return c},!0);var Vb=S("self",function(a,b){var c=new E;a.a(b)&&c.add(b);return c},!1);function Wb(a){J.call(this,1);this.c=a;this.g=a.g;this.b=a.b}m(Wb,J);Wb.prototype.a=function(a){return-L(this.c,a)};Wb.prototype.toString=function(){return"Unary Expression: -"+K(this.c)};function Xb(a){J.call(this,4);this.c=a;sb(this,na(this.c,function(b){return b.g}));tb(this,na(this.c,function(b){return b.b}))}m(Xb,J);Xb.prototype.a=function(a){var b=new E;p(this.c,function(c){c=c.a(a);if(!(c instanceof E))throw Error("Path expression must evaluate to NodeSet.");b=nb(b,c)});return b};Xb.prototype.toString=function(){return ma(this.c,function(a,b){return a+K(b)},"Union Expression:")};function Yb(a,b){this.a=a;this.b=b}function Zb(a){for(var b,c=[];;){T(a,"Missing right hand side of binary expression.");b=bc(a);var d=z(a.a);if(!d)break;var e=(d=zb[d]||null)&&d.D;if(!e){a.a.a--;break}for(;c.length&&e<=c[c.length-1].D;)b=new vb(c.pop(),c.pop(),b);c.push(b,d)}for(;c.length;)b=new vb(c.pop(),c.pop(),b);return b}function T(a,b){if(Va(a.a))throw Error(b);}function cc(a,b){a=z(a.a);if(a!=b)throw Error("Bad token, expected: "+b+" got: "+a);}
+function dc(a){a=z(a.a);if(")"!=a)throw Error("Bad token: "+a);}function ec(a){a=z(a.a);if(2>a.length)throw Error("Unclosed literal string");return new Gb(a)}
+function fc(a){var b=[];if(Mb(y(a.a))){var c=z(a.a);var d=y(a.a);if("/"==c&&(Va(a.a)||"."!=d&&".."!=d&&"@"!=d&&"*"!=d&&!/(?![0-9])[\w]/.test(d)))return new Kb;d=new Kb;T(a,"Missing next location step.");c=gc(a,c);b.push(c)}else{a:{c=y(a.a);d=c.charAt(0);switch(d){case "$":throw Error("Variable reference not allowed in HTML XPath");case "(":z(a.a);c=Zb(a);T(a,'unclosed "("');cc(a,")");break;case '"':case "'":c=ec(a);break;default:if(isNaN(+c))if(!Fb(c)&&/(?![0-9])[\w]/.test(d)&&"("==y(a.a,1)){c=z(a.a);
+c=Eb[c]||null;z(a.a);for(d=[];")"!=y(a.a);){T(a,"Missing function argument list.");d.push(Zb(a));if(","!=y(a.a))break;z(a.a)}T(a,"Unclosed function argument list.");dc(a);c=new Cb(c,d)}else{c=null;break a}else c=new Hb(+z(a.a))}"["==y(a.a)&&(d=new Pb(hc(a)),c=new Ab(c,d))}if(c)if(Mb(y(a.a)))d=c;else return c;else c=gc(a,"/"),d=new Lb,b.push(c)}for(;Mb(y(a.a));)c=z(a.a),T(a,"Missing next location step."),c=gc(a,c),b.push(c);return new Ib(d,b)}
+function gc(a,b){if("/"!=b&&"//"!=b)throw Error('Step op should be "/" or "//"');if("."==y(a.a)){var c=new R(Vb,new G("node"));z(a.a);return c}if(".."==y(a.a))return c=new R(Ub,new G("node")),z(a.a),c;if("@"==y(a.a)){var d=Jb;z(a.a);T(a,"Missing attribute name")}else if("::"==y(a.a,1)){if(!/(?![0-9])[\w]/.test(y(a.a).charAt(0)))throw Error("Bad token: "+z(a.a));var e=z(a.a);d=Tb[e]||null;if(!d)throw Error("No axis with name: "+e);z(a.a);T(a,"Missing node name")}else d=Qb;e=y(a.a);if(/(?![0-9])[\w\*]/.test(e.charAt(0)))if("("==
+y(a.a,1)){if(!Fb(e))throw Error("Invalid node type: "+e);e=z(a.a);if(!Fb(e))throw Error("Invalid type name: "+e);cc(a,"(");T(a,"Bad nodetype");var f=y(a.a).charAt(0),g=null;if('"'==f||"'"==f)g=ec(a);T(a,"Bad nodetype");dc(a);e=new G(e,g)}else if(e=z(a.a),f=e.indexOf(":"),-1==f)e=new F(e);else{g=e.substring(0,f);if("*"==g)var h="*";else if(h=a.b(g),!h)throw Error("Namespace prefix not declared: "+g);e=e.substr(f+1);e=new F(e,h)}else throw Error("Bad token: "+z(a.a));a=new Pb(hc(a),d.s);return c||new R(d,
+e,a,"//"==b)}function hc(a){for(var b=[];"["==y(a.a);){z(a.a);T(a,"Missing predicate expression.");var c=Zb(a);b.push(c);T(a,"Unclosed predicate expression.");cc(a,"]")}return b}function bc(a){if("-"==y(a.a))return z(a.a),new Wb(bc(a));var b=fc(a);if("|"!=y(a.a))a=b;else{for(b=[b];"|"==z(a.a);)T(a,"Missing next union location path."),b.push(fc(a));a.a.a--;a=new Xb(b)}return a};function ic(a){switch(a.nodeType){case 1:return ha(jc,a);case 9:return ic(a.documentElement);case 11:case 10:case 6:case 12:return kc;default:return a.parentNode?ic(a.parentNode):kc}}function kc(){return null}function jc(a,b){if(a.prefix==b)return a.namespaceURI||"http://www.w3.org/1999/xhtml";var c=a.getAttributeNode("xmlns:"+b);return c&&c.specified?c.value||null:a.parentNode&&9!=a.parentNode.nodeType?jc(a.parentNode,b):null};function lc(a,b){if(!a.length)throw Error("Empty XPath expression.");a=Sa(a);if(Va(a))throw Error("Invalid XPath expression.");b?"function"==ca(b)||(b=fa(b.lookupNamespaceURI,b)):b=function(){return null};var c=Zb(new Yb(a,b));if(!Va(a))throw Error("Bad token: "+z(a));this.evaluate=function(d,e){d=c.a(new ia(d));return new U(d,e)}}
+function U(a,b){if(0==b)if(a instanceof E)b=4;else if("string"==typeof a)b=2;else if("number"==typeof a)b=1;else if("boolean"==typeof a)b=3;else throw Error("Unexpected evaluation result.");if(2!=b&&1!=b&&3!=b&&!(a instanceof E))throw Error("value could not be converted to the specified type");this.resultType=b;switch(b){case 2:this.stringValue=a instanceof E?qb(a):""+a;break;case 1:this.numberValue=a instanceof E?+qb(a):+a;break;case 3:this.booleanValue=a instanceof E?0<a.l:!!a;break;case 4:case 5:case 6:case 7:var c=
+H(a);var d=[];for(var e=I(c);e;e=I(c))d.push(e instanceof Pa?e.a:e);this.snapshotLength=a.l;this.invalidIteratorState=!1;break;case 8:case 9:a=pb(a);this.singleNodeValue=a instanceof Pa?a.a:a;break;default:throw Error("Unknown XPathResult type.");}var f=0;this.iterateNext=function(){if(4!=b&&5!=b)throw Error("iterateNext called with wrong result type");return f>=d.length?null:d[f++]};this.snapshotItem=function(g){if(6!=b&&7!=b)throw Error("snapshotItem called with wrong result type");return g>=d.length||
+0>g?null:d[g]}}U.ANY_TYPE=0;U.NUMBER_TYPE=1;U.STRING_TYPE=2;U.BOOLEAN_TYPE=3;U.UNORDERED_NODE_ITERATOR_TYPE=4;U.ORDERED_NODE_ITERATOR_TYPE=5;U.UNORDERED_NODE_SNAPSHOT_TYPE=6;U.ORDERED_NODE_SNAPSHOT_TYPE=7;U.ANY_UNORDERED_NODE_TYPE=8;U.FIRST_ORDERED_NODE_TYPE=9;function mc(a){this.lookupNamespaceURI=ic(a)}
+function nc(a,b){a=a||k;var c=a.Document&&a.Document.prototype||a.document;if(!c.evaluate||b)a.XPathResult=U,c.evaluate=function(d,e,f,g){return(new lc(d,f)).evaluate(e,g)},c.createExpression=function(d,e){return new lc(d,e)},c.createNSResolver=function(d){return new mc(d)}}ba("wgxpath.install",nc);ba("wgxpath.install",nc);var oc={aliceblue:"#f0f8ff",antiquewhite:"#faebd7",aqua:"#00ffff",aquamarine:"#7fffd4",azure:"#f0ffff",beige:"#f5f5dc",bisque:"#ffe4c4",black:"#000000",blanchedalmond:"#ffebcd",blue:"#0000ff",blueviolet:"#8a2be2",brown:"#a52a2a",burlywood:"#deb887",cadetblue:"#5f9ea0",chartreuse:"#7fff00",chocolate:"#d2691e",coral:"#ff7f50",cornflowerblue:"#6495ed",cornsilk:"#fff8dc",crimson:"#dc143c",cyan:"#00ffff",darkblue:"#00008b",darkcyan:"#008b8b",darkgoldenrod:"#b8860b",darkgray:"#a9a9a9",darkgreen:"#006400",
+darkgrey:"#a9a9a9",darkkhaki:"#bdb76b",darkmagenta:"#8b008b",darkolivegreen:"#556b2f",darkorange:"#ff8c00",darkorchid:"#9932cc",darkred:"#8b0000",darksalmon:"#e9967a",darkseagreen:"#8fbc8f",darkslateblue:"#483d8b",darkslategray:"#2f4f4f",darkslategrey:"#2f4f4f",darkturquoise:"#00ced1",darkviolet:"#9400d3",deeppink:"#ff1493",deepskyblue:"#00bfff",dimgray:"#696969",dimgrey:"#696969",dodgerblue:"#1e90ff",firebrick:"#b22222",floralwhite:"#fffaf0",forestgreen:"#228b22",fuchsia:"#ff00ff",gainsboro:"#dcdcdc",
+ghostwhite:"#f8f8ff",gold:"#ffd700",goldenrod:"#daa520",gray:"#808080",green:"#008000",greenyellow:"#adff2f",grey:"#808080",honeydew:"#f0fff0",hotpink:"#ff69b4",indianred:"#cd5c5c",indigo:"#4b0082",ivory:"#fffff0",khaki:"#f0e68c",lavender:"#e6e6fa",lavenderblush:"#fff0f5",lawngreen:"#7cfc00",lemonchiffon:"#fffacd",lightblue:"#add8e6",lightcoral:"#f08080",lightcyan:"#e0ffff",lightgoldenrodyellow:"#fafad2",lightgray:"#d3d3d3",lightgreen:"#90ee90",lightgrey:"#d3d3d3",lightpink:"#ffb6c1",lightsalmon:"#ffa07a",
+lightseagreen:"#20b2aa",lightskyblue:"#87cefa",lightslategray:"#778899",lightslategrey:"#778899",lightsteelblue:"#b0c4de",lightyellow:"#ffffe0",lime:"#00ff00",limegreen:"#32cd32",linen:"#faf0e6",magenta:"#ff00ff",maroon:"#800000",mediumaquamarine:"#66cdaa",mediumblue:"#0000cd",mediumorchid:"#ba55d3",mediumpurple:"#9370db",mediumseagreen:"#3cb371",mediumslateblue:"#7b68ee",mediumspringgreen:"#00fa9a",mediumturquoise:"#48d1cc",mediumvioletred:"#c71585",midnightblue:"#191970",mintcream:"#f5fffa",mistyrose:"#ffe4e1",
+moccasin:"#ffe4b5",navajowhite:"#ffdead",navy:"#000080",oldlace:"#fdf5e6",olive:"#808000",olivedrab:"#6b8e23",orange:"#ffa500",orangered:"#ff4500",orchid:"#da70d6",palegoldenrod:"#eee8aa",palegreen:"#98fb98",paleturquoise:"#afeeee",palevioletred:"#db7093",papayawhip:"#ffefd5",peachpuff:"#ffdab9",peru:"#cd853f",pink:"#ffc0cb",plum:"#dda0dd",powderblue:"#b0e0e6",purple:"#800080",red:"#ff0000",rosybrown:"#bc8f8f",royalblue:"#4169e1",saddlebrown:"#8b4513",salmon:"#fa8072",sandybrown:"#f4a460",seagreen:"#2e8b57",
+seashell:"#fff5ee",sienna:"#a0522d",silver:"#c0c0c0",skyblue:"#87ceeb",slateblue:"#6a5acd",slategray:"#708090",slategrey:"#708090",snow:"#fffafa",springgreen:"#00ff7f",steelblue:"#4682b4",tan:"#d2b48c",teal:"#008080",thistle:"#d8bfd8",tomato:"#ff6347",turquoise:"#40e0d0",violet:"#ee82ee",wheat:"#f5deb3",white:"#ffffff",whitesmoke:"#f5f5f5",yellow:"#ffff00",yellowgreen:"#9acd32"};var pc="backgroundColor borderTopColor borderRightColor borderBottomColor borderLeftColor color outlineColor".split(" "),qc=/#([0-9a-fA-F])([0-9a-fA-F])([0-9a-fA-F])/,rc=/^#(?:[0-9a-f]{3}){1,2}$/i,sc=/^(?:rgba)?\((\d{1,3}),\s?(\d{1,3}),\s?(\d{1,3}),\s?(0|1|0\.\d*)\)$/i,tc=/^(?:rgb)?\((0|[1-9]\d{0,2}),\s?(0|[1-9]\d{0,2}),\s?(0|[1-9]\d{0,2})\)$/i;function uc(a,b){this.code=a;this.a=V[a]||vc;this.message=b||"";a=this.a.replace(/((?:^|\s+)[a-z])/g,function(c){return c.toUpperCase().replace(/^[\s\xa0]+/g,"")});b=a.length-5;if(0>b||a.indexOf("Error",b)!=b)a+="Error";this.name=a;a=Error(this.message);a.name=this.name;this.stack=a.stack||""}m(uc,Error);var vc="unknown error",V={15:"element not selectable",11:"element not visible"};V[31]=vc;V[30]=vc;V[24]="invalid cookie domain";V[29]="invalid element coordinates";V[12]="invalid element state";
+V[32]="invalid selector";V[51]="invalid selector";V[52]="invalid selector";V[17]="javascript error";V[405]="unsupported operation";V[34]="move target out of bounds";V[27]="no such alert";V[7]="no such element";V[8]="no such frame";V[23]="no such window";V[28]="script timeout";V[33]="session not created";V[10]="stale element reference";V[21]="timeout";V[25]="unable to set cookie";V[26]="unexpected alert open";V[13]=vc;V[9]="unknown command";var wc=xa(),xc=Aa()||u("iPod"),yc=u("iPad"),zc=u("Android")&&!(ya()||xa()||u("Opera")||u("Silk")),Ac=ya(),Bc=u("Safari")&&!(ya()||u("Coast")||u("Opera")||u("Edge")||u("Edg/")||u("OPR")||xa()||u("Silk")||u("Android"))&&!(Aa()||u("iPad")||u("iPod"));function Cc(a){return(a=a.exec(r))?a[1]:""}(function(){if(wc)return Cc(/Firefox\/([0-9.]+)/);if(w||Ea||Da)return Ia;if(Ac)return Aa()||u("iPad")||u("iPod")?Cc(/CriOS\/([0-9.]+)/):Cc(/Chrome\/([0-9.]+)/);if(Bc&&!(Aa()||u("iPad")||u("iPod")))return Cc(/Version\/([0-9.]+)/);if(xc||yc){var a=/Version\/(\S+).*Mobile\/(\S+)/.exec(r);if(a)return a[1]+"."+a[2]}else if(zc)return(a=Cc(/Android\s+([0-9.]+)/))?a:Cc(/Version\/([0-9.]+)/);return""})();var Dc=w&&!(9<=Number(Na));function W(a,b){b&&"string"!==typeof b&&(b=b.toString());return!!a&&1==a.nodeType&&(!b||a.tagName.toUpperCase()==b)};var Ec=function(){var a={K:"http://www.w3.org/2000/svg"};return function(b){return a[b]||null}}();
+function Fc(a,b){var c=A(a);if(!c.documentElement)return null;(w||zc)&&nc(c?c.parentWindow||c.defaultView:window);try{var d=c.createNSResolver?c.createNSResolver(c.documentElement):Ec;if(w&&!Ma(7))return c.evaluate.call(c,b,a,d,9,null);if(!w||9<=Number(Na)){for(var e={},f=c.getElementsByTagName("*"),g=0;g<f.length;++g){var h=f[g],l=h.namespaceURI;if(l&&!e[l]){var v=h.lookupPrefix(l);if(!v){var n=l.match(".*/(\\w+)/?$");v=n?n[1]:"xhtml"}e[l]=v}}var D={},M;for(M in e)D[e[M]]=M;d=function(N){return D[N]||
+null}}try{return c.evaluate(b,a,d,9,null)}catch(N){if("TypeError"===N.name)return d=c.createNSResolver?c.createNSResolver(c.documentElement):Ec,c.evaluate(b,a,d,9,null);throw N;}}catch(N){if(!Fa||"NS_ERROR_ILLEGAL_VALUE"!=N.name)throw new uc(32,"Unable to locate an element with the xpath expression "+b+" because of the following error:\n"+N);}}
+function Gc(a,b){var c=function(){var d=Fc(b,a);return d?d.singleNodeValue||null:b.selectSingleNode?(d=A(b),d.setProperty&&d.setProperty("SelectionLanguage","XPath"),b.selectSingleNode(a)):null}();if(null!==c&&(!c||1!=c.nodeType))throw new uc(32,'The result of the xpath expression "'+a+'" is: '+c+". It should be an element.");return c};function Hc(a,b,c,d){this.c=a;this.a=b;this.b=c;this.f=d}Hc.prototype.ceil=function(){this.c=Math.ceil(this.c);this.a=Math.ceil(this.a);this.b=Math.ceil(this.b);this.f=Math.ceil(this.f);return this};Hc.prototype.floor=function(){this.c=Math.floor(this.c);this.a=Math.floor(this.a);this.b=Math.floor(this.b);this.f=Math.floor(this.f);return this};Hc.prototype.round=function(){this.c=Math.round(this.c);this.a=Math.round(this.a);this.b=Math.round(this.b);this.f=Math.round(this.f);return this};function X(a,b,c,d){this.a=a;this.b=b;this.width=c;this.height=d}X.prototype.ceil=function(){this.a=Math.ceil(this.a);this.b=Math.ceil(this.b);this.width=Math.ceil(this.width);this.height=Math.ceil(this.height);return this};X.prototype.floor=function(){this.a=Math.floor(this.a);this.b=Math.floor(this.b);this.width=Math.floor(this.width);this.height=Math.floor(this.height);return this};
+X.prototype.round=function(){this.a=Math.round(this.a);this.b=Math.round(this.b);this.width=Math.round(this.width);this.height=Math.round(this.height);return this};var Ic="function"===typeof ShadowRoot;function Jc(a){for(a=a.parentNode;a&&1!=a.nodeType&&9!=a.nodeType&&11!=a.nodeType;)a=a.parentNode;return W(a)?a:null}
+function Y(a,b){b=za(b);if("float"==b||"cssFloat"==b||"styleFloat"==b)b=Dc?"styleFloat":"cssFloat";a:{var c=b;var d=A(a);if(d.defaultView&&d.defaultView.getComputedStyle&&(d=d.defaultView.getComputedStyle(a,null))){c=d[c]||d.getPropertyValue(c)||"";break a}c=""}a=c||Kc(a,b);if(null===a)a=null;else if(0<=ja(pc,b)){b:{var e=a.match(sc);if(e&&(b=Number(e[1]),c=Number(e[2]),d=Number(e[3]),e=Number(e[4]),0<=b&&255>=b&&0<=c&&255>=c&&0<=d&&255>=d&&0<=e&&1>=e)){b=[b,c,d,e];break b}b=null}if(!b)b:{if(d=a.match(tc))if(b=
+Number(d[1]),c=Number(d[2]),d=Number(d[3]),0<=b&&255>=b&&0<=c&&255>=c&&0<=d&&255>=d){b=[b,c,d,1];break b}b=null}if(!b)b:{b=a.toLowerCase();c=oc[b.toLowerCase()];if(!c&&(c="#"==b.charAt(0)?b:"#"+b,4==c.length&&(c=c.replace(qc,"#$1$1$2$2$3$3")),!rc.test(c))){b=null;break b}b=[parseInt(c.substr(1,2),16),parseInt(c.substr(3,2),16),parseInt(c.substr(5,2),16),1]}a=b?"rgba("+b.join(", ")+")":a}return a}
+function Kc(a,b){var c=a.currentStyle||a.style,d=c[b];void 0===d&&"function"==ca(c.getPropertyValue)&&(d=c.getPropertyValue(b));return"inherit"!=d?void 0!==d?d:null:(a=Jc(a))?Kc(a,b):null}
+function Lc(a,b,c){function d(g){var h=Mc(g);return 0<h.height&&0<h.width?!0:W(g,"PATH")&&(0<h.height||0<h.width)?(g=Y(g,"stroke-width"),!!g&&0<parseInt(g,10)):"hidden"!=Y(g,"overflow")&&na(g.childNodes,function(l){return 3==l.nodeType||W(l)&&d(l)})}function e(g){return Nc(g)==Z&&oa(g.childNodes,function(h){return!W(h)||e(h)||!d(h)})}if(!W(a))throw Error("Argument to isShown must be of type Element");if(W(a,"BODY"))return!0;if(W(a,"OPTION")||W(a,"OPTGROUP"))return a=cb(a,function(g){return W(g,"SELECT")}),
+!!a&&Lc(a,!0,c);var f=Oc(a);if(f)return!!f.image&&0<f.rect.width&&0<f.rect.height&&Lc(f.image,b,c);if(W(a,"INPUT")&&"hidden"==a.type.toLowerCase()||W(a,"NOSCRIPT"))return!1;f=Y(a,"visibility");return"collapse"!=f&&"hidden"!=f&&c(a)&&(b||0!=Pc(a))&&d(a)?!e(a):!1}
+function Qc(a){function b(c){if(W(c)&&"none"==Y(c,"display"))return!1;var d;if((d=c.parentNode)&&d.shadowRoot&&void 0!==c.assignedSlot)d=c.assignedSlot?c.assignedSlot.parentNode:null;else if(c.getDestinationInsertionPoints){var e=c.getDestinationInsertionPoints();0<e.length&&(d=e[e.length-1])}if(Ic&&d instanceof ShadowRoot){if(d.host.shadowRoot!==d)return!1;d=d.host}return!d||9!=d.nodeType&&11!=d.nodeType?d&&W(d,"DETAILS")&&!d.open&&!W(c,"SUMMARY")?!1:!!d&&b(d):!0}return Lc(a,!1,b)}var Z="hidden";
+function Nc(a){function b(q){function t(hb){if(hb==g)return!0;var $b=Y(hb,"display");return 0==$b.lastIndexOf("inline",0)||"contents"==$b||"absolute"==ac&&"static"==Y(hb,"position")?!1:!0}var ac=Y(q,"position");if("fixed"==ac)return v=!0,q==g?null:g;for(q=Jc(q);q&&!t(q);)q=Jc(q);return q}function c(q){var t=q;if("visible"==l)if(q==g&&h)t=h;else if(q==h)return{x:"visible",y:"visible"};t={x:Y(t,"overflow-x"),y:Y(t,"overflow-y")};q==g&&(t.x="visible"==t.x?"auto":t.x,t.y="visible"==t.y?"auto":t.y);return t}
+function d(q){if(q==g){var t=(new db(f)).a;q=t.scrollingElement?t.scrollingElement:Ga||"CSS1Compat"!=t.compatMode?t.body||t.documentElement:t.documentElement;t=t.parentWindow||t.defaultView;q=w&&Ma("10")&&t.pageYOffset!=q.scrollTop?new Wa(q.scrollLeft,q.scrollTop):new Wa(t.pageXOffset||q.scrollLeft,t.pageYOffset||q.scrollTop)}else q=new Wa(q.scrollLeft,q.scrollTop);return q}var e=Rc(a),f=A(a),g=f.documentElement,h=f.body,l=Y(g,"overflow"),v;for(a=b(a);a;a=b(a)){var n=c(a);if("visible"!=n.x||"visible"!=
+n.y){var D=Mc(a);if(0==D.width||0==D.height)return Z;var M=e.a<D.a,N=e.b<D.b;if(M&&"hidden"==n.x||N&&"hidden"==n.y)return Z;if(M&&"visible"!=n.x||N&&"visible"!=n.y){M=d(a);N=e.b<D.b-M.y;if(e.a<D.a-M.x&&"visible"!=n.x||N&&"visible"!=n.x)return Z;e=Nc(a);return e==Z?Z:"scroll"}M=e.f>=D.a+D.width;D=e.c>=D.b+D.height;if(M&&"hidden"==n.x||D&&"hidden"==n.y)return Z;if(M&&"visible"!=n.x||D&&"visible"!=n.y){if(v&&(n=d(a),e.f>=g.scrollWidth-n.x||e.a>=g.scrollHeight-n.y))return Z;e=Nc(a);return e==Z?Z:"scroll"}}}return"none"}
+function Mc(a){var b=Oc(a);if(b)return b.rect;if(W(a,"HTML"))return a=A(a),a=((a?a.parentWindow||a.defaultView:window)||window).document,a="CSS1Compat"==a.compatMode?a.documentElement:a.body,a=new Xa(a.clientWidth,a.clientHeight),new X(0,0,a.width,a.height);try{var c=a.getBoundingClientRect()}catch(d){return new X(0,0,0,0)}b=new X(c.left,c.top,c.right-c.left,c.bottom-c.top);w&&a.ownerDocument.body&&(a=A(a),b.a-=a.documentElement.clientLeft+a.body.clientLeft,b.b-=a.documentElement.clientTop+a.body.clientTop);
+return b}function Oc(a){var b=W(a,"MAP");if(!b&&!W(a,"AREA"))return null;var c=b?a:W(a.parentNode,"MAP")?a.parentNode:null,d=null,e=null;c&&c.name&&(d=Gc('/descendant::*[@usemap = "#'+c.name+'"]',A(c)))&&(e=Mc(d),b||"default"==a.shape.toLowerCase()||(a=Sc(a),b=Math.min(Math.max(a.a,0),e.width),c=Math.min(Math.max(a.b,0),e.height),e=new X(b+e.a,c+e.b,Math.min(a.width,e.width-b),Math.min(a.height,e.height-c))));return{image:d,rect:e||new X(0,0,0,0)}}
+function Sc(a){var b=a.shape.toLowerCase();a=a.coords.split(",");if("rect"==b&&4==a.length){b=a[0];var c=a[1];return new X(b,c,a[2]-b,a[3]-c)}if("circle"==b&&3==a.length)return b=a[2],new X(a[0]-b,a[1]-b,2*b,2*b);if("poly"==b&&2<a.length){b=a[0];c=a[1];for(var d=b,e=c,f=2;f+1<a.length;f+=2)b=Math.min(b,a[f]),d=Math.max(d,a[f]),c=Math.min(c,a[f+1]),e=Math.max(e,a[f+1]);return new X(b,c,d-b,e-c)}return new X(0,0,0,0)}function Rc(a){a=Mc(a);return new Hc(a.b,a.a+a.width,a.b+a.height,a.a)}
+function Tc(a){return a.replace(/^[^\S\xa0]+|[^\S\xa0]+$/g,"")}
+function Uc(a,b,c){if(W(a,"BR"))b.push("");else{var d=W(a,"TD"),e=Y(a,"display"),f=!d&&!(0<=ja(Vc,e)),g=void 0!==a.previousElementSibling?a.previousElementSibling:Ya(a.previousSibling);g=g?Y(g,"display"):"";var h=Y(a,"float")||Y(a,"cssFloat")||Y(a,"styleFloat");!f||"run-in"==g&&"none"==h||/^[\s\xa0]*$/.test(b[b.length-1]||"")||b.push("");var l=Qc(a),v=null,n=null;l&&(v=Y(a,"white-space"),n=Y(a,"text-transform"));p(a.childNodes,function(D){c(D,b,l,v,n)});a=b[b.length-1]||"";!d&&"table-cell"!=e||!a||
+sa(a)||(b[b.length-1]+=" ");f&&"run-in"!=e&&!/^[\s\xa0]*$/.test(a)&&b.push("")}}function Wc(a,b){Uc(a,b,function(c,d,e,f,g){3==c.nodeType&&e?Xc(c,d,f,g):W(c)&&Wc(c,d)})}var Vc="inline inline-block inline-table none table-cell table-column table-column-group".split(" ");
+function Xc(a,b,c,d){a=a.nodeValue.replace(/[\u200b\u200e\u200f]/g,"");a=a.replace(/(\r\n|\r|\n)/g,"\n");if("normal"==c||"nowrap"==c)a=a.replace(/\n/g," ");a="pre"==c||"pre-wrap"==c?a.replace(/[ \f\t\v\u2028\u2029]/g,"\u00a0"):a.replace(/[ \f\t\v\u2028\u2029]+/g," ");"capitalize"==d?a=a.replace(w?/(^|\s|\b)(\S)/g:/(^|[^\d\p{L}\p{S}])([\p{Ll}|\p{S}])/gu,function(e,f,g){return f+g.toUpperCase()}):"uppercase"==d?a=a.toUpperCase():"lowercase"==d&&(a=a.toLowerCase());c=b.pop()||"";sa(c)&&0==a.lastIndexOf(" ",
+0)&&(a=a.substr(1));b.push(c+a)}function Pc(a){if(Dc){if("relative"==Y(a,"position"))return 1;a=Y(a,"filter");return(a=a.match(/^alpha\(opacity=(\d*)\)/)||a.match(/^progid:DXImageTransform.Microsoft.Alpha\(Opacity=(\d*)\)/))?Number(a[1])/100:1}return Yc(a)}function Yc(a){var b=1,c=Y(a,"opacity");c&&(b=Number(c));(a=Jc(a))&&(b*=Yc(a));return b}
+function Zc(a,b,c,d,e){if(3==a.nodeType&&c)Xc(a,b,d,e);else if(W(a))if(W(a,"CONTENT")||W(a,"SLOT")){for(var f=a;f.parentNode;)f=f.parentNode;f instanceof ShadowRoot?(a=W(a,"CONTENT")?a.getDistributedNodes():a.assignedNodes(),p(a,function(g){Zc(g,b,c,d,e)})):$c(a,b)}else if(W(a,"SHADOW")){for(f=a;f.parentNode;)f=f.parentNode;if(f instanceof ShadowRoot&&(a=f))for(a=a.olderShadowRoot;a;)p(a.childNodes,function(g){Zc(g,b,c,d,e)}),a=a.olderShadowRoot}else $c(a,b)}
+function $c(a,b){a.shadowRoot&&p(a.shadowRoot.childNodes,function(c){Zc(c,b,!0,null,null)});Uc(a,b,function(c,d,e,f,g){var h=null;1==c.nodeType?h=c:3==c.nodeType&&(h=c);null!=h&&(null!=h.assignedSlot||h.getDestinationInsertionPoints&&0<h.getDestinationInsertionPoints().length)||Zc(c,d,e,f,g)})};ba("_",function(a){var b=[];Ic?$c(a,b):Wc(a,b);a=la(b,Tc);return Tc(a.join("\n")).replace(/\xa0/g," ")});; return this._.apply(null,arguments);}).apply({navigator:typeof window!='undefined'?window.navigator:null,document:typeof window!='undefined'?window.document:null}, arguments);}
+
+atom.isElementEnabled = function(element, window){return (function(){var k=this||self;function aa(a){return"string"==typeof a}function ba(a,b){a=a.split(".");var c=k;a[0]in c||"undefined"==typeof c.execScript||c.execScript("var "+a[0]);for(var d;a.length&&(d=a.shift());)a.length||void 0===b?c[d]&&c[d]!==Object.prototype[d]?c=c[d]:c=c[d]={}:c[d]=b}
+function ca(a){var b=typeof a;if("object"==b)if(a){if(a instanceof Array)return"array";if(a instanceof Object)return b;var c=Object.prototype.toString.call(a);if("[object Window]"==c)return"object";if("[object Array]"==c||"number"==typeof a.length&&"undefined"!=typeof a.splice&&"undefined"!=typeof a.propertyIsEnumerable&&!a.propertyIsEnumerable("splice"))return"array";if("[object Function]"==c||"undefined"!=typeof a.call&&"undefined"!=typeof a.propertyIsEnumerable&&!a.propertyIsEnumerable("call"))return"function"}else return"null";
+else if("function"==b&&"undefined"==typeof a.call)return"object";return b}function da(a,b,c){return a.call.apply(a.bind,arguments)}function ea(a,b,c){if(!a)throw Error();if(2<arguments.length){var d=Array.prototype.slice.call(arguments,2);return function(){var e=Array.prototype.slice.call(arguments);Array.prototype.unshift.apply(e,d);return a.apply(b,e)}}return function(){return a.apply(b,arguments)}}
+function fa(a,b,c){Function.prototype.bind&&-1!=Function.prototype.bind.toString().indexOf("native code")?fa=da:fa=ea;return fa.apply(null,arguments)}function ha(a,b){var c=Array.prototype.slice.call(arguments,1);return function(){var d=c.slice();d.push.apply(d,arguments);return a.apply(this,d)}}function l(a,b){function c(){}c.prototype=b.prototype;a.prototype=new c;a.prototype.constructor=a};/*
+
+ The MIT License
+
+ Copyright (c) 2007 Cybozu Labs, Inc.
+ Copyright (c) 2012 Google Inc.
+
+ Permission is hereby granted, free of charge, to any person obtaining a copy
+ of this software and associated documentation files (the "Software"), to
+ deal in the Software without restriction, including without limitation the
+ rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+ sell copies of the Software, and to permit persons to whom the Software is
+ furnished to do so, subject to the following conditions:
+
+ The above copyright notice and this permission notice shall be included in
+ all copies or substantial portions of the Software.
+
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+ IN THE SOFTWARE.
+*/
+function m(a,b,c){this.a=a;this.b=b||1;this.f=c||1};var ia=Array.prototype.indexOf?function(a,b){return Array.prototype.indexOf.call(a,b,void 0)}:function(a,b){if("string"===typeof a)return"string"!==typeof b||1!=b.length?-1:a.indexOf(b,0);for(var c=0;c<a.length;c++)if(c in a&&a[c]===b)return c;return-1},n=Array.prototype.forEach?function(a,b){Array.prototype.forEach.call(a,b,void 0)}:function(a,b){for(var c=a.length,d="string"===typeof a?a.split(""):a,e=0;e<c;e++)e in d&&b.call(void 0,d[e],e,a)},ja=Array.prototype.filter?function(a,b){return Array.prototype.filter.call(a,
+b,void 0)}:function(a,b){for(var c=a.length,d=[],e=0,f="string"===typeof a?a.split(""):a,g=0;g<c;g++)if(g in f){var h=f[g];b.call(void 0,h,g,a)&&(d[e++]=h)}return d},p=Array.prototype.reduce?function(a,b,c){return Array.prototype.reduce.call(a,b,c)}:function(a,b,c){var d=c;n(a,function(e,f){d=b.call(void 0,d,e,f,a)});return d},r=Array.prototype.some?function(a,b){return Array.prototype.some.call(a,b,void 0)}:function(a,b){for(var c=a.length,d="string"===typeof a?a.split(""):a,e=0;e<c;e++)if(e in d&&
+b.call(void 0,d[e],e,a))return!0;return!1};function ka(a,b){a:{for(var c=a.length,d="string"===typeof a?a.split(""):a,e=0;e<c;e++)if(e in d&&b.call(void 0,d[e],e,a)){b=e;break a}b=-1}return 0>b?null:"string"===typeof a?a.charAt(b):a[b]}function la(a){return Array.prototype.concat.apply([],arguments)}function ma(a,b,c){return 2>=arguments.length?Array.prototype.slice.call(a,b):Array.prototype.slice.call(a,b,c)};var t;a:{var na=k.navigator;if(na){var oa=na.userAgent;if(oa){t=oa;break a}}t=""}function u(a){return-1!=t.indexOf(a)};function pa(){return u("Firefox")||u("FxiOS")}function qa(){return(u("Chrome")||u("CriOS"))&&!u("Edge")};function ra(){return u("iPhone")&&!u("iPod")&&!u("iPad")};var sa=u("Opera"),v=u("Trident")||u("MSIE"),ta=u("Edge"),ua=u("Gecko")&&!(-1!=t.toLowerCase().indexOf("webkit")&&!u("Edge"))&&!(u("Trident")||u("MSIE"))&&!u("Edge"),va=-1!=t.toLowerCase().indexOf("webkit")&&!u("Edge");function wa(){var a=k.document;return a?a.documentMode:void 0}var xa;
+a:{var ya="",za=function(){var a=t;if(ua)return/rv:([^\);]+)(\)|;)/.exec(a);if(ta)return/Edge\/([\d\.]+)/.exec(a);if(v)return/\b(?:MSIE|rv)[: ]([^\);]+)(\)|;)/.exec(a);if(va)return/WebKit\/(\S+)/.exec(a);if(sa)return/(?:Version)[ \/]?(\S+)/.exec(a)}();za&&(ya=za?za[1]:"");if(v){var Aa=wa();if(null!=Aa&&Aa>parseFloat(ya)){xa=String(Aa);break a}}xa=ya}var Ba;Ba=k.document&&v?wa():void 0;var w=v&&!(9<=Number(Ba)),Ca=v&&!(8<=Number(Ba));function y(a,b,c,d){this.a=a;this.nodeName=c;this.nodeValue=d;this.nodeType=2;this.parentNode=this.ownerElement=b}function Da(a,b){var c=Ca&&"href"==b.nodeName?a.getAttribute(b.nodeName,2):b.nodeValue;return new y(b,a,b.nodeName,c)};function Ea(a){this.b=a;this.a=0}function Fa(a){a=a.match(Ga);for(var b=0;b<a.length;b++)Ha.test(a[b])&&a.splice(b,1);return new Ea(a)}var Ga=/\$?(?:(?![0-9-\.])(?:\*|[\w-\.]+):)?(?![0-9-\.])(?:\*|[\w-\.]+)|\/\/|\.\.|::|\d+(?:\.\d*)?|\.\d+|"[^"]*"|'[^']*'|[!<>]=|\s+|./g,Ha=/^\s/;function z(a,b){return a.b[a.a+(b||0)]}function A(a){return a.b[a.a++]}function Ia(a){return a.b.length<=a.a};function Ja(a){for(;a&&1!=a.nodeType;)a=a.previousSibling;return a}function Ka(a,b){if(!a||!b)return!1;if(a.contains&&1==b.nodeType)return a==b||a.contains(b);if("undefined"!=typeof a.compareDocumentPosition)return a==b||!!(a.compareDocumentPosition(b)&16);for(;b&&a!=b;)b=b.parentNode;return b==a}
+function La(a,b){if(a==b)return 0;if(a.compareDocumentPosition)return a.compareDocumentPosition(b)&2?1:-1;if(v&&!(9<=Number(Ba))){if(9==a.nodeType)return-1;if(9==b.nodeType)return 1}if("sourceIndex"in a||a.parentNode&&"sourceIndex"in a.parentNode){var c=1==a.nodeType,d=1==b.nodeType;if(c&&d)return a.sourceIndex-b.sourceIndex;var e=a.parentNode,f=b.parentNode;return e==f?Ma(a,b):!c&&Ka(e,b)?-1*Na(a,b):!d&&Ka(f,a)?Na(b,a):(c?a.sourceIndex:e.sourceIndex)-(d?b.sourceIndex:f.sourceIndex)}d=9==a.nodeType?
+a:a.ownerDocument||a.document;c=d.createRange();c.selectNode(a);c.collapse(!0);a=d.createRange();a.selectNode(b);a.collapse(!0);return c.compareBoundaryPoints(k.Range.START_TO_END,a)}function Na(a,b){var c=a.parentNode;if(c==b)return-1;for(;b.parentNode!=c;)b=b.parentNode;return Ma(b,a)}function Ma(a,b){for(;b=b.previousSibling;)if(b==a)return-1;return 1}function Oa(a,b){for(var c=0;a;){if(b(a))return a;a=a.parentNode;c++}return null};function B(a){var b=null,c=a.nodeType;1==c&&(b=a.textContent,b=void 0==b||null==b?a.innerText:b,b=void 0==b||null==b?"":b);if("string"!=typeof b)if(w&&"title"==a.nodeName.toLowerCase()&&1==c)b=a.text;else if(9==c||1==c){a=9==c?a.documentElement:a.firstChild;c=0;var d=[];for(b="";a;){do 1!=a.nodeType&&(b+=a.nodeValue),w&&"title"==a.nodeName.toLowerCase()&&(b+=a.text),d[c++]=a;while(a=a.firstChild);for(;c&&!(a=d[--c].nextSibling););}}else b=a.nodeValue;return b}
+function C(a,b,c){if(null===b)return!0;try{if(!a.getAttribute)return!1}catch(d){return!1}Ca&&"class"==b&&(b="className");return null==c?!!a.getAttribute(b):a.getAttribute(b,2)==c}function D(a,b,c,d,e){return(w?Pa:Qa).call(null,a,b,aa(c)?c:null,aa(d)?d:null,e||new E)}
+function Pa(a,b,c,d,e){if(a instanceof F||8==a.b||c&&null===a.b){var f=b.all;if(!f)return e;a=Ra(a);if("*"!=a&&(f=b.getElementsByTagName(a),!f))return e;if(c){for(var g=[],h=0;b=f[h++];)C(b,c,d)&&g.push(b);f=g}for(h=0;b=f[h++];)"*"==a&&"!"==b.tagName||e.add(b);return e}Sa(a,b,c,d,e);return e}
+function Qa(a,b,c,d,e){b.getElementsByName&&d&&"name"==c&&!v?(b=b.getElementsByName(d),n(b,function(f){a.a(f)&&e.add(f)})):b.getElementsByClassName&&d&&"class"==c?(b=b.getElementsByClassName(d),n(b,function(f){f.className==d&&a.a(f)&&e.add(f)})):a instanceof G?Sa(a,b,c,d,e):b.getElementsByTagName&&(b=b.getElementsByTagName(a.f()),n(b,function(f){C(f,c,d)&&e.add(f)}));return e}
+function Ta(a,b,c,d,e){var f;if((a instanceof F||8==a.b||c&&null===a.b)&&(f=b.childNodes)){var g=Ra(a);if("*"!=g&&(f=ja(f,function(h){return h.tagName&&h.tagName.toLowerCase()==g}),!f))return e;c&&(f=ja(f,function(h){return C(h,c,d)}));n(f,function(h){"*"==g&&("!"==h.tagName||"*"==g&&1!=h.nodeType)||e.add(h)});return e}return Ua(a,b,c,d,e)}function Ua(a,b,c,d,e){for(b=b.firstChild;b;b=b.nextSibling)C(b,c,d)&&a.a(b)&&e.add(b);return e}
+function Sa(a,b,c,d,e){for(b=b.firstChild;b;b=b.nextSibling)C(b,c,d)&&a.a(b)&&e.add(b),Sa(a,b,c,d,e)}function Ra(a){if(a instanceof G){if(8==a.b)return"!";if(null===a.b)return"*"}return a.f()};function E(){this.b=this.a=null;this.l=0}function Va(a){this.f=a;this.a=this.b=null}function Wa(a,b){if(!a.a)return b;if(!b.a)return a;var c=a.a;b=b.a;for(var d=null,e,f=0;c&&b;){e=c.f;var g=b.f;e==g||e instanceof y&&g instanceof y&&e.a==g.a?(e=c,c=c.a,b=b.a):0<La(c.f,b.f)?(e=b,b=b.a):(e=c,c=c.a);(e.b=d)?d.a=e:a.a=e;d=e;f++}for(e=c||b;e;)e.b=d,d=d.a=e,f++,e=e.a;a.b=d;a.l=f;return a}function Xa(a,b){b=new Va(b);b.a=a.a;a.b?a.a.b=b:a.a=a.b=b;a.a=b;a.l++}
+E.prototype.add=function(a){a=new Va(a);a.b=this.b;this.a?this.b.a=a:this.a=this.b=a;this.b=a;this.l++};function Ya(a){return(a=a.a)?a.f:null}function Za(a){return(a=Ya(a))?B(a):""}function H(a,b){return new $a(a,!!b)}function $a(a,b){this.f=a;this.b=(this.s=b)?a.b:a.a;this.a=null}function I(a){var b=a.b;if(null==b)return null;var c=a.a=b;a.b=a.s?b.b:b.a;return c.f};function J(a){this.i=a;this.b=this.g=!1;this.f=null}function K(a){return"\n "+a.toString().split("\n").join("\n ")}function ab(a,b){a.g=b}function bb(a,b){a.b=b}function L(a,b){a=a.a(b);return a instanceof E?+Za(a):+a}function M(a,b){a=a.a(b);return a instanceof E?Za(a):""+a}function N(a,b){a=a.a(b);return a instanceof E?!!a.l:!!a};function O(a,b,c){J.call(this,a.i);this.c=a;this.h=b;this.o=c;this.g=b.g||c.g;this.b=b.b||c.b;this.c==cb&&(c.b||c.g||4==c.i||0==c.i||!b.f?b.b||b.g||4==b.i||0==b.i||!c.f||(this.f={name:c.f.name,u:b}):this.f={name:b.f.name,u:c})}l(O,J);
+function P(a,b,c,d,e){b=b.a(d);c=c.a(d);var f;if(b instanceof E&&c instanceof E){b=H(b);for(d=I(b);d;d=I(b))for(e=H(c),f=I(e);f;f=I(e))if(a(B(d),B(f)))return!0;return!1}if(b instanceof E||c instanceof E){b instanceof E?(e=b,d=c):(e=c,d=b);f=H(e);for(var g=typeof d,h=I(f);h;h=I(f)){switch(g){case "number":h=+B(h);break;case "boolean":h=!!B(h);break;case "string":h=B(h);break;default:throw Error("Illegal primitive type for comparison.");}if(e==b&&a(h,d)||e==c&&a(d,h))return!0}return!1}return e?"boolean"==
+typeof b||"boolean"==typeof c?a(!!b,!!c):"number"==typeof b||"number"==typeof c?a(+b,+c):a(b,c):a(+b,+c)}O.prototype.a=function(a){return this.c.m(this.h,this.o,a)};O.prototype.toString=function(){var a="Binary Expression: "+this.c;a+=K(this.h);return a+=K(this.o)};function db(a,b,c,d){this.I=a;this.D=b;this.i=c;this.m=d}db.prototype.toString=function(){return this.I};var eb={};
+function Q(a,b,c,d){if(eb.hasOwnProperty(a))throw Error("Binary operator already created: "+a);a=new db(a,b,c,d);return eb[a.toString()]=a}Q("div",6,1,function(a,b,c){return L(a,c)/L(b,c)});Q("mod",6,1,function(a,b,c){return L(a,c)%L(b,c)});Q("*",6,1,function(a,b,c){return L(a,c)*L(b,c)});Q("+",5,1,function(a,b,c){return L(a,c)+L(b,c)});Q("-",5,1,function(a,b,c){return L(a,c)-L(b,c)});Q("<",4,2,function(a,b,c){return P(function(d,e){return d<e},a,b,c)});
+Q(">",4,2,function(a,b,c){return P(function(d,e){return d>e},a,b,c)});Q("<=",4,2,function(a,b,c){return P(function(d,e){return d<=e},a,b,c)});Q(">=",4,2,function(a,b,c){return P(function(d,e){return d>=e},a,b,c)});var cb=Q("=",3,2,function(a,b,c){return P(function(d,e){return d==e},a,b,c,!0)});Q("!=",3,2,function(a,b,c){return P(function(d,e){return d!=e},a,b,c,!0)});Q("and",2,2,function(a,b,c){return N(a,c)&&N(b,c)});Q("or",1,2,function(a,b,c){return N(a,c)||N(b,c)});function fb(a,b){if(b.a.length&&4!=a.i)throw Error("Primary expression must evaluate to nodeset if filter has predicate(s).");J.call(this,a.i);this.c=a;this.h=b;this.g=a.g;this.b=a.b}l(fb,J);fb.prototype.a=function(a){a=this.c.a(a);return gb(this.h,a)};fb.prototype.toString=function(){var a="Filter:"+K(this.c);return a+=K(this.h)};function hb(a,b){if(b.length<a.C)throw Error("Function "+a.j+" expects at least"+a.C+" arguments, "+b.length+" given");if(null!==a.B&&b.length>a.B)throw Error("Function "+a.j+" expects at most "+a.B+" arguments, "+b.length+" given");a.H&&n(b,function(c,d){if(4!=c.i)throw Error("Argument "+d+" to function "+a.j+" is not of type Nodeset: "+c);});J.call(this,a.i);this.v=a;this.c=b;ab(this,a.g||r(b,function(c){return c.g}));bb(this,a.G&&!b.length||a.F&&!!b.length||r(b,function(c){return c.b}))}l(hb,J);
+hb.prototype.a=function(a){return this.v.m.apply(null,la(a,this.c))};hb.prototype.toString=function(){var a="Function: "+this.v;if(this.c.length){var b=p(this.c,function(c,d){return c+K(d)},"Arguments:");a+=K(b)}return a};function ib(a,b,c,d,e,f,g,h){this.j=a;this.i=b;this.g=c;this.G=d;this.F=!1;this.m=e;this.C=f;this.B=void 0!==g?g:f;this.H=!!h}ib.prototype.toString=function(){return this.j};var jb={};
+function R(a,b,c,d,e,f,g,h){if(jb.hasOwnProperty(a))throw Error("Function already created: "+a+".");jb[a]=new ib(a,b,c,d,e,f,g,h)}R("boolean",2,!1,!1,function(a,b){return N(b,a)},1);R("ceiling",1,!1,!1,function(a,b){return Math.ceil(L(b,a))},1);R("concat",3,!1,!1,function(a,b){return p(ma(arguments,1),function(c,d){return c+M(d,a)},"")},2,null);R("contains",2,!1,!1,function(a,b,c){b=M(b,a);a=M(c,a);return-1!=b.indexOf(a)},2);R("count",1,!1,!1,function(a,b){return b.a(a).l},1,1,!0);
+R("false",2,!1,!1,function(){return!1},0);R("floor",1,!1,!1,function(a,b){return Math.floor(L(b,a))},1);R("id",4,!1,!1,function(a,b){function c(h){if(w){var q=e.all[h];if(q){if(q.nodeType&&h==q.id)return q;if(q.length)return ka(q,function(x){return h==x.id})}return null}return e.getElementById(h)}var d=a.a,e=9==d.nodeType?d:d.ownerDocument;a=M(b,a).split(/\s+/);var f=[];n(a,function(h){h=c(h);!h||0<=ia(f,h)||f.push(h)});f.sort(La);var g=new E;n(f,function(h){g.add(h)});return g},1);
+R("lang",2,!1,!1,function(){return!1},1);R("last",1,!0,!1,function(a){if(1!=arguments.length)throw Error("Function last expects ()");return a.f},0);R("local-name",3,!1,!0,function(a,b){return(a=b?Ya(b.a(a)):a.a)?a.localName||a.nodeName.toLowerCase():""},0,1,!0);R("name",3,!1,!0,function(a,b){return(a=b?Ya(b.a(a)):a.a)?a.nodeName.toLowerCase():""},0,1,!0);R("namespace-uri",3,!0,!1,function(){return""},0,1,!0);
+R("normalize-space",3,!1,!0,function(a,b){return(b?M(b,a):B(a.a)).replace(/[\s\xa0]+/g," ").replace(/^\s+|\s+$/g,"")},0,1);R("not",2,!1,!1,function(a,b){return!N(b,a)},1);R("number",1,!1,!0,function(a,b){return b?L(b,a):+B(a.a)},0,1);R("position",1,!0,!1,function(a){return a.b},0);R("round",1,!1,!1,function(a,b){return Math.round(L(b,a))},1);R("starts-with",2,!1,!1,function(a,b,c){b=M(b,a);a=M(c,a);return 0==b.lastIndexOf(a,0)},2);R("string",3,!1,!0,function(a,b){return b?M(b,a):B(a.a)},0,1);
+R("string-length",1,!1,!0,function(a,b){return(b?M(b,a):B(a.a)).length},0,1);R("substring",3,!1,!1,function(a,b,c,d){c=L(c,a);if(isNaN(c)||Infinity==c||-Infinity==c)return"";d=d?L(d,a):Infinity;if(isNaN(d)||-Infinity===d)return"";c=Math.round(c)-1;var e=Math.max(c,0);a=M(b,a);return Infinity==d?a.substring(e):a.substring(e,c+Math.round(d))},2,3);R("substring-after",3,!1,!1,function(a,b,c){b=M(b,a);a=M(c,a);c=b.indexOf(a);return-1==c?"":b.substring(c+a.length)},2);
+R("substring-before",3,!1,!1,function(a,b,c){b=M(b,a);a=M(c,a);a=b.indexOf(a);return-1==a?"":b.substring(0,a)},2);R("sum",1,!1,!1,function(a,b){a=H(b.a(a));b=0;for(var c=I(a);c;c=I(a))b+=+B(c);return b},1,1,!0);R("translate",3,!1,!1,function(a,b,c,d){b=M(b,a);c=M(c,a);var e=M(d,a);a={};for(d=0;d<c.length;d++){var f=c.charAt(d);f in a||(a[f]=e.charAt(d))}c="";for(d=0;d<b.length;d++)f=b.charAt(d),c+=f in a?a[f]:f;return c},3);R("true",2,!1,!1,function(){return!0},0);function G(a,b){this.h=a;this.c=void 0!==b?b:null;this.b=null;switch(a){case "comment":this.b=8;break;case "text":this.b=3;break;case "processing-instruction":this.b=7;break;case "node":break;default:throw Error("Unexpected argument");}}function kb(a){return"comment"==a||"text"==a||"processing-instruction"==a||"node"==a}G.prototype.a=function(a){return null===this.b||this.b==a.nodeType};G.prototype.f=function(){return this.h};
+G.prototype.toString=function(){var a="Kind Test: "+this.h;null===this.c||(a+=K(this.c));return a};function lb(a){J.call(this,3);this.c=a.substring(1,a.length-1)}l(lb,J);lb.prototype.a=function(){return this.c};lb.prototype.toString=function(){return"Literal: "+this.c};function F(a,b){this.j=a.toLowerCase();a="*"==this.j?"*":"http://www.w3.org/1999/xhtml";this.c=b?b.toLowerCase():a}F.prototype.a=function(a){var b=a.nodeType;if(1!=b&&2!=b)return!1;b=void 0!==a.localName?a.localName:a.nodeName;return"*"!=this.j&&this.j!=b.toLowerCase()?!1:"*"==this.c?!0:this.c==(a.namespaceURI?a.namespaceURI.toLowerCase():"http://www.w3.org/1999/xhtml")};F.prototype.f=function(){return this.j};
+F.prototype.toString=function(){return"Name Test: "+("http://www.w3.org/1999/xhtml"==this.c?"":this.c+":")+this.j};function mb(a){J.call(this,1);this.c=a}l(mb,J);mb.prototype.a=function(){return this.c};mb.prototype.toString=function(){return"Number: "+this.c};function nb(a,b){J.call(this,a.i);this.h=a;this.c=b;this.g=a.g;this.b=a.b;1==this.c.length&&(a=this.c[0],a.A||a.c!=ob||(a=a.o,"*"!=a.f()&&(this.f={name:a.f(),u:null})))}l(nb,J);function S(){J.call(this,4)}l(S,J);S.prototype.a=function(a){var b=new E;a=a.a;9==a.nodeType?b.add(a):b.add(a.ownerDocument);return b};S.prototype.toString=function(){return"Root Helper Expression"};function pb(){J.call(this,4)}l(pb,J);pb.prototype.a=function(a){var b=new E;b.add(a.a);return b};pb.prototype.toString=function(){return"Context Helper Expression"};
+function qb(a){return"/"==a||"//"==a}nb.prototype.a=function(a){var b=this.h.a(a);if(!(b instanceof E))throw Error("Filter expression must evaluate to nodeset.");a=this.c;for(var c=0,d=a.length;c<d&&b.l;c++){var e=a[c],f=H(b,e.c.s);if(e.g||e.c!=rb)if(e.g||e.c!=sb){var g=I(f);for(b=e.a(new m(g));null!=(g=I(f));)g=e.a(new m(g)),b=Wa(b,g)}else g=I(f),b=e.a(new m(g));else{for(g=I(f);(b=I(f))&&(!g.contains||g.contains(b))&&b.compareDocumentPosition(g)&8;g=b);b=e.a(new m(g))}}return b};
+nb.prototype.toString=function(){var a="Path Expression:"+K(this.h);if(this.c.length){var b=p(this.c,function(c,d){return c+K(d)},"Steps:");a+=K(b)}return a};function tb(a,b){this.a=a;this.s=!!b}
+function gb(a,b,c){for(c=c||0;c<a.a.length;c++)for(var d=a.a[c],e=H(b),f=b.l,g,h=0;g=I(e);h++){var q=a.s?f-h:h+1;g=d.a(new m(g,q,f));if("number"==typeof g)q=q==g;else if("string"==typeof g||"boolean"==typeof g)q=!!g;else if(g instanceof E)q=0<g.l;else throw Error("Predicate.evaluate returned an unexpected type.");if(!q){q=e;g=q.f;var x=q.a;if(!x)throw Error("Next must be called at least once before remove.");var T=x.b;x=x.a;T?T.a=x:g.a=x;x?x.b=T:g.b=T;g.l--;q.a=null}}return b}
+tb.prototype.toString=function(){return p(this.a,function(a,b){return a+K(b)},"Predicates:")};function U(a,b,c,d){J.call(this,4);this.c=a;this.o=b;this.h=c||new tb([]);this.A=!!d;b=this.h;b=0<b.a.length?b.a[0].f:null;a.J&&b&&(a=b.name,a=w?a.toLowerCase():a,this.f={name:a,u:b.u});a:{a=this.h;for(b=0;b<a.a.length;b++)if(c=a.a[b],c.g||1==c.i||0==c.i){a=!0;break a}a=!1}this.g=a}l(U,J);
+U.prototype.a=function(a){var b=a.a,c=this.f,d=null,e=null,f=0;c&&(d=c.name,e=c.u?M(c.u,a):null,f=1);if(this.A)if(this.g||this.c!=ub)if(b=H((new U(vb,new G("node"))).a(a)),c=I(b))for(a=this.m(c,d,e,f);null!=(c=I(b));)a=Wa(a,this.m(c,d,e,f));else a=new E;else a=D(this.o,b,d,e),a=gb(this.h,a,f);else a=this.m(a.a,d,e,f);return a};U.prototype.m=function(a,b,c,d){a=this.c.v(this.o,a,b,c);return a=gb(this.h,a,d)};
+U.prototype.toString=function(){var a="Step:"+K("Operator: "+(this.A?"//":"/"));this.c.j&&(a+=K("Axis: "+this.c));a+=K(this.o);if(this.h.a.length){var b=p(this.h.a,function(c,d){return c+K(d)},"Predicates:");a+=K(b)}return a};function wb(a,b,c,d){this.j=a;this.v=b;this.s=c;this.J=d}wb.prototype.toString=function(){return this.j};var xb={};function V(a,b,c,d){if(xb.hasOwnProperty(a))throw Error("Axis already created: "+a);b=new wb(a,b,c,!!d);return xb[a]=b}
+V("ancestor",function(a,b){for(var c=new E;b=b.parentNode;)a.a(b)&&Xa(c,b);return c},!0);V("ancestor-or-self",function(a,b){var c=new E;do a.a(b)&&Xa(c,b);while(b=b.parentNode);return c},!0);
+var ob=V("attribute",function(a,b){var c=new E,d=a.f();if("style"==d&&w&&b.style)return c.add(new y(b.style,b,"style",b.style.cssText)),c;var e=b.attributes;if(e)if(a instanceof G&&null===a.b||"*"==d)for(a=0;d=e[a];a++)w?d.nodeValue&&c.add(Da(b,d)):c.add(d);else(d=e.getNamedItem(d))&&(w?d.nodeValue&&c.add(Da(b,d)):c.add(d));return c},!1),ub=V("child",function(a,b,c,d,e){return(w?Ta:Ua).call(null,a,b,aa(c)?c:null,aa(d)?d:null,e||new E)},!1,!0);V("descendant",D,!1,!0);
+var vb=V("descendant-or-self",function(a,b,c,d){var e=new E;C(b,c,d)&&a.a(b)&&e.add(b);return D(a,b,c,d,e)},!1,!0),rb=V("following",function(a,b,c,d){var e=new E;do for(var f=b;f=f.nextSibling;)C(f,c,d)&&a.a(f)&&e.add(f),e=D(a,f,c,d,e);while(b=b.parentNode);return e},!1,!0);V("following-sibling",function(a,b){for(var c=new E;b=b.nextSibling;)a.a(b)&&c.add(b);return c},!1);V("namespace",function(){return new E},!1);
+var yb=V("parent",function(a,b){var c=new E;if(9==b.nodeType)return c;if(2==b.nodeType)return c.add(b.ownerElement),c;b=b.parentNode;a.a(b)&&c.add(b);return c},!1),sb=V("preceding",function(a,b,c,d){var e=new E,f=[];do f.unshift(b);while(b=b.parentNode);for(var g=1,h=f.length;g<h;g++){var q=[];for(b=f[g];b=b.previousSibling;)q.unshift(b);for(var x=0,T=q.length;x<T;x++)b=q[x],C(b,c,d)&&a.a(b)&&e.add(b),e=D(a,b,c,d,e)}return e},!0,!0);
+V("preceding-sibling",function(a,b){for(var c=new E;b=b.previousSibling;)a.a(b)&&Xa(c,b);return c},!0);var zb=V("self",function(a,b){var c=new E;a.a(b)&&c.add(b);return c},!1);function Ab(a){J.call(this,1);this.c=a;this.g=a.g;this.b=a.b}l(Ab,J);Ab.prototype.a=function(a){return-L(this.c,a)};Ab.prototype.toString=function(){return"Unary Expression: -"+K(this.c)};function Bb(a){J.call(this,4);this.c=a;ab(this,r(this.c,function(b){return b.g}));bb(this,r(this.c,function(b){return b.b}))}l(Bb,J);Bb.prototype.a=function(a){var b=new E;n(this.c,function(c){c=c.a(a);if(!(c instanceof E))throw Error("Path expression must evaluate to NodeSet.");b=Wa(b,c)});return b};Bb.prototype.toString=function(){return p(this.c,function(a,b){return a+K(b)},"Union Expression:")};function Cb(a,b){this.a=a;this.b=b}function Db(a){for(var b,c=[];;){W(a,"Missing right hand side of binary expression.");b=Eb(a);var d=A(a.a);if(!d)break;var e=(d=eb[d]||null)&&d.D;if(!e){a.a.a--;break}for(;c.length&&e<=c[c.length-1].D;)b=new O(c.pop(),c.pop(),b);c.push(b,d)}for(;c.length;)b=new O(c.pop(),c.pop(),b);return b}function W(a,b){if(Ia(a.a))throw Error(b);}function Fb(a,b){a=A(a.a);if(a!=b)throw Error("Bad token, expected: "+b+" got: "+a);}
+function Gb(a){a=A(a.a);if(")"!=a)throw Error("Bad token: "+a);}function Hb(a){a=A(a.a);if(2>a.length)throw Error("Unclosed literal string");return new lb(a)}
+function Ib(a){var b=[];if(qb(z(a.a))){var c=A(a.a);var d=z(a.a);if("/"==c&&(Ia(a.a)||"."!=d&&".."!=d&&"@"!=d&&"*"!=d&&!/(?![0-9])[\w]/.test(d)))return new S;d=new S;W(a,"Missing next location step.");c=Jb(a,c);b.push(c)}else{a:{c=z(a.a);d=c.charAt(0);switch(d){case "$":throw Error("Variable reference not allowed in HTML XPath");case "(":A(a.a);c=Db(a);W(a,'unclosed "("');Fb(a,")");break;case '"':case "'":c=Hb(a);break;default:if(isNaN(+c))if(!kb(c)&&/(?![0-9])[\w]/.test(d)&&"("==z(a.a,1)){c=A(a.a);
+c=jb[c]||null;A(a.a);for(d=[];")"!=z(a.a);){W(a,"Missing function argument list.");d.push(Db(a));if(","!=z(a.a))break;A(a.a)}W(a,"Unclosed function argument list.");Gb(a);c=new hb(c,d)}else{c=null;break a}else c=new mb(+A(a.a))}"["==z(a.a)&&(d=new tb(Kb(a)),c=new fb(c,d))}if(c)if(qb(z(a.a)))d=c;else return c;else c=Jb(a,"/"),d=new pb,b.push(c)}for(;qb(z(a.a));)c=A(a.a),W(a,"Missing next location step."),c=Jb(a,c),b.push(c);return new nb(d,b)}
+function Jb(a,b){if("/"!=b&&"//"!=b)throw Error('Step op should be "/" or "//"');if("."==z(a.a)){var c=new U(zb,new G("node"));A(a.a);return c}if(".."==z(a.a))return c=new U(yb,new G("node")),A(a.a),c;if("@"==z(a.a)){var d=ob;A(a.a);W(a,"Missing attribute name")}else if("::"==z(a.a,1)){if(!/(?![0-9])[\w]/.test(z(a.a).charAt(0)))throw Error("Bad token: "+A(a.a));var e=A(a.a);d=xb[e]||null;if(!d)throw Error("No axis with name: "+e);A(a.a);W(a,"Missing node name")}else d=ub;e=z(a.a);if(/(?![0-9])[\w\*]/.test(e.charAt(0)))if("("==
+z(a.a,1)){if(!kb(e))throw Error("Invalid node type: "+e);e=A(a.a);if(!kb(e))throw Error("Invalid type name: "+e);Fb(a,"(");W(a,"Bad nodetype");var f=z(a.a).charAt(0),g=null;if('"'==f||"'"==f)g=Hb(a);W(a,"Bad nodetype");Gb(a);e=new G(e,g)}else if(e=A(a.a),f=e.indexOf(":"),-1==f)e=new F(e);else{g=e.substring(0,f);if("*"==g)var h="*";else if(h=a.b(g),!h)throw Error("Namespace prefix not declared: "+g);e=e.substr(f+1);e=new F(e,h)}else throw Error("Bad token: "+A(a.a));a=new tb(Kb(a),d.s);return c||new U(d,
+e,a,"//"==b)}function Kb(a){for(var b=[];"["==z(a.a);){A(a.a);W(a,"Missing predicate expression.");var c=Db(a);b.push(c);W(a,"Unclosed predicate expression.");Fb(a,"]")}return b}function Eb(a){if("-"==z(a.a))return A(a.a),new Ab(Eb(a));var b=Ib(a);if("|"!=z(a.a))a=b;else{for(b=[b];"|"==A(a.a);)W(a,"Missing next union location path."),b.push(Ib(a));a.a.a--;a=new Bb(b)}return a};function Lb(a){switch(a.nodeType){case 1:return ha(Mb,a);case 9:return Lb(a.documentElement);case 11:case 10:case 6:case 12:return Nb;default:return a.parentNode?Lb(a.parentNode):Nb}}function Nb(){return null}function Mb(a,b){if(a.prefix==b)return a.namespaceURI||"http://www.w3.org/1999/xhtml";var c=a.getAttributeNode("xmlns:"+b);return c&&c.specified?c.value||null:a.parentNode&&9!=a.parentNode.nodeType?Mb(a.parentNode,b):null};function Ob(a,b){if(!a.length)throw Error("Empty XPath expression.");a=Fa(a);if(Ia(a))throw Error("Invalid XPath expression.");b?"function"==ca(b)||(b=fa(b.lookupNamespaceURI,b)):b=function(){return null};var c=Db(new Cb(a,b));if(!Ia(a))throw Error("Bad token: "+A(a));this.evaluate=function(d,e){d=c.a(new m(d));return new X(d,e)}}
+function X(a,b){if(0==b)if(a instanceof E)b=4;else if("string"==typeof a)b=2;else if("number"==typeof a)b=1;else if("boolean"==typeof a)b=3;else throw Error("Unexpected evaluation result.");if(2!=b&&1!=b&&3!=b&&!(a instanceof E))throw Error("value could not be converted to the specified type");this.resultType=b;switch(b){case 2:this.stringValue=a instanceof E?Za(a):""+a;break;case 1:this.numberValue=a instanceof E?+Za(a):+a;break;case 3:this.booleanValue=a instanceof E?0<a.l:!!a;break;case 4:case 5:case 6:case 7:var c=
+H(a);var d=[];for(var e=I(c);e;e=I(c))d.push(e instanceof y?e.a:e);this.snapshotLength=a.l;this.invalidIteratorState=!1;break;case 8:case 9:a=Ya(a);this.singleNodeValue=a instanceof y?a.a:a;break;default:throw Error("Unknown XPathResult type.");}var f=0;this.iterateNext=function(){if(4!=b&&5!=b)throw Error("iterateNext called with wrong result type");return f>=d.length?null:d[f++]};this.snapshotItem=function(g){if(6!=b&&7!=b)throw Error("snapshotItem called with wrong result type");return g>=d.length||
+0>g?null:d[g]}}X.ANY_TYPE=0;X.NUMBER_TYPE=1;X.STRING_TYPE=2;X.BOOLEAN_TYPE=3;X.UNORDERED_NODE_ITERATOR_TYPE=4;X.ORDERED_NODE_ITERATOR_TYPE=5;X.UNORDERED_NODE_SNAPSHOT_TYPE=6;X.ORDERED_NODE_SNAPSHOT_TYPE=7;X.ANY_UNORDERED_NODE_TYPE=8;X.FIRST_ORDERED_NODE_TYPE=9;function Pb(a){this.lookupNamespaceURI=Lb(a)}
+function Qb(a,b){a=a||k;var c=a.Document&&a.Document.prototype||a.document;if(!c.evaluate||b)a.XPathResult=X,c.evaluate=function(d,e,f,g){return(new Ob(d,f)).evaluate(e,g)},c.createExpression=function(d,e){return new Ob(d,e)},c.createNSResolver=function(d){return new Pb(d)}}ba("wgxpath.install",Qb);ba("wgxpath.install",Qb);var Rb=pa(),Sb=ra()||u("iPod"),Tb=u("iPad"),Ub=u("Android")&&!(qa()||pa()||u("Opera")||u("Silk")),Vb=qa(),Wb=u("Safari")&&!(qa()||u("Coast")||u("Opera")||u("Edge")||u("Edg/")||u("OPR")||pa()||u("Silk")||u("Android"))&&!(ra()||u("iPad")||u("iPod"));function Y(a){return(a=a.exec(t))?a[1]:""}(function(){if(Rb)return Y(/Firefox\/([0-9.]+)/);if(v||ta||sa)return xa;if(Vb)return ra()||u("iPad")||u("iPod")?Y(/CriOS\/([0-9.]+)/):Y(/Chrome\/([0-9.]+)/);if(Wb&&!(ra()||u("iPad")||u("iPod")))return Y(/Version\/([0-9.]+)/);if(Sb||Tb){var a=/Version\/(\S+).*Mobile\/(\S+)/.exec(t);if(a)return a[1]+"."+a[2]}else if(Ub)return(a=Y(/Android\s+([0-9.]+)/))?a:Y(/Version\/([0-9.]+)/);return""})();function Z(a,b){b&&"string"!==typeof b&&(b=b.toString());return!!a&&1==a.nodeType&&(!b||a.tagName.toUpperCase()==b)};var Xb="BUTTON INPUT OPTGROUP OPTION SELECT TEXTAREA".split(" ");function Yb(a){return r(Xb,function(b){return Z(a,b)})?a.disabled?!1:a.parentNode&&1==a.parentNode.nodeType&&Z(a,"OPTGROUP")||Z(a,"OPTION")?Yb(a.parentNode):!Oa(a,function(b){var c=b.parentNode;if(c&&Z(c,"FIELDSET")&&c.disabled){if(!Z(b,"LEGEND"))return!0;for(;b=void 0!==b.previousElementSibling?b.previousElementSibling:Ja(b.previousSibling);)if(Z(b,"LEGEND"))return!0}return!1}):!0};ba("_",Yb);; return this._.apply(null,arguments);}).apply({navigator:typeof window!='undefined'?window.navigator:null,document:typeof window!='undefined'?window.document:null}, arguments);}
+
+atom.isElementDisplayed = function(element, window){return (function(){var k=this||self;function aa(a){return"string"==typeof a}function ba(a,b){a=a.split(".");var c=k;a[0]in c||"undefined"==typeof c.execScript||c.execScript("var "+a[0]);for(var d;a.length&&(d=a.shift());)a.length||void 0===b?c[d]&&c[d]!==Object.prototype[d]?c=c[d]:c=c[d]={}:c[d]=b}
+function ca(a){var b=typeof a;if("object"==b)if(a){if(a instanceof Array)return"array";if(a instanceof Object)return b;var c=Object.prototype.toString.call(a);if("[object Window]"==c)return"object";if("[object Array]"==c||"number"==typeof a.length&&"undefined"!=typeof a.splice&&"undefined"!=typeof a.propertyIsEnumerable&&!a.propertyIsEnumerable("splice"))return"array";if("[object Function]"==c||"undefined"!=typeof a.call&&"undefined"!=typeof a.propertyIsEnumerable&&!a.propertyIsEnumerable("call"))return"function"}else return"null";
+else if("function"==b&&"undefined"==typeof a.call)return"object";return b}function da(a,b,c){return a.call.apply(a.bind,arguments)}function ea(a,b,c){if(!a)throw Error();if(2<arguments.length){var d=Array.prototype.slice.call(arguments,2);return function(){var e=Array.prototype.slice.call(arguments);Array.prototype.unshift.apply(e,d);return a.apply(b,e)}}return function(){return a.apply(b,arguments)}}
+function fa(a,b,c){Function.prototype.bind&&-1!=Function.prototype.bind.toString().indexOf("native code")?fa=da:fa=ea;return fa.apply(null,arguments)}function ha(a,b){var c=Array.prototype.slice.call(arguments,1);return function(){var d=c.slice();d.push.apply(d,arguments);return a.apply(this,d)}}function l(a,b){function c(){}c.prototype=b.prototype;a.prototype=new c;a.prototype.constructor=a};/*
+
+ The MIT License
+
+ Copyright (c) 2007 Cybozu Labs, Inc.
+ Copyright (c) 2012 Google Inc.
+
+ Permission is hereby granted, free of charge, to any person obtaining a copy
+ of this software and associated documentation files (the "Software"), to
+ deal in the Software without restriction, including without limitation the
+ rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+ sell copies of the Software, and to permit persons to whom the Software is
+ furnished to do so, subject to the following conditions:
+
+ The above copyright notice and this permission notice shall be included in
+ all copies or substantial portions of the Software.
+
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
+ IN THE SOFTWARE.
+*/
+function ia(a,b,c){this.a=a;this.b=b||1;this.f=c||1};var ja=Array.prototype.indexOf?function(a,b){return Array.prototype.indexOf.call(a,b,void 0)}:function(a,b){if("string"===typeof a)return"string"!==typeof b||1!=b.length?-1:a.indexOf(b,0);for(var c=0;c<a.length;c++)if(c in a&&a[c]===b)return c;return-1},n=Array.prototype.forEach?function(a,b){Array.prototype.forEach.call(a,b,void 0)}:function(a,b){for(var c=a.length,d="string"===typeof a?a.split(""):a,e=0;e<c;e++)e in d&&b.call(void 0,d[e],e,a)},ka=Array.prototype.filter?function(a,b){return Array.prototype.filter.call(a,
+b,void 0)}:function(a,b){for(var c=a.length,d=[],e=0,f="string"===typeof a?a.split(""):a,g=0;g<c;g++)if(g in f){var h=f[g];b.call(void 0,h,g,a)&&(d[e++]=h)}return d},la=Array.prototype.reduce?function(a,b,c){return Array.prototype.reduce.call(a,b,c)}:function(a,b,c){var d=c;n(a,function(e,f){d=b.call(void 0,d,e,f,a)});return d},ma=Array.prototype.some?function(a,b){return Array.prototype.some.call(a,b,void 0)}:function(a,b){for(var c=a.length,d="string"===typeof a?a.split(""):a,e=0;e<c;e++)if(e in
+d&&b.call(void 0,d[e],e,a))return!0;return!1},na=Array.prototype.every?function(a,b){return Array.prototype.every.call(a,b,void 0)}:function(a,b){for(var c=a.length,d="string"===typeof a?a.split(""):a,e=0;e<c;e++)if(e in d&&!b.call(void 0,d[e],e,a))return!1;return!0};function oa(a,b){a:{for(var c=a.length,d="string"===typeof a?a.split(""):a,e=0;e<c;e++)if(e in d&&b.call(void 0,d[e],e,a)){b=e;break a}b=-1}return 0>b?null:"string"===typeof a?a.charAt(b):a[b]}
+function pa(a){return Array.prototype.concat.apply([],arguments)}function qa(a,b,c){return 2>=arguments.length?Array.prototype.slice.call(a,b):Array.prototype.slice.call(a,b,c)};var ra=String.prototype.trim?function(a){return a.trim()}:function(a){return/^[\s\xa0]*([\s\S]*?)[\s\xa0]*$/.exec(a)[1]};function sa(a,b){return a<b?-1:a>b?1:0};var t;a:{var ta=k.navigator;if(ta){var ua=ta.userAgent;if(ua){t=ua;break a}}t=""}function u(a){return-1!=t.indexOf(a)};function va(){return u("Firefox")||u("FxiOS")}function wa(){return(u("Chrome")||u("CriOS"))&&!u("Edge")};function xa(a){return String(a).replace(/\-([a-z])/g,function(b,c){return c.toUpperCase()})};function ya(){return u("iPhone")&&!u("iPod")&&!u("iPad")};function za(a,b){var c=Aa;return Object.prototype.hasOwnProperty.call(c,a)?c[a]:c[a]=b(a)};var Ba=u("Opera"),v=u("Trident")||u("MSIE"),Ca=u("Edge"),Da=u("Gecko")&&!(-1!=t.toLowerCase().indexOf("webkit")&&!u("Edge"))&&!(u("Trident")||u("MSIE"))&&!u("Edge"),Ea=-1!=t.toLowerCase().indexOf("webkit")&&!u("Edge");function Fa(){var a=k.document;return a?a.documentMode:void 0}var Ga;
+a:{var Ha="",Ia=function(){var a=t;if(Da)return/rv:([^\);]+)(\)|;)/.exec(a);if(Ca)return/Edge\/([\d\.]+)/.exec(a);if(v)return/\b(?:MSIE|rv)[: ]([^\);]+)(\)|;)/.exec(a);if(Ea)return/WebKit\/(\S+)/.exec(a);if(Ba)return/(?:Version)[ \/]?(\S+)/.exec(a)}();Ia&&(Ha=Ia?Ia[1]:"");if(v){var Ja=Fa();if(null!=Ja&&Ja>parseFloat(Ha)){Ga=String(Ja);break a}}Ga=Ha}var Aa={};
+function Ka(a){return za(a,function(){for(var b=0,c=ra(String(Ga)).split("."),d=ra(String(a)).split("."),e=Math.max(c.length,d.length),f=0;0==b&&f<e;f++){var g=c[f]||"",h=d[f]||"";do{g=/(\d*)(\D*)(.*)/.exec(g)||["","","",""];h=/(\d*)(\D*)(.*)/.exec(h)||["","","",""];if(0==g[0].length&&0==h[0].length)break;b=sa(0==g[1].length?0:parseInt(g[1],10),0==h[1].length?0:parseInt(h[1],10))||sa(0==g[2].length,0==h[2].length)||sa(g[2],h[2]);g=g[3];h=h[3]}while(0==b)}return 0<=b})}var La;
+La=k.document&&v?Fa():void 0;var x=v&&!(9<=Number(La)),Ma=v&&!(8<=Number(La));function Na(a,b,c,d){this.a=a;this.nodeName=c;this.nodeValue=d;this.nodeType=2;this.parentNode=this.ownerElement=b}function Oa(a,b){var c=Ma&&"href"==b.nodeName?a.getAttribute(b.nodeName,2):b.nodeValue;return new Na(b,a,b.nodeName,c)};function Pa(a){this.b=a;this.a=0}function Qa(a){a=a.match(Ra);for(var b=0;b<a.length;b++)Sa.test(a[b])&&a.splice(b,1);return new Pa(a)}var Ra=/\$?(?:(?![0-9-\.])(?:\*|[\w-\.]+):)?(?![0-9-\.])(?:\*|[\w-\.]+)|\/\/|\.\.|::|\d+(?:\.\d*)?|\.\d+|"[^"]*"|'[^']*'|[!<>]=|\s+|./g,Sa=/^\s/;function y(a,b){return a.b[a.a+(b||0)]}function z(a){return a.b[a.a++]}function Ta(a){return a.b.length<=a.a};function Ua(a,b){this.x=void 0!==a?a:0;this.y=void 0!==b?b:0}Ua.prototype.ceil=function(){this.x=Math.ceil(this.x);this.y=Math.ceil(this.y);return this};Ua.prototype.floor=function(){this.x=Math.floor(this.x);this.y=Math.floor(this.y);return this};Ua.prototype.round=function(){this.x=Math.round(this.x);this.y=Math.round(this.y);return this};function Va(a,b){this.width=a;this.height=b}Va.prototype.aspectRatio=function(){return this.width/this.height};Va.prototype.ceil=function(){this.width=Math.ceil(this.width);this.height=Math.ceil(this.height);return this};Va.prototype.floor=function(){this.width=Math.floor(this.width);this.height=Math.floor(this.height);return this};Va.prototype.round=function(){this.width=Math.round(this.width);this.height=Math.round(this.height);return this};function Wa(a,b){if(!a||!b)return!1;if(a.contains&&1==b.nodeType)return a==b||a.contains(b);if("undefined"!=typeof a.compareDocumentPosition)return a==b||!!(a.compareDocumentPosition(b)&16);for(;b&&a!=b;)b=b.parentNode;return b==a}
+function Xa(a,b){if(a==b)return 0;if(a.compareDocumentPosition)return a.compareDocumentPosition(b)&2?1:-1;if(v&&!(9<=Number(La))){if(9==a.nodeType)return-1;if(9==b.nodeType)return 1}if("sourceIndex"in a||a.parentNode&&"sourceIndex"in a.parentNode){var c=1==a.nodeType,d=1==b.nodeType;if(c&&d)return a.sourceIndex-b.sourceIndex;var e=a.parentNode,f=b.parentNode;return e==f?Ya(a,b):!c&&Wa(e,b)?-1*Za(a,b):!d&&Wa(f,a)?Za(b,a):(c?a.sourceIndex:e.sourceIndex)-(d?b.sourceIndex:f.sourceIndex)}d=A(a);c=d.createRange();
+c.selectNode(a);c.collapse(!0);a=d.createRange();a.selectNode(b);a.collapse(!0);return c.compareBoundaryPoints(k.Range.START_TO_END,a)}function Za(a,b){var c=a.parentNode;if(c==b)return-1;for(;b.parentNode!=c;)b=b.parentNode;return Ya(b,a)}function Ya(a,b){for(;b=b.previousSibling;)if(b==a)return-1;return 1}function A(a){return 9==a.nodeType?a:a.ownerDocument||a.document}function $a(a,b){a&&(a=a.parentNode);for(var c=0;a;){if(b(a))return a;a=a.parentNode;c++}return null}
+function ab(a){this.a=a||k.document||document}ab.prototype.getElementsByTagName=function(a,b){return(b||this.a).getElementsByTagName(String(a))};function B(a){var b=null,c=a.nodeType;1==c&&(b=a.textContent,b=void 0==b||null==b?a.innerText:b,b=void 0==b||null==b?"":b);if("string"!=typeof b)if(x&&"title"==a.nodeName.toLowerCase()&&1==c)b=a.text;else if(9==c||1==c){a=9==c?a.documentElement:a.firstChild;c=0;var d=[];for(b="";a;){do 1!=a.nodeType&&(b+=a.nodeValue),x&&"title"==a.nodeName.toLowerCase()&&(b+=a.text),d[c++]=a;while(a=a.firstChild);for(;c&&!(a=d[--c].nextSibling););}}else b=a.nodeValue;return b}
+function C(a,b,c){if(null===b)return!0;try{if(!a.getAttribute)return!1}catch(d){return!1}Ma&&"class"==b&&(b="className");return null==c?!!a.getAttribute(b):a.getAttribute(b,2)==c}function bb(a,b,c,d,e){return(x?cb:db).call(null,a,b,aa(c)?c:null,aa(d)?d:null,e||new E)}
+function cb(a,b,c,d,e){if(a instanceof F||8==a.b||c&&null===a.b){var f=b.all;if(!f)return e;a=eb(a);if("*"!=a&&(f=b.getElementsByTagName(a),!f))return e;if(c){for(var g=[],h=0;b=f[h++];)C(b,c,d)&&g.push(b);f=g}for(h=0;b=f[h++];)"*"==a&&"!"==b.tagName||e.add(b);return e}gb(a,b,c,d,e);return e}
+function db(a,b,c,d,e){b.getElementsByName&&d&&"name"==c&&!v?(b=b.getElementsByName(d),n(b,function(f){a.a(f)&&e.add(f)})):b.getElementsByClassName&&d&&"class"==c?(b=b.getElementsByClassName(d),n(b,function(f){f.className==d&&a.a(f)&&e.add(f)})):a instanceof G?gb(a,b,c,d,e):b.getElementsByTagName&&(b=b.getElementsByTagName(a.f()),n(b,function(f){C(f,c,d)&&e.add(f)}));return e}
+function hb(a,b,c,d,e){var f;if((a instanceof F||8==a.b||c&&null===a.b)&&(f=b.childNodes)){var g=eb(a);if("*"!=g&&(f=ka(f,function(h){return h.tagName&&h.tagName.toLowerCase()==g}),!f))return e;c&&(f=ka(f,function(h){return C(h,c,d)}));n(f,function(h){"*"==g&&("!"==h.tagName||"*"==g&&1!=h.nodeType)||e.add(h)});return e}return ib(a,b,c,d,e)}function ib(a,b,c,d,e){for(b=b.firstChild;b;b=b.nextSibling)C(b,c,d)&&a.a(b)&&e.add(b);return e}
+function gb(a,b,c,d,e){for(b=b.firstChild;b;b=b.nextSibling)C(b,c,d)&&a.a(b)&&e.add(b),gb(a,b,c,d,e)}function eb(a){if(a instanceof G){if(8==a.b)return"!";if(null===a.b)return"*"}return a.f()};function E(){this.b=this.a=null;this.l=0}function jb(a){this.f=a;this.a=this.b=null}function kb(a,b){if(!a.a)return b;if(!b.a)return a;var c=a.a;b=b.a;for(var d=null,e,f=0;c&&b;){e=c.f;var g=b.f;e==g||e instanceof Na&&g instanceof Na&&e.a==g.a?(e=c,c=c.a,b=b.a):0<Xa(c.f,b.f)?(e=b,b=b.a):(e=c,c=c.a);(e.b=d)?d.a=e:a.a=e;d=e;f++}for(e=c||b;e;)e.b=d,d=d.a=e,f++,e=e.a;a.b=d;a.l=f;return a}function lb(a,b){b=new jb(b);b.a=a.a;a.b?a.a.b=b:a.a=a.b=b;a.a=b;a.l++}
+E.prototype.add=function(a){a=new jb(a);a.b=this.b;this.a?this.b.a=a:this.a=this.b=a;this.b=a;this.l++};function mb(a){return(a=a.a)?a.f:null}function nb(a){return(a=mb(a))?B(a):""}function H(a,b){return new ob(a,!!b)}function ob(a,b){this.f=a;this.b=(this.s=b)?a.b:a.a;this.a=null}function I(a){var b=a.b;if(null==b)return null;var c=a.a=b;a.b=a.s?b.b:b.a;return c.f};function J(a){this.i=a;this.b=this.g=!1;this.f=null}function K(a){return"\n "+a.toString().split("\n").join("\n ")}function pb(a,b){a.g=b}function qb(a,b){a.b=b}function N(a,b){a=a.a(b);return a instanceof E?+nb(a):+a}function O(a,b){a=a.a(b);return a instanceof E?nb(a):""+a}function rb(a,b){a=a.a(b);return a instanceof E?!!a.l:!!a};function sb(a,b,c){J.call(this,a.i);this.c=a;this.h=b;this.o=c;this.g=b.g||c.g;this.b=b.b||c.b;this.c==tb&&(c.b||c.g||4==c.i||0==c.i||!b.f?b.b||b.g||4==b.i||0==b.i||!c.f||(this.f={name:c.f.name,u:b}):this.f={name:b.f.name,u:c})}l(sb,J);
+function ub(a,b,c,d,e){b=b.a(d);c=c.a(d);var f;if(b instanceof E&&c instanceof E){b=H(b);for(d=I(b);d;d=I(b))for(e=H(c),f=I(e);f;f=I(e))if(a(B(d),B(f)))return!0;return!1}if(b instanceof E||c instanceof E){b instanceof E?(e=b,d=c):(e=c,d=b);f=H(e);for(var g=typeof d,h=I(f);h;h=I(f)){switch(g){case "number":h=+B(h);break;case "boolean":h=!!B(h);break;case "string":h=B(h);break;default:throw Error("Illegal primitive type for comparison.");}if(e==b&&a(h,d)||e==c&&a(d,h))return!0}return!1}return e?"boolean"==
+typeof b||"boolean"==typeof c?a(!!b,!!c):"number"==typeof b||"number"==typeof c?a(+b,+c):a(b,c):a(+b,+c)}sb.prototype.a=function(a){return this.c.m(this.h,this.o,a)};sb.prototype.toString=function(){var a="Binary Expression: "+this.c;a+=K(this.h);return a+=K(this.o)};function vb(a,b,c,d){this.I=a;this.D=b;this.i=c;this.m=d}vb.prototype.toString=function(){return this.I};var wb={};
+function P(a,b,c,d){if(wb.hasOwnProperty(a))throw Error("Binary operator already created: "+a);a=new vb(a,b,c,d);return wb[a.toString()]=a}P("div",6,1,function(a,b,c){return N(a,c)/N(b,c)});P("mod",6,1,function(a,b,c){return N(a,c)%N(b,c)});P("*",6,1,function(a,b,c){return N(a,c)*N(b,c)});P("+",5,1,function(a,b,c){return N(a,c)+N(b,c)});P("-",5,1,function(a,b,c){return N(a,c)-N(b,c)});P("<",4,2,function(a,b,c){return ub(function(d,e){return d<e},a,b,c)});
+P(">",4,2,function(a,b,c){return ub(function(d,e){return d>e},a,b,c)});P("<=",4,2,function(a,b,c){return ub(function(d,e){return d<=e},a,b,c)});P(">=",4,2,function(a,b,c){return ub(function(d,e){return d>=e},a,b,c)});var tb=P("=",3,2,function(a,b,c){return ub(function(d,e){return d==e},a,b,c,!0)});P("!=",3,2,function(a,b,c){return ub(function(d,e){return d!=e},a,b,c,!0)});P("and",2,2,function(a,b,c){return rb(a,c)&&rb(b,c)});P("or",1,2,function(a,b,c){return rb(a,c)||rb(b,c)});function xb(a,b){if(b.a.length&&4!=a.i)throw Error("Primary expression must evaluate to nodeset if filter has predicate(s).");J.call(this,a.i);this.c=a;this.h=b;this.g=a.g;this.b=a.b}l(xb,J);xb.prototype.a=function(a){a=this.c.a(a);return yb(this.h,a)};xb.prototype.toString=function(){var a="Filter:"+K(this.c);return a+=K(this.h)};function zb(a,b){if(b.length<a.C)throw Error("Function "+a.j+" expects at least"+a.C+" arguments, "+b.length+" given");if(null!==a.B&&b.length>a.B)throw Error("Function "+a.j+" expects at most "+a.B+" arguments, "+b.length+" given");a.H&&n(b,function(c,d){if(4!=c.i)throw Error("Argument "+d+" to function "+a.j+" is not of type Nodeset: "+c);});J.call(this,a.i);this.v=a;this.c=b;pb(this,a.g||ma(b,function(c){return c.g}));qb(this,a.G&&!b.length||a.F&&!!b.length||ma(b,function(c){return c.b}))}
+l(zb,J);zb.prototype.a=function(a){return this.v.m.apply(null,pa(a,this.c))};zb.prototype.toString=function(){var a="Function: "+this.v;if(this.c.length){var b=la(this.c,function(c,d){return c+K(d)},"Arguments:");a+=K(b)}return a};function Ab(a,b,c,d,e,f,g,h){this.j=a;this.i=b;this.g=c;this.G=d;this.F=!1;this.m=e;this.C=f;this.B=void 0!==g?g:f;this.H=!!h}Ab.prototype.toString=function(){return this.j};var Bb={};
+function Q(a,b,c,d,e,f,g,h){if(Bb.hasOwnProperty(a))throw Error("Function already created: "+a+".");Bb[a]=new Ab(a,b,c,d,e,f,g,h)}Q("boolean",2,!1,!1,function(a,b){return rb(b,a)},1);Q("ceiling",1,!1,!1,function(a,b){return Math.ceil(N(b,a))},1);Q("concat",3,!1,!1,function(a,b){return la(qa(arguments,1),function(c,d){return c+O(d,a)},"")},2,null);Q("contains",2,!1,!1,function(a,b,c){b=O(b,a);a=O(c,a);return-1!=b.indexOf(a)},2);Q("count",1,!1,!1,function(a,b){return b.a(a).l},1,1,!0);
+Q("false",2,!1,!1,function(){return!1},0);Q("floor",1,!1,!1,function(a,b){return Math.floor(N(b,a))},1);Q("id",4,!1,!1,function(a,b){function c(h){if(x){var m=e.all[h];if(m){if(m.nodeType&&h==m.id)return m;if(m.length)return oa(m,function(w){return h==w.id})}return null}return e.getElementById(h)}var d=a.a,e=9==d.nodeType?d:d.ownerDocument;a=O(b,a).split(/\s+/);var f=[];n(a,function(h){h=c(h);!h||0<=ja(f,h)||f.push(h)});f.sort(Xa);var g=new E;n(f,function(h){g.add(h)});return g},1);
+Q("lang",2,!1,!1,function(){return!1},1);Q("last",1,!0,!1,function(a){if(1!=arguments.length)throw Error("Function last expects ()");return a.f},0);Q("local-name",3,!1,!0,function(a,b){return(a=b?mb(b.a(a)):a.a)?a.localName||a.nodeName.toLowerCase():""},0,1,!0);Q("name",3,!1,!0,function(a,b){return(a=b?mb(b.a(a)):a.a)?a.nodeName.toLowerCase():""},0,1,!0);Q("namespace-uri",3,!0,!1,function(){return""},0,1,!0);
+Q("normalize-space",3,!1,!0,function(a,b){return(b?O(b,a):B(a.a)).replace(/[\s\xa0]+/g," ").replace(/^\s+|\s+$/g,"")},0,1);Q("not",2,!1,!1,function(a,b){return!rb(b,a)},1);Q("number",1,!1,!0,function(a,b){return b?N(b,a):+B(a.a)},0,1);Q("position",1,!0,!1,function(a){return a.b},0);Q("round",1,!1,!1,function(a,b){return Math.round(N(b,a))},1);Q("starts-with",2,!1,!1,function(a,b,c){b=O(b,a);a=O(c,a);return 0==b.lastIndexOf(a,0)},2);Q("string",3,!1,!0,function(a,b){return b?O(b,a):B(a.a)},0,1);
+Q("string-length",1,!1,!0,function(a,b){return(b?O(b,a):B(a.a)).length},0,1);Q("substring",3,!1,!1,function(a,b,c,d){c=N(c,a);if(isNaN(c)||Infinity==c||-Infinity==c)return"";d=d?N(d,a):Infinity;if(isNaN(d)||-Infinity===d)return"";c=Math.round(c)-1;var e=Math.max(c,0);a=O(b,a);return Infinity==d?a.substring(e):a.substring(e,c+Math.round(d))},2,3);Q("substring-after",3,!1,!1,function(a,b,c){b=O(b,a);a=O(c,a);c=b.indexOf(a);return-1==c?"":b.substring(c+a.length)},2);
+Q("substring-before",3,!1,!1,function(a,b,c){b=O(b,a);a=O(c,a);a=b.indexOf(a);return-1==a?"":b.substring(0,a)},2);Q("sum",1,!1,!1,function(a,b){a=H(b.a(a));b=0;for(var c=I(a);c;c=I(a))b+=+B(c);return b},1,1,!0);Q("translate",3,!1,!1,function(a,b,c,d){b=O(b,a);c=O(c,a);var e=O(d,a);a={};for(d=0;d<c.length;d++){var f=c.charAt(d);f in a||(a[f]=e.charAt(d))}c="";for(d=0;d<b.length;d++)f=b.charAt(d),c+=f in a?a[f]:f;return c},3);Q("true",2,!1,!1,function(){return!0},0);function G(a,b){this.h=a;this.c=void 0!==b?b:null;this.b=null;switch(a){case "comment":this.b=8;break;case "text":this.b=3;break;case "processing-instruction":this.b=7;break;case "node":break;default:throw Error("Unexpected argument");}}function Cb(a){return"comment"==a||"text"==a||"processing-instruction"==a||"node"==a}G.prototype.a=function(a){return null===this.b||this.b==a.nodeType};G.prototype.f=function(){return this.h};
+G.prototype.toString=function(){var a="Kind Test: "+this.h;null===this.c||(a+=K(this.c));return a};function Db(a){J.call(this,3);this.c=a.substring(1,a.length-1)}l(Db,J);Db.prototype.a=function(){return this.c};Db.prototype.toString=function(){return"Literal: "+this.c};function F(a,b){this.j=a.toLowerCase();a="*"==this.j?"*":"http://www.w3.org/1999/xhtml";this.c=b?b.toLowerCase():a}F.prototype.a=function(a){var b=a.nodeType;if(1!=b&&2!=b)return!1;b=void 0!==a.localName?a.localName:a.nodeName;return"*"!=this.j&&this.j!=b.toLowerCase()?!1:"*"==this.c?!0:this.c==(a.namespaceURI?a.namespaceURI.toLowerCase():"http://www.w3.org/1999/xhtml")};F.prototype.f=function(){return this.j};
+F.prototype.toString=function(){return"Name Test: "+("http://www.w3.org/1999/xhtml"==this.c?"":this.c+":")+this.j};function Eb(a){J.call(this,1);this.c=a}l(Eb,J);Eb.prototype.a=function(){return this.c};Eb.prototype.toString=function(){return"Number: "+this.c};function Fb(a,b){J.call(this,a.i);this.h=a;this.c=b;this.g=a.g;this.b=a.b;1==this.c.length&&(a=this.c[0],a.A||a.c!=Gb||(a=a.o,"*"!=a.f()&&(this.f={name:a.f(),u:null})))}l(Fb,J);function Hb(){J.call(this,4)}l(Hb,J);Hb.prototype.a=function(a){var b=new E;a=a.a;9==a.nodeType?b.add(a):b.add(a.ownerDocument);return b};Hb.prototype.toString=function(){return"Root Helper Expression"};function Ib(){J.call(this,4)}l(Ib,J);Ib.prototype.a=function(a){var b=new E;b.add(a.a);return b};Ib.prototype.toString=function(){return"Context Helper Expression"};
+function Jb(a){return"/"==a||"//"==a}Fb.prototype.a=function(a){var b=this.h.a(a);if(!(b instanceof E))throw Error("Filter expression must evaluate to nodeset.");a=this.c;for(var c=0,d=a.length;c<d&&b.l;c++){var e=a[c],f=H(b,e.c.s);if(e.g||e.c!=Kb)if(e.g||e.c!=Lb){var g=I(f);for(b=e.a(new ia(g));null!=(g=I(f));)g=e.a(new ia(g)),b=kb(b,g)}else g=I(f),b=e.a(new ia(g));else{for(g=I(f);(b=I(f))&&(!g.contains||g.contains(b))&&b.compareDocumentPosition(g)&8;g=b);b=e.a(new ia(g))}}return b};
+Fb.prototype.toString=function(){var a="Path Expression:"+K(this.h);if(this.c.length){var b=la(this.c,function(c,d){return c+K(d)},"Steps:");a+=K(b)}return a};function Mb(a,b){this.a=a;this.s=!!b}
+function yb(a,b,c){for(c=c||0;c<a.a.length;c++)for(var d=a.a[c],e=H(b),f=b.l,g,h=0;g=I(e);h++){var m=a.s?f-h:h+1;g=d.a(new ia(g,m,f));if("number"==typeof g)m=m==g;else if("string"==typeof g||"boolean"==typeof g)m=!!g;else if(g instanceof E)m=0<g.l;else throw Error("Predicate.evaluate returned an unexpected type.");if(!m){m=e;g=m.f;var w=m.a;if(!w)throw Error("Next must be called at least once before remove.");var r=w.b;w=w.a;r?r.a=w:g.a=w;w?w.b=r:g.b=r;g.l--;m.a=null}}return b}
+Mb.prototype.toString=function(){return la(this.a,function(a,b){return a+K(b)},"Predicates:")};function R(a,b,c,d){J.call(this,4);this.c=a;this.o=b;this.h=c||new Mb([]);this.A=!!d;b=this.h;b=0<b.a.length?b.a[0].f:null;a.J&&b&&(a=b.name,a=x?a.toLowerCase():a,this.f={name:a,u:b.u});a:{a=this.h;for(b=0;b<a.a.length;b++)if(c=a.a[b],c.g||1==c.i||0==c.i){a=!0;break a}a=!1}this.g=a}l(R,J);
+R.prototype.a=function(a){var b=a.a,c=this.f,d=null,e=null,f=0;c&&(d=c.name,e=c.u?O(c.u,a):null,f=1);if(this.A)if(this.g||this.c!=Nb)if(b=H((new R(Ob,new G("node"))).a(a)),c=I(b))for(a=this.m(c,d,e,f);null!=(c=I(b));)a=kb(a,this.m(c,d,e,f));else a=new E;else a=bb(this.o,b,d,e),a=yb(this.h,a,f);else a=this.m(a.a,d,e,f);return a};R.prototype.m=function(a,b,c,d){a=this.c.v(this.o,a,b,c);return a=yb(this.h,a,d)};
+R.prototype.toString=function(){var a="Step:"+K("Operator: "+(this.A?"//":"/"));this.c.j&&(a+=K("Axis: "+this.c));a+=K(this.o);if(this.h.a.length){var b=la(this.h.a,function(c,d){return c+K(d)},"Predicates:");a+=K(b)}return a};function Pb(a,b,c,d){this.j=a;this.v=b;this.s=c;this.J=d}Pb.prototype.toString=function(){return this.j};var Qb={};function S(a,b,c,d){if(Qb.hasOwnProperty(a))throw Error("Axis already created: "+a);b=new Pb(a,b,c,!!d);return Qb[a]=b}
+S("ancestor",function(a,b){for(var c=new E;b=b.parentNode;)a.a(b)&&lb(c,b);return c},!0);S("ancestor-or-self",function(a,b){var c=new E;do a.a(b)&&lb(c,b);while(b=b.parentNode);return c},!0);
+var Gb=S("attribute",function(a,b){var c=new E,d=a.f();if("style"==d&&x&&b.style)return c.add(new Na(b.style,b,"style",b.style.cssText)),c;var e=b.attributes;if(e)if(a instanceof G&&null===a.b||"*"==d)for(a=0;d=e[a];a++)x?d.nodeValue&&c.add(Oa(b,d)):c.add(d);else(d=e.getNamedItem(d))&&(x?d.nodeValue&&c.add(Oa(b,d)):c.add(d));return c},!1),Nb=S("child",function(a,b,c,d,e){return(x?hb:ib).call(null,a,b,aa(c)?c:null,aa(d)?d:null,e||new E)},!1,!0);S("descendant",bb,!1,!0);
+var Ob=S("descendant-or-self",function(a,b,c,d){var e=new E;C(b,c,d)&&a.a(b)&&e.add(b);return bb(a,b,c,d,e)},!1,!0),Kb=S("following",function(a,b,c,d){var e=new E;do for(var f=b;f=f.nextSibling;)C(f,c,d)&&a.a(f)&&e.add(f),e=bb(a,f,c,d,e);while(b=b.parentNode);return e},!1,!0);S("following-sibling",function(a,b){for(var c=new E;b=b.nextSibling;)a.a(b)&&c.add(b);return c},!1);S("namespace",function(){return new E},!1);
+var Rb=S("parent",function(a,b){var c=new E;if(9==b.nodeType)return c;if(2==b.nodeType)return c.add(b.ownerElement),c;b=b.parentNode;a.a(b)&&c.add(b);return c},!1),Lb=S("preceding",function(a,b,c,d){var e=new E,f=[];do f.unshift(b);while(b=b.parentNode);for(var g=1,h=f.length;g<h;g++){var m=[];for(b=f[g];b=b.previousSibling;)m.unshift(b);for(var w=0,r=m.length;w<r;w++)b=m[w],C(b,c,d)&&a.a(b)&&e.add(b),e=bb(a,b,c,d,e)}return e},!0,!0);
+S("preceding-sibling",function(a,b){for(var c=new E;b=b.previousSibling;)a.a(b)&&lb(c,b);return c},!0);var Sb=S("self",function(a,b){var c=new E;a.a(b)&&c.add(b);return c},!1);function Tb(a){J.call(this,1);this.c=a;this.g=a.g;this.b=a.b}l(Tb,J);Tb.prototype.a=function(a){return-N(this.c,a)};Tb.prototype.toString=function(){return"Unary Expression: -"+K(this.c)};function Ub(a){J.call(this,4);this.c=a;pb(this,ma(this.c,function(b){return b.g}));qb(this,ma(this.c,function(b){return b.b}))}l(Ub,J);Ub.prototype.a=function(a){var b=new E;n(this.c,function(c){c=c.a(a);if(!(c instanceof E))throw Error("Path expression must evaluate to NodeSet.");b=kb(b,c)});return b};Ub.prototype.toString=function(){return la(this.c,function(a,b){return a+K(b)},"Union Expression:")};function Vb(a,b){this.a=a;this.b=b}function Yb(a){for(var b,c=[];;){T(a,"Missing right hand side of binary expression.");b=Zb(a);var d=z(a.a);if(!d)break;var e=(d=wb[d]||null)&&d.D;if(!e){a.a.a--;break}for(;c.length&&e<=c[c.length-1].D;)b=new sb(c.pop(),c.pop(),b);c.push(b,d)}for(;c.length;)b=new sb(c.pop(),c.pop(),b);return b}function T(a,b){if(Ta(a.a))throw Error(b);}function $b(a,b){a=z(a.a);if(a!=b)throw Error("Bad token, expected: "+b+" got: "+a);}
+function ac(a){a=z(a.a);if(")"!=a)throw Error("Bad token: "+a);}function bc(a){a=z(a.a);if(2>a.length)throw Error("Unclosed literal string");return new Db(a)}
+function cc(a){var b=[];if(Jb(y(a.a))){var c=z(a.a);var d=y(a.a);if("/"==c&&(Ta(a.a)||"."!=d&&".."!=d&&"@"!=d&&"*"!=d&&!/(?![0-9])[\w]/.test(d)))return new Hb;d=new Hb;T(a,"Missing next location step.");c=dc(a,c);b.push(c)}else{a:{c=y(a.a);d=c.charAt(0);switch(d){case "$":throw Error("Variable reference not allowed in HTML XPath");case "(":z(a.a);c=Yb(a);T(a,'unclosed "("');$b(a,")");break;case '"':case "'":c=bc(a);break;default:if(isNaN(+c))if(!Cb(c)&&/(?![0-9])[\w]/.test(d)&&"("==y(a.a,1)){c=z(a.a);
+c=Bb[c]||null;z(a.a);for(d=[];")"!=y(a.a);){T(a,"Missing function argument list.");d.push(Yb(a));if(","!=y(a.a))break;z(a.a)}T(a,"Unclosed function argument list.");ac(a);c=new zb(c,d)}else{c=null;break a}else c=new Eb(+z(a.a))}"["==y(a.a)&&(d=new Mb(ec(a)),c=new xb(c,d))}if(c)if(Jb(y(a.a)))d=c;else return c;else c=dc(a,"/"),d=new Ib,b.push(c)}for(;Jb(y(a.a));)c=z(a.a),T(a,"Missing next location step."),c=dc(a,c),b.push(c);return new Fb(d,b)}
+function dc(a,b){if("/"!=b&&"//"!=b)throw Error('Step op should be "/" or "//"');if("."==y(a.a)){var c=new R(Sb,new G("node"));z(a.a);return c}if(".."==y(a.a))return c=new R(Rb,new G("node")),z(a.a),c;if("@"==y(a.a)){var d=Gb;z(a.a);T(a,"Missing attribute name")}else if("::"==y(a.a,1)){if(!/(?![0-9])[\w]/.test(y(a.a).charAt(0)))throw Error("Bad token: "+z(a.a));var e=z(a.a);d=Qb[e]||null;if(!d)throw Error("No axis with name: "+e);z(a.a);T(a,"Missing node name")}else d=Nb;e=y(a.a);if(/(?![0-9])[\w\*]/.test(e.charAt(0)))if("("==
+y(a.a,1)){if(!Cb(e))throw Error("Invalid node type: "+e);e=z(a.a);if(!Cb(e))throw Error("Invalid type name: "+e);$b(a,"(");T(a,"Bad nodetype");var f=y(a.a).charAt(0),g=null;if('"'==f||"'"==f)g=bc(a);T(a,"Bad nodetype");ac(a);e=new G(e,g)}else if(e=z(a.a),f=e.indexOf(":"),-1==f)e=new F(e);else{g=e.substring(0,f);if("*"==g)var h="*";else if(h=a.b(g),!h)throw Error("Namespace prefix not declared: "+g);e=e.substr(f+1);e=new F(e,h)}else throw Error("Bad token: "+z(a.a));a=new Mb(ec(a),d.s);return c||new R(d,
+e,a,"//"==b)}function ec(a){for(var b=[];"["==y(a.a);){z(a.a);T(a,"Missing predicate expression.");var c=Yb(a);b.push(c);T(a,"Unclosed predicate expression.");$b(a,"]")}return b}function Zb(a){if("-"==y(a.a))return z(a.a),new Tb(Zb(a));var b=cc(a);if("|"!=y(a.a))a=b;else{for(b=[b];"|"==z(a.a);)T(a,"Missing next union location path."),b.push(cc(a));a.a.a--;a=new Ub(b)}return a};function fc(a){switch(a.nodeType){case 1:return ha(gc,a);case 9:return fc(a.documentElement);case 11:case 10:case 6:case 12:return hc;default:return a.parentNode?fc(a.parentNode):hc}}function hc(){return null}function gc(a,b){if(a.prefix==b)return a.namespaceURI||"http://www.w3.org/1999/xhtml";var c=a.getAttributeNode("xmlns:"+b);return c&&c.specified?c.value||null:a.parentNode&&9!=a.parentNode.nodeType?gc(a.parentNode,b):null};function ic(a,b){if(!a.length)throw Error("Empty XPath expression.");a=Qa(a);if(Ta(a))throw Error("Invalid XPath expression.");b?"function"==ca(b)||(b=fa(b.lookupNamespaceURI,b)):b=function(){return null};var c=Yb(new Vb(a,b));if(!Ta(a))throw Error("Bad token: "+z(a));this.evaluate=function(d,e){d=c.a(new ia(d));return new U(d,e)}}
+function U(a,b){if(0==b)if(a instanceof E)b=4;else if("string"==typeof a)b=2;else if("number"==typeof a)b=1;else if("boolean"==typeof a)b=3;else throw Error("Unexpected evaluation result.");if(2!=b&&1!=b&&3!=b&&!(a instanceof E))throw Error("value could not be converted to the specified type");this.resultType=b;switch(b){case 2:this.stringValue=a instanceof E?nb(a):""+a;break;case 1:this.numberValue=a instanceof E?+nb(a):+a;break;case 3:this.booleanValue=a instanceof E?0<a.l:!!a;break;case 4:case 5:case 6:case 7:var c=
+H(a);var d=[];for(var e=I(c);e;e=I(c))d.push(e instanceof Na?e.a:e);this.snapshotLength=a.l;this.invalidIteratorState=!1;break;case 8:case 9:a=mb(a);this.singleNodeValue=a instanceof Na?a.a:a;break;default:throw Error("Unknown XPathResult type.");}var f=0;this.iterateNext=function(){if(4!=b&&5!=b)throw Error("iterateNext called with wrong result type");return f>=d.length?null:d[f++]};this.snapshotItem=function(g){if(6!=b&&7!=b)throw Error("snapshotItem called with wrong result type");return g>=d.length||
+0>g?null:d[g]}}U.ANY_TYPE=0;U.NUMBER_TYPE=1;U.STRING_TYPE=2;U.BOOLEAN_TYPE=3;U.UNORDERED_NODE_ITERATOR_TYPE=4;U.ORDERED_NODE_ITERATOR_TYPE=5;U.UNORDERED_NODE_SNAPSHOT_TYPE=6;U.ORDERED_NODE_SNAPSHOT_TYPE=7;U.ANY_UNORDERED_NODE_TYPE=8;U.FIRST_ORDERED_NODE_TYPE=9;function jc(a){this.lookupNamespaceURI=fc(a)}
+function kc(a,b){a=a||k;var c=a.Document&&a.Document.prototype||a.document;if(!c.evaluate||b)a.XPathResult=U,c.evaluate=function(d,e,f,g){return(new ic(d,f)).evaluate(e,g)},c.createExpression=function(d,e){return new ic(d,e)},c.createNSResolver=function(d){return new jc(d)}}ba("wgxpath.install",kc);ba("wgxpath.install",kc);var lc={aliceblue:"#f0f8ff",antiquewhite:"#faebd7",aqua:"#00ffff",aquamarine:"#7fffd4",azure:"#f0ffff",beige:"#f5f5dc",bisque:"#ffe4c4",black:"#000000",blanchedalmond:"#ffebcd",blue:"#0000ff",blueviolet:"#8a2be2",brown:"#a52a2a",burlywood:"#deb887",cadetblue:"#5f9ea0",chartreuse:"#7fff00",chocolate:"#d2691e",coral:"#ff7f50",cornflowerblue:"#6495ed",cornsilk:"#fff8dc",crimson:"#dc143c",cyan:"#00ffff",darkblue:"#00008b",darkcyan:"#008b8b",darkgoldenrod:"#b8860b",darkgray:"#a9a9a9",darkgreen:"#006400",
+darkgrey:"#a9a9a9",darkkhaki:"#bdb76b",darkmagenta:"#8b008b",darkolivegreen:"#556b2f",darkorange:"#ff8c00",darkorchid:"#9932cc",darkred:"#8b0000",darksalmon:"#e9967a",darkseagreen:"#8fbc8f",darkslateblue:"#483d8b",darkslategray:"#2f4f4f",darkslategrey:"#2f4f4f",darkturquoise:"#00ced1",darkviolet:"#9400d3",deeppink:"#ff1493",deepskyblue:"#00bfff",dimgray:"#696969",dimgrey:"#696969",dodgerblue:"#1e90ff",firebrick:"#b22222",floralwhite:"#fffaf0",forestgreen:"#228b22",fuchsia:"#ff00ff",gainsboro:"#dcdcdc",
+ghostwhite:"#f8f8ff",gold:"#ffd700",goldenrod:"#daa520",gray:"#808080",green:"#008000",greenyellow:"#adff2f",grey:"#808080",honeydew:"#f0fff0",hotpink:"#ff69b4",indianred:"#cd5c5c",indigo:"#4b0082",ivory:"#fffff0",khaki:"#f0e68c",lavender:"#e6e6fa",lavenderblush:"#fff0f5",lawngreen:"#7cfc00",lemonchiffon:"#fffacd",lightblue:"#add8e6",lightcoral:"#f08080",lightcyan:"#e0ffff",lightgoldenrodyellow:"#fafad2",lightgray:"#d3d3d3",lightgreen:"#90ee90",lightgrey:"#d3d3d3",lightpink:"#ffb6c1",lightsalmon:"#ffa07a",
+lightseagreen:"#20b2aa",lightskyblue:"#87cefa",lightslategray:"#778899",lightslategrey:"#778899",lightsteelblue:"#b0c4de",lightyellow:"#ffffe0",lime:"#00ff00",limegreen:"#32cd32",linen:"#faf0e6",magenta:"#ff00ff",maroon:"#800000",mediumaquamarine:"#66cdaa",mediumblue:"#0000cd",mediumorchid:"#ba55d3",mediumpurple:"#9370db",mediumseagreen:"#3cb371",mediumslateblue:"#7b68ee",mediumspringgreen:"#00fa9a",mediumturquoise:"#48d1cc",mediumvioletred:"#c71585",midnightblue:"#191970",mintcream:"#f5fffa",mistyrose:"#ffe4e1",
+moccasin:"#ffe4b5",navajowhite:"#ffdead",navy:"#000080",oldlace:"#fdf5e6",olive:"#808000",olivedrab:"#6b8e23",orange:"#ffa500",orangered:"#ff4500",orchid:"#da70d6",palegoldenrod:"#eee8aa",palegreen:"#98fb98",paleturquoise:"#afeeee",palevioletred:"#db7093",papayawhip:"#ffefd5",peachpuff:"#ffdab9",peru:"#cd853f",pink:"#ffc0cb",plum:"#dda0dd",powderblue:"#b0e0e6",purple:"#800080",red:"#ff0000",rosybrown:"#bc8f8f",royalblue:"#4169e1",saddlebrown:"#8b4513",salmon:"#fa8072",sandybrown:"#f4a460",seagreen:"#2e8b57",
+seashell:"#fff5ee",sienna:"#a0522d",silver:"#c0c0c0",skyblue:"#87ceeb",slateblue:"#6a5acd",slategray:"#708090",slategrey:"#708090",snow:"#fffafa",springgreen:"#00ff7f",steelblue:"#4682b4",tan:"#d2b48c",teal:"#008080",thistle:"#d8bfd8",tomato:"#ff6347",turquoise:"#40e0d0",violet:"#ee82ee",wheat:"#f5deb3",white:"#ffffff",whitesmoke:"#f5f5f5",yellow:"#ffff00",yellowgreen:"#9acd32"};var mc="backgroundColor borderTopColor borderRightColor borderBottomColor borderLeftColor color outlineColor".split(" "),nc=/#([0-9a-fA-F])([0-9a-fA-F])([0-9a-fA-F])/,oc=/^#(?:[0-9a-f]{3}){1,2}$/i,pc=/^(?:rgba)?\((\d{1,3}),\s?(\d{1,3}),\s?(\d{1,3}),\s?(0|1|0\.\d*)\)$/i,qc=/^(?:rgb)?\((0|[1-9]\d{0,2}),\s?(0|[1-9]\d{0,2}),\s?(0|[1-9]\d{0,2})\)$/i;function rc(a,b){this.code=a;this.a=V[a]||sc;this.message=b||"";a=this.a.replace(/((?:^|\s+)[a-z])/g,function(c){return c.toUpperCase().replace(/^[\s\xa0]+/g,"")});b=a.length-5;if(0>b||a.indexOf("Error",b)!=b)a+="Error";this.name=a;a=Error(this.message);a.name=this.name;this.stack=a.stack||""}l(rc,Error);var sc="unknown error",V={15:"element not selectable",11:"element not visible"};V[31]=sc;V[30]=sc;V[24]="invalid cookie domain";V[29]="invalid element coordinates";V[12]="invalid element state";
+V[32]="invalid selector";V[51]="invalid selector";V[52]="invalid selector";V[17]="javascript error";V[405]="unsupported operation";V[34]="move target out of bounds";V[27]="no such alert";V[7]="no such element";V[8]="no such frame";V[23]="no such window";V[28]="script timeout";V[33]="session not created";V[10]="stale element reference";V[21]="timeout";V[25]="unable to set cookie";V[26]="unexpected alert open";V[13]=sc;V[9]="unknown command";var tc=va(),uc=ya()||u("iPod"),vc=u("iPad"),wc=u("Android")&&!(wa()||va()||u("Opera")||u("Silk")),xc=wa(),yc=u("Safari")&&!(wa()||u("Coast")||u("Opera")||u("Edge")||u("Edg/")||u("OPR")||va()||u("Silk")||u("Android"))&&!(ya()||u("iPad")||u("iPod"));function zc(a){return(a=a.exec(t))?a[1]:""}(function(){if(tc)return zc(/Firefox\/([0-9.]+)/);if(v||Ca||Ba)return Ga;if(xc)return ya()||u("iPad")||u("iPod")?zc(/CriOS\/([0-9.]+)/):zc(/Chrome\/([0-9.]+)/);if(yc&&!(ya()||u("iPad")||u("iPod")))return zc(/Version\/([0-9.]+)/);if(uc||vc){var a=/Version\/(\S+).*Mobile\/(\S+)/.exec(t);if(a)return a[1]+"."+a[2]}else if(wc)return(a=zc(/Android\s+([0-9.]+)/))?a:zc(/Version\/([0-9.]+)/);return""})();var Ac=v&&!(9<=Number(La));function W(a,b){b&&"string"!==typeof b&&(b=b.toString());return!!a&&1==a.nodeType&&(!b||a.tagName.toUpperCase()==b)};var Bc=function(){var a={K:"http://www.w3.org/2000/svg"};return function(b){return a[b]||null}}();
+function Cc(a,b){var c=A(a);if(!c.documentElement)return null;(v||wc)&&kc(c?c.parentWindow||c.defaultView:window);try{var d=c.createNSResolver?c.createNSResolver(c.documentElement):Bc;if(v&&!Ka(7))return c.evaluate.call(c,b,a,d,9,null);if(!v||9<=Number(La)){for(var e={},f=c.getElementsByTagName("*"),g=0;g<f.length;++g){var h=f[g],m=h.namespaceURI;if(m&&!e[m]){var w=h.lookupPrefix(m);if(!w){var r=m.match(".*/(\\w+)/?$");w=r?r[1]:"xhtml"}e[m]=w}}var D={},L;for(L in e)D[e[L]]=L;d=function(M){return D[M]||
+null}}try{return c.evaluate(b,a,d,9,null)}catch(M){if("TypeError"===M.name)return d=c.createNSResolver?c.createNSResolver(c.documentElement):Bc,c.evaluate(b,a,d,9,null);throw M;}}catch(M){if(!Da||"NS_ERROR_ILLEGAL_VALUE"!=M.name)throw new rc(32,"Unable to locate an element with the xpath expression "+b+" because of the following error:\n"+M);}}
+function Dc(a,b){var c=function(){var d=Cc(b,a);return d?d.singleNodeValue||null:b.selectSingleNode?(d=A(b),d.setProperty&&d.setProperty("SelectionLanguage","XPath"),b.selectSingleNode(a)):null}();if(null!==c&&(!c||1!=c.nodeType))throw new rc(32,'The result of the xpath expression "'+a+'" is: '+c+". It should be an element.");return c};function Ec(a,b,c,d){this.c=a;this.a=b;this.b=c;this.f=d}Ec.prototype.ceil=function(){this.c=Math.ceil(this.c);this.a=Math.ceil(this.a);this.b=Math.ceil(this.b);this.f=Math.ceil(this.f);return this};Ec.prototype.floor=function(){this.c=Math.floor(this.c);this.a=Math.floor(this.a);this.b=Math.floor(this.b);this.f=Math.floor(this.f);return this};Ec.prototype.round=function(){this.c=Math.round(this.c);this.a=Math.round(this.a);this.b=Math.round(this.b);this.f=Math.round(this.f);return this};function X(a,b,c,d){this.a=a;this.b=b;this.width=c;this.height=d}X.prototype.ceil=function(){this.a=Math.ceil(this.a);this.b=Math.ceil(this.b);this.width=Math.ceil(this.width);this.height=Math.ceil(this.height);return this};X.prototype.floor=function(){this.a=Math.floor(this.a);this.b=Math.floor(this.b);this.width=Math.floor(this.width);this.height=Math.floor(this.height);return this};
+X.prototype.round=function(){this.a=Math.round(this.a);this.b=Math.round(this.b);this.width=Math.round(this.width);this.height=Math.round(this.height);return this};var Fc="function"===typeof ShadowRoot;function Gc(a){for(a=a.parentNode;a&&1!=a.nodeType&&9!=a.nodeType&&11!=a.nodeType;)a=a.parentNode;return W(a)?a:null}
+function Y(a,b){b=xa(b);if("float"==b||"cssFloat"==b||"styleFloat"==b)b=Ac?"styleFloat":"cssFloat";a:{var c=b;var d=A(a);if(d.defaultView&&d.defaultView.getComputedStyle&&(d=d.defaultView.getComputedStyle(a,null))){c=d[c]||d.getPropertyValue(c)||"";break a}c=""}a=c||Hc(a,b);if(null===a)a=null;else if(0<=ja(mc,b)){b:{var e=a.match(pc);if(e&&(b=Number(e[1]),c=Number(e[2]),d=Number(e[3]),e=Number(e[4]),0<=b&&255>=b&&0<=c&&255>=c&&0<=d&&255>=d&&0<=e&&1>=e)){b=[b,c,d,e];break b}b=null}if(!b)b:{if(d=a.match(qc))if(b=
+Number(d[1]),c=Number(d[2]),d=Number(d[3]),0<=b&&255>=b&&0<=c&&255>=c&&0<=d&&255>=d){b=[b,c,d,1];break b}b=null}if(!b)b:{b=a.toLowerCase();c=lc[b.toLowerCase()];if(!c&&(c="#"==b.charAt(0)?b:"#"+b,4==c.length&&(c=c.replace(nc,"#$1$1$2$2$3$3")),!oc.test(c))){b=null;break b}b=[parseInt(c.substr(1,2),16),parseInt(c.substr(3,2),16),parseInt(c.substr(5,2),16),1]}a=b?"rgba("+b.join(", ")+")":a}return a}
+function Hc(a,b){var c=a.currentStyle||a.style,d=c[b];void 0===d&&"function"==ca(c.getPropertyValue)&&(d=c.getPropertyValue(b));return"inherit"!=d?void 0!==d?d:null:(a=Gc(a))?Hc(a,b):null}
+function Ic(a,b,c){function d(g){var h=Jc(g);return 0<h.height&&0<h.width?!0:W(g,"PATH")&&(0<h.height||0<h.width)?(g=Y(g,"stroke-width"),!!g&&0<parseInt(g,10)):"hidden"!=Y(g,"overflow")&&ma(g.childNodes,function(m){return 3==m.nodeType||W(m)&&d(m)})}function e(g){return Kc(g)==Z&&na(g.childNodes,function(h){return!W(h)||e(h)||!d(h)})}if(!W(a))throw Error("Argument to isShown must be of type Element");if(W(a,"BODY"))return!0;if(W(a,"OPTION")||W(a,"OPTGROUP"))return a=$a(a,function(g){return W(g,"SELECT")}),
+!!a&&Ic(a,!0,c);var f=Lc(a);if(f)return!!f.image&&0<f.rect.width&&0<f.rect.height&&Ic(f.image,b,c);if(W(a,"INPUT")&&"hidden"==a.type.toLowerCase()||W(a,"NOSCRIPT"))return!1;f=Y(a,"visibility");return"collapse"!=f&&"hidden"!=f&&c(a)&&(b||0!=Mc(a))&&d(a)?!e(a):!1}var Z="hidden";
+function Kc(a){function b(p){function q(fb){if(fb==g)return!0;var Wb=Y(fb,"display");return 0==Wb.lastIndexOf("inline",0)||"contents"==Wb||"absolute"==Xb&&"static"==Y(fb,"position")?!1:!0}var Xb=Y(p,"position");if("fixed"==Xb)return w=!0,p==g?null:g;for(p=Gc(p);p&&!q(p);)p=Gc(p);return p}function c(p){var q=p;if("visible"==m)if(p==g&&h)q=h;else if(p==h)return{x:"visible",y:"visible"};q={x:Y(q,"overflow-x"),y:Y(q,"overflow-y")};p==g&&(q.x="visible"==q.x?"auto":q.x,q.y="visible"==q.y?"auto":q.y);return q}
+function d(p){if(p==g){var q=(new ab(f)).a;p=q.scrollingElement?q.scrollingElement:Ea||"CSS1Compat"!=q.compatMode?q.body||q.documentElement:q.documentElement;q=q.parentWindow||q.defaultView;p=v&&Ka("10")&&q.pageYOffset!=p.scrollTop?new Ua(p.scrollLeft,p.scrollTop):new Ua(q.pageXOffset||p.scrollLeft,q.pageYOffset||p.scrollTop)}else p=new Ua(p.scrollLeft,p.scrollTop);return p}var e=Nc(a),f=A(a),g=f.documentElement,h=f.body,m=Y(g,"overflow"),w;for(a=b(a);a;a=b(a)){var r=c(a);if("visible"!=r.x||"visible"!=
+r.y){var D=Jc(a);if(0==D.width||0==D.height)return Z;var L=e.a<D.a,M=e.b<D.b;if(L&&"hidden"==r.x||M&&"hidden"==r.y)return Z;if(L&&"visible"!=r.x||M&&"visible"!=r.y){L=d(a);M=e.b<D.b-L.y;if(e.a<D.a-L.x&&"visible"!=r.x||M&&"visible"!=r.x)return Z;e=Kc(a);return e==Z?Z:"scroll"}L=e.f>=D.a+D.width;D=e.c>=D.b+D.height;if(L&&"hidden"==r.x||D&&"hidden"==r.y)return Z;if(L&&"visible"!=r.x||D&&"visible"!=r.y){if(w&&(r=d(a),e.f>=g.scrollWidth-r.x||e.a>=g.scrollHeight-r.y))return Z;e=Kc(a);return e==Z?Z:"scroll"}}}return"none"}
+function Jc(a){var b=Lc(a);if(b)return b.rect;if(W(a,"HTML"))return a=A(a),a=((a?a.parentWindow||a.defaultView:window)||window).document,a="CSS1Compat"==a.compatMode?a.documentElement:a.body,a=new Va(a.clientWidth,a.clientHeight),new X(0,0,a.width,a.height);try{var c=a.getBoundingClientRect()}catch(d){return new X(0,0,0,0)}b=new X(c.left,c.top,c.right-c.left,c.bottom-c.top);v&&a.ownerDocument.body&&(a=A(a),b.a-=a.documentElement.clientLeft+a.body.clientLeft,b.b-=a.documentElement.clientTop+a.body.clientTop);
+return b}function Lc(a){var b=W(a,"MAP");if(!b&&!W(a,"AREA"))return null;var c=b?a:W(a.parentNode,"MAP")?a.parentNode:null,d=null,e=null;c&&c.name&&(d=Dc('/descendant::*[@usemap = "#'+c.name+'"]',A(c)))&&(e=Jc(d),b||"default"==a.shape.toLowerCase()||(a=Oc(a),b=Math.min(Math.max(a.a,0),e.width),c=Math.min(Math.max(a.b,0),e.height),e=new X(b+e.a,c+e.b,Math.min(a.width,e.width-b),Math.min(a.height,e.height-c))));return{image:d,rect:e||new X(0,0,0,0)}}
+function Oc(a){var b=a.shape.toLowerCase();a=a.coords.split(",");if("rect"==b&&4==a.length){b=a[0];var c=a[1];return new X(b,c,a[2]-b,a[3]-c)}if("circle"==b&&3==a.length)return b=a[2],new X(a[0]-b,a[1]-b,2*b,2*b);if("poly"==b&&2<a.length){b=a[0];c=a[1];for(var d=b,e=c,f=2;f+1<a.length;f+=2)b=Math.min(b,a[f]),d=Math.max(d,a[f]),c=Math.min(c,a[f+1]),e=Math.max(e,a[f+1]);return new X(b,c,d-b,e-c)}return new X(0,0,0,0)}function Nc(a){a=Jc(a);return new Ec(a.b,a.a+a.width,a.b+a.height,a.a)}
+function Mc(a){if(Ac){if("relative"==Y(a,"position"))return 1;a=Y(a,"filter");return(a=a.match(/^alpha\(opacity=(\d*)\)/)||a.match(/^progid:DXImageTransform.Microsoft.Alpha\(Opacity=(\d*)\)/))?Number(a[1])/100:1}return Pc(a)}function Pc(a){var b=1,c=Y(a,"opacity");c&&(b=Number(c));(a=Gc(a))&&(b*=Pc(a));return b};ba("_",function(a,b){function c(d){if(W(d)&&"none"==Y(d,"display"))return!1;var e;if((e=d.parentNode)&&e.shadowRoot&&void 0!==d.assignedSlot)e=d.assignedSlot?d.assignedSlot.parentNode:null;else if(d.getDestinationInsertionPoints){var f=d.getDestinationInsertionPoints();0<f.length&&(e=f[f.length-1])}if(Fc&&e instanceof ShadowRoot){if(e.host.shadowRoot!==e)return!1;e=e.host}return!e||9!=e.nodeType&&11!=e.nodeType?e&&W(e,"DETAILS")&&!e.open&&!W(d,"SUMMARY")?!1:!!e&&c(e):!0}return Ic(a,!!b,c)});; return this._.apply(null,arguments);}).apply({navigator:typeof window!='undefined'?window.navigator:null,document:typeof window!='undefined'?window.document:null}, arguments);}
diff --git a/remote/marionette/browser.sys.mjs b/remote/marionette/browser.sys.mjs
new file mode 100644
index 0000000000..66789be35e
--- /dev/null
+++ b/remote/marionette/browser.sys.mjs
@@ -0,0 +1,384 @@
+/* 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",
+ EventPromise: "chrome://remote/content/shared/Sync.sys.mjs",
+ MessageManagerDestroyedPromise:
+ "chrome://remote/content/marionette/sync.sys.mjs",
+ TabManager: "chrome://remote/content/shared/TabManager.sys.mjs",
+ WebElementEventTarget: "chrome://remote/content/marionette/dom.sys.mjs",
+ windowManager: "chrome://remote/content/shared/WindowManager.sys.mjs",
+});
+
+/** @namespace */
+export const browser = {};
+
+/**
+ * Variations of Marionette contexts.
+ *
+ * Choosing a context through the <tt>Marionette:SetContext</tt>
+ * command directs all subsequent browsing context scoped commands
+ * to that context.
+ *
+ * @class Marionette.Context
+ */
+export class Context {
+ /**
+ * Gets the correct context from a string.
+ *
+ * @param {string} s
+ * Context string serialisation.
+ *
+ * @return {Context}
+ * Context.
+ *
+ * @throws {TypeError}
+ * If <var>s</var> is not a context.
+ */
+ static fromString(s) {
+ switch (s) {
+ case "chrome":
+ return Context.Chrome;
+
+ case "content":
+ return Context.Content;
+
+ default:
+ throw new TypeError(`Unknown context: ${s}`);
+ }
+ }
+}
+
+Context.Chrome = "chrome";
+Context.Content = "content";
+
+/**
+ * Creates a browsing context wrapper.
+ *
+ * Browsing contexts handle interactions with the browser, according to
+ * the current environment.
+ */
+browser.Context = class {
+ /**
+ * @param {ChromeWindow} win
+ * ChromeWindow that contains the top-level browsing context.
+ * @param {GeckoDriver} driver
+ * Reference to driver instance.
+ */
+ constructor(window, driver) {
+ this.window = window;
+ this.driver = driver;
+
+ // In Firefox this is <xul:tabbrowser> (not <xul:browser>!)
+ // and MobileTabBrowser in GeckoView.
+ this.tabBrowser = lazy.TabManager.getTabBrowser(this.window);
+
+ // Used to set curFrameId upon new session
+ this.newSession = true;
+
+ // A reference to the tab corresponding to the current window handle,
+ // if any. Specifically, this.tab refers to the last tab that Marionette
+ // switched to in this browser window. Note that this may not equal the
+ // currently selected tab. For example, if Marionette switches to tab
+ // A, and then clicks on a button that opens a new tab B in the same
+ // browser window, this.tab will still point to tab A, despite tab B
+ // being the currently selected tab.
+ this.tab = null;
+ }
+
+ /**
+ * Returns the content browser for the currently selected tab.
+ * If there is no tab selected, null will be returned.
+ */
+ get contentBrowser() {
+ if (this.tab) {
+ return lazy.TabManager.getBrowserForTab(this.tab);
+ } else if (
+ this.tabBrowser &&
+ this.driver.isReftestBrowser(this.tabBrowser)
+ ) {
+ return this.tabBrowser;
+ }
+
+ return null;
+ }
+
+ get messageManager() {
+ if (this.contentBrowser) {
+ return this.contentBrowser.messageManager;
+ }
+
+ return null;
+ }
+
+ /**
+ * Checks if the browsing context has been discarded.
+ *
+ * The browsing context will have been discarded if the content
+ * browser, represented by the <code>&lt;xul:browser&gt;</code>,
+ * has been detached.
+ *
+ * @return {boolean}
+ * True if browsing context has been discarded, false otherwise.
+ */
+ get closed() {
+ return this.contentBrowser === null;
+ }
+
+ /**
+ * Gets the position and dimensions of the top-level browsing context.
+ *
+ * @return {Map.<string, number>}
+ * Object with |x|, |y|, |width|, and |height| properties.
+ */
+ get rect() {
+ return {
+ x: this.window.screenX,
+ y: this.window.screenY,
+ width: this.window.outerWidth,
+ height: this.window.outerHeight,
+ };
+ }
+
+ /**
+ * Retrieves the current tabmodal UI object. According to the browser
+ * associated with the currently selected tab.
+ */
+ getTabModal() {
+ let br = this.contentBrowser;
+ if (!br.hasAttribute("tabmodalPromptShowing")) {
+ return null;
+ }
+
+ // The modal is a direct sibling of the browser element.
+ // See tabbrowser.xml's getTabModalPromptBox.
+ let modalElements = br.parentNode.getElementsByTagName("tabmodalprompt");
+
+ return br.tabModalPromptBox.getPrompt(modalElements[0]);
+ }
+
+ /**
+ * Close the current window.
+ *
+ * @return {Promise}
+ * A promise which is resolved when the current window has been closed.
+ */
+ async closeWindow() {
+ return lazy.windowManager.closeWindow(this.window);
+ }
+
+ /**
+ * Focus the current window.
+ *
+ * @return {Promise}
+ * A promise which is resolved when the current window has been focused.
+ */
+ async focusWindow() {
+ await lazy.windowManager.focusWindow(this.window);
+
+ // Also focus the currently selected tab if present.
+ this.contentBrowser?.focus();
+ }
+
+ /**
+ * Open a new browser window.
+ *
+ * @return {Promise}
+ * A promise resolving to the newly created chrome window.
+ */
+ openBrowserWindow(focus = false, isPrivate = false) {
+ return lazy.windowManager.openBrowserWindow({
+ openerWindow: this.window,
+ focus,
+ isPrivate,
+ });
+ }
+
+ /**
+ * Close the current tab.
+ *
+ * @return {Promise}
+ * A promise which is resolved when the current tab has been closed.
+ *
+ * @throws UnsupportedOperationError
+ * If tab handling for the current application isn't supported.
+ */
+ async closeTab() {
+ // If the current window is not a browser then close it directly. Do the
+ // same if only one remaining tab is open, or no tab selected at all.
+ //
+ // Note: For GeckoView there will always be a single tab only. But for
+ // consistency with other platforms a specific condition has been added
+ // below as well even it's not really used.
+ if (
+ !this.tabBrowser ||
+ !this.tabBrowser.tabs ||
+ this.tabBrowser.tabs.length === 1 ||
+ !this.tab
+ ) {
+ return this.closeWindow();
+ }
+
+ let destroyed = new lazy.MessageManagerDestroyedPromise(
+ this.messageManager
+ );
+ let tabClosed;
+
+ if (lazy.AppInfo.isAndroid) {
+ await lazy.TabManager.removeTab(this.tab);
+ } else if (lazy.AppInfo.isFirefox) {
+ tabClosed = new lazy.EventPromise(this.tab, "TabClose");
+ await this.tabBrowser.removeTab(this.tab);
+ } else {
+ throw new lazy.error.UnsupportedOperationError(
+ `closeTab() not supported for ${lazy.AppInfo.name}`
+ );
+ }
+
+ return Promise.all([destroyed, tabClosed]);
+ }
+
+ /**
+ * Open a new tab in the currently selected chrome window.
+ */
+ async openTab(focus = false) {
+ let tab = null;
+
+ // Bug 1795841 - For Firefox the TabManager cannot be used yet. As such
+ // handle opening a tab differently for Android.
+ if (lazy.AppInfo.isAndroid) {
+ tab = await lazy.TabManager.addTab({ focus, window: this.window });
+ } else if (lazy.AppInfo.isFirefox) {
+ const opened = new lazy.EventPromise(this.window, "TabOpen");
+ this.window.BrowserOpenTab({ url: "about:blank" });
+ await opened;
+
+ tab = this.tabBrowser.selectedTab;
+
+ // The new tab is always selected by default. If focus is not wanted,
+ // the previously tab needs to be selected again.
+ if (!focus) {
+ await lazy.TabManager.selectTab(this.tab);
+ }
+ } else {
+ throw new lazy.error.UnsupportedOperationError(
+ `openTab() not supported for ${lazy.AppInfo.name}`
+ );
+ }
+
+ return tab;
+ }
+
+ /**
+ * Set the current tab.
+ *
+ * @param {number=} index
+ * Tab index to switch to. If the parameter is undefined,
+ * the currently selected tab will be used.
+ * @param {ChromeWindow=} window
+ * Switch to this window before selecting the tab.
+ * @param {boolean=} focus
+ * A boolean value which determins whether to focus
+ * the window. Defaults to true.
+ *
+ * @return {Tab}
+ * The selected tab.
+ *
+ * @throws UnsupportedOperationError
+ * If tab handling for the current application isn't supported.
+ */
+ async switchToTab(index, window = undefined, focus = true) {
+ if (window) {
+ this.window = window;
+ this.tabBrowser = lazy.TabManager.getTabBrowser(this.window);
+ }
+
+ if (!this.tabBrowser || this.driver.isReftestBrowser(this.tabBrowser)) {
+ return null;
+ }
+
+ if (typeof index == "undefined") {
+ this.tab = this.tabBrowser.selectedTab;
+ } else {
+ this.tab = this.tabBrowser.tabs[index];
+ }
+
+ if (focus) {
+ await lazy.TabManager.selectTab(this.tab);
+ }
+
+ // TODO(ato): Currently tied to curBrowser, but should be moved to
+ // WebReference when introduced by https://bugzil.la/1400256.
+ this.eventObserver = new lazy.WebElementEventTarget(this.messageManager);
+
+ return this.tab;
+ }
+
+ /**
+ * Registers a new frame, and sets its current frame id to this frame
+ * if it is not already assigned, and if a) we already have a session
+ * or b) we're starting a new session and it is the right start frame.
+ *
+ * @param {xul:browser} target
+ * The <xul:browser> that was the target of the originating message.
+ */
+ register(target) {
+ if (!this.tabBrowser) {
+ return;
+ }
+
+ // If we're setting up a new session on Firefox, we only process the
+ // registration for this frame if it belongs to the current tab.
+ if (!this.tab) {
+ this.switchToTab();
+ }
+ }
+};
+
+/**
+ * Marionette representation of the {@link ChromeWindow} window state.
+ *
+ * @enum {string}
+ */
+export const WindowState = {
+ Maximized: "maximized",
+ Minimized: "minimized",
+ Normal: "normal",
+ Fullscreen: "fullscreen",
+
+ /**
+ * Converts {@link nsIDOMChromeWindow.windowState} to WindowState.
+ *
+ * @param {number} windowState
+ * Attribute from {@link nsIDOMChromeWindow.windowState}.
+ *
+ * @return {WindowState}
+ * JSON representation.
+ *
+ * @throws {TypeError}
+ * If <var>windowState</var> was unknown.
+ */
+ from(windowState) {
+ switch (windowState) {
+ case 1:
+ return WindowState.Maximized;
+
+ case 2:
+ return WindowState.Minimized;
+
+ case 3:
+ return WindowState.Normal;
+
+ case 4:
+ return WindowState.Fullscreen;
+
+ default:
+ throw new TypeError(`Unknown window state: ${windowState}`);
+ }
+ },
+};
diff --git a/remote/marionette/cert.sys.mjs b/remote/marionette/cert.sys.mjs
new file mode 100644
index 0000000000..36880b072a
--- /dev/null
+++ b/remote/marionette/cert.sys.mjs
@@ -0,0 +1,61 @@
+/* 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",
+});
+
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "sss",
+ "@mozilla.org/ssservice;1",
+ "nsISiteSecurityService"
+);
+
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "certOverrideService",
+ "@mozilla.org/security/certoverride;1",
+ "nsICertOverrideService"
+);
+
+const CERT_PINNING_ENFORCEMENT_PREF = "security.cert_pinning.enforcement_level";
+const HSTS_PRELOAD_LIST_PREF = "network.stricttransportsecurity.preloadlist";
+
+/** @namespace */
+export const allowAllCerts = {};
+
+/**
+ * Disable all security check and allow all certs.
+ */
+allowAllCerts.enable = function() {
+ // make it possible to register certificate overrides for domains
+ // that use HSTS or HPKP
+ lazy.Preferences.set(HSTS_PRELOAD_LIST_PREF, false);
+ lazy.Preferences.set(CERT_PINNING_ENFORCEMENT_PREF, 0);
+
+ lazy.certOverrideService.setDisableAllSecurityChecksAndLetAttackersInterceptMyData(
+ true
+ );
+};
+
+/**
+ * Enable all security check.
+ */
+allowAllCerts.disable = function() {
+ lazy.certOverrideService.setDisableAllSecurityChecksAndLetAttackersInterceptMyData(
+ false
+ );
+
+ lazy.Preferences.reset(HSTS_PRELOAD_LIST_PREF);
+ lazy.Preferences.reset(CERT_PINNING_ENFORCEMENT_PREF);
+
+ // clear collected HSTS and HPKP state
+ // through the site security service
+ lazy.sss.clearAll();
+};
diff --git a/remote/marionette/chrome/reftest.xhtml b/remote/marionette/chrome/reftest.xhtml
new file mode 100644
index 0000000000..7135ce2862
--- /dev/null
+++ b/remote/marionette/chrome/reftest.xhtml
@@ -0,0 +1,6 @@
+<window id="reftest"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ hidechrome="true"
+ style="background-color:white; overflow:hidden">
+ <script src="reftest-content.js"></script>
+</window>
diff --git a/remote/marionette/chrome/test.xhtml b/remote/marionette/chrome/test.xhtml
new file mode 100644
index 0000000000..1ab4b5d263
--- /dev/null
+++ b/remote/marionette/chrome/test.xhtml
@@ -0,0 +1,27 @@
+<?xml version="1.0"?>
+<!-- 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/. -->
+
+<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?>
+
+<!DOCTYPE window [
+]>
+<window id="winTest" title="Title Test" windowtype="Test Type"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+ <dialog id="dia"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+
+ <vbox id="things">
+ <input xmlns="http://www.w3.org/1999/xhtml" id="textInput" size="6" value="test" label="input" />
+ <input xmlns="http://www.w3.org/1999/xhtml" id="textInput2" size="6" value="test" label="input" />
+ <input xmlns="http://www.w3.org/1999/xhtml" id="textInput3" class="asdf" size="6" value="test" label="input" />
+ <checkbox id="testBox" label="box" />
+ </vbox>
+
+ <iframe id="iframe" name="iframename" src="chrome://remote/content/marionette/test2.xhtml"/>
+ <iframe id="iframe" name="iframename" src="chrome://remote/content/marionette/test_nested_iframe.xhtml"/>
+ <hbox id="testXulBox"/>
+ <browser id="aBrowser" src="chrome://remote/content/marionette/test2.xhtml"/>
+ </dialog>
+</window>
diff --git a/remote/marionette/chrome/test2.xhtml b/remote/marionette/chrome/test2.xhtml
new file mode 100644
index 0000000000..d6b72dab45
--- /dev/null
+++ b/remote/marionette/chrome/test2.xhtml
@@ -0,0 +1,20 @@
+<?xml version="1.0"?>
+<!-- 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/. -->
+
+<!DOCTYPE window [
+]>
+
+<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+<dialog id="dia">
+
+ <vbox id="things">
+ <checkbox id="testBox" label="box" />
+ <input xmlns="http://www.w3.org/1999/xhtml" id="textInput" size="6" value="test" label="input" />
+ <input xmlns="http://www.w3.org/1999/xhtml" id="textInput2" size="6" value="test" label="input" />
+ <input xmlns="http://www.w3.org/1999/xhtml" id="textInput3" class="asdf" size="6" value="test" label="input" />
+ </vbox>
+
+</dialog>
+</window>
diff --git a/remote/marionette/chrome/test_dialog.dtd b/remote/marionette/chrome/test_dialog.dtd
new file mode 100644
index 0000000000..414cb0ee81
--- /dev/null
+++ b/remote/marionette/chrome/test_dialog.dtd
@@ -0,0 +1,7 @@
+<!-- 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/. -->
+
+<!ENTITY testDialog.title "Test Dialog">
+
+<!ENTITY settings.label "Settings">
diff --git a/remote/marionette/chrome/test_dialog.properties b/remote/marionette/chrome/test_dialog.properties
new file mode 100644
index 0000000000..ade7b6bde3
--- /dev/null
+++ b/remote/marionette/chrome/test_dialog.properties
@@ -0,0 +1,7 @@
+# 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/.
+
+testDialog.title=Test Dialog
+
+settings.label=Settings
diff --git a/remote/marionette/chrome/test_dialog.xhtml b/remote/marionette/chrome/test_dialog.xhtml
new file mode 100644
index 0000000000..e1da10dbe9
--- /dev/null
+++ b/remote/marionette/chrome/test_dialog.xhtml
@@ -0,0 +1,37 @@
+<?xml version="1.0"?>
+<!-- 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/. -->
+
+<!DOCTYPE testdialog [
+<!ENTITY % dialogDTD SYSTEM "chrome://remote/content/marionette/test_dialog.dtd" >
+%dialogDTD;
+]>
+
+<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?>
+
+
+<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ title="&testDialog.title;">
+<dialog id="testDialog"
+ buttons="accept,cancel">
+
+ <vbox flex="1" style="min-width: 300px; min-height: 500px;">
+ <label>&settings.label;</label>
+ <separator class="thin"/>
+ <richlistbox id="test-list" flex="1">
+ <richlistitem id="item-choose" orient="horizontal" selected="true">
+ <label id="choose-label" value="First Entry" flex="1"/>
+ <button id="choose-button" oncommand="" label="Choose..."/>
+ </richlistitem>
+ </richlistbox>
+ <separator class="thin"/>
+ <checkbox id="check-box" label="Test Mode 2" />
+ <hbox align="center">
+ <label id="text-box-label" control="text-box">Name:</label>
+ <input xmlns="http://www.w3.org/1999/xhtml" id="text-box" style="-moz-box-flex: 1;" />
+ </hbox>
+ </vbox>
+
+</dialog>
+</window>
diff --git a/remote/marionette/chrome/test_menupopup.xhtml b/remote/marionette/chrome/test_menupopup.xhtml
new file mode 100644
index 0000000000..5d8902f011
--- /dev/null
+++ b/remote/marionette/chrome/test_menupopup.xhtml
@@ -0,0 +1,30 @@
+<?xml version="1.0"?>
+<!-- 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/. -->
+
+<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?>
+
+<!DOCTYPE window [
+]>
+<window id="test-window" xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+ <popupset id="options-popupset">
+ <menupopup id="options-menupopup" position="before_end">
+ <menuitem id="option-enabled"
+ type="checkbox"
+ label="enabled"/>
+ <menuitem id="option-hidden"
+ type="checkbox"
+ label="hidden"
+ hidden="true"/>
+ <menuitem id="option-disabled"
+ type="checkbox"
+ label="disabled"
+ disabled="true"/>
+ </menupopup>
+ </popupset>
+ <hbox align="center" style="height: 300px;">
+ <button id="options-button"
+ popup="options-menupopup" label="button"/>
+ </hbox>
+</window>
diff --git a/remote/marionette/chrome/test_nested_iframe.xhtml b/remote/marionette/chrome/test_nested_iframe.xhtml
new file mode 100644
index 0000000000..1d0edcc65b
--- /dev/null
+++ b/remote/marionette/chrome/test_nested_iframe.xhtml
@@ -0,0 +1,9 @@
+<?xml version="1.0"?>
+<!-- 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/. -->
+
+<!DOCTYPE window [
+]>
+
+ <iframe id="iframe" name="iframename" src="test2.xhtml"/>
diff --git a/remote/marionette/chrome/test_no_xul.xhtml b/remote/marionette/chrome/test_no_xul.xhtml
new file mode 100644
index 0000000000..195d138744
--- /dev/null
+++ b/remote/marionette/chrome/test_no_xul.xhtml
@@ -0,0 +1,31 @@
+<?xml version="1.0"?>
+<!-- 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/. -->
+
+<!-- Test file for a non XUL window by using a XHTML document instead. -->
+
+<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?>
+
+<html id="winTest"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns="http://www.w3.org/1999/xhtml">
+
+ <head>
+ <title>Title Test</title>
+ </head>
+
+ <body xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+ <vbox id="things">
+ <input xmlns="http://www.w3.org/1999/xhtml" id="textInput" size="6" value="test" label="input" />
+ <input xmlns="http://www.w3.org/1999/xhtml" id="textInput2" size="6" value="test" label="input" />
+ <input xmlns="http://www.w3.org/1999/xhtml" id="textInput3" class="asdf" size="6" value="test" label="input" />
+ <input type="checkbox" id="testBox" label="box" />
+ </vbox>
+
+ <html:iframe id="iframe" name="iframename" src="chrome://remote/content/marionette/test2.xhtml" />
+ <html:iframe id="iframe" name="iframename" src="chrome://remote/content/marionette/test_nested_iframe.xhtml" />
+ </body>
+
+</html>
diff --git a/remote/marionette/cookie.sys.mjs b/remote/marionette/cookie.sys.mjs
new file mode 100644
index 0000000000..273a2d353c
--- /dev/null
+++ b/remote/marionette/cookie.sys.mjs
@@ -0,0 +1,295 @@
+/* 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, {
+ 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",
+});
+
+const IPV4_PORT_EXPR = /:\d+$/;
+
+const SAMESITE_MAP = new Map([
+ ["None", Ci.nsICookie.SAMESITE_NONE],
+ ["Lax", Ci.nsICookie.SAMESITE_LAX],
+ ["Strict", Ci.nsICookie.SAMESITE_STRICT],
+]);
+
+/** @namespace */
+export const cookie = {
+ manager: Services.cookies,
+};
+
+/**
+ * @name Cookie
+ *
+ * @return {Object.<string, (number|boolean|string)>
+ */
+
+/**
+ * Unmarshal a JSON Object to a cookie representation.
+ *
+ * Effectively this will run validation checks on ``json``, which
+ * will produce the errors expected by WebDriver if the input is
+ * not valid.
+ *
+ * @param {Object.<string, (number|boolean|string)>} json
+ * Cookie to be deserialised. ``name`` and ``value`` are required
+ * fields which must be strings. The ``path`` and ``domain`` fields
+ * are optional, but must be a string if provided. The ``secure``,
+ * and ``httpOnly`` are similarly optional, but must be booleans.
+ * Likewise, the ``expiry`` field is optional but must be
+ * unsigned integer.
+ *
+ * @return {Cookie}
+ * Valid cookie object.
+ *
+ * @throws {InvalidArgumentError}
+ * If any of the properties are invalid.
+ */
+cookie.fromJSON = function(json) {
+ let newCookie = {};
+
+ lazy.assert.object(json, lazy.pprint`Expected cookie object, got ${json}`);
+
+ newCookie.name = lazy.assert.string(json.name, "Cookie name must be string");
+ newCookie.value = lazy.assert.string(
+ json.value,
+ "Cookie value must be string"
+ );
+
+ if (typeof json.path != "undefined") {
+ newCookie.path = lazy.assert.string(
+ json.path,
+ "Cookie path must be string"
+ );
+ }
+ if (typeof json.domain != "undefined") {
+ newCookie.domain = lazy.assert.string(
+ json.domain,
+ "Cookie domain must be string"
+ );
+ }
+ if (typeof json.secure != "undefined") {
+ newCookie.secure = lazy.assert.boolean(
+ json.secure,
+ "Cookie secure flag must be boolean"
+ );
+ }
+ if (typeof json.httpOnly != "undefined") {
+ newCookie.httpOnly = lazy.assert.boolean(
+ json.httpOnly,
+ "Cookie httpOnly flag must be boolean"
+ );
+ }
+ if (typeof json.expiry != "undefined") {
+ newCookie.expiry = lazy.assert.positiveInteger(
+ json.expiry,
+ "Cookie expiry must be a positive integer"
+ );
+ }
+ if (typeof json.sameSite != "undefined") {
+ newCookie.sameSite = lazy.assert.in(
+ json.sameSite,
+ Array.from(SAMESITE_MAP.keys()),
+ "Cookie SameSite flag must be one of None, Lax, or Strict"
+ );
+ }
+
+ return newCookie;
+};
+
+/**
+ * Insert cookie to the cookie store.
+ *
+ * @param {Cookie} newCookie
+ * Cookie to add.
+ * @param {string=} restrictToHost
+ * Perform test that ``newCookie``'s domain matches this.
+ * @param {string=} protocol
+ * The protocol of the caller. It can be `http:` or `https:`.
+ *
+ * @throws {TypeError}
+ * If ``name``, ``value``, or ``domain`` are not present and
+ * of the correct type.
+ * @throws {InvalidCookieDomainError}
+ * If ``restrictToHost`` is set and ``newCookie``'s domain does
+ * not match.
+ * @throws {UnableToSetCookieError}
+ * If an error occurred while trying to save the cookie.
+ */
+cookie.add = function(
+ newCookie,
+ { restrictToHost = null, protocol = null } = {}
+) {
+ lazy.assert.string(newCookie.name, "Cookie name must be string");
+ lazy.assert.string(newCookie.value, "Cookie value must be string");
+
+ if (typeof newCookie.path == "undefined") {
+ newCookie.path = "/";
+ }
+
+ let hostOnly = false;
+ if (typeof newCookie.domain == "undefined") {
+ hostOnly = true;
+ newCookie.domain = restrictToHost;
+ }
+ lazy.assert.string(newCookie.domain, "Cookie domain must be string");
+ if (newCookie.domain.substring(0, 1) === ".") {
+ newCookie.domain = newCookie.domain.substring(1);
+ }
+
+ if (typeof newCookie.secure == "undefined") {
+ newCookie.secure = false;
+ }
+ if (typeof newCookie.httpOnly == "undefined") {
+ newCookie.httpOnly = false;
+ }
+ if (typeof newCookie.expiry == "undefined") {
+ // The XPCOM interface requires the expiry field even for session cookies.
+ newCookie.expiry = Number.MAX_SAFE_INTEGER;
+ newCookie.session = true;
+ } else {
+ newCookie.session = false;
+ }
+ newCookie.sameSite = SAMESITE_MAP.get(newCookie.sameSite || "None");
+
+ let isIpAddress = false;
+ try {
+ Services.eTLD.getPublicSuffixFromHost(newCookie.domain);
+ } catch (e) {
+ switch (e.result) {
+ case Cr.NS_ERROR_HOST_IS_IP_ADDRESS:
+ isIpAddress = true;
+ break;
+ default:
+ throw new lazy.error.InvalidCookieDomainError(newCookie.domain);
+ }
+ }
+
+ if (!hostOnly && !isIpAddress) {
+ // only store this as a domain cookie if the domain was specified in the
+ // request and it wasn't an IP address.
+ newCookie.domain = "." + newCookie.domain;
+ }
+
+ if (restrictToHost) {
+ if (
+ !restrictToHost.endsWith(newCookie.domain) &&
+ "." + restrictToHost !== newCookie.domain &&
+ restrictToHost !== newCookie.domain
+ ) {
+ throw new lazy.error.InvalidCookieDomainError(
+ `Cookies may only be set ` +
+ `for the current domain (${restrictToHost})`
+ );
+ }
+ }
+
+ let schemeType = Ci.nsICookie.SCHEME_UNSET;
+ switch (protocol) {
+ case "http:":
+ schemeType = Ci.nsICookie.SCHEME_HTTP;
+ break;
+ case "https:":
+ schemeType = Ci.nsICookie.SCHEME_HTTPS;
+ break;
+ default:
+ // Any other protocol that is supported by the cookie service.
+ break;
+ }
+
+ // remove port from domain, if present.
+ // unfortunately this catches IPv6 addresses by mistake
+ // TODO: Bug 814416
+ newCookie.domain = newCookie.domain.replace(IPV4_PORT_EXPR, "");
+
+ try {
+ cookie.manager.add(
+ newCookie.domain,
+ newCookie.path,
+ newCookie.name,
+ newCookie.value,
+ newCookie.secure,
+ newCookie.httpOnly,
+ newCookie.session,
+ newCookie.expiry,
+ {} /* origin attributes */,
+ newCookie.sameSite,
+ schemeType
+ );
+ } catch (e) {
+ throw new lazy.error.UnableToSetCookieError(e);
+ }
+};
+
+/**
+ * Remove cookie from the cookie store.
+ *
+ * @param {Cookie} toDelete
+ * Cookie to remove.
+ */
+cookie.remove = function(toDelete) {
+ cookie.manager.remove(
+ toDelete.domain,
+ toDelete.name,
+ toDelete.path,
+ {} /* originAttributes */
+ );
+};
+
+/**
+ * Iterates over the cookies for the current ``host``. You may
+ * optionally filter for specific paths on that ``host`` by specifying
+ * a path in ``currentPath``.
+ *
+ * @param {string} host
+ * Hostname to retrieve cookies for.
+ * @param {string=} [currentPath="/"] currentPath
+ * Optionally filter the cookies for ``host`` for the specific path.
+ * Defaults to ``/``, meaning all cookies for ``host`` are included.
+ *
+ * @return {Iterable.<Cookie>}
+ * Iterator.
+ */
+cookie.iter = function*(host, currentPath = "/") {
+ lazy.assert.string(host, "host must be string");
+ lazy.assert.string(currentPath, "currentPath must be string");
+
+ const isForCurrentPath = path => currentPath.includes(path);
+
+ let cookies = cookie.manager.getCookiesFromHost(host, {});
+ for (let cookie of cookies) {
+ // take the hostname and progressively shorten
+ let hostname = host;
+ do {
+ if (
+ (cookie.host == "." + hostname || cookie.host == hostname) &&
+ isForCurrentPath(cookie.path)
+ ) {
+ let data = {
+ name: cookie.name,
+ value: cookie.value,
+ path: cookie.path,
+ domain: cookie.host,
+ secure: cookie.isSecure,
+ httpOnly: cookie.isHttpOnly,
+ };
+
+ if (!cookie.isSession) {
+ data.expiry = cookie.expiry;
+ }
+
+ data.sameSite = [...SAMESITE_MAP].find(
+ ([, value]) => cookie.sameSite === value
+ )[0];
+
+ yield data;
+ }
+ hostname = hostname.replace(/^.*?\./, "");
+ } while (hostname.includes("."));
+ }
+};
diff --git a/remote/marionette/dom.sys.mjs b/remote/marionette/dom.sys.mjs
new file mode 100644
index 0000000000..6c9f67fdfc
--- /dev/null
+++ b/remote/marionette/dom.sys.mjs
@@ -0,0 +1,208 @@
+/* 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(lazy.Log.TYPES.MARIONETTE)
+);
+
+/**
+ * The ``EventTarget`` for web elements can be used to observe DOM
+ * events in the content document.
+ *
+ * A caveat of the current implementation is that it is only possible
+ * to listen for top-level ``window`` global events.
+ *
+ * It needs to be backed by a :js:class:`ContentEventObserverService`
+ * in a content frame script.
+ *
+ * Usage::
+ *
+ * let observer = new WebElementEventTarget(messageManager);
+ * await new Promise(resolve => {
+ * observer.addEventListener("visibilitychange", resolve, {once: true});
+ * chromeWindow.minimize();
+ * });
+ */
+export class WebElementEventTarget {
+ /**
+ * @param {function(): nsIMessageListenerManager} messageManagerFn
+ * Message manager to the current browser.
+ */
+ constructor(messageManager) {
+ this.mm = messageManager;
+ this.listeners = {};
+ this.mm.addMessageListener("Marionette:DOM:OnEvent", this);
+ }
+
+ /**
+ * Register an event handler of a specific event type from the content
+ * frame.
+ *
+ * @param {string} type
+ * Event type to listen for.
+ * @param {EventListener} listener
+ * Object which receives a notification (a ``BareEvent``)
+ * when an event of the specified type occurs. This must be
+ * an object implementing the ``EventListener`` interface,
+ * or a JavaScript function.
+ * @param {boolean=} once
+ * Indicates that the ``listener`` should be invoked at
+ * most once after being added. If true, the ``listener``
+ * would automatically be removed when invoked.
+ */
+ addEventListener(type, listener, { once = false } = {}) {
+ if (!(type in this.listeners)) {
+ this.listeners[type] = [];
+ }
+
+ if (!this.listeners[type].includes(listener)) {
+ listener.once = once;
+ this.listeners[type].push(listener);
+ }
+
+ this.mm.sendAsyncMessage("Marionette:DOM:AddEventListener", { type });
+ }
+
+ /**
+ * Removes an event listener.
+ *
+ * @param {string} type
+ * Type of event to cease listening for.
+ * @param {EventListener} listener
+ * Event handler to remove from the event target.
+ */
+ removeEventListener(type, listener) {
+ if (!(type in this.listeners)) {
+ return;
+ }
+
+ let stack = this.listeners[type];
+ for (let i = stack.length - 1; i >= 0; --i) {
+ if (stack[i] === listener) {
+ stack.splice(i, 1);
+ if (!stack.length) {
+ this.mm.sendAsyncMessage("Marionette:DOM:RemoveEventListener", {
+ type,
+ });
+ }
+ return;
+ }
+ }
+ }
+
+ dispatchEvent(event) {
+ if (!(event.type in this.listeners)) {
+ return;
+ }
+
+ event.target = this;
+
+ let stack = this.listeners[event.type].slice(0);
+ stack.forEach(listener => {
+ if (typeof listener.handleEvent == "function") {
+ listener.handleEvent(event);
+ } else {
+ listener(event);
+ }
+
+ if (listener.once) {
+ this.removeEventListener(event.type, listener);
+ }
+ });
+ }
+
+ receiveMessage({ name, data }) {
+ if (name != "Marionette:DOM:OnEvent") {
+ return;
+ }
+
+ let ev = {
+ type: data.type,
+ };
+ this.dispatchEvent(ev);
+ }
+}
+
+/**
+ * Provides the frame script backend for the
+ * :js:class:`WebElementEventTarget`.
+ *
+ * This service receives requests for new DOM events to listen for and
+ * to cease listening for, and despatches IPC messages to the browser
+ * when they fire.
+ */
+export class ContentEventObserverService {
+ /**
+ * @param {WindowProxy} windowGlobal
+ * Window.
+ * @param {nsIMessageSender.sendAsyncMessage} sendAsyncMessage
+ * Function for sending an async message to the parent browser.
+ */
+ constructor(windowGlobal, sendAsyncMessage) {
+ this.window = windowGlobal;
+ this.sendAsyncMessage = sendAsyncMessage;
+ this.events = new Set();
+ }
+
+ /**
+ * Observe a new DOM event.
+ *
+ * When the DOM event of ``type`` fires, a message is passed to
+ * the parent browser's event observer.
+ *
+ * If event type is already being observed, only a single message
+ * is sent. E.g. multiple registration for events will only ever emit
+ * a maximum of one message.
+ *
+ * @param {string} type
+ * DOM event to listen for.
+ */
+ add(type) {
+ if (this.events.has(type)) {
+ return;
+ }
+ this.window.addEventListener(type, this);
+ this.events.add(type);
+ }
+
+ /**
+ * Ceases observing a DOM event.
+ *
+ * @param {string} type
+ * DOM event to stop listening for.
+ */
+ remove(type) {
+ if (!this.events.has(type)) {
+ return;
+ }
+ this.window.removeEventListener(type, this);
+ this.events.delete(type);
+ }
+
+ /** Ceases observing all previously registered DOM events. */
+ clear() {
+ for (let ev of this) {
+ this.remove(ev);
+ }
+ }
+
+ *[Symbol.iterator]() {
+ for (let ev of this.events) {
+ yield ev;
+ }
+ }
+
+ handleEvent({ type, target }) {
+ lazy.logger.trace(`Received DOM event ${type}`);
+ this.sendAsyncMessage("Marionette:DOM:OnEvent", { type });
+ }
+}
diff --git a/remote/marionette/driver.sys.mjs b/remote/marionette/driver.sys.mjs
new file mode 100644
index 0000000000..1306d18569
--- /dev/null
+++ b/remote/marionette/driver.sys.mjs
@@ -0,0 +1,3242 @@
+/* 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";
+
+import {
+ element,
+ WebReference,
+} from "chrome://remote/content/marionette/element.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ Addon: "chrome://remote/content/marionette/addon.sys.mjs",
+ AppInfo: "chrome://remote/content/shared/AppInfo.sys.mjs",
+ assert: "chrome://remote/content/shared/webdriver/Assert.sys.mjs",
+ atom: "chrome://remote/content/marionette/atom.sys.mjs",
+ browser: "chrome://remote/content/marionette/browser.sys.mjs",
+ capture: "chrome://remote/content/shared/Capture.sys.mjs",
+ Context: "chrome://remote/content/marionette/browser.sys.mjs",
+ cookie: "chrome://remote/content/marionette/cookie.sys.mjs",
+ DebounceCallback: "chrome://remote/content/marionette/sync.sys.mjs",
+ disableEventsActor:
+ "chrome://remote/content/marionette/actors/MarionetteEventsParent.sys.mjs",
+ enableEventsActor:
+ "chrome://remote/content/marionette/actors/MarionetteEventsParent.sys.mjs",
+ error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs",
+ EventPromise: "chrome://remote/content/shared/Sync.sys.mjs",
+ getMarionetteCommandsActorProxy:
+ "chrome://remote/content/marionette/actors/MarionetteCommandsParent.sys.mjs",
+ IdlePromise: "chrome://remote/content/marionette/sync.sys.mjs",
+ l10n: "chrome://remote/content/marionette/l10n.sys.mjs",
+ Log: "chrome://remote/content/shared/Log.sys.mjs",
+ Marionette: "chrome://remote/content/components/Marionette.sys.mjs",
+ MarionettePrefs: "chrome://remote/content/marionette/prefs.sys.mjs",
+ modal: "chrome://remote/content/marionette/modal.sys.mjs",
+ navigate: "chrome://remote/content/marionette/navigate.sys.mjs",
+ permissions: "chrome://remote/content/marionette/permissions.sys.mjs",
+ pprint: "chrome://remote/content/shared/Format.sys.mjs",
+ print: "chrome://remote/content/shared/PDF.sys.mjs",
+ reftest: "chrome://remote/content/marionette/reftest.sys.mjs",
+ registerCommandsActor:
+ "chrome://remote/content/marionette/actors/MarionetteCommandsParent.sys.mjs",
+ RemoteAgent: "chrome://remote/content/components/RemoteAgent.sys.mjs",
+ TabManager: "chrome://remote/content/shared/TabManager.sys.mjs",
+ TimedPromise: "chrome://remote/content/marionette/sync.sys.mjs",
+ Timeouts: "chrome://remote/content/shared/webdriver/Capabilities.sys.mjs",
+ UnhandledPromptBehavior:
+ "chrome://remote/content/shared/webdriver/Capabilities.sys.mjs",
+ unregisterCommandsActor:
+ "chrome://remote/content/marionette/actors/MarionetteCommandsParent.sys.mjs",
+ waitForInitialNavigationCompleted:
+ "chrome://remote/content/shared/Navigate.sys.mjs",
+ waitForObserverTopic: "chrome://remote/content/marionette/sync.sys.mjs",
+ WebDriverSession: "chrome://remote/content/shared/webdriver/Session.sys.mjs",
+ windowManager: "chrome://remote/content/shared/WindowManager.sys.mjs",
+ WindowState: "chrome://remote/content/marionette/browser.sys.mjs",
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "logger", () =>
+ lazy.Log.get(lazy.Log.TYPES.MARIONETTE)
+);
+
+const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
+
+const SUPPORTED_STRATEGIES = new Set([
+ element.Strategy.ClassName,
+ element.Strategy.Selector,
+ element.Strategy.ID,
+ element.Strategy.Name,
+ element.Strategy.LinkText,
+ element.Strategy.PartialLinkText,
+ element.Strategy.TagName,
+ element.Strategy.XPath,
+]);
+
+// Timeout used to abort fullscreen, maximize, and minimize
+// commands if no window manager is present.
+const TIMEOUT_NO_WINDOW_MANAGER = 5000;
+
+// Observer topic to wait for until the browser window is ready.
+const TOPIC_BROWSER_READY = "browser-delayed-startup-finished";
+
+/**
+ * The Marionette WebDriver services provides a standard conforming
+ * implementation of the W3C WebDriver specification.
+ *
+ * @see {@link https://w3c.github.io/webdriver/webdriver-spec.html}
+ * @namespace driver
+ */
+
+/**
+ * Implements (parts of) the W3C WebDriver protocol. GeckoDriver lives
+ * in chrome space and mediates calls to the current browsing context's actor.
+ *
+ * Throughout this prototype, functions with the argument <var>cmd</var>'s
+ * documentation refers to the contents of the <code>cmd.parameter</code>
+ * object.
+ *
+ * @class GeckoDriver
+ *
+ * @param {MarionetteServer} server
+ * The instance of Marionette server.
+ */
+export function GeckoDriver(server) {
+ this._server = server;
+
+ // WebDriver Session
+ this._currentSession = null;
+
+ this.browsers = {};
+
+ // points to current browser
+ this.curBrowser = null;
+ // top-most chrome window
+ this.mainFrame = null;
+
+ // Use content context by default
+ this.context = lazy.Context.Content;
+
+ // used for modal dialogs or tab modal alerts
+ this.dialog = null;
+ this.dialogObserver = null;
+}
+
+/**
+ * The current context decides if commands are executed in chrome- or
+ * content space.
+ */
+Object.defineProperty(GeckoDriver.prototype, "context", {
+ get() {
+ return this._context;
+ },
+
+ set(context) {
+ this._context = lazy.Context.fromString(context);
+ },
+});
+
+/**
+ * The current WebDriver Session.
+ */
+Object.defineProperty(GeckoDriver.prototype, "currentSession", {
+ get() {
+ if (lazy.RemoteAgent.webDriverBiDi) {
+ return lazy.RemoteAgent.webDriverBiDi.session;
+ }
+
+ return this._currentSession;
+ },
+});
+
+/**
+ * Returns the current URL of the ChromeWindow or content browser,
+ * depending on context.
+ *
+ * @return {URL}
+ * Read-only property containing the currently loaded URL.
+ */
+Object.defineProperty(GeckoDriver.prototype, "currentURL", {
+ get() {
+ const browsingContext = this.getBrowsingContext({ top: true });
+ return new URL(browsingContext.currentWindowGlobal.documentURI.spec);
+ },
+});
+
+/**
+ * Returns the title of the ChromeWindow or content browser,
+ * depending on context.
+ *
+ * @return {string}
+ * Read-only property containing the title of the loaded URL.
+ */
+Object.defineProperty(GeckoDriver.prototype, "title", {
+ get() {
+ const browsingContext = this.getBrowsingContext({ top: true });
+ return browsingContext.currentWindowGlobal.documentTitle;
+ },
+});
+
+Object.defineProperty(GeckoDriver.prototype, "windowType", {
+ get() {
+ return this.curBrowser.window.document.documentElement.getAttribute(
+ "windowtype"
+ );
+ },
+});
+
+GeckoDriver.prototype.QueryInterface = ChromeUtils.generateQI([
+ "nsIObserver",
+ "nsISupportsWeakReference",
+]);
+
+/**
+ * Callback used to observe the creation of new modal or tab modal dialogs
+ * during the session's lifetime.
+ */
+GeckoDriver.prototype.handleModalDialog = function(action, dialog) {
+ if (!this.currentSession) {
+ return;
+ }
+
+ if (action === lazy.modal.ACTION_OPENED) {
+ this.dialog = new lazy.modal.Dialog(() => this.curBrowser, dialog);
+ this.getActor().notifyDialogOpened();
+ } else if (action === lazy.modal.ACTION_CLOSED) {
+ this.dialog = null;
+ }
+};
+
+/**
+ * Get the current visible URL.
+ */
+GeckoDriver.prototype._getCurrentURL = function() {
+ const browsingContext = this.getBrowsingContext({ top: true });
+ return new URL(browsingContext.currentURI.spec);
+};
+
+/**
+ * Get the current "MarionetteCommands" parent actor.
+ *
+ * @param {Object} options
+ * @param {boolean=} options.top
+ * If set to true use the window's top-level browsing context for the actor,
+ * otherwise the one from the currently selected frame. Defaults to false.
+ *
+ * @returns {MarionetteCommandsParent}
+ * The parent actor.
+ */
+GeckoDriver.prototype.getActor = function(options = {}) {
+ return lazy.getMarionetteCommandsActorProxy(() =>
+ this.getBrowsingContext(options)
+ );
+};
+
+/**
+ * Get the selected BrowsingContext for the current context.
+ *
+ * @param {Object} options
+ * @param {Context=} options.context
+ * Context (content or chrome) for which to retrieve the browsing context.
+ * Defaults to the current one.
+ * @param {boolean=} options.parent
+ * If set to true return the window's parent browsing context,
+ * otherwise the one from the currently selected frame. Defaults to false.
+ * @param {boolean=} options.top
+ * If set to true return the window's top-level browsing context,
+ * otherwise the one from the currently selected frame. Defaults to false.
+ *
+ * @return {BrowsingContext}
+ * The browsing context, or `null` if none is available
+ */
+GeckoDriver.prototype.getBrowsingContext = function(options = {}) {
+ const { context = this.context, parent = false, top = false } = options;
+
+ let browsingContext = null;
+ if (context === lazy.Context.Chrome) {
+ browsingContext = this.currentSession?.chromeBrowsingContext;
+ } else {
+ browsingContext = this.currentSession?.contentBrowsingContext;
+ }
+
+ if (browsingContext && parent) {
+ browsingContext = browsingContext.parent;
+ }
+
+ if (browsingContext && top) {
+ browsingContext = browsingContext.top;
+ }
+
+ return browsingContext;
+};
+
+/**
+ * Get the currently selected window.
+ *
+ * It will return the outer {@link ChromeWindow} previously selected by
+ * window handle through {@link #switchToWindow}, or the first window that
+ * was registered.
+ *
+ * @param {Object} options
+ * @param {Context=} options.context
+ * Optional name of the context to use for finding the window.
+ * It will be required if a command always needs a specific context,
+ * whether which context is currently set. Defaults to the current
+ * context.
+ *
+ * @return {ChromeWindow}
+ * The current top-level browsing context.
+ */
+GeckoDriver.prototype.getCurrentWindow = function(options = {}) {
+ const { context = this.context } = options;
+
+ let win = null;
+ switch (context) {
+ case lazy.Context.Chrome:
+ if (this.curBrowser) {
+ win = this.curBrowser.window;
+ }
+ break;
+
+ case lazy.Context.Content:
+ if (this.curBrowser && this.curBrowser.contentBrowser) {
+ win = this.curBrowser.window;
+ }
+ break;
+ }
+
+ return win;
+};
+
+GeckoDriver.prototype.isReftestBrowser = function(element) {
+ return (
+ this._reftest &&
+ element &&
+ element.tagName === "xul:browser" &&
+ element.parentElement &&
+ element.parentElement.id === "reftest"
+ );
+};
+
+/**
+ * Create a new browsing context for window and add to known browsers.
+ *
+ * @param {ChromeWindow} win
+ * Window for which we will create a browsing context.
+ *
+ * @return {string}
+ * Returns the unique server-assigned ID of the window.
+ */
+GeckoDriver.prototype.addBrowser = function(win) {
+ let context = new lazy.browser.Context(win, this);
+ let winId = lazy.windowManager.getIdForWindow(win);
+
+ this.browsers[winId] = context;
+ this.curBrowser = this.browsers[winId];
+};
+
+/**
+ * Recursively get all labeled text.
+ *
+ * @param {Element} el
+ * The parent element.
+ * @param {Array.<string>} lines
+ * Array that holds the text lines.
+ */
+GeckoDriver.prototype.getVisibleText = function(el, lines) {
+ try {
+ if (lazy.atom.isElementDisplayed(el, this.getCurrentWindow())) {
+ if (el.value) {
+ lines.push(el.value);
+ }
+ for (let child in el.childNodes) {
+ this.getVisibleText(el.childNodes[child], lines);
+ }
+ }
+ } catch (e) {
+ if (el.nodeName == "#text") {
+ lines.push(el.textContent);
+ }
+ }
+};
+
+/**
+ * Handles registration of new content browsers. Depending on
+ * their type they are either accepted or ignored.
+ *
+ * @param {xul:browser} browserElement
+ */
+GeckoDriver.prototype.registerBrowser = function(browserElement) {
+ // We want to ignore frames that are XUL browsers that aren't in the "main"
+ // tabbrowser, but accept things on Fennec (which doesn't have a
+ // xul:tabbrowser), and accept HTML iframes (because tests depend on it),
+ // as well as XUL frames. Ideally this should be cleaned up and we should
+ // keep track of browsers a different way.
+ if (
+ !lazy.AppInfo.isFirefox ||
+ browserElement.namespaceURI != XUL_NS ||
+ browserElement.nodeName != "browser" ||
+ browserElement.getTabBrowser()
+ ) {
+ this.curBrowser.register(browserElement);
+ }
+};
+
+/**
+ * Create a new WebDriver session.
+ *
+ * @param {Object} cmd
+ * @param {Object.<string, *>=} cmd.parameters
+ * JSON Object containing any of the recognised capabilities as listed
+ * on the `WebDriverSession` class.
+ *
+ * @return {Object}
+ * Session ID and capabilities offered by the WebDriver service.
+ *
+ * @throws {SessionNotCreatedError}
+ * If, for whatever reason, a session could not be created.
+ */
+GeckoDriver.prototype.newSession = async function(cmd) {
+ if (this.currentSession) {
+ throw new lazy.error.SessionNotCreatedError(
+ "Maximum number of active sessions"
+ );
+ }
+
+ const { parameters: capabilities } = cmd;
+
+ try {
+ // If the WebDriver BiDi protocol is active always use the Remote Agent
+ // to handle the WebDriver session. If it's not the case then Marionette
+ // itself needs to handle it, and has to nullify the "webSocketUrl"
+ // capability.
+ if (lazy.RemoteAgent.webDriverBiDi) {
+ await lazy.RemoteAgent.webDriverBiDi.createSession(capabilities);
+ } else {
+ this._currentSession = new lazy.WebDriverSession(capabilities);
+ this._currentSession.capabilities.delete("webSocketUrl");
+ }
+
+ // Don't wait for the initial window when Marionette is in windowless mode
+ if (!this.currentSession.capabilities.get("moz:windowless")) {
+ // Creating a WebDriver session too early can cause issues with
+ // clients in not being able to find any available window handle.
+ // Also when closing the application while it's still starting up can
+ // cause shutdown hangs. As such Marionette will return a new session
+ // once the initial application window has finished initializing.
+ lazy.logger.debug(`Waiting for initial application window`);
+ await lazy.Marionette.browserStartupFinished;
+
+ const appWin = await lazy.windowManager.waitForInitialApplicationWindowLoaded();
+
+ if (lazy.MarionettePrefs.clickToStart) {
+ Services.prompt.alert(
+ appWin,
+ "",
+ "Click to start execution of marionette tests"
+ );
+ }
+
+ this.addBrowser(appWin);
+ this.mainFrame = appWin;
+
+ // Setup observer for modal dialogs
+ this.dialogObserver = new lazy.modal.DialogObserver(
+ () => this.curBrowser
+ );
+ this.dialogObserver.add(this.handleModalDialog.bind(this));
+
+ for (let win of lazy.windowManager.windows) {
+ this.registerWindow(win, { registerBrowsers: true });
+ }
+
+ if (this.mainFrame) {
+ this.currentSession.chromeBrowsingContext = this.mainFrame.browsingContext;
+ this.mainFrame.focus();
+ }
+
+ if (this.curBrowser.tab) {
+ const browsingContext = this.curBrowser.contentBrowser.browsingContext;
+ this.currentSession.contentBrowsingContext = browsingContext;
+
+ await lazy.waitForInitialNavigationCompleted(
+ browsingContext.webProgress
+ );
+
+ this.curBrowser.contentBrowser.focus();
+ }
+
+ // Check if there is already an open dialog for the selected browser window.
+ this.dialog = lazy.modal.findModalDialogs(this.curBrowser);
+ }
+
+ lazy.registerCommandsActor();
+ lazy.enableEventsActor();
+
+ Services.obs.addObserver(this, TOPIC_BROWSER_READY);
+ } catch (e) {
+ throw new lazy.error.SessionNotCreatedError(e);
+ }
+
+ return {
+ sessionId: this.currentSession.id,
+ capabilities: this.currentSession.capabilities,
+ };
+};
+
+/**
+ * Start observing the specified window.
+ *
+ * @param {ChromeWindow} win
+ * Chrome window to register event listeners for.
+ * @param {Object=} options
+ * @param {boolean=} options.registerBrowsers
+ * If true, register all content browsers of found tabs. Defaults to false.
+ */
+GeckoDriver.prototype.registerWindow = function(win, options = {}) {
+ const { registerBrowsers = false } = options;
+ const tabBrowser = lazy.TabManager.getTabBrowser(win);
+
+ if (registerBrowsers && tabBrowser) {
+ for (const tab of tabBrowser.tabs) {
+ const contentBrowser = lazy.TabManager.getBrowserForTab(tab);
+ this.registerBrowser(contentBrowser);
+ }
+ }
+
+ // Listen for any kind of top-level process switch
+ tabBrowser?.addEventListener("XULFrameLoaderCreated", this);
+};
+
+/**
+ * Stop observing the specified window.
+ *
+ * @param {ChromeWindow} win
+ * Chrome window to unregister event listeners for.
+ */
+GeckoDriver.prototype.stopObservingWindow = function(win) {
+ const tabBrowser = lazy.TabManager.getTabBrowser(win);
+
+ tabBrowser?.removeEventListener("XULFrameLoaderCreated", this);
+};
+
+GeckoDriver.prototype.handleEvent = function({ target, type }) {
+ switch (type) {
+ case "XULFrameLoaderCreated":
+ if (target === this.curBrowser.contentBrowser) {
+ lazy.logger.trace(
+ "Remoteness change detected. Set new top-level browsing context " +
+ `to ${target.browsingContext.id}`
+ );
+
+ this.currentSession.contentBrowsingContext = target.browsingContext;
+ }
+ break;
+ }
+};
+
+GeckoDriver.prototype.observe = function(subject, topic, data) {
+ switch (topic) {
+ case TOPIC_BROWSER_READY:
+ this.registerWindow(subject);
+ break;
+ }
+};
+
+/**
+ * Send the current session's capabilities to the client.
+ *
+ * Capabilities informs the client of which WebDriver features are
+ * supported by Firefox and Marionette. They are immutable for the
+ * length of the session.
+ *
+ * The return value is an immutable map of string keys
+ * ("capabilities") to values, which may be of types boolean,
+ * numerical or string.
+ */
+GeckoDriver.prototype.getSessionCapabilities = function() {
+ return { capabilities: this.currentSession.capabilities };
+};
+
+/**
+ * Sets the context of the subsequent commands.
+ *
+ * All subsequent requests to commands that in some way involve
+ * interaction with a browsing context will target the chosen browsing
+ * context.
+ *
+ * @param {string} value
+ * Name of the context to be switched to. Must be one of "chrome" or
+ * "content".
+ *
+ * @throws {InvalidArgumentError}
+ * If <var>value</var> is not a string.
+ * @throws {WebDriverError}
+ * If <var>value</var> is not a valid browsing context.
+ */
+GeckoDriver.prototype.setContext = function(cmd) {
+ let value = lazy.assert.string(cmd.parameters.value);
+
+ this.context = value;
+};
+
+/**
+ * Gets the context type that is Marionette's current target for
+ * browsing context scoped commands.
+ *
+ * You may choose a context through the {@link #setContext} command.
+ *
+ * The default browsing context is {@link Context.Content}.
+ *
+ * @return {Context}
+ * Current context.
+ */
+GeckoDriver.prototype.getContext = function() {
+ return this.context;
+};
+
+/**
+ * Executes a JavaScript function in the context of the current browsing
+ * context, if in content space, or in chrome space otherwise, and returns
+ * the return value of the function.
+ *
+ * It is important to note that if the <var>sandboxName</var> parameter
+ * is left undefined, the script will be evaluated in a mutable sandbox,
+ * causing any change it makes on the global state of the document to have
+ * lasting side-effects.
+ *
+ * @param {string} script
+ * Script to evaluate as a function body.
+ * @param {Array.<(string|boolean|number|object|WebReference)>} args
+ * Arguments exposed to the script in <code>arguments</code>.
+ * The array items must be serialisable to the WebDriver protocol.
+ * @param {string=} sandbox
+ * Name of the sandbox to evaluate the script in. The sandbox is
+ * cached for later re-use on the same Window object if
+ * <var>newSandbox</var> is false. If he parameter is undefined,
+ * the script is evaluated in a mutable sandbox. If the parameter
+ * is "system", it will be evaluted in a sandbox with elevated system
+ * privileges, equivalent to chrome space.
+ * @param {boolean=} newSandbox
+ * Forces the script to be evaluated in a fresh sandbox. Note that if
+ * it is undefined, the script will normally be evaluted in a fresh
+ * sandbox.
+ * @param {string=} filename
+ * Filename of the client's program where this script is evaluated.
+ * @param {number=} line
+ * Line in the client's program where this script is evaluated.
+ *
+ * @return {(string|boolean|number|object|WebReference)}
+ * Return value from the script, or null which signifies either the
+ * JavaScript notion of null or undefined.
+ *
+ * @throws {JavaScriptError}
+ * If an {@link Error} was thrown whilst evaluating the script.
+ * @throws {NoSuchElementError}
+ * If an element that was passed as part of <var>args</var> is unknown.
+ * @throws {NoSuchWindowError}
+ * Browsing context has been discarded.
+ * @throws {ScriptTimeoutError}
+ * If the script was interrupted due to reaching the session's
+ * script timeout.
+ * @throws {StaleElementReferenceError}
+ * If an element that was passed as part of <var>args</var> or that is
+ * returned as result has gone stale.
+ */
+GeckoDriver.prototype.executeScript = async function(cmd) {
+ let { script, args } = cmd.parameters;
+ let opts = {
+ script: cmd.parameters.script,
+ args: cmd.parameters.args,
+ sandboxName: cmd.parameters.sandbox,
+ newSandbox: cmd.parameters.newSandbox,
+ file: cmd.parameters.filename,
+ line: cmd.parameters.line,
+ };
+
+ return { value: await this.execute_(script, args, opts) };
+};
+
+/**
+ * Executes a JavaScript function in the context of the current browsing
+ * context, if in content space, or in chrome space otherwise, and returns
+ * the object passed to the callback.
+ *
+ * The callback is always the last argument to the <var>arguments</var>
+ * list passed to the function scope of the script. It can be retrieved
+ * as such:
+ *
+ * <pre><code>
+ * let callback = arguments[arguments.length - 1];
+ * callback("foo");
+ * // "foo" is returned
+ * </code></pre>
+ *
+ * It is important to note that if the <var>sandboxName</var> parameter
+ * is left undefined, the script will be evaluated in a mutable sandbox,
+ * causing any change it makes on the global state of the document to have
+ * lasting side-effects.
+ *
+ * @param {string} script
+ * Script to evaluate as a function body.
+ * @param {Array.<(string|boolean|number|object|WebReference)>} args
+ * Arguments exposed to the script in <code>arguments</code>.
+ * The array items must be serialisable to the WebDriver protocol.
+ * @param {string=} sandbox
+ * Name of the sandbox to evaluate the script in. The sandbox is
+ * cached for later re-use on the same Window object if
+ * <var>newSandbox</var> is false. If the parameter is undefined,
+ * the script is evaluated in a mutable sandbox. If the parameter
+ * is "system", it will be evaluted in a sandbox with elevated system
+ * privileges, equivalent to chrome space.
+ * @param {boolean=} newSandbox
+ * Forces the script to be evaluated in a fresh sandbox. Note that if
+ * it is undefined, the script will normally be evaluted in a fresh
+ * sandbox.
+ * @param {string=} filename
+ * Filename of the client's program where this script is evaluated.
+ * @param {number=} line
+ * Line in the client's program where this script is evaluated.
+ *
+ * @return {(string|boolean|number|object|WebReference)}
+ * Return value from the script, or null which signifies either the
+ * JavaScript notion of null or undefined.
+ *
+ * @throws {JavaScriptError}
+ * If an Error was thrown whilst evaluating the script.
+ * @throws {NoSuchElementError}
+ * If an element that was passed as part of <var>args</var> is unknown.
+ * @throws {NoSuchWindowError}
+ * Browsing context has been discarded.
+ * @throws {ScriptTimeoutError}
+ * If the script was interrupted due to reaching the session's
+ * script timeout.
+ * @throws {StaleElementReferenceError}
+ * If an element that was passed as part of <var>args</var> or that is
+ * returned as result has gone stale.
+ */
+GeckoDriver.prototype.executeAsyncScript = async function(cmd) {
+ let { script, args } = cmd.parameters;
+ let opts = {
+ script: cmd.parameters.script,
+ args: cmd.parameters.args,
+ sandboxName: cmd.parameters.sandbox,
+ newSandbox: cmd.parameters.newSandbox,
+ file: cmd.parameters.filename,
+ line: cmd.parameters.line,
+ async: true,
+ };
+
+ return { value: await this.execute_(script, args, opts) };
+};
+
+GeckoDriver.prototype.execute_ = async function(
+ script,
+ args = [],
+ {
+ sandboxName = null,
+ newSandbox = false,
+ file = "",
+ line = 0,
+ async = false,
+ } = {}
+) {
+ lazy.assert.open(this.getBrowsingContext());
+ await this._handleUserPrompts();
+
+ lazy.assert.string(
+ script,
+ lazy.pprint`Expected "script" to be a string: ${script}`
+ );
+ lazy.assert.array(
+ args,
+ lazy.pprint`Expected script args to be an array: ${args}`
+ );
+ if (sandboxName !== null) {
+ lazy.assert.string(
+ sandboxName,
+ lazy.pprint`Expected sandbox name to be a string: ${sandboxName}`
+ );
+ }
+ lazy.assert.boolean(
+ newSandbox,
+ lazy.pprint`Expected newSandbox to be boolean: ${newSandbox}`
+ );
+ lazy.assert.string(file, lazy.pprint`Expected file to be a string: ${file}`);
+ lazy.assert.number(line, lazy.pprint`Expected line to be a number: ${line}`);
+
+ let opts = {
+ timeout: this.currentSession.timeouts.script,
+ sandboxName,
+ newSandbox,
+ file,
+ line,
+ async,
+ };
+
+ return this.getActor().executeScript(script, args, opts);
+};
+
+/**
+ * Navigate to given URL.
+ *
+ * Navigates the current browsing context to the given URL and waits for
+ * the document to load or the session's page timeout duration to elapse
+ * before returning.
+ *
+ * The command will return with a failure if there is an error loading
+ * the document or the URL is blocked. This can occur if it fails to
+ * reach host, the URL is malformed, or if there is a certificate issue
+ * to name some examples.
+ *
+ * The document is considered successfully loaded when the
+ * DOMContentLoaded event on the frame element associated with the
+ * current window triggers and document.readyState is "complete".
+ *
+ * In chrome context it will change the current window's location to
+ * the supplied URL and wait until document.readyState equals "complete"
+ * or the page timeout duration has elapsed.
+ *
+ * @param {string} url
+ * URL to navigate to.
+ *
+ * @throws {NoSuchWindowError}
+ * Top-level browsing context has been discarded.
+ * @throws {UnexpectedAlertOpenError}
+ * A modal dialog is open, blocking this operation.
+ * @throws {UnsupportedOperationError}
+ * Not available in current context.
+ */
+GeckoDriver.prototype.navigateTo = async function(cmd) {
+ lazy.assert.content(this.context);
+ const browsingContext = lazy.assert.open(
+ this.getBrowsingContext({ top: true })
+ );
+ await this._handleUserPrompts();
+
+ let validURL;
+ try {
+ validURL = new URL(cmd.parameters.url);
+ } catch (e) {
+ throw new lazy.error.InvalidArgumentError(`Malformed URL: ${e.message}`);
+ }
+
+ // Switch to the top-level browsing context before navigating
+ this.currentSession.contentBrowsingContext = browsingContext;
+
+ const loadEventExpected = lazy.navigate.isLoadEventExpected(
+ this._getCurrentURL(),
+ {
+ future: validURL,
+ }
+ );
+
+ await lazy.navigate.waitForNavigationCompleted(
+ this,
+ () => {
+ lazy.navigate.navigateTo(browsingContext, validURL);
+ },
+ { loadEventExpected }
+ );
+
+ this.curBrowser.contentBrowser.focus();
+};
+
+/**
+ * Get a string representing the current URL.
+ *
+ * On Desktop this returns a string representation of the URL of the
+ * current top level browsing context. This is equivalent to
+ * document.location.href.
+ *
+ * When in the context of the chrome, this returns the canonical URL
+ * of the current resource.
+ *
+ * @throws {NoSuchWindowError}
+ * Top-level browsing context has been discarded.
+ * @throws {UnexpectedAlertOpenError}
+ * A modal dialog is open, blocking this operation.
+ */
+GeckoDriver.prototype.getCurrentUrl = async function() {
+ lazy.assert.open(this.getBrowsingContext({ top: true }));
+ await this._handleUserPrompts();
+
+ return this._getCurrentURL().href;
+};
+
+/**
+ * Gets the current title of the window.
+ *
+ * @return {string}
+ * Document title of the top-level browsing context.
+ *
+ * @throws {NoSuchWindowError}
+ * Top-level browsing context has been discarded.
+ * @throws {UnexpectedAlertOpenError}
+ * A modal dialog is open, blocking this operation.
+ */
+GeckoDriver.prototype.getTitle = async function() {
+ lazy.assert.open(this.getBrowsingContext({ top: true }));
+ await this._handleUserPrompts();
+
+ return this.title;
+};
+
+/**
+ * Gets the current type of the window.
+ *
+ * @return {string}
+ * Type of window
+ *
+ * @throws {NoSuchWindowError}
+ * Top-level browsing context has been discarded.
+ */
+GeckoDriver.prototype.getWindowType = function() {
+ lazy.assert.open(this.getBrowsingContext({ top: true }));
+
+ return this.windowType;
+};
+
+/**
+ * Gets the page source of the content document.
+ *
+ * @return {string}
+ * String serialisation of the DOM of the current browsing context's
+ * active document.
+ *
+ * @throws {NoSuchWindowError}
+ * Browsing context has been discarded.
+ * @throws {UnexpectedAlertOpenError}
+ * A modal dialog is open, blocking this operation.
+ */
+GeckoDriver.prototype.getPageSource = async function() {
+ lazy.assert.open(this.getBrowsingContext());
+ await this._handleUserPrompts();
+
+ return this.getActor().getPageSource();
+};
+
+/**
+ * Cause the browser to traverse one step backward in the joint history
+ * of the current browsing context.
+ *
+ * @throws {NoSuchWindowError}
+ * Top-level browsing context has been discarded.
+ * @throws {UnexpectedAlertOpenError}
+ * A modal dialog is open, blocking this operation.
+ * @throws {UnsupportedOperationError}
+ * Not available in current context.
+ */
+GeckoDriver.prototype.goBack = async function() {
+ lazy.assert.content(this.context);
+ const browsingContext = lazy.assert.open(
+ this.getBrowsingContext({ top: true })
+ );
+ await this._handleUserPrompts();
+
+ // If there is no history, just return
+ if (!browsingContext.embedderElement?.canGoBack) {
+ return;
+ }
+
+ await lazy.navigate.waitForNavigationCompleted(this, () => {
+ browsingContext.goBack();
+ });
+};
+
+/**
+ * Cause the browser to traverse one step forward in the joint history
+ * of the current browsing context.
+ *
+ * @throws {NoSuchWindowError}
+ * Top-level browsing context has been discarded.
+ * @throws {UnexpectedAlertOpenError}
+ * A modal dialog is open, blocking this operation.
+ * @throws {UnsupportedOperationError}
+ * Not available in current context.
+ */
+GeckoDriver.prototype.goForward = async function() {
+ lazy.assert.content(this.context);
+ const browsingContext = lazy.assert.open(
+ this.getBrowsingContext({ top: true })
+ );
+ await this._handleUserPrompts();
+
+ // If there is no history, just return
+ if (!browsingContext.embedderElement?.canGoForward) {
+ return;
+ }
+
+ await lazy.navigate.waitForNavigationCompleted(this, () => {
+ browsingContext.goForward();
+ });
+};
+
+/**
+ * Causes the browser to reload the page in current top-level browsing
+ * context.
+ *
+ * @throws {NoSuchWindowError}
+ * Top-level browsing context has been discarded.
+ * @throws {UnexpectedAlertOpenError}
+ * A modal dialog is open, blocking this operation.
+ * @throws {UnsupportedOperationError}
+ * Not available in current context.
+ */
+GeckoDriver.prototype.refresh = async function() {
+ lazy.assert.content(this.context);
+ const browsingContext = lazy.assert.open(
+ this.getBrowsingContext({ top: true })
+ );
+ await this._handleUserPrompts();
+
+ // Switch to the top-level browsing context before navigating
+ this.currentSession.contentBrowsingContext = browsingContext;
+
+ await lazy.navigate.waitForNavigationCompleted(this, () => {
+ lazy.navigate.refresh(browsingContext);
+ });
+};
+
+/**
+ * Get the current window's handle. On desktop this typically corresponds
+ * to the currently selected tab.
+ *
+ * For chrome scope it returns the window identifier for the current chrome
+ * window for tests interested in managing the chrome window and tab separately.
+ *
+ * Return an opaque server-assigned identifier to this window that
+ * uniquely identifies it within this Marionette instance. This can
+ * be used to switch to this window at a later point.
+ *
+ * @return {string}
+ * Unique window handle.
+ *
+ * @throws {NoSuchWindowError}
+ * Top-level browsing context has been discarded.
+ */
+GeckoDriver.prototype.getWindowHandle = function() {
+ lazy.assert.open(this.getBrowsingContext({ top: true }));
+
+ if (this.context == lazy.Context.Chrome) {
+ return lazy.windowManager.getIdForWindow(this.curBrowser.window);
+ }
+ return lazy.TabManager.getIdForBrowser(this.curBrowser.contentBrowser);
+};
+
+/**
+ * Get a list of top-level browsing contexts. On desktop this typically
+ * corresponds to the set of open tabs for browser windows, or the window
+ * itself for non-browser chrome windows.
+ *
+ * For chrome scope it returns identifiers for each open chrome window for
+ * tests interested in managing a set of chrome windows and tabs separately.
+ *
+ * Each window handle is assigned by the server and is guaranteed unique,
+ * however the return array does not have a specified ordering.
+ *
+ * @return {Array.<string>}
+ * Unique window handles.
+ */
+GeckoDriver.prototype.getWindowHandles = function() {
+ if (this.context == lazy.Context.Chrome) {
+ return lazy.windowManager.chromeWindowHandles.map(String);
+ }
+ return lazy.TabManager.allBrowserUniqueIds.map(String);
+};
+
+/**
+ * Get the current position and size of the browser window currently in focus.
+ *
+ * Will return the current browser window size in pixels. Refers to
+ * window outerWidth and outerHeight values, which include scroll bars,
+ * title bars, etc.
+ *
+ * @return {Object.<string, number>}
+ * Object with |x| and |y| coordinates, and |width| and |height|
+ * of browser window.
+ *
+ * @throws {NoSuchWindowError}
+ * Top-level browsing context has been discarded.
+ * @throws {UnexpectedAlertOpenError}
+ * A modal dialog is open, blocking this operation.
+ */
+GeckoDriver.prototype.getWindowRect = async function() {
+ lazy.assert.open(this.getBrowsingContext({ top: true }));
+ await this._handleUserPrompts();
+
+ return this.curBrowser.rect;
+};
+
+/**
+ * Set the window position and size of the browser on the operating
+ * system window manager.
+ *
+ * The supplied `width` and `height` values refer to the window `outerWidth`
+ * and `outerHeight` values, which include browser chrome and OS-level
+ * window borders.
+ *
+ * @param {number} x
+ * X coordinate of the top/left of the window that it will be
+ * moved to.
+ * @param {number} y
+ * Y coordinate of the top/left of the window that it will be
+ * moved to.
+ * @param {number} width
+ * Width to resize the window to.
+ * @param {number} height
+ * Height to resize the window to.
+ *
+ * @return {Object.<string, number>}
+ * Object with `x` and `y` coordinates and `width` and `height`
+ * dimensions.
+ *
+ * @throws {NoSuchWindowError}
+ * Top-level browsing context has been discarded.
+ * @throws {UnexpectedAlertOpenError}
+ * A modal dialog is open, blocking this operation.
+ * @throws {UnsupportedOperationError}
+ * Not applicable to application.
+ */
+GeckoDriver.prototype.setWindowRect = async function(cmd) {
+ lazy.assert.desktop();
+ lazy.assert.open(this.getBrowsingContext({ top: true }));
+ await this._handleUserPrompts();
+
+ const { x = null, y = null, width = null, height = null } = cmd.parameters;
+ if (x !== null) {
+ lazy.assert.integer(x);
+ }
+ if (y !== null) {
+ lazy.assert.integer(y);
+ }
+ if (height !== null) {
+ lazy.assert.positiveInteger(height);
+ }
+ if (width !== null) {
+ lazy.assert.positiveInteger(width);
+ }
+
+ const win = this.getCurrentWindow();
+ switch (lazy.WindowState.from(win.windowState)) {
+ case lazy.WindowState.Fullscreen:
+ await exitFullscreen(win);
+ break;
+
+ case lazy.WindowState.Maximized:
+ case lazy.WindowState.Minimized:
+ await restoreWindow(win);
+ break;
+ }
+
+ function geometryMatches() {
+ if (
+ width !== null &&
+ height !== null &&
+ (win.outerWidth !== width || win.outerHeight !== height)
+ ) {
+ return false;
+ }
+ if (x !== null && y !== null && (win.screenX !== x || win.screenY !== y)) {
+ return false;
+ }
+ lazy.logger.trace(`Requested window geometry matches`);
+ return true;
+ }
+
+ if (!geometryMatches()) {
+ // There might be more than one resize or MozUpdateWindowPos event due
+ // to previous geometry changes, such as from restoreWindow(), so
+ // wait longer if window geometry does not match.
+ const options = { checkFn: geometryMatches, timeout: 500 };
+ const promises = [];
+ if (width !== null && height !== null) {
+ promises.push(new lazy.EventPromise(win, "resize", options));
+ win.resizeTo(width, height);
+ }
+ if (x !== null && y !== null) {
+ promises.push(
+ new lazy.EventPromise(win.windowRoot, "MozUpdateWindowPos", options)
+ );
+ win.moveTo(x, y);
+ }
+ try {
+ await Promise.race(promises);
+ } catch (e) {
+ if (e instanceof lazy.error.TimeoutError) {
+ // The operating system might not honor the move or resize, in which
+ // case assume that geometry will have been adjusted "as close as
+ // possible" to that requested. There may be no event received if the
+ // geometry is already as close as possible.
+ } else {
+ throw e;
+ }
+ }
+ }
+
+ return this.curBrowser.rect;
+};
+
+/**
+ * Switch current top-level browsing context by name or server-assigned
+ * ID. Searches for windows by name, then ID. Content windows take
+ * precedence.
+ *
+ * @param {string} handle
+ * Handle of the window to switch to.
+ * @param {boolean=} focus
+ * A boolean value which determines whether to focus
+ * the window. Defaults to true.
+ *
+ * @throws {InvalidArgumentError}
+ * If <var>handle</var> is not a string or <var>focus</var> not a boolean.
+ * @throws {NoSuchWindowError}
+ * Top-level browsing context has been discarded.
+ */
+GeckoDriver.prototype.switchToWindow = async function(cmd) {
+ const { focus = true, handle } = cmd.parameters;
+
+ lazy.assert.string(
+ handle,
+ lazy.pprint`Expected "handle" to be a string, got ${handle}`
+ );
+ lazy.assert.boolean(
+ focus,
+ lazy.pprint`Expected "focus" to be a boolean, got ${focus}`
+ );
+
+ const found = lazy.windowManager.findWindowByHandle(handle);
+
+ let selected = false;
+ if (found) {
+ try {
+ await this.setWindowHandle(found, focus);
+ selected = true;
+ } catch (e) {
+ lazy.logger.error(e);
+ }
+ }
+
+ if (!selected) {
+ throw new lazy.error.NoSuchWindowError(
+ `Unable to locate window: ${handle}`
+ );
+ }
+};
+
+/**
+ * Switch the marionette window to a given window. If the browser in
+ * the window is unregistered, register that browser and wait for
+ * the registration is complete. If |focus| is true then set the focus
+ * on the window.
+ *
+ * @param {Object} winProperties
+ * Object containing window properties such as returned from
+ * :js:func:`GeckoDriver#getWindowProperties`
+ * @param {boolean=} focus
+ * A boolean value which determines whether to focus the window.
+ * Defaults to true.
+ */
+GeckoDriver.prototype.setWindowHandle = async function(
+ winProperties,
+ focus = true
+) {
+ if (!(winProperties.id in this.browsers)) {
+ // Initialise Marionette if the current chrome window has not been seen
+ // before. Also register the initial tab, if one exists.
+ this.addBrowser(winProperties.win);
+ this.mainFrame = winProperties.win;
+
+ this.currentSession.chromeBrowsingContext = this.mainFrame.browsingContext;
+
+ if (!winProperties.hasTabBrowser) {
+ this.currentSession.contentBrowsingContext = null;
+ } else {
+ const tabBrowser = lazy.TabManager.getTabBrowser(winProperties.win);
+
+ // For chrome windows such as a reftest window, `getTabBrowser` is not
+ // a tabbrowser, it is the content browser which should be used here.
+ const contentBrowser = tabBrowser.tabs
+ ? tabBrowser.selectedBrowser
+ : tabBrowser;
+
+ this.currentSession.contentBrowsingContext =
+ contentBrowser.browsingContext;
+ this.registerBrowser(contentBrowser);
+ }
+ } else {
+ // Otherwise switch to the known chrome window
+ this.curBrowser = this.browsers[winProperties.id];
+ this.mainFrame = this.curBrowser.window;
+
+ // Activate the tab if it's a content window.
+ let tab = null;
+ if (winProperties.hasTabBrowser) {
+ tab = await this.curBrowser.switchToTab(
+ winProperties.tabIndex,
+ winProperties.win,
+ focus
+ );
+ }
+
+ this.currentSession.chromeBrowsingContext = this.mainFrame.browsingContext;
+ this.currentSession.contentBrowsingContext =
+ tab?.linkedBrowser.browsingContext;
+ }
+
+ // Check for existing dialogs for the new window
+ this.dialog = lazy.modal.findModalDialogs(this.curBrowser);
+
+ // If there is an open window modal dialog the underlying chrome window
+ // cannot be focused.
+ if (focus && !this.dialog?.isWindowModal) {
+ await this.curBrowser.focusWindow();
+ }
+};
+
+/**
+ * Set the current browsing context for future commands to the parent
+ * of the current browsing context.
+ *
+ * @throws {NoSuchWindowError}
+ * Browsing context has been discarded.
+ * @throws {UnexpectedAlertOpenError}
+ * A modal dialog is open, blocking this operation.
+ */
+GeckoDriver.prototype.switchToParentFrame = async function() {
+ let browsingContext = this.getBrowsingContext();
+ if (browsingContext && !browsingContext.parent) {
+ return;
+ }
+
+ browsingContext = lazy.assert.open(browsingContext?.parent);
+
+ this.currentSession.contentBrowsingContext = browsingContext;
+};
+
+/**
+ * Switch to a given frame within the current window.
+ *
+ * @param {(string|Object)=} element
+ * A web element reference of the frame or its element id.
+ * @param {number=} id
+ * The index of the frame to switch to.
+ * If both element and id are not defined, switch to top-level frame.
+ *
+ * @throws {NoSuchElementError}
+ * If element represented by reference <var>element</var> is unknown.
+ * @throws {NoSuchWindowError}
+ * Browsing context has been discarded.
+ * @throws {StaleElementReferenceError}
+ * If element represented by reference <var>element</var> has gone stale.
+ * @throws {UnexpectedAlertOpenError}
+ * A modal dialog is open, blocking this operation.
+ */
+GeckoDriver.prototype.switchToFrame = async function(cmd) {
+ const { element: el, id } = cmd.parameters;
+
+ if (typeof id == "number") {
+ lazy.assert.unsignedShort(
+ id,
+ `Expected id to be unsigned short, got ${id}`
+ );
+ }
+
+ const top = id == null && el == null;
+ lazy.assert.open(this.getBrowsingContext({ top }));
+ await this._handleUserPrompts();
+
+ // Bug 1495063: Elements should be passed as WebReference reference
+ let byFrame;
+ if (typeof el == "string") {
+ byFrame = WebReference.fromUUID(el).toJSON();
+ } else if (el) {
+ byFrame = el;
+ }
+
+ const { browsingContext } = await this.getActor({ top }).switchToFrame(
+ byFrame || id
+ );
+
+ this.currentSession.contentBrowsingContext = browsingContext;
+};
+
+GeckoDriver.prototype.getTimeouts = function() {
+ return this.currentSession.timeouts;
+};
+
+/**
+ * Set timeout for page loading, searching, and scripts.
+ *
+ * @param {Object.<string, number>}
+ * Dictionary of timeout types and their new value, where all timeout
+ * types are optional.
+ *
+ * @throws {InvalidArgumentError}
+ * If timeout type key is unknown, or the value provided with it is
+ * not an integer.
+ */
+GeckoDriver.prototype.setTimeouts = function(cmd) {
+ // merge with existing timeouts
+ let merged = Object.assign(
+ this.currentSession.timeouts.toJSON(),
+ cmd.parameters
+ );
+
+ this.currentSession.timeouts = lazy.Timeouts.fromJSON(merged);
+};
+
+/** Single tap. */
+GeckoDriver.prototype.singleTap = async function(cmd) {
+ lazy.assert.open(this.getBrowsingContext());
+
+ let { id, x, y } = cmd.parameters;
+ let webEl = WebReference.fromUUID(id).toJSON();
+
+ await this.getActor().singleTap(
+ webEl,
+ x,
+ y,
+ this.currentSession.capabilities
+ );
+};
+
+/**
+ * Perform a series of grouped actions at the specified points in time.
+ *
+ * @param {Array.<?>} actions
+ * Array of objects that each represent an action sequence.
+ *
+ * @throws {NoSuchElementError}
+ * If an element that is used as part of the action chain is unknown.
+ * @throws {NoSuchWindowError}
+ * Browsing context has been discarded.
+ * @throws {StaleElementReferenceError}
+ * If an element that is used as part of the action chain has gone stale.
+ * @throws {UnexpectedAlertOpenError}
+ * A modal dialog is open, blocking this operation.
+ * @throws {UnsupportedOperationError}
+ * Not yet available in current context.
+ */
+GeckoDriver.prototype.performActions = async function(cmd) {
+ lazy.assert.open(this.getBrowsingContext());
+ await this._handleUserPrompts();
+
+ const actions = cmd.parameters.actions;
+
+ await this.getActor().performActions(
+ actions,
+ this.currentSession.capabilities
+ );
+};
+
+/**
+ * Release all the keys and pointer buttons that are currently depressed.
+ *
+ * @throws {NoSuchWindowError}
+ * Browsing context has been discarded.
+ * @throws {UnexpectedAlertOpenError}
+ * A modal dialog is open, blocking this operation.
+ * @throws {UnsupportedOperationError}
+ * Not available in current context.
+ */
+GeckoDriver.prototype.releaseActions = async function() {
+ lazy.assert.open(this.getBrowsingContext());
+ await this._handleUserPrompts();
+
+ await this.getActor().releaseActions();
+};
+
+/**
+ * Find an element using the indicated search strategy.
+ *
+ * @param {string=} element
+ * Web element reference ID to the element that will be used as start node.
+ * @param {string} using
+ * Indicates which search method to use.
+ * @param {string} value
+ * Value the client is looking for.
+ *
+ * @throws {NoSuchElementError}
+ * If element represented by reference <var>element</var> is unknown.
+ * @throws {NoSuchWindowError}
+ * Browsing context has been discarded.
+ * @throws {StaleElementReferenceError}
+ * If element represented by reference <var>element</var> has gone stale.
+ * @throws {UnexpectedAlertOpenError}
+ * A modal dialog is open, blocking this operation.
+ */
+GeckoDriver.prototype.findElement = async function(cmd) {
+ const { element: el, using, value } = cmd.parameters;
+
+ if (!SUPPORTED_STRATEGIES.has(using)) {
+ throw new lazy.error.InvalidSelectorError(
+ `Strategy not supported: ${using}`
+ );
+ }
+
+ lazy.assert.open(this.getBrowsingContext());
+ await this._handleUserPrompts();
+
+ let startNode;
+ if (typeof el != "undefined") {
+ startNode = WebReference.fromUUID(el).toJSON();
+ }
+
+ let opts = {
+ startNode,
+ timeout: this.currentSession.timeouts.implicit,
+ all: false,
+ };
+
+ return this.getActor().findElement(using, value, opts);
+};
+
+/**
+ * Find elements using the indicated search strategy.
+ *
+ * @param {string=} element
+ * Web element reference ID to the element that will be used as start node.
+ * @param {string} using
+ * Indicates which search method to use.
+ * @param {string} value
+ * Value the client is looking for.
+ *
+ * @throws {NoSuchElementError}
+ * If element represented by reference <var>element</var> is unknown.
+ * @throws {NoSuchWindowError}
+ * Browsing context has been discarded.
+ * @throws {StaleElementReferenceError}
+ * If element represented by reference <var>element</var> has gone stale.
+ * @throws {UnexpectedAlertOpenError}
+ * A modal dialog is open, blocking this operation.
+ */
+GeckoDriver.prototype.findElements = async function(cmd) {
+ const { element: el, using, value } = cmd.parameters;
+
+ if (!SUPPORTED_STRATEGIES.has(using)) {
+ throw new lazy.error.InvalidSelectorError(
+ `Strategy not supported: ${using}`
+ );
+ }
+
+ lazy.assert.open(this.getBrowsingContext());
+ await this._handleUserPrompts();
+
+ let startNode;
+ if (typeof el != "undefined") {
+ startNode = WebReference.fromUUID(el).toJSON();
+ }
+
+ let opts = {
+ startNode,
+ timeout: this.currentSession.timeouts.implicit,
+ all: true,
+ };
+
+ return this.getActor().findElements(using, value, opts);
+};
+
+/**
+ * Return the shadow root of an element in the document.
+ *
+ * @param {id}
+ * A web element id reference.
+ * @return {ShadowRoot}
+ * ShadowRoot of the element.
+ *
+ * @throws {InvalidArgumentError}
+ * If element <var>id</var> is not a string.
+ * @throws {NoSuchElementError}
+ * If element represented by reference <var>id</var> is unknown.
+ * @throws {NoSuchShadowRoot}
+ * Element does not have a shadow root attached.
+ * @throws {NoSuchWindowError}
+ * Browsing context has been discarded.
+ * @throws {StaleElementReferenceError}
+ * If element represented by reference <var>id</var> has gone stale.
+ * @throws {UnexpectedAlertOpenError}
+ * A modal dialog is open, blocking this operation.
+ * @throws {UnsupportedOperationError}
+ * Not available in chrome current context.
+ */
+GeckoDriver.prototype.getShadowRoot = async function(cmd) {
+ // Bug 1743541: Add support for chrome scope.
+ lazy.assert.content(this.context);
+ lazy.assert.open(this.getBrowsingContext());
+ await this._handleUserPrompts();
+
+ let id = lazy.assert.string(
+ cmd.parameters.id,
+ lazy.pprint`Expected "id" to be a string, got ${cmd.parameters.id}`
+ );
+ let webEl = WebReference.fromUUID(id).toJSON();
+
+ return this.getActor().getShadowRoot(webEl);
+};
+
+/**
+ * Return the active element in the document.
+ *
+ * @return {WebReference}
+ * Active element of the current browsing context's document
+ * element, if the document element is non-null.
+ *
+ * @throws {NoSuchElementError}
+ * If the document does not have an active element, i.e. if
+ * its document element has been deleted.
+ * @throws {NoSuchWindowError}
+ * Browsing context has been discarded.
+ * @throws {UnexpectedAlertOpenError}
+ * A modal dialog is open, blocking this operation.
+ * @throws {UnsupportedOperationError}
+ * Not available in chrome context.
+ */
+GeckoDriver.prototype.getActiveElement = async function() {
+ lazy.assert.content(this.context);
+ lazy.assert.open(this.getBrowsingContext());
+ await this._handleUserPrompts();
+
+ return this.getActor().getActiveElement();
+};
+
+/**
+ * Send click event to element.
+ *
+ * @param {string} id
+ * Reference ID to the element that will be clicked.
+ *
+ * @throws {InvalidArgumentError}
+ * If element <var>id</var> is not a string.
+ * @throws {NoSuchElementError}
+ * If element represented by reference <var>id</var> is unknown.
+ * @throws {NoSuchWindowError}
+ * Browsing context has been discarded.
+ * @throws {StaleElementReferenceError}
+ * If element represented by reference <var>id</var> has gone stale.
+ * @throws {UnexpectedAlertOpenError}
+ * A modal dialog is open, blocking this operation.
+ */
+GeckoDriver.prototype.clickElement = async function(cmd) {
+ const browsingContext = lazy.assert.open(this.getBrowsingContext());
+ await this._handleUserPrompts();
+
+ let id = lazy.assert.string(cmd.parameters.id);
+ let webEl = WebReference.fromUUID(id).toJSON();
+
+ const actor = this.getActor();
+
+ const loadEventExpected = lazy.navigate.isLoadEventExpected(
+ this._getCurrentURL(),
+ {
+ browsingContext,
+ target: await actor.getElementAttribute(webEl, "target"),
+ }
+ );
+
+ await lazy.navigate.waitForNavigationCompleted(
+ this,
+ () => actor.clickElement(webEl, this.currentSession.capabilities),
+ {
+ loadEventExpected,
+ // The click might trigger a navigation, so don't count on it.
+ requireBeforeUnload: false,
+ }
+ );
+};
+
+/**
+ * Get a given attribute of an element.
+ *
+ * @param {string} id
+ * Web element reference ID to the element that will be inspected.
+ * @param {string} name
+ * Name of the attribute which value to retrieve.
+ *
+ * @return {string}
+ * Value of the attribute.
+ *
+ * @throws {InvalidArgumentError}
+ * If <var>id</var> or <var>name</var> are not strings.
+ * @throws {NoSuchElementError}
+ * If element represented by reference <var>id</var> is unknown.
+ * @throws {NoSuchWindowError}
+ * Browsing context has been discarded.
+ * @throws {StaleElementReferenceError}
+ * If element represented by reference <var>id</var> has gone stale.
+ * @throws {UnexpectedAlertOpenError}
+ * A modal dialog is open, blocking this operation.
+ */
+GeckoDriver.prototype.getElementAttribute = async function(cmd) {
+ lazy.assert.open(this.getBrowsingContext());
+ await this._handleUserPrompts();
+
+ const id = lazy.assert.string(cmd.parameters.id);
+ const name = lazy.assert.string(cmd.parameters.name);
+ const webEl = WebReference.fromUUID(id).toJSON();
+
+ return this.getActor().getElementAttribute(webEl, name);
+};
+
+/**
+ * Returns the value of a property associated with given element.
+ *
+ * @param {string} id
+ * Web element reference ID to the element that will be inspected.
+ * @param {string} name
+ * Name of the property which value to retrieve.
+ *
+ * @return {string}
+ * Value of the property.
+ *
+ * @throws {InvalidArgumentError}
+ * If <var>id</var> or <var>name</var> are not strings.
+ * @throws {NoSuchElementError}
+ * If element represented by reference <var>id</var> is unknown.
+ * @throws {NoSuchWindowError}
+ * Browsing context has been discarded.
+ * @throws {StaleElementReferenceError}
+ * If element represented by reference <var>id</var> has gone stale.
+ * @throws {UnexpectedAlertOpenError}
+ * A modal dialog is open, blocking this operation.
+ */
+GeckoDriver.prototype.getElementProperty = async function(cmd) {
+ lazy.assert.open(this.getBrowsingContext());
+ await this._handleUserPrompts();
+
+ const id = lazy.assert.string(cmd.parameters.id);
+ const name = lazy.assert.string(cmd.parameters.name);
+ const webEl = WebReference.fromUUID(id).toJSON();
+
+ return this.getActor().getElementProperty(webEl, name);
+};
+
+/**
+ * Get the text of an element, if any. Includes the text of all child
+ * elements.
+ *
+ * @param {string} id
+ * Reference ID to the element that will be inspected.
+ *
+ * @return {string}
+ * Element's text "as rendered".
+ *
+ * @throws {InvalidArgumentError}
+ * If <var>id</var> is not a string.
+ * @throws {NoSuchElementError}
+ * If element represented by reference <var>id</var> is unknown.
+ * @throws {NoSuchWindowError}
+ * Browsing context has been discarded.
+ * @throws {StaleElementReferenceError}
+ * If element represented by reference <var>id</var> has gone stale.
+ * @throws {UnexpectedAlertOpenError}
+ * A modal dialog is open, blocking this operation.
+ */
+GeckoDriver.prototype.getElementText = async function(cmd) {
+ lazy.assert.open(this.getBrowsingContext());
+ await this._handleUserPrompts();
+
+ let id = lazy.assert.string(cmd.parameters.id);
+ let webEl = WebReference.fromUUID(id).toJSON();
+
+ return this.getActor().getElementText(webEl);
+};
+
+/**
+ * Get the tag name of the element.
+ *
+ * @param {string} id
+ * Reference ID to the element that will be inspected.
+ *
+ * @return {string}
+ * Local tag name of element.
+ *
+ * @throws {InvalidArgumentError}
+ * If <var>id</var> is not a string.
+ * @throws {NoSuchElementError}
+ * If element represented by reference <var>id</var> is unknown.
+ * @throws {NoSuchWindowError}
+ * Browsing context has been discarded.
+ * @throws {StaleElementReferenceError}
+ * If element represented by reference <var>id</var> has gone stale.
+ * @throws {UnexpectedAlertOpenError}
+ * A modal dialog is open, blocking this operation.
+ */
+GeckoDriver.prototype.getElementTagName = async function(cmd) {
+ lazy.assert.open(this.getBrowsingContext());
+ await this._handleUserPrompts();
+
+ let id = lazy.assert.string(cmd.parameters.id);
+ let webEl = WebReference.fromUUID(id).toJSON();
+
+ return this.getActor().getElementTagName(webEl);
+};
+
+/**
+ * Check if element is displayed.
+ *
+ * @param {string} id
+ * Reference ID to the element that will be inspected.
+ *
+ * @return {boolean}
+ * True if displayed, false otherwise.
+ *
+ * @throws {InvalidArgumentError}
+ * If <var>id</var> is not a string.
+ * @throws {NoSuchElementError}
+ * If element represented by reference <var>id</var> is unknown.
+ * @throws {NoSuchWindowError}
+ * Browsing context has been discarded.
+ * @throws {UnexpectedAlertOpenError}
+ * A modal dialog is open, blocking this operation.
+ */
+GeckoDriver.prototype.isElementDisplayed = async function(cmd) {
+ lazy.assert.open(this.getBrowsingContext());
+ await this._handleUserPrompts();
+
+ let id = lazy.assert.string(cmd.parameters.id);
+ let webEl = WebReference.fromUUID(id).toJSON();
+
+ return this.getActor().isElementDisplayed(
+ webEl,
+ this.currentSession.capabilities
+ );
+};
+
+/**
+ * Return the property of the computed style of an element.
+ *
+ * @param {string} id
+ * Reference ID to the element that will be checked.
+ * @param {string} propertyName
+ * CSS rule that is being requested.
+ *
+ * @return {string}
+ * Value of |propertyName|.
+ *
+ * @throws {InvalidArgumentError}
+ * If <var>id</var> or <var>propertyName</var> are not strings.
+ * @throws {NoSuchElementError}
+ * If element represented by reference <var>id</var> is unknown.
+ * @throws {NoSuchWindowError}
+ * Browsing context has been discarded.
+ * @throws {StaleElementReferenceError}
+ * If element represented by reference <var>id</var> has gone stale.
+ * @throws {UnexpectedAlertOpenError}
+ * A modal dialog is open, blocking this operation.
+ */
+GeckoDriver.prototype.getElementValueOfCssProperty = async function(cmd) {
+ lazy.assert.open(this.getBrowsingContext());
+ await this._handleUserPrompts();
+
+ let id = lazy.assert.string(cmd.parameters.id);
+ let prop = lazy.assert.string(cmd.parameters.propertyName);
+ let webEl = WebReference.fromUUID(id).toJSON();
+
+ return this.getActor().getElementValueOfCssProperty(webEl, prop);
+};
+
+/**
+ * Check if element is enabled.
+ *
+ * @param {string} id
+ * Reference ID to the element that will be checked.
+ *
+ * @return {boolean}
+ * True if enabled, false if disabled.
+ *
+ * @throws {InvalidArgumentError}
+ * If <var>id</var> is not a string.
+ * @throws {NoSuchElementError}
+ * If element represented by reference <var>id</var> is unknown.
+ * @throws {NoSuchWindowError}
+ * Browsing context has been discarded.
+ * @throws {StaleElementReferenceError}
+ * If element represented by reference <var>id</var> has gone stale.
+ * @throws {UnexpectedAlertOpenError}
+ * A modal dialog is open, blocking this operation.
+ */
+GeckoDriver.prototype.isElementEnabled = async function(cmd) {
+ lazy.assert.open(this.getBrowsingContext());
+ await this._handleUserPrompts();
+
+ let id = lazy.assert.string(cmd.parameters.id);
+ let webEl = WebReference.fromUUID(id).toJSON();
+
+ return this.getActor().isElementEnabled(
+ webEl,
+ this.currentSession.capabilities
+ );
+};
+
+/**
+ * Check if element is selected.
+ *
+ * @param {string} id
+ * Reference ID to the element that will be checked.
+ *
+ * @return {boolean}
+ * True if selected, false if unselected.
+ *
+ * @throws {InvalidArgumentError}
+ * If <var>id</var> is not a string.
+ * @throws {NoSuchElementError}
+ * If element represented by reference <var>id</var> is unknown.
+ * @throws {NoSuchWindowError}
+ * Browsing context has been discarded.
+ * @throws {UnexpectedAlertOpenError}
+ * A modal dialog is open, blocking this operation.
+ */
+GeckoDriver.prototype.isElementSelected = async function(cmd) {
+ lazy.assert.open(this.getBrowsingContext());
+ await this._handleUserPrompts();
+
+ let id = lazy.assert.string(cmd.parameters.id);
+ let webEl = WebReference.fromUUID(id).toJSON();
+
+ return this.getActor().isElementSelected(
+ webEl,
+ this.currentSession.capabilities
+ );
+};
+
+/**
+ * @throws {InvalidArgumentError}
+ * If <var>id</var> is not a string.
+ * @throws {NoSuchElementError}
+ * If element represented by reference <var>id</var> is unknown.
+ * @throws {NoSuchWindowError}
+ * Browsing context has been discarded.
+ * @throws {StaleElementReferenceError}
+ * If element represented by reference <var>id</var> has gone stale.
+ * @throws {UnexpectedAlertOpenError}
+ * A modal dialog is open, blocking this operation.
+ */
+GeckoDriver.prototype.getElementRect = async function(cmd) {
+ lazy.assert.open(this.getBrowsingContext());
+ await this._handleUserPrompts();
+
+ let id = lazy.assert.string(cmd.parameters.id);
+ let webEl = WebReference.fromUUID(id).toJSON();
+
+ return this.getActor().getElementRect(webEl);
+};
+
+/**
+ * Send key presses to element after focusing on it.
+ *
+ * @param {string} id
+ * Reference ID to the element that will be checked.
+ * @param {string} text
+ * Value to send to the element.
+ *
+ * @throws {InvalidArgumentError}
+ * If <var>id</var> or <var>text</var> are not strings.
+ * @throws {NoSuchElementError}
+ * If element represented by reference <var>id</var> is unknown.
+ * @throws {NoSuchWindowError}
+ * Browsing context has been discarded.
+ * @throws {StaleElementReferenceError}
+ * If element represented by reference <var>id</var> has gone stale.
+ * @throws {UnexpectedAlertOpenError}
+ * A modal dialog is open, blocking this operation.
+ */
+GeckoDriver.prototype.sendKeysToElement = async function(cmd) {
+ lazy.assert.open(this.getBrowsingContext());
+ await this._handleUserPrompts();
+
+ let id = lazy.assert.string(cmd.parameters.id);
+ let text = lazy.assert.string(cmd.parameters.text);
+ let webEl = WebReference.fromUUID(id).toJSON();
+
+ return this.getActor().sendKeysToElement(
+ webEl,
+ text,
+ this.currentSession.capabilities
+ );
+};
+
+/**
+ * Clear the text of an element.
+ *
+ * @param {string} id
+ * Reference ID to the element that will be cleared.
+ *
+ * @throws {InvalidArgumentError}
+ * If <var>id</var> is not a string.
+ * @throws {NoSuchElementError}
+ * If element represented by reference <var>id</var> is unknown.
+ * @throws {NoSuchWindowError}
+ * Browsing context has been discarded.
+ * @throws {StaleElementReferenceError}
+ * If element represented by reference <var>id</var> has gone stale.
+ * @throws {UnexpectedAlertOpenError}
+ * A modal dialog is open, blocking this operation.
+ */
+GeckoDriver.prototype.clearElement = async function(cmd) {
+ lazy.assert.open(this.getBrowsingContext());
+ await this._handleUserPrompts();
+
+ let id = lazy.assert.string(cmd.parameters.id);
+ let webEl = WebReference.fromUUID(id).toJSON();
+
+ await this.getActor().clearElement(webEl);
+};
+
+/**
+ * Add a single cookie to the cookie store associated with the active
+ * document's address.
+ *
+ * @param {Map.<string, (string|number|boolean)>} cookie
+ * Cookie object.
+ *
+ * @throws {InvalidCookieDomainError}
+ * If <var>cookie</var> is for a different domain than the active
+ * document's host.
+ * @throws {NoSuchWindowError}
+ * Bbrowsing context has been discarded.
+ * @throws {UnexpectedAlertOpenError}
+ * A modal dialog is open, blocking this operation.
+ * @throws {UnsupportedOperationError}
+ * Not available in current context.
+ */
+GeckoDriver.prototype.addCookie = async function(cmd) {
+ lazy.assert.content(this.context);
+ lazy.assert.open(this.getBrowsingContext());
+ await this._handleUserPrompts();
+
+ let { protocol, hostname } = this._getCurrentURL();
+
+ const networkSchemes = ["http:", "https:"];
+ if (!networkSchemes.includes(protocol)) {
+ throw new lazy.error.InvalidCookieDomainError("Document is cookie-averse");
+ }
+
+ let newCookie = lazy.cookie.fromJSON(cmd.parameters.cookie);
+
+ lazy.cookie.add(newCookie, { restrictToHost: hostname, protocol });
+};
+
+/**
+ * Get all the cookies for the current domain.
+ *
+ * This is the equivalent of calling <code>document.cookie</code> and
+ * parsing the result.
+ *
+ * @throws {NoSuchWindowError}
+ * Browsing context has been discarded.
+ * @throws {UnexpectedAlertOpenError}
+ * A modal dialog is open, blocking this operation.
+ * @throws {UnsupportedOperationError}
+ * Not available in current context.
+ */
+GeckoDriver.prototype.getCookies = async function() {
+ lazy.assert.content(this.context);
+ lazy.assert.open(this.getBrowsingContext());
+ await this._handleUserPrompts();
+
+ let { hostname, pathname } = this._getCurrentURL();
+ return [...lazy.cookie.iter(hostname, pathname)];
+};
+
+/**
+ * Delete all cookies that are visible to a document.
+ *
+ * @throws {NoSuchWindowError}
+ * Browsing context has been discarded.
+ * @throws {UnexpectedAlertOpenError}
+ * A modal dialog is open, blocking this operation.
+ * @throws {UnsupportedOperationError}
+ * Not available in current context.
+ */
+GeckoDriver.prototype.deleteAllCookies = async function() {
+ lazy.assert.content(this.context);
+ lazy.assert.open(this.getBrowsingContext());
+ await this._handleUserPrompts();
+
+ let { hostname, pathname } = this._getCurrentURL();
+ for (let toDelete of lazy.cookie.iter(hostname, pathname)) {
+ lazy.cookie.remove(toDelete);
+ }
+};
+
+/**
+ * Delete a cookie by name.
+ *
+ * @throws {NoSuchWindowError}
+ * Browsing context has been discarded.
+ * @throws {UnexpectedAlertOpenError}
+ * A modal dialog is open, blocking this operation.
+ * @throws {UnsupportedOperationError}
+ * Not available in current context.
+ */
+GeckoDriver.prototype.deleteCookie = async function(cmd) {
+ lazy.assert.content(this.context);
+ lazy.assert.open(this.getBrowsingContext());
+ await this._handleUserPrompts();
+
+ let { hostname, pathname } = this._getCurrentURL();
+ let name = lazy.assert.string(cmd.parameters.name);
+ for (let c of lazy.cookie.iter(hostname, pathname)) {
+ if (c.name === name) {
+ lazy.cookie.remove(c);
+ }
+ }
+};
+
+/**
+ * Open a new top-level browsing context.
+ *
+ * @param {string=} type
+ * Optional type of the new top-level browsing context. Can be one of
+ * `tab` or `window`. Defaults to `tab`.
+ * @param {boolean=} focus
+ * Optional flag if the new top-level browsing context should be opened
+ * in foreground (focused) or background (not focused). Defaults to false.
+ * @param {boolean=} private
+ * Optional flag, which gets only evaluated for type `window`. True if the
+ * new top-level browsing context should be a private window.
+ * Defaults to false.
+ *
+ * @return {Object.<string, string>}
+ * Handle and type of the new browsing context.
+ *
+ * @throws {NoSuchWindowError}
+ * Top-level browsing context has been discarded.
+ * @throws {UnexpectedAlertOpenError}
+ * A modal dialog is open, blocking this operation.
+ */
+GeckoDriver.prototype.newWindow = async function(cmd) {
+ lazy.assert.open(this.getBrowsingContext({ top: true }));
+ await this._handleUserPrompts();
+
+ let focus = false;
+ if (typeof cmd.parameters.focus != "undefined") {
+ focus = lazy.assert.boolean(
+ cmd.parameters.focus,
+ lazy.pprint`Expected "focus" to be a boolean, got ${cmd.parameters.focus}`
+ );
+ }
+
+ let isPrivate = false;
+ if (typeof cmd.parameters.private != "undefined") {
+ isPrivate = lazy.assert.boolean(
+ cmd.parameters.private,
+ lazy.pprint`Expected "private" to be a boolean, got ${cmd.parameters.private}`
+ );
+ }
+
+ let type;
+ if (typeof cmd.parameters.type != "undefined") {
+ type = lazy.assert.string(
+ cmd.parameters.type,
+ lazy.pprint`Expected "type" to be a string, got ${cmd.parameters.type}`
+ );
+ }
+
+ // If an invalid or no type has been specified default to a tab.
+ if (typeof type == "undefined" || !["tab", "window"].includes(type)) {
+ type = "tab";
+ }
+
+ let contentBrowser;
+
+ switch (type) {
+ case "window":
+ let win = await this.curBrowser.openBrowserWindow(focus, isPrivate);
+ contentBrowser = lazy.TabManager.getTabBrowser(win).selectedBrowser;
+ break;
+
+ default:
+ // To not fail if a new type gets added in the future, make opening
+ // a new tab the default action.
+ let tab = await this.curBrowser.openTab(focus);
+ contentBrowser = lazy.TabManager.getBrowserForTab(tab);
+ }
+
+ // Actors need the new window to be loaded to safely execute queries.
+ // Wait until the initial page load has been finished.
+ await lazy.waitForInitialNavigationCompleted(
+ contentBrowser.browsingContext.webProgress
+ );
+
+ const id = lazy.TabManager.getIdForBrowser(contentBrowser);
+
+ return { handle: id.toString(), type };
+};
+
+/**
+ * Close the currently selected tab/window.
+ *
+ * With multiple open tabs present the currently selected tab will
+ * be closed. Otherwise the window itself will be closed. If it is the
+ * last window currently open, the window will not be closed to prevent
+ * a shutdown of the application. Instead the returned list of window
+ * handles is empty.
+ *
+ * @return {Array.<string>}
+ * Unique window handles of remaining windows.
+ *
+ * @throws {NoSuchWindowError}
+ * Top-level browsing context has been discarded.
+ * @throws {UnexpectedAlertOpenError}
+ * A modal dialog is open, blocking this operation.
+ */
+GeckoDriver.prototype.close = async function() {
+ lazy.assert.open(
+ this.getBrowsingContext({ context: lazy.Context.Content, top: true })
+ );
+ await this._handleUserPrompts();
+
+ // If there is only one window left, do not close unless windowless mode is
+ // enabled. Instead return a faked empty array of window handles.
+ // This will instruct geckodriver to terminate the application.
+ if (
+ lazy.TabManager.getTabCount() === 1 &&
+ !this.currentSession.capabilities.get("moz:windowless")
+ ) {
+ return [];
+ }
+
+ await this.curBrowser.closeTab();
+ this.currentSession.contentBrowsingContext = null;
+
+ return lazy.TabManager.allBrowserUniqueIds.map(String);
+};
+
+/**
+ * Close the currently selected chrome window.
+ *
+ * If it is the last window currently open, the chrome window will not be
+ * closed to prevent a shutdown of the application. Instead the returned
+ * list of chrome window handles is empty.
+ *
+ * @return {Array.<string>}
+ * Unique chrome window handles of remaining chrome windows.
+ *
+ * @throws {NoSuchWindowError}
+ * Top-level browsing context has been discarded.
+ */
+GeckoDriver.prototype.closeChromeWindow = async function() {
+ lazy.assert.desktop();
+ lazy.assert.open(
+ this.getBrowsingContext({ context: lazy.Context.Chrome, top: true })
+ );
+
+ let nwins = 0;
+
+ // eslint-disable-next-line
+ for (let _ of lazy.windowManager.windows) {
+ nwins++;
+ }
+
+ // If there is only one window left, do not close unless windowless mode is
+ // enabled. Instead return a faked empty array of window handles.
+ // This will instruct geckodriver to terminate the application.
+ if (nwins == 1 && !this.currentSession.capabilities.get("moz:windowless")) {
+ return [];
+ }
+
+ await this.curBrowser.closeWindow();
+ this.currentSession.chromeBrowsingContext = null;
+ this.currentSession.contentBrowsingContext = null;
+
+ return lazy.windowManager.chromeWindowHandles.map(String);
+};
+
+/** Delete Marionette session. */
+GeckoDriver.prototype.deleteSession = function() {
+ if (!this.currentSession) {
+ return;
+ }
+
+ for (let win of lazy.windowManager.windows) {
+ this.stopObservingWindow(win);
+ }
+
+ // reset to the top-most frame
+ this.mainFrame = null;
+
+ if (this.dialogObserver) {
+ this.dialogObserver.cleanup();
+ this.dialogObserver = null;
+ }
+
+ try {
+ Services.obs.removeObserver(this, TOPIC_BROWSER_READY);
+ } catch (e) {
+ lazy.logger.debug(`Failed to remove observer "${TOPIC_BROWSER_READY}"`);
+ }
+
+ // Always unregister actors after all other observers
+ // and listeners have been removed.
+ lazy.unregisterCommandsActor();
+ // MarionetteEvents actors are only disabled to avoid IPC errors if there are
+ // in flight events being forwarded from the content process to the parent
+ // process.
+ lazy.disableEventsActor();
+
+ if (lazy.RemoteAgent.webDriverBiDi) {
+ lazy.RemoteAgent.webDriverBiDi.deleteSession();
+ } else {
+ this.currentSession.destroy();
+ this._currentSession = null;
+ }
+};
+
+/**
+ * Takes a screenshot of a web element, current frame, or viewport.
+ *
+ * The screen capture is returned as a lossless PNG image encoded as
+ * a base 64 string.
+ *
+ * If called in the content context, the |id| argument is not null and
+ * refers to a present and visible web element's ID, the capture area will
+ * be limited to the bounding box of that element. Otherwise, the capture
+ * area will be the bounding box of the current frame.
+ *
+ * If called in the chrome context, the screenshot will always represent
+ * the entire viewport.
+ *
+ * @param {string=} id
+ * Optional web element reference to take a screenshot of.
+ * If undefined, a screenshot will be taken of the document element.
+ * @param {boolean=} full
+ * True to take a screenshot of the entire document element. Is only
+ * considered if <var>id</var> is not defined. Defaults to true.
+ * @param {boolean=} hash
+ * True if the user requests a hash of the image data. Defaults to false.
+ * @param {boolean=} scroll
+ * Scroll to element if |id| is provided. Defaults to true.
+ *
+ * @return {string}
+ * If <var>hash</var> is false, PNG image encoded as Base64 encoded
+ * string. If <var>hash</var> is true, hex digest of the SHA-256
+ * hash of the Base64 encoded string.
+ *
+ * @throws {NoSuchElementError}
+ * If element represented by reference <var>id</var> is unknown.
+ * @throws {NoSuchWindowError}
+ * Browsing context has been discarded.
+ * @throws {StaleElementReferenceError}
+ * If element represented by reference <var>id</var> has gone stale.
+ */
+GeckoDriver.prototype.takeScreenshot = async function(cmd) {
+ lazy.assert.open(this.getBrowsingContext({ top: true }));
+ await this._handleUserPrompts();
+
+ let { id, full, hash, scroll } = cmd.parameters;
+ let format = hash ? lazy.capture.Format.Hash : lazy.capture.Format.Base64;
+
+ full = typeof full == "undefined" ? true : full;
+ scroll = typeof scroll == "undefined" ? true : scroll;
+
+ let webEl = id ? WebReference.fromUUID(id).toJSON() : null;
+
+ // Only consider full screenshot if no element has been specified
+ full = webEl ? false : full;
+
+ return this.getActor().takeScreenshot(webEl, format, full, scroll);
+};
+
+/**
+ * Get the current browser orientation.
+ *
+ * Will return one of the valid primary orientation values
+ * portrait-primary, landscape-primary, portrait-secondary, or
+ * landscape-secondary.
+ *
+ * @throws {NoSuchWindowError}
+ * Top-level browsing context has been discarded.
+ */
+GeckoDriver.prototype.getScreenOrientation = function() {
+ lazy.assert.mobile();
+ lazy.assert.open(this.getBrowsingContext({ top: true }));
+
+ const win = this.getCurrentWindow();
+
+ return win.screen.orientation.type;
+};
+
+/**
+ * Set the current browser orientation.
+ *
+ * The supplied orientation should be given as one of the valid
+ * orientation values. If the orientation is unknown, an error will
+ * be raised.
+ *
+ * Valid orientations are "portrait" and "landscape", which fall
+ * back to "portrait-primary" and "landscape-primary" respectively,
+ * and "portrait-secondary" as well as "landscape-secondary".
+ *
+ * @throws {NoSuchWindowError}
+ * Top-level browsing context has been discarded.
+ */
+GeckoDriver.prototype.setScreenOrientation = async function(cmd) {
+ lazy.assert.mobile();
+ lazy.assert.open(this.getBrowsingContext({ top: true }));
+
+ const ors = [
+ "portrait",
+ "landscape",
+ "portrait-primary",
+ "landscape-primary",
+ "portrait-secondary",
+ "landscape-secondary",
+ ];
+
+ let or = String(cmd.parameters.orientation);
+ lazy.assert.string(or);
+ let mozOr = or.toLowerCase();
+ if (!ors.includes(mozOr)) {
+ throw new lazy.error.InvalidArgumentError(
+ `Unknown screen orientation: ${or}`
+ );
+ }
+
+ const win = this.getCurrentWindow();
+
+ try {
+ await win.screen.orientation.lock(mozOr);
+ } catch (e) {
+ throw new lazy.error.WebDriverError(
+ `Unable to set screen orientation: ${or}`
+ );
+ }
+};
+
+/**
+ * Synchronously minimizes the user agent window as if the user pressed
+ * the minimize button.
+ *
+ * No action is taken if the window is already minimized.
+ *
+ * Not supported on Fennec.
+ *
+ * @return {Object.<string, number>}
+ * Window rect and window state.
+ *
+ * @throws {NoSuchWindowError}
+ * Top-level browsing context has been discarded.
+ * @throws {UnexpectedAlertOpenError}
+ * A modal dialog is open, blocking this operation.
+ * @throws {UnsupportedOperationError}
+ * Not available for current application.
+ */
+GeckoDriver.prototype.minimizeWindow = async function() {
+ lazy.assert.desktop();
+ lazy.assert.open(this.getBrowsingContext({ top: true }));
+ await this._handleUserPrompts();
+
+ const win = this.getCurrentWindow();
+ switch (lazy.WindowState.from(win.windowState)) {
+ case lazy.WindowState.Fullscreen:
+ await exitFullscreen(win);
+ break;
+
+ case lazy.WindowState.Maximized:
+ await restoreWindow(win);
+ break;
+ }
+
+ if (lazy.WindowState.from(win.windowState) != lazy.WindowState.Minimized) {
+ let cb;
+ // Use a timed promise to abort if no window manager is present
+ await new lazy.TimedPromise(
+ resolve => {
+ cb = new lazy.DebounceCallback(resolve);
+ win.addEventListener("sizemodechange", cb);
+ win.minimize();
+ },
+ { throws: null, timeout: TIMEOUT_NO_WINDOW_MANAGER }
+ );
+ win.removeEventListener("sizemodechange", cb);
+ await new lazy.IdlePromise(win);
+ }
+
+ return this.curBrowser.rect;
+};
+
+/**
+ * Synchronously maximizes the user agent window as if the user pressed
+ * the maximize button.
+ *
+ * No action is taken if the window is already maximized.
+ *
+ * Not supported on Fennec.
+ *
+ * @return {Object.<string, number>}
+ * Window rect.
+ *
+ * @throws {NoSuchWindowError}
+ * Top-level browsing context has been discarded.
+ * @throws {UnexpectedAlertOpenError}
+ * A modal dialog is open, blocking this operation.
+ * @throws {UnsupportedOperationError}
+ * Not available for current application.
+ */
+GeckoDriver.prototype.maximizeWindow = async function() {
+ lazy.assert.desktop();
+ lazy.assert.open(this.getBrowsingContext({ top: true }));
+ await this._handleUserPrompts();
+
+ const win = this.getCurrentWindow();
+ switch (lazy.WindowState.from(win.windowState)) {
+ case lazy.WindowState.Fullscreen:
+ await exitFullscreen(win);
+ break;
+
+ case lazy.WindowState.Minimized:
+ await restoreWindow(win);
+ break;
+ }
+
+ if (lazy.WindowState.from(win.windowState) != lazy.WindowState.Maximized) {
+ let cb;
+ // Use a timed promise to abort if no window manager is present
+ await new lazy.TimedPromise(
+ resolve => {
+ cb = new lazy.DebounceCallback(resolve);
+ win.addEventListener("sizemodechange", cb);
+ win.maximize();
+ },
+ { throws: null, timeout: TIMEOUT_NO_WINDOW_MANAGER }
+ );
+ win.removeEventListener("sizemodechange", cb);
+ await new lazy.IdlePromise(win);
+ }
+
+ return this.curBrowser.rect;
+};
+
+/**
+ * Synchronously sets the user agent window to full screen as if the user
+ * had done "View > Enter Full Screen".
+ *
+ * No action is taken if the window is already in full screen mode.
+ *
+ * Not supported on Fennec.
+ *
+ * @return {Map.<string, number>}
+ * Window rect.
+ *
+ * @throws {NoSuchWindowError}
+ * Top-level browsing context has been discarded.
+ * @throws {UnexpectedAlertOpenError}
+ * A modal dialog is open, blocking this operation.
+ * @throws {UnsupportedOperationError}
+ * Not available for current application.
+ */
+GeckoDriver.prototype.fullscreenWindow = async function() {
+ lazy.assert.desktop();
+ lazy.assert.open(this.getBrowsingContext({ top: true }));
+ await this._handleUserPrompts();
+
+ const win = this.getCurrentWindow();
+ switch (lazy.WindowState.from(win.windowState)) {
+ case lazy.WindowState.Maximized:
+ case lazy.WindowState.Minimized:
+ await restoreWindow(win);
+ break;
+ }
+
+ if (lazy.WindowState.from(win.windowState) != lazy.WindowState.Fullscreen) {
+ let cb;
+ // Use a timed promise to abort if no window manager is present
+ await new lazy.TimedPromise(
+ resolve => {
+ cb = new lazy.DebounceCallback(resolve);
+ win.addEventListener("sizemodechange", cb);
+ win.fullScreen = true;
+ },
+ { throws: null, timeout: TIMEOUT_NO_WINDOW_MANAGER }
+ );
+ win.removeEventListener("sizemodechange", cb);
+ }
+ await new lazy.IdlePromise(win);
+
+ return this.curBrowser.rect;
+};
+
+/**
+ * Dismisses a currently displayed tab modal, or returns no such alert if
+ * no modal is displayed.
+ *
+ * @throws {NoSuchAlertError}
+ * If there is no current user prompt.
+ * @throws {NoSuchWindowError}
+ * Top-level browsing context has been discarded.
+ */
+GeckoDriver.prototype.dismissDialog = async function() {
+ lazy.assert.open(this.getBrowsingContext({ top: true }));
+ this._checkIfAlertIsPresent();
+
+ const dialogClosed = this.dialogObserver.dialogClosed();
+ this.dialog.dismiss();
+ await dialogClosed;
+
+ const win = this.getCurrentWindow();
+ await new lazy.IdlePromise(win);
+};
+
+/**
+ * Accepts a currently displayed tab modal, or returns no such alert if
+ * no modal is displayed.
+ *
+ * @throws {NoSuchAlertError}
+ * If there is no current user prompt.
+ * @throws {NoSuchWindowError}
+ * Top-level browsing context has been discarded.
+ */
+GeckoDriver.prototype.acceptDialog = async function() {
+ lazy.assert.open(this.getBrowsingContext({ top: true }));
+ this._checkIfAlertIsPresent();
+
+ const dialogClosed = this.dialogObserver.dialogClosed();
+ this.dialog.accept();
+ await dialogClosed;
+
+ const win = this.getCurrentWindow();
+ await new lazy.IdlePromise(win);
+};
+
+/**
+ * Returns the message shown in a currently displayed modal, or returns
+ * a no such alert error if no modal is currently displayed.
+ *
+ * @throws {NoSuchAlertError}
+ * If there is no current user prompt.
+ * @throws {NoSuchWindowError}
+ * Top-level browsing context has been discarded.
+ */
+GeckoDriver.prototype.getTextFromDialog = function() {
+ lazy.assert.open(this.getBrowsingContext({ top: true }));
+ this._checkIfAlertIsPresent();
+ return this.dialog.text;
+};
+
+/**
+ * Set the user prompt's value field.
+ *
+ * Sends keys to the input field of a currently displayed modal, or
+ * returns a no such alert error if no modal is currently displayed. If
+ * a tab modal is currently displayed but has no means for text input,
+ * an element not visible error is returned.
+ *
+ * @param {string} text
+ * Input to the user prompt's value field.
+ *
+ * @throws {ElementNotInteractableError}
+ * If the current user prompt is an alert or confirm.
+ * @throws {NoSuchAlertError}
+ * If there is no current user prompt.
+ * @throws {NoSuchWindowError}
+ * Top-level browsing context has been discarded.
+ * @throws {UnsupportedOperationError}
+ * If the current user prompt is something other than an alert,
+ * confirm, or a prompt.
+ */
+GeckoDriver.prototype.sendKeysToDialog = async function(cmd) {
+ lazy.assert.open(this.getBrowsingContext({ top: true }));
+ this._checkIfAlertIsPresent();
+
+ let text = lazy.assert.string(cmd.parameters.text);
+ let promptType = this.dialog.args.promptType;
+
+ switch (promptType) {
+ case "alert":
+ case "confirm":
+ throw new lazy.error.ElementNotInteractableError(
+ `User prompt of type ${promptType} is not interactable`
+ );
+ case "prompt":
+ break;
+ default:
+ await this.dismissDialog();
+ throw new lazy.error.UnsupportedOperationError(
+ `User prompt of type ${promptType} is not supported`
+ );
+ }
+ this.dialog.text = text;
+};
+
+GeckoDriver.prototype._checkIfAlertIsPresent = function() {
+ if (!this.dialog || !this.dialog.isOpen) {
+ throw new lazy.error.NoSuchAlertError();
+ }
+};
+
+GeckoDriver.prototype._handleUserPrompts = async function() {
+ if (!this.dialog || !this.dialog.isOpen) {
+ return;
+ }
+
+ let textContent = this.dialog.text;
+
+ const behavior = this.currentSession.unhandledPromptBehavior;
+ switch (behavior) {
+ case lazy.UnhandledPromptBehavior.Accept:
+ await this.acceptDialog();
+ break;
+
+ case lazy.UnhandledPromptBehavior.AcceptAndNotify:
+ await this.acceptDialog();
+ throw new lazy.error.UnexpectedAlertOpenError(
+ `Accepted user prompt dialog: ${textContent}`
+ );
+
+ case lazy.UnhandledPromptBehavior.Dismiss:
+ await this.dismissDialog();
+ break;
+
+ case lazy.UnhandledPromptBehavior.DismissAndNotify:
+ await this.dismissDialog();
+ throw new lazy.error.UnexpectedAlertOpenError(
+ `Dismissed user prompt dialog: ${textContent}`
+ );
+
+ case lazy.UnhandledPromptBehavior.Ignore:
+ throw new lazy.error.UnexpectedAlertOpenError(
+ "Encountered unhandled user prompt dialog"
+ );
+
+ default:
+ throw new TypeError(`Unknown unhandledPromptBehavior "${behavior}"`);
+ }
+};
+
+/**
+ * Enables or disables accepting new socket connections.
+ *
+ * By calling this method with `false` the server will not accept any
+ * further connections, but existing connections will not be forcible
+ * closed. Use `true` to re-enable accepting connections.
+ *
+ * Please note that when closing the connection via the client you can
+ * end-up in a non-recoverable state if it hasn't been enabled before.
+ *
+ * This method is used for custom in application shutdowns via
+ * marionette.quit() or marionette.restart(), like File -> Quit.
+ *
+ * @param {boolean} state
+ * True if the server should accept new socket connections.
+ */
+GeckoDriver.prototype.acceptConnections = function(cmd) {
+ lazy.assert.boolean(cmd.parameters.value);
+ this._server.acceptConnections = cmd.parameters.value;
+};
+
+/**
+ * Quits the application with the provided flags.
+ *
+ * Marionette will stop accepting new connections before ending the
+ * current session, and finally attempting to quit the application.
+ *
+ * Optional {@link nsIAppStartup} flags may be provided as
+ * an array of masks, and these will be combined by ORing
+ * them with a bitmask. The available masks are defined in
+ * https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XPCOM/Reference/Interface/nsIAppStartup.
+ *
+ * Crucially, only one of the *Quit flags can be specified. The |eRestart|
+ * flag may be bit-wise combined with one of the *Quit flags to cause
+ * the application to restart after it quits.
+ *
+ * @param {Array.<string>=} flags
+ * Constant name of masks to pass to |Services.startup.quit|.
+ * If empty or undefined, |nsIAppStartup.eAttemptQuit| is used.
+ *
+ * @param {boolean=} safeMode
+ * Optional flag to indicate that the application has to
+ * be restarted in safe mode.
+ *
+ * @return {Object<string,boolean>}
+ * Dictionary containing information that explains the shutdown reason.
+ * The value for `cause` contains the shutdown kind like "shutdown" or
+ * "restart", while `forced` will indicate if it was a normal or forced
+ * shutdown of the application. "in_app" is always set to indicate that
+ * it is a shutdown triggered from within the application.
+ *
+ * @throws {InvalidArgumentError}
+ * If <var>flags</var> contains unknown or incompatible flags,
+ * for example multiple Quit flags.
+ */
+GeckoDriver.prototype.quit = async function(cmd) {
+ const { flags = [], safeMode = false } = cmd.parameters;
+ const quits = ["eConsiderQuit", "eAttemptQuit", "eForceQuit"];
+
+ lazy.assert.array(flags, `Expected "flags" to be an array`);
+ lazy.assert.boolean(safeMode, `Expected "safeMode" to be a boolean`);
+
+ if (safeMode && !flags.includes("eRestart")) {
+ throw new lazy.error.InvalidArgumentError(
+ `"safeMode" only works with restart flag`
+ );
+ }
+
+ if (flags.includes("eSilently")) {
+ if (!this.currentSession.capabilities.get("moz:windowless")) {
+ throw new lazy.error.UnsupportedOperationError(
+ `Silent restarts only allowed with "moz:windowless" capability set`
+ );
+ }
+ if (!flags.includes("eRestart")) {
+ throw new lazy.error.InvalidArgumentError(
+ `"silently" only works with restart flag`
+ );
+ }
+ }
+
+ let quitSeen;
+ let mode = 0;
+ if (flags.length) {
+ for (let k of flags) {
+ lazy.assert.in(k, Ci.nsIAppStartup);
+
+ if (quits.includes(k)) {
+ if (quitSeen) {
+ throw new lazy.error.InvalidArgumentError(
+ `${k} cannot be combined with ${quitSeen}`
+ );
+ }
+ quitSeen = k;
+ }
+
+ mode |= Ci.nsIAppStartup[k];
+ }
+ }
+
+ if (!quitSeen) {
+ mode |= Ci.nsIAppStartup.eAttemptQuit;
+ }
+
+ this._server.acceptConnections = false;
+ this.deleteSession();
+
+ // Notify all windows that an application quit has been requested.
+ const cancelQuit = Cc["@mozilla.org/supports-PRBool;1"].createInstance(
+ Ci.nsISupportsPRBool
+ );
+ Services.obs.notifyObservers(cancelQuit, "quit-application-requested");
+
+ // If the shutdown of the application is prevented force quit it instead.
+ if (cancelQuit.data) {
+ mode |= Ci.nsIAppStartup.eForceQuit;
+ }
+
+ // delay response until the application is about to quit
+ let quitApplication = lazy.waitForObserverTopic("quit-application");
+
+ if (safeMode) {
+ Services.startup.restartInSafeMode(mode);
+ } else {
+ Services.startup.quit(mode);
+ }
+
+ return {
+ cause: (await quitApplication).data,
+ forced: cancelQuit.data,
+ in_app: true,
+ };
+};
+
+GeckoDriver.prototype.installAddon = function(cmd) {
+ lazy.assert.desktop();
+
+ let path = cmd.parameters.path;
+ let temp = cmd.parameters.temporary || false;
+ if (
+ typeof path == "undefined" ||
+ typeof path != "string" ||
+ typeof temp != "boolean"
+ ) {
+ throw new lazy.error.InvalidArgumentError();
+ }
+
+ return lazy.Addon.install(path, temp);
+};
+
+GeckoDriver.prototype.uninstallAddon = function(cmd) {
+ lazy.assert.desktop();
+
+ let id = cmd.parameters.id;
+ if (typeof id == "undefined" || typeof id != "string") {
+ throw new lazy.error.InvalidArgumentError();
+ }
+
+ return lazy.Addon.uninstall(id);
+};
+
+/**
+ * Retrieve the localized string for the specified entity id.
+ *
+ * Example:
+ * localizeEntity(["chrome://branding/locale/brand.dtd"], "brandShortName")
+ *
+ * @param {Array.<string>} urls
+ * Array of .dtd URLs.
+ * @param {string} id
+ * The ID of the entity to retrieve the localized string for.
+ *
+ * @return {string}
+ * The localized string for the requested entity.
+ */
+GeckoDriver.prototype.localizeEntity = function(cmd) {
+ let { urls, id } = cmd.parameters;
+
+ if (!Array.isArray(urls)) {
+ throw new lazy.error.InvalidArgumentError(
+ "Value of `urls` should be of type 'Array'"
+ );
+ }
+ if (typeof id != "string") {
+ throw new lazy.error.InvalidArgumentError(
+ "Value of `id` should be of type 'string'"
+ );
+ }
+
+ return lazy.l10n.localizeEntity(urls, id);
+};
+
+/**
+ * Retrieve the localized string for the specified property id.
+ *
+ * Example:
+ *
+ * localizeProperty(
+ * ["chrome://global/locale/findbar.properties"], "FastFind");
+ *
+ * @param {Array.<string>} urls
+ * Array of .properties URLs.
+ * @param {string} id
+ * The ID of the property to retrieve the localized string for.
+ *
+ * @return {string}
+ * The localized string for the requested property.
+ */
+GeckoDriver.prototype.localizeProperty = function(cmd) {
+ let { urls, id } = cmd.parameters;
+
+ if (!Array.isArray(urls)) {
+ throw new lazy.error.InvalidArgumentError(
+ "Value of `urls` should be of type 'Array'"
+ );
+ }
+ if (typeof id != "string") {
+ throw new lazy.error.InvalidArgumentError(
+ "Value of `id` should be of type 'string'"
+ );
+ }
+
+ return lazy.l10n.localizeProperty(urls, id);
+};
+
+/**
+ * Initialize the reftest mode
+ */
+GeckoDriver.prototype.setupReftest = async function(cmd) {
+ if (this._reftest) {
+ throw new lazy.error.UnsupportedOperationError(
+ "Called reftest:setup with a reftest session already active"
+ );
+ }
+
+ let {
+ urlCount = {},
+ screenshot = "unexpected",
+ isPrint = false,
+ } = cmd.parameters;
+ if (!["always", "fail", "unexpected"].includes(screenshot)) {
+ throw new lazy.error.InvalidArgumentError(
+ "Value of `screenshot` should be 'always', 'fail' or 'unexpected'"
+ );
+ }
+
+ this._reftest = new lazy.reftest.Runner(this);
+ this._reftest.setup(urlCount, screenshot, isPrint);
+};
+
+/** Run a reftest. */
+GeckoDriver.prototype.runReftest = async function(cmd) {
+ let {
+ test,
+ references,
+ expected,
+ timeout,
+ width,
+ height,
+ pageRanges,
+ } = cmd.parameters;
+
+ if (!this._reftest) {
+ throw new lazy.error.UnsupportedOperationError(
+ "Called reftest:run before reftest:start"
+ );
+ }
+
+ lazy.assert.string(test);
+ lazy.assert.string(expected);
+ lazy.assert.array(references);
+
+ return {
+ value: await this._reftest.run(
+ test,
+ references,
+ expected,
+ timeout,
+ pageRanges,
+ width,
+ height
+ ),
+ };
+};
+
+/**
+ * End a reftest run.
+ *
+ * Closes the reftest window (without changing the current window handle),
+ * and removes cached canvases.
+ */
+GeckoDriver.prototype.teardownReftest = function() {
+ if (!this._reftest) {
+ throw new lazy.error.UnsupportedOperationError(
+ "Called reftest:teardown before reftest:start"
+ );
+ }
+
+ this._reftest.teardown();
+ this._reftest = null;
+};
+
+/**
+ * Print page as PDF.
+ *
+ * @param {boolean=} landscape
+ * Paper orientation. Defaults to false.
+ * @param {number=} margin.bottom
+ * Bottom margin in cm. Defaults to 1cm (~0.4 inches).
+ * @param {number=} margin.left
+ * Left margin in cm. Defaults to 1cm (~0.4 inches).
+ * @param {number=} margin.right
+ * Right margin in cm. Defaults to 1cm (~0.4 inches).
+ * @param {number=} margin.top
+ * Top margin in cm. Defaults to 1cm (~0.4 inches).
+ * @param {Array.<string|number>=} pageRanges
+ * Paper ranges to print, e.g., ['1-5', 8, '11-13'].
+ * Defaults to the empty array, which means print all pages.
+ * @param {number=} page.height
+ * Paper height in cm. Defaults to US letter height (11 inches / 27.94cm)
+ * @param {number=} page.width
+ * Paper width in cm. Defaults to US letter width (8.5 inches / 21.59cm)
+ * @param {boolean=} shrinkToFit
+ * Whether or not to override page size as defined by CSS.
+ * Defaults to true, in which case the content will be scaled
+ * to fit the paper size.
+ * @param {boolean=} printBackground
+ * Print background graphics. Defaults to false.
+ * @param {number=} scale
+ * Scale of the webpage rendering. Defaults to 1.
+ *
+ * @return {string}
+ * Base64 encoded PDF representing printed document
+ *
+ * @throws {NoSuchWindowError}
+ * Top-level browsing context has been discarded.
+ * @throws {UnexpectedAlertOpenError}
+ * A modal dialog is open, blocking this operation.
+ * @throws {UnsupportedOperationError}
+ * Not available in chrome context.
+ */
+GeckoDriver.prototype.print = async function(cmd) {
+ lazy.assert.content(this.context);
+ lazy.assert.open(this.getBrowsingContext({ top: true }));
+ await this._handleUserPrompts();
+
+ const settings = lazy.print.addDefaultSettings(cmd.parameters);
+ for (let prop of ["top", "bottom", "left", "right"]) {
+ lazy.assert.positiveNumber(
+ settings.margin[prop],
+ lazy.pprint`margin.${prop} is not a positive number`
+ );
+ }
+ for (let prop of ["width", "height"]) {
+ lazy.assert.positiveNumber(
+ settings.page[prop],
+ lazy.pprint`page.${prop} is not a positive number`
+ );
+ }
+ lazy.assert.positiveNumber(
+ settings.scale,
+ `scale ${settings.scale} is not a positive number`
+ );
+ lazy.assert.that(
+ s =>
+ s >= lazy.print.minScaleValue &&
+ settings.scale <= lazy.print.maxScaleValue,
+ `scale ${settings.scale} is outside the range ${lazy.print.minScaleValue}-${lazy.print.maxScaleValue}`
+ )(settings.scale);
+ lazy.assert.boolean(settings.shrinkToFit);
+ lazy.assert.boolean(settings.landscape);
+ lazy.assert.boolean(settings.printBackground);
+ lazy.assert.array(settings.pageRanges);
+
+ const linkedBrowser = this.curBrowser.tab.linkedBrowser;
+ const filePath = await lazy.print.printToFile(linkedBrowser, settings);
+
+ // return all data as a base64 encoded string
+ let bytes;
+ try {
+ bytes = await IOUtils.read(filePath);
+ } finally {
+ await IOUtils.remove(filePath);
+ }
+
+ // Each UCS2 character has an upper byte of 0 and a lower byte matching
+ // the binary data. Splitting the file into chunks to avoid hitting the
+ // internal argument length limit.
+ const chunks = [];
+ // This is the largest power of 2 smaller than MAX_ARGS_LENGTH defined in Spidermonkey
+ const argLengthLimit = 262144;
+
+ for (let offset = 0; offset < bytes.length; offset += argLengthLimit) {
+ const chunkData = bytes.subarray(offset, offset + argLengthLimit);
+
+ chunks.push(String.fromCharCode.apply(null, chunkData));
+ }
+
+ return {
+ value: btoa(chunks.join("")),
+ };
+};
+
+GeckoDriver.prototype.setPermission = async function(cmd) {
+ const { descriptor, state, oneRealm = false } = cmd.parameters;
+
+ lazy.assert.boolean(oneRealm);
+ lazy.assert.that(
+ state => ["granted", "denied", "prompt"].includes(state),
+ `state is ${state}, expected "granted", "denied", or "prompt"`
+ )(state);
+
+ lazy.permissions.set(descriptor, state, oneRealm);
+};
+
+GeckoDriver.prototype.commands = {
+ // Marionette service
+ "Marionette:AcceptConnections": GeckoDriver.prototype.acceptConnections,
+ "Marionette:GetContext": GeckoDriver.prototype.getContext,
+ "Marionette:GetScreenOrientation": GeckoDriver.prototype.getScreenOrientation,
+ "Marionette:GetWindowType": GeckoDriver.prototype.getWindowType,
+ "Marionette:Quit": GeckoDriver.prototype.quit,
+ "Marionette:SetContext": GeckoDriver.prototype.setContext,
+ "Marionette:SetScreenOrientation": GeckoDriver.prototype.setScreenOrientation,
+ "Marionette:SingleTap": GeckoDriver.prototype.singleTap,
+
+ // Addon service
+ "Addon:Install": GeckoDriver.prototype.installAddon,
+ "Addon:Uninstall": GeckoDriver.prototype.uninstallAddon,
+
+ // L10n service
+ "L10n:LocalizeEntity": GeckoDriver.prototype.localizeEntity,
+ "L10n:LocalizeProperty": GeckoDriver.prototype.localizeProperty,
+
+ // Reftest service
+ "reftest:setup": GeckoDriver.prototype.setupReftest,
+ "reftest:run": GeckoDriver.prototype.runReftest,
+ "reftest:teardown": GeckoDriver.prototype.teardownReftest,
+
+ // WebDriver service
+ "WebDriver:AcceptAlert": GeckoDriver.prototype.acceptDialog,
+ // deprecated, no longer used since the geckodriver 0.30.0 release
+ "WebDriver:AcceptDialog": GeckoDriver.prototype.acceptDialog,
+ "WebDriver:AddCookie": GeckoDriver.prototype.addCookie,
+ "WebDriver:Back": GeckoDriver.prototype.goBack,
+ "WebDriver:CloseChromeWindow": GeckoDriver.prototype.closeChromeWindow,
+ "WebDriver:CloseWindow": GeckoDriver.prototype.close,
+ "WebDriver:DeleteAllCookies": GeckoDriver.prototype.deleteAllCookies,
+ "WebDriver:DeleteCookie": GeckoDriver.prototype.deleteCookie,
+ "WebDriver:DeleteSession": GeckoDriver.prototype.deleteSession,
+ "WebDriver:DismissAlert": GeckoDriver.prototype.dismissDialog,
+ "WebDriver:ElementClear": GeckoDriver.prototype.clearElement,
+ "WebDriver:ElementClick": GeckoDriver.prototype.clickElement,
+ "WebDriver:ElementSendKeys": GeckoDriver.prototype.sendKeysToElement,
+ "WebDriver:ExecuteAsyncScript": GeckoDriver.prototype.executeAsyncScript,
+ "WebDriver:ExecuteScript": GeckoDriver.prototype.executeScript,
+ "WebDriver:FindElement": GeckoDriver.prototype.findElement,
+ "WebDriver:FindElements": GeckoDriver.prototype.findElements,
+ "WebDriver:Forward": GeckoDriver.prototype.goForward,
+ "WebDriver:FullscreenWindow": GeckoDriver.prototype.fullscreenWindow,
+ "WebDriver:GetActiveElement": GeckoDriver.prototype.getActiveElement,
+ "WebDriver:GetAlertText": GeckoDriver.prototype.getTextFromDialog,
+ "WebDriver:GetCapabilities": GeckoDriver.prototype.getSessionCapabilities,
+ "WebDriver:GetCookies": GeckoDriver.prototype.getCookies,
+ "WebDriver:GetCurrentURL": GeckoDriver.prototype.getCurrentUrl,
+ "WebDriver:GetElementAttribute": GeckoDriver.prototype.getElementAttribute,
+ "WebDriver:GetElementCSSValue":
+ GeckoDriver.prototype.getElementValueOfCssProperty,
+ "WebDriver:GetElementProperty": GeckoDriver.prototype.getElementProperty,
+ "WebDriver:GetElementRect": GeckoDriver.prototype.getElementRect,
+ "WebDriver:GetElementTagName": GeckoDriver.prototype.getElementTagName,
+ "WebDriver:GetElementText": GeckoDriver.prototype.getElementText,
+ "WebDriver:GetPageSource": GeckoDriver.prototype.getPageSource,
+ "WebDriver:GetShadowRoot": GeckoDriver.prototype.getShadowRoot,
+ "WebDriver:GetTimeouts": GeckoDriver.prototype.getTimeouts,
+ "WebDriver:GetTitle": GeckoDriver.prototype.getTitle,
+ "WebDriver:GetWindowHandle": GeckoDriver.prototype.getWindowHandle,
+ "WebDriver:GetWindowHandles": GeckoDriver.prototype.getWindowHandles,
+ "WebDriver:GetWindowRect": GeckoDriver.prototype.getWindowRect,
+ "WebDriver:IsElementDisplayed": GeckoDriver.prototype.isElementDisplayed,
+ "WebDriver:IsElementEnabled": GeckoDriver.prototype.isElementEnabled,
+ "WebDriver:IsElementSelected": GeckoDriver.prototype.isElementSelected,
+ "WebDriver:MinimizeWindow": GeckoDriver.prototype.minimizeWindow,
+ "WebDriver:MaximizeWindow": GeckoDriver.prototype.maximizeWindow,
+ "WebDriver:Navigate": GeckoDriver.prototype.navigateTo,
+ "WebDriver:NewSession": GeckoDriver.prototype.newSession,
+ "WebDriver:NewWindow": GeckoDriver.prototype.newWindow,
+ "WebDriver:PerformActions": GeckoDriver.prototype.performActions,
+ "WebDriver:Print": GeckoDriver.prototype.print,
+ "WebDriver:Refresh": GeckoDriver.prototype.refresh,
+ "WebDriver:ReleaseActions": GeckoDriver.prototype.releaseActions,
+ "WebDriver:SendAlertText": GeckoDriver.prototype.sendKeysToDialog,
+ "WebDriver:SetPermission": GeckoDriver.prototype.setPermission,
+ "WebDriver:SetTimeouts": GeckoDriver.prototype.setTimeouts,
+ "WebDriver:SetWindowRect": GeckoDriver.prototype.setWindowRect,
+ "WebDriver:SwitchToFrame": GeckoDriver.prototype.switchToFrame,
+ "WebDriver:SwitchToParentFrame": GeckoDriver.prototype.switchToParentFrame,
+ "WebDriver:SwitchToWindow": GeckoDriver.prototype.switchToWindow,
+ "WebDriver:TakeScreenshot": GeckoDriver.prototype.takeScreenshot,
+};
+
+async function exitFullscreen(win) {
+ let cb;
+ // Use a timed promise to abort if no window manager is present
+ await new lazy.TimedPromise(
+ resolve => {
+ cb = new lazy.DebounceCallback(resolve);
+ win.addEventListener("sizemodechange", cb);
+ win.fullScreen = false;
+ },
+ { throws: null, timeout: TIMEOUT_NO_WINDOW_MANAGER }
+ );
+ win.removeEventListener("sizemodechange", cb);
+ await new lazy.IdlePromise(win);
+}
+
+async function restoreWindow(win) {
+ let cb;
+ if (lazy.WindowState.from(win.windowState) == lazy.WindowState.Normal) {
+ return;
+ }
+ // Use a timed promise to abort if no window manager is present
+ await new lazy.TimedPromise(
+ resolve => {
+ cb = new lazy.DebounceCallback(resolve);
+ win.addEventListener("sizemodechange", cb);
+ win.restore();
+ },
+ { throws: null, timeout: TIMEOUT_NO_WINDOW_MANAGER }
+ );
+ win.removeEventListener("sizemodechange", cb);
+ await new lazy.IdlePromise(win);
+}
diff --git a/remote/marionette/element.sys.mjs b/remote/marionette/element.sys.mjs
new file mode 100644
index 0000000000..c344f7005b
--- /dev/null
+++ b/remote/marionette/element.sys.mjs
@@ -0,0 +1,1524 @@
+/* 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, {
+ assert: "chrome://remote/content/shared/webdriver/Assert.sys.mjs",
+ atom: "chrome://remote/content/marionette/atom.sys.mjs",
+ error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs",
+ PollPromise: "chrome://remote/content/marionette/sync.sys.mjs",
+ pprint: "chrome://remote/content/shared/Format.sys.mjs",
+});
+
+const ORDERED_NODE_ITERATOR_TYPE = 5;
+const FIRST_ORDERED_NODE_TYPE = 9;
+
+const ELEMENT_NODE = 1;
+
+const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
+
+/** XUL elements that support checked property. */
+const XUL_CHECKED_ELS = new Set(["button", "checkbox", "toolbarbutton"]);
+
+/** XUL elements that support selected property. */
+const XUL_SELECTED_ELS = new Set([
+ "menu",
+ "menuitem",
+ "menuseparator",
+ "radio",
+ "richlistitem",
+ "tab",
+]);
+
+/**
+ * This module provides shared functionality for dealing with DOM-
+ * and web elements in Marionette.
+ *
+ * A web element is an abstraction used to identify an element when it
+ * is transported across the protocol, between remote- and local ends.
+ *
+ * Each element has an associated web element reference (a UUID) that
+ * uniquely identifies the the element across all browsing contexts. The
+ * web element reference for every element representing the same element
+ * is the same.
+ *
+ * @namespace
+ */
+export const element = {};
+
+element.Strategy = {
+ ClassName: "class name",
+ Selector: "css selector",
+ ID: "id",
+ Name: "name",
+ LinkText: "link text",
+ PartialLinkText: "partial link text",
+ TagName: "tag name",
+ XPath: "xpath",
+};
+
+/**
+ * Find a single element or a collection of elements starting at the
+ * document root or a given node.
+ *
+ * If |timeout| is above 0, an implicit search technique is used.
+ * This will wait for the duration of <var>timeout</var> for the
+ * element to appear in the DOM.
+ *
+ * See the {@link element.Strategy} enum for a full list of supported
+ * search strategies that can be passed to <var>strategy</var>.
+ *
+ * @param {Object.<string, WindowProxy>} container
+ * Window object.
+ * @param {string} strategy
+ * Search strategy whereby to locate the element(s).
+ * @param {string} selector
+ * Selector search pattern. The selector must be compatible with
+ * the chosen search <var>strategy</var>.
+ * @param {Object=} options
+ * @param {boolean=} all
+ * If true, a multi-element search selector is used and a sequence of
+ * elements will be returned, otherwise a single element. Defaults to false.
+ * @param {Element=} startNode
+ * Element to use as the root of the search.
+ * @param {number=} timeout
+ * Duration to wait before timing out the search. If <code>all</code>
+ * is false, a {@link NoSuchElementError} is thrown if unable to
+ * find the element within the timeout duration.
+ *
+ * @return {Promise.<(Element|Array.<Element>)>}
+ * Single element or a sequence of elements.
+ *
+ * @throws InvalidSelectorError
+ * If <var>strategy</var> is unknown.
+ * @throws InvalidSelectorError
+ * If <var>selector</var> is malformed.
+ * @throws NoSuchElementError
+ * If a single element is requested, this error will throw if the
+ * element is not found.
+ */
+element.find = function(container, strategy, selector, options = {}) {
+ const { all = false, startNode, timeout = 0 } = options;
+
+ let searchFn;
+ if (all) {
+ searchFn = findElements.bind(this);
+ } else {
+ searchFn = findElement.bind(this);
+ }
+
+ return new Promise((resolve, reject) => {
+ let findElements = new lazy.PollPromise(
+ (resolve, reject) => {
+ let res = find_(container, strategy, selector, searchFn, {
+ all,
+ startNode,
+ });
+ if (res.length) {
+ resolve(Array.from(res));
+ } else {
+ reject([]);
+ }
+ },
+ { timeout }
+ );
+
+ findElements.then(foundEls => {
+ // the following code ought to be moved into findElement
+ // and findElements when bug 1254486 is addressed
+ if (!all && (!foundEls || !foundEls.length)) {
+ let msg = `Unable to locate element: ${selector}`;
+ reject(new lazy.error.NoSuchElementError(msg));
+ }
+
+ if (all) {
+ resolve(foundEls);
+ }
+ resolve(foundEls[0]);
+ }, reject);
+ });
+};
+
+function find_(
+ container,
+ strategy,
+ selector,
+ searchFn,
+ { startNode = null, all = false } = {}
+) {
+ let rootNode = container.frame.document;
+
+ if (!startNode) {
+ startNode = rootNode;
+ }
+
+ let res;
+ try {
+ res = searchFn(strategy, selector, rootNode, startNode);
+ } catch (e) {
+ throw new lazy.error.InvalidSelectorError(
+ `Given ${strategy} expression "${selector}" is invalid: ${e}`
+ );
+ }
+
+ if (res) {
+ if (all) {
+ return res;
+ }
+ return [res];
+ }
+ return [];
+}
+
+/**
+ * Find a single element by XPath expression.
+ *
+ * @param {Document} document
+ * Document root.
+ * @param {Element} startNode
+ * Where in the DOM hiearchy to begin searching.
+ * @param {string} expression
+ * XPath search expression.
+ *
+ * @return {Node}
+ * First element matching <var>expression</var>.
+ */
+element.findByXPath = function(document, startNode, expression) {
+ let iter = document.evaluate(
+ expression,
+ startNode,
+ null,
+ FIRST_ORDERED_NODE_TYPE,
+ null
+ );
+ return iter.singleNodeValue;
+};
+
+/**
+ * Find elements by XPath expression.
+ *
+ * @param {Document} document
+ * Document root.
+ * @param {Element} startNode
+ * Where in the DOM hierarchy to begin searching.
+ * @param {string} expression
+ * XPath search expression.
+ *
+ * @return {Iterable.<Node>}
+ * Iterator over nodes matching <var>expression</var>.
+ */
+element.findByXPathAll = function*(document, startNode, expression) {
+ let iter = document.evaluate(
+ expression,
+ startNode,
+ null,
+ ORDERED_NODE_ITERATOR_TYPE,
+ null
+ );
+ let el = iter.iterateNext();
+ while (el) {
+ yield el;
+ el = iter.iterateNext();
+ }
+};
+
+/**
+ * Find all hyperlinks descendant of <var>startNode</var> which
+ * link text is <var>linkText</var>.
+ *
+ * @param {Element} startNode
+ * Where in the DOM hierarchy to begin searching.
+ * @param {string} linkText
+ * Link text to search for.
+ *
+ * @return {Iterable.<HTMLAnchorElement>}
+ * Sequence of link elements which text is <var>s</var>.
+ */
+element.findByLinkText = function(startNode, linkText) {
+ return filterLinks(
+ startNode,
+ link => lazy.atom.getElementText(link).trim() === linkText
+ );
+};
+
+/**
+ * Find all hyperlinks descendant of <var>startNode</var> which
+ * link text contains <var>linkText</var>.
+ *
+ * @param {Element} startNode
+ * Where in the DOM hierachy to begin searching.
+ * @param {string} linkText
+ * Link text to search for.
+ *
+ * @return {Iterable.<HTMLAnchorElement>}
+ * Iterator of link elements which text containins
+ * <var>linkText</var>.
+ */
+element.findByPartialLinkText = function(startNode, linkText) {
+ return filterLinks(startNode, link =>
+ lazy.atom.getElementText(link).includes(linkText)
+ );
+};
+
+/**
+ * Filters all hyperlinks that are descendant of <var>startNode</var>
+ * by <var>predicate</var>.
+ *
+ * @param {Element} startNode
+ * Where in the DOM hierarchy to begin searching.
+ * @param {function(HTMLAnchorElement): boolean} predicate
+ * Function that determines if given link should be included in
+ * return value or filtered away.
+ *
+ * @return {Iterable.<HTMLAnchorElement>}
+ * Iterator of link elements matching <var>predicate</var>.
+ */
+function* filterLinks(startNode, predicate) {
+ for (let link of startNode.getElementsByTagName("a")) {
+ if (predicate(link)) {
+ yield link;
+ }
+ }
+}
+
+/**
+ * Finds a single element.
+ *
+ * @param {element.Strategy} strategy
+ * Selector strategy to use.
+ * @param {string} selector
+ * Selector expression.
+ * @param {Document} document
+ * Document root.
+ * @param {Element=} startNode
+ * Optional Element from which to start searching.
+ *
+ * @return {Element}
+ * Found element.
+ *
+ * @throws {InvalidSelectorError}
+ * If strategy <var>using</var> is not recognised.
+ * @throws {Error}
+ * If selector expression <var>selector</var> is malformed.
+ */
+function findElement(strategy, selector, document, startNode = undefined) {
+ switch (strategy) {
+ case element.Strategy.ID: {
+ if (startNode.getElementById) {
+ return startNode.getElementById(selector);
+ }
+ let expr = `.//*[@id="${selector}"]`;
+ return element.findByXPath(document, startNode, expr);
+ }
+
+ case element.Strategy.Name: {
+ if (startNode.getElementsByName) {
+ return startNode.getElementsByName(selector)[0];
+ }
+ let expr = `.//*[@name="${selector}"]`;
+ return element.findByXPath(document, startNode, expr);
+ }
+
+ case element.Strategy.ClassName:
+ return startNode.getElementsByClassName(selector)[0];
+
+ case element.Strategy.TagName:
+ return startNode.getElementsByTagName(selector)[0];
+
+ case element.Strategy.XPath:
+ return element.findByXPath(document, startNode, selector);
+
+ case element.Strategy.LinkText:
+ for (let link of startNode.getElementsByTagName("a")) {
+ if (lazy.atom.getElementText(link).trim() === selector) {
+ return link;
+ }
+ }
+ return undefined;
+
+ case element.Strategy.PartialLinkText:
+ for (let link of startNode.getElementsByTagName("a")) {
+ if (lazy.atom.getElementText(link).includes(selector)) {
+ return link;
+ }
+ }
+ return undefined;
+
+ case element.Strategy.Selector:
+ try {
+ return startNode.querySelector(selector);
+ } catch (e) {
+ throw new lazy.error.InvalidSelectorError(
+ `${e.message}: "${selector}"`
+ );
+ }
+ }
+
+ throw new lazy.error.InvalidSelectorError(`No such strategy: ${strategy}`);
+}
+
+/**
+ * Find multiple elements.
+ *
+ * @param {element.Strategy} strategy
+ * Selector strategy to use.
+ * @param {string} selector
+ * Selector expression.
+ * @param {Document} document
+ * Document root.
+ * @param {Element=} startNode
+ * Optional Element from which to start searching.
+ *
+ * @return {Array.<Element>}
+ * Found elements.
+ *
+ * @throws {InvalidSelectorError}
+ * If strategy <var>strategy</var> is not recognised.
+ * @throws {Error}
+ * If selector expression <var>selector</var> is malformed.
+ */
+function findElements(strategy, selector, document, startNode = undefined) {
+ switch (strategy) {
+ case element.Strategy.ID:
+ selector = `.//*[@id="${selector}"]`;
+
+ // fall through
+ case element.Strategy.XPath:
+ return [...element.findByXPathAll(document, startNode, selector)];
+
+ case element.Strategy.Name:
+ if (startNode.getElementsByName) {
+ return startNode.getElementsByName(selector);
+ }
+ return [
+ ...element.findByXPathAll(
+ document,
+ startNode,
+ `.//*[@name="${selector}"]`
+ ),
+ ];
+
+ case element.Strategy.ClassName:
+ return startNode.getElementsByClassName(selector);
+
+ case element.Strategy.TagName:
+ return startNode.getElementsByTagName(selector);
+
+ case element.Strategy.LinkText:
+ return [...element.findByLinkText(startNode, selector)];
+
+ case element.Strategy.PartialLinkText:
+ return [...element.findByPartialLinkText(startNode, selector)];
+
+ case element.Strategy.Selector:
+ return startNode.querySelectorAll(selector);
+
+ default:
+ throw new lazy.error.InvalidSelectorError(
+ `No such strategy: ${strategy}`
+ );
+ }
+}
+
+/**
+ * Finds the closest parent node of <var>startNode</var> matching a CSS
+ * <var>selector</var> expression.
+ *
+ * @param {Node} startNode
+ * Cycle through <var>startNode</var>'s parent nodes in tree-order
+ * and return the first match to <var>selector</var>.
+ * @param {string} selector
+ * CSS selector expression.
+ *
+ * @return {Node=}
+ * First match to <var>selector</var>, or null if no match was found.
+ */
+element.findClosest = function(startNode, selector) {
+ let node = startNode;
+ while (node.parentNode && node.parentNode.nodeType == ELEMENT_NODE) {
+ node = node.parentNode;
+ if (node.matches(selector)) {
+ return node;
+ }
+ }
+ return null;
+};
+
+/**
+ * Resolve element from specified web element reference.
+ *
+ * @param {ElementIdentifier} id
+ * The WebElement reference identifier for a DOM element.
+ * @param {NodeCache} nodeCache
+ * Node cache that holds already seen WebElement and ShadowRoot references.
+ * @param {WindowProxy} win
+ * Current window, which may differ from the associated
+ * window of <var>el</var>.
+ *
+ * @return {Element|null} The DOM element that the identifier was generated
+ * for, or null if the element does not still exist.
+ *
+ * @throws {NoSuchElementError}
+ * If element represented by reference <var>id</var> doesn't exist
+ * in the current browsing context.
+ * @throws {StaleElementReferenceError}
+ * If the element has gone stale, indicating its node document is no
+ * longer the active document or it is no longer attached to the DOM.
+ */
+element.resolveElement = function(id, nodeCache, win) {
+ const el = nodeCache.resolve(id);
+
+ // For WebDriver classic only elements from the same browsing context
+ // are allowed to be accessed.
+ if (el?.ownerGlobal) {
+ if (win === undefined) {
+ throw new TypeError(
+ "Expected a valid window to resolve the element reference of " +
+ lazy.pprint`${el || JSON.stringify(id.webElRef)}`
+ );
+ }
+
+ const elementBrowsingContext = el.ownerGlobal.browsingContext;
+ let sameBrowsingContext = true;
+
+ if (elementBrowsingContext.top === elementBrowsingContext) {
+ // Cross-group navigations cause a swap of the current top-level browsing
+ // context. The only unique identifier is the browser id the browsing
+ // context actually lives in. If it's equal also treat the browsing context
+ // as the same (bug 1690308).
+ // If the element's browsing context is a top-level browsing context,
+ sameBrowsingContext =
+ elementBrowsingContext.browserId == win.browsingContext.browserId;
+ } else {
+ // For non top-level browsing contexts check for equality directly.
+ sameBrowsingContext = elementBrowsingContext.id == win.browsingContext.id;
+ }
+
+ if (!sameBrowsingContext) {
+ throw new lazy.error.NoSuchElementError(
+ lazy.pprint`The element reference of ${el ||
+ JSON.stringify(id.webElRef)} ` +
+ "is not known in the current browsing context"
+ );
+ }
+ }
+
+ if (element.isStale(el)) {
+ throw new lazy.error.StaleElementReferenceError(
+ lazy.pprint`The element reference of ${el ||
+ JSON.stringify(id.webElRef)} ` +
+ "is stale; either its node document is not the active document, " +
+ "or it is no longer connected to the DOM"
+ );
+ }
+
+ return el;
+};
+
+/**
+ * Determines if <var>obj<var> is an HTML or JS collection.
+ *
+ * @param {Object} seq
+ * Type to determine.
+ *
+ * @return {boolean}
+ * True if <var>seq</va> is a collection.
+ */
+element.isCollection = function(seq) {
+ switch (Object.prototype.toString.call(seq)) {
+ case "[object Arguments]":
+ case "[object Array]":
+ case "[object FileList]":
+ case "[object HTMLAllCollection]":
+ case "[object HTMLCollection]":
+ case "[object HTMLFormControlsCollection]":
+ case "[object HTMLOptionsCollection]":
+ case "[object NodeList]":
+ return true;
+
+ default:
+ return false;
+ }
+};
+
+/**
+ * Determines if <var>el</var> is stale.
+ *
+ * An element is stale if its node document is not the active document
+ * or if it is not connected.
+ *
+ * @param {Element=} el
+ * Element to check for staleness. If null, which may be
+ * the case if the element has been unwrapped from a weak
+ * reference, it is always considered stale.
+ *
+ * @return {boolean}
+ * True if <var>el</var> is stale, false otherwise.
+ */
+element.isStale = function(el) {
+ if (el == null || !el.ownerGlobal) {
+ // Without a valid inner window the document is basically closed.
+ return true;
+ }
+
+ return !el.ownerGlobal.document.isActive() || !el.isConnected;
+};
+
+/**
+ * Determine if <var>el</var> is selected or not.
+ *
+ * This operation only makes sense on
+ * <tt>&lt;input type=checkbox&gt;</tt>,
+ * <tt>&lt;input type=radio&gt;</tt>,
+ * and <tt>&gt;option&gt;</tt> elements.
+ *
+ * @param {Element} el
+ * Element to test if selected.
+ *
+ * @return {boolean}
+ * True if element is selected, false otherwise.
+ */
+element.isSelected = function(el) {
+ if (!el) {
+ return false;
+ }
+
+ if (element.isXULElement(el)) {
+ if (XUL_CHECKED_ELS.has(el.tagName)) {
+ return el.checked;
+ } else if (XUL_SELECTED_ELS.has(el.tagName)) {
+ return el.selected;
+ }
+ } else if (element.isDOMElement(el)) {
+ if (el.localName == "input" && ["checkbox", "radio"].includes(el.type)) {
+ return el.checked;
+ } else if (el.localName == "option") {
+ return el.selected;
+ }
+ }
+
+ return false;
+};
+
+/**
+ * An element is considered read only if it is an
+ * <code>&lt;input&gt;</code> or <code>&lt;textarea&gt;</code>
+ * element whose <code>readOnly</code> content IDL attribute is set.
+ *
+ * @param {Element} el
+ * Element to test is read only.
+ *
+ * @return {boolean}
+ * True if element is read only.
+ */
+element.isReadOnly = function(el) {
+ return (
+ element.isDOMElement(el) &&
+ ["input", "textarea"].includes(el.localName) &&
+ el.readOnly
+ );
+};
+
+/**
+ * An element is considered disabled if it is a an element
+ * that can be disabled, or it belongs to a container group which
+ * <code>disabled</code> content IDL attribute affects it.
+ *
+ * @param {Element} el
+ * Element to test for disabledness.
+ *
+ * @return {boolean}
+ * True if element, or its container group, is disabled.
+ */
+element.isDisabled = function(el) {
+ if (!element.isDOMElement(el)) {
+ return false;
+ }
+
+ switch (el.localName) {
+ case "option":
+ case "optgroup":
+ if (el.disabled) {
+ return true;
+ }
+ let parent = element.findClosest(el, "optgroup,select");
+ return element.isDisabled(parent);
+
+ case "button":
+ case "input":
+ case "select":
+ case "textarea":
+ return el.disabled;
+
+ default:
+ return false;
+ }
+};
+
+/**
+ * Denotes elements that can be used for typing and clearing.
+ *
+ * Elements that are considered WebDriver-editable are non-readonly
+ * and non-disabled <code>&lt;input&gt;</code> elements in the Text,
+ * Search, URL, Telephone, Email, Password, Date, Month, Date and
+ * Time Local, Number, Range, Color, and File Upload states, and
+ * <code>&lt;textarea&gt;</code> elements.
+ *
+ * @param {Element} el
+ * Element to test.
+ *
+ * @return {boolean}
+ * True if editable, false otherwise.
+ */
+element.isMutableFormControl = function(el) {
+ if (!element.isDOMElement(el)) {
+ return false;
+ }
+ if (element.isReadOnly(el) || element.isDisabled(el)) {
+ return false;
+ }
+
+ if (el.localName == "textarea") {
+ return true;
+ }
+
+ if (el.localName != "input") {
+ return false;
+ }
+
+ switch (el.type) {
+ case "color":
+ case "date":
+ case "datetime-local":
+ case "email":
+ case "file":
+ case "month":
+ case "number":
+ case "password":
+ case "range":
+ case "search":
+ case "tel":
+ case "text":
+ case "time":
+ case "url":
+ case "week":
+ return true;
+
+ default:
+ return false;
+ }
+};
+
+/**
+ * An editing host is a node that is either an HTML element with a
+ * <code>contenteditable</code> attribute, or the HTML element child
+ * of a document whose <code>designMode</code> is enabled.
+ *
+ * @param {Element} el
+ * Element to determine if is an editing host.
+ *
+ * @return {boolean}
+ * True if editing host, false otherwise.
+ */
+element.isEditingHost = function(el) {
+ return (
+ element.isDOMElement(el) &&
+ (el.isContentEditable || el.ownerDocument.designMode == "on")
+ );
+};
+
+/**
+ * Determines if an element is editable according to WebDriver.
+ *
+ * An element is considered editable if it is not read-only or
+ * disabled, and one of the following conditions are met:
+ *
+ * <ul>
+ * <li>It is a <code>&lt;textarea&gt;</code> element.
+ *
+ * <li>It is an <code>&lt;input&gt;</code> element that is not of
+ * the <code>checkbox</code>, <code>radio</code>, <code>hidden</code>,
+ * <code>submit</code>, <code>button</code>, or <code>image</code> types.
+ *
+ * <li>It is content-editable.
+ *
+ * <li>It belongs to a document in design mode.
+ * </ul>
+ *
+ * @param {Element}
+ * Element to test if editable.
+ *
+ * @return {boolean}
+ * True if editable, false otherwise.
+ */
+element.isEditable = function(el) {
+ if (!element.isDOMElement(el)) {
+ return false;
+ }
+
+ if (element.isReadOnly(el) || element.isDisabled(el)) {
+ return false;
+ }
+
+ return element.isMutableFormControl(el) || element.isEditingHost(el);
+};
+
+/**
+ * This function generates a pair of coordinates relative to the viewport
+ * given a target element and coordinates relative to that element's
+ * top-left corner.
+ *
+ * @param {Node} node
+ * Target node.
+ * @param {number=} xOffset
+ * Horizontal offset relative to target's top-left corner.
+ * Defaults to the centre of the target's bounding box.
+ * @param {number=} yOffset
+ * Vertical offset relative to target's top-left corner. Defaults to
+ * the centre of the target's bounding box.
+ *
+ * @return {Object.<string, number>}
+ * X- and Y coordinates.
+ *
+ * @throws TypeError
+ * If <var>xOffset</var> or <var>yOffset</var> are not numbers.
+ */
+element.coordinates = function(node, xOffset = undefined, yOffset = undefined) {
+ let box = node.getBoundingClientRect();
+
+ if (typeof xOffset == "undefined" || xOffset === null) {
+ xOffset = box.width / 2.0;
+ }
+ if (typeof yOffset == "undefined" || yOffset === null) {
+ yOffset = box.height / 2.0;
+ }
+
+ if (typeof yOffset != "number" || typeof xOffset != "number") {
+ throw new TypeError("Offset must be a number");
+ }
+
+ return {
+ x: box.left + xOffset,
+ y: box.top + yOffset,
+ };
+};
+
+/**
+ * This function returns true if the node is in the viewport.
+ *
+ * @param {Element} el
+ * Target element.
+ * @param {number=} x
+ * Horizontal offset relative to target. Defaults to the centre of
+ * the target's bounding box.
+ * @param {number=} y
+ * Vertical offset relative to target. Defaults to the centre of
+ * the target's bounding box.
+ *
+ * @return {boolean}
+ * True if if <var>el</var> is in viewport, false otherwise.
+ */
+element.inViewport = function(el, x = undefined, y = undefined) {
+ let win = el.ownerGlobal;
+ let c = element.coordinates(el, x, y);
+ let vp = {
+ top: win.pageYOffset,
+ left: win.pageXOffset,
+ bottom: win.pageYOffset + win.innerHeight,
+ right: win.pageXOffset + win.innerWidth,
+ };
+
+ return (
+ vp.left <= c.x + win.pageXOffset &&
+ c.x + win.pageXOffset <= vp.right &&
+ vp.top <= c.y + win.pageYOffset &&
+ c.y + win.pageYOffset <= vp.bottom
+ );
+};
+
+/**
+ * Gets the element's container element.
+ *
+ * An element container is defined by the WebDriver
+ * specification to be an <tt>&lt;option&gt;</tt> element in a
+ * <a href="https://html.spec.whatwg.org/#concept-element-contexts">valid
+ * element context</a>, meaning that it has an ancestral element
+ * that is either <tt>&lt;datalist&gt;</tt> or <tt>&lt;select&gt;</tt>.
+ *
+ * If the element does not have a valid context, its container element
+ * is itself.
+ *
+ * @param {Element} el
+ * Element to get the container of.
+ *
+ * @return {Element}
+ * Container element of <var>el</var>.
+ */
+element.getContainer = function(el) {
+ // Does <option> or <optgroup> have a valid context,
+ // meaning is it a child of <datalist> or <select>?
+ if (["option", "optgroup"].includes(el.localName)) {
+ return element.findClosest(el, "datalist,select") || el;
+ }
+
+ return el;
+};
+
+/**
+ * An element is in view if it is a member of its own pointer-interactable
+ * paint tree.
+ *
+ * This means an element is considered to be in view, but not necessarily
+ * pointer-interactable, if it is found somewhere in the
+ * <code>elementsFromPoint</code> list at <var>el</var>'s in-view
+ * centre coordinates.
+ *
+ * Before running the check, we change <var>el</var>'s pointerEvents
+ * style property to "auto", since elements without pointer events
+ * enabled do not turn up in the paint tree we get from
+ * document.elementsFromPoint. This is a specialisation that is only
+ * relevant when checking if the element is in view.
+ *
+ * @param {Element} el
+ * Element to check if is in view.
+ *
+ * @return {boolean}
+ * True if <var>el</var> is inside the viewport, or false otherwise.
+ */
+element.isInView = function(el) {
+ let originalPointerEvents = el.style.pointerEvents;
+
+ try {
+ el.style.pointerEvents = "auto";
+ const tree = element.getPointerInteractablePaintTree(el);
+
+ // Bug 1413493 - <tr> is not part of the returned paint tree yet. As
+ // workaround check the visibility based on the first contained cell.
+ if (el.localName === "tr" && el.cells && el.cells.length) {
+ return tree.includes(el.cells[0]);
+ }
+
+ return tree.includes(el);
+ } finally {
+ el.style.pointerEvents = originalPointerEvents;
+ }
+};
+
+/**
+ * Generates a unique identifier.
+ *
+ * The generated uuid will not contain the curly braces.
+ *
+ * @return {string}
+ * UUID.
+ */
+element.generateUUID = function() {
+ return Services.uuid
+ .generateUUID()
+ .toString()
+ .slice(1, -1);
+};
+
+/**
+ * This function throws the visibility of the element error if the element is
+ * not displayed or the given coordinates are not within the viewport.
+ *
+ * @param {Element} el
+ * Element to check if visible.
+ * @param {number=} x
+ * Horizontal offset relative to target. Defaults to the centre of
+ * the target's bounding box.
+ * @param {number=} y
+ * Vertical offset relative to target. Defaults to the centre of
+ * the target's bounding box.
+ *
+ * @return {boolean}
+ * True if visible, false otherwise.
+ */
+element.isVisible = function(el, x = undefined, y = undefined) {
+ let win = el.ownerGlobal;
+
+ if (!lazy.atom.isElementDisplayed(el, win)) {
+ return false;
+ }
+
+ if (el.tagName.toLowerCase() == "body") {
+ return true;
+ }
+
+ if (!element.inViewport(el, x, y)) {
+ element.scrollIntoView(el);
+ if (!element.inViewport(el)) {
+ return false;
+ }
+ }
+ return true;
+};
+
+/**
+ * A pointer-interactable element is defined to be the first
+ * non-transparent element, defined by the paint order found at the centre
+ * point of its rectangle that is inside the viewport, excluding the size
+ * of any rendered scrollbars.
+ *
+ * An element is obscured if the pointer-interactable paint tree at its
+ * centre point is empty, or the first element in this tree is not an
+ * inclusive descendant of itself.
+ *
+ * @param {DOMElement} el
+ * Element determine if is pointer-interactable.
+ *
+ * @return {boolean}
+ * True if element is obscured, false otherwise.
+ */
+element.isObscured = function(el) {
+ let tree = element.getPointerInteractablePaintTree(el);
+ return !el.contains(tree[0]);
+};
+
+// TODO(ato): Only used by deprecated action API
+// https://bugzil.la/1354578
+/**
+ * Calculates the in-view centre point of an element's client rect.
+ *
+ * The portion of an element that is said to be _in view_, is the
+ * intersection of two squares: the first square being the initial
+ * viewport, and the second a DOM element. From this square we
+ * calculate the in-view _centre point_ and convert it into CSS pixels.
+ *
+ * Although Gecko's system internals allow click points to be
+ * given in floating point precision, the DOM operates in CSS pixels.
+ * When the in-view centre point is later used to retrieve a coordinate's
+ * paint tree, we need to ensure to operate in the same language.
+ *
+ * As a word of warning, there appears to be inconsistencies between
+ * how `DOMElement.elementsFromPoint` and `DOMWindowUtils.sendMouseEvent`
+ * internally rounds (ceils/floors) coordinates.
+ *
+ * @param {DOMRect} rect
+ * Element off a DOMRect sequence produced by calling
+ * `getClientRects` on an {@link Element}.
+ * @param {WindowProxy} win
+ * Current window global.
+ *
+ * @return {Map.<string, number>}
+ * X and Y coordinates that denotes the in-view centre point of
+ * `rect`.
+ */
+element.getInViewCentrePoint = function(rect, win) {
+ const { floor, max, min } = Math;
+
+ // calculate the intersection of the rect that is inside the viewport
+ let visible = {
+ left: max(0, min(rect.x, rect.x + rect.width)),
+ right: min(win.innerWidth, max(rect.x, rect.x + rect.width)),
+ top: max(0, min(rect.y, rect.y + rect.height)),
+ bottom: min(win.innerHeight, max(rect.y, rect.y + rect.height)),
+ };
+
+ // arrive at the centre point of the visible rectangle
+ let x = (visible.left + visible.right) / 2.0;
+ let y = (visible.top + visible.bottom) / 2.0;
+
+ // convert to CSS pixels, as centre point can be float
+ x = floor(x);
+ y = floor(y);
+
+ return { x, y };
+};
+
+/**
+ * Produces a pointer-interactable elements tree from a given element.
+ *
+ * The tree is defined by the paint order found at the centre point of
+ * the element's rectangle that is inside the viewport, excluding the size
+ * of any rendered scrollbars.
+ *
+ * @param {DOMElement} el
+ * Element to determine if is pointer-interactable.
+ *
+ * @return {Array.<DOMElement>}
+ * Sequence of elements in paint order.
+ */
+element.getPointerInteractablePaintTree = function(el) {
+ const doc = el.ownerDocument;
+ const win = doc.defaultView;
+ const rootNode = el.getRootNode();
+
+ // pointer-interactable elements tree, step 1
+ if (!el.isConnected) {
+ return [];
+ }
+
+ // steps 2-3
+ let rects = el.getClientRects();
+ if (!rects.length) {
+ return [];
+ }
+
+ // step 4
+ let centre = element.getInViewCentrePoint(rects[0], win);
+
+ // step 5
+ return rootNode.elementsFromPoint(centre.x, centre.y);
+};
+
+// TODO(ato): Not implemented.
+// In fact, it's not defined in the spec.
+element.isKeyboardInteractable = () => true;
+
+/**
+ * Attempts to scroll into view |el|.
+ *
+ * @param {DOMElement} el
+ * Element to scroll into view.
+ */
+element.scrollIntoView = function(el) {
+ if (el.scrollIntoView) {
+ el.scrollIntoView({ block: "end", inline: "nearest" });
+ }
+};
+
+/**
+ * Ascertains whether <var>obj</var> is a DOM-, SVG-, or XUL element.
+ *
+ * @param {Object} obj
+ * Object thought to be an <code>Element</code> or
+ * <code>XULElement</code>.
+ *
+ * @return {boolean}
+ * True if <var>obj</var> is an element, false otherwise.
+ */
+element.isElement = function(obj) {
+ return element.isDOMElement(obj) || element.isXULElement(obj);
+};
+
+/**
+ * Returns the shadow root of an element.
+ *
+ * @param {Element} el
+ * Element thought to have a <code>shadowRoot</code>
+ * @returns {ShadowRoot}
+ * Shadow root of the element.
+ */
+element.getShadowRoot = function(el) {
+ const shadowRoot = el.openOrClosedShadowRoot;
+ if (!shadowRoot) {
+ throw new lazy.error.NoSuchShadowRootError();
+ }
+ return shadowRoot;
+};
+
+/**
+ * Ascertains whether <var>obj</var> is a shadow root.
+ *
+ * @param {ShadowRoot} obj
+ * The node that will be checked to see if it has a shadow root
+ *
+ * @returns {boolean}
+ * True if <var>obj</var> is a shadow root, false otherwise.
+ */
+element.isShadowRoot = function(obj) {
+ return (
+ obj !== null && typeof obj == "object" && obj.containingShadowRoot == obj
+ );
+};
+
+/**
+ * Ascertains whether <var>obj</var> is a DOM element.
+ *
+ * @param {Object} obj
+ * Object to check.
+ *
+ * @return {boolean}
+ * True if <var>obj</var> is a DOM element, false otherwise.
+ */
+element.isDOMElement = function(obj) {
+ return (
+ typeof obj == "object" &&
+ obj !== null &&
+ "nodeType" in obj &&
+ obj.nodeType == ELEMENT_NODE &&
+ !element.isXULElement(obj)
+ );
+};
+
+/**
+ * Ascertains whether <var>obj</var> is a XUL element.
+ *
+ * @param {Object} obj
+ * Object to check.
+ *
+ * @return {boolean}
+ * True if <var>obj</var> is a XULElement, false otherwise.
+ */
+element.isXULElement = function(obj) {
+ return (
+ typeof obj == "object" &&
+ obj !== null &&
+ "nodeType" in obj &&
+ obj.nodeType === obj.ELEMENT_NODE &&
+ obj.namespaceURI === XUL_NS
+ );
+};
+
+/**
+ * Ascertains whether <var>node</var> is in a privileged document.
+ *
+ * @param {Node} node
+ * Node to check.
+ *
+ * @return {boolean}
+ * True if <var>node</var> is in a privileged document,
+ * false otherwise.
+ */
+element.isInPrivilegedDocument = function(node) {
+ return !!node?.nodePrincipal?.isSystemPrincipal;
+};
+
+/**
+ * Ascertains whether <var>obj</var> is a <code>WindowProxy</code>.
+ *
+ * @param {Object} obj
+ * Object to check.
+ *
+ * @return {boolean}
+ * True if <var>obj</var> is a DOM window.
+ */
+element.isDOMWindow = function(obj) {
+ // TODO(ato): This should use Object.prototype.toString.call(node)
+ // but it's not clear how to write a good xpcshell test for that,
+ // seeing as we stub out a WindowProxy.
+ return (
+ typeof obj == "object" &&
+ obj !== null &&
+ typeof obj.toString == "function" &&
+ obj.toString() == "[object Window]" &&
+ obj.self === obj
+ );
+};
+
+const boolEls = {
+ audio: ["autoplay", "controls", "loop", "muted"],
+ button: ["autofocus", "disabled", "formnovalidate"],
+ details: ["open"],
+ dialog: ["open"],
+ fieldset: ["disabled"],
+ form: ["novalidate"],
+ iframe: ["allowfullscreen"],
+ img: ["ismap"],
+ input: [
+ "autofocus",
+ "checked",
+ "disabled",
+ "formnovalidate",
+ "multiple",
+ "readonly",
+ "required",
+ ],
+ keygen: ["autofocus", "disabled"],
+ menuitem: ["checked", "default", "disabled"],
+ ol: ["reversed"],
+ optgroup: ["disabled"],
+ option: ["disabled", "selected"],
+ script: ["async", "defer"],
+ select: ["autofocus", "disabled", "multiple", "required"],
+ textarea: ["autofocus", "disabled", "readonly", "required"],
+ track: ["default"],
+ video: ["autoplay", "controls", "loop", "muted"],
+};
+
+/**
+ * Tests if the attribute is a boolean attribute on element.
+ *
+ * @param {Element} el
+ * Element to test if <var>attr</var> is a boolean attribute on.
+ * @param {string} attr
+ * Attribute to test is a boolean attribute.
+ *
+ * @return {boolean}
+ * True if the attribute is boolean, false otherwise.
+ */
+element.isBooleanAttribute = function(el, attr) {
+ if (!element.isDOMElement(el)) {
+ return false;
+ }
+
+ // global boolean attributes that apply to all HTML elements,
+ // except for custom elements
+ const customElement = !el.localName.includes("-");
+ if ((attr == "hidden" || attr == "itemscope") && customElement) {
+ return true;
+ }
+
+ if (!boolEls.hasOwnProperty(el.localName)) {
+ return false;
+ }
+ return boolEls[el.localName].includes(attr);
+};
+
+/**
+ * A web reference is an abstraction used to identify an element when
+ * it is transported via the protocol, between remote- and local ends.
+ *
+ * In Marionette this abstraction can represent DOM elements,
+ * WindowProxies, and XUL elements.
+ */
+export class WebReference {
+ /**
+ * @param {string} uuid
+ * Identifier that must be unique across all browsing contexts
+ * for the contract to be upheld.
+ */
+ constructor(uuid) {
+ this.uuid = lazy.assert.string(uuid);
+ }
+
+ /**
+ * Performs an equality check between this web element and
+ * <var>other</var>.
+ *
+ * @param {WebReference} other
+ * Web element to compare with this.
+ *
+ * @return {boolean}
+ * True if this and <var>other</var> are the same. False
+ * otherwise.
+ */
+ is(other) {
+ return other instanceof WebReference && this.uuid === other.uuid;
+ }
+
+ toString() {
+ return `[object ${this.constructor.name} uuid=${this.uuid}]`;
+ }
+
+ /**
+ * Returns a new {@link WebReference} reference for a DOM or XUL element,
+ * <code>WindowProxy</code>, or <code>ShadowRoot</code>.
+ *
+ * @param {(Element|ShadowRoot|WindowProxy|XULElement)} node
+ * Node to construct a web element reference for.
+ * @param {string=} uuid
+ * Optional unique identifier of the WebReference if already known.
+ * If not defined a new unique identifier will be created.
+ *
+ * @return {WebReference)}
+ * Web reference for <var>node</var>.
+ *
+ * @throws {InvalidArgumentError}
+ * If <var>node</var> is neither a <code>WindowProxy</code>,
+ * DOM or XUL element, or <code>ShadowRoot</code>.
+ */
+ static from(node, uuid) {
+ if (uuid === undefined) {
+ uuid = element.generateUUID();
+ }
+
+ if (element.isShadowRoot(node) && !element.isInPrivilegedDocument(node)) {
+ // When we support Chrome Shadowroots we will need to
+ // do a check here of shadowroot.host being in a privileged document
+ // See Bug 1743541
+ return new ShadowRoot(uuid);
+ } else if (element.isElement(node)) {
+ return new WebElement(uuid);
+ } else if (element.isDOMWindow(node)) {
+ if (node.parent === node) {
+ return new WebWindow(uuid);
+ }
+ return new WebFrame(uuid);
+ }
+
+ throw new lazy.error.InvalidArgumentError(
+ "Expected DOM window/element " + lazy.pprint`or XUL element, got: ${node}`
+ );
+ }
+
+ /**
+ * Unmarshals a JSON Object to one of {@link ShadowRoot}, {@link WebElement},
+ * {@link WebFrame}, or {@link WebWindow}.
+ *
+ * @param {Object.<string, string>} json
+ * Web reference, which is supposed to be a JSON Object
+ * where the key is one of the {@link WebReference} concrete
+ * classes' UUID identifiers.
+ *
+ * @return {WebReference}
+ * Web reference for the JSON object.
+ *
+ * @throws {InvalidArgumentError}
+ * If <var>json</var> is not a web reference.
+ */
+ static fromJSON(json) {
+ lazy.assert.object(json);
+ if (json instanceof WebReference) {
+ return json;
+ }
+ let keys = Object.keys(json);
+
+ for (let key of keys) {
+ switch (key) {
+ case ShadowRoot.Identifier:
+ return ShadowRoot.fromJSON(json);
+
+ case WebElement.Identifier:
+ return WebElement.fromJSON(json);
+
+ case WebFrame.Identifier:
+ return WebFrame.fromJSON(json);
+
+ case WebWindow.Identifier:
+ return WebWindow.fromJSON(json);
+ }
+ }
+
+ throw new lazy.error.InvalidArgumentError(
+ lazy.pprint`Expected web reference, got: ${json}`
+ );
+ }
+
+ /**
+ * Constructs a {@link WebElement} from a string <var>uuid</var>.
+ *
+ * This whole function is a workaround for the fact that clients
+ * to Marionette occasionally pass <code>{id: <uuid>}</code> JSON
+ * Objects instead of web element representations.
+ *
+ * @param {string} uuid
+ * UUID to be associated with the web reference.
+ *
+ * @return {WebElement}
+ * The web element reference.
+ *
+ * @throws {InvalidArgumentError}
+ * If <var>uuid</var> is not a string.
+ */
+ static fromUUID(uuid) {
+ lazy.assert.string(uuid);
+
+ return new WebElement(uuid);
+ }
+
+ /**
+ * Checks if <var>obj<var> is a {@link WebReference} reference.
+ *
+ * @param {Object.<string, string>} obj
+ * Object that represents a {@link WebReference}.
+ *
+ * @return {boolean}
+ * True if <var>obj</var> is a {@link WebReference}, false otherwise.
+ */
+ static isReference(obj) {
+ if (Object.prototype.toString.call(obj) != "[object Object]") {
+ return false;
+ }
+
+ if (
+ ShadowRoot.Identifier in obj ||
+ WebElement.Identifier in obj ||
+ WebFrame.Identifier in obj ||
+ WebWindow.Identifier in obj
+ ) {
+ return true;
+ }
+ return false;
+ }
+}
+
+/**
+ * DOM elements are represented as web elements when they are
+ * transported over the wire protocol.
+ */
+export class WebElement extends WebReference {
+ toJSON() {
+ return { [WebElement.Identifier]: this.uuid };
+ }
+
+ static fromJSON(json) {
+ const { Identifier } = WebElement;
+
+ if (!(Identifier in json)) {
+ throw new lazy.error.InvalidArgumentError(
+ lazy.pprint`Expected web element reference, got: ${json}`
+ );
+ }
+
+ let uuid = json[Identifier];
+ return new WebElement(uuid);
+ }
+}
+
+WebElement.Identifier = "element-6066-11e4-a52e-4f735466cecf";
+
+/**
+ * Shadow Root elements are represented as shadow root references when they are
+ * transported over the wire protocol
+ */
+export class ShadowRoot extends WebReference {
+ toJSON() {
+ return { [ShadowRoot.Identifier]: this.uuid };
+ }
+
+ static fromJSON(json) {
+ const { Identifier } = ShadowRoot;
+
+ if (!(Identifier in json)) {
+ throw new lazy.error.InvalidArgumentError(
+ lazy.pprint`Expected shadow root reference, got: ${json}`
+ );
+ }
+
+ let uuid = json[Identifier];
+ return new ShadowRoot(uuid);
+ }
+}
+
+ShadowRoot.Identifier = "shadow-6066-11e4-a52e-4f735466cecf";
+
+/**
+ * Top-level browsing contexts, such as <code>WindowProxy</code>
+ * whose <code>opener</code> is null, are represented as web windows
+ * over the wire protocol.
+ */
+export class WebWindow extends WebReference {
+ toJSON() {
+ return { [WebWindow.Identifier]: this.uuid };
+ }
+
+ static fromJSON(json) {
+ if (!(WebWindow.Identifier in json)) {
+ throw new lazy.error.InvalidArgumentError(
+ lazy.pprint`Expected web window reference, got: ${json}`
+ );
+ }
+ let uuid = json[WebWindow.Identifier];
+ return new WebWindow(uuid);
+ }
+}
+
+WebWindow.Identifier = "window-fcc6-11e5-b4f8-330a88ab9d7f";
+
+/**
+ * Nested browsing contexts, such as the <code>WindowProxy</code>
+ * associated with <tt>&lt;frame&gt;</tt> and <tt>&lt;iframe&gt;</tt>,
+ * are represented as web frames over the wire protocol.
+ */
+export class WebFrame extends WebReference {
+ toJSON() {
+ return { [WebFrame.Identifier]: this.uuid };
+ }
+
+ static fromJSON(json) {
+ if (!(WebFrame.Identifier in json)) {
+ throw new lazy.error.InvalidArgumentError(
+ lazy.pprint`Expected web frame reference, got: ${json}`
+ );
+ }
+ let uuid = json[WebFrame.Identifier];
+ return new WebFrame(uuid);
+ }
+}
+
+WebFrame.Identifier = "frame-075b-4da1-b6ba-e579c2d3230a";
diff --git a/remote/marionette/evaluate.sys.mjs b/remote/marionette/evaluate.sys.mjs
new file mode 100644
index 0000000000..a43908e7ad
--- /dev/null
+++ b/remote/marionette/evaluate.sys.mjs
@@ -0,0 +1,354 @@
+/* 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 { clearTimeout, setTimeout } from "resource://gre/modules/Timer.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs",
+});
+
+const ARGUMENTS = "__webDriverArguments";
+const CALLBACK = "__webDriverCallback";
+const COMPLETE = "__webDriverComplete";
+const DEFAULT_TIMEOUT = 10000; // ms
+const FINISH = "finish";
+
+/** @namespace */
+export const evaluate = {};
+
+/**
+ * Evaluate a script in given sandbox.
+ *
+ * The the provided `script` will be wrapped in an anonymous function
+ * with the `args` argument applied.
+ *
+ * The arguments provided by the `args<` argument are exposed
+ * through the `arguments` object available in the script context,
+ * and if the script is executed asynchronously with the `async`
+ * option, an additional last argument that is synonymous to the
+ * name `resolve` is appended, and can be accessed
+ * through `arguments[arguments.length - 1]`.
+ *
+ * The `timeout` option specifies the duration for how long the
+ * script should be allowed to run before it is interrupted and aborted.
+ * An interrupted script will cause a {@link ScriptTimeoutError} to occur.
+ *
+ * The `async` option indicates that the script will not return
+ * until the `resolve` callback is invoked,
+ * which is analogous to the last argument of the `arguments` object.
+ *
+ * The `file` option is used in error messages to provide information
+ * on the origin script file in the local end.
+ *
+ * The `line` option is used in error messages, along with `filename`,
+ * to provide the line number in the origin script file on the local end.
+ *
+ * @param {nsISandbox} sb
+ * Sandbox the script will be evaluted in.
+ * @param {string} script
+ * Script to evaluate.
+ * @param {Array.<?>=} args
+ * A sequence of arguments to call the script with.
+ * @param {boolean=} [async=false] async
+ * Indicates if the script should return immediately or wait for
+ * the callback to be invoked before returning.
+ * @param {string=} [file="dummy file"] file
+ * File location of the program in the client.
+ * @param {number=} [line=0] line
+ * Line number of th eprogram in the client.
+ * @param {number=} [timeout=DEFAULT_TIMEOUT] timeout
+ * Duration in milliseconds before interrupting the script.
+ *
+ * @return {Promise}
+ * A promise that when resolved will give you the return value from
+ * the script. Note that the return value requires serialisation before
+ * it can be sent to the client.
+ *
+ * @throws {JavaScriptError}
+ * If an {@link Error} was thrown whilst evaluating the script.
+ * @throws {ScriptTimeoutError}
+ * If the script was interrupted due to script timeout.
+ */
+evaluate.sandbox = function(
+ sb,
+ script,
+ args = [],
+ {
+ async = false,
+ file = "dummy file",
+ line = 0,
+ timeout = DEFAULT_TIMEOUT,
+ } = {}
+) {
+ let unloadHandler;
+ let marionetteSandbox = sandbox.create(sb.window);
+
+ // timeout handler
+ let scriptTimeoutID, timeoutPromise;
+ if (timeout !== null) {
+ timeoutPromise = new Promise((resolve, reject) => {
+ scriptTimeoutID = setTimeout(() => {
+ reject(
+ new lazy.error.ScriptTimeoutError(`Timed out after ${timeout} ms`)
+ );
+ }, timeout);
+ });
+ }
+
+ let promise = new Promise((resolve, reject) => {
+ let src = "";
+ sb[COMPLETE] = resolve;
+ sb[ARGUMENTS] = sandbox.cloneInto(args, sb);
+
+ // callback function made private
+ // so that introspection is possible
+ // on the arguments object
+ if (async) {
+ sb[CALLBACK] = sb[COMPLETE];
+ src += `${ARGUMENTS}.push(rv => ${CALLBACK}(rv));`;
+ }
+
+ src += `(function() {
+ ${script}
+ }).apply(null, ${ARGUMENTS})`;
+
+ unloadHandler = sandbox.cloneInto(
+ () => reject(new lazy.error.JavaScriptError("Document was unloaded")),
+ marionetteSandbox
+ );
+ marionetteSandbox.window.addEventListener("unload", unloadHandler);
+
+ let promises = [
+ Cu.evalInSandbox(
+ src,
+ sb,
+ "1.8",
+ file,
+ line,
+ /* enforceFilenameRestrictions */ false
+ ),
+ timeoutPromise,
+ ];
+
+ // Wait for the immediate result of calling evalInSandbox, or a timeout.
+ // Only resolve the promise if the scriptPromise was resolved and is not
+ // async, because the latter has to call resolve() itself.
+ Promise.race(promises).then(
+ value => {
+ if (!async) {
+ resolve(value);
+ }
+ },
+ err => {
+ reject(err);
+ }
+ );
+ });
+
+ // This block is mainly for async scripts, which escape the inner promise
+ // when calling resolve() on their own. The timeout promise will be re-used
+ // to break out after the initially setup timeout.
+ return Promise.race([promise, timeoutPromise])
+ .catch(err => {
+ // Only raise valid errors for both the sync and async scripts.
+ if (err instanceof lazy.error.ScriptTimeoutError) {
+ throw err;
+ }
+ throw new lazy.error.JavaScriptError(err);
+ })
+ .finally(() => {
+ clearTimeout(scriptTimeoutID);
+ marionetteSandbox.window.removeEventListener("unload", unloadHandler);
+ });
+};
+
+/**
+ * `Cu.isDeadWrapper` does not return true for a dead sandbox that
+ * was assosciated with and extension popup. This provides a way to
+ * still test for a dead object.
+ *
+ * @param {Object} obj
+ * A potentially dead object.
+ * @param {string} prop
+ * Name of a property on the object.
+ *
+ * @returns {boolean}
+ * True if <var>obj</var> is dead, false otherwise.
+ */
+evaluate.isDead = function(obj, prop) {
+ try {
+ obj[prop];
+ } catch (e) {
+ if (e.message.includes("dead object")) {
+ return true;
+ }
+ throw e;
+ }
+ return false;
+};
+
+export const sandbox = {};
+
+/**
+ * Provides a safe way to take an object defined in a privileged scope and
+ * create a structured clone of it in a less-privileged scope. It returns
+ * a reference to the clone.
+ *
+ * Unlike for {@link Components.utils.cloneInto}, `obj` may contain
+ * functions and DOM elements.
+ */
+sandbox.cloneInto = function(obj, sb) {
+ return Cu.cloneInto(obj, sb, { cloneFunctions: true, wrapReflectors: true });
+};
+
+/**
+ * Augment given sandbox by an adapter that has an `exports` map
+ * property, or a normal map, of function names and function references.
+ *
+ * @param {Sandbox} sb
+ * The sandbox to augment.
+ * @param {Object} adapter
+ * Object that holds an `exports` property, or a map, of function
+ * names and function references.
+ *
+ * @return {Sandbox}
+ * The augmented sandbox.
+ */
+sandbox.augment = function(sb, adapter) {
+ function* entries(obj) {
+ for (let key of Object.keys(obj)) {
+ yield [key, obj[key]];
+ }
+ }
+
+ let funcs = adapter.exports || entries(adapter);
+ for (let [name, func] of funcs) {
+ sb[name] = func;
+ }
+
+ return sb;
+};
+
+/**
+ * Creates a sandbox.
+ *
+ * @param {Window} win
+ * The DOM Window object.
+ * @param {nsIPrincipal=} principal
+ * An optional, custom principal to prefer over the Window. Useful if
+ * you need elevated security permissions.
+ *
+ * @return {Sandbox}
+ * The created sandbox.
+ */
+sandbox.create = function(win, principal = null, opts = {}) {
+ let p = principal || win;
+ opts = Object.assign(
+ {
+ sameZoneAs: win,
+ sandboxPrototype: win,
+ wantComponents: true,
+ wantXrays: true,
+ wantGlobalProperties: ["ChromeUtils"],
+ },
+ opts
+ );
+ return new Cu.Sandbox(p, opts);
+};
+
+/**
+ * Creates a mutable sandbox, where changes to the global scope
+ * will have lasting side-effects.
+ *
+ * @param {Window} win
+ * The DOM Window object.
+ *
+ * @return {Sandbox}
+ * The created sandbox.
+ */
+sandbox.createMutable = function(win) {
+ let opts = {
+ wantComponents: false,
+ wantXrays: false,
+ };
+ // Note: We waive Xrays here to match potentially-accidental old behavior.
+ return Cu.waiveXrays(sandbox.create(win, null, opts));
+};
+
+sandbox.createSystemPrincipal = function(win) {
+ let principal = Cc["@mozilla.org/systemprincipal;1"].createInstance(
+ Ci.nsIPrincipal
+ );
+ return sandbox.create(win, principal);
+};
+
+sandbox.createSimpleTest = function(win, harness) {
+ let sb = sandbox.create(win);
+ sb = sandbox.augment(sb, harness);
+ sb[FINISH] = () => sb[COMPLETE](harness.generate_results());
+ return sb;
+};
+
+/**
+ * Sandbox storage. When the user requests a sandbox by a specific name,
+ * if one exists in the storage this will be used as long as its window
+ * reference is still valid.
+ *
+ * @memberof evaluate
+ */
+export class Sandboxes {
+ /**
+ * @param {function(): Window} windowFn
+ * A function that returns the references to the current Window
+ * object.
+ */
+ constructor(windowFn) {
+ this.windowFn_ = windowFn;
+ this.boxes_ = new Map();
+ }
+
+ get window_() {
+ return this.windowFn_();
+ }
+
+ /**
+ * Factory function for getting a sandbox by name, or failing that,
+ * creating a new one.
+ *
+ * If the sandbox' window does not match the provided window, a new one
+ * will be created.
+ *
+ * @param {string} name
+ * The name of the sandbox to get or create.
+ * @param {boolean=} [fresh=false] fresh
+ * Remove old sandbox by name first, if it exists.
+ *
+ * @return {Sandbox}
+ * A used or fresh sandbox.
+ */
+ get(name = "default", fresh = false) {
+ let sb = this.boxes_.get(name);
+ if (sb) {
+ if (fresh || evaluate.isDead(sb, "window") || sb.window != this.window_) {
+ this.boxes_.delete(name);
+ return this.get(name, false);
+ }
+ } else {
+ if (name == "system") {
+ sb = sandbox.createSystemPrincipal(this.window_);
+ } else {
+ sb = sandbox.create(this.window_);
+ }
+ this.boxes_.set(name, sb);
+ }
+ return sb;
+ }
+
+ /** Clears cache of sandboxes. */
+ clear() {
+ this.boxes_.clear();
+ }
+}
diff --git a/remote/marionette/event.sys.mjs b/remote/marionette/event.sys.mjs
new file mode 100644
index 0000000000..731076d09d
--- /dev/null
+++ b/remote/marionette/event.sys.mjs
@@ -0,0 +1,312 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* eslint-disable no-restricted-globals */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ keyData: "chrome://remote/content/shared/webdriver/KeyData.sys.mjs",
+});
+
+/** Provides functionality for creating and sending DOM events. */
+export const event = {};
+
+XPCOMUtils.defineLazyGetter(lazy, "dblclickTimer", () => {
+ return Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+});
+
+const _eventUtils = new WeakMap();
+
+function _getEventUtils(win) {
+ if (!_eventUtils.has(win)) {
+ const eventUtilsObject = {
+ window: win,
+ parent: win,
+ _EU_Ci: Ci,
+ _EU_Cc: Cc,
+ };
+ Services.scriptloader.loadSubScript(
+ "chrome://remote/content/external/EventUtils.js",
+ eventUtilsObject
+ );
+ _eventUtils.set(win, eventUtilsObject);
+ }
+ return _eventUtils.get(win);
+}
+
+// Max interval between two clicks that should result in a dblclick (in ms)
+const DBLCLICK_INTERVAL = 640;
+
+event.MouseEvents = {
+ click: 0,
+ dblclick: 1,
+ mousedown: 2,
+ mouseup: 3,
+ mouseover: 4,
+ mouseout: 5,
+};
+
+event.Modifiers = {
+ shiftKey: 0,
+ ctrlKey: 1,
+ altKey: 2,
+ metaKey: 3,
+};
+
+event.MouseButton = {
+ isPrimary(button) {
+ return button === 0;
+ },
+ isAuxiliary(button) {
+ return button === 1;
+ },
+ isSecondary(button) {
+ return button === 2;
+ },
+};
+
+event.DoubleClickTracker = {
+ firstClick: false,
+ isClicked() {
+ return event.DoubleClickTracker.firstClick;
+ },
+ setClick() {
+ if (!event.DoubleClickTracker.firstClick) {
+ event.DoubleClickTracker.firstClick = true;
+ event.DoubleClickTracker.startTimer();
+ }
+ },
+ resetClick() {
+ event.DoubleClickTracker.firstClick = false;
+ event.DoubleClickTracker.cancelTimer();
+ },
+ startTimer() {
+ lazy.dblclickTimer.initWithCallback(
+ event.DoubleClickTracker.resetClick,
+ DBLCLICK_INTERVAL,
+ Ci.nsITimer.TYPE_ONE_SHOT
+ );
+ },
+ cancelTimer() {
+ lazy.dblclickTimer.cancel();
+ },
+};
+
+// Only used by legacyactions.js
+event.parseModifiers_ = function(modifiers, win) {
+ return _getEventUtils(win)._parseModifiers(modifiers);
+};
+
+/**
+ * Synthesise a mouse event at a point.
+ *
+ * If the type is specified in opts, an mouse event of that type is
+ * fired. Otherwise, a mousedown followed by a mouseup is performed.
+ *
+ * @param {number} left
+ * Offset from viewport left, in CSS pixels
+ * @param {number} top
+ * Offset from viewport top, in CSS pixels
+ * @param {Object} opts
+ * Object which may contain the properties "shiftKey", "ctrlKey",
+ * "altKey", "metaKey", "accessKey", "clickCount", "button", and
+ * "type".
+ * @param {Window} win
+ * Window object.
+ *
+ * @return {boolean} defaultPrevented
+ */
+event.synthesizeMouseAtPoint = function(left, top, opts, win) {
+ return _getEventUtils(win).synthesizeMouseAtPoint(left, top, opts, win);
+};
+
+/**
+ * Synthesise a touch event at a point.
+ *
+ * If the type is specified in opts, a touch event of that type is
+ * fired. Otherwise, a touchstart followed by a touchend is performed.
+ *
+ * @param {number} left
+ * Offset from viewport left, in CSS pixels
+ * @param {number} top
+ * Offset from viewport top, in CSS pixels
+ * @param {Object} opts
+ * Object which may contain the properties "id", "rx", "ry", "angle",
+ * "force", "shiftKey", "ctrlKey", "altKey", "metaKey", "accessKey",
+ * "type".
+ * @param {Window} win
+ * Window object.
+ *
+ * @return {boolean} defaultPrevented
+ */
+event.synthesizeTouchAtPoint = function(left, top, opts, win) {
+ return _getEventUtils(win).synthesizeTouchAtPoint(left, top, opts, win);
+};
+
+/**
+ * Synthesise a wheel scroll event at a point.
+ *
+ * @param {number} left
+ * Offset from viewport left, in CSS pixels
+ * @param {number} top
+ * Offset from viewport top, in CSS pixels
+ * @param {Object} opts
+ * Object which may contain the properties "shiftKey", "ctrlKey",
+ * "altKey", "metaKey", "accessKey", "deltaX", "deltaY", "deltaZ",
+ * "deltaMode", "lineOrPageDeltaX", "lineOrPageDeltaY", "isMomentum",
+ * "isNoLineOrPageDelta", "isCustomizedByPrefs", "expectedOverflowDeltaX",
+ * "expectedOverflowDeltaY"
+ * @param {Window} win
+ * Window object.
+ */
+event.synthesizeWheelAtPoint = function(left, top, opts, win) {
+ return _getEventUtils(win).synthesizeWheelAtPoint(left, top, opts, win);
+};
+
+event.synthesizeMultiTouch = function(opts, win) {
+ const modifiers = _getEventUtils(win)._parseModifiers(opts);
+ win.windowUtils.sendTouchEvent(
+ opts.type,
+ opts.id,
+ opts.x,
+ opts.y,
+ opts.rx,
+ opts.ry,
+ opts.angle,
+ opts.force,
+ opts.tiltx,
+ opts.tilty,
+ opts.twist,
+ modifiers
+ );
+};
+
+/**
+ * Synthesize a keydown event for a single key.
+ *
+ * @param {Object} key
+ * Key data as returned by keyData.getData
+ * @param {Window} win
+ * Window object.
+ */
+event.sendKeyDown = function(key, win) {
+ event.sendSingleKey(key, win, "keydown");
+};
+
+/**
+ * Synthesize a keyup event for a single key.
+ *
+ * @param {Object} key
+ * Key data as returned by keyData.getData
+ * @param {Window} win
+ * Window object.
+ */
+event.sendKeyUp = function(key, win) {
+ event.sendSingleKey(key, win, "keyup");
+};
+
+/**
+ * Synthesize a key event for a single key.
+ *
+ * @param {Object} key
+ * Key data as returned by keyData.getData
+ * @param {Window} win
+ * Window object.
+ * @param {string=} type
+ * Event to emit. By default the full keydown/keypressed/keyup event
+ * sequence is emitted.
+ */
+event.sendSingleKey = function(key, win, type = null) {
+ let keyValue = key.key;
+ if (!key.printable) {
+ keyValue = `KEY_${keyValue}`;
+ }
+ const event = {
+ code: key.code,
+ location: key.location,
+ altKey: key.altKey ?? false,
+ shiftKey: key.shiftKey ?? false,
+ ctrlKey: key.ctrlKey ?? false,
+ metaKey: key.metaKey ?? false,
+ repeat: key.repeat ?? false,
+ };
+ if (type) {
+ event.type = type;
+ }
+ _getEventUtils(win).synthesizeKey(keyValue, event, win);
+};
+
+/**
+ * Send a string as a series of keypresses.
+ *
+ * @param {string} keyString
+ * Sequence of characters to send as key presses
+ * @param {Window} win
+ * Window object
+ */
+event.sendKeys = function(keyString, win) {
+ const modifiers = {};
+ for (let modifier in event.Modifiers) {
+ modifiers[modifier] = false;
+ }
+
+ for (let i = 0; i < keyString.length; i++) {
+ let keyValue = keyString.charAt(i);
+ if (modifiers.shiftKey) {
+ keyValue = lazy.keyData.getShiftedKey(keyValue);
+ }
+ const data = lazy.keyData.getData(keyValue);
+ const key = { ...data, ...modifiers };
+ if (data.modifier) {
+ modifiers[data.modifier] = true;
+ }
+ event.sendSingleKey(key, win);
+ }
+};
+
+event.sendEvent = function(eventType, el, modifiers = {}, opts = {}) {
+ opts.canBubble = opts.canBubble || true;
+
+ let doc = el.ownerDocument || el.document;
+ let ev = doc.createEvent("Event");
+
+ ev.shiftKey = modifiers.shift;
+ ev.metaKey = modifiers.meta;
+ ev.altKey = modifiers.alt;
+ ev.ctrlKey = modifiers.ctrl;
+
+ ev.initEvent(eventType, opts.canBubble, true);
+ el.dispatchEvent(ev);
+};
+
+event.mouseover = function(el, modifiers = {}, opts = {}) {
+ return event.sendEvent("mouseover", el, modifiers, opts);
+};
+
+event.mousemove = function(el, modifiers = {}, opts = {}) {
+ return event.sendEvent("mousemove", el, modifiers, opts);
+};
+
+event.mousedown = function(el, modifiers = {}, opts = {}) {
+ return event.sendEvent("mousedown", el, modifiers, opts);
+};
+
+event.mouseup = function(el, modifiers = {}, opts = {}) {
+ return event.sendEvent("mouseup", el, modifiers, opts);
+};
+
+event.click = function(el, modifiers = {}, opts = {}) {
+ return event.sendEvent("click", el, modifiers, opts);
+};
+
+event.change = function(el, modifiers = {}, opts = {}) {
+ return event.sendEvent("change", el, modifiers, opts);
+};
+
+event.input = function(el, modifiers = {}, opts = {}) {
+ return event.sendEvent("input", el, modifiers, opts);
+};
diff --git a/remote/marionette/interaction.sys.mjs b/remote/marionette/interaction.sys.mjs
new file mode 100644
index 0000000000..cacdff6972
--- /dev/null
+++ b/remote/marionette/interaction.sys.mjs
@@ -0,0 +1,774 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* eslint-disable no-restricted-globals */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ accessibility: "chrome://remote/content/marionette/accessibility.sys.mjs",
+ atom: "chrome://remote/content/marionette/atom.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",
+ Log: "chrome://remote/content/shared/Log.sys.mjs",
+ pprint: "chrome://remote/content/shared/Format.sys.mjs",
+ TimedPromise: "chrome://remote/content/marionette/sync.sys.mjs",
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "logger", () =>
+ lazy.Log.get(lazy.Log.TYPES.MARIONETTE)
+);
+
+/** XUL elements that support disabled attribute. */
+const DISABLED_ATTRIBUTE_SUPPORTED_XUL = new Set([
+ "ARROWSCROLLBOX",
+ "BUTTON",
+ "CHECKBOX",
+ "COMMAND",
+ "DESCRIPTION",
+ "KEY",
+ "KEYSET",
+ "LABEL",
+ "MENU",
+ "MENUITEM",
+ "MENULIST",
+ "MENUSEPARATOR",
+ "RADIO",
+ "RADIOGROUP",
+ "RICHLISTBOX",
+ "RICHLISTITEM",
+ "TAB",
+ "TABS",
+ "TOOLBARBUTTON",
+ "TREE",
+]);
+
+/**
+ * Common form controls that user can change the value property
+ * interactively.
+ */
+const COMMON_FORM_CONTROLS = new Set(["input", "textarea", "select"]);
+
+/**
+ * Input elements that do not fire <tt>input</tt> and <tt>change</tt>
+ * events when value property changes.
+ */
+const INPUT_TYPES_NO_EVENT = new Set([
+ "checkbox",
+ "radio",
+ "file",
+ "hidden",
+ "image",
+ "reset",
+ "button",
+ "submit",
+]);
+
+/** @namespace */
+export const interaction = {};
+
+/**
+ * Interact with an element by clicking it.
+ *
+ * The element is scrolled into view before visibility- or interactability
+ * checks are performed.
+ *
+ * Selenium-style visibility checks will be performed
+ * if <var>specCompat</var> is false (default). Otherwise
+ * pointer-interactability checks will be performed. If either of these
+ * fail an {@link ElementNotInteractableError} is thrown.
+ *
+ * If <var>strict</var> is enabled (defaults to disabled), further
+ * accessibility checks will be performed, and these may result in an
+ * {@link ElementNotAccessibleError} being returned.
+ *
+ * When <var>el</var> is not enabled, an {@link InvalidElementStateError}
+ * is returned.
+ *
+ * @param {(DOMElement|XULElement)} el
+ * Element to click.
+ * @param {boolean=} [strict=false] strict
+ * Enforce strict accessibility tests.
+ * @param {boolean=} [specCompat=false] specCompat
+ * Use WebDriver specification compatible interactability definition.
+ *
+ * @throws {ElementNotInteractableError}
+ * If either Selenium-style visibility check or
+ * pointer-interactability check fails.
+ * @throws {ElementClickInterceptedError}
+ * If <var>el</var> is obscured by another element and a click would
+ * not hit, in <var>specCompat</var> mode.
+ * @throws {ElementNotAccessibleError}
+ * If <var>strict</var> is true and element is not accessible.
+ * @throws {InvalidElementStateError}
+ * If <var>el</var> is not enabled.
+ */
+interaction.clickElement = async function(
+ el,
+ strict = false,
+ specCompat = false
+) {
+ const a11y = lazy.accessibility.get(strict);
+ if (lazy.element.isXULElement(el)) {
+ await chromeClick(el, a11y);
+ } else if (specCompat) {
+ await webdriverClickElement(el, a11y);
+ } else {
+ lazy.logger.trace(`Using non spec-compatible element click`);
+ await seleniumClickElement(el, a11y);
+ }
+};
+
+async function webdriverClickElement(el, a11y) {
+ const win = getWindow(el);
+
+ // step 3
+ if (el.localName == "input" && el.type == "file") {
+ throw new lazy.error.InvalidArgumentError(
+ "Cannot click <input type=file> elements"
+ );
+ }
+
+ let containerEl = lazy.element.getContainer(el);
+
+ // step 4
+ if (!lazy.element.isInView(containerEl)) {
+ lazy.element.scrollIntoView(containerEl);
+ }
+
+ // step 5
+ // TODO(ato): wait for containerEl to be in view
+
+ // step 6
+ // if we cannot bring the container element into the viewport
+ // there is no point in checking if it is pointer-interactable
+ if (!lazy.element.isInView(containerEl)) {
+ throw new lazy.error.ElementNotInteractableError(
+ lazy.pprint`Element ${el} could not be scrolled into view`
+ );
+ }
+
+ // step 7
+ let rects = containerEl.getClientRects();
+ let clickPoint = lazy.element.getInViewCentrePoint(rects[0], win);
+
+ if (lazy.element.isObscured(containerEl)) {
+ throw new lazy.error.ElementClickInterceptedError(containerEl, clickPoint);
+ }
+
+ let acc = await a11y.getAccessible(el, true);
+ a11y.assertVisible(acc, el, true);
+ a11y.assertEnabled(acc, el, true);
+ a11y.assertActionable(acc, el);
+
+ // step 8
+ if (el.localName == "option") {
+ interaction.selectOption(el);
+ } else {
+ // step 9
+ let clicked = interaction.flushEventLoop(containerEl);
+
+ // Synthesize a pointerMove action.
+ lazy.event.synthesizeMouseAtPoint(
+ clickPoint.x,
+ clickPoint.y,
+ {
+ type: "mousemove",
+ },
+ win
+ );
+
+ // Synthesize a pointerDown + pointerUp action.
+ lazy.event.synthesizeMouseAtPoint(clickPoint.x, clickPoint.y, {}, win);
+
+ await clicked;
+ }
+
+ // step 10
+ // if the click causes navigation, the post-navigation checks are
+ // handled by navigate.js
+}
+
+async function chromeClick(el, a11y) {
+ if (!lazy.atom.isElementEnabled(el)) {
+ throw new lazy.error.InvalidElementStateError("Element is not enabled");
+ }
+
+ let acc = await a11y.getAccessible(el, true);
+ a11y.assertVisible(acc, el, true);
+ a11y.assertEnabled(acc, el, true);
+ a11y.assertActionable(acc, el);
+
+ if (el.localName == "option") {
+ interaction.selectOption(el);
+ } else {
+ el.click();
+ }
+}
+
+async function seleniumClickElement(el, a11y) {
+ let win = getWindow(el);
+
+ let visibilityCheckEl = el;
+ if (el.localName == "option") {
+ visibilityCheckEl = lazy.element.getContainer(el);
+ }
+
+ if (!lazy.element.isVisible(visibilityCheckEl)) {
+ throw new lazy.error.ElementNotInteractableError();
+ }
+
+ if (!lazy.atom.isElementEnabled(el)) {
+ throw new lazy.error.InvalidElementStateError("Element is not enabled");
+ }
+
+ let acc = await a11y.getAccessible(el, true);
+ a11y.assertVisible(acc, el, true);
+ a11y.assertEnabled(acc, el, true);
+ a11y.assertActionable(acc, el);
+
+ if (el.localName == "option") {
+ interaction.selectOption(el);
+ } else {
+ let rects = el.getClientRects();
+ let centre = lazy.element.getInViewCentrePoint(rects[0], win);
+ let opts = {};
+ lazy.event.synthesizeMouseAtPoint(centre.x, centre.y, opts, win);
+ }
+}
+
+/**
+ * Select <tt>&lt;option&gt;</tt> element in a <tt>&lt;select&gt;</tt>
+ * list.
+ *
+ * Because the dropdown list of select elements are implemented using
+ * native widget technology, our trusted synthesised events are not able
+ * to reach them. Dropdowns are instead handled mimicking DOM events,
+ * which for obvious reasons is not ideal, but at the current point in
+ * time considered to be good enough.
+ *
+ * @param {HTMLOptionElement} option
+ * Option element to select.
+ *
+ * @throws {TypeError}
+ * If <var>el</var> is a XUL element or not an <tt>&lt;option&gt;</tt>
+ * element.
+ * @throws {Error}
+ * If unable to find <var>el</var>'s parent <tt>&lt;select&gt;</tt>
+ * element.
+ */
+interaction.selectOption = function(el) {
+ if (lazy.element.isXULElement(el)) {
+ throw new TypeError("XUL dropdowns not supported");
+ }
+ if (el.localName != "option") {
+ throw new TypeError(lazy.pprint`Expected <option> element, got ${el}`);
+ }
+
+ let containerEl = lazy.element.getContainer(el);
+
+ lazy.event.mouseover(containerEl);
+ lazy.event.mousemove(containerEl);
+ lazy.event.mousedown(containerEl);
+ containerEl.focus();
+
+ if (!el.disabled) {
+ // Clicking <option> in <select> should not be deselected if selected.
+ // However, clicking one in a <select multiple> should toggle
+ // selectedness the way holding down Control works.
+ if (containerEl.multiple) {
+ el.selected = !el.selected;
+ } else if (!el.selected) {
+ el.selected = true;
+ }
+ lazy.event.input(containerEl);
+ lazy.event.change(containerEl);
+ }
+
+ lazy.event.mouseup(containerEl);
+ lazy.event.click(containerEl);
+ containerEl.blur();
+};
+
+/**
+ * Clears the form control or the editable element, if required.
+ *
+ * Before clearing the element, it will attempt to scroll it into
+ * view if it is not already in the viewport. An error is raised
+ * if the element cannot be brought into view.
+ *
+ * If the element is a submittable form control and it is empty
+ * (it has no value or it has no files associated with it, in the
+ * case it is a <code>&lt;input type=file&gt;</code> element) or
+ * it is an editing host and its <code>innerHTML</code> content IDL
+ * attribute is empty, this function acts as a no-op.
+ *
+ * @param {Element} el
+ * Element to clear.
+ *
+ * @throws {InvalidElementStateError}
+ * If element is disabled, read-only, non-editable, not a submittable
+ * element or not an editing host, or cannot be scrolled into view.
+ */
+interaction.clearElement = function(el) {
+ if (lazy.element.isDisabled(el)) {
+ throw new lazy.error.InvalidElementStateError(
+ lazy.pprint`Element is disabled: ${el}`
+ );
+ }
+ if (lazy.element.isReadOnly(el)) {
+ throw new lazy.error.InvalidElementStateError(
+ lazy.pprint`Element is read-only: ${el}`
+ );
+ }
+ if (!lazy.element.isEditable(el)) {
+ throw new lazy.error.InvalidElementStateError(
+ lazy.pprint`Unable to clear element that cannot be edited: ${el}`
+ );
+ }
+
+ if (!lazy.element.isInView(el)) {
+ lazy.element.scrollIntoView(el);
+ }
+ if (!lazy.element.isInView(el)) {
+ throw new lazy.error.ElementNotInteractableError(
+ lazy.pprint`Element ${el} could not be scrolled into view`
+ );
+ }
+
+ if (lazy.element.isEditingHost(el)) {
+ clearContentEditableElement(el);
+ } else {
+ clearResettableElement(el);
+ }
+};
+
+function clearContentEditableElement(el) {
+ if (el.innerHTML === "") {
+ return;
+ }
+ el.focus();
+ el.innerHTML = "";
+ lazy.event.change(el);
+ el.blur();
+}
+
+function clearResettableElement(el) {
+ if (!lazy.element.isMutableFormControl(el)) {
+ throw new lazy.error.InvalidElementStateError(
+ lazy.pprint`Not an editable form control: ${el}`
+ );
+ }
+
+ let isEmpty;
+ switch (el.type) {
+ case "file":
+ isEmpty = !el.files.length;
+ break;
+
+ default:
+ isEmpty = el.value === "";
+ break;
+ }
+
+ if (el.validity.valid && isEmpty) {
+ return;
+ }
+
+ el.focus();
+ el.value = "";
+ lazy.event.change(el);
+ el.blur();
+}
+
+/**
+ * Waits until the event loop has spun enough times to process the
+ * DOM events generated by clicking an element, or until the document
+ * is unloaded.
+ *
+ * @param {Element} el
+ * Element that is expected to receive the click.
+ *
+ * @return {Promise}
+ * Promise is resolved once <var>el</var> has been clicked
+ * (its <code>click</code> event fires), the document is unloaded,
+ * or a 500 ms timeout is reached.
+ */
+interaction.flushEventLoop = async function(el) {
+ const win = el.ownerGlobal;
+ let unloadEv, clickEv;
+
+ let spinEventLoop = resolve => {
+ unloadEv = resolve;
+ clickEv = event => {
+ lazy.logger.trace(`Received DOM event click for ${event.target}`);
+ if (win.closed) {
+ resolve();
+ } else {
+ win.setTimeout(resolve, 0);
+ }
+ };
+
+ win.addEventListener("unload", unloadEv, { mozSystemGroup: true });
+ el.addEventListener("click", clickEv, { mozSystemGroup: true });
+ };
+ let removeListeners = () => {
+ // only one event fires
+ win.removeEventListener("unload", unloadEv);
+ el.removeEventListener("click", clickEv);
+ };
+
+ return new lazy.TimedPromise(spinEventLoop, {
+ timeout: 500,
+ throws: null,
+ }).then(removeListeners);
+};
+
+/**
+ * If <var>el<var> is a textual form control, or is contenteditable,
+ * and no previous selection state exists, move the caret to the end
+ * of the form control.
+ *
+ * The element has to be a <code>&lt;input type=text&gt;</code> or
+ * <code>&lt;textarea&gt;</code> element, or have the contenteditable
+ * attribute set, for the cursor to be moved.
+ *
+ * @param {Element} el
+ * Element to potential move the caret in.
+ */
+interaction.moveCaretToEnd = function(el) {
+ if (!lazy.element.isDOMElement(el)) {
+ return;
+ }
+
+ let isTextarea = el.localName == "textarea";
+ let isInputText = el.localName == "input" && el.type == "text";
+
+ if (isTextarea || isInputText) {
+ if (el.selectionEnd == 0) {
+ let len = el.value.length;
+ el.setSelectionRange(len, len);
+ }
+ } else if (el.isContentEditable) {
+ let selection = getWindow(el).getSelection();
+ selection.setPosition(el, el.childNodes.length);
+ }
+};
+
+/**
+ * Performs checks if <var>el</var> is keyboard-interactable.
+ *
+ * To decide if an element is keyboard-interactable various properties,
+ * and computed CSS styles have to be evaluated. Whereby it has to be taken
+ * into account that the element can be part of a container (eg. option),
+ * and as such the container has to be checked instead.
+ *
+ * @param {Element} el
+ * Element to check.
+ *
+ * @return {boolean}
+ * True if element is keyboard-interactable, false otherwise.
+ */
+interaction.isKeyboardInteractable = function(el) {
+ const win = getWindow(el);
+
+ // body and document element are always keyboard-interactable
+ if (el.localName === "body" || el === win.document.documentElement) {
+ return true;
+ }
+
+ // context menu popups do not take the focus from the document.
+ const menuPopup = el.closest("menupopup");
+ if (menuPopup) {
+ if (menuPopup.state !== "open") {
+ // closed menupopups are not keyboard interactable.
+ return false;
+ }
+
+ const menuItem = el.closest("menuitem");
+ if (menuItem) {
+ // hidden or disabled menu items are not keyboard interactable.
+ return !menuItem.disabled && !menuItem.hidden;
+ }
+
+ return true;
+ }
+
+ return Services.focus.elementIsFocusable(el, 0);
+};
+
+/**
+ * Updates an `<input type=file>`'s file list with given `paths`.
+ *
+ * Hereby will the file list be appended with `paths` if the
+ * element allows multiple files. Otherwise the list will be
+ * replaced.
+ *
+ * @param {HTMLInputElement} el
+ * An `input type=file` element.
+ * @param {Array.<string>} paths
+ * List of full paths to any of the files to be uploaded.
+ *
+ * @throws {InvalidArgumentError}
+ * If `path` doesn't exist.
+ */
+interaction.uploadFiles = async function(el, paths) {
+ let files = [];
+
+ if (el.hasAttribute("multiple")) {
+ // for multiple file uploads new files will be appended
+ files = Array.prototype.slice.call(el.files);
+ } else if (paths.length > 1) {
+ throw new lazy.error.InvalidArgumentError(
+ lazy.pprint`Element ${el} doesn't accept multiple files`
+ );
+ }
+
+ for (let path of paths) {
+ let file;
+
+ try {
+ file = await File.createFromFileName(path);
+ } catch (e) {
+ throw new lazy.error.InvalidArgumentError("File not found: " + path);
+ }
+
+ files.push(file);
+ }
+
+ el.mozSetFileArray(files);
+};
+
+/**
+ * Sets a form element's value.
+ *
+ * @param {DOMElement} el
+ * An form element, e.g. input, textarea, etc.
+ * @param {string} value
+ * The value to be set.
+ *
+ * @throws {TypeError}
+ * If <var>el</var> is not an supported form element.
+ */
+interaction.setFormControlValue = function(el, value) {
+ if (!COMMON_FORM_CONTROLS.has(el.localName)) {
+ throw new TypeError("This function is for form elements only");
+ }
+
+ el.value = value;
+
+ if (INPUT_TYPES_NO_EVENT.has(el.type)) {
+ return;
+ }
+
+ lazy.event.input(el);
+ lazy.event.change(el);
+};
+
+/**
+ * Send keys to element.
+ *
+ * @param {DOMElement|XULElement} el
+ * Element to send key events to.
+ * @param {Array.<string>} value
+ * Sequence of keystrokes to send to the element.
+ * @param {boolean=} strictFileInteractability
+ * Run interactability checks on `<input type=file>` elements.
+ * @param {boolean=} accessibilityChecks
+ * Enforce strict accessibility tests.
+ * @param {boolean=} webdriverClick
+ * Use WebDriver specification compatible interactability definition.
+ */
+interaction.sendKeysToElement = async function(
+ el,
+ value,
+ {
+ strictFileInteractability = false,
+ accessibilityChecks = false,
+ webdriverClick = false,
+ } = {}
+) {
+ const a11y = lazy.accessibility.get(accessibilityChecks);
+
+ if (webdriverClick) {
+ await webdriverSendKeysToElement(
+ el,
+ value,
+ a11y,
+ strictFileInteractability
+ );
+ } else {
+ await legacySendKeysToElement(el, value, a11y);
+ }
+};
+
+async function webdriverSendKeysToElement(
+ el,
+ value,
+ a11y,
+ strictFileInteractability
+) {
+ const win = getWindow(el);
+
+ if (el.type !== "file" || strictFileInteractability) {
+ let containerEl = lazy.element.getContainer(el);
+
+ lazy.element.scrollIntoView(containerEl);
+
+ // TODO: Wait for element to be keyboard-interactible
+ if (!interaction.isKeyboardInteractable(containerEl)) {
+ throw new lazy.error.ElementNotInteractableError(
+ lazy.pprint`Element ${el} is not reachable by keyboard`
+ );
+ }
+
+ if (win.document.activeElement !== containerEl) {
+ containerEl.focus();
+ // This validates the correct element types internally
+ interaction.moveCaretToEnd(containerEl);
+ }
+ }
+
+ let acc = await a11y.getAccessible(el, true);
+ a11y.assertActionable(acc, el);
+
+ if (el.type == "file") {
+ let paths = value.split("\n");
+ await interaction.uploadFiles(el, paths);
+
+ lazy.event.input(el);
+ lazy.event.change(el);
+ } else if (el.type == "date" || el.type == "time") {
+ interaction.setFormControlValue(el, value);
+ } else {
+ lazy.event.sendKeys(value, win);
+ }
+}
+
+async function legacySendKeysToElement(el, value, a11y) {
+ const win = getWindow(el);
+
+ if (el.type == "file") {
+ el.focus();
+ await interaction.uploadFiles(el, [value]);
+
+ lazy.event.input(el);
+ lazy.event.change(el);
+ } else if (el.type == "date" || el.type == "time") {
+ interaction.setFormControlValue(el, value);
+ } else {
+ let visibilityCheckEl = el;
+ if (el.localName == "option") {
+ visibilityCheckEl = lazy.element.getContainer(el);
+ }
+
+ if (!lazy.element.isVisible(visibilityCheckEl)) {
+ throw new lazy.error.ElementNotInteractableError(
+ "Element is not visible"
+ );
+ }
+
+ let acc = await a11y.getAccessible(el, true);
+ a11y.assertActionable(acc, el);
+
+ interaction.moveCaretToEnd(el);
+ el.focus();
+ lazy.event.sendKeys(value, win);
+ }
+}
+
+/**
+ * Determine the element displayedness of an element.
+ *
+ * @param {DOMElement|XULElement} el
+ * Element to determine displayedness of.
+ * @param {boolean=} [strict=false] strict
+ * Enforce strict accessibility tests.
+ *
+ * @return {boolean}
+ * True if element is displayed, false otherwise.
+ */
+interaction.isElementDisplayed = function(el, strict = false) {
+ let win = getWindow(el);
+ let displayed = lazy.atom.isElementDisplayed(el, win);
+
+ let a11y = lazy.accessibility.get(strict);
+ return a11y.getAccessible(el).then(acc => {
+ a11y.assertVisible(acc, el, displayed);
+ return displayed;
+ });
+};
+
+/**
+ * Check if element is enabled.
+ *
+ * @param {DOMElement|XULElement} el
+ * Element to test if is enabled.
+ *
+ * @return {boolean}
+ * True if enabled, false otherwise.
+ */
+interaction.isElementEnabled = function(el, strict = false) {
+ let enabled = true;
+ let win = getWindow(el);
+
+ if (lazy.element.isXULElement(el)) {
+ // check if XUL element supports disabled attribute
+ if (DISABLED_ATTRIBUTE_SUPPORTED_XUL.has(el.tagName.toUpperCase())) {
+ if (
+ el.hasAttribute("disabled") &&
+ el.getAttribute("disabled") === "true"
+ ) {
+ enabled = false;
+ }
+ }
+ } else if (
+ ["application/xml", "text/xml"].includes(win.document.contentType)
+ ) {
+ enabled = false;
+ } else {
+ enabled = lazy.atom.isElementEnabled(el, { frame: win });
+ }
+
+ let a11y = lazy.accessibility.get(strict);
+ return a11y.getAccessible(el).then(acc => {
+ a11y.assertEnabled(acc, el, enabled);
+ return enabled;
+ });
+};
+
+/**
+ * Determines if the referenced element is selected or not, with
+ * an additional accessibility check if <var>strict</var> is true.
+ *
+ * This operation only makes sense on input elements of the checkbox-
+ * and radio button states, and option elements.
+ *
+ * @param {(DOMElement|XULElement)} el
+ * Element to test if is selected.
+ * @param {boolean=} [strict=false] strict
+ * Enforce strict accessibility tests.
+ *
+ * @return {boolean}
+ * True if element is selected, false otherwise.
+ *
+ * @throws {ElementNotAccessibleError}
+ * If <var>el</var> is not accessible when <var>strict</var> is true.
+ */
+interaction.isElementSelected = function(el, strict = false) {
+ let selected = lazy.element.isSelected(el);
+
+ let a11y = lazy.accessibility.get(strict);
+ return a11y.getAccessible(el).then(acc => {
+ a11y.assertSelected(acc, el, selected);
+ return selected;
+ });
+};
+
+function getWindow(el) {
+ return el.ownerDocument.defaultView; // eslint-disable-line
+}
diff --git a/remote/marionette/jar.mn b/remote/marionette/jar.mn
new file mode 100644
index 0000000000..ec5a8400cd
--- /dev/null
+++ b/remote/marionette/jar.mn
@@ -0,0 +1,54 @@
+# 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/.
+
+remote.jar:
+% content remote %content/
+ content/marionette/accessibility.sys.mjs (accessibility.sys.mjs)
+ content/marionette/action.sys.mjs (action.sys.mjs)
+ content/marionette/actors/MarionetteCommandsChild.sys.mjs (actors/MarionetteCommandsChild.sys.mjs)
+ content/marionette/actors/MarionetteCommandsParent.sys.mjs (actors/MarionetteCommandsParent.sys.mjs)
+ content/marionette/actors/MarionetteEventsChild.sys.mjs (actors/MarionetteEventsChild.sys.mjs)
+ content/marionette/actors/MarionetteEventsParent.sys.mjs (actors/MarionetteEventsParent.sys.mjs)
+ content/marionette/actors/MarionetteReftestChild.sys.mjs (actors/MarionetteReftestChild.sys.mjs)
+ content/marionette/actors/MarionetteReftestParent.sys.mjs (actors/MarionetteReftestParent.sys.mjs)
+ content/marionette/addon.sys.mjs (addon.sys.mjs)
+ content/marionette/atom.sys.mjs (atom.sys.mjs)
+ content/marionette/browser.sys.mjs (browser.sys.mjs)
+ content/marionette/cert.sys.mjs (cert.sys.mjs)
+ content/marionette/cookie.sys.mjs (cookie.sys.mjs)
+ content/marionette/dom.sys.mjs (dom.sys.mjs)
+ content/marionette/driver.sys.mjs (driver.sys.mjs)
+ content/marionette/element.sys.mjs (element.sys.mjs)
+ content/marionette/evaluate.sys.mjs (evaluate.sys.mjs)
+ content/marionette/event.sys.mjs (event.sys.mjs)
+ content/marionette/interaction.sys.mjs (interaction.sys.mjs)
+ content/marionette/json.sys.mjs (json.sys.mjs)
+ content/marionette/l10n.sys.mjs (l10n.sys.mjs)
+ content/marionette/legacyaction.sys.mjs (legacyaction.sys.mjs)
+ content/marionette/message.sys.mjs (message.sys.mjs)
+ content/marionette/modal.sys.mjs (modal.sys.mjs)
+ content/marionette/navigate.sys.mjs (navigate.sys.mjs)
+ content/marionette/packets.sys.mjs (packets.sys.mjs)
+ content/marionette/permissions.sys.mjs (permissions.sys.mjs)
+ content/marionette/prefs.sys.mjs (prefs.sys.mjs)
+ content/marionette/reftest.sys.mjs (reftest.sys.mjs)
+ content/marionette/reftest.xhtml (chrome/reftest.xhtml)
+ content/marionette/reftest-content.js (reftest-content.js)
+ content/marionette/server.sys.mjs (server.sys.mjs)
+ content/marionette/stream-utils.sys.mjs (stream-utils.sys.mjs)
+ content/marionette/sync.sys.mjs (sync.sys.mjs)
+ content/marionette/transport.sys.mjs (transport.sys.mjs)
+#ifdef ENABLE_TESTS
+ content/marionette/test_dialog.dtd (chrome/test_dialog.dtd)
+ content/marionette/test_dialog.properties (chrome/test_dialog.properties)
+ content/marionette/test_dialog.xhtml (chrome/test_dialog.xhtml)
+ content/marionette/test_menupopup.xhtml (chrome/test_menupopup.xhtml)
+ content/marionette/test_nested_iframe.xhtml (chrome/test_nested_iframe.xhtml)
+ content/marionette/test_no_xul.xhtml (chrome/test_no_xul.xhtml)
+ content/marionette/test.xhtml (chrome/test.xhtml)
+ content/marionette/test2.xhtml (chrome/test2.xhtml)
+#ifdef MOZ_CODE_COVERAGE
+ content/marionette/PerTestCoverageUtils.jsm (../../tools/code-coverage/PerTestCoverageUtils.jsm)
+#endif
+#endif
diff --git a/remote/marionette/json.sys.mjs b/remote/marionette/json.sys.mjs
new file mode 100644
index 0000000000..be27c86b6b
--- /dev/null
+++ b/remote/marionette/json.sys.mjs
@@ -0,0 +1,218 @@
+/* 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, {
+ element: "chrome://remote/content/marionette/element.sys.mjs",
+ error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs",
+ Log: "chrome://remote/content/shared/Log.sys.mjs",
+ pprint: "chrome://remote/content/shared/Format.sys.mjs",
+ ShadowRoot: "chrome://remote/content/marionette/element.sys.mjs",
+ WebElement: "chrome://remote/content/marionette/element.sys.mjs",
+ WebReference: "chrome://remote/content/marionette/element.sys.mjs",
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "logger", () =>
+ lazy.Log.get(lazy.Log.TYPES.MARIONETTE)
+);
+
+/** @namespace */
+export const json = {};
+
+/**
+ * Clone an object including collections.
+ *
+ * @param {Object} value
+ * Object to be cloned.
+ * @param {Set} seen
+ * List of objects already processed.
+ * @param {Function} cloneAlgorithm
+ * The clone algorithm to invoke for individual list entries or object
+ * properties.
+ *
+ * @return {Object}
+ * The cloned object.
+ */
+function cloneObject(value, seen, cloneAlgorithm) {
+ // Only proceed with cloning an object if it hasn't been seen yet.
+ if (seen.has(value)) {
+ throw new lazy.error.JavaScriptError("Cyclic object value");
+ }
+ seen.add(value);
+
+ let result;
+
+ if (lazy.element.isCollection(value)) {
+ result = [...value].map(entry => cloneAlgorithm(entry, seen));
+ } else {
+ // arbitrary objects
+ result = {};
+ for (let prop in value) {
+ try {
+ result[prop] = cloneAlgorithm(value[prop], seen);
+ } catch (e) {
+ if (e.result == Cr.NS_ERROR_NOT_IMPLEMENTED) {
+ lazy.logger.debug(`Skipping ${prop}: ${e.message}`);
+ } else {
+ throw e;
+ }
+ }
+ }
+ }
+
+ seen.delete(value);
+
+ return result;
+}
+
+/**
+ * Clone arbitrary objects to JSON-safe primitives that can be
+ * transported across processes and over the Marionette protocol.
+ *
+ * The marshaling rules are as follows:
+ *
+ * - Primitives are returned as is.
+ *
+ * - Collections, such as `Array`, `NodeList`, `HTMLCollection`
+ * et al. are transformed to arrays and then recursed.
+ *
+ * - Elements and ShadowRoots that are not known WebReference's are added to
+ * the `NodeCache`. For both the associated unique web reference identifier
+ * is returned.
+ *
+ * - Objects with custom JSON representations, i.e. if they have
+ * a callable `toJSON` function, are returned verbatim. This means
+ * their internal integrity _are not_ checked. Be careful.
+ *
+ * - If a cyclic references is detected a JavaScriptError is thrown.
+ *
+ * @param {Object} value
+ * Object to be cloned.
+ * @param {NodeCache} nodeCache
+ * Node cache that holds already seen WebElement and ShadowRoot references.
+ *
+ * @return {Object}
+ * Same object as provided by `value` with the WebDriver specific
+ * elements replaced by WebReference's.
+ *
+ * @throws {JavaScriptError}
+ * If an object contains cyclic references.
+ * @throws {StaleElementReferenceError}
+ * If the element has gone stale, indicating it is no longer
+ * attached to the DOM.
+ */
+json.clone = function(value, nodeCache) {
+ function cloneJSON(value, seen) {
+ if (seen === undefined) {
+ seen = new Set();
+ }
+
+ const type = typeof value;
+
+ if ([undefined, null].includes(value)) {
+ return null;
+ } else if (["boolean", "number", "string"].includes(type)) {
+ // Primitive values
+ return value;
+ } else if (
+ lazy.element.isElement(value) ||
+ lazy.element.isShadowRoot(value)
+ ) {
+ // Convert DOM elements (eg. HTMLElement, XULElement, et al) and
+ // ShadowRoot instances to WebReference references.
+
+ // Evaluation of code will take place in mutable sandboxes, which are
+ // created to waive xrays by default. As such DOM nodes have to be unwaived
+ // before accessing the ownerGlobal is possible, which is needed by
+ // ContentDOMReference.
+ const el = Cu.unwaiveXrays(value);
+
+ // Don't create a reference for stale elements.
+ if (lazy.element.isStale(el)) {
+ throw new lazy.error.StaleElementReferenceError(
+ lazy.pprint`The element ${el} is no longer attached to the DOM`
+ );
+ }
+
+ const sharedId = nodeCache.add(value);
+ return lazy.WebReference.from(el, sharedId).toJSON();
+ } else if (typeof value.toJSON == "function") {
+ // custom JSON representation
+ let unsafeJSON;
+ try {
+ unsafeJSON = value.toJSON();
+ } catch (e) {
+ throw new lazy.error.JavaScriptError(`toJSON() failed with: ${e}`);
+ }
+ return cloneJSON(unsafeJSON, seen);
+ }
+
+ // Collections and arbitrary objects
+ return cloneObject(value, seen, cloneJSON);
+ }
+
+ return cloneJSON(value, new Set());
+};
+
+/**
+ * Deserialize an arbitrary object.
+ *
+ * @param {Object} value
+ * Arbitrary object.
+ * @param {NodeCache} nodeCache
+ * Node cache that holds already seen WebElement and ShadowRoot references.
+ * @param {WindowProxy} win
+ * Current window.
+ *
+ * @return {Object}
+ * Same object as provided by `value` with the WebDriver specific
+ * references replaced with real JavaScript objects.
+ *
+ * @throws {NoSuchElementError}
+ * If the WebElement reference has not been seen before.
+ * @throws {StaleElementReferenceError}
+ * If the element is stale, indicating it is no longer attached to the DOM.
+ */
+json.deserialize = function(value, nodeCache, win) {
+ function deserializeJSON(value, seen) {
+ if (seen === undefined) {
+ seen = new Set();
+ }
+
+ if (value === undefined || value === null) {
+ return value;
+ }
+
+ switch (typeof value) {
+ case "boolean":
+ case "number":
+ case "string":
+ default:
+ return value;
+
+ case "object":
+ if (lazy.WebReference.isReference(value)) {
+ // Create a WebReference based on the WebElement identifier.
+ const webRef = lazy.WebReference.fromJSON(value);
+
+ if (
+ webRef instanceof lazy.WebElement ||
+ webRef instanceof lazy.ShadowRoot
+ ) {
+ return lazy.element.resolveElement(webRef.uuid, nodeCache, win);
+ }
+
+ // WebFrame and WebWindow not supported yet
+ throw new lazy.error.UnsupportedOperationError();
+ }
+
+ return cloneObject(value, seen, deserializeJSON);
+ }
+ }
+
+ return deserializeJSON(value, new Set());
+};
diff --git a/remote/marionette/l10n.sys.mjs b/remote/marionette/l10n.sys.mjs
new file mode 100644
index 0000000000..132b30c6ad
--- /dev/null
+++ b/remote/marionette/l10n.sys.mjs
@@ -0,0 +1,103 @@
+/* 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/. */
+
+/**
+ * An API which allows Marionette to handle localized content.
+ *
+ * The localization (https://mzl.la/2eUMjyF) of UI elements in Gecko
+ * based applications is done via entities and properties. For static
+ * values entities are used, which are located in .dtd files. Whereby for
+ * dynamically updated content the values come from .property files. Both
+ * types of elements can be identifed via a unique id, and the translated
+ * content retrieved.
+ */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs",
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "domParser", () => {
+ const parser = new DOMParser();
+ parser.forceEnableDTD();
+ return parser;
+});
+
+/** @namespace */
+export const l10n = {};
+
+/**
+ * Retrieve the localized string for the specified entity id.
+ *
+ * Example:
+ * localizeEntity(["chrome://branding/locale/brand.dtd"], "brandShortName")
+ *
+ * @param {Array.<string>} urls
+ * Array of .dtd URLs.
+ * @param {string} id
+ * The ID of the entity to retrieve the localized string for.
+ *
+ * @return {string}
+ * The localized string for the requested entity.
+ */
+l10n.localizeEntity = function(urls, id) {
+ // Build a string which contains all possible entity locations
+ let locations = [];
+ urls.forEach((url, index) => {
+ locations.push(`<!ENTITY % dtd_${index} SYSTEM "${url}">%dtd_${index};`);
+ });
+
+ // Use the DOM parser to resolve the entity and extract its real value
+ let header = `<?xml version="1.0"?><!DOCTYPE elem [${locations.join("")}]>`;
+ let elem = `<elem id="elementID">&${id};</elem>`;
+ let doc = lazy.domParser.parseFromString(header + elem, "text/xml");
+ let element = doc.querySelector("elem[id='elementID']");
+
+ if (element === null) {
+ throw new lazy.error.NoSuchElementError(
+ `Entity with id='${id}' hasn't been found`
+ );
+ }
+
+ return element.textContent;
+};
+
+/**
+ * Retrieve the localized string for the specified property id.
+ *
+ * Example:
+ *
+ * localizeProperty(
+ * ["chrome://global/locale/findbar.properties"], "FastFind");
+ *
+ * @param {Array.<string>} urls
+ * Array of .properties URLs.
+ * @param {string} id
+ * The ID of the property to retrieve the localized string for.
+ *
+ * @return {string}
+ * The localized string for the requested property.
+ */
+l10n.localizeProperty = function(urls, id) {
+ let property = null;
+
+ for (let url of urls) {
+ let bundle = Services.strings.createBundle(url);
+ try {
+ property = bundle.GetStringFromName(id);
+ break;
+ } catch (e) {}
+ }
+
+ if (property === null) {
+ throw new lazy.error.NoSuchElementError(
+ `Property with ID '${id}' hasn't been found`
+ );
+ }
+
+ return property;
+};
diff --git a/remote/marionette/legacyaction.sys.mjs b/remote/marionette/legacyaction.sys.mjs
new file mode 100644
index 0000000000..770f13d433
--- /dev/null
+++ b/remote/marionette/legacyaction.sys.mjs
@@ -0,0 +1,632 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* eslint-disable no-restricted-globals */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ Preferences: "resource://gre/modules/Preferences.sys.mjs",
+
+ accessibility: "chrome://remote/content/marionette/accessibility.sys.mjs",
+ element: "chrome://remote/content/marionette/element.sys.mjs",
+ error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs",
+ json: "chrome://remote/content/marionette/json.sys.mjs",
+ event: "chrome://remote/content/marionette/event.sys.mjs",
+ Log: "chrome://remote/content/shared/Log.sys.mjs",
+ WebReference: "chrome://remote/content/marionette/element.sys.mjs",
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "logger", () =>
+ lazy.Log.get(lazy.Log.TYPES.MARIONETTE)
+);
+
+const CONTEXT_MENU_DELAY_PREF = "ui.click_hold_context_menus.delay";
+const DEFAULT_CONTEXT_MENU_DELAY = 750; // ms
+
+/** @namespace */
+export const legacyaction = {};
+
+const action = legacyaction;
+
+/**
+ * Functionality for (single finger) action chains.
+ */
+action.Chain = function() {
+ // for assigning unique ids to all touches
+ this.nextTouchId = 1000;
+ // keep track of active Touches
+ this.touchIds = {};
+ // last touch for each fingerId
+ this.lastCoordinates = null;
+ this.isTap = false;
+ this.scrolling = false;
+ // whether to send mouse event
+ this.mouseEventsOnly = false;
+ this.checkTimer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+
+ // determines if we create touch events
+ this.inputSource = null;
+};
+
+/**
+ * Create a touch based event.
+ *
+ * @param {Element} elem
+ * The Element on which the touch event should be created.
+ * @param {Number} x
+ * x coordinate relative to the viewport.
+ * @param {Number} y
+ * y coordinate relative to the viewport.
+ * @param {Number} touchId
+ * Touch event id used by legacyactions.
+ */
+action.Chain.prototype.createATouch = function(elem, x, y, touchId) {
+ const doc = elem.ownerDocument;
+ const win = doc.defaultView;
+ const [
+ clientX,
+ clientY,
+ pageX,
+ pageY,
+ screenX,
+ screenY,
+ ] = this.getCoordinateInfo(elem, x, y);
+ const atouch = doc.createTouch(
+ win,
+ elem,
+ touchId,
+ pageX,
+ pageY,
+ screenX,
+ screenY,
+ clientX,
+ clientY
+ );
+ return atouch;
+};
+
+action.Chain.prototype.dispatchActions = function(
+ args,
+ touchId,
+ container,
+ seenEls
+) {
+ this.seenEls = seenEls;
+ this.container = container;
+ let commandArray = lazy.json.deserialize(args, seenEls, container.frame);
+
+ if (touchId == null) {
+ touchId = this.nextTSouchId++;
+ }
+
+ if (!container.frame.document.createTouch) {
+ this.mouseEventsOnly = true;
+ }
+
+ let keyModifiers = {
+ shiftKey: false,
+ ctrlKey: false,
+ altKey: false,
+ metaKey: false,
+ };
+
+ return new Promise(resolve => {
+ this.actions(commandArray, touchId, 0, keyModifiers, resolve);
+ }).catch(this.resetValues.bind(this));
+};
+
+/**
+ * This function emit mouse event.
+ *
+ * @param {Document} doc
+ * Current document.
+ * @param {string} type
+ * Type of event to dispatch.
+ * @param {number} clickCount
+ * Number of clicks, button notes the mouse button.
+ * @param {number} elClientX
+ * X coordinate of the mouse relative to the viewport.
+ * @param {number} elClientY
+ * Y coordinate of the mouse relative to the viewport.
+ * @param {Object} modifiers
+ * An object of modifier keys present.
+ */
+action.Chain.prototype.emitMouseEvent = function(
+ doc,
+ type,
+ elClientX,
+ elClientY,
+ button,
+ clickCount,
+ modifiers
+) {
+ lazy.logger.debug(
+ `Emitting ${type} mouse event ` +
+ `at coordinates (${elClientX}, ${elClientY}) ` +
+ `relative to the viewport, ` +
+ `button: ${button}, ` +
+ `clickCount: ${clickCount}`
+ );
+
+ let win = doc.defaultView;
+ let domUtils = win.windowUtils;
+
+ let mods;
+ if (typeof modifiers != "undefined") {
+ mods = lazy.event.parseModifiers_(modifiers, win);
+ } else {
+ mods = 0;
+ }
+
+ domUtils.sendMouseEvent(
+ type,
+ elClientX,
+ elClientY,
+ button || 0,
+ clickCount || 1,
+ mods,
+ false,
+ 0,
+ this.inputSource
+ );
+};
+
+action.Chain.prototype.emitTouchEvent = function(doc, type, touch) {
+ lazy.logger.info(
+ `Emitting Touch event of type ${type} ` +
+ `to element with id: ${touch.target.id} ` +
+ `and tag name: ${touch.target.tagName} ` +
+ `at coordinates (${touch.clientX}), ` +
+ `${touch.clientY}) relative to the viewport`
+ );
+
+ const win = doc.defaultView;
+ if (win.docShell.asyncPanZoomEnabled && this.scrolling) {
+ lazy.logger.debug(
+ `Cannot emit touch event with asyncPanZoomEnabled and legacyactions.scrolling`
+ );
+ return;
+ }
+
+ // we get here if we're not in asyncPacZoomEnabled land, or if we're
+ // the main process
+ win.windowUtils.sendTouchEvent(
+ type,
+ [touch.identifier],
+ [touch.clientX],
+ [touch.clientY],
+ [touch.radiusX],
+ [touch.radiusY],
+ [touch.rotationAngle],
+ [touch.force],
+ [0],
+ [0],
+ [0],
+ 0
+ );
+};
+
+/**
+ * Reset any persisted values after a command completes.
+ */
+action.Chain.prototype.resetValues = function() {
+ this.container = null;
+ this.seenEls = null;
+ this.mouseEventsOnly = false;
+};
+
+/**
+ * Function that performs a single tap.
+ */
+action.Chain.prototype.singleTap = async function(
+ el,
+ corx,
+ cory,
+ capabilities
+) {
+ const doc = el.ownerDocument;
+ // after this block, the element will be scrolled into view
+ let visible = lazy.element.isVisible(el, corx, cory);
+ if (!visible) {
+ throw new lazy.error.ElementNotInteractableError(
+ "Element is not currently visible and may not be manipulated"
+ );
+ }
+
+ let a11y = lazy.accessibility.get(capabilities["moz:accessibilityChecks"]);
+ let acc = await a11y.getAccessible(el, true);
+ a11y.assertVisible(acc, el, visible);
+ a11y.assertActionable(acc, el);
+ if (!doc.createTouch) {
+ this.mouseEventsOnly = true;
+ }
+ let c = lazy.element.coordinates(el, corx, cory);
+ if (!this.mouseEventsOnly) {
+ let touchId = this.nextTouchId++;
+ let touch = this.createATouch(el, c.x, c.y, touchId);
+ this.emitTouchEvent(doc, "touchstart", touch);
+ this.emitTouchEvent(doc, "touchend", touch);
+ }
+ this.mouseTap(doc, c.x, c.y);
+};
+
+/**
+ * Emit events for each action in the provided chain.
+ *
+ * To emit touch events for each finger, one might send a [["press", id],
+ * ["wait", 5], ["release"]] chain.
+ *
+ * @param {Array.<Array<?>>} chain
+ * A multi-dimensional array of actions.
+ * @param {Object.<string, number>} touchId
+ * Represents the finger ID.
+ * @param {number} i
+ * Keeps track of the current action of the chain.
+ * @param {Object.<string, boolean>} keyModifiers
+ * Keeps track of keyDown/keyUp pairs through an action chain.
+ * @param {function(?)} cb
+ * Called on success.
+ *
+ * @return {Object.<string, number>}
+ * Last finger ID, or an empty object.
+ */
+action.Chain.prototype.actions = function(chain, touchId, i, keyModifiers, cb) {
+ if (i == chain.length) {
+ cb(touchId || null);
+ this.resetValues();
+ return;
+ }
+
+ let pack = chain[i];
+ let command = pack[0];
+ let webEl;
+ let el;
+ let c;
+ i++;
+
+ if (!["press", "wait", "keyDown", "keyUp", "click"].includes(command)) {
+ // if mouseEventsOnly, then touchIds isn't used
+ if (!(touchId in this.touchIds) && !this.mouseEventsOnly) {
+ this.resetValues();
+ throw new lazy.error.WebDriverError("Element has not been pressed");
+ }
+ }
+
+ switch (command) {
+ case "keyDown":
+ lazy.event.sendKeyDown(pack[1], keyModifiers, this.container.frame);
+ this.actions(chain, touchId, i, keyModifiers, cb);
+ break;
+
+ case "keyUp":
+ lazy.event.sendKeyUp(pack[1], keyModifiers, this.container.frame);
+ this.actions(chain, touchId, i, keyModifiers, cb);
+ break;
+
+ case "click":
+ webEl = lazy.WebReference.fromUUID(pack[1]);
+ el = this.seenEls.get(webEl);
+ let button = pack[2];
+ let clickCount = pack[3];
+ c = lazy.element.coordinates(el);
+ this.mouseTap(
+ el.ownerDocument,
+ c.x,
+ c.y,
+ button,
+ clickCount,
+ keyModifiers
+ );
+ if (button == 2) {
+ this.emitMouseEvent(
+ el.ownerDocument,
+ "contextmenu",
+ c.x,
+ c.y,
+ button,
+ clickCount,
+ keyModifiers
+ );
+ }
+ this.actions(chain, touchId, i, keyModifiers, cb);
+ break;
+
+ case "press":
+ if (this.lastCoordinates) {
+ this.generateEvents(
+ "cancel",
+ this.lastCoordinates[0],
+ this.lastCoordinates[1],
+ touchId,
+ null,
+ keyModifiers
+ );
+ this.resetValues();
+ throw new lazy.error.WebDriverError(
+ "Invalid Command: press cannot follow an active touch event"
+ );
+ }
+
+ // look ahead to check if we're scrolling,
+ // needed for APZ touch dispatching
+ if (i != chain.length && chain[i][0].includes("move")) {
+ this.scrolling = true;
+ }
+ webEl = lazy.WebReference.fromUUID(pack[1]);
+ el = this.seenEls.get(webEl);
+ c = lazy.element.coordinates(el, pack[2], pack[3]);
+ touchId = this.generateEvents("press", c.x, c.y, null, el, keyModifiers);
+ this.actions(chain, touchId, i, keyModifiers, cb);
+ break;
+
+ case "release":
+ this.generateEvents(
+ "release",
+ this.lastCoordinates[0],
+ this.lastCoordinates[1],
+ touchId,
+ null,
+ keyModifiers
+ );
+ this.actions(chain, null, i, keyModifiers, cb);
+ this.scrolling = false;
+ break;
+
+ case "move":
+ webEl = lazy.WebReference.fromUUID(pack[1]);
+ el = this.seenEls.get(webEl);
+ c = lazy.element.coordinates(el);
+ this.generateEvents("move", c.x, c.y, touchId, null, keyModifiers);
+ this.actions(chain, touchId, i, keyModifiers, cb);
+ break;
+
+ case "moveByOffset":
+ this.generateEvents(
+ "move",
+ this.lastCoordinates[0] + pack[1],
+ this.lastCoordinates[1] + pack[2],
+ touchId,
+ null,
+ keyModifiers
+ );
+ this.actions(chain, touchId, i, keyModifiers, cb);
+ break;
+
+ case "wait":
+ if (pack[1] != null) {
+ let time = pack[1] * 1000;
+
+ // standard waiting time to fire contextmenu
+ let standard = lazy.Preferences.get(
+ CONTEXT_MENU_DELAY_PREF,
+ DEFAULT_CONTEXT_MENU_DELAY
+ );
+
+ if (time >= standard && this.isTap) {
+ chain.splice(i, 0, ["longPress"], ["wait", (time - standard) / 1000]);
+ time = standard;
+ }
+ this.checkTimer.initWithCallback(
+ () => this.actions(chain, touchId, i, keyModifiers, cb),
+ time,
+ Ci.nsITimer.TYPE_ONE_SHOT
+ );
+ } else {
+ this.actions(chain, touchId, i, keyModifiers, cb);
+ }
+ break;
+
+ case "cancel":
+ this.generateEvents(
+ "cancel",
+ this.lastCoordinates[0],
+ this.lastCoordinates[1],
+ touchId,
+ null,
+ keyModifiers
+ );
+ this.actions(chain, touchId, i, keyModifiers, cb);
+ this.scrolling = false;
+ break;
+
+ case "longPress":
+ this.generateEvents(
+ "contextmenu",
+ this.lastCoordinates[0],
+ this.lastCoordinates[1],
+ touchId,
+ null,
+ keyModifiers
+ );
+ this.actions(chain, touchId, i, keyModifiers, cb);
+ break;
+ }
+};
+
+/**
+ * Given an element and a pair of coordinates, returns an array of the
+ * form [clientX, clientY, pageX, pageY, screenX, screenY].
+ */
+action.Chain.prototype.getCoordinateInfo = function(el, corx, cory) {
+ let win = el.ownerGlobal;
+ return [
+ corx, // clientX
+ cory, // clientY
+ corx + win.pageXOffset, // pageX
+ cory + win.pageYOffset, // pageY
+ corx + win.mozInnerScreenX, // screenX
+ cory + win.mozInnerScreenY, // screenY
+ ];
+};
+
+/**
+ * @param {number} x
+ * X coordinate of the location to generate the event that is relative
+ * to the viewport.
+ * @param {number} y
+ * Y coordinate of the location to generate the event that is relative
+ * to the viewport.
+ */
+action.Chain.prototype.generateEvents = function(
+ type,
+ x,
+ y,
+ touchId,
+ target,
+ keyModifiers
+) {
+ this.lastCoordinates = [x, y];
+ let doc = this.container.frame.document;
+
+ switch (type) {
+ case "tap":
+ if (this.mouseEventsOnly) {
+ let touch = this.createATouch(target, x, y, touchId);
+ this.mouseTap(
+ touch.target.ownerDocument,
+ touch.clientX,
+ touch.clientY,
+ null,
+ null,
+ keyModifiers
+ );
+ } else {
+ touchId = this.nextTouchId++;
+ let touch = this.createATouch(target, x, y, touchId);
+ this.emitTouchEvent(doc, "touchstart", touch);
+ this.emitTouchEvent(doc, "touchend", touch);
+ this.mouseTap(
+ touch.target.ownerDocument,
+ touch.clientX,
+ touch.clientY,
+ null,
+ null,
+ keyModifiers
+ );
+ }
+ this.lastCoordinates = null;
+ break;
+
+ case "press":
+ this.isTap = true;
+ if (this.mouseEventsOnly) {
+ this.emitMouseEvent(doc, "mousemove", x, y, null, null, keyModifiers);
+ this.emitMouseEvent(doc, "mousedown", x, y, null, null, keyModifiers);
+ } else {
+ touchId = this.nextTouchId++;
+ let touch = this.createATouch(target, x, y, touchId);
+ this.emitTouchEvent(doc, "touchstart", touch);
+ this.touchIds[touchId] = touch;
+ return touchId;
+ }
+ break;
+
+ case "release":
+ if (this.mouseEventsOnly) {
+ let [x, y] = this.lastCoordinates;
+ this.emitMouseEvent(doc, "mouseup", x, y, null, null, keyModifiers);
+ } else {
+ let touch = this.touchIds[touchId];
+ let [x, y] = this.lastCoordinates;
+
+ touch = this.createATouch(touch.target, x, y, touchId);
+ this.emitTouchEvent(doc, "touchend", touch);
+
+ if (this.isTap) {
+ this.mouseTap(
+ touch.target.ownerDocument,
+ touch.clientX,
+ touch.clientY,
+ null,
+ null,
+ keyModifiers
+ );
+ }
+ delete this.touchIds[touchId];
+ }
+
+ this.isTap = false;
+ this.lastCoordinates = null;
+ break;
+
+ case "cancel":
+ this.isTap = false;
+ if (this.mouseEventsOnly) {
+ let [x, y] = this.lastCoordinates;
+ this.emitMouseEvent(doc, "mouseup", x, y, null, null, keyModifiers);
+ } else {
+ this.emitTouchEvent(doc, "touchcancel", this.touchIds[touchId]);
+ delete this.touchIds[touchId];
+ }
+ this.lastCoordinates = null;
+ break;
+
+ case "move":
+ this.isTap = false;
+ if (this.mouseEventsOnly) {
+ this.emitMouseEvent(doc, "mousemove", x, y, null, null, keyModifiers);
+ } else {
+ let touch = this.createATouch(
+ this.touchIds[touchId].target,
+ x,
+ y,
+ touchId
+ );
+ this.touchIds[touchId] = touch;
+ this.emitTouchEvent(doc, "touchmove", touch);
+ }
+ break;
+
+ case "contextmenu":
+ this.isTap = false;
+ let event = this.container.frame.document.createEvent("MouseEvents");
+ if (this.mouseEventsOnly) {
+ target = doc.elementFromPoint(
+ this.lastCoordinates[0],
+ this.lastCoordinates[1]
+ );
+ } else {
+ target = this.touchIds[touchId].target;
+ }
+
+ let [clientX, clientY, , , screenX, screenY] = this.getCoordinateInfo(
+ target,
+ x,
+ y
+ );
+
+ event.initMouseEvent(
+ "contextmenu",
+ true,
+ true,
+ target.ownerGlobal,
+ 1,
+ screenX,
+ screenY,
+ clientX,
+ clientY,
+ false,
+ false,
+ false,
+ false,
+ 0,
+ null
+ );
+ target.dispatchEvent(event);
+ break;
+
+ default:
+ throw new lazy.error.WebDriverError("Unknown event type: " + type);
+ }
+ return null;
+};
+
+action.Chain.prototype.mouseTap = function(doc, x, y, button, count, mod) {
+ this.emitMouseEvent(doc, "mousemove", x, y, button, count, mod);
+ this.emitMouseEvent(doc, "mousedown", x, y, button, count, mod);
+ this.emitMouseEvent(doc, "mouseup", x, y, button, count, mod);
+};
diff --git a/remote/marionette/message.sys.mjs b/remote/marionette/message.sys.mjs
new file mode 100644
index 0000000000..35b8620e3c
--- /dev/null
+++ b/remote/marionette/message.sys.mjs
@@ -0,0 +1,329 @@
+/* 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, {
+ assert: "chrome://remote/content/shared/webdriver/Assert.sys.mjs",
+ error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs",
+ truncate: "chrome://remote/content/shared/Format.sys.mjs",
+});
+
+/** Representation of the packets transproted over the wire. */
+export class Message {
+ /**
+ * @param {number} messageID
+ * Message ID unique identifying this message.
+ */
+ constructor(messageID) {
+ this.id = lazy.assert.integer(messageID);
+ }
+
+ toString() {
+ function replacer(key, value) {
+ if (typeof value === "string") {
+ return lazy.truncate`${value}`;
+ }
+ return value;
+ }
+
+ return JSON.stringify(this.toPacket(), replacer);
+ }
+
+ /**
+ * Converts a data packet into a {@link Command} or {@link Response}.
+ *
+ * @param {Array.<number, number, ?, ?>} data
+ * A four element array where the elements, in sequence, signifies
+ * message type, message ID, method name or error, and parameters
+ * or result.
+ *
+ * @return {Message}
+ * Based on the message type, a {@link Command} or {@link Response}
+ * instance.
+ *
+ * @throws {TypeError}
+ * If the message type is not recognised.
+ */
+ static fromPacket(data) {
+ const [type] = data;
+
+ switch (type) {
+ case Command.Type:
+ return Command.fromPacket(data);
+
+ case Response.Type:
+ return Response.fromPacket(data);
+
+ default:
+ throw new TypeError(
+ "Unrecognised message type in packet: " + JSON.stringify(data)
+ );
+ }
+ }
+}
+
+/**
+ * Messages may originate from either the server or the client.
+ * Because the remote protocol is full duplex, both endpoints may be
+ * the origin of both commands and responses.
+ *
+ * @enum
+ * @see {@link Message}
+ */
+Message.Origin = {
+ /** Indicates that the message originates from the client. */
+ Client: 0,
+ /** Indicates that the message originates from the server. */
+ Server: 1,
+};
+
+/**
+ * A command is a request from the client to run a series of remote end
+ * steps and return a fitting response.
+ *
+ * The command can be synthesised from the message passed over the
+ * Marionette socket using the {@link fromPacket} function. The format of
+ * a message is:
+ *
+ * <pre>
+ * [<var>type</var>, <var>id</var>, <var>name</var>, <var>params</var>]
+ * </pre>
+ *
+ * where
+ *
+ * <dl>
+ * <dt><var>type</var> (integer)
+ * <dd>
+ * Must be zero (integer). Zero means that this message is
+ * a command.
+ *
+ * <dt><var>id</var> (integer)
+ * <dd>
+ * Integer used as a sequence number. The server replies with
+ * the same ID for the response.
+ *
+ * <dt><var>name</var> (string)
+ * <dd>
+ * String representing the command name with an associated set
+ * of remote end steps.
+ *
+ * <dt><var>params</var> (JSON Object or null)
+ * <dd>
+ * Object of command function arguments. The keys of this object
+ * must be strings, but the values can be arbitrary values.
+ * </dl>
+ *
+ * A command has an associated message <var>id</var> that prevents
+ * the dispatcher from sending responses in the wrong order.
+ *
+ * The command may also have optional error- and result handlers that
+ * are called when the client returns with a response. These are
+ * <code>function onerror({Object})</code>,
+ * <code>function onresult({Object})</code>, and
+ * <code>function onresult({Response})</code>:
+ *
+ * @param {number} messageID
+ * Message ID unique identifying this message.
+ * @param {string} name
+ * Command name.
+ * @param {Object.<string, ?>} params
+ * Command parameters.
+ */
+export class Command extends Message {
+ constructor(messageID, name, params = {}) {
+ super(messageID);
+
+ this.name = lazy.assert.string(name);
+ this.parameters = lazy.assert.object(params);
+
+ this.onerror = null;
+ this.onresult = null;
+
+ this.origin = Message.Origin.Client;
+ this.sent = false;
+ }
+
+ /**
+ * Calls the error- or result handler associated with this command.
+ * This function can be replaced with a custom response handler.
+ *
+ * @param {Response} resp
+ * The response to pass on to the result or error to the
+ * <code>onerror</code> or <code>onresult</code> handlers to.
+ */
+ onresponse(resp) {
+ if (this.onerror && resp.error) {
+ this.onerror(resp.error);
+ } else if (this.onresult && resp.body) {
+ this.onresult(resp.body);
+ }
+ }
+
+ /**
+ * Encodes the command to a packet.
+ *
+ * @return {Array}
+ * Packet.
+ */
+ toPacket() {
+ return [Command.Type, this.id, this.name, this.parameters];
+ }
+
+ /**
+ * Converts a data packet into {@link Command}.
+ *
+ * @param {Array.<number, number, ?, ?>} data
+ * A four element array where the elements, in sequence, signifies
+ * message type, message ID, command name, and parameters.
+ *
+ * @return {Command}
+ * Representation of packet.
+ *
+ * @throws {TypeError}
+ * If the message type is not recognised.
+ */
+ static fromPacket(payload) {
+ let [type, msgID, name, params] = payload;
+ lazy.assert.that(n => n === Command.Type)(type);
+
+ // if parameters are given but null, treat them as undefined
+ if (params === null) {
+ params = undefined;
+ }
+
+ return new Command(msgID, name, params);
+ }
+}
+
+Command.Type = 0;
+
+/**
+ * @callback ResponseCallback
+ *
+ * @param {Response} resp
+ * Response to handle.
+ */
+
+/**
+ * Represents the response returned from the remote end after execution
+ * of its corresponding command.
+ *
+ * The response is a mutable object passed to each command for
+ * modification through the available setters. To send data in a response,
+ * you modify the body property on the response. The body property can
+ * also be replaced completely.
+ *
+ * The response is sent implicitly by
+ * {@link server.TCPConnection#execute when a command has finished
+ * executing, and any modifications made subsequent to that will have
+ * no effect.
+ *
+ * @param {number} messageID
+ * Message ID tied to the corresponding command request this is
+ * a response for.
+ * @param {ResponseHandler} respHandler
+ * Function callback called on sending the response.
+ */
+export class Response extends Message {
+ constructor(messageID, respHandler = () => {}) {
+ super(messageID);
+
+ this.respHandler_ = lazy.assert.callable(respHandler);
+
+ this.error = null;
+ this.body = { value: null };
+
+ this.origin = Message.Origin.Server;
+ this.sent = false;
+ }
+
+ /**
+ * Sends response conditionally, given a predicate.
+ *
+ * @param {function(Response): boolean} predicate
+ * A predicate taking a Response object and returning a boolean.
+ */
+ sendConditionally(predicate) {
+ if (predicate(this)) {
+ this.send();
+ }
+ }
+
+ /**
+ * Sends response using the response handler provided on
+ * construction.
+ *
+ * @throws {RangeError}
+ * If the response has already been sent.
+ */
+ send() {
+ if (this.sent) {
+ throw new RangeError("Response has already been sent: " + this);
+ }
+ this.respHandler_(this);
+ this.sent = true;
+ }
+
+ /**
+ * Send error to client.
+ *
+ * Turns the response into an error response, clears any previously
+ * set body data, and sends it using the response handler provided
+ * on construction.
+ *
+ * @param {Error} err
+ * The Error instance to send.
+ *
+ * @throws {Error}
+ * If <var>err</var> is not a {@link WebDriverError}, the error
+ * is propagated, i.e. rethrown.
+ */
+ sendError(err) {
+ this.error = lazy.error.wrap(err).toJSON();
+ this.body = null;
+ this.send();
+
+ // propagate errors which are implementation problems
+ if (!lazy.error.isWebDriverError(err)) {
+ throw err;
+ }
+ }
+
+ /**
+ * Encodes the response to a packet.
+ *
+ * @return {Array}
+ * Packet.
+ */
+ toPacket() {
+ return [Response.Type, this.id, this.error, this.body];
+ }
+
+ /**
+ * Converts a data packet into {@link Response}.
+ *
+ * @param {Array.<number, number, ?, ?>} data
+ * A four element array where the elements, in sequence, signifies
+ * message type, message ID, error, and result.
+ *
+ * @return {Response}
+ * Representation of packet.
+ *
+ * @throws {TypeError}
+ * If the message type is not recognised.
+ */
+ static fromPacket(payload) {
+ let [type, msgID, err, body] = payload;
+ lazy.assert.that(n => n === Response.Type)(type);
+
+ let resp = new Response(msgID);
+ resp.error = lazy.assert.string(err);
+
+ resp.body = body;
+ return resp;
+ }
+}
+
+Response.Type = 1;
diff --git a/remote/marionette/modal.sys.mjs b/remote/marionette/modal.sys.mjs
new file mode 100644
index 0000000000..98eef0495e
--- /dev/null
+++ b/remote/marionette/modal.sys.mjs
@@ -0,0 +1,377 @@
+/* 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, {
+ AppInfo: "chrome://remote/content/shared/AppInfo.sys.mjs",
+ Log: "chrome://remote/content/shared/Log.sys.mjs",
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "logger", () =>
+ lazy.Log.get(lazy.Log.TYPES.MARIONETTE)
+);
+
+const COMMON_DIALOG = "chrome://global/content/commonDialog.xhtml";
+
+/** @namespace */
+export const modal = {
+ ACTION_CLOSED: "closed",
+ ACTION_OPENED: "opened",
+};
+
+/**
+ * Check for already existing modal or tab modal dialogs
+ *
+ * @param {browser.Context} context
+ * Reference to the browser context to check for existent dialogs.
+ *
+ * @return {modal.Dialog}
+ * Returns instance of the Dialog class, or `null` if no modal dialog
+ * is present.
+ */
+modal.findModalDialogs = function(context) {
+ // First check if there is a modal dialog already present for the
+ // current browser window.
+ for (let win of Services.wm.getEnumerator(null)) {
+ // TODO: Use BrowserWindowTracker.getTopWindow for modal dialogs without
+ // an opener.
+ if (
+ win.document.documentURI === COMMON_DIALOG &&
+ win.opener &&
+ win.opener === context.window
+ ) {
+ lazy.logger.trace("Found open window modal prompt");
+ return new modal.Dialog(() => context, win);
+ }
+ }
+
+ if (lazy.AppInfo.isAndroid) {
+ const geckoViewPrompts = context.window.prompts();
+ if (geckoViewPrompts.length) {
+ lazy.logger.trace("Found open GeckoView prompt");
+ const prompt = geckoViewPrompts[0];
+ return new modal.Dialog(() => context, prompt);
+ }
+ }
+
+ const contentBrowser = context.contentBrowser;
+
+ // If no modal dialog has been found yet, also check for tab and content modal
+ // dialogs for the current tab.
+ //
+ // TODO: Find an adequate implementation for Firefox on Android (bug 1708105)
+ if (contentBrowser?.tabDialogBox) {
+ let dialogs = contentBrowser.tabDialogBox.getTabDialogManager().dialogs;
+ if (dialogs.length) {
+ lazy.logger.trace("Found open tab modal prompt");
+ return new modal.Dialog(() => context, dialogs[0].frameContentWindow);
+ }
+
+ dialogs = contentBrowser.tabDialogBox.getContentDialogManager().dialogs;
+
+ // Even with the dialog manager handing back a dialog, the `Dialog` property
+ // gets lazily added. If it's not set yet, ignore the dialog for now.
+ if (dialogs.length && dialogs[0].frameContentWindow.Dialog) {
+ lazy.logger.trace("Found open content prompt");
+ return new modal.Dialog(() => context, dialogs[0].frameContentWindow);
+ }
+ }
+
+ // If no modal dialog has been found yet, check for old non SubDialog based
+ // content modal dialogs. Even with those deprecated in Firefox 89 we should
+ // keep supporting applications that don't have them implemented yet.
+ if (contentBrowser?.tabModalPromptBox) {
+ const prompts = contentBrowser.tabModalPromptBox.listPrompts();
+ if (prompts.length) {
+ lazy.logger.trace("Found open old-style content prompt");
+ return new modal.Dialog(() => context, null);
+ }
+ }
+
+ return null;
+};
+
+/**
+ * Observer for modal and tab modal dialogs.
+ *
+ * @param {function(): browser.Context} curBrowserFn
+ * Function that returns the current |browser.Context|.
+ *
+ * @return {modal.DialogObserver}
+ * Returns instance of the DialogObserver class.
+ */
+modal.DialogObserver = class {
+ constructor(curBrowserFn) {
+ this._curBrowserFn = curBrowserFn;
+
+ this.callbacks = new Set();
+ this.register();
+ }
+
+ register() {
+ Services.obs.addObserver(this, "common-dialog-loaded");
+ Services.obs.addObserver(this, "domwindowopened");
+ Services.obs.addObserver(this, "geckoview-prompt-show");
+ Services.obs.addObserver(this, "tabmodal-dialog-loaded");
+
+ // Register event listener for all already open windows
+ for (let win of Services.wm.getEnumerator(null)) {
+ win.addEventListener("DOMModalDialogClosed", this);
+ }
+ }
+
+ unregister() {
+ Services.obs.removeObserver(this, "common-dialog-loaded");
+ Services.obs.removeObserver(this, "domwindowopened");
+ Services.obs.removeObserver(this, "geckoview-prompt-show");
+ Services.obs.removeObserver(this, "tabmodal-dialog-loaded");
+
+ // Unregister event listener for all open windows
+ for (let win of Services.wm.getEnumerator(null)) {
+ win.removeEventListener("DOMModalDialogClosed", this);
+ }
+ }
+
+ cleanup() {
+ this.callbacks.clear();
+ this.unregister();
+ }
+
+ handleEvent(event) {
+ lazy.logger.trace(`Received event ${event.type}`);
+
+ const chromeWin = event.target.opener
+ ? event.target.opener.ownerGlobal
+ : event.target.ownerGlobal;
+
+ if (chromeWin != this._curBrowserFn().window) {
+ return;
+ }
+
+ this.callbacks.forEach(callback => {
+ callback(modal.ACTION_CLOSED, event.target);
+ });
+ }
+
+ observe(subject, topic) {
+ lazy.logger.trace(`Received observer notification ${topic}`);
+
+ const curBrowser = this._curBrowserFn();
+
+ switch (topic) {
+ // This topic is only used by the old-style content modal dialogs like
+ // alert, confirm, and prompt. It can be removed when only the new
+ // subdialog based content modals remain. Those will be made default in
+ // Firefox 89, and this case is deprecated.
+ case "tabmodal-dialog-loaded":
+ const container = curBrowser.contentBrowser.closest(
+ ".browserSidebarContainer"
+ );
+ if (!container.contains(subject)) {
+ return;
+ }
+ this.callbacks.forEach(callback =>
+ callback(modal.ACTION_OPENED, subject)
+ );
+ break;
+
+ case "common-dialog-loaded":
+ const modalType = subject.Dialog.args.modalType;
+
+ if (
+ modalType === Services.prompt.MODAL_TYPE_TAB ||
+ modalType === Services.prompt.MODAL_TYPE_CONTENT
+ ) {
+ // Find the container of the dialog in the parent document, and ensure
+ // it is a descendant of the same container as the current browser.
+ const container = curBrowser.contentBrowser.closest(
+ ".browserSidebarContainer"
+ );
+ if (!container.contains(subject.docShell.chromeEventHandler)) {
+ return;
+ }
+ } else if (
+ subject.ownerGlobal != curBrowser.window &&
+ subject.opener?.ownerGlobal != curBrowser.window
+ ) {
+ return;
+ }
+
+ this.callbacks.forEach(callback =>
+ callback(modal.ACTION_OPENED, subject)
+ );
+ break;
+
+ case "domwindowopened":
+ subject.addEventListener("DOMModalDialogClosed", this);
+ break;
+
+ case "geckoview-prompt-show":
+ for (let win of Services.wm.getEnumerator(null)) {
+ const prompt = win.prompts().find(item => item.id == subject.id);
+ if (prompt) {
+ this.callbacks.forEach(callback =>
+ callback(modal.ACTION_OPENED, prompt)
+ );
+ return;
+ }
+ }
+ break;
+ }
+ }
+
+ /**
+ * Add dialog handler by function reference.
+ *
+ * @param {function} callback
+ * The handler to be added.
+ */
+ add(callback) {
+ if (this.callbacks.has(callback)) {
+ return;
+ }
+ this.callbacks.add(callback);
+ }
+
+ /**
+ * Remove dialog handler by function reference.
+ *
+ * @param {function} callback
+ * The handler to be removed.
+ */
+ remove(callback) {
+ if (!this.callbacks.has(callback)) {
+ return;
+ }
+ this.callbacks.delete(callback);
+ }
+
+ /**
+ * Returns a promise that waits for the dialog to be closed.
+ */
+ async dialogClosed() {
+ return new Promise(resolve => {
+ const dialogClosed = (action, dialog) => {
+ if (action == modal.ACTION_CLOSED) {
+ this.remove(dialogClosed);
+ resolve();
+ }
+ };
+
+ this.add(dialogClosed);
+ });
+ }
+};
+
+/**
+ * Represents a modal dialog.
+ *
+ * @param {function(): browser.Context} curBrowserFn
+ * Function that returns the current |browser.Context|.
+ * @param {DOMWindow} dialog
+ * DOMWindow of the dialog.
+ */
+modal.Dialog = class {
+ constructor(curBrowserFn, dialog) {
+ this.curBrowserFn_ = curBrowserFn;
+ this.win_ = Cu.getWeakReference(dialog);
+ }
+
+ get args() {
+ if (lazy.AppInfo.isAndroid) {
+ return this.window.args;
+ }
+ let tm = this.tabModal;
+ return tm ? tm.args : null;
+ }
+
+ get curBrowser_() {
+ return this.curBrowserFn_();
+ }
+
+ get isOpen() {
+ if (lazy.AppInfo.isAndroid) {
+ return this.window !== null;
+ }
+ if (!this.ui) {
+ return false;
+ }
+ return true;
+ }
+
+ get isWindowModal() {
+ return [
+ Services.prompt.MODAL_TYPE_WINDOW,
+ Services.prompt.MODAL_TYPE_INTERNAL_WINDOW,
+ ].includes(this.args.modalType);
+ }
+
+ get tabModal() {
+ let win = this.window;
+ if (win) {
+ return win.Dialog;
+ }
+ return this.curBrowser_.getTabModal();
+ }
+
+ get text() {
+ if (lazy.AppInfo.isAndroid) {
+ return this.window.getPromptText();
+ }
+ return this.ui.infoBody.textContent;
+ }
+
+ get ui() {
+ let tm = this.tabModal;
+ return tm ? tm.ui : null;
+ }
+
+ /**
+ * For Android, this returns a GeckoViewPrompter, which can be used to control prompts.
+ * Otherwise, this returns the ChromeWindow associated with an open dialog window if
+ * it is currently attached to the DOM.
+ */
+ get window() {
+ if (this.win_) {
+ let win = this.win_.get();
+ if (win && (lazy.AppInfo.isAndroid || win.parent)) {
+ return win;
+ }
+ }
+ return null;
+ }
+
+ set text(inputText) {
+ if (lazy.AppInfo.isAndroid) {
+ this.window.setInputText(inputText);
+ } else {
+ // see toolkit/components/prompts/content/commonDialog.js
+ let { loginTextbox } = this.ui;
+ loginTextbox.value = inputText;
+ }
+ }
+
+ accept() {
+ if (lazy.AppInfo.isAndroid) {
+ // GeckoView does not have a UI, so the methods are called directly
+ this.window.acceptPrompt();
+ } else {
+ const { button0 } = this.ui;
+ button0.click();
+ }
+ }
+
+ dismiss() {
+ if (lazy.AppInfo.isAndroid) {
+ // GeckoView does not have a UI, so the methods are called directly
+ this.window.dismissPrompt();
+ } else {
+ const { button0, button1 } = this.ui;
+ (button1 ? button1 : button0).click();
+ }
+ }
+};
diff --git a/remote/marionette/moz.build b/remote/marionette/moz.build
new file mode 100644
index 0000000000..0d88f7a6ea
--- /dev/null
+++ b/remote/marionette/moz.build
@@ -0,0 +1,10 @@
+# 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/.
+
+JAR_MANIFESTS += ["jar.mn"]
+
+XPCSHELL_TESTS_MANIFESTS += ["test/xpcshell/xpcshell.ini"]
+
+with Files("**"):
+ BUG_COMPONENT = ("Testing", "Marionette")
diff --git a/remote/marionette/navigate.sys.mjs b/remote/marionette/navigate.sys.mjs
new file mode 100644
index 0000000000..da756f1f1a
--- /dev/null
+++ b/remote/marionette/navigate.sys.mjs
@@ -0,0 +1,427 @@
+/* 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, {
+ error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs",
+ EventDispatcher:
+ "chrome://remote/content/marionette/actors/MarionetteEventsParent.sys.mjs",
+ Log: "chrome://remote/content/shared/Log.sys.mjs",
+ modal: "chrome://remote/content/marionette/modal.sys.mjs",
+ PageLoadStrategy:
+ "chrome://remote/content/shared/webdriver/Capabilities.sys.mjs",
+ ProgressListener: "chrome://remote/content/shared/Navigate.sys.mjs",
+ TimedPromise: "chrome://remote/content/marionette/sync.sys.mjs",
+ truncate: "chrome://remote/content/shared/Format.sys.mjs",
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "logger", () =>
+ lazy.Log.get(lazy.Log.TYPES.MARIONETTE)
+);
+
+// Timeouts used to check if a new navigation has been initiated.
+const TIMEOUT_BEFOREUNLOAD_EVENT = 200;
+const TIMEOUT_UNLOAD_EVENT = 5000;
+
+/** @namespace */
+export const navigate = {};
+
+/**
+ * Checks the value of readyState for the current page
+ * load activity, and resolves the command if the load
+ * has been finished. It also takes care of the selected
+ * page load strategy.
+ *
+ * @param {PageLoadStrategy} pageLoadStrategy
+ * Strategy when navigation is considered as finished.
+ * @param {object} eventData
+ * @param {string} eventData.documentURI
+ * Current document URI of the document.
+ * @param {string} eventData.readyState
+ * Current ready state of the document.
+ *
+ * @return {boolean}
+ * True if the page load has been finished.
+ */
+function checkReadyState(pageLoadStrategy, eventData = {}) {
+ const { documentURI, readyState } = eventData;
+
+ const result = { error: null, finished: false };
+
+ switch (readyState) {
+ case "interactive":
+ if (documentURI.startsWith("about:certerror")) {
+ result.error = new lazy.error.InsecureCertificateError();
+ result.finished = true;
+ } else if (/about:.*(error)\?/.exec(documentURI)) {
+ result.error = new lazy.error.UnknownError(
+ `Reached error page: ${documentURI}`
+ );
+ result.finished = true;
+
+ // Return early with a page load strategy of eager, and also
+ // special-case about:blocked pages which should be treated as
+ // non-error pages but do not raise a pageshow event. about:blank
+ // is also treaded specifically here, because it gets temporary
+ // loaded for new content processes, and we only want to rely on
+ // complete loads for it.
+ } else if (
+ (pageLoadStrategy === lazy.PageLoadStrategy.Eager &&
+ documentURI != "about:blank") ||
+ /about:blocked\?/.exec(documentURI)
+ ) {
+ result.finished = true;
+ }
+ break;
+
+ case "complete":
+ result.finished = true;
+ break;
+ }
+
+ return result;
+}
+
+/**
+ * Determines if we expect to get a DOM load event (DOMContentLoaded)
+ * on navigating to the <code>future</code> URL.
+ *
+ * @param {URL} current
+ * URL the browser is currently visiting.
+ * @param {Object} options
+ * @param {BrowsingContext=} options.browsingContext
+ * The current browsing context. Needed for targets of _parent and _top.
+ * @param {URL=} options.future
+ * Destination URL, if known.
+ * @param {target=} options.target
+ * Link target, if known.
+ *
+ * @return {boolean}
+ * Full page load would be expected if future is followed.
+ *
+ * @throws TypeError
+ * If <code>current</code> is not defined, or any of
+ * <code>current</code> or <code>future</code> are invalid URLs.
+ */
+navigate.isLoadEventExpected = function(current, options = {}) {
+ const { browsingContext, future, target } = options;
+
+ if (typeof current == "undefined") {
+ throw new TypeError("Expected at least one URL");
+ }
+
+ if (["_parent", "_top"].includes(target) && !browsingContext) {
+ throw new TypeError(
+ "Expected browsingContext when target is _parent or _top"
+ );
+ }
+
+ // Don't wait if the navigation happens in a different browsing context
+ if (
+ target === "_blank" ||
+ (target === "_parent" && browsingContext.parent) ||
+ (target === "_top" && browsingContext.top != browsingContext)
+ ) {
+ return false;
+ }
+
+ // Assume we will go somewhere exciting
+ if (typeof future == "undefined") {
+ return true;
+ }
+
+ // Assume javascript:<whatever> will modify the current document
+ // but this is not an entirely safe assumption to make,
+ // considering it could be used to set window.location
+ if (future.protocol == "javascript:") {
+ return false;
+ }
+
+ // If hashes are present and identical
+ if (
+ current.href.includes("#") &&
+ future.href.includes("#") &&
+ current.hash === future.hash
+ ) {
+ return false;
+ }
+
+ return true;
+};
+
+/**
+ * Load the given URL in the specified browsing context.
+ *
+ * @param {CanonicalBrowsingContext} browsingContext
+ * Browsing context to load the URL into.
+ * @param {string} url
+ * URL to navigate to.
+ */
+navigate.navigateTo = async function(browsingContext, url) {
+ const opts = {
+ loadFlags: Ci.nsIWebNavigation.LOAD_FLAGS_IS_LINK,
+ triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
+ // Fake user activation.
+ hasValidUserGestureActivation: true,
+ };
+ browsingContext.loadURI(url, opts);
+};
+
+/**
+ * Reload the page.
+ *
+ * @param {CanonicalBrowsingContext} browsingContext
+ * Browsing context to refresh.
+ */
+navigate.refresh = async function(browsingContext) {
+ const flags = Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_CACHE;
+ browsingContext.reload(flags);
+};
+
+/**
+ * Execute a callback and wait for a possible navigation to complete
+ *
+ * @param {GeckoDriver} driver
+ * Reference to driver instance.
+ * @param {Function} callback
+ * Callback to execute that might trigger a navigation.
+ * @param {Object} options
+ * @param {BrowsingContext=} browsingContext
+ * Browsing context to observe. Defaults to the current browsing context.
+ * @param {boolean=} loadEventExpected
+ * If false, return immediately and don't wait for
+ * the navigation to be completed. Defaults to true.
+ * @param {boolean=} requireBeforeUnload
+ * If false and no beforeunload event is fired, abort waiting
+ * for the navigation. Defaults to true.
+ */
+navigate.waitForNavigationCompleted = async function waitForNavigationCompleted(
+ driver,
+ callback,
+ options = {}
+) {
+ const {
+ browsingContextFn = driver.getBrowsingContext.bind(driver),
+ loadEventExpected = true,
+ requireBeforeUnload = true,
+ } = options;
+
+ const browsingContext = browsingContextFn();
+ const chromeWindow = browsingContext.topChromeWindow;
+ const pageLoadStrategy = driver.currentSession.pageLoadStrategy;
+
+ // Return immediately if no load event is expected
+ if (!loadEventExpected) {
+ await callback();
+ return Promise.resolve();
+ }
+
+ // When not waiting for page load events, do not return until the navigation has actually started.
+ if (pageLoadStrategy === lazy.PageLoadStrategy.None) {
+ const listener = new lazy.ProgressListener(browsingContext.webProgress, {
+ resolveWhenStarted: true,
+ waitForExplicitStart: true,
+ });
+ const navigated = listener.start();
+ navigated.finally(() => {
+ if (listener.isStarted) {
+ listener.stop();
+ }
+ });
+
+ await callback();
+ await navigated;
+
+ return Promise.resolve();
+ }
+
+ let rejectNavigation;
+ let resolveNavigation;
+
+ let browsingContextChanged = false;
+ let seenBeforeUnload = false;
+ let seenUnload = false;
+
+ let unloadTimer;
+
+ const checkDone = ({ finished, error }) => {
+ if (finished) {
+ if (error) {
+ rejectNavigation(error);
+ } else {
+ resolveNavigation();
+ }
+ }
+ };
+
+ const onDialogOpened = action => {
+ if (action === lazy.modal.ACTION_OPENED) {
+ lazy.logger.trace("Canceled page load listener because a dialog opened");
+ checkDone({ finished: true });
+ }
+ };
+
+ const onTimer = timer => {
+ // In the case when a document has a beforeunload handler
+ // registered, the currently active command will return immediately
+ // due to the modal dialog observer.
+ //
+ // Otherwise the timeout waiting for the document to start
+ // navigating is increased by 5000 ms to ensure a possible load
+ // event is not missed. In the common case such an event should
+ // occur pretty soon after beforeunload, and we optimise for this.
+ if (seenBeforeUnload) {
+ seenBeforeUnload = false;
+ unloadTimer.initWithCallback(
+ onTimer,
+ TIMEOUT_UNLOAD_EVENT,
+ Ci.nsITimer.TYPE_ONE_SHOT
+ );
+
+ // If no page unload has been detected, ensure to properly stop
+ // the load listener, and return from the currently active command.
+ } else if (!seenUnload) {
+ lazy.logger.trace(
+ "Canceled page load listener because no navigation " +
+ "has been detected"
+ );
+ checkDone({ finished: true });
+ }
+ };
+
+ const onNavigation = (eventName, data) => {
+ const browsingContext = browsingContextFn();
+
+ // Ignore events from other browsing contexts than the selected one.
+ if (data.browsingContext != browsingContext) {
+ return;
+ }
+
+ lazy.logger.trace(
+ lazy.truncate`[${data.browsingContext.id}] Received event ${data.type} for ${data.documentURI}`
+ );
+
+ switch (data.type) {
+ case "beforeunload":
+ seenBeforeUnload = true;
+ break;
+
+ case "pagehide":
+ seenUnload = true;
+ break;
+
+ case "hashchange":
+ case "popstate":
+ checkDone({ finished: true });
+ break;
+
+ case "DOMContentLoaded":
+ case "pageshow":
+ // Don't require an unload event when a top-level browsing context
+ // change occurred.
+ if (!seenUnload && !browsingContextChanged) {
+ return;
+ }
+ const result = checkReadyState(pageLoadStrategy, data);
+ checkDone(result);
+ break;
+ }
+ };
+
+ // In the case when the currently selected frame is closed,
+ // there will be no further load events. Stop listening immediately.
+ const onBrowsingContextDiscarded = (subject, topic, why) => {
+ // If the BrowsingContext is being discarded to be replaced by another
+ // context, we don't want to stop waiting for the pageload to complete, as
+ // we will continue listening to the newly created context.
+ if (subject == browsingContextFn() && why != "replace") {
+ lazy.logger.trace(
+ "Canceled page load listener " +
+ `because browsing context with id ${subject.id} has been removed`
+ );
+ checkDone({ finished: true });
+ }
+ };
+
+ // Detect changes to the top-level browsing context to not
+ // necessarily require an unload event.
+ const onBrowsingContextChanged = event => {
+ if (event.target === driver.curBrowser.contentBrowser) {
+ browsingContextChanged = true;
+ }
+ };
+
+ const onUnload = event => {
+ lazy.logger.trace(
+ "Canceled page load listener " +
+ "because the top-browsing context has been closed"
+ );
+ checkDone({ finished: true });
+ };
+
+ chromeWindow.addEventListener("TabClose", onUnload);
+ chromeWindow.addEventListener("unload", onUnload);
+ driver.curBrowser.tabBrowser?.addEventListener(
+ "XULFrameLoaderCreated",
+ onBrowsingContextChanged
+ );
+ driver.dialogObserver.add(onDialogOpened);
+ Services.obs.addObserver(
+ onBrowsingContextDiscarded,
+ "browsing-context-discarded"
+ );
+
+ lazy.EventDispatcher.on("page-load", onNavigation);
+
+ return new lazy.TimedPromise(
+ async (resolve, reject) => {
+ rejectNavigation = reject;
+ resolveNavigation = resolve;
+
+ try {
+ await callback();
+
+ // Certain commands like clickElement can cause a navigation. Setup a timer
+ // to check if a "beforeunload" event has been emitted within the given
+ // time frame. If not resolve the Promise.
+ if (!requireBeforeUnload) {
+ unloadTimer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+ unloadTimer.initWithCallback(
+ onTimer,
+ TIMEOUT_BEFOREUNLOAD_EVENT,
+ Ci.nsITimer.TYPE_ONE_SHOT
+ );
+ }
+ } catch (e) {
+ // Executing the callback above could destroy the actor pair before the
+ // command returns. Such an error has to be ignored.
+ if (e.name !== "AbortError") {
+ checkDone({ finished: true, error: e });
+ }
+ }
+ },
+ {
+ timeout: driver.currentSession.timeouts.pageLoad,
+ }
+ ).finally(() => {
+ // Clean-up all registered listeners and timers
+ Services.obs.removeObserver(
+ onBrowsingContextDiscarded,
+ "browsing-context-discarded"
+ );
+ chromeWindow.removeEventListener("TabClose", onUnload);
+ chromeWindow.removeEventListener("unload", onUnload);
+ driver.curBrowser.tabBrowser?.removeEventListener(
+ "XULFrameLoaderCreated",
+ onBrowsingContextChanged
+ );
+ driver.dialogObserver?.remove(onDialogOpened);
+ unloadTimer?.cancel();
+
+ lazy.EventDispatcher.off("page-load", onNavigation);
+ });
+};
diff --git a/remote/marionette/packets.sys.mjs b/remote/marionette/packets.sys.mjs
new file mode 100644
index 0000000000..3ae663778d
--- /dev/null
+++ b/remote/marionette/packets.sys.mjs
@@ -0,0 +1,425 @@
+/* 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, {
+ StreamUtils: "chrome://remote/content/marionette/stream-utils.sys.mjs",
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "unicodeConverter", () => {
+ const unicodeConverter = Cc[
+ "@mozilla.org/intl/scriptableunicodeconverter"
+ ].createInstance(Ci.nsIScriptableUnicodeConverter);
+ unicodeConverter.charset = "UTF-8";
+
+ return unicodeConverter;
+});
+
+/**
+ * Packets contain read / write functionality for the different packet types
+ * supported by the debugging protocol, so that a transport can focus on
+ * delivery and queue management without worrying too much about the specific
+ * packet types.
+ *
+ * They are intended to be "one use only", so a new packet should be
+ * instantiated for each incoming or outgoing packet.
+ *
+ * A complete Packet type should expose at least the following:
+ * * read(stream, scriptableStream)
+ * Called when the input stream has data to read
+ * * write(stream)
+ * Called when the output stream is ready to write
+ * * get done()
+ * Returns true once the packet is done being read / written
+ * * destroy()
+ * Called to clean up at the end of use
+ */
+
+const defer = function() {
+ let deferred = {
+ promise: new Promise((resolve, reject) => {
+ deferred.resolve = resolve;
+ deferred.reject = reject;
+ }),
+ };
+ return deferred;
+};
+
+// The transport's previous check ensured the header length did not
+// exceed 20 characters. Here, we opt for the somewhat smaller, but still
+// large limit of 1 TiB.
+const PACKET_LENGTH_MAX = Math.pow(2, 40);
+
+/**
+ * A generic Packet processing object (extended by two subtypes below).
+ *
+ * @class
+ */
+export function Packet(transport) {
+ this._transport = transport;
+ this._length = 0;
+}
+
+/**
+ * Attempt to initialize a new Packet based on the incoming packet header
+ * we've received so far. We try each of the types in succession, trying
+ * JSON packets first since they are much more common.
+ *
+ * @param {string} header
+ * Packet header string to attempt parsing.
+ * @param {DebuggerTransport} transport
+ * Transport instance that will own the packet.
+ *
+ * @return {Packet}
+ * Parsed packet of the matching type, or null if no types matched.
+ */
+Packet.fromHeader = function(header, transport) {
+ return (
+ JSONPacket.fromHeader(header, transport) ||
+ BulkPacket.fromHeader(header, transport)
+ );
+};
+
+Packet.prototype = {
+ get length() {
+ return this._length;
+ },
+
+ set length(length) {
+ if (length > PACKET_LENGTH_MAX) {
+ throw new Error(
+ "Packet length " +
+ length +
+ " exceeds the max length of " +
+ PACKET_LENGTH_MAX
+ );
+ }
+ this._length = length;
+ },
+
+ destroy() {
+ this._transport = null;
+ },
+};
+
+/**
+ * With a JSON packet (the typical packet type sent via the transport),
+ * data is transferred as a JSON packet serialized into a string,
+ * with the string length prepended to the packet, followed by a colon
+ * ([length]:[packet]). The contents of the JSON packet are specified in
+ * the Remote Debugging Protocol specification.
+ *
+ * @param {DebuggerTransport} transport
+ * Transport instance that will own the packet.
+ */
+export function JSONPacket(transport) {
+ Packet.call(this, transport);
+ this._data = "";
+ this._done = false;
+}
+
+/**
+ * Attempt to initialize a new JSONPacket based on the incoming packet
+ * header we've received so far.
+ *
+ * @param {string} header
+ * Packet header string to attempt parsing.
+ * @param {DebuggerTransport} transport
+ * Transport instance that will own the packet.
+ *
+ * @return {JSONPacket}
+ * Parsed packet, or null if it's not a match.
+ */
+JSONPacket.fromHeader = function(header, transport) {
+ let match = this.HEADER_PATTERN.exec(header);
+
+ if (!match) {
+ return null;
+ }
+
+ let packet = new JSONPacket(transport);
+ packet.length = +match[1];
+ return packet;
+};
+
+JSONPacket.HEADER_PATTERN = /^(\d+):$/;
+
+JSONPacket.prototype = Object.create(Packet.prototype);
+
+Object.defineProperty(JSONPacket.prototype, "object", {
+ /**
+ * Gets the object (not the serialized string) being read or written.
+ */
+ get() {
+ return this._object;
+ },
+
+ /**
+ * Sets the object to be sent when write() is called.
+ */
+ set(object) {
+ this._object = object;
+ let data = JSON.stringify(object);
+ this._data = lazy.unicodeConverter.ConvertFromUnicode(data);
+ this.length = this._data.length;
+ },
+});
+
+JSONPacket.prototype.read = function(stream, scriptableStream) {
+ // Read in more packet data.
+ this._readData(stream, scriptableStream);
+
+ if (!this.done) {
+ // Don't have a complete packet yet.
+ return;
+ }
+
+ let json = this._data;
+ try {
+ json = lazy.unicodeConverter.ConvertToUnicode(json);
+ this._object = JSON.parse(json);
+ } catch (e) {
+ let msg =
+ "Error parsing incoming packet: " +
+ json +
+ " (" +
+ e +
+ " - " +
+ e.stack +
+ ")";
+ console.error(msg);
+ dump(msg + "\n");
+ return;
+ }
+
+ this._transport._onJSONObjectReady(this._object);
+};
+
+JSONPacket.prototype._readData = function(stream, scriptableStream) {
+ let bytesToRead = Math.min(
+ this.length - this._data.length,
+ stream.available()
+ );
+ this._data += scriptableStream.readBytes(bytesToRead);
+ this._done = this._data.length === this.length;
+};
+
+JSONPacket.prototype.write = function(stream) {
+ if (this._outgoing === undefined) {
+ // Format the serialized packet to a buffer
+ this._outgoing = this.length + ":" + this._data;
+ }
+
+ let written = stream.write(this._outgoing, this._outgoing.length);
+ this._outgoing = this._outgoing.slice(written);
+ this._done = !this._outgoing.length;
+};
+
+Object.defineProperty(JSONPacket.prototype, "done", {
+ get() {
+ return this._done;
+ },
+});
+
+JSONPacket.prototype.toString = function() {
+ return JSON.stringify(this._object, null, 2);
+};
+
+/**
+ * With a bulk packet, data is transferred by temporarily handing over
+ * the transport's input or output stream to the application layer for
+ * writing data directly. This can be much faster for large data sets,
+ * and avoids various stages of copies and data duplication inherent in
+ * the JSON packet type. The bulk packet looks like:
+ *
+ * bulk [actor] [type] [length]:[data]
+ *
+ * The interpretation of the data portion depends on the kind of actor and
+ * the packet's type. See the Remote Debugging Protocol Stream Transport
+ * spec for more details.
+ *
+ * @param {DebuggerTransport} transport
+ * Transport instance that will own the packet.
+ */
+export function BulkPacket(transport) {
+ Packet.call(this, transport);
+ this._done = false;
+ this._readyForWriting = defer();
+}
+
+/**
+ * Attempt to initialize a new BulkPacket based on the incoming packet
+ * header we've received so far.
+ *
+ * @param {string} header
+ * Packet header string to attempt parsing.
+ * @param {DebuggerTransport} transport
+ * Transport instance that will own the packet.
+ *
+ * @return {BulkPacket}
+ * Parsed packet, or null if it's not a match.
+ */
+BulkPacket.fromHeader = function(header, transport) {
+ let match = this.HEADER_PATTERN.exec(header);
+
+ if (!match) {
+ return null;
+ }
+
+ let packet = new BulkPacket(transport);
+ packet.header = {
+ actor: match[1],
+ type: match[2],
+ length: +match[3],
+ };
+ return packet;
+};
+
+BulkPacket.HEADER_PATTERN = /^bulk ([^: ]+) ([^: ]+) (\d+):$/;
+
+BulkPacket.prototype = Object.create(Packet.prototype);
+
+BulkPacket.prototype.read = function(stream) {
+ // Temporarily pause monitoring of the input stream
+ this._transport.pauseIncoming();
+
+ let deferred = defer();
+
+ this._transport._onBulkReadReady({
+ actor: this.actor,
+ type: this.type,
+ length: this.length,
+ copyTo: output => {
+ let copying = lazy.StreamUtils.copyStream(stream, output, this.length);
+ deferred.resolve(copying);
+ return copying;
+ },
+ stream,
+ done: deferred,
+ });
+
+ // Await the result of reading from the stream
+ deferred.promise.then(() => {
+ this._done = true;
+ this._transport.resumeIncoming();
+ }, this._transport.close);
+
+ // Ensure this is only done once
+ this.read = () => {
+ throw new Error("Tried to read() a BulkPacket's stream multiple times.");
+ };
+};
+
+BulkPacket.prototype.write = function(stream) {
+ if (this._outgoingHeader === undefined) {
+ // Format the serialized packet header to a buffer
+ this._outgoingHeader =
+ "bulk " + this.actor + " " + this.type + " " + this.length + ":";
+ }
+
+ // Write the header, or whatever's left of it to write.
+ if (this._outgoingHeader.length) {
+ let written = stream.write(
+ this._outgoingHeader,
+ this._outgoingHeader.length
+ );
+ this._outgoingHeader = this._outgoingHeader.slice(written);
+ return;
+ }
+
+ // Temporarily pause the monitoring of the output stream
+ this._transport.pauseOutgoing();
+
+ let deferred = defer();
+
+ this._readyForWriting.resolve({
+ copyFrom: input => {
+ let copying = lazy.StreamUtils.copyStream(input, stream, this.length);
+ deferred.resolve(copying);
+ return copying;
+ },
+ stream,
+ done: deferred,
+ });
+
+ // Await the result of writing to the stream
+ deferred.promise.then(() => {
+ this._done = true;
+ this._transport.resumeOutgoing();
+ }, this._transport.close);
+
+ // Ensure this is only done once
+ this.write = () => {
+ throw new Error("Tried to write() a BulkPacket's stream multiple times.");
+ };
+};
+
+Object.defineProperty(BulkPacket.prototype, "streamReadyForWriting", {
+ get() {
+ return this._readyForWriting.promise;
+ },
+});
+
+Object.defineProperty(BulkPacket.prototype, "header", {
+ get() {
+ return {
+ actor: this.actor,
+ type: this.type,
+ length: this.length,
+ };
+ },
+
+ set(header) {
+ this.actor = header.actor;
+ this.type = header.type;
+ this.length = header.length;
+ },
+});
+
+Object.defineProperty(BulkPacket.prototype, "done", {
+ get() {
+ return this._done;
+ },
+});
+
+BulkPacket.prototype.toString = function() {
+ return "Bulk: " + JSON.stringify(this.header, null, 2);
+};
+
+/**
+ * RawPacket is used to test the transport's error handling of malformed
+ * packets, by writing data directly onto the stream.
+ * @param transport DebuggerTransport
+ * The transport instance that will own the packet.
+ * @param data string
+ * The raw string to send out onto the stream.
+ */
+export function RawPacket(transport, data) {
+ Packet.call(this, transport);
+ this._data = data;
+ this.length = data.length;
+ this._done = false;
+}
+
+RawPacket.prototype = Object.create(Packet.prototype);
+
+RawPacket.prototype.read = function() {
+ // this has not yet been needed for testing
+ throw new Error("Not implemented");
+};
+
+RawPacket.prototype.write = function(stream) {
+ let written = stream.write(this._data, this._data.length);
+ this._data = this._data.slice(written);
+ this._done = !this._data.length;
+};
+
+Object.defineProperty(RawPacket.prototype, "done", {
+ get() {
+ return this._done;
+ },
+});
diff --git a/remote/marionette/permissions.sys.mjs b/remote/marionette/permissions.sys.mjs
new file mode 100644
index 0000000000..43fac98422
--- /dev/null
+++ b/remote/marionette/permissions.sys.mjs
@@ -0,0 +1,60 @@
+/* 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, {
+ error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs",
+ MarionettePrefs: "chrome://remote/content/marionette/prefs.sys.mjs",
+});
+
+/** @namespace */
+export const permissions = {};
+
+/**
+ * Set a permission's state.
+ * Note: Currently just a shim to support testdriver's set_permission.
+ *
+ * @param {Object} descriptor
+ * Descriptor with the `name` property.
+ * @param {string} state
+ * State of the permission. It can be `granted`, `denied` or `prompt`.
+ * @param {boolean} oneRealm
+ * Currently ignored
+ *
+ * @throws {UnsupportedOperationError}
+ * If `marionette.setpermission.enabled` is not set or
+ * an unsupported permission is used.
+ */
+permissions.set = function(descriptor, state, oneRealm) {
+ if (!lazy.MarionettePrefs.setPermissionEnabled) {
+ throw new lazy.error.UnsupportedOperationError(
+ "'Set Permission' is not available"
+ );
+ }
+
+ const { name } = descriptor;
+ if (!["clipboard-write", "clipboard-read"].includes(name)) {
+ throw new lazy.error.UnsupportedOperationError(
+ `'Set Permission' doesn't support '${name}'`
+ );
+ }
+
+ if (state === "prompt") {
+ throw new lazy.error.UnsupportedOperationError(
+ "'Set Permission' doesn't support prompt"
+ );
+ }
+
+ // This is not a real implementation of the permissions API.
+ // Instead the purpose of this implementation is to have web-platform-tests
+ // that use `set_permission('clipboard-write|read')` not fail.
+ // We enable dom.events.testing.asyncClipboard for the whole test suite anyway,
+ // so no extra permission is necessary.
+ if (!Services.prefs.getBoolPref("dom.events.testing.asyncClipboard", false)) {
+ throw new lazy.error.UnsupportedOperationError(
+ "'Set Permission' expected dom.events.testing.asyncClipboard to be set"
+ );
+ }
+};
diff --git a/remote/marionette/prefs.sys.mjs b/remote/marionette/prefs.sys.mjs
new file mode 100644
index 0000000000..e1be1747bc
--- /dev/null
+++ b/remote/marionette/prefs.sys.mjs
@@ -0,0 +1,180 @@
+/* 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 { PREF_BOOL, PREF_INT, PREF_INVALID, PREF_STRING } = Ci.nsIPrefBranch;
+
+export class Branch {
+ /**
+ * @param {string=} branch
+ * Preference subtree. Uses root tree given `null`.
+ */
+ constructor(branch) {
+ this._branch = Services.prefs.getBranch(branch);
+ }
+
+ /**
+ * Gets value of `pref` in its known type.
+ *
+ * @param {string} pref
+ * Preference name.
+ * @param {?=} fallback
+ * Fallback value to return if `pref` does not exist.
+ *
+ * @return {(string|boolean|number)}
+ * Value of `pref`, or the `fallback` value if `pref` does
+ * not exist.
+ *
+ * @throws {TypeError}
+ * If `pref` is not a recognised preference and no `fallback`
+ * value has been provided.
+ */
+ get(pref, fallback = null) {
+ switch (this._branch.getPrefType(pref)) {
+ case PREF_STRING:
+ return this._branch.getStringPref(pref);
+
+ case PREF_BOOL:
+ return this._branch.getBoolPref(pref);
+
+ case PREF_INT:
+ return this._branch.getIntPref(pref);
+
+ case PREF_INVALID:
+ default:
+ if (fallback != null) {
+ return fallback;
+ }
+ throw new TypeError(`Unrecognised preference: ${pref}`);
+ }
+ }
+
+ /**
+ * Sets the value of `pref`.
+ *
+ * @param {string} pref
+ * Preference name.
+ * @param {(string|boolean|number)} value
+ * `pref`'s new value.
+ *
+ * @throws {TypeError}
+ * If `value` is not the correct type for `pref`.
+ */
+ set(pref, value) {
+ let typ;
+ if (typeof value != "undefined" && value != null) {
+ typ = value.constructor.name;
+ }
+
+ switch (typ) {
+ case "String":
+ // Unicode compliant
+ return this._branch.setStringPref(pref, value);
+
+ case "Boolean":
+ return this._branch.setBoolPref(pref, value);
+
+ case "Number":
+ return this._branch.setIntPref(pref, value);
+
+ default:
+ throw new TypeError(`Illegal preference type value: ${typ}`);
+ }
+ }
+}
+
+/**
+ * Provides shortcuts for lazily getting and setting typed Marionette
+ * preferences.
+ *
+ * Some of Marionette's preferences are stored using primitive values
+ * that internally are represented by complex types. One such example
+ * is `marionette.log.level` which stores a string such as `info` or
+ * `DEBUG`, and which is represented as `Log.Level`.
+ *
+ * Because we cannot trust the input of many of these preferences,
+ * this class provides abstraction that lets us safely deal with
+ * potentially malformed input. In the `marionette.log.level` example,
+ * `DEBUG`, `Debug`, and `dEbUg` are considered valid inputs and the
+ * `LogBranch` specialisation deserialises the string value to the
+ * correct `Log.Level` by sanitising the input data first.
+ *
+ * A further complication is that we cannot rely on `Preferences.sys.mjs`
+ * in Marionette. See https://bugzilla.mozilla.org/show_bug.cgi?id=1357517
+ * for further details.
+ */
+class MarionetteBranch extends Branch {
+ constructor(branch = "marionette.") {
+ super(branch);
+ }
+
+ /**
+ * The `marionette.debugging.clicktostart` preference delays
+ * server startup until a modal dialogue has been clicked to allow
+ * time for user to set breakpoints in the Browser Toolbox.
+ *
+ * @return {boolean}
+ */
+ get clickToStart() {
+ return this.get("debugging.clicktostart", false);
+ }
+
+ /**
+ * The `marionette.port` preference, detailing which port
+ * the TCP server should listen on.
+ *
+ * @return {number}
+ */
+ get port() {
+ return this.get("port", 2828);
+ }
+
+ set port(newPort) {
+ this.set("port", newPort);
+ }
+
+ /**
+ * Gets the `marionette.setpermission.enabled` preference, should
+ * only be used for testdriver's set_permission API.
+ *
+ * @return {boolean}
+ */
+ get setPermissionEnabled() {
+ return this.get("setpermission.enabled", false);
+ }
+}
+
+/** Reads a JSON serialised blob stored in the environment. */
+export class EnvironmentPrefs {
+ /**
+ * Reads the environment variable `key` and tries to parse it as
+ * JSON Object, then provides an iterator over its keys and values.
+ *
+ * If the environment variable is not set, this function returns empty.
+ *
+ * @param {string} key
+ * Environment variable.
+ *
+ * @return {Iterable.<string, (string|boolean|number)>
+ */
+ static *from(key) {
+ if (!Services.env.exists(key)) {
+ return;
+ }
+
+ let prefs;
+ try {
+ prefs = JSON.parse(Services.env.get(key));
+ } catch (e) {
+ throw new TypeError(`Unable to parse prefs from ${key}`, e);
+ }
+
+ for (let prefName of Object.keys(prefs)) {
+ yield [prefName, prefs[prefName]];
+ }
+ }
+}
+
+// There is a future potential of exposing this as Marionette.prefs.port
+// if we introduce a Marionette.jsm module.
+export const MarionettePrefs = new MarionetteBranch();
diff --git a/remote/marionette/reftest-content.js b/remote/marionette/reftest-content.js
new file mode 100644
index 0000000000..3c0712f232
--- /dev/null
+++ b/remote/marionette/reftest-content.js
@@ -0,0 +1,65 @@
+/* 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-env mozilla/frame-script */
+
+"use strict";
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+XPCOMUtils.defineLazyScriptGetter(
+ this,
+ "PrintUtils",
+ "chrome://global/content/printUtils.js"
+);
+
+// This is an implementation of nsIBrowserDOMWindow that handles only opening
+// print browsers, because the "open a new window fallback" is just too slow
+// in some cases and causes timeouts.
+function BrowserDOMWindow() {}
+BrowserDOMWindow.prototype = {
+ QueryInterface: ChromeUtils.generateQI(["nsIBrowserDOMWindow"]),
+
+ _maybeOpen(aOpenWindowInfo, aWhere) {
+ if (aWhere == Ci.nsIBrowserDOMWindow.OPEN_PRINT_BROWSER) {
+ return PrintUtils.handleStaticCloneCreatedForPrint(aOpenWindowInfo);
+ }
+ return null;
+ },
+
+ createContentWindow(
+ aURI,
+ aOpenWindowInfo,
+ aWhere,
+ aFlags,
+ aTriggeringPrincipal,
+ aCsp
+ ) {
+ return this._maybeOpen(aOpenWindowInfo, aWhere)?.browsingContext;
+ },
+
+ openURI(aURI, aOpenWindowInfo, aWhere, aFlags, aTriggeringPrincipal, aCsp) {
+ return this._maybeOpen(aOpenWindowInfo, aWhere)?.browsingContext;
+ },
+
+ createContentWindowInFrame(aURI, aParams, aWhere, aFlags, aName) {
+ return this._maybeOpen(aParams.openWindowInfo, aWhere);
+ },
+
+ openURIInFrame(aURI, aParams, aWhere, aFlags, aName) {
+ return this._maybeOpen(aParams.openWindowInfo, aWhere);
+ },
+
+ canClose() {
+ return true;
+ },
+
+ get tabCount() {
+ return 1;
+ },
+};
+
+window.browserDOMWindow = new BrowserDOMWindow();
diff --git a/remote/marionette/reftest.sys.mjs b/remote/marionette/reftest.sys.mjs
new file mode 100644
index 0000000000..23378e19dd
--- /dev/null
+++ b/remote/marionette/reftest.sys.mjs
@@ -0,0 +1,900 @@
+/* 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, {
+ E10SUtils: "resource://gre/modules/E10SUtils.sys.mjs",
+
+ AppInfo: "chrome://remote/content/shared/AppInfo.sys.mjs",
+ assert: "chrome://remote/content/shared/webdriver/Assert.sys.mjs",
+ capture: "chrome://remote/content/shared/Capture.sys.mjs",
+ Log: "chrome://remote/content/shared/Log.sys.mjs",
+ navigate: "chrome://remote/content/marionette/navigate.sys.mjs",
+ print: "chrome://remote/content/shared/PDF.sys.mjs",
+ windowManager: "chrome://remote/content/shared/WindowManager.sys.mjs",
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "logger", () =>
+ lazy.Log.get(lazy.Log.TYPES.MARIONETTE)
+);
+
+const XHTML_NS = "http://www.w3.org/1999/xhtml";
+const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
+
+const SCREENSHOT_MODE = {
+ unexpected: 0,
+ fail: 1,
+ always: 2,
+};
+
+const STATUS = {
+ PASS: "PASS",
+ FAIL: "FAIL",
+ ERROR: "ERROR",
+ TIMEOUT: "TIMEOUT",
+};
+
+const DEFAULT_REFTEST_WIDTH = 600;
+const DEFAULT_REFTEST_HEIGHT = 600;
+
+// reftest-print page dimensions in cm
+const CM_PER_INCH = 2.54;
+const DEFAULT_PAGE_WIDTH = 5 * CM_PER_INCH;
+const DEFAULT_PAGE_HEIGHT = 3 * CM_PER_INCH;
+const DEFAULT_PAGE_MARGIN = 0.5 * CM_PER_INCH;
+
+// CSS 96 pixels per inch, compared to pdf.js default 72 pixels per inch
+const DEFAULT_PDF_RESOLUTION = 96 / 72;
+
+/**
+ * Implements an fast runner for web-platform-tests format reftests
+ * c.f. http://web-platform-tests.org/writing-tests/reftests.html.
+ *
+ * @namespace
+ */
+export const reftest = {};
+
+/**
+ * @memberof reftest
+ * @class Runner
+ */
+reftest.Runner = class {
+ constructor(driver) {
+ this.driver = driver;
+ this.canvasCache = new DefaultMap(undefined, () => new Map([[null, []]]));
+ this.isPrint = null;
+ this.windowUtils = null;
+ this.lastURL = null;
+ this.useRemoteTabs = lazy.AppInfo.browserTabsRemoteAutostart;
+ this.useRemoteSubframes = lazy.AppInfo.fissionAutostart;
+ }
+
+ /**
+ * Setup the required environment for running reftests.
+ *
+ * This will open a non-browser window in which the tests will
+ * be loaded, and set up various caches for the reftest run.
+ *
+ * @param {Object.<Number>} urlCount
+ * Object holding a map of URL: number of times the URL
+ * will be opened during the reftest run, where that's
+ * greater than 1.
+ * @param {string} screenshotMode
+ * String enum representing when screenshots should be taken
+ */
+ setup(urlCount, screenshotMode, isPrint = false) {
+ this.isPrint = isPrint;
+
+ lazy.assert.open(this.driver.getBrowsingContext({ top: true }));
+ this.parentWindow = this.driver.getCurrentWindow();
+
+ this.screenshotMode =
+ SCREENSHOT_MODE[screenshotMode] || SCREENSHOT_MODE.unexpected;
+
+ this.urlCount = Object.keys(urlCount || {}).reduce(
+ (map, key) => map.set(key, urlCount[key]),
+ new Map()
+ );
+
+ if (isPrint) {
+ this.loadPdfJs();
+ }
+
+ ChromeUtils.registerWindowActor("MarionetteReftest", {
+ kind: "JSWindowActor",
+ parent: {
+ esModuleURI:
+ "chrome://remote/content/marionette/actors/MarionetteReftestParent.sys.mjs",
+ },
+ child: {
+ esModuleURI:
+ "chrome://remote/content/marionette/actors/MarionetteReftestChild.sys.mjs",
+ events: {
+ load: { mozSystemGroup: true, capture: true },
+ },
+ },
+ allFrames: true,
+ });
+ }
+
+ /**
+ * Cleanup the environment once the reftest is finished.
+ */
+ teardown() {
+ // Abort the current test if any.
+ this.abort();
+
+ // Unregister the JSWindowActors.
+ ChromeUtils.unregisterWindowActor("MarionetteReftest");
+ }
+
+ async ensureWindow(timeout, width, height) {
+ lazy.logger.debug(`ensuring we have a window ${width}x${height}`);
+
+ if (this.reftestWin && !this.reftestWin.closed) {
+ let browserRect = this.reftestWin.gBrowser.getBoundingClientRect();
+ if (browserRect.width === width && browserRect.height === height) {
+ return this.reftestWin;
+ }
+ lazy.logger.debug(`current: ${browserRect.width}x${browserRect.height}`);
+ }
+
+ let reftestWin;
+ if (lazy.AppInfo.isAndroid) {
+ lazy.logger.debug("Using current window");
+ reftestWin = this.parentWindow;
+ await lazy.navigate.waitForNavigationCompleted(this.driver, () => {
+ const browsingContext = this.driver.getBrowsingContext();
+ lazy.navigate.navigateTo(browsingContext, "about:blank");
+ });
+ } else {
+ lazy.logger.debug("Using separate window");
+ if (this.reftestWin && !this.reftestWin.closed) {
+ this.reftestWin.close();
+ }
+ reftestWin = await this.openWindow(width, height);
+ }
+
+ this.setupWindow(reftestWin, width, height);
+ this.windowUtils = reftestWin.windowUtils;
+ this.reftestWin = reftestWin;
+
+ let windowHandle = lazy.windowManager.getWindowProperties(reftestWin);
+ await this.driver.setWindowHandle(windowHandle, true);
+
+ const url = await this.driver._getCurrentURL();
+ this.lastURL = url.href;
+ lazy.logger.debug(`loaded initial URL: ${this.lastURL}`);
+
+ let browserRect = reftestWin.gBrowser.getBoundingClientRect();
+ lazy.logger.debug(`new: ${browserRect.width}x${browserRect.height}`);
+
+ return reftestWin;
+ }
+
+ async openWindow(width, height) {
+ lazy.assert.positiveInteger(width);
+ lazy.assert.positiveInteger(height);
+
+ let reftestWin = this.parentWindow.open(
+ "chrome://remote/content/marionette/reftest.xhtml",
+ "reftest",
+ `chrome,height=${height},width=${width}`
+ );
+
+ await new Promise(resolve => {
+ reftestWin.addEventListener("load", resolve, { once: true });
+ });
+ return reftestWin;
+ }
+
+ setupWindow(reftestWin, width, height) {
+ let browser;
+ if (lazy.AppInfo.isAndroid) {
+ browser = reftestWin.document.getElementsByTagName("browser")[0];
+ browser.setAttribute("remote", "false");
+ } else {
+ browser = reftestWin.document.createElementNS(XUL_NS, "xul:browser");
+ browser.permanentKey = {};
+ browser.setAttribute("id", "browser");
+ browser.setAttribute("type", "content");
+ browser.setAttribute("primary", "true");
+ browser.setAttribute("remote", this.useRemoteTabs ? "true" : "false");
+ }
+ // Make sure the browser element is exactly the right size, no matter
+ // what size our window is
+ const windowStyle = `
+ padding: 0px;
+ margin: 0px;
+ border:none;
+ min-width: ${width}px; min-height: ${height}px;
+ max-width: ${width}px; max-height: ${height}px;
+ color-scheme: env(-moz-content-preferred-color-scheme);
+ `;
+ browser.setAttribute("style", windowStyle);
+
+ if (!lazy.AppInfo.isAndroid) {
+ let doc = reftestWin.document.documentElement;
+ while (doc.firstChild) {
+ doc.firstChild.remove();
+ }
+ doc.appendChild(browser);
+ }
+ if (reftestWin.BrowserApp) {
+ reftestWin.BrowserApp = browser;
+ }
+ reftestWin.gBrowser = browser;
+ return reftestWin;
+ }
+
+ async abort() {
+ if (this.reftestWin && this.reftestWin != this.parentWindow) {
+ await this.driver.closeChromeWindow();
+ let parentHandle = lazy.windowManager.getWindowProperties(
+ this.parentWindow
+ );
+ await this.driver.setWindowHandle(parentHandle);
+ }
+ this.reftestWin = null;
+ }
+
+ /**
+ * Run a specific reftest.
+ *
+ * The assumed semantics are those of web-platform-tests where
+ * references form a tree and each test must meet all the conditions
+ * to reach one leaf node of the tree in order for the overall test
+ * to pass.
+ *
+ * @param {string} testUrl
+ * URL of the test itself.
+ * @param {Array.<Array>} references
+ * Array representing a tree of references to try.
+ *
+ * Each item in the array represents a single reference node and
+ * has the form <code>[referenceUrl, references, relation]</code>,
+ * where <var>referenceUrl</var> is a string to the URL, relation
+ * is either <code>==</code> or <code>!=</code> depending on the
+ * type of reftest, and references is another array containing
+ * items of the same form, representing further comparisons treated
+ * as AND with the current item. Sibling entries are treated as OR.
+ *
+ * For example with testUrl of T:
+ *
+ * <pre><code>
+ * references = [[A, [[B, [], ==]], ==]]
+ * Must have T == A AND A == B to pass
+ *
+ * references = [[A, [], ==], [B, [], !=]
+ * Must have T == A OR T != B
+ *
+ * references = [[A, [[B, [], ==], [C, [], ==]], ==], [D, [], ]]
+ * Must have (T == A AND A == B) OR (T == A AND A == C) OR (T == D)
+ * </code></pre>
+ *
+ * @param {string} expected
+ * Expected test outcome (e.g. <tt>PASS</tt>, <tt>FAIL</tt>).
+ * @param {number} timeout
+ * Test timeout in milliseconds.
+ *
+ * @return {Object}
+ * Result object with fields status, message and extra.
+ */
+ async run(
+ testUrl,
+ references,
+ expected,
+ timeout,
+ pageRanges = {},
+ width = DEFAULT_REFTEST_WIDTH,
+ height = DEFAULT_REFTEST_HEIGHT
+ ) {
+ let timeoutHandle;
+
+ let timeoutPromise = new Promise(resolve => {
+ timeoutHandle = this.parentWindow.setTimeout(() => {
+ resolve({ status: STATUS.TIMEOUT, message: null, extra: {} });
+ }, timeout);
+ });
+
+ let testRunner = (async () => {
+ let result;
+ try {
+ result = await this.runTest(
+ testUrl,
+ references,
+ expected,
+ timeout,
+ pageRanges,
+ width,
+ height
+ );
+ } catch (e) {
+ result = {
+ status: STATUS.ERROR,
+ message: String(e),
+ stack: e.stack,
+ extra: {},
+ };
+ }
+ return result;
+ })();
+
+ let result = await Promise.race([testRunner, timeoutPromise]);
+ this.parentWindow.clearTimeout(timeoutHandle);
+ if (result.status === STATUS.TIMEOUT) {
+ await this.abort();
+ }
+
+ return result;
+ }
+
+ async runTest(
+ testUrl,
+ references,
+ expected,
+ timeout,
+ pageRanges,
+ width,
+ height
+ ) {
+ let win = await this.ensureWindow(timeout, width, height);
+
+ function toBase64(screenshot) {
+ let dataURL = screenshot.canvas.toDataURL();
+ return dataURL.split(",")[1];
+ }
+
+ let result = {
+ status: STATUS.FAIL,
+ message: "",
+ stack: null,
+ extra: {},
+ };
+
+ let screenshotData = [];
+
+ let stack = [];
+ for (let i = references.length - 1; i >= 0; i--) {
+ let item = references[i];
+ stack.push([testUrl, ...item]);
+ }
+
+ let done = false;
+
+ while (stack.length && !done) {
+ let [lhsUrl, rhsUrl, references, relation, extras = {}] = stack.pop();
+ result.message += `Testing ${lhsUrl} ${relation} ${rhsUrl}\n`;
+
+ let comparison;
+ try {
+ comparison = await this.compareUrls(
+ win,
+ lhsUrl,
+ rhsUrl,
+ relation,
+ timeout,
+ pageRanges,
+ extras
+ );
+ } catch (e) {
+ comparison = {
+ lhs: null,
+ rhs: null,
+ passed: false,
+ error: e,
+ msg: null,
+ };
+ }
+ if (comparison.msg) {
+ result.message += `${comparison.msg}\n`;
+ }
+ if (comparison.error !== null) {
+ result.status = STATUS.ERROR;
+ result.message += String(comparison.error);
+ result.stack = comparison.error.stack;
+ }
+
+ function recordScreenshot() {
+ let encodedLHS = comparison.lhs ? toBase64(comparison.lhs) : "";
+ let encodedRHS = comparison.rhs ? toBase64(comparison.rhs) : "";
+ screenshotData.push([
+ { url: lhsUrl, screenshot: encodedLHS },
+ relation,
+ { url: rhsUrl, screenshot: encodedRHS },
+ ]);
+ }
+
+ if (this.screenshotMode === SCREENSHOT_MODE.always) {
+ recordScreenshot();
+ }
+
+ if (comparison.passed) {
+ if (references.length) {
+ for (let i = references.length - 1; i >= 0; i--) {
+ let item = references[i];
+ stack.push([rhsUrl, ...item]);
+ }
+ } else {
+ // Reached a leaf node so all of one reference chain passed
+ result.status = STATUS.PASS;
+ if (
+ this.screenshotMode <= SCREENSHOT_MODE.fail &&
+ expected != result.status
+ ) {
+ recordScreenshot();
+ }
+ done = true;
+ }
+ } else if (!stack.length || result.status == STATUS.ERROR) {
+ // If we don't have any alternatives to try then this will be
+ // the last iteration, so save the failing screenshots if required.
+ let isFail = this.screenshotMode === SCREENSHOT_MODE.fail;
+ let isUnexpected = this.screenshotMode === SCREENSHOT_MODE.unexpected;
+ if (isFail || (isUnexpected && expected != result.status)) {
+ recordScreenshot();
+ }
+ }
+
+ // Return any reusable canvases to the pool
+ let cacheKey = width + "x" + height;
+ let canvasPool = this.canvasCache.get(cacheKey).get(null);
+ [comparison.lhs, comparison.rhs].map(screenshot => {
+ if (screenshot !== null && screenshot.reuseCanvas) {
+ canvasPool.push(screenshot.canvas);
+ }
+ });
+ lazy.logger.debug(
+ `Canvas pool (${cacheKey}) is of length ${canvasPool.length}`
+ );
+ }
+
+ if (screenshotData.length) {
+ // For now the tbpl formatter only accepts one screenshot, so just
+ // return the last one we took.
+ let lastScreenshot = screenshotData[screenshotData.length - 1];
+ // eslint-disable-next-line camelcase
+ result.extra.reftest_screenshots = lastScreenshot;
+ }
+
+ return result;
+ }
+
+ async compareUrls(
+ win,
+ lhsUrl,
+ rhsUrl,
+ relation,
+ timeout,
+ pageRanges,
+ extras
+ ) {
+ lazy.logger.info(`Testing ${lhsUrl} ${relation} ${rhsUrl}`);
+
+ if (relation !== "==" && relation != "!=") {
+ throw new error.InvalidArgumentError(
+ "Reftest operator should be '==' or '!='"
+ );
+ }
+
+ let lhsIter, lhsCount, rhsIter, rhsCount;
+ if (!this.isPrint) {
+ // Take the reference screenshot first so that if we pause
+ // we see the test rendering
+ rhsIter = [await this.screenshot(win, rhsUrl, timeout)].values();
+ lhsIter = [await this.screenshot(win, lhsUrl, timeout)].values();
+ lhsCount = rhsCount = 1;
+ } else {
+ [rhsIter, rhsCount] = await this.screenshotPaginated(
+ win,
+ rhsUrl,
+ timeout,
+ pageRanges
+ );
+ [lhsIter, lhsCount] = await this.screenshotPaginated(
+ win,
+ lhsUrl,
+ timeout,
+ pageRanges
+ );
+ }
+
+ let passed = null;
+ let error = null;
+ let pixelsDifferent = null;
+ let maxDifferences = {};
+ let msg = null;
+
+ if (lhsCount != rhsCount) {
+ passed = relation == "!=";
+ if (!passed) {
+ msg = `Got different numbers of pages; test has ${lhsCount}, ref has ${rhsCount}`;
+ }
+ }
+
+ let lhs = null;
+ let rhs = null;
+ lazy.logger.debug(`Comparing ${lhsCount} pages`);
+ if (passed === null) {
+ for (let i = 0; i < lhsCount; i++) {
+ lhs = (await lhsIter.next()).value;
+ rhs = (await rhsIter.next()).value;
+ lazy.logger.debug(
+ `lhs canvas size ${lhs.canvas.width}x${lhs.canvas.height}`
+ );
+ lazy.logger.debug(
+ `rhs canvas size ${rhs.canvas.width}x${rhs.canvas.height}`
+ );
+ try {
+ pixelsDifferent = this.windowUtils.compareCanvases(
+ lhs.canvas,
+ rhs.canvas,
+ maxDifferences
+ );
+ } catch (e) {
+ error = e;
+ passed = false;
+ break;
+ }
+
+ let areEqual = this.isAcceptableDifference(
+ maxDifferences.value,
+ pixelsDifferent,
+ extras.fuzzy
+ );
+ lazy.logger.debug(
+ `Page ${i + 1} maxDifferences: ${maxDifferences.value} ` +
+ `pixelsDifferent: ${pixelsDifferent}`
+ );
+ lazy.logger.debug(
+ `Page ${i + 1} ${areEqual ? "compare equal" : "compare unequal"}`
+ );
+ if (!areEqual) {
+ if (relation == "==") {
+ passed = false;
+ msg =
+ `Found ${pixelsDifferent} pixels different, ` +
+ `maximum difference per channel ${maxDifferences.value}`;
+ if (this.isPrint) {
+ msg += ` on page ${i + 1}`;
+ }
+ } else {
+ passed = true;
+ }
+ break;
+ }
+ }
+ }
+
+ // If passed isn't set we got to the end without finding differences
+ if (passed === null) {
+ if (relation == "==") {
+ passed = true;
+ } else {
+ msg = `mismatch reftest has no differences`;
+ passed = false;
+ }
+ }
+ return { lhs, rhs, passed, error, msg };
+ }
+
+ isAcceptableDifference(maxDifference, pixelsDifferent, allowed) {
+ if (!allowed) {
+ lazy.logger.info(`No differences allowed`);
+ return pixelsDifferent === 0;
+ }
+ let [allowedDiff, allowedPixels] = allowed;
+ lazy.logger.info(
+ `Allowed ${allowedPixels.join("-")} pixels different, ` +
+ `maximum difference per channel ${allowedDiff.join("-")}`
+ );
+ return (
+ (pixelsDifferent === 0 && allowedPixels[0] == 0) ||
+ (maxDifference === 0 && allowedDiff[0] == 0) ||
+ (maxDifference >= allowedDiff[0] &&
+ maxDifference <= allowedDiff[1] &&
+ (pixelsDifferent >= allowedPixels[0] ||
+ pixelsDifferent <= allowedPixels[1]))
+ );
+ }
+
+ ensureFocus(win) {
+ const focusManager = Services.focus;
+ if (focusManager.activeWindow != win) {
+ win.focus();
+ }
+ this.driver.curBrowser.contentBrowser.focus();
+ }
+
+ updateBrowserRemotenessByURL(browser, url) {
+ // We don't use remote tabs on Android.
+ if (lazy.AppInfo.isAndroid) {
+ return;
+ }
+ let oa = lazy.E10SUtils.predictOriginAttributes({ browser });
+ let remoteType = lazy.E10SUtils.getRemoteTypeForURI(
+ url,
+ this.useRemoteTabs,
+ this.useRemoteSubframes,
+ lazy.E10SUtils.DEFAULT_REMOTE_TYPE,
+ null,
+ oa
+ );
+
+ // Only re-construct the browser if its remote type needs to change.
+ if (browser.remoteType !== remoteType) {
+ if (remoteType === lazy.E10SUtils.NOT_REMOTE) {
+ browser.removeAttribute("remote");
+ browser.removeAttribute("remoteType");
+ } else {
+ browser.setAttribute("remote", "true");
+ browser.setAttribute("remoteType", remoteType);
+ }
+
+ browser.changeRemoteness({ remoteType });
+ browser.construct();
+ }
+ }
+
+ async loadTestUrl(win, url, timeout) {
+ const browsingContext = this.driver.getBrowsingContext({ top: true });
+ const webProgress = browsingContext.webProgress;
+
+ lazy.logger.debug(`Starting load of ${url}`);
+ if (this.lastURL === url) {
+ lazy.logger.debug(`Refreshing page`);
+ await lazy.navigate.waitForNavigationCompleted(this.driver, () => {
+ lazy.navigate.refresh(browsingContext);
+ });
+ } else {
+ // HACK: DocumentLoadListener currently doesn't know how to
+ // process-switch loads in a non-tabbed <browser>. We need to manually
+ // set the browser's remote type in order to ensure that the load
+ // happens in the correct process.
+ //
+ // See bug 1636169.
+ this.updateBrowserRemotenessByURL(win.gBrowser, url);
+ lazy.navigate.navigateTo(browsingContext, url);
+
+ this.lastURL = url;
+ }
+
+ this.ensureFocus(win);
+
+ // TODO: Move all the wait logic into the parent process (bug 1669787)
+ let isReftestReady = false;
+ while (!isReftestReady) {
+ // Note: We cannot compare the URL here. Before the navigation is complete
+ // currentWindowGlobal.documentURI.spec will still point to the old URL.
+ const actor = webProgress.browsingContext.currentWindowGlobal.getActor(
+ "MarionetteReftest"
+ );
+ isReftestReady = await actor.reftestWait(url, this.useRemoteTabs);
+ }
+ }
+
+ async screenshot(win, url, timeout) {
+ // On windows the above doesn't *actually* set the window to be the
+ // reftest size; but *does* set the content area to be the right size;
+ // the window is given some extra borders that aren't explicable from CSS
+ let browserRect = win.gBrowser.getBoundingClientRect();
+ let canvas = null;
+ let remainingCount = this.urlCount.get(url) || 1;
+ let cache = remainingCount > 1;
+ let cacheKey = browserRect.width + "x" + browserRect.height;
+ lazy.logger.debug(
+ `screenshot ${url} remainingCount: ` +
+ `${remainingCount} cache: ${cache} cacheKey: ${cacheKey}`
+ );
+ let reuseCanvas = false;
+ let sizedCache = this.canvasCache.get(cacheKey);
+ if (sizedCache.has(url)) {
+ lazy.logger.debug(`screenshot ${url} taken from cache`);
+ canvas = sizedCache.get(url);
+ if (!cache) {
+ sizedCache.delete(url);
+ }
+ } else {
+ let canvasPool = sizedCache.get(null);
+ if (canvasPool.length) {
+ lazy.logger.debug("reusing canvas from canvas pool");
+ canvas = canvasPool.pop();
+ } else {
+ lazy.logger.debug("using new canvas");
+ canvas = null;
+ }
+ reuseCanvas = !cache;
+
+ let ctxInterface = win.CanvasRenderingContext2D;
+ let flags =
+ ctxInterface.DRAWWINDOW_DRAW_CARET |
+ ctxInterface.DRAWWINDOW_DRAW_VIEW |
+ ctxInterface.DRAWWINDOW_USE_WIDGET_LAYERS;
+
+ if (
+ !(
+ 0 <= browserRect.left &&
+ 0 <= browserRect.top &&
+ win.innerWidth >= browserRect.width &&
+ win.innerHeight >= browserRect.height
+ )
+ ) {
+ lazy.logger.error(`Invalid window dimensions:
+browserRect.left: ${browserRect.left}
+browserRect.top: ${browserRect.top}
+win.innerWidth: ${win.innerWidth}
+browserRect.width: ${browserRect.width}
+win.innerHeight: ${win.innerHeight}
+browserRect.height: ${browserRect.height}`);
+ throw new Error("Window has incorrect dimensions");
+ }
+
+ url = new URL(url).href; // normalize the URL
+
+ await this.loadTestUrl(win, url, timeout);
+
+ canvas = await lazy.capture.canvas(
+ win,
+ win.docShell.browsingContext,
+ 0, // left
+ 0, // top
+ browserRect.width,
+ browserRect.height,
+ { canvas, flags, readback: true }
+ );
+ }
+ if (
+ canvas.width !== browserRect.width ||
+ canvas.height !== browserRect.height
+ ) {
+ lazy.logger.warn(
+ `Canvas dimensions changed to ${canvas.width}x${canvas.height}`
+ );
+ reuseCanvas = false;
+ cache = false;
+ }
+ if (cache) {
+ sizedCache.set(url, canvas);
+ }
+ this.urlCount.set(url, remainingCount - 1);
+ return { canvas, reuseCanvas };
+ }
+
+ async screenshotPaginated(win, url, timeout, pageRanges) {
+ url = new URL(url).href; // normalize the URL
+ await this.loadTestUrl(win, url, timeout);
+
+ const [width, height] = [DEFAULT_PAGE_WIDTH, DEFAULT_PAGE_HEIGHT];
+ const margin = DEFAULT_PAGE_MARGIN;
+ const settings = lazy.print.addDefaultSettings({
+ page: {
+ width,
+ height,
+ },
+ margin: {
+ left: margin,
+ right: margin,
+ top: margin,
+ bottom: margin,
+ },
+ shrinkToFit: false,
+ printBackground: true,
+ });
+
+ const filePath = await lazy.print.printToFile(win.gBrowser, settings);
+
+ try {
+ const pdf = await this.loadPdf(url, filePath);
+ let pages = this.getPages(pageRanges, url, pdf.numPages);
+ return [this.renderPages(pdf, pages), pages.size];
+ } finally {
+ await IOUtils.remove(filePath);
+ }
+ }
+
+ async loadPdfJs() {
+ // Ensure pdf.js is loaded in the opener window
+ await new Promise((resolve, reject) => {
+ const doc = this.parentWindow.document;
+ const script = doc.createElement("script");
+ script.src = "resource://pdf.js/build/pdf.js";
+ script.onload = resolve;
+ script.onerror = () => reject(new Error("pdfjs load failed"));
+ doc.documentElement.appendChild(script);
+ });
+ this.parentWindow.pdfjsLib.GlobalWorkerOptions.workerSrc =
+ "resource://pdf.js/build/pdf.worker.js";
+ }
+
+ async loadPdf(url, filePath) {
+ const data = await IOUtils.read(filePath);
+ return this.parentWindow.pdfjsLib.getDocument({ data }).promise;
+ }
+
+ async *renderPages(pdf, pages) {
+ let canvas = null;
+ for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber++) {
+ if (!pages.has(pageNumber)) {
+ lazy.logger.info(`Skipping page ${pageNumber}/${pdf.numPages}`);
+ continue;
+ }
+ lazy.logger.info(`Rendering page ${pageNumber}/${pdf.numPages}`);
+ let page = await pdf.getPage(pageNumber);
+ let viewport = page.getViewport({ scale: DEFAULT_PDF_RESOLUTION });
+ // Prepare canvas using PDF page dimensions
+ if (canvas === null) {
+ canvas = this.parentWindow.document.createElementNS(XHTML_NS, "canvas");
+ canvas.height = viewport.height;
+ canvas.width = viewport.width;
+ }
+
+ // Render PDF page into canvas context
+ let context = canvas.getContext("2d");
+ let renderContext = {
+ canvasContext: context,
+ viewport,
+ };
+ await page.render(renderContext).promise;
+ yield { canvas, reuseCanvas: false };
+ }
+ }
+
+ getPages(pageRanges, url, totalPages) {
+ // Extract test id from URL without parsing
+ let afterHost = url.slice(url.indexOf(":") + 3);
+ afterHost = afterHost.slice(afterHost.indexOf("/"));
+ const ranges = pageRanges[afterHost];
+ let rv = new Set();
+
+ if (!ranges) {
+ for (let i = 1; i <= totalPages; i++) {
+ rv.add(i);
+ }
+ return rv;
+ }
+
+ for (let rangePart of ranges) {
+ if (rangePart.length === 1) {
+ rv.add(rangePart[0]);
+ } else {
+ if (rangePart.length !== 2) {
+ throw new Error(
+ `Page ranges must be <int> or <int> '-' <int>, got ${rangePart}`
+ );
+ }
+ let [lower, upper] = rangePart;
+ if (lower === null) {
+ lower = 1;
+ }
+ if (upper === null) {
+ upper = totalPages;
+ }
+ for (let i = lower; i <= upper; i++) {
+ rv.add(i);
+ }
+ }
+ }
+ return rv;
+ }
+};
+
+class DefaultMap extends Map {
+ constructor(iterable, defaultFactory) {
+ super(iterable);
+ this.defaultFactory = defaultFactory;
+ }
+
+ get(key) {
+ if (this.has(key)) {
+ return super.get(key);
+ }
+
+ let v = this.defaultFactory();
+ this.set(key, v);
+ return v;
+ }
+}
diff --git a/remote/marionette/server.sys.mjs b/remote/marionette/server.sys.mjs
new file mode 100644
index 0000000000..b3ed7bfea7
--- /dev/null
+++ b/remote/marionette/server.sys.mjs
@@ -0,0 +1,410 @@
+/* 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, {
+ assert: "chrome://remote/content/shared/webdriver/Assert.sys.mjs",
+ Command: "chrome://remote/content/marionette/message.sys.mjs",
+ DebuggerTransport: "chrome://remote/content/marionette/transport.sys.mjs",
+ error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs",
+ GeckoDriver: "chrome://remote/content/marionette/driver.sys.mjs",
+ Log: "chrome://remote/content/shared/Log.sys.mjs",
+ MarionettePrefs: "chrome://remote/content/marionette/prefs.sys.mjs",
+ Message: "chrome://remote/content/marionette/message.sys.mjs",
+ Response: "chrome://remote/content/marionette/message.sys.mjs",
+ WebReference: "chrome://remote/content/marionette/element.sys.mjs",
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "logger", () =>
+ lazy.Log.get(lazy.Log.TYPES.MARIONETTE)
+);
+XPCOMUtils.defineLazyGetter(lazy, "ServerSocket", () => {
+ return Components.Constructor(
+ "@mozilla.org/network/server-socket;1",
+ "nsIServerSocket",
+ "initSpecialConnection"
+ );
+});
+
+const { KeepWhenOffline, LoopbackOnly } = Ci.nsIServerSocket;
+
+const PROTOCOL_VERSION = 3;
+
+/**
+ * Bootstraps Marionette and handles incoming client connections.
+ *
+ * Starting the Marionette server will open a TCP socket sporting the
+ * debugger transport interface on the provided `port`. For every
+ * new connection, a {@link TCPConnection} is created.
+ */
+export class TCPListener {
+ /**
+ * @param {number} port
+ * Port for server to listen to.
+ */
+ constructor(port) {
+ this.port = port;
+ this.socket = null;
+ this.conns = new Set();
+ this.nextConnID = 0;
+ this.alive = false;
+ }
+
+ /**
+ * Function produces a {@link GeckoDriver}.
+ *
+ * Determines the application to initialise the driver with.
+ *
+ * @return {GeckoDriver}
+ * A driver instance.
+ */
+ driverFactory() {
+ return new lazy.GeckoDriver(this);
+ }
+
+ set acceptConnections(value) {
+ if (value) {
+ if (!this.socket) {
+ try {
+ const flags = KeepWhenOffline | LoopbackOnly;
+ const backlog = 1;
+ this.socket = new lazy.ServerSocket(this.port, flags, backlog);
+ } catch (e) {
+ throw new Error(`Could not bind to port ${this.port} (${e.name})`);
+ }
+
+ this.port = this.socket.port;
+
+ this.socket.asyncListen(this);
+ lazy.logger.info(`Listening on port ${this.port}`);
+ }
+ } else if (this.socket) {
+ // Note that closing the server socket will not close currently active
+ // connections.
+ this.socket.close();
+ this.socket = null;
+ lazy.logger.info(`Stopped listening on port ${this.port}`);
+ }
+ }
+
+ /**
+ * Bind this listener to {@link #port} and start accepting incoming
+ * socket connections on {@link #onSocketAccepted}.
+ *
+ * The marionette.port preference will be populated with the value
+ * of {@link #port}.
+ */
+ start() {
+ if (this.alive) {
+ return;
+ }
+
+ // Start socket server and listening for connection attempts
+ this.acceptConnections = true;
+ lazy.MarionettePrefs.port = this.port;
+ this.alive = true;
+ }
+
+ stop() {
+ if (!this.alive) {
+ return;
+ }
+
+ // Shutdown server socket, and no longer listen for new connections
+ this.acceptConnections = false;
+ this.alive = false;
+ }
+
+ onSocketAccepted(serverSocket, clientSocket) {
+ let input = clientSocket.openInputStream(0, 0, 0);
+ let output = clientSocket.openOutputStream(0, 0, 0);
+ let transport = new lazy.DebuggerTransport(input, output);
+
+ // Only allow a single active WebDriver session at a time
+ const hasActiveSession = [...this.conns].find(
+ conn => !!conn.driver.currentSession
+ );
+ if (hasActiveSession) {
+ lazy.logger.warn(
+ "Connection attempt denied because an active session has been found"
+ );
+
+ // Ideally we should stop the server to listen for new connection
+ // attempts, but the current architecture doesn't allow us to do that.
+ // As such just close the transport if no further connections are allowed.
+ transport.close();
+ return;
+ }
+
+ let conn = new TCPConnection(
+ this.nextConnID++,
+ transport,
+ this.driverFactory.bind(this)
+ );
+ conn.onclose = this.onConnectionClosed.bind(this);
+ this.conns.add(conn);
+
+ lazy.logger.debug(
+ `Accepted connection ${conn.id} ` +
+ `from ${clientSocket.host}:${clientSocket.port}`
+ );
+ conn.sayHello();
+ transport.ready();
+ }
+
+ onConnectionClosed(conn) {
+ lazy.logger.debug(`Closed connection ${conn.id}`);
+ this.conns.delete(conn);
+ }
+}
+
+/**
+ * Marionette client connection.
+ *
+ * Dispatches packets received to their correct service destinations
+ * and sends back the service endpoint's return values.
+ *
+ * @param {number} connID
+ * Unique identifier of the connection this dispatcher should handle.
+ * @param {DebuggerTransport} transport
+ * Debugger transport connection to the client.
+ * @param {function(): GeckoDriver} driverFactory
+ * Factory function that produces a {@link GeckoDriver}.
+ */
+export class TCPConnection {
+ constructor(connID, transport, driverFactory) {
+ this.id = connID;
+ this.conn = transport;
+
+ // transport hooks are TCPConnection#onPacket
+ // and TCPConnection#onClosed
+ this.conn.hooks = this;
+
+ // callback for when connection is closed
+ this.onclose = null;
+
+ // last received/sent message ID
+ this.lastID = 0;
+
+ this.driver = driverFactory();
+ }
+
+ #log(msg) {
+ let dir = msg.origin == lazy.Message.Origin.Client ? "->" : "<-";
+ lazy.logger.debug(`${this.id} ${dir} ${msg.toString()}`);
+ }
+
+ /**
+ * Debugger transport callback that cleans up
+ * after a connection is closed.
+ */
+ onClosed() {
+ this.driver.deleteSession();
+ if (this.onclose) {
+ this.onclose(this);
+ }
+ }
+
+ /**
+ * Callback that receives data packets from the client.
+ *
+ * If the message is a Response, we look up the command previously
+ * issued to the client and run its callback, if any. In case of
+ * a Command, the corresponding is executed.
+ *
+ * @param {Array.<number, number, ?, ?>} data
+ * A four element array where the elements, in sequence, signifies
+ * message type, message ID, method name or error, and parameters
+ * or result.
+ */
+ onPacket(data) {
+ // unable to determine how to respond
+ if (!Array.isArray(data)) {
+ let e = new TypeError(
+ "Unable to unmarshal packet data: " + JSON.stringify(data)
+ );
+ lazy.error.report(e);
+ return;
+ }
+
+ // return immediately with any error trying to unmarshal message
+ let msg;
+ try {
+ msg = lazy.Message.fromPacket(data);
+ msg.origin = lazy.Message.Origin.Client;
+ this.#log(msg);
+ } catch (e) {
+ let resp = this.createResponse(data[1]);
+ resp.sendError(e);
+ return;
+ }
+
+ // execute new command
+ if (msg instanceof lazy.Command) {
+ (async () => {
+ await this.execute(msg);
+ })();
+ } else {
+ lazy.logger.fatal("Cannot process messages other than Command");
+ }
+ }
+
+ /**
+ * Executes a Marionette command and sends back a response when it
+ * has finished executing.
+ *
+ * If the command implementation sends the response itself by calling
+ * <code>resp.send()</code>, the response is guaranteed to not be
+ * sent twice.
+ *
+ * Errors thrown in commands are marshaled and sent back, and if they
+ * are not {@link WebDriverError} instances, they are additionally
+ * propagated and reported to {@link Components.utils.reportError}.
+ *
+ * @param {Command} cmd
+ * Command to execute.
+ */
+ async execute(cmd) {
+ let resp = this.createResponse(cmd.id);
+ let sendResponse = () => resp.sendConditionally(resp => !resp.sent);
+ let sendError = resp.sendError.bind(resp);
+
+ await this.despatch(cmd, resp)
+ .then(sendResponse, sendError)
+ .catch(lazy.error.report);
+ }
+
+ /**
+ * Despatches command to appropriate Marionette service.
+ *
+ * @param {Command} cmd
+ * Command to run.
+ * @param {Response} resp
+ * Mutable response where the command's return value will be
+ * assigned.
+ *
+ * @throws {Error}
+ * A command's implementation may throw at any time.
+ */
+ async despatch(cmd, resp) {
+ let fn = this.driver.commands[cmd.name];
+ if (typeof fn == "undefined") {
+ throw new lazy.error.UnknownCommandError(cmd.name);
+ }
+
+ if (cmd.name != "WebDriver:NewSession") {
+ lazy.assert.session(this.driver.currentSession);
+ }
+
+ let rv = await fn.bind(this.driver)(cmd);
+
+ if (rv != null) {
+ if (lazy.WebReference.isReference(rv) || typeof rv != "object") {
+ resp.body = { value: rv };
+ } else {
+ resp.body = rv;
+ }
+ }
+ }
+
+ /**
+ * Fail-safe creation of a new instance of {@link Response}.
+ *
+ * @param {number} msgID
+ * Message ID to respond to. If it is not a number, -1 is used.
+ *
+ * @return {Response}
+ * Response to the message with `msgID`.
+ */
+ createResponse(msgID) {
+ if (typeof msgID != "number") {
+ msgID = -1;
+ }
+ return new lazy.Response(msgID, this.send.bind(this));
+ }
+
+ sendError(err, cmdID) {
+ let resp = new lazy.Response(cmdID, this.send.bind(this));
+ resp.sendError(err);
+ }
+
+ /**
+ * When a client connects we send across a JSON Object defining the
+ * protocol level.
+ *
+ * This is the only message sent by Marionette that does not follow
+ * the regular message format.
+ */
+ sayHello() {
+ let whatHo = {
+ applicationType: "gecko",
+ marionetteProtocol: PROTOCOL_VERSION,
+ };
+ this.sendRaw(whatHo);
+ }
+
+ /**
+ * Delegates message to client based on the provided {@code cmdID}.
+ * The message is sent over the debugger transport socket.
+ *
+ * The command ID is a unique identifier assigned to the client's request
+ * that is used to distinguish the asynchronous responses.
+ *
+ * Whilst responses to commands are synchronous and must be sent in the
+ * correct order.
+ *
+ * @param {Message} msg
+ * The command or response to send.
+ */
+ send(msg) {
+ msg.origin = lazy.Message.Origin.Server;
+ if (msg instanceof lazy.Response) {
+ this.sendToClient(msg);
+ } else {
+ lazy.logger.fatal("Cannot send messages other than Response");
+ }
+ }
+
+ // Low-level methods:
+
+ /**
+ * Send given response to the client over the debugger transport socket.
+ *
+ * @param {Response} resp
+ * The response to send back to the client.
+ */
+ sendToClient(resp) {
+ this.sendMessage(resp);
+ }
+
+ /**
+ * Marshal message to the Marionette message format and send it.
+ *
+ * @param {Message} msg
+ * The message to send.
+ */
+ sendMessage(msg) {
+ this.#log(msg);
+ let payload = msg.toPacket();
+ this.sendRaw(payload);
+ }
+
+ /**
+ * Send the given payload over the debugger transport socket to the
+ * connected client.
+ *
+ * @param {Object.<string, ?>} payload
+ * The payload to ship.
+ */
+ sendRaw(payload) {
+ this.conn.send(payload);
+ }
+
+ toString() {
+ return `[object TCPConnection ${this.id}]`;
+ }
+}
diff --git a/remote/marionette/stream-utils.sys.mjs b/remote/marionette/stream-utils.sys.mjs
new file mode 100644
index 0000000000..fc403b1fe5
--- /dev/null
+++ b/remote/marionette/stream-utils.sys.mjs
@@ -0,0 +1,256 @@
+/* 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, {
+ EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs",
+});
+
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "IOUtil",
+ "@mozilla.org/io-util;1",
+ "nsIIOUtil"
+);
+
+XPCOMUtils.defineLazyGetter(lazy, "ScriptableInputStream", () => {
+ return Components.Constructor(
+ "@mozilla.org/scriptableinputstream;1",
+ "nsIScriptableInputStream",
+ "init"
+ );
+});
+
+const BUFFER_SIZE = 0x8000;
+
+/**
+ * This helper function (and its companion object) are used by bulk
+ * senders and receivers to read and write data in and out of other streams.
+ * Functions that make use of this tool are passed to callers when it is
+ * time to read or write bulk data. It is highly recommended to use these
+ * copier functions instead of the stream directly because the copier
+ * enforces the agreed upon length. Since bulk mode reuses an existing
+ * stream, the sender and receiver must write and read exactly the agreed
+ * upon amount of data, or else the entire transport will be left in a
+ * invalid state. Additionally, other methods of stream copying (such as
+ * NetUtil.asyncCopy) close the streams involved, which would terminate
+ * the debugging transport, and so it is avoided here.
+ *
+ * Overall, this *works*, but clearly the optimal solution would be
+ * able to just use the streams directly. If it were possible to fully
+ * implement nsIInputStream/nsIOutputStream in JS, wrapper streams could
+ * be created to enforce the length and avoid closing, and consumers could
+ * use familiar stream utilities like NetUtil.asyncCopy.
+ *
+ * The function takes two async streams and copies a precise number
+ * of bytes from one to the other. Copying begins immediately, but may
+ * complete at some future time depending on data size. Use the returned
+ * promise to know when it's complete.
+ *
+ * @param {nsIAsyncInputStream} input
+ * Stream to copy from.
+ * @param {nsIAsyncOutputStream} output
+ * Stream to copy to.
+ * @param {number} length
+ * Amount of data that needs to be copied.
+ *
+ * @return {Promise}
+ * Promise is resolved when copying completes or rejected if any
+ * (unexpected) errors occur.
+ */
+function copyStream(input, output, length) {
+ let copier = new StreamCopier(input, output, length);
+ return copier.copy();
+}
+
+/** @class */
+function StreamCopier(input, output, length) {
+ lazy.EventEmitter.decorate(this);
+ this._id = StreamCopier._nextId++;
+ this.input = input;
+ // Save off the base output stream, since we know it's async as we've
+ // required
+ this.baseAsyncOutput = output;
+ if (lazy.IOUtil.outputStreamIsBuffered(output)) {
+ this.output = output;
+ } else {
+ this.output = Cc[
+ "@mozilla.org/network/buffered-output-stream;1"
+ ].createInstance(Ci.nsIBufferedOutputStream);
+ this.output.init(output, BUFFER_SIZE);
+ }
+ this._length = length;
+ this._amountLeft = length;
+ this._deferred = {
+ promise: new Promise((resolve, reject) => {
+ this._deferred.resolve = resolve;
+ this._deferred.reject = reject;
+ }),
+ };
+
+ this._copy = this._copy.bind(this);
+ this._flush = this._flush.bind(this);
+ this._destroy = this._destroy.bind(this);
+
+ // Copy promise's then method up to this object.
+ //
+ // Allows the copier to offer a promise interface for the simple succeed
+ // or fail scenarios, but also emit events (due to the EventEmitter)
+ // for other states, like progress.
+ this.then = this._deferred.promise.then.bind(this._deferred.promise);
+ this.then(this._destroy, this._destroy);
+
+ // Stream ready callback starts as |_copy|, but may switch to |_flush|
+ // at end if flushing would block the output stream.
+ this._streamReadyCallback = this._copy;
+}
+StreamCopier._nextId = 0;
+
+StreamCopier.prototype = {
+ copy() {
+ // Dispatch to the next tick so that it's possible to attach a progress
+ // event listener, even for extremely fast copies (like when testing).
+ Services.tm.currentThread.dispatch(() => {
+ try {
+ this._copy();
+ } catch (e) {
+ this._deferred.reject(e);
+ }
+ }, 0);
+ return this;
+ },
+
+ _copy() {
+ let bytesAvailable = this.input.available();
+ let amountToCopy = Math.min(bytesAvailable, this._amountLeft);
+ this._debug("Trying to copy: " + amountToCopy);
+
+ let bytesCopied;
+ try {
+ bytesCopied = this.output.writeFrom(this.input, amountToCopy);
+ } catch (e) {
+ if (e.result == Cr.NS_BASE_STREAM_WOULD_BLOCK) {
+ this._debug("Base stream would block, will retry");
+ this._debug("Waiting for output stream");
+ this.baseAsyncOutput.asyncWait(this, 0, 0, Services.tm.currentThread);
+ return;
+ }
+ throw e;
+ }
+
+ this._amountLeft -= bytesCopied;
+ this._debug("Copied: " + bytesCopied + ", Left: " + this._amountLeft);
+ this._emitProgress();
+
+ if (this._amountLeft === 0) {
+ this._debug("Copy done!");
+ this._flush();
+ return;
+ }
+
+ this._debug("Waiting for input stream");
+ this.input.asyncWait(this, 0, 0, Services.tm.currentThread);
+ },
+
+ _emitProgress() {
+ this.emit("progress", {
+ bytesSent: this._length - this._amountLeft,
+ totalBytes: this._length,
+ });
+ },
+
+ _flush() {
+ try {
+ this.output.flush();
+ } catch (e) {
+ if (
+ e.result == Cr.NS_BASE_STREAM_WOULD_BLOCK ||
+ e.result == Cr.NS_ERROR_FAILURE
+ ) {
+ this._debug("Flush would block, will retry");
+ this._streamReadyCallback = this._flush;
+ this._debug("Waiting for output stream");
+ this.baseAsyncOutput.asyncWait(this, 0, 0, Services.tm.currentThread);
+ return;
+ }
+ throw e;
+ }
+ this._deferred.resolve();
+ },
+
+ _destroy() {
+ this._destroy = null;
+ this._copy = null;
+ this._flush = null;
+ this.input = null;
+ this.output = null;
+ },
+
+ // nsIInputStreamCallback
+ onInputStreamReady() {
+ this._streamReadyCallback();
+ },
+
+ // nsIOutputStreamCallback
+ onOutputStreamReady() {
+ this._streamReadyCallback();
+ },
+
+ _debug() {},
+};
+
+/**
+ * Read from a stream, one byte at a time, up to the next
+ * <var>delimiter</var> character, but stopping if we've read |count|
+ * without finding it. Reading also terminates early if there are less
+ * than <var>count</var> bytes available on the stream. In that case,
+ * we only read as many bytes as the stream currently has to offer.
+ *
+ * @param {nsIInputStream} stream
+ * Input stream to read from.
+ * @param {string} delimiter
+ * Character we're trying to find.
+ * @param {number} count
+ * Max number of characters to read while searching.
+ *
+ * @return {string}
+ * Collected data. If the delimiter was found, this string will
+ * end with it.
+ */
+// TODO: This implementation could be removed if bug 984651 is fixed,
+// which provides a native version of the same idea.
+function delimitedRead(stream, delimiter, count) {
+ let scriptableStream;
+ if (stream instanceof Ci.nsIScriptableInputStream) {
+ scriptableStream = stream;
+ } else {
+ scriptableStream = new lazy.ScriptableInputStream(stream);
+ }
+
+ let data = "";
+
+ // Don't exceed what's available on the stream
+ count = Math.min(count, stream.available());
+
+ if (count <= 0) {
+ return data;
+ }
+
+ let char;
+ while (char !== delimiter && count > 0) {
+ char = scriptableStream.readBytes(1);
+ count--;
+ data += char;
+ }
+
+ return data;
+}
+
+export const StreamUtils = {
+ copyStream,
+ delimitedRead,
+};
diff --git a/remote/marionette/sync.sys.mjs b/remote/marionette/sync.sys.mjs
new file mode 100644
index 0000000000..890773bfbe
--- /dev/null
+++ b/remote/marionette/sync.sys.mjs
@@ -0,0 +1,497 @@
+/* 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 { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs",
+ Log: "chrome://remote/content/shared/Log.sys.mjs",
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "logger", () =>
+ lazy.Log.get(lazy.Log.TYPES.MARIONETTE)
+);
+
+const { TYPE_ONE_SHOT, TYPE_REPEATING_SLACK } = Ci.nsITimer;
+
+const PROMISE_TIMEOUT = AppConstants.DEBUG ? 4500 : 1500;
+
+/**
+ * Dispatch a function to be executed on the main thread.
+ *
+ * @param {function} func
+ * Function to be executed.
+ */
+export function executeSoon(func) {
+ if (typeof func != "function") {
+ throw new TypeError();
+ }
+
+ Services.tm.dispatchToMainThread(func);
+}
+
+/**
+ * Runs a Promise-like function off the main thread until it is resolved
+ * through ``resolve`` or ``rejected`` callbacks. The function is
+ * guaranteed to be run at least once, irregardless of the timeout.
+ *
+ * The ``func`` is evaluated every ``interval`` for as long as its
+ * runtime duration does not exceed ``interval``. Evaluations occur
+ * sequentially, meaning that evaluations of ``func`` are queued if
+ * the runtime evaluation duration of ``func`` is greater than ``interval``.
+ *
+ * ``func`` is given two arguments, ``resolve`` and ``reject``,
+ * of which one must be called for the evaluation to complete.
+ * Calling ``resolve`` with an argument indicates that the expected
+ * wait condition was met and will return the passed value to the
+ * caller. Conversely, calling ``reject`` will evaluate ``func``
+ * again until the ``timeout`` duration has elapsed or ``func`` throws.
+ * The passed value to ``reject`` will also be returned to the caller
+ * once the wait has expired.
+ *
+ * Usage::
+ *
+ * let els = new PollPromise((resolve, reject) => {
+ * let res = document.querySelectorAll("p");
+ * if (res.length > 0) {
+ * resolve(Array.from(res));
+ * } else {
+ * reject([]);
+ * }
+ * }, {timeout: 1000});
+ *
+ * @param {Condition} func
+ * Function to run off the main thread.
+ * @param {number=} [timeout] timeout
+ * Desired timeout if wanted. If 0 or less than the runtime evaluation
+ * time of ``func``, ``func`` is guaranteed to run at least once.
+ * Defaults to using no timeout.
+ * @param {number=} [interval=10] interval
+ * Duration between each poll of ``func`` in milliseconds.
+ * Defaults to 10 milliseconds.
+ *
+ * @return {Promise.<*>}
+ * Yields the value passed to ``func``'s
+ * ``resolve`` or ``reject`` callbacks.
+ *
+ * @throws {*}
+ * If ``func`` throws, its error is propagated.
+ * @throws {TypeError}
+ * If `timeout` or `interval`` are not numbers.
+ * @throws {RangeError}
+ * If `timeout` or `interval` are not unsigned integers.
+ */
+export function PollPromise(func, { timeout = null, interval = 10 } = {}) {
+ const timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+
+ if (typeof func != "function") {
+ throw new TypeError();
+ }
+ if (timeout != null && typeof timeout != "number") {
+ throw new TypeError();
+ }
+ if (typeof interval != "number") {
+ throw new TypeError();
+ }
+ if (
+ (timeout && (!Number.isInteger(timeout) || timeout < 0)) ||
+ !Number.isInteger(interval) ||
+ interval < 0
+ ) {
+ throw new RangeError();
+ }
+
+ return new Promise((resolve, reject) => {
+ let start, end;
+
+ if (Number.isInteger(timeout)) {
+ start = new Date().getTime();
+ end = start + timeout;
+ }
+
+ let evalFn = () => {
+ new Promise(func)
+ .then(resolve, rejected => {
+ if (lazy.error.isError(rejected)) {
+ throw rejected;
+ }
+
+ // return if there is a timeout and set to 0,
+ // allowing |func| to be evaluated at least once
+ if (
+ typeof end != "undefined" &&
+ (start == end || new Date().getTime() >= end)
+ ) {
+ resolve(rejected);
+ }
+ })
+ .catch(reject);
+ };
+
+ // the repeating slack timer waits |interval|
+ // before invoking |evalFn|
+ evalFn();
+
+ timer.init(evalFn, interval, TYPE_REPEATING_SLACK);
+ }).then(
+ res => {
+ timer.cancel();
+ return res;
+ },
+ err => {
+ timer.cancel();
+ throw err;
+ }
+ );
+}
+
+/**
+ * Represents the timed, eventual completion (or failure) of an
+ * asynchronous operation, and its resulting value.
+ *
+ * In contrast to a regular Promise, it times out after ``timeout``.
+ *
+ * @param {Condition} func
+ * Function to run, which will have its ``reject``
+ * callback invoked after the ``timeout`` duration is reached.
+ * It is given two callbacks: ``resolve(value)`` and
+ * ``reject(error)``.
+ * @param {Object=} options
+ * @param {String} [options.errorMessage]
+ * Message to use for the thrown error.
+ * @param {number=} options.timeout
+ * ``condition``'s ``reject`` callback will be called
+ * after this timeout, given in milliseconds.
+ * By default 1500 ms in an optimised build and 4500 ms in
+ * debug builds.
+ * @param {Error=} [options.throws=TimeoutError]
+ * When the ``timeout`` is hit, this error class will be
+ * thrown. If it is null, no error is thrown and the promise is
+ * instead resolved on timeout.
+ *
+ * @return {Promise.<*>}
+ * Timed promise.
+ *
+ * @throws {TypeError}
+ * If `timeout` is not a number.
+ * @throws {RangeError}
+ * If `timeout` is not an unsigned integer.
+ */
+export function TimedPromise(fn, options = {}) {
+ const {
+ errorMessage = "TimedPromise timed out",
+ timeout = PROMISE_TIMEOUT,
+ throws = lazy.error.TimeoutError,
+ } = options;
+
+ const timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+
+ if (typeof fn != "function") {
+ throw new TypeError();
+ }
+ if (typeof timeout != "number") {
+ throw new TypeError();
+ }
+ if (!Number.isInteger(timeout) || timeout < 0) {
+ throw new RangeError();
+ }
+
+ return new Promise((resolve, reject) => {
+ let trace;
+
+ // Reject only if |throws| is given. Otherwise it is assumed that
+ // the user is OK with the promise timing out.
+ let bail = () => {
+ if (throws !== null) {
+ let err = new throws(`${errorMessage} after ${timeout} ms`);
+ reject(err);
+ } else {
+ lazy.logger.warn(`${errorMessage} after ${timeout} ms`, trace);
+ resolve();
+ }
+ };
+
+ trace = lazy.error.stack();
+ timer.initWithCallback({ notify: bail }, timeout, TYPE_ONE_SHOT);
+
+ try {
+ fn(resolve, reject);
+ } catch (e) {
+ reject(e);
+ }
+ }).then(
+ res => {
+ timer.cancel();
+ return res;
+ },
+ err => {
+ timer.cancel();
+ throw err;
+ }
+ );
+}
+
+/**
+ * Pauses for the given duration.
+ *
+ * @param {number} timeout
+ * Duration to wait before fulfilling promise in milliseconds.
+ *
+ * @return {Promise}
+ * Promise that fulfills when the `timeout` is elapsed.
+ *
+ * @throws {TypeError}
+ * If `timeout` is not a number.
+ * @throws {RangeError}
+ * If `timeout` is not an unsigned integer.
+ */
+export function Sleep(timeout) {
+ if (typeof timeout != "number") {
+ throw new TypeError();
+ }
+ if (!Number.isInteger(timeout) || timeout < 0) {
+ throw new RangeError();
+ }
+
+ return new Promise(resolve => {
+ const timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+ timer.init(
+ () => {
+ // Bug 1663880 - Explicitely cancel the timer for now to prevent a hang
+ timer.cancel();
+ resolve();
+ },
+ timeout,
+ TYPE_ONE_SHOT
+ );
+ });
+}
+
+/**
+ * Detects when the specified message manager has been destroyed.
+ *
+ * One can observe the removal and detachment of a content browser
+ * (`<xul:browser>`) or a chrome window by its message manager
+ * disconnecting.
+ *
+ * When a browser is associated with a tab, this is safer than only
+ * relying on the event `TabClose` which signalises the _intent to_
+ * remove a tab and consequently would lead to the destruction of
+ * the content browser and its browser message manager.
+ *
+ * When closing a chrome window it is safer than only relying on
+ * the event 'unload' which signalises the _intent to_ close the
+ * chrome window and consequently would lead to the destruction of
+ * the window and its window message manager.
+ *
+ * @param {MessageListenerManager} messageManager
+ * The message manager to observe for its disconnect state.
+ * Use the browser message manager when closing a content browser,
+ * and the window message manager when closing a chrome window.
+ *
+ * @return {Promise}
+ * A promise that resolves when the message manager has been destroyed.
+ */
+export function MessageManagerDestroyedPromise(messageManager) {
+ return new Promise(resolve => {
+ function observe(subject, topic) {
+ lazy.logger.trace(`Received observer notification ${topic}`);
+
+ if (subject == messageManager) {
+ Services.obs.removeObserver(this, "message-manager-disconnect");
+ resolve();
+ }
+ }
+
+ Services.obs.addObserver(observe, "message-manager-disconnect");
+ });
+}
+
+/**
+ * Throttle until the main thread is idle and `window` has performed
+ * an animation frame (in that order).
+ *
+ * @param {ChromeWindow} win
+ * Window to request the animation frame from.
+ *
+ * @return Promise
+ */
+export function IdlePromise(win) {
+ const animationFramePromise = new Promise(resolve => {
+ executeSoon(() => {
+ win.requestAnimationFrame(resolve);
+ });
+ });
+
+ // Abort if the underlying window gets closed
+ const windowClosedPromise = new PollPromise(resolve => {
+ if (win.closed) {
+ resolve();
+ }
+ });
+
+ return Promise.race([animationFramePromise, windowClosedPromise]);
+}
+
+/**
+ * Wraps a callback function, that, as long as it continues to be
+ * invoked, will not be triggered. The given function will be
+ * called after the timeout duration is reached, after no more
+ * events fire.
+ *
+ * This class implements the {@link EventListener} interface,
+ * which means it can be used interchangably with `addEventHandler`.
+ *
+ * Debouncing events can be useful when dealing with e.g. DOM events
+ * that fire at a high rate. It is generally advisable to avoid
+ * computationally expensive operations such as DOM modifications
+ * under these circumstances.
+ *
+ * One such high frequenecy event is `resize` that can fire multiple
+ * times before the window reaches its final dimensions. In order
+ * to delay an operation until the window has completed resizing,
+ * it is possible to use this technique to only invoke the callback
+ * after the last event has fired::
+ *
+ * let cb = new DebounceCallback(event => {
+ * // fires after the final resize event
+ * console.log("resize", event);
+ * });
+ * window.addEventListener("resize", cb);
+ *
+ * Note that it is not possible to use this synchronisation primitive
+ * with `addEventListener(..., {once: true})`.
+ *
+ * @param {function(Event)} fn
+ * Callback function that is guaranteed to be invoked once only,
+ * after `timeout`.
+ * @param {number=} [timeout = 250] timeout
+ * Time since last event firing, before `fn` will be invoked.
+ */
+export class DebounceCallback {
+ constructor(fn, { timeout = 250 } = {}) {
+ if (typeof fn != "function" || typeof timeout != "number") {
+ throw new TypeError();
+ }
+ if (!Number.isInteger(timeout) || timeout < 0) {
+ throw new RangeError();
+ }
+
+ this.fn = fn;
+ this.timeout = timeout;
+ this.timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+ }
+
+ handleEvent(ev) {
+ this.timer.cancel();
+ this.timer.initWithCallback(
+ () => {
+ this.timer.cancel();
+ this.fn(ev);
+ },
+ this.timeout,
+ TYPE_ONE_SHOT
+ );
+ }
+}
+
+/**
+ * Wait for a message to be fired from a particular message manager.
+ *
+ * This method has been duplicated from BrowserTestUtils.sys.mjs.
+ *
+ * @param {nsIMessageManager} messageManager
+ * The message manager that should be used.
+ * @param {string} messageName
+ * The message to wait for.
+ * @param {Object=} options
+ * Extra options.
+ * @param {function(Message)=} options.checkFn
+ * Called with the ``Message`` object as argument, should return ``true``
+ * if the message is the expected one, or ``false`` if it should be
+ * ignored and listening should continue. If not specified, the first
+ * message with the specified name resolves the returned promise.
+ *
+ * @return {Promise.<Object>}
+ * Promise which resolves to the data property of the received
+ * ``Message``.
+ */
+export function waitForMessage(
+ messageManager,
+ messageName,
+ { checkFn = undefined } = {}
+) {
+ if (messageManager == null || !("addMessageListener" in messageManager)) {
+ throw new TypeError();
+ }
+ if (typeof messageName != "string") {
+ throw new TypeError();
+ }
+ if (checkFn && typeof checkFn != "function") {
+ throw new TypeError();
+ }
+
+ return new Promise(resolve => {
+ messageManager.addMessageListener(messageName, function onMessage(msg) {
+ lazy.logger.trace(`Received ${messageName} for ${msg.target}`);
+ if (checkFn && !checkFn(msg)) {
+ return;
+ }
+ messageManager.removeMessageListener(messageName, onMessage);
+ resolve(msg.data);
+ });
+ });
+}
+
+/**
+ * Wait for the specified observer topic to be observed.
+ *
+ * This method has been duplicated from TestUtils.sys.mjs.
+ *
+ * Because this function is intended for testing, any error in checkFn
+ * will cause the returned promise to be rejected instead of waiting for
+ * the next notification, since this is probably a bug in the test.
+ *
+ * @param {string} topic
+ * The topic to observe.
+ * @param {Object=} options
+ * Extra options.
+ * @param {function(String,Object)=} options.checkFn
+ * Called with ``subject``, and ``data`` as arguments, should return true
+ * if the notification is the expected one, or false if it should be
+ * ignored and listening should continue. If not specified, the first
+ * notification for the specified topic resolves the returned promise.
+ *
+ * @return {Promise.<Array<String, Object>>}
+ * Promise which resolves to an array of ``subject``, and ``data`` from
+ * the observed notification.
+ */
+export function waitForObserverTopic(topic, { checkFn = null } = {}) {
+ if (typeof topic != "string") {
+ throw new TypeError();
+ }
+ if (checkFn != null && typeof checkFn != "function") {
+ throw new TypeError();
+ }
+
+ return new Promise((resolve, reject) => {
+ Services.obs.addObserver(function observer(subject, topic, data) {
+ lazy.logger.trace(`Received observer notification ${topic}`);
+ try {
+ if (checkFn && !checkFn(subject, data)) {
+ return;
+ }
+ Services.obs.removeObserver(observer, topic);
+ resolve({ subject, data });
+ } catch (ex) {
+ Services.obs.removeObserver(observer, topic);
+ reject(ex);
+ }
+ }, topic);
+ });
+}
diff --git a/remote/marionette/test/README b/remote/marionette/test/README
new file mode 100644
index 0000000000..9305b92cab
--- /dev/null
+++ b/remote/marionette/test/README
@@ -0,0 +1 @@
+See ../doc/Testing.md \ No newline at end of file
diff --git a/remote/marionette/test/xpcshell/.eslintrc.js b/remote/marionette/test/xpcshell/.eslintrc.js
new file mode 100644
index 0000000000..2ef179ab5e
--- /dev/null
+++ b/remote/marionette/test/xpcshell/.eslintrc.js
@@ -0,0 +1,7 @@
+"use strict";
+
+module.exports = {
+ rules: {
+ camelcase: "off",
+ },
+};
diff --git a/remote/marionette/test/xpcshell/README b/remote/marionette/test/xpcshell/README
new file mode 100644
index 0000000000..ce516d17ca
--- /dev/null
+++ b/remote/marionette/test/xpcshell/README
@@ -0,0 +1,16 @@
+To run the tests in this directory, from the top source directory,
+either invoke the test despatcher in mach:
+
+ % ./mach test remote/marionette/test/xpcshell
+
+Or call out the harness specifically:
+
+ % ./mach xpcshell-test remote/marionette/test/xpcshell
+
+The latter gives you the --sequential option which can be useful
+when debugging to prevent tests from running in parallel.
+
+When adding new tests you must make sure they are listed in
+xpcshell.ini, otherwise they will not run on try.
+
+See also ../../doc/Testing.md for more advice on our other types of tests.
diff --git a/remote/marionette/test/xpcshell/head.js b/remote/marionette/test/xpcshell/head.js
new file mode 100644
index 0000000000..4ff0e0dfa0
--- /dev/null
+++ b/remote/marionette/test/xpcshell/head.js
@@ -0,0 +1,7 @@
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+
+const SVG_NS = "http://www.w3.org/2000/svg";
+const XHTML_NS = "http://www.w3.org/1999/xhtml";
+const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
+
+const browser = Services.appShell.createWindowlessBrowser(false);
diff --git a/remote/marionette/test/xpcshell/test_action.js b/remote/marionette/test/xpcshell/test_action.js
new file mode 100644
index 0000000000..963a3337ec
--- /dev/null
+++ b/remote/marionette/test/xpcshell/test_action.js
@@ -0,0 +1,745 @@
+/* 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/marionette/action.sys.mjs"
+);
+
+const XHTMLNS = "http://www.w3.org/1999/xhtml";
+
+const domEl = {
+ nodeType: 1,
+ ELEMENT_NODE: 1,
+ namespaceURI: XHTMLNS,
+};
+
+add_test(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);
+ }
+ run_next_test();
+});
+
+add_test(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"
+ );
+
+ run_next_test();
+});
+
+add_test(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
+ );
+ }
+
+ run_next_test();
+});
+
+add_test(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);
+
+ run_next_test();
+});
+
+add_test(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`
+ );
+ }
+ run_next_test();
+});
+
+add_test(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)})`
+ );
+ }
+
+ run_next_test();
+});
+
+add_test(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}`
+ );
+ }
+
+ run_next_test();
+});
+
+add_test(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);
+ run_next_test();
+});
+
+add_test(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,
+ });
+ run_next_test();
+});
+
+add_test(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);
+ }
+ run_next_test();
+});
+
+add_test(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]);
+
+ run_next_test();
+});
+
+add_test(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]);
+
+ run_next_test();
+});
+
+add_test(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
+ );
+ }
+ }
+ }
+ run_next_test();
+});
+
+add_test(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);
+
+ run_next_test();
+});
+
+add_test(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
+ );
+ }
+ run_next_test();
+});
+
+add_test(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");
+
+ run_next_test();
+});
+
+add_test(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
+ );
+
+ run_next_test();
+});
+
+add_test(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");
+ run_next_test();
+});
+
+add_test(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");
+ run_next_test();
+});
+
+add_test(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");
+ run_next_test();
+});
+
+add_test(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
+ );
+
+ run_next_test();
+});
+
+add_test(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
+ );
+ }
+ run_next_test();
+});
+
+add_test(function test_extractActionChainEmpty() {
+ const state = new action.State();
+ deepEqual(action.Chain.fromJSON(state, []), []);
+ run_next_test();
+});
+
+add_test(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);
+
+ run_next_test();
+});
+
+add_test(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");
+ run_next_test();
+});
+
+add_test(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());
+ run_next_test();
+});
+
+add_test(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());
+ run_next_test();
+});
+
+// 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/marionette/test/xpcshell/test_actors.js b/remote/marionette/test/xpcshell/test_actors.js
new file mode 100644
index 0000000000..6514ceebb6
--- /dev/null
+++ b/remote/marionette/test/xpcshell/test_actors.js
@@ -0,0 +1,61 @@
+/* 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 {
+ getMarionetteCommandsActorProxy,
+ registerCommandsActor,
+ unregisterCommandsActor,
+} = ChromeUtils.importESModule(
+ "chrome://remote/content/marionette/actors/MarionetteCommandsParent.sys.mjs"
+);
+const { enableEventsActor, disableEventsActor } = ChromeUtils.importESModule(
+ "chrome://remote/content/marionette/actors/MarionetteEventsParent.sys.mjs"
+);
+
+registerCleanupFunction(function() {
+ unregisterCommandsActor();
+ disableEventsActor();
+});
+
+add_test(function test_commandsActor_register() {
+ registerCommandsActor();
+ unregisterCommandsActor();
+
+ registerCommandsActor();
+ registerCommandsActor();
+ unregisterCommandsActor();
+
+ run_next_test();
+});
+
+add_test(async function test_commandsActor_getActorProxy_noBrowsingContext() {
+ registerCommandsActor();
+
+ try {
+ await getMarionetteCommandsActorProxy(() => null).sendQuery("foo", "bar");
+ ok(false, "Expected NoBrowsingContext error not raised");
+ } catch (e) {
+ ok(
+ e.message.includes("No BrowsingContext found"),
+ "Expected default error message found"
+ );
+ }
+
+ unregisterCommandsActor();
+
+ run_next_test();
+});
+
+add_test(function test_eventsActor_enable_disable() {
+ enableEventsActor();
+ disableEventsActor();
+
+ enableEventsActor();
+ enableEventsActor();
+ disableEventsActor();
+
+ run_next_test();
+});
diff --git a/remote/marionette/test/xpcshell/test_browser.js b/remote/marionette/test/xpcshell/test_browser.js
new file mode 100644
index 0000000000..c00a7063e3
--- /dev/null
+++ b/remote/marionette/test/xpcshell/test_browser.js
@@ -0,0 +1,25 @@
+const { Context } = ChromeUtils.importESModule(
+ "chrome://remote/content/marionette/browser.sys.mjs"
+);
+
+add_test(function test_Context() {
+ ok(Context.hasOwnProperty("Chrome"));
+ ok(Context.hasOwnProperty("Content"));
+ equal(typeof Context.Chrome, "string");
+ equal(typeof Context.Content, "string");
+ equal(Context.Chrome, "chrome");
+ equal(Context.Content, "content");
+
+ run_next_test();
+});
+
+add_test(function test_Context_fromString() {
+ equal(Context.fromString("chrome"), Context.Chrome);
+ equal(Context.fromString("content"), Context.Content);
+
+ for (let typ of ["", "foo", true, 42, [], {}, null, undefined]) {
+ Assert.throws(() => Context.fromString(typ), /TypeError/);
+ }
+
+ run_next_test();
+});
diff --git a/remote/marionette/test/xpcshell/test_cookie.js b/remote/marionette/test/xpcshell/test_cookie.js
new file mode 100644
index 0000000000..08d0f41bbf
--- /dev/null
+++ b/remote/marionette/test/xpcshell/test_cookie.js
@@ -0,0 +1,370 @@
+/* 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 { cookie } = ChromeUtils.importESModule(
+ "chrome://remote/content/marionette/cookie.sys.mjs"
+);
+
+/* eslint-disable mozilla/use-chromeutils-generateqi */
+
+cookie.manager = {
+ cookies: [],
+
+ add(
+ domain,
+ path,
+ name,
+ value,
+ secure,
+ httpOnly,
+ session,
+ expiry,
+ originAttributes,
+ sameSite
+ ) {
+ if (name === "fail") {
+ throw new Error("An error occurred while adding cookie");
+ }
+ let newCookie = {
+ host: domain,
+ path,
+ name,
+ value,
+ isSecure: secure,
+ isHttpOnly: httpOnly,
+ isSession: session,
+ expiry,
+ originAttributes,
+ sameSite,
+ };
+ cookie.manager.cookies.push(newCookie);
+ },
+
+ remove(host, name, path) {
+ for (let i = 0; i < this.cookies.length; ++i) {
+ let candidate = this.cookies[i];
+ if (
+ candidate.host === host &&
+ candidate.name === name &&
+ candidate.path === path
+ ) {
+ return this.cookies.splice(i, 1);
+ }
+ }
+ return false;
+ },
+
+ getCookiesFromHost(host) {
+ let hostCookies = this.cookies.filter(
+ c => c.host === host || c.host === "." + host
+ );
+
+ return hostCookies;
+ },
+};
+
+add_test(function test_fromJSON() {
+ // object
+ for (let invalidType of ["foo", 42, true, [], null, undefined]) {
+ Assert.throws(() => cookie.fromJSON(invalidType), /Expected cookie object/);
+ }
+
+ // name and value
+ for (let invalidType of [42, true, [], {}, null, undefined]) {
+ Assert.throws(
+ () => cookie.fromJSON({ name: invalidType }),
+ /Cookie name must be string/
+ );
+ Assert.throws(
+ () => cookie.fromJSON({ name: "foo", value: invalidType }),
+ /Cookie value must be string/
+ );
+ }
+
+ // domain
+ for (let invalidType of [42, true, [], {}, null]) {
+ let domainTest = {
+ name: "foo",
+ value: "bar",
+ domain: invalidType,
+ };
+ Assert.throws(
+ () => cookie.fromJSON(domainTest),
+ /Cookie domain must be string/
+ );
+ }
+ let domainTest = {
+ name: "foo",
+ value: "bar",
+ domain: "domain",
+ };
+ let parsedCookie = cookie.fromJSON(domainTest);
+ equal(parsedCookie.domain, "domain");
+
+ // path
+ for (let invalidType of [42, true, [], {}, null]) {
+ let pathTest = {
+ name: "foo",
+ value: "bar",
+ path: invalidType,
+ };
+ Assert.throws(
+ () => cookie.fromJSON(pathTest),
+ /Cookie path must be string/
+ );
+ }
+
+ // secure
+ for (let invalidType of ["foo", 42, [], {}, null]) {
+ let secureTest = {
+ name: "foo",
+ value: "bar",
+ secure: invalidType,
+ };
+ Assert.throws(
+ () => cookie.fromJSON(secureTest),
+ /Cookie secure flag must be boolean/
+ );
+ }
+
+ // httpOnly
+ for (let invalidType of ["foo", 42, [], {}, null]) {
+ let httpOnlyTest = {
+ name: "foo",
+ value: "bar",
+ httpOnly: invalidType,
+ };
+ Assert.throws(
+ () => cookie.fromJSON(httpOnlyTest),
+ /Cookie httpOnly flag must be boolean/
+ );
+ }
+
+ // expiry
+ for (let invalidType of [
+ -1,
+ Number.MAX_SAFE_INTEGER + 1,
+ "foo",
+ true,
+ [],
+ {},
+ null,
+ ]) {
+ let expiryTest = {
+ name: "foo",
+ value: "bar",
+ expiry: invalidType,
+ };
+ Assert.throws(
+ () => cookie.fromJSON(expiryTest),
+ /Cookie expiry must be a positive integer/
+ );
+ }
+
+ // sameSite
+ for (let invalidType of ["foo", 42, [], {}, null]) {
+ const sameSiteTest = {
+ name: "foo",
+ value: "bar",
+ sameSite: invalidType,
+ };
+ Assert.throws(
+ () => cookie.fromJSON(sameSiteTest),
+ /Cookie SameSite flag must be one of None, Lax, or Strict/
+ );
+ }
+
+ // bare requirements
+ let bare = cookie.fromJSON({ name: "name", value: "value" });
+ equal("name", bare.name);
+ equal("value", bare.value);
+ for (let missing of [
+ "path",
+ "secure",
+ "httpOnly",
+ "session",
+ "expiry",
+ "sameSite",
+ ]) {
+ ok(!bare.hasOwnProperty(missing));
+ }
+
+ // everything
+ let full = cookie.fromJSON({
+ name: "name",
+ value: "value",
+ domain: ".domain",
+ path: "path",
+ secure: true,
+ httpOnly: true,
+ expiry: 42,
+ sameSite: "Lax",
+ });
+ equal("name", full.name);
+ equal("value", full.value);
+ equal(".domain", full.domain);
+ equal("path", full.path);
+ equal(true, full.secure);
+ equal(true, full.httpOnly);
+ equal(42, full.expiry);
+ equal("Lax", full.sameSite);
+
+ run_next_test();
+});
+
+add_test(function test_add() {
+ cookie.manager.cookies = [];
+
+ for (let invalidType of [42, true, [], {}, null, undefined]) {
+ Assert.throws(
+ () => cookie.add({ name: invalidType }),
+ /Cookie name must be string/
+ );
+ Assert.throws(
+ () => cookie.add({ name: "name", value: invalidType }),
+ /Cookie value must be string/
+ );
+ Assert.throws(
+ () => cookie.add({ name: "name", value: "value", domain: invalidType }),
+ /Cookie domain must be string/
+ );
+ }
+
+ cookie.add({
+ name: "name",
+ value: "value",
+ domain: "domain",
+ });
+ equal(1, cookie.manager.cookies.length);
+ equal("name", cookie.manager.cookies[0].name);
+ equal("value", cookie.manager.cookies[0].value);
+ equal(".domain", cookie.manager.cookies[0].host);
+ equal("/", cookie.manager.cookies[0].path);
+ ok(cookie.manager.cookies[0].expiry > new Date(Date.now()).getTime() / 1000);
+
+ cookie.add({
+ name: "name2",
+ value: "value2",
+ domain: "domain2",
+ });
+ equal(2, cookie.manager.cookies.length);
+
+ Assert.throws(() => {
+ let biscuit = { name: "name3", value: "value3", domain: "domain3" };
+ cookie.add(biscuit, { restrictToHost: "other domain" });
+ }, /Cookies may only be set for the current domain/);
+
+ cookie.add({
+ name: "name4",
+ value: "value4",
+ domain: "my.domain:1234",
+ });
+ equal(".my.domain", cookie.manager.cookies[2].host);
+
+ cookie.add({
+ name: "name5",
+ value: "value5",
+ domain: "domain5",
+ path: "/foo/bar",
+ });
+ equal("/foo/bar", cookie.manager.cookies[3].path);
+
+ cookie.add({
+ name: "name6",
+ value: "value",
+ domain: ".domain",
+ });
+ equal(".domain", cookie.manager.cookies[4].host);
+
+ const sameSiteMap = new Map([
+ ["None", Ci.nsICookie.SAMESITE_NONE],
+ ["Lax", Ci.nsICookie.SAMESITE_LAX],
+ ["Strict", Ci.nsICookie.SAMESITE_STRICT],
+ ]);
+
+ Array.from(sameSiteMap.keys()).forEach((entry, index) => {
+ cookie.add({
+ name: "name" + index,
+ value: "value",
+ domain: ".domain",
+ sameSite: entry,
+ });
+ equal(sameSiteMap.get(entry), cookie.manager.cookies[5 + index].sameSite);
+ });
+
+ Assert.throws(() => {
+ cookie.add({ name: "fail", value: "value6", domain: "domain6" });
+ }, /UnableToSetCookieError/);
+
+ run_next_test();
+});
+
+add_test(function test_remove() {
+ cookie.manager.cookies = [];
+
+ let crumble = {
+ name: "test_remove",
+ value: "value",
+ domain: "domain",
+ path: "/custom/path",
+ };
+
+ equal(0, cookie.manager.cookies.length);
+ cookie.add(crumble);
+ equal(1, cookie.manager.cookies.length);
+
+ cookie.remove(crumble);
+ equal(0, cookie.manager.cookies.length);
+ equal(undefined, cookie.manager.cookies[0]);
+
+ run_next_test();
+});
+
+add_test(function test_iter() {
+ cookie.manager.cookies = [];
+ let tomorrow = new Date();
+ tomorrow.setHours(tomorrow.getHours() + 24);
+
+ cookie.add({
+ expiry: tomorrow,
+ name: "0",
+ value: "",
+ domain: "foo.example.com",
+ });
+ cookie.add({
+ expiry: tomorrow,
+ name: "1",
+ value: "",
+ domain: "bar.example.com",
+ });
+
+ let fooCookies = [...cookie.iter("foo.example.com")];
+ equal(1, fooCookies.length);
+ equal(".foo.example.com", fooCookies[0].domain);
+ equal(true, fooCookies[0].hasOwnProperty("expiry"));
+
+ cookie.add({
+ name: "aSessionCookie",
+ value: "",
+ domain: "session.com",
+ });
+
+ let sessionCookies = [...cookie.iter("session.com")];
+ equal(1, sessionCookies.length);
+ equal("aSessionCookie", sessionCookies[0].name);
+ equal(false, sessionCookies[0].hasOwnProperty("expiry"));
+
+ cookie.add({
+ name: "2",
+ value: "",
+ domain: "samesite.example.com",
+ sameSite: "Lax",
+ });
+
+ let sameSiteCookies = [...cookie.iter("samesite.example.com")];
+ equal(1, sameSiteCookies.length);
+ equal("Lax", sameSiteCookies[0].sameSite);
+
+ run_next_test();
+});
diff --git a/remote/marionette/test/xpcshell/test_dom.js b/remote/marionette/test/xpcshell/test_dom.js
new file mode 100644
index 0000000000..83dc9de3ab
--- /dev/null
+++ b/remote/marionette/test/xpcshell/test_dom.js
@@ -0,0 +1,277 @@
+const {
+ ContentEventObserverService,
+ WebElementEventTarget,
+} = ChromeUtils.importESModule(
+ "chrome://remote/content/marionette/dom.sys.mjs"
+);
+
+class MessageSender {
+ constructor() {
+ this.listeners = {};
+ this.sent = [];
+ }
+
+ addMessageListener(name, listener) {
+ this.listeners[name] = listener;
+ }
+
+ sendAsyncMessage(name, data) {
+ this.sent.push({ name, data });
+ }
+}
+
+class Window {
+ constructor() {
+ this.events = [];
+ }
+
+ addEventListener(type) {
+ this.events.push(type);
+ }
+
+ removeEventListener(type) {
+ for (let i = 0; i < this.events.length; ++i) {
+ if (this.events[i] === type) {
+ this.events.splice(i, 1);
+ return;
+ }
+ }
+ }
+}
+
+add_test(function test_WebElementEventTarget_addEventListener_init() {
+ let ipc = new MessageSender();
+ let eventTarget = new WebElementEventTarget(ipc);
+ equal(Object.keys(eventTarget.listeners).length, 0);
+ equal(Object.keys(ipc.listeners).length, 1);
+
+ run_next_test();
+});
+
+add_test(function test_addEventListener() {
+ let ipc = new MessageSender();
+ let eventTarget = new WebElementEventTarget(ipc);
+
+ let listener = () => {};
+ eventTarget.addEventListener("click", listener);
+
+ // click listener was appended
+ equal(Object.keys(eventTarget.listeners).length, 1);
+ ok("click" in eventTarget.listeners);
+ equal(eventTarget.listeners.click.length, 1);
+ equal(eventTarget.listeners.click[0], listener);
+
+ // should have sent a registration message
+ deepEqual(ipc.sent[0], {
+ name: "Marionette:DOM:AddEventListener",
+ data: { type: "click" },
+ });
+
+ run_next_test();
+});
+
+add_test(function test_addEventListener_sameReference() {
+ let ipc = new MessageSender();
+ let eventTarget = new WebElementEventTarget(ipc);
+
+ let listener = () => {};
+ eventTarget.addEventListener("click", listener);
+ eventTarget.addEventListener("click", listener);
+ equal(eventTarget.listeners.click.length, 1);
+
+ run_next_test();
+});
+
+add_test(function test_WebElementEventTarget_addEventListener_once() {
+ let ipc = new MessageSender();
+ let eventTarget = new WebElementEventTarget(ipc);
+
+ eventTarget.addEventListener("click", () => {}, { once: true });
+ equal(eventTarget.listeners.click[0].once, true);
+
+ eventTarget.dispatchEvent({ type: "click" });
+ equal(eventTarget.listeners.click.length, 0);
+ deepEqual(ipc.sent[1], {
+ name: "Marionette:DOM:RemoveEventListener",
+ data: { type: "click" },
+ });
+
+ run_next_test();
+});
+
+add_test(function test_WebElementEventTarget_removeEventListener() {
+ let ipc = new MessageSender();
+ let eventTarget = new WebElementEventTarget(ipc);
+
+ equal(Object.keys(eventTarget.listeners).length, 0);
+ eventTarget.removeEventListener("click", () => {});
+ equal(Object.keys(eventTarget.listeners).length, 0);
+
+ let firstListener = () => {};
+ eventTarget.addEventListener("click", firstListener);
+ equal(eventTarget.listeners.click.length, 1);
+ ok(eventTarget.listeners.click[0] === firstListener);
+
+ let secondListener = () => {};
+ eventTarget.addEventListener("click", secondListener);
+ equal(eventTarget.listeners.click.length, 2);
+ ok(eventTarget.listeners.click[1] === secondListener);
+
+ ok(eventTarget.listeners.click[0] !== eventTarget.listeners.click[1]);
+
+ eventTarget.removeEventListener("click", secondListener);
+ equal(eventTarget.listeners.click.length, 1);
+ ok(eventTarget.listeners.click[0] === firstListener);
+
+ // event should not have been unregistered
+ // because there still exists another click event
+ equal(ipc.sent[ipc.sent.length - 1].name, "Marionette:DOM:AddEventListener");
+
+ eventTarget.removeEventListener("click", firstListener);
+ equal(eventTarget.listeners.click.length, 0);
+ deepEqual(ipc.sent[ipc.sent.length - 1], {
+ name: "Marionette:DOM:RemoveEventListener",
+ data: { type: "click" },
+ });
+
+ run_next_test();
+});
+
+add_test(function test_WebElementEventTarget_dispatchEvent() {
+ let ipc = new MessageSender();
+ let eventTarget = new WebElementEventTarget(ipc);
+
+ let listenerCalled = false;
+ let listener = () => (listenerCalled = true);
+ eventTarget.addEventListener("click", listener);
+ eventTarget.dispatchEvent({ type: "click" });
+ ok(listenerCalled);
+
+ run_next_test();
+});
+
+add_test(function test_WebElementEventTarget_dispatchEvent_multipleListeners() {
+ let ipc = new MessageSender();
+ let eventTarget = new WebElementEventTarget(ipc);
+
+ let clicksA = 0;
+ let clicksB = 0;
+ let listenerA = () => ++clicksA;
+ let listenerB = () => ++clicksB;
+
+ // the same listener should only be added, and consequently fire, once
+ eventTarget.addEventListener("click", listenerA);
+ eventTarget.addEventListener("click", listenerA);
+ eventTarget.addEventListener("click", listenerB);
+ eventTarget.dispatchEvent({ type: "click" });
+ equal(clicksA, 1);
+ equal(clicksB, 1);
+
+ run_next_test();
+});
+
+add_test(function test_ContentEventObserverService_add() {
+ let ipc = new MessageSender();
+ let win = new Window();
+ let obs = new ContentEventObserverService(
+ win,
+ ipc.sendAsyncMessage.bind(ipc)
+ );
+
+ equal(obs.events.size, 0);
+ equal(win.events.length, 0);
+
+ obs.add("foo");
+ equal(obs.events.size, 1);
+ equal(win.events.length, 1);
+ equal(obs.events.values().next().value, "foo");
+ equal(win.events[0], "foo");
+
+ obs.add("foo");
+ equal(obs.events.size, 1);
+ equal(win.events.length, 1);
+
+ run_next_test();
+});
+
+add_test(function test_ContentEventObserverService_remove() {
+ let ipc = new MessageSender();
+ let win = new Window();
+ let obs = new ContentEventObserverService(
+ win,
+ ipc.sendAsyncMessage.bind(ipc)
+ );
+
+ obs.remove("foo");
+ equal(obs.events.size, 0);
+ equal(win.events.length, 0);
+
+ obs.add("bar");
+ equal(obs.events.size, 1);
+ equal(win.events.length, 1);
+
+ obs.remove("bar");
+ equal(obs.events.size, 0);
+ equal(win.events.length, 0);
+
+ obs.add("baz");
+ obs.add("baz");
+ equal(obs.events.size, 1);
+ equal(win.events.length, 1);
+
+ obs.add("bah");
+ equal(obs.events.size, 2);
+ equal(win.events.length, 2);
+
+ obs.remove("baz");
+ equal(obs.events.size, 1);
+ equal(win.events.length, 1);
+
+ obs.remove("bah");
+ equal(obs.events.size, 0);
+ equal(win.events.length, 0);
+
+ run_next_test();
+});
+
+add_test(function test_ContentEventObserverService_clear() {
+ let ipc = new MessageSender();
+ let win = new Window();
+ let obs = new ContentEventObserverService(
+ win,
+ ipc.sendAsyncMessage.bind(ipc)
+ );
+
+ obs.clear();
+ equal(obs.events.size, 0);
+ equal(win.events.length, 0);
+
+ obs.add("foo");
+ obs.add("foo");
+ obs.add("bar");
+ equal(obs.events.size, 2);
+ equal(win.events.length, 2);
+
+ obs.clear();
+ equal(obs.events.size, 0);
+ equal(win.events.length, 0);
+
+ run_next_test();
+});
+
+add_test(function test_ContentEventObserverService_handleEvent() {
+ let ipc = new MessageSender();
+ let win = new Window();
+ let obs = new ContentEventObserverService(
+ win,
+ ipc.sendAsyncMessage.bind(ipc)
+ );
+
+ obs.handleEvent({ type: "click", target: win });
+ deepEqual(ipc.sent[0], {
+ name: "Marionette:DOM:OnEvent",
+ data: { type: "click" },
+ });
+
+ run_next_test();
+});
diff --git a/remote/marionette/test/xpcshell/test_element.js b/remote/marionette/test/xpcshell/test_element.js
new file mode 100644
index 0000000000..de0cbfb2fa
--- /dev/null
+++ b/remote/marionette/test/xpcshell/test_element.js
@@ -0,0 +1,571 @@
+/* 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 {
+ element,
+ WebElement,
+ WebFrame,
+ WebReference,
+ WebWindow,
+} = ChromeUtils.importESModule(
+ "chrome://remote/content/marionette/element.sys.mjs"
+);
+
+class Element {
+ constructor(tagName, attrs = {}) {
+ this.tagName = tagName;
+ this.localName = tagName;
+
+ for (let attr in attrs) {
+ this[attr] = attrs[attr];
+ }
+ }
+
+ get nodeType() {
+ return 1;
+ }
+ get ELEMENT_NODE() {
+ return 1;
+ }
+
+ // this is a severely limited CSS selector
+ // that only supports lists of tag names
+ matches(selector) {
+ let tags = selector.split(",");
+ return tags.includes(this.localName);
+ }
+}
+
+class DOMElement extends Element {
+ constructor(tagName, attrs = {}) {
+ super(tagName, attrs);
+
+ if (typeof this.namespaceURI == "undefined") {
+ this.namespaceURI = XHTML_NS;
+ }
+ if (typeof this.ownerDocument == "undefined") {
+ this.ownerDocument = { designMode: "off" };
+ }
+ if (typeof this.ownerDocument.documentElement == "undefined") {
+ this.ownerDocument.documentElement = { namespaceURI: XHTML_NS };
+ }
+
+ if (typeof this.type == "undefined") {
+ this.type = "text";
+ }
+
+ if (this.localName == "option") {
+ this.selected = false;
+ }
+
+ if (
+ this.localName == "input" &&
+ ["checkbox", "radio"].includes(this.type)
+ ) {
+ this.checked = false;
+ }
+ }
+
+ getBoundingClientRect() {
+ return {
+ top: 0,
+ left: 0,
+ width: 100,
+ height: 100,
+ };
+ }
+}
+
+class SVGElement extends Element {
+ constructor(tagName, attrs = {}) {
+ super(tagName, attrs);
+ this.namespaceURI = SVG_NS;
+ }
+}
+
+class XULElement extends Element {
+ constructor(tagName, attrs = {}) {
+ super(tagName, attrs);
+ this.namespaceURI = XUL_NS;
+
+ if (typeof this.ownerDocument == "undefined") {
+ this.ownerDocument = {};
+ }
+ if (typeof this.ownerDocument.documentElement == "undefined") {
+ this.ownerDocument.documentElement = { namespaceURI: XUL_NS };
+ }
+ }
+}
+
+const domEl = new DOMElement("p");
+const svgEl = new SVGElement("rect");
+const xulEl = new XULElement("text");
+
+const domElInPrivilegedDocument = new Element("input", {
+ nodePrincipal: { isSystemPrincipal: true },
+});
+const xulElInPrivilegedDocument = new XULElement("text", {
+ nodePrincipal: { isSystemPrincipal: true },
+});
+
+class WindowProxy {
+ get parent() {
+ return this;
+ }
+ get self() {
+ return this;
+ }
+ toString() {
+ return "[object Window]";
+ }
+}
+const domWin = new WindowProxy();
+const domFrame = new (class extends WindowProxy {
+ get parent() {
+ return domWin;
+ }
+})();
+
+add_test(function test_findClosest() {
+ equal(element.findClosest(domEl, "foo"), null);
+
+ let foo = new DOMElement("foo");
+ let bar = new DOMElement("bar");
+ bar.parentNode = foo;
+ equal(element.findClosest(bar, "foo"), foo);
+
+ run_next_test();
+});
+
+add_test(function test_isSelected() {
+ let checkbox = new DOMElement("input", { type: "checkbox" });
+ ok(!element.isSelected(checkbox));
+ checkbox.checked = true;
+ ok(element.isSelected(checkbox));
+
+ // selected is not a property of <input type=checkbox>
+ checkbox.selected = true;
+ checkbox.checked = false;
+ ok(!element.isSelected(checkbox));
+
+ let option = new DOMElement("option");
+ ok(!element.isSelected(option));
+ option.selected = true;
+ ok(element.isSelected(option));
+
+ // checked is not a property of <option>
+ option.checked = true;
+ option.selected = false;
+ ok(!element.isSelected(option));
+
+ // anything else should not be selected
+ for (let typ of [domEl, undefined, null, "foo", true, [], {}]) {
+ ok(!element.isSelected(typ));
+ }
+
+ run_next_test();
+});
+
+add_test(function test_isElement() {
+ ok(element.isElement(domEl));
+ ok(element.isElement(svgEl));
+ ok(element.isElement(xulEl));
+ ok(!element.isElement(domWin));
+ ok(!element.isElement(domFrame));
+ for (let typ of [true, 42, {}, [], undefined, null]) {
+ ok(!element.isElement(typ));
+ }
+
+ run_next_test();
+});
+
+add_test(function test_isDOMElement() {
+ ok(element.isDOMElement(domEl));
+ ok(element.isDOMElement(domElInPrivilegedDocument));
+ ok(element.isDOMElement(svgEl));
+ ok(!element.isDOMElement(xulEl));
+ ok(!element.isDOMElement(xulElInPrivilegedDocument));
+ ok(!element.isDOMElement(domWin));
+ ok(!element.isDOMElement(domFrame));
+ for (let typ of [true, 42, {}, [], undefined, null]) {
+ ok(!element.isDOMElement(typ));
+ }
+
+ run_next_test();
+});
+
+add_test(function test_isXULElement() {
+ ok(element.isXULElement(xulEl));
+ ok(element.isXULElement(xulElInPrivilegedDocument));
+ ok(!element.isXULElement(domElInPrivilegedDocument));
+ ok(!element.isXULElement(domEl));
+ ok(!element.isXULElement(svgEl));
+ ok(!element.isXULElement(domWin));
+ ok(!element.isXULElement(domFrame));
+ for (let typ of [true, 42, {}, [], undefined, null]) {
+ ok(!element.isXULElement(typ));
+ }
+
+ run_next_test();
+});
+
+add_test(function test_isDOMWindow() {
+ ok(element.isDOMWindow(domWin));
+ ok(element.isDOMWindow(domFrame));
+ ok(!element.isDOMWindow(domEl));
+ ok(!element.isDOMWindow(domElInPrivilegedDocument));
+ ok(!element.isDOMWindow(svgEl));
+ ok(!element.isDOMWindow(xulEl));
+ for (let typ of [true, 42, {}, [], undefined, null]) {
+ ok(!element.isDOMWindow(typ));
+ }
+
+ run_next_test();
+});
+
+add_test(function test_isReadOnly() {
+ ok(!element.isReadOnly(null));
+ ok(!element.isReadOnly(domEl));
+ ok(!element.isReadOnly(new DOMElement("p", { readOnly: true })));
+ ok(element.isReadOnly(new DOMElement("input", { readOnly: true })));
+ ok(element.isReadOnly(new DOMElement("textarea", { readOnly: true })));
+
+ run_next_test();
+});
+
+add_test(function test_isDisabled() {
+ ok(!element.isDisabled(new DOMElement("p")));
+ ok(!element.isDisabled(new SVGElement("rect", { disabled: true })));
+ ok(!element.isDisabled(new XULElement("browser", { disabled: true })));
+
+ let select = new DOMElement("select", { disabled: true });
+ let option = new DOMElement("option");
+ option.parentNode = select;
+ ok(element.isDisabled(option));
+
+ let optgroup = new DOMElement("optgroup", { disabled: true });
+ option.parentNode = optgroup;
+ optgroup.parentNode = select;
+ select.disabled = false;
+ ok(element.isDisabled(option));
+
+ ok(element.isDisabled(new DOMElement("button", { disabled: true })));
+ ok(element.isDisabled(new DOMElement("input", { disabled: true })));
+ ok(element.isDisabled(new DOMElement("select", { disabled: true })));
+ ok(element.isDisabled(new DOMElement("textarea", { disabled: true })));
+
+ run_next_test();
+});
+
+add_test(function test_isEditingHost() {
+ ok(!element.isEditingHost(null));
+ ok(element.isEditingHost(new DOMElement("p", { isContentEditable: true })));
+ ok(
+ element.isEditingHost(
+ new DOMElement("p", { ownerDocument: { designMode: "on" } })
+ )
+ );
+
+ run_next_test();
+});
+
+add_test(function test_isEditable() {
+ ok(!element.isEditable(null));
+ ok(!element.isEditable(domEl));
+ ok(!element.isEditable(new DOMElement("textarea", { readOnly: true })));
+ ok(!element.isEditable(new DOMElement("textarea", { disabled: true })));
+
+ for (let type of [
+ "checkbox",
+ "radio",
+ "hidden",
+ "submit",
+ "button",
+ "image",
+ ]) {
+ ok(!element.isEditable(new DOMElement("input", { type })));
+ }
+ ok(element.isEditable(new DOMElement("input", { type: "text" })));
+ ok(element.isEditable(new DOMElement("input")));
+
+ ok(element.isEditable(new DOMElement("textarea")));
+ ok(
+ element.isEditable(
+ new DOMElement("p", { ownerDocument: { designMode: "on" } })
+ )
+ );
+ ok(element.isEditable(new DOMElement("p", { isContentEditable: true })));
+
+ run_next_test();
+});
+
+add_test(function test_isMutableFormControlElement() {
+ ok(!element.isMutableFormControl(null));
+ ok(
+ !element.isMutableFormControl(
+ new DOMElement("textarea", { readOnly: true })
+ )
+ );
+ ok(
+ !element.isMutableFormControl(
+ new DOMElement("textarea", { disabled: true })
+ )
+ );
+
+ const mutableStates = new Set([
+ "color",
+ "date",
+ "datetime-local",
+ "email",
+ "file",
+ "month",
+ "number",
+ "password",
+ "range",
+ "search",
+ "tel",
+ "text",
+ "url",
+ "week",
+ ]);
+ for (let type of mutableStates) {
+ ok(element.isMutableFormControl(new DOMElement("input", { type })));
+ }
+ ok(element.isMutableFormControl(new DOMElement("textarea")));
+
+ ok(
+ !element.isMutableFormControl(new DOMElement("input", { type: "hidden" }))
+ );
+ ok(!element.isMutableFormControl(new DOMElement("p")));
+ ok(
+ !element.isMutableFormControl(
+ new DOMElement("p", { isContentEditable: true })
+ )
+ );
+ ok(
+ !element.isMutableFormControl(
+ new DOMElement("p", { ownerDocument: { designMode: "on" } })
+ )
+ );
+
+ run_next_test();
+});
+
+add_test(function test_coordinates() {
+ let p = element.coordinates(domEl);
+ ok(p.hasOwnProperty("x"));
+ ok(p.hasOwnProperty("y"));
+ equal("number", typeof p.x);
+ equal("number", typeof p.y);
+
+ deepEqual({ x: 50, y: 50 }, element.coordinates(domEl));
+ deepEqual({ x: 10, y: 10 }, element.coordinates(domEl, 10, 10));
+ deepEqual({ x: -5, y: -5 }, element.coordinates(domEl, -5, -5));
+
+ Assert.throws(() => element.coordinates(null), /node is null/);
+
+ Assert.throws(
+ () => element.coordinates(domEl, "string", undefined),
+ /Offset must be a number/
+ );
+ Assert.throws(
+ () => element.coordinates(domEl, undefined, "string"),
+ /Offset must be a number/
+ );
+ Assert.throws(
+ () => element.coordinates(domEl, "string", "string"),
+ /Offset must be a number/
+ );
+ Assert.throws(
+ () => element.coordinates(domEl, {}, undefined),
+ /Offset must be a number/
+ );
+ Assert.throws(
+ () => element.coordinates(domEl, undefined, {}),
+ /Offset must be a number/
+ );
+ Assert.throws(
+ () => element.coordinates(domEl, {}, {}),
+ /Offset must be a number/
+ );
+ Assert.throws(
+ () => element.coordinates(domEl, [], undefined),
+ /Offset must be a number/
+ );
+ Assert.throws(
+ () => element.coordinates(domEl, undefined, []),
+ /Offset must be a number/
+ );
+ Assert.throws(
+ () => element.coordinates(domEl, [], []),
+ /Offset must be a number/
+ );
+
+ run_next_test();
+});
+
+add_test(function test_WebReference_ctor() {
+ let el = new WebReference("foo");
+ equal(el.uuid, "foo");
+
+ for (let t of [42, true, [], {}, null, undefined]) {
+ Assert.throws(() => new WebReference(t), /to be a string/);
+ }
+
+ run_next_test();
+});
+
+add_test(function test_WebElemenet_is() {
+ let a = new WebReference("a");
+ let b = new WebReference("b");
+
+ ok(a.is(a));
+ ok(b.is(b));
+ ok(!a.is(b));
+ ok(!b.is(a));
+
+ ok(!a.is({}));
+
+ run_next_test();
+});
+
+add_test(function test_WebReference_from() {
+ ok(WebReference.from(domEl) instanceof WebElement);
+ ok(WebReference.from(xulEl) instanceof WebElement);
+ ok(WebReference.from(domWin) instanceof WebWindow);
+ ok(WebReference.from(domFrame) instanceof WebFrame);
+ ok(WebReference.from(domElInPrivilegedDocument) instanceof WebElement);
+ ok(WebReference.from(xulElInPrivilegedDocument) instanceof WebElement);
+
+ Assert.throws(() => WebReference.from({}), /InvalidArgumentError/);
+
+ run_next_test();
+});
+
+add_test(function test_WebReference_fromJSON_WebElement() {
+ const { Identifier } = WebElement;
+
+ let ref = { [Identifier]: "foo" };
+ let webEl = WebReference.fromJSON(ref);
+ ok(webEl instanceof WebElement);
+ equal(webEl.uuid, "foo");
+
+ let identifierPrecedence = {
+ [Identifier]: "identifier-uuid",
+ };
+ let precedenceEl = WebReference.fromJSON(identifierPrecedence);
+ ok(precedenceEl instanceof WebElement);
+ equal(precedenceEl.uuid, "identifier-uuid");
+
+ run_next_test();
+});
+
+add_test(function test_WebReference_fromJSON_WebWindow() {
+ let ref = { [WebWindow.Identifier]: "foo" };
+ let win = WebReference.fromJSON(ref);
+ ok(win instanceof WebWindow);
+ equal(win.uuid, "foo");
+
+ run_next_test();
+});
+
+add_test(function test_WebReference_fromJSON_WebFrame() {
+ let ref = { [WebFrame.Identifier]: "foo" };
+ let frame = WebReference.fromJSON(ref);
+ ok(frame instanceof WebFrame);
+ equal(frame.uuid, "foo");
+
+ run_next_test();
+});
+
+add_test(function test_WebReference_fromJSON_malformed() {
+ Assert.throws(() => WebReference.fromJSON({}), /InvalidArgumentError/);
+ Assert.throws(() => WebReference.fromJSON(null), /InvalidArgumentError/);
+ run_next_test();
+});
+
+add_test(function test_WebReference_fromUUID() {
+ let domWebEl = WebReference.fromUUID("bar");
+ ok(domWebEl instanceof WebElement);
+ equal(domWebEl.uuid, "bar");
+
+ run_next_test();
+});
+
+add_test(function test_WebReference_isReference() {
+ for (let t of [42, true, "foo", [], {}]) {
+ ok(!WebReference.isReference(t));
+ }
+
+ ok(WebReference.isReference({ [WebElement.Identifier]: "foo" }));
+ ok(WebReference.isReference({ [WebWindow.Identifier]: "foo" }));
+ ok(WebReference.isReference({ [WebFrame.Identifier]: "foo" }));
+
+ run_next_test();
+});
+
+add_test(function test_generateUUID() {
+ equal(typeof element.generateUUID(), "string");
+ run_next_test();
+});
+
+add_test(function test_WebElement_toJSON() {
+ const { Identifier } = WebElement;
+
+ let el = new WebElement("foo");
+ let json = el.toJSON();
+
+ ok(Identifier in json);
+ equal(json[Identifier], "foo");
+
+ run_next_test();
+});
+
+add_test(function test_WebElement_fromJSON() {
+ const { Identifier } = WebElement;
+
+ let el = WebElement.fromJSON({ [Identifier]: "foo" });
+ ok(el instanceof WebElement);
+ equal(el.uuid, "foo");
+
+ Assert.throws(() => WebElement.fromJSON({}), /InvalidArgumentError/);
+
+ run_next_test();
+});
+
+add_test(function test_WebWindow_toJSON() {
+ let win = new WebWindow("foo");
+ let json = win.toJSON();
+ ok(WebWindow.Identifier in json);
+ equal(json[WebWindow.Identifier], "foo");
+
+ run_next_test();
+});
+
+add_test(function test_WebWindow_fromJSON() {
+ let ref = { [WebWindow.Identifier]: "foo" };
+ let win = WebWindow.fromJSON(ref);
+ ok(win instanceof WebWindow);
+ equal(win.uuid, "foo");
+
+ run_next_test();
+});
+
+add_test(function test_WebFrame_toJSON() {
+ let frame = new WebFrame("foo");
+ let json = frame.toJSON();
+ ok(WebFrame.Identifier in json);
+ equal(json[WebFrame.Identifier], "foo");
+
+ run_next_test();
+});
+
+add_test(function test_WebFrame_fromJSON() {
+ let ref = { [WebFrame.Identifier]: "foo" };
+ let win = WebFrame.fromJSON(ref);
+ ok(win instanceof WebFrame);
+ equal(win.uuid, "foo");
+
+ run_next_test();
+});
diff --git a/remote/marionette/test/xpcshell/test_json.js b/remote/marionette/test/xpcshell/test_json.js
new file mode 100644
index 0000000000..b2956677c6
--- /dev/null
+++ b/remote/marionette/test/xpcshell/test_json.js
@@ -0,0 +1,251 @@
+const { WebElement, WebReference } = ChromeUtils.importESModule(
+ "chrome://remote/content/marionette/element.sys.mjs"
+);
+const { json } = ChromeUtils.importESModule(
+ "chrome://remote/content/marionette/json.sys.mjs"
+);
+const { NodeCache } = ChromeUtils.importESModule(
+ "chrome://remote/content/shared/webdriver/NodeCache.sys.mjs"
+);
+
+const MemoryReporter = Cc["@mozilla.org/memory-reporter-manager;1"].getService(
+ Ci.nsIMemoryReporterManager
+);
+
+const nodeCache = new NodeCache();
+
+const domEl = browser.document.createElement("div");
+const svgEl = browser.document.createElementNS(SVG_NS, "rect");
+
+browser.document.body.appendChild(domEl);
+browser.document.body.appendChild(svgEl);
+
+const win = domEl.ownerGlobal;
+
+add_test(function test_clone_generalTypes() {
+ // null
+ equal(json.clone(undefined, nodeCache), null);
+ equal(json.clone(null, nodeCache), null);
+
+ // primitives
+ equal(json.clone(true, nodeCache), true);
+ equal(json.clone(42, nodeCache), 42);
+ equal(json.clone("foo", nodeCache), "foo");
+
+ // toJSON
+ equal(
+ json.clone({
+ toJSON() {
+ return "foo";
+ },
+ }),
+ "foo"
+ );
+
+ nodeCache.clear({ all: true });
+ run_next_test();
+});
+
+add_test(function test_clone_WebElements() {
+ const domElSharedId = nodeCache.add(domEl);
+ deepEqual(
+ json.clone(domEl, nodeCache),
+ WebReference.from(domEl, domElSharedId).toJSON()
+ );
+
+ const svgElSharedId = nodeCache.add(svgEl);
+ deepEqual(
+ json.clone(svgEl, nodeCache),
+ WebReference.from(svgEl, svgElSharedId).toJSON()
+ );
+
+ nodeCache.clear({ all: true });
+ run_next_test();
+});
+
+add_test(function test_clone_Sequences() {
+ const domElSharedId = nodeCache.add(domEl);
+
+ const input = [
+ null,
+ true,
+ [],
+ domEl,
+ {
+ toJSON() {
+ return "foo";
+ },
+ },
+ { bar: "baz" },
+ ];
+
+ const actual = json.clone(input, nodeCache);
+
+ equal(actual[0], null);
+ equal(actual[1], true);
+ deepEqual(actual[2], []);
+ deepEqual(actual[3], { [WebElement.Identifier]: domElSharedId });
+ equal(actual[4], "foo");
+ deepEqual(actual[5], { bar: "baz" });
+
+ nodeCache.clear({ all: true });
+ run_next_test();
+});
+
+add_test(function test_clone_objects() {
+ const domElSharedId = nodeCache.add(domEl);
+
+ const input = {
+ null: null,
+ boolean: true,
+ array: [42],
+ element: domEl,
+ toJSON: {
+ toJSON() {
+ return "foo";
+ },
+ },
+ object: { bar: "baz" },
+ };
+
+ const actual = json.clone(input, nodeCache);
+
+ equal(actual.null, null);
+ equal(actual.boolean, true);
+ deepEqual(actual.array, [42]);
+ deepEqual(actual.element, { [WebElement.Identifier]: domElSharedId });
+ equal(actual.toJSON, "foo");
+ deepEqual(actual.object, { bar: "baz" });
+
+ nodeCache.clear({ all: true });
+ run_next_test();
+});
+
+add_test(function test_clone_сyclicReference() {
+ // object
+ Assert.throws(() => {
+ const obj = {};
+ obj.reference = obj;
+ json.clone(obj, nodeCache);
+ }, /JavaScriptError/);
+
+ // array
+ Assert.throws(() => {
+ const array = [];
+ array.push(array);
+ json.clone(array, nodeCache);
+ }, /JavaScriptError/);
+
+ // array in object
+ Assert.throws(() => {
+ const array = [];
+ array.push(array);
+ json.clone({ array }, nodeCache);
+ }, /JavaScriptError/);
+
+ // object in array
+ Assert.throws(() => {
+ const obj = {};
+ obj.reference = obj;
+ json.clone([obj], nodeCache);
+ }, /JavaScriptError/);
+
+ run_next_test();
+});
+
+add_test(function test_deserialize_generalTypes() {
+ // null
+ equal(json.deserialize(undefined, nodeCache, win), undefined);
+ equal(json.deserialize(null, nodeCache, win), null);
+
+ // primitives
+ equal(json.deserialize(true, nodeCache, win), true);
+ equal(json.deserialize(42, nodeCache, win), 42);
+ equal(json.deserialize("foo", nodeCache, win), "foo");
+
+ nodeCache.clear({ all: true });
+ run_next_test();
+});
+
+add_test(function test_deserialize_WebElements() {
+ // Fails to resolve for unknown elements
+ const unknownWebElId = { [WebElement.Identifier]: "foo" };
+ Assert.throws(() => {
+ json.deserialize(unknownWebElId, nodeCache, win);
+ }, /NoSuchElementError/);
+
+ const domElSharedId = nodeCache.add(domEl);
+ const domWebEl = { [WebElement.Identifier]: domElSharedId };
+
+ // Fails to resolve for missing window reference
+ Assert.throws(() => json.deserialize(domWebEl, nodeCache), /TypeError/);
+
+ // Previously seen element is associated with original web element reference
+ const el = json.deserialize(domWebEl, nodeCache, win);
+ deepEqual(el, domEl);
+ deepEqual(el, nodeCache.resolve(domElSharedId));
+
+ // Fails with stale element reference for removed element
+ let imgEl = browser.document.createElement("img");
+ const imgElSharedId = nodeCache.add(imgEl);
+ const imgWebEl = { [WebElement.Identifier]: imgElSharedId };
+
+ // Delete element and force a garbage collection
+ imgEl = null;
+
+ MemoryReporter.minimizeMemoryUsage(() => {
+ Assert.throws(
+ () => json.deserialize(imgWebEl, nodeCache, win),
+ /StaleElementReferenceError:/
+ );
+
+ nodeCache.clear({ all: true });
+ run_next_test();
+ });
+});
+
+add_test(function test_deserialize_Sequences() {
+ const domElSharedId = nodeCache.add(domEl);
+
+ const input = [
+ null,
+ true,
+ [42],
+ { [WebElement.Identifier]: domElSharedId },
+ { bar: "baz" },
+ ];
+
+ const actual = json.deserialize(input, nodeCache, win);
+
+ equal(actual[0], null);
+ equal(actual[1], true);
+ deepEqual(actual[2], [42]);
+ deepEqual(actual[3], domEl);
+ deepEqual(actual[4], { bar: "baz" });
+
+ nodeCache.clear({ all: true });
+ run_next_test();
+});
+
+add_test(function test_deserialize_objects() {
+ const domElSharedId = nodeCache.add(domEl);
+
+ const input = {
+ null: null,
+ boolean: true,
+ array: [42],
+ element: { [WebElement.Identifier]: domElSharedId },
+ object: { bar: "baz" },
+ };
+
+ const actual = json.deserialize(input, nodeCache, win);
+
+ equal(actual.null, null);
+ equal(actual.boolean, true);
+ deepEqual(actual.array, [42]);
+ deepEqual(actual.element, domEl);
+ deepEqual(actual.object, { bar: "baz" });
+
+ nodeCache.clear({ all: true });
+ run_next_test();
+});
diff --git a/remote/marionette/test/xpcshell/test_message.js b/remote/marionette/test/xpcshell/test_message.js
new file mode 100644
index 0000000000..5cf717d295
--- /dev/null
+++ b/remote/marionette/test/xpcshell/test_message.js
@@ -0,0 +1,279 @@
+/* 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 { Command, Message, Response } = ChromeUtils.importESModule(
+ "chrome://remote/content/marionette/message.sys.mjs"
+);
+
+add_test(function test_Message_Origin() {
+ equal(0, Message.Origin.Client);
+ equal(1, Message.Origin.Server);
+
+ run_next_test();
+});
+
+add_test(function test_Message_fromPacket() {
+ let cmd = new Command(4, "foo");
+ let resp = new Response(5, () => {});
+ resp.error = "foo";
+
+ ok(Message.fromPacket(cmd.toPacket()) instanceof Command);
+ ok(Message.fromPacket(resp.toPacket()) instanceof Response);
+ Assert.throws(
+ () => Message.fromPacket([3, 4, 5, 6]),
+ /Unrecognised message type in packet/
+ );
+
+ run_next_test();
+});
+
+add_test(function test_Command() {
+ let cmd = new Command(42, "foo", { bar: "baz" });
+ equal(42, cmd.id);
+ equal("foo", cmd.name);
+ deepEqual({ bar: "baz" }, cmd.parameters);
+ equal(null, cmd.onerror);
+ equal(null, cmd.onresult);
+ equal(Message.Origin.Client, cmd.origin);
+ equal(false, cmd.sent);
+
+ run_next_test();
+});
+
+add_test(function test_Command_onresponse() {
+ let onerrorOk = false;
+ let onresultOk = false;
+
+ let cmd = new Command(7, "foo");
+ cmd.onerror = () => (onerrorOk = true);
+ cmd.onresult = () => (onresultOk = true);
+
+ let errorResp = new Response(8, () => {});
+ errorResp.error = new error.WebDriverError("foo");
+
+ let bodyResp = new Response(9, () => {});
+ bodyResp.body = "bar";
+
+ cmd.onresponse(errorResp);
+ equal(true, onerrorOk);
+ equal(false, onresultOk);
+
+ cmd.onresponse(bodyResp);
+ equal(true, onresultOk);
+
+ run_next_test();
+});
+
+add_test(function test_Command_ctor() {
+ let cmd = new Command(42, "bar", { bar: "baz" });
+ let msg = cmd.toPacket();
+
+ equal(Command.Type, msg[0]);
+ equal(cmd.id, msg[1]);
+ equal(cmd.name, msg[2]);
+ equal(cmd.parameters, msg[3]);
+
+ run_next_test();
+});
+
+add_test(function test_Command_toString() {
+ let cmd = new Command(42, "foo", { bar: "baz" });
+ equal(JSON.stringify(cmd.toPacket()), cmd.toString());
+
+ run_next_test();
+});
+
+add_test(function test_Command_fromPacket() {
+ let c1 = new Command(42, "foo", { bar: "baz" });
+
+ let msg = c1.toPacket();
+ let c2 = Command.fromPacket(msg);
+
+ equal(c1.id, c2.id);
+ equal(c1.name, c2.name);
+ equal(c1.parameters, c2.parameters);
+
+ Assert.throws(
+ () => Command.fromPacket([null, 2, "foo", {}]),
+ /InvalidArgumentError/
+ );
+ Assert.throws(
+ () => Command.fromPacket([1, 2, "foo", {}]),
+ /InvalidArgumentError/
+ );
+ Assert.throws(
+ () => Command.fromPacket([0, null, "foo", {}]),
+ /InvalidArgumentError/
+ );
+ Assert.throws(
+ () => Command.fromPacket([0, 2, null, {}]),
+ /InvalidArgumentError/
+ );
+ Assert.throws(
+ () => Command.fromPacket([0, 2, "foo", false]),
+ /InvalidArgumentError/
+ );
+
+ let nullParams = Command.fromPacket([0, 2, "foo", null]);
+ equal(
+ "[object Object]",
+ Object.prototype.toString.call(nullParams.parameters)
+ );
+
+ run_next_test();
+});
+
+add_test(function test_Command_Type() {
+ equal(0, Command.Type);
+ run_next_test();
+});
+
+add_test(function test_Response_ctor() {
+ let handler = () => run_next_test();
+
+ let resp = new Response(42, handler);
+ equal(42, resp.id);
+ equal(null, resp.error);
+ ok("origin" in resp);
+ equal(Message.Origin.Server, resp.origin);
+ equal(false, resp.sent);
+ equal(handler, resp.respHandler_);
+
+ run_next_test();
+});
+
+add_test(function test_Response_sendConditionally() {
+ let fired = false;
+ let resp = new Response(42, () => (fired = true));
+ resp.sendConditionally(() => false);
+ equal(false, resp.sent);
+ equal(false, fired);
+ resp.sendConditionally(() => true);
+ equal(true, resp.sent);
+ equal(true, fired);
+
+ run_next_test();
+});
+
+add_test(function test_Response_send() {
+ let fired = false;
+ let resp = new Response(42, () => (fired = true));
+ resp.send();
+ equal(true, resp.sent);
+ equal(true, fired);
+
+ run_next_test();
+});
+
+add_test(function test_Response_sendError_sent() {
+ let resp = new Response(42, r => equal(false, r.sent));
+ resp.sendError(new error.WebDriverError());
+ ok(resp.sent);
+ Assert.throws(() => resp.send(), /already been sent/);
+
+ run_next_test();
+});
+
+add_test(function test_Response_sendError_body() {
+ let resp = new Response(42, r => equal(null, r.body));
+ resp.sendError(new error.WebDriverError());
+
+ run_next_test();
+});
+
+add_test(function test_Response_sendError_errorSerialisation() {
+ let err1 = new error.WebDriverError();
+ let resp1 = new Response(42);
+ resp1.sendError(err1);
+ equal(err1.status, resp1.error.error);
+ deepEqual(err1.toJSON(), resp1.error);
+
+ let err2 = new error.InvalidArgumentError();
+ let resp2 = new Response(43);
+ resp2.sendError(err2);
+ equal(err2.status, resp2.error.error);
+ deepEqual(err2.toJSON(), resp2.error);
+
+ run_next_test();
+});
+
+add_test(function test_Response_sendError_wrapInternalError() {
+ let err = new ReferenceError("foo");
+
+ // errors that originate from JavaScript (i.e. Marionette implementation
+ // issues) should be converted to UnknownError for transport
+ let resp = new Response(42, r => {
+ equal("unknown error", r.error.error);
+ equal(false, resp.sent);
+ });
+
+ // they should also throw after being sent
+ Assert.throws(() => resp.sendError(err), /foo/);
+ equal(true, resp.sent);
+
+ run_next_test();
+});
+
+add_test(function test_Response_toPacket() {
+ let resp = new Response(42, () => {});
+ let msg = resp.toPacket();
+
+ equal(Response.Type, msg[0]);
+ equal(resp.id, msg[1]);
+ equal(resp.error, msg[2]);
+ equal(resp.body, msg[3]);
+
+ run_next_test();
+});
+
+add_test(function test_Response_toString() {
+ let resp = new Response(42, () => {});
+ resp.error = "foo";
+ resp.body = "bar";
+
+ equal(JSON.stringify(resp.toPacket()), resp.toString());
+
+ run_next_test();
+});
+
+add_test(function test_Response_fromPacket() {
+ let r1 = new Response(42, () => {});
+ r1.error = "foo";
+ r1.body = "bar";
+
+ let msg = r1.toPacket();
+ let r2 = Response.fromPacket(msg);
+
+ equal(r1.id, r2.id);
+ equal(r1.error, r2.error);
+ equal(r1.body, r2.body);
+
+ Assert.throws(
+ () => Response.fromPacket([null, 2, "foo", {}]),
+ /InvalidArgumentError/
+ );
+ Assert.throws(
+ () => Response.fromPacket([0, 2, "foo", {}]),
+ /InvalidArgumentError/
+ );
+ Assert.throws(
+ () => Response.fromPacket([1, null, "foo", {}]),
+ /InvalidArgumentError/
+ );
+ Assert.throws(
+ () => Response.fromPacket([1, 2, null, {}]),
+ /InvalidArgumentError/
+ );
+ Response.fromPacket([1, 2, "foo", null]);
+
+ run_next_test();
+});
+
+add_test(function test_Response_Type() {
+ equal(1, Response.Type);
+ run_next_test();
+});
diff --git a/remote/marionette/test/xpcshell/test_modal.js b/remote/marionette/test/xpcshell/test_modal.js
new file mode 100644
index 0000000000..ac1f020353
--- /dev/null
+++ b/remote/marionette/test/xpcshell/test_modal.js
@@ -0,0 +1,119 @@
+/* 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 { modal } = ChromeUtils.importESModule(
+ "chrome://remote/content/marionette/modal.sys.mjs"
+);
+
+const chromeWindow = {};
+
+const mockModalDialog = {
+ docShell: {
+ chromeEventHandler: null,
+ },
+ opener: {
+ ownerGlobal: chromeWindow,
+ },
+ Dialog: {
+ args: {
+ modalType: Services.prompt.MODAL_TYPE_WINDOW,
+ },
+ },
+};
+
+const mockCurBrowser = {
+ window: chromeWindow,
+};
+
+add_test(function test_addCallback() {
+ let observer = new modal.DialogObserver(() => mockCurBrowser);
+ let cb1 = () => true;
+ let cb2 = () => false;
+
+ equal(observer.callbacks.size, 0);
+ observer.add(cb1);
+ equal(observer.callbacks.size, 1);
+ observer.add(cb1);
+ equal(observer.callbacks.size, 1);
+ observer.add(cb2);
+ equal(observer.callbacks.size, 2);
+
+ run_next_test();
+});
+
+add_test(function test_removeCallback() {
+ let observer = new modal.DialogObserver(() => mockCurBrowser);
+ let cb1 = () => true;
+ let cb2 = () => false;
+
+ equal(observer.callbacks.size, 0);
+ observer.add(cb1);
+ observer.add(cb2);
+
+ equal(observer.callbacks.size, 2);
+ observer.remove(cb1);
+ equal(observer.callbacks.size, 1);
+ observer.remove(cb1);
+ equal(observer.callbacks.size, 1);
+ observer.remove(cb2);
+ equal(observer.callbacks.size, 0);
+
+ run_next_test();
+});
+
+add_test(function test_registerDialogClosedEventHandler() {
+ let observer = new modal.DialogObserver(() => mockCurBrowser);
+ let mockChromeWindow = {
+ addEventListener(event, cb) {
+ equal(
+ event,
+ "DOMModalDialogClosed",
+ "registered event for closing modal"
+ );
+ equal(cb, observer, "set itself as handler");
+ run_next_test();
+ },
+ };
+
+ observer.observe(mockChromeWindow, "domwindowopened");
+});
+
+add_test(function test_handleCallbackOpenModalDialog() {
+ let observer = new modal.DialogObserver(() => mockCurBrowser);
+
+ observer.add((action, dialog) => {
+ equal(action, modal.ACTION_OPENED, "'opened' action has been passed");
+ equal(dialog, mockModalDialog, "dialog has been passed");
+ run_next_test();
+ });
+ observer.observe(mockModalDialog, "common-dialog-loaded");
+});
+
+add_test(function test_handleCallbackCloseModalDialog() {
+ let observer = new modal.DialogObserver(() => mockCurBrowser);
+
+ observer.add((action, dialog) => {
+ equal(action, modal.ACTION_CLOSED, "'closed' action has been passed");
+ equal(dialog, mockModalDialog, "dialog has been passed");
+ run_next_test();
+ });
+ observer.handleEvent({
+ type: "DOMModalDialogClosed",
+ target: mockModalDialog,
+ });
+});
+
+add_test(function test_dialogClosed() {
+ let observer = new modal.DialogObserver(() => mockCurBrowser);
+
+ observer.dialogClosed().then(() => {
+ run_next_test();
+ });
+ observer.handleEvent({
+ type: "DOMModalDialogClosed",
+ target: mockModalDialog,
+ });
+});
diff --git a/remote/marionette/test/xpcshell/test_navigate.js b/remote/marionette/test/xpcshell/test_navigate.js
new file mode 100644
index 0000000000..0bb6573d21
--- /dev/null
+++ b/remote/marionette/test/xpcshell/test_navigate.js
@@ -0,0 +1,96 @@
+/* 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 { navigate } = ChromeUtils.importESModule(
+ "chrome://remote/content/marionette/navigate.sys.mjs"
+);
+
+const mockTopContext = {
+ get children() {
+ return [mockNestedContext];
+ },
+ id: 7,
+ get top() {
+ return this;
+ },
+};
+
+const mockNestedContext = {
+ id: 8,
+ parent: mockTopContext,
+ top: mockTopContext,
+};
+
+add_test(function test_isLoadEventExpectedForCurrent() {
+ Assert.throws(
+ () => navigate.isLoadEventExpected(undefined),
+ /Expected at least one URL/
+ );
+
+ ok(navigate.isLoadEventExpected(new URL("http://a/")));
+
+ run_next_test();
+});
+
+add_test(function test_isLoadEventExpectedForFuture() {
+ const data = [
+ { current: "http://a/", future: undefined, expected: true },
+ { current: "http://a/", future: "http://a/", expected: true },
+ { current: "http://a/", future: "http://a/#", expected: true },
+ { current: "http://a/#", future: "http://a/", expected: true },
+ { current: "http://a/#a", future: "http://a/#A", expected: true },
+ { current: "http://a/#a", future: "http://a/#a", expected: false },
+ { current: "http://a/", future: "javascript:whatever", expected: false },
+ ];
+
+ for (const entry of data) {
+ const current = new URL(entry.current);
+ const future = entry.future ? new URL(entry.future) : undefined;
+ equal(navigate.isLoadEventExpected(current, { future }), entry.expected);
+ }
+
+ run_next_test();
+});
+
+add_test(function test_isLoadEventExpectedForTarget() {
+ for (const target of ["_parent", "_top"]) {
+ Assert.throws(
+ () => navigate.isLoadEventExpected(new URL("http://a"), { target }),
+ /Expected browsingContext when target is _parent or _top/
+ );
+ }
+
+ const data = [
+ { cur: "http://a/", target: "", expected: true },
+ { cur: "http://a/", target: "_blank", expected: false },
+ { cur: "http://a/", target: "_parent", bc: mockTopContext, expected: true },
+ {
+ cur: "http://a/",
+ target: "_parent",
+ bc: mockNestedContext,
+ expected: false,
+ },
+ { cur: "http://a/", target: "_self", expected: true },
+ { cur: "http://a/", target: "_top", bc: mockTopContext, expected: true },
+ {
+ cur: "http://a/",
+ target: "_top",
+ bc: mockNestedContext,
+ expected: false,
+ },
+ ];
+
+ for (const entry of data) {
+ const current = entry.cur ? new URL(entry.cur) : undefined;
+ equal(
+ navigate.isLoadEventExpected(current, {
+ target: entry.target,
+ browsingContext: entry.bc,
+ }),
+ entry.expected
+ );
+ }
+
+ run_next_test();
+});
diff --git a/remote/marionette/test/xpcshell/test_prefs.js b/remote/marionette/test/xpcshell/test_prefs.js
new file mode 100644
index 0000000000..85d1875e99
--- /dev/null
+++ b/remote/marionette/test/xpcshell/test_prefs.js
@@ -0,0 +1,115 @@
+/* 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 {
+ Branch,
+ EnvironmentPrefs,
+ MarionettePrefs,
+} = ChromeUtils.importESModule(
+ "chrome://remote/content/marionette/prefs.sys.mjs"
+);
+
+function reset() {
+ Services.prefs.setBoolPref("test.bool", false);
+ Services.prefs.setStringPref("test.string", "foo");
+ Services.prefs.setIntPref("test.int", 777);
+}
+
+// Give us something to work with:
+reset();
+
+add_test(function test_Branch_get_root() {
+ let root = new Branch(null);
+ equal(false, root.get("test.bool"));
+ equal("foo", root.get("test.string"));
+ equal(777, root.get("test.int"));
+ Assert.throws(() => root.get("doesnotexist"), /TypeError/);
+
+ run_next_test();
+});
+
+add_test(function test_Branch_get_branch() {
+ let test = new Branch("test.");
+ equal(false, test.get("bool"));
+ equal("foo", test.get("string"));
+ equal(777, test.get("int"));
+ Assert.throws(() => test.get("doesnotexist"), /TypeError/);
+
+ run_next_test();
+});
+
+add_test(function test_Branch_set_root() {
+ let root = new Branch(null);
+
+ try {
+ root.set("test.string", "bar");
+ root.set("test.in", 777);
+ root.set("test.bool", true);
+
+ equal("bar", Services.prefs.getStringPref("test.string"));
+ equal(777, Services.prefs.getIntPref("test.int"));
+ equal(true, Services.prefs.getBoolPref("test.bool"));
+ } finally {
+ reset();
+ }
+
+ run_next_test();
+});
+
+add_test(function test_Branch_set_branch() {
+ let test = new Branch("test.");
+
+ try {
+ test.set("string", "bar");
+ test.set("int", 888);
+ test.set("bool", true);
+
+ equal("bar", Services.prefs.getStringPref("test.string"));
+ equal(888, Services.prefs.getIntPref("test.int"));
+ equal(true, Services.prefs.getBoolPref("test.bool"));
+ } finally {
+ reset();
+ }
+
+ run_next_test();
+});
+
+add_test(function test_EnvironmentPrefs_from() {
+ let prefsTable = {
+ "test.bool": true,
+ "test.int": 888,
+ "test.string": "bar",
+ };
+ Services.env.set("FOO", JSON.stringify(prefsTable));
+
+ try {
+ for (let [key, value] of EnvironmentPrefs.from("FOO")) {
+ equal(prefsTable[key], value);
+ }
+ } finally {
+ Services.env.set("FOO", null);
+ }
+
+ run_next_test();
+});
+
+add_test(function test_MarionettePrefs_getters() {
+ equal(false, MarionettePrefs.clickToStart);
+ equal(2828, MarionettePrefs.port);
+
+ run_next_test();
+});
+
+add_test(function test_MarionettePrefs_setters() {
+ try {
+ MarionettePrefs.port = 777;
+ equal(777, MarionettePrefs.port);
+ } finally {
+ Services.prefs.clearUserPref("marionette.port");
+ }
+
+ run_next_test();
+});
diff --git a/remote/marionette/test/xpcshell/test_sync.js b/remote/marionette/test/xpcshell/test_sync.js
new file mode 100644
index 0000000000..e074327a9b
--- /dev/null
+++ b/remote/marionette/test/xpcshell/test_sync.js
@@ -0,0 +1,400 @@
+/* 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 {
+ DebounceCallback,
+ IdlePromise,
+ PollPromise,
+ Sleep,
+ TimedPromise,
+ waitForMessage,
+ waitForObserverTopic,
+} = ChromeUtils.importESModule(
+ "chrome://remote/content/marionette/sync.sys.mjs"
+);
+
+/**
+ * Mimic a message manager for sending messages.
+ */
+class MessageManager {
+ constructor() {
+ this.func = null;
+ this.message = null;
+ }
+
+ addMessageListener(message, func) {
+ this.func = func;
+ this.message = message;
+ }
+
+ removeMessageListener(message) {
+ this.func = null;
+ this.message = null;
+ }
+
+ send(message, data) {
+ if (this.func) {
+ this.func({
+ data,
+ message,
+ target: this,
+ });
+ }
+ }
+}
+
+/**
+ * Mimics nsITimer, but instead of using a system clock you can
+ * preprogram it to invoke the callback after a given number of ticks.
+ */
+class MockTimer {
+ constructor(ticksBeforeFiring) {
+ this.goal = ticksBeforeFiring;
+ this.ticks = 0;
+ this.cancelled = false;
+ }
+
+ initWithCallback(cb, timeout, type) {
+ this.ticks++;
+ if (this.ticks >= this.goal) {
+ cb();
+ }
+ }
+
+ cancel() {
+ this.cancelled = true;
+ }
+}
+
+add_test(function test_executeSoon_callback() {
+ // executeSoon() is already defined for xpcshell in head.js. As such import
+ // our implementation into a custom namespace.
+ let sync = ChromeUtils.importESModule(
+ "chrome://remote/content/marionette/sync.sys.mjs"
+ );
+
+ for (let func of ["foo", null, true, [], {}]) {
+ Assert.throws(() => sync.executeSoon(func), /TypeError/);
+ }
+
+ let a;
+ sync.executeSoon(() => {
+ a = 1;
+ });
+ executeSoon(() => equal(1, a));
+
+ run_next_test();
+});
+
+add_test(function test_PollPromise_funcTypes() {
+ for (let type of ["foo", 42, null, undefined, true, [], {}]) {
+ Assert.throws(() => new PollPromise(type), /TypeError/);
+ }
+ new PollPromise(() => {});
+ new PollPromise(function() {});
+
+ run_next_test();
+});
+
+add_test(function test_PollPromise_timeoutTypes() {
+ for (let timeout of ["foo", true, [], {}]) {
+ Assert.throws(() => new PollPromise(() => {}, { timeout }), /TypeError/);
+ }
+ for (let timeout of [1.2, -1]) {
+ Assert.throws(() => new PollPromise(() => {}, { timeout }), /RangeError/);
+ }
+ for (let timeout of [null, undefined, 42]) {
+ new PollPromise(resolve => resolve(1), { timeout });
+ }
+
+ run_next_test();
+});
+
+add_test(function test_PollPromise_intervalTypes() {
+ for (let interval of ["foo", null, true, [], {}]) {
+ Assert.throws(() => new PollPromise(() => {}, { interval }), /TypeError/);
+ }
+ for (let interval of [1.2, -1]) {
+ Assert.throws(() => new PollPromise(() => {}, { interval }), /RangeError/);
+ }
+ new PollPromise(() => {}, { interval: 42 });
+
+ run_next_test();
+});
+
+add_task(async function test_PollPromise_retvalTypes() {
+ for (let typ of [true, false, "foo", 42, [], {}]) {
+ strictEqual(typ, await new PollPromise(resolve => resolve(typ)));
+ }
+});
+
+add_task(async function test_PollPromise_rethrowError() {
+ let nevals = 0;
+ let err;
+ try {
+ await PollPromise(() => {
+ ++nevals;
+ throw new Error();
+ });
+ } catch (e) {
+ err = e;
+ }
+ equal(1, nevals);
+ ok(err instanceof Error);
+});
+
+add_task(async function test_PollPromise_noTimeout() {
+ let nevals = 0;
+ await new PollPromise((resolve, reject) => {
+ ++nevals;
+ nevals < 100 ? reject() : resolve();
+ });
+ equal(100, nevals);
+});
+
+add_task(async function test_PollPromise_zeroTimeout() {
+ // run at least once when timeout is 0
+ let nevals = 0;
+ let start = new Date().getTime();
+ await new PollPromise(
+ (resolve, reject) => {
+ ++nevals;
+ reject();
+ },
+ { timeout: 0 }
+ );
+ let end = new Date().getTime();
+ equal(1, nevals);
+ less(end - start, 500);
+});
+
+add_task(async function test_PollPromise_timeoutElapse() {
+ let nevals = 0;
+ let start = new Date().getTime();
+ await new PollPromise(
+ (resolve, reject) => {
+ ++nevals;
+ reject();
+ },
+ { timeout: 100 }
+ );
+ let end = new Date().getTime();
+ lessOrEqual(nevals, 11);
+ greaterOrEqual(end - start, 100);
+});
+
+add_task(async function test_PollPromise_interval() {
+ let nevals = 0;
+ await new PollPromise(
+ (resolve, reject) => {
+ ++nevals;
+ reject();
+ },
+ { timeout: 100, interval: 100 }
+ );
+ equal(2, nevals);
+});
+
+add_test(function test_TimedPromise_funcTypes() {
+ for (let type of ["foo", 42, null, undefined, true, [], {}]) {
+ Assert.throws(() => new TimedPromise(type), /TypeError/);
+ }
+ new TimedPromise(resolve => resolve());
+ new TimedPromise(function(resolve) {
+ resolve();
+ });
+
+ run_next_test();
+});
+
+add_test(function test_TimedPromise_timeoutTypes() {
+ for (let timeout of ["foo", null, true, [], {}]) {
+ Assert.throws(
+ () => new TimedPromise(resolve => resolve(), { timeout }),
+ /TypeError/
+ );
+ }
+ for (let timeout of [1.2, -1]) {
+ Assert.throws(
+ () => new TimedPromise(resolve => resolve(), { timeout }),
+ /RangeError/
+ );
+ }
+ new TimedPromise(resolve => resolve(), { timeout: 42 });
+
+ run_next_test();
+});
+
+add_test(async function test_TimedPromise_errorMessage() {
+ try {
+ await new TimedPromise(resolve => {}, { timeout: 0 });
+ ok(false, "Expected Timeout error not raised");
+ } catch (e) {
+ ok(
+ e.message.includes("TimedPromise timed out after"),
+ "Expected default error message found"
+ );
+ }
+
+ try {
+ await new TimedPromise(resolve => {}, {
+ errorMessage: "Not found",
+ timeout: 0,
+ });
+ ok(false, "Expected Timeout error not raised");
+ } catch (e) {
+ ok(
+ e.message.includes("Not found after"),
+ "Expected custom error message found"
+ );
+ }
+
+ run_next_test();
+});
+
+add_task(async function test_Sleep() {
+ await Sleep(0);
+ for (let type of ["foo", true, null, undefined]) {
+ Assert.throws(() => new Sleep(type), /TypeError/);
+ }
+ Assert.throws(() => new Sleep(1.2), /RangeError/);
+ Assert.throws(() => new Sleep(-1), /RangeError/);
+});
+
+add_task(async function test_IdlePromise() {
+ let called = false;
+ let win = {
+ requestAnimationFrame(callback) {
+ called = true;
+ callback();
+ },
+ };
+ await IdlePromise(win);
+ ok(called);
+});
+
+add_task(async function test_IdlePromiseAbortWhenWindowClosed() {
+ let win = {
+ closed: true,
+ requestAnimationFrame() {},
+ };
+ await IdlePromise(win);
+});
+
+add_test(function test_DebounceCallback_constructor() {
+ for (let cb of [42, "foo", true, null, undefined, [], {}]) {
+ Assert.throws(() => new DebounceCallback(cb), /TypeError/);
+ }
+ for (let timeout of ["foo", true, [], {}, () => {}]) {
+ Assert.throws(
+ () => new DebounceCallback(() => {}, { timeout }),
+ /TypeError/
+ );
+ }
+ for (let timeout of [-1, 2.3, NaN]) {
+ Assert.throws(
+ () => new DebounceCallback(() => {}, { timeout }),
+ /RangeError/
+ );
+ }
+
+ run_next_test();
+});
+
+add_task(async function test_DebounceCallback_repeatedCallback() {
+ let uniqueEvent = {};
+ let ncalls = 0;
+
+ let cb = ev => {
+ ncalls++;
+ equal(ev, uniqueEvent);
+ };
+ let debouncer = new DebounceCallback(cb);
+ debouncer.timer = new MockTimer(3);
+
+ // flood the debouncer with events,
+ // we only expect the last one to fire
+ debouncer.handleEvent(uniqueEvent);
+ debouncer.handleEvent(uniqueEvent);
+ debouncer.handleEvent(uniqueEvent);
+
+ equal(ncalls, 1);
+ ok(debouncer.timer.cancelled);
+});
+
+add_task(async function test_waitForMessage_messageManagerAndMessageTypes() {
+ let messageManager = new MessageManager();
+
+ for (let manager of ["foo", 42, null, undefined, true, [], {}]) {
+ Assert.throws(() => waitForMessage(manager, "message"), /TypeError/);
+ }
+
+ for (let message of [42, null, undefined, true, [], {}]) {
+ Assert.throws(() => waitForMessage(messageManager, message), /TypeError/);
+ }
+
+ let data = { foo: "bar" };
+ let sent = waitForMessage(messageManager, "message");
+ messageManager.send("message", data);
+ equal(data, await sent);
+});
+
+add_task(async function test_waitForMessage_checkFnTypes() {
+ let messageManager = new MessageManager();
+
+ for (let checkFn of ["foo", 42, true, [], {}]) {
+ Assert.throws(
+ () => waitForMessage(messageManager, "message", { checkFn }),
+ /TypeError/
+ );
+ }
+
+ let data1 = { fo: "bar" };
+ let data2 = { foo: "bar" };
+
+ for (let checkFn of [null, undefined, msg => "foo" in msg.data]) {
+ let expected_data = checkFn == null ? data1 : data2;
+
+ messageManager = new MessageManager();
+ let sent = waitForMessage(messageManager, "message", { checkFn });
+ messageManager.send("message", data1);
+ messageManager.send("message", data2);
+ equal(expected_data, await sent);
+ }
+});
+
+add_task(async function test_waitForObserverTopic_topicTypes() {
+ for (let topic of [42, null, undefined, true, [], {}]) {
+ Assert.throws(() => waitForObserverTopic(topic), /TypeError/);
+ }
+
+ let data = { foo: "bar" };
+ let sent = waitForObserverTopic("message");
+ Services.obs.notifyObservers(this, "message", data);
+ let result = await sent;
+ equal(this, result.subject);
+ equal(data, result.data);
+});
+
+add_task(async function test_waitForObserverTopic_checkFnTypes() {
+ for (let checkFn of ["foo", 42, true, [], {}]) {
+ Assert.throws(
+ () => waitForObserverTopic("message", { checkFn }),
+ /TypeError/
+ );
+ }
+
+ let data1 = { fo: "bar" };
+ let data2 = { foo: "bar" };
+
+ for (let checkFn of [null, undefined, (subject, data) => data == data2]) {
+ let expected_data = checkFn == null ? data1 : data2;
+
+ let sent = waitForObserverTopic("message");
+ Services.obs.notifyObservers(this, "message", data1);
+ Services.obs.notifyObservers(this, "message", data2);
+ let result = await sent;
+ equal(expected_data, result.data);
+ }
+});
diff --git a/remote/marionette/test/xpcshell/xpcshell.ini b/remote/marionette/test/xpcshell/xpcshell.ini
new file mode 100644
index 0000000000..94ec3f82fa
--- /dev/null
+++ b/remote/marionette/test/xpcshell/xpcshell.ini
@@ -0,0 +1,20 @@
+# 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
+skip-if = appname == "thunderbird"
+
+[test_action.js]
+[test_actors.js]
+[test_browser.js]
+[test_cookie.js]
+[test_dom.js]
+[test_element.js]
+[test_json.js]
+[test_message.js]
+[test_modal.js]
+[test_navigate.js]
+[test_prefs.js]
+[test_sync.js]
diff --git a/remote/marionette/transport.sys.mjs b/remote/marionette/transport.sys.mjs
new file mode 100644
index 0000000000..b3bb0b22d6
--- /dev/null
+++ b/remote/marionette/transport.sys.mjs
@@ -0,0 +1,529 @@
+/* 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, {
+ EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs",
+
+ BulkPacket: "chrome://remote/content/marionette/packets.sys.mjs",
+ executeSoon: "chrome://remote/content/marionette/sync.sys.mjs",
+ JSONPacket: "chrome://remote/content/marionette/packets.sys.mjs",
+ Packet: "chrome://remote/content/marionette/packets.sys.mjs",
+ StreamUtils: "chrome://remote/content/marionette/stream-utils.sys.mjs",
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "ScriptableInputStream", () => {
+ return Components.Constructor(
+ "@mozilla.org/scriptableinputstream;1",
+ "nsIScriptableInputStream",
+ "init"
+ );
+});
+
+const flags = { wantVerbose: false, wantLogging: false };
+
+const dumpv = flags.wantVerbose
+ ? function(msg) {
+ dump(msg + "\n");
+ }
+ : function() {};
+
+const PACKET_HEADER_MAX = 200;
+
+/**
+ * An adapter that handles data transfers between the debugger client
+ * and server. It can work with both nsIPipe and nsIServerSocket
+ * transports so long as the properly created input and output streams
+ * are specified. (However, for intra-process connections,
+ * LocalDebuggerTransport, below, is more efficient than using an nsIPipe
+ * pair with DebuggerTransport.)
+ *
+ * @param {nsIAsyncInputStream} input
+ * The input stream.
+ * @param {nsIAsyncOutputStream} output
+ * The output stream.
+ *
+ * Given a DebuggerTransport instance dt:
+ * 1) Set dt.hooks to a packet handler object (described below).
+ * 2) Call dt.ready() to begin watching for input packets.
+ * 3) Call dt.send() / dt.startBulkSend() to send packets.
+ * 4) Call dt.close() to close the connection, and disengage from
+ * the event loop.
+ *
+ * A packet handler is an object with the following methods:
+ *
+ * - onPacket(packet) - called when we have received a complete packet.
+ * |packet| is the parsed form of the packet --- a JavaScript value, not
+ * a JSON-syntax string.
+ *
+ * - onBulkPacket(packet) - called when we have switched to bulk packet
+ * receiving mode. |packet| is an object containing:
+ * * actor: Name of actor that will receive the packet
+ * * type: Name of actor's method that should be called on receipt
+ * * length: Size of the data to be read
+ * * stream: This input stream should only be used directly if you
+ * can ensure that you will read exactly |length| bytes and
+ * will not close the stream when reading is complete
+ * * done: If you use the stream directly (instead of |copyTo|
+ * below), you must signal completion by resolving/rejecting
+ * this deferred. If it's rejected, the transport will
+ * be closed. If an Error is supplied as a rejection value,
+ * it will be logged via |dump|. If you do use |copyTo|,
+ * resolving is taken care of for you when copying completes.
+ * * copyTo: A helper function for getting your data out of the
+ * stream that meets the stream handling requirements above,
+ * and has the following signature:
+ *
+ * @param nsIAsyncOutputStream {output}
+ * The stream to copy to.
+ *
+ * @return {Promise}
+ * The promise is resolved when copying completes or
+ * rejected if any (unexpected) errors occur. This object
+ * also emits "progress" events for each chunk that is
+ * copied. See stream-utils.js.
+ *
+ * - onClosed(reason) - called when the connection is closed. |reason|
+ * is an optional nsresult or object, typically passed when the
+ * transport is closed due to some error in a underlying stream.
+ *
+ * See ./packets.js and the Remote Debugging Protocol specification for
+ * more details on the format of these packets.
+ *
+ * @class
+ */
+export function DebuggerTransport(input, output) {
+ lazy.EventEmitter.decorate(this);
+
+ this._input = input;
+ this._scriptableInput = new lazy.ScriptableInputStream(input);
+ this._output = output;
+
+ // The current incoming (possibly partial) header, which will determine
+ // which type of Packet |_incoming| below will become.
+ this._incomingHeader = "";
+ // The current incoming Packet object
+ this._incoming = null;
+ // A queue of outgoing Packet objects
+ this._outgoing = [];
+
+ this.hooks = null;
+ this.active = false;
+
+ this._incomingEnabled = true;
+ this._outgoingEnabled = true;
+
+ this.close = this.close.bind(this);
+}
+
+DebuggerTransport.prototype = {
+ /**
+ * Transmit an object as a JSON packet.
+ *
+ * This method returns immediately, without waiting for the entire
+ * packet to be transmitted, registering event handlers as needed to
+ * transmit the entire packet. Packets are transmitted in the order they
+ * are passed to this method.
+ */
+ send(object) {
+ this.emit("send", object);
+
+ let packet = new lazy.JSONPacket(this);
+ packet.object = object;
+ this._outgoing.push(packet);
+ this._flushOutgoing();
+ },
+
+ /**
+ * Transmit streaming data via a bulk packet.
+ *
+ * This method initiates the bulk send process by queuing up the header
+ * data. The caller receives eventual access to a stream for writing.
+ *
+ * N.B.: Do *not* attempt to close the stream handed to you, as it
+ * will continue to be used by this transport afterwards. Most users
+ * should instead use the provided |copyFrom| function instead.
+ *
+ * @param {Object} header
+ * This is modeled after the format of JSON packets above, but does
+ * not actually contain the data, but is instead just a routing
+ * header:
+ *
+ * - actor: Name of actor that will receive the packet
+ * - type: Name of actor's method that should be called on receipt
+ * - length: Size of the data to be sent
+ *
+ * @return {Promise}
+ * The promise will be resolved when you are allowed to write to
+ * the stream with an object containing:
+ *
+ * - stream: This output stream should only be used directly
+ * if you can ensure that you will write exactly
+ * |length| bytes and will not close the stream when
+ * writing is complete.
+ * - done: If you use the stream directly (instead of
+ * |copyFrom| below), you must signal completion by
+ * resolving/rejecting this deferred. If it's
+ * rejected, the transport will be closed. If an
+ * Error is supplied as a rejection value, it will
+ * be logged via |dump|. If you do use |copyFrom|,
+ * resolving is taken care of for you when copying
+ * completes.
+ * - copyFrom: A helper function for getting your data onto the
+ * stream that meets the stream handling requirements
+ * above, and has the following signature:
+ *
+ * @param {nsIAsyncInputStream} input
+ * The stream to copy from.
+ *
+ * @return {Promise}
+ * The promise is resolved when copying completes
+ * or rejected if any (unexpected) errors occur.
+ * This object also emits "progress" events for
+ * each chunkthat is copied. See stream-utils.js.
+ */
+ startBulkSend(header) {
+ this.emit("startbulksend", header);
+
+ let packet = new lazy.BulkPacket(this);
+ packet.header = header;
+ this._outgoing.push(packet);
+ this._flushOutgoing();
+ return packet.streamReadyForWriting;
+ },
+
+ /**
+ * Close the transport.
+ *
+ * @param {(nsresult|object)=} reason
+ * The status code or error message that corresponds to the reason
+ * for closing the transport (likely because a stream closed
+ * or failed).
+ */
+ close(reason) {
+ this.emit("close", reason);
+
+ this.active = false;
+ this._input.close();
+ this._scriptableInput.close();
+ this._output.close();
+ this._destroyIncoming();
+ this._destroyAllOutgoing();
+ if (this.hooks) {
+ this.hooks.onClosed(reason);
+ this.hooks = null;
+ }
+ if (reason) {
+ dumpv("Transport closed: " + reason);
+ } else {
+ dumpv("Transport closed.");
+ }
+ },
+
+ /**
+ * The currently outgoing packet (at the top of the queue).
+ */
+ get _currentOutgoing() {
+ return this._outgoing[0];
+ },
+
+ /**
+ * Flush data to the outgoing stream. Waits until the output
+ * stream notifies us that it is ready to be written to (via
+ * onOutputStreamReady).
+ */
+ _flushOutgoing() {
+ if (!this._outgoingEnabled || this._outgoing.length === 0) {
+ return;
+ }
+
+ // If the top of the packet queue has nothing more to send, remove it.
+ if (this._currentOutgoing.done) {
+ this._finishCurrentOutgoing();
+ }
+
+ if (this._outgoing.length) {
+ let threadManager = Cc["@mozilla.org/thread-manager;1"].getService();
+ this._output.asyncWait(this, 0, 0, threadManager.currentThread);
+ }
+ },
+
+ /**
+ * Pause this transport's attempts to write to the output stream.
+ * This is used when we've temporarily handed off our output stream for
+ * writing bulk data.
+ */
+ pauseOutgoing() {
+ this._outgoingEnabled = false;
+ },
+
+ /**
+ * Resume this transport's attempts to write to the output stream.
+ */
+ resumeOutgoing() {
+ this._outgoingEnabled = true;
+ this._flushOutgoing();
+ },
+
+ // nsIOutputStreamCallback
+ /**
+ * This is called when the output stream is ready for more data to
+ * be written. The current outgoing packet will attempt to write some
+ * amount of data, but may not complete.
+ */
+ onOutputStreamReady(stream) {
+ if (!this._outgoingEnabled || this._outgoing.length === 0) {
+ return;
+ }
+
+ try {
+ this._currentOutgoing.write(stream);
+ } catch (e) {
+ if (e.result != Cr.NS_BASE_STREAM_WOULD_BLOCK) {
+ this.close(e.result);
+ return;
+ }
+ throw e;
+ }
+
+ this._flushOutgoing();
+ },
+
+ /**
+ * Remove the current outgoing packet from the queue upon completion.
+ */
+ _finishCurrentOutgoing() {
+ if (this._currentOutgoing) {
+ this._currentOutgoing.destroy();
+ this._outgoing.shift();
+ }
+ },
+
+ /**
+ * Clear the entire outgoing queue.
+ */
+ _destroyAllOutgoing() {
+ for (let packet of this._outgoing) {
+ packet.destroy();
+ }
+ this._outgoing = [];
+ },
+
+ /**
+ * Initialize the input stream for reading. Once this method has been
+ * called, we watch for packets on the input stream, and pass them to
+ * the appropriate handlers via this.hooks.
+ */
+ ready() {
+ this.active = true;
+ this._waitForIncoming();
+ },
+
+ /**
+ * Asks the input stream to notify us (via onInputStreamReady) when it is
+ * ready for reading.
+ */
+ _waitForIncoming() {
+ if (this._incomingEnabled) {
+ let threadManager = Cc["@mozilla.org/thread-manager;1"].getService();
+ this._input.asyncWait(this, 0, 0, threadManager.currentThread);
+ }
+ },
+
+ /**
+ * Pause this transport's attempts to read from the input stream.
+ * This is used when we've temporarily handed off our input stream for
+ * reading bulk data.
+ */
+ pauseIncoming() {
+ this._incomingEnabled = false;
+ },
+
+ /**
+ * Resume this transport's attempts to read from the input stream.
+ */
+ resumeIncoming() {
+ this._incomingEnabled = true;
+ this._flushIncoming();
+ this._waitForIncoming();
+ },
+
+ // nsIInputStreamCallback
+ /**
+ * Called when the stream is either readable or closed.
+ */
+ onInputStreamReady(stream) {
+ try {
+ while (
+ stream.available() &&
+ this._incomingEnabled &&
+ this._processIncoming(stream, stream.available())
+ ) {
+ // Loop until there is nothing more to process
+ }
+ this._waitForIncoming();
+ } catch (e) {
+ if (e.result != Cr.NS_BASE_STREAM_WOULD_BLOCK) {
+ this.close(e.result);
+ } else {
+ throw e;
+ }
+ }
+ },
+
+ /**
+ * Process the incoming data. Will create a new currently incoming
+ * Packet if needed. Tells the incoming Packet to read as much data
+ * as it can, but reading may not complete. The Packet signals that
+ * its data is ready for delivery by calling one of this transport's
+ * _on*Ready methods (see ./packets.js and the _on*Ready methods below).
+ *
+ * @return {boolean}
+ * Whether incoming stream processing should continue for any
+ * remaining data.
+ */
+ _processIncoming(stream, count) {
+ dumpv("Data available: " + count);
+
+ if (!count) {
+ dumpv("Nothing to read, skipping");
+ return false;
+ }
+
+ try {
+ if (!this._incoming) {
+ dumpv("Creating a new packet from incoming");
+
+ if (!this._readHeader(stream)) {
+ // Not enough data to read packet type
+ return false;
+ }
+
+ // Attempt to create a new Packet by trying to parse each possible
+ // header pattern.
+ this._incoming = lazy.Packet.fromHeader(this._incomingHeader, this);
+ if (!this._incoming) {
+ throw new Error(
+ "No packet types for header: " + this._incomingHeader
+ );
+ }
+ }
+
+ if (!this._incoming.done) {
+ // We have an incomplete packet, keep reading it.
+ dumpv("Existing packet incomplete, keep reading");
+ this._incoming.read(stream, this._scriptableInput);
+ }
+ } catch (e) {
+ dump(`Error reading incoming packet: (${e} - ${e.stack})\n`);
+
+ // Now in an invalid state, shut down the transport.
+ this.close();
+ return false;
+ }
+
+ if (!this._incoming.done) {
+ // Still not complete, we'll wait for more data.
+ dumpv("Packet not done, wait for more");
+ return true;
+ }
+
+ // Ready for next packet
+ this._flushIncoming();
+ return true;
+ },
+
+ /**
+ * Read as far as we can into the incoming data, attempting to build
+ * up a complete packet header (which terminates with ":"). We'll only
+ * read up to PACKET_HEADER_MAX characters.
+ *
+ * @return {boolean}
+ * True if we now have a complete header.
+ */
+ _readHeader() {
+ let amountToRead = PACKET_HEADER_MAX - this._incomingHeader.length;
+ this._incomingHeader += lazy.StreamUtils.delimitedRead(
+ this._scriptableInput,
+ ":",
+ amountToRead
+ );
+ if (flags.wantVerbose) {
+ dumpv("Header read: " + this._incomingHeader);
+ }
+
+ if (this._incomingHeader.endsWith(":")) {
+ if (flags.wantVerbose) {
+ dumpv("Found packet header successfully: " + this._incomingHeader);
+ }
+ return true;
+ }
+
+ if (this._incomingHeader.length >= PACKET_HEADER_MAX) {
+ throw new Error("Failed to parse packet header!");
+ }
+
+ // Not enough data yet.
+ return false;
+ },
+
+ /**
+ * If the incoming packet is done, log it as needed and clear the buffer.
+ */
+ _flushIncoming() {
+ if (!this._incoming.done) {
+ return;
+ }
+ if (flags.wantLogging) {
+ dumpv("Got: " + this._incoming);
+ }
+ this._destroyIncoming();
+ },
+
+ /**
+ * Handler triggered by an incoming JSONPacket completing it's |read|
+ * method. Delivers the packet to this.hooks.onPacket.
+ */
+ _onJSONObjectReady(object) {
+ lazy.executeSoon(() => {
+ // Ensure the transport is still alive by the time this runs.
+ if (this.active) {
+ this.emit("packet", object);
+ this.hooks.onPacket(object);
+ }
+ });
+ },
+
+ /**
+ * Handler triggered by an incoming BulkPacket entering the |read|
+ * phase for the stream portion of the packet. Delivers info about the
+ * incoming streaming data to this.hooks.onBulkPacket. See the main
+ * comment on the transport at the top of this file for more details.
+ */
+ _onBulkReadReady(...args) {
+ lazy.executeSoon(() => {
+ // Ensure the transport is still alive by the time this runs.
+ if (this.active) {
+ this.emit("bulkpacket", ...args);
+ this.hooks.onBulkPacket(...args);
+ }
+ });
+ },
+
+ /**
+ * Remove all handlers and references related to the current incoming
+ * packet, either because it is now complete or because the transport
+ * is closing.
+ */
+ _destroyIncoming() {
+ if (this._incoming) {
+ this._incoming.destroy();
+ }
+ this._incomingHeader = "";
+ this._incoming = null;
+ },
+};