diff options
Diffstat (limited to '')
57 files changed, 15690 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..c500f2121e --- /dev/null +++ b/remote/marionette/accessibility.sys.mjs @@ -0,0 +1,479 @@ +/* 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", + Log: "chrome://remote/content/shared/Log.sys.mjs", + waitForObserverTopic: "chrome://remote/content/marionette/sync.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => + lazy.Log.get(lazy.Log.TYPES.MARIONETTE) +); + +ChromeUtils.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); +}; + +/** + * Wait for the document accessibility state to be different from STATE_BUSY. + * + * @param {Document} doc + * The document to wait for. + * @returns {Promise} + * A promise which resolves when the document's accessibility state is no + * longer busy. + */ +function waitForDocumentAccessibility(doc) { + const documentAccessible = accessibility.service.getAccessibleFor(doc); + const state = {}; + documentAccessible.getState(state, {}); + if ((state.value & Ci.nsIAccessibleStates.STATE_BUSY) == 0) { + return Promise.resolve(); + } + + // Accessibility for the doc is busy, so wait for the state to change. + return lazy.waitForObserverTopic("accessible-event", { + checkFn: subject => { + // If event type does not match expected type, skip the event. + // If event's accessible does not match expected accessible, + // skip the event. + const event = subject.QueryInterface(Ci.nsIAccessibleEvent); + return ( + event.eventType === Ci.nsIAccessibleEvent.EVENT_STATE_CHANGE && + event.accessible === documentAccessible + ); + }, + }); +} + +/** + * Retrieve the Accessible for the provided element. + * + * @param {Element} element + * The element for which we need to retrieve the accessible. + * + * @returns {nsIAccessible|null} + * The Accessible object corresponding to the provided element or null if + * the accessibility service is not available. + */ +accessibility.getAccessible = async function (element) { + if (!accessibility.service) { + return null; + } + + // First, wait for accessibility to be ready for the element's document. + await waitForDocumentAccessibility(element.ownerDocument); + + const acc = accessibility.service.getAccessibleFor(element); + if (acc) { + return acc; + } + + // The Accessible doesn't exist yet. This can happen because a11y tree + // mutations happen during refresh driver ticks. Stop the refresh driver from + // doing its regular ticks and force two refresh driver ticks: the first to + // let layout update and notify a11y, and the second to let a11y process + // updates. + const windowUtils = element.ownerGlobal.windowUtils; + windowUtils.advanceTimeAndRefresh(0); + windowUtils.advanceTimeAndRefresh(0); + // Go back to normal refresh driver ticks. + windowUtils.restoreNormalRefresh(); + return accessibility.service.getAccessibleFor(element); +}; + +/** + * 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; + } + + /** + * Assert that the element has a corresponding accessible object, and retrieve + * this accessible. Note that if the accessibility.Checks component was + * created in non-strict mode, this helper will not attempt to resolve the + * accessible at all and will simply return null. + * + * @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. + * + * @returns {Promise.<nsIAccessible>} + * Promise with an accessibility object for the given element. + */ + async assertAccessible(element, mustHaveAccessible = false) { + if (!this.strict) { + return null; + } + + const accessible = await accessibility.getAccessible(element); + if (!accessible && mustHaveAccessible) { + this.error("Element does not have an accessible object", element); + } + + return accessible; + } + + /** + * Test if the accessible has a role that supports some arbitrary + * action. + * + * @param {nsIAccessible} accessible + * Accessible object. + * + * @returns {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. + * + * @returns {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. + * + * @returns {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. + * + * @returns {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. + * + * @returns {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. + * + * @returns {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 + * 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/actors/MarionetteCommandsChild.sys.mjs b/remote/marionette/actors/MarionetteCommandsChild.sys.mjs new file mode 100644 index 0000000000..078612da56 --- /dev/null +++ b/remote/marionette/actors/MarionetteCommandsChild.sys.mjs @@ -0,0 +1,595 @@ +/* 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 */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + accessibility: "chrome://remote/content/marionette/accessibility.sys.mjs", + action: "chrome://remote/content/shared/webdriver/Actions.sys.mjs", + atom: "chrome://remote/content/marionette/atom.sys.mjs", + dom: "chrome://remote/content/shared/DOM.sys.mjs", + error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs", + evaluate: "chrome://remote/content/marionette/evaluate.sys.mjs", + interaction: "chrome://remote/content/marionette/interaction.sys.mjs", + json: "chrome://remote/content/marionette/json.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", +}); + +ChromeUtils.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; + } + + 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.browsingContext + ); + + 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:getComputedLabel": + result = await this.getComputedLabel(data); + break; + case "MarionetteCommandsParent:getComputedRole": + result = await this.getComputedRole(data); + 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: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)); + } + + const { seenNodeIds, serializedValue, hasSerializedWindows } = + lazy.json.clone(result, this.#processActor.getNodeCache()); + + // Because in WebDriver classic nodes can only be returned from the same + // browsing context, we only need the seen unique ids as flat array. + return { + seenNodeIds: [...seenNodeIds.values()].flat(), + serializedValue, + hasSerializedWindows, + }; + } 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 {string} options.strategy + * @param {string} options.selector + * @param {object} options.opts + * @param {Element} options.opts.startNode + * + */ + async findElement(options = {}) { + const { strategy, selector, opts } = options; + + opts.all = false; + + const container = { frame: this.document.defaultView }; + return lazy.dom.find(container, strategy, selector, opts); + } + + /** + * Find elements in the current browsing context's document using the + * given search strategy. + * + * @param {object=} options + * @param {string} options.strategy + * @param {string} options.selector + * @param {object} options.opts + * @param {Element} options.opts.startNode + * + */ + async findElements(options = {}) { + const { strategy, selector, opts } = options; + + opts.all = true; + + const container = { frame: this.document.defaultView }; + return lazy.dom.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; + } + + /** + * Return the accessible label for a given element. + */ + async getComputedLabel(options = {}) { + const { elem } = options; + + const accessible = await lazy.accessibility.getAccessible(elem); + if (!accessible) { + return null; + } + + // If name is null (absent), expose the empty string. + if (accessible.name === null) { + return ""; + } + + return accessible.name; + } + + /** + * Return the accessible role for a given element. + */ + async getComputedRole(options = {}) { + const { elem } = options; + + const accessible = await lazy.accessibility.getAccessible(elem); + if (!accessible) { + // If it's not in the a11y tree, it's probably presentational. + return "none"; + } + + return accessible.computedARIARole; + } + + /** + * Get the value of an attribute for the given element. + */ + async getElementAttribute(options = {}) { + const { name, elem } = options; + + if (lazy.dom.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 await lazy.atom.getVisibleText(elem, this.document.defaultView); + } catch (e) { + lazy.logger.warn(`Atom getVisibleText 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. + * + * @returns {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.dom.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.dom.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 } = options; + if (this.actionState === null) { + this.actionState = new lazy.action.State(); + } + let actionChain = lazy.action.Chain.fromJSON(this.actionState, actions); + + await actionChain.dispatch(this.actionState, this.document.defaultView); + // Terminate the current wheel transaction if there is one. Wheel + // transactions should not live longer than a single action chain. + ChromeUtils.endWheelTransaction(); + } + + /** + * 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; + } + await this.actionState.release(this.document.defaultView); + this.actionState = null; + } + + /* + * 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); + } + + /** + * 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..970c927eab --- /dev/null +++ b/remote/marionette/actors/MarionetteCommandsParent.sys.mjs @@ -0,0 +1,436 @@ +/* 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, { + capture: "chrome://remote/content/shared/Capture.sys.mjs", + error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs", + getSeenNodesForBrowsingContext: + "chrome://remote/content/shared/webdriver/Session.sys.mjs", + json: "chrome://remote/content/marionette/json.sys.mjs", + Log: "chrome://remote/content/shared/Log.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => + lazy.Log.get(lazy.Log.TYPES.MARIONETTE) +); + +// Because Marionette supports a single session only we store its id +// globally so that the parent actor can access it. +let webDriverSessionId = null; + +export class MarionetteCommandsParent extends JSWindowActorParent { + #deferredDialogOpened; + + actorCreated() { + this.#deferredDialogOpened = null; + } + + async sendQuery(name, serializedValue) { + const seenNodes = lazy.getSeenNodesForBrowsingContext( + webDriverSessionId, + this.manager.browsingContext + ); + + // return early if a dialog is opened + this.#deferredDialogOpened = Promise.withResolvers(); + let { + error, + seenNodeIds, + serializedValue: serializedResult, + hasSerializedWindows, + } = await Promise.race([ + super.sendQuery(name, serializedValue), + this.#deferredDialogOpened.promise, + ]).finally(() => { + this.#deferredDialogOpened = null; + }); + + if (error) { + const err = lazy.error.WebDriverError.fromJSON(error); + this.#handleError(err, seenNodes); + } + + // Update seen nodes for serialized element and shadow root nodes. + seenNodeIds?.forEach(nodeId => seenNodes.add(nodeId)); + + if (hasSerializedWindows) { + // The serialized data contains WebWindow references that need to be + // converted to unique identifiers. + serializedResult = lazy.json.mapToNavigableIds(serializedResult); + } + + return serializedResult; + } + + /** + * Handle WebDriver error and replace error type if necessary. + * + * @param {WebDriverError} error + * The WebDriver error to handle. + * @param {Set<string>} seenNodes + * List of node ids already seen in this navigable. + * + * @throws {WebDriverError} + * The original or replaced WebDriver error. + */ + #handleError(error, seenNodes) { + // If an element hasn't been found during deserialization check if it + // may be a stale reference. + if ( + error instanceof lazy.error.NoSuchElementError && + error.data.elementId !== undefined && + seenNodes.has(error.data.elementId) + ) { + throw new lazy.error.StaleElementReferenceError(error); + } + + // If a shadow root hasn't been found during deserialization check if it + // may be a detached reference. + if ( + error instanceof lazy.error.NoSuchShadowRootError && + error.data.shadowId !== undefined && + seenNodes.has(error.data.shadowId) + ) { + throw new lazy.error.DetachedShadowRootError(error); + } + + throw error; + } + + notifyDialogOpened() { + if (this.#deferredDialogOpened) { + this.#deferredDialogOpened.resolve({ 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: lazy.json.mapFromNavigableIds(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 getComputedLabel(webEl) { + return this.sendQuery("MarionetteCommandsParent:getComputedLabel", { + elem: webEl, + }); + } + + async getComputedRole(webEl) { + return this.sendQuery("MarionetteCommandsParent:getComputedRole", { + elem: webEl, + }); + } + + 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) { + return this.sendQuery("MarionetteCommandsParent:performActions", { + actions, + }); + } + + async releaseActions() { + return this.sendQuery("MarionetteCommandsParent:releaseActions"); + } + + 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", + ]; + + 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. + * + * @param {string} sessionId + * The id of the current WebDriver session. + */ +export function registerCommandsActor(sessionId) { + 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; + } + } + + webDriverSessionId = sessionId; +} + +export function unregisterCommandsActor() { + webDriverSessionId = null; + + 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..b59b4d00e7 --- /dev/null +++ b/remote/marionette/actors/MarionetteEventsChild.sys.mjs @@ -0,0 +1,71 @@ +/* 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 */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + Log: "chrome://remote/content/shared/Log.sys.mjs", +}); + +ChromeUtils.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.isTraceLevelOrOrMore) { + 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; + } + } +} diff --git a/remote/marionette/actors/MarionetteEventsParent.sys.mjs b/remote/marionette/actors/MarionetteEventsParent.sys.mjs new file mode 100644 index 0000000000..c051fb2b1f --- /dev/null +++ b/remote/marionette/actors/MarionetteEventsParent.sys.mjs @@ -0,0 +1,113 @@ +/* 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, { + EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs", + + Log: "chrome://remote/content/shared/Log.sys.mjs", +}); + +ChromeUtils.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..fd73f88a88 --- /dev/null +++ b/remote/marionette/actors/MarionetteReftestChild.sys.mjs @@ -0,0 +1,239 @@ +/* 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, { + setTimeout: "resource://gre/modules/Timer.sys.mjs", + + Log: "chrome://remote/content/shared/Log.sys.mjs", +}); + +ChromeUtils.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 + * @param {boolean} options.warnOnOverflow + * True if we should check the content fits in the viewport. + * This isn't necessary for print reftests where we will render the full + * size of the paginated content. + * @returns {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 => lazy.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 ( + options.warnOnOverflow && + (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"); + lazy.setTimeout(resolve, 0); + } + }); + if (documentElement.classList.contains("reftest-wait")) { + observer.observe(documentElement, { attributes: true }); + } else { + lazy.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..327806ebbf --- /dev/null +++ b/remote/marionette/actors/MarionetteReftestParent.sys.mjs @@ -0,0 +1,90 @@ +/* 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. + * @param {boolean} warnOnOverflow + * True if we should check the content fits in the viewport. + * This isn't necessary for print reftests where we will render the full + * size of the paginated content. + * @returns {boolean} true if the page is fully loaded with the expected url, + * false otherwise. + */ + async reftestWait(url, useRemote, warnOnOverflow) { + try { + const isCorrectUrl = await this.sendQuery( + "MarionetteReftestParent:reftestWait", + { + url, + useRemote, + warnOnOverflow, + } + ); + + 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..f83671694b --- /dev/null +++ b/remote/marionette/addon.sys.mjs @@ -0,0 +1,142 @@ +/* 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, { + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", + FileUtils: "resource://gre/modules/FileUtils.sys.mjs", + error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs", +}); + +// 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_INCORRECT_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.", + [-6]: "ERROR_UNEXPECTED_ADDON_TYPE: The downloaded add-on had a different type than expected (during an update).", + [-7]: "ERROR_INCORRECT_ID: The addon did not have the expected ID (during an update).", + [-8]: "ERROR_INVALID_DOMAIN: The addon install_origins does not list the 3rd party domain.", + [-9]: "ERROR_UNEXPECTED_ADDON_VERSION: The downloaded add-on had a different version than expected (during an update).", + [-10]: "ERROR_BLOCKLISTED: The add-on is blocklisted.", + [-11]: + "ERROR_INCOMPATIBLE: The add-on is incompatible (w.r.t. the compatibility range).", + [-12]: + "ERROR_UNSUPPORTED_ADDON_TYPE: The add-on type is not supported by the platform.", +}; + +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. + * + * @returns {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. + * + * @returns {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..d065849d43 --- /dev/null +++ b/remote/marionette/atom.sys.mjs @@ -0,0 +1,55 @@ +// 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. + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + evaluate: "chrome://remote/content/marionette/evaluate.sys.mjs", + sandbox: "chrome://remote/content/marionette/evaluate.sys.mjs", +}); + +/** @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: bd5cbe5b3a3e60b5970d8168474dd69a996c392c +const ATOMS = { + getVisibleText: "function(){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}\nfunction 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\";\nelse 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)}}\nfunction 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};/*\n\n The MIT License\n\n Copyright (c) 2007 Cybozu Labs, Inc.\n Copyright (c) 2012 Google Inc.\n\n Permission is hereby granted, free of charge, to any person obtaining a copy\n of this software and associated documentation files (the \"Software\"), to\n deal in the Software without restriction, including without limitation the\n rights to use, copy, modify, merge, publish, distribute, sublicense, and/or\n sell copies of the Software, and to permit persons to whom the Software is\n furnished to do so, subject to the following conditions:\n\n The above copyright notice and this permission notice shall be included in\n all copies or substantial portions of the Software.\n\n THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING\n FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS\n IN THE SOFTWARE.\n*/\nfunction 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,\nb,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,\nfunction(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};\nfunction 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;\na:{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={};\nfunction 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;\nNa=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}\nfunction $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();\nc.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}\nfunction 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}\nfunction 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)}\nfunction 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}\nfunction 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}\nfunction 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}\nfunction 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++}\nE.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);\nfunction 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\"==\ntypeof 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={};\nfunction 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)});\nP(\">\",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}))}\nm(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={};\nfunction 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);\nQ(\"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);\nQ(\"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);\nQ(\"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);\nQ(\"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);\nQ(\"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};\nG.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};\nF.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\"};\nfunction 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};\nIb.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}\nfunction 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}\nPb.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);\nR.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)};\nR.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}\nS(\"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);\nvar 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);\nvar 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);\nvar 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);\nS(\"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);}\nfunction 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)}\nfunction 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);\nc=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)}\nfunction 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(\"(\"==\ny(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,\ne,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)}}\nfunction 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=\nH(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||\n0>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)}\nfunction 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\",\ndarkgrey:\"#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\",\nghostwhite:\"#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\",\nlightseagreen:\"#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\",\nmoccasin:\"#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\",\nseashell:\"#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\";\nV[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}}();\nfunction 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]||\nnull}}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);}}\nfunction 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};\nX.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}\nfunction 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=\nNumber(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}\nfunction 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}\nfunction 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\")}),\n!!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}\nfunction 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.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,\nb)}var Z=\"hidden\";\nfunction 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}\nfunction 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\"!=\nn.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\"}\nfunction 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);\nreturn 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)}}\nfunction 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)}\nfunction Tc(a){return a.replace(/^[^\\S\\xa0]+|[^\\S\\xa0]+$/g,\"\")}\nfunction 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||\nsa(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(\" \");\nfunction 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(\" \",\n0)&&(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}\nfunction 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?(f=W(a,\"CONTENT\")?a.getDistributedNodes():a.assignedNodes(),p(0<f.length?f:a.childNodes,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)}\nfunction $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);}\n", + isElementDisplayed: "function(){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}\nfunction 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\";\nelse 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)}}\nfunction 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};/*\n\n The MIT License\n\n Copyright (c) 2007 Cybozu Labs, Inc.\n Copyright (c) 2012 Google Inc.\n\n Permission is hereby granted, free of charge, to any person obtaining a copy\n of this software and associated documentation files (the \"Software\"), to\n deal in the Software without restriction, including without limitation the\n rights to use, copy, modify, merge, publish, distribute, sublicense, and/or\n sell copies of the Software, and to permit persons to whom the Software is\n furnished to do so, subject to the following conditions:\n\n The above copyright notice and this permission notice shall be included in\n all copies or substantial portions of the Software.\n\n THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING\n FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS\n IN THE SOFTWARE.\n*/\nfunction 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,\nb,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\nd&&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]}\nfunction 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;\na:{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={};\nfunction 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;\nLa=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}\nfunction 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();\nc.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}\nfunction 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}\nfunction 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)}\nfunction 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}\nfunction 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}\nfunction 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}\nfunction 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++}\nE.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);\nfunction 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\"==\ntypeof 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={};\nfunction 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)});\nP(\">\",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}))}\nl(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={};\nfunction 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);\nQ(\"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);\nQ(\"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);\nQ(\"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);\nQ(\"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);\nQ(\"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};\nG.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};\nF.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\"};\nfunction 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};\nFb.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}\nfunction 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}\nMb.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);\nR.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)};\nR.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}\nS(\"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);\nvar 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);\nvar 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);\nvar 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);\nS(\"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);}\nfunction 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)}\nfunction 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);\nc=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)}\nfunction 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(\"(\"==\ny(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,\ne,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)}}\nfunction 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=\nH(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||\n0>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)}\nfunction 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\",\ndarkgrey:\"#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\",\nghostwhite:\"#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\",\nlightseagreen:\"#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\",\nmoccasin:\"#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\",\nseashell:\"#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\";\nV[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}}();\nfunction 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]||\nnull}}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);}}\nfunction 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};\nX.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}\nfunction 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=\nNumber(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}\nfunction 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}\nfunction 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\")}),\n!!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\";\nfunction 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}\nfunction 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\"!=\nr.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\"}\nfunction 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);\nreturn 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)}}\nfunction 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)}\nfunction 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.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,\n!!b,c)});; return this._.apply(null,arguments);}).apply({navigator:typeof window!='undefined'?window.navigator:null,document:typeof window!='undefined'?window.document:null}, arguments);}\n", + isElementEnabled: "function(){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}\nfunction 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\";\nelse 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)}}\nfunction 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};/*\n\n The MIT License\n\n Copyright (c) 2007 Cybozu Labs, Inc.\n Copyright (c) 2012 Google Inc.\n\n Permission is hereby granted, free of charge, to any person obtaining a copy\n of this software and associated documentation files (the \"Software\"), to\n deal in the Software without restriction, including without limitation the\n rights to use, copy, modify, merge, publish, distribute, sublicense, and/or\n sell copies of the Software, and to permit persons to whom the Software is\n furnished to do so, subject to the following conditions:\n\n The above copyright notice and this permission notice shall be included in\n all copies or substantial portions of the Software.\n\n THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING\n FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS\n IN THE SOFTWARE.\n*/\nfunction 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,\nb,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&&\nb.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;\na:{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}\nfunction 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?\na: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}\nfunction 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)}\nfunction 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}\nfunction 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}\nfunction 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}\nfunction 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++}\nE.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);\nfunction 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\"==\ntypeof 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={};\nfunction 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)});\nQ(\">\",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);\nhb.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={};\nfunction 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);\nR(\"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);\nR(\"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);\nR(\"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);\nR(\"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);\nR(\"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};\nG.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};\nF.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\"};\nfunction 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};\nnb.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}\nfunction 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}\ntb.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);\nU.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)};\nU.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}\nV(\"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);\nvar 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);\nvar 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);\nvar 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);\nV(\"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);}\nfunction 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)}\nfunction 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);\nc=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)}\nfunction 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(\"(\"==\nz(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,\ne,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)}}\nfunction 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=\nH(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||\n0>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)}\nfunction 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);}\n", +}; + +atom.getVisibleText = async function (element, window) { + return executeInContent("getVisibleText", element, window); +} + +atom.isElementDisplayed = function (element, window) { + return executeInContent("isElementDisplayed", element, window); +} + +atom.isElementEnabled = function (element, window) { + return executeInContent("isElementEnabled", element, window); +} + +function executeInContent(name, element, window) { + const sandbox = lazy.sandbox.createMutable(window); + + return lazy.evaluate.sandbox( + sandbox, + `return (${ATOMS[name]})(arguments[0]);`, + [element] + ); +} diff --git a/remote/marionette/browser.sys.mjs b/remote/marionette/browser.sys.mjs new file mode 100644 index 0000000000..fd5aac21a3 --- /dev/null +++ b/remote/marionette/browser.sys.mjs @@ -0,0 +1,385 @@ +/* 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", + 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. + * + * @returns {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} window + * 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. + * + * @returns {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. + * + * @returns {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. + * + * @returns {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. + * + * @returns {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. + * + * @returns {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. + * + * @returns {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. + * + * @returns {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); + } + + // By accessing the content browser's message manager a new browsing + // context is created for browserless tabs, which is needed to successfully + // run the WebDriver's is browsing context open step. This is temporary + // until we find a better solution on bug 1812258. + 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 {XULBrowser} 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 Window.windowState} to WindowState. + * + * @param {number} windowState + * Attribute from {@link Window.windowState}. + * + * @returns {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..c2cdf7b748 --- /dev/null +++ b/remote/marionette/cert.sys.mjs @@ -0,0 +1,57 @@ +/* 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 = {}; + +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 + Services.prefs.setBoolPref(HSTS_PRELOAD_LIST_PREF, false); + Services.prefs.setIntPref(CERT_PINNING_ENFORCEMENT_PREF, 0); + + lazy.certOverrideService.setDisableAllSecurityChecksAndLetAttackersInterceptMyData( + true + ); +}; + +/** + * Enable all security check. + */ +allowAllCerts.disable = function () { + lazy.certOverrideService.setDisableAllSecurityChecksAndLetAttackersInterceptMyData( + false + ); + + Services.prefs.clearUserPref(HSTS_PRELOAD_LIST_PREF); + Services.prefs.clearUserPref(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..ec4145832d --- /dev/null +++ b/remote/marionette/chrome/reftest.xhtml @@ -0,0 +1,8 @@ +<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..1a94c69617 --- /dev/null +++ b/remote/marionette/chrome/test.xhtml @@ -0,0 +1,59 @@ +<?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 + id="winTest" + title="Title Test" + windowtype="Test Type" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" +> + <html:link rel="stylesheet" href="chrome://global/skin/global.css" /> + + <dialog id="dia"> + <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..17d528c800 --- /dev/null +++ b/remote/marionette/chrome/test2.xhtml @@ -0,0 +1,36 @@ +<?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..0bb0140115 --- /dev/null +++ b/remote/marionette/chrome/test_dialog.xhtml @@ -0,0 +1,38 @@ +<?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; ]> + +<window + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + title="&testDialog.title;" +> + <html:link rel="stylesheet" href="chrome://global/skin/global.css" /> + + <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..f9908072e8 --- /dev/null +++ b/remote/marionette/chrome/test_menupopup.xhtml @@ -0,0 +1,34 @@ +<?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 + id="test-window" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" +> + <html:link rel="stylesheet" href="chrome://global/skin/global.css" /> + + <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..5c45fa54c9 --- /dev/null +++ b/remote/marionette/chrome/test_nested_iframe.xhtml @@ -0,0 +1,8 @@ +<?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..48ef900226 --- /dev/null +++ b/remote/marionette/chrome/test_no_xul.xhtml @@ -0,0 +1,58 @@ +<?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. --> + +<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" +> + <link rel="stylesheet" href="chrome://global/skin/global.css" /> + + <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..117ccc33ed --- /dev/null +++ b/remote/marionette/cookie.sys.mjs @@ -0,0 +1,296 @@ +/* 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 + * + * @returns {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. + * + * @returns {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 {object} options + * @param {string=} options.restrictToHost + * Perform test that ``newCookie``'s domain matches this. + * @param {string=} options.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. + * + * @returns {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/driver.sys.mjs b/remote/marionette/driver.sys.mjs new file mode 100644 index 0000000000..154d2cde83 --- /dev/null +++ b/remote/marionette/driver.sys.mjs @@ -0,0 +1,3575 @@ +/* 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, { + 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", + 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", + dom: "chrome://remote/content/shared/DOM.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/shared/Prompt.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", + PromptListener: + "chrome://remote/content/shared/listeners/PromptListener.sys.mjs", + quit: "chrome://remote/content/shared/Browser.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", + ShadowRoot: "chrome://remote/content/marionette/web-reference.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", + webauthn: "chrome://remote/content/marionette/webauthn.sys.mjs", + WebDriverSession: "chrome://remote/content/shared/webdriver/Session.sys.mjs", + WebElement: "chrome://remote/content/marionette/web-reference.sys.mjs", + windowManager: "chrome://remote/content/shared/WindowManager.sys.mjs", + WindowState: "chrome://remote/content/marionette/browser.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => + lazy.Log.get(lazy.Log.TYPES.MARIONETTE) +); + +const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; + +ChromeUtils.defineLazyGetter( + lazy, + "supportedStrategies", + () => + new Set([ + lazy.dom.Strategy.ClassName, + lazy.dom.Strategy.Selector, + lazy.dom.Strategy.ID, + lazy.dom.Strategy.Name, + lazy.dom.Strategy.LinkText, + lazy.dom.Strategy.PartialLinkText, + lazy.dom.Strategy.TagName, + lazy.dom.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"; +// Observer topic to perform clean up when application quit is requested. +const TOPIC_QUIT_APPLICATION_REQUESTED = "quit-application-requested"; + +/** + * 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; + + // Flag to indicate that the application is shutting down + this._isShuttingDown = false; + + 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 + this.dialog = null; + this.promptListener = 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. + * + * @returns {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. + * + * @returns {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 closing of modal dialogs + * during the session's lifetime. + */ +GeckoDriver.prototype.handleClosedModalDialog = function () { + this.dialog = null; +}; + +/** + * Callback used to observe the creation of new modal dialogs + * during the session's lifetime. + */ +GeckoDriver.prototype.handleOpenModalDialog = function (eventName, data) { + this.dialog = data.prompt; + + if (this.dialog.promptType === "beforeunload") { + lazy.logger.trace(`Implicitly accepted "beforeunload" prompt`); + this.dialog.accept(); + return; + } + + if (!this._isShuttingDown) { + this.getActor().notifyDialogOpened(this.dialog); + } +}; + +/** + * 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. + * + * @returns {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. + * + * @returns {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. + * + * @returns {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]; +}; + +/** + * Handles registration of new content browsers. Depending on + * their type they are either accepted or ignored. + * + * @param {XULBrowser} 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. + * + * @returns {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.promptListener = new lazy.PromptListener(() => this.curBrowser); + this.promptListener.on("closed", this.handleClosedModalDialog.bind(this)); + this.promptListener.on("opened", this.handleOpenModalDialog.bind(this)); + this.promptListener.startListening(); + + 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; + + // Bug 1838381 - Only use a longer unload timeout for desktop, because + // on Android only the initial document is loaded, and loading a + // specific page during startup doesn't succeed. + const options = {}; + if (!lazy.AppInfo.isAndroid) { + options.unloadTimeout = 5000; + } + + await lazy.waitForInitialNavigationCompleted( + browsingContext.webProgress, + options + ); + + this.curBrowser.contentBrowser.focus(); + } + + // Check if there is already an open dialog for the selected browser window. + this.dialog = lazy.modal.findPrompt(this.curBrowser); + } + + lazy.registerCommandsActor(this.currentSession.id); + 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 = async function (subject, topic, data) { + switch (topic) { + case TOPIC_BROWSER_READY: + this.registerWindow(subject); + break; + + case TOPIC_QUIT_APPLICATION_REQUESTED: + // Run Marionette specific cleanup steps before allowing + // the application to shutdown + await this._server.setAcceptConnections(false); + this.deleteSession(); + 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 {object} cmd + * @param {string} cmd.parameters.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}. + * + * @returns {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 {object} cmd + * @param {string} cmd.parameters.script + * Script to evaluate as a function body. + * @param {Array.<(string|boolean|number|object|WebReference)>} cmd.parameters.args + * Arguments exposed to the script in <code>arguments</code>. + * The array items must be serialisable to the WebDriver protocol. + * @param {string=} cmd.parameters.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=} cmd.parameters.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=} cmd.parameters.filename + * Filename of the client's program where this script is evaluated. + * @param {number=} cmd.parameters.line + * Line in the client's program where this script is evaluated. + * + * @returns {(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 = 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 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 {object} cmd + * @param {string} cmd.parameters.script + * Script to evaluate as a function body. + * @param {Array.<(string|boolean|number|object|WebReference)>} cmd.parameters.args + * Arguments exposed to the script in <code>arguments</code>. + * The array items must be serialisable to the WebDriver protocol. + * @param {string=} cmd.parameters.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=} cmd.parameters.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=} cmd.parameters.filename + * Filename of the client's program where this script is evaluated. + * @param {number=} cmd.parameters.line + * Line in the client's program where this script is evaluated. + * + * @returns {(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 = 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 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 {object} cmd + * @param {string} cmd.parameters.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. + * + * @returns {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. + * + * @returns {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. + * + * @returns {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. + * + * @returns {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. + * + * @returns {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. + * + * @returns {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 {object} cmd + * @param {number} cmd.parameters.x + * X coordinate of the top/left of the window that it will be + * moved to. + * @param {number} cmd.parameters.y + * Y coordinate of the top/left of the window that it will be + * moved to. + * @param {number} cmd.parameters.width + * Width to resize the window to. + * @param {number} cmd.parameters.height + * Height to resize the window to. + * + * @returns {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 {object} cmd + * @param {string} cmd.parameters.handle + * Handle of the window to switch to. + * @param {boolean=} cmd.parameters.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 an existing dialog for the new window + this.dialog = lazy.modal.findPrompt(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 {object} cmd + * @param {(string | object)=} cmd.parameters.element + * A web element reference of the frame or its element id. + * @param {number=} cmd.parameters.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 = lazy.WebElement.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} cmd + * @param {Object<string, number>} cmd.parameters + * 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); +}; + +/** + * Perform a series of grouped actions at the specified points in time. + * + * @param {object} cmd + * @param {Array<?>} cmd.parameters.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); +}; + +/** + * 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 {object} cmd + * @param {string=} cmd.parameters.element + * Web element reference ID to the element that will be used as start node. + * @param {string} cmd.parameters.using + * Indicates which search method to use. + * @param {string} cmd.parameters.value + * Value the client is looking for. + * + * @returns {WebElement} + * Return the found element. + * + * @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 (!lazy.supportedStrategies.has(using)) { + throw new lazy.error.InvalidSelectorError( + `Strategy not supported: ${using}` + ); + } + + lazy.assert.defined(value); + lazy.assert.open(this.getBrowsingContext()); + await this._handleUserPrompts(); + + let startNode; + if (typeof el != "undefined") { + startNode = lazy.WebElement.fromUUID(el).toJSON(); + } + + let opts = { + startNode, + timeout: this.currentSession.timeouts.implicit, + all: false, + }; + + return this.getActor().findElement(using, value, opts); +}; + +/** + * Find an element within shadow root using the indicated search strategy. + * + * @param {object} cmd + * @param {string} cmd.parameters.shadowRoot + * Shadow root reference ID. + * @param {string} cmd.parameters.using + * Indicates which search method to use. + * @param {string} cmd.parameters.value + * Value the client is looking for. + * + * @returns {WebElement} + * Return the found element. + * + * @throws {DetachedShadowRootError} + * If shadow root represented by reference <var>id</var> is + * no longer attached to the DOM. + * @throws {NoSuchElementError} + * If the element which is looked for with <var>value</var> was + * not found. + * @throws {NoSuchShadowRoot} + * If shadow root represented by reference <var>shadowRoot</var> is unknown. + * @throws {NoSuchWindowError} + * Browsing context has been discarded. + * @throws {UnexpectedAlertOpenError} + * A modal dialog is open, blocking this operation. + */ +GeckoDriver.prototype.findElementFromShadowRoot = async function (cmd) { + const { shadowRoot, using, value } = cmd.parameters; + + if (!lazy.supportedStrategies.has(using)) { + throw new lazy.error.InvalidSelectorError( + `Strategy not supported: ${using}` + ); + } + + lazy.assert.defined(value); + lazy.assert.open(this.getBrowsingContext()); + await this._handleUserPrompts(); + + const opts = { + all: false, + startNode: lazy.ShadowRoot.fromUUID(shadowRoot).toJSON(), + timeout: this.currentSession.timeouts.implicit, + }; + + return this.getActor().findElement(using, value, opts); +}; + +/** + * Find elements using the indicated search strategy. + * + * @param {object} cmd + * @param {string=} cmd.parameters.element + * Web element reference ID to the element that will be used as start node. + * @param {string} cmd.parameters.using + * Indicates which search method to use. + * @param {string} cmd.parameters.value + * Value the client is looking for. + * + * @returns {Array<WebElement>} + * Return the array of found elements. + * + * @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 (!lazy.supportedStrategies.has(using)) { + throw new lazy.error.InvalidSelectorError( + `Strategy not supported: ${using}` + ); + } + + lazy.assert.defined(value); + lazy.assert.open(this.getBrowsingContext()); + await this._handleUserPrompts(); + + let startNode; + if (typeof el != "undefined") { + startNode = lazy.WebElement.fromUUID(el).toJSON(); + } + + let opts = { + startNode, + timeout: this.currentSession.timeouts.implicit, + all: true, + }; + + return this.getActor().findElements(using, value, opts); +}; + +/** + * Find elements within shadow root using the indicated search strategy. + * + * @param {object} cmd + * @param {string} cmd.parameters.shadowRoot + * Shadow root reference ID. + * @param {string} cmd.parameters.using + * Indicates which search method to use. + * @param {string} cmd.parameters.value + * Value the client is looking for. + * + * @returns {Array<WebElement>} + * Return the array of found elements. + * + * @throws {DetachedShadowRootError} + * If shadow root represented by reference <var>id</var> is + * no longer attached to the DOM. + * @throws {NoSuchShadowRoot} + * If shadow root represented by reference <var>shadowRoot</var> is unknown. + * @throws {NoSuchWindowError} + * Browsing context has been discarded. + * @throws {UnexpectedAlertOpenError} + * A modal dialog is open, blocking this operation. + */ +GeckoDriver.prototype.findElementsFromShadowRoot = async function (cmd) { + const { shadowRoot, using, value } = cmd.parameters; + + if (!lazy.supportedStrategies.has(using)) { + throw new lazy.error.InvalidSelectorError( + `Strategy not supported: ${using}` + ); + } + + lazy.assert.defined(value); + lazy.assert.open(this.getBrowsingContext()); + await this._handleUserPrompts(); + + const opts = { + all: true, + startNode: lazy.ShadowRoot.fromUUID(shadowRoot).toJSON(), + timeout: this.currentSession.timeouts.implicit, + }; + + return this.getActor().findElements(using, value, opts); +}; + +/** + * Return the shadow root of an element in the document. + * + * @param {object} cmd + * @param {id} cmd.parameters.id + * A web element id reference. + * @returns {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 = lazy.WebElement.fromUUID(id).toJSON(); + + return this.getActor().getShadowRoot(webEl); +}; + +/** + * Return the active element in the document. + * + * @returns {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 {object} cmd + * @param {string} cmd.parameters.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 = lazy.WebElement.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 {object} cmd + * @param {string} cmd.parameters.id + * Web element reference ID to the element that will be inspected. + * @param {string} cmd.parameters.name + * Name of the attribute which value to retrieve. + * + * @returns {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 = lazy.WebElement.fromUUID(id).toJSON(); + + return this.getActor().getElementAttribute(webEl, name); +}; + +/** + * Returns the value of a property associated with given element. + * + * @param {object} cmd + * @param {string} cmd.parameters.id + * Web element reference ID to the element that will be inspected. + * @param {string} cmd.parameters.name + * Name of the property which value to retrieve. + * + * @returns {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 = lazy.WebElement.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 {object} cmd + * @param {string} cmd.parameters.id + * Reference ID to the element that will be inspected. + * + * @returns {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 = lazy.WebElement.fromUUID(id).toJSON(); + + return this.getActor().getElementText(webEl); +}; + +/** + * Get the tag name of the element. + * + * @param {object} cmd + * @param {string} cmd.parameters.id + * Reference ID to the element that will be inspected. + * + * @returns {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 = lazy.WebElement.fromUUID(id).toJSON(); + + return this.getActor().getElementTagName(webEl); +}; + +/** + * Check if element is displayed. + * + * @param {object} cmd + * @param {string} cmd.parameters.id + * Reference ID to the element that will be inspected. + * + * @returns {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 = lazy.WebElement.fromUUID(id).toJSON(); + + return this.getActor().isElementDisplayed( + webEl, + this.currentSession.capabilities + ); +}; + +/** + * Return the property of the computed style of an element. + * + * @param {object} cmd + * @param {string} cmd.parameters.id + * Reference ID to the element that will be checked. + * @param {string} cmd.parameters.propertyName + * CSS rule that is being requested. + * + * @returns {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 = lazy.WebElement.fromUUID(id).toJSON(); + + return this.getActor().getElementValueOfCssProperty(webEl, prop); +}; + +/** + * Check if element is enabled. + * + * @param {object} cmd + * @param {string} cmd.parameters.id + * Reference ID to the element that will be checked. + * + * @returns {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 = lazy.WebElement.fromUUID(id).toJSON(); + + return this.getActor().isElementEnabled( + webEl, + this.currentSession.capabilities + ); +}; + +/** + * Check if element is selected. + * + * @param {object} cmd + * @param {string} cmd.parameters.id + * Reference ID to the element that will be checked. + * + * @returns {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 = lazy.WebElement.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 = lazy.WebElement.fromUUID(id).toJSON(); + + return this.getActor().getElementRect(webEl); +}; + +/** + * Send key presses to element after focusing on it. + * + * @param {object} cmd + * @param {string} cmd.parameters.id + * Reference ID to the element that will be checked. + * @param {string} cmd.parameters.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 = lazy.WebElement.fromUUID(id).toJSON(); + + return this.getActor().sendKeysToElement( + webEl, + text, + this.currentSession.capabilities + ); +}; + +/** + * Clear the text of an element. + * + * @param {object} cmd + * @param {string} cmd.parameters.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 = lazy.WebElement.fromUUID(id).toJSON(); + + await this.getActor().clearElement(webEl); +}; + +/** + * Add a single cookie to the cookie store associated with the active + * document's address. + * + * @param {object} cmd + * @param {Map.<string, (string|number|boolean)>} cmd.parameters.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 {object} cmd + * @param {string=} cmd.parameters.type + * Optional type of the new top-level browsing context. Can be one of + * `tab` or `window`. Defaults to `tab`. + * @param {boolean=} cmd.parameters.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=} cmd.parameters.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. + * + * @returns {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. + // On Android always use a new tab instead because the application has a + // single window only. + if ( + typeof type == "undefined" || + !["tab", "window"].includes(type) || + lazy.AppInfo.isAndroid + ) { + 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, + { + unloadTimeout: 5000, + } + ); + + 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. + * + * @returns {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. + * + * @returns {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._isShuttingDown && this.promptListener) { + // Do not stop the prompt listener when quitting the browser to + // allow us to also accept beforeunload prompts during shutdown. + this.promptListener.stopListening(); + this.promptListener = 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 {object} cmd + * @param {string=} cmd.parameters.id + * Optional web element reference to take a screenshot of. + * If undefined, a screenshot will be taken of the document element. + * @param {boolean=} cmd.parameters.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=} cmd.parameters.hash + * True if the user requests a hash of the image data. Defaults to false. + * @param {boolean=} cmd.parameters.scroll + * Scroll to element if |id| is provided. Defaults to true. + * + * @returns {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 ? lazy.WebElement.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. + * + * @returns {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. + * + * @returns {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. + * + * @returns {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 modal dialogs, 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.promptListener.dialogClosed(); + this.dialog.dismiss(); + await dialogClosed; + + const win = this.getCurrentWindow(); + await new lazy.IdlePromise(win); +}; + +/** + * Accepts a currently displayed dialog 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.promptListener.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 = async function () { + lazy.assert.open(this.getBrowsingContext({ top: true })); + this._checkIfAlertIsPresent(); + const text = await this.dialog.getText(); + return 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 modal dialog is currently displayed but has no means for text input, + * an element not visible error is returned. + * + * @param {object} cmd + * @param {string} cmd.parameters.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; + } + + if (this.dialog.promptType == "beforeunload") { + // Wait until the "beforeunload" prompt has been accepted. + await this.promptListener.dialogClosed(); + return; + } + + const textContent = await this.dialog.getText(); + + 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 {object} cmd + * @param {boolean} cmd.parameters.value + * True if the server should accept new socket connections. + */ +GeckoDriver.prototype.acceptConnections = async function (cmd) { + lazy.assert.boolean(cmd.parameters.value); + await this._server.setAcceptConnections(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 {object} cmd + * @param {Array.<string>=} cmd.parameters.flags + * Constant name of masks to pass to |Services.startup.quit|. + * If empty or undefined, |nsIAppStartup.eAttemptQuit| is used. + * @param {boolean=} cmd.parameters.safeMode + * Optional flag to indicate that the application has to + * be restarted in safe mode. + * + * @returns {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; + + 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` + ); + } + + // Register handler to run Marionette specific shutdown code. + Services.obs.addObserver(this, TOPIC_QUIT_APPLICATION_REQUESTED); + + let quitApplicationResponse; + try { + this._isShuttingDown = true; + quitApplicationResponse = await lazy.quit( + flags, + safeMode, + this.currentSession.capabilities.get("moz:windowless") + ); + } catch (e) { + this._isShuttingDown = false; + if (e instanceof TypeError) { + throw new lazy.error.InvalidArgumentError(e.message); + } + throw new lazy.error.UnsupportedOperationError(e.message); + } finally { + Services.obs.removeObserver(this, TOPIC_QUIT_APPLICATION_REQUESTED); + } + + return quitApplicationResponse; +}; + +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 {object} cmd + * @param {Array.<string>} cmd.parameters.urls + * Array of .dtd URLs. + * @param {string} cmd.parameters.id + * The ID of the entity to retrieve the localized string for. + * + * @returns {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 {object} cmd + * @param {Array.<string>} cmd.parameters.urls + * Array of .properties URLs. + * @param {string} cmd.parameters.id + * The ID of the property to retrieve the localized string for. + * + * @returns {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 = 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 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 {object} cmd + * @param {boolean=} cmd.parameters.background + * Whether or not to print background colors and images. + * Defaults to false, which prints without background graphics. + * @param {number=} cmd.parameters.margin.bottom + * Bottom margin in cm. Defaults to 1cm (~0.4 inches). + * @param {number=} cmd.parameters.margin.left + * Left margin in cm. Defaults to 1cm (~0.4 inches). + * @param {number=} cmd.parameters.margin.right + * Right margin in cm. Defaults to 1cm (~0.4 inches). + * @param {number=} cmd.parameters.margin.top + * Top margin in cm. Defaults to 1cm (~0.4 inches). + * @param {('landscape'|'portrait')=} cmd.parameters.options.orientation + * Paper orientation. Defaults to 'portrait'. + * @param {Array.<string|number>=} cmd.parameters.pageRanges + * Paper ranges to print, e.g., ['1-5', 8, '11-13']. + * Defaults to the empty array, which means print all pages. + * @param {number=} cmd.parameters.page.height + * Paper height in cm. Defaults to US letter height (27.94cm / 11 inches) + * @param {number=} cmd.parameters.page.width + * Paper width in cm. Defaults to US letter width (21.59cm / 8.5 inches) + * @param {number=} cmd.parameters.scale + * Scale of the webpage rendering. Defaults to 1.0. + * @param {boolean=} cmd.parameters.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. + * + * @returns {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 (const prop of ["top", "bottom", "left", "right"]) { + lazy.assert.positiveNumber( + settings.margin[prop], + lazy.pprint`margin.${prop} is not a positive number` + ); + } + for (const 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.that( + orientation => lazy.print.defaults.orientationValue.includes(orientation), + `orientation ${ + settings.orientation + } doesn't match allowed values "${lazy.print.defaults.orientationValue.join( + "/" + )}"` + )(settings.orientation); + lazy.assert.boolean(settings.background); + lazy.assert.array(settings.pageRanges); + + const browsingContext = this.curBrowser.tab.linkedBrowser.browsingContext; + const printSettings = await lazy.print.getPrintSettings(settings); + const binaryString = await lazy.print.printToBinaryString( + browsingContext, + printSettings + ); + + return btoa(binaryString); +}; + +GeckoDriver.prototype.addVirtualAuthenticator = function (cmd) { + const { + protocol, + transport, + hasResidentKey, + hasUserVerification, + isUserConsenting, + isUserVerified, + } = cmd.parameters; + + lazy.assert.string( + protocol, + "addVirtualAuthenticator: protocol must be a string" + ); + lazy.assert.string( + transport, + "addVirtualAuthenticator: transport must be a string" + ); + lazy.assert.boolean( + hasResidentKey, + "addVirtualAuthenticator: hasResidentKey must be a boolean" + ); + lazy.assert.boolean( + hasUserVerification, + "addVirtualAuthenticator: hasUserVerification must be a boolean" + ); + lazy.assert.boolean( + isUserConsenting, + "addVirtualAuthenticator: isUserConsenting must be a boolean" + ); + lazy.assert.boolean( + isUserVerified, + "addVirtualAuthenticator: isUserVerified must be a boolean" + ); + + return lazy.webauthn.addVirtualAuthenticator( + protocol, + transport, + hasResidentKey, + hasUserVerification, + isUserConsenting, + isUserVerified + ); +}; + +GeckoDriver.prototype.removeVirtualAuthenticator = function (cmd) { + const { authenticatorId } = cmd.parameters; + + lazy.assert.positiveInteger( + authenticatorId, + "removeVirtualAuthenticator: authenticatorId must be a positiveInteger" + ); + + lazy.webauthn.removeVirtualAuthenticator(authenticatorId); +}; + +GeckoDriver.prototype.addCredential = function (cmd) { + const { + authenticatorId, + credentialId, + isResidentCredential, + rpId, + privateKey, + userHandle, + signCount, + } = cmd.parameters; + + lazy.assert.positiveInteger( + authenticatorId, + "addCredential: authenticatorId must be a positiveInteger" + ); + lazy.assert.string( + credentialId, + "addCredential: credentialId must be a string" + ); + lazy.assert.boolean( + isResidentCredential, + "addCredential: isResidentCredential must be a boolean" + ); + lazy.assert.string(rpId, "addCredential: rpId must be a string"); + lazy.assert.string(privateKey, "addCredential: privateKey must be a string"); + if (userHandle) { + lazy.assert.string( + userHandle, + "addCredential: userHandle must be a string if present" + ); + } + lazy.assert.number(signCount, "addCredential: signCount must be a number"); + + lazy.webauthn.addCredential( + authenticatorId, + credentialId, + isResidentCredential, + rpId, + privateKey, + userHandle, + signCount + ); +}; + +GeckoDriver.prototype.getCredentials = function (cmd) { + const { authenticatorId } = cmd.parameters; + + lazy.assert.positiveInteger( + authenticatorId, + "getCredentials: authenticatorId must be a positiveInteger" + ); + + return lazy.webauthn.getCredentials(authenticatorId); +}; + +GeckoDriver.prototype.removeCredential = function (cmd) { + const { authenticatorId, credentialId } = cmd.parameters; + + lazy.assert.positiveInteger( + authenticatorId, + "removeCredential: authenticatorId must be a positiveInteger" + ); + lazy.assert.string( + credentialId, + "removeCredential: credentialId must be a string" + ); + + lazy.webauthn.removeCredential(authenticatorId, credentialId); +}; + +GeckoDriver.prototype.removeAllCredentials = function (cmd) { + const { authenticatorId } = cmd.parameters; + + lazy.assert.positiveInteger( + authenticatorId, + "removeAllCredentials: authenticatorId must be a positiveInteger" + ); + + lazy.webauthn.removeAllCredentials(authenticatorId); +}; + +GeckoDriver.prototype.setUserVerified = function (cmd) { + const { authenticatorId, isUserVerified } = cmd.parameters; + + lazy.assert.positiveInteger( + authenticatorId, + "setUserVerified: authenticatorId must be a positiveInteger" + ); + lazy.assert.boolean( + isUserVerified, + "setUserVerified: isUserVerified must be a boolean" + ); + + lazy.webauthn.setUserVerified(authenticatorId, isUserVerified); +}; + +GeckoDriver.prototype.setPermission = async function (cmd) { + const { descriptor, state, oneRealm = false } = cmd.parameters; + const browsingContext = lazy.assert.open(this.getBrowsingContext()); + + // XXX: WPT should not have these but currently they do and we pass testing pref to + // pass them, see bug 1875837. + if ( + ["clipboard-read", "clipboard-write"].includes(descriptor.name) && + state === "granted" + ) { + if ( + Services.prefs.getBoolPref("dom.events.testing.asyncClipboard", false) + ) { + // Okay, do nothing. The clipboard module will work without permission. + return; + } + throw new lazy.error.UnsupportedOperationError( + "setPermission: expected dom.events.testing.asyncClipboard to be set" + ); + } + + // XXX: We currently depend on camera/microphone tests throwing UnsupportedOperationError, + // the fix is ongoing in bug 1609427. + if (["camera", "microphone"].includes(descriptor.name)) { + throw new lazy.error.UnsupportedOperationError( + "setPermission: camera and microphone permissions are currently unsupported" + ); + } + + // XXX: Allowing this permission causes timing related Android crash, see also bug 1878741 + if (descriptor.name === "notifications") { + if (Services.prefs.getBoolPref("notification.prompt.testing", false)) { + // Okay, do nothing. The notifications module will work without permission. + return; + } + throw new lazy.error.UnsupportedOperationError( + "setPermission: expected notification.prompt.testing to be set" + ); + } + + let params; + try { + params = + await this.curBrowser.window.navigator.permissions.parseSetParameters({ + descriptor, + state, + }); + } catch (err) { + throw new lazy.error.InvalidArgumentError(`setPermission: ${err.message}`); + } + + lazy.assert.boolean(oneRealm); + + lazy.permissions.set(params.type, params.state, oneRealm, browsingContext); +}; + +/** + * Determines the Accessibility label for this element. + * + * @param {object} cmd + * @param {string} cmd.parameters.id + * Web element reference ID to the element for which the accessibility label + * will be returned. + * + * @returns {string} + * The Accessibility label for this element + */ +GeckoDriver.prototype.getComputedLabel = async function (cmd) { + lazy.assert.open(this.getBrowsingContext()); + await this._handleUserPrompts(); + + let id = lazy.assert.string(cmd.parameters.id); + let webEl = lazy.WebElement.fromUUID(id).toJSON(); + + return this.getActor().getComputedLabel(webEl); +}; + +/** + * Determines the Accessibility role for this element. + * + * @param {object} cmd + * @param {string} cmd.parameters.id + * Web element reference ID to the element for which the accessibility role + * will be returned. + * + * @returns {string} + * The Accessibility role for this element + */ +GeckoDriver.prototype.getComputedRole = async function (cmd) { + lazy.assert.open(this.getBrowsingContext()); + await this._handleUserPrompts(); + + let id = lazy.assert.string(cmd.parameters.id); + let webEl = lazy.WebElement.fromUUID(id).toJSON(); + return this.getActor().getComputedRole(webEl); +}; + +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, + + // 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:FindElementFromShadowRoot": + GeckoDriver.prototype.findElementFromShadowRoot, + "WebDriver:FindElements": GeckoDriver.prototype.findElements, + "WebDriver:FindElementsFromShadowRoot": + GeckoDriver.prototype.findElementsFromShadowRoot, + "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:GetComputedLabel": GeckoDriver.prototype.getComputedLabel, + "WebDriver:GetComputedRole": GeckoDriver.prototype.getComputedRole, + "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, + + // WebAuthn + "WebAuthn:AddVirtualAuthenticator": + GeckoDriver.prototype.addVirtualAuthenticator, + "WebAuthn:RemoveVirtualAuthenticator": + GeckoDriver.prototype.removeVirtualAuthenticator, + "WebAuthn:AddCredential": GeckoDriver.prototype.addCredential, + "WebAuthn:GetCredentials": GeckoDriver.prototype.getCredentials, + "WebAuthn:RemoveCredential": GeckoDriver.prototype.removeCredential, + "WebAuthn:RemoveAllCredentials": GeckoDriver.prototype.removeAllCredentials, + "WebAuthn:SetUserVerified": GeckoDriver.prototype.setUserVerified, +}; + +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/evaluate.sys.mjs b/remote/marionette/evaluate.sys.mjs new file mode 100644 index 0000000000..bdcce779a1 --- /dev/null +++ b/remote/marionette/evaluate.sys.mjs @@ -0,0 +1,356 @@ +/* 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 {object=} options + * @param {boolean=} options.async + * Indicates if the script should return immediately or wait for + * the callback to be invoked before returning. Defaults to false. + * @param {string=} options.file + * File location of the program in the client. Defaults to "dummy file". + * @param {number=} options.line + * Line number of the program in the client. Defaults to 0. + * @param {number=} options.timeout + * Duration in milliseconds before interrupting the script. Defaults to + * DEFAULT_TIMEOUT. + * + * @returns {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. + * + * @returns {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. + * + * @returns {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. + * + * @returns {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. + * + * @returns {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..dbe6567e52 --- /dev/null +++ b/remote/marionette/event.sys.mjs @@ -0,0 +1,291 @@ +/* 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 */ + +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 = {}; + +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); +} + +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; + }, +}; + +/** + * 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. + * + * @returns {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. + * + * @returns {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) { + const dpr = win.devicePixelRatio; + + // All delta properties expect the value in device pixels while the + // WebDriver specification uses CSS pixels. + if (typeof opts.deltaX !== "undefined") { + opts.deltaX *= dpr; + } + if (typeof opts.deltaY !== "undefined") { + opts.deltaY *= dpr; + } + if (typeof opts.deltaZ !== "undefined") { + opts.deltaZ *= dpr; + } + + 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 keyValue of keyString) { + // keyValue will contain enough to represent the UTF-16 encoding of a single abstract character + // i.e. either a single scalar value, or a surrogate pair + if (modifiers.shiftKey) { + keyValue = lazy.keyData.getShiftedKey(keyValue); + } + const data = lazy.keyData.getData(keyValue); + const key = { ...data, ...modifiers }; + if (data.modifier) { + // Negating the state of the modifier here is not spec compliant but + // makes us compatible to Chrome's behavior for now. That's fine unless + // we know the correct behavior. + // + // @see: https://github.com/w3c/webdriver/issues/1734 + modifiers[data.modifier] = !modifiers[data.modifier]; + } + 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..c71149a96a --- /dev/null +++ b/remote/marionette/interaction.sys.mjs @@ -0,0 +1,819 @@ +/* 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 */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + setTimeout: "resource://gre/modules/Timer.sys.mjs", + + accessibility: "chrome://remote/content/marionette/accessibility.sys.mjs", + atom: "chrome://remote/content/marionette/atom.sys.mjs", + dom: "chrome://remote/content/shared/DOM.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", +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => + lazy.Log.get(lazy.Log.TYPES.MARIONETTE) +); + +// dragService may be null if it's in the headless mode (e.g., on Linux). +// It depends on the platform, though. +ChromeUtils.defineLazyGetter(lazy, "dragService", () => { + try { + return Cc["@mozilla.org/widget/dragservice;1"].getService( + Ci.nsIDragService + ); + } catch (e) { + // If we're in the headless mode, the drag service may be never + // instantiated. In this case, an exception is thrown. Let's ignore + // any exceptions since without the drag service, nobody can create a + // drag session. + return null; + } +}); + +/** 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.dom.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.dom.getContainer(el); + + // step 4 + if (!lazy.dom.isInView(containerEl)) { + lazy.dom.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.dom.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.dom.getInViewCentrePoint(rects[0], win); + + if (lazy.dom.isObscured(containerEl)) { + throw new lazy.error.ElementClickInterceptedError( + null, + {}, + containerEl, + clickPoint + ); + } + + let acc = await a11y.assertAccessible(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 { + // Synthesize a pointerMove action. + lazy.event.synthesizeMouseAtPoint( + clickPoint.x, + clickPoint.y, + { + type: "mousemove", + allowToHandleDragDrop: true, + }, + win + ); + + if (lazy.dragService?.getCurrentSession()) { + // Special handling is required if the mousemove started a drag session. + // In this case, mousedown event shouldn't be fired, and the mouseup should + // end the session. Therefore, we should synthesize only mouseup. + lazy.event.synthesizeMouseAtPoint( + clickPoint.x, + clickPoint.y, + { + type: "mouseup", + allowToHandleDragDrop: true, + }, + win + ); + } else { + // step 9 + let clicked = interaction.flushEventLoop(containerEl); + + // Synthesize a pointerDown + pointerUp action. + lazy.event.synthesizeMouseAtPoint( + clickPoint.x, + clickPoint.y, + { allowToHandleDragDrop: true }, + win + ); + + await clicked; + } + } + + // step 10 + // if the click causes navigation, the post-navigation checks are + // handled by navigate.js +} + +async function chromeClick(el, a11y) { + const win = getWindow(el); + + if (!(await lazy.atom.isElementEnabled(el, win))) { + throw new lazy.error.InvalidElementStateError("Element is not enabled"); + } + + let acc = await a11y.assertAccessible(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.dom.getContainer(el); + } + + if (!(await lazy.dom.isVisible(visibilityCheckEl))) { + throw new lazy.error.ElementNotInteractableError(); + } + + if (!(await lazy.atom.isElementEnabled(el, win))) { + throw new lazy.error.InvalidElementStateError("Element is not enabled"); + } + + let acc = await a11y.assertAccessible(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.dom.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} el + * 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.dom.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.dom.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.dom.isDisabled(el)) { + throw new lazy.error.InvalidElementStateError( + lazy.pprint`Element is disabled: ${el}` + ); + } + if (lazy.dom.isReadOnly(el)) { + throw new lazy.error.InvalidElementStateError( + lazy.pprint`Element is read-only: ${el}` + ); + } + if (!lazy.dom.isEditable(el)) { + throw new lazy.error.InvalidElementStateError( + lazy.pprint`Unable to clear element that cannot be edited: ${el}` + ); + } + + if (!lazy.dom.isInView(el)) { + lazy.dom.scrollIntoView(el); + } + if (!lazy.dom.isInView(el)) { + throw new lazy.error.ElementNotInteractableError( + lazy.pprint`Element ${el} could not be scrolled into view` + ); + } + + if (lazy.dom.isEditingHost(el)) { + clearContentEditableElement(el); + } else { + clearResettableElement(el); + } +}; + +function clearContentEditableElement(el) { + if (el.innerHTML === "") { + return; + } + el.focus(); + el.innerHTML = ""; + el.blur(); +} + +function clearResettableElement(el) { + if (!lazy.dom.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. + * + * @returns {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 { + lazy.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.dom.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. + * + * @returns {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 {object=} options + * @param {boolean=} options.strictFileInteractability + * Run interactability checks on `<input type=file>` elements. + * @param {boolean=} options.accessibilityChecks + * Enforce strict accessibility tests. + * @param {boolean=} options.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.dom.getContainer(el); + + lazy.dom.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.assertAccessible(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.dom.getContainer(el); + } + + if (!(await lazy.dom.isVisible(visibilityCheckEl))) { + throw new lazy.error.ElementNotInteractableError( + "Element is not visible" + ); + } + + let acc = await a11y.assertAccessible(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. + * + * @returns {boolean} + * True if element is displayed, false otherwise. + */ +interaction.isElementDisplayed = async function (el, strict = false) { + let win = getWindow(el); + let displayed = await lazy.atom.isElementDisplayed(el, win); + + let a11y = lazy.accessibility.get(strict); + return a11y.assertAccessible(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. + * + * @returns {boolean} + * True if enabled, false otherwise. + */ +interaction.isElementEnabled = async function (el, strict = false) { + let enabled = true; + let win = getWindow(el); + + if (lazy.dom.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 = await lazy.atom.isElementEnabled(el, win); + } + + let a11y = lazy.accessibility.get(strict); + return a11y.assertAccessible(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. + * + * @returns {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.dom.isSelected(el); + + let a11y = lazy.accessibility.get(strict); + return a11y.assertAccessible(el).then(acc => { + a11y.assertSelected(acc, el, selected); + return selected; + }); +}; + +function getWindow(el) { + // eslint-disable-next-line mozilla/use-ownerGlobal + return el.ownerDocument.defaultView; +} diff --git a/remote/marionette/jar.mn b/remote/marionette/jar.mn new file mode 100644 index 0000000000..b206dc2487 --- /dev/null +++ b/remote/marionette/jar.mn @@ -0,0 +1,51 @@ +# 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/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/driver.sys.mjs (driver.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/message.sys.mjs (message.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) + content/marionette/web-reference.sys.mjs (web-reference.sys.mjs) + content/marionette/webauthn.sys.mjs (webauthn.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.sys.mjs (../../tools/code-coverage/PerTestCoverageUtils.sys.mjs) +#endif +#endif diff --git a/remote/marionette/json.sys.mjs b/remote/marionette/json.sys.mjs new file mode 100644 index 0000000000..bae1b99cdd --- /dev/null +++ b/remote/marionette/json.sys.mjs @@ -0,0 +1,491 @@ +/* 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 { WebFrame, WebWindow } from "./web-reference.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + dom: "chrome://remote/content/shared/DOM.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/web-reference.sys.mjs", + TabManager: "chrome://remote/content/shared/TabManager.sys.mjs", + WebElement: "chrome://remote/content/marionette/web-reference.sys.mjs", + WebFrame: "chrome://remote/content/marionette/web-reference.sys.mjs", + WebReference: "chrome://remote/content/marionette/web-reference.sys.mjs", + WebWindow: "chrome://remote/content/marionette/web-reference.sys.mjs", +}); + +ChromeUtils.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. + * + * @returns {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.dom.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. + * + * @returns {Object<Map<BrowsingContext, Array<string>, object>>} + * Object that contains a list of browsing contexts each with a list of + * shared ids for collected elements and shadow root nodes, and second the + * same object as provided by `value` with the WebDriver classic supported + * DOM nodes 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) { + const seenNodeIds = new Map(); + let hasSerializedWindows = false; + + function cloneJSON(value, seen) { + if (seen === undefined) { + seen = new Set(); + } + + if ([undefined, null].includes(value)) { + return null; + } + + const type = typeof value; + + if (["boolean", "number", "string"].includes(type)) { + // Primitive values + return value; + } + + // Evaluation of code might take place in mutable sandboxes, which are + // created to waive XRays by default. As such DOM nodes and windows + // have to be unwaived before accessing properties like "ownerGlobal" + // is possible. + // + // Until bug 1743788 is fixed there might be the possibility that more + // objects might need to be unwaived as well. + const isNode = Node.isInstance(value); + const isWindow = Window.isInstance(value); + if (isNode || isWindow) { + value = Cu.unwaiveXrays(value); + } + + if (isNode && lazy.dom.isElement(value)) { + // Convert DOM elements to WebReference instances. + + if (lazy.dom.isStale(value)) { + // Don't create a reference for stale elements. + throw new lazy.error.StaleElementReferenceError( + lazy.pprint`The element ${value} is no longer attached to the DOM` + ); + } + + const nodeRef = nodeCache.getOrCreateNodeReference(value, seenNodeIds); + + return lazy.WebReference.from(value, nodeRef).toJSON(); + } + + if (isNode && lazy.dom.isShadowRoot(value)) { + // Convert ShadowRoot instances to WebReference references. + + if (lazy.dom.isDetached(value)) { + // Don't create a reference for detached shadow roots. + throw new lazy.error.DetachedShadowRootError( + lazy.pprint`The ShadowRoot ${value} is no longer attached to the DOM` + ); + } + + const nodeRef = nodeCache.getOrCreateNodeReference(value, seenNodeIds); + + return lazy.WebReference.from(value, nodeRef).toJSON(); + } + + if (isWindow) { + // Convert window instances to WebReference references. + let reference; + + if (value.browsingContext.parent == null) { + reference = new WebWindow(value.browsingContext.browserId.toString()); + hasSerializedWindows = true; + } else { + reference = new WebFrame(value.browsingContext.id.toString()); + } + + return reference.toJSON(); + } + + 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 { + seenNodeIds, + serializedValue: cloneJSON(value, new Set()), + hasSerializedWindows, + }; +}; + +/** + * Deserialize an arbitrary object. + * + * @param {object} value + * Arbitrary object. + * @param {NodeCache} nodeCache + * Node cache that holds already seen WebElement and ShadowRoot references. + * @param {BrowsingContext} browsingContext + * The browsing context to check. + * + * @returns {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, browsingContext) { + 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.ShadowRoot) { + return getKnownShadowRoot(browsingContext, webRef.uuid, nodeCache); + } + + if (webRef instanceof lazy.WebElement) { + return getKnownElement(browsingContext, webRef.uuid, nodeCache); + } + + if (webRef instanceof lazy.WebFrame) { + const browsingContext = BrowsingContext.get(webRef.uuid); + + if (browsingContext === null || browsingContext.parent === null) { + throw new lazy.error.NoSuchWindowError( + `Unable to locate frame with id: ${webRef.uuid}` + ); + } + + return browsingContext.window; + } + + if (webRef instanceof lazy.WebWindow) { + const browsingContext = BrowsingContext.getCurrentTopByBrowserId( + webRef.uuid + ); + + if (browsingContext === null) { + throw new lazy.error.NoSuchWindowError( + `Unable to locate window with id: ${webRef.uuid}` + ); + } + + return browsingContext.window; + } + } + + return cloneObject(value, seen, deserializeJSON); + } + } + + return deserializeJSON(value, new Set()); +}; + +/** + * Convert unique navigable ids to internal browser ids. + * + * @param {object} serializedData + * The data to process. + * + * @returns {object} + * The processed data. + */ +json.mapFromNavigableIds = function (serializedData) { + function _processData(data) { + if (lazy.WebReference.isReference(data)) { + const webRef = lazy.WebReference.fromJSON(data); + + if (webRef instanceof lazy.WebWindow) { + const browser = lazy.TabManager.getBrowserById(webRef.uuid); + if (browser) { + webRef.uuid = browser?.browserId.toString(); + data = webRef.toJSON(); + } + } + } else if (typeof data === "object") { + for (const entry in data) { + data[entry] = _processData(data[entry]); + } + } + + return data; + } + + return _processData(serializedData); +}; + +/** + * Convert browser ids to unique navigable ids. + * + * @param {object} serializedData + * The data to process. + * + * @returns {object} + * The processed data. + */ +json.mapToNavigableIds = function (serializedData) { + function _processData(data) { + if (lazy.WebReference.isReference(data)) { + const webRef = lazy.WebReference.fromJSON(data); + if (webRef instanceof lazy.WebWindow) { + const browsingContext = BrowsingContext.getCurrentTopByBrowserId( + webRef.uuid + ); + + webRef.uuid = lazy.TabManager.getIdForBrowsingContext(browsingContext); + data = webRef.toJSON(); + } + } else if (typeof data == "object") { + for (const entry in data) { + data[entry] = _processData(data[entry]); + } + } + + return data; + } + + return _processData(serializedData); +}; + +/** + * Resolve element from specified web reference identifier. + * + * @param {BrowsingContext} browsingContext + * The browsing context to retrieve the element from. + * @param {string} nodeId + * The WebReference uuid for a DOM element. + * @param {NodeCache} nodeCache + * Node cache that holds already seen WebElement and ShadowRoot references. + * + * @returns {Element} + * The DOM element that the identifier was generated for. + * + * @throws {NoSuchElementError} + * If the element 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. + */ +export function getKnownElement(browsingContext, nodeId, nodeCache) { + if (!isNodeReferenceKnown(browsingContext, nodeId, nodeCache)) { + throw new lazy.error.NoSuchElementError( + `The element with the reference ${nodeId} is not known in the current browsing context`, + { elementId: nodeId } + ); + } + + const node = nodeCache.getNode(browsingContext, nodeId); + + // Ensure the node is of the correct Node type. + if (node !== null && !lazy.dom.isElement(node)) { + throw new lazy.error.NoSuchElementError( + `The element with the reference ${nodeId} is not of type HTMLElement` + ); + } + + // If null, which may be the case if the element has been unwrapped from a + // weak reference, it is always considered stale. + if (node === null || lazy.dom.isStale(node)) { + throw new lazy.error.StaleElementReferenceError( + `The element with the reference ${nodeId} ` + + "is stale; either its node document is not the active document, " + + "or it is no longer connected to the DOM" + ); + } + + return node; +} + +/** + * Resolve ShadowRoot from specified web reference identifier. + * + * @param {BrowsingContext} browsingContext + * The browsing context to retrieve the shadow root from. + * @param {string} nodeId + * The WebReference uuid for a ShadowRoot. + * @param {NodeCache} nodeCache + * Node cache that holds already seen WebElement and ShadowRoot references. + * + * @returns {ShadowRoot} + * The ShadowRoot that the identifier was generated for. + * + * @throws {NoSuchShadowRootError} + * If the ShadowRoot doesn't exist in the current browsing context. + * @throws {DetachedShadowRootError} + * If the ShadowRoot is detached, indicating its node document is no + * longer the active document or it is no longer attached to the DOM. + */ +export function getKnownShadowRoot(browsingContext, nodeId, nodeCache) { + if (!isNodeReferenceKnown(browsingContext, nodeId, nodeCache)) { + throw new lazy.error.NoSuchShadowRootError( + `The shadow root with the reference ${nodeId} is not known in the current browsing context`, + { shadowId: nodeId } + ); + } + + const node = nodeCache.getNode(browsingContext, nodeId); + + // Ensure the node is of the correct Node type. + if (node !== null && !lazy.dom.isShadowRoot(node)) { + throw new lazy.error.NoSuchShadowRootError( + `The shadow root with the reference ${nodeId} is not of type ShadowRoot` + ); + } + + // If null, which may be the case if the element has been unwrapped from a + // weak reference, it is always considered stale. + if (node === null || lazy.dom.isDetached(node)) { + throw new lazy.error.DetachedShadowRootError( + `The shadow root with the reference ${nodeId} ` + + "is detached; either its node document is not the active document, " + + "or it is no longer connected to the DOM" + ); + } + + return node; +} + +/** + * Determines if the node reference is known for the given browsing context. + * + * For WebDriver classic only nodes from the same browsing context are + * allowed to be accessed. + * + * @param {BrowsingContext} browsingContext + * The browsing context the element has to be part of. + * @param {ElementIdentifier} nodeId + * The WebElement reference identifier for a DOM element. + * @param {NodeCache} nodeCache + * Node cache that holds already seen node references. + * + * @returns {boolean} + * True if the element is known in the given browsing context. + */ +function isNodeReferenceKnown(browsingContext, nodeId, nodeCache) { + const nodeDetails = nodeCache.getReferenceDetails(nodeId); + if (nodeDetails === null) { + return false; + } + + if (nodeDetails.isTopBrowsingContext) { + // As long as Navigables are not available any cross-group navigation will + // cause a swap of the current top-level browsing context. The only unique + // identifier in such a case is the browser id the top-level browsing + // context actually lives in. + return nodeDetails.browserId === browsingContext.browserId; + } + + return nodeDetails.browsingContextId === browsingContext.id; +} diff --git a/remote/marionette/l10n.sys.mjs b/remote/marionette/l10n.sys.mjs new file mode 100644 index 0000000000..ed9f307463 --- /dev/null +++ b/remote/marionette/l10n.sys.mjs @@ -0,0 +1,101 @@ +/* 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. + */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs", +}); + +ChromeUtils.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. + * + * @returns {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. + * + * @returns {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/message.sys.mjs b/remote/marionette/message.sys.mjs new file mode 100644 index 0000000000..d8b5dd60f9 --- /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. + * + * @returns {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. + * + * @returns {Array} + * Packet. + */ + toPacket() { + return [Command.Type, this.id, this.name, this.parameters]; + } + + /** + * Converts a data packet into {@link Command}. + * + * @param {Array.<number, number, *, *>} payload + * A four element array where the elements, in sequence, signifies + * message type, message ID, command name, and parameters. + * + * @returns {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. + * + * @returns {Array} + * Packet. + */ + toPacket() { + return [Response.Type, this.id, this.error, this.body]; + } + + /** + * Converts a data packet into {@link Response}. + * + * @param {Array.<number, number, ?, ?>} payload + * A four element array where the elements, in sequence, signifies + * message type, message ID, error, and result. + * + * @returns {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/moz.build b/remote/marionette/moz.build new file mode 100644 index 0000000000..52237f8719 --- /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.toml"] + +with Files("**"): + BUG_COMPONENT = ("Remote Protocol", "Marionette") diff --git a/remote/marionette/navigate.sys.mjs b/remote/marionette/navigate.sys.mjs new file mode 100644 index 0000000000..993ca75cf8 --- /dev/null +++ b/remote/marionette/navigate.sys.mjs @@ -0,0 +1,429 @@ +/* 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", + EventDispatcher: + "chrome://remote/content/marionette/actors/MarionetteEventsParent.sys.mjs", + Log: "chrome://remote/content/shared/Log.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", +}); + +ChromeUtils.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. + * + * @returns {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. + * + * @returns {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.fixupAndLoadURIString(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=} options.browsingContext + * Browsing context to observe. Defaults to the current browsing context. + * @param {boolean=} options.loadEventExpected + * If false, return immediately and don't wait for + * the navigation to be completed. Defaults to true. + * @param {boolean=} options.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 onPromptOpened = (_, data) => { + if (data.prompt.promptType === "beforeunload") { + // Ignore beforeunload prompts which are handled by the driver class. + return; + } + + lazy.logger.trace("Canceled page load listener because a dialog opened"); + checkDone({ finished: true }); + }; + + const onTimer = timer => { + // For the command "Element Click" we want to detect a potential navigation + // as early as possible. The `beforeunload` event is an indication for that + // but could still cause the navigation to get aborted by the user. As such + // wait a bit longer for the `unload` event to happen, which usually will + // occur pretty soon after `beforeunload`. + // + // Note that with WebDriver BiDi enabled the `beforeunload` prompts might + // not get implicitly accepted, so lets keep the timer around until we know + // that it is really not required. + 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.promptListener.on("opened", onPromptOpened); + 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 }); + } + } + }, + { + errorMessage: "Navigation timed out", + 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.promptListener?.off("opened", onPromptOpened); + 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..5acb455726 --- /dev/null +++ b/remote/marionette/packets.sys.mjs @@ -0,0 +1,424 @@ +/* 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, { + StreamUtils: "chrome://remote/content/marionette/stream-utils.sys.mjs", +}); + +ChromeUtils.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. + * + * @returns {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. + * + * @returns {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. + * + * @returns {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 {DebuggerTransport} transport + * The transport instance that will own the packet. + * @param {string} data + * 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..5238bf8347 --- /dev/null +++ b/remote/marionette/permissions.sys.mjs @@ -0,0 +1,95 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +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 = {}; + +function mapToInternalPermissionParameters(browsingContext, permissionType) { + const currentURI = browsingContext.currentWindowGlobal.documentURI; + + // storage-access is quite special... + if (permissionType === "storage-access") { + const thirdPartyPrincipalSite = Services.eTLD.getSite(currentURI); + + const topLevelURI = browsingContext.top.currentWindowGlobal.documentURI; + const topLevelPrincipal = + Services.scriptSecurityManager.createContentPrincipal(topLevelURI, {}); + + return { + name: "3rdPartyFrameStorage^" + thirdPartyPrincipalSite, + principal: topLevelPrincipal, + }; + } + + const currentPrincipal = + Services.scriptSecurityManager.createContentPrincipal(currentURI, {}); + + return { + name: permissionType, + principal: currentPrincipal, + }; +} + +/** + * Set a permission's state. + * Note: Currently just a shim to support testdriver's set_permission. + * + * @param {object} permissionType + * The Gecko internal permission type + * @param {string} state + * State of the permission. It can be `granted`, `denied` or `prompt`. + * @param {boolean} oneRealm + * Currently ignored + * @param {browsingContext=} browsingContext + * Current browsing context object + * @throws {UnsupportedOperationError} + * If `marionette.setpermission.enabled` is not set or + * an unsupported permission is used. + */ +permissions.set = function (permissionType, state, oneRealm, browsingContext) { + if (!lazy.MarionettePrefs.setPermissionEnabled) { + throw new lazy.error.UnsupportedOperationError( + "'Set Permission' is not available" + ); + } + + const { name, principal } = mapToInternalPermissionParameters( + browsingContext, + permissionType + ); + + switch (state) { + case "granted": { + Services.perms.addFromPrincipal( + principal, + name, + Services.perms.ALLOW_ACTION + ); + return; + } + case "denied": { + Services.perms.addFromPrincipal( + principal, + name, + Services.perms.DENY_ACTION + ); + return; + } + case "prompt": { + Services.perms.removeFromPrincipal(principal, name); + return; + } + default: + throw new lazy.error.UnsupportedOperationError( + "Unrecognized permission keyword for 'Set Permission' operation" + ); + } +}; diff --git a/remote/marionette/prefs.sys.mjs b/remote/marionette/prefs.sys.mjs new file mode 100644 index 0000000000..17df13d0fd --- /dev/null +++ b/remote/marionette/prefs.sys.mjs @@ -0,0 +1,175 @@ +/* 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. + * + * @returns {(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. + * + * Because we cannot trust the input of many of these preferences, + * this class provides abstraction that lets us safely deal with + * potentially malformed input. + * + * 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. + * + * @returns {boolean} + */ + get clickToStart() { + return this.get("debugging.clicktostart", false); + } + + /** + * The `marionette.port` preference, detailing which port + * the TCP server should listen on. + * + * @returns {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. + * + * @returns {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. + * + * @returns {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..23140fd49f --- /dev/null +++ b/remote/marionette/reftest.sys.mjs @@ -0,0 +1,921 @@ +/* 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, { + clearTimeout: "resource://gre/modules/Timer.sys.mjs", + E10SUtils: "resource://gre/modules/E10SUtils.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", + + AppInfo: "chrome://remote/content/shared/AppInfo.sys.mjs", + assert: "chrome://remote/content/shared/webdriver/Assert.sys.mjs", + 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", +}); + +ChromeUtils.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. + * + * @returns {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 timerId; + + let timeoutPromise = new Promise(resolve => { + timerId = lazy.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]); + lazy.clearTimeout(timerId); + 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}` + ); + if ( + lhs.canvas.width != rhs.canvas.width || + lhs.canvas.height != rhs.canvas.height + ) { + msg = + `Got different page sizes; test is ` + + `${lhs.canvas.width}x${lhs.canvas.height}px, ref is ` + + `${rhs.canvas.width}x${rhs.canvas.height}px`; + passed = false; + break; + } + 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, warnOnOverflow = true) { + 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, + warnOnOverflow + ); + } + } + + 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, false); + + 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, + background: true, + }); + const printSettings = lazy.print.getPrintSettings(settings); + + const binaryString = await lazy.print.printToBinaryString( + win.gBrowser.browsingContext, + printSettings + ); + + try { + const pdf = await this.loadPdf(binaryString); + let pages = this.getPages(pageRanges, url, pdf.numPages); + return [this.renderPages(pdf, pages), pages.size]; + } catch (e) { + lazy.logger.warn(`Loading of pdf failed`); + throw e; + } + } + + 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.type = "module"; + script.src = "resource://pdf.js/build/pdf.mjs"; + 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.mjs"; + } + + async loadPdf(data) { + 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..36e7a9d639 --- /dev/null +++ b/remote/marionette/server.sys.mjs @@ -0,0 +1,460 @@ +/* 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", + 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", + PollPromise: "chrome://remote/content/shared/Sync.sys.mjs", + Response: "chrome://remote/content/marionette/message.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "logger", () => + lazy.Log.get(lazy.Log.TYPES.MARIONETTE) +); +ChromeUtils.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. + * + * @returns {GeckoDriver} + * A driver instance. + */ + driverFactory() { + return new lazy.GeckoDriver(this); + } + + async setAcceptConnections(value) { + if (value) { + if (!this.socket) { + await lazy.PollPromise( + (resolve, reject) => { + try { + const flags = KeepWhenOffline | LoopbackOnly; + const backlog = 1; + this.socket = new lazy.ServerSocket(this.port, flags, backlog); + resolve(); + } catch (e) { + lazy.logger.debug( + `Could not bind to port ${this.port} (${e.name})` + ); + reject(); + } + }, + { interval: 250, timeout: 5000 } + ); + + // Since PollPromise doesn't throw when timeout expires, + // we can end up in the situation when the socket is undefined. + if (!this.socket) { + throw new Error(`Could not bind to port ${this.port}`); + } + + 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}. + */ + async start() { + if (this.alive) { + return; + } + + // Start socket server and listening for connection attempts + await this.setAcceptConnections(true); + lazy.MarionettePrefs.port = this.port; + this.alive = true; + } + + async stop() { + if (!this.alive) { + return; + } + + // Shutdown server socket, and no longer listen for new connections + await this.setAcceptConnections(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) { + const startTime = Cu.now(); + + 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); + + // Bug 1819029: Some older commands cannot return a response wrapped within + // a value field because it would break compatibility with geckodriver and + // Marionette client. It's unlikely that we are going to fix that. + // + // Warning: No more commands should be added to this list! + const commandsNoValueResponse = [ + "Marionette:Quit", + "WebDriver:FindElements", + "WebDriver:FindElementsFromShadowRoot", + "WebDriver:CloseChromeWindow", + "WebDriver:CloseWindow", + "WebDriver:FullscreenWindow", + "WebDriver:GetCookies", + "WebDriver:GetElementRect", + "WebDriver:GetTimeouts", + "WebDriver:GetWindowHandles", + "WebDriver:GetWindowRect", + "WebDriver:MaximizeWindow", + "WebDriver:MinimizeWindow", + "WebDriver:NewSession", + "WebDriver:NewWindow", + "WebDriver:SetWindowRect", + ]; + + if (rv != null) { + // By default the Response' constructor sets the body to `{ value: null }`. + // As such we only want to override the value if it's neither `null` nor + // `undefined`. + if (commandsNoValueResponse.includes(cmd.name)) { + resp.body = rv; + } else { + resp.body.value = rv; + } + } + + if (Services.profiler?.IsActive()) { + ChromeUtils.addProfilerMarker( + "Marionette: Command", + { startTime, category: "Remote-Protocol" }, + `${cmd.name} (${cmd.id})` + ); + } + } + + /** + * 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. + * + * @returns {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..5979280660 --- /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" +); + +ChromeUtils.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. + * + * @returns {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. + * + * @returns {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..284f5ce729 --- /dev/null +++ b/remote/marionette/sync.sys.mjs @@ -0,0 +1,538 @@ +/* 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"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs", + Log: "chrome://remote/content/shared/Log.sys.mjs", +}); + +ChromeUtils.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 {object=} options + * @param {number=} options.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=} options.interval + * Duration between each poll of ``func`` in milliseconds. + * Defaults to 10 milliseconds. + * + * @returns {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 {Function} fn + * 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 + * 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 with a TimeoutError. + * + * @returns {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 = () => { + const message = `${errorMessage} after ${timeout} ms`; + if (throws !== null) { + let err = new throws(message); + reject(err); + } else { + lazy.logger.warn(message, 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. + * + * @returns {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. + * + * @returns {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. + * + * @returns {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): void} 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): boolean=} 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. + * + * @returns {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): boolean=} 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. + * @param {number=} options.timeout + * Timeout duration in milliseconds, if provided. + * If specified, then the returned promise will be rejected with + * TimeoutError, if not already resolved, after this duration has elapsed. + * If not specified, then no timeout is used. Defaults to null. + * + * @returns {Promise.<Array<string, object>>} + * Promise which is either resolved to an array of ``subject``, and ``data`` + * from the observed notification, or rejected with TimeoutError after + * options.timeout milliseconds if specified. + * + * @throws {TypeError} + * @throws {RangeError} + */ +export function waitForObserverTopic(topic, options = {}) { + const { checkFn = null, timeout = null } = options; + if (typeof topic != "string") { + throw new TypeError(); + } + if ( + (checkFn != null && typeof checkFn != "function") || + (timeout !== null && typeof timeout != "number") + ) { + throw new TypeError(); + } + if (timeout && (!Number.isInteger(timeout) || timeout < 0)) { + throw new RangeError(); + } + + return new Promise((resolve, reject) => { + let timer; + + function cleanUp() { + Services.obs.removeObserver(observer, topic); + timer?.cancel(); + } + + function observer(subject, topic, data) { + lazy.logger.trace(`Received observer notification ${topic}`); + try { + if (checkFn && !checkFn(subject, data)) { + return; + } + cleanUp(); + resolve({ subject, data }); + } catch (ex) { + cleanUp(); + reject(ex); + } + } + + Services.obs.addObserver(observer, topic); + + if (timeout !== null) { + timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + timer.init( + () => { + cleanUp(); + reject( + new lazy.error.TimeoutError( + `waitForObserverTopic timed out after ${timeout} ms` + ) + ); + }, + timeout, + TYPE_ONE_SHOT + ); + } + }); +} 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/test_actors.js b/remote/marionette/test/xpcshell/test_actors.js new file mode 100644 index 0000000000..9b24d1d10f --- /dev/null +++ b/remote/marionette/test/xpcshell/test_actors.js @@ -0,0 +1,55 @@ +/* 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_task(function test_commandsActor_register() { + registerCommandsActor(); + unregisterCommandsActor(); + + registerCommandsActor(); + registerCommandsActor(); + unregisterCommandsActor(); +}); + +add_task(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(); +}); + +add_task(function test_eventsActor_enable_disable() { + enableEventsActor(); + disableEventsActor(); + + enableEventsActor(); + enableEventsActor(); + disableEventsActor(); +}); diff --git a/remote/marionette/test/xpcshell/test_browser.js b/remote/marionette/test/xpcshell/test_browser.js new file mode 100644 index 0000000000..fdd83ba7e3 --- /dev/null +++ b/remote/marionette/test/xpcshell/test_browser.js @@ -0,0 +1,21 @@ +const { Context } = ChromeUtils.importESModule( + "chrome://remote/content/marionette/browser.sys.mjs" +); + +add_task(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"); +}); + +add_task(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/); + } +}); diff --git a/remote/marionette/test/xpcshell/test_cookie.js b/remote/marionette/test/xpcshell/test_cookie.js new file mode 100644 index 0000000000..b5ce5e9008 --- /dev/null +++ b/remote/marionette/test/xpcshell/test_cookie.js @@ -0,0 +1,362 @@ +/* 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_task(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); +}); + +add_task(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/); +}); + +add_task(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]); +}); + +add_task(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); +}); diff --git a/remote/marionette/test/xpcshell/test_json.js b/remote/marionette/test/xpcshell/test_json.js new file mode 100644 index 0000000000..f606681a8e --- /dev/null +++ b/remote/marionette/test/xpcshell/test_json.js @@ -0,0 +1,472 @@ +const { json, getKnownElement, getKnownShadowRoot } = + ChromeUtils.importESModule("chrome://remote/content/marionette/json.sys.mjs"); +const { NodeCache } = ChromeUtils.importESModule( + "chrome://remote/content/shared/webdriver/NodeCache.sys.mjs" +); +const { ShadowRoot, WebElement, WebReference } = ChromeUtils.importESModule( + "chrome://remote/content/marionette/web-reference.sys.mjs" +); + +const MemoryReporter = Cc["@mozilla.org/memory-reporter-manager;1"].getService( + Ci.nsIMemoryReporterManager +); + +function setupTest() { + const browser = Services.appShell.createWindowlessBrowser(false); + const nodeCache = new NodeCache(); + + const videoEl = browser.document.createElement("video"); + browser.document.body.appendChild(videoEl); + + const svgEl = browser.document.createElementNS( + "http://www.w3.org/2000/svg", + "rect" + ); + browser.document.body.appendChild(svgEl); + + const shadowRoot = videoEl.openOrClosedShadowRoot; + + const iframeEl = browser.document.createElement("iframe"); + browser.document.body.appendChild(iframeEl); + const childEl = iframeEl.contentDocument.createElement("div"); + + return { + browser, + browsingContext: browser.browsingContext, + nodeCache, + childEl, + iframeEl, + seenNodeIds: new Map(), + shadowRoot, + svgEl, + videoEl, + }; +} + +function assert_cloned_value(value, clonedValue, nodeCache, seenNodes = []) { + const { seenNodeIds, serializedValue } = json.clone(value, nodeCache); + + deepEqual(serializedValue, clonedValue); + deepEqual([...seenNodeIds.values()], seenNodes); +} + +add_task(function test_clone_generalTypes() { + const { nodeCache } = setupTest(); + + // null + assert_cloned_value(undefined, null, nodeCache); + assert_cloned_value(null, null, nodeCache); + + // primitives + assert_cloned_value(true, true, nodeCache); + assert_cloned_value(42, 42, nodeCache); + assert_cloned_value("foo", "foo", nodeCache); + + // toJSON + assert_cloned_value( + { + toJSON() { + return "foo"; + }, + }, + "foo", + nodeCache + ); +}); + +add_task(function test_clone_ShadowRoot() { + const { nodeCache, seenNodeIds, shadowRoot } = setupTest(); + + const shadowRootRef = nodeCache.getOrCreateNodeReference( + shadowRoot, + seenNodeIds + ); + assert_cloned_value( + shadowRoot, + WebReference.from(shadowRoot, shadowRootRef).toJSON(), + nodeCache, + seenNodeIds + ); +}); + +add_task(function test_clone_WebElement() { + const { videoEl, nodeCache, seenNodeIds, svgEl } = setupTest(); + + const videoElRef = nodeCache.getOrCreateNodeReference(videoEl, seenNodeIds); + assert_cloned_value( + videoEl, + WebReference.from(videoEl, videoElRef).toJSON(), + nodeCache, + seenNodeIds + ); + + // Check an element with a different namespace + const svgElRef = nodeCache.getOrCreateNodeReference(svgEl, seenNodeIds); + assert_cloned_value( + svgEl, + WebReference.from(svgEl, svgElRef).toJSON(), + nodeCache, + seenNodeIds + ); +}); + +add_task(function test_clone_Sequences() { + const { videoEl, nodeCache, seenNodeIds } = setupTest(); + + const videoElRef = nodeCache.getOrCreateNodeReference(videoEl, seenNodeIds); + + const input = [ + null, + true, + [42], + videoEl, + { + toJSON() { + return "foo"; + }, + }, + { bar: "baz" }, + ]; + + assert_cloned_value( + input, + [ + null, + true, + [42], + { [WebElement.Identifier]: videoElRef }, + "foo", + { bar: "baz" }, + ], + nodeCache, + seenNodeIds + ); +}); + +add_task(function test_clone_objects() { + const { videoEl, nodeCache, seenNodeIds } = setupTest(); + + const videoElRef = nodeCache.getOrCreateNodeReference(videoEl, seenNodeIds); + + const input = { + null: null, + boolean: true, + array: [42], + element: videoEl, + toJSON: { + toJSON() { + return "foo"; + }, + }, + object: { bar: "baz" }, + }; + + assert_cloned_value( + input, + { + null: null, + boolean: true, + array: [42], + element: { [WebElement.Identifier]: videoElRef }, + toJSON: "foo", + object: { bar: "baz" }, + }, + nodeCache, + seenNodeIds + ); +}); + +add_task(function test_clone_сyclicReference() { + const { nodeCache } = setupTest(); + + // 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/); +}); + +add_task(function test_deserialize_generalTypes() { + const { browsingContext, nodeCache } = setupTest(); + + // null + equal(json.deserialize(undefined, nodeCache, browsingContext), undefined); + equal(json.deserialize(null, nodeCache, browsingContext), null); + + // primitives + equal(json.deserialize(true, nodeCache, browsingContext), true); + equal(json.deserialize(42, nodeCache, browsingContext), 42); + equal(json.deserialize("foo", nodeCache, browsingContext), "foo"); +}); + +add_task(function test_deserialize_ShadowRoot() { + const { browsingContext, nodeCache, seenNodeIds, shadowRoot } = setupTest(); + const seenNodes = new Set(); + + // Fails to resolve for unknown elements + const unknownShadowRootId = { [ShadowRoot.Identifier]: "foo" }; + Assert.throws(() => { + json.deserialize( + unknownShadowRootId, + nodeCache, + browsingContext, + seenNodes + ); + }, /NoSuchShadowRootError/); + + const shadowRootRef = nodeCache.getOrCreateNodeReference( + shadowRoot, + seenNodeIds + ); + const shadowRootEl = { [ShadowRoot.Identifier]: shadowRootRef }; + + // Fails to resolve for missing window reference + Assert.throws(() => json.deserialize(shadowRootEl, nodeCache), /TypeError/); + + // Previously seen element is associated with original web element reference + seenNodes.add(shadowRootRef); + const root = json.deserialize( + shadowRootEl, + nodeCache, + browsingContext, + seenNodes + ); + deepEqual(root, shadowRoot); + deepEqual(root, nodeCache.getNode(browsingContext, shadowRootRef)); +}); + +add_task(function test_deserialize_WebElement() { + const { browser, browsingContext, videoEl, nodeCache, seenNodeIds } = + setupTest(); + const seenNodes = new Set(); + + // Fails to resolve for unknown elements + const unknownWebElId = { [WebElement.Identifier]: "foo" }; + Assert.throws(() => { + json.deserialize(unknownWebElId, nodeCache, browsingContext, seenNodes); + }, /NoSuchElementError/); + + const videoElRef = nodeCache.getOrCreateNodeReference(videoEl, seenNodeIds); + const htmlWebEl = { [WebElement.Identifier]: videoElRef }; + + // Fails to resolve for missing window reference + Assert.throws(() => json.deserialize(htmlWebEl, nodeCache), /TypeError/); + + // Previously seen element is associated with original web element reference + seenNodes.add(videoElRef); + const el = json.deserialize(htmlWebEl, nodeCache, browsingContext, seenNodes); + deepEqual(el, videoEl); + deepEqual(el, nodeCache.getNode(browser.browsingContext, videoElRef)); +}); + +add_task(function test_deserialize_Sequences() { + const { browsingContext, videoEl, nodeCache, seenNodeIds } = setupTest(); + const seenNodes = new Set(); + + const videoElRef = nodeCache.getOrCreateNodeReference(videoEl, seenNodeIds); + seenNodes.add(videoElRef); + + const input = [ + null, + true, + [42], + { [WebElement.Identifier]: videoElRef }, + { bar: "baz" }, + ]; + + const actual = json.deserialize(input, nodeCache, browsingContext, seenNodes); + + equal(actual[0], null); + equal(actual[1], true); + deepEqual(actual[2], [42]); + deepEqual(actual[3], videoEl); + deepEqual(actual[4], { bar: "baz" }); +}); + +add_task(function test_deserialize_objects() { + const { browsingContext, videoEl, nodeCache, seenNodeIds } = setupTest(); + const seenNodes = new Set(); + + const videoElRef = nodeCache.getOrCreateNodeReference(videoEl, seenNodeIds); + seenNodes.add(videoElRef); + + const input = { + null: null, + boolean: true, + array: [42], + element: { [WebElement.Identifier]: videoElRef }, + object: { bar: "baz" }, + }; + + const actual = json.deserialize(input, nodeCache, browsingContext, seenNodes); + + equal(actual.null, null); + equal(actual.boolean, true); + deepEqual(actual.array, [42]); + deepEqual(actual.element, videoEl); + deepEqual(actual.object, { bar: "baz" }); + + nodeCache.clear({ all: true }); +}); + +add_task(async function test_getKnownElement() { + const { browser, nodeCache, seenNodeIds, shadowRoot, videoEl } = setupTest(); + const seenNodes = new Set(); + + // Unknown element reference + Assert.throws(() => { + getKnownElement(browser.browsingContext, "foo", nodeCache, seenNodes); + }, /NoSuchElementError/); + + // With a ShadowRoot reference + const shadowRootRef = nodeCache.getOrCreateNodeReference( + shadowRoot, + seenNodeIds + ); + seenNodes.add(shadowRootRef); + + Assert.throws(() => { + getKnownElement( + browser.browsingContext, + shadowRootRef, + nodeCache, + seenNodes + ); + }, /NoSuchElementError/); + + let detachedEl = browser.document.createElement("div"); + const detachedElRef = nodeCache.getOrCreateNodeReference( + detachedEl, + seenNodeIds + ); + seenNodes.add(detachedElRef); + + // Element not connected to the DOM + Assert.throws(() => { + getKnownElement( + browser.browsingContext, + detachedElRef, + nodeCache, + seenNodes + ); + }, /StaleElementReferenceError/); + + // Element garbage collected + detachedEl = null; + + await new Promise(resolve => MemoryReporter.minimizeMemoryUsage(resolve)); + Assert.throws(() => { + getKnownElement( + browser.browsingContext, + detachedElRef, + nodeCache, + seenNodes + ); + }, /StaleElementReferenceError/); + + // Known element reference + const videoElRef = nodeCache.getOrCreateNodeReference(videoEl, seenNodeIds); + seenNodes.add(videoElRef); + + equal( + getKnownElement(browser.browsingContext, videoElRef, nodeCache, seenNodes), + videoEl + ); +}); + +add_task(async function test_getKnownShadowRoot() { + const { browser, nodeCache, seenNodeIds, shadowRoot, videoEl } = setupTest(); + const seenNodes = new Set(); + + const videoElRef = nodeCache.getOrCreateNodeReference(videoEl, seenNodeIds); + seenNodes.add(videoElRef); + + // Unknown ShadowRoot reference + Assert.throws(() => { + getKnownShadowRoot(browser.browsingContext, "foo", nodeCache, seenNodes); + }, /NoSuchShadowRootError/); + + // With a videoElement reference + Assert.throws(() => { + getKnownShadowRoot( + browser.browsingContext, + videoElRef, + nodeCache, + seenNodes + ); + }, /NoSuchShadowRootError/); + + // Known ShadowRoot reference + const shadowRootRef = nodeCache.getOrCreateNodeReference( + shadowRoot, + seenNodeIds + ); + seenNodes.add(shadowRootRef); + + equal( + getKnownShadowRoot( + browser.browsingContext, + shadowRootRef, + nodeCache, + seenNodes + ), + shadowRoot + ); + + // Detached ShadowRoot host + let el = browser.document.createElement("div"); + let detachedShadowRoot = el.attachShadow({ mode: "open" }); + detachedShadowRoot.innerHTML = "<input></input>"; + + const detachedShadowRootRef = nodeCache.getOrCreateNodeReference( + detachedShadowRoot, + seenNodeIds + ); + seenNodes.add(detachedShadowRootRef); + + // ... not connected to the DOM + Assert.throws(() => { + getKnownShadowRoot( + browser.browsingContext, + detachedShadowRootRef, + nodeCache, + seenNodes + ); + }, /DetachedShadowRootError/); + + // ... host and shadow root garbage collected + el = null; + detachedShadowRoot = null; + + await new Promise(resolve => MemoryReporter.minimizeMemoryUsage(resolve)); + Assert.throws(() => { + getKnownShadowRoot( + browser.browsingContext, + detachedShadowRootRef, + nodeCache, + seenNodes + ); + }, /DetachedShadowRootError/); +}); diff --git a/remote/marionette/test/xpcshell/test_message.js b/remote/marionette/test/xpcshell/test_message.js new file mode 100644 index 0000000000..9926aea191 --- /dev/null +++ b/remote/marionette/test/xpcshell/test_message.js @@ -0,0 +1,245 @@ +/* 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_task(function test_Message_Origin() { + equal(0, Message.Origin.Client); + equal(1, Message.Origin.Server); +}); + +add_task(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/ + ); +}); + +add_task(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); +}); + +add_task(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); +}); + +add_task(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]); +}); + +add_task(function test_Command_toString() { + let cmd = new Command(42, "foo", { bar: "baz" }); + equal(JSON.stringify(cmd.toPacket()), cmd.toString()); +}); + +add_task(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) + ); +}); + +add_task(function test_Command_Type() { + equal(0, Command.Type); +}); + +add_task(function test_Response_ctor() { + let handler = () => { + throw new Error("foo"); + }; + + 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_); +}); + +add_task(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); +}); + +add_task(function test_Response_send() { + let fired = false; + let resp = new Response(42, () => (fired = true)); + resp.send(); + equal(true, resp.sent); + equal(true, fired); +}); + +add_task(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/); +}); + +add_task(function test_Response_sendError_body() { + let resp = new Response(42, r => equal(null, r.body)); + resp.sendError(new error.WebDriverError()); +}); + +add_task(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); +}); + +add_task(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); +}); + +add_task(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]); +}); + +add_task(function test_Response_toString() { + let resp = new Response(42, () => {}); + resp.error = "foo"; + resp.body = "bar"; + + equal(JSON.stringify(resp.toPacket()), resp.toString()); +}); + +add_task(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]); +}); + +add_task(function test_Response_Type() { + equal(1, Response.Type); +}); diff --git a/remote/marionette/test/xpcshell/test_navigate.js b/remote/marionette/test/xpcshell/test_navigate.js new file mode 100644 index 0000000000..9b5e2a1bc7 --- /dev/null +++ b/remote/marionette/test/xpcshell/test_navigate.js @@ -0,0 +1,90 @@ +/* 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_task(function test_isLoadEventExpectedForCurrent() { + Assert.throws( + () => navigate.isLoadEventExpected(undefined), + /Expected at least one URL/ + ); + + ok(navigate.isLoadEventExpected(new URL("http://a/"))); +}); + +add_task(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); + } +}); + +add_task(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 + ); + } +}); diff --git a/remote/marionette/test/xpcshell/test_prefs.js b/remote/marionette/test/xpcshell/test_prefs.js new file mode 100644 index 0000000000..ac3432544b --- /dev/null +++ b/remote/marionette/test/xpcshell/test_prefs.js @@ -0,0 +1,98 @@ +/* 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_task(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/); +}); + +add_task(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/); +}); + +add_task(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(); + } +}); + +add_task(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(); + } +}); + +add_task(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); + } +}); + +add_task(function test_MarionettePrefs_getters() { + equal(false, MarionettePrefs.clickToStart); + equal(2828, MarionettePrefs.port); +}); + +add_task(function test_MarionettePrefs_setters() { + try { + MarionettePrefs.port = 777; + equal(777, MarionettePrefs.port); + } finally { + Services.prefs.clearUserPref("marionette.port"); + } +}); diff --git a/remote/marionette/test/xpcshell/test_sync.js b/remote/marionette/test/xpcshell/test_sync.js new file mode 100644 index 0000000000..87ec44e960 --- /dev/null +++ b/remote/marionette/test/xpcshell/test_sync.js @@ -0,0 +1,419 @@ +/* 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_task(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)); +}); + +add_task(function test_PollPromise_funcTypes() { + for (let type of ["foo", 42, null, undefined, true, [], {}]) { + Assert.throws(() => new PollPromise(type), /TypeError/); + } + new PollPromise(() => {}); + new PollPromise(function () {}); +}); + +add_task(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 }); + } +}); + +add_task(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 }); +}); + +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_task(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(); + }); +}); + +add_task(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 }); +}); + +add_task(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" + ); + } +}); + +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_task(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/ + ); + } +}); + +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); + } +}); + +add_task(async function test_waitForObserverTopic_timeoutTypes() { + for (let timeout of ["foo", true, [], {}]) { + Assert.throws( + () => waitForObserverTopic("message", { timeout }), + /TypeError/ + ); + } + for (let timeout of [1.2, -1]) { + Assert.throws( + () => waitForObserverTopic("message", { timeout }), + /RangeError/ + ); + } + for (let timeout of [null, undefined, 42]) { + let data = { foo: "bar" }; + let sent = waitForObserverTopic("message", { timeout }); + Services.obs.notifyObservers(this, "message", data); + let result = await sent; + equal(this, result.subject); + equal(data, result.data); + } +}); + +add_task(async function test_waitForObserverTopic_timeoutElapse() { + try { + await waitForObserverTopic("message", { timeout: 0 }); + ok(false, "Expected Timeout error not raised"); + } catch (e) { + ok( + e.message.includes("waitForObserverTopic timed out after"), + "Expected error received" + ); + } +}); diff --git a/remote/marionette/test/xpcshell/test_web-reference.js b/remote/marionette/test/xpcshell/test_web-reference.js new file mode 100644 index 0000000000..4884901ae5 --- /dev/null +++ b/remote/marionette/test/xpcshell/test_web-reference.js @@ -0,0 +1,293 @@ +/* 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 { ShadowRoot, WebElement, WebFrame, WebReference, WebWindow } = + ChromeUtils.importESModule( + "chrome://remote/content/marionette/web-reference.sys.mjs" + ); +const { NodeCache } = ChromeUtils.importESModule( + "chrome://remote/content/shared/webdriver/NodeCache.sys.mjs" +); + +const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; + +class MockElement { + constructor(tagName, attrs = {}) { + this.tagName = tagName; + this.localName = tagName; + + this.isConnected = false; + this.ownerGlobal = { + document: { + isActive() { + return true; + }, + }, + }; + + 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 MockXULElement extends MockElement { + 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 xulEl = new MockXULElement("text"); + +const domElInPrivilegedDocument = new MockElement("input", { + nodePrincipal: { isSystemPrincipal: true }, +}); +const xulElInPrivilegedDocument = new MockXULElement("text", { + nodePrincipal: { isSystemPrincipal: true }, +}); + +function setupTest() { + const browser = Services.appShell.createWindowlessBrowser(false); + + browser.document.body.innerHTML = ` + <div id="foo" style="margin: 50px"> + <iframe></iframe> + <video></video> + <svg xmlns="http://www.w3.org/2000/svg"></svg> + <textarea></textarea> + </div> + `; + + const divEl = browser.document.querySelector("div"); + const svgEl = browser.document.querySelector("svg"); + const textareaEl = browser.document.querySelector("textarea"); + const videoEl = browser.document.querySelector("video"); + + const iframeEl = browser.document.querySelector("iframe"); + const childEl = iframeEl.contentDocument.createElement("div"); + iframeEl.contentDocument.body.appendChild(childEl); + + const shadowRoot = videoEl.openOrClosedShadowRoot; + + return { + browser, + childEl, + divEl, + iframeEl, + nodeCache: new NodeCache(), + shadowRoot, + svgEl, + textareaEl, + videoEl, + }; +} + +add_task(function test_WebReference_ctor() { + const 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/); + } +}); + +add_task(function test_WebReference_from() { + const { divEl, iframeEl } = setupTest(); + + ok(WebReference.from(divEl) instanceof WebElement); + ok(WebReference.from(xulEl) instanceof WebElement); + ok(WebReference.from(divEl.ownerGlobal) instanceof WebWindow); + ok(WebReference.from(iframeEl.contentWindow) instanceof WebFrame); + ok(WebReference.from(domElInPrivilegedDocument) instanceof WebElement); + ok(WebReference.from(xulElInPrivilegedDocument) instanceof WebElement); + + Assert.throws(() => WebReference.from({}), /InvalidArgumentError/); +}); + +add_task(function test_WebReference_fromJSON_malformed() { + Assert.throws(() => WebReference.fromJSON({}), /InvalidArgumentError/); + Assert.throws(() => WebReference.fromJSON(null), /InvalidArgumentError/); +}); + +add_task(function test_WebReference_fromJSON_ShadowRoot() { + const { Identifier } = ShadowRoot; + + const ref = { [Identifier]: "foo" }; + const shadowRootEl = WebReference.fromJSON(ref); + ok(shadowRootEl instanceof ShadowRoot); + equal(shadowRootEl.uuid, "foo"); + + let identifierPrecedence = { + [Identifier]: "identifier-uuid", + }; + const precedenceShadowRoot = WebReference.fromJSON(identifierPrecedence); + ok(precedenceShadowRoot instanceof ShadowRoot); + equal(precedenceShadowRoot.uuid, "identifier-uuid"); +}); + +add_task(function test_WebReference_fromJSON_WebElement() { + const { Identifier } = WebElement; + + const ref = { [Identifier]: "foo" }; + const webEl = WebReference.fromJSON(ref); + ok(webEl instanceof WebElement); + equal(webEl.uuid, "foo"); + + let identifierPrecedence = { + [Identifier]: "identifier-uuid", + }; + const precedenceEl = WebReference.fromJSON(identifierPrecedence); + ok(precedenceEl instanceof WebElement); + equal(precedenceEl.uuid, "identifier-uuid"); +}); + +add_task(function test_WebReference_fromJSON_WebFrame() { + const ref = { [WebFrame.Identifier]: "foo" }; + const frame = WebReference.fromJSON(ref); + ok(frame instanceof WebFrame); + equal(frame.uuid, "foo"); +}); + +add_task(function test_WebReference_fromJSON_WebWindow() { + const ref = { [WebWindow.Identifier]: "foo" }; + const win = WebReference.fromJSON(ref); + + ok(win instanceof WebWindow); + equal(win.uuid, "foo"); +}); + +add_task(function test_WebReference_is() { + const a = new WebReference("a"); + const b = new WebReference("b"); + + ok(a.is(a)); + ok(b.is(b)); + ok(!a.is(b)); + ok(!b.is(a)); + + ok(!a.is({})); +}); + +add_task(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" })); +}); + +add_task(function test_ShadowRoot_fromJSON() { + const { Identifier } = ShadowRoot; + + const shadowRoot = ShadowRoot.fromJSON({ [Identifier]: "foo" }); + ok(shadowRoot instanceof ShadowRoot); + equal(shadowRoot.uuid, "foo"); + + Assert.throws(() => ShadowRoot.fromJSON({}), /InvalidArgumentError/); +}); + +add_task(function test_ShadowRoot_fromUUID() { + const shadowRoot = ShadowRoot.fromUUID("baz"); + + ok(shadowRoot instanceof ShadowRoot); + equal(shadowRoot.uuid, "baz"); + + Assert.throws(() => ShadowRoot.fromUUID(), /InvalidArgumentError/); +}); + +add_task(function test_ShadowRoot_toJSON() { + const { Identifier } = ShadowRoot; + + const shadowRoot = new ShadowRoot("foo"); + const json = shadowRoot.toJSON(); + + ok(Identifier in json); + equal(json[Identifier], "foo"); +}); + +add_task(function test_WebElement_fromJSON() { + const { Identifier } = WebElement; + + const el = WebElement.fromJSON({ [Identifier]: "foo" }); + ok(el instanceof WebElement); + equal(el.uuid, "foo"); + + Assert.throws(() => WebElement.fromJSON({}), /InvalidArgumentError/); +}); + +add_task(function test_WebElement_fromUUID() { + const domWebEl = WebElement.fromUUID("bar"); + + ok(domWebEl instanceof WebElement); + equal(domWebEl.uuid, "bar"); + + Assert.throws(() => WebElement.fromUUID(), /InvalidArgumentError/); +}); + +add_task(function test_WebElement_toJSON() { + const { Identifier } = WebElement; + + const el = new WebElement("foo"); + const json = el.toJSON(); + + ok(Identifier in json); + equal(json[Identifier], "foo"); +}); + +add_task(function test_WebFrame_fromJSON() { + const ref = { [WebFrame.Identifier]: "foo" }; + const win = WebFrame.fromJSON(ref); + + ok(win instanceof WebFrame); + equal(win.uuid, "foo"); +}); + +add_task(function test_WebFrame_toJSON() { + const frame = new WebFrame("foo"); + const json = frame.toJSON(); + + ok(WebFrame.Identifier in json); + equal(json[WebFrame.Identifier], "foo"); +}); + +add_task(function test_WebWindow_fromJSON() { + const ref = { [WebWindow.Identifier]: "foo" }; + const win = WebWindow.fromJSON(ref); + + ok(win instanceof WebWindow); + equal(win.uuid, "foo"); +}); + +add_task(function test_WebWindow_toJSON() { + const win = new WebWindow("foo"); + const json = win.toJSON(); + + ok(WebWindow.Identifier in json); + equal(json[WebWindow.Identifier], "foo"); +}); diff --git a/remote/marionette/test/xpcshell/xpcshell.toml b/remote/marionette/test/xpcshell/xpcshell.toml new file mode 100644 index 0000000000..46ee8581fd --- /dev/null +++ b/remote/marionette/test/xpcshell/xpcshell.toml @@ -0,0 +1,20 @@ +[DEFAULT] +skip-if = ["appname == 'thunderbird'"] + +["test_actors.js"] + +["test_browser.js"] + +["test_cookie.js"] + +["test_json.js"] + +["test_message.js"] + +["test_navigate.js"] + +["test_prefs.js"] + +["test_sync.js"] + +["test_web-reference.js"] diff --git a/remote/marionette/transport.sys.mjs b/remote/marionette/transport.sys.mjs new file mode 100644 index 0000000000..3c05c8603e --- /dev/null +++ b/remote/marionette/transport.sys.mjs @@ -0,0 +1,527 @@ +/* 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, { + 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", +}); + +ChromeUtils.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: + * + * - params + * {nsIAsyncOutputStream} output + * The stream to copy to. + * - returns {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 + * + * @returns {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: + * + * - params + * {nsIAsyncInputStream} input + * The stream to copy from. + * - returns {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). + * + * @returns {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. + * + * @returns {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; + }, +}; diff --git a/remote/marionette/web-reference.sys.mjs b/remote/marionette/web-reference.sys.mjs new file mode 100644 index 0000000000..5d2d510265 --- /dev/null +++ b/remote/marionette/web-reference.sys.mjs @@ -0,0 +1,297 @@ +/* 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", + dom: "chrome://remote/content/shared/DOM.sys.mjs", + error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs", + generateUUID: "chrome://remote/content/shared/UUID.sys.mjs", + pprint: "chrome://remote/content/shared/Format.sys.mjs", +}); + +/** + * 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. + * + * @returns {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|MockXULElement)} 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. + * + * @returns {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 = lazy.generateUUID(); + } + + if (lazy.dom.isShadowRoot(node) && !lazy.dom.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 (lazy.dom.isElement(node)) { + return new WebElement(uuid); + } else if (lazy.dom.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. + * + * @returns {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}` + ); + } + + /** + * Checks if <var>obj<var> is a {@link WebReference} reference. + * + * @param {Object<string, string>} obj + * Object that represents a {@link WebReference}. + * + * @returns {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; + } +} + +/** + * 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); + } + + /** + * Constructs a {@link ShadowRoot} 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 shadow root representations. + * + * @param {string} uuid + * UUID to be associated with the web reference. + * + * @returns {ShadowRoot} + * The shadow root reference. + * + * @throws {InvalidArgumentError} + * If <var>uuid</var> is not a string. + */ + static fromUUID(uuid) { + lazy.assert.string(uuid); + + return new ShadowRoot(uuid); + } +} + +ShadowRoot.Identifier = "shadow-6066-11e4-a52e-4f735466cecf"; + +/** + * 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); + } + + /** + * 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. + * + * @returns {WebElement} + * The web element reference. + * + * @throws {InvalidArgumentError} + * If <var>uuid</var> is not a string. + */ + static fromUUID(uuid) { + return new WebElement(uuid); + } +} + +WebElement.Identifier = "element-6066-11e4-a52e-4f735466cecf"; + +/** + * 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"; + +/** + * 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"; diff --git a/remote/marionette/webauthn.sys.mjs b/remote/marionette/webauthn.sys.mjs new file mode 100644 index 0000000000..c52bf6cb5c --- /dev/null +++ b/remote/marionette/webauthn.sys.mjs @@ -0,0 +1,134 @@ +/* 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 = {}; + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "webauthnService", + "@mozilla.org/webauthn/service;1", + "nsIWebAuthnService" +); + +/** @namespace */ +export const webauthn = {}; + +/** + * Add a virtual authenticator. + * + * @param {string} protocol one of "ctap1/u2f", "ctap2", "ctap2_1" + * @param {string} transport one of "usb", "nfc", "ble", "smart-card", + * "hybrid", "internal" + * @param {boolean} hasResidentKey + * @param {boolean} hasUserVerification + * @param {boolean} isUserConsenting + * @param {boolean} isUserVerified + * @returns {id} the id of the added authenticator + */ +webauthn.addVirtualAuthenticator = function ( + protocol, + transport, + hasResidentKey, + hasUserVerification, + isUserConsenting, + isUserVerified +) { + return lazy.webauthnService.addVirtualAuthenticator( + protocol, + transport, + hasResidentKey, + hasUserVerification, + isUserConsenting, + isUserVerified + ); +}; + +/** + * Removes a virtual authenticator. + * + * @param {id} authenticatorId the id of the virtual authenticator + */ +webauthn.removeVirtualAuthenticator = function (authenticatorId) { + lazy.webauthnService.removeVirtualAuthenticator(authenticatorId); +}; + +/** + * Adds a credential to a previously-added virtual authenticator. + * + * @param {id} authenticatorId the id of the virtual authenticator + * @param {string} credentialId a probabilistically-unique byte sequence + * identifying a public key credential source and its + * authentication assertions (encoded using Base64url + * Encoding). + * @param {boolean} isResidentCredential if set to true, a client-side + * discoverable credential is created. If set to false, a + * server-side credential is created instead. + * @param {string} rpId The Relying Party ID the credential is scoped to. + * @param {string} privateKey An asymmetric key package containing a single + * private key per RFC5958, encoded using Base64url Encoding. + * @param {string} userHandle The userHandle associated to the credential + * encoded using Base64url Encoding. + * @param {number} signCount The initial value for a signature counter + * associated to the public key credential source. + */ +webauthn.addCredential = function ( + authenticatorId, + credentialId, + isResidentCredential, + rpId, + privateKey, + userHandle, + signCount +) { + lazy.webauthnService.addCredential( + authenticatorId, + credentialId, + isResidentCredential, + rpId, + privateKey, + userHandle, + signCount + ); +}; + +/** + * Gets all credentials from a virtual authenticator. + * + * @param {id} authenticatorId the id of the virtual authenticator + * @returns {object} the credentials on the authenticator + */ +webauthn.getCredentials = function (authenticatorId) { + return lazy.webauthnService.getCredentials(authenticatorId); +}; + +/** + * Removes a credential from a virtual authenticator. + * + * @param {id} authenticatorId the id of the virtual authenticator + * @param {string} credentialId the id of the credential + */ +webauthn.removeCredential = function (authenticatorId, credentialId) { + lazy.webauthnService.removeCredential(authenticatorId, credentialId); +}; + +/** + * Removes all credentials from a virtual authenticator. + * + * @param {id} authenticatorId the id of the virtual authenticator + */ +webauthn.removeAllCredentials = function (authenticatorId) { + lazy.webauthnService.removeAllCredentials(authenticatorId); +}; + +/** + * Sets the "isUserVerified" bit on a virtual authenticator. + * + * @param {id} authenticatorId the id of the virtual authenticator + * @param {bool} isUserVerified the value to set the "isUserVerified" bit to + */ +webauthn.setUserVerified = function (authenticatorId, isUserVerified) { + lazy.webauthnService.setUserVerified(authenticatorId, isUserVerified); +}; |