diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 09:22:09 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 09:22:09 +0000 |
commit | 43a97878ce14b72f0981164f87f2e35e14151312 (patch) | |
tree | 620249daf56c0258faa40cbdcf9cfba06de2a846 /remote/marionette | |
parent | Initial commit. (diff) | |
download | firefox-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')
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><xul:browser></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><input type=checkbox></tt>, + * <tt><input type=radio></tt>, + * and <tt>>option></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><input></code> or <code><textarea></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><input></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><textarea></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><textarea></code> element. + * + * <li>It is an <code><input></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><option></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><datalist></tt> or <tt><select></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><frame></tt> and <tt><iframe></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><option></tt> element in a <tt><select></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><option></tt> + * element. + * @throws {Error} + * If unable to find <var>el</var>'s parent <tt><select></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><input type=file></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><input type=text></code> or + * <code><textarea></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; + }, +}; |