diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-28 14:29:10 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-28 14:29:10 +0000 |
commit | 2aa4a82499d4becd2284cdb482213d541b8804dd (patch) | |
tree | b80bf8bf13c3766139fbacc530efd0dd9d54394c /testing/marionette | |
parent | Initial commit. (diff) | |
download | firefox-2aa4a82499d4becd2284cdb482213d541b8804dd.tar.xz firefox-2aa4a82499d4becd2284cdb482213d541b8804dd.zip |
Adding upstream version 86.0.1.upstream/86.0.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'testing/marionette')
326 files changed, 53356 insertions, 0 deletions
diff --git a/testing/marionette/.eslintrc.js b/testing/marionette/.eslintrc.js new file mode 100644 index 0000000000..43fe24f843 --- /dev/null +++ b/testing/marionette/.eslintrc.js @@ -0,0 +1,16 @@ +/* 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-fallthrough": "error", + "no-undef-init": "error", + "no-var": "error", + }, +}; diff --git a/testing/marionette/README b/testing/marionette/README new file mode 100644 index 0000000000..6dd268afec --- /dev/null +++ b/testing/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 and Fennec. 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/marionette/ diff --git a/testing/marionette/accessibility.js b/testing/marionette/accessibility.js new file mode 100644 index 0000000000..99aa211d74 --- /dev/null +++ b/testing/marionette/accessibility.js @@ -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/. */ + +"use strict"; + +const EXPORTED_SYMBOLS = ["accessibility"]; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + error: "chrome://marionette/content/error.js", + Log: "chrome://marionette/content/log.js", +}); + +XPCOMUtils.defineLazyGetter(this, "logger", () => Log.get()); + +XPCOMUtils.defineLazyGetter(this, "service", () => { + try { + return Cc["@mozilla.org/accessibilityService;1"].getService( + Ci.nsIAccessibilityService + ); + } catch (e) { + logger.warn("Accessibility module is not present"); + return undefined; + } +}); + +/** @namespace */ +this.accessibility = { + get service() { + return service; + }, +}; + +/** + * Accessible states used to check element"s state from the accessiblity API + * perspective. + * + * Note: if gecko is built with --disable-accessibility, the interfaces + * are not defined. This is why we use getters instead to be able to use + * these statically. + */ +accessibility.State = { + get Unavailable() { + return Ci.nsIAccessibleStates.STATE_UNAVAILABLE; + }, + get Focusable() { + return Ci.nsIAccessibleStates.STATE_FOCUSABLE; + }, + get Selectable() { + return Ci.nsIAccessibleStates.STATE_SELECTABLE; + }, + get Selected() { + return Ci.nsIAccessibleStates.STATE_SELECTED; + }, +}; + +/** + * Accessible object roles that support some action. + */ +accessibility.ActionableRoles = new Set([ + "checkbutton", + "check menu item", + "check rich option", + "combobox", + "combobox option", + "entry", + "key", + "link", + "listbox option", + "listbox rich option", + "menuitem", + "option", + "outlineitem", + "pagetab", + "pushbutton", + "radiobutton", + "radio menu item", + "rowheader", + "slider", + "spinbutton", + "switch", +]); + +/** + * Factory function that constructs a new {@code accessibility.Checks} + * object with enforced strictness or not. + */ +accessibility.get = function(strict = false) { + return new accessibility.Checks(!!strict); +}; + +/** + * Component responsible for interacting with platform accessibility + * API. + * + * Its methods serve as wrappers for testing content and chrome + * accessibility as well as accessibility of user interactions. + */ +accessibility.Checks = class { + /** + * @param {boolean} strict + * Flag indicating whether the accessibility issue should be logged + * or cause an error to be thrown. Default is to log to stdout. + */ + constructor(strict) { + this.strict = strict; + } + + /** + * Get an accessible object for an element. + * + * @param {DOMElement|XULElement} element + * Element to get the accessible object for. + * @param {boolean=} mustHaveAccessible + * Flag indicating that the element must have an accessible object. + * Defaults to not require this. + * + * @return {Promise.<nsIAccessible>} + * Promise with an accessibility object for the given element. + */ + getAccessible(element, mustHaveAccessible = false) { + if (!this.strict) { + return Promise.resolve(); + } + + return new Promise((resolve, reject) => { + if (!accessibility.service) { + reject(); + return; + } + + // First, check if accessibility is ready. + let docAcc = accessibility.service.getAccessibleFor( + element.ownerDocument + ); + let state = {}; + docAcc.getState(state, {}); + if ((state.value & Ci.nsIAccessibleStates.STATE_BUSY) == 0) { + // Accessibility is ready, resolve immediately. + let acc = accessibility.service.getAccessibleFor(element); + if (mustHaveAccessible && !acc) { + reject(); + } else { + resolve(acc); + } + return; + } + // Accessibility for the doc is busy, so wait for the state to change. + let eventObserver = { + observe(subject, topic) { + if (topic !== "accessible-event") { + return; + } + + // If event type does not match expected type, skip the event. + let event = subject.QueryInterface(Ci.nsIAccessibleEvent); + if (event.eventType !== Ci.nsIAccessibleEvent.EVENT_STATE_CHANGE) { + return; + } + + // If event's accessible does not match expected accessible, + // skip the event. + if (event.accessible !== docAcc) { + return; + } + + Services.obs.removeObserver(this, "accessible-event"); + let acc = accessibility.service.getAccessibleFor(element); + if (mustHaveAccessible && !acc) { + reject(); + } else { + resolve(acc); + } + }, + }; + Services.obs.addObserver(eventObserver, "accessible-event"); + }).catch(() => + this.error("Element does not have an accessible object", element) + ); + } + + /** + * Test if the accessible has a role that supports some arbitrary + * action. + * + * @param {nsIAccessible} accessible + * Accessible object. + * + * @return {boolean} + * True if an actionable role is found on the accessible, false + * otherwise. + */ + isActionableRole(accessible) { + return accessibility.ActionableRoles.has( + accessibility.service.getStringRole(accessible.role) + ); + } + + /** + * Test if an accessible has at least one action that it supports. + * + * @param {nsIAccessible} accessible + * Accessible object. + * + * @return {boolean} + * True if the accessible has at least one supported action, + * false otherwise. + */ + hasActionCount(accessible) { + return accessible.actionCount > 0; + } + + /** + * Test if an accessible has a valid name. + * + * @param {nsIAccessible} accessible + * Accessible object. + * + * @return {boolean} + * True if the accessible has a non-empty valid name, or false if + * this is not the case. + */ + hasValidName(accessible) { + return accessible.name && accessible.name.trim(); + } + + /** + * Test if an accessible has a {@code hidden} attribute. + * + * @param {nsIAccessible} accessible + * Accessible object. + * + * @return {boolean} + * True if the accessible object has a {@code hidden} attribute, + * false otherwise. + */ + hasHiddenAttribute(accessible) { + let hidden = false; + try { + hidden = accessible.attributes.getStringProperty("hidden"); + } catch (e) {} + // if the property is missing, error will be thrown + return hidden && hidden === "true"; + } + + /** + * Verify if an accessible has a given state. + * Test if an accessible has a given state. + * + * @param {nsIAccessible} accessible + * Accessible object to test. + * @param {number} stateToMatch + * State to match. + * + * @return {boolean} + * True if |accessible| has |stateToMatch|, false otherwise. + */ + matchState(accessible, stateToMatch) { + let state = {}; + accessible.getState(state, {}); + return !!(state.value & stateToMatch); + } + + /** + * Test if an accessible is hidden from the user. + * + * @param {nsIAccessible} accessible + * Accessible object. + * + * @return {boolean} + * True if element is hidden from user, false otherwise. + */ + isHidden(accessible) { + if (!accessible) { + return true; + } + + while (accessible) { + if (this.hasHiddenAttribute(accessible)) { + return true; + } + accessible = accessible.parent; + } + return false; + } + + /** + * Test if the element's visible state corresponds to its accessibility + * API visibility. + * + * @param {nsIAccessible} accessible + * Accessible object. + * @param {DOMElement|XULElement} element + * Element associated with |accessible|. + * @param {boolean} visible + * Visibility state of |element|. + * + * @throws ElementNotAccessibleError + * If |element|'s visibility state does not correspond to + * |accessible|'s. + */ + assertVisible(accessible, element, visible) { + let hiddenAccessibility = this.isHidden(accessible); + + let message; + if (visible && hiddenAccessibility) { + message = + "Element is not currently visible via the accessibility API " + + "and may not be manipulated by it"; + } else if (!visible && !hiddenAccessibility) { + message = + "Element is currently only visible via the accessibility API " + + "and can be manipulated by it"; + } + this.error(message, element); + } + + /** + * Test if the element's unavailable accessibility state matches the + * enabled state. + * + * @param {nsIAccessible} accessible + * Accessible object. + * @param {DOMElement|XULElement} element + * Element associated with |accessible|. + * @param {boolean} enabled + * Enabled state of |element|. + * + * @throws ElementNotAccessibleError + * If |element|'s enabled state does not match |accessible|'s. + */ + assertEnabled(accessible, element, enabled) { + if (!accessible) { + return; + } + + let win = element.ownerGlobal; + let disabledAccessibility = this.matchState( + accessible, + accessibility.State.Unavailable + ); + let explorable = + win.getComputedStyle(element).getPropertyValue("pointer-events") !== + "none"; + + let message; + if (!explorable && !disabledAccessibility) { + message = + "Element is enabled but is not explorable via the " + + "accessibility API"; + } else if (enabled && disabledAccessibility) { + message = "Element is enabled but disabled via the accessibility API"; + } else if (!enabled && !disabledAccessibility) { + message = "Element is disabled but enabled via the accessibility API"; + } + this.error(message, element); + } + + /** + * Test if it is possible to activate an element with the accessibility + * API. + * + * @param {nsIAccessible} accessible + * Accessible object. + * @param {DOMElement|XULElement} element + * Element associated with |accessible|. + * + * @throws ElementNotAccessibleError + * If it is impossible to activate |element| with |accessible|. + */ + assertActionable(accessible, element) { + if (!accessible) { + return; + } + + let message; + if (!this.hasActionCount(accessible)) { + message = "Element does not support any accessible actions"; + } else if (!this.isActionableRole(accessible)) { + message = + "Element does not have a correct accessibility role " + + "and may not be manipulated via the accessibility API"; + } else if (!this.hasValidName(accessible)) { + message = "Element is missing an accessible name"; + } else if (!this.matchState(accessible, accessibility.State.Focusable)) { + message = "Element is not focusable via the accessibility API"; + } + + this.error(message, element); + } + + /** + * Test that an element's selected state corresponds to its + * accessibility API selected state. + * + * @param {nsIAccessible} accessible + * Accessible object. + * @param {DOMElement|XULElement} + * Element associated with |accessible|. + * @param {boolean} selected + * The |element|s selected state. + * + * @throws ElementNotAccessibleError + * If |element|'s selected state does not correspond to + * |accessible|'s. + */ + assertSelected(accessible, element, selected) { + if (!accessible) { + return; + } + + // element is not selectable via the accessibility API + if (!this.matchState(accessible, accessibility.State.Selectable)) { + return; + } + + let selectedAccessibility = this.matchState( + accessible, + accessibility.State.Selected + ); + + let message; + if (selected && !selectedAccessibility) { + message = + "Element is selected but not selected via the accessibility API"; + } else if (!selected && selectedAccessibility) { + message = + "Element is not selected but selected via the accessibility API"; + } + this.error(message, element); + } + + /** + * Throw an error if strict accessibility checks are enforced and log + * the error to the log. + * + * @param {string} message + * @param {DOMElement|XULElement} element + * Element that caused an error. + * + * @throws ElementNotAccessibleError + * If |strict| is true. + */ + error(message, element) { + if (!message || !this.strict) { + return; + } + if (element) { + let { id, tagName, className } = element; + message += `: id: ${id}, tagName: ${tagName}, className: ${className}`; + } + + throw new error.ElementNotAccessibleError(message); + } +}; diff --git a/testing/marionette/action.js b/testing/marionette/action.js new file mode 100644 index 0000000000..1c47803256 --- /dev/null +++ b/testing/marionette/action.js @@ -0,0 +1,1506 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* eslint no-dupe-keys:off */ +/* eslint-disable no-restricted-globals */ + +"use strict"; + +const EXPORTED_SYMBOLS = ["action"]; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + assert: "chrome://marionette/content/assert.js", + element: "chrome://marionette/content/element.js", + error: "chrome://marionette/content/error.js", + event: "chrome://marionette/content/event.js", + pprint: "chrome://marionette/content/format.js", + Sleep: "chrome://marionette/content/sync.js", +}); + +// TODO? With ES 2016 and Symbol you can make a safer approximation +// to an enum e.g. https://gist.github.com/xmlking/e86e4f15ec32b12c4689 +/** + * Implements WebDriver Actions API: a low-level interface for providing + * virtualised device input to the web browser. + * + * @namespace + */ +this.action = { + Pause: "pause", + KeyDown: "keyDown", + KeyUp: "keyUp", + PointerDown: "pointerDown", + PointerUp: "pointerUp", + PointerMove: "pointerMove", + PointerCancel: "pointerCancel", +}; + +const ACTIONS = { + none: new Set([action.Pause]), + key: new Set([action.Pause, action.KeyDown, action.KeyUp]), + pointer: new Set([ + action.Pause, + action.PointerDown, + action.PointerUp, + action.PointerMove, + action.PointerCancel, + ]), +}; + +/** Map from normalized key value to UI Events modifier key name */ +const MODIFIER_NAME_LOOKUP = { + Alt: "alt", + Shift: "shift", + Control: "ctrl", + Meta: "meta", +}; + +/** Map from raw key (codepoint) to normalized key value */ +const NORMALIZED_KEY_LOOKUP = { + "\uE000": "Unidentified", + "\uE001": "Cancel", + "\uE002": "Help", + "\uE003": "Backspace", + "\uE004": "Tab", + "\uE005": "Clear", + "\uE006": "Enter", + "\uE007": "Enter", + "\uE008": "Shift", + "\uE009": "Control", + "\uE00A": "Alt", + "\uE00B": "Pause", + "\uE00C": "Escape", + "\uE00D": " ", + "\uE00E": "PageUp", + "\uE00F": "PageDown", + "\uE010": "End", + "\uE011": "Home", + "\uE012": "ArrowLeft", + "\uE013": "ArrowUp", + "\uE014": "ArrowRight", + "\uE015": "ArrowDown", + "\uE016": "Insert", + "\uE017": "Delete", + "\uE018": ";", + "\uE019": "=", + "\uE01A": "0", + "\uE01B": "1", + "\uE01C": "2", + "\uE01D": "3", + "\uE01E": "4", + "\uE01F": "5", + "\uE020": "6", + "\uE021": "7", + "\uE022": "8", + "\uE023": "9", + "\uE024": "*", + "\uE025": "+", + "\uE026": ",", + "\uE027": "-", + "\uE028": ".", + "\uE029": "/", + "\uE031": "F1", + "\uE032": "F2", + "\uE033": "F3", + "\uE034": "F4", + "\uE035": "F5", + "\uE036": "F6", + "\uE037": "F7", + "\uE038": "F8", + "\uE039": "F9", + "\uE03A": "F10", + "\uE03B": "F11", + "\uE03C": "F12", + "\uE03D": "Meta", + "\uE040": "ZenkakuHankaku", + "\uE050": "Shift", + "\uE051": "Control", + "\uE052": "Alt", + "\uE053": "Meta", + "\uE054": "PageUp", + "\uE055": "PageDown", + "\uE056": "End", + "\uE057": "Home", + "\uE058": "ArrowLeft", + "\uE059": "ArrowUp", + "\uE05A": "ArrowRight", + "\uE05B": "ArrowDown", + "\uE05C": "Insert", + "\uE05D": "Delete", +}; + +/** Map from raw key (codepoint) to key location */ +const KEY_LOCATION_LOOKUP = { + "\uE007": 1, + "\uE008": 1, + "\uE009": 1, + "\uE00A": 1, + "\uE01A": 3, + "\uE01B": 3, + "\uE01C": 3, + "\uE01D": 3, + "\uE01E": 3, + "\uE01F": 3, + "\uE020": 3, + "\uE021": 3, + "\uE022": 3, + "\uE023": 3, + "\uE024": 3, + "\uE025": 3, + "\uE026": 3, + "\uE027": 3, + "\uE028": 3, + "\uE029": 3, + "\uE03D": 1, + "\uE050": 2, + "\uE051": 2, + "\uE052": 2, + "\uE053": 2, + "\uE054": 3, + "\uE055": 3, + "\uE056": 3, + "\uE057": 3, + "\uE058": 3, + "\uE059": 3, + "\uE05A": 3, + "\uE05B": 3, + "\uE05C": 3, + "\uE05D": 3, +}; + +const KEY_CODE_LOOKUP = { + "\uE00A": "AltLeft", + "\uE052": "AltRight", + "\uE015": "ArrowDown", + "\uE012": "ArrowLeft", + "\uE014": "ArrowRight", + "\uE013": "ArrowUp", + "`": "Backquote", + "~": "Backquote", + "\\": "Backslash", + "|": "Backslash", + "\uE003": "Backspace", + "[": "BracketLeft", + "{": "BracketLeft", + "]": "BracketRight", + "}": "BracketRight", + ",": "Comma", + "<": "Comma", + "\uE009": "ControlLeft", + "\uE051": "ControlRight", + "\uE017": "Delete", + ")": "Digit0", + "0": "Digit0", + "!": "Digit1", + "1": "Digit1", + "2": "Digit2", + "@": "Digit2", + "#": "Digit3", + "3": "Digit3", + $: "Digit4", + "4": "Digit4", + "%": "Digit5", + "5": "Digit5", + "6": "Digit6", + "^": "Digit6", + "&": "Digit7", + "7": "Digit7", + "*": "Digit8", + "8": "Digit8", + "(": "Digit9", + "9": "Digit9", + "\uE010": "End", + "\uE006": "Enter", + "+": "Equal", + "=": "Equal", + "\uE00C": "Escape", + "\uE031": "F1", + "\uE03A": "F10", + "\uE03B": "F11", + "\uE03C": "F12", + "\uE032": "F2", + "\uE033": "F3", + "\uE034": "F4", + "\uE035": "F5", + "\uE036": "F6", + "\uE037": "F7", + "\uE038": "F8", + "\uE039": "F9", + "\uE002": "Help", + "\uE011": "Home", + "\uE016": "Insert", + "<": "IntlBackslash", + ">": "IntlBackslash", + A: "KeyA", + a: "KeyA", + B: "KeyB", + b: "KeyB", + C: "KeyC", + c: "KeyC", + D: "KeyD", + d: "KeyD", + E: "KeyE", + e: "KeyE", + F: "KeyF", + f: "KeyF", + G: "KeyG", + g: "KeyG", + H: "KeyH", + h: "KeyH", + I: "KeyI", + i: "KeyI", + J: "KeyJ", + j: "KeyJ", + K: "KeyK", + k: "KeyK", + L: "KeyL", + l: "KeyL", + M: "KeyM", + m: "KeyM", + N: "KeyN", + n: "KeyN", + O: "KeyO", + o: "KeyO", + P: "KeyP", + p: "KeyP", + Q: "KeyQ", + q: "KeyQ", + R: "KeyR", + r: "KeyR", + S: "KeyS", + s: "KeyS", + T: "KeyT", + t: "KeyT", + U: "KeyU", + u: "KeyU", + V: "KeyV", + v: "KeyV", + W: "KeyW", + w: "KeyW", + X: "KeyX", + x: "KeyX", + Y: "KeyY", + y: "KeyY", + Z: "KeyZ", + z: "KeyZ", + "-": "Minus", + _: "Minus", + "\uE01A": "Numpad0", + "\uE05C": "Numpad0", + "\uE01B": "Numpad1", + "\uE056": "Numpad1", + "\uE01C": "Numpad2", + "\uE05B": "Numpad2", + "\uE01D": "Numpad3", + "\uE055": "Numpad3", + "\uE01E": "Numpad4", + "\uE058": "Numpad4", + "\uE01F": "Numpad5", + "\uE020": "Numpad6", + "\uE05A": "Numpad6", + "\uE021": "Numpad7", + "\uE057": "Numpad7", + "\uE022": "Numpad8", + "\uE059": "Numpad8", + "\uE023": "Numpad9", + "\uE054": "Numpad9", + "\uE024": "NumpadAdd", + "\uE026": "NumpadComma", + "\uE028": "NumpadDecimal", + "\uE05D": "NumpadDecimal", + "\uE029": "NumpadDivide", + "\uE007": "NumpadEnter", + "\uE024": "NumpadMultiply", + "\uE026": "NumpadSubtract", + "\uE03D": "OSLeft", + "\uE053": "OSRight", + "\uE01E": "PageDown", + "\uE01F": "PageUp", + ".": "Period", + ">": "Period", + '"': "Quote", + "'": "Quote", + ":": "Semicolon", + ";": "Semicolon", + "\uE008": "ShiftLeft", + "\uE050": "ShiftRight", + "/": "Slash", + "?": "Slash", + "\uE00D": "Space", + " ": "Space", + "\uE004": "Tab", +}; + +/** Represents possible values for a pointer-move origin. */ +action.PointerOrigin = { + Viewport: "viewport", + Pointer: "pointer", +}; + +/** Flag for WebDriver spec conforming pointer origin calculation. */ +action.specCompatPointerOrigin = true; + +/** + * Look up a PointerOrigin. + * + * @param {(string|Element)=} obj + * Origin for a <code>pointerMove</code> action. Must be one of + * "viewport" (default), "pointer", or a DOM element. + * + * @return {action.PointerOrigin} + * Pointer origin. + * + * @throws {InvalidArgumentError} + * If <var>obj</var> is not a valid origin. + */ +action.PointerOrigin.get = function(obj) { + let origin = obj; + if (typeof obj == "undefined") { + origin = this.Viewport; + } else if (typeof obj == "string") { + let name = capitalize(obj); + assert.in(name, this, pprint`Unknown pointer-move origin: ${obj}`); + origin = this[name]; + } else if (!element.isElement(obj)) { + throw new error.InvalidArgumentError( + "Expected 'origin' to be undefined, " + + '"viewport", "pointer", ' + + pprint`or an element, got: ${obj}` + ); + } + return origin; +}; + +/** Represents possible subtypes for a pointer input source. */ +action.PointerType = { + Mouse: "mouse", + // TODO For now, only mouse is supported + // Pen: "pen", + // Touch: "touch", +}; + +/** + * Look up a PointerType. + * + * @param {string} str + * Name of pointer type. + * + * @return {string} + * A pointer type for processing pointer parameters. + * + * @throws {InvalidArgumentError} + * If <code>str</code> is not a valid pointer type. + */ +action.PointerType.get = function(str) { + let name = capitalize(str); + assert.in(name, this, pprint`Unknown pointerType: ${str}`); + return this[name]; +}; + +/** + * Input state associated with current session. This is a map between + * input ID and the device state for that input source, with one entry + * for each active input source. + * + * Re-initialized in listener.js. + */ +action.inputStateMap = new Map(); + +/** + * List of {@link action.Action} associated with current session. Used to + * manage dispatching events when resetting the state of the input sources. + * Reset operations are assumed to be idempotent. + * + * Re-initialized in listener.js + */ +action.inputsToCancel = []; + +/** + * Represents device state for an input source. + */ +class InputState { + constructor() { + this.type = this.constructor.name.toLowerCase(); + } + + /** + * Check equality of this InputState object with another. + * + * @param {InputState} other + * Object representing an input state. + * + * @return {boolean} + * True if <code>this</code> has the same <code>type</code> + * as <code>other</code>. + */ + is(other) { + if (typeof other == "undefined") { + return false; + } + return this.type === other.type; + } + + toString() { + return `[object ${this.constructor.name}InputState]`; + } + + /** + * @param {Object.<string, ?>} obj + * Object with property <code>type</code> and optionally + * <code>parameters</code> or <code>pointerType</code>, + * representing an action sequence or an action item. + * + * @return {action.InputState} + * An {@link InputState} object for the type of the + * {@link actionSequence}. + * + * @throws {InvalidArgumentError} + * If {@link actionSequence.type} is not valid. + */ + static fromJSON(obj) { + let type = obj.type; + assert.in(type, ACTIONS, pprint`Unknown action type: ${type}`); + let name = type == "none" ? "Null" : capitalize(type); + if (name == "Pointer") { + if ( + !obj.pointerType && + (!obj.parameters || !obj.parameters.pointerType) + ) { + throw new error.InvalidArgumentError( + pprint`Expected obj to have pointerType, got ${obj}` + ); + } + let pointerType = obj.pointerType || obj.parameters.pointerType; + return new action.InputState[name](pointerType); + } + return new action.InputState[name](); + } +} + +/** Possible kinds of |InputState| for supported input sources. */ +action.InputState = {}; + +/** + * Input state associated with a keyboard-type device. + */ +action.InputState.Key = class Key extends InputState { + constructor() { + super(); + this.pressed = new Set(); + this.alt = false; + this.shift = false; + this.ctrl = false; + this.meta = false; + } + + /** + * Update modifier state according to |key|. + * + * @param {string} key + * Normalized key value of a modifier key. + * @param {boolean} value + * Value to set the modifier attribute to. + * + * @throws {InvalidArgumentError} + * If |key| is not a modifier. + */ + setModState(key, value) { + if (key in MODIFIER_NAME_LOOKUP) { + this[MODIFIER_NAME_LOOKUP[key]] = value; + } else { + throw new error.InvalidArgumentError( + "Expected 'key' to be one of " + + Object.keys(MODIFIER_NAME_LOOKUP) + + pprint`, got ${key}` + ); + } + } + + /** + * Check whether |key| is pressed. + * + * @param {string} key + * Normalized key value. + * + * @return {boolean} + * True if |key| is in set of pressed keys. + */ + isPressed(key) { + return this.pressed.has(key); + } + + /** + * Add |key| to the set of pressed keys. + * + * @param {string} key + * Normalized key value. + * + * @return {boolean} + * True if |key| is in list of pressed keys. + */ + press(key) { + return this.pressed.add(key); + } + + /** + * Remove |key| from the set of pressed keys. + * + * @param {string} key + * Normalized key value. + * + * @return {boolean} + * True if |key| was present before removal, false otherwise. + */ + release(key) { + return this.pressed.delete(key); + } +}; + +/** + * Input state not associated with a specific physical device. + */ +action.InputState.Null = class Null extends InputState { + constructor() { + super(); + this.type = "none"; + } +}; + +/** + * Input state associated with a pointer-type input device. + * + * @param {string} subtype + * Kind of pointing device: mouse, pen, touch. + * + * @throws {InvalidArgumentError} + * If subtype is undefined or an invalid pointer type. + */ +action.InputState.Pointer = class Pointer extends InputState { + constructor(subtype) { + super(); + this.pressed = new Set(); + assert.defined( + subtype, + pprint`Expected subtype to be defined, got ${subtype}` + ); + this.subtype = action.PointerType.get(subtype); + this.x = 0; + this.y = 0; + } + + /** + * Check whether |button| is pressed. + * + * @param {number} button + * Positive integer that refers to a mouse button. + * + * @return {boolean} + * True if |button| is in set of pressed buttons. + */ + isPressed(button) { + assert.positiveInteger(button); + return this.pressed.has(button); + } + + /** + * Add |button| to the set of pressed keys. + * + * @param {number} button + * Positive integer that refers to a mouse button. + * + * @return {Set} + * Set of pressed buttons. + */ + press(button) { + assert.positiveInteger(button); + return this.pressed.add(button); + } + + /** + * Remove |button| from the set of pressed buttons. + * + * @param {number} button + * A positive integer that refers to a mouse button. + * + * @return {boolean} + * True if |button| was present before removals, false otherwise. + */ + release(button) { + assert.positiveInteger(button); + return this.pressed.delete(button); + } +}; + +/** + * Repesents an action for dispatch. Used in |action.Chain| and + * |action.Sequence|. + * + * @param {string} id + * Input source ID. + * @param {string} type + * Action type: none, key, pointer. + * @param {string} subtype + * Action subtype: {@link action.Pause}, {@link action.KeyUp}, + * {@link action.KeyDown}, {@link action.PointerUp}, + * {@link action.PointerDown}, {@link action.PointerMove}, or + * {@link action.PointerCancel}. + * + * @throws {InvalidArgumentError} + * If any parameters are undefined. + */ +action.Action = class { + constructor(id, type, subtype) { + if ([id, type, subtype].includes(undefined)) { + throw new error.InvalidArgumentError("Missing id, type or subtype"); + } + for (let attr of [id, type, subtype]) { + assert.string(attr, pprint`Expected string, got ${attr}`); + } + this.id = id; + this.type = type; + this.subtype = subtype; + } + + toString() { + return `[action ${this.type}]`; + } + + /** + * @param {action.Sequence} actionSequence + * Object representing sequence of actions from one input source. + * @param {action.Action} actionItem + * Object representing a single action from |actionSequence|. + * + * @return {action.Action} + * An action that can be dispatched; corresponds to |actionItem|. + * + * @throws {InvalidArgumentError} + * If any <code>actionSequence</code> or <code>actionItem</code> + * attributes are invalid. + * @throws {UnsupportedOperationError} + * If <code>actionItem.type</code> is {@link action.PointerCancel}. + */ + static fromJSON(actionSequence, actionItem) { + let type = actionSequence.type; + let id = actionSequence.id; + let subtypes = ACTIONS[type]; + if (!subtypes) { + throw new error.InvalidArgumentError("Unknown type: " + type); + } + let subtype = actionItem.type; + if (!subtypes.has(subtype)) { + throw new error.InvalidArgumentError( + `Unknown subtype for ${type} action: ${subtype}` + ); + } + + let item = new action.Action(id, type, subtype); + if (type === "pointer") { + action.processPointerAction( + id, + action.PointerParameters.fromJSON(actionSequence.parameters), + item + ); + } + + switch (item.subtype) { + case action.KeyUp: + case action.KeyDown: + let key = actionItem.value; + // TODO countGraphemes + // TODO key.value could be a single code point like "\uE012" + // (see rawKey) or "grapheme cluster" + assert.string( + key, + "Expected 'value' to be a string that represents single code point " + + pprint`or grapheme cluster, got ${key}` + ); + item.value = key; + break; + + case action.PointerDown: + case action.PointerUp: + assert.positiveInteger( + actionItem.button, + pprint`Expected 'button' (${actionItem.button}) to be >= 0` + ); + item.button = actionItem.button; + break; + + case action.PointerMove: + item.duration = actionItem.duration; + if (typeof item.duration != "undefined") { + assert.positiveInteger( + item.duration, + pprint`Expected 'duration' (${item.duration}) to be >= 0` + ); + } + item.origin = action.PointerOrigin.get(actionItem.origin); + item.x = actionItem.x; + if (typeof item.x != "undefined") { + assert.integer( + item.x, + pprint`Expected 'x' (${item.x}) to be an Integer` + ); + } + item.y = actionItem.y; + if (typeof item.y != "undefined") { + assert.integer( + item.y, + pprint`Expected 'y' (${item.y}) to be an Integer` + ); + } + break; + + case action.PointerCancel: + throw new error.UnsupportedOperationError(); + + case action.Pause: + item.duration = actionItem.duration; + if (typeof item.duration != "undefined") { + // eslint-disable-next-line + assert.positiveInteger(item.duration, + pprint`Expected 'duration' (${item.duration}) to be >= 0` + ); + } + break; + } + + return item; + } +}; + +/** + * Represents a series of ticks, specifying which actions to perform at + * each tick. + */ +action.Chain = class extends Array { + toString() { + return `[chain ${super.toString()}]`; + } + + /** + * @param {Array.<?>} actions + * Array of objects that each represent an action sequence. + * + * @return {action.Chain} + * Transpose of <var>actions</var> such that actions to be performed + * in a single tick are grouped together. + * + * @throws {InvalidArgumentError} + * If <var>actions</var> is not an Array. + */ + static fromJSON(actions) { + assert.array( + actions, + pprint`Expected 'actions' to be an array, got ${actions}` + ); + + let actionsByTick = new action.Chain(); + for (let actionSequence of actions) { + // TODO(maja_zf): Check that each actionSequence in actions refers + // to a different input ID. + let inputSourceActions = action.Sequence.fromJSON(actionSequence); + for (let i = 0; i < inputSourceActions.length; i++) { + // new tick + if (actionsByTick.length < i + 1) { + actionsByTick.push([]); + } + actionsByTick[i].push(inputSourceActions[i]); + } + } + return actionsByTick; + } +}; + +/** + * Represents one input source action sequence; this is essentially an + * |Array.<action.Action>|. + */ +action.Sequence = class extends Array { + toString() { + return `[sequence ${super.toString()}]`; + } + + /** + * @param {Object.<string, ?>} actionSequence + * Object that represents a sequence action items for one input source. + * + * @return {action.Sequence} + * Sequence of actions that can be dispatched. + * + * @throws {InvalidArgumentError} + * If <code>actionSequence.id</code> is not a + * string or it's aleady mapped to an |action.InputState} + * incompatible with <code>actionSequence.type</code>, or if + * <code>actionSequence.actions</code> is not an <code>Array</code>. + */ + static fromJSON(actionSequence) { + // used here to validate 'type' in addition to InputState type below + let inputSourceState = InputState.fromJSON(actionSequence); + let id = actionSequence.id; + assert.defined(id, "Expected 'id' to be defined"); + assert.string(id, pprint`Expected 'id' to be a string, got ${id}`); + let actionItems = actionSequence.actions; + assert.array( + actionItems, + "Expected 'actionSequence.actions' to be an array, " + + pprint`got ${actionSequence.actions}` + ); + + if (!action.inputStateMap.has(id)) { + action.inputStateMap.set(id, inputSourceState); + } else if (!action.inputStateMap.get(id).is(inputSourceState)) { + throw new error.InvalidArgumentError( + `Expected ${id} to be mapped to ${inputSourceState}, ` + + `got ${action.inputStateMap.get(id)}` + ); + } + + let actions = new action.Sequence(); + for (let actionItem of actionItems) { + actions.push(action.Action.fromJSON(actionSequence, actionItem)); + } + + return actions; + } +}; + +/** + * Represents parameters in an action for a pointer input source. + * + * @param {string=} pointerType + * Type of pointing device. If the parameter is undefined, "mouse" + * is used. + */ +action.PointerParameters = class { + constructor(pointerType = "mouse") { + this.pointerType = action.PointerType.get(pointerType); + } + + toString() { + return `[pointerParameters ${this.pointerType}]`; + } + + /** + * @param {Object.<string, ?>} parametersData + * Object that represents pointer parameters. + * + * @return {action.PointerParameters} + * Validated pointer paramters. + */ + static fromJSON(parametersData) { + if (typeof parametersData == "undefined") { + return new action.PointerParameters(); + } + return new action.PointerParameters(parametersData.pointerType); + } +}; + +/** + * Adds <var>pointerType</var> attribute to Action <var>act</var>. + * + * Helper function for {@link action.Action.fromJSON}. + * + * @param {string} id + * Input source ID. + * @param {action.PointerParams} pointerParams + * Input source pointer parameters. + * @param {action.Action} act + * Action to be updated. + * + * @throws {InvalidArgumentError} + * If <var>id</var> is already mapped to an + * {@link action.InputState} that is not compatible with + * <code>act.type</code> or <code>pointerParams.pointerType</code>. + */ +action.processPointerAction = function(id, pointerParams, act) { + if ( + action.inputStateMap.has(id) && + action.inputStateMap.get(id).type !== act.type + ) { + throw new error.InvalidArgumentError( + `Expected 'id' ${id} to be mapped to InputState whose type is ` + + action.inputStateMap.get(id).type + + pprint` , got ${act.type}` + ); + } + let pointerType = pointerParams.pointerType; + if ( + action.inputStateMap.has(id) && + action.inputStateMap.get(id).subtype !== pointerType + ) { + throw new error.InvalidArgumentError( + `Expected 'id' ${id} to be mapped to InputState whose subtype is ` + + action.inputStateMap.get(id).subtype + + pprint` , got ${pointerType}` + ); + } + act.pointerType = pointerParams.pointerType; +}; + +/** Collect properties associated with KeyboardEvent */ +action.Key = class { + constructor(rawKey) { + this.key = NORMALIZED_KEY_LOOKUP[rawKey] || rawKey; + this.code = KEY_CODE_LOOKUP[rawKey]; + this.location = KEY_LOCATION_LOOKUP[rawKey] || 0; + this.altKey = false; + this.shiftKey = false; + this.ctrlKey = false; + this.metaKey = false; + this.repeat = false; + this.isComposing = false; + // keyCode will be computed by event.sendKeyDown + } + + update(inputState) { + this.altKey = inputState.alt; + this.shiftKey = inputState.shift; + this.ctrlKey = inputState.ctrl; + this.metaKey = inputState.meta; + } +}; + +/** Collect properties associated with MouseEvent */ +action.Mouse = class { + constructor(type, button = 0) { + this.type = type; + assert.positiveInteger(button); + this.button = button; + this.buttons = 0; + this.altKey = false; + this.shiftKey = false; + this.metaKey = false; + this.ctrlKey = false; + // set modifier properties based on whether any corresponding keys are + // pressed on any key input source + for (let inputState of action.inputStateMap.values()) { + if (inputState.type == "key") { + this.altKey = inputState.alt || this.altKey; + this.ctrlKey = inputState.ctrl || this.ctrlKey; + this.metaKey = inputState.meta || this.metaKey; + this.shiftKey = inputState.shift || this.shiftKey; + } + } + } + + update(inputState) { + let allButtons = Array.from(inputState.pressed); + this.buttons = allButtons.reduce((a, i) => a + Math.pow(2, i), 0); + } +}; + +/** + * Dispatch a chain of actions over |chain.length| ticks. + * + * This is done by creating a Promise for each tick that resolves once + * all the Promises for individual tick-actions are resolved. The next + * tick's actions are not dispatched until the Promise for the current + * tick is resolved. + * + * @param {action.Chain} chain + * Actions grouped by tick; each element in |chain| is a sequence of + * actions for one tick. + * @param {WindowProxy} win + * Current window global. + * @param {boolean=} [specCompatPointerOrigin=true] specCompatPointerOrigin + * Flag to turn off the WebDriver spec conforming pointer origin + * calculation. It has to be kept until all Selenium bindings can + * successfully handle the WebDriver spec conforming Pointer Origin + * calculation. See https://bugzilla.mozilla.org/show_bug.cgi?id=1429338. + * + * @return {Promise} + * Promise for dispatching all actions in |chain|. + */ +action.dispatch = function(chain, win, specCompatPointerOrigin = true) { + action.specCompatPointerOrigin = specCompatPointerOrigin; + + let chainEvents = (async () => { + for (let tickActions of chain) { + await action.dispatchTickActions( + tickActions, + action.computeTickDuration(tickActions), + win + ); + } + })(); + return chainEvents; +}; + +/** + * Dispatch sequence of actions for one tick. + * + * This creates a Promise for one tick that resolves once the Promise + * for each tick-action is resolved, which takes at least |tickDuration| + * milliseconds. The resolved set of events for each tick is followed by + * firing of pending DOM events. + * + * Note that the tick-actions are dispatched in order, but they may have + * different durations and therefore may not end in the same order. + * + * @param {Array.<action.Action>} tickActions + * List of actions for one tick. + * @param {number} tickDuration + * Duration in milliseconds of this tick. + * @param {WindowProxy} win + * Current window global. + * + * @return {Promise} + * Promise for dispatching all tick-actions and pending DOM events. + */ +action.dispatchTickActions = function(tickActions, tickDuration, win) { + let pendingEvents = tickActions.map(toEvents(tickDuration, win)); + return Promise.all(pendingEvents); +}; + +/** + * Compute tick duration in milliseconds for a collection of actions. + * + * @param {Array.<action.Action>} tickActions + * List of actions for one tick. + * + * @return {number} + * Longest action duration in |tickActions| if any, or 0. + */ +action.computeTickDuration = function(tickActions) { + let max = 0; + for (let a of tickActions) { + let affectsWallClockTime = + a.subtype == action.Pause || + (a.type == "pointer" && a.subtype == action.PointerMove); + if (affectsWallClockTime && a.duration) { + max = Math.max(a.duration, max); + } + } + return max; +}; + +/** + * Compute viewport coordinates of pointer target based on given origin. + * + * @param {action.Action} a + * Action that specifies pointer origin and x and y coordinates of target. + * @param {action.InputState} inputState + * Input state that specifies current x and y coordinates of pointer. + * @param {Map.<string, number>=} center + * Object representing x and y coordinates of an element center-point. + * This is only used if |a.origin| is a web element reference. + * + * @return {Map.<string, number>} + * x and y coordinates of pointer destination. + */ +action.computePointerDestination = function(a, inputState, center = undefined) { + let { x, y } = a; + switch (a.origin) { + case action.PointerOrigin.Viewport: + break; + case action.PointerOrigin.Pointer: + x += inputState.x; + y += inputState.y; + break; + default: + // origin represents web element + assert.defined(center); + assert.in("x", center); + assert.in("y", center); + x += center.x; + y += center.y; + } + return { x, y }; +}; + +/** + * Create a closure to use as a map from action definitions to Promise events. + * + * @param {number} tickDuration + * Duration in milliseconds of this tick. + * @param {WindowProxy} win + * Current window global. + * + * @return {function(action.Action): Promise} + * Function that takes an action and returns a Promise for dispatching + * the event that corresponds to that action. + */ +function toEvents(tickDuration, win) { + return a => { + let inputState = action.inputStateMap.get(a.id); + + switch (a.subtype) { + case action.KeyUp: + return dispatchKeyUp(a, inputState, win); + + case action.KeyDown: + return dispatchKeyDown(a, inputState, win); + + case action.PointerDown: + return dispatchPointerDown(a, inputState, win); + + case action.PointerUp: + return dispatchPointerUp(a, inputState, win); + + case action.PointerMove: + return dispatchPointerMove(a, inputState, tickDuration, win); + + case action.PointerCancel: + throw new error.UnsupportedOperationError(); + + case action.Pause: + return dispatchPause(a, tickDuration); + } + + return undefined; + }; +} + +/** + * Dispatch a keyDown action equivalent to pressing a key on a keyboard. + * + * @param {action.Action} a + * Action to dispatch. + * @param {action.InputState} inputState + * Input state for this action's input source. + * @param {WindowProxy} win + * Current window global. + * + * @return {Promise} + * Promise to dispatch at least a keydown event, and keypress if + * appropriate. + */ +function dispatchKeyDown(a, inputState, win) { + return new Promise(resolve => { + let keyEvent = new action.Key(a.value); + keyEvent.repeat = inputState.isPressed(keyEvent.key); + inputState.press(keyEvent.key); + if (keyEvent.key in MODIFIER_NAME_LOOKUP) { + inputState.setModState(keyEvent.key, true); + } + + // Append a copy of |a| with keyUp subtype + action.inputsToCancel.push(Object.assign({}, a, { subtype: action.KeyUp })); + keyEvent.update(inputState); + event.sendKeyDown(a.value, keyEvent, win); + + resolve(); + }); +} + +/** + * Dispatch a keyUp action equivalent to releasing a key on a keyboard. + * + * @param {action.Action} a + * Action to dispatch. + * @param {action.InputState} inputState + * Input state for this action's input source. + * @param {WindowProxy} win + * Current window global. + * + * @return {Promise} + * Promise to dispatch a keyup event. + */ +function dispatchKeyUp(a, inputState, win) { + return new Promise(resolve => { + let keyEvent = new action.Key(a.value); + + if (!inputState.isPressed(keyEvent.key)) { + resolve(); + return; + } + + if (keyEvent.key in MODIFIER_NAME_LOOKUP) { + inputState.setModState(keyEvent.key, false); + } + inputState.release(keyEvent.key); + keyEvent.update(inputState); + + event.sendKeyUp(a.value, keyEvent, win); + resolve(); + }); +} + +/** + * Dispatch a pointerDown action equivalent to pressing a pointer-device + * button. + * + * @param {action.Action} a + * Action to dispatch. + * @param {action.InputState} inputState + * Input state for this action's input source. + * @param {WindowProxy} win + * Current window global. + * + * @return {Promise} + * Promise to dispatch at least a pointerdown event. + */ +function dispatchPointerDown(a, inputState, win) { + return new Promise(resolve => { + if (inputState.isPressed(a.button)) { + resolve(); + return; + } + + inputState.press(a.button); + // Append a copy of |a| with pointerUp subtype + let copy = Object.assign({}, a, { subtype: action.PointerUp }); + action.inputsToCancel.push(copy); + + switch (inputState.subtype) { + case action.PointerType.Mouse: + let mouseEvent = new action.Mouse("mousedown", a.button); + mouseEvent.update(inputState); + if (mouseEvent.ctrlKey) { + if (Services.appinfo.OS === "Darwin") { + mouseEvent.button = 2; + event.DoubleClickTracker.resetClick(); + } + } else if (event.DoubleClickTracker.isClicked()) { + mouseEvent = Object.assign({}, mouseEvent, { clickCount: 2 }); + } + event.synthesizeMouseAtPoint( + inputState.x, + inputState.y, + mouseEvent, + win + ); + if ( + event.MouseButton.isSecondary(a.button) || + (mouseEvent.ctrlKey && Services.appinfo.OS === "Darwin") + ) { + let contextMenuEvent = Object.assign({}, mouseEvent, { + type: "contextmenu", + }); + event.synthesizeMouseAtPoint( + inputState.x, + inputState.y, + contextMenuEvent, + win + ); + } + break; + + case action.PointerType.Pen: + case action.PointerType.Touch: + throw new error.UnsupportedOperationError( + "Only 'mouse' pointer type is supported" + ); + + default: + throw new TypeError(`Unknown pointer type: ${inputState.subtype}`); + } + + resolve(); + }); +} + +/** + * Dispatch a pointerUp action equivalent to releasing a pointer-device + * button. + * + * @param {action.Action} a + * Action to dispatch. + * @param {action.InputState} inputState + * Input state for this action's input source. + * @param {WindowProxy} win + * Current window global. + * + * @return {Promise} + * Promise to dispatch at least a pointerup event. + */ +function dispatchPointerUp(a, inputState, win) { + return new Promise(resolve => { + if (!inputState.isPressed(a.button)) { + resolve(); + return; + } + + inputState.release(a.button); + + switch (inputState.subtype) { + case action.PointerType.Mouse: + let mouseEvent = new action.Mouse("mouseup", a.button); + mouseEvent.update(inputState); + if (event.DoubleClickTracker.isClicked()) { + mouseEvent = Object.assign({}, mouseEvent, { clickCount: 2 }); + } + event.synthesizeMouseAtPoint( + inputState.x, + inputState.y, + mouseEvent, + win + ); + break; + + case action.PointerType.Pen: + case action.PointerType.Touch: + throw new error.UnsupportedOperationError( + "Only 'mouse' pointer type is supported" + ); + + default: + throw new TypeError(`Unknown pointer type: ${inputState.subtype}`); + } + + resolve(); + }); +} + +/** + * Dispatch a pointerMove action equivalent to moving pointer device + * in a line. + * + * If the action duration is 0, the pointer jumps immediately to the + * target coordinates. Otherwise, events are synthesized to mimic a + * pointer travelling in a discontinuous, approximately straight line, + * with the pointer coordinates being updated around 60 times per second. + * + * @param {action.Action} a + * Action to dispatch. + * @param {action.InputState} inputState + * Input state for this action's input source. + * @param {WindowProxy} win + * Current window global. + * + * @return {Promise} + * Promise to dispatch at least one pointermove event, as well as + * mousemove events as appropriate. + */ +function dispatchPointerMove(a, inputState, tickDuration, win) { + const timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + // interval between pointermove increments in ms, based on common vsync + const fps60 = 17; + + return new Promise((resolve, reject) => { + const start = Date.now(); + const [startX, startY] = [inputState.x, inputState.y]; + + let coords = getElementCenter(a.origin, win); + let target = action.computePointerDestination(a, inputState, coords); + const [targetX, targetY] = [target.x, target.y]; + + if (!inViewPort(targetX, targetY, win)) { + throw new error.MoveTargetOutOfBoundsError( + `(${targetX}, ${targetY}) is out of bounds of viewport ` + + `width (${win.innerWidth}) ` + + `and height (${win.innerHeight})` + ); + } + + const duration = + typeof a.duration == "undefined" ? tickDuration : a.duration; + if (duration === 0) { + // move pointer to destination in one step + performOnePointerMove(inputState, targetX, targetY, win); + resolve(); + return; + } + + const distanceX = targetX - startX; + const distanceY = targetY - startY; + const ONE_SHOT = Ci.nsITimer.TYPE_ONE_SHOT; + let intermediatePointerEvents = (async () => { + // wait |fps60| ms before performing first incremental pointer move + await new Promise(resolveTimer => + timer.initWithCallback(resolveTimer, fps60, ONE_SHOT) + ); + + let durationRatio = Math.floor(Date.now() - start) / duration; + const epsilon = fps60 / duration / 10; + while (1 - durationRatio > epsilon) { + let x = Math.floor(durationRatio * distanceX + startX); + let y = Math.floor(durationRatio * distanceY + startY); + performOnePointerMove(inputState, x, y, win); + // wait |fps60| ms before performing next pointer move + await new Promise(resolveTimer => + timer.initWithCallback(resolveTimer, fps60, ONE_SHOT) + ); + + durationRatio = Math.floor(Date.now() - start) / duration; + } + })(); + + // perform last pointer move after all incremental moves are resolved and + // durationRatio is close enough to 1 + intermediatePointerEvents + .then(() => { + performOnePointerMove(inputState, targetX, targetY, win); + resolve(); + }) + .catch(err => { + reject(err); + }); + }); +} + +function performOnePointerMove(inputState, targetX, targetY, win) { + if (targetX == inputState.x && targetY == inputState.y) { + return; + } + + switch (inputState.subtype) { + case action.PointerType.Mouse: + let mouseEvent = new action.Mouse("mousemove"); + mouseEvent.update(inputState); + // TODO both pointermove (if available) and mousemove + event.synthesizeMouseAtPoint(targetX, targetY, mouseEvent, win); + break; + + case action.PointerType.Pen: + case action.PointerType.Touch: + throw new error.UnsupportedOperationError( + "Only 'mouse' pointer type is supported" + ); + + default: + throw new TypeError(`Unknown pointer type: ${inputState.subtype}`); + } + + inputState.x = targetX; + inputState.y = targetY; +} + +/** + * Dispatch a pause action equivalent waiting for `a.duration` + * milliseconds, or a default time interval of `tickDuration`. + * + * @param {action.Action} a + * Action to dispatch. + * @param {number} tickDuration + * Duration in milliseconds of this tick. + * + * @return {Promise} + * Promise that is resolved after the specified time interval. + */ +function dispatchPause(a, tickDuration) { + let ms = typeof a.duration == "undefined" ? tickDuration : a.duration; + return Sleep(ms); +} + +// helpers + +function capitalize(str) { + assert.string(str); + return str.charAt(0).toUpperCase() + str.slice(1); +} + +function inViewPort(x, y, win) { + assert.number(x, `Expected x to be finite number`); + assert.number(y, `Expected y to be finite number`); + // Viewport includes scrollbars if rendered. + return !(x < 0 || y < 0 || x > win.innerWidth || y > win.innerHeight); +} + +function getElementCenter(el, win) { + if (element.isElement(el)) { + if (action.specCompatPointerOrigin) { + return element.getInViewCentrePoint(el.getClientRects()[0], win); + } + return element.coordinates(el); + } + return {}; +} diff --git a/testing/marionette/actors/MarionetteCommandsChild.jsm b/testing/marionette/actors/MarionetteCommandsChild.jsm new file mode 100644 index 0000000000..9f04fec837 --- /dev/null +++ b/testing/marionette/actors/MarionetteCommandsChild.jsm @@ -0,0 +1,546 @@ +/* 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 */ + +"use strict"; + +const EXPORTED_SYMBOLS = ["MarionetteCommandsChild", "clearActionInputState"]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + action: "chrome://marionette/content/action.js", + atom: "chrome://marionette/content/atom.js", + element: "chrome://marionette/content/element.js", + error: "chrome://marionette/content/error.js", + evaluate: "chrome://marionette/content/evaluate.js", + event: "chrome://marionette/content/event.js", + interaction: "chrome://marionette/content/interaction.js", + legacyaction: "chrome://marionette/content/legacyaction.js", + Log: "chrome://marionette/content/log.js", + sandbox: "chrome://marionette/content/evaluate.js", + Sandboxes: "chrome://marionette/content/evaluate.js", +}); + +XPCOMUtils.defineLazyGetter(this, "logger", () => Log.get()); + +let inputStateIsDirty = false; + +class MarionetteCommandsChild extends JSWindowActorChild { + constructor() { + super(); + + // sandbox storage and name of the current sandbox + this.sandboxes = new Sandboxes(() => this.document.defaultView); + } + + get innerWindowId() { + return this.manager.innerWindowId; + } + + /** + * Lazy getter to create a legacyaction Chain instance for touch events. + */ + get legacyactions() { + if (!this._legacyactions) { + this._legacyactions = new legacyaction.Chain(); + } + + return this._legacyactions; + } + + actorCreated() { + logger.trace( + `[${this.browsingContext.id}] MarionetteCommands actor created ` + + `for window id ${this.innerWindowId}` + ); + + clearActionInputState(); + } + + async receiveMessage(msg) { + if (!this.contentWindow) { + throw new DOMException("Actor is no longer active", "InactiveActor"); + } + + try { + let result; + + const { name, data: serializedData } = msg; + const data = evaluate.fromJSON( + serializedData, + null, + this.document.defaultView + ); + + switch (name) { + case "MarionetteCommandsParent:clearElement": + this.clearElement(data); + break; + case "MarionetteCommandsParent:clickElement": + result = await this.clickElement(data); + break; + case "MarionetteCommandsParent:executeScript": + result = await this.executeScript(data); + break; + case "MarionetteCommandsParent:findElement": + result = await this.findElement(data); + break; + case "MarionetteCommandsParent:findElements": + result = await this.findElements(data); + break; + case "MarionetteCommandsParent:getCurrentUrl": + result = await this.getCurrentUrl(); + break; + case "MarionetteCommandsParent:getActiveElement": + result = await this.getActiveElement(); + break; + case "MarionetteCommandsParent:getElementAttribute": + result = await this.getElementAttribute(data); + break; + case "MarionetteCommandsParent:getElementProperty": + result = await this.getElementProperty(data); + break; + case "MarionetteCommandsParent:getElementRect": + result = await this.getElementRect(data); + break; + case "MarionetteCommandsParent:getElementTagName": + result = await this.getElementTagName(data); + break; + case "MarionetteCommandsParent:getElementText": + result = await this.getElementText(data); + break; + case "MarionetteCommandsParent:getElementValueOfCssProperty": + result = await this.getElementValueOfCssProperty(data); + break; + case "MarionetteCommandsParent:getPageSource": + result = await this.getPageSource(); + break; + case "MarionetteCommandsParent:getScreenshotRect": + result = await this.getScreenshotRect(data); + break; + case "MarionetteCommandsParent: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); + break; + case "MarionetteCommandsParent:releaseActions": + result = await this.releaseActions(); + break; + case "MarionetteCommandsParent:sendKeysToElement": + result = await this.sendKeysToElement(data); + break; + case "MarionetteCommandsParent:singleTap": + result = await this.singleTap(data); + break; + case "MarionetteCommandsParent:switchToFrame": + result = await this.switchToFrame(data); + break; + case "MarionetteCommandsParent:switchToParentFrame": + result = await this.switchToParentFrame(); + break; + } + + // The element reference store lives in the parent process. Calling + // toJSON() without a second argument here passes element reference ids + // of DOM nodes to the parent frame. + return { data: evaluate.toJSON(result) }; + } catch (e) { + // Always wrap errors as WebDriverError + return { error: 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; + + interaction.clearElement(elem); + } + + /** + * Click an element. + */ + async clickElement(options = {}) { + const { capabilities, elem } = options; + + return 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 = sandbox.createMutable(this.document.defaultView); + } + + return evaluate.sandbox(sb, script, args, opts); + } + + /** + * Find an element in the current browsing context's document using the + * given search strategy. + * + * @param {Object} options + * @param {Object} options.opts + * @param {Element} opts.startNode + * @param {string} opts.strategy + * @param {string} opts.selector + * + */ + async findElement(options = {}) { + const { strategy, selector, opts } = options; + + opts.all = false; + + const container = { frame: this.document.defaultView }; + return element.find(container, strategy, selector, opts); + } + + /** + * Find elements in the current browsing context's document using the + * given search strategy. + * + * @param {Object} options + * @param {Object} options.opts + * @param {Element} opts.startNode + * @param {string} opts.strategy + * @param {string} opts.selector + * + */ + async findElements(options = {}) { + const { strategy, selector, opts } = options; + + opts.all = true; + + const container = { frame: this.document.defaultView }; + return element.find(container, strategy, selector, opts); + } + + /** + * Return the active element in the document. + */ + async getActiveElement() { + let elem = this.document.activeElement; + if (!elem) { + throw new error.NoSuchElementError(); + } + + return elem; + } + + /** + * Get the current URL. + */ + async getCurrentUrl() { + return this.document.defaultView.location.href; + } + + /** + * Get the value of an attribute for the given element. + */ + async getElementAttribute(options = {}) { + const { name, elem } = options; + + if (element.isBooleanAttribute(elem, name)) { + if (elem.hasAttribute(name)) { + return "true"; + } + return null; + } + return elem.getAttribute(name); + } + + /** + * Get the value of a property for the given element. + */ + async getElementProperty(options = {}) { + const { name, elem } = options; + + return typeof elem[name] != "undefined" ? elem[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; + + return atom.getElementText(elem, this.document.defaultView); + } + + /** + * Get the value of a css property for the given element. + */ + async getElementValueOfCssProperty(options = {}) { + const { name, elem } = options; + + const style = this.document.defaultView.getComputedStyle(elem); + return style.getPropertyValue(name); + } + + /** + * Get the source of the current browsing context's document. + */ + async getPageSource() { + return this.document.documentElement.outerHTML; + } + + /** + * Returns the rect of the element to screenshot. + * + * Because the screen capture takes place in the parent process the dimensions + * for the screenshot have to be determined in the appropriate child process. + * + * Also it takes care of scrolling an element into view if requested. + * + * @param {Object} options + * @param {Element} options.elem + * Optional element to take a screenshot of. + * @param {boolean=} options.full + * True to take a screenshot of the entire document element. + * Defaults to true. + * @param {boolean=} options.scroll + * When <var>elem</var> is given, scroll it into view. + * Defaults to true. + * + * @return {DOMRect} + * The area to take a snapshot from. + */ + async getScreenshotRect(options = {}) { + const { elem, full = true, scroll = true } = options; + const win = elem + ? this.document.defaultView + : this.browsingContext.top.window; + + let rect; + + if (elem) { + if (scroll) { + element.scrollIntoView(elem); + } + rect = this.getElementRect({ elem }); + } else if (full) { + const docEl = win.document.documentElement; + rect = new DOMRect(0, 0, docEl.scrollWidth, docEl.scrollHeight); + } else { + // viewport + rect = new DOMRect( + win.pageXOffset, + win.pageYOffset, + win.innerWidth, + win.innerHeight + ); + } + + return rect; + } + + /** + * Determine the element displayedness of the given web element. + */ + async isElementDisplayed(options = {}) { + const { capabilities, elem } = options; + + return interaction.isElementDisplayed( + elem, + capabilities["moz:accessibilityChecks"] + ); + } + + /** + * Check if element is enabled. + */ + async isElementEnabled(options = {}) { + const { capabilities, elem } = options; + + return interaction.isElementEnabled( + elem, + capabilities["moz:accessibilityChecks"] + ); + } + + /** + * Determine whether the referenced element is selected or not. + */ + async isElementSelected(options = {}) { + const { capabilities, elem } = options; + + return interaction.isElementSelected( + elem, + capabilities["moz:accessibilityChecks"] + ); + } + + /** + * Perform a series of grouped actions at the specified points in time. + * + * @param {Object} options + * @param {Object} options.actions + * Array of objects with each representing an action sequence. + * @param {Object} options.capabilities + * Object with a list of WebDriver session capabilities. + */ + async performActions(options = {}) { + const { actions, capabilities } = options; + + await action.dispatch( + action.Chain.fromJSON(actions), + this.document.defaultView, + !capabilities["moz:useNonSpecCompliantPointerOrigin"] + ); + inputStateIsDirty = + action.inputsToCancel.length || action.inputStateMap.size; + } + + /** + * 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() { + await action.dispatchTickActions( + action.inputsToCancel.reverse(), + 0, + this.document.defaultView + ); + clearActionInputState(); + + event.DoubleClickTracker.resetClick(); + } + + /* + * Send key presses to element after focusing on it. + */ + async sendKeysToElement(options = {}) { + const { capabilities, elem, text } = options; + + const opts = { + strictFileInteractability: capabilities.strictFileInteractability, + accessibilityChecks: capabilities["moz:accessibilityChecks"], + webdriverClick: capabilities["moz:webdriverClick"], + }; + + return interaction.sendKeysToElement(elem, text, opts); + } + + /** + * Perform a single tap. + */ + async singleTap(options = {}) { + const { capabilities, elem, x, y } = options; + return this.legacyactions.singleTap(elem, x, y, capabilities); + } + + /** + * Switch to the specified frame. + * + * @param {Object=} options + * @param {(number|Element)=} options.id + * If it's a number treat it as the index for all the existing frames. + * If it's an Element switch to this specific frame. + * If not specified or `null` switch to the top-level browsing context. + */ + async switchToFrame(options = {}) { + const { id } = options; + + const childContexts = this.browsingContext.children; + let browsingContext; + + if (id == null) { + browsingContext = this.browsingContext.top; + } else if (typeof id == "number") { + if (id < 0 || id >= childContexts.length) { + throw new 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 error.NoSuchFrameError( + `Unable to locate frame for element: ${id}` + ); + } + browsingContext = context; + } + + return { browsingContextId: browsingContext.id }; + } + + /** + * Switch to the parent frame. + */ + async switchToParentFrame() { + const browsingContext = this.browsingContext.parent || this.browsingContext; + + return { browsingContextId: browsingContext.id }; + } +} + +/** + * Reset Action API input state + */ +function clearActionInputState() { + // Avoid loading the action module before it is needed by a command + if (inputStateIsDirty) { + action.inputStateMap.clear(); + action.inputsToCancel.length = 0; + inputStateIsDirty = false; + } +} diff --git a/testing/marionette/actors/MarionetteCommandsParent.jsm b/testing/marionette/actors/MarionetteCommandsParent.jsm new file mode 100644 index 0000000000..f7c1990ef5 --- /dev/null +++ b/testing/marionette/actors/MarionetteCommandsParent.jsm @@ -0,0 +1,396 @@ +/* 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 EXPORTED_SYMBOLS = [ + "clearElementIdCache", + "getMarionetteCommandsActorProxy", + "MarionetteCommandsParent", + "registerCommandsActor", + "unregisterCommandsActor", +]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + capture: "chrome://marionette/content/capture.js", + element: "chrome://marionette/content/element.js", + error: "chrome://marionette/content/error.js", + evaluate: "chrome://marionette/content/evaluate.js", + Log: "chrome://marionette/content/log.js", + modal: "chrome://marionette/content/modal.js", +}); + +XPCOMUtils.defineLazyGetter(this, "logger", () => Log.get()); +XPCOMUtils.defineLazyGetter(this, "elementIdCache", () => { + return new element.ReferenceStore(); +}); + +class MarionetteCommandsParent extends JSWindowActorParent { + actorCreated() { + this._resolveDialogOpened = null; + + this.dialogObserver = new modal.DialogObserver(); + this.dialogObserver.add(this.onDialog.bind(this)); + + this.topWindow = this.browsingContext.top.embedderElement?.ownerGlobal; + this.topWindow?.addEventListener("TabClose", _onTabClose); + } + + dialogOpenedPromise() { + return new Promise(resolve => { + this._resolveDialogOpened = resolve; + }); + } + + async sendQuery(name, data) { + const serializedData = evaluate.toJSON(data, elementIdCache); + + // return early if a dialog is opened + const result = await Promise.race([ + super.sendQuery(name, serializedData), + this.dialogOpenedPromise(), + ]).finally(() => { + this._resolveDialogOpened = null; + }); + + if ("error" in result) { + throw error.WebDriverError.fromJSON(result.error); + } else { + return evaluate.fromJSON(result.data, elementIdCache); + } + } + + didDestroy() { + this.dialogObserver.remove(this.onDialog); + this.dialogObserver.unregister(); + + this.topWindow?.removeEventListener("TabClose", _onTabClose); + } + + onDialog(action, dialogRef, win) { + if ( + this._resolveDialogOpened && + action == "opened" && + win == this.browsingContext.topChromeWindow + ) { + this._resolveDialogOpened({ data: null }); + } + } + + // Proxying methods for WebDriver commands + // TODO: Maybe using a proxy class instead similar to proxy.js + + clearElement(webEl) { + return this.sendQuery("MarionetteCommandsParent:clearElement", { + elem: webEl, + }); + } + + clickElement(webEl, capabilities) { + return this.sendQuery("MarionetteCommandsParent:clickElement", { + elem: webEl, + capabilities, + }); + } + + async executeScript(script, args, opts) { + return this.sendQuery("MarionetteCommandsParent:executeScript", { + script, + args, + opts, + }); + } + + findElement(strategy, selector, opts) { + return this.sendQuery("MarionetteCommandsParent:findElement", { + strategy, + selector, + opts, + }); + } + + findElements(strategy, selector, opts) { + return this.sendQuery("MarionetteCommandsParent:findElements", { + strategy, + selector, + opts, + }); + } + + async getActiveElement() { + return this.sendQuery("MarionetteCommandsParent:getActiveElement"); + } + + async getCurrentUrl() { + return this.sendQuery("MarionetteCommandsParent:getCurrentUrl"); + } + + 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, + elem: webEl, + }); + } + + async isElementEnabled(webEl, capabilities) { + return this.sendQuery("MarionetteCommandsParent:isElementEnabled", { + capabilities, + elem: webEl, + }); + } + + async isElementSelected(webEl, capabilities) { + return this.sendQuery("MarionetteCommandsParent:isElementSelected", { + capabilities, + elem: webEl, + }); + } + + async sendKeysToElement(webEl, text, capabilities) { + return this.sendQuery("MarionetteCommandsParent:sendKeysToElement", { + capabilities, + elem: webEl, + text, + }); + } + + async performActions(actions, capabilities) { + return this.sendQuery("MarionetteCommandsParent:performActions", { + actions, + capabilities, + }); + } + + async releaseActions() { + return this.sendQuery("MarionetteCommandsParent:releaseActions"); + } + + async singleTap(webEl, x, y, capabilities) { + return this.sendQuery("MarionetteCommandsParent:singleTap", { + capabilities, + elem: webEl, + x, + y, + }); + } + + async switchToFrame(id) { + const { + browsingContextId, + } = await this.sendQuery("MarionetteCommandsParent:switchToFrame", { id }); + + return { + browsingContext: BrowsingContext.get(browsingContextId), + }; + } + + async switchToParentFrame() { + const { browsingContextId } = await this.sendQuery( + "MarionetteCommandsParent:switchToParentFrame" + ); + + return { + browsingContext: BrowsingContext.get(browsingContextId), + }; + } + + async takeScreenshot(webEl, format, full, scroll) { + const rect = await this.sendQuery( + "MarionetteCommandsParent:getScreenshotRect", + { + elem: webEl, + full, + scroll, + } + ); + + // If no element has been specified use the top-level browsing context. + // Otherwise use the browsing context from the currently selected frame. + const browsingContext = webEl + ? this.browsingContext + : this.browsingContext.top; + + let canvas = await capture.canvas( + browsingContext.topChromeWindow, + browsingContext, + rect.x, + rect.y, + rect.width, + rect.height + ); + + switch (format) { + case capture.Format.Hash: + return capture.toHash(canvas); + + case capture.Format.Base64: + return capture.toBase64(canvas); + + default: + throw new TypeError(`Invalid capture format: ${format}`); + } + } +} + +/** + * Clear all the entries from the element id cache. + */ +function clearElementIdCache() { + elementIdCache.clear(); +} + +function _onTabClose(event) { + elementIdCache.clear(event.target.linkedBrowser.browsingContext); +} + +/** + * 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. + */ +function getMarionetteCommandsActorProxy(browsingContextFn) { + const MAX_ATTEMPTS = 10; + + /** + * Methods which modify the content page cannot be retried safely. + * See Bug 1673345. + */ + const NO_RETRY_METHODS = [ + "clickElement", + "executeScript", + "performActions", + "releaseActions", + "sendKeysToElement", + "singleTap", + ]; + + return new Proxy( + {}, + { + get(target, methodName) { + return async (...args) => { + let attempts = 0; + while (true) { + try { + // TODO: Scenarios where the window/tab got closed and + // currentWindowGlobal is null will be handled in Bug 1662808. + const actor = browsingContextFn().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)) { + return null; + } + + if (++attempts > MAX_ATTEMPTS) { + const browsingContextId = browsingContextFn()?.id; + logger.trace( + `[${browsingContextId}] Querying "${methodName} "` + + `reached the limit of retry attempts (${MAX_ATTEMPTS})` + ); + throw e; + } + + logger.trace(`Retrying "${methodName}", attempt: ${attempts}`); + } + } + }; + }, + } + ); +} + +/** + * Register the MarionetteCommands actor that holds all the commands. + */ +function registerCommandsActor() { + try { + ChromeUtils.registerWindowActor("MarionetteCommands", { + kind: "JSWindowActor", + parent: { + moduleURI: + "chrome://marionette/content/actors/MarionetteCommandsParent.jsm", + }, + child: { + moduleURI: + "chrome://marionette/content/actors/MarionetteCommandsChild.jsm", + }, + + allFrames: true, + includeChrome: true, + }); + } catch (e) { + if (e.name === "NotSupportedError") { + logger.warn(`MarionetteCommands actor is already registered!`); + } else { + throw e; + } + } +} + +function unregisterCommandsActor() { + ChromeUtils.unregisterWindowActor("MarionetteCommands"); +} diff --git a/testing/marionette/actors/MarionetteEventsChild.jsm b/testing/marionette/actors/MarionetteEventsChild.jsm new file mode 100644 index 0000000000..e890c513c9 --- /dev/null +++ b/testing/marionette/actors/MarionetteEventsChild.jsm @@ -0,0 +1,74 @@ +/* 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 */ + +"use strict"; + +const EXPORTED_SYMBOLS = ["MarionetteEventsChild"]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + event: "chrome://marionette/content/event.js", + Log: "chrome://marionette/content/log.js", +}); + +XPCOMUtils.defineLazyGetter(this, "logger", () => Log.get()); + +class MarionetteEventsChild extends JSWindowActorChild { + get innerWindowId() { + return this.manager.innerWindowId; + } + + actorCreated() { + logger.trace( + `[${this.browsingContext.id}] MarionetteEvents actor created ` + + `for window id ${this.innerWindowId}` + ); + } + + handleEvent({ target, type }) { + // Ignore invalid combinations of load events and document's readyState. + if ( + (type === "DOMContentLoaded" && target.readyState != "interactive") || + (type === "pageshow" && target.readyState != "complete") + ) { + logger.warn( + `Ignoring event '${type}' because document has an invalid ` + + `readyState of '${target.readyState}'.` + ); + return; + } + + switch (type) { + case "beforeunload": + case "DOMContentLoaded": + case "hashchange": + case "pagehide": + case "pageshow": + case "popstate": + this.sendAsyncMessage("MarionetteEventsChild:PageLoadEvent", { + browsingContext: this.browsingContext, + documentURI: target.documentURI, + readyState: target.readyState, + type, + windowId: this.innerWindowId, + }); + break; + + // Listen for click event to indicate one click has happened, so actions + // code can send dblclick event + case "click": + event.DoubleClickTracker.setClick(); + break; + case "dblclick": + case "unload": + event.DoubleClickTracker.resetClick(); + break; + } + } +} diff --git a/testing/marionette/actors/MarionetteEventsParent.jsm b/testing/marionette/actors/MarionetteEventsParent.jsm new file mode 100644 index 0000000000..4db861a8b0 --- /dev/null +++ b/testing/marionette/actors/MarionetteEventsParent.jsm @@ -0,0 +1,92 @@ +/* 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 EXPORTED_SYMBOLS = [ + "EventDispatcher", + "MarionetteEventsParent", + "registerEventsActor", + "unregisterEventsActor", +]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + EventEmitter: "resource://gre/modules/EventEmitter.jsm", + Log: "chrome://marionette/content/log.js", +}); + +XPCOMUtils.defineLazyGetter(this, "logger", () => Log.get()); + +// Singleton to allow forwarding events to registered listeners. +const EventDispatcher = { + init() { + EventEmitter.decorate(this); + }, +}; +EventDispatcher.init(); + +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; + } +} + +/** + * Register Events actors to listen for page load events via EventDispatcher. + */ +function registerEventsActor() { + try { + // Register the JSWindowActor pair for events as used by Marionette + ChromeUtils.registerWindowActor("MarionetteEvents", { + kind: "JSWindowActor", + parent: { + moduleURI: + "chrome://marionette/content/actors/MarionetteEventsParent.jsm", + }, + child: { + moduleURI: + "chrome://marionette/content/actors/MarionetteEventsChild.jsm", + 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 }, + }, + }, + + allFrames: true, + includeChrome: true, + }); + } catch (e) { + if (e.name === "NotSupportedError") { + logger.warn(`MarionetteEvents actor is already registered!`); + } else { + throw e; + } + } +} + +function unregisterEventsActor() { + ChromeUtils.unregisterWindowActor("MarionetteEvents"); +} diff --git a/testing/marionette/actors/MarionetteReftestChild.jsm b/testing/marionette/actors/MarionetteReftestChild.jsm new file mode 100644 index 0000000000..dd1743d62c --- /dev/null +++ b/testing/marionette/actors/MarionetteReftestChild.jsm @@ -0,0 +1,209 @@ +/* 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 EXPORTED_SYMBOLS = ["MarionetteReftestChild"]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + Log: "chrome://marionette/content/log.js", +}); + +XPCOMUtils.defineLazyGetter(this, "logger", () => Log.get()); + +/** + * Child JSWindowActor to handle navigation for reftests relying on marionette. + */ +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; + logger.debug(`Handle load event with URL ${url}`); + this._resolveLoadedURLPromise(url); + } + } + + actorCreated() { + 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:reftestWait": + result = await this.reftestWait(data); + break; + } + return result; + } + + /** + * Wait for a reftest page to be ready for screenshots: + * - wait for the loadedURL to be available (see handleEvent) + * - check if the URL matches the expected URL + * - if present, wait for the "reftest-wait" classname to be removed from the + * document element + * + * @param {Object} options + * @param {String} options.url + * The expected test page URL + * @param {Boolean} options.useRemote + * True when using e10s + * @return {Boolean} + * Returns true when the correct page is loaded and ready for + * screenshots. Returns false if the page loaded bug does not have the + * expected URL. + */ + async reftestWait(options = {}) { + const { url, useRemote } = options; + const loadedURL = await this._loadedURLPromise; + if (loadedURL !== url) { + 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"); + + logger.debug("Waiting for event loop to spin"); + await new Promise(resolve => + this.document.defaultView.setTimeout(resolve, 0) + ); + + await this.paintComplete(useRemote); + + if (hasReftestWait) { + const event = new Event("TestRendered", { bubbles: true }); + documentElement.dispatchEvent(event); + logger.info("Emitted TestRendered event"); + await this.reftestWaitRemoved(); + await this.paintComplete(useRemote); + } + if ( + this.document.defaultView.innerWidth < documentElement.scrollWidth || + this.document.defaultView.innerHeight < documentElement.scrollHeight + ) { + logger.warn( + `${url} overflows viewport (width: ${documentElement.scrollWidth}, height: ${documentElement.scrollHeight})` + ); + } + return true; + } + + paintComplete(useRemote) { + logger.debug("Waiting for rendering"); + let windowUtils = this.document.defaultView.windowUtils; + return new Promise(resolve => { + let maybeResolve = () => { + this.flushRendering(); + if (useRemote) { + // Flush display (paint) + logger.debug("Force update of layer tree"); + windowUtils.updateLayerTree(); + } + + if (windowUtils.isMozAfterPaintPending) { + 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 + logger.debug("isMozAfterPaintPending: false"); + this.document.defaultView.requestAnimationFrame(() => { + this.document.defaultView.requestAnimationFrame(resolve); + }); + } + }; + maybeResolve(); + }); + } + + reftestWaitRemoved() { + 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(); + logger.debug("reftest-wait removed"); + this.document.defaultView.setTimeout(resolve, 0); + } + }); + if (documentElement.classList.contains("reftest-wait")) { + observer.observe(documentElement, { attributes: true }); + } else { + this.document.defaultView.setTimeout(resolve, 0); + } + }); + } + + flushRendering() { + 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 { + // Flush pending restyles and reflows for this window (layout) + root.getBoundingClientRect(); + } catch (e) { + logger.error("flushWindow failed", e); + } + } + + if (!afterPaintWasPending && utils.isMozAfterPaintPending) { + anyPendingPaintsGeneratedInDescendants = true; + } + + for (let i = 0; i < win.frames.length; ++i) { + flushWindow(win.frames[i]); + } + } + flushWindow(this.document.defaultView); + + if ( + anyPendingPaintsGeneratedInDescendants && + !windowUtils.isMozAfterPaintPending + ) { + logger.error( + "Descendant frame generated a MozAfterPaint event, " + + "but the root document doesn't have one!" + ); + } + } +} diff --git a/testing/marionette/actors/MarionetteReftestParent.jsm b/testing/marionette/actors/MarionetteReftestParent.jsm new file mode 100644 index 0000000000..6a1a2187d8 --- /dev/null +++ b/testing/marionette/actors/MarionetteReftestParent.jsm @@ -0,0 +1,44 @@ +/* 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 EXPORTED_SYMBOLS = ["MarionetteReftestParent"]; + +/** + * Parent JSWindowActor to handle navigation for reftests relying on marionette. + */ +class MarionetteReftestParent extends JSWindowActorParent { + /** + * Wait for the expected URL to be loaded. + * + * @param {String} url + * The expected url. + * @param {Boolean} useRemote + * True if tests are running with e10s. + * @return {Boolean} true if the page is fully loaded with the expected url, + * false otherwise. + */ + async reftestWait(url, useRemote) { + try { + const isCorrectUrl = await this.sendQuery( + "MarionetteReftestParent:reftestWait", + { + url, + useRemote, + } + ); + 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; + } + } +} diff --git a/testing/marionette/addon.js b/testing/marionette/addon.js new file mode 100644 index 0000000000..6702f015a3 --- /dev/null +++ b/testing/marionette/addon.js @@ -0,0 +1,136 @@ +/* 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 EXPORTED_SYMBOLS = ["Addon"]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + AddonManager: "resource://gre/modules/AddonManager.jsm", + FileUtils: "resource://gre/modules/FileUtils.jsm", + + error: "chrome://marionette/content/error.js", +}); + +// from https://developer.mozilla.org/en-US/Add-ons/Add-on_Manager/AddonManager#AddonInstall_errors +const ERRORS = { + [-1]: "ERROR_NETWORK_FAILURE: A network error occured.", + [-2]: "ERROR_INCORECT_HASH: The downloaded file did not match the expected hash.", + [-3]: "ERROR_CORRUPT_FILE: The file appears to be corrupt.", + [-4]: "ERROR_FILE_ACCESS: There was an error accessing the filesystem.", + [-5]: "ERROR_SIGNEDSTATE_REQUIRED: The addon must be signed and isn't.", +}; + +async function installAddon(file) { + let install = await AddonManager.getInstallForFile(file, null, { + source: "internal", + }); + + if (install.error) { + throw new error.UnknownError(ERRORS[install.error]); + } + + return install.install().catch(err => { + throw new error.UnknownError(ERRORS[install.error]); + }); +} + +/** Installs addons by path and uninstalls by ID. */ +class Addon { + /** + * Install a Firefox addon. + * + * If the addon is restartless, it can be used right away. Otherwise a + * restart is required. + * + * Temporary addons will automatically be uninstalled on shutdown and + * do not need to be signed, though they must be restartless. + * + * @param {string} path + * Full path to the extension package archive. + * @param {boolean=} temporary + * True to install the addon temporarily, false (default) otherwise. + * + * @return {Promise.<string>} + * Addon ID. + * + * @throws {UnknownError} + * If there is a problem installing the addon. + */ + static async install(path, temporary = false) { + let addon; + let file; + + try { + file = new FileUtils.File(path); + } catch (e) { + throw new error.UnknownError(`Expected absolute path: ${e}`, e); + } + + if (!file.exists()) { + throw new error.UnknownError(`No such file or directory: ${path}`); + } + + try { + if (temporary) { + addon = await AddonManager.installTemporaryAddon(file); + } else { + addon = await installAddon(file); + } + } catch (e) { + throw new error.UnknownError( + `Could not install add-on: ${path}: ${e.message}`, + e + ); + } + + return addon.id; + } + + /** + * Uninstall a Firefox addon. + * + * If the addon is restartless it will be uninstalled right away. + * Otherwise, Firefox must be restarted for the change to take effect. + * + * @param {string} id + * ID of the addon to uninstall. + * + * @return {Promise} + * + * @throws {UnknownError} + * If there is a problem uninstalling the addon. + */ + static async uninstall(id) { + let candidate = await AddonManager.getAddonByID(id); + + return new Promise(resolve => { + let listener = { + onOperationCancelled: addon => { + if (addon.id === candidate.id) { + AddonManager.removeAddonListener(listener); + throw new error.UnknownError( + `Uninstall of ${candidate.id} has been canceled` + ); + } + }, + + onUninstalled: addon => { + if (addon.id === candidate.id) { + AddonManager.removeAddonListener(listener); + resolve(); + } + }, + }; + + AddonManager.addAddonListener(listener); + candidate.uninstall(); + }); + } +} +this.Addon = Addon; diff --git a/testing/marionette/assert.js b/testing/marionette/assert.js new file mode 100644 index 0000000000..d8776e21ff --- /dev/null +++ b/testing/marionette/assert.js @@ -0,0 +1,464 @@ +/* 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 EXPORTED_SYMBOLS = ["assert"]; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + AppConstants: "resource://gre/modules/AppConstants.jsm", + + browser: "chrome://marionette/content/browser.js", + error: "chrome://marionette/content/error.js", + evaluate: "chrome://marionette/content/evaluate.js", + pprint: "chrome://marionette/content/format.js", +}); + +const isFennec = () => AppConstants.platform == "android"; +const isFirefox = () => + Services.appinfo.ID == "{ec8030f7-c20a-464f-9b0e-13a3a9e97384}"; +const isThunderbird = () => + Services.appinfo.ID == "{3550f703-e582-4d05-9a08-453d09bdfdc6}"; + +/** + * Shorthands for common assertions made in Marionette. + * + * @namespace + */ +this.assert = {}; + +/** + * Asserts that an arbitrary object is not acyclic. + * + * @param {*} obj + * Object to test. This assertion is only meaningful if passed + * an actual object or array. + * @param {Error=} [error=JavaScriptError] error + * Error to throw if assertion fails. + * @param {string=} message + * Custom message to use for `error` if assertion fails. + * + * @throws {JavaScriptError} + * If the object is cyclic. + */ +assert.acyclic = function(obj, msg = "", err = error.JavaScriptError) { + if (evaluate.isCyclic(obj)) { + throw new err(msg || "Cyclic object value"); + } +}; + +/** + * Asserts that Marionette has a session. + * + * @param {GeckoDriver} driver + * Marionette driver instance. + * @param {string=} msg + * Custom error message. + * + * @return {string} + * Current session's ID. + * + * @throws {InvalidSessionIDError} + * If <var>driver</var> does not have a session ID. + */ +assert.session = function(driver, msg = "") { + assert.that( + sessionID => sessionID, + msg, + error.InvalidSessionIDError + )(driver.sessionID); + return driver.sessionID; +}; + +/** + * Asserts that the current browser is Firefox Desktop. + * + * @param {string=} msg + * Custom error message. + * + * @throws {UnsupportedOperationError} + * If current browser is not Firefox. + */ +assert.firefox = function(msg = "") { + msg = msg || "Only supported in Firefox"; + assert.that(isFirefox, msg, error.UnsupportedOperationError)(); +}; + +/** + * Asserts that the current browser is Firefox Desktop or Thunderbird. + * + * @param {string=} msg + * Custom error message. + * + * @throws {UnsupportedOperationError} + * If current browser is not Firefox or Thunderbird. + */ +assert.desktop = function(msg = "") { + msg = msg || "Only supported in desktop applications"; + assert.that( + obj => isFirefox(obj) || isThunderbird(obj), + msg, + error.UnsupportedOperationError + )(); +}; + +/** + * Asserts that the current browser is Fennec, or Firefox for Android. + * + * @param {string=} msg + * Custom error message. + * + * @throws {UnsupportedOperationError} + * If current browser is not Fennec. + */ +assert.fennec = function(msg = "") { + msg = msg || "Only supported in Fennec"; + assert.that(isFennec, msg, error.UnsupportedOperationError)(); +}; + +/** + * Asserts that the current <var>context</var> is content. + * + * @param {string} context + * Context to test. + * @param {string=} msg + * Custom error message. + * + * @return {string} + * <var>context</var> is returned unaltered. + * + * @throws {UnsupportedOperationError} + * If <var>context</var> is not content. + */ +assert.content = function(context, msg = "") { + msg = msg || "Only supported in content context"; + assert.that( + c => c.toString() == "content", + msg, + error.UnsupportedOperationError + )(context); +}; + +/** + * Asserts that the {@link CanonicalBrowsingContext} is open. + * + * @param {CanonicalBrowsingContext} browsingContext + * Canonical browsing context to check. + * @param {string=} msg + * Custom error message. + * + * @return {CanonicalBrowsingContext} + * <var>browsingContext</var> is returned unaltered. + * + * @throws {NoSuchWindowError} + * If <var>browsingContext</var> is no longer open. + */ +assert.open = function(browsingContext, msg = "") { + msg = msg || "Browsing context has been discarded"; + return assert.that( + browsingContext => !!browsingContext?.currentWindowGlobal, + msg, + error.NoSuchWindowError + )(browsingContext); +}; + +/** + * Asserts that there is no current user prompt. + * + * @param {modal.Dialog} dialog + * Reference to current dialogue. + * @param {string=} msg + * Custom error message. + * + * @throws {UnexpectedAlertOpenError} + * If there is a user prompt. + */ +assert.noUserPrompt = function(dialog, msg = "") { + assert.that( + d => d === null || typeof d == "undefined", + msg, + error.UnexpectedAlertOpenError + )(dialog); +}; + +/** + * Asserts that <var>obj</var> is defined. + * + * @param {?} obj + * Value to test. + * @param {string=} msg + * Custom error message. + * + * @return {?} + * <var>obj</var> is returned unaltered. + * + * @throws {InvalidArgumentError} + * If <var>obj</var> is not defined. + */ +assert.defined = function(obj, msg = "") { + msg = msg || pprint`Expected ${obj} to be defined`; + return assert.that(o => typeof o != "undefined", msg)(obj); +}; + +/** + * Asserts that <var>obj</var> is a finite number. + * + * @param {?} obj + * Value to test. + * @param {string=} msg + * Custom error message. + * + * @return {number} + * <var>obj</var> is returned unaltered. + * + * @throws {InvalidArgumentError} + * If <var>obj</var> is not a number. + */ +assert.number = function(obj, msg = "") { + msg = msg || pprint`Expected ${obj} to be finite number`; + return assert.that(Number.isFinite, msg)(obj); +}; + +/** + * Asserts that <var>obj</var> is a positive number. + * + * @param {?} obj + * Value to test. + * @param {string=} msg + * Custom error message. + * + * @return {number} + * <var>obj</var> is returned unaltered. + * + * @throws {InvalidArgumentError} + * If <var>obj</var> is not a positive integer. + */ +assert.positiveNumber = function(obj, msg = "") { + assert.number(obj, msg); + msg = msg || pprint`Expected ${obj} to be >= 0`; + return assert.that(n => n >= 0, msg)(obj); +}; + +/** + * Asserts that <var>obj</var> is callable. + * + * @param {?} obj + * Value to test. + * @param {string=} msg + * Custom error message. + * + * @return {Function} + * <var>obj</var> is returned unaltered. + * + * @throws {InvalidArgumentError} + * If <var>obj</var> is not callable. + */ +assert.callable = function(obj, msg = "") { + msg = msg || pprint`${obj} is not callable`; + return assert.that(o => typeof o == "function", msg)(obj); +}; + +/** + * Asserts that <var>obj</var> is an unsigned short number. + * + * @param {?} obj + * Value to test. + * @param {string=} msg + * Custom error message. + * + * @return {number} + * <var>obj</var> is returned unaltered. + * + * @throws {InvalidArgumentError} + * If <var>obj</var> is not an unsigned short. + */ +assert.unsignedShort = function(obj, msg = "") { + msg = msg || pprint`Expected ${obj} to be >= 0 and < 65536`; + return assert.that(n => n >= 0 && n < 65536, msg)(obj); +}; + +/** + * Asserts that <var>obj</var> is an integer. + * + * @param {?} obj + * Value to test. + * @param {string=} msg + * Custom error message. + * + * @return {number} + * <var>obj</var> is returned unaltered. + * + * @throws {InvalidArgumentError} + * If <var>obj</var> is not an integer. + */ +assert.integer = function(obj, msg = "") { + msg = msg || pprint`Expected ${obj} to be an integer`; + return assert.that(Number.isSafeInteger, msg)(obj); +}; + +/** + * Asserts that <var>obj</var> is a positive integer. + * + * @param {?} obj + * Value to test. + * @param {string=} msg + * Custom error message. + * + * @return {number} + * <var>obj</var> is returned unaltered. + * + * @throws {InvalidArgumentError} + * If <var>obj</var> is not a positive integer. + */ +assert.positiveInteger = function(obj, msg = "") { + assert.integer(obj, msg); + msg = msg || pprint`Expected ${obj} to be >= 0`; + return assert.that(n => n >= 0, msg)(obj); +}; + +/** + * Asserts that <var>obj</var> is a boolean. + * + * @param {?} obj + * Value to test. + * @param {string=} msg + * Custom error message. + * + * @return {boolean} + * <var>obj</var> is returned unaltered. + * + * @throws {InvalidArgumentError} + * If <var>obj</var> is not a boolean. + */ +assert.boolean = function(obj, msg = "") { + msg = msg || pprint`Expected ${obj} to be boolean`; + return assert.that(b => typeof b == "boolean", msg)(obj); +}; + +/** + * Asserts that <var>obj</var> is a string. + * + * @param {?} obj + * Value to test. + * @param {string=} msg + * Custom error message. + * + * @return {string} + * <var>obj</var> is returned unaltered. + * + * @throws {InvalidArgumentError} + * If <var>obj</var> is not a string. + */ +assert.string = function(obj, msg = "") { + msg = msg || pprint`Expected ${obj} to be a string`; + return assert.that(s => typeof s == "string", msg)(obj); +}; + +/** + * Asserts that <var>obj</var> is an object. + * + * @param {?} obj + * Value to test. + * @param {string=} msg + * Custom error message. + * + * @return {Object} + * obj| is returned unaltered. + * + * @throws {InvalidArgumentError} + * If <var>obj</var> is not an object. + */ +assert.object = function(obj, msg = "") { + msg = msg || pprint`Expected ${obj} to be an object`; + return assert.that(o => { + // unable to use instanceof because LHS and RHS may come from + // different globals + let s = Object.prototype.toString.call(o); + return s == "[object Object]" || s == "[object nsJSIID]"; + }, msg)(obj); +}; + +/** + * Asserts that <var>prop</var> is in <var>obj</var>. + * + * @param {?} prop + * An array element or own property to test if is in <var>obj</var>. + * @param {?} obj + * An array or an Object that is being tested. + * @param {string=} msg + * Custom error message. + * + * @return {?} + * The array element, or the value of <var>obj</var>'s own property + * <var>prop</var>. + * + * @throws {InvalidArgumentError} + * If the <var>obj</var> was an array and did not contain <var>prop</var>. + * Otherwise if <var>prop</var> is not in <var>obj</var>, or <var>obj</var> + * is not an object. + */ +assert.in = function(prop, obj, msg = "") { + if (Array.isArray(obj)) { + assert.that(p => obj.includes(p), msg)(prop); + return prop; + } + assert.object(obj, msg); + msg = msg || pprint`Expected ${prop} in ${obj}`; + assert.that(p => obj.hasOwnProperty(p), msg)(prop); + return obj[prop]; +}; + +/** + * Asserts that <var>obj</var> is an Array. + * + * @param {?} obj + * Value to test. + * @param {string=} msg + * Custom error message. + * + * @return {Object} + * <var>obj</var> is returned unaltered. + * + * @throws {InvalidArgumentError} + * If <var>obj</var> is not an Array. + */ +assert.array = function(obj, msg = "") { + msg = msg || pprint`Expected ${obj} to be an Array`; + return assert.that(Array.isArray, msg)(obj); +}; + +/** + * Returns a function that is used to assert the |predicate|. + * + * @param {function(?): boolean} predicate + * Evaluated on calling the return value of this function. If its + * return value of the inner function is false, <var>error</var> + * is thrown with <var>message</var>. + * @param {string=} message + * Custom error message. + * @param {Error=} error + * Custom error type by its class. + * + * @return {function(?): ?} + * Function that takes and returns the passed in value unaltered, + * and which may throw <var>error</var> with <var>message</var> + * if <var>predicate</var> evaluates to false. + */ +assert.that = function( + predicate, + message = "", + err = error.InvalidArgumentError +) { + return obj => { + if (!predicate(obj)) { + throw new err(message); + } + return obj; + }; +}; diff --git a/testing/marionette/atom.js b/testing/marionette/atom.js new file mode 100644 index 0000000000..0a17742fb2 --- /dev/null +++ b/testing/marionette/atom.js @@ -0,0 +1,223 @@ +// 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 EXPORTED_SYMBOLS = ["atom"]; + +/** @namespace */ +this.atom = {}; + +// https://github.com/SeleniumHQ/selenium/blob/master/javascript/atoms/dom.js#L979 +atom.getElementText = function(element, window){return function(){var g,l=this;function n(a){return void 0!==a}function p(a){return"string"==typeof a}function aa(a){return"number"==typeof a}function ba(a,b){a=a.split(".");var c=l;a[0]in c||!c.execScript||c.execScript("var "+a[0]);for(var d;a.length&&(d=a.shift());)!a.length&&n(b)?c[d]=b:c[d]&&c[d]!==Object.prototype[d]?c=c[d]:c=c[d]={}} +function ca(a){var b=typeof a;if("object"==b)if(a){if(a instanceof Array)return"array";if(a instanceof Object)return b;var c=Object.prototype.toString.call(a);if("[object Window]"==c)return"object";if("[object Array]"==c||"number"==typeof a.length&&"undefined"!=typeof a.splice&&"undefined"!=typeof a.propertyIsEnumerable&&!a.propertyIsEnumerable("splice"))return"array";if("[object Function]"==c||"undefined"!=typeof a.call&&"undefined"!=typeof a.propertyIsEnumerable&&!a.propertyIsEnumerable("call"))return"function"}else return"null"; +else if("function"==b&&"undefined"==typeof a.call)return"object";return b}function da(a,b,c){return a.call.apply(a.bind,arguments)}function ea(a,b,c){if(!a)throw Error();if(2<arguments.length){var d=Array.prototype.slice.call(arguments,2);return function(){var c=Array.prototype.slice.call(arguments);Array.prototype.unshift.apply(c,d);return a.apply(b,c)}}return function(){return a.apply(b,arguments)}} +function fa(a,b,c){Function.prototype.bind&&-1!=Function.prototype.bind.toString().indexOf("native code")?fa=da:fa=ea;return fa.apply(null,arguments)}function ha(a,b){var c=Array.prototype.slice.call(arguments,1);return function(){var b=c.slice();b.push.apply(b,arguments);return a.apply(this,b)}} +function q(a,b){function c(){}c.prototype=b.prototype;a.U=b.prototype;a.prototype=new c;a.prototype.constructor=a;a.S=function(a,c,f){for(var d=Array(arguments.length-2),e=2;e<arguments.length;e++)d[e-2]=arguments[e];return b.prototype[c].apply(a,d)}};function ia(a,b){this.code=a;this.a=u[a]||ja;this.message=b||"";a=this.a.replace(/((?:^|\s+)[a-z])/g,function(a){return a.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||""}q(ia,Error);var ja="unknown error",u={15:"element not selectable",11:"element not visible"};u[31]=ja;u[30]=ja;u[24]="invalid cookie domain";u[29]="invalid element coordinates";u[12]="invalid element state"; +u[32]="invalid selector";u[51]="invalid selector";u[52]="invalid selector";u[17]="javascript error";u[405]="unsupported operation";u[34]="move target out of bounds";u[27]="no such alert";u[7]="no such element";u[8]="no such frame";u[23]="no such window";u[28]="script timeout";u[33]="session not created";u[10]="stale element reference";u[21]="timeout";u[25]="unable to set cookie";u[26]="unexpected alert open";u[13]=ja;u[9]="unknown command";ia.prototype.toString=function(){return this.name+": "+this.message};var ka={aliceblue:"#f0f8ff",antiquewhite:"#faebd7",aqua:"#00ffff",aquamarine:"#7fffd4",azure:"#f0ffff",beige:"#f5f5dc",bisque:"#ffe4c4",black:"#000000",blanchedalmond:"#ffebcd",blue:"#0000ff",blueviolet:"#8a2be2",brown:"#a52a2a",burlywood:"#deb887",cadetblue:"#5f9ea0",chartreuse:"#7fff00",chocolate:"#d2691e",coral:"#ff7f50",cornflowerblue:"#6495ed",cornsilk:"#fff8dc",crimson:"#dc143c",cyan:"#00ffff",darkblue:"#00008b",darkcyan:"#008b8b",darkgoldenrod:"#b8860b",darkgray:"#a9a9a9",darkgreen:"#006400", +darkgrey:"#a9a9a9",darkkhaki:"#bdb76b",darkmagenta:"#8b008b",darkolivegreen:"#556b2f",darkorange:"#ff8c00",darkorchid:"#9932cc",darkred:"#8b0000",darksalmon:"#e9967a",darkseagreen:"#8fbc8f",darkslateblue:"#483d8b",darkslategray:"#2f4f4f",darkslategrey:"#2f4f4f",darkturquoise:"#00ced1",darkviolet:"#9400d3",deeppink:"#ff1493",deepskyblue:"#00bfff",dimgray:"#696969",dimgrey:"#696969",dodgerblue:"#1e90ff",firebrick:"#b22222",floralwhite:"#fffaf0",forestgreen:"#228b22",fuchsia:"#ff00ff",gainsboro:"#dcdcdc", +ghostwhite:"#f8f8ff",gold:"#ffd700",goldenrod:"#daa520",gray:"#808080",green:"#008000",greenyellow:"#adff2f",grey:"#808080",honeydew:"#f0fff0",hotpink:"#ff69b4",indianred:"#cd5c5c",indigo:"#4b0082",ivory:"#fffff0",khaki:"#f0e68c",lavender:"#e6e6fa",lavenderblush:"#fff0f5",lawngreen:"#7cfc00",lemonchiffon:"#fffacd",lightblue:"#add8e6",lightcoral:"#f08080",lightcyan:"#e0ffff",lightgoldenrodyellow:"#fafad2",lightgray:"#d3d3d3",lightgreen:"#90ee90",lightgrey:"#d3d3d3",lightpink:"#ffb6c1",lightsalmon:"#ffa07a", +lightseagreen:"#20b2aa",lightskyblue:"#87cefa",lightslategray:"#778899",lightslategrey:"#778899",lightsteelblue:"#b0c4de",lightyellow:"#ffffe0",lime:"#00ff00",limegreen:"#32cd32",linen:"#faf0e6",magenta:"#ff00ff",maroon:"#800000",mediumaquamarine:"#66cdaa",mediumblue:"#0000cd",mediumorchid:"#ba55d3",mediumpurple:"#9370db",mediumseagreen:"#3cb371",mediumslateblue:"#7b68ee",mediumspringgreen:"#00fa9a",mediumturquoise:"#48d1cc",mediumvioletred:"#c71585",midnightblue:"#191970",mintcream:"#f5fffa",mistyrose:"#ffe4e1", +moccasin:"#ffe4b5",navajowhite:"#ffdead",navy:"#000080",oldlace:"#fdf5e6",olive:"#808000",olivedrab:"#6b8e23",orange:"#ffa500",orangered:"#ff4500",orchid:"#da70d6",palegoldenrod:"#eee8aa",palegreen:"#98fb98",paleturquoise:"#afeeee",palevioletred:"#db7093",papayawhip:"#ffefd5",peachpuff:"#ffdab9",peru:"#cd853f",pink:"#ffc0cb",plum:"#dda0dd",powderblue:"#b0e0e6",purple:"#800080",red:"#ff0000",rosybrown:"#bc8f8f",royalblue:"#4169e1",saddlebrown:"#8b4513",salmon:"#fa8072",sandybrown:"#f4a460",seagreen:"#2e8b57", +seashell:"#fff5ee",sienna:"#a0522d",silver:"#c0c0c0",skyblue:"#87ceeb",slateblue:"#6a5acd",slategray:"#708090",slategrey:"#708090",snow:"#fffafa",springgreen:"#00ff7f",steelblue:"#4682b4",tan:"#d2b48c",teal:"#008080",thistle:"#d8bfd8",tomato:"#ff6347",turquoise:"#40e0d0",violet:"#ee82ee",wheat:"#f5deb3",white:"#ffffff",whitesmoke:"#f5f5f5",yellow:"#ffff00",yellowgreen:"#9acd32"};function la(a,b){this.width=a;this.height=b}g=la.prototype;g.toString=function(){return"("+this.width+" x "+this.height+")"};g.ceil=function(){this.width=Math.ceil(this.width);this.height=Math.ceil(this.height);return this};g.floor=function(){this.width=Math.floor(this.width);this.height=Math.floor(this.height);return this};g.round=function(){this.width=Math.round(this.width);this.height=Math.round(this.height);return this};g.scale=function(a,b){b=aa(b)?b:a;this.width*=a;this.height*=b;return this};function ma(a){var b=a.length-1;return 0<=b&&a.indexOf(" ",b)==b}function na(a){return String(a).replace(/\-([a-z])/g,function(a,c){return c.toUpperCase()})}; +function v(a,b,c){this.a=a;this.b=b||1;this.f=c||1};function oa(a){this.b=a;this.a=0}function pa(a){a=a.match(qa);for(var b=0;b<a.length;b++)ra.test(a[b])&&a.splice(b,1);return new oa(a)}var qa=/\$?(?:(?![0-9-\.])(?:\*|[\w-\.]+):)?(?![0-9-\.])(?:\*|[\w-\.]+)|\/\/|\.\.|::|\d+(?:\.\d*)?|\.\d+|"[^"]*"|'[^']*'|[!<>]=|\s+|./g,ra=/^\s/;function w(a,b){return a.b[a.a+(b||0)]}oa.prototype.next=function(){return this.b[this.a++]};function sa(a){return a.b.length<=a.a};var ta;a:{var ua=l.navigator;if(ua){var va=ua.userAgent;if(va){ta=va;break a}}ta=""};function x(a,b){this.j=a;this.c=n(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 wa(a){return"comment"==a||"text"==a||"processing-instruction"==a||"node"==a}x.prototype.a=function(a){return null===this.b||this.b==a.nodeType};x.prototype.f=function(){return this.j}; +x.prototype.toString=function(){var a="Kind Test: "+this.j;null===this.c||(a+=y(this.c));return a};function xa(a,b){this.o=a.toLowerCase();a="*"==this.o?"*":"http://www.w3.org/1999/xhtml";this.b=b?b.toLowerCase():a}xa.prototype.a=function(a){var b=a.nodeType;if(1!=b&&2!=b)return!1;b=n(a.localName)?a.localName:a.nodeName;return"*"!=this.o&&this.o!=b.toLowerCase()?!1:"*"==this.b?!0:this.b==(a.namespaceURI?a.namespaceURI.toLowerCase():"http://www.w3.org/1999/xhtml")};xa.prototype.f=function(){return this.o}; +xa.prototype.toString=function(){return"Name Test: "+("http://www.w3.org/1999/xhtml"==this.b?"":this.b+":")+this.o};function ya(a){switch(a.nodeType){case 1:return ha(za,a);case 9:return ya(a.documentElement);case 11:case 10:case 6:case 12:return Aa;default:return a.parentNode?ya(a.parentNode):Aa}}function Aa(){return null}function za(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?za(a.parentNode,b):null};function Ba(a,b){if(p(a))return p(b)&&1==b.length?a.indexOf(b,0):-1;for(var c=0;c<a.length;c++)if(c in a&&a[c]===b)return c;return-1}function z(a,b){for(var c=a.length,d=p(a)?a.split(""):a,e=0;e<c;e++)e in d&&b.call(void 0,d[e],e,a)}function A(a,b,c){var d=c;z(a,function(c,f){d=b.call(void 0,d,c,f,a)});return d}function Ca(a,b){for(var c=a.length,d=p(a)?a.split(""):a,e=0;e<c;e++)if(e in d&&b.call(void 0,d[e],e,a))return!0;return!1} +function Da(a,b){for(var c=a.length,d=p(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 Ea(a){return Array.prototype.concat.apply([],arguments)}function Fa(a,b,c){return 2>=arguments.length?Array.prototype.slice.call(a,b):Array.prototype.slice.call(a,b,c)};var Ga="backgroundColor borderTopColor borderRightColor borderBottomColor borderLeftColor color outlineColor".split(" "),Ha=/#([0-9a-fA-F])([0-9a-fA-F])([0-9a-fA-F])/,Ia=/^#(?:[0-9a-f]{3}){1,2}$/i,Ja=/^(?:rgba)?\((\d{1,3}),\s?(\d{1,3}),\s?(\d{1,3}),\s?(0|1|0\.\d*)\)$/i,Ka=/^(?:rgb)?\((0|[1-9]\d{0,2}),\s?(0|[1-9]\d{0,2}),\s?(0|[1-9]\d{0,2})\)$/i;function La(a,b){this.x=n(a)?a:0;this.y=n(b)?b:0}g=La.prototype;g.toString=function(){return"("+this.x+", "+this.y+")"};g.ceil=function(){this.x=Math.ceil(this.x);this.y=Math.ceil(this.y);return this};g.floor=function(){this.x=Math.floor(this.x);this.y=Math.floor(this.y);return this};g.round=function(){this.x=Math.round(this.x);this.y=Math.round(this.y);return this};g.scale=function(a,b){b=aa(b)?b:a;this.x*=a;this.y*=b;return this};var Ma=-1!=ta.indexOf("Macintosh"),Na=-1!=ta.indexOf("Windows");function Oa(a,b,c,d){this.c=a;this.a=b;this.b=c;this.f=d}g=Oa.prototype;g.toString=function(){return"("+this.c+"t, "+this.a+"r, "+this.b+"b, "+this.f+"l)"};g.contains=function(a){return this&&a?a instanceof Oa?a.f>=this.f&&a.a<=this.a&&a.c>=this.c&&a.b<=this.b:a.x>=this.f&&a.x<=this.a&&a.y>=this.c&&a.y<=this.b:!1};g.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}; +g.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};g.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};g.scale=function(a,b){b=aa(b)?b:a;this.f*=a;this.a*=a;this.c*=b;this.b*=b;return this};function Pa(a,b){this.w={};this.m=[];this.a=0;var c=arguments.length;if(1<c){if(c%2)throw Error("Uneven number of arguments");for(var d=0;d<c;d+=2)this.set(arguments[d],arguments[d+1])}else if(a){if(a instanceof Pa){d=Qa(a);Ra(a);var e=[];for(c=0;c<a.m.length;c++)e.push(a.w[a.m[c]])}else{var c=[],f=0;for(d in a)c[f++]=d;d=c;c=[];f=0;for(e in a)c[f++]=a[e];e=c}for(c=0;c<d.length;c++)this.set(d[c],e[c])}}function Qa(a){Ra(a);return a.m.concat()} +function Ra(a){var b,c;if(a.a!=a.m.length){for(b=c=0;c<a.m.length;){var d=a.m[c];Object.prototype.hasOwnProperty.call(a.w,d)&&(a.m[b++]=d);c++}a.m.length=b}if(a.a!=a.m.length){var e={};for(b=c=0;c<a.m.length;)d=a.m[c],Object.prototype.hasOwnProperty.call(e,d)||(a.m[b++]=d,e[d]=1),c++;a.m.length=b}}Pa.prototype.get=function(a,b){return Object.prototype.hasOwnProperty.call(this.w,a)?this.w[a]:b}; +Pa.prototype.set=function(a,b){Object.prototype.hasOwnProperty.call(this.w,a)||(this.a++,this.m.push(a));this.w[a]=b};function B(a,b,c,d){this.a=a;this.b=b;this.width=c;this.height=d}g=B.prototype;g.toString=function(){return"("+this.a+", "+this.b+" - "+this.width+"w x "+this.height+"h)"};g.contains=function(a){return a instanceof La?a.x>=this.a&&a.x<=this.a+this.width&&a.y>=this.b&&a.y<=this.b+this.height:this.a<=a.a&&this.a+this.width>=a.a+a.width&&this.b<=a.b&&this.b+this.height>=a.b+a.height}; +g.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};g.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};g.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}; +g.scale=function(a,b){b=aa(b)?b:a;this.a*=a;this.width*=a;this.b*=b;this.height*=b;return this};var Sa,Ta,Ua=function(){var a=l.Components;if(!a)return!1;try{if(!a.classes)return!1}catch(f){return!1}var b=a.classes,a=a.interfaces,c=b["@mozilla.org/xpcom/version-comparator;1"].getService(a.nsIVersionComparator),b=b["@mozilla.org/xre/app-info;1"].getService(a.nsIXULAppInfo),d=b.platformVersion,e=b.version;Sa=function(a){c.compare(d,""+a)};Ta=function(a){c.compare(e,""+a)};return!0}();function Va(a){for(;a&&1!=a.nodeType;)a=a.previousSibling;return a}function Wa(a,b){if(!a||!b)return!1;if(a.contains&&1==b.nodeType)return a==b||a.contains(b);if("undefined"!=typeof a.compareDocumentPosition)return a==b||!!(a.compareDocumentPosition(b)&16);for(;b&&a!=b;)b=b.parentNode;return b==a} +function Xa(a,b){if(a==b)return 0;if(a.compareDocumentPosition)return a.compareDocumentPosition(b)&2?1:-1;if("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=D(a);c=d.createRange();c.selectNode(a);c.collapse(!0);a=d.createRange();a.selectNode(b); +a.collapse(!0);return c.compareBoundaryPoints(l.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 D(a){return 9==a.nodeType?a:a.ownerDocument||a.document}function $a(a,b){a&&(a=a.parentNode);for(var c=0;a;){if(b(a))return a;a=a.parentNode;c++}return null}function ab(a){this.a=a||l.document||document} +ab.prototype.getElementsByTagName=function(a,b){return(b||this.a).getElementsByTagName(String(a))};ab.prototype.contains=Wa;function E(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(9==c||1==c){a=9==c?a.documentElement:a.firstChild;for(var c=0,d=[],b="";a;){do 1!=a.nodeType&&(b+=a.nodeValue),d[c++]=a;while(a=a.firstChild);for(;c&&!(a=d[--c].nextSibling););}}else b=a.nodeValue;return""+b} +function F(a,b,c){if(null===b)return!0;try{if(!a.getAttribute)return!1}catch(d){return!1}return null==c?!!a.getAttribute(b):a.getAttribute(b,2)==c}function bb(a,b,c,d,e){return cb.call(null,a,b,p(c)?c:null,p(d)?d:null,e||new G)} +function cb(a,b,c,d,e){b.getElementsByName&&d&&"name"==c?(b=b.getElementsByName(d),z(b,function(b){a.a(b)&&H(e,b)})):b.getElementsByClassName&&d&&"class"==c?(b=b.getElementsByClassName(d),z(b,function(b){b.className==d&&a.a(b)&&H(e,b)})):a instanceof x?db(a,b,c,d,e):b.getElementsByTagName&&(b=b.getElementsByTagName(a.f()),z(b,function(a){F(a,c,d)&&H(e,a)}));return e}function db(a,b,c,d,e){for(b=b.firstChild;b;b=b.nextSibling)F(b,c,d)&&a.a(b)&&H(e,b),db(a,b,c,d,e)};function I(a,b){b&&"string"!==typeof b&&(b=b.toString());return!!a&&1==a.nodeType&&(!b||a.tagName.toUpperCase()==b)};function G(){this.b=this.a=null;this.s=0}function eb(a){this.node=a;this.next=this.a=null}function fb(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;)c.node==b.node?(e=c,c=c.next,b=b.next):0<Xa(c.node,b.node)?(e=b,b=b.next):(e=c,c=c.next),(e.a=d)?d.next=e:a.a=e,d=e,f++;for(e=c||b;e;)e.a=d,d=d.next=e,f++,e=e.next;a.b=d;a.s=f;return a}function gb(a,b){b=new eb(b);b.next=a.a;a.b?a.a.a=b:a.a=a.b=b;a.a=b;a.s++} +function H(a,b){b=new eb(b);b.a=a.b;a.a?a.b.next=b:a.a=a.b=b;a.b=b;a.s++}function hb(a){return(a=a.a)?a.node:null}function ib(a){return(a=hb(a))?E(a):""}G.prototype.iterator=function(a){return new jb(this,!!a)};function jb(a,b){this.f=a;this.b=(this.A=b)?a.b:a.a;this.a=null}jb.prototype.next=function(){var a=this.b;if(a){var b=this.a=a;this.b=this.A?a.a:a.next;return b.node}return null};function J(a){this.l=a;this.b=this.i=!1;this.f=null}function y(a){return"\n "+a.toString().split("\n").join("\n ")}function kb(a,b){a.i=b}function lb(a,b){a.b=b}function K(a,b){a=a.a(b);return a instanceof G?+ib(a):+a}function M(a,b){a=a.a(b);return a instanceof G?ib(a):""+a}function N(a,b){a=a.a(b);return a instanceof G?!!a.s:!!a};function mb(a,b,c){J.call(this,a.l);this.c=a;this.j=b;this.v=c;this.i=b.i||c.i;this.b=b.b||c.b;this.c==nb&&(c.b||c.i||4==c.l||0==c.l||!b.f?b.b||b.i||4==b.l||0==b.l||!c.f||(this.f={name:c.f.name,B:b}):this.f={name:b.f.name,B:c})}q(mb,J); +function O(a,b,c,d,e){b=b.a(d);c=c.a(d);var f;if(b instanceof G&&c instanceof G){b=b.iterator();for(d=b.next();d;d=b.next())for(e=c.iterator(),f=e.next();f;f=e.next())if(a(E(d),E(f)))return!0;return!1}if(b instanceof G||c instanceof G){b instanceof G?(e=b,d=c):(e=c,d=b);f=e.iterator();for(var h=typeof d,k=f.next();k;k=f.next()){switch(h){case "number":k=+E(k);break;case "boolean":k=!!E(k);break;case "string":k=E(k);break;default:throw Error("Illegal primitive type for comparison.");}if(e==b&&a(k, +d)||e==c&&a(d,k))return!0}return!1}return e?"boolean"==typeof b||"boolean"==typeof c?a(!!b,!!c):"number"==typeof b||"number"==typeof c?a(+b,+c):a(b,c):a(+b,+c)}mb.prototype.a=function(a){return this.c.u(this.j,this.v,a)};mb.prototype.toString=function(){var a="Binary Expression: "+this.c,a=a+y(this.j);return a+=y(this.v)};function ob(a,b,c,d){this.O=a;this.K=b;this.l=c;this.u=d}ob.prototype.toString=function(){return this.O};var pb={}; +function P(a,b,c,d){if(pb.hasOwnProperty(a))throw Error("Binary operator already created: "+a);a=new ob(a,b,c,d);return pb[a.toString()]=a}P("div",6,1,function(a,b,c){return K(a,c)/K(b,c)});P("mod",6,1,function(a,b,c){return K(a,c)%K(b,c)});P("*",6,1,function(a,b,c){return K(a,c)*K(b,c)});P("+",5,1,function(a,b,c){return K(a,c)+K(b,c)});P("-",5,1,function(a,b,c){return K(a,c)-K(b,c)});P("<",4,2,function(a,b,c){return O(function(a,b){return a<b},a,b,c)}); +P(">",4,2,function(a,b,c){return O(function(a,b){return a>b},a,b,c)});P("<=",4,2,function(a,b,c){return O(function(a,b){return a<=b},a,b,c)});P(">=",4,2,function(a,b,c){return O(function(a,b){return a>=b},a,b,c)});var nb=P("=",3,2,function(a,b,c){return O(function(a,b){return a==b},a,b,c,!0)});P("!=",3,2,function(a,b,c){return O(function(a,b){return a!=b},a,b,c,!0)});P("and",2,2,function(a,b,c){return N(a,c)&&N(b,c)});P("or",1,2,function(a,b,c){return N(a,c)||N(b,c)});function qb(a,b){if(b.a.length&&4!=a.l)throw Error("Primary expression must evaluate to nodeset if filter has predicate(s).");J.call(this,a.l);this.c=a;this.j=b;this.i=a.i;this.b=a.b}q(qb,J);qb.prototype.a=function(a){a=this.c.a(a);return rb(this.j,a)};qb.prototype.toString=function(){var a="Filter:"+y(this.c);return a+=y(this.j)};function sb(a,b){if(b.length<a.J)throw Error("Function "+a.o+" expects at least"+a.J+" arguments, "+b.length+" given");if(null!==a.F&&b.length>a.F)throw Error("Function "+a.o+" expects at most "+a.F+" arguments, "+b.length+" given");a.N&&z(b,function(b,d){if(4!=b.l)throw Error("Argument "+d+" to function "+a.o+" is not of type Nodeset: "+b);});J.call(this,a.l);this.C=a;this.c=b;kb(this,a.i||Ca(b,function(a){return a.i}));lb(this,a.M&&!b.length||a.L&&!!b.length||Ca(b,function(a){return a.b}))} +q(sb,J);sb.prototype.a=function(a){return this.C.u.apply(null,Ea(a,this.c))};sb.prototype.toString=function(){var a="Function: "+this.C;if(this.c.length)var b=A(this.c,function(a,b){return a+y(b)},"Arguments:"),a=a+y(b);return a};function tb(a,b,c,d,e,f,h,k,r){this.o=a;this.l=b;this.i=c;this.M=d;this.L=e;this.u=f;this.J=h;this.F=n(k)?k:h;this.N=!!r}tb.prototype.toString=function(){return this.o};var ub={}; +function Q(a,b,c,d,e,f,h,k){if(ub.hasOwnProperty(a))throw Error("Function already created: "+a+".");ub[a]=new tb(a,b,c,d,!1,e,f,h,k)}Q("boolean",2,!1,!1,function(a,b){return N(b,a)},1);Q("ceiling",1,!1,!1,function(a,b){return Math.ceil(K(b,a))},1);Q("concat",3,!1,!1,function(a,b){return A(Fa(arguments,1),function(b,d){return b+M(d,a)},"")},2,null);Q("contains",2,!1,!1,function(a,b,c){b=M(b,a);a=M(c,a);return-1!=b.indexOf(a)},2);Q("count",1,!1,!1,function(a,b){return b.a(a).s},1,1,!0); +Q("false",2,!1,!1,function(){return!1},0);Q("floor",1,!1,!1,function(a,b){return Math.floor(K(b,a))},1);Q("id",4,!1,!1,function(a,b){var c=a.a,d=9==c.nodeType?c:c.ownerDocument;a=M(b,a).split(/\s+/);var e=[];z(a,function(a){a=d.getElementById(a);!a||0<=Ba(e,a)||e.push(a)});e.sort(Xa);var f=new G;z(e,function(a){H(f,a)});return f},1);Q("lang",2,!1,!1,function(){return!1},1);Q("last",1,!0,!1,function(a){if(1!=arguments.length)throw Error("Function last expects ()");return a.f},0); +Q("local-name",3,!1,!0,function(a,b){return(a=b?hb(b.a(a)):a.a)?a.localName||a.nodeName.toLowerCase():""},0,1,!0);Q("name",3,!1,!0,function(a,b){return(a=b?hb(b.a(a)):a.a)?a.nodeName.toLowerCase():""},0,1,!0);Q("namespace-uri",3,!0,!1,function(){return""},0,1,!0);Q("normalize-space",3,!1,!0,function(a,b){return(b?M(b,a):E(a.a)).replace(/[\s\xa0]+/g," ").replace(/^\s+|\s+$/g,"")},0,1);Q("not",2,!1,!1,function(a,b){return!N(b,a)},1);Q("number",1,!1,!0,function(a,b){return b?K(b,a):+E(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(K(b,a))},1);Q("starts-with",2,!1,!1,function(a,b,c){b=M(b,a);a=M(c,a);return!b.lastIndexOf(a,0)},2);Q("string",3,!1,!0,function(a,b){return b?M(b,a):E(a.a)},0,1);Q("string-length",1,!1,!0,function(a,b){return(b?M(b,a):E(a.a)).length},0,1); +Q("substring",3,!1,!1,function(a,b,c,d){c=K(c,a);if(isNaN(c)||Infinity==c||-Infinity==c)return"";d=d?K(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);Q("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); +Q("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);Q("sum",1,!1,!1,function(a,b){a=b.a(a).iterator();b=0;for(var c=a.next();c;c=a.next())b+=+E(c);return b},1,1,!0);Q("translate",3,!1,!1,function(a,b,c,d){b=M(b,a);c=M(c,a);var e=M(d,a);d={};for(var f=0;f<c.length;f++)a=c.charAt(f),a in d||(d[a]=e.charAt(f));c="";for(f=0;f<b.length;f++)a=b.charAt(f),c+=a in d?d[a]:a;return c},3);Q("true",2,!1,!1,function(){return!0},0);function vb(a){J.call(this,3);this.c=a.substring(1,a.length-1)}q(vb,J);vb.prototype.a=function(){return this.c};vb.prototype.toString=function(){return"Literal: "+this.c};function wb(a){J.call(this,1);this.c=a}q(wb,J);wb.prototype.a=function(){return this.c};wb.prototype.toString=function(){return"Number: "+this.c};function xb(a,b){J.call(this,a.l);this.j=a;this.c=b;this.i=a.i;this.b=a.b;1==this.c.length&&(a=this.c[0],a.D||a.c!=yb||(a=a.v,"*"!=a.f()&&(this.f={name:a.f(),B:null})))}q(xb,J);function zb(){J.call(this,4)}q(zb,J);zb.prototype.a=function(a){var b=new G;a=a.a;9==a.nodeType?H(b,a):H(b,a.ownerDocument);return b};zb.prototype.toString=function(){return"Root Helper Expression"};function Ab(){J.call(this,4)}q(Ab,J);Ab.prototype.a=function(a){var b=new G;H(b,a.a);return b};Ab.prototype.toString=function(){return"Context Helper Expression"}; +function Bb(a){return"/"==a||"//"==a}xb.prototype.a=function(a){var b=this.j.a(a);if(!(b instanceof G))throw Error("Filter expression must evaluate to nodeset.");a=this.c;for(var c=0,d=a.length;c<d&&b.s;c++){var e=a[c],f=b.iterator(e.c.A);if(e.i||e.c!=Cb)if(e.i||e.c!=Db){var h=f.next();for(b=e.a(new v(h));h=f.next();)h=e.a(new v(h)),b=fb(b,h)}else h=f.next(),b=e.a(new v(h));else{for(h=f.next();(b=f.next())&&(!h.contains||h.contains(b))&&b.compareDocumentPosition(h)&8;h=b);b=e.a(new v(h))}}return b}; +xb.prototype.toString=function(){var a="Path Expression:"+y(this.j);if(this.c.length){var b=A(this.c,function(a,b){return a+y(b)},"Steps:");a+=y(b)}return a};function Eb(a,b){this.a=a;this.A=!!b} +function rb(a,b,c){for(c=c||0;c<a.a.length;c++)for(var d=a.a[c],e=b.iterator(),f=b.s,h,k=0;h=e.next();k++){var r=a.A?f-k:k+1;h=d.a(new v(h,r,f));if("number"==typeof h)r=r==h;else if("string"==typeof h||"boolean"==typeof h)r=!!h;else if(h instanceof G)r=0<h.s;else throw Error("Predicate.evaluate returned an unexpected type.");if(!r){r=e;h=r.f;var t=r.a;if(!t)throw Error("Next must be called at least once before remove.");var m=t.a,t=t.next;m?m.next=t:h.a=t;t?t.a=m:h.b=m;h.s--;r.a=null}}return b} +Eb.prototype.toString=function(){return A(this.a,function(a,b){return a+y(b)},"Predicates:")};function Fb(a){J.call(this,1);this.c=a;this.i=a.i;this.b=a.b}q(Fb,J);Fb.prototype.a=function(a){return-K(this.c,a)};Fb.prototype.toString=function(){return"Unary Expression: -"+y(this.c)};function Gb(a){J.call(this,4);this.c=a;kb(this,Ca(this.c,function(a){return a.i}));lb(this,Ca(this.c,function(a){return a.b}))}q(Gb,J);Gb.prototype.a=function(a){var b=new G;z(this.c,function(c){c=c.a(a);if(!(c instanceof G))throw Error("Path expression must evaluate to NodeSet.");b=fb(b,c)});return b};Gb.prototype.toString=function(){return A(this.c,function(a,b){return a+y(b)},"Union Expression:")};function R(a,b,c,d){J.call(this,4);this.c=a;this.v=b;this.j=c||new Eb([]);this.D=!!d;b=this.j;b=0<b.a.length?b.a[0].f:null;a.R&&b&&(this.f={name:b.name,B:b.B});a:{a=this.j;for(b=0;b<a.a.length;b++)if(c=a.a[b],c.i||1==c.l||0==c.l){a=!0;break a}a=!1}this.i=a}q(R,J); +R.prototype.a=function(a){var b=a.a,c=this.f,d=null,e=null,f=0;c&&(d=c.name,e=c.B?M(c.B,a):null,f=1);if(this.D)if(this.i||this.c!=Hb)if(b=(new R(Ib,new x("node"))).a(a).iterator(),c=b.next())for(a=this.u(c,d,e,f);c=b.next();)a=fb(a,this.u(c,d,e,f));else a=new G;else a=bb(this.v,b,d,e),a=rb(this.j,a,f);else a=this.u(a.a,d,e,f);return a};R.prototype.u=function(a,b,c,d){a=this.c.C(this.v,a,b,c);return a=rb(this.j,a,d)}; +R.prototype.toString=function(){var a="Step:"+y("Operator: "+(this.D?"//":"/"));this.c.o&&(a+=y("Axis: "+this.c));a+=y(this.v);if(this.j.a.length){var b=A(this.j.a,function(a,b){return a+y(b)},"Predicates:");a+=y(b)}return a};function Jb(a,b,c,d){this.o=a;this.C=b;this.A=c;this.R=d}Jb.prototype.toString=function(){return this.o};var Kb={};function S(a,b,c,d){if(Kb.hasOwnProperty(a))throw Error("Axis already created: "+a);b=new Jb(a,b,c,!!d);return Kb[a]=b} +S("ancestor",function(a,b){for(var c=new G;b=b.parentNode;)a.a(b)&&gb(c,b);return c},!0);S("ancestor-or-self",function(a,b){var c=new G;do a.a(b)&&gb(c,b);while(b=b.parentNode);return c},!0); +var yb=S("attribute",function(a,b){var c=new G,d=a.f();if(b=b.attributes)if(a instanceof x&&null===a.b||"*"==d)for(d=0;a=b[d];d++)H(c,a);else(a=b.getNamedItem(d))&&H(c,a);return c},!1),Hb=S("child",function(a,b,c,d,e){c=p(c)?c:null;d=p(d)?d:null;e=e||new G;for(b=b.firstChild;b;b=b.nextSibling)F(b,c,d)&&a.a(b)&&H(e,b);return e},!1,!0);S("descendant",bb,!1,!0); +var Ib=S("descendant-or-self",function(a,b,c,d){var e=new G;F(b,c,d)&&a.a(b)&&H(e,b);return bb(a,b,c,d,e)},!1,!0),Cb=S("following",function(a,b,c,d){var e=new G;do for(var f=b;f=f.nextSibling;)F(f,c,d)&&a.a(f)&&H(e,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 G;b=b.nextSibling;)a.a(b)&&H(c,b);return c},!1);S("namespace",function(){return new G},!1); +var Lb=S("parent",function(a,b){var c=new G;if(9==b.nodeType)return c;if(2==b.nodeType)return H(c,b.ownerElement),c;b=b.parentNode;a.a(b)&&H(c,b);return c},!1),Db=S("preceding",function(a,b,c,d){var e=new G,f=[];do f.unshift(b);while(b=b.parentNode);for(var h=1,k=f.length;h<k;h++){var r=[];for(b=f[h];b=b.previousSibling;)r.unshift(b);for(var t=0,m=r.length;t<m;t++)b=r[t],F(b,c,d)&&a.a(b)&&H(e,b),e=bb(a,b,c,d,e)}return e},!0,!0); +S("preceding-sibling",function(a,b){for(var c=new G;b=b.previousSibling;)a.a(b)&&gb(c,b);return c},!0);var Mb=S("self",function(a,b){var c=new G;a.a(b)&&H(c,b);return c},!1);function Nb(a,b){this.a=a;this.b=b}function Ob(a){for(var b,c=[];;){U(a,"Missing right hand side of binary expression.");b=Pb(a);var d=a.a.next();if(!d)break;var e=(d=pb[d]||null)&&d.K;if(!e){a.a.a--;break}for(;c.length&&e<=c[c.length-1].K;)b=new mb(c.pop(),c.pop(),b);c.push(b,d)}for(;c.length;)b=new mb(c.pop(),c.pop(),b);return b}function U(a,b){if(sa(a.a))throw Error(b);}function Qb(a,b){a=a.a.next();if(a!=b)throw Error("Bad token, expected: "+b+" got: "+a);} +function Rb(a){a=a.a.next();if(")"!=a)throw Error("Bad token: "+a);}function Sb(a){a=a.a.next();if(2>a.length)throw Error("Unclosed literal string");return new vb(a)} +function Tb(a){var b=[];if(Bb(w(a.a))){var c=a.a.next();var d=w(a.a);if("/"==c&&(sa(a.a)||"."!=d&&".."!=d&&"@"!=d&&"*"!=d&&!/(?![0-9])[\w]/.test(d)))return new zb;d=new zb;U(a,"Missing next location step.");c=Ub(a,c);b.push(c)}else{a:{c=w(a.a);d=c.charAt(0);switch(d){case "$":throw Error("Variable reference not allowed in HTML XPath");case "(":a.a.next();c=Ob(a);U(a,'unclosed "("');Qb(a,")");break;case '"':case "'":c=Sb(a);break;default:if(isNaN(+c))if(!wa(c)&&/(?![0-9])[\w]/.test(d)&&"("==w(a.a, +1)){c=a.a.next();c=ub[c]||null;a.a.next();for(d=[];")"!=w(a.a);){U(a,"Missing function argument list.");d.push(Ob(a));if(","!=w(a.a))break;a.a.next()}U(a,"Unclosed function argument list.");Rb(a);c=new sb(c,d)}else{c=null;break a}else c=new wb(+a.a.next())}"["==w(a.a)&&(d=new Eb(Vb(a)),c=new qb(c,d))}if(c)if(Bb(w(a.a)))d=c;else return c;else c=Ub(a,"/"),d=new Ab,b.push(c)}for(;Bb(w(a.a));)c=a.a.next(),U(a,"Missing next location step."),c=Ub(a,c),b.push(c);return new xb(d,b)} +function Ub(a,b){if("/"!=b&&"//"!=b)throw Error('Step op should be "/" or "//"');if("."==w(a.a)){var c=new R(Mb,new x("node"));a.a.next();return c}if(".."==w(a.a))return c=new R(Lb,new x("node")),a.a.next(),c;if("@"==w(a.a)){var d=yb;a.a.next();U(a,"Missing attribute name")}else if("::"==w(a.a,1)){if(!/(?![0-9])[\w]/.test(w(a.a).charAt(0)))throw Error("Bad token: "+a.a.next());var e=a.a.next();d=Kb[e]||null;if(!d)throw Error("No axis with name: "+e);a.a.next();U(a,"Missing node name")}else d=Hb;e= +w(a.a);if(/(?![0-9])[\w\*]/.test(e.charAt(0)))if("("==w(a.a,1)){if(!wa(e))throw Error("Invalid node type: "+e);e=a.a.next();if(!wa(e))throw Error("Invalid type name: "+e);Qb(a,"(");U(a,"Bad nodetype");var f=w(a.a).charAt(0),h=null;if('"'==f||"'"==f)h=Sb(a);U(a,"Bad nodetype");Rb(a);e=new x(e,h)}else if(e=a.a.next(),f=e.indexOf(":"),-1==f)e=new xa(e);else{var h=e.substring(0,f);if("*"==h)var k="*";else if(k=a.b(h),!k)throw Error("Namespace prefix not declared: "+h);e=e.substr(f+1);e=new xa(e,k)}else throw Error("Bad token: "+ +a.a.next());a=new Eb(Vb(a),d.A);return c||new R(d,e,a,"//"==b)}function Vb(a){for(var b=[];"["==w(a.a);){a.a.next();U(a,"Missing predicate expression.");var c=Ob(a);b.push(c);U(a,"Unclosed predicate expression.");Qb(a,"]")}return b}function Pb(a){if("-"==w(a.a))return a.a.next(),new Fb(Pb(a));var b=Tb(a);if("|"!=w(a.a))a=b;else{for(b=[b];"|"==a.a.next();)U(a,"Missing next union location path."),b.push(Tb(a));a.a.a--;a=new Gb(b)}return a};function Wb(a,b){if(!a.length)throw Error("Empty XPath expression.");a=pa(a);if(sa(a))throw Error("Invalid XPath expression.");b?"function"==ca(b)||(b=fa(b.lookupNamespaceURI,b)):b=function(){return null};var c=Ob(new Nb(a,b));if(!sa(a))throw Error("Bad token: "+a.next());this.evaluate=function(a,b){a=c.a(new v(a));return new V(a,b)}} +function V(a,b){if(!b)if(a instanceof G)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 G))throw Error("value could not be converted to the specified type");this.resultType=b;switch(b){case 2:this.stringValue=a instanceof G?ib(a):""+a;break;case 1:this.numberValue=a instanceof G?+ib(a):+a;break;case 3:this.booleanValue=a instanceof G?0<a.s:!!a;break;case 4:case 5:case 6:case 7:var c= +a.iterator();var d=[];for(var e=c.next();e;e=c.next())d.push(e);this.snapshotLength=a.s;this.invalidIteratorState=!1;break;case 8:case 9:this.singleNodeValue=hb(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(a){if(6!=b&&7!=b)throw Error("snapshotItem called with wrong result type");return a>=d.length||0>a?null:d[a]}} +V.ANY_TYPE=0;V.NUMBER_TYPE=1;V.STRING_TYPE=2;V.BOOLEAN_TYPE=3;V.UNORDERED_NODE_ITERATOR_TYPE=4;V.ORDERED_NODE_ITERATOR_TYPE=5;V.UNORDERED_NODE_SNAPSHOT_TYPE=6;V.ORDERED_NODE_SNAPSHOT_TYPE=7;V.ANY_UNORDERED_NODE_TYPE=8;V.FIRST_ORDERED_NODE_TYPE=9;function Xb(a){this.lookupNamespaceURI=ya(a)} +ba("wgxpath.install",function(a,b){a=a||l;var c=a.Document&&a.Document.prototype||a.document;if(!c.evaluate||b)a.XPathResult=V,c.evaluate=function(a,b,c,h){return(new Wb(a,c)).evaluate(b,h)},c.createExpression=function(a,b){return new Wb(a,b)},c.createNSResolver=function(a){return new Xb(a)}});var W={};W.G=function(){var a={V:"http://www.w3.org/2000/svg"};return function(b){return a[b]||null}}(); +W.u=function(a,b,c){var d=D(a);if(!d.documentElement)return null;try{for(var e=d.createNSResolver?d.createNSResolver(d.documentElement):W.G,f={},h=d.getElementsByTagName("*"),k=0;k<h.length;++k){var r=h[k],t=r.namespaceURI;if(t&&!f[t]){var m=r.lookupPrefix(t);if(!m)var C=t.match(".*/(\\w+)/?$"),m=C?C[1]:"xhtml";f[t]=m}}var L={},T;for(T in f)L[f[T]]=T;e=function(a){return L[a]||null};try{return d.evaluate(b,a,e,c,null)}catch(ga){if("TypeError"===ga.name)return e=d.createNSResolver?d.createNSResolver(d.documentElement): +W.G,d.evaluate(b,a,e,c,null);throw ga;}}catch(ga){if("NS_ERROR_ILLEGAL_VALUE"!=ga.name)throw new ia(32,"Unable to locate an element with the xpath expression "+b+" because of the following error:\n"+ga);}};W.H=function(a,b){if(!a||1!=a.nodeType)throw new ia(32,'The result of the xpath expression "'+b+'" is: '+a+". It should be an element.");}; +W.P=function(a,b){var c=function(){var c=W.u(b,a,9);return c?c.singleNodeValue||null:b.selectSingleNode?(c=D(b),c.setProperty&&c.setProperty("SelectionLanguage","XPath"),b.selectSingleNode(a)):null}();null===c||W.H(c,a);return c}; +W.T=function(a,b){var c=function(){var c=W.u(b,a,7);if(c){for(var e=c.snapshotLength,f=[],h=0;h<e;++h)f.push(c.snapshotItem(h));return f}return b.selectNodes?(c=D(b),c.setProperty&&c.setProperty("SelectionLanguage","XPath"),b.selectNodes(a)):[]}();z(c,function(b){W.H(b,a)});return c};var Yb="function"===typeof ShadowRoot;function Zb(a){for(a=a.parentNode;a&&1!=a.nodeType&&9!=a.nodeType&&11!=a.nodeType;)a=a.parentNode;return I(a)?a:null} +function X(a,b){b=na(b);if("float"==b||"cssFloat"==b||"styleFloat"==b)b="cssFloat";a:{var c=b;var d=D(a);if(d.defaultView&&d.defaultView.getComputedStyle&&(d=d.defaultView.getComputedStyle(a,null))){c=d[c]||d.getPropertyValue(c)||"";break a}c=""}a=c||$b(a,b);if(null===a)a=null;else if(0<=Ba(Ga,b)){b:{var e=a.match(Ja);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(Ka))if(b=Number(d[1]), +c=Number(d[2]),d=Number(d[3]),0<=b&&255>=b&&0<=c&&255>=c&&0<=d&&255>=d){b=[b,c,d,1];break b}b=null}if(!b)b:{b=a.toLowerCase();c=ka[b.toLowerCase()];if(!c&&(c="#"==b.charAt(0)?b:"#"+b,4==c.length&&(c=c.replace(Ha,"#$1$1$2$2$3$3")),!Ia.test(c))){b=null;break b}b=[parseInt(c.substr(1,2),16),parseInt(c.substr(3,2),16),parseInt(c.substr(5,2),16),1]}a=b?"rgba("+b.join(", ")+")":a}return a} +function $b(a,b){var c=a.currentStyle||a.style,d=c[b];!n(d)&&"function"==ca(c.getPropertyValue)&&(d=c.getPropertyValue(b));return"inherit"!=d?n(d)?d:null:(a=Zb(a))?$b(a,b):null} +function ac(a,b,c){function d(a){var b=bc(a);return 0<b.height&&0<b.width?!0:I(a,"PATH")&&(0<b.height||0<b.width)?(a=X(a,"stroke-width"),!!a&&0<parseInt(a,10)):"hidden"!=X(a,"overflow")&&Ca(a.childNodes,function(a){return 3==a.nodeType||I(a)&&d(a)})}function e(a){return cc(a)==Y&&Da(a.childNodes,function(a){return!I(a)||e(a)||!d(a)})}if(!I(a))throw Error("Argument to isShown must be of type Element");if(I(a,"BODY"))return!0;if(I(a,"OPTION")||I(a,"OPTGROUP"))return a=$a(a,function(a){return I(a,"SELECT")}), +!!a&&ac(a,!0,c);var f=dc(a);if(f)return!!f.I&&0<f.rect.width&&0<f.rect.height&&ac(f.I,b,c);if(I(a,"INPUT")&&"hidden"==a.type.toLowerCase()||I(a,"NOSCRIPT"))return!1;f=X(a,"visibility");return"collapse"!=f&&"hidden"!=f&&c(a)&&(b||ec(a))&&d(a)?!e(a):!1} +function fc(a){var b=Yb?function(c){if("none"==X(c,"display"))return!1;do{var d=c.parentNode;if(c.getDestinationInsertionPoints){var e=c.getDestinationInsertionPoints();0<e.length&&(d=e[e.length-1])}if(d instanceof ShadowRoot){if(d.host.shadowRoot!=d)return!1;d=d.host}else!d||9!=d.nodeType&&11!=d.nodeType||(d=null)}while(a&&1!=a.nodeType);return!d||b(d)}:function(a){if("none"==X(a,"display"))return!1;a=Zb(a);return!a||b(a)};return ac(a,!1,b)}var Y="hidden"; +function cc(a){function b(a){function b(a){return a==h?!0:!X(a,"display").lastIndexOf("inline",0)||"absolute"==c&&"static"==X(a,"position")?!1:!0}var c=X(a,"position");if("fixed"==c)return t=!0,a==h?null:h;for(a=Zb(a);a&&!b(a);)a=Zb(a);return a}function c(a){var b=a;if("visible"==r)if(a==h&&k)b=k;else if(a==k)return{x:"visible",y:"visible"};b={x:X(b,"overflow-x"),y:X(b,"overflow-y")};a==h&&(b.x="visible"==b.x?"auto":b.x,b.y="visible"==b.y?"auto":b.y);return b}function d(a){if(a==h){var b=(new ab(f)).a; +a=b.scrollingElement?b.scrollingElement:"CSS1Compat"==b.compatMode?b.documentElement:b.body||b.documentElement;b=b.parentWindow||b.defaultView;a=new La(b.pageXOffset||a.scrollLeft,b.pageYOffset||a.scrollTop)}else a=new La(a.scrollLeft,a.scrollTop);return a}var e=gc(a);var f=D(a),h=f.documentElement,k=f.body,r=X(h,"overflow"),t;for(a=b(a);a;a=b(a)){var m=c(a);if("visible"!=m.x||"visible"!=m.y){var C=bc(a);if(!C.width||!C.height)return Y;var L=e.a<C.a,T=e.b<C.b;if(L&&"hidden"==m.x||T&&"hidden"==m.y)return Y; +if(L&&"visible"!=m.x||T&&"visible"!=m.y){L=d(a);T=e.b<C.b-L.y;if(e.a<C.a-L.x&&"visible"!=m.x||T&&"visible"!=m.x)return Y;e=cc(a);return e==Y?Y:"scroll"}L=e.f>=C.a+C.width;C=e.c>=C.b+C.height;if(L&&"hidden"==m.x||C&&"hidden"==m.y)return Y;if(L&&"visible"!=m.x||C&&"visible"!=m.y){if(t&&(m=d(a),e.f>=h.scrollWidth-m.x||e.a>=h.scrollHeight-m.y))return Y;e=cc(a);return e==Y?Y:"scroll"}}}return"none"} +function bc(a){var b=dc(a);if(b)return b.rect;if(I(a,"HTML"))return a=D(a),a=((a?a.parentWindow||a.defaultView:window)||window).document,a="CSS1Compat"==a.compatMode?a.documentElement:a.body,a=new la(a.clientWidth,a.clientHeight),new B(0,0,a.width,a.height);try{var c=a.getBoundingClientRect()}catch(d){return new B(0,0,0,0)}return new B(c.left,c.top,c.right-c.left,c.bottom-c.top)} +function dc(a){var b=I(a,"MAP");if(!b&&!I(a,"AREA"))return null;var c=b?a:I(a.parentNode,"MAP")?a.parentNode:null,d=null,e=null;c&&c.name&&(d=W.P('/descendant::*[@usemap = "#'+c.name+'"]',D(c)))&&(e=bc(d),b||"default"==a.shape.toLowerCase()||(a=hc(a),b=Math.min(Math.max(a.a,0),e.width),c=Math.min(Math.max(a.b,0),e.height),e=new B(b+e.a,c+e.b,Math.min(a.width,e.width-b),Math.min(a.height,e.height-c))));return{I:d,rect:e||new B(0,0,0,0)}} +function hc(a){var b=a.shape.toLowerCase();a=a.coords.split(",");if("rect"==b&&4==a.length){var b=a[0],c=a[1];return new B(b,c,a[2]-b,a[3]-c)}if("circle"==b&&3==a.length)return b=a[2],new B(a[0]-b,a[1]-b,2*b,2*b);if("poly"==b&&2<a.length){for(var b=a[0],c=a[1],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 B(b,c,d-b,e-c)}return new B(0,0,0,0)}function gc(a){a=bc(a);return new Oa(a.b,a.a+a.width,a.b+a.height,a.a)} +function ic(a){return a.replace(/^[^\S\xa0]+|[^\S\xa0]+$/g,"")} +function jc(a,b,c){if(I(a,"BR"))b.push("");else{var d=I(a,"TD"),e=X(a,"display"),f=!d&&!(0<=Ba(kc,e)),h=n(a.previousElementSibling)?a.previousElementSibling:Va(a.previousSibling),h=h?X(h,"display"):"",k=X(a,"float")||X(a,"cssFloat")||X(a,"styleFloat");!f||"run-in"==h&&"none"==k||/^[\s\xa0]*$/.test(b[b.length-1]||"")||b.push("");var r=fc(a),t=null,m=null;r&&(t=X(a,"white-space"),m=X(a,"text-transform"));z(a.childNodes,function(a){c(a,b,r,t,m)});a=b[b.length-1]||"";!d&&"table-cell"!=e||!a||ma(a)||(b[b.length- +1]+=" ");f&&"run-in"!=e&&!/^[\s\xa0]*$/.test(a)&&b.push("")}}function lc(a,b){jc(a,b,function(a,b,e,f,h){3==a.nodeType&&e?mc(a,b,f,h):I(a)&&lc(a,b)})}var kc="inline inline-block inline-table none table-cell table-column table-column-group".split(" "); +function mc(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(/(^|\s)(\S)/g,function(a,b,c){return b+c.toUpperCase()}):"uppercase"==d?a=a.toUpperCase():"lowercase"==d&&(a=a.toLowerCase());c=b.pop()||"";ma(c)&&!a.lastIndexOf(" ",0)&&(a=a.substr(1));b.push(c+a)} +function ec(a){var b=1,c=X(a,"opacity");c&&(b=Number(c));(a=Zb(a))&&(b*=ec(a));return b} +function nc(a,b,c,d,e){var f;if(3==a.nodeType&&c)mc(a,b,d,e);else if(I(a))if(I(a,"CONTENT")){for(f=a;f.parentNode;)f=f.parentNode;f instanceof ShadowRoot?z(a.getDistributedNodes(),function(a){nc(a,b,c,d,e)}):oc(a,b)}else if(I(a,"SHADOW")){for(f=a;f.parentNode;)f=f.parentNode;if(f instanceof ShadowRoot&&(a=f))for(a=a.olderShadowRoot;a;)z(a.childNodes,function(a){nc(a,b,c,d,e)}),a=a.olderShadowRoot}else oc(a,b)} +function oc(a,b){a.shadowRoot&&z(a.shadowRoot.childNodes,function(a){nc(a,b,!0,null,null)});jc(a,b,function(a,b,e,f,h){var c=null;1==a.nodeType?c=a:3==a.nodeType&&(c=a);c&&c.getDestinationInsertionPoints&&0<c.getDestinationInsertionPoints().length||nc(a,b,e,f,h)})};Ua&&Ua&&Ta(3.6);var pc={};function Z(a,b,c){var d=typeof a;("object"==d&&null!=a||"function"==d)&&(a=a.g);a=new qc(a);!b||b in pc&&!c||(pc[b]={key:a,shift:!1},c&&(pc[c]={key:a,shift:!0}));return a}function qc(a){this.code=a}Z(8);Z(9);Z(13);var rc=Z(16),sc=Z(17),tc=Z(18);Z(19);Z(20);Z(27);Z(32," ");Z(33);Z(34);Z(35);Z(36);Z(37);Z(38);Z(39);Z(40);Z(44);Z(45);Z(46);Z(48,"0",")");Z(49,"1","!");Z(50,"2","@");Z(51,"3","#");Z(52,"4","$");Z(53,"5","%");Z(54,"6","^");Z(55,"7","&");Z(56,"8","*");Z(57,"9","(");Z(65,"a","A"); +Z(66,"b","B");Z(67,"c","C");Z(68,"d","D");Z(69,"e","E");Z(70,"f","F");Z(71,"g","G");Z(72,"h","H");Z(73,"i","I");Z(74,"j","J");Z(75,"k","K");Z(76,"l","L");Z(77,"m","M");Z(78,"n","N");Z(79,"o","O");Z(80,"p","P");Z(81,"q","Q");Z(82,"r","R");Z(83,"s","S");Z(84,"t","T");Z(85,"u","U");Z(86,"v","V");Z(87,"w","W");Z(88,"x","X");Z(89,"y","Y");Z(90,"z","Z");var uc=Z(Na?{g:91,h:91}:Ma?{g:224,h:91}:{g:0,h:91});Z(Na?{g:92,h:92}:Ma?{g:224,h:93}:{g:0,h:92});Z(Na?{g:93,h:93}:Ma?{g:0,h:0}:{g:93,h:null}); +Z({g:96,h:96},"0");Z({g:97,h:97},"1");Z({g:98,h:98},"2");Z({g:99,h:99},"3");Z({g:100,h:100},"4");Z({g:101,h:101},"5");Z({g:102,h:102},"6");Z({g:103,h:103},"7");Z({g:104,h:104},"8");Z({g:105,h:105},"9");Z({g:106,h:106},"*");Z({g:107,h:107},"+");Z({g:109,h:109},"-");Z({g:110,h:110},".");Z({g:111,h:111},"/");Z(144);Z(112);Z(113);Z(114);Z(115);Z(116);Z(117);Z(118);Z(119);Z(120);Z(121);Z(122);Z(123);Z({g:107,h:187},"=","+");Z(108,",");Z({g:109,h:189},"-","_");Z(188,",","<");Z(190,".",">");Z(191,"/","?"); +Z(192,"`","~");Z(219,"[","{");Z(220,"\\","|");Z(221,"]","}");Z({g:59,h:186},";",":");Z(222,"'",'"');var vc=new Pa;vc.set(1,rc);vc.set(2,sc);vc.set(4,tc);vc.set(8,uc);(function(a){var b=new Pa;z(Qa(a),function(c){b.set(a.get(c).code,c)});return b})(vc);Ua&&Sa(12);ba("_",function(a){var b=[];Yb?oc(a,b):lc(a,b);a=b;for(var b=a.length,c=Array(b),d=p(a)?a.split(""):a,e=0;e<b;e++)e in d&&(c[e]=ic.call(void 0,d[e],e,a));return ic(c.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);} + +// https://github.com/SeleniumHQ/selenium/blob/master/javascript/atoms/dom.js#L189 +atom.isElementEnabled = function(element, window){return function(){var h=this;function k(a){return"string"==typeof a}function aa(a,b){a=a.split(".");var c=h;a[0]in c||!c.execScript||c.execScript("var "+a[0]);for(var d;a.length&&(d=a.shift());)a.length||void 0===b?c[d]&&c[d]!==Object.prototype[d]?c=c[d]:c=c[d]={}:c[d]=b} +function ba(a){var b=typeof a;if("object"==b)if(a){if(a instanceof Array)return"array";if(a instanceof Object)return b;var c=Object.prototype.toString.call(a);if("[object Window]"==c)return"object";if("[object Array]"==c||"number"==typeof a.length&&"undefined"!=typeof a.splice&&"undefined"!=typeof a.propertyIsEnumerable&&!a.propertyIsEnumerable("splice"))return"array";if("[object Function]"==c||"undefined"!=typeof a.call&&"undefined"!=typeof a.propertyIsEnumerable&&!a.propertyIsEnumerable("call"))return"function"}else return"null"; +else if("function"==b&&"undefined"==typeof a.call)return"object";return b}function ca(a,b,c){return a.call.apply(a.bind,arguments)}function da(a,b,c){if(!a)throw Error();if(2<arguments.length){var d=Array.prototype.slice.call(arguments,2);return function(){var c=Array.prototype.slice.call(arguments);Array.prototype.unshift.apply(c,d);return a.apply(b,c)}}return function(){return a.apply(b,arguments)}} +function m(a,b,c){Function.prototype.bind&&-1!=Function.prototype.bind.toString().indexOf("native code")?m=ca:m=da;return m.apply(null,arguments)}function ea(a,b){var c=Array.prototype.slice.call(arguments,1);return function(){var b=c.slice();b.push.apply(b,arguments);return a.apply(this,b)}} +function n(a){var b=p;function c(){}c.prototype=b.prototype;a.K=b.prototype;a.prototype=new c;a.prototype.constructor=a;a.J=function(a,c,f){for(var d=Array(arguments.length-2),e=2;e<arguments.length;e++)d[e-2]=arguments[e];return b.prototype[c].apply(a,d)}}; +function q(a,b,c){this.a=a;this.b=b||1;this.g=c||1};function fa(a){this.b=a;this.a=0}function ga(a){a=a.match(ha);for(var b=0;b<a.length;b++)ia.test(a[b])&&a.splice(b,1);return new fa(a)}var ha=/\$?(?:(?![0-9-\.])(?:\*|[\w-\.]+):)?(?![0-9-\.])(?:\*|[\w-\.]+)|\/\/|\.\.|::|\d+(?:\.\d*)?|\.\d+|"[^"]*"|'[^']*'|[!<>]=|\s+|./g,ia=/^\s/;function r(a,b){return a.b[a.a+(b||0)]}function t(a){return a.b[a.a++]}function u(a){return a.b.length<=a.a};function w(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 ja(a){return"comment"==a||"text"==a||"processing-instruction"==a||"node"==a}w.prototype.a=function(a){return null===this.b||this.b==a.nodeType};w.prototype.g=function(){return this.h}; +w.prototype.toString=function(){var a="Kind Test: "+this.h;null===this.c||(a+=x(this.c));return a};function y(a,b){this.j=a.toLowerCase();a="*"==this.j?"*":"http://www.w3.org/1999/xhtml";this.b=b?b.toLowerCase():a}y.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.b?!0:this.b==(a.namespaceURI?a.namespaceURI.toLowerCase():"http://www.w3.org/1999/xhtml")};y.prototype.g=function(){return this.j}; +y.prototype.toString=function(){return"Name Test: "+("http://www.w3.org/1999/xhtml"==this.b?"":this.b+":")+this.j};function ka(a){switch(a.nodeType){case 1:return ea(la,a);case 9:return ka(a.documentElement);case 11:case 10:case 6:case 12:return ma;default:return a.parentNode?ka(a.parentNode):ma}}function ma(){return null}function la(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?la(a.parentNode,b):null};function z(a,b){for(var c=a.length,d=k(a)?a.split(""):a,e=0;e<c;e++)e in d&&b.call(void 0,d[e],e,a)}function B(a,b,c){var d=c;z(a,function(c,f){d=b.call(void 0,d,c,f,a)});return d}function C(a,b){for(var c=a.length,d=k(a)?a.split(""):a,e=0;e<c;e++)if(e in d&&b.call(void 0,d[e],e,a))return!0;return!1}function na(a){return Array.prototype.concat.apply([],arguments)}function oa(a,b,c){return 2>=arguments.length?Array.prototype.slice.call(a,b):Array.prototype.slice.call(a,b,c)};(function(){var a=h.Components;if(!a)return!1;try{if(!a.classes)return!1}catch(c){return!1}var b=a.classes,a=a.interfaces;b["@mozilla.org/xpcom/version-comparator;1"].getService(a.nsIVersionComparator);b["@mozilla.org/xre/app-info;1"].getService(a.nsIXULAppInfo);return!0})();function pa(a){for(;a&&1!=a.nodeType;)a=a.previousSibling;return a}function qa(a,b){if(!a||!b)return!1;if(a.contains&&1==b.nodeType)return a==b||a.contains(b);if("undefined"!=typeof a.compareDocumentPosition)return a==b||!!(a.compareDocumentPosition(b)&16);for(;b&&a!=b;)b=b.parentNode;return b==a} +function ra(a,b){if(a==b)return 0;if(a.compareDocumentPosition)return a.compareDocumentPosition(b)&2?1:-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?sa(a,b):!c&&qa(e,b)?-1*ta(a,b):!d&&qa(f,a)?ta(b,a):(c?a.sourceIndex:e.sourceIndex)-(d?b.sourceIndex:f.sourceIndex)}d=9==a.nodeType?a:a.ownerDocument||a.document;c=d.createRange();c.selectNode(a);c.collapse(!0); +a=d.createRange();a.selectNode(b);a.collapse(!0);return c.compareBoundaryPoints(h.Range.START_TO_END,a)}function ta(a,b){var c=a.parentNode;if(c==b)return-1;for(;b.parentNode!=c;)b=b.parentNode;return sa(b,a)}function sa(a,b){for(;b=b.previousSibling;)if(b==a)return-1;return 1}function ua(a,b){for(var c=0;a;){if(b(a))return a;a=a.parentNode;c++}return null};function D(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(9==c||1==c){a=9==c?a.documentElement:a.firstChild;for(var c=0,d=[],b="";a;){do 1!=a.nodeType&&(b+=a.nodeValue),d[c++]=a;while(a=a.firstChild);for(;c&&!(a=d[--c].nextSibling););}}else b=a.nodeValue;return""+b} +function E(a,b,c){if(null===b)return!0;try{if(!a.getAttribute)return!1}catch(d){return!1}return null==c?!!a.getAttribute(b):a.getAttribute(b,2)==c}function F(a,b,c,d,e){return va.call(null,a,b,k(c)?c:null,k(d)?d:null,e||new G)} +function va(a,b,c,d,e){b.getElementsByName&&d&&"name"==c?(b=b.getElementsByName(d),z(b,function(b){a.a(b)&&H(e,b)})):b.getElementsByClassName&&d&&"class"==c?(b=b.getElementsByClassName(d),z(b,function(b){b.className==d&&a.a(b)&&H(e,b)})):a instanceof w?wa(a,b,c,d,e):b.getElementsByTagName&&(b=b.getElementsByTagName(a.g()),z(b,function(a){E(a,c,d)&&H(e,a)}));return e}function wa(a,b,c,d,e){for(b=b.firstChild;b;b=b.nextSibling)E(b,c,d)&&a.a(b)&&H(e,b),wa(a,b,c,d,e)};function I(a,b){b&&"string"!==typeof b&&(b=b.toString());return!!a&&1==a.nodeType&&(!b||a.tagName.toUpperCase()==b)};function G(){this.b=this.a=null;this.l=0}function xa(a){this.node=a;this.a=this.b=null}function ya(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;)c.node==b.node?(e=c,c=c.a,b=b.a):0<ra(c.node,b.node)?(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 za(a,b){b=new xa(b);b.a=a.a;a.b?a.a.b=b:a.a=a.b=b;a.a=b;a.l++}function H(a,b){b=new xa(b);b.b=a.b;a.a?a.b.a=b:a.a=a.b=b;a.b=b;a.l++} +function J(a){return(a=a.a)?a.node:null}function K(a){return(a=J(a))?D(a):""}function L(a,b){return new Aa(a,!!b)}function Aa(a,b){this.g=a;this.b=(this.s=b)?a.b:a.a;this.a=null}function M(a){var b=a.b;if(b){var c=a.a=b;a.b=a.s?b.b:b.a;return c.node}return null};function p(a){this.i=a;this.b=this.f=!1;this.g=null}function x(a){return"\n "+a.toString().split("\n").join("\n ")}function Ba(a,b){a.f=b}function Ca(a,b){a.b=b}function N(a,b){a=a.a(b);return a instanceof G?+K(a):+a}function O(a,b){a=a.a(b);return a instanceof G?K(a):""+a}function Q(a,b){a=a.a(b);return a instanceof G?!!a.l:!!a};function R(a,b,c){p.call(this,a.i);this.c=a;this.h=b;this.o=c;this.f=b.f||c.f;this.b=b.b||c.b;this.c==Da&&(c.b||c.f||4==c.i||0==c.i||!b.g?b.b||b.f||4==b.i||0==b.i||!c.g||(this.g={name:c.g.name,u:b}):this.g={name:b.g.name,u:c})}n(R); +function S(a,b,c,d,e){b=b.a(d);c=c.a(d);var f;if(b instanceof G&&c instanceof G){b=L(b);for(d=M(b);d;d=M(b))for(e=L(c),f=M(e);f;f=M(e))if(a(D(d),D(f)))return!0;return!1}if(b instanceof G||c instanceof G){b instanceof G?(e=b,d=c):(e=c,d=b);f=L(e);for(var g=typeof d,l=M(f);l;l=M(f)){switch(g){case "number":l=+D(l);break;case "boolean":l=!!D(l);break;case "string":l=D(l);break;default:throw Error("Illegal primitive type for comparison.");}if(e==b&&a(l,d)||e==c&&a(d,l))return!0}return!1}return e?"boolean"== +typeof b||"boolean"==typeof c?a(!!b,!!c):"number"==typeof b||"number"==typeof c?a(+b,+c):a(b,c):a(+b,+c)}R.prototype.a=function(a){return this.c.m(this.h,this.o,a)};R.prototype.toString=function(){var a="Binary Expression: "+this.c,a=a+x(this.h);return a+=x(this.o)};function Ea(a,b,c,d){this.H=a;this.C=b;this.i=c;this.m=d}Ea.prototype.toString=function(){return this.H};var Fa={}; +function T(a,b,c,d){if(Fa.hasOwnProperty(a))throw Error("Binary operator already created: "+a);a=new Ea(a,b,c,d);return Fa[a.toString()]=a}T("div",6,1,function(a,b,c){return N(a,c)/N(b,c)});T("mod",6,1,function(a,b,c){return N(a,c)%N(b,c)});T("*",6,1,function(a,b,c){return N(a,c)*N(b,c)});T("+",5,1,function(a,b,c){return N(a,c)+N(b,c)});T("-",5,1,function(a,b,c){return N(a,c)-N(b,c)});T("<",4,2,function(a,b,c){return S(function(a,b){return a<b},a,b,c)}); +T(">",4,2,function(a,b,c){return S(function(a,b){return a>b},a,b,c)});T("<=",4,2,function(a,b,c){return S(function(a,b){return a<=b},a,b,c)});T(">=",4,2,function(a,b,c){return S(function(a,b){return a>=b},a,b,c)});var Da=T("=",3,2,function(a,b,c){return S(function(a,b){return a==b},a,b,c,!0)});T("!=",3,2,function(a,b,c){return S(function(a,b){return a!=b},a,b,c,!0)});T("and",2,2,function(a,b,c){return Q(a,c)&&Q(b,c)});T("or",1,2,function(a,b,c){return Q(a,c)||Q(b,c)});function Ga(a,b){if(b.a.length&&4!=a.i)throw Error("Primary expression must evaluate to nodeset if filter has predicate(s).");p.call(this,a.i);this.c=a;this.h=b;this.f=a.f;this.b=a.b}n(Ga);Ga.prototype.a=function(a){a=this.c.a(a);return Ha(this.h,a)};Ga.prototype.toString=function(){var a="Filter:"+x(this.c);return a+=x(this.h)};function Ia(a,b){if(b.length<a.B)throw Error("Function "+a.j+" expects at least"+a.B+" arguments, "+b.length+" given");if(null!==a.A&&b.length>a.A)throw Error("Function "+a.j+" expects at most "+a.A+" arguments, "+b.length+" given");a.G&&z(b,function(b,d){if(4!=b.i)throw Error("Argument "+d+" to function "+a.j+" is not of type Nodeset: "+b);});p.call(this,a.i);this.v=a;this.c=b;Ba(this,a.f||C(b,function(a){return a.f}));Ca(this,a.F&&!b.length||a.D&&!!b.length||C(b,function(a){return a.b}))}n(Ia); +Ia.prototype.a=function(a){return this.v.m.apply(null,na(a,this.c))};Ia.prototype.toString=function(){var a="Function: "+this.v;if(this.c.length)var b=B(this.c,function(a,b){return a+x(b)},"Arguments:"),a=a+x(b);return a};function Ja(a,b,c,d,e,f,g,l,v){this.j=a;this.i=b;this.f=c;this.F=d;this.D=e;this.m=f;this.B=g;this.A=void 0!==l?l:g;this.G=!!v}Ja.prototype.toString=function(){return this.j};var Ka={}; +function U(a,b,c,d,e,f,g,l){if(Ka.hasOwnProperty(a))throw Error("Function already created: "+a+".");Ka[a]=new Ja(a,b,c,d,!1,e,f,g,l)}U("boolean",2,!1,!1,function(a,b){return Q(b,a)},1);U("ceiling",1,!1,!1,function(a,b){return Math.ceil(N(b,a))},1);U("concat",3,!1,!1,function(a,b){return B(oa(arguments,1),function(b,d){return b+O(d,a)},"")},2,null);U("contains",2,!1,!1,function(a,b,c){b=O(b,a);a=O(c,a);return-1!=b.indexOf(a)},2);U("count",1,!1,!1,function(a,b){return b.a(a).l},1,1,!0); +U("false",2,!1,!1,function(){return!1},0);U("floor",1,!1,!1,function(a,b){return Math.floor(N(b,a))},1);U("id",4,!1,!1,function(a,b){var c=a.a,d=9==c.nodeType?c:c.ownerDocument;a=O(b,a).split(/\s+/);var e=[];z(a,function(a){a=d.getElementById(a);var b;if(!(b=!a)){a:if(k(e))b=k(a)&&1==a.length?e.indexOf(a,0):-1;else{for(b=0;b<e.length;b++)if(b in e&&e[b]===a)break a;b=-1}b=0<=b}b||e.push(a)});e.sort(ra);var f=new G;z(e,function(a){H(f,a)});return f},1);U("lang",2,!1,!1,function(){return!1},1); +U("last",1,!0,!1,function(a){if(1!=arguments.length)throw Error("Function last expects ()");return a.g},0);U("local-name",3,!1,!0,function(a,b){return(a=b?J(b.a(a)):a.a)?a.localName||a.nodeName.toLowerCase():""},0,1,!0);U("name",3,!1,!0,function(a,b){return(a=b?J(b.a(a)):a.a)?a.nodeName.toLowerCase():""},0,1,!0);U("namespace-uri",3,!0,!1,function(){return""},0,1,!0);U("normalize-space",3,!1,!0,function(a,b){return(b?O(b,a):D(a.a)).replace(/[\s\xa0]+/g," ").replace(/^\s+|\s+$/g,"")},0,1); +U("not",2,!1,!1,function(a,b){return!Q(b,a)},1);U("number",1,!1,!0,function(a,b){return b?N(b,a):+D(a.a)},0,1);U("position",1,!0,!1,function(a){return a.b},0);U("round",1,!1,!1,function(a,b){return Math.round(N(b,a))},1);U("starts-with",2,!1,!1,function(a,b,c){b=O(b,a);a=O(c,a);return!b.lastIndexOf(a,0)},2);U("string",3,!1,!0,function(a,b){return b?O(b,a):D(a.a)},0,1);U("string-length",1,!1,!0,function(a,b){return(b?O(b,a):D(a.a)).length},0,1); +U("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);U("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); +U("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);U("sum",1,!1,!1,function(a,b){a=L(b.a(a));b=0;for(var c=M(a);c;c=M(a))b+=+D(c);return b},1,1,!0);U("translate",3,!1,!1,function(a,b,c,d){b=O(b,a);c=O(c,a);var e=O(d,a);d={};for(var f=0;f<c.length;f++)a=c.charAt(f),a in d||(d[a]=e.charAt(f));c="";for(f=0;f<b.length;f++)a=b.charAt(f),c+=a in d?d[a]:a;return c},3);U("true",2,!1,!1,function(){return!0},0);function La(a){p.call(this,3);this.c=a.substring(1,a.length-1)}n(La);La.prototype.a=function(){return this.c};La.prototype.toString=function(){return"Literal: "+this.c};function Ma(a){p.call(this,1);this.c=a}n(Ma);Ma.prototype.a=function(){return this.c};Ma.prototype.toString=function(){return"Number: "+this.c};function Na(a,b){p.call(this,a.i);this.h=a;this.c=b;this.f=a.f;this.b=a.b;1==this.c.length&&(a=this.c[0],a.w||a.c!=Oa||(a=a.o,"*"!=a.g()&&(this.g={name:a.g(),u:null})))}n(Na);function V(){p.call(this,4)}n(V);V.prototype.a=function(a){var b=new G;a=a.a;9==a.nodeType?H(b,a):H(b,a.ownerDocument);return b};V.prototype.toString=function(){return"Root Helper Expression"};function Pa(){p.call(this,4)}n(Pa);Pa.prototype.a=function(a){var b=new G;H(b,a.a);return b};Pa.prototype.toString=function(){return"Context Helper Expression"}; +function Qa(a){return"/"==a||"//"==a}Na.prototype.a=function(a){var b=this.h.a(a);if(!(b instanceof G))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=L(b,e.c.s);if(e.f||e.c!=Ra)if(e.f||e.c!=Sa){var g=M(f);for(b=e.a(new q(g));g=M(f);)g=e.a(new q(g)),b=ya(b,g)}else g=M(f),b=e.a(new q(g));else{for(g=M(f);(b=M(f))&&(!g.contains||g.contains(b))&&b.compareDocumentPosition(g)&8;g=b);b=e.a(new q(g))}}return b}; +Na.prototype.toString=function(){var a="Path Expression:"+x(this.h);if(this.c.length){var b=B(this.c,function(a,b){return a+x(b)},"Steps:");a+=x(b)}return a};function Ta(a,b){this.a=a;this.s=!!b} +function Ha(a,b,c){for(c=c||0;c<a.a.length;c++)for(var d=a.a[c],e=L(b),f=b.l,g,l=0;g=M(e);l++){var v=a.s?f-l:l+1;g=d.a(new q(g,v,f));if("number"==typeof g)v=v==g;else if("string"==typeof g||"boolean"==typeof g)v=!!g;else if(g instanceof G)v=0<g.l;else throw Error("Predicate.evaluate returned an unexpected type.");if(!v){v=e;g=v.g;var A=v.a;if(!A)throw Error("Next must be called at least once before remove.");var P=A.b,A=A.a;P?P.a=A:g.a=A;A?A.b=P:g.b=P;g.l--;v.a=null}}return b} +Ta.prototype.toString=function(){return B(this.a,function(a,b){return a+x(b)},"Predicates:")};function Ua(a){p.call(this,1);this.c=a;this.f=a.f;this.b=a.b}n(Ua);Ua.prototype.a=function(a){return-N(this.c,a)};Ua.prototype.toString=function(){return"Unary Expression: -"+x(this.c)};function Va(a){p.call(this,4);this.c=a;Ba(this,C(this.c,function(a){return a.f}));Ca(this,C(this.c,function(a){return a.b}))}n(Va);Va.prototype.a=function(a){var b=new G;z(this.c,function(c){c=c.a(a);if(!(c instanceof G))throw Error("Path expression must evaluate to NodeSet.");b=ya(b,c)});return b};Va.prototype.toString=function(){return B(this.c,function(a,b){return a+x(b)},"Union Expression:")};function W(a,b,c,d){p.call(this,4);this.c=a;this.o=b;this.h=c||new Ta([]);this.w=!!d;b=this.h;b=0<b.a.length?b.a[0].g:null;a.I&&b&&(this.g={name:b.name,u:b.u});a:{a=this.h;for(b=0;b<a.a.length;b++)if(c=a.a[b],c.f||1==c.i||0==c.i){a=!0;break a}a=!1}this.f=a}n(W); +W.prototype.a=function(a){var b=a.a,c=this.g,d=null,e=null,f=0;c&&(d=c.name,e=c.u?O(c.u,a):null,f=1);if(this.w)if(this.f||this.c!=Wa)if(b=L((new W(Xa,new w("node"))).a(a)),c=M(b))for(a=this.m(c,d,e,f);c=M(b);)a=ya(a,this.m(c,d,e,f));else a=new G;else a=F(this.o,b,d,e),a=Ha(this.h,a,f);else a=this.m(a.a,d,e,f);return a};W.prototype.m=function(a,b,c,d){a=this.c.v(this.o,a,b,c);return a=Ha(this.h,a,d)}; +W.prototype.toString=function(){var a="Step:"+x("Operator: "+(this.w?"//":"/"));this.c.j&&(a+=x("Axis: "+this.c));a+=x(this.o);if(this.h.a.length){var b=B(this.h.a,function(a,b){return a+x(b)},"Predicates:");a+=x(b)}return a};function Ya(a,b,c,d){this.j=a;this.v=b;this.s=c;this.I=d}Ya.prototype.toString=function(){return this.j};var Za={};function X(a,b,c,d){if(Za.hasOwnProperty(a))throw Error("Axis already created: "+a);b=new Ya(a,b,c,!!d);return Za[a]=b} +X("ancestor",function(a,b){for(var c=new G;b=b.parentNode;)a.a(b)&&za(c,b);return c},!0);X("ancestor-or-self",function(a,b){var c=new G;do a.a(b)&&za(c,b);while(b=b.parentNode);return c},!0); +var Oa=X("attribute",function(a,b){var c=new G,d=a.g();if(b=b.attributes)if(a instanceof w&&null===a.b||"*"==d)for(d=0;a=b[d];d++)H(c,a);else(a=b.getNamedItem(d))&&H(c,a);return c},!1),Wa=X("child",function(a,b,c,d,e){c=k(c)?c:null;d=k(d)?d:null;e=e||new G;for(b=b.firstChild;b;b=b.nextSibling)E(b,c,d)&&a.a(b)&&H(e,b);return e},!1,!0);X("descendant",F,!1,!0); +var Xa=X("descendant-or-self",function(a,b,c,d){var e=new G;E(b,c,d)&&a.a(b)&&H(e,b);return F(a,b,c,d,e)},!1,!0),Ra=X("following",function(a,b,c,d){var e=new G;do for(var f=b;f=f.nextSibling;)E(f,c,d)&&a.a(f)&&H(e,f),e=F(a,f,c,d,e);while(b=b.parentNode);return e},!1,!0);X("following-sibling",function(a,b){for(var c=new G;b=b.nextSibling;)a.a(b)&&H(c,b);return c},!1);X("namespace",function(){return new G},!1); +var $a=X("parent",function(a,b){var c=new G;if(9==b.nodeType)return c;if(2==b.nodeType)return H(c,b.ownerElement),c;b=b.parentNode;a.a(b)&&H(c,b);return c},!1),Sa=X("preceding",function(a,b,c,d){var e=new G,f=[];do f.unshift(b);while(b=b.parentNode);for(var g=1,l=f.length;g<l;g++){var v=[];for(b=f[g];b=b.previousSibling;)v.unshift(b);for(var A=0,P=v.length;A<P;A++)b=v[A],E(b,c,d)&&a.a(b)&&H(e,b),e=F(a,b,c,d,e)}return e},!0,!0); +X("preceding-sibling",function(a,b){for(var c=new G;b=b.previousSibling;)a.a(b)&&za(c,b);return c},!0);var ab=X("self",function(a,b){var c=new G;a.a(b)&&H(c,b);return c},!1);function bb(a,b){this.a=a;this.b=b}function cb(a){for(var b,c=[];;){Y(a,"Missing right hand side of binary expression.");b=db(a);var d=t(a.a);if(!d)break;var e=(d=Fa[d]||null)&&d.C;if(!e){a.a.a--;break}for(;c.length&&e<=c[c.length-1].C;)b=new R(c.pop(),c.pop(),b);c.push(b,d)}for(;c.length;)b=new R(c.pop(),c.pop(),b);return b}function Y(a,b){if(u(a.a))throw Error(b);}function eb(a,b){a=t(a.a);if(a!=b)throw Error("Bad token, expected: "+b+" got: "+a);} +function fb(a){a=t(a.a);if(")"!=a)throw Error("Bad token: "+a);}function gb(a){a=t(a.a);if(2>a.length)throw Error("Unclosed literal string");return new La(a)} +function hb(a){var b=[];if(Qa(r(a.a))){var c=t(a.a);var d=r(a.a);if("/"==c&&(u(a.a)||"."!=d&&".."!=d&&"@"!=d&&"*"!=d&&!/(?![0-9])[\w]/.test(d)))return new V;d=new V;Y(a,"Missing next location step.");c=ib(a,c);b.push(c)}else{a:{c=r(a.a);d=c.charAt(0);switch(d){case "$":throw Error("Variable reference not allowed in HTML XPath");case "(":t(a.a);c=cb(a);Y(a,'unclosed "("');eb(a,")");break;case '"':case "'":c=gb(a);break;default:if(isNaN(+c))if(!ja(c)&&/(?![0-9])[\w]/.test(d)&&"("==r(a.a,1)){c=t(a.a); +c=Ka[c]||null;t(a.a);for(d=[];")"!=r(a.a);){Y(a,"Missing function argument list.");d.push(cb(a));if(","!=r(a.a))break;t(a.a)}Y(a,"Unclosed function argument list.");fb(a);c=new Ia(c,d)}else{c=null;break a}else c=new Ma(+t(a.a))}"["==r(a.a)&&(d=new Ta(jb(a)),c=new Ga(c,d))}if(c)if(Qa(r(a.a)))d=c;else return c;else c=ib(a,"/"),d=new Pa,b.push(c)}for(;Qa(r(a.a));)c=t(a.a),Y(a,"Missing next location step."),c=ib(a,c),b.push(c);return new Na(d,b)} +function ib(a,b){if("/"!=b&&"//"!=b)throw Error('Step op should be "/" or "//"');if("."==r(a.a)){var c=new W(ab,new w("node"));t(a.a);return c}if(".."==r(a.a))return c=new W($a,new w("node")),t(a.a),c;if("@"==r(a.a)){var d=Oa;t(a.a);Y(a,"Missing attribute name")}else if("::"==r(a.a,1)){if(!/(?![0-9])[\w]/.test(r(a.a).charAt(0)))throw Error("Bad token: "+t(a.a));var e=t(a.a);d=Za[e]||null;if(!d)throw Error("No axis with name: "+e);t(a.a);Y(a,"Missing node name")}else d=Wa;e=r(a.a);if(/(?![0-9])[\w\*]/.test(e.charAt(0)))if("("== +r(a.a,1)){if(!ja(e))throw Error("Invalid node type: "+e);e=t(a.a);if(!ja(e))throw Error("Invalid type name: "+e);eb(a,"(");Y(a,"Bad nodetype");var f=r(a.a).charAt(0),g=null;if('"'==f||"'"==f)g=gb(a);Y(a,"Bad nodetype");fb(a);e=new w(e,g)}else if(e=t(a.a),f=e.indexOf(":"),-1==f)e=new y(e);else{var g=e.substring(0,f);if("*"==g)var l="*";else if(l=a.b(g),!l)throw Error("Namespace prefix not declared: "+g);e=e.substr(f+1);e=new y(e,l)}else throw Error("Bad token: "+t(a.a));a=new Ta(jb(a),d.s);return c|| +new W(d,e,a,"//"==b)}function jb(a){for(var b=[];"["==r(a.a);){t(a.a);Y(a,"Missing predicate expression.");var c=cb(a);b.push(c);Y(a,"Unclosed predicate expression.");eb(a,"]")}return b}function db(a){if("-"==r(a.a))return t(a.a),new Ua(db(a));var b=hb(a);if("|"!=r(a.a))a=b;else{for(b=[b];"|"==t(a.a);)Y(a,"Missing next union location path."),b.push(hb(a));a.a.a--;a=new Va(b)}return a};function kb(a,b){if(!a.length)throw Error("Empty XPath expression.");a=ga(a);if(u(a))throw Error("Invalid XPath expression.");b?"function"==ba(b)||(b=m(b.lookupNamespaceURI,b)):b=function(){return null};var c=cb(new bb(a,b));if(!u(a))throw Error("Bad token: "+t(a));this.evaluate=function(a,b){a=c.a(new q(a));return new Z(a,b)}} +function Z(a,b){if(!b)if(a instanceof G)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 G))throw Error("value could not be converted to the specified type");this.resultType=b;switch(b){case 2:this.stringValue=a instanceof G?K(a):""+a;break;case 1:this.numberValue=a instanceof G?+K(a):+a;break;case 3:this.booleanValue=a instanceof G?0<a.l:!!a;break;case 4:case 5:case 6:case 7:var c= +L(a);var d=[];for(var e=M(c);e;e=M(c))d.push(e);this.snapshotLength=a.l;this.invalidIteratorState=!1;break;case 8:case 9:this.singleNodeValue=J(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(a){if(6!=b&&7!=b)throw Error("snapshotItem called with wrong result type");return a>=d.length||0>a?null:d[a]}}Z.ANY_TYPE=0; +Z.NUMBER_TYPE=1;Z.STRING_TYPE=2;Z.BOOLEAN_TYPE=3;Z.UNORDERED_NODE_ITERATOR_TYPE=4;Z.ORDERED_NODE_ITERATOR_TYPE=5;Z.UNORDERED_NODE_SNAPSHOT_TYPE=6;Z.ORDERED_NODE_SNAPSHOT_TYPE=7;Z.ANY_UNORDERED_NODE_TYPE=8;Z.FIRST_ORDERED_NODE_TYPE=9;function lb(a){this.lookupNamespaceURI=ka(a)} +aa("wgxpath.install",function(a,b){a=a||h;var c=a.Document&&a.Document.prototype||a.document;if(!c.evaluate||b)a.XPathResult=Z,c.evaluate=function(a,b,c,g){return(new kb(a,c)).evaluate(b,g)},c.createExpression=function(a,b){return new kb(a,b)},c.createNSResolver=function(a){return new lb(a)}});var mb="BUTTON INPUT OPTGROUP OPTION SELECT TEXTAREA".split(" ");function nb(a){return C(mb,function(b){return I(a,b)})?a.disabled?!1:a.parentNode&&1==a.parentNode.nodeType&&I(a,"OPTGROUP")||I(a,"OPTION")?nb(a.parentNode):!ua(a,function(a){var b=a.parentNode;if(b&&I(b,"FIELDSET")&&b.disabled){if(!I(a,"LEGEND"))return!0;for(;a=void 0!==a.previousElementSibling?a.previousElementSibling:pa(a.previousSibling);)if(I(a,"LEGEND"))return!0}return!1}):!0};aa("_",nb);; return this._.apply(null,arguments);}.apply({navigator:typeof window!='undefined'?window.navigator:null,document:typeof window!='undefined'?window.document:null}, arguments);} + +// https://github.com/SeleniumHQ/selenium/blob/master/javascript/atoms/dom.js#L435 +atom.isElementDisplayed = function(element, window){return function(){var aa=this;function h(a){return void 0!==a}function l(a){return"string"==typeof a}function ba(a,b){a=a.split(".");var c=aa;a[0]in c||!c.execScript||c.execScript("var "+a[0]);for(var d;a.length&&(d=a.shift());)!a.length&&h(b)?c[d]=b:c[d]&&c[d]!==Object.prototype[d]?c=c[d]:c=c[d]={}} +function ca(a){var b=typeof a;if("object"==b)if(a){if(a instanceof Array)return"array";if(a instanceof Object)return b;var c=Object.prototype.toString.call(a);if("[object Window]"==c)return"object";if("[object Array]"==c||"number"==typeof a.length&&"undefined"!=typeof a.splice&&"undefined"!=typeof a.propertyIsEnumerable&&!a.propertyIsEnumerable("splice"))return"array";if("[object Function]"==c||"undefined"!=typeof a.call&&"undefined"!=typeof a.propertyIsEnumerable&&!a.propertyIsEnumerable("call"))return"function"}else return"null"; +else if("function"==b&&"undefined"==typeof a.call)return"object";return b}function da(a,b,c){return a.call.apply(a.bind,arguments)}function ea(a,b,c){if(!a)throw Error();if(2<arguments.length){var d=Array.prototype.slice.call(arguments,2);return function(){var c=Array.prototype.slice.call(arguments);Array.prototype.unshift.apply(c,d);return a.apply(b,c)}}return function(){return a.apply(b,arguments)}} +function fa(a,b,c){Function.prototype.bind&&-1!=Function.prototype.bind.toString().indexOf("native code")?fa=da:fa=ea;return fa.apply(null,arguments)}function ga(a,b){var c=Array.prototype.slice.call(arguments,1);return function(){var b=c.slice();b.push.apply(b,arguments);return a.apply(this,b)}} +function n(a,b){function c(){}c.prototype=b.prototype;a.L=b.prototype;a.prototype=new c;a.prototype.constructor=a;a.K=function(a,c,f){for(var d=Array(arguments.length-2),e=2;e<arguments.length;e++)d[e-2]=arguments[e];return b.prototype[c].apply(a,d)}};function ha(a,b){this.code=a;this.a=p[a]||ia;this.message=b||"";a=this.a.replace(/((?:^|\s+)[a-z])/g,function(a){return a.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||""}n(ha,Error);var ia="unknown error",p={15:"element not selectable",11:"element not visible"};p[31]=ia;p[30]=ia;p[24]="invalid cookie domain";p[29]="invalid element coordinates";p[12]="invalid element state"; +p[32]="invalid selector";p[51]="invalid selector";p[52]="invalid selector";p[17]="javascript error";p[405]="unsupported operation";p[34]="move target out of bounds";p[27]="no such alert";p[7]="no such element";p[8]="no such frame";p[23]="no such window";p[28]="script timeout";p[33]="session not created";p[10]="stale element reference";p[21]="timeout";p[25]="unable to set cookie";p[26]="unexpected alert open";p[13]=ia;p[9]="unknown command";ha.prototype.toString=function(){return this.name+": "+this.message};var ja={aliceblue:"#f0f8ff",antiquewhite:"#faebd7",aqua:"#00ffff",aquamarine:"#7fffd4",azure:"#f0ffff",beige:"#f5f5dc",bisque:"#ffe4c4",black:"#000000",blanchedalmond:"#ffebcd",blue:"#0000ff",blueviolet:"#8a2be2",brown:"#a52a2a",burlywood:"#deb887",cadetblue:"#5f9ea0",chartreuse:"#7fff00",chocolate:"#d2691e",coral:"#ff7f50",cornflowerblue:"#6495ed",cornsilk:"#fff8dc",crimson:"#dc143c",cyan:"#00ffff",darkblue:"#00008b",darkcyan:"#008b8b",darkgoldenrod:"#b8860b",darkgray:"#a9a9a9",darkgreen:"#006400", +darkgrey:"#a9a9a9",darkkhaki:"#bdb76b",darkmagenta:"#8b008b",darkolivegreen:"#556b2f",darkorange:"#ff8c00",darkorchid:"#9932cc",darkred:"#8b0000",darksalmon:"#e9967a",darkseagreen:"#8fbc8f",darkslateblue:"#483d8b",darkslategray:"#2f4f4f",darkslategrey:"#2f4f4f",darkturquoise:"#00ced1",darkviolet:"#9400d3",deeppink:"#ff1493",deepskyblue:"#00bfff",dimgray:"#696969",dimgrey:"#696969",dodgerblue:"#1e90ff",firebrick:"#b22222",floralwhite:"#fffaf0",forestgreen:"#228b22",fuchsia:"#ff00ff",gainsboro:"#dcdcdc", +ghostwhite:"#f8f8ff",gold:"#ffd700",goldenrod:"#daa520",gray:"#808080",green:"#008000",greenyellow:"#adff2f",grey:"#808080",honeydew:"#f0fff0",hotpink:"#ff69b4",indianred:"#cd5c5c",indigo:"#4b0082",ivory:"#fffff0",khaki:"#f0e68c",lavender:"#e6e6fa",lavenderblush:"#fff0f5",lawngreen:"#7cfc00",lemonchiffon:"#fffacd",lightblue:"#add8e6",lightcoral:"#f08080",lightcyan:"#e0ffff",lightgoldenrodyellow:"#fafad2",lightgray:"#d3d3d3",lightgreen:"#90ee90",lightgrey:"#d3d3d3",lightpink:"#ffb6c1",lightsalmon:"#ffa07a", +lightseagreen:"#20b2aa",lightskyblue:"#87cefa",lightslategray:"#778899",lightslategrey:"#778899",lightsteelblue:"#b0c4de",lightyellow:"#ffffe0",lime:"#00ff00",limegreen:"#32cd32",linen:"#faf0e6",magenta:"#ff00ff",maroon:"#800000",mediumaquamarine:"#66cdaa",mediumblue:"#0000cd",mediumorchid:"#ba55d3",mediumpurple:"#9370db",mediumseagreen:"#3cb371",mediumslateblue:"#7b68ee",mediumspringgreen:"#00fa9a",mediumturquoise:"#48d1cc",mediumvioletred:"#c71585",midnightblue:"#191970",mintcream:"#f5fffa",mistyrose:"#ffe4e1", +moccasin:"#ffe4b5",navajowhite:"#ffdead",navy:"#000080",oldlace:"#fdf5e6",olive:"#808000",olivedrab:"#6b8e23",orange:"#ffa500",orangered:"#ff4500",orchid:"#da70d6",palegoldenrod:"#eee8aa",palegreen:"#98fb98",paleturquoise:"#afeeee",palevioletred:"#db7093",papayawhip:"#ffefd5",peachpuff:"#ffdab9",peru:"#cd853f",pink:"#ffc0cb",plum:"#dda0dd",powderblue:"#b0e0e6",purple:"#800080",red:"#ff0000",rosybrown:"#bc8f8f",royalblue:"#4169e1",saddlebrown:"#8b4513",salmon:"#fa8072",sandybrown:"#f4a460",seagreen:"#2e8b57", +seashell:"#fff5ee",sienna:"#a0522d",silver:"#c0c0c0",skyblue:"#87ceeb",slateblue:"#6a5acd",slategray:"#708090",slategrey:"#708090",snow:"#fffafa",springgreen:"#00ff7f",steelblue:"#4682b4",tan:"#d2b48c",teal:"#008080",thistle:"#d8bfd8",tomato:"#ff6347",turquoise:"#40e0d0",violet:"#ee82ee",wheat:"#f5deb3",white:"#ffffff",whitesmoke:"#f5f5f5",yellow:"#ffff00",yellowgreen:"#9acd32"};function ka(a,b){this.width=a;this.height=b}ka.prototype.toString=function(){return"("+this.width+" x "+this.height+")"};ka.prototype.ceil=function(){this.width=Math.ceil(this.width);this.height=Math.ceil(this.height);return this};ka.prototype.floor=function(){this.width=Math.floor(this.width);this.height=Math.floor(this.height);return this};ka.prototype.round=function(){this.width=Math.round(this.width);this.height=Math.round(this.height);return this};function la(a){return String(a).replace(/\-([a-z])/g,function(a,c){return c.toUpperCase()})}; +function r(a,b,c){this.a=a;this.b=b||1;this.f=c||1};function ma(a){this.b=a;this.a=0}function na(a){a=a.match(oa);for(var b=0;b<a.length;b++)pa.test(a[b])&&a.splice(b,1);return new ma(a)}var oa=/\$?(?:(?![0-9-\.])(?:\*|[\w-\.]+):)?(?![0-9-\.])(?:\*|[\w-\.]+)|\/\/|\.\.|::|\d+(?:\.\d*)?|\.\d+|"[^"]*"|'[^']*'|[!<>]=|\s+|./g,pa=/^\s/;function t(a,b){return a.b[a.a+(b||0)]}function v(a){return a.b[a.a++]}function qa(a){return a.b.length<=a.a};function w(a,b){this.h=a;this.c=h(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 ra(a){return"comment"==a||"text"==a||"processing-instruction"==a||"node"==a}w.prototype.a=function(a){return null===this.b||this.b==a.nodeType};w.prototype.f=function(){return this.h}; +w.prototype.toString=function(){var a="Kind Test: "+this.h;null===this.c||(a+=x(this.c));return a};function sa(a,b){this.j=a.toLowerCase();a="*"==this.j?"*":"http://www.w3.org/1999/xhtml";this.b=b?b.toLowerCase():a}sa.prototype.a=function(a){var b=a.nodeType;if(1!=b&&2!=b)return!1;b=h(a.localName)?a.localName:a.nodeName;return"*"!=this.j&&this.j!=b.toLowerCase()?!1:"*"==this.b?!0:this.b==(a.namespaceURI?a.namespaceURI.toLowerCase():"http://www.w3.org/1999/xhtml")};sa.prototype.f=function(){return this.j}; +sa.prototype.toString=function(){return"Name Test: "+("http://www.w3.org/1999/xhtml"==this.b?"":this.b+":")+this.j};function ta(a){switch(a.nodeType){case 1:return ga(ua,a);case 9:return ta(a.documentElement);case 11:case 10:case 6:case 12:return va;default:return a.parentNode?ta(a.parentNode):va}}function va(){return null}function ua(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?ua(a.parentNode,b):null};function wa(a,b){if(l(a))return l(b)&&1==b.length?a.indexOf(b,0):-1;for(var c=0;c<a.length;c++)if(c in a&&a[c]===b)return c;return-1}function y(a,b){for(var c=a.length,d=l(a)?a.split(""):a,e=0;e<c;e++)e in d&&b.call(void 0,d[e],e,a)}function z(a,b,c){var d=c;y(a,function(c,f){d=b.call(void 0,d,c,f,a)});return d}function xa(a,b){for(var c=a.length,d=l(a)?a.split(""):a,e=0;e<c;e++)if(e in d&&b.call(void 0,d[e],e,a))return!0;return!1} +function ya(a,b){for(var c=a.length,d=l(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 za(a){return Array.prototype.concat.apply([],arguments)}function Aa(a,b,c){return 2>=arguments.length?Array.prototype.slice.call(a,b):Array.prototype.slice.call(a,b,c)};var Ba="backgroundColor borderTopColor borderRightColor borderBottomColor borderLeftColor color outlineColor".split(" "),Ca=/#([0-9a-fA-F])([0-9a-fA-F])([0-9a-fA-F])/,Da=/^#(?:[0-9a-f]{3}){1,2}$/i,Ea=/^(?:rgba)?\((\d{1,3}),\s?(\d{1,3}),\s?(\d{1,3}),\s?(0|1|0\.\d*)\)$/i,Fa=/^(?:rgb)?\((0|[1-9]\d{0,2}),\s?(0|[1-9]\d{0,2}),\s?(0|[1-9]\d{0,2})\)$/i;function B(a,b){this.x=h(a)?a:0;this.y=h(b)?b:0}B.prototype.toString=function(){return"("+this.x+", "+this.y+")"};B.prototype.ceil=function(){this.x=Math.ceil(this.x);this.y=Math.ceil(this.y);return this};B.prototype.floor=function(){this.x=Math.floor(this.x);this.y=Math.floor(this.y);return this};B.prototype.round=function(){this.x=Math.round(this.x);this.y=Math.round(this.y);return this};function Ga(a,b,c,d){this.c=a;this.a=b;this.b=c;this.f=d}Ga.prototype.toString=function(){return"("+this.c+"t, "+this.a+"r, "+this.b+"b, "+this.f+"l)"};Ga.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};Ga.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}; +Ga.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 C(a,b,c,d){this.a=a;this.b=b;this.width=c;this.height=d}C.prototype.toString=function(){return"("+this.a+", "+this.b+" - "+this.width+"w x "+this.height+"h)"};C.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};C.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}; +C.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};(function(){var a=aa.Components;if(!a)return!1;try{if(!a.classes)return!1}catch(c){return!1}var b=a.classes,a=a.interfaces;b["@mozilla.org/xpcom/version-comparator;1"].getService(a.nsIVersionComparator);b["@mozilla.org/xre/app-info;1"].getService(a.nsIXULAppInfo);return!0})();function Ha(a,b){if(!a||!b)return!1;if(a.contains&&1==b.nodeType)return a==b||a.contains(b);if("undefined"!=typeof a.compareDocumentPosition)return a==b||!!(a.compareDocumentPosition(b)&16);for(;b&&a!=b;)b=b.parentNode;return b==a} +function Ia(a,b){if(a==b)return 0;if(a.compareDocumentPosition)return a.compareDocumentPosition(b)&2?1:-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?Ja(a,b):!c&&Ha(e,b)?-1*Ka(a,b):!d&&Ha(f,a)?Ka(b,a):(c?a.sourceIndex:e.sourceIndex)-(d?b.sourceIndex:f.sourceIndex)}d=D(a);c=d.createRange();c.selectNode(a);c.collapse(!0);a=d.createRange();a.selectNode(b); +a.collapse(!0);return c.compareBoundaryPoints(aa.Range.START_TO_END,a)}function Ka(a,b){var c=a.parentNode;if(c==b)return-1;for(;b.parentNode!=c;)b=b.parentNode;return Ja(b,a)}function Ja(a,b){for(;b=b.previousSibling;)if(b==a)return-1;return 1}function D(a){return 9==a.nodeType?a:a.ownerDocument||a.document}function La(a,b){a&&(a=a.parentNode);for(var c=0;a;){if(b(a))return a;a=a.parentNode;c++}return null}function Ma(a){this.a=a||aa.document||document} +Ma.prototype.getElementsByTagName=function(a,b){return(b||this.a).getElementsByTagName(String(a))};function E(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(9==c||1==c){a=9==c?a.documentElement:a.firstChild;for(var c=0,d=[],b="";a;){do 1!=a.nodeType&&(b+=a.nodeValue),d[c++]=a;while(a=a.firstChild);for(;c&&!(a=d[--c].nextSibling););}}else b=a.nodeValue;return""+b} +function F(a,b,c){if(null===b)return!0;try{if(!a.getAttribute)return!1}catch(d){return!1}return null==c?!!a.getAttribute(b):a.getAttribute(b,2)==c}function Na(a,b,c,d,e){return Oa.call(null,a,b,l(c)?c:null,l(d)?d:null,e||new G)} +function Oa(a,b,c,d,e){b.getElementsByName&&d&&"name"==c?(b=b.getElementsByName(d),y(b,function(b){a.a(b)&&H(e,b)})):b.getElementsByClassName&&d&&"class"==c?(b=b.getElementsByClassName(d),y(b,function(b){b.className==d&&a.a(b)&&H(e,b)})):a instanceof w?Pa(a,b,c,d,e):b.getElementsByTagName&&(b=b.getElementsByTagName(a.f()),y(b,function(a){F(a,c,d)&&H(e,a)}));return e}function Pa(a,b,c,d,e){for(b=b.firstChild;b;b=b.nextSibling)F(b,c,d)&&a.a(b)&&H(e,b),Pa(a,b,c,d,e)};function J(a,b){b&&"string"!==typeof b&&(b=b.toString());return!!a&&1==a.nodeType&&(!b||a.tagName.toUpperCase()==b)};function G(){this.b=this.a=null;this.l=0}function Qa(a){this.node=a;this.a=this.b=null}function Ra(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;)c.node==b.node?(e=c,c=c.a,b=b.a):0<Ia(c.node,b.node)?(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 Sa(a,b){b=new Qa(b);b.a=a.a;a.b?a.a.b=b:a.a=a.b=b;a.a=b;a.l++}function H(a,b){b=new Qa(b);b.b=a.b;a.a?a.b.a=b:a.a=a.b=b;a.b=b;a.l++} +function Ta(a){return(a=a.a)?a.node:null}function Ua(a){return(a=Ta(a))?E(a):""}function K(a,b){return new Va(a,!!b)}function Va(a,b){this.f=a;this.b=(this.s=b)?a.b:a.a;this.a=null}function M(a){var b=a.b;if(b){var c=a.a=b;a.b=a.s?b.b:b.a;return c.node}return null};function N(a){this.i=a;this.b=this.g=!1;this.f=null}function x(a){return"\n "+a.toString().split("\n").join("\n ")}function Wa(a,b){a.g=b}function Xa(a,b){a.b=b}function O(a,b){a=a.a(b);return a instanceof G?+Ua(a):+a}function P(a,b){a=a.a(b);return a instanceof G?Ua(a):""+a}function Q(a,b){a=a.a(b);return a instanceof G?!!a.l:!!a};function Ya(a,b,c){N.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==Za&&(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})}n(Ya,N); +function R(a,b,c,d,e){b=b.a(d);c=c.a(d);var f;if(b instanceof G&&c instanceof G){b=K(b);for(d=M(b);d;d=M(b))for(e=K(c),f=M(e);f;f=M(e))if(a(E(d),E(f)))return!0;return!1}if(b instanceof G||c instanceof G){b instanceof G?(e=b,d=c):(e=c,d=b);f=K(e);for(var g=typeof d,k=M(f);k;k=M(f)){switch(g){case "number":k=+E(k);break;case "boolean":k=!!E(k);break;case "string":k=E(k);break;default:throw Error("Illegal primitive type for comparison.");}if(e==b&&a(k,d)||e==c&&a(d,k))return!0}return!1}return e?"boolean"== +typeof b||"boolean"==typeof c?a(!!b,!!c):"number"==typeof b||"number"==typeof c?a(+b,+c):a(b,c):a(+b,+c)}Ya.prototype.a=function(a){return this.c.m(this.h,this.o,a)};Ya.prototype.toString=function(){var a="Binary Expression: "+this.c,a=a+x(this.h);return a+=x(this.o)};function $a(a,b,c,d){this.I=a;this.D=b;this.i=c;this.m=d}$a.prototype.toString=function(){return this.I};var ab={}; +function S(a,b,c,d){if(ab.hasOwnProperty(a))throw Error("Binary operator already created: "+a);a=new $a(a,b,c,d);return ab[a.toString()]=a}S("div",6,1,function(a,b,c){return O(a,c)/O(b,c)});S("mod",6,1,function(a,b,c){return O(a,c)%O(b,c)});S("*",6,1,function(a,b,c){return O(a,c)*O(b,c)});S("+",5,1,function(a,b,c){return O(a,c)+O(b,c)});S("-",5,1,function(a,b,c){return O(a,c)-O(b,c)});S("<",4,2,function(a,b,c){return R(function(a,b){return a<b},a,b,c)}); +S(">",4,2,function(a,b,c){return R(function(a,b){return a>b},a,b,c)});S("<=",4,2,function(a,b,c){return R(function(a,b){return a<=b},a,b,c)});S(">=",4,2,function(a,b,c){return R(function(a,b){return a>=b},a,b,c)});var Za=S("=",3,2,function(a,b,c){return R(function(a,b){return a==b},a,b,c,!0)});S("!=",3,2,function(a,b,c){return R(function(a,b){return a!=b},a,b,c,!0)});S("and",2,2,function(a,b,c){return Q(a,c)&&Q(b,c)});S("or",1,2,function(a,b,c){return Q(a,c)||Q(b,c)});function bb(a,b){if(b.a.length&&4!=a.i)throw Error("Primary expression must evaluate to nodeset if filter has predicate(s).");N.call(this,a.i);this.c=a;this.h=b;this.g=a.g;this.b=a.b}n(bb,N);bb.prototype.a=function(a){a=this.c.a(a);return cb(this.h,a)};bb.prototype.toString=function(){var a="Filter:"+x(this.c);return a+=x(this.h)};function db(a,b){if(b.length<a.C)throw Error("Function "+a.j+" expects at least"+a.C+" arguments, "+b.length+" given");if(null!==a.A&&b.length>a.A)throw Error("Function "+a.j+" expects at most "+a.A+" arguments, "+b.length+" given");a.H&&y(b,function(b,d){if(4!=b.i)throw Error("Argument "+d+" to function "+a.j+" is not of type Nodeset: "+b);});N.call(this,a.i);this.v=a;this.c=b;Wa(this,a.g||xa(b,function(a){return a.g}));Xa(this,a.G&&!b.length||a.F&&!!b.length||xa(b,function(a){return a.b}))} +n(db,N);db.prototype.a=function(a){return this.v.m.apply(null,za(a,this.c))};db.prototype.toString=function(){var a="Function: "+this.v;if(this.c.length)var b=z(this.c,function(a,b){return a+x(b)},"Arguments:"),a=a+x(b);return a};function eb(a,b,c,d,e,f,g,k,q){this.j=a;this.i=b;this.g=c;this.G=d;this.F=e;this.m=f;this.C=g;this.A=h(k)?k:g;this.H=!!q}eb.prototype.toString=function(){return this.j};var fb={}; +function T(a,b,c,d,e,f,g,k){if(fb.hasOwnProperty(a))throw Error("Function already created: "+a+".");fb[a]=new eb(a,b,c,d,!1,e,f,g,k)}T("boolean",2,!1,!1,function(a,b){return Q(b,a)},1);T("ceiling",1,!1,!1,function(a,b){return Math.ceil(O(b,a))},1);T("concat",3,!1,!1,function(a,b){return z(Aa(arguments,1),function(b,d){return b+P(d,a)},"")},2,null);T("contains",2,!1,!1,function(a,b,c){b=P(b,a);a=P(c,a);return-1!=b.indexOf(a)},2);T("count",1,!1,!1,function(a,b){return b.a(a).l},1,1,!0); +T("false",2,!1,!1,function(){return!1},0);T("floor",1,!1,!1,function(a,b){return Math.floor(O(b,a))},1);T("id",4,!1,!1,function(a,b){var c=a.a,d=9==c.nodeType?c:c.ownerDocument;a=P(b,a).split(/\s+/);var e=[];y(a,function(a){a=d.getElementById(a);!a||0<=wa(e,a)||e.push(a)});e.sort(Ia);var f=new G;y(e,function(a){H(f,a)});return f},1);T("lang",2,!1,!1,function(){return!1},1);T("last",1,!0,!1,function(a){if(1!=arguments.length)throw Error("Function last expects ()");return a.f},0); +T("local-name",3,!1,!0,function(a,b){return(a=b?Ta(b.a(a)):a.a)?a.localName||a.nodeName.toLowerCase():""},0,1,!0);T("name",3,!1,!0,function(a,b){return(a=b?Ta(b.a(a)):a.a)?a.nodeName.toLowerCase():""},0,1,!0);T("namespace-uri",3,!0,!1,function(){return""},0,1,!0);T("normalize-space",3,!1,!0,function(a,b){return(b?P(b,a):E(a.a)).replace(/[\s\xa0]+/g," ").replace(/^\s+|\s+$/g,"")},0,1);T("not",2,!1,!1,function(a,b){return!Q(b,a)},1);T("number",1,!1,!0,function(a,b){return b?O(b,a):+E(a.a)},0,1); +T("position",1,!0,!1,function(a){return a.b},0);T("round",1,!1,!1,function(a,b){return Math.round(O(b,a))},1);T("starts-with",2,!1,!1,function(a,b,c){b=P(b,a);a=P(c,a);return!b.lastIndexOf(a,0)},2);T("string",3,!1,!0,function(a,b){return b?P(b,a):E(a.a)},0,1);T("string-length",1,!1,!0,function(a,b){return(b?P(b,a):E(a.a)).length},0,1); +T("substring",3,!1,!1,function(a,b,c,d){c=O(c,a);if(isNaN(c)||Infinity==c||-Infinity==c)return"";d=d?O(d,a):Infinity;if(isNaN(d)||-Infinity===d)return"";c=Math.round(c)-1;var e=Math.max(c,0);a=P(b,a);return Infinity==d?a.substring(e):a.substring(e,c+Math.round(d))},2,3);T("substring-after",3,!1,!1,function(a,b,c){b=P(b,a);a=P(c,a);c=b.indexOf(a);return-1==c?"":b.substring(c+a.length)},2); +T("substring-before",3,!1,!1,function(a,b,c){b=P(b,a);a=P(c,a);a=b.indexOf(a);return-1==a?"":b.substring(0,a)},2);T("sum",1,!1,!1,function(a,b){a=K(b.a(a));b=0;for(var c=M(a);c;c=M(a))b+=+E(c);return b},1,1,!0);T("translate",3,!1,!1,function(a,b,c,d){b=P(b,a);c=P(c,a);var e=P(d,a);d={};for(var f=0;f<c.length;f++)a=c.charAt(f),a in d||(d[a]=e.charAt(f));c="";for(f=0;f<b.length;f++)a=b.charAt(f),c+=a in d?d[a]:a;return c},3);T("true",2,!1,!1,function(){return!0},0);function gb(a){N.call(this,3);this.c=a.substring(1,a.length-1)}n(gb,N);gb.prototype.a=function(){return this.c};gb.prototype.toString=function(){return"Literal: "+this.c};function hb(a){N.call(this,1);this.c=a}n(hb,N);hb.prototype.a=function(){return this.c};hb.prototype.toString=function(){return"Number: "+this.c};function ib(a,b){N.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.w||a.c!=jb||(a=a.o,"*"!=a.f()&&(this.f={name:a.f(),u:null})))}n(ib,N);function kb(){N.call(this,4)}n(kb,N);kb.prototype.a=function(a){var b=new G;a=a.a;9==a.nodeType?H(b,a):H(b,a.ownerDocument);return b};kb.prototype.toString=function(){return"Root Helper Expression"};function lb(){N.call(this,4)}n(lb,N);lb.prototype.a=function(a){var b=new G;H(b,a.a);return b};lb.prototype.toString=function(){return"Context Helper Expression"}; +function mb(a){return"/"==a||"//"==a}ib.prototype.a=function(a){var b=this.h.a(a);if(!(b instanceof G))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=K(b,e.c.s);if(e.g||e.c!=nb)if(e.g||e.c!=ob){var g=M(f);for(b=e.a(new r(g));g=M(f);)g=e.a(new r(g)),b=Ra(b,g)}else g=M(f),b=e.a(new r(g));else{for(g=M(f);(b=M(f))&&(!g.contains||g.contains(b))&&b.compareDocumentPosition(g)&8;g=b);b=e.a(new r(g))}}return b}; +ib.prototype.toString=function(){var a="Path Expression:"+x(this.h);if(this.c.length){var b=z(this.c,function(a,b){return a+x(b)},"Steps:");a+=x(b)}return a};function pb(a,b){this.a=a;this.s=!!b} +function cb(a,b,c){for(c=c||0;c<a.a.length;c++)for(var d=a.a[c],e=K(b),f=b.l,g,k=0;g=M(e);k++){var q=a.s?f-k:k+1;g=d.a(new r(g,q,f));if("number"==typeof g)q=q==g;else if("string"==typeof g||"boolean"==typeof g)q=!!g;else if(g instanceof G)q=0<g.l;else throw Error("Predicate.evaluate returned an unexpected type.");if(!q){q=e;g=q.f;var u=q.a;if(!u)throw Error("Next must be called at least once before remove.");var m=u.b,u=u.a;m?m.a=u:g.a=u;u?u.b=m:g.b=m;g.l--;q.a=null}}return b} +pb.prototype.toString=function(){return z(this.a,function(a,b){return a+x(b)},"Predicates:")};function qb(a){N.call(this,1);this.c=a;this.g=a.g;this.b=a.b}n(qb,N);qb.prototype.a=function(a){return-O(this.c,a)};qb.prototype.toString=function(){return"Unary Expression: -"+x(this.c)};function rb(a){N.call(this,4);this.c=a;Wa(this,xa(this.c,function(a){return a.g}));Xa(this,xa(this.c,function(a){return a.b}))}n(rb,N);rb.prototype.a=function(a){var b=new G;y(this.c,function(c){c=c.a(a);if(!(c instanceof G))throw Error("Path expression must evaluate to NodeSet.");b=Ra(b,c)});return b};rb.prototype.toString=function(){return z(this.c,function(a,b){return a+x(b)},"Union Expression:")};function U(a,b,c,d){N.call(this,4);this.c=a;this.o=b;this.h=c||new pb([]);this.w=!!d;b=this.h;b=0<b.a.length?b.a[0].f:null;a.J&&b&&(this.f={name:b.name,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}n(U,N); +U.prototype.a=function(a){var b=a.a,c=this.f,d=null,e=null,f=0;c&&(d=c.name,e=c.u?P(c.u,a):null,f=1);if(this.w)if(this.g||this.c!=sb)if(b=K((new U(tb,new w("node"))).a(a)),c=M(b))for(a=this.m(c,d,e,f);c=M(b);)a=Ra(a,this.m(c,d,e,f));else a=new G;else a=Na(this.o,b,d,e),a=cb(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=cb(this.h,a,d)}; +U.prototype.toString=function(){var a="Step:"+x("Operator: "+(this.w?"//":"/"));this.c.j&&(a+=x("Axis: "+this.c));a+=x(this.o);if(this.h.a.length){var b=z(this.h.a,function(a,b){return a+x(b)},"Predicates:");a+=x(b)}return a};function ub(a,b,c,d){this.j=a;this.v=b;this.s=c;this.J=d}ub.prototype.toString=function(){return this.j};var vb={};function V(a,b,c,d){if(vb.hasOwnProperty(a))throw Error("Axis already created: "+a);b=new ub(a,b,c,!!d);return vb[a]=b} +V("ancestor",function(a,b){for(var c=new G;b=b.parentNode;)a.a(b)&&Sa(c,b);return c},!0);V("ancestor-or-self",function(a,b){var c=new G;do a.a(b)&&Sa(c,b);while(b=b.parentNode);return c},!0); +var jb=V("attribute",function(a,b){var c=new G,d=a.f();if(b=b.attributes)if(a instanceof w&&null===a.b||"*"==d)for(d=0;a=b[d];d++)H(c,a);else(a=b.getNamedItem(d))&&H(c,a);return c},!1),sb=V("child",function(a,b,c,d,e){c=l(c)?c:null;d=l(d)?d:null;e=e||new G;for(b=b.firstChild;b;b=b.nextSibling)F(b,c,d)&&a.a(b)&&H(e,b);return e},!1,!0);V("descendant",Na,!1,!0); +var tb=V("descendant-or-self",function(a,b,c,d){var e=new G;F(b,c,d)&&a.a(b)&&H(e,b);return Na(a,b,c,d,e)},!1,!0),nb=V("following",function(a,b,c,d){var e=new G;do for(var f=b;f=f.nextSibling;)F(f,c,d)&&a.a(f)&&H(e,f),e=Na(a,f,c,d,e);while(b=b.parentNode);return e},!1,!0);V("following-sibling",function(a,b){for(var c=new G;b=b.nextSibling;)a.a(b)&&H(c,b);return c},!1);V("namespace",function(){return new G},!1); +var wb=V("parent",function(a,b){var c=new G;if(9==b.nodeType)return c;if(2==b.nodeType)return H(c,b.ownerElement),c;b=b.parentNode;a.a(b)&&H(c,b);return c},!1),ob=V("preceding",function(a,b,c,d){var e=new G,f=[];do f.unshift(b);while(b=b.parentNode);for(var g=1,k=f.length;g<k;g++){var q=[];for(b=f[g];b=b.previousSibling;)q.unshift(b);for(var u=0,m=q.length;u<m;u++)b=q[u],F(b,c,d)&&a.a(b)&&H(e,b),e=Na(a,b,c,d,e)}return e},!0,!0); +V("preceding-sibling",function(a,b){for(var c=new G;b=b.previousSibling;)a.a(b)&&Sa(c,b);return c},!0);var xb=V("self",function(a,b){var c=new G;a.a(b)&&H(c,b);return c},!1);function yb(a,b){this.a=a;this.b=b}function zb(a){for(var b,c=[];;){W(a,"Missing right hand side of binary expression.");b=Ab(a);var d=v(a.a);if(!d)break;var e=(d=ab[d]||null)&&d.D;if(!e){a.a.a--;break}for(;c.length&&e<=c[c.length-1].D;)b=new Ya(c.pop(),c.pop(),b);c.push(b,d)}for(;c.length;)b=new Ya(c.pop(),c.pop(),b);return b}function W(a,b){if(qa(a.a))throw Error(b);}function Bb(a,b){a=v(a.a);if(a!=b)throw Error("Bad token, expected: "+b+" got: "+a);} +function Cb(a){a=v(a.a);if(")"!=a)throw Error("Bad token: "+a);}function Db(a){a=v(a.a);if(2>a.length)throw Error("Unclosed literal string");return new gb(a)} +function Eb(a){var b=[];if(mb(t(a.a))){var c=v(a.a);var d=t(a.a);if("/"==c&&(qa(a.a)||"."!=d&&".."!=d&&"@"!=d&&"*"!=d&&!/(?![0-9])[\w]/.test(d)))return new kb;d=new kb;W(a,"Missing next location step.");c=Fb(a,c);b.push(c)}else{a:{c=t(a.a);d=c.charAt(0);switch(d){case "$":throw Error("Variable reference not allowed in HTML XPath");case "(":v(a.a);c=zb(a);W(a,'unclosed "("');Bb(a,")");break;case '"':case "'":c=Db(a);break;default:if(isNaN(+c))if(!ra(c)&&/(?![0-9])[\w]/.test(d)&&"("==t(a.a,1)){c=v(a.a); +c=fb[c]||null;v(a.a);for(d=[];")"!=t(a.a);){W(a,"Missing function argument list.");d.push(zb(a));if(","!=t(a.a))break;v(a.a)}W(a,"Unclosed function argument list.");Cb(a);c=new db(c,d)}else{c=null;break a}else c=new hb(+v(a.a))}"["==t(a.a)&&(d=new pb(Gb(a)),c=new bb(c,d))}if(c)if(mb(t(a.a)))d=c;else return c;else c=Fb(a,"/"),d=new lb,b.push(c)}for(;mb(t(a.a));)c=v(a.a),W(a,"Missing next location step."),c=Fb(a,c),b.push(c);return new ib(d,b)} +function Fb(a,b){if("/"!=b&&"//"!=b)throw Error('Step op should be "/" or "//"');if("."==t(a.a)){var c=new U(xb,new w("node"));v(a.a);return c}if(".."==t(a.a))return c=new U(wb,new w("node")),v(a.a),c;if("@"==t(a.a)){var d=jb;v(a.a);W(a,"Missing attribute name")}else if("::"==t(a.a,1)){if(!/(?![0-9])[\w]/.test(t(a.a).charAt(0)))throw Error("Bad token: "+v(a.a));var e=v(a.a);d=vb[e]||null;if(!d)throw Error("No axis with name: "+e);v(a.a);W(a,"Missing node name")}else d=sb;e=t(a.a);if(/(?![0-9])[\w\*]/.test(e.charAt(0)))if("("== +t(a.a,1)){if(!ra(e))throw Error("Invalid node type: "+e);e=v(a.a);if(!ra(e))throw Error("Invalid type name: "+e);Bb(a,"(");W(a,"Bad nodetype");var f=t(a.a).charAt(0),g=null;if('"'==f||"'"==f)g=Db(a);W(a,"Bad nodetype");Cb(a);e=new w(e,g)}else if(e=v(a.a),f=e.indexOf(":"),-1==f)e=new sa(e);else{var g=e.substring(0,f);if("*"==g)var k="*";else if(k=a.b(g),!k)throw Error("Namespace prefix not declared: "+g);e=e.substr(f+1);e=new sa(e,k)}else throw Error("Bad token: "+v(a.a));a=new pb(Gb(a),d.s);return c|| +new U(d,e,a,"//"==b)}function Gb(a){for(var b=[];"["==t(a.a);){v(a.a);W(a,"Missing predicate expression.");var c=zb(a);b.push(c);W(a,"Unclosed predicate expression.");Bb(a,"]")}return b}function Ab(a){if("-"==t(a.a))return v(a.a),new qb(Ab(a));var b=Eb(a);if("|"!=t(a.a))a=b;else{for(b=[b];"|"==v(a.a);)W(a,"Missing next union location path."),b.push(Eb(a));a.a.a--;a=new rb(b)}return a};function Hb(a,b){if(!a.length)throw Error("Empty XPath expression.");a=na(a);if(qa(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(!qa(a))throw Error("Bad token: "+v(a));this.evaluate=function(a,b){a=c.a(new r(a));return new X(a,b)}} +function X(a,b){if(!b)if(a instanceof G)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 G))throw Error("value could not be converted to the specified type");this.resultType=b;switch(b){case 2:this.stringValue=a instanceof G?Ua(a):""+a;break;case 1:this.numberValue=a instanceof G?+Ua(a):+a;break;case 3:this.booleanValue=a instanceof G?0<a.l:!!a;break;case 4:case 5:case 6:case 7:var c= +K(a);var d=[];for(var e=M(c);e;e=M(c))d.push(e);this.snapshotLength=a.l;this.invalidIteratorState=!1;break;case 8:case 9:this.singleNodeValue=Ta(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(a){if(6!=b&&7!=b)throw Error("snapshotItem called with wrong result type");return a>=d.length||0>a?null:d[a]}}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 Ib(a){this.lookupNamespaceURI=ta(a)} +ba("wgxpath.install",function(a,b){a=a||aa;var c=a.Document&&a.Document.prototype||a.document;if(!c.evaluate||b)a.XPathResult=X,c.evaluate=function(a,b,c,g){return(new Hb(a,c)).evaluate(b,g)},c.createExpression=function(a,b){return new Hb(a,b)},c.createNSResolver=function(a){return new Ib(a)}});var Jb=function(){var a={M:"http://www.w3.org/2000/svg"};return function(b){return a[b]||null}}(); +function Kb(a,b){var c=D(a);if(!c.documentElement)return null;try{for(var d=c.createNSResolver?c.createNSResolver(c.documentElement):Jb,e={},f=c.getElementsByTagName("*"),g=0;g<f.length;++g){var k=f[g],q=k.namespaceURI;if(q&&!e[q]){var u=k.lookupPrefix(q);if(!u)var m=q.match(".*/(\\w+)/?$"),u=m?m[1]:"xhtml";e[q]=u}}var A={},I;for(I in e)A[e[I]]=I;d=function(a){return A[a]||null};try{return c.evaluate(b,a,d,9,null)}catch(L){if("TypeError"===L.name)return d=c.createNSResolver?c.createNSResolver(c.documentElement): +Jb,c.evaluate(b,a,d,9,null);throw L;}}catch(L){if("NS_ERROR_ILLEGAL_VALUE"!=L.name)throw new ha(32,"Unable to locate an element with the xpath expression "+b+" because of the following error:\n"+L);}} +function Lb(a,b){var c=function(){var c=Kb(b,a);return c?c.singleNodeValue||null:b.selectSingleNode?(c=D(b),c.setProperty&&c.setProperty("SelectionLanguage","XPath"),b.selectSingleNode(a)):null}();if(null!==c&&(!c||1!=c.nodeType))throw new ha(32,'The result of the xpath expression "'+a+'" is: '+c+". It should be an element.");return c};var Mb="function"===typeof ShadowRoot;function Nb(a){for(a=a.parentNode;a&&1!=a.nodeType&&9!=a.nodeType&&11!=a.nodeType;)a=a.parentNode;return J(a)?a:null} +function Y(a,b){b=la(b);if("float"==b||"cssFloat"==b||"styleFloat"==b)b="cssFloat";a:{var c=b;var d=D(a);if(d.defaultView&&d.defaultView.getComputedStyle&&(d=d.defaultView.getComputedStyle(a,null))){c=d[c]||d.getPropertyValue(c)||"";break a}c=""}a=c||Ob(a,b);if(null===a)a=null;else if(0<=wa(Ba,b)){b:{var e=a.match(Ea);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(Fa))if(b=Number(d[1]), +c=Number(d[2]),d=Number(d[3]),0<=b&&255>=b&&0<=c&&255>=c&&0<=d&&255>=d){b=[b,c,d,1];break b}b=null}if(!b)b:{b=a.toLowerCase();c=ja[b.toLowerCase()];if(!c&&(c="#"==b.charAt(0)?b:"#"+b,4==c.length&&(c=c.replace(Ca,"#$1$1$2$2$3$3")),!Da.test(c))){b=null;break b}b=[parseInt(c.substr(1,2),16),parseInt(c.substr(3,2),16),parseInt(c.substr(5,2),16),1]}a=b?"rgba("+b.join(", ")+")":a}return a} +function Ob(a,b){var c=a.currentStyle||a.style,d=c[b];!h(d)&&"function"==ca(c.getPropertyValue)&&(d=c.getPropertyValue(b));return"inherit"!=d?h(d)?d:null:(a=Nb(a))?Ob(a,b):null} +function Pb(a,b,c){function d(a){var b=Qb(a);return 0<b.height&&0<b.width?!0:J(a,"PATH")&&(0<b.height||0<b.width)?(a=Y(a,"stroke-width"),!!a&&0<parseInt(a,10)):"hidden"!=Y(a,"overflow")&&xa(a.childNodes,function(a){return 3==a.nodeType||J(a)&&d(a)})}function e(a){return Rb(a)==Z&&ya(a.childNodes,function(a){return!J(a)||e(a)||!d(a)})}if(!J(a))throw Error("Argument to isShown must be of type Element");if(J(a,"BODY"))return!0;if(J(a,"OPTION")||J(a,"OPTGROUP"))return a=La(a,function(a){return J(a,"SELECT")}), +!!a&&Pb(a,!0,c);var f=Sb(a);if(f)return!!f.B&&0<f.rect.width&&0<f.rect.height&&Pb(f.B,b,c);if(J(a,"INPUT")&&"hidden"==a.type.toLowerCase()||J(a,"NOSCRIPT"))return!1;f=Y(a,"visibility");return"collapse"!=f&&"hidden"!=f&&c(a)&&(b||Tb(a))&&d(a)?!e(a):!1}var Z="hidden"; +function Rb(a){function b(a){function b(a){return a==g?!0:!Y(a,"display").lastIndexOf("inline",0)||"absolute"==c&&"static"==Y(a,"position")?!1:!0}var c=Y(a,"position");if("fixed"==c)return u=!0,a==g?null:g;for(a=Nb(a);a&&!b(a);)a=Nb(a);return a}function c(a){var b=a;if("visible"==q)if(a==g&&k)b=k;else if(a==k)return{x:"visible",y:"visible"};b={x:Y(b,"overflow-x"),y:Y(b,"overflow-y")};a==g&&(b.x="visible"==b.x?"auto":b.x,b.y="visible"==b.y?"auto":b.y);return b}function d(a){if(a==g){var b=(new Ma(f)).a; +a=b.scrollingElement?b.scrollingElement:"CSS1Compat"==b.compatMode?b.documentElement:b.body||b.documentElement;b=b.parentWindow||b.defaultView;a=new B(b.pageXOffset||a.scrollLeft,b.pageYOffset||a.scrollTop)}else a=new B(a.scrollLeft,a.scrollTop);return a}var e=Ub(a);var f=D(a),g=f.documentElement,k=f.body,q=Y(g,"overflow"),u;for(a=b(a);a;a=b(a)){var m=c(a);if("visible"!=m.x||"visible"!=m.y){var A=Qb(a);if(!A.width||!A.height)return Z;var I=e.a<A.a,L=e.b<A.b;if(I&&"hidden"==m.x||L&&"hidden"==m.y)return Z; +if(I&&"visible"!=m.x||L&&"visible"!=m.y){I=d(a);L=e.b<A.b-I.y;if(e.a<A.a-I.x&&"visible"!=m.x||L&&"visible"!=m.x)return Z;e=Rb(a);return e==Z?Z:"scroll"}I=e.f>=A.a+A.width;A=e.c>=A.b+A.height;if(I&&"hidden"==m.x||A&&"hidden"==m.y)return Z;if(I&&"visible"!=m.x||A&&"visible"!=m.y){if(u&&(m=d(a),e.f>=g.scrollWidth-m.x||e.a>=g.scrollHeight-m.y))return Z;e=Rb(a);return e==Z?Z:"scroll"}}}return"none"} +function Qb(a){var b=Sb(a);if(b)return b.rect;if(J(a,"HTML"))return a=D(a),a=((a?a.parentWindow||a.defaultView:window)||window).document,a="CSS1Compat"==a.compatMode?a.documentElement:a.body,a=new ka(a.clientWidth,a.clientHeight),new C(0,0,a.width,a.height);try{var c=a.getBoundingClientRect()}catch(d){return new C(0,0,0,0)}return new C(c.left,c.top,c.right-c.left,c.bottom-c.top)} +function Sb(a){var b=J(a,"MAP");if(!b&&!J(a,"AREA"))return null;var c=b?a:J(a.parentNode,"MAP")?a.parentNode:null,d=null,e=null;c&&c.name&&(d=Lb('/descendant::*[@usemap = "#'+c.name+'"]',D(c)))&&(e=Qb(d),b||"default"==a.shape.toLowerCase()||(a=Vb(a),b=Math.min(Math.max(a.a,0),e.width),c=Math.min(Math.max(a.b,0),e.height),e=new C(b+e.a,c+e.b,Math.min(a.width,e.width-b),Math.min(a.height,e.height-c))));return{B:d,rect:e||new C(0,0,0,0)}} +function Vb(a){var b=a.shape.toLowerCase();a=a.coords.split(",");if("rect"==b&&4==a.length){var b=a[0],c=a[1];return new C(b,c,a[2]-b,a[3]-c)}if("circle"==b&&3==a.length)return b=a[2],new C(a[0]-b,a[1]-b,2*b,2*b);if("poly"==b&&2<a.length){for(var b=a[0],c=a[1],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 C(b,c,d-b,e-c)}return new C(0,0,0,0)}function Ub(a){a=Qb(a);return new Ga(a.b,a.a+a.width,a.b+a.height,a.a)} +function Tb(a){var b=1,c=Y(a,"opacity");c&&(b=Number(c));(a=Nb(a))&&(b*=Tb(a));return b};ba("_",function(a,b){var c=Mb?function(b){if("none"==Y(b,"display"))return!1;do{var d=b.parentNode;if(b.getDestinationInsertionPoints){var f=b.getDestinationInsertionPoints();0<f.length&&(d=f[f.length-1])}if(d instanceof ShadowRoot){if(d.host.shadowRoot!=d)return!1;d=d.host}else!d||9!=d.nodeType&&11!=d.nodeType||(d=null)}while(a&&1!=a.nodeType);return!d||c(d)}:function(a){if("none"==Y(a,"display"))return!1;a=Nb(a);return!a||c(a)};return Pb(a,!!b,c)});; return this._.apply(null,arguments);}.apply({navigator:typeof window!='undefined'?window.navigator:null,document:typeof window!='undefined'?window.document:null}, arguments);} diff --git a/testing/marionette/browser.js b/testing/marionette/browser.js new file mode 100644 index 0000000000..60b402e395 --- /dev/null +++ b/testing/marionette/browser.js @@ -0,0 +1,532 @@ +/* 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"; +/* global frame */ + +const EXPORTED_SYMBOLS = ["browser", "Context", "WindowState"]; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + element: "chrome://marionette/content/element.js", + error: "chrome://marionette/content/error.js", + Log: "chrome://marionette/content/log.js", + MessageManagerDestroyedPromise: "chrome://marionette/content/sync.js", + waitForEvent: "chrome://marionette/content/sync.js", + waitForObserverTopic: "chrome://marionette/content/sync.js", + WebElementEventTarget: "chrome://marionette/content/dom.js", +}); + +XPCOMUtils.defineLazyGetter(this, "logger", () => Log.get()); + +/** @namespace */ +this.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 Context { + /** + * Gets the correct context from a string. + * + * @param {string} s + * Context string serialisation. + * + * @return {Context} + * Context. + * + * @throws {TypeError} + * If <var>s</var> is not a context. + */ + static fromString(s) { + switch (s) { + case "chrome": + return Context.Chrome; + + case "content": + return Context.Content; + + default: + throw new TypeError(`Unknown context: ${s}`); + } + } +} +Context.Chrome = "chrome"; +Context.Content = "content"; +this.Context = Context; + +// GeckoView shim for Desktop's gBrowser +class MobileTabBrowser { + constructor(window) { + this.window = window; + } + + get tabs() { + return [this.window.tab]; + } + + get selectedTab() { + return this.window.tab; + } + + set selectedTab(tab) { + if (tab != this.selectedTab) { + throw new Error("GeckoView only supports a single tab"); + } + + // Synthesize a custom TabSelect event to indicate that a tab has been + // selected even when we don't change it. + const event = this.window.CustomEvent("TabSelect", { + bubbles: true, + cancelable: false, + detail: { + previousTab: this.selectedTab, + }, + }); + this.window.document.dispatchEvent(event); + } + + get selectedBrowser() { + return this.selectedTab.linkedBrowser; + } +} + +/** + * Get the <code><xul:browser></code> for the specified tab. + * + * @param {Tab} tab + * The tab whose browser needs to be returned. + * + * @return {Browser} + * The linked browser for the tab or null if no browser can be found. + */ +browser.getBrowserForTab = function(tab) { + if (tab && "linkedBrowser" in tab) { + return tab.linkedBrowser; + } + + return null; +}; + +/** + * Return the tab browser for the specified chrome window. + * + * @param {ChromeWindow} win + * Window whose <code>tabbrowser</code> needs to be accessed. + * + * @return {Tab} + * Tab browser or null if it's not a browser window. + */ +browser.getTabBrowser = function(window) { + // GeckoView + if (Services.androidBridge) { + return new MobileTabBrowser(window); + // Firefox + } else if ("gBrowser" in window) { + return window.gBrowser; + // Thunderbird + } else if (window.document.getElementById("tabmail")) { + return window.document.getElementById("tabmail"); + } + + return null; +}; + +/** + * Creates a browsing context wrapper. + * + * Browsing contexts handle interactions with the browser, according to + * the current environment. + */ +browser.Context = class { + /** + * @param {ChromeWindow} win + * ChromeWindow that contains the top-level browsing context. + * @param {GeckoDriver} driver + * Reference to driver instance. + */ + constructor(window, driver) { + this.window = window; + this.driver = driver; + + // In Firefox this is <xul:tabbrowser> (not <xul:browser>!) + // and MobileTabBrowser in GeckoView. + this.tabBrowser = browser.getTabBrowser(this.window); + + // Used to set curFrameId upon new session + this.newSession = true; + + this.seenEls = new element.Store(); + + // 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; + + this.frameRegsPending = 0; + + this.getIdForBrowser = driver.getIdForBrowser.bind(driver); + this.updateIdForBrowser = driver.updateIdForBrowser.bind(driver); + } + + /** + * 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 browser.getBrowserForTab(this.tab); + } else if ( + this.tabBrowser && + this.driver.isReftestBrowser(this.tabBrowser) + ) { + return this.tabBrowser; + } + + return null; + } + + get messageManager() { + if (this.contentBrowser) { + return this.contentBrowser.messageManager; + } + + return null; + } + + /** + * Checks if the browsing context has been discarded. + * + * The browsing context will have been discarded if the content + * browser, represented by the <code><xul:browser></code>, + * has been detached. + * + * @return {boolean} + * True if browsing context has been discarded, false otherwise. + */ + get closed() { + return this.contentBrowser === null; + } + + /** + * The current frame ID is managed per browser element on desktop in + * case the ID needs to be refreshed. The currently selected window is + * identified by a tab. + */ + get curFrameId() { + let rv = null; + if (this.tab || this.driver.isReftestBrowser(this.contentBrowser)) { + rv = this.getIdForBrowser(this.contentBrowser); + } + return rv; + } + + /** + * Gets the position and dimensions of the top-level browsing context. + * + * @return {Map.<string, number>} + * Object with |x|, |y|, |width|, and |height| properties. + */ + get rect() { + return { + x: this.window.screenX, + y: this.window.screenY, + width: this.window.outerWidth, + height: this.window.outerHeight, + }; + } + + /** + * Retrieves the current tabmodal UI object. According to the browser + * associated with the currently selected tab. + */ + getTabModal() { + let br = this.contentBrowser; + if (!br.hasAttribute("tabmodalPromptShowing")) { + return null; + } + + // The modal is a direct sibling of the browser element. + // See tabbrowser.xml's getTabModalPromptBox. + let modalElements = br.parentNode.getElementsByTagName("tabmodalprompt"); + + return br.tabModalPromptBox.getPrompt(modalElements[0]); + } + + /** + * Close the current window. + * + * @return {Promise} + * A promise which is resolved when the current window has been closed. + */ + async closeWindow() { + const destroyed = waitForObserverTopic("xul-window-destroyed", { + checkFn: () => this.window && this.window.closed, + }); + + this.window.close(); + + return destroyed; + } + + /** + * Focus the current window. + * + * @return {Promise} + * A promise which is resolved when the current window has been focused. + */ + async focusWindow() { + if (Services.focus.activeWindow != this.window) { + let activated = waitForEvent(this.window, "activate"); + let focused = waitForEvent(this.window, "focus", { capture: true }); + + this.window.focus(); + + await Promise.all([activated, focused]); + } + } + + /** + * Open a new browser window. + * + * @return {Promise} + * A promise resolving to the newly created chrome window. + */ + async openBrowserWindow(focus = false, isPrivate = false) { + switch (this.driver.appName) { + case "firefox": + // Open new browser window, and wait until it is fully loaded. + // Also wait for the window to be focused and activated to prevent a + // race condition when promptly focusing to the original window again. + const win = this.window.OpenBrowserWindow({ private: isPrivate }); + + const activated = waitForEvent(win, "activate"); + const focused = waitForEvent(win, "focus", { capture: true }); + const startup = waitForObserverTopic( + "browser-delayed-startup-finished", + { + checkFn: subject => subject == win, + } + ); + + win.focus(); + await Promise.all([activated, focused, startup]); + + // The new window shouldn't get focused. As such set the + // focus back to the opening window. + if (!focus) { + await this.focusWindow(); + } + + return win; + + default: + throw new error.UnsupportedOperationError( + `openWindow() not supported in ${this.driver.appName}` + ); + } + } + + /** + * Close the current tab. + * + * @return {Promise} + * A promise which is resolved when the current tab has been closed. + * + * @throws UnsupportedOperationError + * If tab handling for the current application isn't supported. + */ + 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. + if ( + !this.tabBrowser || + !this.tabBrowser.tabs || + this.tabBrowser.tabs.length === 1 || + !this.tab + ) { + return this.closeWindow(); + } + + let destroyed = new MessageManagerDestroyedPromise(this.messageManager); + let tabClosed; + + switch (this.driver.appName) { + case "firefox": + tabClosed = waitForEvent(this.tab, "TabClose"); + this.tabBrowser.removeTab(this.tab); + break; + + default: + throw new error.UnsupportedOperationError( + `closeTab() not supported in ${this.driver.appName}` + ); + } + + return Promise.all([destroyed, tabClosed]); + } + + /** + * Open a new tab in the currently selected chrome window. + */ + async openTab(focus = false) { + let tab = null; + + switch (this.driver.appName) { + case "firefox": + const opened = waitForEvent(this.window, "TabOpen"); + this.window.BrowserOpenTab(); + 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) { + this.tabBrowser.selectedTab = this.tab; + } + + break; + + default: + throw new error.UnsupportedOperationError( + `openTab() not supported in ${this.driver.appName}` + ); + } + + return tab; + } + + /** + * Set the current tab. + * + * @param {number=} index + * Tab index to switch to. If the parameter is undefined, + * the currently selected tab will be used. + * @param {ChromeWindow=} window + * Switch to this window before selecting the tab. + * @param {boolean=} focus + * A boolean value which determins whether to focus + * the window. Defaults to true. + * + * @return {Tab} + * The selected tab. + * + * @throws UnsupportedOperationError + * If tab handling for the current application isn't supported. + */ + async switchToTab(index, window = undefined, focus = true) { + let currentTab = this.tabBrowser.selectedTab; + + if (window) { + this.window = window; + this.tabBrowser = browser.getTabBrowser(this.window); + } + + if (!this.tabBrowser) { + return null; + } + + if (typeof index == "undefined") { + this.tab = this.tabBrowser.selectedTab; + } else { + this.tab = this.tabBrowser.tabs[index]; + } + + if (focus && this.tab != currentTab) { + const tabSelected = waitForEvent(this.window, "TabSelect"); + this.tabBrowser.selectedTab = this.tab; + await tabSelected; + } + + // TODO(ato): Currently tied to curBrowser, but should be moved to + // WebElement when introduced by https://bugzil.la/1400256. + this.eventObserver = new WebElementEventTarget(this.messageManager); + + return this.tab; + } + + /** + * Registers a new frame, and sets its current frame id to this frame + * if it is not already assigned, and if a) we already have a session + * or b) we're starting a new session and it is the right start frame. + * + * @param {xul:browser} target + * The <xul:browser> that was the target of the originating message. + */ + register(target) { + if (!this.tabBrowser) { + return; + } + + // If we're setting up a new session on Firefox, we only process the + // registration for this frame if it belongs to the current tab. + if (!this.tab) { + this.switchToTab(); + } + + if (target === this.contentBrowser) { + // Note that browsing contexts can be swapped during navigation in which + // case this id would no longer match the target. See Bug 1680479. + const uid = target.browsingContext.id; + this.updateIdForBrowser(this.contentBrowser, uid); + } + } +}; + +/** + * Marionette representation of the {@link ChromeWindow} window state. + * + * @enum {string} + */ +const WindowState = { + Maximized: "maximized", + Minimized: "minimized", + Normal: "normal", + Fullscreen: "fullscreen", + + /** + * Converts {@link nsIDOMChromeWindow.windowState} to WindowState. + * + * @param {number} windowState + * Attribute from {@link nsIDOMChromeWindow.windowState}. + * + * @return {WindowState} + * JSON representation. + * + * @throws {TypeError} + * If <var>windowState</var> was unknown. + */ + from(windowState) { + switch (windowState) { + case 1: + return WindowState.Maximized; + + case 2: + return WindowState.Minimized; + + case 3: + return WindowState.Normal; + + case 4: + return WindowState.Fullscreen; + + default: + throw new TypeError(`Unknown window state: ${windowState}`); + } + }, +}; +this.WindowState = WindowState; diff --git a/testing/marionette/capabilities.js b/testing/marionette/capabilities.js new file mode 100644 index 0000000000..c7583c1baf --- /dev/null +++ b/testing/marionette/capabilities.js @@ -0,0 +1,715 @@ +/* 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 EXPORTED_SYMBOLS = [ + "Capabilities", + "PageLoadStrategy", + "Proxy", + "Timeouts", + "UnhandledPromptBehavior", +]; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + Preferences: "resource://gre/modules/Preferences.jsm", + + assert: "chrome://marionette/content/assert.js", + error: "chrome://marionette/content/error.js", + Log: "chrome://marionette/content/log.js", + pprint: "chrome://marionette/content/format.js", +}); + +XPCOMUtils.defineLazyGlobalGetters(this, ["URL"]); + +XPCOMUtils.defineLazyGetter(this, "appinfo", () => { + // Enable testing this module, as Services.appinfo.* is not available + // in xpcshell tests. + const appinfo = { name: "<missing>", version: "<missing>" }; + try { + appinfo.name = Services.appinfo.name.toLowerCase(); + } catch (e) {} + try { + appinfo.version = Services.appinfo.version; + } catch (e) {} + + return appinfo; +}); + +XPCOMUtils.defineLazyGetter(this, "logger", () => Log.get()); + +XPCOMUtils.defineLazyGetter(this, "remoteAgent", () => { + // The Remote Agent is currently not available on Android, and all + // release channels (bug 1606604), + try { + return Cc["@mozilla.org/remote/agent;1"].createInstance(Ci.nsIRemoteAgent); + } catch (e) { + logger.debug("Remote agent not available for this build and platform"); + return null; + } +}); + +/** Representation of WebDriver session timeouts. */ +class Timeouts { + constructor() { + // disabled + this.implicit = 0; + // five mintues + this.pageLoad = 300000; + // 30 seconds + this.script = 30000; + } + + toString() { + return "[object Timeouts]"; + } + + /** Marshals timeout durations to a JSON Object. */ + toJSON() { + return { + implicit: this.implicit, + pageLoad: this.pageLoad, + script: this.script, + }; + } + + static fromJSON(json) { + assert.object( + json, + pprint`Expected "timeouts" to be an object, got ${json}` + ); + let t = new Timeouts(); + + for (let [type, ms] of Object.entries(json)) { + switch (type) { + case "implicit": + t.implicit = assert.positiveInteger( + ms, + pprint`Expected ${type} to be a positive integer, got ${ms}` + ); + break; + + case "script": + if (ms !== null) { + assert.positiveInteger( + ms, + pprint`Expected ${type} to be a positive integer, got ${ms}` + ); + } + t.script = ms; + break; + + case "pageLoad": + t.pageLoad = assert.positiveInteger( + ms, + pprint`Expected ${type} to be a positive integer, got ${ms}` + ); + break; + + default: + throw new error.InvalidArgumentError("Unrecognised timeout: " + type); + } + } + + return t; + } +} + +/** + * Enum of page loading strategies. + * + * @enum + */ +const PageLoadStrategy = { + /** No page load strategy. Navigation will return immediately. */ + None: "none", + /** + * Eager, causing navigation to complete when the document reaches + * the <code>interactive</code> ready state. + */ + Eager: "eager", + /** + * Normal, causing navigation to return when the document reaches the + * <code>complete</code> ready state. + */ + Normal: "normal", +}; + +/** Proxy configuration object representation. */ +class Proxy { + /** @class */ + constructor() { + this.proxyType = null; + this.ftpProxy = null; + this.ftpProxyPort = null; + this.httpProxy = null; + this.httpProxyPort = null; + this.noProxy = null; + this.sslProxy = null; + this.sslProxyPort = null; + this.socksProxy = null; + this.socksProxyPort = null; + this.socksVersion = null; + this.proxyAutoconfigUrl = null; + } + + /** + * Sets Firefox proxy settings. + * + * @return {boolean} + * True if proxy settings were updated as a result of calling this + * function, or false indicating that this function acted as + * a no-op. + */ + init() { + switch (this.proxyType) { + case "autodetect": + Preferences.set("network.proxy.type", 4); + return true; + + case "direct": + Preferences.set("network.proxy.type", 0); + return true; + + case "manual": + Preferences.set("network.proxy.type", 1); + + if (this.ftpProxy) { + Preferences.set("network.proxy.ftp", this.ftpProxy); + if (Number.isInteger(this.ftpProxyPort)) { + Preferences.set("network.proxy.ftp_port", this.ftpProxyPort); + } + } + + if (this.httpProxy) { + Preferences.set("network.proxy.http", this.httpProxy); + if (Number.isInteger(this.httpProxyPort)) { + Preferences.set("network.proxy.http_port", this.httpProxyPort); + } + } + + if (this.sslProxy) { + Preferences.set("network.proxy.ssl", this.sslProxy); + if (Number.isInteger(this.sslProxyPort)) { + Preferences.set("network.proxy.ssl_port", this.sslProxyPort); + } + } + + if (this.socksProxy) { + Preferences.set("network.proxy.socks", this.socksProxy); + if (Number.isInteger(this.socksProxyPort)) { + Preferences.set("network.proxy.socks_port", this.socksProxyPort); + } + if (this.socksVersion) { + Preferences.set("network.proxy.socks_version", this.socksVersion); + } + } + + if (this.noProxy) { + Preferences.set( + "network.proxy.no_proxies_on", + this.noProxy.join(", ") + ); + } + return true; + + case "pac": + Preferences.set("network.proxy.type", 2); + Preferences.set( + "network.proxy.autoconfig_url", + this.proxyAutoconfigUrl + ); + return true; + + case "system": + Preferences.set("network.proxy.type", 5); + return true; + + default: + return false; + } + } + + /** + * @param {Object.<string, ?>} json + * JSON Object to unmarshal. + * + * @throws {InvalidArgumentError} + * When proxy configuration is invalid. + */ + static fromJSON(json) { + function stripBracketsFromIpv6Hostname(hostname) { + return hostname.includes(":") + ? hostname.replace(/[\[\]]/g, "") + : hostname; + } + + // Parse hostname and optional port from host + function fromHost(scheme, host) { + assert.string( + host, + pprint`Expected proxy "host" to be a string, got ${host}` + ); + + if (host.includes("://")) { + throw new error.InvalidArgumentError(`${host} contains a scheme`); + } + + let url; + try { + // To parse the host a scheme has to be added temporarily. + // If the returned value for the port is an empty string it + // could mean no port or the default port for this scheme was + // specified. In such a case parse again with a different + // scheme to ensure we filter out the default port. + url = new URL("http://" + host); + if (url.port == "") { + url = new URL("https://" + host); + } + } catch (e) { + throw new error.InvalidArgumentError(e.message); + } + + let hostname = stripBracketsFromIpv6Hostname(url.hostname); + + // If the port hasn't been set, use the default port of + // the selected scheme (except for socks which doesn't have one). + let port = parseInt(url.port); + if (!Number.isInteger(port)) { + if (scheme === "socks") { + port = null; + } else { + port = Services.io.getProtocolHandler(scheme).defaultPort; + } + } + + if ( + url.username != "" || + url.password != "" || + url.pathname != "/" || + url.search != "" || + url.hash != "" + ) { + throw new error.InvalidArgumentError( + `${host} was not of the form host[:port]` + ); + } + + return [hostname, port]; + } + + let p = new Proxy(); + if (typeof json == "undefined" || json === null) { + return p; + } + + assert.object(json, pprint`Expected "proxy" to be an object, got ${json}`); + + assert.in( + "proxyType", + json, + pprint`Expected "proxyType" in "proxy" object, got ${json}` + ); + p.proxyType = assert.string( + json.proxyType, + pprint`Expected "proxyType" to be a string, got ${json.proxyType}` + ); + + switch (p.proxyType) { + case "autodetect": + case "direct": + case "system": + break; + + case "pac": + p.proxyAutoconfigUrl = assert.string( + json.proxyAutoconfigUrl, + `Expected "proxyAutoconfigUrl" to be a string, ` + + pprint`got ${json.proxyAutoconfigUrl}` + ); + break; + + case "manual": + if (typeof json.ftpProxy != "undefined") { + [p.ftpProxy, p.ftpProxyPort] = fromHost("ftp", json.ftpProxy); + } + if (typeof json.httpProxy != "undefined") { + [p.httpProxy, p.httpProxyPort] = fromHost("http", json.httpProxy); + } + if (typeof json.sslProxy != "undefined") { + [p.sslProxy, p.sslProxyPort] = fromHost("https", json.sslProxy); + } + if (typeof json.socksProxy != "undefined") { + [p.socksProxy, p.socksProxyPort] = fromHost("socks", json.socksProxy); + p.socksVersion = assert.positiveInteger( + json.socksVersion, + pprint`Expected "socksVersion" to be a positive integer, got ${json.socksVersion}` + ); + } + if (typeof json.noProxy != "undefined") { + let entries = assert.array( + json.noProxy, + pprint`Expected "noProxy" to be an array, got ${json.noProxy}` + ); + p.noProxy = entries.map(entry => { + assert.string( + entry, + pprint`Expected "noProxy" entry to be a string, got ${entry}` + ); + return stripBracketsFromIpv6Hostname(entry); + }); + } + break; + + default: + throw new error.InvalidArgumentError( + `Invalid type of proxy: ${p.proxyType}` + ); + } + + return p; + } + + /** + * @return {Object.<string, (number|string)>} + * JSON serialisation of proxy object. + */ + toJSON() { + function addBracketsToIpv6Hostname(hostname) { + return hostname.includes(":") ? `[${hostname}]` : hostname; + } + + function toHost(hostname, port) { + if (!hostname) { + return null; + } + + // Add brackets around IPv6 addresses + hostname = addBracketsToIpv6Hostname(hostname); + + if (port != null) { + return `${hostname}:${port}`; + } + + return hostname; + } + + let excludes = this.noProxy; + if (excludes) { + excludes = excludes.map(addBracketsToIpv6Hostname); + } + + return marshal({ + proxyType: this.proxyType, + ftpProxy: toHost(this.ftpProxy, this.ftpProxyPort), + httpProxy: toHost(this.httpProxy, this.httpProxyPort), + noProxy: excludes, + sslProxy: toHost(this.sslProxy, this.sslProxyPort), + socksProxy: toHost(this.socksProxy, this.socksProxyPort), + socksVersion: this.socksVersion, + proxyAutoconfigUrl: this.proxyAutoconfigUrl, + }); + } + + toString() { + return "[object Proxy]"; + } +} + +/** + * Enum of unhandled prompt behavior. + * + * @enum + */ +const UnhandledPromptBehavior = { + /** All simple dialogs encountered should be accepted. */ + Accept: "accept", + /** + * All simple dialogs encountered should be accepted, and an error + * returned that the dialog was handled. + */ + AcceptAndNotify: "accept and notify", + /** All simple dialogs encountered should be dismissed. */ + Dismiss: "dismiss", + /** + * All simple dialogs encountered should be dismissed, and an error + * returned that the dialog was handled. + */ + DismissAndNotify: "dismiss and notify", + /** All simple dialogs encountered should be left to the user to handle. */ + Ignore: "ignore", +}; + +/** WebDriver session capabilities representation. */ +class Capabilities extends Map { + /** @class */ + constructor() { + super([ + // webdriver + ["browserName", getWebDriverBrowserName()], + ["browserVersion", appinfo.version], + ["platformName", getWebDriverPlatformName()], + ["platformVersion", Services.sysinfo.getProperty("version")], + ["acceptInsecureCerts", false], + ["pageLoadStrategy", PageLoadStrategy.Normal], + ["proxy", new Proxy()], + ["setWindowRect", !Services.androidBridge], + ["timeouts", new Timeouts()], + ["strictFileInteractability", false], + ["unhandledPromptBehavior", UnhandledPromptBehavior.DismissAndNotify], + + // features + ["rotatable", appinfo.name == "B2G"], + + // proprietary + ["moz:accessibilityChecks", false], + ["moz:buildID", Services.appinfo.appBuildID], + ["moz:debuggerAddress", remoteAgent?.debuggerAddress || null], + [ + "moz:headless", + Cc["@mozilla.org/gfx/info;1"].getService(Ci.nsIGfxInfo).isHeadless, + ], + ["moz:processID", Services.appinfo.processID], + ["moz:profile", maybeProfile()], + [ + "moz:shutdownTimeout", + Services.prefs.getIntPref("toolkit.asyncshutdown.crash_timeout"), + ], + ["moz:useNonSpecCompliantPointerOrigin", false], + ["moz:webdriverClick", true], + ]); + } + + /** + * @param {string} key + * Capability key. + * @param {(string|number|boolean)} value + * JSON-safe capability value. + */ + set(key, value) { + if (key === "timeouts" && !(value instanceof Timeouts)) { + throw new TypeError(); + } else if (key === "proxy" && !(value instanceof Proxy)) { + throw new TypeError(); + } + + return super.set(key, value); + } + + toString() { + return "[object Capabilities]"; + } + + /** + * JSON serialisation of capabilities object. + * + * @return {Object.<string, ?>} + */ + toJSON() { + let marshalled = marshal(this); + marshalled.timeouts = super.get("timeouts"); + return marshalled; + } + + /** + * Unmarshal a JSON object representation of WebDriver capabilities. + * + * @param {Object.<string, *>=} json + * WebDriver capabilities. + * + * @return {Capabilities} + * Internal representation of WebDriver capabilities. + */ + static fromJSON(json) { + if (typeof json == "undefined" || json === null) { + json = {}; + } + assert.object( + json, + pprint`Expected "capabilities" to be an object, got ${json}"` + ); + + return Capabilities.match_(json); + } + + // Matches capabilities as described by WebDriver. + static match_(json = {}) { + let matched = new Capabilities(); + + for (let [k, v] of Object.entries(json)) { + switch (k) { + case "acceptInsecureCerts": + assert.boolean(v, pprint`Expected ${k} to be a boolean, got ${v}`); + break; + + case "pageLoadStrategy": + assert.string(v, pprint`Expected ${k} to be a string, got ${v}`); + if (!Object.values(PageLoadStrategy).includes(v)) { + throw new error.InvalidArgumentError( + "Unknown page load strategy: " + v + ); + } + break; + + case "proxy": + v = Proxy.fromJSON(v); + break; + + case "setWindowRect": + assert.boolean(v, pprint`Expected ${k} to be boolean, got ${v}`); + if (!Services.androidBridge && !v) { + throw new error.InvalidArgumentError( + "setWindowRect cannot be disabled" + ); + } else if (Services.androidBridge && v) { + throw new error.InvalidArgumentError( + "setWindowRect is only supported on desktop" + ); + } + break; + + case "timeouts": + v = Timeouts.fromJSON(v); + break; + + case "strictFileInteractability": + v = assert.boolean(v); + break; + + case "unhandledPromptBehavior": + assert.string(v, pprint`Expected ${k} to be a string, got ${v}`); + if (!Object.values(UnhandledPromptBehavior).includes(v)) { + throw new error.InvalidArgumentError( + `Unknown unhandled prompt behavior: ${v}` + ); + } + break; + + case "moz:accessibilityChecks": + assert.boolean(v, pprint`Expected ${k} to be boolean, got ${v}`); + break; + + case "moz:useNonSpecCompliantPointerOrigin": + assert.boolean(v, pprint`Expected ${k} to be boolean, got ${v}`); + break; + + case "moz:webdriverClick": + assert.boolean(v, pprint`Expected ${k} to be boolean, got ${v}`); + break; + + // Don't set the value because it's only used to return the address + // of the Remote Agent's debugger (HTTP server). + case "moz:debuggerAddress": + continue; + } + + matched.set(k, v); + } + + return matched; + } +} + +this.Capabilities = Capabilities; +this.PageLoadStrategy = PageLoadStrategy; +this.Proxy = Proxy; +this.Timeouts = Timeouts; +this.UnhandledPromptBehavior = UnhandledPromptBehavior; + +function getWebDriverBrowserName() { + // Similar to chromedriver which reports "chrome" as browser name for all + // WebView apps, we will report "firefox" for all GeckoView apps. + if (Services.androidBridge) { + return "firefox"; + } + + return appinfo.name; +} + +function getWebDriverPlatformName() { + let name = Services.sysinfo.getProperty("name"); + + if (Services.androidBridge) { + return "android"; + } + + switch (name) { + case "Windows_NT": + return "windows"; + + case "Darwin": + return "mac"; + + default: + return name.toLowerCase(); + } +} + +// Specialisation of |JSON.stringify| that produces JSON-safe object +// literals, dropping empty objects and entries which values are undefined +// or null. Objects are allowed to produce their own JSON representations +// by implementing a |toJSON| function. +function marshal(obj) { + let rv = Object.create(null); + + function* iter(mapOrObject) { + if (mapOrObject instanceof Map) { + for (const [k, v] of mapOrObject) { + yield [k, v]; + } + } else { + for (const k of Object.keys(mapOrObject)) { + yield [k, mapOrObject[k]]; + } + } + } + + for (let [k, v] of iter(obj)) { + // Skip empty values when serialising to JSON. + if (typeof v == "undefined" || v === null) { + continue; + } + + // Recursively marshal objects that are able to produce their own + // JSON representation. + if (typeof v.toJSON == "function") { + v = marshal(v.toJSON()); + + // Or do the same for object literals. + } else if (isObject(v)) { + v = marshal(v); + } + + // And finally drop (possibly marshaled) objects which have no + // entries. + if (!isObjectEmpty(v)) { + rv[k] = v; + } + } + + return rv; +} + +function isObject(obj) { + return Object.prototype.toString.call(obj) == "[object Object]"; +} + +function isObjectEmpty(obj) { + return isObject(obj) && Object.keys(obj).length === 0; +} + +// Services.dirsvc is not accessible from content frame scripts, +// but we should not panic about that. +function maybeProfile() { + try { + return Services.dirsvc.get("ProfD", Ci.nsIFile).path; + } catch (e) { + return "<protected>"; + } +} diff --git a/testing/marionette/capture.js b/testing/marionette/capture.js new file mode 100644 index 0000000000..6746baa56c --- /dev/null +++ b/testing/marionette/capture.js @@ -0,0 +1,205 @@ +/* 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 EXPORTED_SYMBOLS = ["capture"]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + Log: "chrome://marionette/content/log.js", +}); + +XPCOMUtils.defineLazyGetter(this, "logger", () => Log.get()); +XPCOMUtils.defineLazyGlobalGetters(this, ["crypto"]); + +const CONTEXT_2D = "2d"; +const BG_COLOUR = "rgb(255,255,255)"; +const MAX_CANVAS_DIMENSION = 32767; +const MAX_CANVAS_AREA = 472907776; +const PNG_MIME = "image/png"; +const XHTML_NS = "http://www.w3.org/1999/xhtml"; + +/** + * Provides primitives to capture screenshots. + * + * @namespace + */ +this.capture = {}; + +capture.Format = { + Base64: 0, + Hash: 1, +}; + +/** + * Draw a rectangle off the framebuffer. + * + * @param {DOMWindow} win + * The DOM window used for the framebuffer, and providing the interfaces + * for creating an HTMLCanvasElement. + * @param {number} left + * The left, X axis offset of the rectangle. + * @param {number} top + * The top, Y axis offset of the rectangle. + * @param {number} width + * The width dimension of the rectangle to paint. + * @param {number} height + * The height dimension of the rectangle to paint. + * @param {HTMLCanvasElement=} canvas + * Optional canvas to reuse for the screenshot. + * @param {number=} flags + * Optional integer representing flags to pass to drawWindow; these + * are defined on CanvasRenderingContext2D. + * @param {number=} dX + * Horizontal offset between the browser window and content area. Defaults to 0. + * @param {number=} dY + * Vertical offset between the browser window and content area. Defaults to 0. + * @param {boolean=} readback + * If true, read back a snapshot of the pixel data currently in the + * compositor/window. Defaults to false. + * + * @return {HTMLCanvasElement} + * The canvas on which the selection from the window's framebuffer + * has been painted on. + */ +capture.canvas = async function( + win, + browsingContext, + left, + top, + width, + height, + { canvas = null, flags = null, dX = 0, dY = 0, readback = false } = {} +) { + const scale = win.devicePixelRatio; + + let canvasHeight = height * scale; + let canvasWidth = width * scale; + + // Cap the screenshot size for width and height at 2^16 pixels, + // which is the maximum allowed canvas size. Higher dimensions will + // trigger exceptions in Gecko. + if (canvasWidth > MAX_CANVAS_DIMENSION) { + logger.warn( + "Limiting screen capture width to maximum allowed " + + MAX_CANVAS_DIMENSION + + " pixels" + ); + width = Math.floor(MAX_CANVAS_DIMENSION / scale); + canvasWidth = width * scale; + } + + if (canvasHeight > MAX_CANVAS_DIMENSION) { + logger.warn( + "Limiting screen capture height to maximum allowed " + + MAX_CANVAS_DIMENSION + + " pixels" + ); + height = Math.floor(MAX_CANVAS_DIMENSION / scale); + canvasHeight = height * scale; + } + + // If the area is larger, reduce the height to keep the full width. + if (canvasWidth * canvasHeight > MAX_CANVAS_AREA) { + logger.warn( + "Limiting screen capture area to maximum allowed " + + MAX_CANVAS_AREA + + " pixels" + ); + height = Math.floor(MAX_CANVAS_AREA / (canvasWidth * scale)); + canvasHeight = height * scale; + } + + if (canvas === null) { + canvas = win.document.createElementNS(XHTML_NS, "canvas"); + canvas.width = canvasWidth; + canvas.height = canvasHeight; + } + + const ctx = canvas.getContext(CONTEXT_2D); + + if (readback) { + if (flags === null) { + flags = + ctx.DRAWWINDOW_DRAW_CARET | + ctx.DRAWWINDOW_DRAW_VIEW | + ctx.DRAWWINDOW_USE_WIDGET_LAYERS; + } + + // drawWindow doesn't take scaling into account. + ctx.scale(scale, scale); + ctx.drawWindow(win, left + dX, top + dY, width, height, BG_COLOUR, flags); + } else { + let rect = new DOMRect(left, top, width, height); + let snapshot = await browsingContext.currentWindowGlobal.drawSnapshot( + rect, + scale, + BG_COLOUR + ); + + ctx.drawImage(snapshot, 0, 0); + + // Bug 1574935 - Huge dimensions can trigger an OOM because multiple copies + // of the bitmap will exist in memory. Force the removal of the snapshot + // because it is no longer needed. + snapshot.close(); + } + + return canvas; +}; + +/** + * Encode the contents of an HTMLCanvasElement to a Base64 encoded string. + * + * @param {HTMLCanvasElement} canvas + * The canvas to encode. + * + * @return {string} + * A Base64 encoded string. + */ +capture.toBase64 = function(canvas) { + let u = canvas.toDataURL(PNG_MIME); + return u.substring(u.indexOf(",") + 1); +}; + +/** + * Hash the contents of an HTMLCanvasElement to a SHA-256 hex digest. + * + * @param {HTMLCanvasElement} canvas + * The canvas to encode. + * + * @return {string} + * A hex digest of the SHA-256 hash of the base64 encoded string. + */ +capture.toHash = function(canvas) { + let u = capture.toBase64(canvas); + let buffer = new TextEncoder("utf-8").encode(u); + return crypto.subtle.digest("SHA-256", buffer).then(hash => hex(hash)); +}; + +/** + * Convert buffer into to hex. + * + * @param {ArrayBuffer} buffer + * The buffer containing the data to convert to hex. + * + * @return {string} + * A hex digest of the input buffer. + */ +function hex(buffer) { + let hexCodes = []; + let view = new DataView(buffer); + for (let i = 0; i < view.byteLength; i += 4) { + let value = view.getUint32(i); + let stringValue = value.toString(16); + let padding = "00000000"; + let paddedValue = (padding + stringValue).slice(-padding.length); + hexCodes.push(paddedValue); + } + return hexCodes.join(""); +} diff --git a/testing/marionette/cert.js b/testing/marionette/cert.js new file mode 100644 index 0000000000..2c0ca554a7 --- /dev/null +++ b/testing/marionette/cert.js @@ -0,0 +1,66 @@ +/* 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 EXPORTED_SYMBOLS = ["allowAllCerts"]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + Preferences: "resource://gre/modules/Preferences.jsm", +}); + +XPCOMUtils.defineLazyServiceGetter( + this, + "sss", + "@mozilla.org/ssservice;1", + "nsISiteSecurityService" +); + +XPCOMUtils.defineLazyServiceGetter( + this, + "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 */ +this.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 + Preferences.set(HSTS_PRELOAD_LIST_PREF, false); + Preferences.set(CERT_PINNING_ENFORCEMENT_PREF, 0); + + certOverrideService.setDisableAllSecurityChecksAndLetAttackersInterceptMyData( + true + ); +}; + +/** + * Enable all security check. + */ +allowAllCerts.disable = function() { + certOverrideService.setDisableAllSecurityChecksAndLetAttackersInterceptMyData( + false + ); + + Preferences.reset(HSTS_PRELOAD_LIST_PREF); + Preferences.reset(CERT_PINNING_ENFORCEMENT_PREF); + + // clear collected HSTS and HPKP state + // through the site security service + sss.clearAll(); + sss.clearPreloads(); +}; diff --git a/testing/marionette/chrome/test.xhtml b/testing/marionette/chrome/test.xhtml new file mode 100644 index 0000000000..d878fb13b1 --- /dev/null +++ b/testing/marionette/chrome/test.xhtml @@ -0,0 +1,27 @@ +<?xml version="1.0"?> +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> + +<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?> + +<!DOCTYPE window [ +]> +<window id="winTest" title="Title Test" windowtype="Test Type" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + <dialog id="dia" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <vbox id="things"> + <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> + + <iframe id="iframe" name="iframename" src="chrome://marionette/content/test2.xhtml"/> + <iframe id="iframe" name="iframename" src="chrome://marionette/content/test_nested_iframe.xhtml"/> + <hbox id="testXulBox"/> + <browser id='aBrowser' src="chrome://marionette/content/test2.xhtml"/> + </dialog> +</window> diff --git a/testing/marionette/chrome/test2.xhtml b/testing/marionette/chrome/test2.xhtml new file mode 100644 index 0000000000..d6b72dab45 --- /dev/null +++ b/testing/marionette/chrome/test2.xhtml @@ -0,0 +1,20 @@ +<?xml version="1.0"?> +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> + +<!DOCTYPE window [ +]> + +<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> +<dialog id="dia"> + + <vbox id="things"> + <checkbox id="testBox" label="box" /> + <input xmlns="http://www.w3.org/1999/xhtml" id="textInput" size="6" value="test" label="input" /> + <input xmlns="http://www.w3.org/1999/xhtml" id="textInput2" size="6" value="test" label="input" /> + <input xmlns="http://www.w3.org/1999/xhtml" id="textInput3" class="asdf" size="6" value="test" label="input" /> + </vbox> + +</dialog> +</window> diff --git a/testing/marionette/chrome/test_dialog.dtd b/testing/marionette/chrome/test_dialog.dtd new file mode 100644 index 0000000000..414cb0ee81 --- /dev/null +++ b/testing/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/testing/marionette/chrome/test_dialog.properties b/testing/marionette/chrome/test_dialog.properties new file mode 100644 index 0000000000..ade7b6bde3 --- /dev/null +++ b/testing/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/testing/marionette/chrome/test_dialog.xhtml b/testing/marionette/chrome/test_dialog.xhtml new file mode 100644 index 0000000000..bd2d7bb75b --- /dev/null +++ b/testing/marionette/chrome/test_dialog.xhtml @@ -0,0 +1,37 @@ +<?xml version="1.0"?> +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> + +<!DOCTYPE testdialog [ +<!ENTITY % dialogDTD SYSTEM "chrome://marionette/content/test_dialog.dtd" > +%dialogDTD; +]> + +<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?> + + +<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + title="&testDialog.title;"> +<dialog id="testDialog" + buttons="accept,cancel"> + + <vbox flex="1" style="min-width: 300px; min-height: 500px;"> + <label>&settings.label;</label> + <separator class="thin"/> + <richlistbox id="test-list" flex="1"> + <richlistitem id="item-choose" orient="horizontal" selected="true"> + <label id="choose-label" value="First Entry" flex="1"/> + <button id="choose-button" oncommand="" label="Choose..."/> + </richlistitem> + </richlistbox> + <separator class="thin"/> + <checkbox id="check-box" label="Test Mode 2" /> + <hbox align="center"> + <label id="text-box-label" control="text-box">Name:</label> + <input xmlns="http://www.w3.org/1999/xhtml" id="text-box" style="-moz-box-flex: 1;" /> + </hbox> + </vbox> + +</dialog> +</window> diff --git a/testing/marionette/chrome/test_menupopup.xhtml b/testing/marionette/chrome/test_menupopup.xhtml new file mode 100644 index 0000000000..5d8902f011 --- /dev/null +++ b/testing/marionette/chrome/test_menupopup.xhtml @@ -0,0 +1,30 @@ +<?xml version="1.0"?> +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> + +<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?> + +<!DOCTYPE window [ +]> +<window id="test-window" xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + <popupset id="options-popupset"> + <menupopup id="options-menupopup" position="before_end"> + <menuitem id="option-enabled" + type="checkbox" + label="enabled"/> + <menuitem id="option-hidden" + type="checkbox" + label="hidden" + hidden="true"/> + <menuitem id="option-disabled" + type="checkbox" + label="disabled" + disabled="true"/> + </menupopup> + </popupset> + <hbox align="center" style="height: 300px;"> + <button id="options-button" + popup="options-menupopup" label="button"/> + </hbox> +</window> diff --git a/testing/marionette/chrome/test_nested_iframe.xhtml b/testing/marionette/chrome/test_nested_iframe.xhtml new file mode 100644 index 0000000000..1d0edcc65b --- /dev/null +++ b/testing/marionette/chrome/test_nested_iframe.xhtml @@ -0,0 +1,9 @@ +<?xml version="1.0"?> +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> + +<!DOCTYPE window [ +]> + + <iframe id="iframe" name="iframename" src="test2.xhtml"/> diff --git a/testing/marionette/client/MANIFEST.in b/testing/marionette/client/MANIFEST.in new file mode 100644 index 0000000000..cf628b039c --- /dev/null +++ b/testing/marionette/client/MANIFEST.in @@ -0,0 +1,2 @@ +exclude MANIFEST.in +include requirements.txt diff --git a/testing/marionette/client/docs/Makefile b/testing/marionette/client/docs/Makefile new file mode 100644 index 0000000000..f3d89d6d47 --- /dev/null +++ b/testing/marionette/client/docs/Makefile @@ -0,0 +1,153 @@ +# Makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +PAPER = +BUILDDIR = _build + +# Internal variables. +PAPEROPT_a4 = -D latex_paper_size=a4 +PAPEROPT_letter = -D latex_paper_size=letter +ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . +# the i18n builder cannot share the environment and doctrees with the others +I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . + +.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext + +help: + @echo "Please use \`make <target>' where <target> is one of" + @echo " html to make standalone HTML files" + @echo " dirhtml to make HTML files named index.html in directories" + @echo " singlehtml to make a single large HTML file" + @echo " pickle to make pickle files" + @echo " json to make JSON files" + @echo " htmlhelp to make HTML files and a HTML help project" + @echo " qthelp to make HTML files and a qthelp project" + @echo " devhelp to make HTML files and a Devhelp project" + @echo " epub to make an epub" + @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" + @echo " latexpdf to make LaTeX files and run them through pdflatex" + @echo " text to make text files" + @echo " man to make manual pages" + @echo " texinfo to make Texinfo files" + @echo " info to make Texinfo files and run them through makeinfo" + @echo " gettext to make PO message catalogs" + @echo " changes to make an overview of all changed/added/deprecated items" + @echo " linkcheck to check all external links for integrity" + @echo " doctest to run all doctests embedded in the documentation (if enabled)" + +clean: + -rm -rf $(BUILDDIR)/* + +html: + $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." + +dirhtml: + $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml + @echo + @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." + +singlehtml: + $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml + @echo + @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." + +pickle: + $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle + @echo + @echo "Build finished; now you can process the pickle files." + +json: + $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json + @echo + @echo "Build finished; now you can process the JSON files." + +htmlhelp: + $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp + @echo + @echo "Build finished; now you can run HTML Help Workshop with the" \ + ".hhp project file in $(BUILDDIR)/htmlhelp." + +qthelp: + $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp + @echo + @echo "Build finished; now you can run "qcollectiongenerator" with the" \ + ".qhcp project file in $(BUILDDIR)/qthelp, like this:" + @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/MarionettePythonClient.qhcp" + @echo "To view the help file:" + @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/MarionettePythonClient.qhc" + +devhelp: + $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp + @echo + @echo "Build finished." + @echo "To view the help file:" + @echo "# mkdir -p $$HOME/.local/share/devhelp/MarionettePythonClient" + @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/MarionettePythonClient" + @echo "# devhelp" + +epub: + $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub + @echo + @echo "Build finished. The epub file is in $(BUILDDIR)/epub." + +latex: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo + @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." + @echo "Run \`make' in that directory to run these through (pdf)latex" \ + "(use \`make latexpdf' here to do that automatically)." + +latexpdf: + $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex + @echo "Running LaTeX files through pdflatex..." + $(MAKE) -C $(BUILDDIR)/latex all-pdf + @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." + +text: + $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text + @echo + @echo "Build finished. The text files are in $(BUILDDIR)/text." + +man: + $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man + @echo + @echo "Build finished. The manual pages are in $(BUILDDIR)/man." + +texinfo: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo + @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." + @echo "Run \`make' in that directory to run these through makeinfo" \ + "(use \`make info' here to do that automatically)." + +info: + $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo + @echo "Running Texinfo files through makeinfo..." + make -C $(BUILDDIR)/texinfo info + @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." + +gettext: + $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale + @echo + @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." + +changes: + $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes + @echo + @echo "The overview file is in $(BUILDDIR)/changes." + +linkcheck: + $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck + @echo + @echo "Link check complete; look for any errors in the above output " \ + "or in $(BUILDDIR)/linkcheck/output.txt." + +doctest: + $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest + @echo "Testing of doctests in the sources finished, look at the " \ + "results in $(BUILDDIR)/doctest/output.txt." diff --git a/testing/marionette/client/docs/advanced/actions.rst b/testing/marionette/client/docs/advanced/actions.rst new file mode 100644 index 0000000000..c767bdecdc --- /dev/null +++ b/testing/marionette/client/docs/advanced/actions.rst @@ -0,0 +1,21 @@ +Actions +======= + +.. py:currentmodule:: marionette_driver.marionette + +Action Sequences +---------------- + +:class:`Actions` are designed as a way to simulate user input like a keyboard +or a pointer device as closely as possible. For multiple interactions an +action sequence can be used:: + + element = marionette.find_element("id", "input") + element.click() + + key_chain = self.marionette.actions.sequence("key", "keyboard1") + key_chain.send_keys("fooba").pause(100).key_down("r").perform() + +This will simulate entering "fooba" into the input field, waiting for 100ms, +and pressing the key "r". The pause is optional in this case, but can be useful +for simulating delays typical to a users behaviour. diff --git a/testing/marionette/client/docs/advanced/debug.rst b/testing/marionette/client/docs/advanced/debug.rst new file mode 100644 index 0000000000..895009ef7f --- /dev/null +++ b/testing/marionette/client/docs/advanced/debug.rst @@ -0,0 +1,35 @@ +Debugging +========= + +.. py:currentmodule:: marionette_driver.marionette + +Sometimes when working with Marionette you'll run into unexpected behaviour and +need to do some debugging. This page outlines some of the Marionette methods +that can be useful to you. + +Please note that the best tools for debugging are the `ones that ship with +Gecko`_. This page doesn't describe how to use those with Marionette. Also see +a related topic about `using the debugger with Marionette`_ on MDN. + +.. _ones that ship with Gecko: https://developer.mozilla.org/en-US/docs/Tools +.. _using the debugger with Marionette: https://developer.mozilla.org/en-US/docs/Marionette/Debugging + +Seeing What's on the Page +~~~~~~~~~~~~~~~~~~~~~~~~~ + +Sometimes it's difficult to tell what is actually on the page that is being +manipulated. Either because it happens too fast, the window isn't big enough or +you are manipulating a remote server! There are two methods that can help you +out. The first is :func:`~Marionette.screenshot`:: + + marionette.screenshot() # takes screenshot of entire frame + elem = marionette.find_element(By.ID, 'some-div') + marionette.screenshot(elem) # takes a screenshot of only the given element + +Sometimes you just want to see the DOM layout. You can do this with the +:attr:`~Marionette.page_source` property. Note that the page source depends on +the context you are in:: + + print(marionette.page_source) + marionette.set_context('chrome') + print(marionette.page_source) diff --git a/testing/marionette/client/docs/advanced/findelement.rst b/testing/marionette/client/docs/advanced/findelement.rst new file mode 100644 index 0000000000..6f61fa5e25 --- /dev/null +++ b/testing/marionette/client/docs/advanced/findelement.rst @@ -0,0 +1,87 @@ +Finding Elements +================ +.. py:currentmodule:: marionette_driver.marionette + +One of the most common and yet often most difficult tasks in Marionette is +finding a DOM element on a webpage or in the chrome UI. Marionette provides +several different search strategies to use when finding elements. All search +strategies work with both :func:`~Marionette.find_element` and +:func:`~Marionette.find_elements`, though some strategies are not implemented +in chrome scope. + +In the event that more than one element is matched by the query, +:func:`~Marionette.find_element` will only return the first element found. In +the event that no elements are matched by the query, +:func:`~Marionette.find_element` will raise `NoSuchElementException` while +:func:`~Marionette.find_elements` will return an empty list. + +Search Strategies +----------------- + +Search strategies are defined in the :class:`By` class:: + + from marionette_driver import By + print(By.ID) + +The strategies are: + +* `id` - The easiest way to find an element is to refer to its id directly:: + + container = client.find_element(By.ID, 'container') + +* `class name` - To find elements belonging to a certain class, use `class name`:: + + buttons = client.find_elements(By.CLASS_NAME, 'button') + +* `css selector` - It's also possible to find elements using a `css selector`_:: + + container_buttons = client.find_elements(By.CSS_SELECTOR, '#container .buttons') + +* `name` - Find elements by their name attribute (not implemented in chrome + scope):: + + form = client.find_element(By.NAME, 'signup') + +* `tag name` - To find all the elements with a given tag, use `tag name`:: + + paragraphs = client.find_elements(By.TAG_NAME, 'p') + +* `link text` - A convenience strategy for finding link elements by their + innerHTML (not implemented in chrome scope):: + + link = client.find_element(By.LINK_TEXT, 'Click me!') + +* `partial link text` - Same as `link text` except substrings of the innerHTML + are matched (not implemented in chrome scope):: + + link = client.find_element(By.PARTIAL_LINK_TEXT, 'Clic') + +* `xpath` - Find elements using an xpath_ query:: + + elem = client.find_element(By.XPATH, './/*[@id="foobar"') + +.. _css selector: https://developer.mozilla.org/en-US/docs/Web/Guide/CSS/Getting_Started/Selectors +.. _xpath: https://developer.mozilla.org/en-US/docs/Web/XPath + + + +Chaining Searches +----------------- + +In addition to the methods on the Marionette object, HTMLElement objects also +provide :func:`~HTMLElement.find_element` and :func:`~HTMLElement.find_elements` +methods. The difference is that only child nodes of the element will be searched. +Consider the following html snippet:: + + <div id="content"> + <span id="main"></span> + </div> + <div id="footer"></div> + +Doing the following will work:: + + client.find_element(By.ID, 'container').find_element(By.ID, 'main') + +But this will raise a `NoSuchElementException`:: + + client.find_element(By.ID, 'container').find_element(By.ID, 'footer') diff --git a/testing/marionette/client/docs/advanced/landing.rst b/testing/marionette/client/docs/advanced/landing.rst new file mode 100644 index 0000000000..0a44de63d7 --- /dev/null +++ b/testing/marionette/client/docs/advanced/landing.rst @@ -0,0 +1,13 @@ +Advanced Topics +=============== + +Here are a collection of articles explaining some of the more complicated +aspects of Marionette. + +.. toctree:: + :maxdepth: 1 + + findelement + stale + actions + debug diff --git a/testing/marionette/client/docs/advanced/stale.rst b/testing/marionette/client/docs/advanced/stale.rst new file mode 100644 index 0000000000..885083993c --- /dev/null +++ b/testing/marionette/client/docs/advanced/stale.rst @@ -0,0 +1,76 @@ +Dealing with Stale Elements +=========================== +.. py:currentmodule:: marionette_driver.marionette + +Marionette does not keep a live representation of the DOM saved. All it can do +is send commands to the Marionette server which queries the DOM on the client's +behalf. References to elements are also not passed from server to client. A +unique id is generated for each element that gets referenced and a mapping of +id to element object is stored on the server. When commands such as +:func:`~HTMLElement.click` are run, the client sends the element's id along +with the command. The server looks up the proper DOM element in its reference +table and executes the command on it. + +In practice this means that the DOM can change state and Marionette will never +know until it sends another query. For example, look at the following HTML:: + + <head> + <script type=text/javascript> + function addDiv() { + var div = document.createElement("div"); + document.getElementById("container").appendChild(div); + } + </script> + </head> + + <body> + <div id="container"> + </div> + <input id="button" type=button onclick="addDiv();"> + </body> + +Care needs to be taken as the DOM is being modified after the page has loaded. +The following code has a race condition:: + + button = client.find_element('id', 'button') + button.click() + assert len(client.find_elements('css selector', '#container div')) > 0 + + +Explicit Waiting and Expected Conditions +---------------------------------------- +.. py:currentmodule:: marionette_driver + +To avoid the above scenario, manual synchronisation is needed. Waits are used +to pause program execution until a given condition is true. This is a useful +technique to employ when documents load new content or change after +``Document.readyState``'s value changes to "complete". + +The :class:`Wait` helper class provided by Marionette avoids some of the +caveats of ``time.sleep(n)``. It will return immediately once the provided +condition evaluates to true. + +To avoid the race condition in the above example, one could do:: + + from marionette_driver import Wait + + button = client.find_element('id', 'button') + button.click() + + def find_divs(): + return client.find_elements('css selector', '#container div') + + divs = Wait(client).until(find_divs) + assert len(divs) > 0 + +This avoids the race condition. Because finding elements is a common condition +to wait for, it is built in to Marionette. Instead of the above, you could +write:: + + from marionette_driver import Wait + + button = client.find_element('id', 'button') + button.click() + assert len(Wait(client).until(expected.elements_present('css selector', '#container div'))) > 0 + +For a full list of built-in conditions, see :mod:`~marionette_driver.expected`. diff --git a/testing/marionette/client/docs/basics.rst b/testing/marionette/client/docs/basics.rst new file mode 100644 index 0000000000..57f10cd69a --- /dev/null +++ b/testing/marionette/client/docs/basics.rst @@ -0,0 +1,185 @@ +.. py:currentmodule:: marionette_driver.marionette + +Marionette Python Client +======================== + +The Marionette Python client library allows you to remotely control a +Gecko-based browser or device which is running a Marionette_ +server. This includes Firefox Desktop and Firefox for Android. + +The Marionette server is built directly into Gecko and can be started by +passing in a command line option to Gecko, or by using a Marionette-enabled +build. The server listens for connections from various clients. Clients can +then control Gecko by sending commands to the server. + +This is the official Python client for Marionette. There also exists a +`NodeJS client`_ maintained by the Firefox OS automation team. + +.. _Marionette: https://developer.mozilla.org/en-US/docs/Marionette +.. _NodeJS client: https://github.com/mozilla-b2g/gaia/tree/master/tests/jsmarionette + +Getting the Client +------------------ + +The Python client is officially supported. To install it, first make sure you +have `pip installed`_ then run: + +.. parsed-literal:: + pip install marionette_driver + +It's highly recommended to use virtualenv_ when installing Marionette to avoid +package conflicts and other general nastiness. + +You should now be ready to start using Marionette. The best way to learn is to +play around with it. Start a `Marionette-enabled instance of Firefox`_, fire up +a python shell and follow along with the +:doc:`interactive tutorial <interactive>`! + +.. _pip installed: https://pip.pypa.io/en/latest/installing.html +.. _virtualenv: http://virtualenv.readthedocs.org/en/latest/ +.. _Marionette-enabled instance of Firefox: https://developer.mozilla.org/en-US/docs/Mozilla/QA/Marionette/Builds + +Using the Client for Testing +---------------------------- + +Please visit the `Marionette Tests`_ section on MDN for information regarding +testing with Marionette. + +.. _Marionette Tests: https://developer.mozilla.org/en/Marionette/Tests + +Session Management +------------------ +A session is a single instance of a Marionette client connected to a Marionette +server. Before you can start executing commands, you need to start a session +with :func:`start_session() <Marionette.start_session>`: + +.. parsed-literal:: + from marionette_driver.marionette import Marionette + + client = Marionette('127.0.0.1', port=2828) + client.start_session() + +This returns a session id and an object listing the capabilities of the +Marionette server. For example, a server running on Firefox Desktop will +have some features which a server running from Firefox Android won't. +It's also possible to access the capabilities using the +:attr:`~Marionette.session_capabilities` attribute. After finishing with a +session, you can delete it with :func:`~Marionette.delete_session()`. Note that +this will also happen automatically when the Marionette object is garbage +collected. + +Context Management +------------------ +Commands can only be executed in a single window, frame and scope at a time. In +order to run commands elsewhere, it's necessary to explicitly switch to the +appropriate context. + +Use :func:`~Marionette.switch_to_window` to execute commands in the context of a +new window: + +.. parsed-literal:: + original_window = client.current_window_handle + for handle in client.window_handles: + if handle != original_window: + client.switch_to_window(handle) + print("Switched to window with '{}' loaded.".format(client.get_url())) + client.switch_to_window(original_window) + +Similarly, use :func:`~Marionette.switch_to_frame` to execute commands in the +context of a new frame (e.g an <iframe> element): + +.. parsed-literal:: + iframe = client.find_element(By.TAG_NAME, 'iframe') + client.switch_to_frame(iframe) + +Finally Marionette can switch between `chrome` and `content` scope. Chrome is a +privileged scope where you can access things like the Firefox UI itself. +Content scope is where things like webpages live. You can switch between +`chrome` and `content` using the :func:`~Marionette.set_context` and :func:`~Marionette.using_context` functions: + +.. parsed-literal:: + client.set_context(client.CONTEXT_CONTENT) + # content scope + with client.using_context(client.CONTEXT_CHROME): + #chrome scope + ... do stuff ... + # content scope restored + + +Navigation +---------- + +Use :func:`~Marionette.navigate` to open a new website. It's also possible to +move through the back/forward cache using :func:`~Marionette.go_forward` and +:func:`~Marionette.go_back` respectively. To retrieve the currently +open website, use :func:`~Marionette.get_url`: + +.. parsed-literal:: + url = 'http://mozilla.org' + client.navigate(url) + client.go_back() + client.go_forward() + assert client.get_url() == url + + +DOM Elements +------------ + +In order to inspect or manipulate actual DOM elements, they must first be found +using the :func:`~Marionette.find_element` or :func:`~Marionette.find_elements` +methods: + +.. parsed-literal:: + from marionette_driver.marionette import HTMLElement + element = client.find_element(By.ID, 'my-id') + assert type(element) == HTMLElement + elements = client.find_elements(By.TAG_NAME, 'a') + assert type(elements) == list + +For a full list of valid search strategies, see :doc:`advanced/findelement`. + +Now that an element has been found, it's possible to manipulate it: + +.. parsed-literal:: + element.click() + element.send_keys('hello!') + print(element.get_attribute('style')) + +For the full list of possible commands, see the :class:`HTMLElement` +reference. + +Be warned that a reference to an element object can become stale if it was +modified or removed from the document. See :doc:`advanced/stale` for tips +on working around this limitation. + +Script Execution +---------------- + +Sometimes Marionette's provided APIs just aren't enough and it is necessary to +run arbitrary javascript. This is accomplished with the +:func:`~Marionette.execute_script` and :func:`~Marionette.execute_async_script` +functions. They accomplish what their names suggest, the former executes some +synchronous JavaScript, while the latter provides a callback mechanism for +running asynchronous JavaScript: + +.. parsed-literal:: + result = client.execute_script("return arguments[0] + arguments[1];", + script_args=[2, 3]) + assert result == 5 + +The async method works the same way, except it won't return until the +`resolve()` function is called: + +.. parsed-literal:: + result = client.execute_async_script(""" + let [resolve] = arguments; + setTimeout(function() { + resolve("all done"); + }, arguments[0]); + """, script_args=[1000]) + assert result == "all done" + +Beware that running asynchronous scripts can potentially hang the program +indefinitely if they are not written properly. It is generally a good idea to +set a script timeout using :func:`~Marionette.timeout.script` and handling +`ScriptTimeoutException`. diff --git a/testing/marionette/client/docs/conf.py b/testing/marionette/client/docs/conf.py new file mode 100644 index 0000000000..86fc57f20b --- /dev/null +++ b/testing/marionette/client/docs/conf.py @@ -0,0 +1,276 @@ +# -*- coding: utf-8 -*- +# +# Marionette Python Client documentation build configuration file, created by +# sphinx-quickstart on Tue Aug 6 13:54:46 2013. +# +# This file is execfile()d with the current directory set to its containing dir. +# +# Note that not all possible configuration values are present in this +# autogenerated file. +# +# All configuration values have a default; values that are commented out +# serve to show the default. + +from __future__ import absolute_import + +import os +import sys + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# sys.path.insert(0, os.path.abspath('.')) + +here = os.path.dirname(os.path.abspath(__file__)) +parent = os.path.dirname(here) +sys.path.insert(0, parent) + +# -- General configuration ----------------------------------------------------- + +# If your documentation needs a minimal Sphinx version, state it here. +# needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be extensions +# coming with Sphinx (named 'sphinx.ext.*') or your custom ones. +extensions = ["sphinx.ext.autodoc"] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ["_templates"] + +# The suffix of source filenames. +source_suffix = ".rst" + +# The encoding of source files. +# source_encoding = 'utf-8-sig' + +# The master toctree document. +master_doc = "index" + +# General information about the project. +project = u"Marionette Python Client" +copyright = u"2013, Mozilla Automation and Tools and individual contributors" + +# The version info for the project you're documenting, acts as replacement for +# |version| and |release|, also used in various other places throughout the +# built documents. +# +# The short X.Y version. +# version = '0' +# The full version, including alpha/beta/rc tags. +# release = '0' + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +# language = None + +# There are two options for replacing |today|: either, you set today to some +# non-false value, then it is used: +# today = '' +# Else, today_fmt is used as the format for a strftime call. +# today_fmt = '%B %d, %Y' + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +exclude_patterns = ["_build"] + +# The reST default role (used for this markup: `text`) to use for all documents. +# default_role = None + +# If true, '()' will be appended to :func: etc. cross-reference text. +# add_function_parentheses = True + +# If true, the current module name will be prepended to all description +# unit titles (such as .. function::). +# add_module_names = True + +# If true, sectionauthor and moduleauthor directives will be shown in the +# output. They are ignored by default. +# show_authors = False + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = "sphinx" + +# A list of ignored prefixes for module index sorting. +# modindex_common_prefix = [] + + +# -- Options for HTML output --------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. + +html_theme = "default" + +on_rtd = os.environ.get("READTHEDOCS", None) == "True" + +if not on_rtd: + try: + import sphinx_rtd_theme + + html_theme = "sphinx_rtd_theme" + html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] + except ImportError: + pass + + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +# html_theme_options = {} + +# Add any paths that contain custom themes here, relative to this directory. +# html_theme_path = [] + +# The name for this set of Sphinx documents. If None, it defaults to +# "<project> v<release> documentation". +# html_title = None + +# A shorter title for the navigation bar. Default is the same as html_title. +# html_short_title = None + +# The name of an image file (relative to this directory) to place at the top +# of the sidebar. +# html_logo = None + +# The name of an image file (within the static path) to use as favicon of the +# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 +# pixels large. +# html_favicon = None + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +# html_static_path = ['_static'] + +# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, +# using the given strftime format. +# html_last_updated_fmt = '%b %d, %Y' + +# If true, SmartyPants will be used to convert quotes and dashes to +# typographically correct entities. +# html_use_smartypants = True + +# Custom sidebar templates, maps document names to template names. +# html_sidebars = {} + +# Additional templates that should be rendered to pages, maps page names to +# template names. +# html_additional_pages = {} + +# If false, no module index is generated. +# html_domain_indices = True + +# If false, no index is generated. +# html_use_index = True + +# If true, the index is split into individual pages for each letter. +# html_split_index = False + +# If true, links to the reST sources are added to the pages. +# html_show_sourcelink = True + +# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. +# html_show_sphinx = True + +# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. +html_show_copyright = False + +# If true, an OpenSearch description file will be output, and all pages will +# contain a <link> tag referring to it. The value of this option must be the +# base URL from which the finished HTML is served. +# html_use_opensearch = '' + +# This is the file name suffix for HTML files (e.g. ".xhtml"). +# html_file_suffix = None + +# Output file base name for HTML help builder. +htmlhelp_basename = "MarionettePythonClientdoc" + + +# -- Options for LaTeX output -------------------------------------------------- + +latex_elements = { + # The paper size ('letterpaper' or 'a4paper'). + # 'papersize': 'letterpaper', + # The font size ('10pt', '11pt' or '12pt'). + # 'pointsize': '10pt', + # Additional stuff for the LaTeX preamble. + # 'preamble': '', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, author, documentclass [howto/manual]). +latex_documents = [ + ( + "index", + "MarionettePythonClient.tex", + u"Marionette Python Client Documentation", + u"Mozilla Automation and Tools team", + "manual", + ), +] + +# The name of an image file (relative to this directory) to place at the top of +# the title page. +# latex_logo = None + +# For "manual" documents, if this is true, then toplevel headings are parts, +# not chapters. +# latex_use_parts = False + +# If true, show page references after internal links. +# latex_show_pagerefs = False + +# If true, show URL addresses after external links. +# latex_show_urls = False + +# Documents to append as an appendix to all manuals. +# latex_appendices = [] + +# If false, no module index is generated. +# latex_domain_indices = True + + +# -- Options for manual page output -------------------------------------------- + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ + ( + "index", + "marionettepythonclient", + u"Marionette Python Client Documentation", + [u"Mozilla Automation and Tools team"], + 1, + ) +] + +# If true, show URL addresses after external links. +# man_show_urls = False + + +# -- Options for Texinfo output ------------------------------------------------ + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + ( + "index", + "MarionettePythonClient", + "Marionette Python Client Documentation", + "Mozilla Automation and Tools team", + "MarionettePythonClient", + "One line description of project.", + "Miscellaneous", + ), +] + +# Documents to append as an appendix to all manuals. +# texinfo_appendices = [] + +# If false, no module index is generated. +# texinfo_domain_indices = True + +# How to display URL addresses: 'footnote', 'no', or 'inline'. +# texinfo_show_urls = 'footnote' diff --git a/testing/marionette/client/docs/index.rst b/testing/marionette/client/docs/index.rst new file mode 100644 index 0000000000..b1f266726c --- /dev/null +++ b/testing/marionette/client/docs/index.rst @@ -0,0 +1,16 @@ +.. include:: basics.rst + +Indices and tables +------------------ + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` + +.. toctree:: + :hidden: + + Getting Started <basics> + Interactive Tutorial <interactive> + advanced/landing + reference diff --git a/testing/marionette/client/docs/interactive.rst b/testing/marionette/client/docs/interactive.rst new file mode 100644 index 0000000000..7b2ebe2ec3 --- /dev/null +++ b/testing/marionette/client/docs/interactive.rst @@ -0,0 +1,52 @@ +Using the Client Interactively +============================== + +Once you installed the client and have Marionette running, you can fire +up your favourite interactive python environment and start playing with +Marionette. Let's use a typical python shell: + +.. parsed-literal:: + python + +First, import Marionette: + +.. parsed-literal:: + from marionette_driver.marionette import Marionette + +Now create the client for this session. Assuming you're using the default +port on a Marionette instance running locally: + +.. parsed-literal:: + client = Marionette(host='127.0.0.1', port=2828) + client.start_session() + +This will return some id representing your session id. Now that you've +established a connection, let's start doing interesting things: + +.. parsed-literal:: + client.navigate("http://www.mozilla.org") + +Now you're at mozilla.org! You can even verify it using the following: + +.. parsed-literal:: + client.get_url() + +You can execute Javascript code in the scope of the web page: + +.. parsed-literal:: + client.execute_script("return window.document.title;") + +This will you return the title of the web page as set in the head section +of the HTML document. + +Also you can find elements and click on those. Let's say you want to get +the first link: + +.. parsed-literal:: + from marionette_driver import By + first_link = client.find_element(By.TAG_NAME, "a") + +first_link now holds a reference to the first link on the page. You can click it: + +.. parsed-literal:: + first_link.click() diff --git a/testing/marionette/client/docs/make.bat b/testing/marionette/client/docs/make.bat new file mode 100644 index 0000000000..fb02fc1a8c --- /dev/null +++ b/testing/marionette/client/docs/make.bat @@ -0,0 +1,190 @@ +@ECHO OFF + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set BUILDDIR=_build +set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . +set I18NSPHINXOPTS=%SPHINXOPTS% . +if NOT "%PAPER%" == "" ( + set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% + set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% +) + +if "%1" == "" goto help + +if "%1" == "help" ( + :help + echo.Please use `make ^<target^>` where ^<target^> is one of + echo. html to make standalone HTML files + echo. dirhtml to make HTML files named index.html in directories + echo. singlehtml to make a single large HTML file + echo. pickle to make pickle files + echo. json to make JSON files + echo. htmlhelp to make HTML files and a HTML help project + echo. qthelp to make HTML files and a qthelp project + echo. devhelp to make HTML files and a Devhelp project + echo. epub to make an epub + echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter + echo. text to make text files + echo. man to make manual pages + echo. texinfo to make Texinfo files + echo. gettext to make PO message catalogs + echo. changes to make an overview over all changed/added/deprecated items + echo. linkcheck to check all external links for integrity + echo. doctest to run all doctests embedded in the documentation if enabled + goto end +) + +if "%1" == "clean" ( + for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i + del /q /s %BUILDDIR%\* + goto end +) + +if "%1" == "html" ( + %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/html. + goto end +) + +if "%1" == "dirhtml" ( + %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. + goto end +) + +if "%1" == "singlehtml" ( + %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. + goto end +) + +if "%1" == "pickle" ( + %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can process the pickle files. + goto end +) + +if "%1" == "json" ( + %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can process the JSON files. + goto end +) + +if "%1" == "htmlhelp" ( + %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can run HTML Help Workshop with the ^ +.hhp project file in %BUILDDIR%/htmlhelp. + goto end +) + +if "%1" == "qthelp" ( + %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; now you can run "qcollectiongenerator" with the ^ +.qhcp project file in %BUILDDIR%/qthelp, like this: + echo.^> qcollectiongenerator %BUILDDIR%\qthelp\MarionettePythonClient.qhcp + echo.To view the help file: + echo.^> assistant -collectionFile %BUILDDIR%\qthelp\MarionettePythonClient.ghc + goto end +) + +if "%1" == "devhelp" ( + %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. + goto end +) + +if "%1" == "epub" ( + %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The epub file is in %BUILDDIR%/epub. + goto end +) + +if "%1" == "latex" ( + %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex + if errorlevel 1 exit /b 1 + echo. + echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. + goto end +) + +if "%1" == "text" ( + %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The text files are in %BUILDDIR%/text. + goto end +) + +if "%1" == "man" ( + %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The manual pages are in %BUILDDIR%/man. + goto end +) + +if "%1" == "texinfo" ( + %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. + goto end +) + +if "%1" == "gettext" ( + %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale + if errorlevel 1 exit /b 1 + echo. + echo.Build finished. The message catalogs are in %BUILDDIR%/locale. + goto end +) + +if "%1" == "changes" ( + %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes + if errorlevel 1 exit /b 1 + echo. + echo.The overview file is in %BUILDDIR%/changes. + goto end +) + +if "%1" == "linkcheck" ( + %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck + if errorlevel 1 exit /b 1 + echo. + echo.Link check complete; look for any errors in the above output ^ +or in %BUILDDIR%/linkcheck/output.txt. + goto end +) + +if "%1" == "doctest" ( + %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest + if errorlevel 1 exit /b 1 + echo. + echo.Testing of doctests in the sources finished, look at the ^ +results in %BUILDDIR%/doctest/output.txt. + goto end +) + +:end diff --git a/testing/marionette/client/docs/reference.rst b/testing/marionette/client/docs/reference.rst new file mode 100644 index 0000000000..2765e8b1a4 --- /dev/null +++ b/testing/marionette/client/docs/reference.rst @@ -0,0 +1,66 @@ +============= +API Reference +============= + +Marionette +---------- +.. py:currentmodule:: marionette_driver.marionette.Marionette +.. autoclass:: marionette_driver.marionette.Marionette + :members: + +HTMLElement +----------- +.. py:currentmodule:: marionette_driver.marionette.HTMLElement +.. autoclass:: marionette_driver.marionette.HTMLElement + :members: + +DateTimeValue +------------- +.. py:currentmodule:: marionette_driver.DateTimeValue +.. autoclass:: marionette_driver.DateTimeValue + :members: + +Actions +------- +.. py:currentmodule:: marionette_driver.marionette.Actions +.. autoclass:: marionette_driver.marionette.Actions + :members: + +Alert +----- +.. py:currentmodule:: marionette_driver.marionette.Alert +.. autoclass:: marionette_driver.marionette.Alert + :members: + +Wait +---- +.. py:currentmodule:: marionette_driver.Wait +.. autoclass:: marionette_driver.Wait + :members: + :special-members: +.. autoattribute marionette_driver.wait.DEFAULT_TIMEOUT +.. autoattribute marionette_driver.wait.DEFAULT_INTERVAL + +Built-in Conditions +^^^^^^^^^^^^^^^^^^^ +.. py:currentmodule:: marionette_driver.expected +.. automodule:: marionette_driver.expected + :members: + +Timeouts +-------- +.. py:currentmodule:: marionette_driver.timeout.Timeouts +.. autoclass:: marionette_driver.timeout.Timeouts + :members: + +Addons +------ +.. py:currentmodule:: marionette_driver.addons.Addons +.. autoclass:: marionette_driver.addons.Addons + :members: + +Localization +------------ +.. py:currentmodule:: marionette_driver.localization.L10n +.. autoclass:: marionette_driver.localization.L10n + :members: diff --git a/testing/marionette/client/marionette_driver/__init__.py b/testing/marionette/client/marionette_driver/__init__.py new file mode 100644 index 0000000000..0718925e20 --- /dev/null +++ b/testing/marionette/client/marionette_driver/__init__.py @@ -0,0 +1,24 @@ +# 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/. + +from __future__ import absolute_import + +__version__ = "3.1.0" + +from marionette_driver import ( + addons, + by, + date_time_value, + decorators, + errors, + expected, + geckoinstance, + keys, + localization, + marionette, + wait, +) +from marionette_driver.by import By +from marionette_driver.date_time_value import DateTimeValue +from marionette_driver.wait import Wait diff --git a/testing/marionette/client/marionette_driver/addons.py b/testing/marionette/client/marionette_driver/addons.py new file mode 100644 index 0000000000..8dab5086be --- /dev/null +++ b/testing/marionette/client/marionette_driver/addons.py @@ -0,0 +1,77 @@ +# 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/. + +from __future__ import absolute_import +import os + +from . import errors + +__all__ = ["Addons", "AddonInstallException"] + + +class AddonInstallException(errors.MarionetteException): + pass + + +class Addons(object): + """An API for installing and inspecting addons during Gecko + runtime. This is a partially implemented wrapper around Gecko's + `AddonManager API`_. + + For example:: + + from marionette_driver.addons import Addons + addons = Addons(marionette) + addons.install("/path/to/extension.xpi") + + .. _AddonManager API: https://developer.mozilla.org/en-US/Add-ons/Add-on_Manager + + """ + + def __init__(self, marionette): + self._mn = marionette + + def install(self, path, temp=False): + """Install a Firefox addon. + + If the addon is restartless, it can be used right away. Otherwise + a restart using :func:`~marionette_driver.marionette.Marionette.restart` + will be needed. + + :param path: A file path to the extension to be installed. + :param temp: Install a temporary addon. Temporary addons will + automatically be uninstalled on shutdown and do not need + to be signed, though they must be restartless. + + :returns: The addon ID string of the newly installed addon. + + :raises: :exc:`AddonInstallException` + + """ + # On windows we can end up with a path with mixed \ and / + # which Firefox doesn't like + path = path.replace("/", os.path.sep) + + body = {"path": path, "temporary": temp} + try: + return self._mn._send_message("Addon:Install", body, key="value") + except errors.UnknownException as e: + raise AddonInstallException(e) + + def uninstall(self, addon_id): + """Uninstall a Firefox addon. + + If the addon is restartless, it will be uninstalled right away. + Otherwise a restart using :func:`~marionette_driver.marionette.Marionette.restart` + will be needed. + + If the call to uninstall is resulting in a `ScriptTimeoutException`, + an invalid ID is likely being passed in. Unfortunately due to + AddonManager's implementation, it's hard to retrieve this error from + Python. + + :param addon_id: The addon ID string to uninstall. + + """ + self._mn._send_message("Addon:Uninstall", {"id": addon_id}) diff --git a/testing/marionette/client/marionette_driver/by.py b/testing/marionette/client/marionette_driver/by.py new file mode 100644 index 0000000000..9b0b611ac0 --- /dev/null +++ b/testing/marionette/client/marionette_driver/by.py @@ -0,0 +1,27 @@ +# Copyright 2008-2009 WebDriver committers +# Copyright 2008-2009 Google Inc. +# +# 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. + +from __future__ import absolute_import + + +class By(object): + ID = "id" + XPATH = "xpath" + LINK_TEXT = "link text" + PARTIAL_LINK_TEXT = "partial link text" + NAME = "name" + TAG_NAME = "tag name" + CLASS_NAME = "class name" + CSS_SELECTOR = "css selector" diff --git a/testing/marionette/client/marionette_driver/date_time_value.py b/testing/marionette/client/marionette_driver/date_time_value.py new file mode 100644 index 0000000000..fd646db6d8 --- /dev/null +++ b/testing/marionette/client/marionette_driver/date_time_value.py @@ -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/. + +from __future__ import absolute_import + + +class DateTimeValue(object): + """ + Interface for setting the value of HTML5 "date" and "time" input elements. + + Simple usage example: + + :: + + element = marionette.find_element(By.ID, "date-test") + dt_value = DateTimeValue(element) + dt_value.date = datetime(1998, 6, 2) + + """ + + def __init__(self, element): + self.element = element + + @property + def date(self): + """ + Retrieve the element's string value + """ + return self.element.get_attribute("value") + + # As per the W3C "date" element specification + # (http://dev.w3.org/html5/markup/input.date.html), this value is formatted + # according to RFC 3339: http://tools.ietf.org/html/rfc3339#section-5.6 + @date.setter + def date(self, date_value): + self.element.send_keys(date_value.strftime("%Y-%m-%d")) + + @property + def time(self): + """ + Retrieve the element's string value + """ + return self.element.get_attribute("value") + + # As per the W3C "time" element specification + # (http://dev.w3.org/html5/markup/input.time.html), this value is formatted + # according to RFC 3339: http://tools.ietf.org/html/rfc3339#section-5.6 + @time.setter + def time(self, time_value): + self.element.send_keys(time_value.strftime("%H:%M:%S")) diff --git a/testing/marionette/client/marionette_driver/decorators.py b/testing/marionette/client/marionette_driver/decorators.py new file mode 100644 index 0000000000..223440a8a8 --- /dev/null +++ b/testing/marionette/client/marionette_driver/decorators.py @@ -0,0 +1,82 @@ +# 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/. + +from __future__ import absolute_import, print_function + +import socket + +from functools import wraps + + +def _find_marionette_in_args(*args, **kwargs): + try: + m = [a for a in args + tuple(kwargs.values()) if hasattr(a, "session")][0] + except IndexError: + print("Can only apply decorator to function using a marionette object") + raise + return m + + +def do_process_check(func): + """Decorator which checks the process status after the function has run.""" + + @wraps(func) + def _(*args, **kwargs): + try: + return func(*args, **kwargs) + except (socket.error, socket.timeout): + m = _find_marionette_in_args(*args, **kwargs) + + # In case of socket failures which will also include crashes of the + # application, make sure to handle those correctly. In case of an + # active shutdown just let it bubble up. + if m.is_shutting_down: + raise + + m._handle_socket_failure() + + return _ + + +def uses_marionette(func): + """Decorator which creates a marionette session and deletes it + afterwards if one doesn't already exist. + """ + + @wraps(func) + def _(*args, **kwargs): + m = _find_marionette_in_args(*args, **kwargs) + delete_session = False + if not m.session: + delete_session = True + m.start_session() + + m.set_context(m.CONTEXT_CHROME) + ret = func(*args, **kwargs) + + if delete_session: + m.delete_session() + + return ret + + return _ + + +def using_context(context): + """Decorator which allows a function to execute in certain scope + using marionette.using_context functionality and returns to old + scope once the function exits. + :param context: Either 'chrome' or 'content'. + """ + + def wrap(func): + @wraps(func) + def inner(*args, **kwargs): + m = _find_marionette_in_args(*args, **kwargs) + with m.using_context(context): + return func(*args, **kwargs) + + return inner + + return wrap diff --git a/testing/marionette/client/marionette_driver/errors.py b/testing/marionette/client/marionette_driver/errors.py new file mode 100644 index 0000000000..062eb12883 --- /dev/null +++ b/testing/marionette/client/marionette_driver/errors.py @@ -0,0 +1,200 @@ +# 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/. + +from __future__ import absolute_import + +import traceback + +import six + + +@six.python_2_unicode_compatible +class MarionetteException(Exception): + + """Raised when a generic non-recoverable exception has occured.""" + + status = "webdriver error" + + def __init__(self, message=None, cause=None, stacktrace=None): + """Construct new MarionetteException instance. + + :param message: An optional exception message. + + :param cause: An optional tuple of three values giving + information about the root exception cause. Expected + tuple values are (type, value, traceback). + + :param stacktrace: Optional string containing a stacktrace + (typically from a failed JavaScript execution) that will + be displayed in the exception's string representation. + + """ + self.cause = cause + self.stacktrace = stacktrace + self._message = six.text_type(message) + + def __str__(self): + # pylint: disable=W1645 + msg = self.message + tb = None + + if self.cause: + if type(self.cause) is tuple: + msg += u", caused by {0!r}".format(self.cause[0]) + tb = self.cause[2] + else: + msg += u", caused by {}".format(self.cause) + + if self.stacktrace: + st = u"".join(["\t{}\n".format(x) for x in self.stacktrace.splitlines()]) + msg += u"\nstacktrace:\n{}".format(st) + + if tb: + msg += u": " + u"".join(traceback.format_tb(tb)) + + return six.text_type(msg) + + @property + def message(self): + return self._message + + +class ElementNotSelectableException(MarionetteException): + status = "element not selectable" + + +class ElementClickInterceptedException(MarionetteException): + status = "element click intercepted" + + +class InsecureCertificateException(MarionetteException): + status = "insecure certificate" + + +class InvalidArgumentException(MarionetteException): + status = "invalid argument" + + +class InvalidSessionIdException(MarionetteException): + status = "invalid session id" + + +class TimeoutException(MarionetteException): + status = "timeout" + + +class JavascriptException(MarionetteException): + status = "javascript error" + + +class NoSuchElementException(MarionetteException): + status = "no such element" + + +class NoSuchWindowException(MarionetteException): + status = "no such window" + + +class StaleElementException(MarionetteException): + status = "stale element reference" + + +class ScriptTimeoutException(MarionetteException): + status = "script timeout" + + +class ElementNotVisibleException(MarionetteException): + """Deprecated. Will be removed with the release of Firefox 54.""" + + status = "element not visible" + + def __init__( + self, + message="Element is not currently visible and may not be manipulated", + stacktrace=None, + cause=None, + ): + super(ElementNotVisibleException, self).__init__( + message, cause=cause, stacktrace=stacktrace + ) + + +class ElementNotAccessibleException(MarionetteException): + status = "element not accessible" + + +class ElementNotInteractableException(MarionetteException): + status = "element not interactable" + + +class NoSuchFrameException(MarionetteException): + status = "no such frame" + + +class InvalidElementStateException(MarionetteException): + status = "invalid element state" + + +class NoAlertPresentException(MarionetteException): + status = "no such alert" + + +class InvalidCookieDomainException(MarionetteException): + status = "invalid cookie domain" + + +class UnableToSetCookieException(MarionetteException): + status = "unable to set cookie" + + +class InvalidElementCoordinates(MarionetteException): + status = "invalid element coordinates" + + +class InvalidSelectorException(MarionetteException): + status = "invalid selector" + + +class MoveTargetOutOfBoundsException(MarionetteException): + status = "move target out of bounds" + + +class SessionNotCreatedException(MarionetteException): + status = "session not created" + + +class UnexpectedAlertOpen(MarionetteException): + status = "unexpected alert open" + + +class UnknownCommandException(MarionetteException): + status = "unknown command" + + +class UnknownException(MarionetteException): + status = "unknown error" + + +class UnsupportedOperationException(MarionetteException): + status = "unsupported operation" + + +class UnresponsiveInstanceException(Exception): + pass + + +es_ = [ + e + for e in locals().values() + if type(e) == type and issubclass(e, MarionetteException) +] +by_string = {e.status: e for e in es_} + + +def lookup(identifier): + """Finds error exception class by associated Selenium JSON wire + protocol number code, or W3C WebDriver protocol string. + + """ + return by_string.get(identifier, MarionetteException) diff --git a/testing/marionette/client/marionette_driver/expected.py b/testing/marionette/client/marionette_driver/expected.py new file mode 100644 index 0000000000..1605b2adad --- /dev/null +++ b/testing/marionette/client/marionette_driver/expected.py @@ -0,0 +1,317 @@ +# 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/. + +from __future__ import absolute_import + +import types + +from . import errors +from .marionette import HTMLElement + +"""This file provides a set of expected conditions for common use +cases when writing Marionette tests. + +The conditions rely on explicit waits that retries conditions a number +of times until they are either successfully met, or they time out. + +""" + + +class element_present(object): + """Checks that a web element is present in the DOM of the current + context. This does not necessarily mean that the element is + visible. + + You can select which element to be checked for presence by + supplying a locator:: + + el = Wait(marionette).until(expected.element_present(By.ID, "foo")) + + Or by using a function/lambda returning an element:: + + el = Wait(marionette).until( + expected.element_present(lambda m: m.find_element(By.ID, "foo"))) + + :param args: locator or function returning web element + :returns: the web element once it is located, or False + + """ + + def __init__(self, *args): + if len(args) == 1 and isinstance(args[0], types.FunctionType): + self.locator = args[0] + else: + self.locator = lambda m: m.find_element(*args) + + def __call__(self, marionette): + return _find(marionette, self.locator) + + +class element_not_present(element_present): + """Checks that a web element is not present in the DOM of the current + context. + + You can select which element to be checked for lack of presence by + supplying a locator:: + + r = Wait(marionette).until(expected.element_not_present(By.ID, "foo")) + + Or by using a function/lambda returning an element:: + + r = Wait(marionette).until( + expected.element_present(lambda m: m.find_element(By.ID, "foo"))) + + :param args: locator or function returning web element + :returns: True if element is not present, or False if it is present + + """ + + def __init__(self, *args): + super(element_not_present, self).__init__(*args) + + def __call__(self, marionette): + return not super(element_not_present, self).__call__(marionette) + + +class element_stale(object): + """Check that the given element is no longer attached to DOM of the + current context. + + This can be useful for waiting until an element is no longer + present. + + Sample usage:: + + el = marionette.find_element(By.ID, "foo") + # ... + Wait(marionette).until(expected.element_stale(el)) + + :param element: the element to wait for + :returns: False if the element is still attached to the DOM, True + otherwise + + """ + + def __init__(self, element): + self.el = element + + def __call__(self, marionette): + try: + # Calling any method forces a staleness check + self.el.is_enabled() + return False + except (errors.StaleElementException, errors.NoSuchElementException): + # StaleElementException is raised when the element is gone, and + # NoSuchElementException is raised after a process swap. + return True + + +class elements_present(object): + """Checks that web elements are present in the DOM of the current + context. This does not necessarily mean that the elements are + visible. + + You can select which elements to be checked for presence by + supplying a locator:: + + els = Wait(marionette).until(expected.elements_present(By.TAG_NAME, "a")) + + Or by using a function/lambda returning a list of elements:: + + els = Wait(marionette).until( + expected.elements_present(lambda m: m.find_elements(By.TAG_NAME, "a"))) + + :param args: locator or function returning a list of web elements + :returns: list of web elements once they are located, or False + + """ + + def __init__(self, *args): + if len(args) == 1 and isinstance(args[0], types.FunctionType): + self.locator = args[0] + else: + self.locator = lambda m: m.find_elements(*args) + + def __call__(self, marionette): + return _find(marionette, self.locator) + + +class elements_not_present(elements_present): + """Checks that web elements are not present in the DOM of the + current context. + + You can select which elements to be checked for not being present + by supplying a locator:: + + r = Wait(marionette).until(expected.elements_not_present(By.TAG_NAME, "a")) + + Or by using a function/lambda returning a list of elements:: + + r = Wait(marionette).until( + expected.elements_not_present(lambda m: m.find_elements(By.TAG_NAME, "a"))) + + :param args: locator or function returning a list of web elements + :returns: True if elements are missing, False if one or more are + present + + """ + + def __init__(self, *args): + super(elements_not_present, self).__init__(*args) + + def __call__(self, marionette): + return not super(elements_not_present, self).__call__(marionette) + + +class element_displayed(object): + """An expectation for checking that an element is visible. + + Visibility means that the element is not only displayed, but also + has a height and width that is greater than 0 pixels. + + Stale elements, meaning elements that have been detached from the + DOM of the current context are treated as not being displayed, + meaning this expectation is not analogous to the behaviour of + calling :func:`~marionette_driver.marionette.HTMLElement.is_displayed` + on an :class:`~marionette_driver.marionette.HTMLElement`. + + You can select which element to be checked for visibility by + supplying a locator:: + + displayed = Wait(marionette).until(expected.element_displayed(By.ID, "foo")) + + Or by supplying an element:: + + el = marionette.find_element(By.ID, "foo") + displayed = Wait(marionette).until(expected.element_displayed(el)) + + :param args: locator or web element + :returns: True if element is displayed, False if hidden + + """ + + def __init__(self, *args): + self.el = None + if len(args) == 1 and isinstance(args[0], HTMLElement): + self.el = args[0] + else: + self.locator = lambda m: m.find_element(*args) + + def __call__(self, marionette): + if self.el is None: + self.el = _find(marionette, self.locator) + if not self.el: + return False + try: + return self.el.is_displayed() + except errors.StaleElementException: + return False + + +class element_not_displayed(element_displayed): + """An expectation for checking that an element is not visible. + + Visibility means that the element is not only displayed, but also + has a height and width that is greater than 0 pixels. + + Stale elements, meaning elements that have been detached fom the + DOM of the current context are treated as not being displayed, + meaning this expectation is not analogous to the behaviour of + calling :func:`~marionette_driver.marionette.HTMLElement.is_displayed` + on an :class:`~marionette_driver.marionette.HTMLElement`. + + You can select which element to be checked for visibility by + supplying a locator:: + + hidden = Wait(marionette).until(expected.element_not_displayed(By.ID, "foo")) + + Or by supplying an element:: + + el = marionette.find_element(By.ID, "foo") + hidden = Wait(marionette).until(expected.element_not_displayed(el)) + + :param args: locator or web element + :returns: True if element is hidden, False if displayed + + """ + + def __init__(self, *args): + super(element_not_displayed, self).__init__(*args) + + def __call__(self, marionette): + return not super(element_not_displayed, self).__call__(marionette) + + +class element_selected(object): + """An expectation for checking that the given element is selected. + + :param element: the element to be selected + :returns: True if element is selected, False otherwise + + """ + + def __init__(self, element): + self.el = element + + def __call__(self, marionette): + return self.el.is_selected() + + +class element_not_selected(element_selected): + """An expectation for checking that the given element is not + selected. + + :param element: the element to not be selected + :returns: True if element is not selected, False if selected + + """ + + def __init__(self, element): + super(element_not_selected, self).__init__(element) + + def __call__(self, marionette): + return not super(element_not_selected, self).__call__(marionette) + + +class element_enabled(object): + """An expectation for checking that the given element is enabled. + + :param element: the element to check if enabled + :returns: True if element is enabled, False otherwise + + """ + + def __init__(self, element): + self.el = element + + def __call__(self, marionette): + return self.el.is_enabled() + + +class element_not_enabled(element_enabled): + """An expectation for checking that the given element is disabled. + + :param element: the element to check if disabled + :returns: True if element is disabled, False if enabled + + """ + + def __init__(self, element): + super(element_not_enabled, self).__init__(element) + + def __call__(self, marionette): + return not super(element_not_enabled, self).__call__(marionette) + + +def _find(marionette, func): + el = None + + try: + el = func(marionette) + except errors.NoSuchElementException: + pass + + if el is None: + return False + return el diff --git a/testing/marionette/client/marionette_driver/geckoinstance.py b/testing/marionette/client/marionette_driver/geckoinstance.py new file mode 100644 index 0000000000..9ff56428e0 --- /dev/null +++ b/testing/marionette/client/marionette_driver/geckoinstance.py @@ -0,0 +1,639 @@ +# 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/ + +# ALL CHANGES TO THIS FILE MUST HAVE REVIEW FROM A MARIONETTE PEER! +# +# The Marionette Python client is used out-of-tree with various builds of +# Firefox. Removing a preference from this file will cause regressions, +# so please be careful and get review from a Testing :: Marionette peer +# before you make any changes to this file. + +from __future__ import absolute_import + +import codecs +import os +import sys +import tempfile +import time +import traceback + +from copy import deepcopy + +import mozversion + +from mozprofile import Profile +from mozrunner import Runner, FennecEmulatorRunner +import six +from six import reraise + +from . import errors + + +class GeckoInstance(object): + required_prefs = { + # Make sure Shield doesn't hit the network. + "app.normandy.api_url": "", + # Increase the APZ content response timeout in tests to 1 minute. + # This is to accommodate the fact that test environments tends to be slower + # than production environments (with the b2g emulator being the slowest of them + # all), resulting in the production timeout value sometimes being exceeded + # and causing false-positive test failures. See bug 1176798, bug 1177018, + # bug 1210465. + "apz.content_response_timeout": 60000, + # Defensively disable data reporting systems + "datareporting.healthreport.documentServerURI": "http://%(server)s/dummy/healthreport/", + "datareporting.healthreport.logging.consoleEnabled": False, + "datareporting.healthreport.service.enabled": False, + "datareporting.healthreport.service.firstRun": False, + "datareporting.healthreport.uploadEnabled": False, + # Do not show datareporting policy notifications which can interfere with tests + "datareporting.policy.dataSubmissionEnabled": False, + "datareporting.policy.dataSubmissionPolicyBypassNotification": True, + # Automatically unload beforeunload alerts + "dom.disable_beforeunload": True, + # Disable the ProcessHangMonitor + "dom.ipc.reportProcessHangs": False, + # No slow script dialogs + "dom.max_chrome_script_run_time": 0, + "dom.max_script_run_time": 0, + # DOM Push + "dom.push.connection.enabled": False, + # Only load extensions from the application and user profile + # AddonManager.SCOPE_PROFILE + AddonManager.SCOPE_APPLICATION + "extensions.autoDisableScopes": 0, + "extensions.enabledScopes": 5, + # Disable metadata caching for installed add-ons by default + "extensions.getAddons.cache.enabled": False, + # Disable intalling any distribution add-ons + "extensions.installDistroAddons": False, + # Turn off extension updates so they don't bother tests + "extensions.update.enabled": False, + "extensions.update.notifyUser": False, + # Make sure opening about:addons won"t hit the network + "extensions.getAddons.discovery.api_url": "data:, ", + # Allow the application to have focus even it runs in the background + "focusmanager.testmode": True, + # Disable useragent updates + "general.useragent.updates.enabled": False, + # Always use network provider for geolocation tests + # so we bypass the OSX dialog raised by the corelocation provider + "geo.provider.testing": True, + # Do not scan Wifi + "geo.wifi.scan": False, + # Disable idle-daily notifications to avoid expensive operations + # that may cause unexpected test timeouts. + "idle.lastDailyNotification": -1, + "javascript.options.showInConsole": True, + # (deprecated and can be removed when Firefox 60 ships) + "marionette.defaultPrefs.enabled": True, + # Disable recommended automation prefs in CI + "marionette.prefs.recommended": False, + # Disable download and usage of OpenH264, and Widevine plugins + "media.gmp-manager.updateEnabled": False, + # Disable the GFX sanity window + "media.sanity-test.disabled": True, + "media.volume_scale": "0.01", + # Do not prompt for temporary redirects + "network.http.prompt-temp-redirect": False, + # Do not automatically switch between offline and online + "network.manage-offline-status": False, + # Make sure SNTP requests don't hit the network + "network.sntp.pools": "%(server)s", + # Privacy and Tracking Protection + "privacy.trackingprotection.enabled": False, + # Don't do network connections for mitm priming + "security.certerrors.mitm.priming.enabled": False, + # Tests don't wait for the notification button security delay + "security.notification_enable_delay": 0, + # Ensure blocklist updates don't hit the network + "services.settings.server": "http://%(server)s/dummy/blocklist/", + # Disable password capture, so that tests that include forms aren"t + # influenced by the presence of the persistent doorhanger notification + "signon.rememberSignons": False, + # Prevent starting into safe mode after application crashes + "toolkit.startup.max_resumed_crashes": -1, + # Enabling the support for File object creation in the content process. + "dom.file.createInChild": True, + } + + def __init__( + self, + host=None, + port=None, + bin=None, + profile=None, + addons=None, + app_args=None, + symbols_path=None, + gecko_log=None, + prefs=None, + workspace=None, + verbose=0, + headless=False, + enable_webrender=False, + ): + self.runner_class = Runner + self.app_args = app_args or [] + self.runner = None + self.symbols_path = symbols_path + self.binary = bin + + self.marionette_host = host + self.marionette_port = port + self.addons = addons + self.prefs = prefs + self.required_prefs = deepcopy(self.required_prefs) + if prefs: + self.required_prefs.update(prefs) + + self._gecko_log_option = gecko_log + self._gecko_log = None + self.verbose = verbose + self.headless = headless + self.enable_webrender = enable_webrender + + # keep track of errors to decide whether instance is unresponsive + self.unresponsive_count = 0 + + # Alternative to default temporary directory + self.workspace = workspace + + # Don't use the 'profile' property here, because sub-classes could add + # further preferences and data, which would not be included in the new + # profile + self._profile = profile + + @property + def gecko_log(self): + if self._gecko_log: + return self._gecko_log + + path = self._gecko_log_option + if path != "-": + if path is None: + path = "gecko.log" + elif os.path.isdir(path): + fname = "gecko-{}.log".format(time.time()) + path = os.path.join(path, fname) + + path = os.path.realpath(path) + if os.access(path, os.F_OK): + os.remove(path) + + self._gecko_log = path + return self._gecko_log + + @property + def profile(self): + return self._profile + + @profile.setter + def profile(self, value): + self._update_profile(value) + + def _update_profile(self, profile=None, profile_name=None): + """Check if the profile has to be created, or replaced. + + :param profile: A Profile instance to be used. + :param name: Profile name to be used in the path. + """ + if self.runner and self.runner.is_running(): + raise errors.MarionetteException( + "The current profile can only be updated " + "when the instance is not running" + ) + + if isinstance(profile, Profile): + # Only replace the profile if it is not the current one + if hasattr(self, "_profile") and profile is self._profile: + return + + else: + profile_args = self.profile_args + profile_path = profile + + # If a path to a profile is given then clone it + if isinstance(profile_path, six.string_types): + profile_args["path_from"] = profile_path + profile_args["path_to"] = tempfile.mkdtemp( + suffix=u".{}".format( + profile_name or os.path.basename(profile_path) + ), + dir=self.workspace, + ) + # The target must not exist yet + os.rmdir(profile_args["path_to"]) + + profile = Profile.clone(**profile_args) + + # Otherwise create a new profile + else: + profile_args["profile"] = tempfile.mkdtemp( + suffix=u".{}".format(profile_name or "mozrunner"), + dir=self.workspace, + ) + profile = Profile(**profile_args) + profile.create_new = True + + if isinstance(self.profile, Profile): + self.profile.cleanup() + + self._profile = profile + + def switch_profile(self, profile_name=None, clone_from=None): + """Switch the profile by using the given name, and optionally clone it. + + Compared to :attr:`profile` this method allows to switch the profile + by giving control over the profile name as used for the new profile. It + also always creates a new blank profile, or as clone of an existent one. + + :param profile_name: Optional, name of the profile, which will be used + as part of the profile path (folder name containing the profile). + :clone_from: Optional, if specified the new profile will be cloned + based on the given profile. This argument can be an instance of + ``mozprofile.Profile``, or the path of the profile. + """ + if isinstance(clone_from, Profile): + clone_from = clone_from.profile + + self._update_profile(clone_from, profile_name=profile_name) + + @property + def profile_args(self): + args = {"preferences": deepcopy(self.required_prefs)} + args["preferences"]["marionette.port"] = self.marionette_port + args["preferences"]["marionette.defaultPrefs.port"] = self.marionette_port + + if self.prefs: + args["preferences"].update(self.prefs) + + if self.verbose: + level = "Trace" if self.verbose >= 2 else "Debug" + args["preferences"]["marionette.log.level"] = level + args["preferences"]["marionette.logging"] = level + + if "-jsdebugger" in self.app_args: + args["preferences"].update( + { + "devtools.browsertoolbox.panel": "jsdebugger", + "devtools.debugger.remote-enabled": True, + "devtools.chrome.enabled": True, + "devtools.debugger.prompt-connection": False, + "marionette.debugging.clicktostart": True, + } + ) + + if self.addons: + args["addons"] = self.addons + + return args + + @classmethod + def create(cls, app=None, *args, **kwargs): + try: + if not app and kwargs["bin"] is not None: + app_id = mozversion.get_version(binary=kwargs["bin"])["application_id"] + app = app_ids[app_id] + + instance_class = apps[app] + except (IOError, KeyError): + exc, val, tb = sys.exc_info() + msg = 'Application "{0}" unknown (should be one of {1})'.format( + app, list(apps.keys()) + ) + reraise(NotImplementedError, NotImplementedError(msg), tb) + + return instance_class(*args, **kwargs) + + def start(self): + self._update_profile(self.profile) + self.runner = self.runner_class(**self._get_runner_args()) + self.runner.start() + + def _get_runner_args(self): + process_args = { + "processOutputLine": [NullOutput()], + "universal_newlines": True, + } + + if self.gecko_log == "-": + if six.PY2: + process_args["stream"] = codecs.getwriter("utf-8")(sys.stdout) + else: + process_args["stream"] = codecs.getwriter("utf-8")(sys.stdout.buffer) + else: + process_args["logfile"] = self.gecko_log + + env = os.environ.copy() + + if self.headless: + env["MOZ_HEADLESS"] = "1" + env["DISPLAY"] = "77" # Set a fake display. + + if self.enable_webrender: + env["MOZ_WEBRENDER"] = "1" + env["MOZ_ACCELERATED"] = "1" + else: + env["MOZ_WEBRENDER"] = "0" + + # environment variables needed for crashreporting + # https://developer.mozilla.org/docs/Environment_variables_affecting_crash_reporting + env.update( + { + "MOZ_CRASHREPORTER": "1", + "MOZ_CRASHREPORTER_NO_REPORT": "1", + "MOZ_CRASHREPORTER_SHUTDOWN": "1", + } + ) + + return { + "binary": self.binary, + "profile": self.profile, + "cmdargs": ["-no-remote", "-marionette"] + self.app_args, + "env": env, + "symbols_path": self.symbols_path, + "process_args": process_args, + } + + def close(self, clean=False): + """ + Close the managed Gecko process. + + Depending on self.runner_class, setting `clean` to True may also kill + the emulator process in which this instance is running. + + :param clean: If True, also perform runner cleanup. + """ + if self.runner: + self.runner.stop() + if clean: + self.runner.cleanup() + + if clean: + if isinstance(self.profile, Profile): + self.profile.cleanup() + self.profile = None + + def restart(self, prefs=None, clean=True): + """ + Close then start the managed Gecko process. + + :param prefs: Dictionary of preference names and values. + :param clean: If True, reset the profile before starting. + """ + if prefs: + self.prefs = prefs + else: + self.prefs = None + + self.close(clean=clean) + self.start() + + +class FennecInstance(GeckoInstance): + fennec_prefs = { + # Enable output for dump() and chrome console API + "browser.dom.window.dump.enabled": True, + "devtools.console.stdout.chrome": True, + # Disable safebrowsing components + "browser.safebrowsing.blockedURIs.enabled": False, + "browser.safebrowsing.downloads.enabled": False, + "browser.safebrowsing.passwords.enabled": False, + "browser.safebrowsing.malware.enabled": False, + "browser.safebrowsing.phishing.enabled": False, + # Do not restore the last open set of tabs if the browser has crashed + "browser.sessionstore.resume_from_crash": False, + # Disable e10s by default + "browser.tabs.remote.autostart": False, + # Do not allow background tabs to be zombified, otherwise for tests that + # open additional tabs, the test harness tab itself might get unloaded + "browser.tabs.disableBackgroundZombification": True, + } + + def __init__( + self, + emulator_binary=None, + avd_home=None, + avd=None, + adb_path=None, + serial=None, + connect_to_running_emulator=False, + package_name=None, + env=None, + *args, + **kwargs + ): + required_prefs = deepcopy(FennecInstance.fennec_prefs) + required_prefs.update(kwargs.get("prefs", {})) + + super(FennecInstance, self).__init__(*args, **kwargs) + self.required_prefs.update(required_prefs) + + self.runner_class = FennecEmulatorRunner + # runner args + self._package_name = package_name + self.emulator_binary = emulator_binary + self.avd_home = avd_home + self.adb_path = adb_path + self.avd = avd + self.env = env + self.serial = serial + self.connect_to_running_emulator = connect_to_running_emulator + + @property + def package_name(self): + """ + Name of app to run on emulator. + + Note that FennecInstance does not use self.binary + """ + if self._package_name is None: + self._package_name = "org.mozilla.fennec" + user = os.getenv("USER") + if user: + self._package_name += "_" + user + return self._package_name + + def start(self): + self._update_profile(self.profile) + self.runner = self.runner_class(**self._get_runner_args()) + try: + if self.connect_to_running_emulator: + self.runner.device.connect() + self.runner.start() + except Exception: + exc_cls, exc, tb = sys.exc_info() + reraise( + exc_cls, + exc_cls("Error possibly due to runner or device args: {}".format(exc)), + tb, + ) + + # forward marionette port + self.runner.device.device.forward( + local="tcp:{}".format(self.marionette_port), + remote="tcp:{}".format(self.marionette_port), + ) + + def _get_runner_args(self): + process_args = { + "processOutputLine": [NullOutput()], + "universal_newlines": True, + } + + env = {} if self.env is None else self.env.copy() + if self.enable_webrender: + env["MOZ_WEBRENDER"] = "1" + else: + env["MOZ_WEBRENDER"] = "0" + + runner_args = { + "app": self.package_name, + "avd_home": self.avd_home, + "adb_path": self.adb_path, + "binary": self.emulator_binary, + "env": env, + "profile": self.profile, + "cmdargs": ["-marionette"] + self.app_args, + "symbols_path": self.symbols_path, + "process_args": process_args, + "logdir": self.workspace or os.getcwd(), + "serial": self.serial, + } + if self.avd: + runner_args["avd"] = self.avd + + return runner_args + + def close(self, clean=False): + """ + Close the managed Gecko process. + + If `clean` is True and the Fennec instance is running in an + emulator managed by mozrunner, this will stop the emulator. + + :param clean: If True, also perform runner cleanup. + """ + super(FennecInstance, self).close(clean) + if clean and self.runner and self.runner.device.connected: + try: + self.runner.device.device.remove_forwards( + "tcp:{}".format(self.marionette_port) + ) + self.unresponsive_count = 0 + except Exception: + self.unresponsive_count += 1 + traceback.print_exception(*sys.exc_info()) + + +class DesktopInstance(GeckoInstance): + desktop_prefs = { + # Disable Firefox old build background check + "app.update.checkInstallTime": False, + # Disable automatically upgrading Firefox + # + # Note: Possible update tests could reset or flip the value to allow + # updates to be downloaded and applied. + "app.update.disabledForTesting": True, + # !!! For backward compatibility up to Firefox 64. Only remove + # when this Firefox version is no longer supported by the client !!! + "app.update.auto": False, + # Don't show the content blocking introduction panel + # We use a larger number than the default 22 to have some buffer + # This can be removed once Firefox 69 and 68 ESR and are no longer supported. + "browser.contentblocking.introCount": 99, + # Enable output for dump() and chrome console API + "browser.dom.window.dump.enabled": True, + "devtools.console.stdout.chrome": True, + # Indicate that the download panel has been shown once so that whichever + # download test runs first doesn"t show the popup inconsistently + "browser.download.panel.shown": True, + # Do not show the EULA notification which can interfer with tests + "browser.EULA.override": True, + # Always display a blank page + "browser.newtabpage.enabled": False, + # Background thumbnails in particular cause grief, and disabling thumbnails + # in general can"t hurt - we re-enable them when tests need them + "browser.pagethumbnails.capturing_disabled": True, + # Disable safebrowsing components + "browser.safebrowsing.blockedURIs.enabled": False, + "browser.safebrowsing.downloads.enabled": False, + "browser.safebrowsing.passwords.enabled": False, + "browser.safebrowsing.malware.enabled": False, + "browser.safebrowsing.phishing.enabled": False, + # Disable updates to search engines + "browser.search.update": False, + # Do not restore the last open set of tabs if the browser has crashed + "browser.sessionstore.resume_from_crash": False, + # Don't check for the default web browser during startup + "browser.shell.checkDefaultBrowser": False, + # Needed for branded builds to prevent opening a second tab on startup + "browser.startup.homepage_override.mstone": "ignore", + # Start with a blank page by default + "browser.startup.page": 0, + # Don't unload tabs when available memory is running low + "browser.tabs.unloadOnLowMemory": False, + # Do not warn when closing all open tabs + "browser.tabs.warnOnClose": False, + # Do not warn when closing all other open tabs + "browser.tabs.warnOnCloseOtherTabs": False, + # Do not warn when multiple tabs will be opened + "browser.tabs.warnOnOpen": False, + # Don't show the Bookmarks Toolbar on any tab (the above pref that + # disables the New Tab Page ends up showing the toolbar on about:blank). + "browser.toolbars.bookmarks.visibility": "never", + # Disable the UI tour + "browser.uitour.enabled": False, + # Turn off search suggestions in the location bar so as not to trigger network + # connections. + "browser.urlbar.suggest.searches": False, + # Don't warn when exiting the browser + "browser.warnOnQuit": False, + # Only allow the old modal dialogs. This should be removed when there is + # support for the new modal UI (see Bug 1686741). + "prompts.contentPromptSubDialog": False, + # Disable first-run welcome page + "startup.homepage_welcome_url": "about:blank", + "startup.homepage_welcome_url.additional": "", + } + + def __init__(self, *args, **kwargs): + required_prefs = deepcopy(DesktopInstance.desktop_prefs) + required_prefs.update(kwargs.get("prefs", {})) + + super(DesktopInstance, self).__init__(*args, **kwargs) + self.required_prefs.update(required_prefs) + + +class ThunderbirdInstance(GeckoInstance): + def __init__(self, *args, **kwargs): + super(ThunderbirdInstance, self).__init__(*args, **kwargs) + try: + # Copied alongside in the test archive + from .thunderbirdinstance import thunderbird_prefs + except ImportError: + try: + # Coming from source tree through virtualenv + from thunderbirdinstance import thunderbird_prefs + except ImportError: + thunderbird_prefs = {} + self.required_prefs.update(thunderbird_prefs) + + +class NullOutput(object): + def __call__(self, line): + pass + + +apps = { + "fennec": FennecInstance, + "fxdesktop": DesktopInstance, + "thunderbird": ThunderbirdInstance, +} + +app_ids = { + "{aa3c5121-dab2-40e2-81ca-7ea25febc110}": "fennec", + "{ec8030f7-c20a-464f-9b0e-13a3a9e97384}": "fxdesktop", + "{3550f703-e582-4d05-9a08-453d09bdfdc6}": "thunderbird", +} diff --git a/testing/marionette/client/marionette_driver/keys.py b/testing/marionette/client/marionette_driver/keys.py new file mode 100644 index 0000000000..205e9556e2 --- /dev/null +++ b/testing/marionette/client/marionette_driver/keys.py @@ -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/. + +# copyright 2008-2009 WebDriver committers +# Copyright 2008-2009 Google Inc. +# +# Licensed under the Apache License Version 2.0 = uthe "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. + +from __future__ import absolute_import + + +class Keys(object): + + NULL = u"\ue000" + CANCEL = u"\ue001" # ^break + HELP = u"\ue002" + BACK_SPACE = u"\ue003" + TAB = u"\ue004" + CLEAR = u"\ue005" + RETURN = u"\ue006" + ENTER = u"\ue007" + SHIFT = u"\ue008" + LEFT_SHIFT = u"\ue008" # alias + CONTROL = u"\ue009" + LEFT_CONTROL = u"\ue009" # alias + ALT = u"\ue00a" + LEFT_ALT = u"\ue00a" # alias + PAUSE = u"\ue00b" + ESCAPE = u"\ue00c" + SPACE = u"\ue00d" + PAGE_UP = u"\ue00e" + PAGE_DOWN = u"\ue00f" + END = u"\ue010" + HOME = u"\ue011" + LEFT = u"\ue012" + ARROW_LEFT = u"\ue012" # alias + UP = u"\ue013" + ARROW_UP = u"\ue013" # alias + RIGHT = u"\ue014" + ARROW_RIGHT = u"\ue014" # alias + DOWN = u"\ue015" + ARROW_DOWN = u"\ue015" # alias + INSERT = u"\ue016" + DELETE = u"\ue017" + SEMICOLON = u"\ue018" + EQUALS = u"\ue019" + + NUMPAD0 = u"\ue01a" # numbe pad keys + NUMPAD1 = u"\ue01b" + NUMPAD2 = u"\ue01c" + NUMPAD3 = u"\ue01d" + NUMPAD4 = u"\ue01e" + NUMPAD5 = u"\ue01f" + NUMPAD6 = u"\ue020" + NUMPAD7 = u"\ue021" + NUMPAD8 = u"\ue022" + NUMPAD9 = u"\ue023" + MULTIPLY = u"\ue024" + ADD = u"\ue025" + SEPARATOR = u"\ue026" + SUBTRACT = u"\ue027" + DECIMAL = u"\ue028" + DIVIDE = u"\ue029" + + F1 = u"\ue031" # function keys + F2 = u"\ue032" + F3 = u"\ue033" + F4 = u"\ue034" + F5 = u"\ue035" + F6 = u"\ue036" + F7 = u"\ue037" + F8 = u"\ue038" + F9 = u"\ue039" + F10 = u"\ue03a" + F11 = u"\ue03b" + F12 = u"\ue03c" + + META = u"\ue03d" + COMMAND = u"\ue03d" diff --git a/testing/marionette/client/marionette_driver/localization.py b/testing/marionette/client/marionette_driver/localization.py new file mode 100644 index 0000000000..5270ff5ffb --- /dev/null +++ b/testing/marionette/client/marionette_driver/localization.py @@ -0,0 +1,56 @@ +# 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/. + +from __future__ import absolute_import + + +class L10n(object): + """An API which allows Marionette to handle localized content. + + The `localization`_ 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. + + For example:: + + from marionette_driver.localization import L10n + l10n = L10n(marionette) + + l10n.localize_entity(["chrome://branding/locale/brand.dtd"], "brandShortName") + l10n.localize_property(["chrome://global/locale/findbar.properties"], "FastFind")) + + .. _localization: https://mzl.la/2eUMjyF + """ + + def __init__(self, marionette): + self._marionette = marionette + + def localize_entity(self, dtd_urls, entity_id): + """Retrieve the localized string for the specified entity id. + + :param dtd_urls: List of .dtd URLs which will be used to search for the entity. + :param entity_id: ID of the entity to retrieve the localized string for. + + :returns: The localized string for the requested entity. + :raises: :exc:`NoSuchElementException` + """ + body = {"urls": dtd_urls, "id": entity_id} + return self._marionette._send_message("L10n:LocalizeEntity", body, key="value") + + def localize_property(self, properties_urls, property_id): + """Retrieve the localized string for the specified property id. + + :param properties_urls: List of .properties URLs which will be used to + search for the property. + :param property_id: ID of the property to retrieve the localized string for. + + :returns: The localized string for the requested property. + :raises: :exc:`NoSuchElementException` + """ + body = {"urls": properties_urls, "id": property_id} + return self._marionette._send_message( + "L10n:LocalizeProperty", body, key="value" + ) diff --git a/testing/marionette/client/marionette_driver/marionette.py b/testing/marionette/client/marionette_driver/marionette.py new file mode 100644 index 0000000000..ce3f67f19d --- /dev/null +++ b/testing/marionette/client/marionette_driver/marionette.py @@ -0,0 +1,1984 @@ +# 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/. + +from __future__ import absolute_import, division + +import base64 +import datetime +import json +import os +import socket +import sys +import time +import traceback + +from contextlib import contextmanager + +import six +from six import reraise + +from . import errors +from . import transport +from .decorators import do_process_check +from .geckoinstance import GeckoInstance +from .keys import Keys +from .timeout import Timeouts + +CHROME_ELEMENT_KEY = "chromeelement-9fc5-4b51-a3c8-01716eedeb04" +FRAME_KEY = "frame-075b-4da1-b6ba-e579c2d3230a" +WEB_ELEMENT_KEY = "element-6066-11e4-a52e-4f735466cecf" +WINDOW_KEY = "window-fcc6-11e5-b4f8-330a88ab9d7f" + + +class MouseButton(object): + """Enum-like class for mouse button constants.""" + + LEFT = 0 + MIDDLE = 1 + RIGHT = 2 + + +class ActionSequence(object): + r"""API for creating and performing action sequences. + + Each action method adds one or more actions to a queue. When perform() + is called, the queued actions fire in order. + + May be chained together as in:: + + ActionSequence(self.marionette, "key", id) \ + .key_down("a") \ + .key_up("a") \ + .perform() + """ + + def __init__(self, marionette, action_type, input_id, pointer_params=None): + self.marionette = marionette + self._actions = [] + self._id = input_id + self._pointer_params = pointer_params + self._type = action_type + + @property + def dict(self): + d = { + "type": self._type, + "id": self._id, + "actions": self._actions, + } + if self._pointer_params is not None: + d["parameters"] = self._pointer_params + return d + + def perform(self): + """Perform all queued actions.""" + self.marionette.actions.perform([self.dict]) + + def _key_action(self, subtype, value): + self._actions.append({"type": subtype, "value": value}) + + def _pointer_action(self, subtype, button): + self._actions.append({"type": subtype, "button": button}) + + def pause(self, duration): + self._actions.append({"type": "pause", "duration": duration}) + return self + + def pointer_move(self, x, y, duration=None, origin=None): + """Queue a pointerMove action. + + :param x: Destination x-axis coordinate of pointer in CSS pixels. + :param y: Destination y-axis coordinate of pointer in CSS pixels. + :param duration: Number of milliseconds over which to distribute the + move. If None, remote end defaults to 0. + :param origin: Origin of coordinates, either "viewport", "pointer" or + an Element. If None, remote end defaults to "viewport". + """ + action = {"type": "pointerMove", "x": x, "y": y} + if duration is not None: + action["duration"] = duration + if origin is not None: + if isinstance(origin, HTMLElement): + action["origin"] = {origin.kind: origin.id} + else: + action["origin"] = origin + self._actions.append(action) + return self + + def pointer_up(self, button=MouseButton.LEFT): + """Queue a pointerUp action for `button`. + + :param button: Pointer button to perform action with. + Default: 0, which represents main device button. + """ + self._pointer_action("pointerUp", button) + return self + + def pointer_down(self, button=MouseButton.LEFT): + """Queue a pointerDown action for `button`. + + :param button: Pointer button to perform action with. + Default: 0, which represents main device button. + """ + self._pointer_action("pointerDown", button) + return self + + def click(self, element=None, button=MouseButton.LEFT): + """Queue a click with the specified button. + + If an element is given, move the pointer to that element first, + otherwise click current pointer coordinates. + + :param element: Optional element to click. + :param button: Integer representing pointer button to perform action + with. Default: 0, which represents main device button. + """ + if element: + self.pointer_move(0, 0, origin=element) + return self.pointer_down(button).pointer_up(button) + + def key_down(self, value): + """Queue a keyDown action for `value`. + + :param value: Single character to perform key action with. + """ + self._key_action("keyDown", value) + return self + + def key_up(self, value): + """Queue a keyUp action for `value`. + + :param value: Single character to perform key action with. + """ + self._key_action("keyUp", value) + return self + + def send_keys(self, keys): + """Queue a keyDown and keyUp action for each character in `keys`. + + :param keys: String of keys to perform key actions with. + """ + for c in keys: + self.key_down(c) + self.key_up(c) + return self + + +class Actions(object): + def __init__(self, marionette): + self.marionette = marionette + + def perform(self, actions=None): + """Perform actions by tick from each action sequence in `actions`. + + :param actions: List of input source action sequences. A single action + sequence may be created with the help of + ``ActionSequence.dict``. + """ + body = {"actions": [] if actions is None else actions} + return self.marionette._send_message("WebDriver:PerformActions", body) + + def release(self): + return self.marionette._send_message("WebDriver:ReleaseActions") + + def sequence(self, *args, **kwargs): + """Return an empty ActionSequence of the designated type. + + See ActionSequence for parameter list. + """ + return ActionSequence(self.marionette, *args, **kwargs) + + +class HTMLElement(object): + """Represents a DOM Element.""" + + identifiers = (CHROME_ELEMENT_KEY, FRAME_KEY, WINDOW_KEY, WEB_ELEMENT_KEY) + + def __init__(self, marionette, id, kind=WEB_ELEMENT_KEY): + self.marionette = marionette + assert id is not None + self.id = id + self.kind = kind + + def __str__(self): + return self.id + + def __eq__(self, other_element): + return self.id == other_element.id + + def __hash__(self): + # pylint --py3k: W1641 + return hash(self.id) + + def find_element(self, method, target): + """Returns an ``HTMLElement`` instance that matches the specified + method and target, relative to the current element. + + For more details on this function, see the + :func:`~marionette_driver.marionette.Marionette.find_element` method + in the Marionette class. + """ + return self.marionette.find_element(method, target, self.id) + + def find_elements(self, method, target): + """Returns a list of all ``HTMLElement`` instances that match the + specified method and target in the current context. + + For more details on this function, see the + :func:`~marionette_driver.marionette.Marionette.find_elements` method + in the Marionette class. + """ + return self.marionette.find_elements(method, target, self.id) + + def get_attribute(self, name): + """Returns the requested attribute, or None if no attribute + is set. + """ + body = {"id": self.id, "name": name} + return self.marionette._send_message( + "WebDriver:GetElementAttribute", body, key="value" + ) + + def get_property(self, name): + """Returns the requested property, or None if the property is + not set. + """ + try: + body = {"id": self.id, "name": name} + return self.marionette._send_message( + "WebDriver:GetElementProperty", body, key="value" + ) + except errors.UnknownCommandException: + # Keep backward compatibility for code which uses get_attribute() to + # also retrieve element properties. + # Remove when Firefox 55 is stable. + return self.get_attribute(name) + + def click(self): + """Simulates a click on the element.""" + self.marionette._send_message("WebDriver:ElementClick", {"id": self.id}) + + def tap(self, x=None, y=None): + """Simulates a set of tap events on the element. + + :param x: X coordinate of tap event. If not given, default to + the centre of the element. + :param y: Y coordinate of tap event. If not given, default to + the centre of the element. + """ + body = {"id": self.id, "x": x, "y": y} + self.marionette._send_message("Marionette:SingleTap", body) + + @property + def text(self): + """Returns the visible text of the element, and its child elements.""" + body = {"id": self.id} + return self.marionette._send_message( + "WebDriver:GetElementText", body, key="value" + ) + + def send_keys(self, *strings): + """Sends the string via synthesized keypresses to the element. + If an array is passed in like `marionette.send_keys(Keys.SHIFT, "a")` it + will be joined into a string. + If an integer is passed in like `marionette.send_keys(1234)` it will be + coerced into a string. + """ + keys = Marionette.convert_keys(*strings) + self.marionette._send_message( + "WebDriver:ElementSendKeys", {"id": self.id, "text": keys} + ) + + def clear(self): + """Clears the input of the element.""" + self.marionette._send_message("WebDriver:ElementClear", {"id": self.id}) + + def is_selected(self): + """Returns True if the element is selected.""" + body = {"id": self.id} + return self.marionette._send_message( + "WebDriver:IsElementSelected", body, key="value" + ) + + def is_enabled(self): + """This command will return False if all the following criteria + are met otherwise return True: + + * A form control is disabled. + * A ``HTMLElement`` has a disabled boolean attribute. + """ + body = {"id": self.id} + return self.marionette._send_message( + "WebDriver:IsElementEnabled", body, key="value" + ) + + def is_displayed(self): + """Returns True if the element is displayed, False otherwise.""" + body = {"id": self.id} + return self.marionette._send_message( + "WebDriver:IsElementDisplayed", body, key="value" + ) + + @property + def tag_name(self): + """The tag name of the element.""" + body = {"id": self.id} + return self.marionette._send_message( + "WebDriver:GetElementTagName", body, key="value" + ) + + @property + def rect(self): + """Gets the element's bounding rectangle. + + This will return a dictionary with the following: + + * x and y represent the top left coordinates of the ``HTMLElement`` + relative to top left corner of the document. + * height and the width will contain the height and the width + of the DOMRect of the ``HTMLElement``. + """ + return self.marionette._send_message( + "WebDriver:GetElementRect", {"id": self.id} + ) + + def value_of_css_property(self, property_name): + """Gets the value of the specified CSS property name. + + :param property_name: Property name to get the value of. + """ + body = {"id": self.id, "propertyName": property_name} + return self.marionette._send_message( + "WebDriver:GetElementCSSValue", body, key="value" + ) + + @classmethod + def _from_json(cls, json, marionette): + if isinstance(json, dict): + if WEB_ELEMENT_KEY in json: + return cls(marionette, json[WEB_ELEMENT_KEY], WEB_ELEMENT_KEY) + elif CHROME_ELEMENT_KEY in json: + return cls(marionette, json[CHROME_ELEMENT_KEY], CHROME_ELEMENT_KEY) + elif FRAME_KEY in json: + return cls(marionette, json[FRAME_KEY], FRAME_KEY) + elif WINDOW_KEY in json: + return cls(marionette, json[WINDOW_KEY], WINDOW_KEY) + raise ValueError("Unrecognised web element") + + +class Alert(object): + """A class for interacting with alerts. + + :: + + Alert(marionette).accept() + Alert(marionette).dismiss() + """ + + def __init__(self, marionette): + self.marionette = marionette + + def accept(self): + """Accept a currently displayed modal dialog.""" + self.marionette._send_message("WebDriver:AcceptAlert") + + def dismiss(self): + """Dismiss a currently displayed modal dialog.""" + self.marionette._send_message("WebDriver:DismissAlert") + + @property + def text(self): + """Return the currently displayed text in a tab modal.""" + return self.marionette._send_message("WebDriver:GetAlertText", key="value") + + def send_keys(self, *string): + """Send keys to the currently displayed text input area in an open + tab modal dialog.""" + self.marionette._send_message( + "WebDriver:SendAlertText", {"text": Marionette.convert_keys(*string)} + ) + + +class Marionette(object): + """Represents a Marionette connection to a browser or device.""" + + CONTEXT_CHROME = "chrome" # non-browser content: windows, dialogs, etc. + CONTEXT_CONTENT = "content" # browser content: iframes, divs, etc. + DEFAULT_STARTUP_TIMEOUT = 120 + DEFAULT_SHUTDOWN_TIMEOUT = ( + 70 # By default Firefox will kill hanging threads after 60s + ) + + # Bug 1336953 - Until we can remove the socket timeout parameter it has to be + # set a default value which is larger than the longest timeout as defined by the + # WebDriver spec. In that case its 300s for page load. Also add another minute + # so that slow builds have enough time to send the timeout error to the client. + DEFAULT_SOCKET_TIMEOUT = 360 + + def __init__( + self, + host="127.0.0.1", + port=2828, + app=None, + bin=None, + baseurl=None, + socket_timeout=None, + startup_timeout=None, + **instance_args + ): + """Construct a holder for the Marionette connection. + + Remember to call ``start_session`` in order to initiate the + connection and start a Marionette session. + + :param host: Host where the Marionette server listens. + Defaults to 127.0.0.1. + :param port: Port where the Marionette server listens. + Defaults to port 2828. + :param baseurl: Where to look for files served from Marionette's + www directory. + :param socket_timeout: Timeout for Marionette socket operations. + :param startup_timeout: Seconds to wait for a connection with + binary. + :param bin: Path to browser binary. If any truthy value is given + this will attempt to start a Gecko instance with the specified + `app`. + :param app: Type of ``instance_class`` to use for managing app + instance. See ``marionette_driver.geckoinstance``. + :param instance_args: Arguments to pass to ``instance_class``. + + """ + self.host = "127.0.0.1" # host + if int(port) == 0: + port = Marionette.check_port_available(port) + self.port = self.local_port = int(port) + self.bin = bin + self.client = None + self.instance = None + self.session = None + self.session_id = None + self.process_id = None + self.profile = None + self.window = None + self.chrome_window = None + self.baseurl = baseurl + self._test_name = None + self.crashed = 0 + self.is_shutting_down = False + + if socket_timeout is None: + self.socket_timeout = self.DEFAULT_SOCKET_TIMEOUT + else: + self.socket_timeout = float(socket_timeout) + + if startup_timeout is None: + self.startup_timeout = self.DEFAULT_STARTUP_TIMEOUT + else: + self.startup_timeout = int(startup_timeout) + + self.shutdown_timeout = self.DEFAULT_SHUTDOWN_TIMEOUT + + if self.bin: + self.instance = GeckoInstance.create( + app, host=self.host, port=self.port, bin=self.bin, **instance_args + ) + self.start_binary(self.startup_timeout) + + self.actions = Actions(self) + self.timeout = Timeouts(self) + + @property + def profile_path(self): + if self.instance and self.instance.profile: + return self.instance.profile.profile + + def start_binary(self, timeout): + try: + self.check_port_available(self.port, host=self.host) + except socket.error: + _, value, tb = sys.exc_info() + msg = "Port {}:{} is unavailable ({})".format(self.host, self.port, value) + reraise(IOError, IOError(msg), tb) + + try: + self.instance.start() + self.raise_for_port(timeout=timeout) + except socket.timeout: + # Something went wrong with starting up Marionette server. Given + # that the process will not quit itself, force a shutdown immediately. + self.cleanup() + + msg = ( + "Process killed after {}s because no connection to Marionette " + "server could be established. Check gecko.log for errors" + ) + reraise(IOError, IOError(msg.format(timeout)), sys.exc_info()[2]) + + def cleanup(self): + if self.session is not None: + try: + self.delete_session() + except (errors.MarionetteException, IOError): + # These exceptions get thrown if the Marionette server + # hit an exception/died or the connection died. We can + # do no further server-side cleanup in this case. + pass + if self.instance: + # stop application and, if applicable, stop emulator + self.instance.close(clean=True) + if self.instance.unresponsive_count >= 3: + raise errors.UnresponsiveInstanceException( + "Application clean-up has failed >2 consecutive times." + ) + + def __del__(self): + self.cleanup() + + @staticmethod + def check_port_available(port, host=""): + """Check if "host:port" is available. + + Raise socket.error if port is not available. + """ + port = int(port) + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + s.bind((host, port)) + port = s.getsockname()[1] + finally: + s.close() + return port + + def raise_for_port(self, timeout=None, check_process_status=True): + """Raise socket.timeout if no connection can be established. + + :param timeout: Optional timeout in seconds for the server to be ready. + :param check_process_status: Optional, if `True` the process will be + continuously checked if it has exited, and the connection + attempt will be aborted. + """ + if timeout is None: + timeout = self.startup_timeout + + runner = None + if self.instance is not None: + runner = self.instance.runner + + poll_interval = 0.1 + starttime = datetime.datetime.now() + timeout_time = starttime + datetime.timedelta(seconds=timeout) + + client = transport.TcpTransport(self.host, self.port, 0.5) + + connected = False + while datetime.datetime.now() < timeout_time: + # If the instance we want to connect to is not running return immediately + if check_process_status and runner is not None and not runner.is_running(): + break + + try: + client.connect() + return True + except socket.error: + pass + finally: + client.close() + + time.sleep(poll_interval) + + if not connected: + # There might have been a startup crash of the application + if runner is not None and self.check_for_crash() > 0: + raise IOError("Process crashed (Exit code: {})".format(runner.wait(0))) + + raise socket.timeout( + "Timed out waiting for connection on {0}:{1}!".format( + self.host, self.port + ) + ) + + @do_process_check + def _send_message(self, name, params=None, key=None): + """Send a blocking message to the server. + + Marionette provides an asynchronous, non-blocking interface and + this attempts to paper over this by providing a synchronous API + to the user. + + :param name: Requested command key. + :param params: Optional dictionary of key/value arguments. + :param key: Optional key to extract from response. + + :returns: Full response from the server, or if `key` is given, + the value of said key in the response. + """ + if not self.session_id and name != "WebDriver:NewSession": + raise errors.InvalidSessionIdException("Please start a session") + + try: + msg = self.client.request(name, params) + + except IOError: + self.delete_session(send_request=False) + raise + + res, err = msg.result, msg.error + if err: + self._handle_error(err) + + if key is not None: + return self._unwrap_response(res.get(key)) + else: + return self._unwrap_response(res) + + def _unwrap_response(self, value): + if isinstance(value, dict) and any( + k in value.keys() for k in HTMLElement.identifiers + ): + return HTMLElement._from_json(value, self) + elif isinstance(value, list): + return list(self._unwrap_response(item) for item in value) + else: + return value + + def _handle_error(self, obj): + error = obj["error"] + message = obj["message"] + stacktrace = obj["stacktrace"] + + raise errors.lookup(error)(message, stacktrace=stacktrace) + + def check_for_crash(self): + """Check if the process crashed. + + :returns: True, if a crash happened since the method has been called the last time. + """ + crash_count = 0 + + if self.instance: + name = self.test_name or "marionette.py" + crash_count = self.instance.runner.check_for_crashes(test_name=name) + self.crashed = self.crashed + crash_count + + return crash_count > 0 + + def _handle_socket_failure(self): + """Handle socket failures for the currently connected application. + + If the application crashed then clean-up internal states, or in case of a content + crash also kill the process. If there are other reasons for a socket failure, + wait for the process to shutdown itself, or force kill it. + + Please note that the method expects an exception to be handled on the current stack + frame, and is only called via the `@do_process_check` decorator. + + """ + exc_cls, exc, tb = sys.exc_info() + + # If the application hasn't been launched by Marionette no further action can be done. + # In such cases we simply re-throw the exception. + if not self.instance: + reraise(exc_cls, exc, tb) + + else: + # Somehow the socket disconnected. Give the application some time to shutdown + # itself before killing the process. + returncode = self.instance.runner.wait(timeout=self.shutdown_timeout) + + if returncode is None: + message = ( + "Process killed because the connection to Marionette server is " + "lost. Check gecko.log for errors" + ) + # This will force-close the application without sending any other message. + self.cleanup() + else: + # If Firefox quit itself check if there was a crash + crash_count = self.check_for_crash() + + if crash_count > 0: + if returncode == 0: + message = "Content process crashed" + else: + message = "Process crashed (Exit code: {returncode})" + else: + message = ( + "Process has been unexpectedly closed (Exit code: {returncode})" + ) + + self.delete_session(send_request=False) + + message += " (Reason: {reason})" + + reraise( + IOError, IOError(message.format(returncode=returncode, reason=exc)), tb + ) + + @staticmethod + def convert_keys(*string): + typing = [] + for val in string: + if isinstance(val, Keys): + typing.append(val) + elif isinstance(val, int): + val = str(val) + for i in range(len(val)): + typing.append(val[i]) + else: + for i in range(len(val)): + typing.append(val[i]) + return "".join(typing) + + def clear_pref(self, pref): + """Clear the user-defined value from the specified preference. + + :param pref: Name of the preference. + """ + with self.using_context(self.CONTEXT_CHROME): + self.execute_script( + """ + Components.utils.import("resource://gre/modules/Preferences.jsm"); + Preferences.reset(arguments[0]); + """, + script_args=(pref,), + ) + + def get_pref(self, pref, default_branch=False, value_type="unspecified"): + """Get the value of the specified preference. + + :param pref: Name of the preference. + :param default_branch: Optional, if `True` the preference value will be read + from the default branch. Otherwise the user-defined + value if set is returned. Defaults to `False`. + :param value_type: Optional, XPCOM interface of the pref's complex value. + Possible values are: `nsIFile` and + `nsIPrefLocalizedString`. + + Usage example:: + + marionette.get_pref("browser.tabs.warnOnClose") + + """ + with self.using_context(self.CONTEXT_CHROME): + pref_value = self.execute_script( + """ + Components.utils.import("resource://gre/modules/Preferences.jsm"); + + let pref = arguments[0]; + let defaultBranch = arguments[1]; + let valueType = arguments[2]; + + prefs = new Preferences({defaultBranch: defaultBranch}); + return prefs.get(pref, null, Components.interfaces[valueType]); + """, + script_args=(pref, default_branch, value_type), + ) + return pref_value + + def set_pref(self, pref, value, default_branch=False): + """Set the value of the specified preference. + + :param pref: Name of the preference. + :param value: The value to set the preference to. If the value is None, + reset the preference to its default value. If no default + value exists, the preference will cease to exist. + :param default_branch: Optional, if `True` the preference value will + be written to the default branch, and will remain until + the application gets restarted. Otherwise a user-defined + value is set. Defaults to `False`. + + Usage example:: + + marionette.set_pref("browser.tabs.warnOnClose", True) + + """ + with self.using_context(self.CONTEXT_CHROME): + if value is None: + self.clear_pref(pref) + return + + self.execute_script( + """ + Components.utils.import("resource://gre/modules/Preferences.jsm"); + + let pref = arguments[0]; + let value = arguments[1]; + let defaultBranch = arguments[2]; + + prefs = new Preferences({defaultBranch: defaultBranch}); + prefs.set(pref, value); + """, + script_args=(pref, value, default_branch), + ) + + def set_prefs(self, prefs, default_branch=False): + """Set the value of a list of preferences. + + :param prefs: A dict containing one or more preferences and their values + to be set. See :func:`set_pref` for further details. + :param default_branch: Optional, if `True` the preference value will + be written to the default branch, and will remain until + the application gets restarted. Otherwise a user-defined + value is set. Defaults to `False`. + + Usage example:: + + marionette.set_prefs({"browser.tabs.warnOnClose": True}) + + """ + for pref, value in prefs.items(): + self.set_pref(pref, value, default_branch=default_branch) + + @contextmanager + def using_prefs(self, prefs, default_branch=False): + """Set preferences for code executed in a `with` block, and restores them on exit. + + :param prefs: A dict containing one or more preferences and their values + to be set. See :func:`set_prefs` for further details. + :param default_branch: Optional, if `True` the preference value will + be written to the default branch, and will remain until + the application gets restarted. Otherwise a user-defined + value is set. Defaults to `False`. + + Usage example:: + + with marionette.using_prefs({"browser.tabs.warnOnClose": True}): + # ... do stuff ... + + """ + original_prefs = {p: self.get_pref(p) for p in prefs} + self.set_prefs(prefs, default_branch=default_branch) + + try: + yield + finally: + self.set_prefs(original_prefs, default_branch=default_branch) + + @do_process_check + def enforce_gecko_prefs(self, prefs): + """Checks if the running instance has the given prefs. If not, + it will kill the currently running instance, and spawn a new + instance with the requested preferences. + + :param prefs: A dictionary whose keys are preference names. + """ + if not self.instance: + raise errors.MarionetteException( + "enforce_gecko_prefs() can only be called " + "on Gecko instances launched by Marionette" + ) + pref_exists = True + with self.using_context(self.CONTEXT_CHROME): + for pref, value in six.iteritems(prefs): + if type(value) is not str: + value = json.dumps(value) + pref_exists = self.execute_script( + """ + let prefInterface = Components.classes["@mozilla.org/preferences-service;1"] + .getService(Components.interfaces.nsIPrefBranch); + let pref = '{0}'; + let value = '{1}'; + let type = prefInterface.getPrefType(pref); + switch(type) {{ + case prefInterface.PREF_STRING: + return value == prefInterface.getCharPref(pref).toString(); + case prefInterface.PREF_BOOL: + return value == prefInterface.getBoolPref(pref).toString(); + case prefInterface.PREF_INT: + return value == prefInterface.getIntPref(pref).toString(); + case prefInterface.PREF_INVALID: + return false; + }} + """.format( + pref, value + ) + ) + if not pref_exists: + break + + if not pref_exists: + context = self._send_message("Marionette:GetContext", key="value") + self.delete_session() + self.instance.restart(prefs) + self.raise_for_port() + self.start_session() + + # Restore the context as used before the restart + self.set_context(context) + + def _request_in_app_shutdown(self, *shutdown_flags): + """Attempt to quit the currently running instance from inside the + application. + + Duplicate entries in `shutdown_flags` are removed, and + `"eForceQuit"` is added if no other `*Quit` flags are given. + This provides backwards compatible behaviour with earlier + Firefoxen. + + This method effectively calls `Services.startup.quit` in Gecko. + Possible flag values are listed at http://mzl.la/1X0JZsC. + + :param shutdown_flags: Optional additional quit masks to include. + Duplicates are removed, and `"eForceQuit"` is added if no + flags ending with `"Quit"` are present. + + :throws InvalidArgumentException: If there are multiple + `shutdown_flags` ending with `"Quit"`. + + :returns: The cause of shutdown. + """ + + # The vast majority of this function was implemented inside + # the quit command as part of bug 1337743, and can be + # removed from here in Firefox 55 at the earliest. + + # remove duplicates + flags = set(shutdown_flags) + + # add eForceQuit if there are no *Quits + if not any(flag.endswith("Quit") for flag in flags): + flags = flags | set(("eForceQuit",)) + + # Trigger a quit-application-requested observer notification + # so that components can safely shutdown before quitting the + # application. + with self.using_context("chrome"): + canceled = self.execute_script( + """ + Components.utils.import("resource://gre/modules/Services.jsm"); + let cancelQuit = Components.classes["@mozilla.org/supports-PRBool;1"] + .createInstance(Components.interfaces.nsISupportsPRBool); + Services.obs.notifyObservers(cancelQuit, "quit-application-requested", null); + return cancelQuit.data; + """ + ) + if canceled: + raise errors.MarionetteException( + "Something cancelled the quit application request" + ) + + body = None + if len(flags) > 0: + body = {"flags": list(flags)} + + return self._send_message("Marionette:Quit", body, key="cause") + + @do_process_check + def quit(self, clean=False, in_app=False, callback=None): + """Terminate the currently running instance. + + This command will delete the active marionette session. It also allows + manipulation of eg. the profile data while the application is not running. + To start the application again, :func:`start_session` has to be called. + + :param clean: If False the same profile will be used after the next start of + the application. Note that the in app initiated restart always + maintains the same profile. + :param in_app: If True, marionette will cause a quit from within the + browser. Otherwise the browser will be quit immediately + by killing the process. + :param callback: If provided and `in_app` is True, the callback will + be used to trigger the shutdown. + """ + if not self.instance: + raise errors.MarionetteException( + "quit() can only be called " "on Gecko instances launched by Marionette" + ) + + cause = None + if in_app: + if callback is not None and not callable(callback): + raise ValueError( + "Specified callback '{}' is not callable".format(callback) + ) + + # Block Marionette from accepting new connections + self._send_message("Marionette:AcceptConnections", {"value": False}) + + try: + self.is_shutting_down = True + if callback is not None: + callback() + else: + cause = self._request_in_app_shutdown() + + except IOError: + # A possible IOError should be ignored at this point, given that + # quit() could have been called inside of `using_context`, + # which wants to reset the context but fails sending the message. + pass + + returncode = self.instance.runner.wait(timeout=self.shutdown_timeout) + if returncode is None: + # The process did not shutdown itself, so force-closing it. + self.cleanup() + + message = "Process still running {}s after quit request" + raise IOError(message.format(self.shutdown_timeout)) + + self.is_shutting_down = False + self.delete_session(send_request=False) + + else: + self.delete_session(send_request=False) + self.instance.close(clean=clean) + + if cause not in (None, "shutdown"): + raise errors.MarionetteException( + "Unexpected shutdown reason '{}' for " + "quitting the process.".format(cause) + ) + + @do_process_check + def restart(self, clean=False, in_app=False, callback=None): + """ + This will terminate the currently running instance, and spawn a new instance + with the same profile and then reuse the session id when creating a session again. + + :param clean: If False the same profile will be used after the restart. Note + that the in app initiated restart always maintains the same + profile. + :param in_app: If True, marionette will cause a restart from within the + browser. Otherwise the browser will be restarted immediately + by killing the process. + :param callback: If provided and `in_app` is True, the callback will be + used to trigger the restart. + """ + if not self.instance: + raise errors.MarionetteException( + "restart() can only be called " + "on Gecko instances launched by Marionette" + ) + context = self._send_message("Marionette:GetContext", key="value") + + cause = None + if in_app: + if clean: + raise ValueError( + "An in_app restart cannot be triggered with the clean flag set" + ) + + if callback is not None and not callable(callback): + raise ValueError( + "Specified callback '{}' is not callable".format(callback) + ) + + # Block Marionette from accepting new connections + self._send_message("Marionette:AcceptConnections", {"value": False}) + + try: + self.is_shutting_down = True + if callback is not None: + callback() + else: + cause = self._request_in_app_shutdown("eRestart") + + except IOError: + # A possible IOError should be ignored at this point, given that + # restart() could have been called inside of `using_context`, + # which wants to reset the context but fails sending the message. + pass + + timeout_restart = self.shutdown_timeout + self.startup_timeout + try: + # Wait for a new Marionette connection to appear while the + # process restarts itself. + self.raise_for_port(timeout=timeout_restart, check_process_status=False) + except socket.timeout: + exc_cls, _, tb = sys.exc_info() + + if self.instance.runner.returncode is None: + # The process is still running, which means the shutdown + # request was not correct or the application ignored it. + # Allow Marionette to accept connections again. + self._send_message("Marionette:AcceptConnections", {"value": True}) + + message = "Process still running {}s after restart request" + reraise(exc_cls, exc_cls(message.format(timeout_restart)), tb) + + else: + # The process shutdown but didn't start again. + self.cleanup() + msg = "Process unexpectedly quit without restarting (exit code: {})" + reraise( + exc_cls, + exc_cls(msg.format(self.instance.runner.returncode)), + tb, + ) + + finally: + self.is_shutting_down = False + + self.delete_session(send_request=False) + + else: + self.delete_session() + self.instance.restart(clean=clean) + self.raise_for_port(timeout=self.DEFAULT_STARTUP_TIMEOUT) + + if cause not in (None, "restart"): + raise errors.MarionetteException( + "Unexpected shutdown reason '{}' for " + "restarting the process".format(cause) + ) + + self.start_session() + # Restore the context as used before the restart + self.set_context(context) + + if in_app and self.process_id: + # In some cases Firefox restarts itself by spawning into a new process group. + # As long as mozprocess cannot track that behavior (bug 1284864) we assist by + # informing about the new process id. + self.instance.runner.process_handler.check_for_detached(self.process_id) + + def absolute_url(self, relative_url): + """ + Returns an absolute url for files served from Marionette's www directory. + + :param relative_url: The url of a static file, relative to Marionette's www directory. + """ + return "{0}{1}".format(self.baseurl, relative_url) + + @do_process_check + def start_session(self, capabilities=None, timeout=None): + """Create a new WebDriver session. + This method must be called before performing any other action. + + :param capabilities: An optional dictionary of + Marionette-recognised capabilities. It does not + accept a WebDriver conforming capabilities dictionary + (including alwaysMatch, firstMatch, desiredCapabilities, + or requriedCapabilities), and only recognises extension + capabilities that are specific to Marionette. + :param timeout: Optional timeout in seconds for the server to be ready. + :returns: A dictionary of the capabilities offered. + """ + if capabilities is None: + capabilities = {"strictFileInteractability": True} + + if timeout is None: + timeout = self.startup_timeout + + self.crashed = 0 + + if self.instance: + returncode = self.instance.runner.returncode + # We're managing a binary which has terminated. Start it again + # and implicitely wait for the Marionette server to be ready. + if returncode is not None: + self.start_binary(timeout) + + else: + # In the case when Marionette doesn't manage the binary wait until + # its server component has been started. + self.raise_for_port(timeout=timeout) + + self.client = transport.TcpTransport(self.host, self.port, self.socket_timeout) + self.protocol, _ = self.client.connect() + + try: + resp = self._send_message("WebDriver:NewSession", capabilities) + except errors.UnknownException: + # Force closing the managed process when the session cannot be + # created due to global JavaScript errors. + exc_type, value, tb = sys.exc_info() + if self.instance and self.instance.runner.is_running(): + self.instance.close() + reraise(exc_type, exc_type(value.message), tb) + + self.session_id = resp["sessionId"] + self.session = resp["capabilities"] + # fallback to processId can be removed in Firefox 55 + self.process_id = self.session.get( + "moz:processID", self.session.get("processId") + ) + self.profile = self.session.get("moz:profile") + + timeout = self.session.get("moz:shutdownTimeout") + if timeout is not None: + # pylint --py3k W1619 + self.shutdown_timeout = timeout / 1000 + 10 + + return self.session + + @property + def test_name(self): + return self._test_name + + @test_name.setter + def test_name(self, test_name): + self._test_name = test_name + + def delete_session(self, send_request=True): + """Close the current session and disconnect from the server. + + :param send_request: Optional, if `True` a request to close the session on + the server side will be sent. Use `False` in case of eg. in_app restart() + or quit(), which trigger a deletion themselves. Defaults to `True`. + """ + try: + if send_request: + try: + self._send_message("WebDriver:DeleteSession") + except errors.InvalidSessionIdException: + pass + finally: + self.process_id = None + self.profile = None + self.session = None + self.session_id = None + self.window = None + + if self.client is not None: + self.client.close() + + @property + def session_capabilities(self): + """A JSON dictionary representing the capabilities of the + current session. + + """ + return self.session + + @property + def current_window_handle(self): + """Get the current window's handle. + + Returns 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: unique window handle + :rtype: string + """ + self.window = self._send_message("WebDriver:GetWindowHandle", key="value") + return self.window + + @property + def current_chrome_window_handle(self): + """Get the current chrome window's handle. Corresponds to + a chrome window that may itself contain tabs identified by + window_handles. + + Returns 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: unique window handle + :rtype: string + """ + self.chrome_window = self._send_message( + "WebDriver:GetChromeWindowHandle", key="value" + ) + + return self.chrome_window + + def set_window_rect(self, x=None, y=None, height=None, width=None): + """Set the position and size of the current window. + + The supplied width and height values refer to the window outerWidth + and outerHeight values, which include scroll bars, title bars, etc. + + An error will be returned if the requested window size would result + in the window being in the maximised state. + + :param x: x coordinate for the top left of the window + :param y: y coordinate for the top left of the window + :param width: The width to resize the window to. + :param height: The height to resize the window to. + """ + if (x is None and y is None) and (height is None and width is None): + raise errors.InvalidArgumentException( + "x and y or height and width need values" + ) + + body = {"x": x, "y": y, "height": height, "width": width} + return self._send_message("WebDriver:SetWindowRect", body) + + @property + def window_rect(self): + return self._send_message("WebDriver:GetWindowRect") + + @property + def title(self): + """Current title of the active window.""" + return self._send_message("WebDriver:GetTitle", key="value") + + @property + def window_handles(self): + """Get list of windows in the current context. + + If called in the content context it will return a list of + references to all available browser windows. Called in the + chrome context, it will list all available windows, not just + browser windows (e.g. not just navigator.browser). + + Each window handle is assigned by the server, and the list of + strings returned does not have a guaranteed ordering. + + :returns: Unordered list of unique window handles as strings + """ + return self._send_message("WebDriver:GetWindowHandles") + + @property + def chrome_window_handles(self): + """Get a list of currently open chrome windows. + + Each window handle is assigned by the server, and the list of + strings returned does not have a guaranteed ordering. + + :returns: Unordered list of unique chrome window handles as strings + """ + return self._send_message("WebDriver:GetChromeWindowHandles") + + @property + def page_source(self): + """A string representation of the DOM.""" + return self._send_message("WebDriver:GetPageSource", key="value") + + def open(self, type=None, focus=False, private=False): + """Open a new window, or tab based on the specified context type. + + If no context type is given the application will choose the best + option based on tab and window support. + + :param type: Type of window to be opened. Can be one of "tab" or "window" + :param focus: If true, the opened window will be focused + :param private: If true, open a private window + + :returns: Dict with new window handle, and type of opened window + """ + body = {"type": type, "focus": focus, "private": private} + return self._send_message("WebDriver:NewWindow", body) + + def close(self): + """Close the current window, ending the session if it's the last + window currently open. + + :returns: Unordered list of remaining unique window handles as strings + """ + return self._send_message("WebDriver:CloseWindow") + + def close_chrome_window(self): + """Close the currently selected chrome window, ending the session + if it's the last window open. + + :returns: Unordered list of remaining unique chrome window handles as strings + """ + return self._send_message("WebDriver:CloseChromeWindow") + + def set_context(self, context): + """Sets the context that Marionette commands are running in. + + :param context: Context, may be one of the class properties + `CONTEXT_CHROME` or `CONTEXT_CONTENT`. + + Usage example:: + + marionette.set_context(marionette.CONTEXT_CHROME) + """ + if context not in [self.CONTEXT_CHROME, self.CONTEXT_CONTENT]: + raise ValueError("Unknown context: {}".format(context)) + + self._send_message("Marionette:SetContext", {"value": context}) + + @contextmanager + def using_context(self, context): + """Sets the context that Marionette commands are running in using + a `with` statement. The state of the context on the server is + saved before entering the block, and restored upon exiting it. + + :param context: Context, may be one of the class properties + `CONTEXT_CHROME` or `CONTEXT_CONTENT`. + + Usage example:: + + with marionette.using_context(marionette.CONTEXT_CHROME): + # chrome scope + ... do stuff ... + """ + scope = self._send_message("Marionette:GetContext", key="value") + self.set_context(context) + try: + yield + finally: + self.set_context(scope) + + def switch_to_alert(self): + """Returns an :class:`~marionette_driver.marionette.Alert` object for + interacting with a currently displayed alert. + + :: + + alert = self.marionette.switch_to_alert() + text = alert.text + alert.accept() + """ + return Alert(self) + + def switch_to_window(self, handle, focus=True): + """Switch to the specified window; subsequent commands will be + directed at the new window. + + :param handle: The id of the window to switch to. + + :param focus: A boolean value which determins whether to focus + the window that we just switched to. + """ + self._send_message( + "WebDriver:SwitchToWindow", {"handle": handle, "focus": focus} + ) + self.window = handle + + def switch_to_default_content(self): + """Switch the current context to page's default content.""" + return self.switch_to_frame() + + def switch_to_parent_frame(self): + """ + Switch to the Parent Frame + """ + self._send_message("WebDriver:SwitchToParentFrame") + + def switch_to_frame(self, frame=None): + """Switch the current context to the specified frame. Subsequent + commands will operate in the context of the specified frame, + if applicable. + + :param frame: A reference to the frame to switch to. This can + be an :class:`~marionette_driver.marionette.HTMLElement`, + or an integer index. If you call ``switch_to_frame`` without an + argument, it will switch to the top-level frame. + """ + body = {} + if isinstance(frame, HTMLElement): + body["element"] = frame.id + elif frame is not None: + body["id"] = frame + + self._send_message("WebDriver:SwitchToFrame", body) + + def get_url(self): + """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. + + :returns: string representation of URL + """ + return self._send_message("WebDriver:GetCurrentURL", key="value") + + def get_window_type(self): + """Gets the windowtype attribute of the window Marionette is + currently acting on. + + This command only makes sense in a chrome context. You might use this + method to distinguish a browser window from an editor window. + """ + try: + return self._send_message("Marionette:GetWindowType", key="value") + except errors.UnknownCommandException: + return self._send_message("getWindowType", key="value") + + def navigate(self, url): + """Navigate to given `url`. + + Navigates the current top-level browsing context's content + frame 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 the host, the URL is malformed, the page is + restricted (about:* pages), 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 + `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 url: The URL to navigate to. + """ + self._send_message("WebDriver:Navigate", {"url": url}) + + def go_back(self): + """Causes the browser to perform a back navigation.""" + self._send_message("WebDriver:Back") + + def go_forward(self): + """Causes the browser to perform a forward navigation.""" + self._send_message("WebDriver:Forward") + + def refresh(self): + """Causes the browser to perform to refresh the current page.""" + self._send_message("WebDriver:Refresh") + + def _to_json(self, args): + if isinstance(args, list) or isinstance(args, tuple): + wrapped = [] + for arg in args: + wrapped.append(self._to_json(arg)) + elif isinstance(args, dict): + wrapped = {} + for arg in args: + wrapped[arg] = self._to_json(args[arg]) + elif type(args) == HTMLElement: + wrapped = {WEB_ELEMENT_KEY: args.id, CHROME_ELEMENT_KEY: args.id} + elif ( + isinstance(args, bool) + or isinstance(args, six.string_types) + or isinstance(args, int) + or isinstance(args, float) + or args is None + ): + wrapped = args + return wrapped + + def _from_json(self, value): + if isinstance(value, list): + unwrapped = [] + for item in value: + unwrapped.append(self._from_json(item)) + return unwrapped + elif isinstance(value, dict): + unwrapped = {} + for key in value: + if key in HTMLElement.identifiers: + return HTMLElement._from_json(value[key], self) + else: + unwrapped[key] = self._from_json(value[key]) + return unwrapped + else: + return value + + def execute_script( + self, + script, + script_args=(), + new_sandbox=True, + sandbox="default", + script_timeout=None, + ): + """Executes a synchronous JavaScript script, and returns the + result (or None if the script does return a value). + + The script is executed in the context set by the most recent + :func:`set_context` call, or to the CONTEXT_CONTENT context if + :func:`set_context` has not been called. + + :param script: A string containing the JavaScript to execute. + :param script_args: An interable of arguments to pass to the script. + :param new_sandbox: If False, preserve global variables from + the last execute_*script call. This is True by default, in which + case no globals are preserved. + :param sandbox: A tag referring to the sandbox you wish to use; + if you specify a new tag, a new sandbox will be created. + If you use the special tag `system`, the sandbox will + be created using the system principal which has elevated + privileges. + :param script_timeout: Timeout in milliseconds, overriding + the session's default script timeout. + + Simple usage example: + + :: + + result = marionette.execute_script("return 1;") + assert result == 1 + + You can use the `script_args` parameter to pass arguments to the + script: + + :: + + result = marionette.execute_script("return arguments[0] + arguments[1];", + script_args=(2, 3,)) + assert result == 5 + some_element = marionette.find_element(By.ID, "someElement") + sid = marionette.execute_script("return arguments[0].id;", script_args=(some_element,)) + assert some_element.get_attribute("id") == sid + + Scripts wishing to access non-standard properties of the window + object must use window.wrappedJSObject: + + :: + + result = marionette.execute_script(''' + window.wrappedJSObject.test1 = "foo"; + window.wrappedJSObject.test2 = "bar"; + return window.wrappedJSObject.test1 + window.wrappedJSObject.test2; + ''') + assert result == "foobar" + + Global variables set by individual scripts do not persist between + script calls by default. If you wish to persist data between + script calls, you can set `new_sandbox` to False on your next call, + and add any new variables to a new 'global' object like this: + + :: + + marionette.execute_script("global.test1 = 'foo';") + result = self.marionette.execute_script("return global.test1;", new_sandbox=False) + assert result == "foo" + + """ + original_timeout = None + if script_timeout is not None: + original_timeout = self.timeout.script + self.timeout.script = script_timeout / 1000.0 + + try: + args = self._to_json(script_args) + stack = traceback.extract_stack() + frame = stack[-2:-1][0] # grab the second-to-last frame + filename = ( + frame[0] if sys.platform == "win32" else os.path.relpath(frame[0]) + ) + body = { + "script": script.strip(), + "args": args, + "newSandbox": new_sandbox, + "sandbox": sandbox, + "line": int(frame[1]), + "filename": filename, + } + rv = self._send_message("WebDriver:ExecuteScript", body, key="value") + + finally: + if script_timeout is not None: + self.timeout.script = original_timeout + + return self._from_json(rv) + + def execute_async_script( + self, + script, + script_args=(), + new_sandbox=True, + sandbox="default", + script_timeout=None, + ): + """Executes an asynchronous JavaScript script, and returns the + result (or None if the script does return a value). + + The script is executed in the context set by the most recent + :func:`set_context` call, or to the CONTEXT_CONTENT context if + :func:`set_context` has not been called. + + :param script: A string containing the JavaScript to execute. + :param script_args: An interable of arguments to pass to the script. + :param new_sandbox: If False, preserve global variables from + the last execute_*script call. This is True by default, + in which case no globals are preserved. + :param sandbox: A tag referring to the sandbox you wish to use; if + you specify a new tag, a new sandbox will be created. If you + use the special tag `system`, the sandbox will be created + using the system principal which has elevated privileges. + :param script_timeout: Timeout in milliseconds, overriding + the session's default script timeout. + + Usage example: + + :: + + marionette.timeout.script = 10 + result = self.marionette.execute_async_script(''' + // this script waits 5 seconds, and then returns the number 1 + let [resolve] = arguments; + setTimeout(function() { + resolve(1); + }, 5000); + ''') + assert result == 1 + """ + original_timeout = None + if script_timeout is not None: + original_timeout = self.timeout.script + self.timeout.script = script_timeout / 1000.0 + + try: + args = self._to_json(script_args) + stack = traceback.extract_stack() + frame = stack[-2:-1][0] # grab the second-to-last frame + filename = ( + frame[0] if sys.platform == "win32" else os.path.relpath(frame[0]) + ) + body = { + "script": script.strip(), + "args": args, + "newSandbox": new_sandbox, + "sandbox": sandbox, + "scriptTimeout": script_timeout, + "line": int(frame[1]), + "filename": filename, + } + rv = self._send_message("WebDriver:ExecuteAsyncScript", body, key="value") + + finally: + if script_timeout is not None: + self.timeout.script = original_timeout + + return self._from_json(rv) + + def find_element(self, method, target, id=None): + """Returns an :class:`~marionette_driver.marionette.HTMLElement` + instance that matches the specified method and target in the current + context. + + An :class:`~marionette_driver.marionette.HTMLElement` instance may be + used to call other methods on the element, such as + :func:`~marionette_driver.marionette.HTMLElement.click`. If no element + is immediately found, the attempt to locate an element will be repeated + for up to the amount of time set by + :attr:`marionette_driver.timeout.Timeouts.implicit`. If multiple + elements match the given criteria, only the first is returned. If no + element matches, a ``NoSuchElementException`` will be raised. + + :param method: The method to use to locate the element; one of: + "id", "name", "class name", "tag name", "css selector", + "link text", "partial link text" and "xpath". + Note that the "name", "link text" and "partial link test" + methods are not supported in the chrome DOM. + :param target: The target of the search. For example, if method = + "tag", target might equal "div". If method = "id", target would + be an element id. + :param id: If specified, search for elements only inside the element + with the specified id. + """ + body = {"value": target, "using": method} + if id: + body["element"] = id + + return self._send_message("WebDriver:FindElement", body, key="value") + + def find_elements(self, method, target, id=None): + """Returns a list of all + :class:`~marionette_driver.marionette.HTMLElement` instances that match + the specified method and target in the current context. + + An :class:`~marionette_driver.marionette.HTMLElement` instance may be + used to call other methods on the element, such as + :func:`~marionette_driver.marionette.HTMLElement.click`. If no element + is immediately found, the attempt to locate an element will be repeated + for up to the amount of time set by + :attr:`marionette_driver.timeout.Timeouts.implicit`. + + :param method: The method to use to locate the elements; one + of: "id", "name", "class name", "tag name", "css selector", + "link text", "partial link text" and "xpath". + Note that the "name", "link text" and "partial link test" + methods are not supported in the chrome DOM. + :param target: The target of the search. For example, if method = + "tag", target might equal "div". If method = "id", target would be + an element id. + :param id: If specified, search for elements only inside the element + with the specified id. + """ + body = {"value": target, "using": method} + if id: + body["element"] = id + + return self._send_message("WebDriver:FindElements", body) + + def get_active_element(self): + el_or_ref = self._send_message("WebDriver:GetActiveElement", key="value") + return el_or_ref + + def add_cookie(self, cookie): + """Adds a cookie to your current session. + + :param cookie: A dictionary object, with required keys - "name" + and "value"; optional keys - "path", "domain", "secure", + "expiry". + + Usage example: + + :: + + driver.add_cookie({"name": "foo", "value": "bar"}) + driver.add_cookie({"name": "foo", "value": "bar", "path": "/"}) + driver.add_cookie({"name": "foo", "value": "bar", "path": "/", + "secure": True}) + """ + self._send_message("WebDriver:AddCookie", {"cookie": cookie}) + + def delete_all_cookies(self): + """Delete all cookies in the scope of the current session. + + Usage example: + + :: + + driver.delete_all_cookies() + """ + self._send_message("WebDriver:DeleteAllCookies") + + def delete_cookie(self, name): + """Delete a cookie by its name. + + :param name: Name of cookie to delete. + + Usage example: + + :: + + driver.delete_cookie("foo") + """ + self._send_message("WebDriver:DeleteCookie", {"name": name}) + + def get_cookie(self, name): + """Get a single cookie by name. Returns the cookie if found, + None if not. + + :param name: Name of cookie to get. + """ + cookies = self.get_cookies() + for cookie in cookies: + if cookie["name"] == name: + return cookie + return None + + def get_cookies(self): + """Get all the cookies for the current domain. + + This is the equivalent of calling `document.cookie` and + parsing the result. + + :returns: A list of cookies for the current domain. + """ + return self._send_message("WebDriver:GetCookies") + + def save_screenshot(self, fh, element=None, full=True, scroll=True): + """Takes a screenhot of a web element or the current frame and + saves it in the filehandle. + + It is a wrapper around screenshot() + :param fh: The filehandle to save the screenshot at. + + The rest of the parameters are defined like in screenshot() + """ + data = self.screenshot(element, "binary", full, scroll) + fh.write(data) + + def screenshot(self, element=None, format="base64", full=True, scroll=True): + """Takes a screenshot of a web element or the current frame. + + The screen capture is returned as a lossless PNG image encoded + as a base 64 string by default. If the `element` argument is defined 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. + + :param element: The element to take a screenshot of. If None, will + take a screenshot of the current frame. + + :param format: if "base64" (the default), returns the screenshot + as a base64-string. If "binary", the data is decoded and + returned as raw binary. If "hash", the data is hashed using + the SHA-256 algorithm and the result is returned as a hex digest. + + :param full: If True (the default), the capture area will be the + complete frame. Else only the viewport is captured. Only applies + when `element` is None. + + :param scroll: When `element` is provided, scroll to it before + taking the screenshot (default). Otherwise, avoid scrolling + `element` into view. + """ + + if element: + element = element.id + + body = {"id": element, "full": full, "hash": False, "scroll": scroll} + if format == "hash": + body["hash"] = True + + data = self._send_message("WebDriver:TakeScreenshot", body, key="value") + + if format == "base64" or format == "hash": + return data + elif format == "binary": + return base64.b64decode(data.encode("ascii")) + else: + raise ValueError( + "format parameter must be either 'base64'" + " or 'binary', not {0}".format(repr(format)) + ) + + @property + def orientation(self): + """Get the current browser orientation. + + Will return one of the valid primary orientation values + portrait-primary, landscape-primary, portrait-secondary, or + landscape-secondary. + """ + try: + return self._send_message("Marionette:GetScreenOrientation", key="value") + except errors.UnknownCommandException: + return self._send_message("getScreenOrientation", key="value") + + def set_orientation(self, orientation): + """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". + + :param orientation: The orientation to lock the screen in. + """ + body = {"orientation": orientation} + try: + self._send_message("Marionette:SetScreenOrientation", body) + except errors.UnknownCommandException: + self._send_message("setScreenOrientation", body) + + def minimize_window(self): + """Iconify the browser window currently receiving commands. + The action should be equivalent to the user pressing the minimize + button in the OS window. + + Note that this command is not available on Fennec. It may also + not be available in certain window managers. + + :returns Window rect. + """ + return self._send_message("WebDriver:MinimizeWindow") + + def maximize_window(self): + """Resize the browser window currently receiving commands. + The action should be equivalent to the user pressing the maximize + button in the OS window. + + + Note that this command is not available on Fennec. It may also + not be available in certain window managers. + + :returns: Window rect. + """ + return self._send_message("WebDriver:MaximizeWindow") + + def fullscreen(self): + """Synchronously sets the user agent window to full screen as + if the user had done "View > Enter Full Screen", or restores + it if it is already in full screen. + + :returns: Window rect. + """ + return self._send_message("WebDriver:FullscreenWindow") diff --git a/testing/marionette/client/marionette_driver/timeout.py b/testing/marionette/client/marionette_driver/timeout.py new file mode 100644 index 0000000000..99a9746082 --- /dev/null +++ b/testing/marionette/client/marionette_driver/timeout.py @@ -0,0 +1,106 @@ +# 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/. + +from __future__ import absolute_import + +from . import errors + + +DEFAULT_SCRIPT_TIMEOUT = 30 +DEFAULT_PAGE_LOAD_TIMEOUT = 300 +DEFAULT_IMPLICIT_WAIT_TIMEOUT = 0 + + +class Timeouts(object): + """Manage timeout settings in the Marionette session. + + Usage:: + + marionette = Marionette(...) + marionette.start_session() + marionette.timeout.page_load = 10 + marionette.timeout.page_load + # => 10 + + """ + + def __init__(self, marionette): + self._marionette = marionette + + def _set(self, name, sec): + ms = sec * 1000 + self._marionette._send_message("WebDriver:SetTimeouts", {name: ms}) + + def _get(self, name): + ts = self._marionette._send_message("WebDriver:GetTimeouts") + if name not in ts: + raise KeyError() + ms = ts[name] + return ms / 1000.0 + + @property + def script(self): + """Get the session's script timeout. This specifies the time + to wait for injected scripts to finished before interrupting + them. It is by default 30 seconds. + + """ + return self._get("script") + + @script.setter + def script(self, sec): + """Set the session's script timeout. This specifies the time + to wait for injected scripts to finish before interrupting them. + + """ + self._set("script", sec) + + @property + def page_load(self): + """Get the session's page load timeout. This specifies the time + to wait for the page loading to complete. It is by default 5 + minutes (or 300 seconds). + + """ + # remove fallback when Firefox 56 is stable + try: + return self._get("pageLoad") + except KeyError: + return self._get("page load") + + @page_load.setter + def page_load(self, sec): + """Set the session's page load timeout. This specifies the time + to wait for the page loading to complete. + + """ + # remove fallback when Firefox 56 is stable + try: + self._set("pageLoad", sec) + except errors.InvalidArgumentException: + return self._set("page load", sec) + + @property + def implicit(self): + """Get the session's implicit wait timeout. This specifies the + time to wait for the implicit element location strategy when + retrieving elements. It is by default disabled (0 seconds). + + """ + return self._get("implicit") + + @implicit.setter + def implicit(self, sec): + """Set the session's implicit wait timeout. This specifies the + time to wait for the implicit element location strategy when + retrieving elements. + + """ + self._set("implicit", sec) + + def reset(self): + """Resets timeouts to their default values.""" + self.script = DEFAULT_SCRIPT_TIMEOUT + self.page_load = DEFAULT_PAGE_LOAD_TIMEOUT + self.implicit = DEFAULT_IMPLICIT_WAIT_TIMEOUT diff --git a/testing/marionette/client/marionette_driver/transport.py b/testing/marionette/client/marionette_driver/transport.py new file mode 100644 index 0000000000..1cdc777d9d --- /dev/null +++ b/testing/marionette/client/marionette_driver/transport.py @@ -0,0 +1,318 @@ +# 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/. + +from __future__ import absolute_import + +import json +import socket +import sys +import time + +import six + + +class SocketTimeout(object): + def __init__(self, socket, timeout): + self.sock = socket + self.timeout = timeout + self.old_timeout = None + + def __enter__(self): + self.old_timeout = self.sock.gettimeout() + self.sock.settimeout(self.timeout) + + def __exit__(self, *args, **kwargs): + self.sock.settimeout(self.old_timeout) + + +class Message(object): + def __init__(self, msgid): + self.id = msgid + + def __eq__(self, other): + return self.id == other.id + + def __ne__(self, other): + return not self.__eq__(other) + + def __hash__(self): + # pylint --py3k: W1641 + return hash(self.id) + + +class Command(Message): + TYPE = 0 + + def __init__(self, msgid, name, params): + Message.__init__(self, msgid) + self.name = name + self.params = params + + def __str__(self): + return "<Command id={0}, name={1}, params={2}>".format( + self.id, self.name, self.params + ) + + def to_msg(self): + msg = [Command.TYPE, self.id, self.name, self.params] + return json.dumps(msg) + + @staticmethod + def from_msg(payload): + data = json.loads(payload) + assert data[0] == Command.TYPE + cmd = Command(data[1], data[2], data[3]) + return cmd + + +class Response(Message): + TYPE = 1 + + def __init__(self, msgid, error, result): + Message.__init__(self, msgid) + self.error = error + self.result = result + + def __str__(self): + return "<Response id={0}, error={1}, result={2}>".format( + self.id, self.error, self.result + ) + + def to_msg(self): + msg = [Response.TYPE, self.id, self.error, self.result] + return json.dumps(msg) + + @staticmethod + def from_msg(payload): + data = json.loads(payload) + assert data[0] == Response.TYPE + return Response(data[1], data[2], data[3]) + + +class TcpTransport(object): + """Socket client that communciates with Marionette via TCP. + + It speaks the protocol of the remote debugger in Gecko, in which + messages are always preceded by the message length and a colon, e.g.: + + 7:MESSAGE + + On top of this protocol it uses a Marionette message format, that + depending on the protocol level offered by the remote server, varies. + Supported protocol levels are `min_protocol_level` and above. + """ + + max_packet_length = 4096 + min_protocol_level = 3 + + def __init__(self, host, port, socket_timeout=60.0): + """If `socket_timeout` is `0` or `0.0`, non-blocking socket mode + will be used. Setting it to `1` or `None` disables timeouts on + socket operations altogether. + """ + self._sock = None + + self.host = host + self.port = port + self.socket_timeout = socket_timeout + + self.protocol = self.min_protocol_level + self.application_type = None + self.last_id = 0 + self.expected_response = None + + @property + def socket_timeout(self): + return self._socket_timeout + + @socket_timeout.setter + def socket_timeout(self, value): + self._socket_timeout = value + + if self._sock: + self._sock.settimeout(value) + + def _unmarshal(self, packet): + msg = None + + # protocol 3 and above + if self.protocol >= 3: + if six.PY3: + typ = int(chr(packet[1])) + else: + typ = int(packet[1]) + if typ == Command.TYPE: + msg = Command.from_msg(packet) + elif typ == Response.TYPE: + msg = Response.from_msg(packet) + + return msg + + def receive(self, unmarshal=True): + """Wait for the next complete response from the remote. + + :param unmarshal: Default is to deserialise the packet and + return a ``Message`` type. Setting this to false will return + the raw packet. + """ + now = time.time() + data = b"" + bytes_to_recv = 10 + + while self.socket_timeout is None or (time.time() - now < self.socket_timeout): + try: + chunk = self._sock.recv(bytes_to_recv) + data += chunk + except socket.timeout: + pass + else: + if not chunk: + raise socket.error("No data received over socket") + + sep = data.find(b":") + if sep > -1: + length = data[0:sep] + remaining = data[sep + 1 :] + + if len(remaining) == int(length): + if unmarshal: + msg = self._unmarshal(remaining) + self.last_id = msg.id + + # keep reading incoming responses until + # we receive the user's expected response + if isinstance(msg, Response) and msg != self.expected_response: + return self.receive(unmarshal) + + return msg + + else: + return remaining + + bytes_to_recv = int(length) - len(remaining) + + raise socket.timeout( + "Connection timed out after {}s".format(self.socket_timeout) + ) + + def connect(self): + """Connect to the server and process the hello message we expect + to receive in response. + + Returns a tuple of the protocol level and the application type. + """ + try: + self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self._sock.settimeout(self.socket_timeout) + + self._sock.connect((self.host, self.port)) + except Exception: + # Unset so that the next attempt to send will cause + # another connection attempt. + self._sock = None + raise + + try: + with SocketTimeout(self._sock, 60.0): + # first packet is always a JSON Object + # which we can use to tell which protocol level we are at + raw = self.receive(unmarshal=False) + except socket.timeout: + exc_cls, exc, tb = sys.exc_info() + msg = "Connection attempt failed because no data has been received over the socket: {}" + six.reraise(exc_cls, exc_cls(msg.format(exc)), tb) + + hello = json.loads(raw) + application_type = hello.get("applicationType") + protocol = hello.get("marionetteProtocol") + + if application_type != "gecko": + raise ValueError( + "Application type '{}' is not supported".format(application_type) + ) + + if not isinstance(protocol, int) or protocol < self.min_protocol_level: + msg = "Earliest supported protocol level is '{}' but got '{}'" + raise ValueError(msg.format(self.min_protocol_level, protocol)) + + self.application_type = application_type + self.protocol = protocol + + return (self.protocol, self.application_type) + + def send(self, obj): + """Send message to the remote server. Allowed input is a + ``Message`` instance or a JSON serialisable object. + """ + if not self._sock: + self.connect() + + if isinstance(obj, Message): + data = obj.to_msg() + if isinstance(obj, Command): + self.expected_response = obj + else: + data = json.dumps(obj) + data = six.ensure_binary(data) + payload = six.ensure_binary(str(len(data))) + b":" + data + + totalsent = 0 + while totalsent < len(payload): + sent = self._sock.send(payload[totalsent:]) + if sent == 0: + raise IOError( + "Socket error after sending {0} of {1} bytes".format( + totalsent, len(payload) + ) + ) + else: + totalsent += sent + + def respond(self, obj): + """Send a response to a command. This can be an arbitrary JSON + serialisable object or an ``Exception``. + """ + res, err = None, None + if isinstance(obj, Exception): + err = obj + else: + res = obj + msg = Response(self.last_id, err, res) + self.send(msg) + return self.receive() + + def request(self, name, params): + """Sends a message to the remote server and waits for a response + to come back. + """ + self.last_id = self.last_id + 1 + cmd = Command(self.last_id, name, params) + self.send(cmd) + return self.receive() + + def close(self): + """Close the socket. + + First forces the socket to not send data anymore, and then explicitly + close it to free up its resources. + + See: https://docs.python.org/2/howto/sockets.html#disconnecting + """ + if self._sock: + try: + self._sock.shutdown(socket.SHUT_RDWR) + except IOError as exc: + # If the socket is already closed, don't care about: + # Errno 57: Socket not connected + # Errno 107: Transport endpoint is not connected + if exc.errno not in (57, 107): + raise + + if self._sock: + # Guard against unclean shutdown. + self._sock.close() + self._sock = None + + def __del__(self): + self.close() diff --git a/testing/marionette/client/marionette_driver/wait.py b/testing/marionette/client/marionette_driver/wait.py new file mode 100644 index 0000000000..caa9cb1f86 --- /dev/null +++ b/testing/marionette/client/marionette_driver/wait.py @@ -0,0 +1,178 @@ +# 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/. + +from __future__ import absolute_import + +import collections +import sys +import time + +from . import errors + + +DEFAULT_TIMEOUT = 5 +DEFAULT_INTERVAL = 0.1 + + +class Wait(object): + + """An explicit conditional utility class for waiting until a condition + evaluates to true or not null. + + This will repeatedly evaluate a condition in anticipation for a + truthy return value, or its timeout to expire, or its waiting + predicate to become true. + + A `Wait` instance defines the maximum amount of time to wait for a + condition, as well as the frequency with which to check the + condition. Furthermore, the user may configure the wait to ignore + specific types of exceptions whilst waiting, such as + `errors.NoSuchElementException` when searching for an element on + the page. + + """ + + def __init__( + self, + marionette, + timeout=None, + interval=None, + ignored_exceptions=None, + clock=None, + ): + """Configure the Wait instance to have a custom timeout, interval, and + list of ignored exceptions. Optionally a different time + implementation than the one provided by the standard library + (time) can also be provided. + + Sample usage:: + + # Wait 30 seconds for window to open, checking for its presence once + # every 5 seconds. + wait = Wait(marionette, timeout=30, interval=5, + ignored_exceptions=errors.NoSuchWindowException) + window = wait.until(lambda m: m.switch_to_window(42)) + + :param marionette: The input value to be provided to + conditions, usually a Marionette instance. + + :param timeout: How long to wait for the evaluated condition + to become true. The default timeout is + `wait.DEFAULT_TIMEOUT`. + + :param interval: How often the condition should be evaluated. + In reality the interval may be greater as the cost of + evaluating the condition function. If that is not the case the + interval for the next condition function call is shortend to keep + the original interval sequence as best as possible. + The default polling interval is `wait.DEFAULT_INTERVAL`. + + :param ignored_exceptions: Ignore specific types of exceptions + whilst waiting for the condition. Any exceptions not + whitelisted will be allowed to propagate, terminating the + wait. + + :param clock: Allows overriding the use of the runtime's + default time library. See `wait.SystemClock` for + implementation details. + + """ + + self.marionette = marionette + self.timeout = timeout if timeout is not None else DEFAULT_TIMEOUT + self.interval = interval if interval is not None else DEFAULT_INTERVAL + self.clock = clock or SystemClock() + self.end = self.clock.now + self.timeout + + exceptions = [] + if ignored_exceptions is not None: + if isinstance(ignored_exceptions, collections.Iterable): + exceptions.extend(iter(ignored_exceptions)) + else: + exceptions.append(ignored_exceptions) + self.exceptions = tuple(set(exceptions)) + + def until(self, condition, is_true=None, message=""): + """Repeatedly runs condition until its return value evaluates to true, + or its timeout expires or the predicate evaluates to true. + + This will poll at the given interval until the given timeout + is reached, or the predicate or conditions returns true. A + condition that returns null or does not evaluate to true will + fully elapse its timeout before raising an + `errors.TimeoutException`. + + If an exception is raised in the condition function and it's + not ignored, this function will raise immediately. If the + exception is ignored, it will continue polling for the + condition until it returns successfully or a + `TimeoutException` is raised. + + :param condition: A callable function whose return value will + be returned by this function if it evaluates to true. + + :param is_true: An optional predicate that will terminate and + return when it evaluates to False. It should be a + function that will be passed clock and an end time. The + default predicate will terminate a wait when the clock + elapses the timeout. + + :param message: An optional message to include in the + exception's message if this function times out. + + """ + + rv = None + last_exc = None + until = is_true or until_pred + start = self.clock.now + + while not until(self.clock, self.end): + try: + next = self.clock.now + self.interval + rv = condition(self.marionette) + except (KeyboardInterrupt, SystemExit): + raise + except self.exceptions: + last_exc = sys.exc_info() + + # Re-adjust the interval depending on how long the callback + # took to evaluate the condition + interval_new = max(next - self.clock.now, 0) + + if not rv: + self.clock.sleep(interval_new) + continue + + if rv is not None: + return rv + + self.clock.sleep(interval_new) + + if message: + message = " with message: {}".format(message) + + raise errors.TimeoutException( + # pylint: disable=W1633 + "Timed out after {0:.1f} seconds{1}".format( + float(round((self.clock.now - start), 1)), message if message else "" + ), + cause=last_exc, + ) + + +def until_pred(clock, end): + return clock.now >= end + + +class SystemClock(object): + def __init__(self): + self._time = time + + def sleep(self, duration): + self._time.sleep(duration) + + @property + def now(self): + return self._time.time() diff --git a/testing/marionette/client/requirements.txt b/testing/marionette/client/requirements.txt new file mode 100644 index 0000000000..220531c4f5 --- /dev/null +++ b/testing/marionette/client/requirements.txt @@ -0,0 +1,3 @@ +mozrunner >= 7.4.0 +mozversion >= 2.1.0 +six diff --git a/testing/marionette/client/setup.py b/testing/marionette/client/setup.py new file mode 100644 index 0000000000..7c8fddeba6 --- /dev/null +++ b/testing/marionette/client/setup.py @@ -0,0 +1,53 @@ +# 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/. + +from __future__ import absolute_import + +import os +import re +from setuptools import setup, find_packages + +THIS_DIR = os.path.dirname(os.path.realpath(__name__)) + + +def read(*parts): + with open(os.path.join(THIS_DIR, *parts)) as f: + return f.read() + + +def get_version(): + return re.findall( + '__version__ = "([\d\.]+)"', read("marionette_driver", "__init__.py"), re.M + )[0] + + +setup( + name="marionette_driver", + version=get_version(), + description="Marionette Driver", + long_description="See https://firefox-source-docs.mozilla.org/python/marionette_driver.html", + # Get strings from http://pypi.python.org/pypi?%3Aaction=list_classifiers + classifiers=[ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)", + "Operating System :: MacOS :: MacOS X", + "Operating System :: Microsoft :: Windows", + "Operating System :: POSIX", + "Topic :: Software Development :: Quality Assurance", + "Topic :: Software Development :: Testing", + "Topic :: Utilities", + "Programming Language :: Python", + "Programming Language :: Python :: 2.7", + ], + keywords="mozilla", + author="Auto-tools", + author_email="tools-marionette@lists.mozilla.org", + url="https://wiki.mozilla.org/Auto-tools/Projects/Marionette", + license="MPL", + packages=find_packages(), + include_package_data=True, + zip_safe=False, + install_requires=read("requirements.txt").splitlines(), +) diff --git a/testing/marionette/components/marionette.js b/testing/marionette/components/marionette.js new file mode 100644 index 0000000000..6e5591725a --- /dev/null +++ b/testing/marionette/components/marionette.js @@ -0,0 +1,605 @@ +/* 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 { ComponentUtils } = ChromeUtils.import( + "resource://gre/modules/ComponentUtils.jsm" +); +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +const { EnvironmentPrefs, MarionettePrefs } = ChromeUtils.import( + "chrome://marionette/content/prefs.js", + null +); + +XPCOMUtils.defineLazyModuleGetters(this, { + Log: "chrome://marionette/content/log.js", + Preferences: "resource://gre/modules/Preferences.jsm", + TCPListener: "chrome://marionette/content/server.js", +}); + +XPCOMUtils.defineLazyGetter(this, "logger", () => Log.get()); + +XPCOMUtils.defineLazyServiceGetter( + this, + "env", + "@mozilla.org/process/environment;1", + "nsIEnvironment" +); + +const XMLURI_PARSE_ERROR = + "http://www.mozilla.org/newlayout/xml/parsererror.xml"; + +const NOTIFY_LISTENING = "marionette-listening"; + +// Complements -marionette flag for starting the Marionette server. +// We also set this if Marionette is running in order to start the server +// again after a Firefox restart. +const ENV_ENABLED = "MOZ_MARIONETTE"; + +// Besides starting based on existing prefs in a profile and a command +// line flag, we also support inheriting prefs out of an env var, and to +// start Marionette that way. +// +// This allows marionette prefs to persist when we do a restart into +// a different profile in order to test things like Firefox refresh. +// The environment variable itself, if present, is interpreted as a +// JSON structure, with the keys mapping to preference names in the +// "marionette." branch, and the values to the values of those prefs. So +// something like {"port": 4444} would result in the marionette.port +// pref being set to 4444. +const ENV_PRESERVE_PREFS = "MOZ_MARIONETTE_PREF_STATE_ACROSS_RESTARTS"; + +// ALL CHANGES TO THIS LIST MUST HAVE REVIEW FROM A MARIONETTE PEER! +// +// Marionette sets preferences recommended for automation when it starts, +// unless marionette.prefs.recommended has been set to false. +// +// All prefs as added here have immediate effect, and don't require a restart +// nor have to be set in the profile before the application starts. If such a +// latter preference has to be added, it needs to be done for the client like +// Marionette client (geckoinstance.py), or geckodriver (prefs.rs). +// +// Note: Clients do not always use the latest version of the application. As +// such backward compatibility has to be ensured at least for the last three +// releases. +const RECOMMENDED_PREFS = new Map([ + // Make sure Shield doesn't hit the network. + ["app.normandy.api_url", ""], + + // Disable automatically upgrading Firefox + // + // Note: This preference should have already been set by the client when + // creating the profile. But if not and to absolutely make sure that updates + // of Firefox aren't downloaded and applied, enforce its presence. + ["app.update.disabledForTesting", true], + + // Increase the APZ content response timeout in tests to 1 minute. + // This is to accommodate the fact that test environments tends to be + // slower than production environments (with the b2g emulator being + // the slowest of them all), resulting in the production timeout value + // sometimes being exceeded and causing false-positive test failures. + // + // (bug 1176798, bug 1177018, bug 1210465) + ["apz.content_response_timeout", 60000], + + // Don't show the content blocking introduction panel. + // We use a larger number than the default 22 to have some buffer + // This can be removed once Firefox 69 and 68 ESR and are no longer supported. + ["browser.contentblocking.introCount", 99], + + // Indicate that the download panel has been shown once so that + // whichever download test runs first doesn't show the popup + // inconsistently. + ["browser.download.panel.shown", true], + + // Always display a blank page + ["browser.newtabpage.enabled", false], + + // Background thumbnails in particular cause grief, and disabling + // thumbnails in general cannot hurt + ["browser.pagethumbnails.capturing_disabled", true], + + // Disable safebrowsing components. + // + // These should also be set in the profile prior to starting Firefox, + // as it is picked up at runtime. + ["browser.safebrowsing.blockedURIs.enabled", false], + ["browser.safebrowsing.downloads.enabled", false], + ["browser.safebrowsing.passwords.enabled", false], + ["browser.safebrowsing.malware.enabled", false], + ["browser.safebrowsing.phishing.enabled", false], + + // Disable updates to search engines. + // + // Should be set in profile. + ["browser.search.update", false], + + // Do not restore the last open set of tabs if the browser has crashed + ["browser.sessionstore.resume_from_crash", false], + + // Don't check for the default web browser during startup. + // + // These should also be set in the profile prior to starting Firefox, + // as it is picked up at runtime. + ["browser.shell.checkDefaultBrowser", false], + + // Do not redirect user when a milstone upgrade of Firefox is detected + ["browser.startup.homepage_override.mstone", "ignore"], + + // Do not close the window when the last tab gets closed + ["browser.tabs.closeWindowWithLastTab", false], + + // Do not allow background tabs to be zombified on Android, otherwise for + // tests that open additional tabs, the test harness tab itself might get + // unloaded + ["browser.tabs.disableBackgroundZombification", false], + + // Bug 1557457: Disable because modal dialogs might not appear in Firefox + ["browser.tabs.remote.separatePrivilegedContentProcess", false], + + // Don't unload tabs when available memory is running low + ["browser.tabs.unloadOnLowMemory", false], + + // Do not warn when closing all open tabs + ["browser.tabs.warnOnClose", false], + + // Do not warn when closing all other open tabs + ["browser.tabs.warnOnCloseOtherTabs", false], + + // Do not warn when multiple tabs will be opened + ["browser.tabs.warnOnOpen", false], + + // Don't show the Bookmarks Toolbar on any tab (the above pref that + // disables the New Tab Page ends up showing the toolbar on about:blank). + ["browser.toolbars.bookmarks.visibility", "never"], + + // Disable first run splash page on Windows 10 + ["browser.usedOnWindows10.introURL", ""], + + // Disable the UI tour. + // + // Should be set in profile. + ["browser.uitour.enabled", false], + + // Turn off search suggestions in the location bar so as not to trigger + // network connections. + ["browser.urlbar.suggest.searches", false], + + // Do not warn on quitting Firefox + ["browser.warnOnQuit", false], + + // Do not show datareporting policy notifications which can + // interfere with tests + [ + "datareporting.healthreport.documentServerURI", + "http://%(server)s/dummy/healthreport/", + ], + ["datareporting.healthreport.logging.consoleEnabled", false], + ["datareporting.healthreport.service.enabled", false], + ["datareporting.healthreport.service.firstRun", false], + ["datareporting.healthreport.uploadEnabled", false], + ["datareporting.policy.dataSubmissionEnabled", false], + ["datareporting.policy.dataSubmissionPolicyAccepted", false], + ["datareporting.policy.dataSubmissionPolicyBypassNotification", true], + + // Automatically unload beforeunload alerts + ["dom.disable_beforeunload", true], + + // Disable popup-blocker + ["dom.disable_open_during_load", false], + + // Enabling the support for File object creation in the content process + ["dom.file.createInChild", true], + + // Disable the ProcessHangMonitor + ["dom.ipc.reportProcessHangs", false], + + // Disable slow script dialogues + ["dom.max_chrome_script_run_time", 0], + ["dom.max_script_run_time", 0], + + // DOM Push + ["dom.push.connection.enabled", false], + + // Only load extensions from the application and user profile + // AddonManager.SCOPE_PROFILE + AddonManager.SCOPE_APPLICATION + // + // Should be set in profile. + ["extensions.autoDisableScopes", 0], + ["extensions.enabledScopes", 5], + + // Disable metadata caching for installed add-ons by default + ["extensions.getAddons.cache.enabled", false], + + // Disable installing any distribution extensions or add-ons. + // Should be set in profile. + ["extensions.installDistroAddons", false], + + // Turn off extension updates so they do not bother tests + ["extensions.update.enabled", false], + ["extensions.update.notifyUser", false], + + // Make sure opening about:addons will not hit the network + ["extensions.getAddons.discovery.api_url", "data:, "], + + // Allow the application to have focus even it runs in the background + ["focusmanager.testmode", true], + + // Disable useragent updates + ["general.useragent.updates.enabled", false], + + // Always use network provider for geolocation tests so we bypass the + // macOS dialog raised by the corelocation provider + ["geo.provider.testing", true], + + // Do not scan Wifi + ["geo.wifi.scan", false], + + // Show chrome errors and warnings in the error console + ["javascript.options.showInConsole", true], + + // Do not prompt with long usernames or passwords in URLs + ["network.http.phishy-userpass-length", 255], + + // Do not prompt for temporary redirects + ["network.http.prompt-temp-redirect", false], + + // Do not automatically switch between offline and online + ["network.manage-offline-status", false], + + // Make sure SNTP requests do not hit the network + ["network.sntp.pools", "%(server)s"], + + // Privacy and Tracking Protection + ["privacy.trackingprotection.enabled", false], + + // Only allow the old modal dialogs. This should be removed when there is + // support for the new modal UI (see Bug 1686741). + ["prompts.contentPromptSubDialog", false], + + // Don't do network connections for mitm priming + ["security.certerrors.mitm.priming.enabled", false], + + // Local documents have access to all other local documents, + // including directory listings + ["security.fileuri.strict_origin_policy", false], + + // Tests do not wait for the notification button security delay + ["security.notification_enable_delay", 0], + + // Ensure blocklist updates do not hit the network + ["services.settings.server", "http://%(server)s/dummy/blocklist/"], + + // Do not automatically fill sign-in forms with known usernames and + // passwords + ["signon.autofillForms", false], + + // Disable password capture, so that tests that include forms are not + // influenced by the presence of the persistent doorhanger notification + ["signon.rememberSignons", false], + + // Disable first-run welcome page + ["startup.homepage_welcome_url", "about:blank"], + ["startup.homepage_welcome_url.additional", ""], + + // Prevent starting into safe mode after application crashes + ["toolkit.startup.max_resumed_crashes", -1], +]); + +const isRemote = + Services.appinfo.processType == Services.appinfo.PROCESS_TYPE_CONTENT; + +class MarionetteParentProcess { + constructor() { + this.server = null; + + // holds reference to ChromeWindow + // used to run GFX sanity tests on Windows + this.gfxWindow = null; + + // indicates that all pending window checks have been completed + // and that we are ready to start the Marionette server + this.finalUIStartup = false; + + this.alteredPrefs = new Set(); + + if (env.exists(ENV_ENABLED)) { + this.enabled = true; + } else { + // TODO: Don't read the preference anymore (bug 1632821) + this.enabled = MarionettePrefs.enabled; + } + + if (this.enabled) { + logger.trace(`Marionette enabled`); + } + + Services.ppmm.addMessageListener("Marionette:IsRunning", this); + } + + get enabled() { + return !!this._enabled; + } + + set enabled(value) { + if (value) { + // Only update the preference when Marionette is going to be enabled + MarionettePrefs.enabled = value; + } + + this._enabled = value; + } + + get running() { + return !!this.server && this.server.alive; + } + + receiveMessage({ name }) { + switch (name) { + case "Marionette:IsRunning": + return this.running; + + default: + logger.warn("Unknown IPC message to parent process: " + name); + return null; + } + } + + observe(subject, topic) { + if (this.enabled) { + logger.trace(`Received observer notification ${topic}`); + } + + switch (topic) { + case "profile-after-change": + Services.obs.addObserver(this, "command-line-startup"); + break; + + // In safe mode the command line handlers are getting parsed after the + // safe mode dialog has been closed. To allow Marionette to start + // earlier, use the CLI startup observer notification for + // special-cased handlers, which gets fired before the dialog appears. + case "command-line-startup": + Services.obs.removeObserver(this, topic); + + if (!this.enabled && subject.handleFlag("marionette", false)) { + logger.trace(`Marionette enabled`); + this.enabled = true; + } + + if (this.enabled) { + Services.obs.addObserver(this, "toplevel-window-ready"); + Services.obs.addObserver(this, "marionette-startup-requested"); + + // Only set preferences to preserve in a new profile + // when Marionette is enabled. + for (let [pref, value] of EnvironmentPrefs.from(ENV_PRESERVE_PREFS)) { + Preferences.set(pref, value); + } + + // We want to suppress the modal dialog that's shown + // when starting up in safe-mode to enable testing. + if (Services.appinfo.inSafeMode) { + Services.obs.addObserver(this, "domwindowopened"); + } + } + + break; + + case "domwindowclosed": + if (this.gfxWindow === null || subject === this.gfxWindow) { + Services.obs.removeObserver(this, topic); + Services.obs.removeObserver(this, "toplevel-window-ready"); + + Services.obs.addObserver(this, "xpcom-will-shutdown"); + + this.finalUIStartup = true; + this.init(); + } + break; + + case "domwindowopened": + Services.obs.removeObserver(this, topic); + this.suppressSafeModeDialog(subject); + break; + + case "toplevel-window-ready": + subject.addEventListener( + "load", + ev => { + if (ev.target.documentElement.namespaceURI == XMLURI_PARSE_ERROR) { + Services.obs.removeObserver(this, topic); + + let parserError = ev.target.querySelector("parsererror"); + logger.fatal(parserError.textContent); + this.uninit(); + Services.startup.quit(Ci.nsIAppStartup.eForceQuit); + } + }, + { once: true } + ); + break; + + case "marionette-startup-requested": + Services.obs.removeObserver(this, topic); + + // When Firefox starts on Windows, an additional GFX sanity test + // window may appear off-screen. Marionette should wait for it + // to close. + for (let win of Services.wm.getEnumerator(null)) { + if ( + win.document.documentURI == + "chrome://gfxsanity/content/sanityparent.html" + ) { + this.gfxWindow = win; + break; + } + } + + if (this.gfxWindow) { + logger.trace( + "GFX sanity window detected, waiting until it has been closed..." + ); + Services.obs.addObserver(this, "domwindowclosed"); + } else { + Services.obs.removeObserver(this, "toplevel-window-ready"); + + Services.obs.addObserver(this, "xpcom-will-shutdown"); + + this.finalUIStartup = true; + this.init(); + } + + break; + + case "xpcom-will-shutdown": + Services.obs.removeObserver(this, "xpcom-will-shutdown"); + this.uninit(); + break; + } + } + + suppressSafeModeDialog(win) { + win.addEventListener( + "load", + () => { + let dialog = win.document.getElementById("safeModeDialog"); + if (dialog) { + // accept the dialog to start in safe-mode + logger.trace("Safe mode detected, supressing dialog"); + win.setTimeout(() => { + dialog.getButton("accept").click(); + }); + } + }, + { once: true } + ); + } + + init(quit = true) { + if (this.running || !this.enabled || !this.finalUIStartup) { + logger.debug( + `Init aborted (running=${this.running}, ` + + `enabled=${this.enabled}, finalUIStartup=${this.finalUIStartup})` + ); + return; + } + + logger.trace( + `Waiting until startup recorder finished recording startup scripts...` + ); + Services.tm.idleDispatchToMainThread(async () => { + let startupRecorder = Promise.resolve(); + if ("@mozilla.org/test/startuprecorder;1" in Cc) { + startupRecorder = Cc["@mozilla.org/test/startuprecorder;1"].getService() + .wrappedJSObject.done; + } + await startupRecorder; + logger.trace(`All scripts recorded.`); + + if (MarionettePrefs.recommendedPrefs) { + for (let [k, v] of RECOMMENDED_PREFS) { + if (!Preferences.isSet(k)) { + logger.debug(`Setting recommended pref ${k} to ${v}`); + Preferences.set(k, v); + this.alteredPrefs.add(k); + } + } + } + + try { + this.server = new TCPListener(MarionettePrefs.port); + this.server.start(); + } catch (e) { + logger.fatal("Remote protocol server failed to start", e); + this.uninit(); + if (quit) { + Services.startup.quit(Ci.nsIAppStartup.eForceQuit); + } + return; + } + + env.set(ENV_ENABLED, "1"); + Services.obs.notifyObservers(this, NOTIFY_LISTENING, true); + logger.debug("Marionette is listening"); + }); + } + + uninit() { + for (let k of this.alteredPrefs) { + logger.debug(`Resetting recommended pref ${k}`); + Preferences.reset(k); + } + this.alteredPrefs.clear(); + + if (this.running) { + this.server.stop(); + Services.obs.notifyObservers(this, NOTIFY_LISTENING); + logger.debug("Marionette stopped listening"); + } + } + + get QueryInterface() { + return ChromeUtils.generateQI([ + "nsICommandLineHandler", + "nsIMarionette", + "nsIObserver", + ]); + } +} + +class MarionetteContentProcess { + get running() { + let reply = Services.cpmm.sendSyncMessage("Marionette:IsRunning"); + if (reply.length == 0) { + logger.warn("No reply from parent process"); + return false; + } + return reply[0]; + } + + get QueryInterface() { + return ChromeUtils.generateQI(["nsIMarionette"]); + } +} + +const MarionetteFactory = { + instance_: null, + + createInstance(outer, iid) { + if (outer) { + throw Components.Exception("", Cr.NS_ERROR_NO_AGGREGATION); + } + + if (!this.instance_) { + if (isRemote) { + this.instance_ = new MarionetteContentProcess(); + } else { + this.instance_ = new MarionetteParentProcess(); + } + } + + return this.instance_.QueryInterface(iid); + }, +}; + +function Marionette() {} + +Marionette.prototype = { + classDescription: "Marionette component", + classID: Components.ID("{786a1369-dca5-4adc-8486-33d23c88010a}"), + contractID: "@mozilla.org/remote/marionette;1", + + /* eslint-disable-next-line camelcase */ + _xpcom_factory: MarionetteFactory, + + helpInfo: " --marionette Enable remote control server.\n", +}; + +this.NSGetFactory = ComponentUtils.generateNSGetFactory([Marionette]); diff --git a/testing/marionette/components/marionette.manifest b/testing/marionette/components/marionette.manifest new file mode 100644 index 0000000000..5558796ba1 --- /dev/null +++ b/testing/marionette/components/marionette.manifest @@ -0,0 +1,4 @@ +component {786a1369-dca5-4adc-8486-33d23c88010a} marionette.js +contract @mozilla.org/remote/marionette;1 {786a1369-dca5-4adc-8486-33d23c88010a} +category command-line-handler b-marionette @mozilla.org/remote/marionette;1 +category profile-after-change Marionette @mozilla.org/remote/marionette;1 diff --git a/testing/marionette/components/moz.build b/testing/marionette/components/moz.build new file mode 100644 index 0000000000..1771ec39d6 --- /dev/null +++ b/testing/marionette/components/moz.build @@ -0,0 +1,11 @@ +# 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/. + +EXTRA_COMPONENTS += [ + "marionette.js", + "marionette.manifest", +] + +XPIDL_MODULE = "remote" +XPIDL_SOURCES += ["nsIMarionette.idl"] diff --git a/testing/marionette/components/nsIMarionette.idl b/testing/marionette/components/nsIMarionette.idl new file mode 100644 index 0000000000..a889b32bad --- /dev/null +++ b/testing/marionette/components/nsIMarionette.idl @@ -0,0 +1,17 @@ +/* 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/. */ + +#include "nsISupports.idl" + +%{C++ +#define NS_MARIONETTE_CONTRACTID "@mozilla.org/remote/marionette;1" +%} + +/** Interface for accessing the Marionette server instance. */ +[scriptable, uuid(13fa7d76-f976-4711-a00c-29ac9c1881e1)] +interface nsIMarionette : nsISupports +{ + /** Indicates whether the remote protocol is enabled. */ + readonly attribute boolean running; +}; diff --git a/testing/marionette/cookie.js b/testing/marionette/cookie.js new file mode 100644 index 0000000000..82dbde3d00 --- /dev/null +++ b/testing/marionette/cookie.js @@ -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/. */ + +"use strict"; + +const EXPORTED_SYMBOLS = ["cookie"]; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + assert: "chrome://marionette/content/assert.js", + error: "chrome://marionette/content/error.js", + pprint: "chrome://marionette/content/format.js", +}); + +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 */ +this.cookie = { + manager: Services.cookies, +}; + +/** + * @name Cookie + * + * @return {Object.<string, (number|boolean|string)> + */ + +/** + * Unmarshal a JSON Object to a cookie representation. + * + * Effectively this will run validation checks on ``json``, which + * will produce the errors expected by WebDriver if the input is + * not valid. + * + * @param {Object.<string, (number|boolean|string)>} json + * Cookie to be deserialised. ``name`` and ``value`` are required + * fields which must be strings. The ``path`` and ``domain`` fields + * are optional, but must be a string if provided. The ``secure``, + * and ``httpOnly`` are similarly optional, but must be booleans. + * Likewise, the ``expiry`` field is optional but must be + * unsigned integer. + * + * @return {Cookie} + * Valid cookie object. + * + * @throws {InvalidArgumentError} + * If any of the properties are invalid. + */ +cookie.fromJSON = function(json) { + let newCookie = {}; + + assert.object(json, pprint`Expected cookie object, got ${json}`); + + newCookie.name = assert.string(json.name, "Cookie name must be string"); + newCookie.value = assert.string(json.value, "Cookie value must be string"); + + if (typeof json.path != "undefined") { + newCookie.path = assert.string(json.path, "Cookie path must be string"); + } + if (typeof json.domain != "undefined") { + newCookie.domain = assert.string( + json.domain, + "Cookie domain must be string" + ); + } + if (typeof json.secure != "undefined") { + newCookie.secure = assert.boolean( + json.secure, + "Cookie secure flag must be boolean" + ); + } + if (typeof json.httpOnly != "undefined") { + newCookie.httpOnly = assert.boolean( + json.httpOnly, + "Cookie httpOnly flag must be boolean" + ); + } + if (typeof json.expiry != "undefined") { + newCookie.expiry = assert.positiveInteger( + json.expiry, + "Cookie expiry must be a positive integer" + ); + } + if (typeof json.sameSite != "undefined") { + newCookie.sameSite = assert.in( + json.sameSite, + Array.from(SAMESITE_MAP.keys()), + "Cookie SameSite flag must be one of None, Lax, or Strict" + ); + } + + return newCookie; +}; + +/** + * Insert cookie to the cookie store. + * + * @param {Cookie} newCookie + * Cookie to add. + * @param {string=} restrictToHost + * Perform test that ``newCookie``'s domain matches this. + * @param {string=} protocol + * The protocol of the caller. It can be `ftp:`, `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 } = {} +) { + assert.string(newCookie.name, "Cookie name must be string"); + 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; + } + 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 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 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: + // ftp: or any other protocol 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 error.UnableToSetCookieError(e); + } +}; + +/** + * Remove cookie from the cookie store. + * + * @param {Cookie} toDelete + * Cookie to remove. + */ +cookie.remove = function(toDelete) { + cookie.manager.remove( + toDelete.domain, + toDelete.name, + toDelete.path, + {} /* originAttributes */ + ); +}; + +/** + * Iterates over the cookies for the current ``host``. You may + * optionally filter for specific paths on that ``host`` by specifying + * a path in ``currentPath``. + * + * @param {string} host + * Hostname to retrieve cookies for. + * @param {string=} [currentPath="/"] currentPath + * Optionally filter the cookies for ``host`` for the specific path. + * Defaults to ``/``, meaning all cookies for ``host`` are included. + * + * @return {Iterable.<Cookie>} + * Iterator. + */ +cookie.iter = function*(host, currentPath = "/") { + assert.string(host, "host must be string"); + 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/testing/marionette/doc/Building.md b/testing/marionette/doc/Building.md new file mode 100644 index 0000000000..e2b84c17fa --- /dev/null +++ b/testing/marionette/doc/Building.md @@ -0,0 +1,50 @@ +Building +======== + +Marionette is built in to Firefox and ships in the official +Firefox binary. As Marionette is written in [XPCOM] flavoured +JavaScript, you may choose to rely on so called [artifact builds], +which will download pre-compiled Firefox blobs to your computer. +This means you don’t have to compile Firefox locally, but does +come at the cost of having a good internet connection. To enable +[artifact builds] you may choose ‘Firefox for Desktop Artifact +Mode’ when bootstrapping. + +Once you have a clone of [mozilla-unified], you can set up your +development environment by running this command and following the +on-screen instructions: + + % ./mach bootstrap + +When you're getting asked to choose the version of Firefox you want to build, +you may want to consider choosing "Firefox for Desktop Artifact Mode". This +significantly reduces the time it takes to build Firefox on your machine +(from 30+ minutes to just 1-2 minutes) if you have a fast internet connection. + +To perform a regular build, simply do: + + % ./mach build + +You can clean out the objdir using this command: + + % ./mach clobber + +Occasionally a clean build will be required after you fetch the +latest changes from mozilla-central. You will find that the the +build will error when this is the case. To automatically do clean +builds when this happens you may optionally add this line to the +_mozconfig_ file in your top source directory: + + mk_add_options AUTOCLOBBER=1 + +If you compile Firefox frequently you will also want to enable +[ccache] and [sccache] if you develop on a macOS or Linux system: + + mk_add_options 'export RUSTC_WRAPPER=sccache' + mk_add_options 'export CCACHE_CPP2=yes' + ac_add_options --with-ccache + +[mozilla-unified]: https://mozilla-version-control-tools.readthedocs.io/en/latest/hgmozilla/unifiedrepo.html +[artifact builds]: https://developer.mozilla.org/en-US/docs/Mozilla/Developer_guide/Build_Instructions/Artifact_builds +[ccache]: https://ccache.samba.org/ +[sccache]: https://github.com/mozilla/sccache diff --git a/testing/marionette/doc/CodeStyle.md b/testing/marionette/doc/CodeStyle.md new file mode 100644 index 0000000000..bc332fb17a --- /dev/null +++ b/testing/marionette/doc/CodeStyle.md @@ -0,0 +1,254 @@ +Style guide +=========== + +Like other projects, we also have some guidelines to keep to the code. +For the overall Marionette project, a few rough rules are: + + * Make your code readable and sensible, and don’t try to be + clever. Prefer simple and easy solutions over more convoluted + and foreign syntax. + + * Fixing style violations whilst working on a real change as a + preparatory clean-up step is good, but otherwise avoid useless + code churn for the sake of conforming to the style guide. + + * Code is mutable and not written in stone. Nothing that + is checked in is sacred and we encourage change to make + testing/marionette a pleasant ecosystem to work in. + + +JavaScript +---------- + +Marionette is written in [XPCOM] flavoured JavaScript and ships +as part of Firefox. We have access to all the latest ECMAScript +features currently in development, usually before it ships in the +wild and we try to make use of new features when appropriate, +especially when they move us off legacy internal replacements +(such as Promise.jsm and Task.jsm). + +One of the peculiarities of working on JavaScript code that ships as +part of a runtime platform is, that unlike in a regular web document, +we share a single global state with the rest of Firefox. This means +we have to be responsible and not leak resources unnecessarily. + +JS code in Gecko is organised into _modules_ carrying _.js_ or _.jsm_ +file extensions. Depending on the area of Gecko you’re working on, +you may find they have different techniques for exporting symbols, +varying indentation and code style, as well as varying linting +requirements. + +To export symbols to other Marionette modules, remember to assign +your exported symbols to the shared global `this`: + + const EXPORTED_SYMBOLS = ["PollPromise", "TimedPromise"]; + +When importing symbols in Marionette code, try to be specific about +what you need: + + const {TimedPromise} = Cu.import("chrome://marionette/content/sync.js", {}); + +The [linter] will complain if you import a named symbol that is +not in use. If however you _need_ to import every symbol, you can: + + const wait = {}; + Cu.import("chrome://marionette/content/sync.js", wait); + + wait.sleep(42); + await wait.TimedPromise(…); + +We prefer object assignment shorthands when redefining names, +for example when you use functionality from the `Components` global: + + const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components; + +When using symbols by their own name, the assignment name can be +omitted: + + const {TYPE_ONE_SHOT, TYPE_REPEATING_SLACK} = Ci.nsITimer; + +In addition to the default [Mozilla eslint rules], we have [our +own specialisations] that are stricter and enforce more security. +A few notable examples are that we disallow fallthrough `case` +statements unless they are explicitly grouped together: + + switch (x) { + case "foo": + doSomething(); + + case "bar": // <-- disallowed! + doSomethingElse(); + break; + + case "baz": + case "bah": // <-- allowed (-: + doCrazyThings(); + } + +We disallow the use of `var`, for which we always prefer `let` and +`const` as replacements. Do be aware that `const` does not mean +that the variable is immutable: just that it cannot be reassigned. +We require all lines to end with semicolons, disallow construction +of plain `new Object()`, require variable names to be camel-cased, +and complain about unused variables. + +For purely aesthetic reasons we indent our code with two spaces, +which includes switch-statement `case`s, and limit the maximum +line length to 78 columns. When you need to wrap a statement to +the next line, the second line is indented with four spaces, like this: + + throw new TypeError( + "Expected an element or WindowProxy, " + + pprint`got: ${el}`); + +This is not normally something you have to think to deeply about as +it is enforced by the [linter]. The linter also has an automatic +mode that fixes and formats certain classes of style violations. + +If you find yourself struggling to fit a long statement on one line, +this is usually an indication that it is too long and should be +split into multiple lines. This is also a helpful tip to make the +code easier to read. Assigning transitive values to descriptive +variable names can serve as self-documentation: + + let location = event.target.documentURI || event.target.location.href; + log.debug(`Received DOM event ${event.type} for ${location}`); + +On the topic of variable naming the opinions are as many as programmers +writing code, but it is often helpful to keep the input and output +arguments to functions descriptive (longer), and let transitive +internal values to be described more succinctly: + + /** Prettifies instance of Error and its stacktrace to a string. */ + function stringify(error) { + try { + let s = error.toString(); + if ("stack" in error) { + s += "\n" + error.stack; + } + return s; + } catch (e) { + return "<unprintable error>"; + } + } + +When we can, we try to extract the relevant object properties in +the arguments to an event handler or a function: + + const responseListener = ({name, target, json, data}) => { … }; + +Instead of: + + const responseListener = msg => { + let name = msg.name; + let target = msg.target; + let json = msg.json; + let data = msg.data; + … + }; + +All source files should have `"use strict";` as the first directive +so that the file is parsed in [strict mode]. + +Every source code file that ships as part of the Firefox bundle +must also have a [copying header], such as this: + + /* 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/. */ + +New xpcshell test files _should not_ have a license header as all +new Mozilla tests should be in the [public domain] so that they can +easily be shared with other browser vendors. We want to re-license +existing tests covered by the [MPL] so that they can be shared. +We very much welcome your help in doing version control archeology +to make this happen! + +The practical details of working on the Marionette code is outlined +in [CONTRIBUTING.md], but generally you do not have to re-build +Firefox when changing code. Any change to testing/marionette/*.js +will be picked up on restarting Firefox. The only notable exception +is testing/marionette/components/marionette.js, which does require +a re-build. + +[XPCOM]: https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XPCOM +[strict mode]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Strict_mode +[our own specialisations]: https://searchfox.org/mozilla-central/source/testing/marionette/.eslintrc.js +[linter]: #linting +[copying header]: https://www.mozilla.org/en-US/MPL/headers/ +[public domain]: https://creativecommons.org/publicdomain/zero/1.0/ +[MPL]: https://www.mozilla.org/en-US/MPL/2.0/ +[CONTRIBUTING.md]: ../CONTRIBUTING.md + + +Python +------ + +TODO + + +Documentation +------------- + +We keep our documentation in-tree under [testing/marionette/doc] +and [testing/geckodriver/doc]. Updates and minor changes to +documentation should ideally not be scrutinised to the same degree +as code changes to encourage frequent updates so that the documentation +does not go stale. To that end, documentation changes with `r=me` +from module peers are permitted. + +Use fmt(1) or an equivalent editor specific mechanism (such as Meta-Q +in Emacs) to format paragraphs at a maximum width of 75 columns +with a goal of roughly 65. This is equivalent to `fmt -w 75 -g 65`, +which happens to be the default on BSD and macOS. + +We endeavour to document all _public APIs_ of the Marionette component. +These include public functions—or command implementations—on +the `GeckoDriver` class, as well as all exported symbols from +other modules. Documentation for non-exported symbols is not required. + +The API documentation can be regenerated to [testing/marionette/doc/api] +so: + +The API documentation uses [jsdoc] and is generated to <https://firefox-source-docs.mozilla.org/testing/marionette/marionette/internals> on Taskcluster. You may also build the documentation locally: + + % ./mach doc + +[Mozilla eslint rules]: https://searchfox.org/mozilla-central/source/.eslintrc.js +[testing/geckodriver/doc]: https://searchfox.org/mozilla-central/source/testing/geckodriver/doc +[testing/marionette/doc]: https://searchfox.org/mozilla-central/source/testing/marionette/doc +[jsdoc]: http://usejsdoc.org/ + + +Linting +------- + +Marionette consists mostly of JavaScript (server) and Python (client, +harness, test runner) code. We lint our code with [mozlint], +which harmonises the output from [eslint] and [flake8]. + +To run the linter with a sensible output: + + % ./mach lint -funix testing/marionette + +For certain classes of style violations the eslint linter has +an automatic mode for fixing and formatting your code. This is +particularly useful to keep to whitespace and indentation rules: + + % ./mach eslint --fix testing/marionette + +The linter is also run as a try job (shorthand `ES`) which means +any style violations will automatically block a patch from landing +(if using Autoland) or cause your changeset to be backed out (if +landing directly on mozilla-inbound). + +If you use git(1) you can [enable automatic linting] before you push +to a remote through a pre-push (or pre-commit) hook. This will +run the linters on the changed files before a push and abort if +there are any problems. This is convenient for avoiding a try run +failing due to a stupid linting issue. + +[mozlint]: https://firefox-source-docs.mozilla.org/tools/lint/usage.html +[eslint]: https://eslint.org/ +[flake8]: http://flake8.pycqa.org/en/latest/ +[enable automatic linting]: https://firefox-source-docs.mozilla.org/tools/lint/usage.html#using-a-vcs-hook diff --git a/testing/marionette/doc/Contributing.md b/testing/marionette/doc/Contributing.md new file mode 100644 index 0000000000..e325b1a952 --- /dev/null +++ b/testing/marionette/doc/Contributing.md @@ -0,0 +1,78 @@ +Contributing +============ + +If you are new to open source or to Mozilla, you might like this +[tutorial for new Marionette contributors](NewContributors.html). + +We are delighted that you want to help improve Marionette! +‘Marionette’ means different a few different things, depending +on who you talk to, but the overall scope of the project involves +these components: + + * [_Marionette_] is a Firefox remote protocol to communicate with, + instrument, and control Gecko-based browsers such as Firefox + and Fennec. It is built in to Firefox and written in [XPCOM] + flavoured JavaScript. + + It serves as the backend for the geckodriver WebDriver implementation, + and is used in the context of Firefox UI tests, reftesting, + Web Platform Tests, test harness bootstrapping, and in many + other far-reaching places where browser instrumentation is required. + + * [_geckodriver_] provides the HTTP API described by the [WebDriver + protocol] to communicate with Gecko-based browsers such as + Firefox and Fennec. It is a standalone executable written in + Rust, and can be used with compatible W3C WebDriver clients. + + * [_webdriver_] is a Rust crate providing interfaces, traits + and types, errors, type- and bounds checks, and JSON marshaling + for correctly parsing and emitting the [WebDriver protocol]. + +By participating in this project, you agree to abide by the Mozilla +[Community Participation Guidelines]. Here are some guidelines +for contributing high-quality and actionable bugs and code. + +[_Marionette_]: ./index.html +[_geckodriver_]: ../../geckodriver/geckodriver +[_webdriver_]: https://searchfox.org/mozilla-central/source/testing/webdriver/README.md +[WebDriver protocol]: https://w3c.github.io/webdriver/webdriver-spec.html#protocol +[XPCOM]: https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XPCOM/Guide +[Community Participation Guidelines]: https://www.mozilla.org/en-US/about/governance/policies/participation/ + + +Writing code +------------ + +Because there are many moving parts involved remote controlling +a web browser, it can be challenging to a new contributor to know +where to start. Please don’t hesitate to [ask questions]! + +The canonical source code repository is [mozilla-central]. Bugs are +filed in the `Testing :: Marionette` component on Bugzilla. We also +have a curated set of [good first bugs] you may consider attempting first. + +We have collected a lot of good advice for working on Marionette +code in our [code style document], which we highly recommend you read. + +[ask questions]: ./index.html#communication +[reach out to us]: ./index.html#communication +[mozilla-central]: https://searchfox.org/mozilla-central/source/testing/marionette/ +[good first bugs]: https://codetribute.mozilla.org/projects/automation +[code style document]: CodeStyle.html + + +Next steps +---------- + + * [Building](Building.html) + * [Debugging](Debugging.html) + * [Testing](Testing.html) + * [Patching](Patches.html) + + +Other resources +--------------- + + * [Code style](CodeStyle.html) + * [Internals](internals/) + * [New Contributor Tutorial](NewContributors.html) diff --git a/testing/marionette/doc/Debugging.md b/testing/marionette/doc/Debugging.md new file mode 100644 index 0000000000..8a5e1fa580 --- /dev/null +++ b/testing/marionette/doc/Debugging.md @@ -0,0 +1,86 @@ +Debugging +========= + +Redirecting the Gecko output +---------------------------- + +The most common way to debug Marionette, as well as chrome code in +general, is to use `dump()` to print a string to stdout. In Firefox, +this log output normally ends up in the gecko.log file in your current +working directory. With Fennec it can be inspected using `adb logcat`. + +`mach marionette-test` takes a `--gecko-log` option which lets +you redirect this output stream. This is convenient if you want to +“merge” the test harness output with the stdout from the browser. +Per Unix conventions you can use `-` (dash) to have Firefox write +its log to stdout instead of file: + + % ./mach marionette-test --gecko-log - + +It is common to use this in conjunction with an option to increase +the Marionette log level: + + % ./mach test --gecko-log - -vv TEST + +A single `-v` enables debug logging, and a double `-vv` enables +trace logging. + +This debugging technique can be particularly effective when combined +with using [pdb] in the Python client or the JS remote debugger +that is described below. + +[pdb]: https://docs.python.org/2/library/pdb.html + + +JavaScript debugger +------------------- + +You can attach the [Browser Toolbox] JavaScript debugger to the +Marionette server using the `--jsdebugger` flag. This enables you +to introspect and set breakpoints in Gecko chrome code, which is a +more powerful debugging technique than using `dump()` or `console.log()`. + +To automatically open the JS debugger for `Mn` tests: + + % ./mach marionette-test --jsdebugger + +It will prompt you when to start to allow you time to set your +breakpoints. It will also prompt you between each test. + +You can also use the `debugger;` statement anywhere in chrome code +to add a breakpoint. In this example, a breakpoint will be added +whenever the `WebDriver:GetPageSource` command is called: + + GeckoDriver.prototype.getPageSource = async function() { + debugger; + … + } + +To not be prompted at the start of the test run or between tests, +you can set the `marionette.debugging.clicktostart` preference to +false this way: + + % ./mach marionette-test --pref 'marionette.debugging.clicktostart:false' --jsdebugger + +For reference, below is the list of preferences that enables the +chrome debugger for Marionette. These are all set implicitly when +`--jsdebugger` is passed to mach. In non-official builds, which +are the default when built using `./mach build`, you will find that +the chrome debugger won’t prompt for connection and will allow +remote connections. + + * `devtools.debugger.prompt-connection` → true + + Controls the remote connection prompt. Note that this will + automatically expose your Firefox instance to the network. + + * `devtools.chrome.enabled` → true + + Enables debugging of chrome code. + + * `devtools.debugger.remote-enabled` → true + + Allows a remote debugger to connect, which is necessary for + debugging chrome code. + +[Browser Toolbox]: https://developer.mozilla.org/en-US/docs/Tools/Browser_Toolbox diff --git a/testing/marionette/doc/Intro.md b/testing/marionette/doc/Intro.md new file mode 100644 index 0000000000..4e52e11276 --- /dev/null +++ b/testing/marionette/doc/Intro.md @@ -0,0 +1,82 @@ +Introduction to Marionette +========================== + +Marionette is an automation driver for Mozilla's Gecko engine. +It can remotely control either the UI or the internal JavaScript of +a Gecko platform, such as Firefox. It can control both the chrome +(i.e. menus and functions) or the content (the webpage loaded inside +the browsing context), giving a high level of control and ability +to replicate user actions. In addition to performing actions on the +browser, Marionette can also read the properties and attributes of +the DOM. + +If this sounds similar to [Selenium/WebDriver] then you're +correct! Marionette shares much of the same ethos and API as +Selenium/WebDriver, with additional commands to interact with +Gecko's chrome interface. Its goal is to replicate what Selenium +does for web content: to enable the tester to have the ability to +send commands to remotely control a user agent. + +[Selenium/WebDriver]: https://dvcs.w3.org/hg/webdriver/raw-file/tip/webdriver-spec.html + + +How does it work? +----------------- + +Marionette consists of two parts: a server which takes requests and +executes them in Gecko, and a client. The client sends commands to +the server and the server executes the command inside the browser. + + +When would I use it? +-------------------- + +If you want to perform UI tests with browser chrome or content, +Marionette is the tool you're looking for! You can use it to +control either web content, or Firefox itself. + +A test engineer would typically import the Marionette client package +into their test framework, import the classes and use the class +functions and methods to control the browser. After controlling +the browser, Marionette can be used to return information about +the state of the browser which can then be used to validate that +the action was performed correctly. + + +Using Marionette +---------------- + +Marionette combines a gecko component (the Marionette server) with an +outside component (the Marionette client), which drives the tests. +The Marionette server ships with Firefox, and to use it you will +need to download a Marionette client or use the in-tree client. + + * [Download and setup the Python client for Marionette][1] + * [Run Tests with Python][2] – How to run tests using the + Python client + * You might want to experiment with [using Marionette interactively + at a Python command prompt][2] + * Start [writing and running][3] tests + * Tips on [debugging][4] Marionette code + * [Get a Build][5] – Instructions on how to get a Marionette-enabled + build of Firefox + * [Download and setup the Marionette JS client][6] + * [Protocol definition][7] + +[1]: ../../python/marionette_driver.html +[2]: ../../python/marionette_driver.html +[3]: ./PythonTests.html +[4]: ./Debugging.html +[5]: https://developer.mozilla.org/en-US/docs/Marionette/Builds +[6]: https://github.com/mozilla-b2g/marionette_js_client +[7]: ./Protocol.html + + +Bugs +---- + +Please file any bugs you may find in the `Testing :: Marionette` +component in Bugzilla. You can view a [list of current bugs] +to see if your problem is already being addressed. + +[list of current bugs]: https://bugzilla.mozilla.org/buglist.cgi?product=Testing&component=Marionette&resolution=---&list_id=1844713 diff --git a/testing/marionette/doc/NewContributors.md b/testing/marionette/doc/NewContributors.md new file mode 100644 index 0000000000..5411b32862 --- /dev/null +++ b/testing/marionette/doc/NewContributors.md @@ -0,0 +1,90 @@ +New contributors +================ + +This page is aimed at people who are new to Mozilla and want to contribute +to Mozilla source code related to Marionette Python tests, WebDriver +spec tests and related test harnesses and tools. Mozilla has both +git and Mercurial repositories, but this guide only describes Mercurial. + +If you run into issues or have doubts, check out the [Resources](#resources) +section below and **don't hesitate to ask questions**. :) The goal of these +steps is to make sure you have the basics of your development environment +working. Once you do, we can get you started with working on an +actual bug, yay! + + +Accounts, communication +----------------------- + + 1. Set up a [Bugzilla] account (and, if you like, a [Mozillians] profile). + Please include your Matrix nickname in both of these accounts so we can work + with you more easily. For example, Eve Smith would set the Bugzilla name + to "Eve Smith (:esmith)", where "esmith" is the Matrix nick. + + 2. For a direct communication with us it will be beneficial to setup [Matrix]. + Make sure to also register your nickname as described in the linked document. + + 3. Join our #interop channel, and introduce yourself to the team. :jgraham, + :maja_zf, and :whimboo are all familiar with Marionette. + We're nice, I promise, but we might not answer right away due to different + time zones, time off, etc. So please be patient. + + 4. When you want to ask a question on Matrix, just go ahead an ask it even if + no one appears to be around/responding. + Provide lots of detail so that we have a better chance of helping you. + If you don't get an answer right away, check again in a few hours -- + someone may have answered you in the mean time. + + 5. If you're having trouble reaching us over Matrix, you are welcome to send an + email to our [mailing list](index.html#communication) instead. It's a good + idea to include your Matrix nick in your email message. + +[Matrix]: https://chat.mozilla.org +[Bugzilla]: https://bugzilla.mozilla.org/ +[Mozillians]: https://mozillians.org/ +[logbot]: https://mozilla.logbot.info/ateam/ + +Getting the code, running tests +------------------------------- + +Follow the documentation on [Contributing](Contributing.html) to get a sense of +our projects, and which is of most interest for you. You will also learn how to +get the Firefox source code, build your custom Firefox build, and how to run the +tests. + + +Work on bugs and get code review +-------------------------------- + +Once you are familiar with the code of the test harnesses, and the tests you might +want to start with your first contribution. The necessary steps to submit and verify +your patches are laid out in [Patches.md](Patches.html). + + +Resources +--------- + + * Search Mozilla's code repositories with [searchfox]. + + * Another [guide for new contributors]. It has not been updated in a long + time but it's a good general resource if you ever get stuck on something. + The most relevant sections to you are about Bugzilla, Mercurial, Python and the + Development Process. + + * [Mercurial for Mozillians] + + * More general resources are available in this little [guide] :maja_zf wrote + in 2015 to help a student get started with open source contributions. + + * Textbook about general open source practices: [Practical Open Source Software Exploration] + + * If you'd rather use git instead of hg, see [git workflow for + Gecko development] and/or [this blog post by :ato]. + +[searchfox]: https://searchfox.org/mozilla-central/source/testing/marionette/ +[guide for new contributors]: https://ateam-bootcamp.readthedocs.org/en/latest/guide/index.html#new-contributor-guide +[Mercurial for Mozillians]: https://mozilla-version-control-tools.readthedocs.org/en/latest/hgmozilla/index.html +[guide]: https://gist.github.com/mjzffr/d2adef328a416081f543 +[Practical Open Source Software Exploration]: https://quaid.fedorapeople.org/TOS/Practical_Open_Source_Software_Exploration/html/index.html +[git workflow for Gecko development]: https://github.com/glandium/git-cinnabar/wiki/Mozilla:-A-git-workflow-for-Gecko-development +[this blog post by :ato]: https://sny.no/2016/03/geckogit diff --git a/testing/marionette/doc/Patches.md b/testing/marionette/doc/Patches.md new file mode 100644 index 0000000000..7e5ece9fd3 --- /dev/null +++ b/testing/marionette/doc/Patches.md @@ -0,0 +1,33 @@ +Submitting patches +================== + +You can submit patches by using [Phabricator]. Walk through its documentation +in how to set it up, and uploading patches for review. Don't worry about which +person to select for reviewing your code. It will be done automatically. + +Please also make sure to follow the [commit creation guidelines]. + +Once you have contributed a couple of patches, we are happy to +sponsor you in [becoming a Mozilla committer]. When you have been +granted commit access level 1 you will have permission to use the +[Firefox CI] to trigger your own “try runs” to test your changes. + +This is a good try syntax to use when testing Marionette changes: + + -b do -p linux,linux64,macosx64,win64,android-api-16 -u marionette,marionette-headless,xpcshell,web-platform-tests,firefox-ui-functional -t none + +You can also use the `marionette` [try preset]: + + mach try --preset marionette + +This preset will schedule Marionette-related tests on various platforms. You can +reduce the number of tasks by filtering on platforms (e.g. linux) or build type +(e.g. opt): + + mach try --preset marionette -xq "'linux 'opt" + +[Phabricator]: https://moz-conduit.readthedocs.io/en/latest/phabricator-user.html +[commit creation guidelines]: https://mozilla-version-control-tools.readthedocs.io/en/latest/devguide/contributing.html?highlight=phabricator#submitting-patches-for-review +[becoming a Mozilla committer]: https://www.mozilla.org/en-US/about/governance/policies/commit/ +[Firefox CI]: https://treeherder.mozilla.org/ +[try preset]: https://firefox-source-docs.mozilla.org/tools/try/presets.html diff --git a/testing/marionette/doc/Prefs.md b/testing/marionette/doc/Prefs.md new file mode 100644 index 0000000000..2918a25c9e --- /dev/null +++ b/testing/marionette/doc/Prefs.md @@ -0,0 +1,80 @@ +Preferences +=========== + +There are a couple of preferences associated with the Gecko remote +protocol: + + +`marionette.enabled` +-------------------- + +Starts and stops the Marionette server. This will cause a TCP +server to bind to the port defined by `marionette.port`. + +If Gecko has not been started with the `-marionette` flag or the +`MOZ_MARIONETTE` environment variable, changing this preference +will have no effect. For Marionette to be enabled, either one of +these options _must_ be given to Firefox or Fennec for Marionette +to start. + + +`marionette.debugging.clicktostart` +----------------------------------- + +Delay server startup until a modal dialogue has been clicked to +allow time for user to set breakpoints in the [Browser Toolbox]. + +[Browser Toolbox]: https://developer.mozilla.org/en-US/docs/Tools/Browser_Toolbox + + +`marionette.log.level` +---------------------- + +Sets the verbosity level of the Marionette logger repository. Note +that this preference does not control the verbosity of other loggers +used in Firefox or Fennec. + +The available levels are, in descending order of severity, `Trace`, +`debug`, `config`, `info`, `warn`, `error`, and `fatal`. The value +is treated case-insensitively. + + +`marionette.log.truncate` +------------------------- + +Certain log messages that are known to be long, such as wire protocol +dumps, are truncated. This preference causes them not to be truncated. + + +`marionette.port` +----------------- + +Defines the port on which the Marionette server will listen. Defaults +to port 2828. + +This can be set to 0 to have the system atomically allocate a free +port, which can be useful when running multiple Marionette servers +on the same system. The effective port is written to the user +preference file when the server has started and is also logged to +stdout. + + +`marionette.prefs.recommended` +------------------------------ + +By default Marionette attempts to set a range of preferences deemed +suitable in automation when it starts. These include the likes of +disabling auto-updates, Telemetry, and first-run UX. + +The user preference file takes presedence over the recommended +preferences, meaning any user-defined preference value will not be +overridden. + + +`marionette.contentListener` +---------------------------- + +Used internally in Marionette for determining whether content scripts +can safely be reused. Should not be tweaked manually. + +This preference is scheduled for removal. diff --git a/testing/marionette/doc/Protocol.md b/testing/marionette/doc/Protocol.md new file mode 100644 index 0000000000..d9c9508409 --- /dev/null +++ b/testing/marionette/doc/Protocol.md @@ -0,0 +1,122 @@ +Protocol +======== + +Marionette provides an asynchronous, parallel pipelining user-facing +interface. Message sequencing limits chances of payload race +conditions and provides a uniform way in which payloads are serialised. + +Clients that deliver a blocking WebDriver interface are still +expected to not send further command requests before the response +from the last command has come back, but if they still happen to do +so because of programming error, no harm will be done. This guards +against [mixing up responses]. + +Schematic flow of messages: + + client server + | | + msgid=1 |----------->| + | command | + | | + msgid=2 |<-----------| + | command | + | | + msgid=2 |----------->| + | response | + | | + msgid=1 |<-----------| + | response | + | | + +The protocol consists of a [command] message and the corresponding +[response] message. A [response] message must always be sent in +reply to a [command] message. + +This means that the server implementation does not need to send +the reply precisely in the order of the received commands: if it +receives multiple messages, the server may even reply in random order. +It is therefore strongly advised that clients take this into account +when imlpementing the client end of this wire protocol. + +This is required for pipelining messages. On the server side, +some functions are fast, and some less so. If the server must +reply in order, the slow functions delay the other replies even if +its execution is already completed. + +[mixing up responses]: https://bugzil.la/1207125 + + +Command +------- + +The request, or command message, is a four element JSON Array as shown +below, that may originate from either the client- or server remote ends: + + [type, message ID, command, parameters] + + * _type_ must be 0 (integer). This indicates that the message + is a [command]. + + * _message ID_ is a 32-bit unsigned integer. This number is + used as a sequencing number that uniquely identifies a pair of + [command] and [response] messages. The other remote part will + reply with a corresponding [response] with the same message ID. + + * _command_ is a string identifying the RPC method or command + to execute. + + * _parameters_ is an arbitrary JSON serialisable object. + + +Response +-------- + +The response message is also a four element array as shown below, +and must always be sent after receiving a [command]: + + [type, message ID, error, result] + + * _type_ must be 1 (integer). This indicates that the message is a + [response]. + + * _message ID_ is a 32-bit unsigned integer. This corresponds + to the [command]’s message ID. + + * _error_ is null if the command executed correctly. If the + error occurred on the server-side, then this is an [error] object. + + * _result_ is the result object from executing the [command], iff + it executed correctly. If an error occurred on the server-side, + this field is null. + +The structure of the result field can vary, but is documented +individually for each command. + + +Error object +------------ + +An error object is a serialisation of JavaScript error types, +and it is structured like this: + + { + "error": "invalid session id", + "message": "No active session with ID 1234", + "stacktrace": "" + } + +All the fields of the error object are required, so the stacktrace and +message fields may be empty strings. The error field is guaranteed +to be one of the JSON error codes as laid out by the [WebDriver standard]. + + +Clients +------- + +Clients may be implemented in any language that is capable of writing +and receiving data over TCP socket. A [reference client] is provided. +Clients may be implemented both synchronously and asynchronously, +although the latter is impossible in protocol levels 2 and earlier +due to the lack of message sequencing. + +[reference client]: https://searchfox.org/mozilla-central/source/testing/marionette/client/ diff --git a/testing/marionette/doc/PythonTests.md b/testing/marionette/doc/PythonTests.md new file mode 100644 index 0000000000..02d2da4d29 --- /dev/null +++ b/testing/marionette/doc/PythonTests.md @@ -0,0 +1,71 @@ +Mn Python tests +=============== + +_Marionette_ is the codename of a [remote protocol] built in to +Firefox as well as the name of a functional test framework for +automating user interface tests. + +The in-tree test framework supports tests written in Python, using +Python’s [unittest] library. Test cases are written as a subclass +of `MarionetteTestCase`, with child tests belonging to instance +methods that have a name starting with `test_`. + +You can additionally define [`setUp`] and [`tearDown`] instance +methods to execute code before and after child tests, and +[`setUpClass`]/[`tearDownClass`] for the parent test. When you use +these, it is important to remember calling the [`MarionetteTestCase`] +superclass’ own `setUp`/`tearDown` methods since they handle +setup/cleanup of the session. + +The test structure is illustrated here: + + from marionette_test import MarionetteTestCase + + class TestSomething(MarionetteTestCase): + def setUp(self): + # code to execute before any tests are run + MarionetteTestCase.setUp(self) + + def test_foo(self): + # run test for 'foo' + + def test_bar(self): + # run test for 'bar' + + def tearDown(self): + # code to execute after all tests are run + MarionetteTestCase.tearDown(self) + +[remote protocol]: Protocol.html +[unittest]: https://docs.python.org/2.7/library/unittest.html +[`setUp`]: https://docs.python.org/2.7/library/unittest.html#unittest.TestCase.setUp +[`setUpClass`]: https://docs.python.org/2.7/library/unittest.html#unittest.TestCase.setUpClass +[`tearDown`]: https://docs.python.org/2.7/library/unittest.html#unittest.TestCase.tearDown +[`tearDownClass`]: https://docs.python.org/2.7/library/unittest.html#unittest.TestCase.tearDownClass + + +Test assertions +--------------- + +Assertions are provided courtesy of [unittest]. For example: + + from marionette_test import MarionetteTestCase + + class TestSomething(MarionetteTestCase): + def test_foo(self): + self.assertEqual(9, 3 * 3, '3 x 3 should be 9') + self.assertTrue(type(2) == int, '2 should be an integer') + + +The API +------- + +The full API documentation is found [here], but the key objects are: + + * `MarionetteTestCase`: a subclass for `unittest.TestCase` + used as a base class for all tests to run. + + * [`Marionette`]: client that speaks to Firefox. + +[here]: ../../../python/marionette_driver.html +[`Marionette`]: ../../../python/marionette_driver.html#marionette_driver.marionette.Marionette diff --git a/testing/marionette/doc/SeleniumAtoms.md b/testing/marionette/doc/SeleniumAtoms.md new file mode 100644 index 0000000000..9f5a73f36c --- /dev/null +++ b/testing/marionette/doc/SeleniumAtoms.md @@ -0,0 +1,84 @@ +Selenium atoms +============== + +Marionette uses a small list of [Selenium atoms] to interact with +web elements. Initially those have been added to ensure a better +reliability due to a wider usage inside the Selenium project. But +by adding full support for the [WebDriver specification] they will +be removed step by step. + +Currently the following atoms are in use: + +- `getElementText` +- `isDisplayed` + +To use one of those atoms Javascript modules will have to import +[atom.js]. + +[Selenium atoms]: https://github.com/SeleniumHQ/selenium/tree/master/javascript/webdriver/atoms +[WebDriver specification]: https://w3c.github.io/webdriver/webdriver-spec.html +[atom.js]: https://searchfox.org/mozilla-central/source/testing/marionette/atom.js + + +Update required Selenium atoms +------------------------------ + +In regular intervals the atoms, which are still in use, have to +be updated. Therefore they have to be exported from the Selenium +repository first, and then updated in [atom.js]. + + +### Export Selenium Atoms + +The canonical GitHub repository for Selenium is + + https://github.com/SeleniumHQ/selenium.git + +so make sure to have a local copy of it. For the cloning process +it is recommended to specify the `--depth=1` argument, so only the +last changeset is getting downloaded (which itself will already be +more than 100 MB). Once the clone is ready the export of the atoms +can be triggered by running the following commands: + + % cd selenium + % ./go + % python buck-out/crazy-fun/%changeset%/buck.pex build --show-output %atom% + +Hereby `%changeset%` corresponds to the currently used version of +buck, and `%atom%` to the atom to export. The following targets +for exporting are available: + + - `//javascript/webdriver/atoms:clear-element-firefox` + - `//javascript/webdriver/atoms:get-text-firefox` + - `//javascript/webdriver/atoms:is-displayed-firefox` + - `//javascript/webdriver/atoms:is-enabled-firefox` + - `//javascript/webdriver/atoms:is-selected-firefox` + +For each of the exported atoms a file can now be found in the folder +`buck-out/gen/javascript/webdriver/atoms/`. They contain all the +code including dependencies for the atom wrapped into a single function. + + +### Update atom.js + +To update the atoms for Marionette the `atoms.js` file has to be edited. For +each atom to be updated the steps as laid out below have to be performed: + +1. Open the Javascript file of the exported atom. See above for + its location. + +2. Remove the contained license header, which can be found somewhere + in the middle of the file. + +3. Update the parameters of the wrapper function (at the very top) + so that those are equal with the used parameters in `atom.js`. + +4. Copy the whole content of the file, and replace the existing + code for the atom in `atom.js`. + + +### Test the changes + +To ensure that the update of the atoms doesn't cause a regression +a try build should be run including Marionette unit tests, Firefox +ui tests, and all the web-platform-tests. diff --git a/testing/marionette/doc/Taskcluster.md b/testing/marionette/doc/Taskcluster.md new file mode 100644 index 0000000000..b8490d4bad --- /dev/null +++ b/testing/marionette/doc/Taskcluster.md @@ -0,0 +1,94 @@ +Testing with one-click loaners +============================== + +[Taskcluster] is the task execution framework that supports Mozilla's +continuous integration and release processes. + +Build and test jobs (like Marionette) are executed across all supported +platforms, and job results are pushed to [Treeherder] for observation. + +The best way to debug issues for intermittent test failures of +Marionette tests for Firefox and Fennec (Android) is to use a +one-click loaner as provided by Taskcluster. Such a loaner creates +an interactive task you can interact with via a shell and VNC. + +To create an interactive task for a Marionette job which is shown +as failed on Treeherder, follow the Taskcluster documentation for +[Debugging a task]. + +Please note that you need special permissions to actually request +such a loaner. + +When the task has been created the shell needs to be opened. +Once that has been done a wizard will automatically launch and +provide some options. Best here is to choose the second option, +which will run all the setup steps, installs the Firefox or Fennec +binary, and then exits. + +[Taskcluster]: https://docs.taskcluster.net/ +[Treeherder]: https://treeherder.mozilla.org +[Debugging a task]: https://docs.taskcluster.net/tutorial/debug-task#content + + +Setting up the Marionette environment +------------------------------------- + +Best here is to use a virtual environment, which has all the +necessary packages installed. If no modifications to any Python +package will be done, the already created environment by the +wizard can be used: + + % cd /builds/worker/workspace/build + % source venv/bin/activate + +Otherwise a new virtual environment needs to be created and +populated with the mozbase and marionette packages installed: + + % cd /builds/worker/workspace/build && rm -r venv + % virtualenv venv && source venv/bin/activate + % cd tests/mozbase && ./setup_development.py + % cd ../marionette/client && python setup.py develop + % cd ../harness && python setup.py develop + % cd ../../../ + + +Running Marionette tests +------------------------ + +### Firefox + +To run the Marionette tests execute the `runtests.py` script. For all +the required options as best search in the log file of the failing job +the interactive task has been created from. Then copy the complete +command and run it inside the already sourced virtual environment: + + % /builds/worker/workspace/build/venv/bin/python -u /builds/worker/workspace/build/tests/marionette/harness/marionette_harness/runtests.py --gecko-log=- -vv --binary=/builds/worker/workspace/build/application/firefox/firefox --address=127.0.0.1:2828 --symbols-path=https://queue.taskcluster.net/v1/task/GSuwee61Qyibujtxq4UV3A/artifacts/public/build/target.crashreporter-symbols.zip /builds/worker/workspace/build/tests/marionette/tests/testing/marionette/harness/marionette_harness/tests/unit-tests.ini + + +#### Fennec + +The Marionette tests for Fennec are executed by using an Android +emulator which runs on the host platform. As such some extra setup +steps compared to Firefox on desktop are required. + +The following lines set necessary environment variables before +starting the emulator in the background, and to let Marionette +know of various Android SDK tools. + + % export ADB_PATH=/builds/worker/workspace/build/android-sdk-linux/platform-tools/adb + % export ANDROID_AVD_HOME=/builds/worker/workspace/build/.android/avd/ + % /builds/worker/workspace/build/android-sdk-linux/tools/emulator -avd test-1 -show-kernel -debug init,console,gles,memcheck,adbserver,adbclient,adb,avd_config,socket & + +The actual call to `runtests.py` is different per test job because +those are using chunks on Android. As best search for the command +and its options in the log file of the failing job the interactive +task has been created from. Then copy the complete command and run +it inside the already sourced virtual environment. + +Here an example for chunk 1 which runs all the tests in the current +chunk with some options for logs removed: + + % /builds/worker/workspace/build/venv/bin/python -u /builds/worker/workspace/build/tests/marionette/harness/marionette_harness/runtests.py --emulator --app=fennec --package=org.mozilla.fennec_aurora --address=127.0.0.1:2828 /builds/worker/workspace/build/tests/marionette/tests/testing/marionette/harness/marionette_harness/tests/unit-tests.ini --gecko-log=- --symbols-path=/builds/worker/workspace/build/symbols --startup-timeout=300 --this-chunk 1 --total-chunks 10 + +To execute a specific test only simply replace `unit-tests.ini` +with its name. diff --git a/testing/marionette/doc/Testing.md b/testing/marionette/doc/Testing.md new file mode 100644 index 0000000000..1b9db0d8f3 --- /dev/null +++ b/testing/marionette/doc/Testing.md @@ -0,0 +1,205 @@ +Testing +======= + +We verify and test Marionette in a couple of different ways, using +a combination of unit tests and functional tests. There are three +distinct components that we test: + + - the Marionette **server**, using a combination of xpcshell + unit tests and functional tests written in Python spread across + Marionette- and WPT tests; + + - the Python **client** is tested with the same body of functional + Marionette tests; + + - and the **harness** that backs the Marionette, or `Mn` job on + try, tests is verified using separate mock-styled unit tests. + +All these tests can be run by using [mach]. + +[mach]: https://developer.mozilla.org/en-US/docs/Mozilla/Developer_guide/mach + +xpcshell unit tests +------------------- + +Marionette has a set of [xpcshell] unit tests located in +_testing/marionette/test/unit. These can be run this way: + + % ./mach test testing/marionette/test/unit + +Because tests are run in parallel and xpcshell itself is quite +chatty, it can sometimes be useful to run the tests sequentially: + + % ./mach test --sequential testing/marionette/test/unit/test_error.js + +These unit tests run as part of the `X` jobs on Treeherder. + +[xpcshell]: https://developer.mozilla.org/en-US/docs/Mozilla/QA/Writing_xpcshell-based_unit_tests + + +Marionette functional tests +--------------------------- + +We also have a set of [functional tests] that make use of the Marionette +Python client. These start a Firefox process and tests the Marionette +protocol input and output, and will appear as `Mn` on Treeherder. +The following command will run all tests locally: + + % ./mach marionette-test + +But you can also run individual tests: + + % ./mach marionette-test testing/marionette/harness/marionette_harness/tests/unit/test_navigation.py + +In case you want to run the tests with another binary like [Firefox Nightly]: + + % ./mach marionette-test --binary /path/to/nightly/firefox TEST + +When working on Marionette it is often useful to surface the stdout +from Gecko, which can be achieved using the `--gecko-log` option. +See <Debugging.html> for usage instructions, but the gist is that +you can redirect all Gecko output to stdout: + + % ./mach marionette-test --gecko-log - TEST + +Our functional integration tests pop up Firefox windows sporadically, +and a helpful tip is to suppress the window can be to use Firefox’ +[headless mode]: + + % ./mach marionette-test -z TEST + +`-z` is an alias for the `--headless` flag and equivalent to setting +the `MOZ_HEADLESS` output variable. In addition to `MOZ_HEADLESS` +there is also `MOZ_HEADLESS_WIDTH` and `MOZ_HEADLESS_HEIGHT` for +controlling the dimensions of the no-op virtual display. This is +similar to using Xvfb(1) which you may know from the X windowing system, +but has the additional benefit of also working on macOS and Windows. + +[functional tests]: PythonTests.html +[Firefox Nightly]: https://nightly.mozilla.org/ + + +### Android + +Prerequisites: + +* You have [built Fennec](https://developer.mozilla.org/en-US/docs/Mozilla/Developer_guide/Build_Instructions/Simple_Firefox_for_Android_build). +* You can run an Android [emulator](https://wiki.mozilla.org/Mobile/Fennec/Android/Testing#Running_tests_on_the_Android_emulator), + which means you have the AVD you need. + +When running tests on Fennec, you can have Marionette runner take care of +starting Fennec and an emulator, as shown below. + + % ./mach marionette-test --emulator --app fennec + --avd-home /path/to/.mozbuild/android-device/avd + --emulator-binary /path/to/.mozbuild/android-sdk/emulator/emulator + --avd=mozemulator-x86 + +For Fennec tests, if the appropriate `emulator` command is in your `PATH`, you may omit the `--emulator-binary` argument. See `./mach marionette-test -h` +for additional options. + +Alternately, you can start an emulator yourself and have the Marionette runner +start Fennec for you: + + % ./mach marionette-test --emulator --app='fennec' --address=127.0.0.1:2828 + +To connect to an already-running Fennec in an already running emulator or on a device, you will need to enable Marionette manually by setting the browser preference +`marionette.enabled` set to true in the Fennec profile. + +Make sure port 2828 is forwarded: + + % adb forward tcp:2828 tcp:2828 + +If Fennec is already started: + + % ./mach marionette-test --app='fennec' --address=127.0.0.1:2828 + +If Fennec is not already started on the emulator/device, add the `--emulator` +option. Marionette Test Runner will take care of forwarding the port and +starting Fennec with the correct prefs. (You may need to run +`adb forward --remove-all` to allow the runner to start.) + + % ./mach marionette-test --emulator --app='fennec' --address=127.0.0.1:2828 --startup-timeout=300 + +If you need to troubleshoot the Marionette connection, the most basic check is +to start Fennec, make sure the `marionette.enabled` browser preference is +true and port 2828 is forwarded, then see if you get any response from +Marionette when you connect manually: + + % telnet 127.0.0.1:2828 + +You should see output like `{"applicationType":"gecko","marionetteProtocol":3}` + +[headless mode]: https://developer.mozilla.org/en-US/Firefox/Headless_mode +[geckodriver]: /testing/geckodriver/geckodriver + + +WPT functional tests +-------------------- + +Marionette is also indirectly tested through [geckodriver] with WPT +(`Wd` on Treeherder). To run them: + + % ./mach wpt testing/web-platform/tests/webdriver + +WPT tests conformance to the [WebDriver] standard and uses +[geckodriver]. Together with the Marionette remote protocol in +Gecko, they make up Mozilla’s WebDriver implementation. + +This command supports a `--webdriver-arg='-vv'` argument that +enables more detailed logging, as well as `--jsdebugger` for opening +the Browser Toolbox. + +A particularly useful trick is to combine this with the [headless +mode] for Firefox we learned about earlier: + + % MOZ_HEADLESS=1 ./mach wpt --webdriver-arg='-vv' testing/web-platform/tests/webdriver + +[WebDriver]: https://w3c.github.io/webdriver/webdriver-spec.html + + +Harness tests +------------- + +The Marionette harness Python package has a set of mock-styled unit +tests that uses the [pytest] framework. The following command will +run all tests: + + % ./mach python-test testing/marionette + +To run a specific test specify the full path to the module: + + % ./mach python-test testing/marionette/harness/marionette_harness/tests/harness_unit/test_serve.py + +[pytest]: https://docs.pytest.org/en/latest/ + + +One-click loaners +----------------- + +Additionally, for debugging hard-to-reproduce test failures in CI, +one-click loaners from <Taskcluster.html> can be particularly useful. + + +Out-of-tree testing +------------------- + +All the above examples show tests running _in-tree_, with a local +checkout of _central_ and a local build of Firefox. It is also +possibly to run the Marionette tests _without_ a local build and +with a downloaded test archive from <Taskcluster.html>. + +If you want to run tests from a downloaded test archive, you will +need to download the `target.common.tests.tar.gz` artifact attached to +Treeherder [build jobs] `B` for your system. Extract the archive +and set up the Python Marionette client and harness by executing +the following command in a virtual environment: + + % pip install -r config/marionette_requirements.txt + +The tests can then be found under +_marionette/tests/testing/marionette/harness/marionette_harness/tests_ +and can be executed with the command `marionette`. It supports +the same options as described above for `mach`. + +[build jobs]: https://treeherder.mozilla.org/#/jobs?repo=mozilla-central&filter-searchStr=build diff --git a/testing/marionette/doc/index.rst b/testing/marionette/doc/index.rst new file mode 100644 index 0000000000..3309928f6d --- /dev/null +++ b/testing/marionette/doc/index.rst @@ -0,0 +1,68 @@ +========== +Marionette +========== + +Marionette is a remote `protocol`_ that lets out-of-process programs +communicate with, instrument, and control Gecko-based browsers. + +It provides interfaces for interacting with both the internal JavaScript +runtime and UI elements of Gecko-based browsers, such as Firefox +and Fennec. It can control both the chrome- and content documents, +giving a high level of control and ability to emulate user interaction. + +Within the central tree, Marionette is used in most TaskCluster +test jobs to instrument Gecko. It can additionally be used to +write different kinds of functional tests: + + * The `Marionette Python client`_ is used in the `Mn` job, which + is generally what you want to use for interacting with web documents + +Outside the tree, Marionette is used by `geckodriver`_ to implement +`WebDriver`_. + +Marionette supports to various degrees all the Gecko based applications, +including Firefox, Thunderbird, Fennec, and Fenix. + +.. _protocol: Protocol.html +.. _Marionette Python client: /python/marionette_driver.html +.. _geckodriver: /testing/geckodriver/ +.. _WebDriver: https://w3c.github.io/webdriver/ + +Some further documentation can be found here: + +.. toctree:: + :maxdepth: 1 + + Intro.md + Building.md + PythonTests.md + Protocol.md + Contributing.md + NewContributors.md + Patches.md + Debugging.md + Testing.md + Taskcluster.md + CodeStyle.md + SeleniumAtoms.md + Prefs.md + internals/index + + +Bugs +==== + +Bugs are tracked in the `Testing :: Marionette` component. + + +Communication +============= + +The mailing list for Marionette discussion is +tools-marionette@lists.mozilla.org (`subscribe`_, `archive`_). + +If you prefer real-time chat, ask your questions +on `#interop:mozilla.org <https://chat.mozilla.org/#/room/#interop:mozilla.org>`__. + +.. _subscribe: https://lists.mozilla.org/listinfo/tools-marionette +.. _archive: https://lists.mozilla.org/pipermail/tools-marionette/ diff --git a/testing/marionette/doc/internals/action.rst b/testing/marionette/doc/internals/action.rst new file mode 100644 index 0000000000..1f466ee2d8 --- /dev/null +++ b/testing/marionette/doc/internals/action.rst @@ -0,0 +1,4 @@ +action module +============= +.. js:autoclass:: action + :members: diff --git a/testing/marionette/doc/internals/addon.rst b/testing/marionette/doc/internals/addon.rst new file mode 100644 index 0000000000..21a3997807 --- /dev/null +++ b/testing/marionette/doc/internals/addon.rst @@ -0,0 +1,7 @@ +addon module +============ + +Addon +----- +.. js:autoclass:: Addon + :members: diff --git a/testing/marionette/doc/internals/assert.rst b/testing/marionette/doc/internals/assert.rst new file mode 100644 index 0000000000..ec143f6679 --- /dev/null +++ b/testing/marionette/doc/internals/assert.rst @@ -0,0 +1,4 @@ +assert module +============= +.. js:autoclass:: assert + :members: diff --git a/testing/marionette/doc/internals/browser.rst b/testing/marionette/doc/internals/browser.rst new file mode 100644 index 0000000000..ed3839b5c9 --- /dev/null +++ b/testing/marionette/doc/internals/browser.rst @@ -0,0 +1,4 @@ +browser module +============== +.. js:autoclass:: event + :members: diff --git a/testing/marionette/doc/internals/capabilities.rst b/testing/marionette/doc/internals/capabilities.rst new file mode 100644 index 0000000000..2ce5d3b19c --- /dev/null +++ b/testing/marionette/doc/internals/capabilities.rst @@ -0,0 +1,22 @@ +capabilities module +=================== + +Timeouts +-------- +.. js:autoclass:: Timeouts + :members: + +PageLoadStrategy +---------------- +.. js:autoclass:: PageLoadStrategy + :members: + +Proxy +----- +.. js:autoclass:: Proxy + :members: + +Capabilities +------------ +.. js:autoclass:: Capabilities + :members: diff --git a/testing/marionette/doc/internals/capture.rst b/testing/marionette/doc/internals/capture.rst new file mode 100644 index 0000000000..a3abd6b152 --- /dev/null +++ b/testing/marionette/doc/internals/capture.rst @@ -0,0 +1,7 @@ +capture module +============== + +capture.canvas +-------------- +.. js:autoclass:: capture.canvas + :members: diff --git a/testing/marionette/doc/internals/cert.rst b/testing/marionette/doc/internals/cert.rst new file mode 100644 index 0000000000..33e00d7d1e --- /dev/null +++ b/testing/marionette/doc/internals/cert.rst @@ -0,0 +1,4 @@ +cert module +=========== +.. js:autoclass:: allowAllCerts + :members: diff --git a/testing/marionette/doc/internals/cookie.rst b/testing/marionette/doc/internals/cookie.rst new file mode 100644 index 0000000000..502c7f4c2b --- /dev/null +++ b/testing/marionette/doc/internals/cookie.rst @@ -0,0 +1,4 @@ +cookie module +============= +.. js:autoclass:: cookie + :members: diff --git a/testing/marionette/doc/internals/dom.rst b/testing/marionette/doc/internals/dom.rst new file mode 100644 index 0000000000..cc09042fb1 --- /dev/null +++ b/testing/marionette/doc/internals/dom.rst @@ -0,0 +1,8 @@ +dom module +========== + +.. js:autoclass:: ContentEventObserverService + :members: + +.. js:autoclass:: WebElementEventTarget + :members: diff --git a/testing/marionette/doc/internals/driver.rst b/testing/marionette/doc/internals/driver.rst new file mode 100644 index 0000000000..2181395c1f --- /dev/null +++ b/testing/marionette/doc/internals/driver.rst @@ -0,0 +1,4 @@ +driver module +============= +.. js:autoclass:: driver + :members: diff --git a/testing/marionette/doc/internals/element.rst b/testing/marionette/doc/internals/element.rst new file mode 100644 index 0000000000..5fabfc2c34 --- /dev/null +++ b/testing/marionette/doc/internals/element.rst @@ -0,0 +1,144 @@ +element module +============== + +element.Store +------------- +.. js:autoclass:: element.Store + :members: + +element.find +------------ +.. js:autofunction:: element.find + +element.findByXPath +------------------- +.. js:autofunction:: element.findByXPath + +element.findByXPathAll +---------------------- +.. js:autofunction:: element.findByXPathAll + +element.findByLinkText +---------------------- +.. js:autofunction:: element.findByLinkText + +element.findByPartialLinkText +----------------------------- +.. js:autofunction:: element.findByPartialLinkText + +element.findClosest +------------------- +.. js:autofunction:: element.findClosest + +element.isCollection +-------------------- +.. js:autofunction:: element.isCollection + +element.isStale +--------------- +.. js:autofunction:: element.isStale + +element.isSelected +------------------ +.. js:autofunction:: element.isSelected + +element.isReadOnly +------------------ +.. js:autofunction:: element.isReadOnly + +element.isDisabled +------------------ +.. js:autofunction:: element.isDisabled + +element.isMutableFormControl +---------------------------- +.. js:autofunction:: element.isMutableFormControl + +element.isEditingHost +--------------------- +.. js:autofunction:: element.isEditingHost + +element.isEditable +------------------ +.. js:autofunction:: element.isEditable + +element.coordinates +------------------- +.. js:autofunction:: element.coordinates + +element.inViewport +------------------ +.. js:autofunction:: element.inViewport + +element.getContainer +--------------------- +.. js:autofunction:: element.getContainer + +element.isInView +---------------- +.. js:autofunction:: element.isInView + +element.isVisible +----------------- +.. js:autofunction:: element.isVisible + +element.isObscured +------------------ +.. js:autofunction:: element.isObscured + +element.getInViewCentrePoint +---------------------------- +.. js:autofunction:: element.getInViewCentrePoint + +element.getPointerInteractablePaintTree +--------------------------------------- +.. js:autofunction:: element.getPointerInteractablePaintTree + +element.scrollIntoView +---------------------- +.. js:autofunction:: element.scrollIntoView + +element.isElement +----------------- +.. js:autofunction:: element.isElement + +element.isDOMElement +-------------------- +.. js:autofunction:: element.isDOMElement + +element.isXULElement +-------------------- +.. js:autofunction:: element.isXULElement + +element.isDOMWindow +-------------------- +.. js:autofunction:: element.isDOMWindow + +element.isBooleanAttribute +-------------------------- +.. js:autofunction:: element.isBooleanAttribute + +ChromeWebElement +---------------- +.. js:autoclass:: ChromeWebElement + :members: + +ContentWebElement +----------------- +.. js:autoclass:: ContentWebElement + :members: + +ContentWebFrame +--------------- +.. js:autoclass:: ContentWebFrame + :members: + +ContentWebWindow +---------------- +.. js:autoclass:: ContentWebWindow + :members: + +WebElement +---------- +.. js:autoclass:: WebElement + :members: diff --git a/testing/marionette/doc/internals/error.rst b/testing/marionette/doc/internals/error.rst new file mode 100644 index 0000000000..fb012af737 --- /dev/null +++ b/testing/marionette/doc/internals/error.rst @@ -0,0 +1,35 @@ +error module +============ + +.. js:autofunction:: error.isError +.. js:autofunction:: error.isWebDriverError +.. js:autofunction:: error.wrap +.. js:autofunction:: error.report +.. js:autofunction:: error.stringify +.. js:autofunction:: stack + +.. js:autoclass:: ElementClickInterceptedError +.. js:autoclass:: ElementNotAccessibleError +.. js:autoclass:: ElementNotInteractableError +.. js:autoclass:: InsecureCertificateError +.. js:autoclass:: InvalidArgumentError +.. js:autoclass:: InvalidCookieDomainError +.. js:autoclass:: InvalidElementStateError +.. js:autoclass:: InvalidSelectorError +.. js:autoclass:: InvalidSessionIDError +.. js:autoclass:: JavaScriptError +.. js:autoclass:: MoveTargetOutOfBoundsError +.. js:autoclass:: NoSuchAlertError +.. js:autoclass:: NoSuchElementError +.. js:autoclass:: NoSuchFrameError +.. js:autoclass:: NoSuchWindowError +.. js:autoclass:: ScriptTimeoutError +.. js:autoclass:: SessionNotCreatedError +.. js:autoclass:: StaleElementReferenceError +.. js:autoclass:: TimeoutError +.. js:autoclass:: UnableToSetCookieError +.. js:autoclass:: UnexpectedAlertOpenError +.. js:autoclass:: UnknownCommandError +.. js:autoclass:: UnknownError +.. js:autoclass:: UnsupportedOperationError +.. js:autoclass:: WebDriverError diff --git a/testing/marionette/doc/internals/evaluate.rst b/testing/marionette/doc/internals/evaluate.rst new file mode 100644 index 0000000000..8cbf4448d6 --- /dev/null +++ b/testing/marionette/doc/internals/evaluate.rst @@ -0,0 +1,4 @@ +evaluate module +=============== +.. js:autoclass:: evaluate + :members: diff --git a/testing/marionette/doc/internals/event.rst b/testing/marionette/doc/internals/event.rst new file mode 100644 index 0000000000..296d1b4bdb --- /dev/null +++ b/testing/marionette/doc/internals/event.rst @@ -0,0 +1,4 @@ +event module +============ +.. js:autoclass:: event + :members: diff --git a/testing/marionette/doc/internals/format.rst b/testing/marionette/doc/internals/format.rst new file mode 100644 index 0000000000..0d90c6375b --- /dev/null +++ b/testing/marionette/doc/internals/format.rst @@ -0,0 +1,11 @@ +format module +============= + + +pprint +------ +.. js:autofunction:: pprint + +truncate +-------- +.. js:autofunction:: truncate diff --git a/testing/marionette/doc/internals/index.rst b/testing/marionette/doc/internals/index.rst new file mode 100644 index 0000000000..a52012e0bf --- /dev/null +++ b/testing/marionette/doc/internals/index.rst @@ -0,0 +1,11 @@ +========= +Internals +========= + +This is an overview of all documented internals in the Marionette server: + +.. toctree:: + :glob: + :maxdepth: 1 + + * diff --git a/testing/marionette/doc/internals/interaction.rst b/testing/marionette/doc/internals/interaction.rst new file mode 100644 index 0000000000..298fd5a0f0 --- /dev/null +++ b/testing/marionette/doc/internals/interaction.rst @@ -0,0 +1,4 @@ +interaction module +================== +.. js:autoclass:: interaction + :members: diff --git a/testing/marionette/doc/internals/listener.rst b/testing/marionette/doc/internals/listener.rst new file mode 100644 index 0000000000..4e6b49d0d3 --- /dev/null +++ b/testing/marionette/doc/internals/listener.rst @@ -0,0 +1,2 @@ +listener module +=============== diff --git a/testing/marionette/doc/internals/log.rst b/testing/marionette/doc/internals/log.rst new file mode 100644 index 0000000000..bf8dad1369 --- /dev/null +++ b/testing/marionette/doc/internals/log.rst @@ -0,0 +1,4 @@ +log module +========== +.. js:autoclass:: Log + :members: diff --git a/testing/marionette/doc/internals/message.rst b/testing/marionette/doc/internals/message.rst new file mode 100644 index 0000000000..c44005a5d7 --- /dev/null +++ b/testing/marionette/doc/internals/message.rst @@ -0,0 +1,17 @@ +message module +============== + +Command +------- +.. js:autoclass:: Command + :members: + +Message +------- +.. js:autoclass:: Message + :members: + +Response +-------- +.. js:autoclass:: Response + :members: diff --git a/testing/marionette/doc/internals/modal.rst b/testing/marionette/doc/internals/modal.rst new file mode 100644 index 0000000000..5a9b30ba50 --- /dev/null +++ b/testing/marionette/doc/internals/modal.rst @@ -0,0 +1,4 @@ +modal module +============ +.. js:autoclass:: modal + :members: diff --git a/testing/marionette/doc/internals/navigate.rst b/testing/marionette/doc/internals/navigate.rst new file mode 100644 index 0000000000..4141c79d3c --- /dev/null +++ b/testing/marionette/doc/internals/navigate.rst @@ -0,0 +1,7 @@ +navigate module +=============== + +isLoadEventExpected +------------------- +.. js:autoclass:: navigate.isLoadEventExpected + :members: diff --git a/testing/marionette/doc/internals/packets.rst b/testing/marionette/doc/internals/packets.rst new file mode 100644 index 0000000000..5bc76d48af --- /dev/null +++ b/testing/marionette/doc/internals/packets.rst @@ -0,0 +1,22 @@ +packets module +============== + +RawPacket +--------- +.. js:autoclass:: RawPacket + :members: + +Packet +------ +.. js:autoclass:: Packet + :members: + +JSONPacket +---------- +.. js:autoclass:: JSONPacket + :members: + +BulkPacket +---------- +.. js:autoclass:: BulkPacket + :members: diff --git a/testing/marionette/doc/internals/prefs.rst b/testing/marionette/doc/internals/prefs.rst new file mode 100644 index 0000000000..d54383b70f --- /dev/null +++ b/testing/marionette/doc/internals/prefs.rst @@ -0,0 +1,17 @@ +prefs module +============ + +Branch +------ +.. js:autoclass:: Branch + :members: + +EnvironmentPrefs +---------------- +.. js:autoclass:: EnvironmentPrefs + :members: + +MarionetteBranch +---------------- +.. js:autoclass:: MarionetteBranch + :members: diff --git a/testing/marionette/doc/internals/proxy.rst b/testing/marionette/doc/internals/proxy.rst new file mode 100644 index 0000000000..fe7a775c62 --- /dev/null +++ b/testing/marionette/doc/internals/proxy.rst @@ -0,0 +1,4 @@ +proxy module +============ +.. js:autoclass:: proxy + :members: diff --git a/testing/marionette/doc/internals/reftest.rst b/testing/marionette/doc/internals/reftest.rst new file mode 100644 index 0000000000..85e811850c --- /dev/null +++ b/testing/marionette/doc/internals/reftest.rst @@ -0,0 +1,4 @@ +reftest module +============== +.. js:autoclass:: reftest + :members: diff --git a/testing/marionette/doc/internals/server.rst b/testing/marionette/doc/internals/server.rst new file mode 100644 index 0000000000..8b2f1c391b --- /dev/null +++ b/testing/marionette/doc/internals/server.rst @@ -0,0 +1,12 @@ +server module +============= + +TCPConnection +------------- +.. js:autoclass:: TCPConnection + :members: + +TCPListener +----------- +.. js:autoclass:: TCPListener + :members: diff --git a/testing/marionette/doc/internals/sync.rst b/testing/marionette/doc/internals/sync.rst new file mode 100644 index 0000000000..3ec1ac3f31 --- /dev/null +++ b/testing/marionette/doc/internals/sync.rst @@ -0,0 +1,24 @@ +sync module +=========== + +Provides an assortment of synchronisation primitives. + +.. js:autofunction:: executeSoon + +.. js:autoclass:: MessageManagerDestroyedPromise + :members: + +.. js:autoclass:: PollPromise + :members: + +.. js:autoclass:: Sleep + :members: + +.. js:autoclass:: TimedPromise + :members: + +.. js:autofunction:: waitForEvent + +.. js:autofunction:: waitForMessage + +.. js:autofunction:: waitForObserverTopic diff --git a/testing/marionette/dom.js b/testing/marionette/dom.js new file mode 100644 index 0000000000..ff90e35a1d --- /dev/null +++ b/testing/marionette/dom.js @@ -0,0 +1,215 @@ +/* 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 EXPORTED_SYMBOLS = [ + "ContentEventObserverService", + "WebElementEventTarget", +]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + Log: "chrome://marionette/content/log.js", +}); + +XPCOMUtils.defineLazyGetter(this, "logger", () => Log.get()); + +/** + * The ``EventTarget`` for web elements can be used to observe DOM + * events in the content document. + * + * A caveat of the current implementation is that it is only possible + * to listen for top-level ``window`` global events. + * + * It needs to be backed by a :js:class:`ContentEventObserverService` + * in a content frame script. + * + * Usage:: + * + * let observer = new WebElementEventTarget(messageManager); + * await new Promise(resolve => { + * observer.addEventListener("visibilitychange", resolve, {once: true}); + * chromeWindow.minimize(); + * }); + */ +class WebElementEventTarget { + /** + * @param {function(): nsIMessageListenerManager} messageManagerFn + * Message manager to the current browser. + */ + constructor(messageManager) { + this.mm = messageManager; + this.listeners = {}; + this.mm.addMessageListener("Marionette:DOM:OnEvent", this); + } + + /** + * Register an event handler of a specific event type from the content + * frame. + * + * @param {string} type + * Event type to listen for. + * @param {EventListener} listener + * Object which receives a notification (a ``BareEvent``) + * when an event of the specified type occurs. This must be + * an object implementing the ``EventListener`` interface, + * or a JavaScript function. + * @param {boolean=} once + * Indicates that the ``listener`` should be invoked at + * most once after being added. If true, the ``listener`` + * would automatically be removed when invoked. + */ + addEventListener(type, listener, { once = false } = {}) { + if (!(type in this.listeners)) { + this.listeners[type] = []; + } + + if (!this.listeners[type].includes(listener)) { + listener.once = once; + this.listeners[type].push(listener); + } + + this.mm.sendAsyncMessage("Marionette:DOM:AddEventListener", { type }); + } + + /** + * Removes an event listener. + * + * @param {string} type + * Type of event to cease listening for. + * @param {EventListener} listener + * Event handler to remove from the event target. + */ + removeEventListener(type, listener) { + if (!(type in this.listeners)) { + return; + } + + let stack = this.listeners[type]; + for (let i = stack.length - 1; i >= 0; --i) { + if (stack[i] === listener) { + stack.splice(i, 1); + if (stack.length == 0) { + this.mm.sendAsyncMessage("Marionette:DOM:RemoveEventListener", { + type, + }); + } + return; + } + } + } + + dispatchEvent(event) { + if (!(event.type in this.listeners)) { + return; + } + + event.target = this; + + let stack = this.listeners[event.type].slice(0); + stack.forEach(listener => { + if (typeof listener.handleEvent == "function") { + listener.handleEvent(event); + } else { + listener(event); + } + + if (listener.once) { + this.removeEventListener(event.type, listener); + } + }); + } + + receiveMessage({ name, data }) { + if (name != "Marionette:DOM:OnEvent") { + return; + } + + let ev = { + type: data.type, + }; + this.dispatchEvent(ev); + } +} +this.WebElementEventTarget = WebElementEventTarget; + +/** + * Provides the frame script backend for the + * :js:class:`WebElementEventTarget`. + * + * This service receives requests for new DOM events to listen for and + * to cease listening for, and despatches IPC messages to the browser + * when they fire. + */ +class ContentEventObserverService { + /** + * @param {WindowProxy} windowGlobal + * Window. + * @param {nsIMessageSender.sendAsyncMessage} sendAsyncMessage + * Function for sending an async message to the parent browser. + */ + constructor(windowGlobal, sendAsyncMessage) { + this.window = windowGlobal; + this.sendAsyncMessage = sendAsyncMessage; + this.events = new Set(); + } + + /** + * Observe a new DOM event. + * + * When the DOM event of ``type`` fires, a message is passed to + * the parent browser's event observer. + * + * If event type is already being observed, only a single message + * is sent. E.g. multiple registration for events will only ever emit + * a maximum of one message. + * + * @param {string} type + * DOM event to listen for. + */ + add(type) { + if (this.events.has(type)) { + return; + } + this.window.addEventListener(type, this); + this.events.add(type); + } + + /** + * Ceases observing a DOM event. + * + * @param {string} type + * DOM event to stop listening for. + */ + remove(type) { + if (!this.events.has(type)) { + return; + } + this.window.removeEventListener(type, this); + this.events.delete(type); + } + + /** Ceases observing all previously registered DOM events. */ + clear() { + for (let ev of this) { + this.remove(ev); + } + } + + *[Symbol.iterator]() { + for (let ev of this.events) { + yield ev; + } + } + + handleEvent({ type, target }) { + logger.trace(`Received DOM event ${type}`); + this.sendAsyncMessage("Marionette:DOM:OnEvent", { type }); + } +} +this.ContentEventObserverService = ContentEventObserverService; diff --git a/testing/marionette/driver.js b/testing/marionette/driver.js new file mode 100644 index 0000000000..5589d30b5f --- /dev/null +++ b/testing/marionette/driver.js @@ -0,0 +1,4016 @@ +/* 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"; +/* global XPCNativeWrapper */ + +const EXPORTED_SYMBOLS = ["GeckoDriver"]; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + OS: "resource://gre/modules/osfile.jsm", + + accessibility: "chrome://marionette/content/accessibility.js", + Addon: "chrome://marionette/content/addon.js", + allowAllCerts: "chrome://marionette/content/cert.js", + assert: "chrome://marionette/content/assert.js", + atom: "chrome://marionette/content/atom.js", + browser: "chrome://marionette/content/browser.js", + Capabilities: "chrome://marionette/content/capabilities.js", + capture: "chrome://marionette/content/capture.js", + ChromeWebElement: "chrome://marionette/content/element.js", + clearElementIdCache: + "chrome://marionette/content/actors/MarionetteCommandsParent.jsm", + clearActionInputState: + "chrome://marionette/content/actors/MarionetteCommandsChild.jsm", + Context: "chrome://marionette/content/browser.js", + cookie: "chrome://marionette/content/cookie.js", + DebounceCallback: "chrome://marionette/content/sync.js", + element: "chrome://marionette/content/element.js", + error: "chrome://marionette/content/error.js", + evaluate: "chrome://marionette/content/evaluate.js", + getMarionetteCommandsActorProxy: + "chrome://marionette/content/actors/MarionetteCommandsParent.jsm", + IdlePromise: "chrome://marionette/content/sync.js", + interaction: "chrome://marionette/content/interaction.js", + l10n: "chrome://marionette/content/l10n.js", + legacyaction: "chrome://marionette/content/legacyaction.js", + Log: "chrome://marionette/content/log.js", + MarionettePrefs: "chrome://marionette/content/prefs.js", + modal: "chrome://marionette/content/modal.js", + navigate: "chrome://marionette/content/navigate.js", + PollPromise: "chrome://marionette/content/sync.js", + pprint: "chrome://marionette/content/format.js", + print: "chrome://marionette/content/print.js", + proxy: "chrome://marionette/content/proxy.js", + reftest: "chrome://marionette/content/reftest.js", + registerCommandsActor: + "chrome://marionette/content/actors/MarionetteCommandsParent.jsm", + registerEventsActor: + "chrome://marionette/content/actors/MarionetteEventsParent.jsm", + Sandboxes: "chrome://marionette/content/evaluate.js", + TimedPromise: "chrome://marionette/content/sync.js", + Timeouts: "chrome://marionette/content/capabilities.js", + UnhandledPromptBehavior: "chrome://marionette/content/capabilities.js", + unregisterCommandsActor: + "chrome://marionette/content/actors/MarionetteCommandsParent.jsm", + unregisterEventsActor: + "chrome://marionette/content/actors/MarionetteEventsParent.jsm", + waitForEvent: "chrome://marionette/content/sync.js", + waitForLoadEvent: "chrome://marionette/content/sync.js", + waitForObserverTopic: "chrome://marionette/content/sync.js", + WebElement: "chrome://marionette/content/element.js", + WebElementEventTarget: "chrome://marionette/content/dom.js", + WindowState: "chrome://marionette/content/browser.js", +}); + +XPCOMUtils.defineLazyGetter(this, "logger", () => Log.get()); +XPCOMUtils.defineLazyGlobalGetters(this, ["URL"]); + +const APP_ID_FIREFOX = "{ec8030f7-c20a-464f-9b0e-13a3a9e97384}"; +const APP_ID_THUNDERBIRD = "{3550f703-e582-4d05-9a08-453d09bdfdc6}"; + +const FRAME_SCRIPT = "chrome://marionette/content/listener.js"; +const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; + +const SUPPORTED_STRATEGIES = new Set([ + element.Strategy.ClassName, + element.Strategy.Selector, + element.Strategy.ID, + element.Strategy.Name, + element.Strategy.LinkText, + element.Strategy.PartialLinkText, + element.Strategy.TagName, + element.Strategy.XPath, +]); + +// Timeout used to abort fullscreen, maximize, and minimize +// commands if no window manager is present. +const TIMEOUT_NO_WINDOW_MANAGER = 5000; + +const globalMessageManager = Services.mm; + +/** + * 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 message listener of the current + * browsing context's content frame message listener via ListenerProxy. + * + * 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. + */ +this.GeckoDriver = function(server) { + this.appId = Services.appinfo.ID; + this.appName = Services.appinfo.name.toLowerCase(); + this._server = server; + + this.sessionID = null; + this.browsers = {}; + + // Maps permanentKey to browsing context id: WeakMap.<Object, number> + this._browserIds = new WeakMap(); + + // points to current browser + this.curBrowser = null; + // top-most chrome window + this.mainFrame = null; + + // current browsing contexts for chrome and content + this.chromeBrowsingContext = null; + this.contentBrowsingContext = null; + + // Use content context by default + this.context = Context.Content; + + this.sandboxes = new Sandboxes(() => this.getCurrentWindow()); + this.legacyactions = new legacyaction.Chain(); + + this.capabilities = new Capabilities(); + + this.mm = globalMessageManager; + if (!MarionettePrefs.useActors) { + this.listener = proxy.toListener( + this.sendAsync.bind(this), + () => this.curBrowser + ); + } + + // used for modal dialogs or tab modal alerts + this.dialog = null; + this.dialogObserver = null; +}; + +Object.defineProperty(GeckoDriver.prototype, "a11yChecks", { + get() { + return this.capabilities.get("moz:accessibilityChecks"); + }, +}); + +/** + * 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 = Context.fromString(context); + }, +}); + +/** + * Returns the current URL of the ChromeWindow or content browser, + * depending on context. + * + * @return {URL} + * Read-only property containing the currently loaded URL. + */ +Object.defineProperty(GeckoDriver.prototype, "currentURL", { + get() { + const browsingContext = this.getBrowsingContext({ top: true }); + return new URL(browsingContext.currentWindowGlobal.documentURI.spec); + }, +}); + +/** + * Returns the title of the ChromeWindow or content browser, + * depending on context. + * + * @return {string} + * Read-only property containing the title of the loaded URL. + */ +Object.defineProperty(GeckoDriver.prototype, "title", { + get() { + const browsingContext = this.getBrowsingContext({ top: true }); + return browsingContext.currentWindowGlobal.documentTitle; + }, +}); + +Object.defineProperty(GeckoDriver.prototype, "proxy", { + get() { + return this.capabilities.get("proxy"); + }, +}); + +Object.defineProperty(GeckoDriver.prototype, "secureTLS", { + get() { + return !this.capabilities.get("acceptInsecureCerts"); + }, +}); + +Object.defineProperty(GeckoDriver.prototype, "timeouts", { + get() { + return this.capabilities.get("timeouts"); + }, + + set(newTimeouts) { + this.capabilities.set("timeouts", newTimeouts); + }, +}); + +Object.defineProperty(GeckoDriver.prototype, "windows", { + get() { + return Services.wm.getEnumerator(null); + }, +}); + +Object.defineProperty(GeckoDriver.prototype, "windowType", { + get() { + return this.curBrowser.window.document.documentElement.getAttribute( + "windowtype" + ); + }, +}); + +Object.defineProperty(GeckoDriver.prototype, "windowHandles", { + get() { + let hs = []; + + for (let win of this.windows) { + let tabBrowser = browser.getTabBrowser(win); + + // Only return handles for browser windows + if (tabBrowser && tabBrowser.tabs) { + for (let tab of tabBrowser.tabs) { + let winId = this.getIdForBrowser(browser.getBrowserForTab(tab)); + if (winId !== null) { + hs.push(winId); + } + } + } + } + + return hs; + }, +}); + +Object.defineProperty(GeckoDriver.prototype, "chromeWindowHandles", { + get() { + let hs = []; + + for (let win of this.windows) { + hs.push(getWindowId(win)); + } + + return hs; + }, +}); + +GeckoDriver.prototype.QueryInterface = ChromeUtils.generateQI([ + "nsIObserver", + "nsISupportsWeakReference", +]); + +GeckoDriver.prototype.init = function() { + if (MarionettePrefs.useActors) { + // When using JSWindowActors, we are not relying on framescript events + return; + } + + this.mm.addMessageListener("Marionette:ListenersAttached", this); + this.mm.addMessageListener("Marionette:Register", this); + this.mm.addMessageListener("Marionette:switchedToFrame", this); + this.mm.addMessageListener("Marionette:NavigationEvent", this); + this.mm.addMessageListener("Marionette:Unloaded", this, true); +}; + +GeckoDriver.prototype.uninit = function() { + if (MarionettePrefs.useActors) { + return; + } + + this.mm.removeMessageListener("Marionette:ListenersAttached", this); + this.mm.removeMessageListener("Marionette:Register", this); + this.mm.removeMessageListener("Marionette:switchedToFrame", this); + this.mm.removeMessageListener("Marionette:NavigationEvent", this); + this.mm.removeMessageListener("Marionette:Unloaded", this); +}; + +/** + * Callback used to observe the creation of new modal or tab modal dialogs + * during the session's lifetime. + */ +GeckoDriver.prototype.handleModalDialog = function(action, dialog, win) { + // Only care about modals of the currently selected window. + if (win !== this.curBrowser.window) { + return; + } + + if (action === modal.ACTION_OPENED) { + this.dialog = new modal.Dialog(() => this.curBrowser, dialog); + } else if (action === modal.ACTION_CLOSED) { + this.dialog = null; + } +}; + +/** + * Get the current visible URL. + * + * Can be removed once WindowGlobal supports visibleURL (bug 1664881). + */ +GeckoDriver.prototype._getCurrentURL = async function() { + let url; + + if (MarionettePrefs.useActors) { + url = await this.getActor({ top: true }).getCurrentUrl(); + return new URL(url); + } + + switch (this.context) { + case Context.Chrome: + const browsingContext = this.getBrowsingContext({ top: true }); + url = browsingContext.window.location.href; + break; + case Context.Content: + url = await this.listener.getCurrentUrl(); + break; + } + + return new URL(url); +}; + +/** + * Helper method to send async messages to the content listener. + * Correct usage is to pass in the name of a function in listener.js, + * a serialisable object, and optionally the current command's ID + * when not using the modern dispatching technique. + * + * @param {string} name + * Suffix of the target message handler <tt>Marionette:SUFFIX</tt>. + * @param {Object=} data + * Data that must be serialisable using {@link evaluate.toJSON}. + * @param {number=} commandID + * Optional command ID to ensure synchronisity. + * + * @throws {JavaScriptError} + * If <var>data</var> could not be marshaled. + * @throws {NoSuchWindowError} + * If there is no current target frame. + */ +GeckoDriver.prototype.sendAsync = function(name, data, commandID) { + let payload = evaluate.toJSON(data, this.seenEls); + + if (payload === null) { + payload = {}; + } + + // TODO(ato): When proxy.AsyncMessageChannel + // is used for all chrome <-> content communication + // this can be removed. + if (commandID) { + payload.commandID = commandID; + } + + if (this.curBrowser.curFrameId) { + let target = `Marionette:${name}`; + this.curBrowser.messageManager.sendAsyncMessage(target, payload); + } else { + throw new error.NoSuchWindowError( + "No such content frame; perhaps the listener was not registered?" + ); + } +}; + +/** + * 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 getMarionetteCommandsActorProxy(() => + this.getBrowsingContext(options) + ); +}; + +/** + * Get the selected BrowsingContext for the current context. + * + * @param {Object} options + * @param {Context=} options.context + * Context (content or chrome) for which to retrieve the browsing context. + * Defaults to the current one. + * @param {boolean=} options.parent + * If set to true return the window's parent browsing context, + * otherwise the one from the currently selected frame. Defaults to false. + * @param {boolean=} options.top + * If set to true return the window's top-level browsing context, + * otherwise the one from the currently selected frame. Defaults to false. + * + * @return {BrowsingContext} + * The browsing context. + */ +GeckoDriver.prototype.getBrowsingContext = function(options = {}) { + const { context = this.context, parent = false, top = false } = options; + + let browsingContext = null; + if (context === Context.Chrome) { + browsingContext = this.chromeBrowsingContext; + } else { + browsingContext = this.contentBrowsingContext; + } + + if (browsingContext && parent) { + browsingContext = browsingContext.parent; + } + + if (browsingContext && top) { + browsingContext = browsingContext.top; + } + + return browsingContext; +}; + +/** + * Get the currently selected window. + * + * It will return the outer {@link ChromeWindow} previously selected by + * window handle through {@link #switchToWindow}, or the first window that + * was registered. + * + * @param {Object} options + * @param {Context=} options.context + * Optional name of the context to use for finding the window. + * It will be required if a command always needs a specific context, + * whether which context is currently set. Defaults to the current + * context. + * + * @return {ChromeWindow} + * The current top-level browsing context. + */ +GeckoDriver.prototype.getCurrentWindow = function(options = {}) { + const { context = this.context } = options; + + let win = null; + switch (context) { + case Context.Chrome: + if (this.curBrowser) { + win = this.curBrowser.window; + } + break; + + case 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" + ); +}; + +GeckoDriver.prototype.addFrameCloseListener = function(action) { + let win = this.getCurrentWindow(); + this.mozBrowserClose = e => { + if (e.target.id == this.oopFrameId) { + win.removeEventListener("mozbrowserclose", this.mozBrowserClose, true); + throw new error.NoSuchWindowError( + "The window closed during action: " + action + ); + } + }; + win.addEventListener("mozbrowserclose", this.mozBrowserClose, true); +}; + +/** + * Create a new browsing context for window and add to known browsers. + * + * @param {ChromeWindow} win + * Window for which we will create a browsing context. + * + * @return {string} + * Returns the unique server-assigned ID of the window. + */ +GeckoDriver.prototype.addBrowser = function(win) { + let context = new browser.Context(win, this); + let winId = getWindowId(win); + + this.browsers[winId] = context; + this.curBrowser = this.browsers[winId]; +}; + +/** + * Registers a new browser, win, with Marionette. + * + * If we have not seen the browser content window before, the listener + * frame script will be loaded into it. If isNewSession is true, we will + * switch focus to the start frame when it registers. + * + * @param {ChromeWindow} win + * Window whose browser we need to access. + * @param {boolean=} [false] isNewSession + * True if this is the first time we're talking to this browser. + */ +GeckoDriver.prototype.startBrowser = function(window, isNewSession = false) { + this.mainFrame = window; + + this.addBrowser(window); + this.whenBrowserStarted(window, isNewSession); +}; + +/** + * Callback invoked after a new session has been started in a browser. + * Loads the Marionette frame script into the browser if needed. + * + * @param {ChromeWindow} window + * Window whose browser we need to access. + * @param {boolean} isNewSession + * True if this is the first time we're talking to this browser. + */ +GeckoDriver.prototype.whenBrowserStarted = function(window, isNewSession) { + // Do not load the framescript when actors are used. + if (MarionettePrefs.useActors) { + return; + } + + let mm = window.messageManager; + if (mm) { + if (!isNewSession) { + // Loading the frame script corresponds to a situation we need to + // return to the server. If the messageManager is a message broadcaster + // with no children, we don't have a hope of coming back from this + // call, so send the ack here. Otherwise, make a note of how many + // child scripts will be loaded so we known when it's safe to return. + // Child managers may not have child scripts yet (e.g. socialapi), + // only count child managers that have children, but only count the top + // level children as they are the ones that we expect a response from. + if (mm.childCount !== 0) { + this.curBrowser.frameRegsPending = 0; + for (let i = 0; i < mm.childCount; i++) { + if (mm.getChildAt(i).childCount !== 0) { + this.curBrowser.frameRegsPending += 1; + } + } + } + } + + if (!MarionettePrefs.contentListener || !isNewSession) { + // load listener into the remote frame + // and any applicable new frames + // opened after this call + mm.loadFrameScript(FRAME_SCRIPT, true); + MarionettePrefs.contentListener = true; + } + } else { + logger.error("Unable to load content frame script"); + } +}; + +/** + * Recursively get all labeled text. + * + * @param {Element} el + * The parent element. + * @param {Array.<string>} lines + * Array that holds the text lines. + */ +GeckoDriver.prototype.getVisibleText = function(el, lines) { + try { + if (atom.isElementDisplayed(el, this.getCurrentWindow())) { + if (el.value) { + lines.push(el.value); + } + for (let child in el.childNodes) { + this.getVisibleText(el.childNodes[child], lines); + } + } + } catch (e) { + if (el.nodeName == "#text") { + lines.push(el.textContent); + } + } +}; + +/** + * Handles registration of new content listener browsers. Depending on + * their type they are either accepted or ignored. + * + * @param {xul:browser} browserElement + */ +GeckoDriver.prototype.registerBrowser = function(browserElement) { + // We want to ignore frames that are XUL browsers that aren't in the "main" + // tabbrowser, but accept things on Fennec (which doesn't have a + // xul:tabbrowser), and accept HTML iframes (because tests depend on it), + // as well as XUL frames. Ideally this should be cleaned up and we should + // keep track of browsers a different way. + if ( + this.appId != APP_ID_FIREFOX || + browserElement.namespaceURI != XUL_NS || + browserElement.nodeName != "browser" || + browserElement.getTabBrowser() + ) { + this.curBrowser.register(browserElement); + } +}; + +GeckoDriver.prototype.registerPromise = function() { + const li = "Marionette:Register"; + + return new Promise(resolve => { + let cb = ({ json, target }) => { + this.registerBrowser(target); + + if (this.curBrowser.frameRegsPending > 0) { + this.curBrowser.frameRegsPending--; + } + + if (this.curBrowser.frameRegsPending === 0) { + this.mm.removeMessageListener(li, cb); + resolve(); + } + + return { frameId: json.frameId }; + }; + this.mm.addMessageListener(li, cb); + }); +}; + +GeckoDriver.prototype.listeningPromise = function() { + const li = "Marionette:ListenersAttached"; + + return new Promise(resolve => { + let cb = msg => { + if (msg.json.frameId === this.curBrowser.curFrameId) { + this.mm.removeMessageListener(li, cb); + resolve(msg.json.frameId); + } + }; + this.mm.addMessageListener(li, cb); + }); +}; + +/** + * Create a new WebDriver session. + * + * It is expected that the caller performs the necessary checks on + * the requested capabilities to be WebDriver conforming. The WebDriver + * service offered by Marionette does not match or negotiate capabilities + * beyond type- and bounds checks. + * + * <h3>Capabilities</h3> + * + * <dl> + * <dt><code>pageLoadStrategy</code> (string) + * <dd>The page load strategy to use for the current session. Must be + * one of "<tt>none</tt>", "<tt>eager</tt>", and "<tt>normal</tt>". + * + * <dt><code>acceptInsecureCerts</code> (boolean) + * <dd>Indicates whether untrusted and self-signed TLS certificates + * are implicitly trusted on navigation for the duration of the session. + * + * <dt><code>timeouts</code> (Timeouts object) + * <dd>Describes the timeouts imposed on certian session operations. + * + * <dt><code>proxy</code> (Proxy object) + * <dd>Defines the proxy configuration. + * + * <dt><code>moz:accessibilityChecks</code> (boolean) + * <dd>Run a11y checks when clicking elements. + * + * <dt><code>moz:useNonSpecCompliantPointerOrigin</code> (boolean) + * <dd>Use the not WebDriver conforming calculation of the pointer origin + * when the origin is an element, and the element center point is used. + * + * <dt><code>moz:webdriverClick</code> (boolean) + * <dd>Use a WebDriver conforming <i>WebDriver::ElementClick</i>. + * </dl> + * + * <h4>Timeouts object</h4> + * + * <dl> + * <dt><code>script</code> (number) + * <dd>Determines when to interrupt a script that is being evaluates. + * + * <dt><code>pageLoad</code> (number) + * <dd>Provides the timeout limit used to interrupt navigation of the + * browsing context. + * + * <dt><code>implicit</code> (number) + * <dd>Gives the timeout of when to abort when locating an element. + * </dl> + * + * <h4>Proxy object</h4> + * + * <dl> + * <dt><code>proxyType</code> (string) + * <dd>Indicates the type of proxy configuration. Must be one + * of "<tt>pac</tt>", "<tt>direct</tt>", "<tt>autodetect</tt>", + * "<tt>system</tt>", or "<tt>manual</tt>". + * + * <dt><code>proxyAutoconfigUrl</code> (string) + * <dd>Defines the URL for a proxy auto-config file if + * <code>proxyType</code> is equal to "<tt>pac</tt>". + * + * <dt><code>ftpProxy</code> (string) + * <dd>Defines the proxy host for FTP traffic when the + * <code>proxyType</code> is "<tt>manual</tt>". + * + * <dt><code>httpProxy</code> (string) + * <dd>Defines the proxy host for HTTP traffic when the + * <code>proxyType</code> is "<tt>manual</tt>". + * + * <dt><code>noProxy</code> (string) + * <dd>Lists the adress for which the proxy should be bypassed when + * the <code>proxyType</code> is "<tt>manual</tt>". Must be a JSON + * List containing any number of any of domains, IPv4 addresses, or IPv6 + * addresses. + * + * <dt><code>sslProxy</code> (string) + * <dd>Defines the proxy host for encrypted TLS traffic when the + * <code>proxyType</code> is "<tt>manual</tt>". + * + * <dt><code>socksProxy</code> (string) + * <dd>Defines the proxy host for a SOCKS proxy traffic when the + * <code>proxyType</code> is "<tt>manual</tt>". + * + * <dt><code>socksVersion</code> (string) + * <dd>Defines the SOCKS proxy version when the <code>proxyType</code> is + * "<tt>manual</tt>". It must be any integer between 0 and 255 + * inclusive. + * </dl> + * + * <h3>Example</h3> + * + * Input: + * + * <pre><code> + * {"capabilities": {"acceptInsecureCerts": true}} + * </code></pre> + * + * @param {string=} sessionId + * Normally a unique ID is given to a new session, however this can + * be overriden by providing this field. + * @param {Object.<string, *>=} capabilities + * JSON Object containing any of the recognised capabilities listed + * above. + * + * @return {Object} + * Session ID and capabilities offered by the WebDriver service. + * + * @throws {SessionNotCreatedError} + * If, for whatever reason, a session could not be created. + */ +GeckoDriver.prototype.newSession = async function(cmd) { + if (this.sessionID) { + throw new error.SessionNotCreatedError("Maximum number of active sessions"); + } + this.sessionID = WebElement.generateUUID(); + + try { + this.capabilities = Capabilities.fromJSON(cmd.parameters); + + if (!this.secureTLS) { + logger.warn("TLS certificate errors will be ignored for this session"); + allowAllCerts.enable(); + } + + if (this.proxy.init()) { + logger.info("Proxy settings initialised: " + JSON.stringify(this.proxy)); + } + } catch (e) { + throw new error.SessionNotCreatedError(e); + } + + // If we are testing accessibility with marionette, start a11y service in + // chrome first. This will ensure that we do not have any content-only + // services hanging around. + if (this.a11yChecks && accessibility.service) { + logger.info("Preemptively starting accessibility service in Chrome"); + } + + let waitForWindow = function() { + let windowTypes; + switch (this.appId) { + case APP_ID_THUNDERBIRD: + windowTypes = ["mail:3pane"]; + break; + default: + // We assume that an app either has GeckoView windows, or + // Firefox/Fennec windows, but not both. + windowTypes = ["navigator:browser", "navigator:geckoview"]; + break; + } + let win; + for (let windowType of windowTypes) { + win = Services.wm.getMostRecentWindow(windowType); + if (win) { + break; + } + } + if (!win) { + // if the window isn't even created, just poll wait for it + let checkTimer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + checkTimer.initWithCallback( + waitForWindow.bind(this), + 100, + Ci.nsITimer.TYPE_ONE_SHOT + ); + } else if (win.document.readyState != "complete") { + // otherwise, wait for it to be fully loaded before proceeding + let listener = ev => { + // ensure that we proceed, on the top level document load event + // (not an iframe one...) + if (ev.target != win.document) { + return; + } + win.removeEventListener("load", listener); + waitForWindow.call(this); + }; + win.addEventListener("load", listener, true); + } else { + if (MarionettePrefs.clickToStart) { + Services.prompt.alert( + win, + "", + "Click to start execution of marionette tests" + ); + } + this.startBrowser(win, true); + } + }; + + let registerBrowsers; + let browserListening; + + if (MarionettePrefs.useActors) { + registerCommandsActor(); + registerEventsActor(); + } else { + registerBrowsers = this.registerPromise(); + browserListening = this.listeningPromise(); + } + + if (!MarionettePrefs.contentListener) { + waitForWindow.call(this); + } else if (this.appId != APP_ID_FIREFOX && this.curBrowser === null) { + // if there is a content listener, then we just wake it up + let win = this.getCurrentWindow(); + this.addBrowser(win); + this.whenBrowserStarted(win, false); + } else { + throw new error.WebDriverError("Session already running"); + } + + if (MarionettePrefs.useActors) { + for (let win of this.windows) { + const tabBrowser = browser.getTabBrowser(win); + + if (tabBrowser) { + for (const tab of tabBrowser.tabs) { + const contentBrowser = browser.getBrowserForTab(tab); + this.registerBrowser(contentBrowser); + } + } + } + } else { + await registerBrowsers; + await browserListening; + } + + if (this.mainFrame) { + this.chromeBrowsingContext = this.mainFrame.browsingContext; + this.mainFrame.focus(); + } + + if (this.curBrowser.tab) { + this.contentBrowsingContext = this.curBrowser.contentBrowser.browsingContext; + this.curBrowser.contentBrowser.focus(); + } + + // Setup observer for modal dialogs + this.dialogObserver = new modal.DialogObserver(this); + this.dialogObserver.add(this.handleModalDialog.bind(this)); + + Services.obs.addObserver(this, "browsing-context-attached"); + + // Check if there is already an open dialog for the selected browser window. + this.dialog = modal.findModalDialogs(this.curBrowser); + + return { + sessionId: this.sessionID, + capabilities: this.capabilities, + }; +}; + +GeckoDriver.prototype.observe = function(subject, topic, data) { + switch (topic) { + case "browsing-context-attached": + // For cross-group navigations the complete browsing context tree of a tab + // gets replaced. An indication for that is when the newly attached + // browsing context has the same browserId as the currently selected + // content browsing context, and doesn't have a parent. + // + // Also the current content browsing context gets only updated when it's + // the top-level one to not automatically switch away from the currently + // selected frame. + if ( + subject.browserId == this.contentBrowsingContext?.browserId && + !subject.parent && + !this.contentBrowsingContext?.parent + ) { + logger.trace( + "Remoteness change detected. Set new top-level browsing context " + + `to ${subject.id}` + ); + this.contentBrowsingContext = subject; + if (MarionettePrefs.useActors) { + // When using the framescript, the new browsing context created after + // a remoteness change will self-register. With JSWindowActors, we + // manually update the stored browsing context id. + // Switching to browserId instead of browsingContext.id would make + // this call unnecessary. See Bug 1681973. + this.updateIdForBrowser(this.curBrowser.contentBrowser, subject.id); + } + } + 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.capabilities }; +}; + +/** + * Sets the context of the subsequent commands. + * + * All subsequent requests to commands that in some way involve + * interaction with a browsing context will target the chosen browsing + * context. + * + * @param {string} value + * Name of the context to be switched to. Must be one of "chrome" or + * "content". + * + * @throws {InvalidArgumentError} + * If <var>value</var> is not a string. + * @throws {WebDriverError} + * If <var>value</var> is not a valid browsing context. + */ +GeckoDriver.prototype.setContext = function(cmd) { + let value = assert.string(cmd.parameters.value); + + this.context = value; +}; + +/** + * Gets the context type that is Marionette's current target for + * browsing context scoped commands. + * + * You may choose a context through the {@link #setContext} command. + * + * The default browsing context is {@link Context.Content}. + * + * @return {Context} + * Current context. + */ +GeckoDriver.prototype.getContext = function() { + return this.context; +}; + +/** + * Executes a JavaScript function in the context of the current browsing + * context, if in content space, or in chrome space otherwise, and returns + * the return value of the function. + * + * It is important to note that if the <var>sandboxName</var> parameter + * is left undefined, the script will be evaluated in a mutable sandbox, + * causing any change it makes on the global state of the document to have + * lasting side-effects. + * + * @param {string} script + * Script to evaluate as a function body. + * @param {Array.<(string|boolean|number|object|WebElement)>} args + * Arguments exposed to the script in <code>arguments</code>. + * The array items must be serialisable to the WebDriver protocol. + * @param {string=} sandbox + * Name of the sandbox to evaluate the script in. The sandbox is + * cached for later re-use on the same Window object if + * <var>newSandbox</var> is false. If he parameter is undefined, + * the script is evaluated in a mutable sandbox. If the parameter + * is "system", it will be evaluted in a sandbox with elevated system + * privileges, equivalent to chrome space. + * @param {boolean=} newSandbox + * Forces the script to be evaluated in a fresh sandbox. Note that if + * it is undefined, the script will normally be evaluted in a fresh + * sandbox. + * @param {string=} filename + * Filename of the client's program where this script is evaluated. + * @param {number=} line + * Line in the client's program where this script is evaluated. + * + * @return {(string|boolean|number|object|WebElement)} + * 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 {NoSuchWindowError} + * Browsing context has been discarded. + * @throws {ScriptTimeoutError} + * If the script was interrupted due to reaching the session's + * script timeout. + */ +GeckoDriver.prototype.executeScript = async function(cmd) { + let { script, args } = cmd.parameters; + let opts = { + script: cmd.parameters.script, + args: cmd.parameters.args, + sandboxName: cmd.parameters.sandbox, + newSandbox: cmd.parameters.newSandbox, + file: cmd.parameters.filename, + line: cmd.parameters.line, + }; + + return { value: await this.execute_(script, args, opts) }; +}; + +/** + * Executes a JavaScript function in the context of the current browsing + * context, if in content space, or in chrome space otherwise, and returns + * the object passed to the callback. + * + * The callback is always the last argument to the <var>arguments</var> + * list passed to the function scope of the script. It can be retrieved + * as such: + * + * <pre><code> + * let callback = arguments[arguments.length - 1]; + * callback("foo"); + * // "foo" is returned + * </code></pre> + * + * It is important to note that if the <var>sandboxName</var> parameter + * is left undefined, the script will be evaluated in a mutable sandbox, + * causing any change it makes on the global state of the document to have + * lasting side-effects. + * + * @param {string} script + * Script to evaluate as a function body. + * @param {Array.<(string|boolean|number|object|WebElement)>} args + * Arguments exposed to the script in <code>arguments</code>. + * The array items must be serialisable to the WebDriver protocol. + * @param {string=} sandbox + * Name of the sandbox to evaluate the script in. The sandbox is + * cached for later re-use on the same Window object if + * <var>newSandbox</var> is false. If the parameter is undefined, + * the script is evaluated in a mutable sandbox. If the parameter + * is "system", it will be evaluted in a sandbox with elevated system + * privileges, equivalent to chrome space. + * @param {boolean=} newSandbox + * Forces the script to be evaluated in a fresh sandbox. Note that if + * it is undefined, the script will normally be evaluted in a fresh + * sandbox. + * @param {string=} filename + * Filename of the client's program where this script is evaluated. + * @param {number=} line + * Line in the client's program where this script is evaluated. + * + * @return {(string|boolean|number|object|WebElement)} + * 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 {NoSuchWindowError} + * Browsing context has been discarded. + * @throws {ScriptTimeoutError} + * If the script was interrupted due to reaching the session's + * script timeout. + */ +GeckoDriver.prototype.executeAsyncScript = async function(cmd) { + let { script, args } = cmd.parameters; + let opts = { + script: cmd.parameters.script, + args: cmd.parameters.args, + sandboxName: cmd.parameters.sandbox, + newSandbox: cmd.parameters.newSandbox, + file: cmd.parameters.filename, + line: cmd.parameters.line, + async: true, + }; + + return { value: await this.execute_(script, args, opts) }; +}; + +GeckoDriver.prototype.execute_ = async function( + script, + args = [], + { + sandboxName = null, + newSandbox = false, + file = "", + line = 0, + async = false, + } = {} +) { + assert.open(this.getBrowsingContext()); + await this._handleUserPrompts(); + + assert.string(script, pprint`Expected "script" to be a string: ${script}`); + assert.array(args, pprint`Expected script args to be an array: ${args}`); + if (sandboxName !== null) { + assert.string( + sandboxName, + pprint`Expected sandbox name to be a string: ${sandboxName}` + ); + } + assert.boolean( + newSandbox, + pprint`Expected newSandbox to be boolean: ${newSandbox}` + ); + assert.string(file, pprint`Expected file to be a string: ${file}`); + assert.number(line, pprint`Expected line to be a number: ${line}`); + + let opts = { + timeout: this.timeouts.script, + sandboxName, + newSandbox, + file, + line, + async, + }; + + if (MarionettePrefs.useActors) { + return this.getActor().executeScript(script, args, opts); + } + + let res, els; + + switch (this.context) { + case Context.Chrome: + let sb = this.sandboxes.get(sandboxName, newSandbox); + let wargs = evaluate.fromJSON(args, this.curBrowser.seenEls, sb.window); + res = await evaluate.sandbox(sb, script, wargs, opts); + els = this.curBrowser.seenEls; + break; + + case Context.Content: + // evaluate in content with lasting side-effects + opts.useSandbox = !!sandboxName; + res = await this.listener.executeScript(script, args, opts); + break; + + default: + throw new TypeError(`Unknown context: ${this.context}`); + } + + return evaluate.toJSON(res, els); +}; + +/** + * Navigate to given URL. + * + * Navigates the current browsing context to the given URL and waits for + * the document to load or the session's page timeout duration to elapse + * before returning. + * + * The command will return with a failure if there is an error loading + * the document or the URL is blocked. This can occur if it fails to + * reach host, the URL is malformed, or if there is a certificate issue + * to name some examples. + * + * The document is considered successfully loaded when the + * DOMContentLoaded event on the frame element associated with the + * current window triggers and document.readyState is "complete". + * + * In chrome context it will change the current window's location to + * the supplied URL and wait until document.readyState equals "complete" + * or the page timeout duration has elapsed. + * + * @param {string} url + * URL to navigate to. + * + * @throws {NoSuchWindowError} + * Top-level browsing context has been discarded. + * @throws {UnexpectedAlertOpenError} + * A modal dialog is open, blocking this operation. + * @throws {UnsupportedOperationError} + * Not available in current context. + */ +GeckoDriver.prototype.navigateTo = async function(cmd) { + assert.content(this.context); + const browsingContext = assert.open(this.getBrowsingContext({ top: true })); + await this._handleUserPrompts(); + + let validURL; + try { + validURL = new URL(cmd.parameters.url); + } catch (e) { + throw new error.InvalidArgumentError(`Malformed URL: ${e.message}`); + } + + // Switch to the top-level browsing context before navigating + if (MarionettePrefs.useActors) { + this.contentBrowsingContext = browsingContext; + } else { + await this.listener.switchToFrame(); + } + + const loadEventExpected = navigate.isLoadEventExpected( + await this._getCurrentURL(), + { + future: validURL, + } + ); + + await navigate.waitForNavigationCompleted( + this, + () => { + 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() { + assert.open(this.getBrowsingContext({ top: true })); + await this._handleUserPrompts(); + + const url = await this._getCurrentURL(); + return url.href; +}; + +/** + * Gets the current title of the window. + * + * @return {string} + * Document title of the top-level browsing context. + * + * @throws {NoSuchWindowError} + * Top-level browsing context has been discarded. + * @throws {UnexpectedAlertOpenError} + * A modal dialog is open, blocking this operation. + */ +GeckoDriver.prototype.getTitle = async function() { + assert.open(this.getBrowsingContext({ top: true })); + await this._handleUserPrompts(); + + return this.title; +}; + +/** + * Gets the current type of the window. + * + * @return {string} + * Type of window + * + * @throws {NoSuchWindowError} + * Top-level browsing context has been discarded. + */ +GeckoDriver.prototype.getWindowType = function() { + assert.open(this.getBrowsingContext({ top: true })); + + return this.windowType; +}; + +/** + * Gets the page source of the content document. + * + * @return {string} + * String serialisation of the DOM of the current browsing context's + * active document. + * + * @throws {NoSuchWindowError} + * Browsing context has been discarded. + * @throws {UnexpectedAlertOpenError} + * A modal dialog is open, blocking this operation. + */ +GeckoDriver.prototype.getPageSource = async function() { + assert.open(this.getBrowsingContext()); + await this._handleUserPrompts(); + + if (MarionettePrefs.useActors) { + return this.getActor().getPageSource(); + } + + switch (this.context) { + case Context.Chrome: + const win = this.getCurrentWindow(); + const s = new win.XMLSerializer(); + return s.serializeToString(win.document); + + case Context.Content: + return this.listener.getPageSource(); + + default: + throw new TypeError(`Unknown context: ${this.context}`); + } +}; + +/** + * 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() { + assert.content(this.context); + const browsingContext = assert.open(this.getBrowsingContext({ top: true })); + await this._handleUserPrompts(); + + // If there is no history, just return + if (!browsingContext.embedderElement?.canGoBack) { + return; + } + + await 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() { + assert.content(this.context); + const browsingContext = assert.open(this.getBrowsingContext({ top: true })); + await this._handleUserPrompts(); + + // If there is no history, just return + if (!browsingContext.embedderElement?.canGoForward) { + return; + } + + await 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() { + assert.content(this.context); + const browsingContext = assert.open(this.getBrowsingContext({ top: true })); + await this._handleUserPrompts(); + + // Switch to the top-level browsing context before navigating + if (MarionettePrefs.useActors) { + this.contentBrowsingContext = browsingContext; + } else { + await this.listener.switchToFrame(); + } + + await navigate.waitForNavigationCompleted(this, () => { + navigate.refresh(browsingContext); + }); +}; + +/** + * Forces an update for the given browser's id. + */ +GeckoDriver.prototype.updateIdForBrowser = function(browser, newId) { + this._browserIds.set(browser.permanentKey, newId); +}; + +/** + * Retrieves a listener id for the given xul browser element. In case + * the browser is not known, an attempt is made to retrieve the id from + * a CPOW, and null is returned if this fails. + */ +GeckoDriver.prototype.getIdForBrowser = function(browser) { + if (browser === null) { + return null; + } + + let permKey = browser.permanentKey; + if (this._browserIds.has(permKey)) { + return this._browserIds.get(permKey); + } + + let winId = browser.browsingContext.id; + if (winId) { + this._browserIds.set(permKey, winId); + return winId; + } + return null; +}; + +/** + * Get the current window's handle. On desktop this typically corresponds + * to the currently selected tab. + * + * Return an opaque server-assigned identifier to this window that + * uniquely identifies it within this Marionette instance. This can + * be used to switch to this window at a later point. + * + * @return {string} + * Unique window handle. + * + * @throws {NoSuchWindowError} + * Top-level browsing context has been discarded. + */ +GeckoDriver.prototype.getWindowHandle = function() { + const browsingContext = assert.open( + this.getBrowsingContext({ + context: Context.Content, + top: true, + }) + ); + + return browsingContext.id.toString(); +}; + +/** + * 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. + * + * Each window handle is assigned by the server and is guaranteed unique, + * however the return array does not have a specified ordering. + * + * @return {Array.<string>} + * Unique window handles. + */ +GeckoDriver.prototype.getWindowHandles = function() { + return this.windowHandles.map(String); +}; + +/** + * Get the current window's handle. This corresponds to a window that + * may itself contain tabs. + * + * Return an opaque server-assigned identifier to this window that + * uniquely identifies it within this Marionette instance. This can + * be used to switch to this window at a later point. + * + * @return {string} + * Unique window handle. + * + * @throws {NoSuchWindowError} + * Top-level browsing context has been discarded. + * @throws {UnknownError} + * Internal browsing context reference not found + */ +GeckoDriver.prototype.getChromeWindowHandle = function() { + const browsingContext = assert.open( + this.getBrowsingContext({ + context: Context.Chrome, + top: true, + }) + ); + + return browsingContext.id.toString(); +}; + +/** + * Returns identifiers for each open chrome window for tests interested in + * managing a set of chrome windows and tabs separately. + * + * @return {Array.<string>} + * Unique window handles. + */ +GeckoDriver.prototype.getChromeWindowHandles = function() { + return this.chromeWindowHandles.map(String); +}; + +/** + * Get the current position and size of the browser window currently in focus. + * + * Will return the current browser window size in pixels. Refers to + * window outerWidth and outerHeight values, which include scroll bars, + * title bars, etc. + * + * @return {Object.<string, number>} + * Object with |x| and |y| coordinates, and |width| and |height| + * of browser window. + * + * @throws {NoSuchWindowError} + * Top-level browsing context has been discarded. + * @throws {UnexpectedAlertOpenError} + * A modal dialog is open, blocking this operation. + */ +GeckoDriver.prototype.getWindowRect = async function() { + assert.open(this.getBrowsingContext({ top: true })); + await this._handleUserPrompts(); + + return this.curBrowser.rect; +}; + +/** + * Set the window position and size of the browser on the operating + * system window manager. + * + * The supplied `width` and `height` values refer to the window `outerWidth` + * and `outerHeight` values, which include browser chrome and OS-level + * window borders. + * + * @param {number} x + * X coordinate of the top/left of the window that it will be + * moved to. + * @param {number} y + * Y coordinate of the top/left of the window that it will be + * moved to. + * @param {number} width + * Width to resize the window to. + * @param {number} height + * Height to resize the window to. + * + * @return {Object.<string, number>} + * Object with `x` and `y` coordinates and `width` and `height` + * dimensions. + * + * @throws {NoSuchWindowError} + * Top-level browsing context has been discarded. + * @throws {UnexpectedAlertOpenError} + * A modal dialog is open, blocking this operation. + * @throws {UnsupportedOperationError} + * Not applicable to application. + */ +GeckoDriver.prototype.setWindowRect = async function(cmd) { + assert.firefox(); + assert.open(this.getBrowsingContext({ top: true })); + await this._handleUserPrompts(); + + let { x, y, width, height } = cmd.parameters; + + const win = this.getCurrentWindow(); + switch (WindowState.from(win.windowState)) { + case WindowState.Fullscreen: + await exitFullscreen(win); + break; + + case WindowState.Maximized: + case WindowState.Minimized: + await restoreWindow(win); + break; + } + + if (width != null && height != null) { + assert.positiveInteger(height); + assert.positiveInteger(width); + + if (win.outerWidth != width || win.outerHeight != height) { + win.resizeTo(width, height); + await new IdlePromise(win); + } + } + + if (x != null && y != null) { + assert.integer(x); + assert.integer(y); + + if (win.screenX != x || win.screenY != y) { + win.moveTo(x, y); + await new IdlePromise(win); + } + } + + return this.curBrowser.rect; +}; + +/** + * Switch current top-level browsing context by name or server-assigned + * ID. Searches for windows by name, then ID. Content windows take + * precedence. + * + * @param {string} handle + * Handle of the window to switch to. + * @param {boolean=} focus + * A boolean value which determines whether to focus + * the window. Defaults to true. + * + * @throws {NoSuchWindowError} + * Top-level browsing context has been discarded. + */ +GeckoDriver.prototype.switchToWindow = async function(cmd) { + const { focus = true, handle } = cmd.parameters; + + assert.string( + handle, + pprint`Expected "handle" to be a string, got ${handle}` + ); + assert.boolean(focus, pprint`Expected "focus" to be a boolean, got ${focus}`); + + const id = parseInt(handle); + const found = this.findWindow(this.windows, (win, winId) => id == winId); + + let selected = false; + if (found) { + try { + await this.setWindowHandle(found, focus); + selected = true; + } catch (e) { + logger.error(e); + } + } + + if (!selected) { + throw new error.NoSuchWindowError(`Unable to locate window: ${handle}`); + } +}; + +/** + * Find a specific window according to some filter function. + * + * @param {Iterable.<Window>} winIterable + * Iterable that emits Window objects. + * @param {function(Window, number): boolean} filter + * A callback function taking two arguments; the window and + * the outerId of the window, and returning a boolean indicating + * whether the window is the target. + * + * @return {Object} + * A window handle object containing the window and some + * associated metadata. + */ +GeckoDriver.prototype.findWindow = function(winIterable, filter) { + for (const win of winIterable) { + const browsingContext = win.docShell.browsingContext; + const tabBrowser = browser.getTabBrowser(win); + + // In case the wanted window is a chrome window, we are done. + if (filter(win, browsingContext.id)) { + return { win, id: browsingContext.id, hasTabBrowser: !!tabBrowser }; + + // Otherwise check if the chrome window has a tab browser, and that it + // contains a tab with the wanted window handle. + } else if (tabBrowser && tabBrowser.tabs) { + for (let i = 0; i < tabBrowser.tabs.length; ++i) { + let contentBrowser = browser.getBrowserForTab(tabBrowser.tabs[i]); + let contentWindowId = this.getIdForBrowser(contentBrowser); + + if (filter(win, contentWindowId)) { + return { + win, + id: browsingContext.id, + hasTabBrowser: true, + tabIndex: i, + }; + } + } + } + } + + return null; +}; + +/** + * 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 + * GeckoDriver#findWindow + * @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. + let registerBrowsers, browserListening; + if (!MarionettePrefs.useActors && winProperties.hasTabBrowser) { + registerBrowsers = this.registerPromise(); + browserListening = this.listeningPromise(); + } + + this.startBrowser(winProperties.win, false /* isNewSession */); + + this.chromeBrowsingContext = this.mainFrame.browsingContext; + + if (!winProperties.hasTabBrowser) { + this.contentBrowsingContext = null; + } else if (MarionettePrefs.useActors) { + const tabBrowser = browser.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.contentBrowsingContext = contentBrowser.browsingContext; + this.registerBrowser(contentBrowser); + } else { + await registerBrowsers; + const id = await browserListening; + this.contentBrowsingContext = BrowsingContext.get(id); + } + } 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.chromeBrowsingContext = this.mainFrame.browsingContext; + this.contentBrowsingContext = tab?.linkedBrowser.browsingContext; + } + + if (focus) { + 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 = assert.open(browsingContext?.parent); + + if (MarionettePrefs.useActors) { + this.contentBrowsingContext = browsingContext; + return; + } + + await this.listener.switchToParentFrame(); +}; + +/** + * Switch to a given frame within the current window. + * + * @param {(string|Object)=} element + * A web element reference of the frame or its element id. + * @param {number=} id + * The index of the frame to switch to. + * If both element and id are not defined, switch to top-level frame. + * + * @throws {NoSuchWindowError} + * Browsing context has been discarded. + * @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") { + assert.unsignedShort(id, `Expected id to be unsigned short, got ${id}`); + } + + const top = id == null && el == null; + assert.open(this.getBrowsingContext({ top })); + await this._handleUserPrompts(); + + // Bug 1495063: Elements should be passed as WebElement reference + let byFrame; + if (typeof el == "string") { + byFrame = WebElement.fromUUID(el, this.context); + } else if (el) { + byFrame = WebElement.fromJSON(el); + } + + if (MarionettePrefs.useActors) { + const { browsingContext } = await this.getActor({ top }).switchToFrame( + byFrame || id + ); + + this.contentBrowsingContext = browsingContext; + return; + } + + const checkLoad = function(win) { + const otherErrorsExpr = /about:.+(error)|(blocked)\?/; + + return new PollPromise(resolve => { + if (win.document.readyState == "complete") { + resolve(); + } else if (win.document.readyState == "interactive") { + let documentURI = win.document.documentURI; + if (documentURI.startsWith("about:certerror")) { + throw new error.InsecureCertificateError(); + } else if (otherErrorsExpr.exec(documentURI)) { + throw new error.UnknownError("Reached error page: " + documentURI); + } + } + }); + }; + + if (this.context == Context.Chrome) { + const childContexts = this.getBrowsingContext().children; + + let browsingContext; + if (id == null && !byFrame) { + browsingContext = this.getBrowsingContext({ top: true }); + } else if (typeof id == "number") { + if (id < 0 || id >= childContexts.length) { + throw new error.NoSuchFrameError( + `Unable to locate frame with index: ${id}` + ); + } + browsingContext = childContexts[id]; + } else { + const wantedFrame = this.curBrowser.seenEls.get(byFrame); + const context = childContexts.find(context => { + return context.embedderElement === wantedFrame; + }); + if (!context) { + throw new error.NoSuchFrameError( + `Unable to locate frame for element: ${byFrame}` + ); + } + browsingContext = context; + } + + this.contentBrowsingContext = browsingContext; + + const frameWindow = browsingContext.window; + await checkLoad(frameWindow); + } else if (this.context == Context.Content) { + cmd.commandID = cmd.id; + await this.listener.switchToFrame(cmd.parameters); + } +}; + +GeckoDriver.prototype.getTimeouts = function() { + return this.timeouts; +}; + +/** + * Set timeout for page loading, searching, and scripts. + * + * @param {Object.<string, number>} + * Dictionary of timeout types and their new value, where all timeout + * types are optional. + * + * @throws {InvalidArgumentError} + * If timeout type key is unknown, or the value provided with it is + * not an integer. + */ +GeckoDriver.prototype.setTimeouts = function(cmd) { + // merge with existing timeouts + let merged = Object.assign(this.timeouts.toJSON(), cmd.parameters); + this.timeouts = Timeouts.fromJSON(merged); +}; + +/** Single tap. */ +GeckoDriver.prototype.singleTap = async function(cmd) { + assert.open(this.getBrowsingContext()); + + let { id, x, y } = cmd.parameters; + let webEl = WebElement.fromUUID(id, this.context); + + if (MarionettePrefs.useActors) { + await this.getActor().singleTap(webEl, x, y, this.capabilities); + return; + } + + switch (this.context) { + case Context.Chrome: + throw new error.UnsupportedOperationError( + "Command 'singleTap' is not yet available in chrome context" + ); + + case Context.Content: + await this.listener.singleTap(webEl, x, y, this.capabilities); + break; + } +}; + +/** + * Perform a series of grouped actions at the specified points in time. + * + * @param {Array.<?>} actions + * Array of objects that each represent an action sequence. + * + * @throws {NoSuchWindowError} + * Browsing context has been discarded. + * @throws {UnexpectedAlertOpenError} + * A modal dialog is open, blocking this operation. + * @throws {UnsupportedOperationError} + * Not yet available in current context. + */ +GeckoDriver.prototype.performActions = async function(cmd) { + assert.open(this.getBrowsingContext()); + await this._handleUserPrompts(); + + const actions = cmd.parameters.actions; + + if (MarionettePrefs.useActors) { + await this.getActor().performActions(actions, this.capabilities); + return; + } + + assert.content( + this.context, + "Command 'performActions' is not yet available in chrome context" + ); + + await this.listener.performActions({ actions }, this.capabilities); +}; + +/** + * Release all the keys and pointer buttons that are currently depressed. + * + * @throws {NoSuchWindowError} + * Browsing context has been discarded. + * @throws {UnexpectedAlertOpenError} + * A modal dialog is open, blocking this operation. + * @throws {UnsupportedOperationError} + * Not available in current context. + */ +GeckoDriver.prototype.releaseActions = async function() { + assert.open(this.getBrowsingContext()); + await this._handleUserPrompts(); + + if (MarionettePrefs.useActors) { + await this.getActor().releaseActions(); + return; + } + + assert.content( + this.context, + "Command 'releaseActions' is not yet available in chrome context" + ); + await this.listener.releaseActions(); +}; + +/** + * Find an element using the indicated search strategy. + * + * @param {string} using + * Indicates which search method to use. + * @param {string} value + * Value the client is looking for. + * + * @throws {NoSuchWindowError} + * Browsing context has been discarded. + * @throws {UnexpectedAlertOpenError} + * A modal dialog is open, blocking this operation. + */ +GeckoDriver.prototype.findElement = async function(cmd) { + const { element: el, using, value } = cmd.parameters; + + if (!SUPPORTED_STRATEGIES.has(using)) { + throw new error.InvalidSelectorError(`Strategy not supported: ${using}`); + } + + assert.open(this.getBrowsingContext()); + await this._handleUserPrompts(); + + let startNode; + if (typeof el != "undefined") { + startNode = WebElement.fromUUID(el, this.context); + } + + let opts = { + startNode, + timeout: this.timeouts.implicit, + all: false, + }; + + if (MarionettePrefs.useActors) { + return this.getActor().findElement(using, value, opts); + } + + switch (this.context) { + case Context.Chrome: + let container = { frame: this.getCurrentWindow() }; + if (opts.startNode) { + opts.startNode = this.curBrowser.seenEls.get(opts.startNode); + } + let el = await element.find(container, using, value, opts); + return this.curBrowser.seenEls.add(el); + + case Context.Content: + return this.listener.findElementContent(using, value, opts); + + default: + throw new TypeError(`Unknown context: ${this.context}`); + } +}; + +/** + * Find elements using the indicated search strategy. + * + * @param {string} using + * Indicates which search method to use. + * @param {string} value + * Value the client is looking for. + * + * @throws {NoSuchWindowError} + * Browsing context has been discarded. + */ +GeckoDriver.prototype.findElements = async function(cmd) { + const { element: el, using, value } = cmd.parameters; + + if (!SUPPORTED_STRATEGIES.has(using)) { + throw new error.InvalidSelectorError(`Strategy not supported: ${using}`); + } + + assert.open(this.getBrowsingContext()); + await this._handleUserPrompts(); + + let startNode; + if (typeof el != "undefined") { + startNode = WebElement.fromUUID(el, this.context); + } + + let opts = { + startNode, + timeout: this.timeouts.implicit, + all: true, + }; + + if (MarionettePrefs.useActors) { + return this.getActor().findElements(using, value, opts); + } + + switch (this.context) { + case Context.Chrome: + let container = { frame: this.getCurrentWindow() }; + if (startNode) { + opts.startNode = this.curBrowser.seenEls.get(opts.startNode); + } + let els = await element.find(container, using, value, opts); + return this.curBrowser.seenEls.addAll(els); + + case Context.Content: + return this.listener.findElementsContent(using, value, opts); + + default: + throw new TypeError(`Unknown context: ${this.context}`); + } +}; + +/** + * Return the active element in the document. + * + * @return {WebElement} + * 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 current context. + */ +GeckoDriver.prototype.getActiveElement = async function() { + assert.content(this.context); + assert.open(this.getBrowsingContext()); + await this._handleUserPrompts(); + + if (MarionettePrefs.useActors) { + return this.getActor().getActiveElement(); + } + return this.listener.getActiveElement(); +}; + +/** + * Send click event to element. + * + * @param {string} id + * Reference ID to the element that will be clicked. + * + * @throws {InvalidArgumentError} + * If element <var>id</var> is not a string. + * @throws {NoSuchElementError} + * If element represented by reference <var>id</var> is unknown. + * @throws {NoSuchWindowError} + * Browsing context has been discarded. + * @throws {UnexpectedAlertOpenError} + * A modal dialog is open, blocking this operation. + */ +GeckoDriver.prototype.clickElement = async function(cmd) { + const browsingContext = assert.open(this.getBrowsingContext()); + await this._handleUserPrompts(); + + let id = assert.string(cmd.parameters.id); + let webEl = WebElement.fromUUID(id, this.context); + + if (MarionettePrefs.useActors) { + const actor = this.getActor(); + + const loadEventExpected = navigate.isLoadEventExpected( + await this._getCurrentURL(), + { + browsingContext, + target: await actor.getElementAttribute(webEl, "target"), + } + ); + + await navigate.waitForNavigationCompleted( + this, + () => actor.clickElement(webEl, this.capabilities), + { + loadEventExpected, + // The click might trigger a navigation, so don't count on it. + requireBeforeUnload: false, + } + ); + return; + } + + switch (this.context) { + case Context.Chrome: + let el = this.curBrowser.seenEls.get(webEl); + await interaction.clickElement(el, this.a11yChecks); + break; + + case Context.Content: + const loadEventExpected = navigate.isLoadEventExpected( + await this._getCurrentURL(), + { + browsingContext, + target: this.listener.getElementAttribute(webEl, "target"), + } + ); + + await navigate.waitForNavigationCompleted( + this, + () => this.listener.clickElement(webEl, this.capabilities), + { + loadEventExpected, + // The click might trigger a navigation, so don't count on it. + requireBeforeUnload: false, + } + ); + break; + + default: + throw new TypeError(`Unknown context: ${this.context}`); + } +}; + +/** + * Get a given attribute of an element. + * + * @param {string} id + * Web element reference ID to the element that will be inspected. + * @param {string} name + * Name of the attribute which value to retrieve. + * + * @return {string} + * Value of the attribute. + * + * @throws {InvalidArgumentError} + * If <var>id</var> or <var>name</var> are not strings. + * @throws {NoSuchElementError} + * If element represented by reference <var>id</var> is unknown. + * @throws {NoSuchWindowError} + * Browsing context has been discarded. + * @throws {UnexpectedAlertOpenError} + * A modal dialog is open, blocking this operation. + */ +GeckoDriver.prototype.getElementAttribute = async function(cmd) { + assert.open(this.getBrowsingContext()); + await this._handleUserPrompts(); + + const id = assert.string(cmd.parameters.id); + const name = assert.string(cmd.parameters.name); + const webEl = WebElement.fromUUID(id, this.context); + + if (MarionettePrefs.useActors) { + return this.getActor().getElementAttribute(webEl, name); + } + + switch (this.context) { + case Context.Chrome: + let el = this.curBrowser.seenEls.get(webEl); + return el.getAttribute(name); + + case Context.Content: + return this.listener.getElementAttribute(webEl, name); + + default: + throw new TypeError(`Unknown context: ${this.context}`); + } +}; + +/** + * Returns the value of a property associated with given element. + * + * @param {string} id + * Web element reference ID to the element that will be inspected. + * @param {string} name + * Name of the property which value to retrieve. + * + * @return {string} + * Value of the property. + * + * @throws {InvalidArgumentError} + * If <var>id</var> or <var>name</var> are not strings. + * @throws {NoSuchElementError} + * If element represented by reference <var>id</var> is unknown. + * @throws {NoSuchWindowError} + * Browsing context has been discarded. + * @throws {UnexpectedAlertOpenError} + * A modal dialog is open, blocking this operation. + */ +GeckoDriver.prototype.getElementProperty = async function(cmd) { + assert.open(this.getBrowsingContext()); + await this._handleUserPrompts(); + + const id = assert.string(cmd.parameters.id); + const name = assert.string(cmd.parameters.name); + const webEl = WebElement.fromUUID(id, this.context); + + if (MarionettePrefs.useActors) { + return this.getActor().getElementProperty(webEl, name); + } + + switch (this.context) { + case Context.Chrome: + let el = this.curBrowser.seenEls.get(webEl); + return evaluate.toJSON(el[name], this.curBrowser.seenEls); + + case Context.Content: + return this.listener.getElementProperty(webEl, name); + + default: + throw new TypeError(`Unknown context: ${this.context}`); + } +}; + +/** + * Get the text of an element, if any. Includes the text of all child + * elements. + * + * @param {string} id + * Reference ID to the element that will be inspected. + * + * @return {string} + * Element's text "as rendered". + * + * @throws {InvalidArgumentError} + * If <var>id</var> is not a string. + * @throws {NoSuchElementError} + * If element represented by reference <var>id</var> is unknown. + * @throws {NoSuchWindowError} + * Browsing context has been discarded. + * @throws {UnexpectedAlertOpenError} + * A modal dialog is open, blocking this operation. + */ +GeckoDriver.prototype.getElementText = async function(cmd) { + assert.open(this.getBrowsingContext()); + await this._handleUserPrompts(); + + let id = assert.string(cmd.parameters.id); + let webEl = WebElement.fromUUID(id, this.context); + + if (MarionettePrefs.useActors) { + return this.getActor().getElementText(webEl); + } + + switch (this.context) { + case Context.Chrome: + // for chrome, we look at text nodes, and any node with a "label" field + let el = this.curBrowser.seenEls.get(webEl); + let lines = []; + this.getVisibleText(el, lines); + return lines.join("\n"); + + case Context.Content: + return this.listener.getElementText(webEl); + + default: + throw new TypeError(`Unknown context: ${this.context}`); + } +}; + +/** + * Get the tag name of the element. + * + * @param {string} id + * Reference ID to the element that will be inspected. + * + * @return {string} + * Local tag name of element. + * + * @throws {InvalidArgumentError} + * If <var>id</var> is not a string. + * @throws {NoSuchElementError} + * If element represented by reference <var>id</var> is unknown. + * @throws {NoSuchWindowError} + * Browsing context has been discarded. + * @throws {UnexpectedAlertOpenError} + * A modal dialog is open, blocking this operation. + */ +GeckoDriver.prototype.getElementTagName = async function(cmd) { + assert.open(this.getBrowsingContext()); + await this._handleUserPrompts(); + + let id = assert.string(cmd.parameters.id); + let webEl = WebElement.fromUUID(id, this.context); + + if (MarionettePrefs.useActors) { + return this.getActor().getElementTagName(webEl); + } + + switch (this.context) { + case Context.Chrome: + let el = this.curBrowser.seenEls.get(webEl); + return el.tagName.toLowerCase(); + + case Context.Content: + return this.listener.getElementTagName(webEl); + + default: + throw new TypeError(`Unknown context: ${this.context}`); + } +}; + +/** + * Check if element is displayed. + * + * @param {string} id + * Reference ID to the element that will be inspected. + * + * @return {boolean} + * True if displayed, false otherwise. + * + * @throws {InvalidArgumentError} + * If <var>id</var> is not a string. + * @throws {NoSuchElementError} + * If element represented by reference <var>id</var> is unknown. + * @throws {NoSuchWindowError} + * Browsing context has been discarded. + * @throws {UnexpectedAlertOpenError} + * A modal dialog is open, blocking this operation. + */ +GeckoDriver.prototype.isElementDisplayed = async function(cmd) { + assert.open(this.getBrowsingContext()); + await this._handleUserPrompts(); + + let id = assert.string(cmd.parameters.id); + let webEl = WebElement.fromUUID(id, this.context); + + if (MarionettePrefs.useActors) { + return this.getActor().isElementDisplayed(webEl, this.capabilities); + } + + switch (this.context) { + case Context.Chrome: + let el = this.curBrowser.seenEls.get(webEl); + return interaction.isElementDisplayed(el, this.a11yChecks); + + case Context.Content: + return this.listener.isElementDisplayed(webEl, this.capabilities); + + default: + throw new TypeError(`Unknown context: ${this.context}`); + } +}; + +/** + * Return the property of the computed style of an element. + * + * @param {string} id + * Reference ID to the element that will be checked. + * @param {string} propertyName + * CSS rule that is being requested. + * + * @return {string} + * Value of |propertyName|. + * + * @throws {InvalidArgumentError} + * If <var>id</var> or <var>propertyName</var> are not strings. + * @throws {NoSuchElementError} + * If element represented by reference <var>id</var> is unknown. + * @throws {NoSuchWindowError} + * Browsing context has been discarded. + * @throws {UnexpectedAlertOpenError} + * A modal dialog is open, blocking this operation. + */ +GeckoDriver.prototype.getElementValueOfCssProperty = async function(cmd) { + assert.open(this.getBrowsingContext()); + await this._handleUserPrompts(); + + let id = assert.string(cmd.parameters.id); + let prop = assert.string(cmd.parameters.propertyName); + let webEl = WebElement.fromUUID(id, this.context); + + if (MarionettePrefs.useActors) { + return this.getActor().getElementValueOfCssProperty(webEl, prop); + } + + switch (this.context) { + case Context.Chrome: + const win = this.getCurrentWindow(); + const el = this.curBrowser.seenEls.get(webEl); + const style = win.document.defaultView.getComputedStyle(el); + return style.getPropertyValue(prop); + + case Context.Content: + return this.listener.getElementValueOfCssProperty(webEl, prop); + + default: + throw new TypeError(`Unknown context: ${this.context}`); + } +}; + +/** + * Check if element is enabled. + * + * @param {string} id + * Reference ID to the element that will be checked. + * + * @return {boolean} + * True if enabled, false if disabled. + * + * @throws {InvalidArgumentError} + * If <var>id</var> is not a string. + * @throws {NoSuchElementError} + * If element represented by reference <var>id</var> is unknown. + * @throws {NoSuchWindowError} + * Browsing context has been discarded. + * @throws {UnexpectedAlertOpenError} + * A modal dialog is open, blocking this operation. + */ +GeckoDriver.prototype.isElementEnabled = async function(cmd) { + assert.open(this.getBrowsingContext()); + await this._handleUserPrompts(); + + let id = assert.string(cmd.parameters.id); + let webEl = WebElement.fromUUID(id, this.context); + + if (MarionettePrefs.useActors) { + return this.getActor().isElementEnabled(webEl, this.capabilities); + } + + switch (this.context) { + case Context.Chrome: + // Selenium atom doesn't quite work here + let el = this.curBrowser.seenEls.get(webEl); + return interaction.isElementEnabled(el, this.a11yChecks); + + case Context.Content: + return this.listener.isElementEnabled(webEl, this.capabilities); + + default: + throw new TypeError(`Unknown context: ${this.context}`); + } +}; + +/** + * Check if element is selected. + * + * @param {string} id + * Reference ID to the element that will be checked. + * + * @return {boolean} + * True if selected, false if unselected. + * + * @throws {InvalidArgumentError} + * If <var>id</var> is not a string. + * @throws {NoSuchElementError} + * If element represented by reference <var>id</var> is unknown. + * @throws {NoSuchWindowError} + * Browsing context has been discarded. + * @throws {UnexpectedAlertOpenError} + * A modal dialog is open, blocking this operation. + */ +GeckoDriver.prototype.isElementSelected = async function(cmd) { + assert.open(this.getBrowsingContext()); + await this._handleUserPrompts(); + + let id = assert.string(cmd.parameters.id); + let webEl = WebElement.fromUUID(id, this.context); + + if (MarionettePrefs.useActors) { + return this.getActor().isElementSelected(webEl, this.capabilities); + } + + switch (this.context) { + case Context.Chrome: + // Selenium atom doesn't quite work here + let el = this.curBrowser.seenEls.get(webEl); + return interaction.isElementSelected(el, this.a11yChecks); + + case Context.Content: + return this.listener.isElementSelected(webEl, this.capabilities); + + default: + throw new TypeError(`Unknown context: ${this.context}`); + } +}; + +/** + * @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.getElementRect = async function(cmd) { + assert.open(this.getBrowsingContext()); + await this._handleUserPrompts(); + + let id = assert.string(cmd.parameters.id); + let webEl = WebElement.fromUUID(id, this.context); + + if (MarionettePrefs.useActors) { + return this.getActor().getElementRect(webEl); + } + + switch (this.context) { + case Context.Chrome: + const win = this.getCurrentWindow(); + const el = this.curBrowser.seenEls.get(webEl); + const rect = el.getBoundingClientRect(); + return { + x: rect.x + win.pageXOffset, + y: rect.y + win.pageYOffset, + width: rect.width, + height: rect.height, + }; + + case Context.Content: + return this.listener.getElementRect(webEl); + + default: + throw new TypeError(`Unknown context: ${this.context}`); + } +}; + +/** + * Send key presses to element after focusing on it. + * + * @param {string} id + * Reference ID to the element that will be checked. + * @param {string} text + * Value to send to the element. + * + * @throws {InvalidArgumentError} + * If `id` or `text` are not strings. + * @throws {NoSuchElementError} + * If element represented by reference `id` is unknown. + * @throws {NoSuchWindowError} + * Browsing context has been discarded. + * @throws {UnexpectedAlertOpenError} + * A modal dialog is open, blocking this operation. + */ +GeckoDriver.prototype.sendKeysToElement = async function(cmd) { + assert.open(this.getBrowsingContext()); + await this._handleUserPrompts(); + + let id = assert.string(cmd.parameters.id); + let text = assert.string(cmd.parameters.text); + let webEl = WebElement.fromUUID(id, this.context); + + if (MarionettePrefs.useActors) { + await this.getActor().sendKeysToElement(webEl, text, this.capabilities); + return; + } + + switch (this.context) { + case Context.Chrome: + let el = this.curBrowser.seenEls.get(webEl); + await interaction.sendKeysToElement(el, text, { + accessibilityChecks: this.a11yChecks, + }); + break; + + case Context.Content: + await this.listener.sendKeysToElement(webEl, text, this.capabilities); + break; + + default: + throw new TypeError(`Unknown context: ${this.context}`); + } +}; + +/** + * Clear the text of an element. + * + * @param {string} id + * Reference ID to the element that will be cleared. + * + * @throws {InvalidArgumentError} + * If <var>id</var> is not a string. + * @throws {NoSuchElementError} + * If element represented by reference <var>id</var> is unknown. + * @throws {NoSuchWindowError} + * Browsing context has been discarded. + * @throws {UnexpectedAlertOpenError} + * A modal dialog is open, blocking this operation. + */ +GeckoDriver.prototype.clearElement = async function(cmd) { + assert.open(this.getBrowsingContext()); + await this._handleUserPrompts(); + + let id = assert.string(cmd.parameters.id); + let webEl = WebElement.fromUUID(id, this.context); + + if (MarionettePrefs.useActors) { + await this.getActor().clearElement(webEl); + return; + } + + switch (this.context) { + case Context.Chrome: + // the selenium atom doesn't work here + let el = this.curBrowser.seenEls.get(webEl); + if (el.nodeName == "input" && el.type == "text") { + el.value = ""; + } else if (el.nodeName == "checkbox") { + el.checked = false; + } + break; + + case Context.Content: + await this.listener.clearElement(webEl); + break; + + default: + throw new TypeError(`Unknown context: ${this.context}`); + } +}; + +/** + * Add a single cookie to the cookie store associated with the active + * document's address. + * + * @param {Map.<string, (string|number|boolean)> cookie + * Cookie object. + * + * @throws {InvalidCookieDomainError} + * If <var>cookie</var> is for a different domain than the active + * document's host. + * @throws {NoSuchWindowError} + * Bbrowsing context has been discarded. + * @throws {UnexpectedAlertOpenError} + * A modal dialog is open, blocking this operation. + * @throws {UnsupportedOperationError} + * Not available in current context. + */ +GeckoDriver.prototype.addCookie = async function(cmd) { + assert.content(this.context); + assert.open(this.getBrowsingContext()); + await this._handleUserPrompts(); + + let { protocol, hostname } = await this._getCurrentURL(); + + const networkSchemes = ["ftp:", "http:", "https:"]; + if (!networkSchemes.includes(protocol)) { + throw new error.InvalidCookieDomainError("Document is cookie-averse"); + } + + let newCookie = cookie.fromJSON(cmd.parameters.cookie); + + 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() { + assert.content(this.context); + assert.open(this.getBrowsingContext()); + await this._handleUserPrompts(); + + let { hostname, pathname } = await this._getCurrentURL(); + return [...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() { + assert.content(this.context); + assert.open(this.getBrowsingContext()); + await this._handleUserPrompts(); + + let { hostname, pathname } = await this._getCurrentURL(); + for (let toDelete of cookie.iter(hostname, pathname)) { + 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) { + assert.content(this.context); + assert.open(this.getBrowsingContext()); + await this._handleUserPrompts(); + + let { hostname, pathname } = await this._getCurrentURL(); + let name = assert.string(cmd.parameters.name); + for (let c of cookie.iter(hostname, pathname)) { + if (c.name === name) { + cookie.remove(c); + } + } +}; + +/** + * Open a new top-level browsing context. + * + * @param {string=} type + * Optional type of the new top-level browsing context. Can be one of + * `tab` or `window`. Defaults to `tab`. + * @param {boolean=} focus + * Optional flag if the new top-level browsing context should be opened + * in foreground (focused) or background (not focused). Defaults to false. + * @param {boolean=} private + * Optional flag, which gets only evaluated for type `window`. True if the + * new top-level browsing context should be a private window. + * Defaults to false. + * + * @return {Object.<string, string>} + * Handle and type of the new browsing context. + * + * @throws {NoSuchWindowError} + * Top-level browsing context has been discarded. + */ +GeckoDriver.prototype.newWindow = async function(cmd) { + assert.open(this.getBrowsingContext({ top: true })); + await this._handleUserPrompts(); + + let focus = false; + if (typeof cmd.parameters.focus != "undefined") { + focus = assert.boolean( + cmd.parameters.focus, + pprint`Expected "focus" to be a boolean, got ${cmd.parameters.focus}` + ); + } + + let isPrivate = false; + if (typeof cmd.parameters.private != "undefined") { + isPrivate = assert.boolean( + cmd.parameters.private, + pprint`Expected "private" to be a boolean, got ${cmd.parameters.private}` + ); + } + + let type; + if (typeof cmd.parameters.type != "undefined") { + type = assert.string( + cmd.parameters.type, + pprint`Expected "type" to be a string, got ${cmd.parameters.type}` + ); + } + + // If an invalid or no type has been specified default to a tab. + if (typeof type == "undefined" || !["tab", "window"].includes(type)) { + type = "tab"; + } + + let contentBrowser; + + let onBrowserContentLoaded; + if (MarionettePrefs.useActors) { + // Actors need the new window to be loaded to safely execute queries. + // Wait until a load event is dispatched for the new browsing context. + onBrowserContentLoaded = waitForLoadEvent( + "pageshow", + () => contentBrowser?.browsingContext + ); + } + + switch (type) { + case "window": + let win = await this.curBrowser.openBrowserWindow(focus, isPrivate); + contentBrowser = browser.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 = browser.getBrowserForTab(tab); + } + + await onBrowserContentLoaded; + + // Even with the framescript registered, the browser might not be known to + // the parent process yet. Wait until it is available. + // TODO: Fix by using `Browser:Init` or equivalent on bug 1311041 + let windowId = await new PollPromise((resolve, reject) => { + let id = this.getIdForBrowser(contentBrowser); + this.windowHandles.includes(id) ? resolve(id) : reject(); + }); + + return { handle: windowId.toString(), type }; +}; + +/** + * Close the currently selected tab/window. + * + * With multiple open tabs present the currently selected tab will + * be closed. Otherwise the window itself will be closed. If it is the + * last window currently open, the window will not be closed to prevent + * a shutdown of the application. Instead the returned list of window + * handles is empty. + * + * @return {Array.<string>} + * Unique window handles of remaining windows. + * + * @throws {NoSuchWindowError} + * Top-level browsing context has been discarded. + * @throws {UnexpectedAlertOpenError} + * A modal dialog is open, blocking this operation. + */ +GeckoDriver.prototype.close = async function() { + assert.open(this.getBrowsingContext({ context: Context.Content, top: true })); + await this._handleUserPrompts(); + + let nwins = 0; + + for (let win of this.windows) { + // For browser windows count the tabs. Otherwise take the window itself. + let tabbrowser = browser.getTabBrowser(win); + if (tabbrowser && tabbrowser.tabs) { + nwins += tabbrowser.tabs.length; + } else { + nwins += 1; + } + } + + // If there is only one window left, do not close it. Instead return + // a faked empty array of window handles. This will instruct geckodriver + // to terminate the application. + if (nwins === 1) { + return []; + } + + await this.curBrowser.closeTab(); + this.contentBrowsingContext = null; + + return this.windowHandles.map(String); +}; + +/** + * Close the currently selected chrome window. + * + * If it is the last window currently open, the chrome window will not be + * closed to prevent a shutdown of the application. Instead the returned + * list of chrome window handles is empty. + * + * @return {Array.<string>} + * Unique chrome window handles of remaining chrome windows. + * + * @throws {NoSuchWindowError} + * Top-level browsing context has been discarded. + */ +GeckoDriver.prototype.closeChromeWindow = async function() { + assert.firefox(); + assert.open(this.getBrowsingContext({ context: Context.Chrome, top: true })); + + let nwins = 0; + + // eslint-disable-next-line + for (let _ of this.windows) { + nwins++; + } + + // If there is only one window left, do not close it. Instead return + // a faked empty array of window handles. This will instruct geckodriver + // to terminate the application. + if (nwins == 1) { + return []; + } + + await this.curBrowser.closeWindow(); + this.chromeBrowsingContext = null; + this.contentBrowsingContext = null; + + return this.chromeWindowHandles.map(String); +}; + +/** Delete Marionette session. */ +GeckoDriver.prototype.deleteSession = function() { + if (MarionettePrefs.useActors) { + clearActionInputState(); + clearElementIdCache(); + + unregisterCommandsActor(); + unregisterEventsActor(); + } else if (this.curBrowser !== null) { + // frame scripts can be safely reused + MarionettePrefs.contentListener = false; + + globalMessageManager.broadcastAsyncMessage("Marionette:Session:Delete"); + globalMessageManager.broadcastAsyncMessage("Marionette:Deregister"); + + for (let win of this.windows) { + if (win.messageManager) { + win.messageManager.removeDelayedFrameScript(FRAME_SCRIPT); + } else { + logger.error( + `Could not remove listener from page ${win.location.href}` + ); + } + } + } + + // reset to the top-most frame, and clear browsing context references + this.mainFrame = null; + this.chromeBrowsingContext = null; + this.contentBrowsingContext = null; + + if (this.dialogObserver) { + this.dialogObserver.cleanup(); + this.dialogObserver = null; + } + + try { + Services.obs.removeObserver(this, "browsing-context-attached"); + } catch (e) {} + + this.sandboxes.clear(); + allowAllCerts.disable(); + + this.sessionID = null; + this.capabilities = new Capabilities(); +}; + +/** + * Takes a screenshot of a web element, current frame, or viewport. + * + * The screen capture is returned as a lossless PNG image encoded as + * a base 64 string. + * + * If called in the content context, the |id| argument is not null and + * refers to a present and visible web element's ID, the capture area will + * be limited to the bounding box of that element. Otherwise, the capture + * area will be the bounding box of the current frame. + * + * If called in the chrome context, the screenshot will always represent + * the entire viewport. + * + * @param {string=} id + * Optional web element reference to take a screenshot of. + * If undefined, a screenshot will be taken of the document element. + * @param {boolean=} full + * True to take a screenshot of the entire document element. Is only + * considered if <var>id</var> is not defined. Defaults to true. + * @param {boolean=} hash + * True if the user requests a hash of the image data. Defaults to false. + * @param {boolean=} scroll + * Scroll to element if |id| is provided. Defaults to true. + * + * @return {string} + * If <var>hash</var> is false, PNG image encoded as Base64 encoded + * string. If <var>hash</var> is true, hex digest of the SHA-256 + * hash of the Base64 encoded string. + * + * @throws {NoSuchWindowError} + * Top-level browsing context has been discarded. + */ +GeckoDriver.prototype.takeScreenshot = async function(cmd) { + assert.open(this.getBrowsingContext({ top: true })); + await this._handleUserPrompts(); + + let { id, full, hash, scroll } = cmd.parameters; + let format = hash ? capture.Format.Hash : capture.Format.Base64; + + full = typeof full == "undefined" ? true : full; + scroll = typeof scroll == "undefined" ? true : scroll; + + let webEl = id ? WebElement.fromUUID(id, this.context) : null; + + // Only consider full screenshot if no element has been specified + full = webEl ? false : full; + + if (MarionettePrefs.useActors) { + return this.getActor().takeScreenshot(webEl, format, full, scroll); + } + + const win = this.getCurrentWindow(); + + let rect; + switch (this.context) { + case Context.Chrome: + if (id) { + let el = this.curBrowser.seenEls.get(webEl, win); + rect = el.getBoundingClientRect(); + } else if (full) { + const docEl = win.document.documentElement; + rect = new DOMRect(0, 0, docEl.scrollWidth, docEl.scrollHeight); + } else { + // viewport + rect = new win.DOMRect( + win.pageXOffset, + win.pageYOffset, + win.innerWidth, + win.innerHeight + ); + } + break; + + case Context.Content: + rect = await this.listener.getScreenshotRect({ el: webEl, full, scroll }); + break; + } + + // If no element has been specified use the top-level browsing context. + // Otherwise use the browsing context from the currently selected frame. + const browsingContext = this.getBrowsingContext({ top: !webEl }); + + let canvas = await capture.canvas( + win, + browsingContext, + rect.x, + rect.y, + rect.width, + rect.height + ); + + switch (format) { + case capture.Format.Hash: + return capture.toHash(canvas); + + case capture.Format.Base64: + return capture.toBase64(canvas); + } + + throw new TypeError(`Unknown context: ${this.context}`); +}; + +/** + * 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() { + assert.fennec(); + assert.open(this.getBrowsingContext({ top: true })); + + const win = this.getCurrentWindow(); + + return win.screen.mozOrientation; +}; + +/** + * 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 = function(cmd) { + assert.fennec(); + assert.open(this.getBrowsingContext({ top: true })); + + const ors = [ + "portrait", + "landscape", + "portrait-primary", + "landscape-primary", + "portrait-secondary", + "landscape-secondary", + ]; + + let or = String(cmd.parameters.orientation); + assert.string(or); + let mozOr = or.toLowerCase(); + if (!ors.includes(mozOr)) { + throw new error.InvalidArgumentError(`Unknown screen orientation: ${or}`); + } + + const win = this.getCurrentWindow(); + if (!win.screen.mozLockOrientation(mozOr)) { + throw new error.WebDriverError(`Unable to set screen orientation: ${or}`); + } +}; + +/** + * Synchronously minimizes the user agent window as if the user pressed + * the minimize button. + * + * No action is taken if the window is already minimized. + * + * Not supported on Fennec. + * + * @return {Object.<string, number>} + * Window rect and window state. + * + * @throws {NoSuchWindowError} + * Top-level browsing context has been discarded. + * @throws {UnexpectedAlertOpenError} + * A modal dialog is open, blocking this operation. + * @throws {UnsupportedOperationError} + * Not available for current application. + */ +GeckoDriver.prototype.minimizeWindow = async function() { + assert.firefox(); + assert.open(this.getBrowsingContext({ top: true })); + await this._handleUserPrompts(); + + const win = this.getCurrentWindow(); + switch (WindowState.from(win.windowState)) { + case WindowState.Fullscreen: + await exitFullscreen(win); + break; + + case WindowState.Maximized: + await restoreWindow(win); + break; + } + + if (WindowState.from(win.windowState) != WindowState.Minimized) { + let cb; + let observer = new WebElementEventTarget(this.curBrowser.messageManager); + // Use a timed promise to abort if no window manager is present + await new TimedPromise( + resolve => { + cb = new DebounceCallback(resolve); + observer.addEventListener("visibilitychange", cb); + win.minimize(); + }, + { throws: null, timeout: TIMEOUT_NO_WINDOW_MANAGER } + ); + observer.removeEventListener("visibilitychange", cb); + await new IdlePromise(win); + } + + return this.curBrowser.rect; +}; + +/** + * Synchronously maximizes the user agent window as if the user pressed + * the maximize button. + * + * No action is taken if the window is already maximized. + * + * Not supported on Fennec. + * + * @return {Object.<string, number>} + * Window rect. + * + * @throws {NoSuchWindowError} + * Top-level browsing context has been discarded. + * @throws {UnexpectedAlertOpenError} + * A modal dialog is open, blocking this operation. + * @throws {UnsupportedOperationError} + * Not available for current application. + */ +GeckoDriver.prototype.maximizeWindow = async function() { + assert.firefox(); + assert.open(this.getBrowsingContext({ top: true })); + await this._handleUserPrompts(); + + const win = this.getCurrentWindow(); + switch (WindowState.from(win.windowState)) { + case WindowState.Fullscreen: + await exitFullscreen(win); + break; + + case WindowState.Minimized: + await restoreWindow(win); + break; + } + + if (WindowState.from(win.windowState) != WindowState.Maximized) { + let cb; + // Use a timed promise to abort if no window manager is present + await new TimedPromise( + resolve => { + cb = new DebounceCallback(resolve); + win.addEventListener("sizemodechange", cb); + win.maximize(); + }, + { throws: null, timeout: TIMEOUT_NO_WINDOW_MANAGER } + ); + win.removeEventListener("sizemodechange", cb); + await new IdlePromise(win); + } + + return this.curBrowser.rect; +}; + +/** + * Synchronously sets the user agent window to full screen as if the user + * had done "View > Enter Full Screen". + * + * No action is taken if the window is already in full screen mode. + * + * Not supported on Fennec. + * + * @return {Map.<string, number>} + * Window rect. + * + * @throws {NoSuchWindowError} + * Top-level browsing context has been discarded. + * @throws {UnexpectedAlertOpenError} + * A modal dialog is open, blocking this operation. + * @throws {UnsupportedOperationError} + * Not available for current application. + */ +GeckoDriver.prototype.fullscreenWindow = async function() { + assert.firefox(); + assert.open(this.getBrowsingContext({ top: true })); + await this._handleUserPrompts(); + + const win = this.getCurrentWindow(); + switch (WindowState.from(win.windowState)) { + case WindowState.Maximized: + case WindowState.Minimized: + await restoreWindow(win); + break; + } + + if (WindowState.from(win.windowState) != WindowState.Fullscreen) { + let cb; + // Use a timed promise to abort if no window manager is present + await new TimedPromise( + resolve => { + cb = new DebounceCallback(resolve); + win.addEventListener("sizemodechange", cb); + win.fullScreen = true; + }, + { throws: null, timeout: TIMEOUT_NO_WINDOW_MANAGER } + ); + win.removeEventListener("sizemodechange", cb); + } + await new IdlePromise(win); + + return this.curBrowser.rect; +}; + +/** + * Dismisses a currently displayed tab modal, or returns no such alert if + * no modal is displayed. + * + * @throws {NoSuchWindowError} + * Top-level browsing context has been discarded. + */ +GeckoDriver.prototype.dismissDialog = async function() { + assert.open(this.getBrowsingContext({ top: true })); + this._checkIfAlertIsPresent(); + + const win = this.getCurrentWindow(); + const dialogClosed = waitForEvent(win, "DOMModalDialogClosed"); + + const { button0, button1 } = this.dialog.ui; + (button1 ? button1 : button0).click(); + + await dialogClosed; + await new IdlePromise(win); +}; + +/** + * Accepts a currently displayed tab modal, or returns no such alert if + * no modal is displayed. + * + * @throws {NoSuchWindowError} + * Top-level browsing context has been discarded. + */ +GeckoDriver.prototype.acceptDialog = async function() { + assert.open(this.getBrowsingContext({ top: true })); + this._checkIfAlertIsPresent(); + + const win = this.getCurrentWindow(); + const dialogClosed = waitForEvent(win, "DOMModalDialogClosed"); + + const { button0 } = this.dialog.ui; + button0.click(); + + await dialogClosed; + await new 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 {NoSuchWindowError} + * Top-level browsing context has been discarded. + */ +GeckoDriver.prototype.getTextFromDialog = function() { + assert.open(this.getBrowsingContext({ top: true })); + this._checkIfAlertIsPresent(); + + return this.dialog.ui.infoBody.textContent; +}; + +/** + * Set the user prompt's value field. + * + * Sends keys to the input field of a currently displayed modal, or + * returns a no such alert error if no modal is currently displayed. If + * a tab modal is currently displayed but has no means for text input, + * an element not visible error is returned. + * + * @param {string} text + * Input to the user prompt's value field. + * + * @throws {ElementNotInteractableError} + * If the current user prompt is an alert or confirm. + * @throws {NoSuchAlertError} + * If there is no current user prompt. + * @throws {NoSuchWindowError} + * Top-level browsing context has been discarded. + * @throws {UnsupportedOperationError} + * If the current user prompt is something other than an alert, + * confirm, or a prompt. + */ +GeckoDriver.prototype.sendKeysToDialog = async function(cmd) { + assert.open(this.getBrowsingContext({ top: true })); + this._checkIfAlertIsPresent(); + + let text = assert.string(cmd.parameters.text); + let promptType = this.dialog.args.promptType; + + switch (promptType) { + case "alert": + case "confirm": + throw new error.ElementNotInteractableError( + `User prompt of type ${promptType} is not interactable` + ); + case "prompt": + break; + default: + await this.dismissDialog(); + throw new error.UnsupportedOperationError( + `User prompt of type ${promptType} is not supported` + ); + } + + // see toolkit/components/prompts/content/commonDialog.js + let { loginTextbox } = this.dialog.ui; + loginTextbox.value = text; +}; + +GeckoDriver.prototype._checkIfAlertIsPresent = function() { + if (!this.dialog || !this.dialog.ui) { + throw new error.NoSuchAlertError(); + } +}; + +GeckoDriver.prototype._handleUserPrompts = async function() { + if (!this.dialog || !this.dialog.ui) { + return; + } + + let { textContent } = this.dialog.ui.infoBody; + + let behavior = this.capabilities.get("unhandledPromptBehavior"); + switch (behavior) { + case UnhandledPromptBehavior.Accept: + await this.acceptDialog(); + break; + + case UnhandledPromptBehavior.AcceptAndNotify: + await this.acceptDialog(); + throw new error.UnexpectedAlertOpenError( + `Accepted user prompt dialog: ${textContent}` + ); + + case UnhandledPromptBehavior.Dismiss: + await this.dismissDialog(); + break; + + case UnhandledPromptBehavior.DismissAndNotify: + await this.dismissDialog(); + throw new error.UnexpectedAlertOpenError( + `Dismissed user prompt dialog: ${textContent}` + ); + + case UnhandledPromptBehavior.Ignore: + throw new error.UnexpectedAlertOpenError( + "Encountered unhandled user prompt dialog" + ); + + default: + throw new TypeError(`Unknown unhandledPromptBehavior "${behavior}"`); + } +}; + +/** + * Enables or disables accepting new socket connections. + * + * By calling this method with `false` the server will not accept any + * further connections, but existing connections will not be forcible + * closed. Use `true` to re-enable accepting connections. + * + * Please note that when closing the connection via the client you can + * end-up in a non-recoverable state if it hasn't been enabled before. + * + * This method is used for custom in application shutdowns via + * marionette.quit() or marionette.restart(), like File -> Quit. + * + * @param {boolean} state + * True if the server should accept new socket connections. + */ +GeckoDriver.prototype.acceptConnections = function(cmd) { + assert.boolean(cmd.parameters.value); + this._server.acceptConnections = cmd.parameters.value; +}; + +/** + * Quits the application with the provided flags. + * + * Marionette will stop accepting new connections before ending the + * current session, and finally attempting to quit the application. + * + * Optional {@link nsIAppStartup} flags may be provided as + * an array of masks, and these will be combined by ORing + * them with a bitmask. The available masks are defined in + * https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XPCOM/Reference/Interface/nsIAppStartup. + * + * Crucially, only one of the *Quit flags can be specified. The |eRestart| + * flag may be bit-wise combined with one of the *Quit flags to cause + * the application to restart after it quits. + * + * @param {Array.<string>=} flags + * Constant name of masks to pass to |Services.startup.quit|. + * If empty or undefined, |nsIAppStartup.eAttemptQuit| is used. + * + * @return {string} + * Explaining the reason why the application quit. This can be + * in response to a normal shutdown or restart, yielding "shutdown" + * or "restart", respectively. + * + * @throws {InvalidArgumentError} + * If <var>flags</var> contains unknown or incompatible flags, + * for example multiple Quit flags. + */ +GeckoDriver.prototype.quit = async function(cmd) { + const quits = ["eConsiderQuit", "eAttemptQuit", "eForceQuit"]; + + let flags = []; + if (typeof cmd.parameters.flags != "undefined") { + flags = assert.array(cmd.parameters.flags); + } + + let quitSeen; + let mode = 0; + if (flags.length > 0) { + for (let k of flags) { + assert.in(k, Ci.nsIAppStartup); + + if (quits.includes(k)) { + if (quitSeen) { + throw new error.InvalidArgumentError( + `${k} cannot be combined with ${quitSeen}` + ); + } + quitSeen = k; + } + + mode |= Ci.nsIAppStartup[k]; + } + } else { + mode = Ci.nsIAppStartup.eAttemptQuit; + } + + this._server.acceptConnections = false; + this.deleteSession(); + + // delay response until the application is about to quit + let quitApplication = waitForObserverTopic("quit-application"); + Services.startup.quit(mode); + + return { cause: (await quitApplication).data }; +}; + +GeckoDriver.prototype.installAddon = function(cmd) { + 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 error.InvalidArgumentError(); + } + + return Addon.install(path, temp); +}; + +GeckoDriver.prototype.uninstallAddon = function(cmd) { + assert.firefox(); + + let id = cmd.parameters.id; + if (typeof id == "undefined" || typeof id != "string") { + throw new error.InvalidArgumentError(); + } + + return Addon.uninstall(id); +}; + +/** Receives all messages from content messageManager. */ +/* eslint-disable consistent-return */ +GeckoDriver.prototype.receiveMessage = function(message) { + switch (message.name) { + case "Marionette:switchedToFrame": + this.contentBrowsingContext = BrowsingContext.get( + message.json.browsingContextId + ); + break; + + case "Marionette:Register": + this.registerBrowser(message.target); + return { frameId: message.json.frameId }; + + case "Marionette:ListenersAttached": + if (message.json.frameId === this.curBrowser.curFrameId) { + const browsingContext = BrowsingContext.get(message.json.frameId); + + // If the framescript for the current content browsing context + // has been re-attached due to a remoteness change (the browserId is + // always persistent) then track the new browsing context. + if ( + browsingContext.browserId == this.contentBrowsingContext?.browserId + ) { + logger.trace( + "Detected remoteness change. New browsing context: " + + browsingContext.id + ); + this.contentBrowsingContext = browsingContext; + } + } + break; + } +}; +/* eslint-enable consistent-return */ + +/** + * Retrieve the localized string for the specified entity id. + * + * Example: + * localizeEntity(["chrome://branding/locale/brand.dtd"], "brandShortName") + * + * @param {Array.<string>} urls + * Array of .dtd URLs. + * @param {string} id + * The ID of the entity to retrieve the localized string for. + * + * @return {string} + * The localized string for the requested entity. + */ +GeckoDriver.prototype.localizeEntity = function(cmd) { + let { urls, id } = cmd.parameters; + + if (!Array.isArray(urls)) { + throw new error.InvalidArgumentError( + "Value of `urls` should be of type 'Array'" + ); + } + if (typeof id != "string") { + throw new error.InvalidArgumentError( + "Value of `id` should be of type 'string'" + ); + } + + return l10n.localizeEntity(urls, id); +}; + +/** + * Retrieve the localized string for the specified property id. + * + * Example: + * + * localizeProperty( + * ["chrome://global/locale/findbar.properties"], "FastFind"); + * + * @param {Array.<string>} urls + * Array of .properties URLs. + * @param {string} id + * The ID of the property to retrieve the localized string for. + * + * @return {string} + * The localized string for the requested property. + */ +GeckoDriver.prototype.localizeProperty = function(cmd) { + let { urls, id } = cmd.parameters; + + if (!Array.isArray(urls)) { + throw new error.InvalidArgumentError( + "Value of `urls` should be of type 'Array'" + ); + } + if (typeof id != "string") { + throw new error.InvalidArgumentError( + "Value of `id` should be of type 'string'" + ); + } + + return l10n.localizeProperty(urls, id); +}; + +/** + * Initialize the reftest mode + */ +GeckoDriver.prototype.setupReftest = async function(cmd) { + if (this._reftest) { + throw new 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 error.InvalidArgumentError( + "Value of `screenshot` should be 'always', 'fail' or 'unexpected'" + ); + } + + this._reftest = new reftest.Runner(this); + this._reftest.setup(urlCount, screenshot, isPrint); +}; + +/** Run a reftest. */ +GeckoDriver.prototype.runReftest = async function(cmd) { + let { + test, + references, + expected, + timeout, + width, + height, + pageRanges, + } = cmd.parameters; + + if (!this._reftest) { + throw new error.UnsupportedOperationError( + "Called reftest:run before reftest:start" + ); + } + + assert.string(test); + assert.string(expected); + assert.array(references); + + return { + value: await this._reftest.run( + test, + references, + expected, + timeout, + pageRanges, + width, + height + ), + }; +}; + +/** + * End a reftest run. + * + * Closes the reftest window (without changing the current window handle), + * and removes cached canvases. + */ +GeckoDriver.prototype.teardownReftest = function() { + if (!this._reftest) { + throw new error.UnsupportedOperationError( + "Called reftest:teardown before reftest:start" + ); + } + + this._reftest.teardown(); + this._reftest = null; +}; + +/** + * Print page as PDF. + * + * @param {boolean=} landscape + * Paper orientation. Defaults to false. + * @param {number=} margin.bottom + * Bottom margin in cm. Defaults to 1cm (~0.4 inches). + * @param {number=} margin.left + * Left margin in cm. Defaults to 1cm (~0.4 inches). + * @param {number=} margin.right + * Right margin in cm. Defaults to 1cm (~0.4 inches). + * @param {number=} margin.top + * Top margin in cm. Defaults to 1cm (~0.4 inches). + * @param {string=} pageRanges (not supported) + * Paper ranges to print, e.g., '1-5, 8, 11-13'. + * Defaults to the empty string, which means print all pages. + * @param {number=} page.height + * Paper height in cm. Defaults to US letter height (11 inches / 27.94cm) + * @param {number=} page.width + * Paper width in cm. Defaults to US letter width (8.5 inches / 21.59cm) + * @param {boolean=} shrinkToFit + * Whether or not to override page size as defined by CSS. + * Defaults to true, in which case the content will be scaled + * to fit the paper size. + * @param {boolean=} printBackground + * Print background graphics. Defaults to false. + * @param {number=} scale + * Scale of the webpage rendering. Defaults to 1. + * + * @return {string} + * Base64 encoded PDF representing printed document + * + * @throws {NoSuchWindowError} + * Top-level browsing context has been discarded. + */ +GeckoDriver.prototype.print = async function(cmd) { + assert.content(this.context); + assert.open(this.getBrowsingContext({ top: true })); + await this._handleUserPrompts(); + + const settings = print.addDefaultSettings(cmd.parameters); + for (let prop of ["top", "bottom", "left", "right"]) { + assert.positiveNumber( + settings.margin[prop], + pprint`margin.${prop} is not a positive number` + ); + } + for (let prop of ["width", "height"]) { + assert.positiveNumber( + settings.page[prop], + pprint`page.${prop} is not a positive number` + ); + } + assert.positiveNumber( + settings.scale, + `scale ${settings.scale} is not a positive number` + ); + assert.that( + s => s >= print.minScaleValue && settings.scale <= print.maxScaleValue, + `scale ${settings.scale} is outside the range ${print.minScaleValue}-${print.maxScaleValue}` + )(settings.scale); + assert.boolean(settings.shrinkToFit); + assert.boolean(settings.landscape); + assert.boolean(settings.printBackground); + + const linkedBrowser = this.curBrowser.tab.linkedBrowser; + const filePath = await print.printToFile( + linkedBrowser, + linkedBrowser.outerWindowID, + settings + ); + + // return all data as a base64 encoded string + let bytes; + const file = await OS.File.open(filePath); + try { + bytes = await file.read(); + } finally { + file.close(); + await OS.File.remove(filePath); + } + + // Each UCS2 character has an upper byte of 0 and a lower byte matching + // the binary data + return { + value: btoa(String.fromCharCode.apply(null, bytes)), + }; +}; + +GeckoDriver.prototype.commands = { + // Marionette service + "Marionette:AcceptConnections": GeckoDriver.prototype.acceptConnections, + "Marionette:GetContext": GeckoDriver.prototype.getContext, + "Marionette:GetScreenOrientation": GeckoDriver.prototype.getScreenOrientation, + "Marionette:GetWindowType": GeckoDriver.prototype.getWindowType, + "Marionette:Quit": GeckoDriver.prototype.quit, + "Marionette:SetContext": GeckoDriver.prototype.setContext, + "Marionette:SetScreenOrientation": GeckoDriver.prototype.setScreenOrientation, + "Marionette:SingleTap": GeckoDriver.prototype.singleTap, + + // Addon service + "Addon:Install": GeckoDriver.prototype.installAddon, + "Addon:Uninstall": GeckoDriver.prototype.uninstallAddon, + + // L10n service + "L10n:LocalizeEntity": GeckoDriver.prototype.localizeEntity, + "L10n:LocalizeProperty": GeckoDriver.prototype.localizeProperty, + + // Reftest service + "reftest:setup": GeckoDriver.prototype.setupReftest, + "reftest:run": GeckoDriver.prototype.runReftest, + "reftest:teardown": GeckoDriver.prototype.teardownReftest, + + // WebDriver service + "WebDriver:AcceptAlert": GeckoDriver.prototype.acceptDialog, + "WebDriver:AcceptDialog": GeckoDriver.prototype.acceptDialog, // deprecated, but used in geckodriver (see also bug 1495063) + "WebDriver:AddCookie": GeckoDriver.prototype.addCookie, + "WebDriver:Back": GeckoDriver.prototype.goBack, + "WebDriver:CloseChromeWindow": GeckoDriver.prototype.closeChromeWindow, + "WebDriver:CloseWindow": GeckoDriver.prototype.close, + "WebDriver:DeleteAllCookies": GeckoDriver.prototype.deleteAllCookies, + "WebDriver:DeleteCookie": GeckoDriver.prototype.deleteCookie, + "WebDriver:DeleteSession": GeckoDriver.prototype.deleteSession, + "WebDriver:DismissAlert": GeckoDriver.prototype.dismissDialog, + "WebDriver:ElementClear": GeckoDriver.prototype.clearElement, + "WebDriver:ElementClick": GeckoDriver.prototype.clickElement, + "WebDriver:ElementSendKeys": GeckoDriver.prototype.sendKeysToElement, + "WebDriver:ExecuteAsyncScript": GeckoDriver.prototype.executeAsyncScript, + "WebDriver:ExecuteScript": GeckoDriver.prototype.executeScript, + "WebDriver:FindElement": GeckoDriver.prototype.findElement, + "WebDriver:FindElements": GeckoDriver.prototype.findElements, + "WebDriver:Forward": GeckoDriver.prototype.goForward, + "WebDriver:FullscreenWindow": GeckoDriver.prototype.fullscreenWindow, + "WebDriver:GetActiveElement": GeckoDriver.prototype.getActiveElement, + "WebDriver:GetAlertText": GeckoDriver.prototype.getTextFromDialog, + "WebDriver:GetCapabilities": GeckoDriver.prototype.getSessionCapabilities, + "WebDriver:GetChromeWindowHandle": + GeckoDriver.prototype.getChromeWindowHandle, + "WebDriver:GetChromeWindowHandles": + GeckoDriver.prototype.getChromeWindowHandles, + "WebDriver:GetCookies": GeckoDriver.prototype.getCookies, + "WebDriver:GetCurrentChromeWindowHandle": + GeckoDriver.prototype.getChromeWindowHandle, + "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: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: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, +}; + +function getWindowId(win) { + return win.docShell.browsingContext.id; +} + +async function exitFullscreen(win) { + let cb; + // Use a timed promise to abort if no window manager is present + await new TimedPromise( + resolve => { + cb = new DebounceCallback(resolve); + win.addEventListener("sizemodechange", cb); + win.fullScreen = false; + }, + { throws: null, timeout: TIMEOUT_NO_WINDOW_MANAGER } + ); + win.removeEventListener("sizemodechange", cb); +} + +async function restoreWindow(win) { + win.restore(); + // Use a poll promise to abort if no window manager is present + await new PollPromise( + (resolve, reject) => { + if (WindowState.from(win.windowState) == WindowState.Normal) { + resolve(); + } else { + reject(); + } + }, + { timeout: TIMEOUT_NO_WINDOW_MANAGER } + ); +} diff --git a/testing/marionette/element.js b/testing/marionette/element.js new file mode 100644 index 0000000000..0a22beb929 --- /dev/null +++ b/testing/marionette/element.js @@ -0,0 +1,1840 @@ +/* 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"; +/* global XPCNativeWrapper */ + +const EXPORTED_SYMBOLS = [ + "ChromeWebElement", + "ContentWebElement", + "ContentWebFrame", + "ContentWebWindow", + "element", + "WebElement", +]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + ContentDOMReference: "resource://gre/modules/ContentDOMReference.jsm", + + assert: "chrome://marionette/content/assert.js", + atom: "chrome://marionette/content/atom.js", + error: "chrome://marionette/content/error.js", + PollPromise: "chrome://marionette/content/sync.js", + pprint: "chrome://marionette/content/format.js", +}); + +XPCOMUtils.defineLazyServiceGetter( + this, + "uuidGen", + "@mozilla.org/uuid-generator;1", + "nsIUUIDGenerator" +); + +const ORDERED_NODE_ITERATOR_TYPE = 5; +const FIRST_ORDERED_NODE_TYPE = 9; + +const ELEMENT_NODE = 1; +const DOCUMENT_NODE = 9; + +const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; + +/** XUL elements that support checked property. */ +const XUL_CHECKED_ELS = new Set(["button", "checkbox", "toolbarbutton"]); + +/** XUL elements that support selected property. */ +const XUL_SELECTED_ELS = new Set([ + "menu", + "menuitem", + "menuseparator", + "radio", + "richlistitem", + "tab", +]); + +/** + * This module provides shared functionality for dealing with DOM- + * and web elements in Marionette. + * + * A web element is an abstraction used to identify an element when it + * is transported across the protocol, between remote- and local ends. + * + * Each element has an associated web element reference (a UUID) that + * uniquely identifies the the element across all browsing contexts. The + * web element reference for every element representing the same element + * is the same. + * + * The {@link element.Store} provides a mapping between web element + * references and DOM elements for each browsing context. It also provides + * functionality for looking up and retrieving elements. + * + * @namespace + */ +this.element = {}; + +element.Strategy = { + ClassName: "class name", + Selector: "css selector", + ID: "id", + Name: "name", + LinkText: "link text", + PartialLinkText: "partial link text", + TagName: "tag name", + XPath: "xpath", +}; + +/** + * Stores known/seen elements and their associated web element + * references. + * + * Elements are added by calling {@link #add()} or {@link addAll()}, + * and may be queried by their web element reference using {@link get()}. + * + * @class + * @memberof element + */ +element.Store = class { + constructor() { + this.els = {}; + } + + clear() { + this.els = {}; + } + + /** + * Make a collection of elements seen. + * + * The order of the returned web element references is guaranteed to + * match that of the collection passed in. + * + * @param {NodeList} els + * Sequence of elements to add to set of seen elements. + * + * @return {Array.<WebElement>} + * List of the web element references associated with each element + * from <var>els</var>. + */ + addAll(els) { + let add = this.add.bind(this); + return [...els].map(add); + } + + /** + * Make an element seen. + * + * @param {(Element|WindowProxy|XULElement)} el + * Element to add to set of seen elements. + * + * @return {WebElement} + * Web element reference associated with element. + * + * @throws {TypeError} + * If <var>el</var> is not an {@link Element} or a {@link XULElement}. + */ + add(el) { + const isDOMElement = element.isDOMElement(el); + const isDOMWindow = element.isDOMWindow(el); + const isXULElement = element.isXULElement(el); + const context = element.isInXULDocument(el) ? "chrome" : "content"; + + if (!(isDOMElement || isDOMWindow || isXULElement)) { + throw new TypeError( + "Expected an element or WindowProxy, " + pprint`got: ${el}` + ); + } + + for (let i in this.els) { + let foundEl; + try { + foundEl = this.els[i].get(); + } catch (e) {} + + if (foundEl) { + if (new XPCNativeWrapper(foundEl) == new XPCNativeWrapper(el)) { + return WebElement.fromUUID(i, context); + } + + // cleanup reference to gc'd element + } else { + delete this.els[i]; + } + } + + let webEl = WebElement.from(el); + this.els[webEl.uuid] = Cu.getWeakReference(el); + return webEl; + } + + /** + * Determine if the provided web element reference has been seen + * before/is in the element store. + * + * Unlike when getting the element, a staleness check is not + * performed. + * + * @param {WebElement} webEl + * Element's associated web element reference. + * + * @return {boolean} + * True if element is in the store, false otherwise. + * + * @throws {TypeError} + * If <var>webEl</var> is not a {@link WebElement}. + */ + has(webEl) { + if (!(webEl instanceof WebElement)) { + throw new TypeError(pprint`Expected web element, got: ${webEl}`); + } + return Object.keys(this.els).includes(webEl.uuid); + } + + /** + * Retrieve a DOM {@link Element} or a {@link XULElement} by its + * unique {@link WebElement} reference. + * + * @param {WebElement} webEl + * Web element reference to find the associated {@link Element} + * of. + * @param {WindowProxy} win + * Current window global, which may differ from the associated + * window global of <var>el</var>. + * + * @returns {(Element|XULElement)} + * Element associated with reference. + * + * @throws {TypeError} + * If <var>webEl</var> is not a {@link WebElement}. + * @throws {NoSuchElementError} + * If the web element reference <var>uuid</var> has not been + * seen before. + * @throws {StaleElementReferenceError} + * If the element has gone stale, indicating it is no longer + * attached to the DOM, or its node document is no longer the + * active document. + */ + get(webEl, win) { + if (!(webEl instanceof WebElement)) { + throw new TypeError(pprint`Expected web element, got: ${webEl}`); + } + if (!this.has(webEl)) { + throw new error.NoSuchElementError( + "Web element reference not seen before: " + webEl.uuid + ); + } + + let el; + let ref = this.els[webEl.uuid]; + try { + el = ref.get(); + } catch (e) { + delete this.els[webEl.uuid]; + } + + if (element.isStale(el, win)) { + throw new error.StaleElementReferenceError( + pprint`The element reference of ${el || webEl.uuid} is stale; ` + + "either the element is no longer attached to the DOM, " + + "it is not in the current frame context, " + + "or the document has been refreshed" + ); + } + + return el; + } +}; + +/** + * Stores known/seen web element references and their associated + * ContentDOMReference ElementIdentifiers. + * + * The ContentDOMReference ElementIdentifier is augmented with a WebElement + * reference, so in Marionette's IPC it looks like the following example: + * + * { browsingContextId: 9, + * id: 0.123, + * webElRef: {element-6066-11e4-a52e-4f735466cecf: <uuid>} } + * + * For use in parent process in conjunction with ContentDOMReference in content. + * Implements all `element.Store` methods for duck typing. + * + * @class + * @memberof element + */ +element.ReferenceStore = class { + constructor() { + // uuid -> { id, browsingContextId, webElRef } + this.refs = new Map(); + // id -> webElRef + this.domRefs = new Map(); + } + + clear(browsingContext) { + if (!browsingContext) { + this.refs.clear(); + this.domRefs.clear(); + return; + } + for (const context of browsingContext.getAllBrowsingContextsInSubtree()) { + for (const [uuid, elId] of this.refs) { + if (elId.browsingContextId == context.id) { + this.refs.delete(uuid); + this.domRefs.delete(elId.id); + } + } + } + } + + /** + * Make a collection of elements seen. + * + * The order of the returned web element references is guaranteed to + * match that of the collection passed in. + * + * @param {Array.<ElementIdentifer>} elIds + * Sequence of ids to add to set of seen elements. + * + * @return {Array.<WebElement>} + * List of the web element references associated with each element + * from <var>els</var>. + */ + addAll(elIds) { + return [...elIds].map(elId => this.add(elId)); + } + + /** + * Make an element seen. + * + * @param {ElementIdentifier} elId + * {id, browsingContextId} to add to set of seen elements. + * + * @return {WebElement} + * Web element reference associated with element. + * + */ + add(elId) { + if (!elId.id || !elId.browsingContextId) { + throw new TypeError(pprint`Expected ElementIdentifier, got: ${elId}`); + } + if (this.domRefs.has(elId.id)) { + return WebElement.fromJSON(this.domRefs.get(elId.id)); + } + const webEl = WebElement.fromJSON(elId.webElRef); + this.refs.set(webEl.uuid, elId); + this.domRefs.set(elId.id, elId.webElRef); + return webEl; + } + + /** + * Determine if the provided web element reference is in the store. + * + * Unlike when getting the element, a staleness check is not + * performed. + * + * @param {WebElement} webEl + * Element's associated web element reference. + * + * @return {boolean} + * True if element is in the store, false otherwise. + * + * @throws {TypeError} + * If <var>webEl</var> is not a {@link WebElement}. + */ + has(webEl) { + if (!(webEl instanceof WebElement)) { + throw new TypeError(pprint`Expected web element, got: ${webEl}`); + } + return this.refs.has(webEl.uuid); + } + + /** + * Retrieve a DOM {@link Element} or a {@link XULElement} by its + * unique {@link WebElement} reference. + * + * @param {WebElement} webEl + * Web element reference to find the associated {@link Element} + * of. + * @returns {ElementIdentifier} + * ContentDOMReference identifier + * + * @throws {TypeError} + * If <var>webEl</var> is not a {@link WebElement}. + * @throws {NoSuchElementError} + * If the web element reference <var>uuid</var> has not been + * seen before. + */ + get(webEl) { + if (!(webEl instanceof WebElement)) { + throw new TypeError(pprint`Expected web element, got: ${webEl}`); + } + const elId = this.refs.get(webEl.uuid); + if (!elId) { + throw new error.NoSuchElementError( + "Web element reference not seen before: " + webEl.uuid + ); + } + + return elId; + } +}; + +/** + * Find a single element or a collection of elements starting at the + * document root or a given node. + * + * If |timeout| is above 0, an implicit search technique is used. + * This will wait for the duration of <var>timeout</var> for the + * element to appear in the DOM. + * + * See the {@link element.Strategy} enum for a full list of supported + * search strategies that can be passed to <var>strategy</var>. + * + * Available flags for <var>opts</var>: + * + * <dl> + * <dt><code>all</code> + * <dd> + * If true, a multi-element search selector is used and a sequence + * of elements will be returned. Otherwise a single element. + * + * <dt><code>timeout</code> + * <dd> + * Duration to wait before timing out the search. If <code>all</code> + * is false, a {@link NoSuchElementError} is thrown if unable to + * find the element within the timeout duration. + * + * <dt><code>startNode</code> + * <dd>Element to use as the root of the search. + * + * @param {Object.<string, WindowProxy>} container + * Window object. + * @param {string} strategy + * Search strategy whereby to locate the element(s). + * @param {string} selector + * Selector search pattern. The selector must be compatible with + * the chosen search <var>strategy</var>. + * @param {Object.<string, ?>} opts + * Options. + * + * @return {Promise.<(Element|Array.<Element>)>} + * Single element or a sequence of elements. + * + * @throws InvalidSelectorError + * If <var>strategy</var> is unknown. + * @throws InvalidSelectorError + * If <var>selector</var> is malformed. + * @throws NoSuchElementError + * If a single element is requested, this error will throw if the + * element is not found. + */ +element.find = function(container, strategy, selector, opts = {}) { + let all = !!opts.all; + let timeout = opts.timeout || 0; + let startNode = opts.startNode; + + let searchFn; + if (opts.all) { + searchFn = findElements.bind(this); + } else { + searchFn = findElement.bind(this); + } + + return new Promise((resolve, reject) => { + let findElements = new PollPromise( + (resolve, reject) => { + let res = find_(container, strategy, selector, searchFn, { + all, + startNode, + }); + if (res.length > 0) { + resolve(Array.from(res)); + } else { + reject([]); + } + }, + { timeout } + ); + + findElements.then(foundEls => { + // the following code ought to be moved into findElement + // and findElements when bug 1254486 is addressed + if (!opts.all && (!foundEls || foundEls.length == 0)) { + let msg = `Unable to locate element: ${selector}`; + reject(new error.NoSuchElementError(msg)); + } + + if (opts.all) { + resolve(foundEls); + } + resolve(foundEls[0]); + }, reject); + }); +}; + +function find_( + container, + strategy, + selector, + searchFn, + { startNode = null, all = false } = {} +) { + let rootNode = container.frame.document; + + if (!startNode) { + startNode = rootNode; + } + + let res; + try { + res = searchFn(strategy, selector, rootNode, startNode); + } catch (e) { + throw new error.InvalidSelectorError( + `Given ${strategy} expression "${selector}" is invalid: ${e}` + ); + } + + if (res) { + if (all) { + return res; + } + return [res]; + } + return []; +} + +/** + * Find a single element by XPath expression. + * + * @param {HTMLDocument} document + * Document root. + * @param {Element} startNode + * Where in the DOM hiearchy to begin searching. + * @param {string} expression + * XPath search expression. + * + * @return {Node} + * First element matching <var>expression</var>. + */ +element.findByXPath = function(document, startNode, expression) { + let iter = document.evaluate( + expression, + startNode, + null, + FIRST_ORDERED_NODE_TYPE, + null + ); + return iter.singleNodeValue; +}; + +/** + * Find elements by XPath expression. + * + * @param {HTMLDocument} document + * Document root. + * @param {Element} startNode + * Where in the DOM hierarchy to begin searching. + * @param {string} expression + * XPath search expression. + * + * @return {Iterable.<Node>} + * Iterator over elements matching <var>expression</var>. + */ +element.findByXPathAll = function*(document, startNode, expression) { + let iter = document.evaluate( + expression, + startNode, + null, + ORDERED_NODE_ITERATOR_TYPE, + null + ); + let el = iter.iterateNext(); + while (el) { + yield el; + el = iter.iterateNext(); + } +}; + +/** + * Find all hyperlinks descendant of <var>startNode</var> which + * link text is <var>linkText</var>. + * + * @param {Element} startNode + * Where in the DOM hierarchy to begin searching. + * @param {string} linkText + * Link text to search for. + * + * @return {Iterable.<HTMLAnchorElement>} + * Sequence of link elements which text is <var>s</var>. + */ +element.findByLinkText = function(startNode, linkText) { + return filterLinks( + startNode, + link => atom.getElementText(link).trim() === linkText + ); +}; + +/** + * Find all hyperlinks descendant of <var>startNode</var> which + * link text contains <var>linkText</var>. + * + * @param {Element} startNode + * Where in the DOM hierachy to begin searching. + * @param {string} linkText + * Link text to search for. + * + * @return {Iterable.<HTMLAnchorElement>} + * Iterator of link elements which text containins + * <var>linkText</var>. + */ +element.findByPartialLinkText = function(startNode, linkText) { + return filterLinks(startNode, link => + atom.getElementText(link).includes(linkText) + ); +}; + +/** + * Filters all hyperlinks that are descendant of <var>startNode</var> + * by <var>predicate</var>. + * + * @param {Element} startNode + * Where in the DOM hierarchy to begin searching. + * @param {function(HTMLAnchorElement): boolean} predicate + * Function that determines if given link should be included in + * return value or filtered away. + * + * @return {Iterable.<HTMLAnchorElement>} + * Iterator of link elements matching <var>predicate</var>. + */ +function* filterLinks(startNode, predicate) { + for (let link of startNode.getElementsByTagName("a")) { + if (predicate(link)) { + yield link; + } + } +} + +/** + * Finds a single element. + * + * @param {element.Strategy} strategy + * Selector strategy to use. + * @param {string} selector + * Selector expression. + * @param {HTMLDocument} document + * Document root. + * @param {Element=} startNode + * Optional node from which to start searching. + * + * @return {Element} + * Found elements. + * + * @throws {InvalidSelectorError} + * If strategy <var>using</var> is not recognised. + * @throws {Error} + * If selector expression <var>selector</var> is malformed. + */ +function findElement(strategy, selector, document, startNode = undefined) { + switch (strategy) { + case element.Strategy.ID: { + if (startNode.getElementById) { + return startNode.getElementById(selector); + } + let expr = `.//*[@id="${selector}"]`; + return element.findByXPath(document, startNode, expr); + } + + case element.Strategy.Name: { + if (startNode.getElementsByName) { + return startNode.getElementsByName(selector)[0]; + } + let expr = `.//*[@name="${selector}"]`; + return element.findByXPath(document, startNode, expr); + } + + case element.Strategy.ClassName: + return startNode.getElementsByClassName(selector)[0]; + + case element.Strategy.TagName: + return startNode.getElementsByTagName(selector)[0]; + + case element.Strategy.XPath: + return element.findByXPath(document, startNode, selector); + + case element.Strategy.LinkText: + for (let link of startNode.getElementsByTagName("a")) { + if (atom.getElementText(link).trim() === selector) { + return link; + } + } + return undefined; + + case element.Strategy.PartialLinkText: + for (let link of startNode.getElementsByTagName("a")) { + if (atom.getElementText(link).includes(selector)) { + return link; + } + } + return undefined; + + case element.Strategy.Selector: + try { + return startNode.querySelector(selector); + } catch (e) { + throw new error.InvalidSelectorError(`${e.message}: "${selector}"`); + } + } + + throw new error.InvalidSelectorError(`No such strategy: ${strategy}`); +} + +/** + * Find multiple elements. + * + * @param {element.Strategy} strategy + * Selector strategy to use. + * @param {string} selector + * Selector expression. + * @param {HTMLDocument} document + * Document root. + * @param {Element=} startNode + * Optional node from which to start searching. + * + * @return {Array.<Element>} + * Found elements. + * + * @throws {InvalidSelectorError} + * If strategy <var>strategy</var> is not recognised. + * @throws {Error} + * If selector expression <var>selector</var> is malformed. + */ +function findElements(strategy, selector, document, startNode = undefined) { + switch (strategy) { + case element.Strategy.ID: + selector = `.//*[@id="${selector}"]`; + + // fall through + case element.Strategy.XPath: + return [...element.findByXPathAll(document, startNode, selector)]; + + case element.Strategy.Name: + if (startNode.getElementsByName) { + return startNode.getElementsByName(selector); + } + return [ + ...element.findByXPathAll( + document, + startNode, + `.//*[@name="${selector}"]` + ), + ]; + + case element.Strategy.ClassName: + return startNode.getElementsByClassName(selector); + + case element.Strategy.TagName: + return startNode.getElementsByTagName(selector); + + case element.Strategy.LinkText: + return [...element.findByLinkText(startNode, selector)]; + + case element.Strategy.PartialLinkText: + return [...element.findByPartialLinkText(startNode, selector)]; + + case element.Strategy.Selector: + return startNode.querySelectorAll(selector); + + default: + throw new error.InvalidSelectorError(`No such strategy: ${strategy}`); + } +} + +/** + * Finds the closest parent node of <var>startNode</var> by CSS a + * <var>selector</var> expression. + * + * @param {Node} startNode + * Cyce through <var>startNode</var>'s parent nodes in tree-order + * and return the first match to <var>selector</var>. + * @param {string} selector + * CSS selector expression. + * + * @return {Node=} + * First match to <var>selector</var>, or null if no match was found. + */ +element.findClosest = function(startNode, selector) { + let node = startNode; + while (node.parentNode && node.parentNode.nodeType == ELEMENT_NODE) { + node = node.parentNode; + if (node.matches(selector)) { + return node; + } + } + return null; +}; + +/** + * Wrapper around ContentDOMReference.get with additional steps specific to + * Marionette. + * + * @param {Element} el + * The DOM element to generate the identifier for. + * + * @return {object} The ContentDOMReference ElementIdentifier for the DOM + * element augmented with a Marionette WebElement reference. + */ +element.getElementId = function(el) { + const id = ContentDOMReference.get(el); + const webEl = WebElement.from(el); + id.webElRef = webEl.toJSON(); + return id; +}; + +/** + * Wrapper around ContentDOMReference.resolve with additional error handling + * specific to Marionette. + * + * @param {ElementIdentifier} id + * The identifier generated via ContentDOMReference.get for a DOM element. + * + * @param {WindowProxy} win + * Current window, which may differ from the associated + * window of <var>el</var>. + * + * @return {Element} The DOM element that the identifier was generated for, or + * null if the element does not still exist. + * + * @throws {NoSuchElementError} + * If element represented by reference <var>id</var> doesn't exist + * in the current browsing context. + * @throws {StaleElementReferenceError} + * If the element has gone stale, indicating it is no longer + * attached to the DOM, or its node document is no longer the + * active document. + */ +element.resolveElement = function(id, win) { + // Don't allow elements whose browsing context differs from the current one. + if (id.browsingContextId != win?.browsingContext.id) { + throw new error.NoSuchElementError( + `Web element reference not seen before: ${JSON.stringify(id.webElRef)}` + ); + } + + const el = ContentDOMReference.resolve(id); + + if (element.isStale(el, win)) { + throw new error.StaleElementReferenceError( + pprint`The element reference of ${el || JSON.stringify(id.webElRef)} ` + + "is stale; either the element is no longer attached to the DOM, " + + "it is not in the current frame context, " + + "or the document has been refreshed" + ); + } + return el; +}; + +/** + * Determines if <var>obj<var> is an HTML or JS collection. + * + * @param {*} seq + * Type to determine. + * + * @return {boolean} + * True if <var>seq</va> is collection. + */ +element.isCollection = function(seq) { + switch (Object.prototype.toString.call(seq)) { + case "[object Arguments]": + case "[object Array]": + case "[object FileList]": + case "[object HTMLAllCollection]": + case "[object HTMLCollection]": + case "[object HTMLFormControlsCollection]": + case "[object HTMLOptionsCollection]": + case "[object NodeList]": + return true; + + default: + return false; + } +}; + +/** + * Determines if <var>el</var> is stale. + * + * A stale element is an element no longer attached to the DOM or which + * node document is not the active document of the current browsing + * context. + * + * The currently selected browsing context, specified through + * <var>win<var>, is a WebDriver concept defining the target + * against which commands will run. As the current browsing context + * may differ from <var>el</var>'s associated context, an element is + * considered stale even if it is connected to a living (not discarded) + * browsing context such as an <tt><iframe></tt>. + * + * @param {Element=} el + * DOM element to check for staleness. If null, which may be + * the case if the element has been unwrapped from a weak + * reference, it is always considered stale. + * @param {WindowProxy=} win + * Current window global, which may differ from the associated + * window global of <var>el</var>. When retrieving XUL + * elements, this is optional. + * + * @return {boolean} + * True if <var>el</var> is stale, false otherwise. + */ +element.isStale = function(el, win = undefined) { + if (typeof win == "undefined") { + win = el.ownerGlobal; + } + if (el === null || !el.ownerGlobal || el.ownerDocument !== win.document) { + return true; + } + + return !el.isConnected; +}; + +/** + * Determine if <var>el</var> is selected or not. + * + * This operation only makes sense on + * <tt><input type=checkbox></tt>, + * <tt><input type=radio></tt>, + * and <tt>>option></tt> elements. + * + * @param {(DOMElement|XULElement)} el + * Element to test if selected. + * + * @return {boolean} + * True if element is selected, false otherwise. + */ +element.isSelected = function(el) { + if (!el) { + return false; + } + + if (element.isXULElement(el)) { + if (XUL_CHECKED_ELS.has(el.tagName)) { + return el.checked; + } else if (XUL_SELECTED_ELS.has(el.tagName)) { + return el.selected; + } + } else if (element.isDOMElement(el)) { + if (el.localName == "input" && ["checkbox", "radio"].includes(el.type)) { + return el.checked; + } else if (el.localName == "option") { + return el.selected; + } + } + + return false; +}; + +/** + * An element is considered read only if it is an + * <code><input></code> or <code><textarea></code> + * element whose <code>readOnly</code> content IDL attribute is set. + * + * @param {Element} el + * Element to test is read only. + * + * @return {boolean} + * True if element is read only. + */ +element.isReadOnly = function(el) { + return ( + element.isDOMElement(el) && + ["input", "textarea"].includes(el.localName) && + el.readOnly + ); +}; + +/** + * An element is considered disabled if it is a an element + * that can be disabled, or it belongs to a container group which + * <code>disabled</code> content IDL attribute affects it. + * + * @param {Element} el + * Element to test for disabledness. + * + * @return {boolean} + * True if element, or its container group, is disabled. + */ +element.isDisabled = function(el) { + if (!element.isDOMElement(el)) { + return false; + } + + switch (el.localName) { + case "option": + case "optgroup": + if (el.disabled) { + return true; + } + let parent = element.findClosest(el, "optgroup,select"); + return element.isDisabled(parent); + + case "button": + case "input": + case "select": + case "textarea": + return el.disabled; + + default: + return false; + } +}; + +/** + * Denotes elements that can be used for typing and clearing. + * + * Elements that are considered WebDriver-editable are non-readonly + * and non-disabled <code><input></code> elements in the Text, + * Search, URL, Telephone, Email, Password, Date, Month, Date and + * Time Local, Number, Range, Color, and File Upload states, and + * <code><textarea></code> elements. + * + * @param {Element} el + * Element to test. + * + * @return {boolean} + * True if editable, false otherwise. + */ +element.isMutableFormControl = function(el) { + if (!element.isDOMElement(el)) { + return false; + } + if (element.isReadOnly(el) || element.isDisabled(el)) { + return false; + } + + if (el.localName == "textarea") { + return true; + } + + if (el.localName != "input") { + return false; + } + + switch (el.type) { + case "color": + case "date": + case "datetime-local": + case "email": + case "file": + case "month": + case "number": + case "password": + case "range": + case "search": + case "tel": + case "text": + case "time": + case "url": + case "week": + return true; + + default: + return false; + } +}; + +/** + * An editing host is a node that is either an HTML element with a + * <code>contenteditable</code> attribute, or the HTML element child + * of a document whose <code>designMode</code> is enabled. + * + * @param {Element} el + * Element to determine if is an editing host. + * + * @return {boolean} + * True if editing host, false otherwise. + */ +element.isEditingHost = function(el) { + return ( + element.isDOMElement(el) && + (el.isContentEditable || el.ownerDocument.designMode == "on") + ); +}; + +/** + * Determines if an element is editable according to WebDriver. + * + * An element is considered editable if it is not read-only or + * disabled, and one of the following conditions are met: + * + * <ul> + * <li>It is a <code><textarea></code> element. + * + * <li>It is an <code><input></code> element that is not of + * the <code>checkbox</code>, <code>radio</code>, <code>hidden</code>, + * <code>submit</code>, <code>button</code>, or <code>image</code> types. + * + * <li>It is content-editable. + * + * <li>It belongs to a document in design mode. + * </ul> + * + * @param {Element} + * Element to test if editable. + * + * @return {boolean} + * True if editable, false otherwise. + */ +element.isEditable = function(el) { + if (!element.isDOMElement(el)) { + return false; + } + + if (element.isReadOnly(el) || element.isDisabled(el)) { + return false; + } + + return element.isMutableFormControl(el) || element.isEditingHost(el); +}; + +/** + * This function generates a pair of coordinates relative to the viewport + * given a target element and coordinates relative to that element's + * top-left corner. + * + * @param {Node} node + * Target node. + * @param {number=} xOffset + * Horizontal offset relative to target's top-left corner. + * Defaults to the centre of the target's bounding box. + * @param {number=} yOffset + * Vertical offset relative to target's top-left corner. Defaults to + * the centre of the target's bounding box. + * + * @return {Object.<string, number>} + * X- and Y coordinates. + * + * @throws TypeError + * If <var>xOffset</var> or <var>yOffset</var> are not numbers. + */ +element.coordinates = function(node, xOffset = undefined, yOffset = undefined) { + let box = node.getBoundingClientRect(); + + if (typeof xOffset == "undefined" || xOffset === null) { + xOffset = box.width / 2.0; + } + if (typeof yOffset == "undefined" || yOffset === null) { + yOffset = box.height / 2.0; + } + + if (typeof yOffset != "number" || typeof xOffset != "number") { + throw new TypeError("Offset must be a number"); + } + + return { + x: box.left + xOffset, + y: box.top + yOffset, + }; +}; + +/** + * This function returns true if the node is in the viewport. + * + * @param {Element} el + * Target element. + * @param {number=} x + * Horizontal offset relative to target. Defaults to the centre of + * the target's bounding box. + * @param {number=} y + * Vertical offset relative to target. Defaults to the centre of + * the target's bounding box. + * + * @return {boolean} + * True if if <var>el</var> is in viewport, false otherwise. + */ +element.inViewport = function(el, x = undefined, y = undefined) { + let win = el.ownerGlobal; + let c = element.coordinates(el, x, y); + let vp = { + top: win.pageYOffset, + left: win.pageXOffset, + bottom: win.pageYOffset + win.innerHeight, + right: win.pageXOffset + win.innerWidth, + }; + + return ( + vp.left <= c.x + win.pageXOffset && + c.x + win.pageXOffset <= vp.right && + vp.top <= c.y + win.pageYOffset && + c.y + win.pageYOffset <= vp.bottom + ); +}; + +/** + * Gets the element's container element. + * + * An element container is defined by the WebDriver + * specification to be an <tt><option></tt> element in a + * <a href="https://html.spec.whatwg.org/#concept-element-contexts">valid + * element context</a>, meaning that it has an ancestral element + * that is either <tt><datalist></tt> or <tt><select></tt>. + * + * If the element does not have a valid context, its container element + * is itself. + * + * @param {Element} el + * Element to get the container of. + * + * @return {Element} + * Container element of <var>el</var>. + */ +element.getContainer = function(el) { + // Does <option> or <optgroup> have a valid context, + // meaning is it a child of <datalist> or <select>? + if (["option", "optgroup"].includes(el.localName)) { + return element.findClosest(el, "datalist,select") || el; + } + + return el; +}; + +/** + * An element is in view if it is a member of its own pointer-interactable + * paint tree. + * + * This means an element is considered to be in view, but not necessarily + * pointer-interactable, if it is found somewhere in the + * <code>elementsFromPoint</code> list at <var>el</var>'s in-view + * centre coordinates. + * + * Before running the check, we change <var>el</var>'s pointerEvents + * style property to "auto", since elements without pointer events + * enabled do not turn up in the paint tree we get from + * document.elementsFromPoint. This is a specialisation that is only + * relevant when checking if the element is in view. + * + * @param {Element} el + * Element to check if is in view. + * + * @return {boolean} + * True if <var>el</var> is inside the viewport, or false otherwise. + */ +element.isInView = function(el) { + let originalPointerEvents = el.style.pointerEvents; + + try { + el.style.pointerEvents = "auto"; + const tree = element.getPointerInteractablePaintTree(el); + + // Bug 1413493 - <tr> is not part of the returned paint tree yet. As + // workaround check the visibility based on the first contained cell. + if (el.localName === "tr" && el.cells && el.cells.length > 0) { + return tree.includes(el.cells[0]); + } + + return tree.includes(el); + } finally { + el.style.pointerEvents = originalPointerEvents; + } +}; + +/** + * This function throws the visibility of the element error if the element is + * not displayed or the given coordinates are not within the viewport. + * + * @param {Element} el + * Element to check if visible. + * @param {number=} x + * Horizontal offset relative to target. Defaults to the centre of + * the target's bounding box. + * @param {number=} y + * Vertical offset relative to target. Defaults to the centre of + * the target's bounding box. + * + * @return {boolean} + * True if visible, false otherwise. + */ +element.isVisible = function(el, x = undefined, y = undefined) { + let win = el.ownerGlobal; + + if (!atom.isElementDisplayed(el, win)) { + return false; + } + + if (el.tagName.toLowerCase() == "body") { + return true; + } + + if (!element.inViewport(el, x, y)) { + element.scrollIntoView(el); + if (!element.inViewport(el)) { + return false; + } + } + return true; +}; + +/** + * A pointer-interactable element is defined to be the first + * non-transparent element, defined by the paint order found at the centre + * point of its rectangle that is inside the viewport, excluding the size + * of any rendered scrollbars. + * + * An element is obscured if the pointer-interactable paint tree at its + * centre point is empty, or the first element in this tree is not an + * inclusive descendant of itself. + * + * @param {DOMElement} el + * Element determine if is pointer-interactable. + * + * @return {boolean} + * True if element is obscured, false otherwise. + */ +element.isObscured = function(el) { + let tree = element.getPointerInteractablePaintTree(el); + return !el.contains(tree[0]); +}; + +// TODO(ato): Only used by deprecated action API +// https://bugzil.la/1354578 +/** + * Calculates the in-view centre point of an element's client rect. + * + * The portion of an element that is said to be _in view_, is the + * intersection of two squares: the first square being the initial + * viewport, and the second a DOM element. From this square we + * calculate the in-view _centre point_ and convert it into CSS pixels. + * + * Although Gecko's system internals allow click points to be + * given in floating point precision, the DOM operates in CSS pixels. + * When the in-view centre point is later used to retrieve a coordinate's + * paint tree, we need to ensure to operate in the same language. + * + * As a word of warning, there appears to be inconsistencies between + * how `DOMElement.elementsFromPoint` and `DOMWindowUtils.sendMouseEvent` + * internally rounds (ceils/floors) coordinates. + * + * @param {DOMRect} rect + * Element off a DOMRect sequence produced by calling + * `getClientRects` on an {@link Element}. + * @param {WindowProxy} win + * Current window global. + * + * @return {Map.<string, number>} + * X and Y coordinates that denotes the in-view centre point of + * `rect`. + */ +element.getInViewCentrePoint = function(rect, win) { + const { floor, max, min } = Math; + + // calculate the intersection of the rect that is inside the viewport + let visible = { + left: max(0, min(rect.x, rect.x + rect.width)), + right: min(win.innerWidth, max(rect.x, rect.x + rect.width)), + top: max(0, min(rect.y, rect.y + rect.height)), + bottom: min(win.innerHeight, max(rect.y, rect.y + rect.height)), + }; + + // arrive at the centre point of the visible rectangle + let x = (visible.left + visible.right) / 2.0; + let y = (visible.top + visible.bottom) / 2.0; + + // convert to CSS pixels, as centre point can be float + x = floor(x); + y = floor(y); + + return { x, y }; +}; + +/** + * Produces a pointer-interactable elements tree from a given element. + * + * The tree is defined by the paint order found at the centre point of + * the element's rectangle that is inside the viewport, excluding the size + * of any rendered scrollbars. + * + * @param {DOMElement} el + * Element to determine if is pointer-interactable. + * + * @return {Array.<DOMElement>} + * Sequence of elements in paint order. + */ +element.getPointerInteractablePaintTree = function(el) { + const doc = el.ownerDocument; + const win = doc.defaultView; + const rootNode = el.getRootNode(); + + // pointer-interactable elements tree, step 1 + if (!el.isConnected) { + return []; + } + + // steps 2-3 + let rects = el.getClientRects(); + if (rects.length == 0) { + return []; + } + + // step 4 + let centre = element.getInViewCentrePoint(rects[0], win); + + // step 5 + return rootNode.elementsFromPoint(centre.x, centre.y); +}; + +// TODO(ato): Not implemented. +// In fact, it's not defined in the spec. +element.isKeyboardInteractable = () => true; + +/** + * Attempts to scroll into view |el|. + * + * @param {DOMElement} el + * Element to scroll into view. + */ +element.scrollIntoView = function(el) { + if (el.scrollIntoView) { + el.scrollIntoView({ block: "end", inline: "nearest", behavior: "instant" }); + } +}; + +/** + * Ascertains whether <var>node</var> is a DOM-, SVG-, or XUL element. + * + * @param {*} node + * Element thought to be an <code>Element</code> or + * <code>XULElement</code>. + * + * @return {boolean} + * True if <var>node</var> is an element, false otherwise. + */ +element.isElement = function(node) { + return element.isDOMElement(node) || element.isXULElement(node); +}; + +/** + * Ascertains whether <var>node</var> is a DOM element. + * + * @param {*} node + * Element thought to be an <code>Element</code>. + * + * @return {boolean} + * True if <var>node</var> is a DOM element, false otherwise. + */ +element.isDOMElement = function(node) { + return ( + typeof node == "object" && + node !== null && + "nodeType" in node && + [ELEMENT_NODE, DOCUMENT_NODE].includes(node.nodeType) && + !element.isXULElement(node) + ); +}; + +/** + * Ascertains whether <var>node</var> is a XUL element. + * + * @param {*} node + * Element to check + * + * @return {boolean} + * True if <var>node</var> is a XULElement, + * false otherwise. + */ +element.isXULElement = function(node) { + return ( + typeof node == "object" && + node !== null && + "nodeType" in node && + node.nodeType === node.ELEMENT_NODE && + node.namespaceURI === XUL_NS + ); +}; + +/** + * Ascertains whether <var>node</var> is in a XUL document. + * + * @param {*} node + * Element to check + * + * @return {boolean} + * True if <var>node</var> is in a XUL document, + * false otherwise. + */ +element.isInXULDocument = function(node) { + return ( + typeof node == "object" && + node !== null && + "ownerDocument" in node && + node.ownerDocument.documentElement.namespaceURI === XUL_NS + ); +}; + +/** + * Ascertains whether <var>node</var> is a <code>WindowProxy</code>. + * + * @param {*} node + * Node thought to be a <code>WindowProxy</code>. + * + * @return {boolean} + * True if <var>node</var> is a DOM window. + */ +element.isDOMWindow = function(node) { + // TODO(ato): This should use Object.prototype.toString.call(node) + // but it's not clear how to write a good xpcshell test for that, + // seeing as we stub out a WindowProxy. + return ( + typeof node == "object" && + node !== null && + typeof node.toString == "function" && + node.toString() == "[object Window]" && + node.self === node + ); +}; + +const boolEls = { + audio: ["autoplay", "controls", "loop", "muted"], + button: ["autofocus", "disabled", "formnovalidate"], + details: ["open"], + dialog: ["open"], + fieldset: ["disabled"], + form: ["novalidate"], + iframe: ["allowfullscreen"], + img: ["ismap"], + input: [ + "autofocus", + "checked", + "disabled", + "formnovalidate", + "multiple", + "readonly", + "required", + ], + keygen: ["autofocus", "disabled"], + menuitem: ["checked", "default", "disabled"], + ol: ["reversed"], + optgroup: ["disabled"], + option: ["disabled", "selected"], + script: ["async", "defer"], + select: ["autofocus", "disabled", "multiple", "required"], + textarea: ["autofocus", "disabled", "readonly", "required"], + track: ["default"], + video: ["autoplay", "controls", "loop", "muted"], +}; + +/** + * Tests if the attribute is a boolean attribute on element. + * + * @param {DOMElement} el + * Element to test if <var>attr</var> is a boolean attribute on. + * @param {string} attr + * Attribute to test is a boolean attribute. + * + * @return {boolean} + * True if the attribute is boolean, false otherwise. + */ +element.isBooleanAttribute = function(el, attr) { + if (!element.isDOMElement(el)) { + return false; + } + + // global boolean attributes that apply to all HTML elements, + // except for custom elements + const customElement = !el.localName.includes("-"); + if ((attr == "hidden" || attr == "itemscope") && customElement) { + return true; + } + + if (!boolEls.hasOwnProperty(el.localName)) { + return false; + } + return boolEls[el.localName].includes(attr); +}; + +/** + * A web element 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. + */ +class WebElement { + /** + * @param {string} uuid + * Identifier that must be unique across all browsing contexts + * for the contract to be upheld. + */ + constructor(uuid) { + this.uuid = assert.string(uuid); + } + + /** + * Performs an equality check between this web element and + * <var>other</var>. + * + * @param {WebElement} other + * Web element to compare with this. + * + * @return {boolean} + * True if this and <var>other</var> are the same. False + * otherwise. + */ + is(other) { + return other instanceof WebElement && this.uuid === other.uuid; + } + + toString() { + return `[object ${this.constructor.name} uuid=${this.uuid}]`; + } + + /** + * Returns a new {@link WebElement} reference for a DOM element, + * <code>WindowProxy</code>, or XUL element. + * + * @param {(Element|WindowProxy|XULElement)} node + * Node to construct a web element reference for. + * + * @return {(ContentWebElement|ChromeWebElement)} + * Web element reference for <var>el</var>. + * + * @throws {InvalidArgumentError} + * If <var>node</var> is neither a <code>WindowProxy</code>, + * DOM element, or a XUL element. + */ + static from(node) { + const uuid = WebElement.generateUUID(); + + if (element.isElement(node)) { + if (element.isInXULDocument(node)) { + // If the node is in a XUL document, we are in "chrome" context. + return new ChromeWebElement(uuid); + } + return new ContentWebElement(uuid); + } else if (element.isDOMWindow(node)) { + if (node.parent === node) { + return new ContentWebWindow(uuid); + } + return new ContentWebFrame(uuid); + } + + throw new error.InvalidArgumentError( + "Expected DOM window/element " + pprint`or XUL element, got: ${node}` + ); + } + + /** + * Unmarshals a JSON Object to one of {@link ContentWebElement}, + * {@link ContentWebWindow}, {@link ContentWebFrame}, or + * {@link ChromeWebElement}. + * + * @param {Object.<string, string>} json + * Web element reference, which is supposed to be a JSON Object + * where the key is one of the {@link WebElement} concrete + * classes' UUID identifiers. + * + * @return {WebElement} + * Representation of the web element. + * + * @throws {InvalidArgumentError} + * If <var>json</var> is not a web element reference. + */ + static fromJSON(json) { + assert.object(json); + if (json instanceof WebElement) { + return json; + } + let keys = Object.keys(json); + + for (let key of keys) { + switch (key) { + case ContentWebElement.Identifier: + return ContentWebElement.fromJSON(json); + + case ContentWebWindow.Identifier: + return ContentWebWindow.fromJSON(json); + + case ContentWebFrame.Identifier: + return ContentWebFrame.fromJSON(json); + + case ChromeWebElement.Identifier: + return ChromeWebElement.fromJSON(json); + } + } + + throw new error.InvalidArgumentError( + pprint`Expected web element reference, got: ${json}` + ); + } + + /** + * Constructs a {@link ContentWebElement} or {@link ChromeWebElement} + * from a 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. For that reason + * we need the <var>context</var> argument to determine what kind of + * {@link WebElement} to return. + * + * @param {string} uuid + * UUID to be associated with the web element. + * @param {Context} context + * Context, which is used to determine if the returned type + * should be a content web element or a chrome web element. + * + * @return {WebElement} + * One of {@link ContentWebElement} or {@link ChromeWebElement}, + * based on <var>context</var>. + * + * @throws {InvalidArgumentError} + * If <var>uuid</var> is not a string or <var>context</var> + * is an invalid context. + */ + static fromUUID(uuid, context) { + assert.string(uuid); + + switch (context) { + case "chrome": + return new ChromeWebElement(uuid); + + case "content": + return new ContentWebElement(uuid); + + default: + throw new error.InvalidArgumentError("Unknown context: " + context); + } + } + + /** + * Checks if <var>ref<var> is a {@link WebElement} reference, + * i.e. if it has {@link ContentWebElement.Identifier}, or + * {@link ChromeWebElement.Identifier} as properties. + * + * @param {Object.<string, string>} obj + * Object that represents a reference to a {@link WebElement}. + * @return {boolean} + * True if <var>obj</var> is a {@link WebElement}, false otherwise. + */ + static isReference(obj) { + if (Object.prototype.toString.call(obj) != "[object Object]") { + return false; + } + + if ( + ContentWebElement.Identifier in obj || + ContentWebWindow.Identifier in obj || + ContentWebFrame.Identifier in obj || + ChromeWebElement.Identifier in obj + ) { + return true; + } + return false; + } + + /** + * Generates a unique identifier. + * + * @return {string} + * UUID. + */ + static generateUUID() { + let uuid = uuidGen.generateUUID().toString(); + return uuid.substring(1, uuid.length - 1); + } +} +this.WebElement = WebElement; + +/** + * DOM elements are represented as web elements when they are + * transported over the wire protocol. + */ +class ContentWebElement extends WebElement { + toJSON() { + return { [ContentWebElement.Identifier]: this.uuid }; + } + + static fromJSON(json) { + const { Identifier } = ContentWebElement; + + if (!(Identifier in json)) { + throw new error.InvalidArgumentError( + pprint`Expected web element reference, got: ${json}` + ); + } + + let uuid = json[Identifier]; + return new ContentWebElement(uuid); + } +} +ContentWebElement.Identifier = "element-6066-11e4-a52e-4f735466cecf"; +this.ContentWebElement = ContentWebElement; + +/** + * Top-level browsing contexts, such as <code>WindowProxy</code> + * whose <code>opener</code> is null, are represented as web windows + * over the wire protocol. + */ +class ContentWebWindow extends WebElement { + toJSON() { + return { [ContentWebWindow.Identifier]: this.uuid }; + } + + static fromJSON(json) { + if (!(ContentWebWindow.Identifier in json)) { + throw new error.InvalidArgumentError( + pprint`Expected web window reference, got: ${json}` + ); + } + let uuid = json[ContentWebWindow.Identifier]; + return new ContentWebWindow(uuid); + } +} +ContentWebWindow.Identifier = "window-fcc6-11e5-b4f8-330a88ab9d7f"; +this.ContentWebWindow = ContentWebWindow; + +/** + * 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. + */ +class ContentWebFrame extends WebElement { + toJSON() { + return { [ContentWebFrame.Identifier]: this.uuid }; + } + + static fromJSON(json) { + if (!(ContentWebFrame.Identifier in json)) { + throw new error.InvalidArgumentError( + pprint`Expected web frame reference, got: ${json}` + ); + } + let uuid = json[ContentWebFrame.Identifier]; + return new ContentWebFrame(uuid); + } +} +ContentWebFrame.Identifier = "frame-075b-4da1-b6ba-e579c2d3230a"; +this.ContentWebFrame = ContentWebFrame; + +/** + * XUL elements in chrome space are represented as chrome web elements + * over the wire protocol. + */ +class ChromeWebElement extends WebElement { + toJSON() { + return { [ChromeWebElement.Identifier]: this.uuid }; + } + + static fromJSON(json) { + if (!(ChromeWebElement.Identifier in json)) { + throw new error.InvalidArgumentError( + "Expected chrome element reference " + + pprint`for XUL element, got: ${json}` + ); + } + let uuid = json[ChromeWebElement.Identifier]; + return new ChromeWebElement(uuid); + } +} +ChromeWebElement.Identifier = "chromeelement-9fc5-4b51-a3c8-01716eedeb04"; +this.ChromeWebElement = ChromeWebElement; diff --git a/testing/marionette/error.js b/testing/marionette/error.js new file mode 100644 index 0000000000..d8c8263934 --- /dev/null +++ b/testing/marionette/error.js @@ -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/. */ + +"use strict"; + +const EXPORTED_SYMBOLS = ["error"]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + pprint: "chrome://marionette/content/format.js", +}); + +const ERRORS = new Set([ + "ElementClickInterceptedError", + "ElementNotAccessibleError", + "ElementNotInteractableError", + "InsecureCertificateError", + "InvalidArgumentError", + "InvalidCookieDomainError", + "InvalidElementStateError", + "InvalidSelectorError", + "InvalidSessionIDError", + "JavaScriptError", + "MoveTargetOutOfBoundsError", + "NoSuchAlertError", + "NoSuchElementError", + "NoSuchFrameError", + "NoSuchWindowError", + "ScriptTimeoutError", + "SessionNotCreatedError", + "StaleElementReferenceError", + "TimeoutError", + "UnableToSetCookieError", + "UnexpectedAlertOpenError", + "UnknownCommandError", + "UnknownError", + "UnsupportedOperationError", + "WebDriverError", +]); + +const BUILTIN_ERRORS = new Set([ + "Error", + "EvalError", + "InternalError", + "RangeError", + "ReferenceError", + "SyntaxError", + "TypeError", + "URIError", +]); + +/** @namespace */ +this.error = { + /** + * Check if ``val`` is an instance of the ``Error`` prototype. + * + * Because error objects may originate from different globals, comparing + * the prototype of the left hand side with the prototype property from + * the right hand side, which is what ``instanceof`` does, will not work. + * If the LHS and RHS come from different globals, this check will always + * fail because the two objects will not have the same identity. + * + * Therefore it is not safe to use ``instanceof`` in any multi-global + * situation, e.g. in content across multiple ``Window`` objects or anywhere + * in chrome scope. + * + * This function also contains a special check if ``val`` is an XPCOM + * ``nsIException`` because they are special snowflakes and may indeed + * cause Firefox to crash if used with ``instanceof``. + * + * @param {*} val + * Any value that should be undergo the test for errorness. + * @return {boolean} + * True if error, false otherwise. + */ + isError(val) { + if (val === null || typeof val != "object") { + return false; + } else if (val instanceof Ci.nsIException) { + return true; + } + + // DOMRectList errors on string comparison + try { + let proto = Object.getPrototypeOf(val); + return BUILTIN_ERRORS.has(proto.toString()); + } catch (e) { + return false; + } + }, + + /** + * Checks if ``obj`` is an object in the :js:class:`WebDriverError` + * prototypal chain. + * + * @param {*} obj + * Arbitrary object to test. + * + * @return {boolean} + * True if ``obj`` is of the WebDriverError prototype chain, + * false otherwise. + */ + isWebDriverError(obj) { + return error.isError(obj) && "name" in obj && ERRORS.has(obj.name); + }, + + /** + * Ensures error instance is a :js:class:`WebDriverError`. + * + * If the given error is already in the WebDriverError prototype + * chain, ``err`` is returned unmodified. If it is not, it is wrapped + * in :js:class:`UnknownError`. + * + * @param {Error} err + * Error to conditionally turn into a WebDriverError. + * + * @return {WebDriverError} + * If ``err`` is a WebDriverError, it is returned unmodified. + * Otherwise an UnknownError type is returned. + */ + wrap(err) { + if (error.isWebDriverError(err)) { + return err; + } + return new UnknownError(err); + }, + + /** + * Unhandled error reporter. Dumps the error and its stacktrace to console, + * and reports error to the Browser Console. + */ + report(err) { + let msg = "Marionette threw an error: " + error.stringify(err); + dump(msg + "\n"); + if (Cu.reportError) { + Cu.reportError(msg); + } + }, + + /** + * Prettifies an instance of Error and its stacktrace to a string. + */ + stringify(err) { + try { + let s = err.toString(); + if ("stack" in err) { + s += "\n" + err.stack; + } + return s; + } catch (e) { + return "<unprintable error>"; + } + }, + + /** Create a stacktrace to the current line in the program. */ + stack() { + let trace = new Error().stack; + let sa = trace.split("\n"); + sa = sa.slice(1); + let rv = "stacktrace:\n" + sa.join("\n"); + return rv.trimEnd(); + }, +}; + +/** + * WebDriverError is the prototypal parent of all WebDriver errors. + * It should not be used directly, as it does not correspond to a real + * error in the specification. + */ +class WebDriverError extends Error { + /** + * @param {(string|Error)=} x + * Optional string describing error situation or Error instance + * to propagate. + */ + constructor(x) { + super(x); + this.name = this.constructor.name; + this.status = "webdriver error"; + + // Error's ctor does not preserve x' stack + if (error.isError(x)) { + this.stack = x.stack; + } + } + + /** + * @return {Object.<string, string>} + * JSON serialisation of error prototype. + */ + toJSON() { + return { + error: this.status, + message: this.message || "", + stacktrace: this.stack || "", + }; + } + + /** + * Unmarshals a JSON error representation to the appropriate Marionette + * error type. + * + * @param {Object.<string, string>} json + * Error object. + * + * @return {Error} + * Error prototype. + */ + static fromJSON(json) { + if (typeof json.error == "undefined") { + let s = JSON.stringify(json); + throw new TypeError("Undeserialisable error type: " + s); + } + if (!STATUSES.has(json.error)) { + throw new TypeError("Not of WebDriverError descent: " + json.error); + } + + let cls = STATUSES.get(json.error); + let err = new cls(); + if ("message" in json) { + err.message = json.message; + } + if ("stacktrace" in json) { + err.stack = json.stacktrace; + } + return err; + } +} + +/** The Gecko a11y API indicates that the element is not accessible. */ +class ElementNotAccessibleError extends WebDriverError { + constructor(message) { + super(message); + this.status = "element not accessible"; + } +} + +/** + * An element click could not be completed because the element receiving + * the events is obscuring the element that was requested clicked. + * + * @param {Element=} obscuredEl + * Element obscuring the element receiving the click. Providing this + * is not required, but will produce a nicer error message. + * @param {Map.<string, number>} coords + * Original click location. Providing this is not required, but + * will produce a nicer error message. + */ +class ElementClickInterceptedError extends WebDriverError { + constructor(obscuredEl = undefined, coords = undefined) { + let msg = ""; + if (obscuredEl && coords) { + const doc = obscuredEl.ownerDocument; + const overlayingEl = doc.elementFromPoint(coords.x, coords.y); + + switch (obscuredEl.style.pointerEvents) { + case "none": + msg = + pprint`Element ${obscuredEl} is not clickable ` + + `at point (${coords.x},${coords.y}) ` + + `because it does not have pointer events enabled, ` + + pprint`and element ${overlayingEl} ` + + `would receive the click instead`; + break; + + default: + msg = + pprint`Element ${obscuredEl} is not clickable ` + + `at point (${coords.x},${coords.y}) ` + + pprint`because another element ${overlayingEl} ` + + `obscures it`; + break; + } + } + + super(msg); + this.status = "element click intercepted"; + } +} + +/** + * A command could not be completed because the element is not pointer- + * or keyboard interactable. + */ +class ElementNotInteractableError extends WebDriverError { + constructor(message) { + super(message); + this.status = "element not interactable"; + } +} + +/** + * Navigation caused the user agent to hit a certificate warning, which + * is usually the result of an expired or invalid TLS certificate. + */ +class InsecureCertificateError extends WebDriverError { + constructor(message) { + super(message); + this.status = "insecure certificate"; + } +} + +/** The arguments passed to a command are either invalid or malformed. */ +class InvalidArgumentError extends WebDriverError { + constructor(message) { + super(message); + this.status = "invalid argument"; + } +} + +/** + * An illegal attempt was made to set a cookie under a different + * domain than the current page. + */ +class InvalidCookieDomainError extends WebDriverError { + constructor(message) { + super(message); + this.status = "invalid cookie domain"; + } +} + +/** + * A command could not be completed because the element is in an + * invalid state, e.g. attempting to clear an element that isn't both + * editable and resettable. + */ +class InvalidElementStateError extends WebDriverError { + constructor(message) { + super(message); + this.status = "invalid element state"; + } +} + +/** Argument was an invalid selector. */ +class InvalidSelectorError extends WebDriverError { + constructor(message) { + super(message); + this.status = "invalid selector"; + } +} + +/** + * Occurs if the given session ID is not in the list of active sessions, + * meaning the session either does not exist or that it's not active. + */ +class InvalidSessionIDError extends WebDriverError { + constructor(message) { + super(message); + this.status = "invalid session id"; + } +} + +/** An error occurred whilst executing JavaScript supplied by the user. */ +class JavaScriptError extends WebDriverError { + constructor(x) { + super(x); + this.status = "javascript error"; + } +} + +/** + * The target for mouse interaction is not in the browser's viewport + * and cannot be brought into that viewport. + */ +class MoveTargetOutOfBoundsError extends WebDriverError { + constructor(message) { + super(message); + this.status = "move target out of bounds"; + } +} + +/** + * An attempt was made to operate on a modal dialog when one was + * not open. + */ +class NoSuchAlertError extends WebDriverError { + constructor(message) { + super(message); + this.status = "no such alert"; + } +} + +/** + * An element could not be located on the page using the given + * search parameters. + */ +class NoSuchElementError extends WebDriverError { + constructor(message) { + super(message); + this.status = "no such element"; + } +} + +/** + * A command to switch to a frame could not be satisfied because + * the frame could not be found. + */ +class NoSuchFrameError extends WebDriverError { + constructor(message) { + super(message); + this.status = "no such frame"; + } +} + +/** + * A command to switch to a window could not be satisfied because + * the window could not be found. + */ +class NoSuchWindowError extends WebDriverError { + constructor(message) { + super(message); + this.status = "no such window"; + } +} + +/** A script did not complete before its timeout expired. */ +class ScriptTimeoutError extends WebDriverError { + constructor(message) { + super(message); + this.status = "script timeout"; + } +} + +/** A new session could not be created. */ +class SessionNotCreatedError extends WebDriverError { + constructor(message) { + super(message); + this.status = "session not created"; + } +} + +/** + * A command failed because the referenced element is no longer + * attached to the DOM. + */ +class StaleElementReferenceError extends WebDriverError { + constructor(message) { + super(message); + this.status = "stale element reference"; + } +} + +/** An operation did not complete before its timeout expired. */ +class TimeoutError extends WebDriverError { + constructor(message) { + super(message); + this.status = "timeout"; + } +} + +/** A command to set a cookie's value could not be satisfied. */ +class UnableToSetCookieError extends WebDriverError { + constructor(message) { + super(message); + this.status = "unable to set cookie"; + } +} + +/** A modal dialog was open, blocking this operation. */ +class UnexpectedAlertOpenError extends WebDriverError { + constructor(message) { + super(message); + this.status = "unexpected alert open"; + } +} + +/** + * A command could not be executed because the remote end is not + * aware of it. + */ +class UnknownCommandError extends WebDriverError { + constructor(message) { + super(message); + this.status = "unknown command"; + } +} + +/** + * An unknown error occurred in the remote end while processing + * the command. + */ +class UnknownError extends WebDriverError { + constructor(message) { + super(message); + this.status = "unknown error"; + } +} + +/** + * Indicates that a command that should have executed properly + * cannot be supported for some reason. + */ +class UnsupportedOperationError extends WebDriverError { + constructor(message) { + super(message); + this.status = "unsupported operation"; + } +} + +const STATUSES = new Map([ + ["element click intercepted", ElementClickInterceptedError], + ["element not accessible", ElementNotAccessibleError], + ["element not interactable", ElementNotInteractableError], + ["insecure certificate", InsecureCertificateError], + ["invalid argument", InvalidArgumentError], + ["invalid cookie domain", InvalidCookieDomainError], + ["invalid element state", InvalidElementStateError], + ["invalid selector", InvalidSelectorError], + ["invalid session id", InvalidSessionIDError], + ["javascript error", JavaScriptError], + ["move target out of bounds", MoveTargetOutOfBoundsError], + ["no such alert", NoSuchAlertError], + ["no such element", NoSuchElementError], + ["no such frame", NoSuchFrameError], + ["no such window", NoSuchWindowError], + ["script timeout", ScriptTimeoutError], + ["session not created", SessionNotCreatedError], + ["stale element reference", StaleElementReferenceError], + ["timeout", TimeoutError], + ["unable to set cookie", UnableToSetCookieError], + ["unexpected alert open", UnexpectedAlertOpenError], + ["unknown command", UnknownCommandError], + ["unknown error", UnknownError], + ["unsupported operation", UnsupportedOperationError], + ["webdriver error", WebDriverError], +]); + +// Errors must be expored on the local this scope so that the +// EXPORTED_SYMBOLS and the Cu.import("foo", {}) machinery sees them. +// We could assign each error definition directly to |this|, but +// because they are Error prototypes this would mess up their names. +for (let cls of STATUSES.values()) { + error[cls.name] = cls; +} diff --git a/testing/marionette/evaluate.js b/testing/marionette/evaluate.js new file mode 100644 index 0000000000..5dd22d4010 --- /dev/null +++ b/testing/marionette/evaluate.js @@ -0,0 +1,629 @@ +/* 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 EXPORTED_SYMBOLS = ["evaluate", "sandbox", "Sandboxes"]; + +const { clearTimeout, setTimeout } = ChromeUtils.import( + "resource://gre/modules/Timer.jsm" +); +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + assert: "chrome://marionette/content/assert.js", + element: "chrome://marionette/content/element.js", + error: "chrome://marionette/content/error.js", + Log: "chrome://marionette/content/log.js", + WebElement: "chrome://marionette/content/element.js", +}); + +XPCOMUtils.defineLazyGetter(this, "logger", () => Log.get()); + +const ARGUMENTS = "__webDriverArguments"; +const CALLBACK = "__webDriverCallback"; +const COMPLETE = "__webDriverComplete"; +const DEFAULT_TIMEOUT = 10000; // ms +const FINISH = "finish"; + +/** @namespace */ +this.evaluate = {}; + +/** + * Evaluate a script in given sandbox. + * + * The the provided `script` will be wrapped in an anonymous function + * with the `args` argument applied. + * + * The arguments provided by the `args<` argument are exposed + * through the `arguments` object available in the script context, + * and if the script is executed asynchronously with the `async` + * option, an additional last argument that is synonymous to the + * name `resolve` is appended, and can be accessed + * through `arguments[arguments.length - 1]`. + * + * The `timeout` option specifies the duration for how long the + * script should be allowed to run before it is interrupted and aborted. + * An interrupted script will cause a {@link ScriptTimeoutError} to occur. + * + * The `async` option indicates that the script will not return + * until the `resolve` callback is invoked, + * which is analogous to the last argument of the `arguments` object. + * + * The `file` option is used in error messages to provide information + * on the origin script file in the local end. + * + * The `line` option is used in error messages, along with `filename`, + * to provide the line number in the origin script file on the local end. + * + * @param {nsISandbox} sb + * Sandbox the script will be evaluted in. + * @param {string} script + * Script to evaluate. + * @param {Array.<?>=} args + * A sequence of arguments to call the script with. + * @param {boolean=} [async=false] async + * Indicates if the script should return immediately or wait for + * the callback to be invoked before returning. + * @param {string=} [file="dummy file"] file + * File location of the program in the client. + * @param {number=} [line=0] line + * Line number of th eprogram in the client. + * @param {number=} [timeout=DEFAULT_TIMEOUT] timeout + * Duration in milliseconds before interrupting the script. + * + * @return {Promise} + * A promise that when resolved will give you the return value from + * the script. Note that the return value requires serialisation before + * it can be sent to the client. + * + * @throws {JavaScriptError} + * If an {@link Error} was thrown whilst evaluating the script. + * @throws {ScriptTimeoutError} + * If the script was interrupted due to script timeout. + */ +evaluate.sandbox = function( + sb, + script, + args = [], + { + async = false, + file = "dummy file", + line = 0, + timeout = DEFAULT_TIMEOUT, + } = {} +) { + let unloadHandler; + let marionetteSandbox = sandbox.create(sb.window); + + // timeout handler + let scriptTimeoutID, timeoutPromise; + if (timeout !== null) { + timeoutPromise = new Promise((resolve, reject) => { + scriptTimeoutID = setTimeout(() => { + reject(new 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 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 error.ScriptTimeoutError) { + throw err; + } + throw new error.JavaScriptError(err); + }) + .finally(() => { + clearTimeout(scriptTimeoutID); + marionetteSandbox.window.removeEventListener("unload", unloadHandler); + }); +}; + +/** + * Convert any web elements in arbitrary objects to DOM elements by + * looking them up in the seen element store. For ElementIdentifiers a new + * entry in the seen element reference store gets added when running in the + * parent process, otherwise ContentDOMReference is used to retrieve the DOM + * node. + * + * @param {Object} obj + * Arbitrary object containing web elements or ElementIdentifiers. + * @param {(element.Store|element.ReferenceStore)=} seenEls + * Known element store to look up web elements from. If `seenEls` is an + * instance of `element.ReferenceStore`, return WebElement. If `seenEls` + * is an instance of `element.Store`, return Element. If `seenEls` is + * `undefined` the Element from the ContentDOMReference cache is returned + * when executed in the child process, in the parent process the WebElement + * is passed-through. + * @param {WindowProxy=} win + * Current browsing context, if `seenEls` is provided. + * + * @return {Object} + * Same object as provided by `obj` with the web elements + * replaced by DOM elements. + * + * @throws {NoSuchElementError} + * If `seenEls` is an `element.Store` and the web element reference has not + * been seen before. + * @throws {StaleElementReferenceError} + * If `seenEls` is an `element.ReferenceStore` or `element.Store` and the + * element has gone stale, indicating it is no longer attached to the DOM, + * or its node document is no longer the active document. + */ +evaluate.fromJSON = function(obj, seenEls = undefined, win = undefined) { + switch (typeof obj) { + case "boolean": + case "number": + case "string": + default: + return obj; + + case "object": + if (obj === null) { + return obj; + + // arrays + } else if (Array.isArray(obj)) { + return obj.map(e => evaluate.fromJSON(e, seenEls, win)); + + // ElementIdentifier and ReferenceStore (used by JSWindowActor) + } else if (WebElement.isReference(obj.webElRef)) { + if (seenEls instanceof element.ReferenceStore) { + // Parent: Store web element reference in the cache + return seenEls.add(obj); + } else if (!seenEls) { + // Child: Resolve ElementIdentifier by using ContentDOMReference + return element.resolveElement(obj, win); + } + throw new TypeError("seenEls is not an instance of ReferenceStore"); + + // WebElement and Store (used by framescript) + } else if (WebElement.isReference(obj)) { + const webEl = WebElement.fromJSON(obj); + if (seenEls instanceof element.Store) { + // Child: Get web element from the store + return seenEls.get(webEl, win); + } else if (!seenEls) { + // Parent: No conversion. Just return the web element + return webEl; + } + throw new TypeError("seenEls is not an instance of Store"); + } + + // arbitrary objects + let rv = {}; + for (let prop in obj) { + rv[prop] = evaluate.fromJSON(obj[prop], seenEls, win); + } + return rv; + } +}; + +/** + * Marshal arbitrary objects to JSON-safe primitives that can be + * transported over the Marionette protocol or across processes. + * + * The marshaling rules are as follows: + * + * - Primitives are returned as is. + * + * - Collections, such as `Array<`, `NodeList`, `HTMLCollection` + * et al. are expanded to arrays and then recursed. + * + * - Elements that are not known web elements are added to the `seenEls` element + * store, or the ContentDOMReference registry. Once known, the elements' + * associated web element representation is returned. + * + * - WebElements are transformed to the corresponding ElementIdentifier + * for use in the content process, if an `element.ReferenceStore` is provided. + * + * - 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. + * + * - Other arbitrary objects are first tested for cyclic references + * and then recursed into. + * + * @param {Object} obj + * Object to be marshaled. + * + * @param {(element.Store|element.ReferenceStore)=} seenEls + * Element store to use for lookup of web element references. + * + * @return {Object} + * Same object as provided by `obj` with the elements + * replaced by web elements. + * + * @throws {JavaScriptError} + * If an object contains cyclic references. + */ +evaluate.toJSON = function(obj, seenEls) { + const t = Object.prototype.toString.call(obj); + + // null + if (t == "[object Undefined]" || t == "[object Null]") { + return null; + + // primitives + } else if ( + t == "[object Boolean]" || + t == "[object Number]" || + t == "[object String]" + ) { + return obj; + + // Array, NodeList, HTMLCollection, et al. + } else if (element.isCollection(obj)) { + assert.acyclic(obj); + return [...obj].map(el => evaluate.toJSON(el, seenEls)); + + // WebElement + } else if (WebElement.isReference(obj)) { + // Parent: Convert to ElementIdentifier for use in child actor + if (seenEls instanceof element.ReferenceStore) { + return seenEls.get(WebElement.fromJSON(obj)); + } + + return obj; + + // ElementIdentifier + } else if (WebElement.isReference(obj.webElRef)) { + // Parent: Pass-through ElementIdentifiers to the child + if (seenEls instanceof element.ReferenceStore) { + return obj; + } + + // Parent: Otherwise return the web element + return WebElement.fromJSON(obj.webElRef); + + // Element (HTMLElement, SVGElement, XULElement, et al.) + } else if (element.isElement(obj)) { + // Parent + if (seenEls instanceof element.ReferenceStore) { + throw new TypeError(`ReferenceStore can't be used with Element`); + + // Child: Add element to the Store, return as WebElement + } else if (seenEls instanceof element.Store) { + return seenEls.add(obj); + } + + // If no storage has been specified assume we are in a child process. + // Evaluation of code will take place in mutable sandboxes, which are + // created to waive xrays by default. As such DOM nodes have to be unwaived + // before accessing the ownerGlobal is possible, which is needed by + // ContentDOMReference. + return element.getElementId(Cu.unwaiveXrays(obj)); + + // custom JSON representation + } else if (typeof obj.toJSON == "function") { + let unsafeJSON = obj.toJSON(); + return evaluate.toJSON(unsafeJSON, seenEls); + } + + // arbitrary objects + files + let rv = {}; + for (let prop in obj) { + assert.acyclic(obj[prop]); + + try { + rv[prop] = evaluate.toJSON(obj[prop], seenEls); + } catch (e) { + if (e.result == Cr.NS_ERROR_NOT_IMPLEMENTED) { + logger.debug(`Skipping ${prop}: ${e.message}`); + } else { + throw e; + } + } + } + return rv; +}; + +/** + * Tests if an arbitrary object is cyclic. + * + * Element prototypes are by definition acyclic, even when they + * contain cyclic references. This is because `evaluate.toJSON` + * ensures they are marshaled as web elements. + * + * @param {*} value + * Object to test for cyclical references. + * + * @return {boolean} + * True if object is cyclic, false otherwise. + */ +evaluate.isCyclic = function(value, stack = []) { + let t = Object.prototype.toString.call(value); + + // null + if (t == "[object Undefined]" || t == "[object Null]") { + return false; + + // primitives + } else if ( + t == "[object Boolean]" || + t == "[object Number]" || + t == "[object String]" + ) { + return false; + + // HTMLElement, SVGElement, XULElement, et al. + } else if (element.isElement(value)) { + return false; + + // Array, NodeList, HTMLCollection, et al. + } else if (element.isCollection(value)) { + if (stack.includes(value)) { + return true; + } + stack.push(value); + + for (let i = 0; i < value.length; i++) { + if (evaluate.isCyclic(value[i], stack)) { + return true; + } + } + + stack.pop(); + return false; + } + + // arbitrary objects + if (stack.includes(value)) { + return true; + } + stack.push(value); + + for (let prop in value) { + if (evaluate.isCyclic(value[prop], stack)) { + return true; + } + } + + stack.pop(); + return false; +}; + +/** + * `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; +}; + +this.sandbox = {}; + +/** + * Provides a safe way to take an object defined in a privileged scope and + * create a structured clone of it in a less-privileged scope. It returns + * a reference to the clone. + * + * Unlike for {@link Components.utils.cloneInto}, `obj` may contain + * functions and DOM elements. + */ +sandbox.cloneInto = function(obj, sb) { + return Cu.cloneInto(obj, sb, { cloneFunctions: true, wrapReflectors: true }); +}; + +/** + * Augment given sandbox by an adapter that has an `exports` map + * property, or a normal map, of function names and function references. + * + * @param {Sandbox} sb + * The sandbox to augment. + * @param {Object} adapter + * Object that holds an `exports` property, or a map, of function + * names and function references. + * + * @return {Sandbox} + * The augmented sandbox. + */ +sandbox.augment = function(sb, adapter) { + function* entries(obj) { + for (let key of Object.keys(obj)) { + yield [key, obj[key]]; + } + } + + let funcs = adapter.exports || entries(adapter); + for (let [name, func] of funcs) { + sb[name] = func; + } + + return sb; +}; + +/** + * Creates a sandbox. + * + * @param {Window} win + * The DOM Window object. + * @param {nsIPrincipal=} principal + * An optional, custom principal to prefer over the Window. Useful if + * you need elevated security permissions. + * + * @return {Sandbox} + * The created sandbox. + */ +sandbox.create = function(win, principal = null, opts = {}) { + let p = principal || win; + opts = Object.assign( + { + sameZoneAs: win, + sandboxPrototype: win, + wantComponents: true, + wantXrays: true, + wantGlobalProperties: ["ChromeUtils"], + }, + opts + ); + return new Cu.Sandbox(p, opts); +}; + +/** + * Creates a mutable sandbox, where changes to the global scope + * will have lasting side-effects. + * + * @param {Window} win + * The DOM Window object. + * + * @return {Sandbox} + * The created sandbox. + */ +sandbox.createMutable = function(win) { + let opts = { + wantComponents: false, + wantXrays: false, + }; + // Note: We waive Xrays here to match potentially-accidental old behavior. + return Cu.waiveXrays(sandbox.create(win, null, opts)); +}; + +sandbox.createSystemPrincipal = function(win) { + let principal = Cc["@mozilla.org/systemprincipal;1"].createInstance( + Ci.nsIPrincipal + ); + return sandbox.create(win, principal); +}; + +sandbox.createSimpleTest = function(win, harness) { + let sb = sandbox.create(win); + sb = sandbox.augment(sb, harness); + sb[FINISH] = () => sb[COMPLETE](harness.generate_results()); + return sb; +}; + +/** + * Sandbox storage. When the user requests a sandbox by a specific name, + * if one exists in the storage this will be used as long as its window + * reference is still valid. + * + * @memberof evaluate + */ +this.Sandboxes = class { + /** + * @param {function(): Window} windowFn + * A function that returns the references to the current Window + * object. + */ + constructor(windowFn) { + this.windowFn_ = windowFn; + this.boxes_ = new Map(); + } + + get window_() { + return this.windowFn_(); + } + + /** + * Factory function for getting a sandbox by name, or failing that, + * creating a new one. + * + * If the sandbox' window does not match the provided window, a new one + * will be created. + * + * @param {string} name + * The name of the sandbox to get or create. + * @param {boolean=} [fresh=false] fresh + * Remove old sandbox by name first, if it exists. + * + * @return {Sandbox} + * A used or fresh sandbox. + */ + get(name = "default", fresh = false) { + let sb = this.boxes_.get(name); + if (sb) { + if (fresh || evaluate.isDead(sb, "window") || sb.window != this.window_) { + this.boxes_.delete(name); + return this.get(name, false); + } + } else { + if (name == "system") { + sb = sandbox.createSystemPrincipal(this.window_); + } else { + sb = sandbox.create(this.window_); + } + this.boxes_.set(name, sb); + } + return sb; + } + + /** Clears cache of sandboxes. */ + clear() { + this.boxes_.clear(); + } +}; diff --git a/testing/marionette/event.js b/testing/marionette/event.js new file mode 100644 index 0000000000..b7f6565b1f --- /dev/null +++ b/testing/marionette/event.js @@ -0,0 +1,1138 @@ +/* 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"; +/* global content, is */ +/* eslint-disable no-restricted-globals */ + +const EXPORTED_SYMBOLS = ["event"]; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + element: "chrome://marionette/content/element.js", +}); + +/** Provides functionality for creating and sending DOM events. */ +this.event = {}; + +XPCOMUtils.defineLazyGetter(this, "dblclickTimer", () => { + return Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); +}); + +// Max interval between two clicks that should result in a dblclick (in ms) +const DBLCLICK_INTERVAL = 640; + +// TODO(ato): Document! +let seenEvent = false; + +event.MouseEvents = { + click: 0, + dblclick: 1, + mousedown: 2, + mouseup: 3, + mouseover: 4, + mouseout: 5, +}; + +event.Modifiers = { + shiftKey: 0, + ctrlKey: 1, + altKey: 2, + metaKey: 3, +}; + +event.MouseButton = { + isPrimary(button) { + return button === 0; + }, + isAuxiliary(button) { + return button === 1; + }, + isSecondary(button) { + return button === 2; + }, +}; + +event.DoubleClickTracker = { + firstClick: false, + isClicked() { + return event.DoubleClickTracker.firstClick; + }, + setClick() { + if (!event.DoubleClickTracker.firstClick) { + event.DoubleClickTracker.firstClick = true; + event.DoubleClickTracker.startTimer(); + } + }, + resetClick() { + event.DoubleClickTracker.firstClick = false; + event.DoubleClickTracker.cancelTimer(); + }, + startTimer() { + dblclickTimer.initWithCallback( + event.DoubleClickTracker.resetClick, + DBLCLICK_INTERVAL, + Ci.nsITimer.TYPE_ONE_SHOT + ); + }, + cancelTimer() { + dblclickTimer.cancel(); + }, +}; + +// TODO(ato): Unexpose this when action.Chain#emitMouseEvent +// no longer emits its own events +event.parseModifiers_ = function(modifiers) { + let mval = 0; + if (modifiers.shiftKey) { + mval |= Ci.nsIDOMWindowUtils.MODIFIER_SHIFT; + } + if (modifiers.ctrlKey) { + mval |= Ci.nsIDOMWindowUtils.MODIFIER_CONTROL; + } + if (modifiers.altKey) { + mval |= Ci.nsIDOMWindowUtils.MODIFIER_ALT; + } + if (modifiers.metaKey) { + mval |= Ci.nsIDOMWindowUtils.MODIFIER_META; + } + if (modifiers.accelKey) { + if (Services.appinfo.OS === "Darwin") { + mval |= Ci.nsIDOMWindowUtils.MODIFIER_META; + } else { + mval |= Ci.nsIDOMWindowUtils.MODIFIER_CONTROL; + } + } + return mval; +}; + +/** + * Synthesise a mouse event on a target. + * + * The actual client point is determined by taking the aTarget's client + * box and offseting it by offsetX and offsetY. This allows mouse clicks + * to be simulated by calling this method. + * + * If the type is specified, an mouse event of that type is + * fired. Otherwise, a mousedown followed by a mouse up is performed. + * + * @param {Element} element + * Element to click. + * @param {number} offsetX + * Horizontal offset to click from the target's bounding box. + * @param {number} offsetY + * Vertical offset to click from the target's bounding box. + * @param {Object.<string, ?>} opts + * Object which may contain the properties "shiftKey", "ctrlKey", + * "altKey", "metaKey", "accessKey", "clickCount", "button", and + * "type". + * @param {Window} win + * Window object. + */ +event.synthesizeMouse = function(element, offsetX, offsetY, opts, win) { + let rect = element.getBoundingClientRect(); + event.synthesizeMouseAtPoint( + rect.left + offsetX, + rect.top + offsetY, + opts, + win + ); +}; + +/* + * Synthesize a mouse event at a particular point in a window. + * + * If the type of the event is specified, a mouse event of that type is + * fired. Otherwise, a mousedown followed by a mouse up is performed. + * + * @param {number} left + * CSS pixels from the left document margin. + * @param {number} top + * CSS pixels from the top document margin. + * @param {Object.<string, ?>} opts + * Object which may contain the properties "shiftKey", "ctrlKey", + * "altKey", "metaKey", "accessKey", "clickCount", "button", and + * "type". + * @param {Window} win + * Window object. + */ +event.synthesizeMouseAtPoint = function(left, top, opts, win) { + let domutils = win.windowUtils; + + let button = opts.button || 0; + let clickCount = opts.clickCount || 1; + let modifiers = event.parseModifiers_(opts); + let pressure = "pressure" in opts ? opts.pressure : 0; + let inputSource = + "inputSource" in opts ? opts.inputSource : win.MouseEvent.MOZ_SOURCE_MOUSE; + let isDOMEventSynthesized = + "isSynthesized" in opts ? opts.isSynthesized : true; + let isWidgetEventSynthesized; + if ("isWidgetEventSynthesized" in opts) { + isWidgetEventSynthesized = opts.isWidgetEventSynthesized; + } else { + isWidgetEventSynthesized = false; + } + let buttons; + if ("buttons" in opts) { + buttons = opts.buttons; + } else { + buttons = domutils.MOUSE_BUTTONS_NOT_SPECIFIED; + } + + if ("type" in opts && opts.type) { + domutils.sendMouseEvent( + opts.type, + left, + top, + button, + clickCount, + modifiers, + false, + pressure, + inputSource, + isDOMEventSynthesized, + isWidgetEventSynthesized, + buttons + ); + } else { + domutils.sendMouseEvent( + "mousedown", + left, + top, + button, + clickCount, + modifiers, + false, + pressure, + inputSource, + isDOMEventSynthesized, + isWidgetEventSynthesized, + buttons + ); + domutils.sendMouseEvent( + "mouseup", + left, + top, + button, + clickCount, + modifiers, + false, + pressure, + inputSource, + isDOMEventSynthesized, + isWidgetEventSynthesized, + buttons + ); + } +}; + +/* eslint-disable */ +function computeKeyCodeFromChar_(char, win) { + if (char.length != 1) { + return 0; + } + + let KeyboardEvent = getKeyboardEvent_(win); + + if (char in VIRTUAL_KEYCODE_LOOKUP) { + return KeyboardEvent["DOM_" + VIRTUAL_KEYCODE_LOOKUP[char]]; + } + + if (char >= "a" && char <= "z") { + return KeyboardEvent.DOM_VK_A + char.charCodeAt(0) - "a".charCodeAt(0); + } + if (char >= "A" && char <= "Z") { + return KeyboardEvent.DOM_VK_A + char.charCodeAt(0) - "A".charCodeAt(0); + } + if (char >= "0" && char <= "9") { + return KeyboardEvent.DOM_VK_0 + char.charCodeAt(0) - "0".charCodeAt(0); + } + + // returns US keyboard layout's keycode + switch (char) { + case "~": + case "`": + return KeyboardEvent.DOM_VK_BACK_QUOTE; + + case "!": + return KeyboardEvent.DOM_VK_1; + + case "@": + return KeyboardEvent.DOM_VK_2; + + case "#": + return KeyboardEvent.DOM_VK_3; + + case "$": + return KeyboardEvent.DOM_VK_4; + + case "%": + return KeyboardEvent.DOM_VK_5; + + case "^": + return KeyboardEvent.DOM_VK_6; + + case "&": + return KeyboardEvent.DOM_VK_7; + + case "*": + return KeyboardEvent.DOM_VK_8; + + case "(": + return KeyboardEvent.DOM_VK_9; + + case ")": + return KeyboardEvent.DOM_VK_0; + + case "-": + case "_": + return KeyboardEvent.DOM_VK_SUBTRACT; + + case "+": + case "=": + return KeyboardEvent.DOM_VK_EQUALS; + + case "{": + case "[": + return KeyboardEvent.DOM_VK_OPEN_BRACKET; + + case "}": + case "]": + return KeyboardEvent.DOM_VK_CLOSE_BRACKET; + + case "|": + case "\\": + return KeyboardEvent.DOM_VK_BACK_SLASH; + + case ":": + case ";": + return KeyboardEvent.DOM_VK_SEMICOLON; + + case "'": + case "\"": + return KeyboardEvent.DOM_VK_QUOTE; + + case "<": + case ",": + return KeyboardEvent.DOM_VK_COMMA; + + case ">": + case ".": + return KeyboardEvent.DOM_VK_PERIOD; + + case "?": + case "/": + return KeyboardEvent.DOM_VK_SLASH; + + case "\n": + return KeyboardEvent.DOM_VK_RETURN; + + case " ": + return KeyboardEvent.DOM_VK_SPACE; + + default: + return 0; + } +} +/* eslint-enable */ +/* eslint-disable no-restricted-globals */ + +/** + * Returns true if the given key should cause keypress event when widget + * handles the native key event. Otherwise, false. + * + * The key code should be one of consts of KeyboardEvent.DOM_VK_*, + * or a key name begins with "VK_", or a character. + */ +event.isKeypressFiredKey = function(key, win) { + let KeyboardEvent = getKeyboardEvent_(win); + + if (typeof key == "string") { + if (key.indexOf("VK_") === 0) { + key = KeyboardEvent["DOM_" + key]; + if (!key) { + throw new TypeError("Unknown key: " + key); + } + + // if key generates a character, it must cause a keypress event + } else { + return true; + } + } + + switch (key) { + case KeyboardEvent.DOM_VK_SHIFT: + case KeyboardEvent.DOM_VK_CONTROL: + case KeyboardEvent.DOM_VK_ALT: + case KeyboardEvent.DOM_VK_CAPS_LOCK: + case KeyboardEvent.DOM_VK_NUM_LOCK: + case KeyboardEvent.DOM_VK_SCROLL_LOCK: + case KeyboardEvent.DOM_VK_META: + return false; + + default: + return true; + } +}; + +/** + * Synthesise a key event. + * + * It is targeted at whatever would be targeted by an actual keypress + * by the user, typically the focused element. + * + * @param {string} key + * Key to synthesise. Should either be a character or a key code + * starting with "VK_" such as VK_RETURN, or a normalized key value. + * @param {Object.<string, ?>} event + * Object which may contain the properties shiftKey, ctrlKey, altKey, + * metaKey, accessKey, type. If the type is specified (keydown or keyup), + * a key event of that type is fired. Otherwise, a keydown, a keypress, + * and then a keyup event are fired in sequence. + * @param {Window} win + * Window object. + * + * @throws {TypeError} + * If unknown key. + */ +event.synthesizeKey = function(key, event, win) { + let TIP = getTIP_(win); + if (!TIP) { + return; + } + let KeyboardEvent = getKeyboardEvent_(win); + let modifiers = emulateToActivateModifiers_(TIP, event, win); + let keyEventDict = createKeyboardEventDictionary_(key, event, win); + let keyEvent = new KeyboardEvent("", keyEventDict.dictionary); + let dispatchKeydown = + !("type" in event) || event.type === "keydown" || !event.type; + let dispatchKeyup = + !("type" in event) || event.type === "keyup" || !event.type; + + try { + if (dispatchKeydown) { + TIP.keydown(keyEvent, keyEventDict.flags); + if ("repeat" in event && event.repeat > 1) { + keyEventDict.dictionary.repeat = true; + let repeatedKeyEvent = new KeyboardEvent("", keyEventDict.dictionary); + for (let i = 1; i < event.repeat; i++) { + TIP.keydown(repeatedKeyEvent, keyEventDict.flags); + } + } + } + if (dispatchKeyup) { + TIP.keyup(keyEvent, keyEventDict.flags); + } + } finally { + emulateToInactivateModifiers_(TIP, modifiers, win); + } +}; + +const TIPMap = new WeakMap(); + +function getTIP_(win, callback) { + let tip; + + if (TIPMap.has(win)) { + tip = TIPMap.get(win); + } else { + tip = Cc["@mozilla.org/text-input-processor;1"].createInstance( + Ci.nsITextInputProcessor + ); + TIPMap.set(win, tip); + } + if (!tip.beginInputTransactionForTests(win, callback)) { + tip = null; + TIPMap.delete(win); + } + return tip; +} + +function getKeyboardEvent_(win) { + if (typeof KeyboardEvent != "undefined") { + try { + // See if the object can be instantiated; sometimes this yields + // 'TypeError: can't access dead object' or 'KeyboardEvent is not + // a constructor'. + new KeyboardEvent("", {}); + return KeyboardEvent; + } catch (ex) {} + } + if (typeof content != "undefined" && "KeyboardEvent" in content) { + return content.KeyboardEvent; + } + return win.KeyboardEvent; +} + +function createKeyboardEventDictionary_(key, keyEvent, win) { + let result = { dictionary: null, flags: 0 }; + let keyCodeIsDefined = "keyCode" in keyEvent && keyEvent.keyCode != undefined; + let keyCode = + keyCodeIsDefined && keyEvent.keyCode >= 0 && keyEvent.keyCode <= 255 + ? keyEvent.keyCode + : 0; + let keyName = "Unidentified"; + + let printable = false; + + if (key.indexOf("KEY_") == 0) { + keyName = key.substr("KEY_".length); + result.flags |= Ci.nsITextInputProcessor.KEY_NON_PRINTABLE_KEY; + } else if (key.indexOf("VK_") == 0) { + keyCode = getKeyboardEvent_(win)["DOM_" + key]; + if (!keyCode) { + throw new Error("Unknown key: " + key); + } + keyName = guessKeyNameFromKeyCode_(keyCode, win); + if (!isPrintable(keyCode, win)) { + result.flags |= Ci.nsITextInputProcessor.KEY_NON_PRINTABLE_KEY; + } + } else if (key != "") { + keyName = key; + if (!keyCodeIsDefined) { + keyCode = computeKeyCodeFromChar_(key.charAt(0), win); + } + if (!keyCode) { + result.flags |= Ci.nsITextInputProcessor.KEY_KEEP_KEYCODE_ZERO; + } + // only force printable if "raw character" and event key match, like "a" + if (!("key" in keyEvent && key != keyEvent.key)) { + result.flags |= Ci.nsITextInputProcessor.KEY_FORCE_PRINTABLE_KEY; + printable = true; + } + } + + let locationIsDefined = "location" in keyEvent; + if (locationIsDefined && keyEvent.location === 0) { + result.flags |= Ci.nsITextInputProcessor.KEY_KEEP_KEY_LOCATION_STANDARD; + } + + let resultKey = "key" in keyEvent ? keyEvent.key : keyName; + if (printable && keyEvent.shiftKey) { + resultKey = resultKey.toUpperCase(); + } + + result.dictionary = { + key: resultKey, + code: "code" in keyEvent ? keyEvent.code : "", + location: locationIsDefined ? keyEvent.location : 0, + repeat: "repeat" in keyEvent ? keyEvent.repeat === true : false, + keyCode, + }; + + return result; +} + +function emulateToActivateModifiers_(TIP, keyEvent, win) { + if (!keyEvent) { + return null; + } + let KeyboardEvent = getKeyboardEvent_(win); + + let modifiers = { + normal: [ + { key: "Alt", attr: "altKey" }, + { key: "AltGraph", attr: "altGraphKey" }, + { key: "Control", attr: "ctrlKey" }, + { key: "Fn", attr: "fnKey" }, + { key: "Meta", attr: "metaKey" }, + { key: "OS", attr: "osKey" }, + { key: "Shift", attr: "shiftKey" }, + { key: "Symbol", attr: "symbolKey" }, + { + key: Services.appinfo.OS === "Darwin" ? "Meta" : "Control", + attr: "accelKey", + }, + ], + lockable: [ + { key: "CapsLock", attr: "capsLockKey" }, + { key: "FnLock", attr: "fnLockKey" }, + { key: "NumLock", attr: "numLockKey" }, + { key: "ScrollLock", attr: "scrollLockKey" }, + { key: "SymbolLock", attr: "symbolLockKey" }, + ], + }; + + for (let i = 0; i < modifiers.normal.length; i++) { + if (!keyEvent[modifiers.normal[i].attr]) { + continue; + } + if (TIP.getModifierState(modifiers.normal[i].key)) { + continue; // already activated. + } + let event = new KeyboardEvent("", { key: modifiers.normal[i].key }); + TIP.keydown( + event, + TIP.KEY_NON_PRINTABLE_KEY | TIP.KEY_DONT_DISPATCH_MODIFIER_KEY_EVENT + ); + modifiers.normal[i].activated = true; + } + + for (let j = 0; j < modifiers.lockable.length; j++) { + if (!keyEvent[modifiers.lockable[j].attr]) { + continue; + } + if (TIP.getModifierState(modifiers.lockable[j].key)) { + continue; // already activated. + } + let event = new KeyboardEvent("", { key: modifiers.lockable[j].key }); + TIP.keydown( + event, + TIP.KEY_NON_PRINTABLE_KEY | TIP.KEY_DONT_DISPATCH_MODIFIER_KEY_EVENT + ); + TIP.keyup( + event, + TIP.KEY_NON_PRINTABLE_KEY | TIP.KEY_DONT_DISPATCH_MODIFIER_KEY_EVENT + ); + modifiers.lockable[j].activated = true; + } + + return modifiers; +} + +function emulateToInactivateModifiers_(TIP, modifiers, win) { + if (!modifiers) { + return; + } + let KeyboardEvent = getKeyboardEvent_(win); + for (let i = 0; i < modifiers.normal.length; i++) { + if (!modifiers.normal[i].activated) { + continue; + } + let event = new KeyboardEvent("", { key: modifiers.normal[i].key }); + TIP.keyup( + event, + TIP.KEY_NON_PRINTABLE_KEY | TIP.KEY_DONT_DISPATCH_MODIFIER_KEY_EVENT + ); + } + for (let j = 0; j < modifiers.lockable.length; j++) { + if (!modifiers.lockable[j].activated) { + continue; + } + if (!TIP.getModifierState(modifiers.lockable[j].key)) { + continue; // who already inactivated this? + } + let event = new KeyboardEvent("", { key: modifiers.lockable[j].key }); + TIP.keydown( + event, + TIP.KEY_NON_PRINTABLE_KEY | TIP.KEY_DONT_DISPATCH_MODIFIER_KEY_EVENT + ); + TIP.keyup( + event, + TIP.KEY_NON_PRINTABLE_KEY | TIP.KEY_DONT_DISPATCH_MODIFIER_KEY_EVENT + ); + } +} + +/* eslint-disable */ +function guessKeyNameFromKeyCode_(aKeyCode, win) { + let KeyboardEvent = getKeyboardEvent_(win); + switch (aKeyCode) { + case KeyboardEvent.DOM_VK_CANCEL: + return "Cancel"; + case KeyboardEvent.DOM_VK_HELP: + return "Help"; + case KeyboardEvent.DOM_VK_BACK_SPACE: + return "Backspace"; + case KeyboardEvent.DOM_VK_TAB: + return "Tab"; + case KeyboardEvent.DOM_VK_CLEAR: + return "Clear"; + case KeyboardEvent.DOM_VK_RETURN: + return "Enter"; + case KeyboardEvent.DOM_VK_SHIFT: + return "Shift"; + case KeyboardEvent.DOM_VK_CONTROL: + return "Control"; + case KeyboardEvent.DOM_VK_ALT: + return "Alt"; + case KeyboardEvent.DOM_VK_PAUSE: + return "Pause"; + case KeyboardEvent.DOM_VK_EISU: + return "Eisu"; + case KeyboardEvent.DOM_VK_ESCAPE: + return "Escape"; + case KeyboardEvent.DOM_VK_CONVERT: + return "Convert"; + case KeyboardEvent.DOM_VK_NONCONVERT: + return "NonConvert"; + case KeyboardEvent.DOM_VK_ACCEPT: + return "Accept"; + case KeyboardEvent.DOM_VK_MODECHANGE: + return "ModeChange"; + case KeyboardEvent.DOM_VK_PAGE_UP: + return "PageUp"; + case KeyboardEvent.DOM_VK_PAGE_DOWN: + return "PageDown"; + case KeyboardEvent.DOM_VK_END: + return "End"; + case KeyboardEvent.DOM_VK_HOME: + return "Home"; + case KeyboardEvent.DOM_VK_LEFT: + return "ArrowLeft"; + case KeyboardEvent.DOM_VK_UP: + return "ArrowUp"; + case KeyboardEvent.DOM_VK_RIGHT: + return "ArrowRight"; + case KeyboardEvent.DOM_VK_DOWN: + return "ArrowDown"; + case KeyboardEvent.DOM_VK_SELECT: + return "Select"; + case KeyboardEvent.DOM_VK_PRINT: + return "Print"; + case KeyboardEvent.DOM_VK_EXECUTE: + return "Execute"; + case KeyboardEvent.DOM_VK_PRINTSCREEN: + return "PrintScreen"; + case KeyboardEvent.DOM_VK_INSERT: + return "Insert"; + case KeyboardEvent.DOM_VK_DELETE: + return "Delete"; + case KeyboardEvent.DOM_VK_WIN: + return "OS"; + case KeyboardEvent.DOM_VK_CONTEXT_MENU: + return "ContextMenu"; + case KeyboardEvent.DOM_VK_SLEEP: + return "Standby"; + case KeyboardEvent.DOM_VK_F1: + return "F1"; + case KeyboardEvent.DOM_VK_F2: + return "F2"; + case KeyboardEvent.DOM_VK_F3: + return "F3"; + case KeyboardEvent.DOM_VK_F4: + return "F4"; + case KeyboardEvent.DOM_VK_F5: + return "F5"; + case KeyboardEvent.DOM_VK_F6: + return "F6"; + case KeyboardEvent.DOM_VK_F7: + return "F7"; + case KeyboardEvent.DOM_VK_F8: + return "F8"; + case KeyboardEvent.DOM_VK_F9: + return "F9"; + case KeyboardEvent.DOM_VK_F10: + return "F10"; + case KeyboardEvent.DOM_VK_F11: + return "F11"; + case KeyboardEvent.DOM_VK_F12: + return "F12"; + case KeyboardEvent.DOM_VK_F13: + return "F13"; + case KeyboardEvent.DOM_VK_F14: + return "F14"; + case KeyboardEvent.DOM_VK_F15: + return "F15"; + case KeyboardEvent.DOM_VK_F16: + return "F16"; + case KeyboardEvent.DOM_VK_F17: + return "F17"; + case KeyboardEvent.DOM_VK_F18: + return "F18"; + case KeyboardEvent.DOM_VK_F19: + return "F19"; + case KeyboardEvent.DOM_VK_F20: + return "F20"; + case KeyboardEvent.DOM_VK_F21: + return "F21"; + case KeyboardEvent.DOM_VK_F22: + return "F22"; + case KeyboardEvent.DOM_VK_F23: + return "F23"; + case KeyboardEvent.DOM_VK_F24: + return "F24"; + case KeyboardEvent.DOM_VK_NUM_LOCK: + return "NumLock"; + case KeyboardEvent.DOM_VK_SCROLL_LOCK: + return "ScrollLock"; + case KeyboardEvent.DOM_VK_VOLUME_MUTE: + return "AudioVolumeMute"; + case KeyboardEvent.DOM_VK_VOLUME_DOWN: + return "AudioVolumeDown"; + case KeyboardEvent.DOM_VK_VOLUME_UP: + return "AudioVolumeUp"; + case KeyboardEvent.DOM_VK_META: + return "Meta"; + case KeyboardEvent.DOM_VK_ALTGR: + return "AltGraph"; + case KeyboardEvent.DOM_VK_ATTN: + return "Attn"; + case KeyboardEvent.DOM_VK_CRSEL: + return "CrSel"; + case KeyboardEvent.DOM_VK_EXSEL: + return "ExSel"; + case KeyboardEvent.DOM_VK_EREOF: + return "EraseEof"; + case KeyboardEvent.DOM_VK_PLAY: + return "Play"; + default: + return "Unidentified"; + } +} +/* eslint-enable */ + +/** + * Indicate that an event with an original target and type is expected + * to be fired, or not expected to be fired. + */ +/* eslint-disable */ +function expectEvent_(expectedTarget, expectedEvent, testName) { + if (!expectedTarget || !expectedEvent) { + return null; + } + + seenEvent = false; + + let type; + if (expectedEvent.charAt(0) == "!") { + type = expectedEvent.substring(1); + } else { + type = expectedEvent; + } + + let handler = ev => { + let pass = (!seenEvent && ev.originalTarget == expectedTarget && ev.type == type); + is(pass, true, `${testName} ${type} event target ${seenEvent ? "twice" : ""}`); + seenEvent = true; + }; + + expectedTarget.addEventListener(type, handler); + return handler; +} +/* eslint-enable */ +/* eslint-disable no-restricted-globals */ + +/** + * Check if the event was fired or not. The provided event handler will + * be removed. + */ +function checkExpectedEvent_( + expectedTarget, + expectedEvent, + eventHandler, + testName +) { + if (eventHandler) { + let expectEvent = expectedEvent.charAt(0) != "!"; + let type = expectEvent; + if (!type) { + type = expectedEvent.substring(1); + } + expectedTarget.removeEventListener(type, eventHandler); + + let desc = `${type} event`; + if (!expectEvent) { + desc += " not"; + } + is(seenEvent, expectEvent, `${testName} ${desc} fired`); + } + + seenEvent = false; +} + +/** + * Similar to event.synthesizeMouse except that a test is performed to + * see if an event is fired at the right target as a result. + * + * To test that an event is not fired, use an expected type preceded by + * an exclamation mark, such as "!select". This might be used to test that + * a click on a disabled element doesn't fire certain events for instance. + * + * @param {Element} target + * Synthesise the mouse event on this target. + * @param {number} offsetX + * Horizontal offset from the target's bounding box. + * @param {number} offsetY + * Vertical offset from the target's bounding box. + * @param {Object.<string, ?>} ev + * Object which may contain the properties shiftKey, ctrlKey, altKey, + * metaKey, accessKey, type. + * @param {Element} expectedTarget + * Expected originalTarget of the event. + * @param {DOMEvent} expectedEvent + * Expected type of the event, such as "select". + * @param {string} testName + * Test name when outputing results. + * @param {Window} win + * Window object. + */ +event.synthesizeMouseExpectEvent = function( + target, + offsetX, + offsetY, + ev, + expectedTarget, + expectedEvent, + testName, + win +) { + let eventHandler = expectEvent_(expectedTarget, expectedEvent, testName); + event.synthesizeMouse(target, offsetX, offsetY, ev, win); + checkExpectedEvent_(expectedTarget, expectedEvent, eventHandler, testName); +}; + +const MODIFIER_KEYCODES_LOOKUP = { + VK_SHIFT: "shiftKey", + VK_CONTROL: "ctrlKey", + VK_ALT: "altKey", + VK_META: "metaKey", +}; + +const VIRTUAL_KEYCODE_LOOKUP = { + "\uE001": "VK_CANCEL", + "\uE002": "VK_HELP", + "\uE003": "VK_BACK_SPACE", + "\uE004": "VK_TAB", + "\uE005": "VK_CLEAR", + "\uE006": "VK_RETURN", + "\uE007": "VK_RETURN", + "\uE008": "VK_SHIFT", + "\uE009": "VK_CONTROL", + "\uE00A": "VK_ALT", + "\uE00B": "VK_PAUSE", + "\uE00C": "VK_ESCAPE", + "\uE00D": "VK_SPACE", // printable + "\uE00E": "VK_PAGE_UP", + "\uE00F": "VK_PAGE_DOWN", + "\uE010": "VK_END", + "\uE011": "VK_HOME", + "\uE012": "VK_LEFT", + "\uE013": "VK_UP", + "\uE014": "VK_RIGHT", + "\uE015": "VK_DOWN", + "\uE016": "VK_INSERT", + "\uE017": "VK_DELETE", + "\uE018": "VK_SEMICOLON", + "\uE019": "VK_EQUALS", + "\uE01A": "VK_NUMPAD0", + "\uE01B": "VK_NUMPAD1", + "\uE01C": "VK_NUMPAD2", + "\uE01D": "VK_NUMPAD3", + "\uE01E": "VK_NUMPAD4", + "\uE01F": "VK_NUMPAD5", + "\uE020": "VK_NUMPAD6", + "\uE021": "VK_NUMPAD7", + "\uE022": "VK_NUMPAD8", + "\uE023": "VK_NUMPAD9", + "\uE024": "VK_MULTIPLY", + "\uE025": "VK_ADD", + "\uE026": "VK_SEPARATOR", + "\uE027": "VK_SUBTRACT", + "\uE028": "VK_DECIMAL", + "\uE029": "VK_DIVIDE", + "\uE031": "VK_F1", + "\uE032": "VK_F2", + "\uE033": "VK_F3", + "\uE034": "VK_F4", + "\uE035": "VK_F5", + "\uE036": "VK_F6", + "\uE037": "VK_F7", + "\uE038": "VK_F8", + "\uE039": "VK_F9", + "\uE03A": "VK_F10", + "\uE03B": "VK_F11", + "\uE03C": "VK_F12", + "\uE03D": "VK_META", + "\uE050": "VK_SHIFT", + "\uE051": "VK_CONTROL", + "\uE052": "VK_ALT", + "\uE053": "VK_META", + "\uE054": "VK_PAGE_UP", + "\uE055": "VK_PAGE_DOWN", + "\uE056": "VK_END", + "\uE057": "VK_HOME", + "\uE058": "VK_LEFT", + "\uE059": "VK_UP", + "\uE05A": "VK_RIGHT", + "\uE05B": "VK_DOWN", + "\uE05C": "VK_INSERT", + "\uE05D": "VK_DELETE", +}; + +function getKeyCode(c) { + if (c in VIRTUAL_KEYCODE_LOOKUP) { + return VIRTUAL_KEYCODE_LOOKUP[c]; + } + return c; +} + +function isPrintable(c, win) { + let KeyboardEvent = getKeyboardEvent_(win); + let NON_PRINT_KEYS = [ + KeyboardEvent.DOM_VK_CANCEL, + KeyboardEvent.DOM_VK_HELP, + KeyboardEvent.DOM_VK_BACK_SPACE, + KeyboardEvent.DOM_VK_TAB, + KeyboardEvent.DOM_VK_CLEAR, + KeyboardEvent.DOM_VK_SHIFT, + KeyboardEvent.DOM_VK_CONTROL, + KeyboardEvent.DOM_VK_ALT, + KeyboardEvent.DOM_VK_PAUSE, + KeyboardEvent.DOM_VK_EISU, + KeyboardEvent.DOM_VK_ESCAPE, + KeyboardEvent.DOM_VK_CONVERT, + KeyboardEvent.DOM_VK_NONCONVERT, + KeyboardEvent.DOM_VK_ACCEPT, + KeyboardEvent.DOM_VK_MODECHANGE, + KeyboardEvent.DOM_VK_PAGE_UP, + KeyboardEvent.DOM_VK_PAGE_DOWN, + KeyboardEvent.DOM_VK_END, + KeyboardEvent.DOM_VK_HOME, + KeyboardEvent.DOM_VK_LEFT, + KeyboardEvent.DOM_VK_UP, + KeyboardEvent.DOM_VK_RIGHT, + KeyboardEvent.DOM_VK_DOWN, + KeyboardEvent.DOM_VK_SELECT, + KeyboardEvent.DOM_VK_PRINT, + KeyboardEvent.DOM_VK_EXECUTE, + KeyboardEvent.DOM_VK_PRINTSCREEN, + KeyboardEvent.DOM_VK_INSERT, + KeyboardEvent.DOM_VK_DELETE, + KeyboardEvent.DOM_VK_WIN, + KeyboardEvent.DOM_VK_CONTEXT_MENU, + KeyboardEvent.DOM_VK_SLEEP, + KeyboardEvent.DOM_VK_F1, + KeyboardEvent.DOM_VK_F2, + KeyboardEvent.DOM_VK_F3, + KeyboardEvent.DOM_VK_F4, + KeyboardEvent.DOM_VK_F5, + KeyboardEvent.DOM_VK_F6, + KeyboardEvent.DOM_VK_F7, + KeyboardEvent.DOM_VK_F8, + KeyboardEvent.DOM_VK_F9, + KeyboardEvent.DOM_VK_F10, + KeyboardEvent.DOM_VK_F11, + KeyboardEvent.DOM_VK_F12, + KeyboardEvent.DOM_VK_F13, + KeyboardEvent.DOM_VK_F14, + KeyboardEvent.DOM_VK_F15, + KeyboardEvent.DOM_VK_F16, + KeyboardEvent.DOM_VK_F17, + KeyboardEvent.DOM_VK_F18, + KeyboardEvent.DOM_VK_F19, + KeyboardEvent.DOM_VK_F20, + KeyboardEvent.DOM_VK_F21, + KeyboardEvent.DOM_VK_F22, + KeyboardEvent.DOM_VK_F23, + KeyboardEvent.DOM_VK_F24, + KeyboardEvent.DOM_VK_NUM_LOCK, + KeyboardEvent.DOM_VK_SCROLL_LOCK, + KeyboardEvent.DOM_VK_VOLUME_MUTE, + KeyboardEvent.DOM_VK_VOLUME_DOWN, + KeyboardEvent.DOM_VK_VOLUME_UP, + KeyboardEvent.DOM_VK_META, + KeyboardEvent.DOM_VK_ALTGR, + KeyboardEvent.DOM_VK_ATTN, + KeyboardEvent.DOM_VK_CRSEL, + KeyboardEvent.DOM_VK_EXSEL, + KeyboardEvent.DOM_VK_EREOF, + KeyboardEvent.DOM_VK_PLAY, + KeyboardEvent.DOM_VK_RETURN, + ]; + return !NON_PRINT_KEYS.includes(c); +} + +event.sendKeyDown = function(keyToSend, modifiers, win) { + modifiers.type = "keydown"; + event.sendSingleKey(keyToSend, modifiers, win); + delete modifiers.type; +}; + +event.sendKeyUp = function(keyToSend, modifiers, win) { + modifiers.type = "keyup"; + event.sendSingleKey(keyToSend, modifiers, win); + delete modifiers.type; +}; + +/** + * Synthesize a key event for a single key. + * + * @param {string} keyToSend + * Code point or normalized key value + * @param {Object.<string, boolean>} modifiers + * Object with properties used in KeyboardEvent (shiftkey, repeat, ...) + * as well as, the event |type| such as keydown. All properties + * are optional. + * @param {Window} win + * Window object. + */ +event.sendSingleKey = function(keyToSend, modifiers, win) { + let keyCode = getKeyCode(keyToSend); + if (keyCode in MODIFIER_KEYCODES_LOOKUP) { + // For |sendKeysToElement| and legacy actions we assume that if + // |keyToSend| is a raw code point (like "\uE009") then |modifiers| does + // not already have correct value for corresponding |modName| attribute + // (like ctrlKey), so that value needs to be flipped. + let modName = MODIFIER_KEYCODES_LOOKUP[keyCode]; + modifiers[modName] = !modifiers[modName]; + } + event.synthesizeKey(keyCode, modifiers, win); +}; + +/** + * @param {string} keyString + * @param {Element} element + * @param {Window} win + */ +event.sendKeysToElement = function(keyString, el, win) { + // make Object.<modifier, false> map + let modifiers = Object.create(event.Modifiers); + for (let modifier in event.Modifiers) { + modifiers[modifier] = false; + } + + for (let i = 0; i < keyString.length; i++) { + let c = keyString.charAt(i); + event.sendSingleKey(c, modifiers, 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/testing/marionette/format.js b/testing/marionette/format.js new file mode 100644 index 0000000000..51f2d4005d --- /dev/null +++ b/testing/marionette/format.js @@ -0,0 +1,194 @@ +/* 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 EXPORTED_SYMBOLS = ["pprint", "truncate"]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + Log: "chrome://marionette/content/log.js", + MarionettePrefs: "chrome://marionette/content/prefs.js", +}); + +XPCOMUtils.defineLazyGetter(this, "logger", () => Log.get()); + +const ELEMENT_NODE = 1; +const MAX_STRING_LENGTH = 250; + +/** + * Pretty-print values passed to template strings. + * + * Usage:: + * + * const { pprint } = Cu.import( + * "chrome://marionette/content/format.js", {} + * ); + * + * let bool = {value: true}; + * pprint`Expected boolean, got ${bool}`; + * => 'Expected boolean, got [object Object] {"value": true}' + * + * let htmlElement = document.querySelector("input#foo"); + * pprint`Expected element ${htmlElement}`; + * => 'Expected element <input id="foo" class="bar baz" type="input">' + * + * pprint`Current window: ${window}`; + * => '[object Window https://www.mozilla.org/]' + */ +function pprint(ss, ...values) { + function pretty(val) { + let proto = Object.prototype.toString.call(val); + if ( + typeof val == "object" && + val !== null && + "nodeType" in val && + val.nodeType === ELEMENT_NODE + ) { + return prettyElement(val); + } else if (["[object Window]", "[object ChromeWindow]"].includes(proto)) { + return prettyWindowGlobal(val); + } else if (proto == "[object Attr]") { + return prettyAttr(val); + } + return prettyObject(val); + } + + function prettyElement(el) { + let attrs = ["id", "class", "href", "name", "src", "type"]; + + let idents = ""; + for (let attr of attrs) { + if (el.hasAttribute(attr)) { + idents += ` ${attr}="${el.getAttribute(attr)}"`; + } + } + + return `<${el.localName}${idents}>`; + } + + function prettyWindowGlobal(win) { + let proto = Object.prototype.toString.call(win); + return `[${proto.substring(1, proto.length - 1)} ${win.location}]`; + } + + function prettyAttr(obj) { + return `[object Attr ${obj.name}="${obj.value}"]`; + } + + function prettyObject(obj) { + let proto = Object.prototype.toString.call(obj); + let s = ""; + try { + s = JSON.stringify(obj); + } catch (e) { + if (e instanceof TypeError) { + s = `<${e.message}>`; + } else { + throw e; + } + } + return `${proto} ${s}`; + } + + let res = []; + for (let i = 0; i < ss.length; i++) { + res.push(ss[i]); + if (i < values.length) { + let s; + try { + s = pretty(values[i]); + } catch (e) { + logger.warn("Problem pretty printing:", e); + s = typeof values[i]; + } + res.push(s); + } + } + return res.join(""); +} +this.pprint = pprint; + +/** + * Template literal that truncates string values in arbitrary objects. + * + * Given any object, the template will walk the object and truncate + * any strings it comes across to a reasonable limit. This is suitable + * when you have arbitrary data and data integrity is not important. + * + * The strings are truncated in the middle so that the beginning and + * the end is preserved. This will make a long, truncated string look + * like "X <...> Y", where X and Y are half the number of characters + * of the maximum string length from either side of the string. + * + * If the `marionette.log.truncate` preference is false, this + * function acts as a no-op. + * + * Usage:: + * + * truncate`Hello ${"x".repeat(260)}!`; + * // Hello xxx ... xxx! + * + * Functions named `toJSON` or `toString` on objects will be called. + */ +function truncate(strings, ...values) { + function walk(obj) { + const typ = Object.prototype.toString.call(obj); + + switch (typ) { + case "[object Undefined]": + case "[object Null]": + case "[object Boolean]": + case "[object Number]": + return obj; + + case "[object String]": + if (MarionettePrefs.truncateLog) { + if (obj.length > MAX_STRING_LENGTH) { + let s1 = obj.substring(0, MAX_STRING_LENGTH / 2); + let s2 = obj.substring(obj.length - MAX_STRING_LENGTH / 2); + return `${s1} ... ${s2}`; + } + } + return obj; + + case "[object Array]": + return obj.map(walk); + + // arbitrary object + default: + if ( + Object.getOwnPropertyNames(obj).includes("toString") && + typeof obj.toString == "function" + ) { + return walk(obj.toString()); + } + + let rv = {}; + for (let prop in obj) { + rv[prop] = walk(obj[prop]); + } + return rv; + } + } + + let res = []; + for (let i = 0; i < strings.length; ++i) { + res.push(strings[i]); + if (i < values.length) { + let obj = walk(values[i]); + let t = Object.prototype.toString.call(obj); + if (t == "[object Array]" || t == "[object Object]") { + res.push(JSON.stringify(obj)); + } else { + res.push(obj); + } + } + } + return res.join(""); +} +this.truncate = truncate; diff --git a/testing/marionette/harness/MANIFEST.in b/testing/marionette/harness/MANIFEST.in new file mode 100644 index 0000000000..ce2d97cd30 --- /dev/null +++ b/testing/marionette/harness/MANIFEST.in @@ -0,0 +1,4 @@ +exclude MANIFEST.in +include requirements.txt +recursive-include marionette_harness/certificates * +recursive-include marionette_harness/www * diff --git a/testing/marionette/harness/README.rst b/testing/marionette/harness/README.rst new file mode 100644 index 0000000000..3f8865603e --- /dev/null +++ b/testing/marionette/harness/README.rst @@ -0,0 +1,30 @@ +marionette-harness +================== + +Marionette is an automation driver for Mozilla's Gecko engine. It can remotely +control either the UI or the internal JavaScript of a Gecko platform, such as +Firefox. It can control both the chrome (i.e. menus and functions) or the +content (the webpage loaded inside the browsing context), giving a high level +of control and ability to replicate user actions. In addition to performing +actions on the browser, Marionette can also read the properties and attributes +of the DOM. + +The marionette_harness package contains the test runner for Marionette, and +allows you to run automated tests written in Python for Gecko based +applications. Therefore it offers the necessary testcase classes, which are +based on the unittest framework. + +For more information and the repository please checkout: + +- home and docs: https://developer.mozilla.org/en-US/docs/Mozilla/QA/Marionette + + +Example +------- + +The following command will run the tests as specified via a manifest file, or +test path, or test folder in Firefox: + + marionette --binary %path_to_firefox% [manifest_file | test_file | test_folder] + +To get an overview about all possible option run `marionette --help`. diff --git a/testing/marionette/harness/marionette_harness/__init__.py b/testing/marionette/harness/marionette_harness/__init__.py new file mode 100644 index 0000000000..dc80408329 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/__init__.py @@ -0,0 +1,34 @@ +# 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/. + +from __future__ import absolute_import + +__version__ = "5.0.2" + +from .marionette_test import ( + CommonTestCase, + expectedFailure, + MarionetteTestCase, + parameterized, + run_if_manage_instance, + skip, + skip_if_chrome, + skip_if_desktop, + skip_if_framescript, + SkipTest, + skip_unless_browser_pref, + skip_unless_protocol, +) +from .runner import ( + BaseMarionetteArguments, + BaseMarionetteTestRunner, + Marionette, + MarionetteTest, + MarionetteTestResult, + MarionetteTextTestRunner, + TestManifest, + TestResult, + TestResultCollection, + WindowManagerMixin, +) diff --git a/testing/marionette/harness/marionette_harness/certificates/test.cert b/testing/marionette/harness/marionette_harness/certificates/test.cert new file mode 100644 index 0000000000..3fd1cba2b7 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/certificates/test.cert @@ -0,0 +1,86 @@ +Certificate: + Data: + Version: 3 (0x2) + Serial Number: 2 (0x2) + Signature Algorithm: sha256WithRSAEncryption + Issuer: CN=web-platform-tests + Validity + Not Before: Dec 22 12:09:16 2014 GMT + Not After : Dec 21 12:09:16 2024 GMT + Subject: CN=web-platform.test + Subject Public Key Info: + Public Key Algorithm: rsaEncryption + Public-Key: (2048 bit) + Modulus: + 00:b3:84:d6:8b:01:59:18:85:d1:dc:32:df:38:f7: + 90:85:1b:3e:a5:5e:81:3e:2f:fc:3a:5f:7f:77:ef: + 23:bb:3a:88:27:0f:be:25:46:cd:63:7d:cb:95:d8: + a5:50:10:d2:a2:d2:b7:97:d1:0d:6c:fb:f9:05:e8: + 6f:a8:4b:bd:95:67:9e:7b:94:58:a9:6d:93:fd:e0: + 12:c5:cd:b4:8a:64:52:31:5f:0e:e3:89:84:71:da: + 98:dd:4b:ec:02:25:a5:7d:35:fe:63:da:b3:ac:ec: + a5:46:0f:0d:64:23:5c:6d:f3:ec:cc:28:63:23:c0: + 4b:9a:ec:8f:c1:ee:b1:a2:3e:72:4d:70:b5:09:c1: + eb:b4:10:55:3c:8b:ea:1b:94:7e:4b:74:e6:f4:9f: + 4f:a6:45:30:b5:f0:b8:b4:d1:59:50:65:0a:86:53: + ea:4c:9f:9e:f4:58:6c:31:f5:17:3a:6f:57:8b:cb: + 5f:f0:28:0b:45:92:8d:30:20:49:ff:52:e6:2c:cb: + 18:9a:d7:e6:ee:3e:4f:34:35:15:13:c5:02:da:c5: + 5f:be:fb:5b:ce:8d:bf:b5:35:76:3c:7c:e6:9c:3b: + 26:87:4d:8d:80:e6:16:c6:27:f2:50:49:b6:72:74: + 43:49:49:44:38:bb:78:43:23:ee:16:3e:d9:62:e6: + a5:d7 + Exponent: 65537 (0x10001) + X509v3 extensions: + X509v3 Basic Constraints: + CA:FALSE + X509v3 Subject Key Identifier: + 2D:98:A3:99:39:1C:FE:E9:9A:6D:17:94:D2:3A:96:EE:C8:9E:04:22 + X509v3 Authority Key Identifier: + keyid:6A:AB:53:64:92:36:87:23:34:B3:1D:6F:85:4B:F5:DF:5A:5C:74:8F + + X509v3 Key Usage: + Digital Signature, Non Repudiation, Key Encipherment + X509v3 Extended Key Usage: + TLS Web Server Authentication + X509v3 Subject Alternative Name: + DNS:web-platform.test, DNS:www.web-platform.test, DNS:xn--n8j6ds53lwwkrqhv28a.web-platform.test, DNS:xn--lve-6lad.web-platform.test, DNS:www2.web-platform.test, DNS:www1.web-platform.test + Signature Algorithm: sha256WithRSAEncryption + 33:db:f7:f0:f6:92:16:4f:2d:42:bc:b8:aa:e6:ab:5e:f9:b9: + b0:48:ae:b5:8d:cc:02:7b:e9:6f:4e:75:f7:17:a0:5e:7b:87: + 06:49:48:83:c5:bb:ca:95:07:37:0e:5d:e3:97:de:9e:0c:a4: + 82:30:11:81:49:5d:50:29:72:92:a5:ca:17:b1:7c:f1:32:11: + 17:57:e6:59:c1:ac:e3:3b:26:d2:94:97:50:6a:b9:54:88:84: + 9b:6f:b1:06:f5:80:04:22:10:14:b1:f5:97:25:fc:66:d6:69: + a3:36:08:85:23:ff:8e:3c:2b:e0:6d:e7:61:f1:00:8f:61:3d: + b0:87:ad:72:21:f6:f0:cc:4f:c9:20:bf:83:11:0f:21:f4:b8: + c0:dd:9c:51:d7:bb:27:32:ec:ab:a4:62:14:28:32:da:f2:87: + 80:68:9c:ea:ac:eb:f5:7f:f5:de:f4:c0:39:91:c8:76:a4:ee: + d0:a8:50:db:c1:4b:f9:c4:3d:d9:e8:8e:b6:3f:c0:96:79:12: + d8:fa:4d:0a:b3:36:76:aa:4e:b2:82:2f:a2:d4:0d:db:fd:64: + 77:6f:6e:e9:94:7f:0f:c8:3a:3c:96:3d:cd:4d:6c:ba:66:95: + f7:b4:9d:a4:94:9f:97:b3:9a:0d:dc:18:8c:11:0b:56:65:8e: + 46:4c:e6:5e +-----BEGIN CERTIFICATE----- +MIID2jCCAsKgAwIBAgIBAjANBgkqhkiG9w0BAQsFADAdMRswGQYDVQQDDBJ3ZWIt +cGxhdGZvcm0tdGVzdHMwHhcNMTQxMjIyMTIwOTE2WhcNMjQxMjIxMTIwOTE2WjAc +MRowGAYDVQQDExF3ZWItcGxhdGZvcm0udGVzdDCCASIwDQYJKoZIhvcNAQEBBQAD +ggEPADCCAQoCggEBALOE1osBWRiF0dwy3zj3kIUbPqVegT4v/Dpff3fvI7s6iCcP +viVGzWN9y5XYpVAQ0qLSt5fRDWz7+QXob6hLvZVnnnuUWKltk/3gEsXNtIpkUjFf +DuOJhHHamN1L7AIlpX01/mPas6zspUYPDWQjXG3z7MwoYyPAS5rsj8HusaI+ck1w +tQnB67QQVTyL6huUfkt05vSfT6ZFMLXwuLTRWVBlCoZT6kyfnvRYbDH1FzpvV4vL +X/AoC0WSjTAgSf9S5izLGJrX5u4+TzQ1FRPFAtrFX777W86Nv7U1djx85pw7JodN +jYDmFsYn8lBJtnJ0Q0lJRDi7eEMj7hY+2WLmpdcCAwEAAaOCASQwggEgMAkGA1Ud +EwQCMAAwHQYDVR0OBBYEFC2Yo5k5HP7pmm0XlNI6lu7IngQiMB8GA1UdIwQYMBaA +FGqrU2SSNocjNLMdb4VL9d9aXHSPMAsGA1UdDwQEAwIF4DATBgNVHSUEDDAKBggr +BgEFBQcDATCBsAYDVR0RBIGoMIGlghF3ZWItcGxhdGZvcm0udGVzdIIVd3d3Lndl +Yi1wbGF0Zm9ybS50ZXN0gil4bi0tbjhqNmRzNTNsd3drcnFodjI4YS53ZWItcGxh +dGZvcm0udGVzdIIeeG4tLWx2ZS02bGFkLndlYi1wbGF0Zm9ybS50ZXN0ghZ3d3cy +LndlYi1wbGF0Zm9ybS50ZXN0ghZ3d3cxLndlYi1wbGF0Zm9ybS50ZXN0MA0GCSqG +SIb3DQEBCwUAA4IBAQAz2/fw9pIWTy1CvLiq5qte+bmwSK61jcwCe+lvTnX3F6Be +e4cGSUiDxbvKlQc3Dl3jl96eDKSCMBGBSV1QKXKSpcoXsXzxMhEXV+ZZwazjOybS +lJdQarlUiISbb7EG9YAEIhAUsfWXJfxm1mmjNgiFI/+OPCvgbedh8QCPYT2wh61y +IfbwzE/JIL+DEQ8h9LjA3ZxR17snMuyrpGIUKDLa8oeAaJzqrOv1f/Xe9MA5kch2 +pO7QqFDbwUv5xD3Z6I62P8CWeRLY+k0KszZ2qk6ygi+i1A3b/WR3b27plH8PyDo8 +lj3NTWy6ZpX3tJ2klJ+Xs5oN3BiMEQtWZY5GTOZe +-----END CERTIFICATE-----
\ No newline at end of file diff --git a/testing/marionette/harness/marionette_harness/certificates/test.key b/testing/marionette/harness/marionette_harness/certificates/test.key new file mode 100644 index 0000000000..194a49ec42 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/certificates/test.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCzhNaLAVkYhdHc +Mt8495CFGz6lXoE+L/w6X3937yO7OognD74lRs1jfcuV2KVQENKi0reX0Q1s+/kF +6G+oS72VZ557lFipbZP94BLFzbSKZFIxXw7jiYRx2pjdS+wCJaV9Nf5j2rOs7KVG +Dw1kI1xt8+zMKGMjwEua7I/B7rGiPnJNcLUJweu0EFU8i+oblH5LdOb0n0+mRTC1 +8Li00VlQZQqGU+pMn570WGwx9Rc6b1eLy1/wKAtFko0wIEn/UuYsyxia1+buPk80 +NRUTxQLaxV+++1vOjb+1NXY8fOacOyaHTY2A5hbGJ/JQSbZydENJSUQ4u3hDI+4W +Ptli5qXXAgMBAAECggEBAIcwDQSnIjo2ZECHytQykpG6X6XXEksLhc1Lp0lhPC49 +uNR5pX6a4AcBb3PLr0opMQZO2tUoKA0ff3t0e8loKD+/xXhY0Z/dlioEOP7elwv0 +2nS1mhe9spCuxpk4GGXRhdtR8t2tj8s0do3YvgPgITXoEDX6YBZHNGhZpzSrFPgQ +/c3eGCVmzWYuLFfdj5OPQ9bwTaY4JSvDLZT0/WTgiica7VySwfz3HP1fFqNykTiK +ACQREvtxfk5Ym2nT6oni7CM2zOEJL9SXicXI5HO4bERH0ZYh//F3g6mwGiFXUJPd +NKgaTM1oT9kRGkUaEYsRWrddwR8d5mXLvBuTJbgIsSECgYEA1+2uJSYRW1OqbhYP +ms59YQHSs3VjpJpnCV2zNa2Wixs57KS2cOH7B6KrQCogJFLtgCDVLtyoErfVkD7E +FivTgYr1pVCRppJddQzXik31uOINOBVffr7/09g3GcRN+ubHPZPq3K+dD6gHa3Aj +0nH1EjEEV0QpSTQFn87OF2mc9wcCgYEA1NVqMbbzd+9Xft5FXuSbX6E+S02dOGat +SgpnkTM80rjqa6eHdQzqk3JqyteHPgdi1vdYRlSPOj/X+6tySY0Ej9sRnYOfddA2 +kpiDiVkmiqVolyJPY69Utj+E3TzJ1vhCQuYknJmB7zP9tDcTxMeq0l/NaWvGshEK +yC4UTQog1rECgYASOFILfGzWgfbNlzr12xqlRtwanHst9oFfPvLSQrWDQ2bd2wAy +Aj+GY2mD3oobxouX1i1m6OOdwLlalJFDNauBMNKNgoDnx03vhIfjebSURy7KXrNS +JJe9rm7n07KoyzRgs8yLlp3wJkOKA0pihY8iW9R78JpzPNqEo5SsURMXnQKBgBlV +gfuC9H4tPjP6zzUZbyk1701VYsaI6k2q6WMOP0ox+q1v1p7nN7DvaKjWeOG4TVqb +PKW6gQYE/XeWk9cPcyCQigs+1KdYbnaKsvWRaBYO1GFREzQhdarv6qfPCZOOH40J +Cgid+Sp4/NULzU2aGspJ3xCSZKdjge4MFhyJfRkxAoGBAJlwqY4nue0MBLGNpqcs +WwDtSasHvegKAcxGBKL5oWPbLBk7hk+hdqc8f6YqCkCNqv/ooBspL15ESItL+6yT +zt0YkK4oH9tmLDb+rvqZ7ZdXbWSwKITMoCyyHUtT6OKt/RtA0Vdy9LPnP27oSO/C +dk8Qf7KgKZLWo0ZNkvw38tEC +-----END PRIVATE KEY-----
\ No newline at end of file diff --git a/testing/marionette/harness/marionette_harness/marionette_test/__init__.py b/testing/marionette/harness/marionette_harness/marionette_test/__init__.py new file mode 100644 index 0000000000..6e3c42c4d6 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/marionette_test/__init__.py @@ -0,0 +1,30 @@ +# 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/. + +from __future__ import absolute_import + +__version__ = "3.1.0" + +from unittest.case import ( + expectedFailure, + skip, + SkipTest, +) + +from .decorators import ( + parameterized, + run_if_manage_instance, + skip_if_chrome, + skip_if_desktop, + skip_if_framescript, + skip_unless_browser_pref, + skip_unless_protocol, + with_parameters, +) + +from .testcases import ( + CommonTestCase, + MarionetteTestCase, + MetaParameterized, +) diff --git a/testing/marionette/harness/marionette_harness/marionette_test/decorators.py b/testing/marionette/harness/marionette_harness/marionette_test/decorators.py new file mode 100644 index 0000000000..8896364c0d --- /dev/null +++ b/testing/marionette/harness/marionette_harness/marionette_test/decorators.py @@ -0,0 +1,215 @@ +# 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/. + +from __future__ import absolute_import + +import functools +import types + +from unittest.case import SkipTest + + +def parameterized(func_suffix, *args, **kwargs): + r"""Decorator which generates methods given a base method and some data. + + **func_suffix** is used as a suffix for the new created method and must be + unique given a base method. if **func_suffix** countains characters that + are not allowed in normal python function name, these characters will be + replaced with "_". + + This decorator can be used more than once on a single base method. The class + must have a metaclass of :class:`MetaParameterized`. + + Example:: + + # This example will generate two methods: + # + # - MyTestCase.test_it_1 + # - MyTestCase.test_it_2 + # + class MyTestCase(MarionetteTestCase): + @parameterized("1", 5, named='name') + @parameterized("2", 6, named='name2') + def test_it(self, value, named=None): + print value, named + + :param func_suffix: will be used as a suffix for the new method + :param \*args: arguments to pass to the new method + :param \*\*kwargs: named arguments to pass to the new method + """ + + def wrapped(func): + if not hasattr(func, "metaparameters"): + func.metaparameters = [] + func.metaparameters.append((func_suffix, args, kwargs)) + return func + + return wrapped + + +def run_if_manage_instance(reason): + """Decorator which runs a test if Marionette manages the application instance.""" + + def decorator(test_item): + if not isinstance(test_item, types.FunctionType): + raise Exception("Decorator only supported for functions") + + @functools.wraps(test_item) + def skip_wrapper(self, *args, **kwargs): + if self.marionette.instance is None: + raise SkipTest(reason) + return test_item(self, *args, **kwargs) + + return skip_wrapper + + return decorator + + +def skip_if_chrome(reason): + """Decorator which skips a test if chrome context is active.""" + + def decorator(test_item): + if not isinstance(test_item, types.FunctionType): + raise Exception("Decorator only supported for functions") + + @functools.wraps(test_item) + def skip_wrapper(self, *args, **kwargs): + if self.marionette._send_message("getContext", key="value") == "chrome": + raise SkipTest(reason) + return test_item(self, *args, **kwargs) + + return skip_wrapper + + return decorator + + +def skip_if_desktop(reason): + """Decorator which skips a test if run on desktop.""" + + def decorator(test_item): + if not isinstance(test_item, types.FunctionType): + raise Exception("Decorator only supported for functions") + + @functools.wraps(test_item) + def skip_wrapper(self, *args, **kwargs): + if self.marionette.session_capabilities.get("browserName") == "firefox": + raise SkipTest(reason) + return test_item(self, *args, **kwargs) + + return skip_wrapper + + return decorator + + +def skip_if_framescript(reason): + """Decorator which skips a test if the framescript implementation is used.""" + + def decorator(test_item): + if not isinstance(test_item, types.FunctionType): + raise Exception("Decorator only supported for functions") + + @functools.wraps(test_item) + def skip_wrapper(self, *args, **kwargs): + if self.marionette.get_pref("marionette.actors.enabled") is False: + raise SkipTest(reason) + return test_item(self, *args, **kwargs) + + return skip_wrapper + + return decorator + + +def skip_unless_browser_pref(reason, pref, predicate=bool): + """Decorator which skips a test based on the value of a browser preference. + + :param reason: Message describing why the test need to be skipped. + :param pref: the preference name + :param predicate: a function that should return false to skip the test. + The function takes one parameter, the preference value. + Defaults to the python built-in bool function. + + Note that the preference must exist, else a failure is raised. + + Example: :: + + class TestSomething(MarionetteTestCase): + @skip_unless_browser_pref("Sessionstore needs to be enabled for crashes", + "browser.sessionstore.resume_from_crash", + lambda value: value is True, + ) + def test_foo(self): + pass # test implementation here + + """ + + def decorator(test_item): + if not isinstance(test_item, types.FunctionType): + raise Exception("Decorator only supported for functions") + if not callable(predicate): + raise ValueError("predicate must be callable") + + @functools.wraps(test_item) + def skip_wrapper(self, *args, **kwargs): + value = self.marionette.get_pref(pref) + if value is None: + self.fail("No such browser preference: {0!r}".format(pref)) + if not predicate(value): + raise SkipTest(reason) + return test_item(self, *args, **kwargs) + + return skip_wrapper + + return decorator + + +def skip_unless_protocol(reason, predicate): + """Decorator which skips a test if the predicate does not match the current protocol level.""" + + def decorator(test_item): + if not isinstance(test_item, types.FunctionType): + raise Exception("Decorator only supported for functions") + if not callable(predicate): + raise ValueError("predicate must be callable") + + @functools.wraps(test_item) + def skip_wrapper(self, *args, **kwargs): + level = self.marionette.client.protocol + if not predicate(level): + raise SkipTest(reason) + return test_item(self, *args, **kwargs) + + return skip_wrapper + + return decorator + + +def with_parameters(parameters): + """Decorator which generates methods given a base method and some data. + + Acts like :func:`parameterized`, but define all methods in one call. + + Example:: + + # This example will generate two methods: + # + # - MyTestCase.test_it_1 + # - MyTestCase.test_it_2 + # + + DATA = [("1", [5], {'named':'name'}), ("2", [6], {'named':'name2'})] + + class MyTestCase(MarionetteTestCase): + @with_parameters(DATA) + def test_it(self, value, named=None): + print value, named + + :param parameters: list of tuples (**func_suffix**, **args**, **kwargs**) + defining parameters like in :func:`todo`. + """ + + def wrapped(func): + func.metaparameters = parameters + return func + + return wrapped diff --git a/testing/marionette/harness/marionette_harness/marionette_test/testcases.py b/testing/marionette/harness/marionette_harness/marionette_test/testcases.py new file mode 100644 index 0000000000..0aa1c6f745 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/marionette_test/testcases.py @@ -0,0 +1,438 @@ +# 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/. + +from __future__ import absolute_import + +import imp +import os +import re +import sys +import time +import unittest +import warnings +import weakref + +from unittest.case import SkipTest + +import six + +from marionette_driver.errors import TimeoutException, UnresponsiveInstanceException +from mozlog import get_default_logger + + +# ExpectedFailure and UnexpectedSuccess are adapted from the Python 2 +# private classes _ExpectedFailure and _UnexpectedSuccess in +# unittest/case.py which are no longer available in Python 3. +class ExpectedFailure(Exception): + """ + Raise this when a test is expected to fail. + + This is an implementation detail. + """ + + def __init__(self, exc_info): + super(ExpectedFailure, self).__init__() + self.exc_info = exc_info + + +class UnexpectedSuccess(Exception): + """ + The test was supposed to fail, but it didn't! + """ + + pass + + +try: + # Since these errors can be thrown during execution under Python 2 + # we must support them until Marionette completes its transition + # from Python 2 to Python 3. + from unittest.case import ( + _ExpectedFailure, + _UnexpectedSuccess, + ) +except ImportError: + _ExpectedFailure = ExpectedFailure + _UnexpectedSuccess = UnexpectedSuccess + + +def _wraps_parameterized(func, func_suffix, args, kwargs): + """Internal: Decorator used in class MetaParameterized.""" + + def wrapper(self): + return func(self, *args, **kwargs) + + wrapper.__name__ = func.__name__ + "_" + str(func_suffix) + wrapper.__doc__ = "[{0}] {1}".format(func_suffix, func.__doc__) + return wrapper + + +class MetaParameterized(type): + """ + A metaclass that allow a class to use decorators. + + It can be used like :func:`parameterized` + or :func:`with_parameters` to generate new methods. + """ + + RE_ESCAPE_BAD_CHARS = re.compile(r"[\.\(\) -/]") + + def __new__(cls, name, bases, attrs): + for k, v in list(attrs.items()): + if callable(v) and hasattr(v, "metaparameters"): + for func_suffix, args, kwargs in v.metaparameters: + func_suffix = cls.RE_ESCAPE_BAD_CHARS.sub("_", func_suffix) + wrapper = _wraps_parameterized(v, func_suffix, args, kwargs) + if wrapper.__name__ in attrs: + raise KeyError( + "{0} is already a defined method on {1}".format( + wrapper.__name__, name + ) + ) + attrs[wrapper.__name__] = wrapper + del attrs[k] + + return type.__new__(cls, name, bases, attrs) + + +@six.add_metaclass(MetaParameterized) +class CommonTestCase(unittest.TestCase): + + match_re = None + failureException = AssertionError + pydebugger = None + + def __init__(self, methodName, marionette_weakref, fixtures, **kwargs): + super(CommonTestCase, self).__init__(methodName) + self.methodName = methodName + + self._marionette_weakref = marionette_weakref + self.fixtures = fixtures + + self.duration = 0 + self.start_time = 0 + self.expected = kwargs.pop("expected", "pass") + self.logger = get_default_logger() + + def _enter_pm(self): + if self.pydebugger: + self.pydebugger.post_mortem(sys.exc_info()[2]) + + def _addSkip(self, result, reason): + addSkip = getattr(result, "addSkip", None) + if addSkip is not None: + addSkip(self, reason) + else: + warnings.warn( + "TestResult has no addSkip method, skips not reported", + RuntimeWarning, + 2, + ) + result.addSuccess(self) + + def assertRaisesRegxp( + self, expected_exception, expected_regexp, callable_obj=None, *args, **kwargs + ): + return six.assertRaisesRegex( + self, + expected_exception, + expected_regexp, + callable_obj=None, + *args, + **kwargs + ) + + def run(self, result=None): + # Bug 967566 suggests refactoring run, which would hopefully + # mean getting rid of this inner function, which only sits + # here to reduce code duplication: + def expected_failure(result, exc_info): + addExpectedFailure = getattr(result, "addExpectedFailure", None) + if addExpectedFailure is not None: + addExpectedFailure(self, exc_info) + else: + warnings.warn( + "TestResult has no addExpectedFailure method, " + "reporting as passes", + RuntimeWarning, + ) + result.addSuccess(self) + + self.start_time = time.time() + orig_result = result + if result is None: + result = self.defaultTestResult() + startTestRun = getattr(result, "startTestRun", None) + if startTestRun is not None: + startTestRun() + + result.startTest(self) + + testMethod = getattr(self, self._testMethodName) + if getattr(self.__class__, "__unittest_skip__", False) or getattr( + testMethod, "__unittest_skip__", False + ): + # If the class or method was skipped. + try: + skip_why = getattr( + self.__class__, "__unittest_skip_why__", "" + ) or getattr(testMethod, "__unittest_skip_why__", "") + self._addSkip(result, skip_why) + finally: + result.stopTest(self) + self.stop_time = time.time() + return + try: + success = False + try: + if self.expected == "fail": + try: + self.setUp() + except Exception: + raise _ExpectedFailure(sys.exc_info()) + else: + self.setUp() + except SkipTest as e: + self._addSkip(result, str(e)) + except (KeyboardInterrupt, UnresponsiveInstanceException): + raise + except _ExpectedFailure as e: + expected_failure(result, e.exc_info) + except Exception: + self._enter_pm() + result.addError(self, sys.exc_info()) + else: + try: + if self.expected == "fail": + try: + testMethod() + except Exception: + raise _ExpectedFailure(sys.exc_info()) + raise _UnexpectedSuccess + else: + testMethod() + except self.failureException: + self._enter_pm() + result.addFailure(self, sys.exc_info()) + except (KeyboardInterrupt, UnresponsiveInstanceException): + raise + except _ExpectedFailure as e: + expected_failure(result, e.exc_info) + except _UnexpectedSuccess: + addUnexpectedSuccess = getattr(result, "addUnexpectedSuccess", None) + if addUnexpectedSuccess is not None: + addUnexpectedSuccess(self) + else: + warnings.warn( + "TestResult has no addUnexpectedSuccess method, " + "reporting as failures", + RuntimeWarning, + ) + result.addFailure(self, sys.exc_info()) + except SkipTest as e: + self._addSkip(result, str(e)) + except Exception: + self._enter_pm() + result.addError(self, sys.exc_info()) + else: + success = True + try: + if self.expected == "fail": + try: + self.tearDown() + except Exception: + raise _ExpectedFailure(sys.exc_info()) + else: + self.tearDown() + except (KeyboardInterrupt, UnresponsiveInstanceException): + raise + except _ExpectedFailure as e: + expected_failure(result, e.exc_info) + except Exception: + self._enter_pm() + result.addError(self, sys.exc_info()) + success = False + # Here we could handle doCleanups() instead of calling cleanTest directly + self.cleanTest() + + if success: + result.addSuccess(self) + + finally: + result.stopTest(self) + if orig_result is None: + stopTestRun = getattr(result, "stopTestRun", None) + if stopTestRun is not None: + stopTestRun() + + @classmethod + def match(cls, filename): + """Determine if the specified filename should be handled by this test class. + + This is done by looking for a match for the filename using cls.match_re. + """ + if not cls.match_re: + return False + m = cls.match_re.match(filename) + return m is not None + + @classmethod + def add_tests_to_suite( + cls, + mod_name, + filepath, + suite, + testloader, + marionette, + fixtures, + testvars, + **kwargs + ): + """Add all the tests in the specified file to the specified suite.""" + raise NotImplementedError + + @property + def test_name(self): + rel_path = None + if os.path.exists(self.filepath): + rel_path = self._fix_test_path(self.filepath) + + return "{0} {1}.{2}".format( + rel_path, self.__class__.__name__, self._testMethodName + ) + + def id(self): + # TBPL starring requires that the "test name" field of a failure message + # not differ over time. The test name to be used is passed to + # mozlog via the test id, so this is overriden to maintain + # consistency. + return self.test_name + + def setUp(self): + # Convert the marionette weakref to an object, just for the + # duration of the test; this is deleted in tearDown() to prevent + # a persistent circular reference which in turn would prevent + # proper garbage collection. + self.start_time = time.time() + self.marionette = self._marionette_weakref() + if self.marionette.session is None: + self.marionette.start_session() + self.marionette.timeout.reset() + + super(CommonTestCase, self).setUp() + + def cleanTest(self): + self._delete_session() + + def _delete_session(self): + if hasattr(self, "start_time"): + self.duration = time.time() - self.start_time + if self.marionette.session is not None: + try: + self.marionette.delete_session() + except IOError: + # Gecko has crashed? + pass + self.marionette = None + + def _fix_test_path(self, path): + """Normalize a logged test path from the test package.""" + test_path_prefixes = [ + "tests{}".format(os.path.sep), + ] + + path = os.path.relpath(path) + for prefix in test_path_prefixes: + if path.startswith(prefix): + path = path[len(prefix) :] + break + path = path.replace("\\", "/") + + return path + + +class MarionetteTestCase(CommonTestCase): + + match_re = re.compile(r"test_(.*)\.py$") + + def __init__( + self, marionette_weakref, fixtures, methodName="runTest", filepath="", **kwargs + ): + self.filepath = filepath + self.testvars = kwargs.pop("testvars", None) + + super(MarionetteTestCase, self).__init__( + methodName, + marionette_weakref=marionette_weakref, + fixtures=fixtures, + **kwargs + ) + + @classmethod + def add_tests_to_suite( + cls, + mod_name, + filepath, + suite, + testloader, + marionette, + fixtures, + testvars, + **kwargs + ): + # since we use imp.load_source to load test modules, if a module + # is loaded with the same name as another one the module would just be + # reloaded. + # + # We may end up by finding too many test in a module then since + # reload() only update the module dict (so old keys are still there!) + # see https://docs.python.org/2/library/functions.html#reload + # + # we get rid of that by removing the module from sys.modules, + # so we ensure that it will be fully loaded by the + # imp.load_source call. + if mod_name in sys.modules: + del sys.modules[mod_name] + + test_mod = imp.load_source(mod_name, filepath) + + for name in dir(test_mod): + obj = getattr(test_mod, name) + if isinstance(obj, six.class_types) and issubclass(obj, unittest.TestCase): + testnames = testloader.getTestCaseNames(obj) + for testname in testnames: + suite.addTest( + obj( + weakref.ref(marionette), + fixtures, + methodName=testname, + filepath=filepath, + testvars=testvars, + **kwargs + ) + ) + + def setUp(self): + super(MarionetteTestCase, self).setUp() + self.marionette.test_name = self.test_name + + def tearDown(self): + # In the case no session is active (eg. the application was quit), start + # a new session for clean-up steps. + if not self.marionette.session: + self.marionette.start_session() + + self.marionette.test_name = None + + super(MarionetteTestCase, self).tearDown() + + def wait_for_condition(self, method, timeout=30): + timeout = float(timeout) + time.time() + while time.time() < timeout: + value = method(self.marionette) + if value: + return value + time.sleep(0.5) + else: + raise TimeoutException("wait_for_condition timed out") diff --git a/testing/marionette/harness/marionette_harness/runner/__init__.py b/testing/marionette/harness/marionette_harness/runner/__init__.py new file mode 100644 index 0000000000..bf5dfd9977 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/runner/__init__.py @@ -0,0 +1,19 @@ +# 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/. + +from __future__ import absolute_import + +from .base import ( + BaseMarionetteArguments, + BaseMarionetteTestRunner, + Marionette, + MarionetteTest, + MarionetteTestResult, + MarionetteTextTestRunner, + TestManifest, + TestResult, + TestResultCollection, +) + +from .mixins import WindowManagerMixin diff --git a/testing/marionette/harness/marionette_harness/runner/base.py b/testing/marionette/harness/marionette_harness/runner/base.py new file mode 100644 index 0000000000..926aa12c35 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/runner/base.py @@ -0,0 +1,1309 @@ +# 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/. + +from __future__ import absolute_import + +import json +import os +import random +import re +import socket +import sys +import time +import traceback +import unittest + +from argparse import ArgumentParser +from collections import defaultdict +from copy import deepcopy + +import six + +import mozinfo +import moznetwork +import mozprofile +import mozversion + +from manifestparser import TestManifest +from manifestparser.filters import tags +from marionette_driver.marionette import Marionette +from moztest.adapters.unit import StructuredTestResult, StructuredTestRunner +from moztest.results import TestResult, TestResultCollection, relevant_line + +from six import reraise, MAXSIZE + +from . import serve + + +here = os.path.abspath(os.path.dirname(__file__)) + + +def update_mozinfo(path=None): + """Walk up directories to find mozinfo.json and update the info.""" + path = path or here + dirs = set() + while path != os.path.expanduser("~"): + if path in dirs: + break + dirs.add(path) + path = os.path.split(path)[0] + + return mozinfo.find_and_update_from_json(*dirs) + + +class MarionetteTest(TestResult): + @property + def test_name(self): + if self.test_class is not None: + return "{0}.py {1}.{2}".format( + self.test_class.split(".")[0], self.test_class, self.name + ) + else: + return self.name + + +class MarionetteTestResult(StructuredTestResult, TestResultCollection): + + resultClass = MarionetteTest + + def __init__(self, *args, **kwargs): + self.marionette = kwargs.pop("marionette") + TestResultCollection.__init__(self, "MarionetteTest") + self.passed = 0 + self.testsRun = 0 + self.result_modifiers = [] # used by mixins to modify the result + StructuredTestResult.__init__(self, *args, **kwargs) + + @property + def skipped(self): + return [t for t in self if t.result == "SKIPPED"] + + @skipped.setter + def skipped(self, value): + pass + + @property + def expectedFailures(self): + return [t for t in self if t.result == "KNOWN-FAIL"] + + @expectedFailures.setter + def expectedFailures(self, value): + pass + + @property + def unexpectedSuccesses(self): + return [t for t in self if t.result == "UNEXPECTED-PASS"] + + @unexpectedSuccesses.setter + def unexpectedSuccesses(self, value): + pass + + @property + def tests_passed(self): + return [t for t in self if t.result == "PASS"] + + @property + def errors(self): + return [t for t in self if t.result == "ERROR"] + + @errors.setter + def errors(self, value): + pass + + @property + def failures(self): + return [t for t in self if t.result == "UNEXPECTED-FAIL"] + + @failures.setter + def failures(self, value): + pass + + @property + def duration(self): + if self.stop_time: + return self.stop_time - self.start_time + else: + return 0 + + def add_test_result( + self, + test, + result_expected="PASS", + result_actual="PASS", + output="", + context=None, + **kwargs + ): + def get_class(test): + return test.__class__.__module__ + "." + test.__class__.__name__ + + name = str(test).split()[0] + test_class = get_class(test) + if hasattr(test, "jsFile"): + name = os.path.basename(test.jsFile) + test_class = None + + t = self.resultClass( + name=name, + test_class=test_class, + time_start=test.start_time, + result_expected=result_expected, + context=context, + **kwargs + ) + # call any registered result modifiers + for modifier in self.result_modifiers: + result_expected, result_actual, output, context = modifier( + t, result_expected, result_actual, output, context + ) + t.finish( + result_actual, + time_end=time.time() if test.start_time else 0, + reason=relevant_line(output), + output=output, + ) + self.append(t) + + def addError(self, test, err): + self.add_test_result( + test, output=self._exc_info_to_string(err, test), result_actual="ERROR" + ) + super(MarionetteTestResult, self).addError(test, err) + + def addFailure(self, test, err): + self.add_test_result( + test, + output=self._exc_info_to_string(err, test), + result_actual="UNEXPECTED-FAIL", + ) + super(MarionetteTestResult, self).addFailure(test, err) + + def addSuccess(self, test): + self.passed += 1 + self.add_test_result(test, result_actual="PASS") + super(MarionetteTestResult, self).addSuccess(test) + + def addExpectedFailure(self, test, err): + """Called when an expected failure/error occured.""" + self.add_test_result( + test, output=self._exc_info_to_string(err, test), result_actual="KNOWN-FAIL" + ) + super(MarionetteTestResult, self).addExpectedFailure(test, err) + + def addUnexpectedSuccess(self, test): + """Called when a test was expected to fail, but succeed.""" + self.add_test_result(test, result_actual="UNEXPECTED-PASS") + super(MarionetteTestResult, self).addUnexpectedSuccess(test) + + def addSkip(self, test, reason): + self.add_test_result(test, output=reason, result_actual="SKIPPED") + super(MarionetteTestResult, self).addSkip(test, reason) + + def getInfo(self, test): + return test.test_name + + def getDescription(self, test): + doc_first_line = test.shortDescription() + if self.descriptions and doc_first_line: + return "\n".join((str(test), doc_first_line)) + else: + desc = str(test) + return desc + + def printLogs(self, test): + for testcase in test._tests: + if hasattr(testcase, "loglines") and testcase.loglines: + # Don't dump loglines to the console if they only contain + # TEST-START and TEST-END. + skip_log = True + for line in testcase.loglines: + str_line = " ".join(line) + if "TEST-END" not in str_line and "TEST-START" not in str_line: + skip_log = False + break + if skip_log: + return + self.logger.info("START LOG:") + for line in testcase.loglines: + self.logger.info(" ".join(line).encode("ascii", "replace")) + self.logger.info("END LOG:") + + def stopTest(self, *args, **kwargs): + unittest._TextTestResult.stopTest(self, *args, **kwargs) + if self.marionette.check_for_crash(): + # this tells unittest.TestSuite not to continue running tests + self.shouldStop = True + test = next((a for a in args if isinstance(a, unittest.TestCase)), None) + if test: + self.addError(test, sys.exc_info()) + + +class MarionetteTextTestRunner(StructuredTestRunner): + + resultclass = MarionetteTestResult + + def __init__(self, **kwargs): + self.marionette = kwargs.pop("marionette") + self.capabilities = kwargs.pop("capabilities") + + StructuredTestRunner.__init__(self, **kwargs) + + def _makeResult(self): + return self.resultclass( + self.stream, + self.descriptions, + self.verbosity, + marionette=self.marionette, + logger=self.logger, + result_callbacks=self.result_callbacks, + ) + + def run(self, test): + result = super(MarionetteTextTestRunner, self).run(test) + result.printLogs(test) + return result + + +class BaseMarionetteArguments(ArgumentParser): + def __init__(self, **kwargs): + ArgumentParser.__init__(self, **kwargs) + + def dir_path(path): + path = os.path.abspath(os.path.expanduser(path)) + if not os.access(path, os.F_OK): + os.makedirs(path) + return path + + self.argument_containers = [] + self.add_argument( + "tests", + nargs="*", + default=[], + help="Tests to run. " + "One or more paths to test files (Python or JS), " + "manifest files (.ini) or directories. " + "When a directory is specified, " + "all test files in the directory will be run.", + ) + self.add_argument( + "--binary", + help="path to gecko executable to launch before running the test", + ) + self.add_argument( + "--address", help="host:port of running Gecko instance to connect to" + ) + self.add_argument( + "--emulator", + action="store_true", + help="If no --address is given, then the harness will launch an " + "emulator. (See Remote options group.) " + "If --address is given, then the harness assumes you are " + "running an emulator already, and will launch gecko app " + "on that emulator.", + ) + self.add_argument( + "--app", help="application to use. see marionette_driver.geckoinstance" + ) + self.add_argument( + "--app-arg", + dest="app_args", + action="append", + default=[], + help="specify a command line argument to be passed onto the application", + ) + self.add_argument( + "--profile", + help="profile to use when launching the gecko process. If not passed, " + "then a profile will be constructed and used", + type=dir_path, + ) + self.add_argument( + "--setpref", + action="append", + metavar="PREF=VALUE", + dest="prefs_args", + help="set a browser preference; repeat for multiple preferences.", + ) + self.add_argument( + "--preferences", + action="append", + dest="prefs_files", + help="read preferences from a JSON or INI file. For INI, use " + "'file.ini:section' to specify a particular section.", + ) + self.add_argument( + "--addon", + action="append", + dest="addons", + help="addon to install; repeat for multiple addons.", + ) + self.add_argument( + "--repeat", type=int, help="number of times to repeat the test(s)" + ) + self.add_argument( + "--run-until-failure", + action="store_true", + help="Run tests repeatedly and stop on the first time a test fails. " + "Default cap is 30 runs, which can be overwritten " + "with the --repeat parameter.", + ) + self.add_argument( + "--testvars", + action="append", + help="path to a json file with any test data required", + ) + self.add_argument( + "--symbols-path", + help="absolute path to directory containing breakpad symbols, or the " + "url of a zip file containing symbols", + ) + self.add_argument( + "--socket-timeout", + type=float, + default=Marionette.DEFAULT_SOCKET_TIMEOUT, + help="Set the global timeout for marionette socket operations." + " Default: %(default)ss.", + ) + self.add_argument( + "--startup-timeout", + type=int, + default=Marionette.DEFAULT_STARTUP_TIMEOUT, + help="the max number of seconds to wait for a Marionette connection " + "after launching a binary. Default: %(default)ss.", + ) + self.add_argument( + "--shuffle", + action="store_true", + default=False, + help="run tests in a random order", + ) + self.add_argument( + "--shuffle-seed", + type=int, + default=random.randint(0, MAXSIZE), + help="Use given seed to shuffle tests", + ) + self.add_argument( + "--total-chunks", + type=int, + help="how many chunks to split the tests up into", + ) + self.add_argument("--this-chunk", type=int, help="which chunk to run") + self.add_argument( + "--server-root", + help="url to a webserver or path to a document root from which content " + "resources are served (default: {}).".format( + os.path.join(os.path.dirname(here), "www") + ), + ) + self.add_argument( + "--gecko-log", + help="Define the path to store log file. If the path is" + " a directory, the real log file will be created" + " given the format gecko-(timestamp).log. If it is" + " a file, if will be used directly. '-' may be passed" + " to write to stdout. Default: './gecko.log'", + ) + self.add_argument( + "--logger-name", + default="Marionette-based Tests", + help="Define the name to associate with the logger used", + ) + self.add_argument( + "--jsdebugger", + action="store_true", + default=False, + help="Enable the jsdebugger for marionette javascript.", + ) + self.add_argument( + "--pydebugger", + help="Enable python post-mortem debugger when a test fails." + " Pass in the debugger you want to use, eg pdb or ipdb.", + ) + self.add_argument( + "--disable-actors", + action="store_true", + dest="disable_actors", + default=False, + help="Disable the usage of JSWindowActors in Marionette.", + ) + self.add_argument( + "--enable-fission", + action="store_true", + dest="enable_fission", + default=False, + help="Enable Fission (site isolation) in Gecko.", + ) + self.add_argument( + "--enable-webrender", + action="store_true", + dest="enable_webrender", + default=False, + help="Enable the WebRender compositor in Gecko.", + ) + self.add_argument( + "-z", + "--headless", + action="store_true", + dest="headless", + default=os.environ.get("MOZ_HEADLESS", False), + help="Run tests in headless mode.", + ) + self.add_argument( + "--tag", + action="append", + dest="test_tags", + default=None, + help="Filter out tests that don't have the given tag. Can be " + "used multiple times in which case the test must contain " + "at least one of the given tags.", + ) + self.add_argument( + "--workspace", + action="store", + default=None, + help="Path to directory for Marionette output. " + "(Default: .) (Default profile dest: TMP)", + type=dir_path, + ) + self.add_argument( + "-v", + "--verbose", + action="count", + help="Increase verbosity to include debug messages with -v, " + "and trace messages with -vv.", + ) + self.register_argument_container(RemoteMarionetteArguments()) + + def register_argument_container(self, container): + group = self.add_argument_group(container.name) + + for cli, kwargs in container.args: + group.add_argument(*cli, **kwargs) + + self.argument_containers.append(container) + + def parse_known_args(self, args=None, namespace=None): + args, remainder = ArgumentParser.parse_known_args(self, args, namespace) + for container in self.argument_containers: + if hasattr(container, "parse_args_handler"): + container.parse_args_handler(args) + return (args, remainder) + + def _get_preferences(self, prefs_files, prefs_args): + """Return user defined profile preferences as a dict.""" + # object that will hold the preferences + prefs = mozprofile.prefs.Preferences() + + # add preferences files + if prefs_files: + for prefs_file in prefs_files: + prefs.add_file(prefs_file) + + separator = "=" + cli_prefs = [] + if prefs_args: + misformatted = [] + for pref in prefs_args: + if separator not in pref: + misformatted.append(pref) + else: + cli_prefs.append(pref.split(separator, 1)) + if misformatted: + self._print_message( + "Warning: Ignoring preferences not in key{}value format: {}\n".format( + separator, ", ".join(misformatted) + ) + ) + # string preferences + prefs.add(cli_prefs, cast=True) + + return dict(prefs()) + + def verify_usage(self, args): + if not args.tests: + self.error( + "You must specify one or more test files, manifests, or directories." + ) + + missing_tests = [path for path in args.tests if not os.path.exists(path)] + if missing_tests: + self.error( + "Test file(s) not found: " + " ".join([path for path in missing_tests]) + ) + + if not args.address and not args.binary and not args.emulator: + self.error("You must specify --binary, or --address, or --emulator") + + if args.repeat is not None and args.repeat < 0: + self.error("The value of --repeat has to be equal or greater than 0.") + + if args.total_chunks is not None and args.this_chunk is None: + self.error("You must specify which chunk to run.") + + if args.this_chunk is not None and args.total_chunks is None: + self.error("You must specify how many chunks to split the tests into.") + + if args.total_chunks is not None: + if not 1 < args.total_chunks: + self.error("Total chunks must be greater than 1.") + if not 1 <= args.this_chunk <= args.total_chunks: + self.error( + "Chunk to run must be between 1 and {}.".format(args.total_chunks) + ) + + if args.jsdebugger: + args.app_args.append("-jsdebugger") + args.socket_timeout = None + + args.prefs = self._get_preferences(args.prefs_files, args.prefs_args) + + for container in self.argument_containers: + if hasattr(container, "verify_usage_handler"): + container.verify_usage_handler(args) + + return args + + +class RemoteMarionetteArguments(object): + name = "Remote (Emulator/Device)" + args = [ + [ + ["--emulator-binary"], + { + "help": "Path to emulator binary. By default mozrunner uses `which emulator`", + "dest": "emulator_bin", + }, + ], + [ + ["--adb"], + { + "help": "Path to the adb. By default mozrunner uses `which adb`", + "dest": "adb_path", + }, + ], + [ + ["--avd"], + { + "help": ( + "Name of an AVD available in your environment." + "See mozrunner.FennecEmulatorRunner" + ), + }, + ], + [ + ["--avd-home"], + { + "help": "Path to avd parent directory", + }, + ], + [ + ["--device"], + { + "help": ( + "Serial ID to connect to as seen in `adb devices`," + "e.g emulator-5444" + ), + "dest": "device_serial", + }, + ], + [ + ["--package"], + { + "help": "Name of Android package, e.g. org.mozilla.fennec", + "dest": "package_name", + }, + ], + ] + + +class Fixtures(object): + def where_is(self, uri, on="http"): + return serve.where_is(uri, on) + + +class BaseMarionetteTestRunner(object): + + textrunnerclass = MarionetteTextTestRunner + driverclass = Marionette + + def __init__( + self, + address=None, + app=None, + app_args=None, + binary=None, + profile=None, + logger=None, + logdir=None, + repeat=None, + run_until_failure=None, + testvars=None, + symbols_path=None, + shuffle=False, + shuffle_seed=random.randint(0, MAXSIZE), + this_chunk=1, + total_chunks=1, + server_root=None, + gecko_log=None, + result_callbacks=None, + prefs=None, + test_tags=None, + socket_timeout=None, + startup_timeout=None, + addons=None, + workspace=None, + verbose=0, + emulator=False, + headless=False, + disable_actors=False, + enable_fission=False, + enable_webrender=False, + **kwargs + ): + self._appName = None + self._capabilities = None + self._filename_pattern = None + self._version_info = {} + + self.fixture_servers = {} + self.fixtures = Fixtures() + self.extra_kwargs = kwargs + self.test_kwargs = deepcopy(kwargs) + self.address = address + self.app = app + self.app_args = app_args or [] + self.bin = binary + self.emulator = emulator + self.profile = profile + self.addons = addons + self.logger = logger + self.marionette = None + self.logdir = logdir + self.repeat = repeat or 0 + self.run_until_failure = run_until_failure or False + self.symbols_path = symbols_path + self.socket_timeout = socket_timeout + self.startup_timeout = startup_timeout + self.shuffle = shuffle + self.shuffle_seed = shuffle_seed + self.server_root = server_root + self.this_chunk = this_chunk + self.total_chunks = total_chunks + self.mixin_run_tests = [] + self.manifest_skipped_tests = [] + self.tests = [] + self.result_callbacks = result_callbacks or [] + self.prefs = prefs or {} + self.test_tags = test_tags + self.workspace = workspace + # If no workspace is set, default location for gecko.log is . + # and default location for profile is TMP + self.workspace_path = workspace or os.getcwd() + self.verbose = verbose + self.headless = headless + self.enable_webrender = enable_webrender + + self.disable_actors = disable_actors + if self.disable_actors: + self.prefs.update( + { + "marionette.actors.enabled": False, + } + ) + + self.enable_fission = enable_fission + if self.enable_fission: + self.prefs.update( + { + "fission.autostart": True, + } + ) + + # If no repeat has been set, default to 30 extra runs + if self.run_until_failure and repeat is None: + self.repeat = 30 + + def gather_debug(test, status): + # No screenshots and page source for skipped tests + if status == "SKIP": + return + + rv = {} + marionette = test._marionette_weakref() + + # In the event we're gathering debug without starting a session, + # skip marionette commands + if marionette.session is not None: + try: + with marionette.using_context(marionette.CONTEXT_CHROME): + rv["screenshot"] = marionette.screenshot() + with marionette.using_context(marionette.CONTEXT_CONTENT): + rv["source"] = marionette.page_source + except Exception as exc: + self.logger.warning( + "Failed to gather test failure debug: {}".format(exc) + ) + return rv + + self.result_callbacks.append(gather_debug) + + # testvars are set up in self.testvars property + self._testvars = None + self.testvars_paths = testvars + + self.test_handlers = [] + + self.reset_test_stats() + + self.logger.info( + "Using workspace for temporary data: " '"{}"'.format(self.workspace_path) + ) + + if not gecko_log: + self.gecko_log = os.path.join(self.workspace_path or "", "gecko.log") + else: + self.gecko_log = gecko_log + + self.results = [] + + @property + def filename_pattern(self): + if self._filename_pattern is None: + self._filename_pattern = re.compile("^test(((_.+?)+?\.((py))))$") + + return self._filename_pattern + + @property + def testvars(self): + if self._testvars is not None: + return self._testvars + + self._testvars = {} + + def update(d, u): + """Update a dictionary that may contain nested dictionaries.""" + for k, v in six.iteritems(u): + o = d.get(k, {}) + if isinstance(v, dict) and isinstance(o, dict): + d[k] = update(d.get(k, {}), v) + else: + d[k] = u[k] + return d + + json_testvars = self._load_testvars() + for j in json_testvars: + self._testvars = update(self._testvars, j) + return self._testvars + + def _load_testvars(self): + data = [] + if self.testvars_paths is not None: + for path in list(self.testvars_paths): + path = os.path.abspath(os.path.expanduser(path)) + if not os.path.exists(path): + raise IOError("--testvars file {} does not exist".format(path)) + try: + with open(path) as f: + data.append(json.loads(f.read())) + except ValueError as e: + msg = "JSON file ({0}) is not properly formatted: {1}" + reraise( + ValueError, + ValueError(msg.format(os.path.abspath(path), e)), + sys.exc_info()[2], + ) + return data + + @property + def capabilities(self): + if self._capabilities: + return self._capabilities + + self.marionette.start_session() + self._capabilities = self.marionette.session_capabilities + self.marionette.delete_session() + return self._capabilities + + @property + def appName(self): + if self._appName: + return self._appName + + self._appName = self.capabilities.get("browserName") + return self._appName + + @property + def bin(self): + return self._bin + + @bin.setter + def bin(self, path): + """Set binary and reset parts of runner accordingly. + Intended use: to change binary between calls to run_tests + """ + self._bin = path + self.tests = [] + self.cleanup() + + @property + def version_info(self): + if not self._version_info: + try: + # TODO: Get version_info in Fennec case + self._version_info = mozversion.get_version(binary=self.bin) + except Exception: + self.logger.warning( + "Failed to retrieve version information for {}".format(self.bin) + ) + return self._version_info + + def reset_test_stats(self): + self.passed = 0 + self.failed = 0 + self.crashed = 0 + self.unexpected_successes = 0 + self.todo = 0 + self.skipped = 0 + self.failures = [] + + def _build_kwargs(self): + if self.logdir and not os.access(self.logdir, os.F_OK): + os.mkdir(self.logdir) + + kwargs = { + "socket_timeout": self.socket_timeout, + "prefs": self.prefs, + "startup_timeout": self.startup_timeout, + "verbose": self.verbose, + "symbols_path": self.symbols_path, + "enable_webrender": self.enable_webrender, + } + if self.bin or self.emulator: + kwargs.update( + { + "host": "127.0.0.1", + "port": 2828, + "app": self.app, + "app_args": self.app_args, + "profile": self.profile, + "addons": self.addons, + "gecko_log": self.gecko_log, + # ensure Marionette class takes care of starting gecko instance + "bin": True, + } + ) + + if self.bin: + kwargs.update( + { + "bin": self.bin, + } + ) + + if self.emulator: + kwargs.update( + { + "avd_home": self.extra_kwargs.get("avd_home"), + "adb_path": self.extra_kwargs.get("adb_path"), + "emulator_binary": self.extra_kwargs.get("emulator_bin"), + "avd": self.extra_kwargs.get("avd"), + "package_name": self.extra_kwargs.get("package_name"), + } + ) + + if self.address: + host, port = self.address.split(":") + kwargs.update( + { + "host": host, + "port": int(port), + } + ) + if self.emulator: + kwargs.update( + { + "connect_to_running_emulator": True, + } + ) + if not self.bin and not self.emulator: + try: + # Establish a socket connection so we can vertify the data come back + connection = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + connection.connect((host, int(port))) + connection.close() + except Exception as e: + exc_cls, _, tb = sys.exc_info() + msg = "Connection attempt to {0}:{1} failed with error: {2}" + reraise(exc_cls, exc_cls(msg.format(host, port, e)), tb) + if self.workspace: + kwargs["workspace"] = self.workspace_path + if self.headless: + kwargs["headless"] = True + + return kwargs + + def record_crash(self): + crash = True + try: + crash = self.marionette.check_for_crash() + self.crashed += int(crash) + except Exception: + traceback.print_exc() + return crash + + def _initialize_test_run(self, tests): + assert len(tests) > 0 + assert len(self.test_handlers) > 0 + self.reset_test_stats() + + def _add_tests(self, tests): + for test in tests: + self.add_test(test) + + invalid_tests = [ + t["filepath"] + for t in self.tests + if not self._is_filename_valid(t["filepath"]) + ] + if invalid_tests: + raise Exception( + "Test file names must be of the form " + "'test_something.py'." + " Invalid test names:\n {}".format("\n ".join(invalid_tests)) + ) + + def _is_filename_valid(self, filename): + filename = os.path.basename(filename) + return self.filename_pattern.match(filename) + + def _fix_test_path(self, path): + """Normalize a logged test path from the test package.""" + test_path_prefixes = [ + "tests{}".format(os.path.sep), + ] + + path = os.path.relpath(path) + for prefix in test_path_prefixes: + if path.startswith(prefix): + path = path[len(prefix) :] + break + path = path.replace("\\", "/") + + return path + + def _log_skipped_tests(self): + for test in self.manifest_skipped_tests: + rel_path = None + if os.path.exists(test["path"]): + rel_path = self._fix_test_path(test["path"]) + + self.logger.test_start(rel_path) + self.logger.test_end(rel_path, "SKIP", message=test["disabled"]) + self.todo += 1 + + def run_tests(self, tests): + start_time = time.time() + self._initialize_test_run(tests) + + if self.marionette is None: + self.marionette = self.driverclass(**self._build_kwargs()) + self.logger.info("Profile path is %s" % self.marionette.profile_path) + + if len(self.fixture_servers) == 0 or any( + not server.is_alive for _, server in self.fixture_servers + ): + self.logger.info("Starting fixture servers") + self.fixture_servers = self.start_fixture_servers() + for url in serve.iter_url(self.fixture_servers): + self.logger.info("Fixture server listening on %s" % url) + + # backwards compatibility + self.marionette.baseurl = serve.where_is("/") + + self._add_tests(tests) + + device_info = None + if self.marionette.instance and self.emulator: + try: + device_info = self.marionette.instance.runner.device.device.get_info() + except Exception: + self.logger.warning("Could not get device info", exc_info=True) + + tests_by_group = defaultdict(list) + for test in self.tests: + group = self._fix_test_path(test["group"]) + filepath = self._fix_test_path(test["filepath"]) + tests_by_group[group].append(filepath) + + self.logger.suite_start( + tests_by_group, + name="marionette-test", + version_info=self.version_info, + device_info=device_info, + ) + + if self.shuffle: + self.logger.info("Using shuffle seed: %d" % self.shuffle_seed) + + self._log_skipped_tests() + + interrupted = None + try: + repeat_index = 0 + while repeat_index <= self.repeat: + if repeat_index > 0: + self.logger.info("\nREPEAT {}\n-------".format(repeat_index)) + self.run_test_sets() + if self.run_until_failure and self.failed > 0: + break + + repeat_index += 1 + + except KeyboardInterrupt: + # in case of KeyboardInterrupt during the test execution + # we want to display current test results. + # so we keep the exception to raise it later. + interrupted = sys.exc_info() + except Exception: + # For any other exception we return immediately and have to + # cleanup running processes + self.cleanup() + raise + + try: + self._print_summary(tests) + self.record_crash() + self.elapsedtime = time.time() - start_time + + for run_tests in self.mixin_run_tests: + run_tests(tests) + + self.logger.suite_end() + except Exception: + # raise only the exception if we were not interrupted + if not interrupted: + raise + finally: + self.cleanup() + + # reraise previous interruption now + if interrupted: + reraise(interrupted[0], interrupted[1], interrupted[2]) + + def _print_summary(self, tests): + self.logger.info("\nSUMMARY\n-------") + self.logger.info("passed: {}".format(self.passed)) + if self.unexpected_successes == 0: + self.logger.info("failed: {}".format(self.failed)) + else: + self.logger.info( + "failed: {0} (unexpected sucesses: {1})".format( + self.failed, self.unexpected_successes + ) + ) + if self.skipped == 0: + self.logger.info("todo: {}".format(self.todo)) + else: + self.logger.info("todo: {0} (skipped: {1})".format(self.todo, self.skipped)) + + if self.failed > 0: + self.logger.info("\nFAILED TESTS\n-------") + for failed_test in self.failures: + self.logger.info("{}".format(failed_test[0])) + + def start_fixture_servers(self): + root = self.server_root or os.path.join(os.path.dirname(here), "www") + if self.appName == "fennec": + return serve.start(root, host=moznetwork.get_ip()) + else: + return serve.start(root) + + def add_test(self, test, expected="pass", group="default"): + filepath = os.path.abspath(test) + + if os.path.isdir(filepath): + for root, dirs, files in os.walk(filepath): + for filename in files: + if filename.endswith(".ini"): + msg_tmpl = ( + "Ignoring manifest '{0}'; running all tests in '{1}'." + " See --help for details." + ) + relpath = os.path.relpath( + os.path.join(root, filename), filepath + ) + self.logger.warning(msg_tmpl.format(relpath, filepath)) + elif self._is_filename_valid(filename): + test_file = os.path.join(root, filename) + self.add_test(test_file) + return + + file_ext = os.path.splitext(os.path.split(filepath)[-1])[1] + + if file_ext == ".ini": + group = filepath + + manifest = TestManifest() + manifest.read(filepath) + + json_path = update_mozinfo(filepath) + mozinfo.update( + { + "appname": self.appName, + "manage_instance": self.marionette.instance is not None, + "headless": self.headless, + "actors": not self.disable_actors, + "webrender": self.enable_webrender, + } + ) + self.logger.info("mozinfo updated from: {}".format(json_path)) + self.logger.info("mozinfo is: {}".format(mozinfo.info)) + + filters = [] + if self.test_tags: + filters.append(tags(self.test_tags)) + + manifest_tests = manifest.active_tests( + exists=False, disabled=True, filters=filters, **mozinfo.info + ) + if len(manifest_tests) == 0: + self.logger.error( + "No tests to run using specified " + "combination of filters: {}".format(manifest.fmt_filters()) + ) + + target_tests = [] + for test in manifest_tests: + if test.get("disabled"): + self.manifest_skipped_tests.append(test) + else: + target_tests.append(test) + + for i in target_tests: + if not os.path.exists(i["path"]): + raise IOError("test file: {} does not exist".format(i["path"])) + + self.add_test(i["path"], i["expected"], group=group) + return + + self.tests.append({"filepath": filepath, "expected": expected, "group": group}) + + def run_test(self, filepath, expected): + testloader = unittest.TestLoader() + suite = unittest.TestSuite() + self.test_kwargs["expected"] = expected + mod_name = os.path.splitext(os.path.split(filepath)[-1])[0] + for handler in self.test_handlers: + if handler.match(os.path.basename(filepath)): + handler.add_tests_to_suite( + mod_name, + filepath, + suite, + testloader, + self.marionette, + self.fixtures, + self.testvars, + **self.test_kwargs + ) + break + + if suite.countTestCases(): + runner = self.textrunnerclass( + logger=self.logger, + marionette=self.marionette, + capabilities=self.capabilities, + result_callbacks=self.result_callbacks, + ) + + results = runner.run(suite) + self.results.append(results) + + self.failed += len(results.failures) + len(results.errors) + if hasattr(results, "skipped"): + self.skipped += len(results.skipped) + self.todo += len(results.skipped) + self.passed += results.passed + for failure in results.failures + results.errors: + self.failures.append( + (results.getInfo(failure), failure.output, "TEST-UNEXPECTED-FAIL") + ) + if hasattr(results, "unexpectedSuccesses"): + self.failed += len(results.unexpectedSuccesses) + self.unexpected_successes += len(results.unexpectedSuccesses) + for failure in results.unexpectedSuccesses: + self.failures.append( + ( + results.getInfo(failure), + failure.output, + "TEST-UNEXPECTED-PASS", + ) + ) + if hasattr(results, "expectedFailures"): + self.todo += len(results.expectedFailures) + + self.mixin_run_tests = [] + for result in self.results: + result.result_modifiers = [] + + def run_test_set(self, tests): + if self.shuffle: + random.seed(self.shuffle_seed) + random.shuffle(tests) + + for test in tests: + self.run_test(test["filepath"], test["expected"]) + if self.record_crash(): + break + + def run_test_sets(self): + if len(self.tests) < 1: + raise Exception("There are no tests to run.") + elif self.total_chunks is not None and self.total_chunks > len(self.tests): + raise ValueError( + "Total number of chunks must be between 1 and {}.".format( + len(self.tests) + ) + ) + if self.total_chunks is not None and self.total_chunks > 1: + chunks = [[] for i in range(self.total_chunks)] + for i, test in enumerate(self.tests): + target_chunk = i % self.total_chunks + chunks[target_chunk].append(test) + + self.logger.info( + "Running chunk {0} of {1} ({2} tests selected from a " + "total of {3})".format( + self.this_chunk, + self.total_chunks, + len(chunks[self.this_chunk - 1]), + len(self.tests), + ) + ) + self.tests = chunks[self.this_chunk - 1] + + self.run_test_set(self.tests) + + def cleanup(self): + for proc in serve.iter_proc(self.fixture_servers): + proc.stop() + proc.kill() + self.fixture_servers = {} + + if hasattr(self, "marionette") and self.marionette: + if self.marionette.instance is not None: + if self.marionette.instance.runner.is_running(): + # Force a clean shutdown of the application process first if + # it is still running. If that fails, kill the process. + # Therefore a new session needs to be started. + self.marionette.start_session() + self.marionette.quit(in_app=True) + + self.marionette.instance.close(clean=True) + self.marionette.instance = None + + self.marionette.cleanup() + self.marionette = None + + __del__ = cleanup diff --git a/testing/marionette/harness/marionette_harness/runner/httpd.py b/testing/marionette/harness/marionette_harness/runner/httpd.py new file mode 100755 index 0000000000..aab83a23e7 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/runner/httpd.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python + +# 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/. + +"""Specialisation of wptserver.server.WebTestHttpd for testing +Marionette. + +""" + +from __future__ import absolute_import, print_function + +import argparse +import os +import select +import sys +import time + +from wptserve import handlers, request, routes as default_routes, server + +from six.moves.urllib import parse as urlparse + + +root = os.path.abspath(os.path.dirname(os.path.dirname(__file__))) +default_doc_root = os.path.join(root, "www") +default_ssl_cert = os.path.join(root, "certificates", "test.cert") +default_ssl_key = os.path.join(root, "certificates", "test.key") + + +@handlers.handler +def http_auth_handler(req, response): + # Allow the test to specify the username and password + params = dict(urlparse.parse_qsl(req.url_parts.query)) + username = params.get("username", "guest") + password = params.get("password", "guest") + + auth = request.Authentication(req.headers) + content = """<!doctype html> +<title>HTTP Authentication</title> +<p id="status">{}</p>""" + + if auth.username == username and auth.password == password: + response.status = 200 + response.content = content.format("success") + + else: + response.status = 401 + response.headers.set("WWW-Authenticate", 'Basic realm="secret"') + response.content = content.format("restricted") + + +@handlers.handler +def upload_handler(request, response): + return 200, [], [request.headers.get("Content-Type")] or [] + + +@handlers.handler +def slow_loading_handler(request, response): + # Allow the test specify the delay for delivering the content + params = dict(urlparse.parse_qsl(request.url_parts.query)) + delay = int(params.get("delay", 5)) + time.sleep(delay) + + # Do not allow the page to be cached to circumvent the bfcache of the browser + response.headers.set("Cache-Control", "no-cache, no-store") + response.content = """<!doctype html> +<meta charset="UTF-8"> +<title>Slow page loading</title> + +<p>Delay: <span id="delay">{}</span></p> +""".format( + delay + ) + + +class NotAliveError(Exception): + """Occurs when attempting to run a function that requires the HTTPD + to have been started, and it has not. + + """ + + pass + + +class FixtureServer(object): + def __init__( + self, + doc_root, + url="http://127.0.0.1:0", + use_ssl=False, + ssl_cert=None, + ssl_key=None, + ): + if not os.path.isdir(doc_root): + raise ValueError("Server root is not a directory: %s" % doc_root) + + url = urlparse.urlparse(url) + if url.scheme is None: + raise ValueError("Server scheme not provided") + + scheme, host, port = url.scheme, url.hostname, url.port + if host is None: + host = "127.0.0.1" + if port is None: + port = 0 + + routes = [ + ("POST", "/file_upload", upload_handler), + ("GET", "/http_auth", http_auth_handler), + ("GET", "/slow", slow_loading_handler), + ] + routes.extend(default_routes.routes) + + self._httpd = server.WebTestHttpd( + host=host, + port=port, + bind_address=True, + doc_root=doc_root, + routes=routes, + use_ssl=True if scheme == "https" else False, + certificate=ssl_cert, + key_file=ssl_key, + ) + + def start(self, block=False): + if self.is_alive: + return + self._httpd.start(block=block) + + def wait(self): + if not self.is_alive: + return + try: + select.select([], [], []) + except KeyboardInterrupt: + self.stop() + + def stop(self): + if not self.is_alive: + return + self._httpd.stop() + + def get_url(self, path): + if not self.is_alive: + raise NotAliveError() + return self._httpd.get_url(path) + + @property + def doc_root(self): + return self._httpd.router.doc_root + + @property + def router(self): + return self._httpd.router + + @property + def routes(self): + return self._httpd.router.routes + + @property + def is_alive(self): + return self._httpd.started + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="Specialised HTTP server for testing Marionette." + ) + parser.add_argument( + "url", + help=""" +service address including scheme, hostname, port, and prefix for document root, +e.g. \"https://0.0.0.0:0/base/\"""", + ) + parser.add_argument( + "-r", + dest="doc_root", + default=default_doc_root, + help="path to document root (default %(default)s)", + ) + parser.add_argument( + "-c", + dest="ssl_cert", + default=default_ssl_cert, + help="path to SSL certificate (default %(default)s)", + ) + parser.add_argument( + "-k", + dest="ssl_key", + default=default_ssl_key, + help="path to SSL certificate key (default %(default)s)", + ) + args = parser.parse_args() + + httpd = FixtureServer( + args.doc_root, args.url, ssl_cert=args.ssl_cert, ssl_key=args.ssl_key + ) + httpd.start() + print( + "{0}: started fixture server on {1}".format(sys.argv[0], httpd.get_url("/")), + file=sys.stderr, + ) + httpd.wait() diff --git a/testing/marionette/harness/marionette_harness/runner/mixins/__init__.py b/testing/marionette/harness/marionette_harness/runner/mixins/__init__.py new file mode 100644 index 0000000000..d6a97bb742 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/runner/mixins/__init__.py @@ -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/. + +from __future__ import absolute_import + +from .window_manager import WindowManagerMixin diff --git a/testing/marionette/harness/marionette_harness/runner/mixins/window_manager.py b/testing/marionette/harness/marionette_harness/runner/mixins/window_manager.py new file mode 100644 index 0000000000..d90ce122ce --- /dev/null +++ b/testing/marionette/harness/marionette_harness/runner/mixins/window_manager.py @@ -0,0 +1,211 @@ +# 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/. + +from __future__ import absolute_import + +import sys + +from marionette_driver import Wait +from six import reraise + + +class WindowManagerMixin(object): + def setUp(self): + super(WindowManagerMixin, self).setUp() + + self.start_window = self.marionette.current_chrome_window_handle + self.start_windows = self.marionette.chrome_window_handles + + self.start_tab = self.marionette.current_window_handle + self.start_tabs = self.marionette.window_handles + + def tearDown(self): + if len(self.marionette.chrome_window_handles) > len(self.start_windows): + raise Exception("Not all windows as opened by the test have been closed") + + if len(self.marionette.window_handles) > len(self.start_tabs): + raise Exception("Not all tabs as opened by the test have been closed") + + super(WindowManagerMixin, self).tearDown() + + def close_all_tabs(self): + current_window_handles = self.marionette.window_handles + + # If the start tab is not present anymore, use the next one of the list + if self.start_tab not in current_window_handles: + self.start_tab = current_window_handles[0] + + current_window_handles.remove(self.start_tab) + for handle in current_window_handles: + self.marionette.switch_to_window(handle) + self.marionette.close() + + self.marionette.switch_to_window(self.start_tab) + + def close_all_windows(self): + current_chrome_window_handles = self.marionette.chrome_window_handles + + # If the start window is not present anymore, use the next one of the list + if self.start_window not in current_chrome_window_handles: + self.start_window = current_chrome_window_handles[0] + current_chrome_window_handles.remove(self.start_window) + + for handle in current_chrome_window_handles: + self.marionette.switch_to_window(handle) + self.marionette.close_chrome_window() + + self.marionette.switch_to_window(self.start_window) + + def open_tab(self, callback=None, focus=False): + current_tabs = self.marionette.window_handles + + try: + if callable(callback): + callback() + else: + result = self.marionette.open(type="tab", focus=focus) + if result["type"] != "tab": + raise Exception( + "Newly opened browsing context is of type {} and not tab.".format( + result["type"] + ) + ) + except Exception: + exc_cls, exc, tb = sys.exc_info() + reraise( + exc_cls, + exc_cls("Failed to trigger opening a new tab: {}".format(exc)), + tb, + ) + else: + Wait(self.marionette).until( + lambda mn: len(mn.window_handles) == len(current_tabs) + 1, + message="No new tab has been opened", + ) + + [new_tab] = list(set(self.marionette.window_handles) - set(current_tabs)) + + return new_tab + + def open_window(self, callback=None, focus=False, private=False): + current_windows = self.marionette.chrome_window_handles + current_tabs = self.marionette.window_handles + + def loaded(handle): + with self.marionette.using_context("chrome"): + return self.marionette.execute_script( + """ + Components.utils.import("resource://gre/modules/Services.jsm"); + + const win = BrowsingContext.get(Number(arguments[0])).window; + return win.document.readyState == "complete"; + """, + script_args=[handle], + ) + + try: + if callable(callback): + callback(focus) + else: + result = self.marionette.open( + type="window", focus=focus, private=private + ) + if result["type"] != "window": + raise Exception( + "Newly opened browsing context is of type {} and not window.".format( + result["type"] + ) + ) + except Exception: + exc_cls, exc, tb = sys.exc_info() + reraise( + exc_cls, + exc_cls("Failed to trigger opening a new window: {}".format(exc)), + tb, + ) + else: + Wait(self.marionette).until( + lambda mn: len(mn.chrome_window_handles) == len(current_windows) + 1, + message="No new window has been opened", + ) + + [new_window] = list( + set(self.marionette.chrome_window_handles) - set(current_windows) + ) + + # Before continuing ensure the window has been completed loading + Wait(self.marionette).until( + lambda _: loaded(new_window), + message="Window with handle '{}'' did not finish loading".format( + new_window + ), + ) + + # Bug 1507771 - Return the correct handle based on the currently selected context + # as long as "WebDriver:NewWindow" is not handled separtely in chrome context + context = self.marionette._send_message( + "Marionette:GetContext", key="value" + ) + if context == "chrome": + return new_window + elif context == "content": + [new_tab] = list( + set(self.marionette.window_handles) - set(current_tabs) + ) + return new_tab + + def open_chrome_window(self, url, focus=False): + """Open a new chrome window with the specified chrome URL. + + Can be replaced with "WebDriver:NewWindow" once the command + supports opening generic chrome windows beside browsers (bug 1507771). + """ + + def open_with_js(focus): + with self.marionette.using_context("chrome"): + self.marionette.execute_async_script( + """ + let [url, focus, resolve] = arguments; + + function waitForEvent(target, type, args) { + return new Promise(resolve => { + let params = Object.assign({once: true}, args); + target.addEventListener(type, event => { + dump(`** Received DOM event ${event.type} for ${event.target}\n`); + resolve(); + }, params); + }); + } + + function waitForFocus(win) { + return Promise.all([ + waitForEvent(win, "activate"), + waitForEvent(win, "focus", {capture: true}), + ]); + } + + (async function() { + // Open a window, wait for it to receive focus + let win = window.openDialog(url, null, "chrome,centerscreen"); + let focused = waitForFocus(win); + + win.focus(); + await focused; + + // The new window shouldn't get focused. As such set the + // focus back to the opening window. + if (!focus && Services.focus.activeWindow != window) { + let focused = waitForFocus(window); + window.focus(); + await focused; + } + + resolve(win.docShell.browsingContext.id); + })(); + """, + script_args=(url, focus), + ) + + with self.marionette.using_context("chrome"): + return self.open_window(callback=open_with_js, focus=focus) diff --git a/testing/marionette/harness/marionette_harness/runner/serve.py b/testing/marionette/harness/marionette_harness/runner/serve.py new file mode 100755 index 0000000000..654dc08859 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/runner/serve.py @@ -0,0 +1,243 @@ +#!/usr/bin/env python + +# 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/. + +"""Spawns necessary HTTP servers for testing Marionette in child +processes. + +""" + +from __future__ import absolute_import, print_function + +import argparse +import multiprocessing +import os +import sys + +from collections import defaultdict + +from six import iteritems + +from . import httpd + + +__all__ = [ + "default_doc_root", + "iter_proc", + "iter_url", + "registered_servers", + "servers", + "start", + "where_is", +] +here = os.path.abspath(os.path.dirname(__file__)) + + +class BlockingChannel(object): + def __init__(self, channel): + self.chan = channel + self.lock = multiprocessing.Lock() + + def call(self, func, args=()): + self.send((func, args)) + return self.recv() + + def send(self, *args): + try: + self.lock.acquire() + self.chan.send(args) + finally: + self.lock.release() + + def recv(self): + try: + self.lock.acquire() + payload = self.chan.recv() + if isinstance(payload, tuple) and len(payload) == 1: + return payload[0] + return payload + except KeyboardInterrupt: + return ("stop", ()) + finally: + self.lock.release() + + +class ServerProxy(multiprocessing.Process, BlockingChannel): + def __init__(self, channel, init_func, *init_args, **init_kwargs): + multiprocessing.Process.__init__(self) + BlockingChannel.__init__(self, channel) + self.init_func = init_func + self.init_args = init_args + self.init_kwargs = init_kwargs + + def run(self): + try: + server = self.init_func(*self.init_args, **self.init_kwargs) + server.start(block=False) + self.send(("ok", ())) + + while True: + # ["func", ("arg", ...)] + # ["prop", ()] + sattr, fargs = self.recv() + attr = getattr(server, sattr) + + # apply fargs to attr if it is a function + if callable(attr): + rv = attr(*fargs) + + # otherwise attr is a property + else: + rv = attr + + self.send(rv) + + if sattr == "stop": + return + + except Exception as e: + self.send(("stop", e)) + + except KeyboardInterrupt: + server.stop() + + +class ServerProc(BlockingChannel): + def __init__(self, init_func): + self._init_func = init_func + self.proc = None + + parent_chan, self.child_chan = multiprocessing.Pipe() + BlockingChannel.__init__(self, parent_chan) + + def start(self, doc_root, ssl_config, **kwargs): + self.proc = ServerProxy( + self.child_chan, self._init_func, doc_root, ssl_config, **kwargs + ) + self.proc.daemon = True + self.proc.start() + + res, exc = self.recv() + if res == "stop": + raise exc + + def get_url(self, url): + return self.call("get_url", (url,)) + + @property + def doc_root(self): + return self.call("doc_root", ()) + + def stop(self): + self.call("stop") + if not self.is_alive: + return + self.proc.join() + + def kill(self): + if not self.is_alive: + return + self.proc.terminate() + self.proc.join(0) + + @property + def is_alive(self): + if self.proc is not None: + return self.proc.is_alive() + return False + + +def http_server(doc_root, ssl_config, host="127.0.0.1", **kwargs): + return httpd.FixtureServer(doc_root, url="http://{}:0/".format(host), **kwargs) + + +def https_server(doc_root, ssl_config, host="127.0.0.1", **kwargs): + return httpd.FixtureServer( + doc_root, + url="https://{}:0/".format(host), + ssl_key=ssl_config["key_path"], + ssl_cert=ssl_config["cert_path"], + **kwargs + ) + + +def start_servers(doc_root, ssl_config, **kwargs): + servers = defaultdict() + for schema, builder_fn in registered_servers: + proc = ServerProc(builder_fn) + proc.start(doc_root, ssl_config, **kwargs) + servers[schema] = (proc.get_url("/"), proc) + return servers + + +def start(doc_root=None, **kwargs): + """Start all relevant test servers. + + If no `doc_root` is given the default + testing/marionette/harness/marionette_harness/www directory will be used. + + Additional keyword arguments can be given which will be passed on + to the individual ``FixtureServer``'s in httpd.py. + + """ + doc_root = doc_root or default_doc_root + ssl_config = { + "cert_path": httpd.default_ssl_cert, + "key_path": httpd.default_ssl_key, + } + + global servers + servers = start_servers(doc_root, ssl_config, **kwargs) + return servers + + +def where_is(uri, on="http"): + """Returns the full URL, including scheme, hostname, and port, for + a fixture resource from the server associated with the ``on`` key. + It will by default look for the resource in the "http" server. + + """ + return servers.get(on)[1].get_url(uri) + + +def iter_proc(servers): + for _, (_, proc) in iteritems(servers): + yield proc + + +def iter_url(servers): + for _, (url, _) in iteritems(servers): + yield url + + +default_doc_root = os.path.join(os.path.dirname(here), "www") +registered_servers = [("http", http_server), ("https", https_server)] +servers = defaultdict() + + +def main(args): + global servers + + parser = argparse.ArgumentParser() + parser.add_argument( + "-r", dest="doc_root", help="Path to document root. Overrides default." + ) + args = parser.parse_args() + + servers = start(args.doc_root) + for url in iter_url(servers): + print("{}: listening on {}".format(sys.argv[0], url), file=sys.stderr) + + try: + while any(proc.is_alive for proc in iter_proc(servers)): + for proc in iter_proc(servers): + proc.proc.join(1) + except KeyboardInterrupt: + for proc in iter_proc(servers): + proc.kill() + + +if __name__ == "__main__": + main(sys.argv[1:]) diff --git a/testing/marionette/harness/marionette_harness/runtests.py b/testing/marionette/harness/marionette_harness/runtests.py new file mode 100644 index 0000000000..8ddc7fa9d3 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/runtests.py @@ -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/. + +from __future__ import absolute_import + +import sys + +import mozlog + +from marionette_driver import __version__ as driver_version + +from marionette_harness import ( + __version__, + BaseMarionetteTestRunner, + BaseMarionetteArguments, + MarionetteTestCase, +) + + +class MarionetteTestRunner(BaseMarionetteTestRunner): + def __init__(self, **kwargs): + BaseMarionetteTestRunner.__init__(self, **kwargs) + self.test_handlers = [MarionetteTestCase] + + +class MarionetteArguments(BaseMarionetteArguments): + pass + + +class MarionetteHarness(object): + def __init__( + self, + runner_class=MarionetteTestRunner, + parser_class=MarionetteArguments, + testcase_class=MarionetteTestCase, + args=None, + ): + self._runner_class = runner_class + self._parser_class = parser_class + self._testcase_class = testcase_class + self.args = args or self.parse_args() + + def parse_args(self, logger_defaults=None): + parser = self._parser_class( + usage="%(prog)s [options] test_file_or_dir <test_file_or_dir> ..." + ) + parser.add_argument( + "--version", + action="version", + help="Show version information.", + version="%(prog)s {version}" + " (using marionette-driver: {driver_version}, ".format( + version=__version__, driver_version=driver_version + ), + ) + mozlog.commandline.add_logging_group(parser) + args = parser.parse_args() + parser.verify_usage(args) + + logger = mozlog.commandline.setup_logging( + args.logger_name, args, logger_defaults or {"tbpl": sys.stdout} + ) + + args.logger = logger + return vars(args) + + def process_args(self): + if self.args.get("pydebugger"): + self._testcase_class.pydebugger = __import__(self.args["pydebugger"]) + + def run(self): + self.process_args() + tests = self.args.pop("tests") + runner = self._runner_class(**self.args) + try: + runner.run_tests(tests) + finally: + runner.cleanup() + return runner.failed + runner.crashed + + +def cli( + runner_class=MarionetteTestRunner, + parser_class=MarionetteArguments, + harness_class=MarionetteHarness, + testcase_class=MarionetteTestCase, + args=None, +): + """ + Call the harness to parse args and run tests. + + The following exit codes are expected: + - Test failures: 10 + - Harness/other failures: 1 + - Success: 0 + """ + logger = mozlog.commandline.setup_logging("Marionette test runner", {}) + try: + harness_instance = harness_class( + runner_class, parser_class, testcase_class, args=args + ) + failed = harness_instance.run() + if failed > 0: + sys.exit(10) + except Exception as e: + logger.error(str(e), exc_info=True) + sys.exit(1) + sys.exit(0) + + +if __name__ == "__main__": + cli() diff --git a/testing/marionette/harness/marionette_harness/tests/harness_unit/conftest.py b/testing/marionette/harness/marionette_harness/tests/harness_unit/conftest.py new file mode 100644 index 0000000000..f708fce6db --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/harness_unit/conftest.py @@ -0,0 +1,108 @@ +# 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/. + +from __future__ import absolute_import + +import sys + +import pytest + +PY2 = sys.version_info.major == 2 + +if PY2: + from mock import Mock, MagicMock +else: + from unittest.mock import Mock, MagicMock + +from marionette_driver.marionette import Marionette + +from marionette_harness.runner.httpd import FixtureServer + + +@pytest.fixture(scope="module") +def logger(): + """ + Fake logger to help with mocking out other runner-related classes. + """ + import mozlog + + return Mock(spec=mozlog.structuredlog.StructuredLogger) + + +@pytest.fixture +def mach_parsed_kwargs(logger): + """ + Parsed and verified dictionary used during simplest + call to mach marionette-test + """ + return { + "adb_path": None, + "addons": None, + "address": None, + "app": None, + "app_args": [], + "avd": None, + "avd_home": None, + "binary": u"/path/to/firefox", + "browsermob_port": None, + "browsermob_script": None, + "device_serial": None, + "emulator": False, + "emulator_bin": None, + "gecko_log": None, + "jsdebugger": False, + "log_errorsummary": None, + "log_html": None, + "log_mach": None, + "log_mach_buffer": None, + "log_mach_level": None, + "log_mach_verbose": None, + "log_raw": None, + "log_raw_level": None, + "log_tbpl": None, + "log_tbpl_buffer": None, + "log_tbpl_compact": None, + "log_tbpl_level": None, + "log_unittest": None, + "log_xunit": None, + "logger_name": "Marionette-based Tests", + "prefs": {}, + "prefs_args": None, + "prefs_files": None, + "profile": None, + "pydebugger": None, + "repeat": None, + "run_until_failure": None, + "server_root": None, + "shuffle": False, + "shuffle_seed": 2276870381009474531, + "socket_timeout": 60.0, + "startup_timeout": 60, + "symbols_path": None, + "test_tags": None, + "tests": [u"/path/to/unit-tests.ini"], + "testvars": None, + "this_chunk": None, + "timeout": None, + "total_chunks": None, + "verbose": None, + "workspace": None, + "logger": logger, + } + + +@pytest.fixture +def mock_httpd(request): + """ Mock httpd instance """ + httpd = MagicMock(spec=FixtureServer) + return httpd + + +@pytest.fixture +def mock_marionette(request): + """ Mock marionette instance """ + marionette = MagicMock(spec=Marionette()) + if "has_crashed" in request.funcargnames: + marionette.check_for_crash.return_value = request.getfuncargvalue("has_crashed") + return marionette diff --git a/testing/marionette/harness/marionette_harness/tests/harness_unit/python.ini b/testing/marionette/harness/marionette_harness/tests/harness_unit/python.ini new file mode 100644 index 0000000000..47bbcb1693 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/harness_unit/python.ini @@ -0,0 +1,10 @@ +[DEFAULT] +subsuite = marionette-harness +skip-if = python == 3 + +[test_httpd.py] +[test_marionette_arguments.py] +[test_marionette_harness.py] +[test_marionette_runner.py] +[test_marionette_test_result.py] +[test_serve.py] diff --git a/testing/marionette/harness/marionette_harness/tests/harness_unit/test_httpd.py b/testing/marionette/harness/marionette_harness/tests/harness_unit/test_httpd.py new file mode 100644 index 0000000000..1f027e8d46 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/harness_unit/test_httpd.py @@ -0,0 +1,94 @@ +# 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/. + +from __future__ import absolute_import + +import json +import os +import types + +import six +from six.moves.urllib_request import urlopen + +import mozunit +import pytest + +from wptserve.handlers import json_handler + +from marionette_harness.runner import httpd + +here = os.path.abspath(os.path.dirname(__file__)) +parent = os.path.dirname(here) +default_doc_root = os.path.join(os.path.dirname(parent), "www") + + +@pytest.yield_fixture +def server(): + server = httpd.FixtureServer(default_doc_root) + yield server + server.stop() + + +def test_ctor(): + with pytest.raises(ValueError): + httpd.FixtureServer("foo") + httpd.FixtureServer(default_doc_root) + + +def test_start_stop(server): + server.start() + server.stop() + + +def test_get_url(server): + server.start() + url = server.get_url("/") + assert isinstance(url, six.string_types) + assert "http://" in url + + server.stop() + with pytest.raises(httpd.NotAliveError): + server.get_url("/") + + +def test_doc_root(server): + server.start() + assert isinstance(server.doc_root, six.string_types) + server.stop() + assert isinstance(server.doc_root, six.string_types) + + +def test_router(server): + assert server.router is not None + + +def test_routes(server): + assert server.routes is not None + + +def test_is_alive(server): + assert server.is_alive == False + server.start() + assert server.is_alive == True + + +def test_handler(server): + counter = 0 + + @json_handler + def handler(request, response): + return {"count": counter} + + route = ("GET", "/httpd/test_handler", handler) + server.router.register(*route) + server.start() + + url = server.get_url("/httpd/test_handler") + body = urlopen(url).read() + res = json.loads(body) + assert res["count"] == counter + + +if __name__ == "__main__": + mozunit.main("-p", "no:terminalreporter", "--log-tbpl=-", "--capture", "no") diff --git a/testing/marionette/harness/marionette_harness/tests/harness_unit/test_marionette_arguments.py b/testing/marionette/harness/marionette_harness/tests/harness_unit/test_marionette_arguments.py new file mode 100644 index 0000000000..59ae36b8d8 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/harness_unit/test_marionette_arguments.py @@ -0,0 +1,82 @@ +# 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/. + +from __future__ import absolute_import + +import mozunit +import pytest + +from marionette_harness.runtests import MarionetteArguments, MarionetteTestRunner + + +@pytest.mark.parametrize("socket_timeout", ["A", "10", "1B-", "1C2", "44.35"]) +def test_parse_arg_socket_timeout(socket_timeout): + argv = ["marionette", "--socket-timeout", socket_timeout] + parser = MarionetteArguments() + + def _is_float_convertible(value): + try: + float(value) + return True + except: + return False + + if not _is_float_convertible(socket_timeout): + with pytest.raises(SystemExit) as ex: + parser.parse_args(args=argv) + assert ex.value.code == 2 + else: + args = parser.parse_args(args=argv) + assert hasattr(args, "socket_timeout") and args.socket_timeout == float( + socket_timeout + ) + + +@pytest.mark.parametrize( + "arg_name, arg_dest, arg_value, expected_value", + [ + ("app-arg", "app_args", "samplevalue", ["samplevalue"]), + ("symbols-path", "symbols_path", "samplevalue", "samplevalue"), + ("gecko-log", "gecko_log", "samplevalue", "samplevalue"), + ("app", "app", "samplevalue", "samplevalue"), + ], +) +def test_parsing_optional_arguments( + mach_parsed_kwargs, arg_name, arg_dest, arg_value, expected_value +): + parser = MarionetteArguments() + parsed_args = parser.parse_args(["--" + arg_name, arg_value]) + result = vars(parsed_args) + assert result.get(arg_dest) == expected_value + mach_parsed_kwargs[arg_dest] = result[arg_dest] + runner = MarionetteTestRunner(**mach_parsed_kwargs) + built_kwargs = runner._build_kwargs() + assert built_kwargs[arg_dest] == expected_value + + +@pytest.mark.parametrize( + "arg_name, arg_dest, arg_value, expected_value", + [ + ("adb", "adb_path", "samplevalue", "samplevalue"), + ("avd", "avd", "samplevalue", "samplevalue"), + ("avd-home", "avd_home", "samplevalue", "samplevalue"), + ("package", "package_name", "samplevalue", "samplevalue"), + ], +) +def test_parse_opt_args_emulator( + mach_parsed_kwargs, arg_name, arg_dest, arg_value, expected_value +): + parser = MarionetteArguments() + parsed_args = parser.parse_args(["--" + arg_name, arg_value]) + result = vars(parsed_args) + assert result.get(arg_dest) == expected_value + mach_parsed_kwargs[arg_dest] = result[arg_dest] + mach_parsed_kwargs["emulator"] = True + runner = MarionetteTestRunner(**mach_parsed_kwargs) + built_kwargs = runner._build_kwargs() + assert built_kwargs[arg_dest] == expected_value + + +if __name__ == "__main__": + mozunit.main("-p", "no:terminalreporter", "--log-tbpl=-", "--capture", "no") diff --git a/testing/marionette/harness/marionette_harness/tests/harness_unit/test_marionette_harness.py b/testing/marionette/harness/marionette_harness/tests/harness_unit/test_marionette_harness.py new file mode 100644 index 0000000000..c48a7e5122 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/harness_unit/test_marionette_harness.py @@ -0,0 +1,118 @@ +# 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/. + +from __future__ import absolute_import + +import sys + +import mozunit +import pytest + +PY2 = sys.version_info.major == 2 + +if PY2: + from mock import Mock, patch, sentinel +else: + from unittest.mock import Mock, patch, sentinel + +import marionette_harness.marionette_test as marionette_test + +from marionette_harness.runtests import MarionetteTestRunner, MarionetteHarness, cli + + +@pytest.fixture +def harness_class(request): + """ + Mock based on MarionetteHarness whose run method just returns a number of + failures according to the supplied test parameter + """ + if "num_fails_crashed" in request.funcargnames: + num_fails_crashed = request.getfuncargvalue("num_fails_crashed") + else: + num_fails_crashed = (0, 0) + harness_cls = Mock(spec=MarionetteHarness) + harness = harness_cls.return_value + if num_fails_crashed is None: + harness.run.side_effect = Exception + else: + harness.run.return_value = sum(num_fails_crashed) + return harness_cls + + +@pytest.fixture +def runner_class(request): + """ + Mock based on MarionetteTestRunner, wherein the runner.failed, + runner.crashed attributes are provided by a test parameter + """ + if "num_fails_crashed" in request.funcargnames: + failures, crashed = request.getfuncargvalue("num_fails_crashed") + else: + failures = 0 + crashed = 0 + mock_runner_class = Mock(spec=MarionetteTestRunner) + runner = mock_runner_class.return_value + runner.failed = failures + runner.crashed = crashed + return mock_runner_class + + +@pytest.mark.parametrize( + "num_fails_crashed,exit_code", + [((0, 0), 0), ((1, 0), 10), ((0, 1), 10), (None, 1)], +) +def test_cli_exit_code(num_fails_crashed, exit_code, harness_class): + with pytest.raises(SystemExit) as err: + cli(harness_class=harness_class) + assert err.value.code == exit_code + + +@pytest.mark.parametrize("num_fails_crashed", [(0, 0), (1, 0), (1, 1)]) +def test_call_harness_with_parsed_args_yields_num_failures( + mach_parsed_kwargs, runner_class, num_fails_crashed +): + with patch( + "marionette_harness.runtests.MarionetteHarness.parse_args" + ) as parse_args: + failed_or_crashed = MarionetteHarness( + runner_class, args=mach_parsed_kwargs + ).run() + parse_args.assert_not_called() + assert failed_or_crashed == sum(num_fails_crashed) + + +def test_call_harness_with_no_args_yields_num_failures(runner_class): + with patch( + "marionette_harness.runtests.MarionetteHarness.parse_args", + return_value={"tests": []}, + ) as parse_args: + failed_or_crashed = MarionetteHarness(runner_class).run() + assert parse_args.call_count == 1 + assert failed_or_crashed == 0 + + +def test_args_passed_to_runner_class(mach_parsed_kwargs, runner_class): + arg_list = list(mach_parsed_kwargs.keys()) + arg_list.remove("tests") + mach_parsed_kwargs.update([(a, getattr(sentinel, a)) for a in arg_list]) + harness = MarionetteHarness(runner_class, args=mach_parsed_kwargs) + harness.process_args = Mock() + harness.run() + for arg in arg_list: + assert harness._runner_class.call_args[1][arg] is getattr(sentinel, arg) + + +def test_harness_sets_up_default_test_handlers(mach_parsed_kwargs): + """ + If the necessary TestCase is not in test_handlers, + tests are omitted silently + """ + harness = MarionetteHarness(args=mach_parsed_kwargs) + mach_parsed_kwargs.pop("tests") + runner = harness._runner_class(**mach_parsed_kwargs) + assert marionette_test.MarionetteTestCase in runner.test_handlers + + +if __name__ == "__main__": + mozunit.main("-p", "no:terminalreporter", "--log-tbpl=-", "--capture", "no") diff --git a/testing/marionette/harness/marionette_harness/tests/harness_unit/test_marionette_runner.py b/testing/marionette/harness/marionette_harness/tests/harness_unit/test_marionette_runner.py new file mode 100644 index 0000000000..fcffb38dba --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/harness_unit/test_marionette_runner.py @@ -0,0 +1,552 @@ +# 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/. + +from __future__ import absolute_import + +import os +import sys + +import manifestparser +import mozinfo +import mozunit +import pytest + +PY2 = sys.version_info.major == 2 + +if PY2: + from mock import Mock, patch, mock_open, sentinel, DEFAULT +else: + from unittest.mock import Mock, patch, mock_open, sentinel, DEFAULT + +from marionette_harness.runtests import MarionetteTestRunner + + +@pytest.fixture +def runner(mach_parsed_kwargs): + """ + MarionetteTestRunner instance initialized with default options. + """ + return MarionetteTestRunner(**mach_parsed_kwargs) + + +@pytest.fixture +def mock_runner(runner, mock_marionette, monkeypatch): + """ + MarionetteTestRunner instance with mocked-out + self.marionette and other properties, + to enable testing runner.run_tests(). + """ + runner.driverclass = Mock(return_value=mock_marionette) + for attr in ["run_test", "_capabilities"]: + setattr(runner, attr, Mock()) + runner._appName = "fake_app" + monkeypatch.setattr("marionette_harness.runner.base.mozversion", Mock()) + return runner + + +@pytest.fixture +def build_kwargs_using(mach_parsed_kwargs): + """Helper function for test_build_kwargs_* functions""" + + def kwarg_builder(new_items, return_socket=False): + mach_parsed_kwargs.update(new_items) + runner = MarionetteTestRunner(**mach_parsed_kwargs) + with patch("marionette_harness.runner.base.socket") as socket: + built_kwargs = runner._build_kwargs() + if return_socket: + return built_kwargs, socket + return built_kwargs + + return kwarg_builder + + +@pytest.fixture +def expected_driver_args(runner): + """Helper fixture for tests of _build_kwargs + with binary/emulator. + Provides a dictionary of certain arguments + related to binary/emulator settings + which we expect to be passed to the + driverclass constructor. Expected values can + be updated in tests as needed. + Provides convenience methods for comparing the + expected arguments to the argument dictionary + created by _build_kwargs.""" + + class ExpectedDict(dict): + def assert_matches(self, actual): + for (k, v) in self.items(): + assert actual[k] == v + + def assert_keys_not_in(self, actual): + for k in self.keys(): + assert k not in actual + + expected = ExpectedDict(host=None, port=None, bin=None) + for attr in ["app", "app_args", "profile", "addons", "gecko_log"]: + expected[attr] = getattr(runner, attr) + return expected + + +class ManifestFixture: + def __init__( + self, + name="mock_manifest", + tests=[{"path": u"test_something.py", "expected": "pass"}], + ): + self.filepath = "/path/to/fake/manifest.ini" + self.n_disabled = len([t for t in tests if "disabled" in t]) + self.n_enabled = len(tests) - self.n_disabled + mock_manifest = Mock( + spec=manifestparser.TestManifest, active_tests=Mock(return_value=tests) + ) + self.manifest_class = Mock(return_value=mock_manifest) + self.__repr__ = lambda: "<ManifestFixture {}>".format(name) + + +@pytest.fixture +def manifest(): + return ManifestFixture() + + +@pytest.fixture(params=["enabled", "disabled", "enabled_disabled", "empty"]) +def manifest_with_tests(request): + """ + Fixture for the contents of mock_manifest, where a manifest + can include enabled tests, disabled tests, both, or neither (empty) + """ + included = [] + if "enabled" in request.param: + included += [ + (u"test_expected_pass.py", "pass"), + (u"test_expected_fail.py", "fail"), + ] + if "disabled" in request.param: + included += [ + (u"test_pass_disabled.py", "pass", "skip-if: true"), + (u"test_fail_disabled.py", "fail", "skip-if: true"), + ] + keys = ("path", "expected", "disabled") + active_tests = [dict(list(zip(keys, values))) for values in included] + + return ManifestFixture(request.param, active_tests) + + +def test_args_passed_to_driverclass(mock_runner): + built_kwargs = {"arg1": "value1", "arg2": "value2"} + mock_runner._build_kwargs = Mock(return_value=built_kwargs) + with pytest.raises(IOError): + mock_runner.run_tests(["fake_tests.ini"]) + assert mock_runner.driverclass.call_args[1] == built_kwargs + + +def test_build_kwargs_basic_args(build_kwargs_using): + """Test the functionality of runner._build_kwargs: + make sure that basic arguments (those which should + always be included, irrespective of the runner's settings) + get passed to the call to runner.driverclass""" + + basic_args = [ + "socket_timeout", + "prefs", + "startup_timeout", + "verbose", + "symbols_path", + ] + args_dict = {a: getattr(sentinel, a) for a in basic_args} + # Mock an update method to work with calls to MarionetteTestRunner() + args_dict["prefs"].update = Mock(return_value={}) + built_kwargs = build_kwargs_using([(a, getattr(sentinel, a)) for a in basic_args]) + for arg in basic_args: + assert built_kwargs[arg] is getattr(sentinel, arg) + + +@pytest.mark.parametrize("workspace", ["path/to/workspace", None]) +def test_build_kwargs_with_workspace(build_kwargs_using, workspace): + built_kwargs = build_kwargs_using({"workspace": workspace}) + if workspace: + assert built_kwargs["workspace"] == workspace + else: + assert "workspace" not in built_kwargs + + +@pytest.mark.parametrize("address", ["host:123", None]) +def test_build_kwargs_with_address(build_kwargs_using, address): + built_kwargs, socket = build_kwargs_using( + {"address": address, "binary": None, "emulator": None}, return_socket=True + ) + assert "connect_to_running_emulator" not in built_kwargs + if address is not None: + host, port = address.split(":") + assert built_kwargs["host"] == host and built_kwargs["port"] == int(port) + socket.socket().connect.assert_called_with((host, int(port))) + assert socket.socket().close.called + else: + assert not socket.socket.called + + +@pytest.mark.parametrize("address", ["host:123", None]) +@pytest.mark.parametrize("binary", ["path/to/bin", None]) +def test_build_kwargs_with_binary_or_address( + expected_driver_args, build_kwargs_using, binary, address +): + built_kwargs = build_kwargs_using( + {"binary": binary, "address": address, "emulator": None} + ) + if binary: + expected_driver_args["bin"] = binary + if address: + host, port = address.split(":") + expected_driver_args.update({"host": host, "port": int(port)}) + else: + expected_driver_args.update({"host": "127.0.0.1", "port": 2828}) + expected_driver_args.assert_matches(built_kwargs) + elif address is None: + expected_driver_args.assert_keys_not_in(built_kwargs) + + +@pytest.mark.parametrize("address", ["host:123", None]) +@pytest.mark.parametrize("emulator", [True, False, None]) +def test_build_kwargs_with_emulator_or_address( + expected_driver_args, build_kwargs_using, emulator, address +): + emulator_props = [ + (a, getattr(sentinel, a)) for a in ["avd_home", "adb_path", "emulator_bin"] + ] + built_kwargs = build_kwargs_using( + [("emulator", emulator), ("address", address), ("binary", None)] + + emulator_props + ) + if emulator: + expected_driver_args.update(emulator_props) + expected_driver_args["emulator_binary"] = expected_driver_args.pop( + "emulator_bin" + ) + expected_driver_args["bin"] = True + if address: + expected_driver_args["connect_to_running_emulator"] = True + host, port = address.split(":") + expected_driver_args.update({"host": host, "port": int(port)}) + else: + expected_driver_args.update({"host": "127.0.0.1", "port": 2828}) + assert "connect_to_running_emulator" not in built_kwargs + expected_driver_args.assert_matches(built_kwargs) + elif not address: + expected_driver_args.assert_keys_not_in(built_kwargs) + + +def test_parsing_testvars(mach_parsed_kwargs): + mach_parsed_kwargs.pop("tests") + testvars_json_loads = [ + {"wifi": {"ssid": "blah", "keyManagement": "WPA-PSK", "psk": "foo"}}, + {"wifi": {"PEAP": "bar"}, "device": {"stuff": "buzz"}}, + ] + expected_dict = { + "wifi": { + "ssid": "blah", + "keyManagement": "WPA-PSK", + "psk": "foo", + "PEAP": "bar", + }, + "device": {"stuff": "buzz"}, + } + with patch( + "marionette_harness.runtests.MarionetteTestRunner._load_testvars", + return_value=testvars_json_loads, + ) as load: + runner = MarionetteTestRunner(**mach_parsed_kwargs) + assert runner.testvars == expected_dict + assert load.call_count == 1 + + +def test_load_testvars_throws_expected_errors(mach_parsed_kwargs): + mach_parsed_kwargs["testvars"] = ["some_bad_path.json"] + runner = MarionetteTestRunner(**mach_parsed_kwargs) + with pytest.raises(IOError) as io_exc: + runner._load_testvars() + assert "does not exist" in str(io_exc.value) + with patch("os.path.exists", return_value=True): + with patch("__builtin__.open", mock_open(read_data="[not {valid JSON]")): + with pytest.raises(Exception) as json_exc: + runner._load_testvars() + assert "not properly formatted" in str(json_exc.value) + + +def _check_crash_counts(has_crashed, runner, mock_marionette): + if has_crashed: + assert mock_marionette.check_for_crash.call_count == 1 + assert runner.crashed == 1 + else: + assert runner.crashed == 0 + + +@pytest.mark.parametrize("has_crashed", [True, False]) +def test_increment_crash_count_in_run_test_set(runner, has_crashed, mock_marionette): + fake_tests = [{"filepath": i, "expected": "pass"} for i in "abc"] + + with patch.multiple(runner, run_test=DEFAULT, marionette=mock_marionette): + runner.run_test_set(fake_tests) + if not has_crashed: + assert runner.marionette.check_for_crash.call_count == len(fake_tests) + _check_crash_counts(has_crashed, runner, runner.marionette) + + +@pytest.mark.parametrize("has_crashed", [True, False]) +def test_record_crash(runner, has_crashed, mock_marionette): + with patch.object(runner, "marionette", mock_marionette): + assert runner.record_crash() == has_crashed + _check_crash_counts(has_crashed, runner, runner.marionette) + + +def test_add_test_module(runner): + tests = ["test_something.py", "testSomething.js", "bad_test.py"] + assert len(runner.tests) == 0 + for test in tests: + with patch("os.path.abspath", return_value=test) as abspath: + runner.add_test(test) + assert abspath.called + expected = {"filepath": test, "expected": "pass", "group": "default"} + assert expected in runner.tests + # add_test doesn't validate module names; 'bad_test.py' gets through + assert len(runner.tests) == 3 + + +def test_add_test_directory(runner): + test_dir = "path/to/tests" + dir_contents = [ + (test_dir, ("subdir",), ("test_a.py", "bad_test_a.py")), + (test_dir + "/subdir", (), ("test_b.py", "bad_test_b.py")), + ] + tests = list(dir_contents[0][2] + dir_contents[1][2]) + assert len(runner.tests) == 0 + # Need to use side effect to make isdir return True for test_dir and False for tests + with patch("os.path.isdir", side_effect=[True] + [False for t in tests]) as isdir: + with patch("os.walk", return_value=dir_contents) as walk: + runner.add_test(test_dir) + assert isdir.called and walk.called + for test in runner.tests: + assert os.path.normpath(test_dir) in test["filepath"] + assert len(runner.tests) == 2 + + +@pytest.mark.parametrize("test_files_exist", [True, False]) +def test_add_test_manifest( + mock_runner, manifest_with_tests, monkeypatch, test_files_exist +): + monkeypatch.setattr( + "marionette_harness.runner.base.TestManifest", + manifest_with_tests.manifest_class, + ) + mock_runner.marionette = mock_runner.driverclass() + with patch( + "marionette_harness.runner.base.os.path.exists", return_value=test_files_exist + ): + if test_files_exist or manifest_with_tests.n_enabled == 0: + mock_runner.add_test(manifest_with_tests.filepath) + assert len(mock_runner.tests) == manifest_with_tests.n_enabled + assert ( + len(mock_runner.manifest_skipped_tests) + == manifest_with_tests.n_disabled + ) + for test in mock_runner.tests: + assert test["filepath"].endswith(test["expected"] + ".py") + else: + pytest.raises(IOError, "mock_runner.add_test(manifest_with_tests.filepath)") + assert manifest_with_tests.manifest_class().read.called + assert manifest_with_tests.manifest_class().active_tests.called + + +def get_kwargs_passed_to_manifest(mock_runner, manifest, monkeypatch, **kwargs): + """Helper function for test_manifest_* tests. + Returns the kwargs passed to the call to manifest.active_tests.""" + monkeypatch.setattr( + "marionette_harness.runner.base.TestManifest", manifest.manifest_class + ) + monkeypatch.setitem(mozinfo.info, "mozinfo_key", "mozinfo_val") + for attr in kwargs: + setattr(mock_runner, attr, kwargs[attr]) + mock_runner.marionette = mock_runner.driverclass() + with patch("marionette_harness.runner.base.os.path.exists", return_value=True): + mock_runner.add_test(manifest.filepath) + call_args, call_kwargs = manifest.manifest_class().active_tests.call_args + return call_kwargs + + +def test_manifest_basic_args(mock_runner, manifest, monkeypatch): + kwargs = get_kwargs_passed_to_manifest(mock_runner, manifest, monkeypatch) + assert kwargs["exists"] is False + assert kwargs["disabled"] is True + assert kwargs["appname"] == "fake_app" + assert kwargs["actors"] is True + assert "mozinfo_key" in kwargs and kwargs["mozinfo_key"] == "mozinfo_val" + + +def test_manifest_actors_disabled(mock_runner, manifest, monkeypatch): + kwargs = get_kwargs_passed_to_manifest( + mock_runner, manifest, monkeypatch, disable_actors=True + ) + assert kwargs["actors"] is False + + +@pytest.mark.parametrize("test_tags", (None, ["tag", "tag2"])) +def test_manifest_with_test_tags(mock_runner, manifest, monkeypatch, test_tags): + kwargs = get_kwargs_passed_to_manifest( + mock_runner, manifest, monkeypatch, test_tags=test_tags + ) + if test_tags is None: + assert kwargs["filters"] == [] + else: + assert len(kwargs["filters"]) == 1 and kwargs["filters"][0].tags == test_tags + + +def test_cleanup_with_manifest(mock_runner, manifest_with_tests, monkeypatch): + monkeypatch.setattr( + "marionette_harness.runner.base.TestManifest", + manifest_with_tests.manifest_class, + ) + if manifest_with_tests.n_enabled > 0: + context = patch( + "marionette_harness.runner.base.os.path.exists", return_value=True + ) + else: + context = pytest.raises(Exception) + with context: + mock_runner.run_tests([manifest_with_tests.filepath]) + assert mock_runner.marionette is None + assert mock_runner.fixture_servers == {} + + +def test_reset_test_stats(mock_runner): + def reset_successful(runner): + stats = [ + "passed", + "failed", + "unexpected_successes", + "todo", + "skipped", + "failures", + ] + return all([((s in vars(runner)) and (not vars(runner)[s])) for s in stats]) + + assert reset_successful(mock_runner) + mock_runner.passed = 1 + mock_runner.failed = 1 + mock_runner.failures.append(["TEST-UNEXPECTED-FAIL"]) + assert not reset_successful(mock_runner) + mock_runner.run_tests([u"test_fake_thing.py"]) + assert reset_successful(mock_runner) + + +def test_initialize_test_run(mock_runner): + tests = [u"test_fake_thing.py"] + mock_runner.reset_test_stats = Mock() + mock_runner.run_tests(tests) + assert mock_runner.reset_test_stats.called + with pytest.raises(AssertionError) as test_exc: + mock_runner.run_tests([]) + assert "len(tests)" in str(test_exc.traceback[-1].statement) + with pytest.raises(AssertionError) as hndl_exc: + mock_runner.test_handlers = [] + mock_runner.run_tests(tests) + assert "test_handlers" in str(hndl_exc.traceback[-1].statement) + assert mock_runner.reset_test_stats.call_count == 1 + + +def test_add_tests(mock_runner): + assert len(mock_runner.tests) == 0 + fake_tests = ["test_" + i + ".py" for i in "abc"] + mock_runner.run_tests(fake_tests) + assert len(mock_runner.tests) == 3 + for (test_name, added_test) in zip(fake_tests, mock_runner.tests): + assert added_test["filepath"].endswith(test_name) + + +def test_repeat(mock_runner): + def update_result(test, expected): + mock_runner.failed += 1 + + fake_tests = ["test_1.py"] + mock_runner.repeat = 4 + mock_runner.run_test = Mock(side_effect=update_result) + mock_runner.run_tests(fake_tests) + + assert mock_runner.failed == 5 + assert mock_runner.passed == 0 + assert mock_runner.todo == 0 + + +def test_run_until_failure(mock_runner): + def update_result(test, expected): + mock_runner.failed += 1 + + fake_tests = ["test_1.py"] + mock_runner.run_until_failure = True + mock_runner.repeat = 4 + mock_runner.run_test = Mock(side_effect=update_result) + mock_runner.run_tests(fake_tests) + + assert mock_runner.failed == 1 + assert mock_runner.passed == 0 + assert mock_runner.todo == 0 + + +def test_catch_invalid_test_names(runner): + good_tests = [u"test_ok.py", u"test_is_ok.py"] + bad_tests = [ + u"bad_test.py", + u"testbad.py", + u"_test_bad.py", + u"test_bad.notpy", + u"test_bad", + u"test.py", + u"test_.py", + ] + with pytest.raises(Exception) as exc: + runner._add_tests(good_tests + bad_tests) + msg = str(exc.value) + assert "Test file names must be of the form" in msg + for bad_name in bad_tests: + assert bad_name in msg + for good_name in good_tests: + assert good_name not in msg + + +@pytest.mark.parametrize("repeat", (None, 0, 42, -1)) +def test_option_repeat(mach_parsed_kwargs, repeat): + if repeat is not None: + mach_parsed_kwargs["repeat"] = repeat + runner = MarionetteTestRunner(**mach_parsed_kwargs) + + if repeat is None: + assert runner.repeat == 0 + else: + assert runner.repeat == repeat + + +@pytest.mark.parametrize("repeat", (None, 42)) +@pytest.mark.parametrize("run_until_failure", (None, True)) +def test_option_run_until_failure(mach_parsed_kwargs, repeat, run_until_failure): + if run_until_failure is not None: + mach_parsed_kwargs["run_until_failure"] = run_until_failure + if repeat is not None: + mach_parsed_kwargs["repeat"] = repeat + runner = MarionetteTestRunner(**mach_parsed_kwargs) + + if run_until_failure is None: + assert runner.run_until_failure is False + if repeat is None: + assert runner.repeat == 0 + else: + assert runner.repeat == repeat + + else: + assert runner.run_until_failure == run_until_failure + if repeat is None: + assert runner.repeat == 30 + else: + assert runner.repeat == repeat + + +if __name__ == "__main__": + mozunit.main("-p", "no:terminalreporter", "--log-tbpl=-", "--capture", "no") diff --git a/testing/marionette/harness/marionette_harness/tests/harness_unit/test_marionette_test_result.py b/testing/marionette/harness/marionette_harness/tests/harness_unit/test_marionette_test_result.py new file mode 100644 index 0000000000..42e11f6f19 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/harness_unit/test_marionette_test_result.py @@ -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/. + +from __future__ import absolute_import + +import mozunit +import pytest + +from marionette_harness import MarionetteTestResult + + +@pytest.fixture +def empty_marionette_testcase(): + """ Testable MarionetteTestCase class """ + from marionette_harness import MarionetteTestCase + + class EmptyTestCase(MarionetteTestCase): + def test_nothing(self): + pass + + return EmptyTestCase + + +@pytest.fixture +def empty_marionette_test(mock_marionette, empty_marionette_testcase): + return empty_marionette_testcase( + lambda: mock_marionette, lambda: mock_httpd, "test_nothing" + ) + + +@pytest.mark.parametrize("has_crashed", [True, False]) +def test_crash_is_recorded_as_error(empty_marionette_test, logger, has_crashed): + """ Number of errors is incremented by stopTest iff has_crashed is true """ + # collect results from the empty test + result = MarionetteTestResult( + marionette=empty_marionette_test._marionette_weakref(), + logger=logger, + verbosity=1, + stream=None, + descriptions=None, + ) + result.startTest(empty_marionette_test) + assert len(result.errors) == 0 + assert len(result.failures) == 0 + assert result.testsRun == 1 + assert result.shouldStop is False + result.stopTest(empty_marionette_test) + assert result.shouldStop == has_crashed + if has_crashed: + assert len(result.errors) == 1 + else: + assert len(result.errors) == 0 + + +if __name__ == "__main__": + mozunit.main("-p", "no:terminalreporter", "--log-tbpl=-", "--capture", "no") diff --git a/testing/marionette/harness/marionette_harness/tests/harness_unit/test_serve.py b/testing/marionette/harness/marionette_harness/tests/harness_unit/test_serve.py new file mode 100644 index 0000000000..68fbc51bae --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/harness_unit/test_serve.py @@ -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/. + +from __future__ import absolute_import + +import types + +import six + +import mozunit +import pytest + +from marionette_harness.runner import serve +from marionette_harness.runner.serve import iter_proc, iter_url + + +def teardown_function(func): + for server in [s for s in iter_proc(serve.servers) if s.is_alive]: + server.stop() + server.kill() + + +def test_registered_servers(): + # [(name, factory), ...] + assert serve.registered_servers[0][0] == "http" + assert serve.registered_servers[1][0] == "https" + + +def test_globals(): + assert serve.default_doc_root is not None + assert serve.registered_servers is not None + assert serve.servers is not None + + +def test_start(): + serve.start() + assert len(serve.servers) == 2 + assert "http" in serve.servers + assert "https" in serve.servers + for url in iter_url(serve.servers): + assert isinstance(url, six.string_types) + + +def test_start_with_custom_root(tmpdir_factory): + tdir = tmpdir_factory.mktemp("foo") + serve.start(str(tdir)) + for server in iter_proc(serve.servers): + assert server.doc_root == tdir + + +def test_iter_proc(): + serve.start() + for server in iter_proc(serve.servers): + server.stop() + + +def test_iter_url(): + serve.start() + for url in iter_url(serve.servers): + assert isinstance(url, six.string_types) + + +def test_where_is(): + serve.start() + assert serve.where_is("/") == serve.servers["http"][1].get_url("/") + assert serve.where_is("/", on="https") == serve.servers["https"][1].get_url("/") + + +if __name__ == "__main__": + mozunit.main("-p", "no:terminalreporter", "--log-tbpl=-", "--capture", "no") diff --git a/testing/marionette/harness/marionette_harness/tests/unit-tests.ini b/testing/marionette/harness/marionette_harness/tests/unit-tests.ini new file mode 100644 index 0000000000..a776385b33 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit-tests.ini @@ -0,0 +1,29 @@ +# marionette unit tests +[include:unit/unit-tests.ini] + +# layout tests +[include:../../../../../layout/base/tests/marionette/manifest.ini] + +# migration tests +[include:../../../../../browser/components/migration/tests/marionette/manifest.ini] + +# xpconnect tests +[include:../../../../../js/xpconnect/tests/marionette/manifest.ini] + +# xre tests +[include:../../../../../toolkit/xre/test/marionette/marionette.ini] + +# searchservice tests +[include:../../../../../browser/components/search/test/marionette/manifest.ini] + +# autoconfig tests +[include:../../../../../extensions/pref/autoconfig/test/marionette/manifest.ini] + +# cleardata tests +[include:../../../../../toolkit/components/cleardata/tests/marionette/manifest.ini] + +# webextensions tests +[include:../../../../../toolkit/components/extensions/test/marionette/manifest.ini] + +# workers tests +[include:../../../../../dom/workers/test/marionette/manifest.ini] diff --git a/testing/marionette/harness/marionette_harness/tests/unit/data/test.html b/testing/marionette/harness/marionette_harness/tests/unit/data/test.html new file mode 100644 index 0000000000..8334cf0a2e --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/data/test.html @@ -0,0 +1,13 @@ +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> + +<!DOCTYPE html> +<html> +<head> +<title>Marionette Test</title> +</head> +<body> + <p id="file-url">Loaded via file://</p> +</body> +</html> diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_accessibility.py b/testing/marionette/harness/marionette_harness/tests/unit/test_accessibility.py new file mode 100644 index 0000000000..2edad1f402 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_accessibility.py @@ -0,0 +1,273 @@ +# 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/. + +from __future__ import absolute_import + +import sys +import unittest + +from marionette_driver.by import By +from marionette_driver.errors import ( + ElementNotAccessibleException, + ElementNotInteractableException, + ElementClickInterceptedException, +) + +from marionette_harness import MarionetteTestCase + + +class TestAccessibility(MarionetteTestCase): + def setUp(self): + super(TestAccessibility, self).setUp() + with self.marionette.using_context("chrome"): + self.marionette.set_pref("dom.ipc.processCount", 1) + + def tearDown(self): + with self.marionette.using_context("chrome"): + self.marionette.clear_pref("dom.ipc.processCount") + + # Elements that are accessible with and without the accessibliity API + valid_elementIDs = [ + # Button1 is an accessible button with a valid accessible name + # computed from subtree + "button1", + # Button2 is an accessible button with a valid accessible name + # computed from aria-label + "button2", + # Button13 is an accessible button that is implemented via role="button" + # and is explorable using tabindex="0" + "button13", + # button17 is an accessible button that overrides parent's + # pointer-events:none; property with its own pointer-events:all; + "button17", + ] + + # Elements that are not accessible with the accessibility API + invalid_elementIDs = [ + # Button3 does not have an accessible object + "button3", + # Button4 does not support any accessible actions + "button4", + # Button5 does not have a correct accessibility role and may not be + # manipulated via the accessibility API + "button5", + # Button6 is missing an accessible name + "button6", + # Button7 is not currently visible via the accessibility API and may + # not be manipulated by it + "button7", + # Button8 is not currently visible via the accessibility API and may + # not be manipulated by it (in hidden subtree) + "button8", + # Button14 is accessible button but is not explorable because of lack + # of tabindex that would make it focusable. + "button14", + ] + + # Elements that are either accessible to accessibility API or not accessible + # at all + falsy_elements = [ + # Element is only visible to the accessibility API and may be + # manipulated by it + "button9", + # Element is not currently visible + "button10", + ] + + displayed_elementIDs = ["button1", "button2", "button4", "button5", "button6"] + + displayed_but_have_no_accessible_elementIDs = [ + # Button3 does not have an accessible object + "button3", + # Button 7 is hidden with aria-hidden set to true + "button7", + # Button 8 is inside an element with aria-hidden set to true + "button8", + "no_accessible_but_displayed", + ] + + disabled_elementIDs = ["button11", "no_accessible_but_disabled"] + + # Elements that are enabled but otherwise disabled or not explorable + # via the accessibility API + aria_disabled_elementIDs = ["button12"] + + # pointer-events: "none", which will return + # ElementClickInterceptedException if clicked + # when Marionette switches + # to using WebDriver conforming interaction + pointer_events_none_elementIDs = ["button15", "button16"] + + # Elements that are reporting selected state + valid_option_elementIDs = ["option1", "option2"] + + def run_element_test(self, ids, testFn): + for id in ids: + element = self.marionette.find_element(By.ID, id) + testFn(element) + + def setup_accessibility(self, enable_a11y_checks=True, navigate=True): + self.marionette.delete_session() + self.marionette.start_session({"moz:accessibilityChecks": enable_a11y_checks}) + self.assertEqual( + self.marionette.session_capabilities["moz:accessibilityChecks"], + enable_a11y_checks, + ) + + # Navigate to test_accessibility.html + if navigate: + test_accessibility = self.marionette.absolute_url("test_accessibility.html") + self.marionette.navigate(test_accessibility) + + def test_valid_single_tap(self): + self.setup_accessibility() + # No exception should be raised + self.run_element_test(self.valid_elementIDs, lambda button: button.tap()) + + def test_single_tap_raises_element_not_accessible(self): + self.setup_accessibility() + self.run_element_test( + self.invalid_elementIDs, + lambda button: self.assertRaises(ElementNotAccessibleException, button.tap), + ) + self.run_element_test( + self.falsy_elements, + lambda button: self.assertRaises( + ElementNotInteractableException, button.tap + ), + ) + + def test_single_tap_raises_no_exceptions(self): + self.setup_accessibility(False, True) + # No exception should be raised + self.run_element_test(self.invalid_elementIDs, lambda button: button.tap()) + # Elements are invisible + self.run_element_test( + self.falsy_elements, + lambda button: self.assertRaises( + ElementNotInteractableException, button.tap + ), + ) + + def test_valid_click(self): + self.setup_accessibility() + # No exception should be raised + self.run_element_test(self.valid_elementIDs, lambda button: button.click()) + + def test_click_raises_element_not_accessible(self): + self.setup_accessibility() + self.run_element_test( + self.invalid_elementIDs, + lambda button: self.assertRaises( + ElementNotAccessibleException, button.click + ), + ) + self.run_element_test( + self.falsy_elements, + lambda button: self.assertRaises( + ElementNotInteractableException, button.click + ), + ) + + def test_click_raises_no_exceptions(self): + self.setup_accessibility(False, True) + # No exception should be raised + self.run_element_test(self.invalid_elementIDs, lambda button: button.click()) + # Elements are invisible + self.run_element_test( + self.falsy_elements, + lambda button: self.assertRaises( + ElementNotInteractableException, button.click + ), + ) + + def test_element_visible_but_not_visible_to_accessbility(self): + self.setup_accessibility() + # Elements are displayed but hidden from accessibility API + self.run_element_test( + self.displayed_but_have_no_accessible_elementIDs, + lambda element: self.assertRaises( + ElementNotAccessibleException, element.is_displayed + ), + ) + + def test_element_is_visible_to_accessibility(self): + self.setup_accessibility() + # No exception should be raised + self.run_element_test( + self.displayed_elementIDs, lambda element: element.is_displayed() + ) + + def test_element_is_not_enabled_to_accessbility(self): + self.setup_accessibility() + # Buttons are enabled but disabled/not-explorable via the accessibility API + self.run_element_test( + self.aria_disabled_elementIDs, + lambda element: self.assertRaises( + ElementNotAccessibleException, element.is_enabled + ), + ) + self.run_element_test( + self.pointer_events_none_elementIDs, + lambda element: self.assertRaises( + ElementNotAccessibleException, element.is_enabled + ), + ) + + # Buttons are enabled but disabled/not-explorable via + # the accessibility API and thus are not clickable via the + # accessibility API. + self.run_element_test( + self.aria_disabled_elementIDs, + lambda element: self.assertRaises( + ElementNotAccessibleException, element.click + ), + ) + # To be removed with bug 1405967 + if not self.marionette.session_capabilities["moz:webdriverClick"]: + self.run_element_test( + self.pointer_events_none_elementIDs, + lambda element: self.assertRaises( + ElementNotAccessibleException, element.click + ), + ) + + self.setup_accessibility(False, False) + self.run_element_test( + self.aria_disabled_elementIDs, lambda element: element.is_enabled() + ) + self.run_element_test( + self.pointer_events_none_elementIDs, lambda element: element.is_enabled() + ) + self.run_element_test( + self.aria_disabled_elementIDs, lambda element: element.click() + ) + # To be removed with bug 1405967 + if not self.marionette.session_capabilities["moz:webdriverClick"]: + self.run_element_test( + self.pointer_events_none_elementIDs, lambda element: element.click() + ) + + def test_element_is_enabled_to_accessibility(self): + self.setup_accessibility() + # No exception should be raised + self.run_element_test( + self.disabled_elementIDs, lambda element: element.is_enabled() + ) + + def test_send_keys_raises_no_exception(self): + self.setup_accessibility() + # Sending keys to valid input should not raise any exceptions + self.run_element_test(["input1"], lambda element: element.send_keys("a")) + + def test_is_selected_raises_no_exception(self): + self.setup_accessibility() + # No exception should be raised for valid options + self.run_element_test( + self.valid_option_elementIDs, lambda element: element.is_selected() + ) + # No exception should be raised for non-selectable elements + self.run_element_test( + self.valid_elementIDs, lambda element: element.is_selected() + ) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_addons.py b/testing/marionette/harness/marionette_harness/tests/unit/test_addons.py new file mode 100644 index 0000000000..63f0cde9ed --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_addons.py @@ -0,0 +1,128 @@ +# 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/. + +from __future__ import absolute_import + +import os +import sys +from unittest import skipIf + +from marionette_driver.addons import Addons, AddonInstallException +from marionette_harness import MarionetteTestCase + + +here = os.path.abspath(os.path.dirname(__file__)) + + +class TestAddons(MarionetteTestCase): + def setUp(self): + super(TestAddons, self).setUp() + + self.addons = Addons(self.marionette) + self.preinstalled_addons = self.all_addon_ids + + def tearDown(self): + self.reset_addons() + + super(TestAddons, self).tearDown() + + @property + def all_addon_ids(self): + with self.marionette.using_context("chrome"): + addons = self.marionette.execute_async_script( + """ + let [resolve] = arguments; + Components.utils.import("resource://gre/modules/AddonManager.jsm"); + + AddonManager.getAllAddons().then(function(addons) { + let ids = addons.map(x => x.id); + resolve(ids); + }); + """ + ) + + return set(addons) + + def reset_addons(self): + with self.marionette.using_context("chrome"): + for addon in self.all_addon_ids - self.preinstalled_addons: + addon_id = self.marionette.execute_async_script( + """ + let [resolve] = arguments; + Components.utils.import("resource://gre/modules/AddonManager.jsm"); + + return new Promise(await resolve => { + let addon = await AddonManager.getAddonByID(arguments[0]); + addon.uninstall(); + resolve(addon.id); + }); + """, + script_args=(addon,), + ) + self.assertEqual( + addon_id, addon, msg="Failed to uninstall {}".format(addon) + ) + + def test_temporary_install_and_remove_unsigned_addon(self): + addon_path = os.path.join(here, "webextension-unsigned.xpi") + + addon_id = self.addons.install(addon_path, temp=True) + self.assertIn(addon_id, self.all_addon_ids) + self.assertEqual(addon_id, "{d3e7c1f1-2e35-4a49-89fe-9f46eb8abf0a}") + + self.addons.uninstall(addon_id) + self.assertNotIn(addon_id, self.all_addon_ids) + + def test_temporary_install_invalid_addon(self): + addon_path = os.path.join(here, "webextension-invalid.xpi") + + with self.assertRaises(AddonInstallException): + self.addons.install(addon_path, temp=True) + self.assertNotIn("{d3e7c1f1-2e35-4a49-89fe-9f46eb8abf0a}", self.all_addon_ids) + + def test_install_and_remove_signed_addon(self): + addon_path = os.path.join(here, "webextension-signed.xpi") + + addon_id = self.addons.install(addon_path) + self.assertIn(addon_id, self.all_addon_ids) + self.assertEqual(addon_id, "{d3e7c1f1-2e35-4a49-89fe-9f46eb8abf0a}") + + self.addons.uninstall(addon_id) + self.assertNotIn(addon_id, self.all_addon_ids) + + def test_install_invalid_addon(self): + addon_path = os.path.join(here, "webextension-invalid.xpi") + + with self.assertRaises(AddonInstallException): + self.addons.install(addon_path) + self.assertNotIn("{d3e7c1f1-2e35-4a49-89fe-9f46eb8abf0a}", self.all_addon_ids) + + def test_install_unsigned_addon_fails(self): + addon_path = os.path.join(here, "webextension-unsigned.xpi") + + with self.assertRaises(AddonInstallException): + self.addons.install(addon_path) + + def test_install_nonexistent_addon(self): + addon_path = os.path.join(here, "does-not-exist.xpi") + + with self.assertRaises(AddonInstallException): + self.addons.install(addon_path) + + def test_install_with_relative_path(self): + with self.assertRaises(AddonInstallException): + self.addons.install("webextension.xpi") + + @skipIf(sys.platform != "win32", "Only makes sense on Windows") + def test_install_mixed_separator_windows(self): + # Ensure the base path has only \ + addon_path = here.replace("/", "\\") + addon_path += "/webextension-signed.xpi" + + addon_id = self.addons.install(addon_path, temp=True) + self.assertIn(addon_id, self.all_addon_ids) + self.assertEqual(addon_id, "{d3e7c1f1-2e35-4a49-89fe-9f46eb8abf0a}") + + self.addons.uninstall(addon_id) + self.assertNotIn(addon_id, self.all_addon_ids) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_capabilities.py b/testing/marionette/harness/marionette_harness/tests/unit/test_capabilities.py new file mode 100644 index 0000000000..d186e4af35 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_capabilities.py @@ -0,0 +1,249 @@ +# 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/. + +from __future__ import absolute_import, print_function + +import os + +from marionette_driver.errors import SessionNotCreatedException +from marionette_harness import MarionetteTestCase + + +class TestCapabilities(MarionetteTestCase): + def setUp(self): + super(TestCapabilities, self).setUp() + self.caps = self.marionette.session_capabilities + with self.marionette.using_context("chrome"): + self.appinfo = self.marionette.execute_script( + """ + return { + name: Services.appinfo.name, + version: Services.appinfo.version, + processID: Services.appinfo.processID, + buildID: Services.appinfo.appBuildID, + } + """ + ) + self.os_name = self.marionette.execute_script( + """ + let name = Services.sysinfo.getProperty("name"); + switch (name) { + case "Windows_NT": + return "windows"; + case "Darwin": + return "mac"; + default: + return name.toLowerCase(); + } + """ + ) + self.os_version = self.marionette.execute_script( + "return Services.sysinfo.getProperty('version')" + ) + + def test_mandated_capabilities(self): + self.assertIn("browserName", self.caps) + self.assertIn("browserVersion", self.caps) + self.assertIn("platformName", self.caps) + self.assertIn("platformVersion", self.caps) + self.assertIn("acceptInsecureCerts", self.caps) + self.assertIn("setWindowRect", self.caps) + self.assertIn("timeouts", self.caps) + self.assertIn("strictFileInteractability", self.caps) + + self.assertEqual(self.caps["browserName"], self.appinfo["name"].lower()) + self.assertEqual(self.caps["browserVersion"], self.appinfo["version"]) + self.assertEqual(self.caps["platformName"], self.os_name) + self.assertEqual(self.caps["platformVersion"], self.os_version) + self.assertFalse(self.caps["acceptInsecureCerts"]) + if self.appinfo["name"] == "Firefox": + self.assertTrue(self.caps["setWindowRect"]) + else: + self.assertFalse(self.caps["setWindowRect"]) + self.assertDictEqual( + self.caps["timeouts"], {"implicit": 0, "pageLoad": 300000, "script": 30000} + ) + self.assertTrue(self.caps["strictFileInteractability"]) + + def test_supported_features(self): + self.assertIn("rotatable", self.caps) + + def test_additional_capabilities(self): + self.assertIn("moz:processID", self.caps) + self.assertEqual(self.caps["moz:processID"], self.appinfo["processID"]) + self.assertEqual(self.marionette.process_id, self.appinfo["processID"]) + + self.assertIn("moz:profile", self.caps) + if self.marionette.instance is not None: + if self.caps["browserName"] == "fennec": + current_profile = ( + self.marionette.instance.runner.device.app_ctx.remote_profile + ) + else: + current_profile = self.marionette.profile_path + # Bug 1438461 - mozprofile uses lower-case letters even on case-sensitive filesystems + # Bug 1533221 - paths may differ due to file system links or aliases + self.assertEqual( + os.path.basename(self.caps["moz:profile"]).lower(), + os.path.basename(current_profile).lower(), + ) + + self.assertIn("moz:accessibilityChecks", self.caps) + self.assertFalse(self.caps["moz:accessibilityChecks"]) + + self.assertIn("moz:buildID", self.caps) + self.assertEqual(self.caps["moz:buildID"], self.appinfo["buildID"]) + + self.assertNotIn("moz:debuggerAddress", self.caps) + + self.assertIn("moz:useNonSpecCompliantPointerOrigin", self.caps) + self.assertFalse(self.caps["moz:useNonSpecCompliantPointerOrigin"]) + + self.assertIn("moz:webdriverClick", self.caps) + self.assertTrue(self.caps["moz:webdriverClick"]) + + def test_disable_webdriver_click(self): + self.marionette.delete_session() + self.marionette.start_session({"moz:webdriverClick": False}) + caps = self.marionette.session_capabilities + self.assertFalse(caps["moz:webdriverClick"]) + + def test_use_non_spec_compliant_pointer_origin(self): + self.marionette.delete_session() + self.marionette.start_session({"moz:useNonSpecCompliantPointerOrigin": True}) + caps = self.marionette.session_capabilities + self.assertTrue(caps["moz:useNonSpecCompliantPointerOrigin"]) + + def test_we_get_valid_uuid4_when_creating_a_session(self): + self.assertNotIn( + "{", + self.marionette.session_id, + "Session ID has {{}} in it: {}".format(self.marionette.session_id), + ) + + +class TestCapabilityMatching(MarionetteTestCase): + def setUp(self): + MarionetteTestCase.setUp(self) + self.browser_name = self.marionette.session_capabilities["browserName"] + self.delete_session() + + def delete_session(self): + if self.marionette.session is not None: + self.marionette.delete_session() + + def test_accept_insecure_certs(self): + for value in ["", 42, {}, []]: + print(" type {}".format(type(value))) + with self.assertRaises(SessionNotCreatedException): + self.marionette.start_session({"acceptInsecureCerts": value}) + + self.delete_session() + self.marionette.start_session({"acceptInsecureCerts": True}) + self.assertTrue(self.marionette.session_capabilities["acceptInsecureCerts"]) + + def test_page_load_strategy(self): + for strategy in ["none", "eager", "normal"]: + print("valid strategy {}".format(strategy)) + self.delete_session() + self.marionette.start_session({"pageLoadStrategy": strategy}) + self.assertEqual( + self.marionette.session_capabilities["pageLoadStrategy"], strategy + ) + + for value in ["", "EAGER", True, 42, {}, [], None]: + print("invalid strategy {}".format(value)) + with self.assertRaisesRegexp( + SessionNotCreatedException, "InvalidArgumentError" + ): + self.marionette.start_session({"pageLoadStrategy": value}) + + def test_set_window_rect(self): + if self.browser_name == "firefox": + self.marionette.start_session({"setWindowRect": True}) + self.delete_session() + with self.assertRaisesRegexp( + SessionNotCreatedException, "InvalidArgumentError" + ): + self.marionette.start_session({"setWindowRect": False}) + else: + self.marionette.start_session({"setWindowRect": False}) + self.delete_session() + with self.assertRaisesRegexp( + SessionNotCreatedException, "InvalidArgumentError" + ): + self.marionette.start_session({"setWindowRect": True}) + + def test_timeouts(self): + for value in ["", 2.5, {}, []]: + print(" type {}".format(type(value))) + with self.assertRaises(SessionNotCreatedException): + self.marionette.start_session({"timeouts": {"pageLoad": value}}) + + self.delete_session() + + timeouts = {"implicit": 0, "pageLoad": 2.0, "script": 2 ** 53 - 1} + self.marionette.start_session({"timeouts": timeouts}) + self.assertIn("timeouts", self.marionette.session_capabilities) + self.assertDictEqual(self.marionette.session_capabilities["timeouts"], timeouts) + self.assertDictEqual( + self.marionette._send_message("WebDriver:GetTimeouts"), timeouts + ) + + def test_strict_file_interactability(self): + for value in ["", 2.5, {}, []]: + print(" type {}".format(type(value))) + with self.assertRaises(SessionNotCreatedException): + self.marionette.start_session({"strictFileInteractability": value}) + + self.delete_session() + + self.marionette.start_session({"strictFileInteractability": True}) + self.assertIn("strictFileInteractability", self.marionette.session_capabilities) + self.assertTrue( + self.marionette.session_capabilities["strictFileInteractability"] + ) + + self.delete_session() + + self.marionette.start_session({"strictFileInteractability": False}) + self.assertIn("strictFileInteractability", self.marionette.session_capabilities) + self.assertFalse( + self.marionette.session_capabilities["strictFileInteractability"] + ) + + def test_unhandled_prompt_behavior(self): + behaviors = [ + "accept", + "accept and notify", + "dismiss", + "dismiss and notify", + "ignore", + ] + + for behavior in behaviors: + print("valid unhandled prompt behavior {}".format(behavior)) + self.delete_session() + self.marionette.start_session({"unhandledPromptBehavior": behavior}) + self.assertEqual( + self.marionette.session_capabilities["unhandledPromptBehavior"], + behavior, + ) + + # Default value + self.delete_session() + self.marionette.start_session() + self.assertEqual( + self.marionette.session_capabilities["unhandledPromptBehavior"], + "dismiss and notify", + ) + + # Invalid values + self.delete_session() + for behavior in [None, "", "ACCEPT", True, 42, {}, []]: + print("invalid unhandled prompt behavior {}".format(behavior)) + with self.assertRaisesRegexp( + SessionNotCreatedException, "InvalidArgumentError" + ): + self.marionette.start_session({"unhandledPromptBehavior": behavior}) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_checkbox.py b/testing/marionette/harness/marionette_harness/tests/unit/test_checkbox.py new file mode 100644 index 0000000000..d01fe4bc3a --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_checkbox.py @@ -0,0 +1,19 @@ +# 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/. + +from __future__ import absolute_import + +from marionette_driver.by import By + +from marionette_harness import MarionetteTestCase + + +class TestCheckbox(MarionetteTestCase): + def test_selected(self): + test_html = self.marionette.absolute_url("test.html") + self.marionette.navigate(test_html) + box = self.marionette.find_element(By.NAME, "myCheckBox") + self.assertFalse(box.is_selected()) + box.click() + self.assertTrue(box.is_selected()) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_checkbox_chrome.py b/testing/marionette/harness/marionette_harness/tests/unit/test_checkbox_chrome.py new file mode 100644 index 0000000000..30b1c0fe6d --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_checkbox_chrome.py @@ -0,0 +1,33 @@ +# 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/. + +from __future__ import absolute_import + +from marionette_driver.by import By + +from marionette_harness import MarionetteTestCase, WindowManagerMixin + + +class TestSelectedChrome(WindowManagerMixin, MarionetteTestCase): + def setUp(self): + super(TestSelectedChrome, self).setUp() + + self.marionette.set_context("chrome") + + new_window = self.open_chrome_window("chrome://marionette/content/test.xhtml") + self.marionette.switch_to_window(new_window) + + def tearDown(self): + try: + self.close_all_windows() + finally: + super(TestSelectedChrome, self).tearDown() + + def test_selected(self): + box = self.marionette.find_element(By.ID, "testBox") + self.assertFalse(box.is_selected()) + self.assertFalse( + self.marionette.execute_script("arguments[0].checked = true;", [box]) + ) + self.assertTrue(box.is_selected()) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_chrome.py b/testing/marionette/harness/marionette_harness/tests/unit/test_chrome.py new file mode 100644 index 0000000000..3efe769f0a --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_chrome.py @@ -0,0 +1,33 @@ +from __future__ import absolute_import + +from marionette_harness import MarionetteTestCase, WindowManagerMixin + + +class ChromeTests(WindowManagerMixin, MarionetteTestCase): + def setUp(self): + super(ChromeTests, self).setUp() + + def tearDown(self): + self.close_all_windows() + super(ChromeTests, self).tearDown() + + def test_hang_until_timeout(self): + with self.marionette.using_context("chrome"): + new_window = self.open_window() + self.marionette.switch_to_window(new_window) + + try: + try: + # Raise an exception type which should not be thrown by Marionette + # while running this test. Otherwise it would mask eg. IOError as + # thrown for a socket timeout. + raise NotImplementedError( + "Exception should not cause a hang when " + "closing the chrome window in content " + "context" + ) + finally: + self.marionette.close_chrome_window() + self.marionette.switch_to_window(self.start_window) + except NotImplementedError: + pass diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_chrome_action.py b/testing/marionette/harness/marionette_harness/tests/unit/test_chrome_action.py new file mode 100644 index 0000000000..038194f4da --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_chrome_action.py @@ -0,0 +1,74 @@ +# 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/. + +from __future__ import absolute_import + +from marionette_driver import By, errors +from marionette_driver.keys import Keys + +from marionette_harness import MarionetteTestCase, WindowManagerMixin + + +class TestPointerActions(WindowManagerMixin, MarionetteTestCase): + def setUp(self): + super(TestPointerActions, self).setUp() + + self.actors_enabled = self.marionette.get_pref("marionette.actors.enabled") + if self.actors_enabled is None: + self.actors_enabled = True + + self.mouse_chain = self.marionette.actions.sequence( + "pointer", "pointer_id", {"pointerType": "mouse"} + ) + self.key_chain = self.marionette.actions.sequence("key", "keyboard_id") + + if self.marionette.session_capabilities["platformName"] == "mac": + self.mod_key = Keys.META + else: + self.mod_key = Keys.CONTROL + + self.marionette.set_context("chrome") + + self.win = self.open_chrome_window("chrome://marionette/content/test.xhtml") + self.marionette.switch_to_window(self.win) + + def tearDown(self): + if self.actors_enabled: + self.marionette.actions.release() + self.close_all_windows() + + super(TestPointerActions, self).tearDown() + + def test_click_action(self): + box = self.marionette.find_element(By.ID, "testBox") + box.get_property("localName") + if self.actors_enabled: + self.assertFalse( + self.marionette.execute_script( + "return document.getElementById('testBox').checked" + ) + ) + self.mouse_chain.click(element=box).perform() + self.assertTrue( + self.marionette.execute_script( + "return document.getElementById('testBox').checked" + ) + ) + else: + with self.assertRaises(errors.UnsupportedOperationException): + self.mouse_chain.click(element=box).perform() + + def test_key_action(self): + self.marionette.find_element(By.ID, "textInput").click() + if self.actors_enabled: + self.key_chain.send_keys("x").perform() + self.assertEqual( + self.marionette.execute_script( + "return document.getElementById('textInput').value" + ), + "testx", + ) + else: + with self.assertRaises(errors.UnsupportedOperationException): + self.key_chain.send_keys("x").perform() diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_chrome_element_css.py b/testing/marionette/harness/marionette_harness/tests/unit/test_chrome_element_css.py new file mode 100644 index 0000000000..a160c62fc9 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_chrome_element_css.py @@ -0,0 +1,33 @@ +# 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/. + +from __future__ import absolute_import + +from marionette_driver.by import By + +from marionette_harness import MarionetteTestCase + + +class TestChromeElementCSS(MarionetteTestCase): + def get_element_computed_style(self, element, property): + return self.marionette.execute_script( + """ + const [el, prop] = arguments; + const elStyle = window.getComputedStyle(el); + return elStyle[prop];""", + script_args=(element, property), + sandbox=None, + ) + + def test_we_can_get_css_value_on_chrome_element(self): + with self.marionette.using_context("chrome"): + identity_icon = self.marionette.find_element(By.ID, "identity-icon") + favicon_image = identity_icon.value_of_css_property("list-style-image") + self.assertIn("chrome://", favicon_image) + identity_box = self.marionette.find_element(By.ID, "identity-box") + expected_bg_colour = self.get_element_computed_style( + identity_box, "backgroundColor" + ) + actual_bg_colour = identity_box.value_of_css_property("background-color") + self.assertEqual(expected_bg_colour, actual_bg_colour) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_cli_arguments.py b/testing/marionette/harness/marionette_harness/tests/unit/test_cli_arguments.py new file mode 100644 index 0000000000..6997eba01a --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_cli_arguments.py @@ -0,0 +1,68 @@ +# 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/. + +from __future__ import absolute_import + +import copy + +import requests + +from marionette_harness import MarionetteTestCase + + +class TestCommandLineArguments(MarionetteTestCase): + def setUp(self): + super(TestCommandLineArguments, self).setUp() + + self.orig_arguments = copy.copy(self.marionette.instance.app_args) + + def tearDown(self): + self.marionette.instance.app_args = self.orig_arguments + self.marionette.quit(clean=True) + + super(TestCommandLineArguments, self).tearDown() + + def test_remote_agent_enabled(self): + debugger_address = self.marionette.session_capabilities.get( + "moz:debuggerAddress" + ) + self.assertIsNone(debugger_address) + + self.marionette.instance.app_args.append("-remote-debugging-port") + + self.marionette.quit() + self.marionette.start_session() + + debugger_address = self.marionette.session_capabilities.get( + "moz:debuggerAddress" + ) + + self.assertEqual(debugger_address, "localhost:9222") + result = requests.get(url="http://{}/json/version".format(debugger_address)) + self.assertTrue(result.ok) + + def test_start_in_safe_mode(self): + self.marionette.instance.app_args.append("-safe-mode") + + self.marionette.quit() + self.marionette.start_session() + + with self.marionette.using_context("chrome"): + safe_mode = self.marionette.execute_script( + """ + Cu.import("resource://gre/modules/Services.jsm"); + + return Services.appinfo.inSafeMode; + """ + ) + self.assertTrue(safe_mode, "Safe Mode has not been enabled") + + def test_startup_timeout(self): + try: + self.marionette.quit() + with self.assertRaisesRegexp(IOError, "Process killed after 0s"): + # Use a small enough timeout which should always cause an IOError + self.marionette.start_session(timeout=0) + finally: + self.marionette.start_session() diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_click.py b/testing/marionette/harness/marionette_harness/tests/unit/test_click.py new file mode 100644 index 0000000000..bbe8172bab --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_click.py @@ -0,0 +1,589 @@ +# 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/. + +from __future__ import absolute_import + +from six.moves.urllib.parse import quote + +from marionette_driver import By, errors +from marionette_driver.marionette import Alert + +from marionette_harness import ( + MarionetteTestCase, + WindowManagerMixin, +) + + +def inline(doc): + return "data:text/html;charset=utf-8,{}".format(quote(doc)) + + +# The <a> element in the following HTML is not interactable because it +# is hidden by an overlay when scrolled into the top of the viewport. +# It should be interactable when scrolled in at the bottom of the +# viewport. +fixed_overlay = inline( + """ +<style> +* { margin: 0; padding: 0; } +body { height: 300vh } +div, a { display: block } +div { + background-color: pink; + position: fixed; + width: 100%; + height: 40px; + top: 0; +} +a { + margin-top: 1000px; +} +</style> + +<div>overlay</div> +<a href=#>link</a> + +<script> +window.clicked = false; + +let link = document.querySelector("a"); +link.addEventListener("click", () => window.clicked = true); +</script> +""" +) + + +obscured_overlay = inline( + """ +<style> +* { margin: 0; padding: 0; } +body { height: 100vh } +#overlay { + background-color: pink; + position: absolute; + width: 100%; + height: 100%; +} +</style> + +<div id=overlay></div> +<a id=obscured href=#>link</a> + +<script> +window.clicked = false; + +let link = document.querySelector("#obscured"); +link.addEventListener("click", () => window.clicked = true); +</script> +""" +) + + +class ClickBaseTestCase(WindowManagerMixin, MarionetteTestCase): + def setUp(self): + super(ClickBaseTestCase, self).setUp() + + # Always use a blank new tab for an empty history + self.new_tab = self.open_tab() + self.marionette.switch_to_window(self.new_tab) + + def tearDown(self): + self.close_all_tabs() + + def test_click(self): + self.marionette.navigate( + inline( + """ + <button>click me</button> + <script> + window.clicks = 0; + let button = document.querySelector("button"); + button.addEventListener("click", () => window.clicks++); + </script> + """ + ) + ) + button = self.marionette.find_element(By.TAG_NAME, "button") + button.click() + self.assertEqual( + 1, self.marionette.execute_script("return window.clicks", sandbox=None) + ) + + def test_click_number_link(self): + test_html = self.marionette.absolute_url("clicks.html") + self.marionette.navigate(test_html) + self.marionette.find_element(By.LINK_TEXT, "333333").click() + self.marionette.find_element(By.ID, "testDiv") + self.assertEqual(self.marionette.title, "Marionette Test") + + def test_clicking_an_element_that_is_not_displayed_raises(self): + self.marionette.navigate( + inline( + """ + <p hidden>foo</p> + """ + ) + ) + + with self.assertRaises(errors.ElementNotInteractableException): + self.marionette.find_element(By.TAG_NAME, "p").click() + + def test_clicking_on_a_multiline_link(self): + test_html = self.marionette.absolute_url("clicks.html") + self.marionette.navigate(test_html) + self.marionette.find_element(By.ID, "overflowLink").click() + self.marionette.find_element(By.ID, "testDiv") + self.assertEqual(self.marionette.title, "Marionette Test") + + def test_click_mathml(self): + self.marionette.navigate( + inline( + """ + <math><mtext id="target">click me</mtext></math> + <script> + window.clicks = 0; + let mtext = document.getElementById("target"); + mtext.addEventListener("click", () => window.clicks++); + </script> + """ + ) + ) + mtext = self.marionette.find_element(By.ID, "target") + mtext.click() + self.assertEqual( + 1, self.marionette.execute_script("return window.clicks", sandbox=None) + ) + + def test_scroll_into_view_near_end(self): + self.marionette.navigate(fixed_overlay) + link = self.marionette.find_element(By.TAG_NAME, "a") + link.click() + self.assertTrue( + self.marionette.execute_script("return window.clicked", sandbox=None) + ) + + def test_inclusive_descendant(self): + self.marionette.navigate( + inline( + """ + <select multiple> + <option>first + <option>second + <option>third + </select>""" + ) + ) + select = self.marionette.find_element(By.TAG_NAME, "select") + + # This tests that the pointer-interactability test does not + # cause an ElementClickInterceptedException. + # + # At a <select multiple>'s in-view centre point, you might + # find a fully rendered <option>. Marionette should test that + # the paint tree at this point _contains_ <option>, not that the + # first element of the paint tree is _equal_ to <select>. + select.click() + + # Bug 1413821 - Click does not select an option on Android + if self.marionette.session_capabilities["browserName"] != "fennec": + self.assertNotEqual(select.get_property("selectedIndex"), -1) + + def test_container_is_select(self): + self.marionette.navigate( + inline( + """ + <select> + <option>foo</option> + </select>""" + ) + ) + option = self.marionette.find_element(By.TAG_NAME, "option") + option.click() + self.assertTrue(option.get_property("selected")) + + def test_container_is_button(self): + self.marionette.navigate( + inline( + """ + <button onclick="window.clicked = true;"> + <span><em>foo</em></span> + </button>""" + ) + ) + span = self.marionette.find_element(By.TAG_NAME, "span") + span.click() + self.assertTrue( + self.marionette.execute_script("return window.clicked", sandbox=None) + ) + + def test_container_element_outside_view(self): + self.marionette.navigate( + inline( + """ + <select style="margin-top: 100vh"> + <option>foo</option> + </select>""" + ) + ) + option = self.marionette.find_element(By.TAG_NAME, "option") + option.click() + self.assertTrue(option.get_property("selected")) + + def test_table_tr(self): + self.marionette.navigate( + inline( + """ + <table> + <tr><td onclick="window.clicked = true;"> + foo + </td></tr> + </table>""" + ) + ) + tr = self.marionette.find_element(By.TAG_NAME, "tr") + tr.click() + self.assertTrue( + self.marionette.execute_script("return window.clicked", sandbox=None) + ) + + +class TestLegacyClick(ClickBaseTestCase): + """Uses legacy Selenium element displayedness checks.""" + + def setUp(self): + super(TestLegacyClick, self).setUp() + + self.marionette.delete_session() + self.marionette.start_session({"moz:webdriverClick": False}) + + +class TestClick(ClickBaseTestCase): + """Uses WebDriver specification compatible element interactability checks.""" + + def setUp(self): + super(TestClick, self).setUp() + + self.marionette.delete_session() + self.marionette.start_session({"moz:webdriverClick": True}) + + def test_click_element_obscured_by_absolute_positioned_element(self): + self.marionette.navigate(obscured_overlay) + overlay = self.marionette.find_element(By.ID, "overlay") + obscured = self.marionette.find_element(By.ID, "obscured") + + overlay.click() + with self.assertRaises(errors.ElementClickInterceptedException): + obscured.click() + + def test_centre_outside_viewport_vertically(self): + self.marionette.navigate( + inline( + """ + <style> + * { margin: 0; padding: 0; } + div { + display: block; + position: absolute; + background-color: blue; + width: 200px; + height: 200px; + + /* move centre point off viewport vertically */ + top: -105px; + } + </style> + + <div onclick="window.clicked = true;"></div>""" + ) + ) + + self.marionette.find_element(By.TAG_NAME, "div").click() + self.assertTrue( + self.marionette.execute_script("return window.clicked", sandbox=None) + ) + + def test_centre_outside_viewport_horizontally(self): + self.marionette.navigate( + inline( + """ + <style> + * { margin: 0; padding: 0; } + div { + display: block; + position: absolute; + background-color: blue; + width: 200px; + height: 200px; + + /* move centre point off viewport horizontally */ + left: -105px; + } + </style> + + <div onclick="window.clicked = true;"></div>""" + ) + ) + + self.marionette.find_element(By.TAG_NAME, "div").click() + self.assertTrue( + self.marionette.execute_script("return window.clicked", sandbox=None) + ) + + def test_centre_outside_viewport(self): + self.marionette.navigate( + inline( + """ + <style> + * { margin: 0; padding: 0; } + div { + display: block; + position: absolute; + background-color: blue; + width: 200px; + height: 200px; + + /* move centre point off viewport */ + left: -105px; + top: -105px; + } + </style> + + <div onclick="window.clicked = true;"></div>""" + ) + ) + + self.marionette.find_element(By.TAG_NAME, "div").click() + self.assertTrue( + self.marionette.execute_script("return window.clicked", sandbox=None) + ) + + def test_css_transforms(self): + self.marionette.navigate( + inline( + """ + <style> + * { margin: 0; padding: 0; } + div { + display: block; + background-color: blue; + width: 200px; + height: 200px; + + transform: translateX(-105px); + } + </style> + + <div onclick="window.clicked = true;"></div>""" + ) + ) + + self.marionette.find_element(By.TAG_NAME, "div").click() + self.assertTrue( + self.marionette.execute_script("return window.clicked", sandbox=None) + ) + + def test_input_file(self): + self.marionette.navigate(inline("<input type=file>")) + with self.assertRaises(errors.InvalidArgumentException): + self.marionette.find_element(By.TAG_NAME, "input").click() + + def test_obscured_element(self): + self.marionette.navigate(obscured_overlay) + overlay = self.marionette.find_element(By.ID, "overlay") + obscured = self.marionette.find_element(By.ID, "obscured") + + overlay.click() + with self.assertRaises(errors.ElementClickInterceptedException): + obscured.click() + self.assertFalse( + self.marionette.execute_script("return window.clicked", sandbox=None) + ) + + def test_pointer_events_none(self): + self.marionette.navigate( + inline( + """ + <button style="pointer-events: none">click me</button> + <script> + window.clicked = false; + let button = document.querySelector("button"); + button.addEventListener("click", () => window.clicked = true); + </script> + """ + ) + ) + button = self.marionette.find_element(By.TAG_NAME, "button") + self.assertEqual("none", button.value_of_css_property("pointer-events")) + + with self.assertRaisesRegexp( + errors.ElementClickInterceptedException, + "does not have pointer events enabled", + ): + button.click() + self.assertFalse( + self.marionette.execute_script("return window.clicked", sandbox=None) + ) + + def test_prevent_default(self): + self.marionette.navigate( + inline( + """ + <button>click me</button> + <script> + let button = document.querySelector("button"); + button.addEventListener("click", event => event.preventDefault()); + </script> + """ + ) + ) + button = self.marionette.find_element(By.TAG_NAME, "button") + # should not time out + button.click() + + def test_stop_propagation(self): + self.marionette.navigate( + inline( + """ + <button>click me</button> + <script> + let button = document.querySelector("button"); + button.addEventListener("click", event => event.stopPropagation()); + </script> + """ + ) + ) + button = self.marionette.find_element(By.TAG_NAME, "button") + # should not time out + button.click() + + def test_stop_immediate_propagation(self): + self.marionette.navigate( + inline( + """ + <button>click me</button> + <script> + let button = document.querySelector("button"); + button.addEventListener("click", event => event.stopImmediatePropagation()); + </script> + """ + ) + ) + button = self.marionette.find_element(By.TAG_NAME, "button") + # should not time out + button.click() + + +class TestClickNavigation(WindowManagerMixin, MarionetteTestCase): + def setUp(self): + super(TestClickNavigation, self).setUp() + + # Always use a blank new tab for an empty history + self.new_tab = self.open_tab() + self.marionette.switch_to_window(self.new_tab) + + self.test_page = self.marionette.absolute_url("clicks.html") + self.marionette.navigate(self.test_page) + + def tearDown(self): + self.close_all_tabs() + + def close_notification(self): + try: + with self.marionette.using_context("chrome"): + elem = self.marionette.find_element( + By.CSS_SELECTOR, + "#notification-popup popupnotification .popup-notification-closebutton", + ) + elem.click() + except errors.NoSuchElementException: + pass + + def test_click_link_page_load(self): + self.marionette.find_element(By.LINK_TEXT, "333333").click() + self.assertNotEqual(self.marionette.get_url(), self.test_page) + self.assertEqual(self.marionette.title, "Marionette Test") + + def test_click_link_page_load_dismissed_beforeunload_prompt(self): + self.marionette.navigate( + inline( + """ + <input type="text"></input> + <a href="{}">Click</a> + <script> + window.addEventListener("beforeunload", function (event) {{ + event.preventDefault(); + }}); + </script> + """.format( + self.marionette.absolute_url("clicks.html") + ) + ) + ) + self.marionette.find_element(By.TAG_NAME, "input").send_keys("foo") + self.marionette.find_element(By.TAG_NAME, "a").click() + + # navigation auto-dismisses beforeunload prompt + with self.assertRaises(errors.NoAlertPresentException): + Alert(self.marionette).text + + def test_click_link_anchor(self): + self.marionette.find_element(By.ID, "anchor").click() + self.assertEqual(self.marionette.get_url(), "{}#".format(self.test_page)) + + def test_click_link_install_addon(self): + try: + self.marionette.find_element(By.ID, "install-addon").click() + self.assertEqual(self.marionette.get_url(), self.test_page) + finally: + self.close_notification() + + def test_click_no_link(self): + self.marionette.find_element(By.ID, "links").click() + self.assertEqual(self.marionette.get_url(), self.test_page) + + def test_click_option_navigate(self): + self.marionette.find_element(By.ID, "option").click() + self.marionette.find_element(By.ID, "delay") + + def test_click_remoteness_change(self): + self.marionette.navigate("about:robots") + self.marionette.navigate(self.test_page) + self.marionette.find_element(By.ID, "anchor") + + self.marionette.navigate("about:robots") + with self.assertRaises(errors.NoSuchElementException): + self.marionette.find_element(By.ID, "anchor") + + self.marionette.go_back() + self.marionette.find_element(By.ID, "anchor") + + self.marionette.find_element(By.ID, "history-back").click() + with self.assertRaises(errors.NoSuchElementException): + self.marionette.find_element(By.ID, "anchor") + + +class TestClickCloseContext(WindowManagerMixin, MarionetteTestCase): + def setUp(self): + super(TestClickCloseContext, self).setUp() + + self.test_page = self.marionette.absolute_url("clicks.html") + + def tearDown(self): + self.close_all_tabs() + + super(TestClickCloseContext, self).tearDown() + + def test_click_close_tab(self): + new_tab = self.open_tab() + self.marionette.switch_to_window(new_tab) + + self.marionette.navigate(self.test_page) + self.marionette.find_element(By.ID, "close-window").click() + + def test_click_close_window(self): + new_tab = self.open_window() + self.marionette.switch_to_window(new_tab) + + self.marionette.navigate(self.test_page) + self.marionette.find_element(By.ID, "close-window").click() diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_click_chrome.py b/testing/marionette/harness/marionette_harness/tests/unit/test_click_chrome.py new file mode 100644 index 0000000000..d97d2a3893 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_click_chrome.py @@ -0,0 +1,35 @@ +# 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/. + +from __future__ import absolute_import + +from marionette_driver.by import By + +from marionette_harness import MarionetteTestCase, WindowManagerMixin + + +class TestClickChrome(WindowManagerMixin, MarionetteTestCase): + def setUp(self): + super(TestClickChrome, self).setUp() + + self.marionette.set_context("chrome") + + def tearDown(self): + self.close_all_windows() + + super(TestClickChrome, self).tearDown() + + def test_click(self): + win = self.open_chrome_window("chrome://marionette/content/test.xhtml") + self.marionette.switch_to_window(win) + + def checked(): + return self.marionette.execute_script( + "return arguments[0].checked", script_args=[box] + ) + + box = self.marionette.find_element(By.ID, "testBox") + self.assertFalse(checked()) + box.click() + self.assertTrue(checked()) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_click_scrolling.py b/testing/marionette/harness/marionette_harness/tests/unit/test_click_scrolling.py new file mode 100644 index 0000000000..227afd2499 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_click_scrolling.py @@ -0,0 +1,169 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +from __future__ import absolute_import + +from six.moves.urllib.parse import quote + +from marionette_driver.by import By +from marionette_driver.errors import MoveTargetOutOfBoundsException + +from marionette_harness import MarionetteTestCase + + +def inline(doc): + return "data:text/html;charset=utf-8,{}".format(quote(doc)) + + +class TestClickScrolling(MarionetteTestCase): + def test_clicking_on_anchor_scrolls_page(self): + self.marionette.navigate( + inline( + """ + <a href="#content">Link to content</a> + <div id="content" style="margin-top: 205vh;">Text</div> + """ + ) + ) + + # Focusing on to click, but not actually following, + # the link will scroll it in to view, which is a few + # pixels further than 0 + self.marionette.find_element(By.CSS_SELECTOR, "a").click() + + y_offset = self.marionette.execute_script( + """ + var pageY; + if (typeof(window.pageYOffset) == 'number') { + pageY = window.pageYOffset; + } else { + pageY = document.documentElement.scrollTop; + } + return pageY; + """ + ) + + self.assertGreater(y_offset, 300) + + def test_should_scroll_to_click_on_an_element_hidden_by_overflow(self): + test_html = self.marionette.absolute_url("click_out_of_bounds_overflow.html") + self.marionette.navigate(test_html) + + link = self.marionette.find_element(By.ID, "link") + try: + link.click() + except MoveTargetOutOfBoundsException: + self.fail("Should not be out of bounds") + + def test_should_not_scroll_elements_if_click_point_is_in_view(self): + test_html = self.marionette.absolute_url("element_outside_viewport.html") + + for s in ["top", "right", "bottom", "left"]: + for p in ["50", "30"]: + self.marionette.navigate(test_html) + scroll = self.marionette.execute_script( + "return [window.scrollX, window.scrollY];" + ) + self.marionette.find_element(By.ID, "{0}-{1}".format(s, p)).click() + self.assertEqual( + scroll, + self.marionette.execute_script( + "return [window.scrollX, window.scrollY];" + ), + ) + + def test_do_not_scroll_again_if_element_is_already_in_view(self): + self.marionette.navigate( + inline( + """ + <div style="height: 200vh;"> + <button id="button1" style="margin-top: 105vh">Button1</button> + <button id="button2" style="position: relative; top: 5em">Button2</button> + </div> + """ + ) + ) + button1 = self.marionette.find_element(By.ID, "button1") + button2 = self.marionette.find_element(By.ID, "button2") + + button2.click() + scroll_top = self.marionette.execute_script("return document.body.scrollTop;") + button1.click() + + self.assertEqual( + scroll_top, + self.marionette.execute_script("return document.body.scrollTop;"), + ) + + def test_scroll_radio_button_into_view(self): + self.marionette.navigate( + inline( + """ + <input type="radio" id="radio" style="margin-top: 105vh;"> + """ + ) + ) + self.marionette.find_element(By.ID, "radio").click() + + def test_overflow_scroll_do_not_scroll_elements_which_are_visible(self): + self.marionette.navigate( + inline( + """ + <ul style='overflow: scroll; height: 8em; line-height: 3em'> + <li></li> + <li id="desired">Text</li> + <li></li> + <li></li> + </ul> + """ + ) + ) + + list_el = self.marionette.find_element(By.TAG_NAME, "ul") + expected_y_offset = self.marionette.execute_script( + "return arguments[0].scrollTop;", script_args=(list_el,) + ) + + item = list_el.find_element(By.ID, "desired") + item.click() + + y_offset = self.marionette.execute_script( + "return arguments[0].scrollTop;", script_args=(list_el,) + ) + self.assertEqual(expected_y_offset, y_offset) + + def test_overflow_scroll_click_on_hidden_element(self): + self.marionette.navigate( + inline( + """ + Result: <span id="result"></span> + <ul style='overflow: scroll; width: 150px; height: 8em; line-height: 4em' + onclick="document.getElementById('result').innerText = event.target.id;"> + <li>line1</li> + <li>line2</li> + <li>line3</li> + <li id="line4">line4</li> + </ul> + """ + ) + ) + + self.marionette.find_element(By.ID, "line4").click() + self.assertEqual("line4", self.marionette.find_element(By.ID, "result").text) + + def test_overflow_scroll_vertically_for_click_point_outside_of_viewport(self): + self.marionette.navigate( + inline( + """ + Result: <span id="result"></span> + <div style='overflow: scroll; width: 100px; height: 100px; background-color: yellow;'> + <div id="inner" style="width: 100px; height: 300px; background-color: green;" + onclick="document.getElementById('result').innerText = event.type" ></div> + </div> + """ + ) + ) + + self.marionette.find_element(By.ID, "inner").click() + self.assertEqual("click", self.marionette.find_element(By.ID, "result").text) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_context.py b/testing/marionette/harness/marionette_harness/tests/unit/test_context.py new file mode 100644 index 0000000000..f463e17f42 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_context.py @@ -0,0 +1,84 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +from __future__ import absolute_import + +from marionette_driver.decorators import using_context +from marionette_driver.errors import MarionetteException +from marionette_harness import MarionetteTestCase + + +class ContextTestCase(MarionetteTestCase): + def setUp(self): + super(ContextTestCase, self).setUp() + + # shortcuts to improve readability of these tests + self.chrome = self.marionette.CONTEXT_CHROME + self.content = self.marionette.CONTEXT_CONTENT + + self.assertEqual(self.get_context(), self.content) + + test_url = self.marionette.absolute_url("empty.html") + self.marionette.navigate(test_url) + + def get_context(self): + return self.marionette._send_message("Marionette:GetContext", key="value") + + +class TestSetContext(ContextTestCase): + def test_switch_context(self): + self.marionette.set_context(self.chrome) + self.assertEqual(self.get_context(), self.chrome) + + self.marionette.set_context(self.content) + self.assertEqual(self.get_context(), self.content) + + def test_invalid_context(self): + with self.assertRaises(ValueError): + self.marionette.set_context("foobar") + + +class TestUsingContext(ContextTestCase): + def test_set_different_context_using_with_block(self): + with self.marionette.using_context(self.chrome): + self.assertEqual(self.get_context(), self.chrome) + self.assertEquals(self.get_context(), self.content) + + def test_set_same_context_using_with_block(self): + with self.marionette.using_context(self.content): + self.assertEquals(self.get_context(), self.content) + self.assertEquals(self.get_context(), self.content) + + def test_nested_with_blocks(self): + with self.marionette.using_context(self.chrome): + self.assertEquals(self.get_context(), self.chrome) + with self.marionette.using_context(self.content): + self.assertEquals(self.get_context(), self.content) + self.assertEquals(self.get_context(), self.chrome) + self.assertEquals(self.get_context(), self.content) + + def test_set_scope_while_in_with_block(self): + with self.marionette.using_context(self.chrome): + self.assertEquals(self.get_context(), self.chrome) + self.marionette.set_context(self.content) + self.assertEquals(self.get_context(), self.content) + self.assertEquals(self.get_context(), self.content) + + def test_exception_raised_while_in_with_block_is_propagated(self): + with self.assertRaises(MarionetteException): + with self.marionette.using_context(self.chrome): + raise MarionetteException + self.assertEquals(self.get_context(), self.content) + + def test_with_using_context_decorator(self): + @using_context("content") + def inner_content(m): + self.assertEquals(self.get_context(), "content") + + @using_context("chrome") + def inner_chrome(m): + self.assertEquals(self.get_context(), "chrome") + + inner_content(self.marionette) + inner_chrome(self.marionette) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_cookies.py b/testing/marionette/harness/marionette_harness/tests/unit/test_cookies.py new file mode 100644 index 0000000000..551a572490 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_cookies.py @@ -0,0 +1,117 @@ +# 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/. + +from __future__ import absolute_import, print_function + +import calendar +import random +import time + +from marionette_driver.errors import UnsupportedOperationException +from marionette_harness import MarionetteTestCase + + +class CookieTest(MarionetteTestCase): + def setUp(self): + MarionetteTestCase.setUp(self) + test_url = self.marionette.absolute_url("test.html") + self.marionette.navigate(test_url) + self.COOKIE_A = {"name": "foo", "value": "bar", "path": "/", "secure": False} + + def tearDown(self): + self.marionette.delete_all_cookies() + MarionetteTestCase.tearDown(self) + + def test_add_cookie(self): + self.marionette.add_cookie(self.COOKIE_A) + cookie_returned = str(self.marionette.execute_script("return document.cookie")) + self.assertTrue(self.COOKIE_A["name"] in cookie_returned) + + def test_adding_a_cookie_that_expired_in_the_past(self): + cookie = self.COOKIE_A.copy() + cookie["expiry"] = calendar.timegm(time.gmtime()) - (60 * 60 * 24) + self.marionette.add_cookie(cookie) + cookies = self.marionette.get_cookies() + self.assertEquals(0, len(cookies)) + + def test_chrome_error(self): + with self.marionette.using_context("chrome"): + self.assertRaises( + UnsupportedOperationException, self.marionette.add_cookie, self.COOKIE_A + ) + self.assertRaises( + UnsupportedOperationException, + self.marionette.delete_cookie, + self.COOKIE_A, + ) + self.assertRaises( + UnsupportedOperationException, self.marionette.delete_all_cookies + ) + self.assertRaises( + UnsupportedOperationException, self.marionette.get_cookies + ) + + def test_delete_all_cookie(self): + self.marionette.add_cookie(self.COOKIE_A) + cookie_returned = str(self.marionette.execute_script("return document.cookie")) + print(cookie_returned) + self.assertTrue(self.COOKIE_A["name"] in cookie_returned) + self.marionette.delete_all_cookies() + self.assertFalse(self.marionette.get_cookies()) + + def test_delete_cookie(self): + self.marionette.add_cookie(self.COOKIE_A) + cookie_returned = str(self.marionette.execute_script("return document.cookie")) + self.assertTrue(self.COOKIE_A["name"] in cookie_returned) + self.marionette.delete_cookie("foo") + cookie_returned = str(self.marionette.execute_script("return document.cookie")) + self.assertFalse(self.COOKIE_A["name"] in cookie_returned) + + def test_should_get_cookie_by_name(self): + key = "key_{}".format(int(random.random() * 10000000)) + self.marionette.execute_script( + "document.cookie = arguments[0] + '=set';", [key] + ) + + cookie = self.marionette.get_cookie(key) + self.assertEquals("set", cookie["value"]) + + def test_get_all_cookies(self): + key1 = "key_{}".format(int(random.random() * 10000000)) + key2 = "key_{}".format(int(random.random() * 10000000)) + + cookies = self.marionette.get_cookies() + count = len(cookies) + + one = {"name": key1, "value": "value"} + two = {"name": key2, "value": "value"} + + self.marionette.add_cookie(one) + self.marionette.add_cookie(two) + + test_url = self.marionette.absolute_url("test.html") + self.marionette.navigate(test_url) + cookies = self.marionette.get_cookies() + self.assertEquals(count + 2, len(cookies)) + + def test_should_not_delete_cookies_with_a_similar_name(self): + cookieOneName = "fish" + cookie1 = {"name": cookieOneName, "value": "cod"} + cookie2 = {"name": cookieOneName + "x", "value": "earth"} + self.marionette.add_cookie(cookie1) + self.marionette.add_cookie(cookie2) + + self.marionette.delete_cookie(cookieOneName) + cookies = self.marionette.get_cookies() + + self.assertFalse(cookie1["name"] == cookies[0]["name"], msg=str(cookies)) + self.assertEquals(cookie2["name"], cookies[0]["name"], msg=str(cookies)) + + def test_we_get_required_elements_when_available(self): + self.marionette.add_cookie(self.COOKIE_A) + cookies = self.marionette.get_cookies() + + self.assertIn("name", cookies[0], "name not available") + self.assertIn("value", cookies[0], "value not available") + self.assertIn("httpOnly", cookies[0], "httpOnly not available") diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_crash.py b/testing/marionette/harness/marionette_harness/tests/unit/test_crash.py new file mode 100644 index 0000000000..65e4b87a1c --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_crash.py @@ -0,0 +1,216 @@ +# 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/. + +from __future__ import absolute_import, print_function + +import glob +import os +import shutil +import sys +import unittest +from io import StringIO + +import six + +from marionette_driver import Wait +from marionette_driver.errors import ( + InvalidSessionIdException, + NoSuchWindowException, + TimeoutException, +) + +from marionette_harness import MarionetteTestCase, expectedFailure + +# Import runner module to monkey patch mozcrash module +from mozrunner.base import runner + + +class MockMozCrash(object): + """Mock object to replace original mozcrash methods.""" + + def __init__(self, marionette): + self.marionette = marionette + + with self.marionette.using_context("chrome"): + self.crash_reporter_enabled = self.marionette.execute_script( + """ + Cu.import("resource://gre/modules/AppConstants.jsm"); + return AppConstants.MOZ_CRASHREPORTER; + """ + ) + + def check_for_crashes(self, dump_directory, *args, **kwargs): + if self.crash_reporter_enabled: + # Workaround until bug 1376795 has been fixed + # Wait at maximum 5s for the minidump files being created + # minidump_files = glob.glob('{}/*.dmp'.format(dump_directory)) + try: + minidump_files = Wait(None, timeout=5).until( + lambda _: glob.glob("{}/*.dmp".format(dump_directory)) + ) + except TimeoutException: + minidump_files = [] + + if os.path.isdir(dump_directory): + shutil.rmtree(dump_directory) + + return len(minidump_files) + else: + return len(minidump_files) == 0 + + def log_crashes(self, logger, dump_directory, *args, **kwargs): + return self.check_for_crashes(dump_directory, *args, **kwargs) + + +class BaseCrashTestCase(MarionetteTestCase): + + # Reduce the timeout for faster processing of the tests + socket_timeout = 10 + + def setUp(self): + super(BaseCrashTestCase, self).setUp() + + # Monkey patch mozcrash to avoid crash info output only for our triggered crashes. + mozcrash_mock = MockMozCrash(self.marionette) + if not mozcrash_mock.crash_reporter_enabled: + self.skipTest("Crash reporter disabled") + return + + self.mozcrash = runner.mozcrash + runner.mozcrash = mozcrash_mock + + self.crash_count = self.marionette.crashed + self.pid = self.marionette.process_id + + def tearDown(self): + # Replace mockup with original mozcrash instance + runner.mozcrash = self.mozcrash + + self.marionette.crashed = self.crash_count + + super(BaseCrashTestCase, self).tearDown() + + def crash(self, parent=True): + socket_timeout = self.marionette.client.socket_timeout + self.marionette.client.socket_timeout = self.socket_timeout + + self.marionette.set_context("content") + try: + self.marionette.navigate( + "about:crash{}".format("parent" if parent else "content") + ) + finally: + self.marionette.client.socket_timeout = socket_timeout + + +class TestCrash(BaseCrashTestCase): + def setUp(self): + if os.environ.get("MOZ_AUTOMATION"): + # Capture stdout, otherwise the Gecko output causes mozharness to fail + # the task due to "A content process has crashed" appearing in the log. + # To view stdout for debugging, use `print(self.new_out.getvalue())` + print( + "Suppressing GECKO output. To view, add `print(self.new_out.getvalue())` " + "to the end of this test." + ) + self.new_out, self.new_err = StringIO(), StringIO() + self.old_out, self.old_err = sys.stdout, sys.stderr + sys.stdout, sys.stderr = self.new_out, self.new_err + + super(TestCrash, self).setUp() + + def tearDown(self): + super(TestCrash, self).tearDown() + + if os.environ.get("MOZ_AUTOMATION"): + sys.stdout, sys.stderr = self.old_out, self.old_err + + def test_crash_chrome_process(self): + self.assertRaisesRegexp(IOError, "Process crashed", self.crash, parent=True) + + # A crash results in a non zero exit code + self.assertNotIn(self.marionette.instance.runner.returncode, (None, 0)) + + self.assertEqual(self.marionette.crashed, 1) + self.assertIsNone(self.marionette.session) + with self.assertRaisesRegexp( + InvalidSessionIdException, "Please start a session" + ): + self.marionette.get_url() + + self.marionette.start_session() + self.assertNotEqual(self.marionette.process_id, self.pid) + + self.marionette.get_url() + + def test_crash_content_process(self): + # For a content process crash and MOZ_CRASHREPORTER_SHUTDOWN set the top + # browsing context will be gone first. As such the raised NoSuchWindowException + # has to be ignored. To check for the IOError, further commands have to + # be executed until the process is gone. + with self.assertRaisesRegexp(IOError, "Content process crashed"): + self.crash(parent=False) + Wait( + self.marionette, + timeout=self.socket_timeout, + ignored_exceptions=NoSuchWindowException, + ).until( + lambda _: self.marionette.get_url(), + message="Expected IOError exception for content crash not raised.", + ) + + # In the case of a content crash Firefox will be closed and its + # returncode will report 0 (this will change with 1370520). + self.assertEqual(self.marionette.instance.runner.returncode, 0) + + self.assertEqual(self.marionette.crashed, 1) + self.assertIsNone(self.marionette.session) + with self.assertRaisesRegexp( + InvalidSessionIdException, "Please start a session" + ): + self.marionette.get_url() + + self.marionette.start_session() + self.assertNotEqual(self.marionette.process_id, self.pid) + self.marionette.get_url() + + @unittest.expectedFailure + @unittest.skipIf(six.PY3, "Bug 1641226 - Not supported in Python3.") + def test_unexpected_crash(self): + self.crash(parent=True) + + +class TestCrashInSetUp(BaseCrashTestCase): + def setUp(self): + super(TestCrashInSetUp, self).setUp() + + self.assertRaisesRegexp(IOError, "Process crashed", self.crash, parent=True) + + # A crash results in a non zero exit code + self.assertNotIn(self.marionette.instance.runner.returncode, (None, 0)) + + self.assertEqual(self.marionette.crashed, 1) + self.assertIsNone(self.marionette.session) + + def test_crash_in_setup(self): + self.marionette.start_session() + self.assertNotEqual(self.marionette.process_id, self.pid) + + +class TestCrashInTearDown(BaseCrashTestCase): + def tearDown(self): + try: + self.assertRaisesRegexp(IOError, "Process crashed", self.crash, parent=True) + + # A crash results in a non zero exit code + self.assertNotIn(self.marionette.instance.runner.returncode, (None, 0)) + + self.assertEqual(self.marionette.crashed, 1) + self.assertIsNone(self.marionette.session) + + finally: + super(TestCrashInTearDown, self).tearDown() + + def test_crash_in_teardown(self): + pass diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_data_driven.py b/testing/marionette/harness/marionette_harness/tests/unit/test_data_driven.py new file mode 100644 index 0000000000..4d2a882cae --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_data_driven.py @@ -0,0 +1,74 @@ +# 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/. + +from __future__ import absolute_import + +import six + +from marionette_harness.marionette_test import ( + parameterized, + with_parameters, + MetaParameterized, + MarionetteTestCase, +) + + +@six.add_metaclass(MetaParameterized) +class Parameterizable(object): + pass + + +class TestDataDriven(MarionetteTestCase): + def test_parameterized(self): + class Test(Parameterizable): + def __init__(self): + self.parameters = [] + + @parameterized("1", "thing", named=43) + @parameterized("2", "thing2") + def test(self, thing, named=None): + self.parameters.append((thing, named)) + + self.assertFalse(hasattr(Test, "test")) + self.assertTrue(hasattr(Test, "test_1")) + self.assertTrue(hasattr(Test, "test_2")) + + test = Test() + test.test_1() + test.test_2() + + self.assertEquals(test.parameters, [("thing", 43), ("thing2", None)]) + + def test_with_parameters(self): + DATA = [("1", ("thing",), {"named": 43}), ("2", ("thing2",), {"named": None})] + + class Test(Parameterizable): + def __init__(self): + self.parameters = [] + + @with_parameters(DATA) + def test(self, thing, named=None): + self.parameters.append((thing, named)) + + self.assertFalse(hasattr(Test, "test")) + self.assertTrue(hasattr(Test, "test_1")) + self.assertTrue(hasattr(Test, "test_2")) + + test = Test() + test.test_1() + test.test_2() + + self.assertEquals(test.parameters, [("thing", 43), ("thing2", None)]) + + def test_parameterized_same_name_raises_error(self): + with self.assertRaises(KeyError): + + class Test(Parameterizable): + @parameterized("1", "thing", named=43) + @parameterized("1", "thing2") + def test(self, thing, named=None): + pass + + def test_marionette_test_case_is_parameterizable(self): + self.assertTrue(isinstance(MarionetteTestCase, MetaParameterized)) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_date_time_value.py b/testing/marionette/harness/marionette_harness/tests/unit/test_date_time_value.py new file mode 100644 index 0000000000..6f81c25b1d --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_date_time_value.py @@ -0,0 +1,35 @@ +# 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/. + +from __future__ import absolute_import + +from datetime import datetime + +from six.moves.urllib.parse import quote + +from marionette_driver.by import By +from marionette_driver.date_time_value import DateTimeValue +from marionette_harness import MarionetteTestCase + + +def inline(doc): + return "data:text/html;charset=utf-8,{}".format(quote(doc)) + + +class TestDateTime(MarionetteTestCase): + def test_set_date(self): + self.marionette.navigate(inline("<input id='date-test' type='date'/>")) + + element = self.marionette.find_element(By.ID, "date-test") + dt_value = DateTimeValue(element) + dt_value.date = datetime(1998, 6, 2) + self.assertEqual("1998-06-02", element.get_property("value")) + + def test_set_time(self): + self.marionette.navigate(inline("<input id='time-test' type='time'/>")) + + element = self.marionette.find_element(By.ID, "time-test") + dt_value = DateTimeValue(element) + dt_value.time = datetime(1998, 11, 19, 9, 8, 7) + self.assertEqual("09:08:07", element.get_property("value")) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_element_rect.py b/testing/marionette/harness/marionette_harness/tests/unit/test_element_rect.py new file mode 100644 index 0000000000..cfcbd25c16 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_element_rect.py @@ -0,0 +1,24 @@ +# 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/. + +from __future__ import absolute_import + +from six.moves.urllib.parse import quote + +from marionette_driver.by import By +from marionette_harness import MarionetteTestCase + + +def inline(doc): + return "data:text/html;charset=utf-8,{}".format(quote(doc)) + + +class TestElementSize(MarionetteTestCase): + def test_payload(self): + self.marionette.navigate(inline("""<a href="#">link</a>""")) + rect = self.marionette.find_element(By.LINK_TEXT, "link").rect + self.assertTrue(rect["x"] > 0) + self.assertTrue(rect["y"] > 0) + self.assertTrue(rect["width"] > 0) + self.assertTrue(rect["height"] > 0) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_element_rect_chrome.py b/testing/marionette/harness/marionette_harness/tests/unit/test_element_rect_chrome.py new file mode 100644 index 0000000000..edb82405f2 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_element_rect_chrome.py @@ -0,0 +1,30 @@ +# 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/. + +from __future__ import absolute_import + +from marionette_driver.by import By + +from marionette_harness import MarionetteTestCase, WindowManagerMixin + + +class TestElementSizeChrome(WindowManagerMixin, MarionetteTestCase): + def setUp(self): + super(TestElementSizeChrome, self).setUp() + + self.marionette.set_context("chrome") + + new_window = self.open_chrome_window("chrome://marionette/content/test2.xhtml") + self.marionette.switch_to_window(new_window) + + def tearDown(self): + self.close_all_windows() + super(TestElementSizeChrome, self).tearDown() + + def test_payload(self): + rect = self.marionette.find_element(By.ID, "textInput").rect + self.assertTrue(rect["x"] > 0) + self.assertTrue(rect["y"] > 0) + self.assertTrue(rect["width"] > 0) + self.assertTrue(rect["height"] > 0) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_element_retrieval.py b/testing/marionette/harness/marionette_harness/tests/unit/test_element_retrieval.py new file mode 100644 index 0000000000..5e893984f1 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_element_retrieval.py @@ -0,0 +1,503 @@ +# 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/. + +from __future__ import absolute_import + +import re + +from six.moves.urllib.parse import quote + +from marionette_driver.by import By +from marionette_driver.errors import NoSuchElementException, InvalidSelectorException +from marionette_driver.marionette import HTMLElement + +from marionette_harness import MarionetteTestCase, skip + + +def inline(doc, doctype="html"): + if doctype == "html": + return "data:text/html;charset=utf-8,{}".format(quote(doc)) + elif doctype == "xhtml": + return "data:application/xhtml+xml,{}".format( + quote( + r"""<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" + "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"> +<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en"> + <head> + <title>XHTML might be the future</title> + </head> + + <body> + {} + </body> +</html>""".format( + doc + ) + ) + ) + + +id_html = inline("<p id=foo></p>", doctype="html") +id_xhtml = inline('<p id="foo"></p>', doctype="xhtml") +parent_child_html = inline("<div id=parent><p id=child></p></div>", doctype="html") +parent_child_xhtml = inline( + '<div id="parent"><p id="child"></p></div>', doctype="xhtml" +) +children_html = inline("<div><p>foo <p>bar</div>", doctype="html") +children_xhtml = inline("<div><p>foo</p> <p>bar</p></div>", doctype="xhtml") +class_html = inline("<p class='foo bar'>", doctype="html") +class_xhtml = inline('<p class="foo bar"></p>', doctype="xhtml") +name_html = inline("<p name=foo>", doctype="html") +name_xhtml = inline('<p name="foo"></p>', doctype="xhtml") + + +class TestFindElementHTML(MarionetteTestCase): + def setUp(self): + MarionetteTestCase.setUp(self) + self.marionette.timeout.implicit = 0 + + def test_id(self): + self.marionette.navigate(id_html) + expected = self.marionette.execute_script("return document.querySelector('p')") + found = self.marionette.find_element(By.ID, "foo") + self.assertIsInstance(found, HTMLElement) + self.assertEqual(expected, found) + + def test_child_element(self): + self.marionette.navigate(parent_child_html) + parent = self.marionette.find_element(By.ID, "parent") + child = self.marionette.find_element(By.ID, "child") + found = parent.find_element(By.TAG_NAME, "p") + self.assertEqual(found.tag_name, "p") + self.assertIsInstance(found, HTMLElement) + self.assertEqual(child, found) + + def test_tag_name(self): + self.marionette.navigate(children_html) + el = self.marionette.execute_script("return document.querySelector('p')") + found = self.marionette.find_element(By.TAG_NAME, "p") + self.assertIsInstance(found, HTMLElement) + self.assertEqual(el, found) + + def test_class_name(self): + self.marionette.navigate(class_html) + el = self.marionette.execute_script("return document.querySelector('.foo')") + found = self.marionette.find_element(By.CLASS_NAME, "foo") + self.assertIsInstance(found, HTMLElement) + self.assertEqual(el, found) + + def test_by_name(self): + self.marionette.navigate(name_html) + el = self.marionette.execute_script( + "return document.querySelector('[name=foo]')" + ) + found = self.marionette.find_element(By.NAME, "foo") + self.assertIsInstance(found, HTMLElement) + self.assertEqual(el, found) + + def test_css_selector(self): + self.marionette.navigate(children_html) + el = self.marionette.execute_script("return document.querySelector('p')") + found = self.marionette.find_element(By.CSS_SELECTOR, "p") + self.assertIsInstance(found, HTMLElement) + self.assertEqual(el, found) + + def test_invalid_css_selector_should_throw(self): + with self.assertRaises(InvalidSelectorException): + self.marionette.find_element(By.CSS_SELECTOR, "#") + + def test_xpath(self): + self.marionette.navigate(id_html) + el = self.marionette.execute_script("return document.querySelector('#foo')") + found = self.marionette.find_element(By.XPATH, "id('foo')") + self.assertIsInstance(found, HTMLElement) + self.assertEqual(el, found) + + def test_not_found(self): + self.marionette.timeout.implicit = 0 + self.assertRaises( + NoSuchElementException, + self.marionette.find_element, + By.CLASS_NAME, + "cheese", + ) + self.assertRaises( + NoSuchElementException, + self.marionette.find_element, + By.CSS_SELECTOR, + "cheese", + ) + self.assertRaises( + NoSuchElementException, self.marionette.find_element, By.ID, "cheese" + ) + self.assertRaises( + NoSuchElementException, self.marionette.find_element, By.LINK_TEXT, "cheese" + ) + self.assertRaises( + NoSuchElementException, self.marionette.find_element, By.NAME, "cheese" + ) + self.assertRaises( + NoSuchElementException, + self.marionette.find_element, + By.PARTIAL_LINK_TEXT, + "cheese", + ) + self.assertRaises( + NoSuchElementException, self.marionette.find_element, By.TAG_NAME, "cheese" + ) + self.assertRaises( + NoSuchElementException, self.marionette.find_element, By.XPATH, "cheese" + ) + + def test_not_found_implicit_wait(self): + self.marionette.timeout.implicit = 0.5 + self.assertRaises( + NoSuchElementException, + self.marionette.find_element, + By.CLASS_NAME, + "cheese", + ) + self.assertRaises( + NoSuchElementException, + self.marionette.find_element, + By.CSS_SELECTOR, + "cheese", + ) + self.assertRaises( + NoSuchElementException, self.marionette.find_element, By.ID, "cheese" + ) + self.assertRaises( + NoSuchElementException, self.marionette.find_element, By.LINK_TEXT, "cheese" + ) + self.assertRaises( + NoSuchElementException, self.marionette.find_element, By.NAME, "cheese" + ) + self.assertRaises( + NoSuchElementException, + self.marionette.find_element, + By.PARTIAL_LINK_TEXT, + "cheese", + ) + self.assertRaises( + NoSuchElementException, self.marionette.find_element, By.TAG_NAME, "cheese" + ) + self.assertRaises( + NoSuchElementException, self.marionette.find_element, By.XPATH, "cheese" + ) + + def test_not_found_from_element(self): + self.marionette.timeout.implicit = 0 + self.marionette.navigate(id_html) + el = self.marionette.find_element(By.ID, "foo") + self.assertRaises( + NoSuchElementException, el.find_element, By.CLASS_NAME, "cheese" + ) + self.assertRaises( + NoSuchElementException, el.find_element, By.CSS_SELECTOR, "cheese" + ) + self.assertRaises(NoSuchElementException, el.find_element, By.ID, "cheese") + self.assertRaises( + NoSuchElementException, el.find_element, By.LINK_TEXT, "cheese" + ) + self.assertRaises(NoSuchElementException, el.find_element, By.NAME, "cheese") + self.assertRaises( + NoSuchElementException, el.find_element, By.PARTIAL_LINK_TEXT, "cheese" + ) + self.assertRaises( + NoSuchElementException, el.find_element, By.TAG_NAME, "cheese" + ) + self.assertRaises(NoSuchElementException, el.find_element, By.XPATH, "cheese") + + def test_not_found_implicit_wait_from_element(self): + self.marionette.timeout.implicit = 0.5 + self.marionette.navigate(id_html) + el = self.marionette.find_element(By.ID, "foo") + self.assertRaises( + NoSuchElementException, el.find_element, By.CLASS_NAME, "cheese" + ) + self.assertRaises( + NoSuchElementException, el.find_element, By.CSS_SELECTOR, "cheese" + ) + self.assertRaises(NoSuchElementException, el.find_element, By.ID, "cheese") + self.assertRaises( + NoSuchElementException, el.find_element, By.LINK_TEXT, "cheese" + ) + self.assertRaises(NoSuchElementException, el.find_element, By.NAME, "cheese") + self.assertRaises( + NoSuchElementException, el.find_element, By.PARTIAL_LINK_TEXT, "cheese" + ) + self.assertRaises( + NoSuchElementException, el.find_element, By.TAG_NAME, "cheese" + ) + self.assertRaises(NoSuchElementException, el.find_element, By.XPATH, "cheese") + + def test_css_selector_scope_doesnt_start_at_rootnode(self): + self.marionette.navigate(parent_child_html) + el = self.marionette.find_element(By.ID, "child") + parent = self.marionette.find_element(By.ID, "parent") + found = parent.find_element(By.CSS_SELECTOR, "p") + self.assertEqual(el, found) + + def test_unknown_selector(self): + with self.assertRaises(InvalidSelectorException): + self.marionette.find_elements("foo", "bar") + + def test_element_id_is_valid_uuid(self): + self.marionette.navigate(id_html) + el = self.marionette.find_element(By.TAG_NAME, "p") + uuid_regex = re.compile( + "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + ) + self.assertIsNotNone( + re.search(uuid_regex, el.id), + "UUID for the WebElement is not valid. ID is {}".format(el.id), + ) + + def test_invalid_xpath_selector(self): + with self.assertRaises(InvalidSelectorException): + self.marionette.find_element(By.XPATH, "count(//input)") + with self.assertRaises(InvalidSelectorException): + parent = self.marionette.execute_script("return document.documentElement") + parent.find_element(By.XPATH, "count(//input)") + + def test_invalid_css_selector(self): + with self.assertRaises(InvalidSelectorException): + self.marionette.find_element(By.CSS_SELECTOR, "") + with self.assertRaises(InvalidSelectorException): + parent = self.marionette.execute_script("return document.documentElement") + parent.find_element(By.CSS_SELECTOR, "") + + def test_finding_active_element_returns_element(self): + self.marionette.navigate(id_html) + active = self.marionette.execute_script("return document.activeElement") + self.assertEqual(active, self.marionette.get_active_element()) + + +class TestFindElementXHTML(MarionetteTestCase): + def setUp(self): + MarionetteTestCase.setUp(self) + self.marionette.timeout.implicit = 0 + + def test_id(self): + self.marionette.navigate(id_xhtml) + expected = self.marionette.execute_script("return document.querySelector('p')") + found = self.marionette.find_element(By.ID, "foo") + self.assertIsInstance(found, HTMLElement) + self.assertEqual(expected, found) + + def test_child_element(self): + self.marionette.navigate(parent_child_xhtml) + parent = self.marionette.find_element(By.ID, "parent") + child = self.marionette.find_element(By.ID, "child") + found = parent.find_element(By.TAG_NAME, "p") + self.assertEqual(found.tag_name, "p") + self.assertIsInstance(found, HTMLElement) + self.assertEqual(child, found) + + def test_tag_name(self): + self.marionette.navigate(children_xhtml) + el = self.marionette.execute_script("return document.querySelector('p')") + found = self.marionette.find_element(By.TAG_NAME, "p") + self.assertIsInstance(found, HTMLElement) + self.assertEqual(el, found) + + def test_class_name(self): + self.marionette.navigate(class_xhtml) + el = self.marionette.execute_script("return document.querySelector('.foo')") + found = self.marionette.find_element(By.CLASS_NAME, "foo") + self.assertIsInstance(found, HTMLElement) + self.assertEqual(el, found) + + def test_by_name(self): + self.marionette.navigate(name_xhtml) + el = self.marionette.execute_script( + "return document.querySelector('[name=foo]')" + ) + found = self.marionette.find_element(By.NAME, "foo") + self.assertIsInstance(found, HTMLElement) + self.assertEqual(el, found) + + def test_css_selector(self): + self.marionette.navigate(children_xhtml) + el = self.marionette.execute_script("return document.querySelector('p')") + found = self.marionette.find_element(By.CSS_SELECTOR, "p") + self.assertIsInstance(found, HTMLElement) + self.assertEqual(el, found) + + def test_xpath(self): + self.marionette.navigate(id_xhtml) + el = self.marionette.execute_script("return document.querySelector('#foo')") + found = self.marionette.find_element(By.XPATH, "id('foo')") + self.assertIsInstance(found, HTMLElement) + self.assertEqual(el, found) + + def test_css_selector_scope_does_not_start_at_rootnode(self): + self.marionette.navigate(parent_child_xhtml) + el = self.marionette.find_element(By.ID, "child") + parent = self.marionette.find_element(By.ID, "parent") + found = parent.find_element(By.CSS_SELECTOR, "p") + self.assertEqual(el, found) + + def test_active_element(self): + self.marionette.navigate(id_xhtml) + active = self.marionette.execute_script("return document.activeElement") + self.assertEqual(active, self.marionette.get_active_element()) + + +class TestFindElementsHTML(MarionetteTestCase): + def setUp(self): + MarionetteTestCase.setUp(self) + self.marionette.timeout.implicit = 0 + + def assertItemsIsInstance(self, items, typ): + for item in items: + self.assertIsInstance(item, typ) + + def test_child_elements(self): + self.marionette.navigate(children_html) + parent = self.marionette.find_element(By.TAG_NAME, "div") + children = self.marionette.find_elements(By.TAG_NAME, "p") + found = parent.find_elements(By.TAG_NAME, "p") + self.assertItemsIsInstance(found, HTMLElement) + self.assertSequenceEqual(found, children) + + def test_tag_name(self): + self.marionette.navigate(children_html) + els = self.marionette.execute_script("return document.querySelectorAll('p')") + found = self.marionette.find_elements(By.TAG_NAME, "p") + self.assertItemsIsInstance(found, HTMLElement) + self.assertSequenceEqual(els, found) + + def test_class_name(self): + self.marionette.navigate(class_html) + els = self.marionette.execute_script("return document.querySelectorAll('.foo')") + found = self.marionette.find_elements(By.CLASS_NAME, "foo") + self.assertItemsIsInstance(found, HTMLElement) + self.assertSequenceEqual(els, found) + + def test_by_name(self): + self.marionette.navigate(name_html) + els = self.marionette.execute_script( + "return document.querySelectorAll('[name=foo]')" + ) + found = self.marionette.find_elements(By.NAME, "foo") + self.assertItemsIsInstance(found, HTMLElement) + self.assertSequenceEqual(els, found) + + def test_css_selector(self): + self.marionette.navigate(children_html) + els = self.marionette.execute_script("return document.querySelectorAll('p')") + found = self.marionette.find_elements(By.CSS_SELECTOR, "p") + self.assertItemsIsInstance(found, HTMLElement) + self.assertSequenceEqual(els, found) + + def test_invalid_css_selector_should_throw(self): + with self.assertRaises(InvalidSelectorException): + self.marionette.find_elements(By.CSS_SELECTOR, "#") + + def test_xpath(self): + self.marionette.navigate(children_html) + els = self.marionette.execute_script("return document.querySelectorAll('p')") + found = self.marionette.find_elements(By.XPATH, ".//p") + self.assertItemsIsInstance(found, HTMLElement) + self.assertSequenceEqual(els, found) + + def test_css_selector_scope_doesnt_start_at_rootnode(self): + self.marionette.navigate(parent_child_html) + els = self.marionette.find_elements(By.ID, "child") + parent = self.marionette.find_element(By.ID, "parent") + found = parent.find_elements(By.CSS_SELECTOR, "p") + self.assertSequenceEqual(els, found) + + def test_unknown_selector(self): + with self.assertRaises(InvalidSelectorException): + self.marionette.find_element("foo", "bar") + + def test_element_id_is_valid_uuid(self): + self.marionette.navigate(id_html) + els = self.marionette.find_elements(By.TAG_NAME, "p") + uuid_regex = re.compile( + "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + ) + self.assertIsNotNone( + re.search(uuid_regex, els[0].id), + "UUID for the WebElement is not valid. ID is {}".format(els[0].id), + ) + + def test_invalid_xpath_selector(self): + with self.assertRaises(InvalidSelectorException): + self.marionette.find_elements(By.XPATH, "count(//input)") + with self.assertRaises(InvalidSelectorException): + parent = self.marionette.execute_script("return document.documentElement") + parent.find_elements(By.XPATH, "count(//input)") + + def test_invalid_css_selector(self): + with self.assertRaises(InvalidSelectorException): + self.marionette.find_elements(By.CSS_SELECTOR, "") + with self.assertRaises(InvalidSelectorException): + parent = self.marionette.execute_script("return document.documentElement") + parent.find_elements(By.CSS_SELECTOR, "") + + +class TestFindElementsXHTML(MarionetteTestCase): + def setUp(self): + MarionetteTestCase.setUp(self) + self.marionette.timeout.implicit = 0 + + def assertItemsIsInstance(self, items, typ): + for item in items: + self.assertIsInstance(item, typ) + + def test_child_elements(self): + self.marionette.navigate(children_xhtml) + parent = self.marionette.find_element(By.TAG_NAME, "div") + children = self.marionette.find_elements(By.TAG_NAME, "p") + found = parent.find_elements(By.TAG_NAME, "p") + self.assertItemsIsInstance(found, HTMLElement) + self.assertSequenceEqual(found, children) + + def test_tag_name(self): + self.marionette.navigate(children_xhtml) + els = self.marionette.execute_script("return document.querySelectorAll('p')") + found = self.marionette.find_elements(By.TAG_NAME, "p") + self.assertItemsIsInstance(found, HTMLElement) + self.assertSequenceEqual(els, found) + + def test_class_name(self): + self.marionette.navigate(class_xhtml) + els = self.marionette.execute_script("return document.querySelectorAll('.foo')") + found = self.marionette.find_elements(By.CLASS_NAME, "foo") + self.assertItemsIsInstance(found, HTMLElement) + self.assertSequenceEqual(els, found) + + def test_by_name(self): + self.marionette.navigate(name_xhtml) + els = self.marionette.execute_script( + "return document.querySelectorAll('[name=foo]')" + ) + found = self.marionette.find_elements(By.NAME, "foo") + self.assertItemsIsInstance(found, HTMLElement) + self.assertSequenceEqual(els, found) + + def test_css_selector(self): + self.marionette.navigate(children_xhtml) + els = self.marionette.execute_script("return document.querySelectorAll('p')") + found = self.marionette.find_elements(By.CSS_SELECTOR, "p") + self.assertItemsIsInstance(found, HTMLElement) + self.assertSequenceEqual(els, found) + + @skip("XHTML namespace not yet supported") + def test_xpath(self): + self.marionette.navigate(children_xhtml) + els = self.marionette.execute_script("return document.querySelectorAll('p')") + found = self.marionette.find_elements(By.XPATH, "//xhtml:p") + self.assertItemsIsInstance(found, HTMLElement) + self.assertSequenceEqual(els, found) + + def test_css_selector_scope_doesnt_start_at_rootnode(self): + self.marionette.navigate(parent_child_xhtml) + els = self.marionette.find_elements(By.ID, "child") + parent = self.marionette.find_element(By.ID, "parent") + found = parent.find_elements(By.CSS_SELECTOR, "p") + self.assertSequenceEqual(els, found) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_element_state.py b/testing/marionette/harness/marionette_harness/tests/unit/test_element_state.py new file mode 100644 index 0000000000..231f70c78d --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_element_state.py @@ -0,0 +1,177 @@ +# 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/. + +from __future__ import absolute_import, print_function + +import types + +import six +from six.moves.urllib.parse import quote + +from marionette_driver.by import By +from marionette_harness import MarionetteTestCase + + +boolean_attributes = { + "audio": ["autoplay", "controls", "loop", "muted"], + "button": ["autofocus", "disabled", "formnovalidate"], + "details": ["open"], + "dialog": ["open"], + "fieldset": ["disabled"], + "form": ["novalidate"], + "iframe": ["allowfullscreen"], + "img": ["ismap"], + "input": [ + "autofocus", + "checked", + "disabled", + "formnovalidate", + "multiple", + "readonly", + "required", + ], + "menuitem": ["checked", "default", "disabled"], + "ol": ["reversed"], + "optgroup": ["disabled"], + "option": ["disabled", "selected"], + "script": ["async", "defer"], + "select": ["autofocus", "disabled", "multiple", "required"], + "textarea": ["autofocus", "disabled", "readonly", "required"], + "track": ["default"], + "video": ["autoplay", "controls", "loop", "muted"], +} + + +def inline(doc, doctype="html"): + if doctype == "html": + return "data:text/html;charset=utf-8,{}".format(quote(doc)) + elif doctype == "xhtml": + return "data:application/xhtml+xml,{}".format( + quote( + r"""<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" + "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"> +<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en"> + <head> + <title>XHTML might be the future</title> + </head> + + <body> + {} + </body> +</html>""".format( + doc + ) + ) + ) + + +attribute = inline("<input foo=bar>") +input = inline("<input>") +disabled = inline("<input disabled=baz>") +check = inline("<input type=checkbox>") + + +class TestIsElementEnabled(MarionetteTestCase): + def test_is_enabled(self): + test_html = self.marionette.absolute_url("test.html") + self.marionette.navigate(test_html) + l = self.marionette.find_element(By.NAME, "myCheckBox") + self.assertTrue(l.is_enabled()) + self.marionette.execute_script("arguments[0].disabled = true;", [l]) + self.assertFalse(l.is_enabled()) + + +class TestIsElementDisplayed(MarionetteTestCase): + def test_is_displayed(self): + test_html = self.marionette.absolute_url("test.html") + self.marionette.navigate(test_html) + l = self.marionette.find_element(By.NAME, "myCheckBox") + self.assertTrue(l.is_displayed()) + self.marionette.execute_script("arguments[0].hidden = true;", [l]) + self.assertFalse(l.is_displayed()) + + +class TestGetElementAttribute(MarionetteTestCase): + def test_normal_attribute(self): + self.marionette.navigate(inline("<p style=foo>")) + el = self.marionette.find_element(By.TAG_NAME, "p") + attr = el.get_attribute("style") + self.assertIsInstance(attr, six.string_types) + self.assertEqual("foo", attr) + + def test_boolean_attributes(self): + for tag, attrs in six.iteritems(boolean_attributes): + for attr in attrs: + print("testing boolean attribute <{0} {1}>".format(tag, attr)) + doc = inline("<{0} {1}>".format(tag, attr)) + self.marionette.navigate(doc) + el = self.marionette.find_element(By.TAG_NAME, tag) + res = el.get_attribute(attr) + self.assertIsInstance(res, six.string_types) + self.assertEqual("true", res) + + def test_global_boolean_attributes(self): + self.marionette.navigate(inline("<p hidden>foo")) + el = self.marionette.find_element(By.TAG_NAME, "p") + attr = el.get_attribute("hidden") + self.assertIsInstance(attr, six.string_types) + self.assertEqual("true", attr) + + self.marionette.navigate(inline("<p>foo")) + el = self.marionette.find_element(By.TAG_NAME, "p") + attr = el.get_attribute("hidden") + self.assertIsNone(attr) + + self.marionette.navigate(inline("<p itemscope>foo")) + el = self.marionette.find_element(By.TAG_NAME, "p") + attr = el.get_attribute("itemscope") + self.assertIsInstance(attr, six.string_types) + self.assertEqual("true", attr) + + self.marionette.navigate(inline("<p>foo")) + el = self.marionette.find_element(By.TAG_NAME, "p") + attr = el.get_attribute("itemscope") + self.assertIsNone(attr) + + # TODO(ato): Test for custom elements + + def test_xhtml(self): + doc = inline('<p hidden="true">foo</p>', doctype="xhtml") + self.marionette.navigate(doc) + el = self.marionette.find_element(By.TAG_NAME, "p") + attr = el.get_attribute("hidden") + self.assertIsInstance(attr, six.string_types) + self.assertEqual("true", attr) + + +class TestGetElementProperty(MarionetteTestCase): + def test_get(self): + self.marionette.navigate(disabled) + el = self.marionette.find_element(By.TAG_NAME, "input") + prop = el.get_property("disabled") + self.assertIsInstance(prop, bool) + self.assertTrue(prop) + + def test_missing_property_returns_default(self): + self.marionette.navigate(input) + el = self.marionette.find_element(By.TAG_NAME, "input") + prop = el.get_property("checked") + self.assertIsInstance(prop, bool) + self.assertFalse(prop) + + def test_attribute_not_returned(self): + self.marionette.navigate(attribute) + el = self.marionette.find_element(By.TAG_NAME, "input") + self.assertEqual(el.get_property("foo"), None) + + def test_manipulated_element(self): + self.marionette.navigate(check) + el = self.marionette.find_element(By.TAG_NAME, "input") + self.assertEqual(el.get_property("checked"), False) + + el.click() + self.assertEqual(el.get_property("checked"), True) + + el.click() + self.assertEqual(el.get_property("checked"), False) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_element_state_chrome.py b/testing/marionette/harness/marionette_harness/tests/unit/test_element_state_chrome.py new file mode 100644 index 0000000000..af7246980b --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_element_state_chrome.py @@ -0,0 +1,56 @@ +# 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/. + +from __future__ import absolute_import + +from marionette_driver.by import By + +from marionette_harness import MarionetteTestCase, skip, WindowManagerMixin + + +class TestElementState(WindowManagerMixin, MarionetteTestCase): + def setUp(self): + super(TestElementState, self).setUp() + + self.marionette.set_context("chrome") + + self.win = self.open_chrome_window("chrome://marionette/content/test.xhtml") + self.marionette.switch_to_window(self.win) + + def tearDown(self): + self.close_all_windows() + + super(TestElementState, self).tearDown() + + def test_is_displayed(self): + l = self.marionette.find_element(By.ID, "textInput") + self.assertTrue(l.is_displayed()) + self.marionette.execute_script("arguments[0].hidden = true;", [l]) + self.assertFalse(l.is_displayed()) + self.marionette.execute_script("arguments[0].hidden = false;", [l]) + + def test_enabled(self): + l = self.marionette.find_element(By.ID, "textInput") + self.assertTrue(l.is_enabled()) + self.marionette.execute_script("arguments[0].disabled = true;", [l]) + self.assertFalse(l.is_enabled()) + self.marionette.execute_script("arguments[0].disabled = false;", [l]) + + def test_can_get_element_rect(self): + l = self.marionette.find_element(By.ID, "textInput") + rect = l.rect + self.assertTrue(rect["x"] > 0) + self.assertTrue(rect["y"] > 0) + + def test_get_attribute(self): + el = self.marionette.execute_script( + "return window.document.getElementById('textInput');" + ) + self.assertEqual(el.get_attribute("id"), "textInput") + + def test_get_property(self): + el = self.marionette.execute_script( + "return window.document.getElementById('textInput');" + ) + self.assertEqual(el.get_property("id"), "textInput") diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_errors.py b/testing/marionette/harness/marionette_harness/tests/unit/test_errors.py new file mode 100644 index 0000000000..0ebca29948 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_errors.py @@ -0,0 +1,107 @@ +# 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/. + +from __future__ import absolute_import + +import sys + +import six + +from marionette_driver import errors + +from marionette_harness import marionette_test + + +def fake_cause(): + try: + raise ValueError("bar") + except ValueError: + return sys.exc_info() + + +message = "foo" +unicode_message = u"\u201Cfoo" +cause = fake_cause() +stacktrace = "first\nsecond" + + +class TestErrors(marionette_test.MarionetteTestCase): + def test_defaults(self): + exc = errors.MarionetteException() + self.assertEquals(str(exc), "None") + self.assertIsNone(exc.cause) + self.assertIsNone(exc.stacktrace) + + def test_construction(self): + exc = errors.MarionetteException( + message=message, cause=cause, stacktrace=stacktrace + ) + self.assertEquals(exc.message, message) + self.assertEquals(exc.cause, cause) + self.assertEquals(exc.stacktrace, stacktrace) + + def test_str_message(self): + exc = errors.MarionetteException( + message=message, cause=cause, stacktrace=stacktrace + ) + r = str(exc) + self.assertIn(message, r) + self.assertIn(", caused by {0!r}".format(cause[0]), r) + self.assertIn("\nstacktrace:\n\tfirst\n\tsecond", r) + + def test_unicode_message(self): + exc = errors.MarionetteException( + message=unicode_message, cause=cause, stacktrace=stacktrace + ) + r = six.text_type(exc) + self.assertIn(unicode_message, r) + self.assertIn(", caused by {0!r}".format(cause[0]), r) + self.assertIn("\nstacktrace:\n\tfirst\n\tsecond", r) + + def test_unicode_message_as_str(self): + exc = errors.MarionetteException( + message=unicode_message, cause=cause, stacktrace=stacktrace + ) + r = str(exc) + self.assertIn(six.ensure_str(unicode_message, encoding="utf-8"), r) + self.assertIn(", caused by {0!r}".format(cause[0]), r) + self.assertIn("\nstacktrace:\n\tfirst\n\tsecond", r) + + def test_cause_string(self): + exc = errors.MarionetteException(cause="foo") + self.assertEqual(exc.cause, "foo") + r = str(exc) + self.assertIn(", caused by foo", r) + + def test_cause_tuple(self): + exc = errors.MarionetteException(cause=cause) + self.assertEqual(exc.cause, cause) + r = str(exc) + self.assertIn(", caused by {0!r}".format(cause[0]), r) + + +class TestLookup(marionette_test.MarionetteTestCase): + def test_by_unknown_number(self): + self.assertEqual(errors.MarionetteException, errors.lookup(123456)) + + def test_by_known_string(self): + self.assertEqual( + errors.NoSuchElementException, errors.lookup("no such element") + ) + + def test_by_unknown_string(self): + self.assertEqual(errors.MarionetteException, errors.lookup("barbera")) + + def test_by_known_unicode_string(self): + self.assertEqual( + errors.NoSuchElementException, errors.lookup(u"no such element") + ) + + +class TestAllErrors(marionette_test.MarionetteTestCase): + def test_properties(self): + for exc in errors.es_: + self.assertTrue( + hasattr(exc, "status"), "expected exception to have attribute `status'" + ) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_execute_async_script.py b/testing/marionette/harness/marionette_harness/tests/unit/test_execute_async_script.py new file mode 100644 index 0000000000..b16f349c99 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_execute_async_script.py @@ -0,0 +1,242 @@ +from __future__ import absolute_import + +import os + +from marionette_driver.errors import ( + JavascriptException, + NoAlertPresentException, + ScriptTimeoutException, +) +from marionette_driver.marionette import Alert +from marionette_driver.wait import Wait + +from marionette_harness import MarionetteTestCase + + +class TestExecuteAsyncContent(MarionetteTestCase): + def setUp(self): + super(TestExecuteAsyncContent, self).setUp() + self.marionette.timeout.script = 1 + + def tearDown(self): + if self.alert_present(): + alert = self.marionette.switch_to_alert() + alert.dismiss() + self.wait_for_alert_closed() + + def alert_present(self): + try: + Alert(self.marionette).text + return True + except NoAlertPresentException: + return False + + def wait_for_alert_closed(self, timeout=None): + Wait(self.marionette, timeout=timeout).until(lambda _: not self.alert_present()) + + def test_execute_async_simple(self): + self.assertEqual( + 1, self.marionette.execute_async_script("arguments[arguments.length-1](1);") + ) + + def test_execute_async_ours(self): + self.assertEqual(1, self.marionette.execute_async_script("arguments[0](1);")) + + def test_script_timeout_error(self): + with self.assertRaisesRegexp(ScriptTimeoutException, "Timed out after 100 ms"): + self.marionette.execute_async_script("var x = 1;", script_timeout=100) + + def test_script_timeout_reset_after_timeout_error(self): + script_timeout = self.marionette.timeout.script + with self.assertRaises(ScriptTimeoutException): + self.marionette.execute_async_script("var x = 1;", script_timeout=100) + self.assertEqual(self.marionette.timeout.script, script_timeout) + + def test_script_timeout_no_timeout_error(self): + self.assertTrue( + self.marionette.execute_async_script( + """ + var callback = arguments[arguments.length - 1]; + setTimeout(function() { callback(true); }, 500); + """, + script_timeout=1000, + ) + ) + + def test_no_timeout(self): + self.marionette.timeout.script = 10 + self.assertTrue( + self.marionette.execute_async_script( + """ + var callback = arguments[arguments.length - 1]; + setTimeout(function() { callback(true); }, 500); + """ + ) + ) + + def test_execute_async_unload(self): + self.marionette.timeout.script = 5 + unload = """ + window.location.href = "about:blank"; + """ + self.assertRaises( + JavascriptException, self.marionette.execute_async_script, unload + ) + + def test_check_window(self): + self.assertTrue( + self.marionette.execute_async_script( + "arguments[0](window != null && window != undefined);" + ) + ) + + def test_same_context(self): + var1 = "testing" + self.assertEqual( + self.marionette.execute_script( + """ + this.testvar = '{}'; + return this.testvar; + """.format( + var1 + ) + ), + var1, + ) + self.assertEqual( + self.marionette.execute_async_script( + "arguments[0](this.testvar);", new_sandbox=False + ), + var1, + ) + + def test_execute_no_return(self): + self.assertEqual(self.marionette.execute_async_script("arguments[0]()"), None) + + def test_execute_js_exception(self): + try: + self.marionette.execute_async_script( + """ + let a = 1; + foo(bar); + """ + ) + self.fail() + except JavascriptException as e: + self.assertIsNotNone(e.stacktrace) + self.assertIn( + os.path.relpath(__file__.replace(".pyc", ".py")), e.stacktrace + ) + + def test_execute_async_js_exception(self): + try: + self.marionette.execute_async_script( + """ + let [resolve] = arguments; + resolve(foo()); + """ + ) + self.fail() + except JavascriptException as e: + self.assertIsNotNone(e.stacktrace) + self.assertIn( + os.path.relpath(__file__.replace(".pyc", ".py")), e.stacktrace + ) + + def test_script_finished(self): + self.assertTrue( + self.marionette.execute_async_script( + """ + arguments[0](true); + """ + ) + ) + + def test_execute_permission(self): + self.assertRaises( + JavascriptException, + self.marionette.execute_async_script, + """ +let prefs = Components.classes["@mozilla.org/preferences-service;1"] + .getService(Components.interfaces.nsIPrefBranch); +arguments[0](4); +""", + ) + + def test_sandbox_reuse(self): + # Sandboxes between `execute_script()` invocations are shared. + self.marionette.execute_async_script( + "this.foobar = [23, 42];" "arguments[0]();" + ) + self.assertEqual( + self.marionette.execute_async_script( + "arguments[0](this.foobar);", new_sandbox=False + ), + [23, 42], + ) + + def test_sandbox_refresh_arguments(self): + self.marionette.execute_async_script( + "this.foobar = [arguments[0], arguments[1]];" + "let resolve = " + "arguments[arguments.length - 1];" + "resolve();", + script_args=[23, 42], + ) + self.assertEqual( + self.marionette.execute_async_script( + "arguments[0](this.foobar);", new_sandbox=False + ), + [23, 42], + ) + + # Functions defined in higher privilege scopes, such as the privileged + # content frame script listener.js runs in, cannot be accessed from + # content. This tests that it is possible to introspect the objects on + # `arguments` without getting permission defined errors. This is made + # possible because the last argument is always the callback/complete + # function. + # + # See bug 1290966. + def test_introspection_of_arguments(self): + self.marionette.execute_async_script( + "arguments[0].cheese; __webDriverCallback();", script_args=[], sandbox=None + ) + + def test_return_value_on_alert(self): + res = self.marionette.execute_async_script("alert()") + self.assertIsNone(res) + + +class TestExecuteAsyncChrome(TestExecuteAsyncContent): + def setUp(self): + super(TestExecuteAsyncChrome, self).setUp() + self.marionette.set_context("chrome") + + def test_execute_async_unload(self): + pass + + def test_execute_permission(self): + self.assertEqual( + 5, + self.marionette.execute_async_script( + """ + var c = Components.classes; + arguments[0](5); + """ + ), + ) + + def test_execute_async_js_exception(self): + # Javascript exceptions are not propagated in chrome code + self.marionette.timeout.script = 0.2 + with self.assertRaises(ScriptTimeoutException): + self.marionette.execute_async_script( + """ + var callback = arguments[arguments.length - 1]; + setTimeout(function() { callback(foo()); }, 50); + """ + ) + + def test_return_value_on_alert(self): + pass diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_execute_isolate.py b/testing/marionette/harness/marionette_harness/tests/unit/test_execute_isolate.py new file mode 100644 index 0000000000..7db93e4eb4 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_execute_isolate.py @@ -0,0 +1,48 @@ +# 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/. + +from __future__ import absolute_import + +from marionette_driver.errors import ScriptTimeoutException + +from marionette_harness import MarionetteTestCase + + +class TestExecuteIsolationContent(MarionetteTestCase): + def setUp(self): + super(TestExecuteIsolationContent, self).setUp() + self.content = True + + def test_execute_async_isolate(self): + # Results from one execute call that has timed out should not + # contaminate a future call. + multiplier = "*3" if self.content else "*1" + self.marionette.timeout.script = 0.5 + self.assertRaises( + ScriptTimeoutException, + self.marionette.execute_async_script, + ( + "setTimeout(function() {{ arguments[0](5{}); }}, 3000);".format( + multiplier + ) + ), + ) + + self.marionette.timeout.script = 6 + result = self.marionette.execute_async_script( + """ + let [resolve] = arguments; + setTimeout(function() {{ resolve(10{}); }}, 5000); + """.format( + multiplier + ) + ) + self.assertEqual(result, 30 if self.content else 10) + + +class TestExecuteIsolationChrome(TestExecuteIsolationContent): + def setUp(self): + super(TestExecuteIsolationChrome, self).setUp() + self.marionette.set_context("chrome") + self.content = False diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_execute_sandboxes.py b/testing/marionette/harness/marionette_harness/tests/unit/test_execute_sandboxes.py new file mode 100644 index 0000000000..d3b93f8e83 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_execute_sandboxes.py @@ -0,0 +1,88 @@ +# 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/. + +from __future__ import absolute_import + +from marionette_driver.errors import JavascriptException + +from marionette_harness import MarionetteTestCase + + +class TestExecuteSandboxes(MarionetteTestCase): + def setUp(self): + super(TestExecuteSandboxes, self).setUp() + + def test_execute_system_sandbox(self): + # Test that "system" sandbox has elevated privileges in execute_script + result = self.marionette.execute_script( + "return Components.interfaces.nsIPermissionManager.ALLOW_ACTION", + sandbox="system", + ) + self.assertEqual(result, 1) + + def test_execute_async_system_sandbox(self): + # Test that "system" sandbox has elevated privileges in + # execute_async_script. + result = self.marionette.execute_async_script( + """ + let result = Ci.nsIPermissionManager.ALLOW_ACTION; + arguments[0](result); + """, + sandbox="system", + ) + self.assertEqual(result, 1) + + def test_execute_switch_sandboxes(self): + # Test that sandboxes are retained when switching between them + # for execute_script. + self.marionette.execute_script("foo = 1", sandbox="1") + self.marionette.execute_script("foo = 2", sandbox="2") + foo = self.marionette.execute_script( + "return foo", sandbox="1", new_sandbox=False + ) + self.assertEqual(foo, 1) + foo = self.marionette.execute_script( + "return foo", sandbox="2", new_sandbox=False + ) + self.assertEqual(foo, 2) + + def test_execute_new_sandbox(self): + # test that clearing a sandbox does not affect other sandboxes + self.marionette.execute_script("foo = 1", sandbox="1") + self.marionette.execute_script("foo = 2", sandbox="2") + + # deprecate sandbox 1 by asking explicitly for a fresh one + with self.assertRaises(JavascriptException): + self.marionette.execute_script( + """ + return foo + """, + sandbox="1", + new_sandbox=True, + ) + + foo = self.marionette.execute_script( + "return foo", sandbox="2", new_sandbox=False + ) + self.assertEqual(foo, 2) + + def test_execute_async_switch_sandboxes(self): + # Test that sandboxes are retained when switching between them + # for execute_async_script. + self.marionette.execute_async_script("foo = 1; arguments[0]();", sandbox="1") + self.marionette.execute_async_script("foo = 2; arguments[0]();", sandbox="2") + foo = self.marionette.execute_async_script( + "arguments[0](foo);", sandbox="1", new_sandbox=False + ) + self.assertEqual(foo, 1) + foo = self.marionette.execute_async_script( + "arguments[0](foo);", sandbox="2", new_sandbox=False + ) + self.assertEqual(foo, 2) + + +class TestExecuteSandboxesChrome(TestExecuteSandboxes): + def setUp(self): + super(TestExecuteSandboxesChrome, self).setUp() + self.marionette.set_context("chrome") diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_execute_script.py b/testing/marionette/harness/marionette_harness/tests/unit/test_execute_script.py new file mode 100644 index 0000000000..e7e354881d --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_execute_script.py @@ -0,0 +1,502 @@ +from __future__ import absolute_import + +import os + +from six.moves.urllib.parse import quote + +from marionette_driver import By, errors +from marionette_driver.marionette import Alert, HTMLElement +from marionette_driver.wait import Wait + +from marionette_harness import MarionetteTestCase, WindowManagerMixin + + +def inline(doc): + return "data:text/html;charset=utf-8,{}".format(quote(doc)) + + +elements = inline("<p>foo</p> <p>bar</p>") + +globals = set( + [ + "atob", + "Audio", + "btoa", + "document", + "navigator", + "URL", + "window", + ] +) + + +class TestExecuteContent(MarionetteTestCase): + def alert_present(self): + try: + Alert(self.marionette).text + return True + except errors.NoAlertPresentException: + return False + + def wait_for_alert_closed(self, timeout=None): + Wait(self.marionette, timeout=timeout).until(lambda _: not self.alert_present()) + + def tearDown(self): + if self.alert_present(): + alert = self.marionette.switch_to_alert() + alert.dismiss() + self.wait_for_alert_closed() + + def assert_is_defined(self, property, sandbox="default"): + self.assertTrue( + self.marionette.execute_script( + "return typeof arguments[0] != 'undefined'", [property], sandbox=sandbox + ), + "property {} is undefined".format(property), + ) + + def assert_is_web_element(self, element): + self.assertIsInstance(element, HTMLElement) + + def test_return_number(self): + self.assertEqual(1, self.marionette.execute_script("return 1")) + self.assertEqual(1.5, self.marionette.execute_script("return 1.5")) + + def test_return_boolean(self): + self.assertTrue(self.marionette.execute_script("return true")) + + def test_return_string(self): + self.assertEqual("foo", self.marionette.execute_script("return 'foo'")) + + def test_return_array(self): + self.assertEqual([1, 2], self.marionette.execute_script("return [1, 2]")) + self.assertEqual( + [1.25, 1.75], self.marionette.execute_script("return [1.25, 1.75]") + ) + self.assertEqual( + [True, False], self.marionette.execute_script("return [true, false]") + ) + self.assertEqual( + ["foo", "bar"], self.marionette.execute_script("return ['foo', 'bar']") + ) + self.assertEqual( + [1, 1.5, True, "foo"], + self.marionette.execute_script("return [1, 1.5, true, 'foo']"), + ) + self.assertEqual([1, [2]], self.marionette.execute_script("return [1, [2]]")) + + def test_return_object(self): + self.assertEqual({"foo": 1}, self.marionette.execute_script("return {foo: 1}")) + self.assertEqual( + {"foo": 1.5}, self.marionette.execute_script("return {foo: 1.5}") + ) + self.assertEqual( + {"foo": True}, self.marionette.execute_script("return {foo: true}") + ) + self.assertEqual( + {"foo": "bar"}, self.marionette.execute_script("return {foo: 'bar'}") + ) + self.assertEqual( + {"foo": [1, 2]}, self.marionette.execute_script("return {foo: [1, 2]}") + ) + self.assertEqual( + {"foo": {"bar": [1, 2]}}, + self.marionette.execute_script("return {foo: {bar: [1, 2]}}"), + ) + + def test_no_return_value(self): + self.assertIsNone(self.marionette.execute_script("true")) + + def test_argument_null(self): + self.assertIsNone( + self.marionette.execute_script( + "return arguments[0]", script_args=(None,), sandbox="default" + ) + ) + self.assertIsNone( + self.marionette.execute_script( + "return arguments[0]", script_args=(None,), sandbox="system" + ) + ) + self.assertIsNone( + self.marionette.execute_script( + "return arguments[0]", script_args=(None,), sandbox=None + ) + ) + + def test_argument_number(self): + self.assertEqual(1, self.marionette.execute_script("return arguments[0]", (1,))) + self.assertEqual( + 1.5, self.marionette.execute_script("return arguments[0]", (1.5,)) + ) + + def test_argument_boolean(self): + self.assertTrue(self.marionette.execute_script("return arguments[0]", (True,))) + + def test_argument_string(self): + self.assertEqual( + "foo", self.marionette.execute_script("return arguments[0]", ("foo",)) + ) + + def test_argument_array(self): + self.assertEqual( + [1, 2], self.marionette.execute_script("return arguments[0]", ([1, 2],)) + ) + + def test_argument_object(self): + self.assertEqual( + {"foo": 1}, + self.marionette.execute_script("return arguments[0]", ({"foo": 1},)), + ) + + def test_default_sandbox_globals(self): + for property in globals: + self.assert_is_defined(property, sandbox="default") + + self.assert_is_defined("Components") + self.assert_is_defined("window.wrappedJSObject") + + def test_system_globals(self): + for property in globals: + self.assert_is_defined(property, sandbox="system") + + self.assert_is_defined("Components", sandbox="system") + self.assert_is_defined("window.wrappedJSObject", sandbox="system") + + def test_mutable_sandbox_globals(self): + for property in globals: + self.assert_is_defined(property, sandbox=None) + + # Components is there, but will be removed soon + self.assert_is_defined("Components", sandbox=None) + # wrappedJSObject is always there in sandboxes + self.assert_is_defined("window.wrappedJSObject", sandbox=None) + + def test_exception(self): + self.assertRaises( + errors.JavascriptException, self.marionette.execute_script, "return foo" + ) + + def test_stacktrace(self): + with self.assertRaises(errors.JavascriptException) as cm: + self.marionette.execute_script("return b") + + # by default execute_script pass the name of the python file + self.assertIn( + os.path.relpath(__file__.replace(".pyc", ".py")), cm.exception.stacktrace + ) + self.assertIn("b is not defined", str(cm.exception)) + + def test_permission(self): + for sandbox in ["default", None]: + with self.assertRaises(errors.JavascriptException): + self.marionette.execute_script( + "Components.classes['@mozilla.org/preferences-service;1']" + ) + + def test_return_web_element(self): + self.marionette.navigate(elements) + expected = self.marionette.find_element(By.TAG_NAME, "p") + actual = self.marionette.execute_script("return document.querySelector('p')") + self.assertEqual(expected, actual) + + def test_return_web_element_array(self): + self.marionette.navigate(elements) + expected = self.marionette.find_elements(By.TAG_NAME, "p") + actual = self.marionette.execute_script( + """ + let els = document.querySelectorAll('p') + return [els[0], els[1]]""" + ) + self.assertEqual(expected, actual) + + # Bug 938228 identifies a problem with unmarshaling NodeList + # objects from the DOM. document.querySelectorAll returns this + # construct. + def test_return_web_element_nodelist(self): + self.marionette.navigate(elements) + expected = self.marionette.find_elements(By.TAG_NAME, "p") + actual = self.marionette.execute_script("return document.querySelectorAll('p')") + self.assertEqual(expected, actual) + + def test_sandbox_reuse(self): + # Sandboxes between `execute_script()` invocations are shared. + self.marionette.execute_script("this.foobar = [23, 42];") + self.assertEqual( + self.marionette.execute_script("return this.foobar;", new_sandbox=False), + [23, 42], + ) + + def test_sandbox_refresh_arguments(self): + self.marionette.execute_script( + "this.foobar = [arguments[0], arguments[1]]", [23, 42] + ) + self.assertEqual( + self.marionette.execute_script("return this.foobar", new_sandbox=False), + [23, 42], + ) + + def test_mutable_sandbox_wrappedjsobject(self): + self.assert_is_defined("window.wrappedJSObject") + with self.assertRaises(errors.JavascriptException): + self.marionette.execute_script( + "window.wrappedJSObject.foo = 1", sandbox=None + ) + + def test_default_sandbox_wrappedjsobject(self): + self.assert_is_defined("window.wrappedJSObject", sandbox="default") + + try: + self.marionette.execute_script( + "window.wrappedJSObject.foo = 4", sandbox="default" + ) + self.assertEqual( + self.marionette.execute_script( + "return window.wrappedJSObject.foo", sandbox="default" + ), + 4, + ) + finally: + self.marionette.execute_script( + "delete window.wrappedJSObject.foo", sandbox="default" + ) + + def test_system_sandbox_wrappedjsobject(self): + self.assert_is_defined("window.wrappedJSObject", sandbox="system") + + self.marionette.execute_script( + "window.wrappedJSObject.foo = 4", sandbox="system" + ) + self.assertEqual( + self.marionette.execute_script( + "return window.wrappedJSObject.foo", sandbox="system" + ), + 4, + ) + + def test_system_dead_object(self): + self.assert_is_defined("window.wrappedJSObject", sandbox="system") + + self.marionette.execute_script( + "window.wrappedJSObject.foo = function() { return 'yo' }", sandbox="system" + ) + self.marionette.execute_script( + "dump(window.wrappedJSObject.foo)", sandbox="system" + ) + + self.marionette.execute_script( + "window.wrappedJSObject.foo = function() { return 'yolo' }", + sandbox="system", + ) + typ = self.marionette.execute_script( + "return typeof window.wrappedJSObject.foo", sandbox="system" + ) + self.assertEqual("function", typ) + obj = self.marionette.execute_script( + "return window.wrappedJSObject.foo.toString()", sandbox="system" + ) + self.assertIn("yolo", obj) + + def test_lasting_side_effects(self): + def send(script): + return self.marionette._send_message( + "WebDriver:ExecuteScript", {"script": script}, key="value" + ) + + send("window.foo = 1") + foo = send("return window.foo") + self.assertEqual(1, foo) + + for property in globals: + exists = send("return typeof {} != 'undefined'".format(property)) + self.assertTrue(exists, "property {} is undefined".format(property)) + + self.assertTrue( + send( + """ + return (typeof Components == 'undefined') || + (typeof Components.utils == 'undefined') + """ + ) + ) + self.assertTrue(send("return typeof window.wrappedJSObject == 'undefined'")) + + def test_no_callback(self): + self.assertTrue( + self.marionette.execute_script("return typeof arguments[0] == 'undefined'") + ) + + def test_window_set_timeout_is_not_cancelled(self): + def content_timeout_triggered(mn): + return mn.execute_script("return window.n", sandbox=None) > 0 + + # subsequent call to execute_script after this + # should not cancel the setTimeout event + self.marionette.navigate( + inline( + """ + <script> + window.n = 0; + setTimeout(() => ++window.n, 4000); + </script>""" + ) + ) + + # as debug builds are inherently slow, + # we need to assert the event did not already fire + self.assertEqual( + 0, + self.marionette.execute_script("return window.n", sandbox=None), + "setTimeout already fired", + ) + + # if event was cancelled, this will time out + Wait(self.marionette, timeout=8).until( + content_timeout_triggered, + message="Scheduled setTimeout event was cancelled by call to execute_script", + ) + + def test_access_chrome_objects_in_event_listeners(self): + # sandbox.window.addEventListener/removeEventListener + # is used by Marionette for installing the unloadHandler which + # is used to return an error when a document is unloaded during + # script execution. + # + # Certain web frameworks, notably Angular, override + # window.addEventListener/removeEventListener and introspects + # objects passed to them. If these objects originates from chrome + # without having been cloned, a permission denied error is thrown + # as part of the security precautions put in place by the sandbox. + + # addEventListener is called when script is injected + self.marionette.navigate( + inline( + """ + <script> + window.addEventListener = (event, listener) => listener.toString(); + </script> + """ + ) + ) + self.marionette.execute_script("", sandbox=None) + + # removeEventListener is called when sandbox is unloaded + self.marionette.navigate( + inline( + """ + <script> + window.removeEventListener = (event, listener) => listener.toString(); + </script> + """ + ) + ) + self.marionette.execute_script("", sandbox=None) + + def test_access_global_objects_from_chrome(self): + # test inspection of arguments + self.marionette.execute_script("__webDriverArguments.toString()") + + def test_toJSON(self): + foo = self.marionette.execute_script( + """ + return { + toJSON () { + return "foo"; + } + } + """, + sandbox=None, + ) + self.assertEqual("foo", foo) + + def test_unsafe_toJSON(self): + el = self.marionette.execute_script( + """ + return { + toJSON () { + return document.documentElement; + } + } + """, + sandbox=None, + ) + self.assert_is_web_element(el) + self.assertEqual(el, self.marionette.find_element(By.CSS_SELECTOR, ":root")) + self.assertEqual(el.get_property("localName"), "html") + + def test_comment_in_last_line(self): + self.marionette.execute_script(" // comment ") + + def test_return_value_on_alert(self): + res = self.marionette.execute_script("alert()") + self.assertIsNone(res) + + +class TestExecuteChrome(WindowManagerMixin, TestExecuteContent): + def setUp(self): + super(TestExecuteChrome, self).setUp() + + self.marionette.set_context("chrome") + + def tearDown(self): + super(TestExecuteChrome, self).tearDown() + + def test_permission(self): + self.marionette.execute_script( + "Components.classes['@mozilla.org/preferences-service;1']" + ) + + def test_unmarshal_element_collection(self): + try: + win = self.open_chrome_window("chrome://marionette/content/test.xhtml") + self.marionette.switch_to_window(win) + + expected = self.marionette.find_elements(By.TAG_NAME, "input") + actual = self.marionette.execute_script( + "return document.querySelectorAll('input')" + ) + self.assertTrue(len(expected) > 0) + self.assertEqual(expected, actual) + + finally: + self.close_all_windows() + + def test_async_script_timeout(self): + with self.assertRaises(errors.ScriptTimeoutException): + self.marionette.execute_async_script( + """ + var cb = arguments[arguments.length - 1]; + setTimeout(function() { cb() }, 2500); + """, + script_timeout=100, + ) + + def test_lasting_side_effects(self): + pass + + def test_return_web_element(self): + pass + + def test_return_web_element_array(self): + pass + + def test_return_web_element_nodelist(self): + pass + + def test_window_set_timeout_is_not_cancelled(self): + pass + + def test_mutable_sandbox_wrappedjsobject(self): + pass + + def test_default_sandbox_wrappedjsobject(self): + pass + + def test_system_sandbox_wrappedjsobject(self): + pass + + def test_access_chrome_objects_in_event_listeners(self): + pass + + def test_return_value_on_alert(self): + pass diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_expected.py b/testing/marionette/harness/marionette_harness/tests/unit/test_expected.py new file mode 100644 index 0000000000..7ea0e68f9b --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_expected.py @@ -0,0 +1,235 @@ +# 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/. + +from __future__ import absolute_import + +from six.moves.urllib.parse import quote + +from marionette_driver import expected +from marionette_driver.by import By + +from marionette_harness import marionette_test + + +def inline(doc): + return "data:text/html;charset=utf-8,{}".format(quote(doc)) + + +static_element = inline("""<p>foo</p>""") +static_elements = static_element + static_element + +remove_element_by_tag_name = """var el = document.getElementsByTagName('{}')[0]; + document.getElementsByTagName("body")[0].remove(el);""" + +hidden_element = inline("<p style='display: none'>hidden</p>") + +selected_element = inline("<option selected>selected</option>") +unselected_element = inline("<option>unselected</option>") + +enabled_element = inline("<input>") +disabled_element = inline("<input disabled>") + + +def no_such_element(marionette): + return marionette.find_element(By.ID, "nosuchelement") + + +def no_such_elements(marionette): + return marionette.find_elements(By.ID, "nosuchelement") + + +def p(marionette): + return marionette.find_element(By.TAG_NAME, "p") + + +def ps(marionette): + return marionette.find_elements(By.TAG_NAME, "p") + + +class TestExpected(marionette_test.MarionetteTestCase): + def test_element_present_func(self): + self.marionette.navigate(static_element) + el = expected.element_present(p)(self.marionette) + self.assertIsNotNone(el) + + def test_element_present_locator(self): + self.marionette.navigate(static_element) + el = expected.element_present(By.TAG_NAME, "p")(self.marionette) + self.assertIsNotNone(el) + + def test_element_present_not_present(self): + r = expected.element_present(no_such_element)(self.marionette) + self.assertIsInstance(r, bool) + self.assertFalse(r) + + def test_element_not_present_func(self): + r = expected.element_not_present(no_such_element)(self.marionette) + self.assertIsInstance(r, bool) + self.assertTrue(r) + + def test_element_not_present_locator(self): + r = expected.element_not_present(By.ID, "nosuchelement")(self.marionette) + self.assertIsInstance(r, bool) + self.assertTrue(r) + + def test_element_not_present_is_present(self): + self.marionette.navigate(static_element) + r = expected.element_not_present(p)(self.marionette) + self.assertIsInstance(r, bool) + self.assertFalse(r) + + def test_element_stale(self): + self.marionette.navigate(static_element) + el = self.marionette.find_element(By.TAG_NAME, "p") + self.assertIsNotNone(el) + self.marionette.execute_script(remove_element_by_tag_name.format("p")) + r = expected.element_stale(el)(self.marionette) + self.assertTrue(r) + + def test_element_stale_is_not_stale(self): + self.marionette.navigate(static_element) + el = self.marionette.find_element(By.TAG_NAME, "p") + r = expected.element_stale(el)(self.marionette) + self.assertFalse(r) + + def test_elements_present_func(self): + self.marionette.navigate(static_elements) + els = expected.elements_present(ps)(self.marionette) + self.assertEqual(len(els), 2) + + def test_elements_present_locator(self): + self.marionette.navigate(static_elements) + els = expected.elements_present(By.TAG_NAME, "p")(self.marionette) + self.assertEqual(len(els), 2) + + def test_elements_present_not_present(self): + r = expected.elements_present(no_such_elements)(self.marionette) + self.assertEqual(r, []) + + def test_elements_not_present_func(self): + r = expected.element_not_present(no_such_elements)(self.marionette) + self.assertIsInstance(r, bool) + self.assertTrue(r) + + def test_elements_not_present_locator(self): + r = expected.element_not_present(By.ID, "nosuchelement")(self.marionette) + self.assertIsInstance(r, bool) + self.assertTrue(r) + + def test_elements_not_present_is_present(self): + self.marionette.navigate(static_elements) + r = expected.elements_not_present(ps)(self.marionette) + self.assertIsInstance(r, bool) + self.assertFalse(r) + + def test_element_displayed(self): + self.marionette.navigate(static_element) + el = self.marionette.find_element(By.TAG_NAME, "p") + visible = expected.element_displayed(el)(self.marionette) + self.assertTrue(visible) + + def test_element_displayed_locator(self): + self.marionette.navigate(static_element) + visible = expected.element_displayed(By.TAG_NAME, "p")(self.marionette) + self.assertTrue(visible) + + def test_element_displayed_when_hidden(self): + self.marionette.navigate(hidden_element) + el = self.marionette.find_element(By.TAG_NAME, "p") + visible = expected.element_displayed(el)(self.marionette) + self.assertFalse(visible) + + def test_element_displayed_when_hidden_locator(self): + self.marionette.navigate(hidden_element) + visible = expected.element_displayed(By.TAG_NAME, "p")(self.marionette) + self.assertFalse(visible) + + def test_element_displayed_when_not_present(self): + self.marionette.navigate("about:blank") + visible = expected.element_displayed(By.TAG_NAME, "p")(self.marionette) + self.assertFalse(visible) + + def test_element_displayed_when_stale_element(self): + self.marionette.navigate(static_element) + el = self.marionette.find_element(By.TAG_NAME, "p") + self.marionette.navigate("about:blank") + missing = expected.element_displayed(el)(self.marionette) + self.assertFalse(missing) + + def test_element_not_displayed(self): + self.marionette.navigate(hidden_element) + el = self.marionette.find_element(By.TAG_NAME, "p") + hidden = expected.element_not_displayed(el)(self.marionette) + self.assertTrue(hidden) + + def test_element_not_displayed_locator(self): + self.marionette.navigate(hidden_element) + hidden = expected.element_not_displayed(By.TAG_NAME, "p")(self.marionette) + self.assertTrue(hidden) + + def test_element_not_displayed_when_visible(self): + self.marionette.navigate(static_element) + el = self.marionette.find_element(By.TAG_NAME, "p") + hidden = expected.element_not_displayed(el)(self.marionette) + self.assertFalse(hidden) + + def test_element_not_displayed_when_visible_locator(self): + self.marionette.navigate(static_element) + hidden = expected.element_not_displayed(By.TAG_NAME, "p")(self.marionette) + self.assertFalse(hidden) + + def test_element_not_displayed_when_stale_element(self): + self.marionette.navigate(static_element) + el = self.marionette.find_element(By.TAG_NAME, "p") + self.marionette.navigate("about:blank") + missing = expected.element_not_displayed(el)(self.marionette) + self.assertTrue(missing) + + def test_element_selected(self): + self.marionette.navigate(selected_element) + el = self.marionette.find_element(By.TAG_NAME, "option") + selected = expected.element_selected(el)(self.marionette) + self.assertTrue(selected) + + def test_element_selected_when_not_selected(self): + self.marionette.navigate(unselected_element) + el = self.marionette.find_element(By.TAG_NAME, "option") + unselected = expected.element_selected(el)(self.marionette) + self.assertFalse(unselected) + + def test_element_not_selected(self): + self.marionette.navigate(unselected_element) + el = self.marionette.find_element(By.TAG_NAME, "option") + unselected = expected.element_not_selected(el)(self.marionette) + self.assertTrue(unselected) + + def test_element_not_selected_when_selected(self): + self.marionette.navigate(selected_element) + el = self.marionette.find_element(By.TAG_NAME, "option") + selected = expected.element_not_selected(el)(self.marionette) + self.assertFalse(selected) + + def test_element_enabled(self): + self.marionette.navigate(enabled_element) + el = self.marionette.find_element(By.TAG_NAME, "input") + enabled = expected.element_enabled(el)(self.marionette) + self.assertTrue(enabled) + + def test_element_enabled_when_disabled(self): + self.marionette.navigate(disabled_element) + el = self.marionette.find_element(By.TAG_NAME, "input") + disabled = expected.element_enabled(el)(self.marionette) + self.assertFalse(disabled) + + def test_element_not_enabled(self): + self.marionette.navigate(disabled_element) + el = self.marionette.find_element(By.TAG_NAME, "input") + disabled = expected.element_not_enabled(el)(self.marionette) + self.assertTrue(disabled) + + def test_element_not_enabled_when_enabled(self): + self.marionette.navigate(enabled_element) + el = self.marionette.find_element(By.TAG_NAME, "input") + enabled = expected.element_not_enabled(el)(self.marionette) + self.assertFalse(enabled) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_expectedfail.py b/testing/marionette/harness/marionette_harness/tests/unit/test_expectedfail.py new file mode 100644 index 0000000000..e970bc870a --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_expectedfail.py @@ -0,0 +1,13 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +from __future__ import absolute_import + +from marionette_harness import MarionetteTestCase + + +class TestFail(MarionetteTestCase): + def test_fails(self): + # this test is supposed to fail! + self.assertEquals(True, False) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_file_upload.py b/testing/marionette/harness/marionette_harness/tests/unit/test_file_upload.py new file mode 100644 index 0000000000..692393af4b --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_file_upload.py @@ -0,0 +1,171 @@ +# 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/. + +from __future__ import absolute_import + +import contextlib + +from tempfile import NamedTemporaryFile as tempfile + +import six +from six.moves.urllib.parse import quote + +from marionette_driver import By, errors, expected +from marionette_driver.wait import Wait +from marionette_harness import MarionetteTestCase, skip + + +single = "data:text/html,{}".format(quote("<input type=file>")) +multiple = "data:text/html,{}".format(quote("<input type=file multiple>")) +upload = lambda url: "data:text/html,{}".format( + quote( + """ + <form action='{}' method=post enctype='multipart/form-data'> + <input type=file> + <input type=submit> + </form>""".format( + url + ) + ) +) + + +class TestFileUpload(MarionetteTestCase): + def test_sets_one_file(self): + self.marionette.navigate(single) + input = self.input + + exp = None + with tempfile() as f: + input.send_keys(f.name) + exp = [f.name] + + files = self.get_file_names(input) + self.assertEqual(len(files), 1) + self.assertFileNamesEqual(files, exp) + + def test_sets_multiple_files(self): + self.marionette.navigate(multiple) + input = self.input + + exp = None + with tempfile() as a, tempfile() as b: + input.send_keys(a.name) + input.send_keys(b.name) + exp = [a.name, b.name] + + files = self.get_file_names(input) + self.assertEqual(len(files), 2) + self.assertFileNamesEqual(files, exp) + + def test_sets_multiple_indentical_files(self): + self.marionette.navigate(multiple) + input = self.input + + exp = [] + with tempfile() as f: + input.send_keys(f.name) + input.send_keys(f.name) + exp = f.name + + files = self.get_file_names(input) + self.assertEqual(len(files), 2) + self.assertFileNamesEqual(files, exp) + + def test_clear_file(self): + self.marionette.navigate(single) + input = self.input + + with tempfile() as f: + input.send_keys(f.name) + + self.assertEqual(len(self.get_files(input)), 1) + input.clear() + self.assertEqual(len(self.get_files(input)), 0) + + def test_clear_files(self): + self.marionette.navigate(multiple) + input = self.input + + with tempfile() as a, tempfile() as b: + input.send_keys(a.name) + input.send_keys(b.name) + + self.assertEqual(len(self.get_files(input)), 2) + input.clear() + self.assertEqual(len(self.get_files(input)), 0) + + def test_illegal_file(self): + self.marionette.navigate(single) + with self.assertRaisesRegexp(errors.MarionetteException, "File not found"): + self.input.send_keys("rochefort") + + def test_upload(self): + self.marionette.navigate(upload(self.marionette.absolute_url("file_upload"))) + url = self.marionette.get_url() + + with tempfile() as f: + f.write(six.ensure_binary("camembert")) + f.flush() + self.input.send_keys(f.name) + self.submit.click() + + Wait(self.marionette, timeout=self.marionette.timeout.page_load).until( + lambda m: m.get_url() != url, + message="URL didn't change after submitting a file upload", + ) + self.assertIn("multipart/form-data", self.body.text) + + def test_change_event(self): + self.marionette.navigate(single) + self.marionette.execute_script( + """ + window.changeEvs = []; + let el = arguments[arguments.length - 1]; + el.addEventListener("change", ev => window.changeEvs.push(ev)); + console.log(window.changeEvs.length); + """, + script_args=(self.input,), + sandbox=None, + ) + + with tempfile() as f: + self.input.send_keys(f.name) + + nevs = self.marionette.execute_script( + "return window.changeEvs.length", sandbox=None + ) + self.assertEqual(1, nevs) + + def find_inputs(self): + return self.marionette.find_elements(By.TAG_NAME, "input") + + @property + def input(self): + return self.find_inputs()[0] + + @property + def submit(self): + return self.find_inputs()[1] + + @property + def body(self): + return Wait(self.marionette).until( + expected.element_present(By.TAG_NAME, "body") + ) + + def get_file_names(self, el): + fl = self.get_files(el) + return [f["name"] for f in fl] + + def get_files(self, el): + return self.marionette.execute_script( + "return arguments[0].files", script_args=[el] + ) + + def assertFileNamesEqual(self, act, exp): + # File array returned from browser doesn't contain full path names, + # this cuts off the path of the expected files. + filenames = [f.rsplit("/", 0)[-1] for f in act] + self.assertListEqual(filenames, act) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_findelement_chrome.py b/testing/marionette/harness/marionette_harness/tests/unit/test_findelement_chrome.py new file mode 100644 index 0000000000..63b1661365 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_findelement_chrome.py @@ -0,0 +1,116 @@ +# 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/. + +from __future__ import absolute_import + +from marionette_driver.by import By +from marionette_driver.errors import NoSuchElementException +from marionette_driver.marionette import HTMLElement + +from marionette_harness import MarionetteTestCase, WindowManagerMixin + + +class TestElementsChrome(WindowManagerMixin, MarionetteTestCase): + def setUp(self): + super(TestElementsChrome, self).setUp() + + self.marionette.set_context("chrome") + + win = self.open_chrome_window("chrome://marionette/content/test.xhtml") + self.marionette.switch_to_window(win) + + def tearDown(self): + self.close_all_windows() + + super(TestElementsChrome, self).tearDown() + + def test_id(self): + el = self.marionette.execute_script( + "return window.document.getElementById('textInput');" + ) + found_el = self.marionette.find_element(By.ID, "textInput") + self.assertEqual(HTMLElement, type(found_el)) + self.assertEqual(el, found_el) + + def test_that_we_can_find_elements_from_css_selectors(self): + el = self.marionette.execute_script( + "return window.document.getElementById('textInput');" + ) + found_el = self.marionette.find_element(By.CSS_SELECTOR, "#textInput") + self.assertEqual(HTMLElement, type(found_el)) + self.assertEqual(el, found_el) + + def test_child_element(self): + el = self.marionette.find_element(By.ID, "textInput") + parent = self.marionette.find_element(By.ID, "things") + found_el = parent.find_element(By.TAG_NAME, "input") + self.assertEqual(HTMLElement, type(found_el)) + self.assertEqual(el, found_el) + + def test_child_elements(self): + el = self.marionette.find_element(By.ID, "textInput3") + parent = self.marionette.find_element(By.ID, "things") + found_els = parent.find_elements(By.TAG_NAME, "input") + self.assertTrue(el.id in [found_el.id for found_el in found_els]) + + def test_tag_name(self): + el = self.marionette.execute_script( + "return window.document.getElementsByTagName('vbox')[0];" + ) + found_el = self.marionette.find_element(By.TAG_NAME, "vbox") + self.assertEquals("vbox", found_el.tag_name) + self.assertEqual(HTMLElement, type(found_el)) + self.assertEqual(el, found_el) + + def test_class_name(self): + el = self.marionette.execute_script( + "return window.document.getElementsByClassName('asdf')[0];" + ) + found_el = self.marionette.find_element(By.CLASS_NAME, "asdf") + self.assertEqual(HTMLElement, type(found_el)) + self.assertEqual(el, found_el) + + def test_xpath(self): + el = self.marionette.execute_script( + "return window.document.getElementById('testBox');" + ) + found_el = self.marionette.find_element(By.XPATH, "id('testBox')") + self.assertEqual(HTMLElement, type(found_el)) + self.assertEqual(el, found_el) + + def test_not_found(self): + self.marionette.timeout.implicit = 1 + self.assertRaises( + NoSuchElementException, + self.marionette.find_element, + By.ID, + "I'm not on the page", + ) + self.marionette.timeout.implicit = 0 + self.assertRaises( + NoSuchElementException, + self.marionette.find_element, + By.ID, + "I'm not on the page", + ) + + def test_timeout(self): + self.assertRaises( + NoSuchElementException, self.marionette.find_element, By.ID, "myid" + ) + self.marionette.timeout.implicit = 4 + self.marionette.execute_script( + """ + window.setTimeout(function () { + var b = window.document.createXULElement('button'); + b.id = 'myid'; + document.getElementById('things').appendChild(b); + }, 1000); """ + ) + self.assertEqual(HTMLElement, type(self.marionette.find_element(By.ID, "myid"))) + self.marionette.execute_script( + """ + var elem = window.document.getElementById('things'); + elem.removeChild(window.document.getElementById('myid')); """ + ) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_geckoinstance.py b/testing/marionette/harness/marionette_harness/tests/unit/test_geckoinstance.py new file mode 100644 index 0000000000..97b2dd06c2 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_geckoinstance.py @@ -0,0 +1,27 @@ +# 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/. + +from __future__ import absolute_import + +from marionette_driver.geckoinstance import apps, GeckoInstance + +from marionette_harness import MarionetteTestCase + + +class TestGeckoInstance(MarionetteTestCase): + def test_create(self): + """Test that the correct gecko instance is determined.""" + for app in apps: + # If app has been specified we directly return the appropriate instance class + self.assertEqual(type(GeckoInstance.create(app=app, bin="n/a")), apps[app]) + + # Unknown applications and binaries should fail + self.assertRaises( + NotImplementedError, + GeckoInstance.create, + app="n/a", + bin=self.marionette.bin, + ) + self.assertRaises(NotImplementedError, GeckoInstance.create, bin="n/a") + self.assertRaises(NotImplementedError, GeckoInstance.create, bin=None) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_get_current_url_chrome.py b/testing/marionette/harness/marionette_harness/tests/unit/test_get_current_url_chrome.py new file mode 100644 index 0000000000..67889df7e4 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_get_current_url_chrome.py @@ -0,0 +1,41 @@ +# 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/. + +from __future__ import absolute_import + +from marionette_driver.errors import NoSuchWindowException + +from marionette_harness import MarionetteTestCase, WindowManagerMixin + + +class TestGetCurrentUrlChrome(WindowManagerMixin, MarionetteTestCase): + def setUp(self): + super(TestGetCurrentUrlChrome, self).setUp() + self.marionette.set_context("chrome") + + def tearDown(self): + self.close_all_windows() + super(TestGetCurrentUrlChrome, self).tearDown() + + def test_browser_window(self): + url = self.marionette.absolute_url("test.html") + + with self.marionette.using_context("content"): + self.marionette.navigate(url) + self.assertEqual(self.marionette.get_url(), url) + + chrome_url = self.marionette.execute_script("return window.location.href;") + self.assertEqual(self.marionette.get_url(), chrome_url) + + def test_no_browser_window(self): + win = self.open_chrome_window("chrome://marionette/content/test.xhtml") + self.marionette.switch_to_window(win) + + chrome_url = self.marionette.execute_script("return window.location.href;") + self.assertEqual(self.marionette.get_url(), chrome_url) + + # With no tabbrowser available an exception will be thrown + with self.assertRaises(NoSuchWindowException): + with self.marionette.using_context("content"): + self.marionette.get_url() diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_implicit_waits.py b/testing/marionette/harness/marionette_harness/tests/unit/test_implicit_waits.py new file mode 100644 index 0000000000..41863a9d83 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_implicit_waits.py @@ -0,0 +1,28 @@ +# 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/. + +from __future__ import absolute_import + +from marionette_driver.by import By +from marionette_driver.errors import NoSuchElementException + +from marionette_harness import MarionetteTestCase + + +class TestImplicitWaits(MarionetteTestCase): + def test_implicitly_wait_for_single_element(self): + test_html = self.marionette.absolute_url("test_dynamic.html") + self.marionette.navigate(test_html) + add = self.marionette.find_element(By.ID, "adder") + self.marionette.timeout.implicit = 30 + add.click() + # all is well if this does not throw + self.marionette.find_element(By.ID, "box0") + + def test_implicit_wait_reaches_timeout(self): + test_html = self.marionette.absolute_url("test_dynamic.html") + self.marionette.navigate(test_html) + self.marionette.timeout.implicit = 3 + with self.assertRaises(NoSuchElementException): + self.marionette.find_element(By.ID, "box0") diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_key_actions.py b/testing/marionette/harness/marionette_harness/tests/unit/test_key_actions.py new file mode 100644 index 0000000000..8481e9d251 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_key_actions.py @@ -0,0 +1,73 @@ +# 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/. + +from __future__ import absolute_import + +from six.moves.urllib.parse import quote + +from marionette_driver.by import By +from marionette_driver.keys import Keys +from marionette_harness import MarionetteTestCase, WindowManagerMixin + + +def inline(doc): + return "data:text/html;charset=utf-8,{}".format(quote(doc)) + + +class TestKeyActions(WindowManagerMixin, MarionetteTestCase): + def setUp(self): + super(TestKeyActions, self).setUp() + self.key_chain = self.marionette.actions.sequence("key", "keyboard_id") + + if self.marionette.session_capabilities["platformName"] == "mac": + self.mod_key = Keys.META + else: + self.mod_key = Keys.CONTROL + + test_html = self.marionette.absolute_url("keyboard.html") + self.marionette.navigate(test_html) + self.reporter_element = self.marionette.find_element(By.ID, "keyReporter") + self.reporter_element.click() + + def tearDown(self): + self.marionette.actions.release() + + super(TestKeyActions, self).tearDown() + + @property + def key_reporter_value(self): + return self.reporter_element.get_property("value") + + def test_basic_input(self): + self.key_chain.key_down("a").key_down("b").key_down("c").perform() + self.assertEqual(self.key_reporter_value, "abc") + + def test_upcase_input(self): + self.key_chain.key_down(Keys.SHIFT).key_down("a").key_up(Keys.SHIFT).key_down( + "b" + ).key_down("c").perform() + self.assertEqual(self.key_reporter_value, "Abc") + + def test_replace_input(self): + self.key_chain.key_down("a").key_down("b").key_down("c").perform() + self.assertEqual(self.key_reporter_value, "abc") + + self.key_chain.key_down(self.mod_key).key_down("a").key_up( + self.mod_key + ).key_down("x").perform() + self.assertEqual(self.key_reporter_value, "x") + + def test_clear_input(self): + self.key_chain.key_down("a").key_down("b").key_down("c").perform() + self.assertEqual(self.key_reporter_value, "abc") + + self.key_chain.key_down(self.mod_key).key_down("a").key_down("x").perform() + self.assertEqual(self.key_reporter_value, "") + + def test_input_with_wait(self): + self.key_chain.key_down("a").key_down("b").key_down("c").perform() + self.key_chain.key_down(self.mod_key).key_down("a").pause(250).key_down( + "x" + ).perform() + self.assertEqual(self.key_reporter_value, "") diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_localization.py b/testing/marionette/harness/marionette_harness/tests/unit/test_localization.py new file mode 100644 index 0000000000..e1e112d21e --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_localization.py @@ -0,0 +1,73 @@ +# 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/. + +from __future__ import absolute_import + +from marionette_driver import By +from marionette_driver.errors import ( + InvalidArgumentException, + NoSuchElementException, + UnknownException, +) +from marionette_driver.localization import L10n + +from marionette_harness import MarionetteTestCase + + +class TestL10n(MarionetteTestCase): + def setUp(self): + super(TestL10n, self).setUp() + + self.l10n = L10n(self.marionette) + + def test_localize_entity(self): + dtds = ["chrome://marionette/content/test_dialog.dtd"] + value = self.l10n.localize_entity(dtds, "testDialog.title") + + self.assertEqual(value, "Test Dialog") + + def test_localize_entity_invalid_arguments(self): + dtds = ["chrome://marionette/content/test_dialog.dtd"] + + self.assertRaises( + NoSuchElementException, self.l10n.localize_entity, dtds, "notExistent" + ) + self.assertRaises( + InvalidArgumentException, self.l10n.localize_entity, dtds[0], "notExistent" + ) + self.assertRaises( + InvalidArgumentException, self.l10n.localize_entity, dtds, True + ) + + def test_localize_property(self): + properties = ["chrome://marionette/content/test_dialog.properties"] + + value = self.l10n.localize_property(properties, "testDialog.title") + self.assertEqual(value, "Test Dialog") + + self.assertRaises( + NoSuchElementException, + self.l10n.localize_property, + properties, + "notExistent", + ) + + def test_localize_property_invalid_arguments(self): + properties = ["chrome://global/locale/filepicker.properties"] + + self.assertRaises( + NoSuchElementException, + self.l10n.localize_property, + properties, + "notExistent", + ) + self.assertRaises( + InvalidArgumentException, + self.l10n.localize_property, + properties[0], + "notExistent", + ) + self.assertRaises( + InvalidArgumentException, self.l10n.localize_property, properties, True + ) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_marionette.py b/testing/marionette/harness/marionette_harness/tests/unit/test_marionette.py new file mode 100644 index 0000000000..c3d43bbe7b --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_marionette.py @@ -0,0 +1,111 @@ +# 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/. + +from __future__ import absolute_import + +import socket +import time + +from marionette_driver import errors +from marionette_driver.marionette import Marionette +from marionette_harness import MarionetteTestCase, run_if_manage_instance + + +class TestMarionette(MarionetteTestCase): + def test_correct_test_name(self): + """Test that the correct test name gets set.""" + expected_test_name = "{module}.py {cls}.{func}".format( + module=__name__, + cls=self.__class__.__name__, + func=self.test_correct_test_name.__name__, + ) + + self.assertIn(expected_test_name, self.marionette.test_name) + + @run_if_manage_instance("Only runnable if Marionette manages the instance") + def test_raise_for_port_non_existing_process(self): + """Test that raise_for_port doesn't run into a timeout if instance is not running.""" + self.marionette.quit() + self.assertIsNotNone(self.marionette.instance.runner.returncode) + start_time = time.time() + self.assertRaises(socket.timeout, self.marionette.raise_for_port, timeout=5) + self.assertLess(time.time() - start_time, 5) + + def test_disable_enable_new_connections(self): + # Do not re-create socket if it already exists + self.marionette._send_message("Marionette:AcceptConnections", {"value": True}) + + try: + # Disabling new connections does not affect existing ones... + self.marionette._send_message( + "Marionette:AcceptConnections", {"value": False} + ) + self.assertEqual(1, self.marionette.execute_script("return 1")) + + # but only new connection attempts + marionette = Marionette( + host=self.marionette.host, port=self.marionette.port + ) + self.assertRaises(socket.timeout, marionette.raise_for_port, timeout=1.0) + + self.marionette._send_message( + "Marionette:AcceptConnections", {"value": True} + ) + marionette.raise_for_port(timeout=10.0) + + finally: + self.marionette._send_message( + "Marionette:AcceptConnections", {"value": True} + ) + + def test_client_socket_uses_expected_socket_timeout(self): + current_socket_timeout = self.marionette.socket_timeout + + self.assertEqual(current_socket_timeout, self.marionette.client.socket_timeout) + self.assertEqual( + current_socket_timeout, self.marionette.client._sock.gettimeout() + ) + + def test_application_update_disabled(self): + # Updates of the application should always be disabled by default + with self.marionette.using_context("chrome"): + update_allowed = self.marionette.execute_script( + """ + let aus = Cc['@mozilla.org/updates/update-service;1'] + .getService(Ci.nsIApplicationUpdateService); + return aus.canCheckForUpdates; + """ + ) + + self.assertFalse(update_allowed) + + +class TestContext(MarionetteTestCase): + def setUp(self): + MarionetteTestCase.setUp(self) + self.marionette.set_context(self.marionette.CONTEXT_CONTENT) + + def get_context(self): + return self.marionette._send_message("Marionette:GetContext", key="value") + + def set_context(self, value): + return self.marionette._send_message("Marionette:SetContext", {"value": value}) + + def test_set_context(self): + self.assertEqual(self.set_context("content"), {"value": None}) + self.assertEqual(self.set_context("chrome"), {"value": None}) + + for typ in [True, 42, [], {}, None]: + with self.assertRaises(errors.InvalidArgumentException): + self.set_context(typ) + + with self.assertRaises(errors.MarionetteException): + self.set_context("foo") + + def test_get_context(self): + self.assertEqual(self.get_context(), "content") + self.set_context("chrome") + self.assertEqual(self.get_context(), "chrome") + self.set_context("content") + self.assertEqual(self.get_context(), "content") diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_modal_dialogs.py b/testing/marionette/harness/marionette_harness/tests/unit/test_modal_dialogs.py new file mode 100644 index 0000000000..2177214ca0 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_modal_dialogs.py @@ -0,0 +1,201 @@ +from __future__ import absolute_import + +from marionette_driver.by import By +from marionette_driver.expected import element_present +from marionette_driver import errors +from marionette_driver.marionette import Alert +from marionette_driver.wait import Wait + +from marionette_harness import MarionetteTestCase, parameterized, WindowManagerMixin + + +class BaseAlertTestCase(WindowManagerMixin, MarionetteTestCase): + def setUp(self): + super(BaseAlertTestCase, self).setUp() + self.new_tab = self.open_tab() + self.marionette.switch_to_window(self.new_tab) + + def tearDown(self): + self.close_all_tabs() + super(BaseAlertTestCase, self).tearDown() + + @property + def alert_present(self): + try: + Alert(self.marionette).text + return True + except errors.NoAlertPresentException: + return False + + def wait_for_alert(self, timeout=None): + Wait(self.marionette, timeout=timeout).until(lambda _: self.alert_present) + + +class TestTabModalAlerts(BaseAlertTestCase): + def setUp(self): + super(TestTabModalAlerts, self).setUp() + + self.test_page = self.marionette.absolute_url("test_tab_modal_dialogs.html") + self.marionette.navigate(self.test_page) + + def tearDown(self): + # Ensure to close all possible remaining tab modal dialogs + try: + while True: + alert = self.marionette.switch_to_alert() + alert.dismiss() + except errors.NoAlertPresentException: + pass + + super(TestTabModalAlerts, self).tearDown() + + def test_no_alert_raises(self): + with self.assertRaises(errors.NoAlertPresentException): + Alert(self.marionette).accept() + with self.assertRaises(errors.NoAlertPresentException): + Alert(self.marionette).dismiss() + + def test_alert_opened_before_session_starts(self): + self.marionette.find_element(By.ID, "tab-modal-alert").click() + self.wait_for_alert() + + # Restart the session to ensure we still find the formerly left-open dialog. + self.marionette.delete_session() + self.marionette.start_session() + + alert = self.marionette.switch_to_alert() + alert.dismiss() + + @parameterized("alert", "alert", "undefined") + @parameterized("confirm", "confirm", "true") + @parameterized("prompt", "prompt", "") + def test_accept(self, value, result): + self.marionette.find_element(By.ID, "tab-modal-{}".format(value)).click() + self.wait_for_alert() + alert = self.marionette.switch_to_alert() + alert.accept() + self.assertEqual(self.marionette.find_element(By.ID, "text").text, result) + + @parameterized("alert", "alert", "undefined") + @parameterized("confirm", "confirm", "false") + @parameterized("prompt", "prompt", "null") + def test_dismiss(self, value, result): + self.marionette.find_element(By.ID, "tab-modal-{}".format(value)).click() + self.wait_for_alert() + alert = self.marionette.switch_to_alert() + alert.dismiss() + self.assertEqual(self.marionette.find_element(By.ID, "text").text, result) + + @parameterized("alert", "alert", "Marionette alert") + @parameterized("confirm", "confirm", "Marionette confirm") + @parameterized("prompt", "prompt", "Marionette prompt") + def test_text(self, value, text): + with self.assertRaises(errors.NoAlertPresentException): + alert = self.marionette.switch_to_alert() + alert.text + self.marionette.find_element(By.ID, "tab-modal-{}".format(value)).click() + self.wait_for_alert() + alert = self.marionette.switch_to_alert() + self.assertEqual(alert.text, text) + alert.accept() + + @parameterized("alert", "alert") + @parameterized("confirm", "confirm") + def test_set_text_throws(self, value): + with self.assertRaises(errors.NoAlertPresentException): + Alert(self.marionette).send_keys("Foo") + self.marionette.find_element(By.ID, "tab-modal-{}".format(value)).click() + self.wait_for_alert() + alert = self.marionette.switch_to_alert() + with self.assertRaises(errors.ElementNotInteractableException): + alert.send_keys("Foo") + alert.accept() + + def test_set_text_accept(self): + self.marionette.find_element(By.ID, "tab-modal-prompt").click() + self.wait_for_alert() + alert = self.marionette.switch_to_alert() + alert.send_keys("Foo bar") + alert.accept() + self.assertEqual(self.marionette.find_element(By.ID, "text").text, "Foo bar") + + def test_set_text_dismiss(self): + self.marionette.find_element(By.ID, "tab-modal-prompt").click() + self.wait_for_alert() + alert = self.marionette.switch_to_alert() + alert.send_keys("Some text!") + alert.dismiss() + self.assertEqual(self.marionette.find_element(By.ID, "text").text, "null") + + def test_unrelated_command_when_alert_present(self): + self.marionette.find_element(By.ID, "tab-modal-alert").click() + self.wait_for_alert() + with self.assertRaises(errors.UnexpectedAlertOpen): + self.marionette.find_element(By.ID, "text") + + def test_modal_is_dismissed_after_unexpected_alert(self): + self.marionette.find_element(By.ID, "tab-modal-alert").click() + self.wait_for_alert() + with self.assertRaises(errors.UnexpectedAlertOpen): + self.marionette.find_element(By.ID, "text") + + assert not self.alert_present + + def test_handle_two_dialogs(self): + self.marionette.find_element(By.ID, "open-two-dialogs").click() + + alert1 = self.marionette.switch_to_alert() + alert1.send_keys("foo") + alert1.accept() + + alert2 = self.marionette.switch_to_alert() + alert2.send_keys("bar") + alert2.accept() + + self.assertEqual(self.marionette.find_element(By.ID, "text1").text, "foo") + self.assertEqual(self.marionette.find_element(By.ID, "text2").text, "bar") + + +class TestModalAlerts(BaseAlertTestCase): + def setUp(self): + super(TestModalAlerts, self).setUp() + self.marionette.set_pref( + "network.auth.non-web-content-triggered-resources-http-auth-allow", + True, + ) + + def tearDown(self): + self.marionette.clear_pref( + "network.auth.non-web-content-triggered-resources-http-auth-allow" + ) + super(TestModalAlerts, self).tearDown() + + def test_http_auth_dismiss(self): + self.marionette.navigate(self.marionette.absolute_url("http_auth")) + self.wait_for_alert(timeout=self.marionette.timeout.page_load) + alert = self.marionette.switch_to_alert() + alert.dismiss() + + status = Wait(self.marionette, timeout=self.marionette.timeout.page_load).until( + element_present(By.ID, "status") + ) + self.assertEqual(status.text, "restricted") + + def test_http_auth_send_keys(self): + self.marionette.navigate(self.marionette.absolute_url("http_auth")) + self.wait_for_alert(timeout=self.marionette.timeout.page_load) + + alert = self.marionette.switch_to_alert() + with self.assertRaises(errors.UnsupportedOperationException): + alert.send_keys("foo") + + def test_alert_opened_before_session_starts(self): + self.marionette.navigate(self.marionette.absolute_url("http_auth")) + self.wait_for_alert(timeout=self.marionette.timeout.page_load) + + # Restart the session to ensure we still find the formerly left-open dialog. + self.marionette.delete_session() + self.marionette.start_session() + + alert = self.marionette.switch_to_alert() + alert.dismiss() diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_mouse_action.py b/testing/marionette/harness/marionette_harness/tests/unit/test_mouse_action.py new file mode 100644 index 0000000000..a27965964a --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_mouse_action.py @@ -0,0 +1,199 @@ +# 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/. + +from __future__ import absolute_import, division + +from six.moves.urllib.parse import quote + +from marionette_driver import By, errors, Wait +from marionette_driver.keys import Keys + +from marionette_harness import MarionetteTestCase + + +def inline(doc): + return "data:text/html;charset=utf-8,{}".format(quote(doc)) + + +class BaseMouseAction(MarionetteTestCase): + def setUp(self): + super(BaseMouseAction, self).setUp() + self.mouse_chain = self.marionette.actions.sequence( + "pointer", "pointer_id", {"pointerType": "mouse"} + ) + + if self.marionette.session_capabilities["platformName"] == "mac": + self.mod_key = Keys.META + else: + self.mod_key = Keys.CONTROL + + def tearDown(self): + self.marionette.actions.release() + + super(BaseMouseAction, self).tearDown() + + @property + def click_position(self): + return self.marionette.execute_script( + """ + if (window.click_x && window.click_y) { + return {x: window.click_x, y: window.click_y}; + } + """, + sandbox=None, + ) + + def get_element_center_point(self, elem): + # pylint --py3k W1619 + return { + "x": elem.rect["x"] + elem.rect["width"] / 2, + "y": elem.rect["y"] + elem.rect["height"] / 2, + } + + +class TestPointerActions(BaseMouseAction): + def test_click_action(self): + test_html = self.marionette.absolute_url("test.html") + self.marionette.navigate(test_html) + link = self.marionette.find_element(By.ID, "mozLink") + self.mouse_chain.click(element=link).perform() + self.assertEqual( + "Clicked", + self.marionette.execute_script( + "return document.getElementById('mozLink').innerHTML" + ), + ) + + def test_clicking_element_out_of_view(self): + self.marionette.navigate( + inline( + """ + <div style="position:relative;top:200vh;">foo</div> + """ + ) + ) + el = self.marionette.find_element(By.TAG_NAME, "div") + with self.assertRaises(errors.MoveTargetOutOfBoundsException): + self.mouse_chain.click(element=el).perform() + + def test_double_click_action(self): + self.marionette.navigate( + inline( + """ + <script>window.eventCount = 0;</script> + <button onclick="window.eventCount++">foobar</button> + """ + ) + ) + + el = self.marionette.find_element(By.CSS_SELECTOR, "button") + self.mouse_chain.click(el).pause(100).click(el).perform() + + event_count = self.marionette.execute_script( + "return window.eventCount", sandbox=None + ) + self.assertEqual(event_count, 2) + + def test_context_click_action(self): + test_html = self.marionette.absolute_url("clicks.html") + self.marionette.navigate(test_html) + click_el = self.marionette.find_element(By.ID, "normal") + + def context_menu_state(): + with self.marionette.using_context("chrome"): + cm_el = self.marionette.find_element(By.ID, "contentAreaContextMenu") + return cm_el.get_property("state") + + self.assertEqual("closed", context_menu_state()) + self.mouse_chain.click(element=click_el, button=2).perform() + Wait(self.marionette).until( + lambda _: context_menu_state() == "open", + message="Context menu did not open", + ) + with self.marionette.using_context("chrome"): + self.marionette.find_element(By.ID, "main-window").send_keys(Keys.ESCAPE) + Wait(self.marionette).until( + lambda _: context_menu_state() == "closed", + message="Context menu did not close", + ) + + def test_middle_click_action(self): + test_html = self.marionette.absolute_url("clicks.html") + self.marionette.navigate(test_html) + + self.marionette.find_element(By.ID, "addbuttonlistener").click() + + el = self.marionette.find_element(By.ID, "showbutton") + self.mouse_chain.click(element=el, button=1).perform() + + Wait(self.marionette).until( + lambda _: el.get_property("innerHTML") == "1", + message="Middle-click hasn't been fired", + ) + + +class TestNonSpecCompliantPointerOrigin(BaseMouseAction): + def setUp(self): + super(TestNonSpecCompliantPointerOrigin, self).setUp() + + self.marionette.delete_session() + self.marionette.start_session({"moz:useNonSpecCompliantPointerOrigin": True}) + + def tearDown(self): + self.marionette.delete_session() + self.marionette.start_session() + + super(TestNonSpecCompliantPointerOrigin, self).tearDown() + + def test_click_element_smaller_than_viewport(self): + self.marionette.navigate( + inline( + """ + <div id="div" style="width: 10vw; height: 10vh; background: green;" + onclick="window.click_x = event.clientX; window.click_y = event.clientY" /> + """ + ) + ) + elem = self.marionette.find_element(By.ID, "div") + elem_center_point = self.get_element_center_point(elem) + + self.mouse_chain.click(element=elem).perform() + click_position = Wait(self.marionette).until( + lambda _: self.click_position, message="No click event has been detected" + ) + self.assertAlmostEqual(click_position["x"], elem_center_point["x"], delta=1) + self.assertAlmostEqual(click_position["y"], elem_center_point["y"], delta=1) + + def test_click_element_larger_than_viewport_with_center_point_inside(self): + self.marionette.navigate( + inline( + """ + <div id="div" style="width: 150vw; height: 150vh; background: green;" + onclick="window.click_x = event.clientX; window.click_y = event.clientY" /> + """ + ) + ) + elem = self.marionette.find_element(By.ID, "div") + elem_center_point = self.get_element_center_point(elem) + + self.mouse_chain.click(element=elem).perform() + click_position = Wait(self.marionette).until( + lambda _: self.click_position, message="No click event has been detected" + ) + self.assertAlmostEqual(click_position["x"], elem_center_point["x"], delta=1) + self.assertAlmostEqual(click_position["y"], elem_center_point["y"], delta=1) + + def test_click_element_larger_than_viewport_with_center_point_outside(self): + self.marionette.navigate( + inline( + """ + <div id="div" style="width: 300vw; height: 300vh; background: green;" + onclick="window.click_x = event.clientX; window.click_y = event.clientY" /> + """ + ) + ) + elem = self.marionette.find_element(By.ID, "div") + + with self.assertRaises(errors.MoveTargetOutOfBoundsException): + self.mouse_chain.click(element=elem).perform() diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_navigation.py b/testing/marionette/harness/marionette_harness/tests/unit/test_navigation.py new file mode 100644 index 0000000000..09bee9e6b3 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_navigation.py @@ -0,0 +1,931 @@ +# 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/. + +from __future__ import absolute_import, print_function + +import contextlib +import os + +from six.moves.urllib.parse import quote + +from marionette_driver import By, errors, expected, Wait +from marionette_driver.keys import Keys +from marionette_driver.marionette import Alert +from marionette_harness import ( + MarionetteTestCase, + run_if_manage_instance, + skip_if_framescript, + skip_unless_browser_pref, + WindowManagerMixin, +) + +here = os.path.abspath(os.path.dirname(__file__)) + + +BLACK_PIXEL = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==" # noqa +RED_PIXEL = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEX/TQBcNTh/AAAAAXRSTlPM0jRW/QAAAApJREFUeJxjYgAAAAYAAzY3fKgAAAAASUVORK5CYII=" # noqa + + +def inline(doc): + return "data:text/html;charset=utf-8,%s" % quote(doc) + + +def inline_image(data): + return "data:image/png;base64,%s" % data + + +class BaseNavigationTestCase(WindowManagerMixin, MarionetteTestCase): + def setUp(self): + super(BaseNavigationTestCase, self).setUp() + + file_path = os.path.join(here, "data", "test.html").replace("\\", "/") + + self.test_page_file_url = "file:///{}".format(file_path) + self.test_page_frameset = self.marionette.absolute_url("frameset.html") + self.test_page_insecure = self.fixtures.where_is("test.html", on="https") + self.test_page_not_remote = "about:robots" + self.test_page_push_state = self.marionette.absolute_url( + "navigation_pushstate.html" + ) + self.test_page_remote = self.marionette.absolute_url("test.html") + self.test_page_slow_resource = self.marionette.absolute_url( + "slow_resource.html" + ) + + if self.marionette.session_capabilities["platformName"] == "mac": + self.mod_key = Keys.META + else: + self.mod_key = Keys.CONTROL + + # Always use a blank new tab for an empty history + self.new_tab = self.open_tab() + self.marionette.switch_to_window(self.new_tab) + Wait(self.marionette, timeout=self.marionette.timeout.page_load).until( + lambda _: self.history_length == 1, + message="The newly opened tab doesn't have a browser history length of 1", + ) + + def tearDown(self): + self.marionette.timeout.reset() + + self.close_all_tabs() + + super(BaseNavigationTestCase, self).tearDown() + + @property + def history_length(self): + return self.marionette.execute_script("return window.history.length;") + + @property + def is_remote_tab(self): + with self.marionette.using_context("chrome"): + # TODO: DO NOT USE MOST RECENT WINDOW BUT CURRENT ONE + return self.marionette.execute_script( + """ + Components.utils.import("resource://gre/modules/AppConstants.jsm"); + + let win = null; + + if (AppConstants.MOZ_APP_NAME == "fennec") { + Components.utils.import("resource://gre/modules/Services.jsm"); + win = Services.wm.getMostRecentWindow("navigator:browser"); + } else { + Components.utils.import("resource:///modules/BrowserWindowTracker.jsm"); + win = BrowserWindowTracker.getTopWindow(); + } + + let tabBrowser = null; + + // Fennec + if (win.BrowserApp) { + tabBrowser = win.BrowserApp.selectedBrowser; + + // Firefox + } else if (win.gBrowser) { + tabBrowser = win.gBrowser.selectedBrowser; + + } else { + return null; + } + + return tabBrowser.isRemoteBrowser; + """ + ) + + @property + def ready_state(self): + return self.marionette.execute_script( + "return window.document.readyState;", sandbox=None + ) + + +class TestNavigate(BaseNavigationTestCase): + def test_set_location_through_execute_script(self): + # To avoid unexpected remoteness changes and a hang in any non-navigation + # command (bug 1519354) when navigating via the location bar, already + # pre-load a page which causes a remoteness change. + self.marionette.navigate(self.test_page_push_state) + + self.marionette.execute_script( + "window.location.href = arguments[0];", + script_args=(self.test_page_remote,), + sandbox=None, + ) + + Wait(self.marionette, timeout=self.marionette.timeout.page_load).until( + expected.element_present(*(By.ID, "testh1")), + message="Target element 'testh1' has not been found", + ) + + self.assertEqual(self.test_page_remote, self.marionette.get_url()) + + def test_navigate_chrome_unsupported_error(self): + with self.marionette.using_context("chrome"): + self.assertRaises( + errors.UnsupportedOperationException, + self.marionette.navigate, + "about:blank", + ) + self.assertRaises( + errors.UnsupportedOperationException, self.marionette.go_back + ) + self.assertRaises( + errors.UnsupportedOperationException, self.marionette.go_forward + ) + self.assertRaises( + errors.UnsupportedOperationException, self.marionette.refresh + ) + + def test_get_current_url_returns_top_level_browsing_context_url(self): + page_iframe = self.marionette.absolute_url("test_iframe.html") + + self.marionette.navigate(page_iframe) + self.assertEqual(page_iframe, self.marionette.get_url()) + frame = self.marionette.find_element(By.CSS_SELECTOR, "#test_iframe") + self.marionette.switch_to_frame(frame) + self.assertEqual(page_iframe, self.marionette.get_url()) + + def test_get_current_url(self): + self.marionette.navigate(self.test_page_remote) + self.assertEqual(self.test_page_remote, self.marionette.get_url()) + self.marionette.navigate("about:blank") + self.assertEqual("about:blank", self.marionette.get_url()) + + def test_navigate_in_child_frame_changes_to_top(self): + self.marionette.navigate(self.test_page_frameset) + frame = self.marionette.find_element(By.NAME, "third") + self.marionette.switch_to_frame(frame) + self.assertRaises( + errors.NoSuchElementException, + self.marionette.find_element, + By.NAME, + "third", + ) + + self.marionette.navigate(self.test_page_frameset) + self.marionette.find_element(By.NAME, "third") + + def test_invalid_url(self): + with self.assertRaises(errors.MarionetteException): + self.marionette.navigate("foo") + with self.assertRaises(errors.MarionetteException): + self.marionette.navigate("thisprotocoldoesnotexist://") + + def test_find_element_state_complete(self): + self.marionette.navigate(self.test_page_remote) + self.assertEqual("complete", self.ready_state) + self.assertTrue(self.marionette.find_element(By.ID, "mozLink")) + + def test_navigate_timeout_error_no_remoteness_change(self): + is_remote_before_timeout = self.is_remote_tab + self.marionette.timeout.page_load = 0.5 + with self.assertRaises(errors.TimeoutException): + self.marionette.navigate(self.marionette.absolute_url("slow")) + self.assertEqual(self.is_remote_tab, is_remote_before_timeout) + + def test_navigate_timeout_error_remoteness_change(self): + self.assertTrue(self.is_remote_tab) + self.marionette.navigate("about:robots") + self.assertFalse(self.is_remote_tab) + + self.marionette.timeout.page_load = 0.5 + with self.assertRaises(errors.TimeoutException): + self.marionette.navigate(self.marionette.absolute_url("slow")) + + def test_navigate_to_same_image_document_twice(self): + self.marionette.navigate(self.fixtures.where_is("black.png")) + self.assertIn("black.png", self.marionette.title) + self.marionette.navigate(self.fixtures.where_is("black.png")) + self.assertIn("black.png", self.marionette.title) + + def test_navigate_hash_change(self): + doc = inline("<p id=foo>") + self.marionette.navigate(doc) + self.marionette.execute_script("window.visited = true", sandbox=None) + self.marionette.navigate("{}#foo".format(doc)) + self.assertTrue( + self.marionette.execute_script("return window.visited", sandbox=None) + ) + + def test_navigate_hash_argument_identical(self): + test_page = "{}#foo".format(inline("<p id=foo>")) + + self.marionette.navigate(test_page) + self.marionette.find_element(By.ID, "foo") + self.marionette.navigate(test_page) + self.marionette.find_element(By.ID, "foo") + + def test_navigate_hash_argument_differnt(self): + test_page = "{}#Foo".format(inline("<p id=foo>")) + + self.marionette.navigate(test_page) + self.marionette.find_element(By.ID, "foo") + self.marionette.navigate(test_page.lower()) + self.marionette.find_element(By.ID, "foo") + + def test_navigate_history_pushstate(self): + target_page = self.marionette.absolute_url("navigation_pushstate_target.html") + + self.marionette.navigate(self.test_page_push_state) + self.marionette.find_element(By.ID, "forward").click() + + # By using pushState() the URL is updated but the target page is not loaded + # and as such the element is not displayed + self.assertEqual(self.marionette.get_url(), target_page) + with self.assertRaises(errors.NoSuchElementException): + self.marionette.find_element(By.ID, "target") + + self.marionette.go_back() + self.assertEqual(self.marionette.get_url(), self.test_page_push_state) + + # The target page still gets not loaded + self.marionette.go_forward() + self.assertEqual(self.marionette.get_url(), target_page) + with self.assertRaises(errors.NoSuchElementException): + self.marionette.find_element(By.ID, "target") + + # Navigating to a different page, and returning to the injected + # page, it will be loaded. + self.marionette.navigate(self.test_page_remote) + self.assertEqual(self.marionette.get_url(), self.test_page_remote) + + self.marionette.go_back() + self.assertEqual(self.marionette.get_url(), target_page) + self.marionette.find_element(By.ID, "target") + + self.marionette.go_back() + self.assertEqual(self.marionette.get_url(), self.test_page_push_state) + + def test_navigate_file_url(self): + self.marionette.navigate(self.test_page_file_url) + self.marionette.find_element(By.ID, "file-url") + self.marionette.navigate(self.test_page_remote) + + def test_navigate_file_url_remoteness_change(self): + self.marionette.navigate("about:robots") + self.assertFalse(self.is_remote_tab) + + self.marionette.navigate(self.test_page_file_url) + self.assertTrue(self.is_remote_tab) + self.marionette.find_element(By.ID, "file-url") + + self.marionette.navigate("about:robots") + self.assertFalse(self.is_remote_tab) + + def test_about_blank_for_new_docshell(self): + self.assertEqual(self.marionette.get_url(), "about:blank") + + self.marionette.navigate("about:blank") + + def test_about_newtab(self): + with self.marionette.using_prefs({"browser.newtabpage.enabled": True}): + self.marionette.navigate("about:newtab") + + self.marionette.navigate(self.test_page_remote) + self.marionette.find_element(By.ID, "testDiv") + + @run_if_manage_instance("Only runnable if Marionette manages the instance") + def test_focus_after_navigation(self): + self.marionette.restart() + + self.marionette.navigate(inline("<input autofocus>")) + focus_el = self.marionette.find_element(By.CSS_SELECTOR, ":focus") + self.assertEqual(self.marionette.get_active_element(), focus_el) + + def test_no_hang_when_navigating_after_closing_original_tab(self): + # Close the start tab + self.marionette.switch_to_window(self.start_tab) + self.marionette.close() + + self.marionette.switch_to_window(self.new_tab) + self.marionette.navigate(self.test_page_remote) + + def test_type_to_non_remote_tab(self): + self.marionette.navigate(self.test_page_not_remote) + self.assertFalse(self.is_remote_tab) + + with self.marionette.using_context("chrome"): + urlbar = self.marionette.find_element(By.ID, "urlbar-input") + urlbar.send_keys(self.mod_key + "a") + urlbar.send_keys(self.mod_key + "x") + urlbar.send_keys("about:support" + Keys.ENTER) + + Wait(self.marionette, timeout=self.marionette.timeout.page_load).until( + lambda mn: mn.get_url() == "about:support", + message="'about:support' hasn't been loaded", + ) + self.assertFalse(self.is_remote_tab) + + def test_type_to_remote_tab(self): + self.assertTrue(self.is_remote_tab) + + with self.marionette.using_context("chrome"): + urlbar = self.marionette.find_element(By.ID, "urlbar-input") + urlbar.send_keys(self.mod_key + "a") + urlbar.send_keys(self.mod_key + "x") + urlbar.send_keys(self.test_page_remote + Keys.ENTER) + + Wait(self.marionette, timeout=self.marionette.timeout.page_load).until( + lambda mn: mn.get_url() == self.test_page_remote, + message="'{}' hasn't been loaded".format(self.test_page_remote), + ) + self.assertTrue(self.is_remote_tab) + + +class TestBackForwardNavigation(BaseNavigationTestCase): + def run_bfcache_test(self, test_pages): + # Helper method to run simple back and forward testcases. + + def check_page_status(page, expected_history_length): + if "alert_text" in page: + if page["alert_text"] is None: + # navigation auto-dismisses beforeunload prompt + with self.assertRaises(errors.NoAlertPresentException): + Alert(self.marionette).text + else: + self.assertEqual(Alert(self.marionette).text, page["alert_text"]) + + self.assertEqual(self.marionette.get_url(), page["url"]) + self.assertEqual(self.history_length, expected_history_length) + + if "is_remote" in page: + self.assertEqual( + page["is_remote"], + self.is_remote_tab, + "'{}' doesn't match expected remoteness state: {}".format( + page["url"], page["is_remote"] + ), + ) + + if "callback" in page and callable(page["callback"]): + page["callback"]() + + for index, page in enumerate(test_pages): + if "error" in page: + with self.assertRaises(page["error"]): + self.marionette.navigate(page["url"]) + else: + self.marionette.navigate(page["url"]) + + check_page_status(page, index + 1) + + # Now going back in history for all test pages by backward iterating + # through the list (-1) and skipping the first entry at the end (-2). + for page in test_pages[-2::-1]: + if "error" in page: + with self.assertRaises(page["error"]): + self.marionette.go_back() + else: + self.marionette.go_back() + + check_page_status(page, len(test_pages)) + + # Now going forward in history by skipping the first entry. + for page in test_pages[1::]: + if "error" in page: + with self.assertRaises(page["error"]): + self.marionette.go_forward() + else: + self.marionette.go_forward() + + check_page_status(page, len(test_pages)) + + def test_no_history_items(self): + # Both methods should not raise a failure if no navigation is possible + self.marionette.go_back() + self.marionette.go_forward() + + @skip_unless_browser_pref( + "Bug 1656208 - Always turn on session history in the parent for fission", + "fission.autostart", + lambda value: value is False, + ) + def test_dismissed_beforeunload_prompt(self): + url_beforeunload = inline( + """ + <input type="text"> + <script> + window.addEventListener("beforeunload", function (event) { + event.preventDefault(); + }); + </script> + """ + ) + + def modify_page(): + self.marionette.find_element(By.TAG_NAME, "input").send_keys("foo") + + test_pages = [ + {"url": inline("<p>foobar</p>"), "alert_text": None}, + {"url": url_beforeunload, "callback": modify_page}, + {"url": inline("<p>foobar</p>"), "alert_text": None}, + ] + + self.run_bfcache_test(test_pages) + + @skip_unless_browser_pref( + "Bug 1656208 - Always turn on session history in the parent for fission", + "fission.autostart", + lambda value: value is False, + ) + def test_data_urls(self): + test_pages = [ + {"url": inline("<p>foobar</p>")}, + {"url": self.test_page_remote}, + {"url": inline("<p>foobar</p>")}, + ] + self.run_bfcache_test(test_pages) + + @skip_unless_browser_pref( + "Bug 1656208 - Always turn on session history in the parent for fission", + "fission.autostart", + lambda value: value is False, + ) + def test_same_document_hash_change(self): + test_pages = [ + {"url": "{}#23".format(self.test_page_remote)}, + {"url": self.test_page_remote}, + {"url": "{}#42".format(self.test_page_remote)}, + ] + self.run_bfcache_test(test_pages) + + @skip_unless_browser_pref( + "Bug 1656208 - Always turn on session history in the parent for fission", + "fission.autostart", + lambda value: value is False, + ) + def test_file_url(self): + test_pages = [ + {"url": self.test_page_remote}, + {"url": self.test_page_file_url}, + {"url": self.test_page_remote}, + ] + self.run_bfcache_test(test_pages) + + @skip_unless_browser_pref( + "Bug 1656208 - Always turn on session history in the parent for fission", + "fission.autostart", + lambda value: value is False, + ) + def test_frameset(self): + test_pages = [ + {"url": self.marionette.absolute_url("frameset.html")}, + {"url": self.test_page_remote}, + {"url": self.marionette.absolute_url("frameset.html")}, + ] + self.run_bfcache_test(test_pages) + + @skip_unless_browser_pref( + "Bug 1656208 - Always turn on session history in the parent for fission", + "fission.autostart", + lambda value: value is False, + ) + def test_frameset_after_navigating_in_frame(self): + test_element_locator = (By.ID, "email") + + self.marionette.navigate(self.test_page_remote) + self.assertEqual(self.marionette.get_url(), self.test_page_remote) + self.assertEqual(self.history_length, 1) + page = self.marionette.absolute_url("frameset.html") + self.marionette.navigate(page) + self.assertEqual(self.marionette.get_url(), page) + self.assertEqual(self.history_length, 2) + frame = self.marionette.find_element(By.ID, "fifth") + self.marionette.switch_to_frame(frame) + link = self.marionette.find_element(By.ID, "linkId") + link.click() + + # We cannot use get_url() to wait until the target page has been loaded, + # because it will return the URL of the top browsing context and doesn't + # wait for the page load to be complete. + Wait(self.marionette, timeout=self.marionette.timeout.page_load).until( + expected.element_present(*test_element_locator), + message="Target element 'email' has not been found", + ) + self.assertEqual(self.history_length, 3) + + # Go back to the frame the click navigated away from + self.marionette.go_back() + self.assertEqual(self.marionette.get_url(), page) + with self.assertRaises(errors.NoSuchElementException): + self.marionette.find_element(*test_element_locator) + + # Go back to the non-frameset page + self.marionette.switch_to_parent_frame() + self.marionette.go_back() + self.assertEqual(self.marionette.get_url(), self.test_page_remote) + + # Go forward to the frameset page + self.marionette.go_forward() + self.assertEqual(self.marionette.get_url(), page) + + # Go forward to the frame the click navigated to + # TODO: See above for automatic browser context switches. Hard to do here + frame = self.marionette.find_element(By.ID, "fifth") + self.marionette.switch_to_frame(frame) + self.marionette.go_forward() + self.marionette.find_element(*test_element_locator) + self.assertEqual(self.marionette.get_url(), page) + + @skip_unless_browser_pref( + "Bug 1656208 - Always turn on session history in the parent for fission", + "fission.autostart", + lambda value: value is False, + ) + def test_image_to_html_to_image(self): + test_pages = [ + {"url": self.marionette.absolute_url("black.png")}, + {"url": self.test_page_remote}, + {"url": self.marionette.absolute_url("white.png")}, + ] + self.run_bfcache_test(test_pages) + + @skip_unless_browser_pref( + "Bug 1656208 - Always turn on session history in the parent for fission", + "fission.autostart", + lambda value: value is False, + ) + def test_image_to_image(self): + test_pages = [ + {"url": self.marionette.absolute_url("black.png")}, + {"url": self.marionette.absolute_url("white.png")}, + {"url": inline_image(RED_PIXEL)}, + {"url": inline_image(BLACK_PIXEL)}, + {"url": self.marionette.absolute_url("black.png")}, + ] + self.run_bfcache_test(test_pages) + + @skip_unless_browser_pref( + "Bug 1656208 - Always turn on session history in the parent for fission", + "fission.autostart", + lambda value: value is False, + ) + def test_remoteness_change(self): + test_pages = [ + {"url": "about:robots", "is_remote": False}, + {"url": self.test_page_remote, "is_remote": True}, + {"url": "about:robots", "is_remote": False}, + ] + self.run_bfcache_test(test_pages) + + @skip_unless_browser_pref( + "Bug 1656208 - Always turn on session history in the parent for fission", + "fission.autostart", + lambda value: value is False, + ) + def test_non_remote_about_pages(self): + test_pages = [ + {"url": "about:preferences", "is_remote": False}, + {"url": "about:robots", "is_remote": False}, + {"url": "about:support", "is_remote": False}, + ] + self.run_bfcache_test(test_pages) + + @skip_unless_browser_pref( + "Bug 1656208 - Always turn on session history in the parent for fission", + "fission.autostart", + lambda value: value is False, + ) + def test_navigate_to_requested_about_page_after_error_page(self): + test_pages = [ + {"url": "about:neterror"}, + {"url": self.test_page_remote}, + {"url": "about:blocked"}, + ] + self.run_bfcache_test(test_pages) + + @skip_unless_browser_pref( + "Bug 1656208 - Always turn on session history in the parent for fission", + "fission.autostart", + lambda value: value is False, + ) + def test_timeout_error(self): + urls = [ + self.marionette.absolute_url("slow?delay=3"), + self.test_page_remote, + self.marionette.absolute_url("slow?delay=4"), + ] + + # First, load all pages completely to get them added to the cache + for index, url in enumerate(urls): + self.marionette.navigate(url) + self.assertEqual(url, self.marionette.get_url()) + self.assertEqual(self.history_length, index + 1) + + self.marionette.go_back() + self.assertEqual(urls[1], self.marionette.get_url()) + + # Force triggering a timeout error + self.marionette.timeout.page_load = 0.5 + with self.assertRaises(errors.TimeoutException): + self.marionette.go_back() + self.marionette.timeout.reset() + + delay = Wait(self.marionette, timeout=self.marionette.timeout.page_load).until( + expected.element_present(By.ID, "delay"), + message="Target element 'delay' has not been found after timeout in 'back'", + ) + self.assertEqual(delay.text, "3") + + self.marionette.go_forward() + self.assertEqual(urls[1], self.marionette.get_url()) + + # Force triggering a timeout error + self.marionette.timeout.page_load = 0.5 + with self.assertRaises(errors.TimeoutException): + self.marionette.go_forward() + self.marionette.timeout.reset() + + delay = Wait(self.marionette, timeout=self.marionette.timeout.page_load).until( + expected.element_present(By.ID, "delay"), + message="Target element 'delay' has not been found after timeout in 'forward'", + ) + self.assertEqual(delay.text, "4") + + @skip_unless_browser_pref( + "Bug 1656208 - Always turn on session history in the parent for fission", + "fission.autostart", + lambda value: value is False, + ) + def test_certificate_error(self): + test_pages = [ + { + "url": self.test_page_insecure, + "error": errors.InsecureCertificateException, + }, + {"url": self.test_page_remote}, + { + "url": self.test_page_insecure, + "error": errors.InsecureCertificateException, + }, + ] + self.run_bfcache_test(test_pages) + + +class TestRefresh(BaseNavigationTestCase): + def test_basic(self): + self.marionette.navigate(self.test_page_remote) + self.assertEqual(self.test_page_remote, self.marionette.get_url()) + + self.marionette.execute_script( + """ + let elem = window.document.createElement('div'); + elem.id = 'someDiv'; + window.document.body.appendChild(elem); + """ + ) + self.marionette.find_element(By.ID, "someDiv") + + self.marionette.refresh() + self.assertEqual(self.test_page_remote, self.marionette.get_url()) + with self.assertRaises(errors.NoSuchElementException): + self.marionette.find_element(By.ID, "someDiv") + + def test_refresh_in_child_frame_navigates_to_top(self): + self.marionette.navigate(self.test_page_frameset) + self.assertEqual(self.test_page_frameset, self.marionette.get_url()) + + frame = self.marionette.find_element(By.NAME, "third") + self.marionette.switch_to_frame(frame) + self.assertRaises( + errors.NoSuchElementException, + self.marionette.find_element, + By.NAME, + "third", + ) + + self.marionette.refresh() + self.marionette.find_element(By.NAME, "third") + + def test_file_url(self): + self.marionette.navigate(self.test_page_file_url) + self.assertEqual(self.test_page_file_url, self.marionette.get_url()) + + self.marionette.refresh() + self.assertEqual(self.test_page_file_url, self.marionette.get_url()) + + def test_dismissed_beforeunload_prompt(self): + self.marionette.navigate( + inline( + """ + <input type="text"> + <script> + window.addEventListener("beforeunload", function (event) { + event.preventDefault(); + }); + </script> + """ + ) + ) + self.marionette.find_element(By.TAG_NAME, "input").send_keys("foo") + self.marionette.refresh() + + # navigation auto-dismisses beforeunload prompt + with self.assertRaises(errors.NoAlertPresentException): + Alert(self.marionette).text + + def test_image(self): + image = self.marionette.absolute_url("black.png") + + self.marionette.navigate(image) + self.assertEqual(image, self.marionette.get_url()) + + self.marionette.refresh() + self.assertEqual(image, self.marionette.get_url()) + + def test_history_pushstate(self): + target_page = self.marionette.absolute_url("navigation_pushstate_target.html") + + self.marionette.navigate(self.test_page_push_state) + self.marionette.find_element(By.ID, "forward").click() + + # By using pushState() the URL is updated but the target page is not loaded + # and as such the element is not displayed + self.assertEqual(self.marionette.get_url(), target_page) + with self.assertRaises(errors.NoSuchElementException): + self.marionette.find_element(By.ID, "target") + + # Refreshing the target page will trigger a full page load. + self.marionette.refresh() + self.assertEqual(self.marionette.get_url(), target_page) + self.marionette.find_element(By.ID, "target") + + self.marionette.go_back() + self.assertEqual(self.marionette.get_url(), self.test_page_push_state) + + def test_timeout_error(self): + slow_page = self.marionette.absolute_url("slow?delay=3") + + self.marionette.navigate(slow_page) + self.assertEqual(slow_page, self.marionette.get_url()) + + self.marionette.timeout.page_load = 0.5 + with self.assertRaises(errors.TimeoutException): + self.marionette.refresh() + self.assertEqual(slow_page, self.marionette.get_url()) + + def test_insecure_error(self): + with self.assertRaises(errors.InsecureCertificateException): + self.marionette.navigate(self.test_page_insecure) + self.assertEqual(self.marionette.get_url(), self.test_page_insecure) + + with self.assertRaises(errors.InsecureCertificateException): + self.marionette.refresh() + + +class TestTLSNavigation(BaseNavigationTestCase): + insecure_tls = {"acceptInsecureCerts": True} + secure_tls = {"acceptInsecureCerts": False} + + def setUp(self): + super(TestTLSNavigation, self).setUp() + + self.test_page_insecure = self.fixtures.where_is("test.html", on="https") + + self.marionette.delete_session() + self.capabilities = self.marionette.start_session(self.insecure_tls) + + def tearDown(self): + try: + self.marionette.delete_session() + self.marionette.start_session() + except: + pass + + super(TestTLSNavigation, self).tearDown() + + @contextlib.contextmanager + def safe_session(self): + try: + self.capabilities = self.marionette.start_session(self.secure_tls) + self.assertFalse(self.capabilities["acceptInsecureCerts"]) + # Always use a blank new tab for an empty history + self.new_tab = self.open_tab() + self.marionette.switch_to_window(self.new_tab) + Wait(self.marionette, timeout=self.marionette.timeout.page_load).until( + lambda _: self.history_length == 1, + message="The newly opened tab doesn't have a browser history length of 1", + ) + yield self.marionette + finally: + self.close_all_tabs() + self.marionette.delete_session() + + @contextlib.contextmanager + def unsafe_session(self): + try: + self.capabilities = self.marionette.start_session(self.insecure_tls) + self.assertTrue(self.capabilities["acceptInsecureCerts"]) + # Always use a blank new tab for an empty history + self.new_tab = self.open_tab() + self.marionette.switch_to_window(self.new_tab) + Wait(self.marionette, timeout=self.marionette.timeout.page_load).until( + lambda _: self.history_length == 1, + message="The newly opened tab doesn't have a browser history length of 1", + ) + yield self.marionette + finally: + self.close_all_tabs() + self.marionette.delete_session() + + def test_navigate_by_command(self): + self.marionette.navigate(self.test_page_insecure) + self.assertIn("https", self.marionette.get_url()) + + def test_navigate_by_click(self): + link_url = self.test_page_insecure + self.marionette.navigate( + inline("<a href=%s>https is the future</a>" % link_url) + ) + self.marionette.find_element(By.TAG_NAME, "a").click() + self.assertIn("https", self.marionette.get_url()) + + def test_deactivation(self): + invalid_cert_url = self.test_page_insecure + + print("with safe session") + with self.safe_session() as session: + with self.assertRaises(errors.InsecureCertificateException): + session.navigate(invalid_cert_url) + + print("with unsafe session") + with self.unsafe_session() as session: + session.navigate(invalid_cert_url) + + print("with safe session again") + with self.safe_session() as session: + with self.assertRaises(errors.InsecureCertificateException): + session.navigate(invalid_cert_url) + + +class TestPageLoadStrategy(BaseNavigationTestCase): + def tearDown(self): + self.marionette.delete_session() + self.marionette.start_session() + + super(TestPageLoadStrategy, self).tearDown() + + @skip_if_framescript("Bug 1675173: Won't be fixed for framescript mode") + def test_none(self): + self.marionette.delete_session() + self.marionette.start_session({"pageLoadStrategy": "none"}) + + self.marionette.navigate(self.test_page_slow_resource) + Wait(self.marionette, timeout=self.marionette.timeout.page_load).until( + lambda _: self.marionette.get_url() == self.test_page_slow_resource, + message="Target page has not been loaded", + ) + self.marionette.find_element(By.ID, "slow") + + def test_eager(self): + self.marionette.delete_session() + self.marionette.start_session({"pageLoadStrategy": "eager"}) + + self.marionette.navigate(self.test_page_slow_resource) + self.assertEqual("interactive", self.ready_state) + self.assertEqual(self.test_page_slow_resource, self.marionette.get_url()) + self.marionette.find_element(By.ID, "slow") + + def test_normal(self): + self.marionette.delete_session() + self.marionette.start_session({"pageLoadStrategy": "normal"}) + + self.marionette.navigate(self.test_page_slow_resource) + self.assertEqual(self.test_page_slow_resource, self.marionette.get_url()) + self.assertEqual("complete", self.ready_state) + self.marionette.find_element(By.ID, "slow") + + def test_strategy_after_remoteness_change(self): + """Bug 1378191 - Reset of capabilities after listener reload.""" + self.marionette.delete_session() + self.marionette.start_session({"pageLoadStrategy": "eager"}) + + # Trigger a remoteness change which will reload the listener script + self.assertTrue( + self.is_remote_tab, "Initial tab doesn't have remoteness flag set" + ) + self.marionette.navigate("about:robots") + self.assertFalse(self.is_remote_tab, "Tab has remoteness flag set") + self.marionette.navigate(self.test_page_slow_resource) + self.assertEqual("interactive", self.ready_state) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_pagesource.py b/testing/marionette/harness/marionette_harness/tests/unit/test_pagesource.py new file mode 100644 index 0000000000..352d4c4fea --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_pagesource.py @@ -0,0 +1,54 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +from __future__ import absolute_import + +from six.moves.urllib.parse import quote + +from marionette_harness import MarionetteTestCase + + +def inline(doc, mime=None, charset=None): + mime = "html" if mime is None else mime + charset = "utf-8" if (charset is None) else charset + return "data:text/{};charset={},{}".format(mime, charset, quote(doc)) + + +class TestPageSource(MarionetteTestCase): + def testShouldReturnTheSourceOfAPage(self): + test_html = inline("<body><p> Check the PageSource</body>") + self.marionette.navigate(test_html) + source = self.marionette.page_source + from_web_api = self.marionette.execute_script( + "return document.documentElement.outerHTML" + ) + self.assertTrue("<html" in source) + self.assertTrue("PageSource" in source) + self.assertEqual(source, from_web_api) + + def testShouldReturnTheSourceOfAPageWhenThereAreUnicodeChars(self): + test_html = inline( + '<head><meta http-equiv="pragma" content="no-cache"/></head><body><!-- the \u00ab section[id^="wifi-"] \u00bb selector.--></body>' + ) + self.marionette.navigate(test_html) + # if we don't throw on the next line we are good! + source = self.marionette.page_source + from_web_api = self.marionette.execute_script( + "return document.documentElement.outerHTML" + ) + self.assertEqual(source, from_web_api) + + def testShouldReturnAXMLDocumentSource(self): + test_xml = inline("<xml><foo><bar>baz</bar></foo></xml>", "xml") + self.marionette.navigate(test_xml) + source = self.marionette.page_source + from_web_api = self.marionette.execute_script( + "return document.documentElement.outerHTML" + ) + import re + + self.assertEqual( + re.sub("\s", "", source), "<xml><foo><bar>baz</bar></foo></xml>" + ) + self.assertEqual(source, from_web_api) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_pagesource_chrome.py b/testing/marionette/harness/marionette_harness/tests/unit/test_pagesource_chrome.py new file mode 100644 index 0000000000..a8a8af14af --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_pagesource_chrome.py @@ -0,0 +1,26 @@ +# 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/. + +from __future__ import absolute_import + +from marionette_harness import MarionetteTestCase, WindowManagerMixin + + +class TestPageSourceChrome(WindowManagerMixin, MarionetteTestCase): + def setUp(self): + super(TestPageSourceChrome, self).setUp() + self.marionette.set_context("chrome") + + new_window = self.open_chrome_window("chrome://marionette/content/test.xhtml") + self.marionette.switch_to_window(new_window) + + def tearDown(self): + self.close_all_windows() + super(TestPageSourceChrome, self).tearDown() + + def testShouldReturnXULDetails(self): + source = self.marionette.page_source + self.assertTrue( + '<input xmlns="http://www.w3.org/1999/xhtml" id="textInput"' in source + ) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_position.py b/testing/marionette/harness/marionette_harness/tests/unit/test_position.py new file mode 100644 index 0000000000..6be86d0381 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_position.py @@ -0,0 +1,48 @@ +# 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/. + +from __future__ import absolute_import + +from six.moves.urllib.parse import quote + +from marionette_driver.by import By + +from marionette_harness import MarionetteTestCase + + +def inline(doc): + return "data:text/html;charset=utf-8,{}".format(quote(doc)) + + +class TestPosition(MarionetteTestCase): + def test_should_get_element_position_back(self): + doc = """ + <head> + <title>Rectangles</title> + <style> + div { + position: absolute; + margin: 0; + border: 0; + padding: 0; + } + #r { + background-color: red; + left: 11px; + top: 10px; + width: 48.666666667px; + height: 49.333333333px; + } + </style> + </head> + <body> + <div id="r">r</div> + </body> + """ + self.marionette.navigate(inline(doc)) + + r2 = self.marionette.find_element(By.ID, "r") + location = r2.rect + self.assertEqual(11, location["x"]) + self.assertEqual(10, location["y"]) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_prefs.py b/testing/marionette/harness/marionette_harness/tests/unit/test_prefs.py new file mode 100644 index 0000000000..b4d7ac3963 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_prefs.py @@ -0,0 +1,219 @@ +# 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/. + +from __future__ import absolute_import + +import six + +from marionette_driver import geckoinstance +from marionette_driver.errors import JavascriptException + +from marionette_harness import ( + MarionetteTestCase, + run_if_manage_instance, +) + + +class TestPreferences(MarionetteTestCase): + prefs = { + "bool": "marionette.test.bool", + "int": "marionette.test.int", + "string": "marionette.test.string", + } + + def tearDown(self): + for pref in self.prefs.values(): + self.marionette.clear_pref(pref) + + super(TestPreferences, self).tearDown() + + def test_gecko_instance_preferences(self): + required_prefs = geckoinstance.GeckoInstance.required_prefs + + for key, value in six.iteritems(required_prefs): + self.assertEqual( + self.marionette.get_pref(key), + value, + "Preference {} hasn't been set to {}".format(key, repr(value)), + ) + + def test_desktop_instance_preferences(self): + required_prefs = geckoinstance.DesktopInstance.desktop_prefs + + for key, value in six.iteritems(required_prefs): + if key in ["browser.tabs.remote.autostart"]: + return + + self.assertEqual( + self.marionette.get_pref(key), + value, + "Preference {} hasn't been set to {}".format(key, value), + ) + + def test_clear_pref(self): + self.assertIsNone(self.marionette.get_pref(self.prefs["bool"])) + + self.marionette.set_pref(self.prefs["bool"], True) + self.assertTrue(self.marionette.get_pref(self.prefs["bool"])) + + self.marionette.clear_pref(self.prefs["bool"]) + self.assertIsNone(self.marionette.get_pref(self.prefs["bool"])) + + def test_get_and_set_pref(self): + # By default none of the preferences are set + self.assertIsNone(self.marionette.get_pref(self.prefs["bool"])) + self.assertIsNone(self.marionette.get_pref(self.prefs["int"])) + self.assertIsNone(self.marionette.get_pref(self.prefs["string"])) + + # Test boolean values + self.marionette.set_pref(self.prefs["bool"], True) + value = self.marionette.get_pref(self.prefs["bool"]) + self.assertTrue(value) + self.assertEqual(type(value), bool) + + # Test int values + self.marionette.set_pref(self.prefs["int"], 42) + value = self.marionette.get_pref(self.prefs["int"]) + self.assertEqual(value, 42) + self.assertEqual(type(value), int) + + # Test string values + self.marionette.set_pref(self.prefs["string"], "abc") + value = self.marionette.get_pref(self.prefs["string"]) + self.assertEqual(value, "abc") + self.assertTrue(isinstance(value, six.string_types)) + + # Test reset value + self.marionette.set_pref(self.prefs["string"], None) + self.assertIsNone(self.marionette.get_pref(self.prefs["string"])) + + def test_get_set_pref_default_branch(self): + pref_default = "marionette.test.pref_default1" + self.assertIsNone(self.marionette.get_pref(self.prefs["string"])) + + self.marionette.set_pref(pref_default, "default_value", default_branch=True) + self.assertEqual(self.marionette.get_pref(pref_default), "default_value") + self.assertEqual( + self.marionette.get_pref(pref_default, default_branch=True), "default_value" + ) + + self.marionette.set_pref(pref_default, "user_value") + self.assertEqual(self.marionette.get_pref(pref_default), "user_value") + self.assertEqual( + self.marionette.get_pref(pref_default, default_branch=True), "default_value" + ) + + self.marionette.clear_pref(pref_default) + self.assertEqual(self.marionette.get_pref(pref_default), "default_value") + + def test_get_pref_value_type(self): + # Without a given value type the properties URL will be returned only + pref_complex = "browser.menu.showCharacterEncoding" + properties_file = "chrome://browser/locale/browser.properties" + self.assertEqual( + self.marionette.get_pref(pref_complex, default_branch=True), properties_file + ) + + # Otherwise the property named like the pref will be translated + value = self.marionette.get_pref( + pref_complex, default_branch=True, value_type="nsIPrefLocalizedString" + ) + self.assertNotEqual(value, properties_file) + + def test_set_prefs(self): + # By default none of the preferences are set + self.assertIsNone(self.marionette.get_pref(self.prefs["bool"])) + self.assertIsNone(self.marionette.get_pref(self.prefs["int"])) + self.assertIsNone(self.marionette.get_pref(self.prefs["string"])) + + # Set a value on the default branch first + pref_default = "marionette.test.pref_default2" + self.assertIsNone(self.marionette.get_pref(pref_default)) + self.marionette.set_prefs({pref_default: "default_value"}, default_branch=True) + + # Set user values + prefs = { + self.prefs["bool"]: True, + self.prefs["int"]: 42, + self.prefs["string"]: "abc", + pref_default: "user_value", + } + self.marionette.set_prefs(prefs) + + self.assertTrue(self.marionette.get_pref(self.prefs["bool"])) + self.assertEqual(self.marionette.get_pref(self.prefs["int"]), 42) + self.assertEqual(self.marionette.get_pref(self.prefs["string"]), "abc") + self.assertEqual(self.marionette.get_pref(pref_default), "user_value") + self.assertEqual( + self.marionette.get_pref(pref_default, default_branch=True), "default_value" + ) + + def test_using_prefs(self): + # Test that multiple preferences can be set with "using_prefs", and that + # they are set correctly and unset correctly after leaving the context + # manager. + pref_not_existent = "marionette.test.not_existent1" + pref_default = "marionette.test.pref_default3" + + self.marionette.set_prefs( + { + self.prefs["string"]: "abc", + self.prefs["int"]: 42, + self.prefs["bool"]: False, + } + ) + self.assertFalse(self.marionette.get_pref(self.prefs["bool"])) + self.assertEqual(self.marionette.get_pref(self.prefs["int"]), 42) + self.assertEqual(self.marionette.get_pref(self.prefs["string"]), "abc") + self.assertIsNone(self.marionette.get_pref(pref_not_existent)) + + with self.marionette.using_prefs( + { + self.prefs["bool"]: True, + self.prefs["int"]: 24, + self.prefs["string"]: "def", + pref_not_existent: "existent", + } + ): + + self.assertTrue(self.marionette.get_pref(self.prefs["bool"]), True) + self.assertEquals(self.marionette.get_pref(self.prefs["int"]), 24) + self.assertEquals(self.marionette.get_pref(self.prefs["string"]), "def") + self.assertEquals(self.marionette.get_pref(pref_not_existent), "existent") + + self.assertFalse(self.marionette.get_pref(self.prefs["bool"])) + self.assertEqual(self.marionette.get_pref(self.prefs["int"]), 42) + self.assertEqual(self.marionette.get_pref(self.prefs["string"]), "abc") + self.assertIsNone(self.marionette.get_pref(pref_not_existent)) + + # Using context with default branch + self.marionette.set_pref(pref_default, "default_value", default_branch=True) + self.assertEqual( + self.marionette.get_pref(pref_default, default_branch=True), "default_value" + ) + + with self.marionette.using_prefs( + {pref_default: "new_value"}, default_branch=True + ): + self.assertEqual( + self.marionette.get_pref(pref_default, default_branch=True), "new_value" + ) + + self.assertEqual( + self.marionette.get_pref(pref_default, default_branch=True), "default_value" + ) + + def test_using_prefs_exception(self): + # Test that throwing an exception inside the context manager doesn"t + # prevent the preferences from being restored at context manager exit. + self.marionette.set_pref(self.prefs["string"], "abc") + + try: + with self.marionette.using_prefs({self.prefs["string"]: "def"}): + self.assertEquals(self.marionette.get_pref(self.prefs["string"]), "def") + self.marionette.execute_script("return foo.bar.baz;") + except JavascriptException: + pass + + self.assertEquals(self.marionette.get_pref(self.prefs["string"]), "abc") diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_prefs_enforce.py b/testing/marionette/harness/marionette_harness/tests/unit/test_prefs_enforce.py new file mode 100644 index 0000000000..6673e27d73 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_prefs_enforce.py @@ -0,0 +1,45 @@ +# 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/. + +from __future__ import absolute_import + +import six + +from marionette_harness import MarionetteTestCase + + +class TestEnforcePreferences(MarionetteTestCase): + def setUp(self): + super(TestEnforcePreferences, self).setUp() + + self.marionette.enforce_gecko_prefs( + { + "marionette.test.bool": True, + "marionette.test.int": 3, + "marionette.test.string": "testing", + } + ) + self.marionette.set_context("chrome") + + def tearDown(self): + self.marionette.quit(clean=True) + + super(TestEnforcePreferences, self).tearDown() + + def test_preferences_are_set(self): + self.assertTrue(self.marionette.get_pref("marionette.test.bool")) + self.assertEqual(self.marionette.get_pref("marionette.test.string"), "testing") + self.assertEqual(self.marionette.get_pref("marionette.test.int"), 3) + + def test_change_preference(self): + self.assertTrue(self.marionette.get_pref("marionette.test.bool")) + + self.marionette.enforce_gecko_prefs({"marionette.test.bool": False}) + + self.assertFalse(self.marionette.get_pref("marionette.test.bool")) + + def test_restart_with_clean_profile(self): + self.marionette.restart(clean=True) + + self.assertEqual(self.marionette.get_pref("marionette.test.bool"), None) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_profile_management.py b/testing/marionette/harness/marionette_harness/tests/unit/test_profile_management.py new file mode 100644 index 0000000000..7bb93d8dc4 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_profile_management.py @@ -0,0 +1,253 @@ +# coding=UTF-8 + +# 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/. + +from __future__ import absolute_import + +import os +import shutil +import tempfile + +import mozprofile + +from marionette_driver import errors +from marionette_harness import MarionetteTestCase + + +class BaseProfileManagement(MarionetteTestCase): + def setUp(self): + super(BaseProfileManagement, self).setUp() + + self.orig_profile_path = self.profile_path + + def tearDown(self): + shutil.rmtree(self.orig_profile_path, ignore_errors=True) + + self.marionette.profile = None + + super(BaseProfileManagement, self).tearDown() + + @property + def profile(self): + return self.marionette.instance.profile + + @property + def profile_path(self): + return self.marionette.instance.profile.profile + + +class WorkspaceProfileManagement(BaseProfileManagement): + def setUp(self): + super(WorkspaceProfileManagement, self).setUp() + + # Set a new workspace for the instance, which will be used + # the next time a new profile is requested by a test. + self.workspace = tempfile.mkdtemp() + self.marionette.instance.workspace = self.workspace + + def tearDown(self): + self.marionette.instance.workspace = None + + shutil.rmtree(self.workspace, ignore_errors=True) + + super(WorkspaceProfileManagement, self).tearDown() + + +class ExternalProfileMixin(object): + def setUp(self): + super(ExternalProfileMixin, self).setUp() + + # Create external profile + tmp_dir = tempfile.mkdtemp(suffix="external") + shutil.rmtree(tmp_dir, ignore_errors=True) + + self.external_profile = mozprofile.Profile(profile=tmp_dir) + # Prevent profile from being removed during cleanup + self.external_profile.create_new = False + + def tearDown(self): + shutil.rmtree(self.external_profile.profile, ignore_errors=True) + + super(ExternalProfileMixin, self).tearDown() + + +class TestQuitRestartWithoutWorkspace(BaseProfileManagement): + def test_quit_keeps_same_profile(self): + self.marionette.quit() + self.marionette.start_session() + + self.assertEqual(self.profile_path, self.orig_profile_path) + self.assertTrue(os.path.exists(self.orig_profile_path)) + + def test_quit_clean_creates_new_profile(self): + self.marionette.quit(clean=True) + self.marionette.start_session() + + self.assertNotEqual(self.profile_path, self.orig_profile_path) + self.assertFalse(os.path.exists(self.orig_profile_path)) + + def test_restart_keeps_same_profile(self): + self.marionette.restart() + + self.assertEqual(self.profile_path, self.orig_profile_path) + self.assertTrue(os.path.exists(self.orig_profile_path)) + + def test_restart_clean_creates_new_profile(self): + self.marionette.restart(clean=True) + + self.assertNotEqual(self.profile_path, self.orig_profile_path) + self.assertFalse(os.path.exists(self.orig_profile_path)) + + +class TestQuitRestartWithWorkspace(WorkspaceProfileManagement): + def test_quit_keeps_same_profile(self): + self.marionette.quit() + self.marionette.start_session() + + self.assertEqual(self.profile_path, self.orig_profile_path) + self.assertNotIn(self.workspace, self.profile_path) + self.assertTrue(os.path.exists(self.orig_profile_path)) + + def test_quit_clean_creates_new_profile(self): + self.marionette.quit(clean=True) + self.marionette.start_session() + + self.assertNotEqual(self.profile_path, self.orig_profile_path) + self.assertIn(self.workspace, self.profile_path) + self.assertFalse(os.path.exists(self.orig_profile_path)) + + def test_restart_keeps_same_profile(self): + self.marionette.restart() + + self.assertEqual(self.profile_path, self.orig_profile_path) + self.assertNotIn(self.workspace, self.profile_path) + self.assertTrue(os.path.exists(self.orig_profile_path)) + + def test_restart_clean_creates_new_profile(self): + self.marionette.restart(clean=True) + + self.assertNotEqual(self.profile_path, self.orig_profile_path) + self.assertIn(self.workspace, self.profile_path) + self.assertFalse(os.path.exists(self.orig_profile_path)) + + +class TestSwitchProfileFailures(BaseProfileManagement): + def test_raise_for_switching_profile_while_instance_is_running(self): + with self.assertRaisesRegexp( + errors.MarionetteException, "instance is not running" + ): + self.marionette.instance.switch_profile() + + +class TestSwitchProfileWithoutWorkspace(ExternalProfileMixin, BaseProfileManagement): + def setUp(self): + super(TestSwitchProfileWithoutWorkspace, self).setUp() + + self.marionette.quit() + + def test_do_not_call_cleanup_of_profile_for_path_only(self): + # If a path to a profile has been given (eg. via the --profile command + # line argument) and the profile hasn't been created yet, switching the + # profile should not try to call `cleanup()` on a string. + self.marionette.instance._profile = self.external_profile.profile + self.marionette.instance.switch_profile() + + def test_new_random_profile_name(self): + self.marionette.instance.switch_profile() + self.marionette.start_session() + + self.assertNotEqual(self.profile_path, self.orig_profile_path) + self.assertFalse(os.path.exists(self.orig_profile_path)) + + def test_new_named_profile(self): + self.marionette.instance.switch_profile("foobar") + self.marionette.start_session() + + self.assertNotEqual(self.profile_path, self.orig_profile_path) + self.assertIn("foobar", self.profile_path) + self.assertFalse(os.path.exists(self.orig_profile_path)) + + def test_new_named_profile_unicode(self): + """Test using unicode string with 1-4 bytes encoding works.""" + self.marionette.instance.switch_profile(u"$¢€🍪") + self.marionette.start_session() + + self.assertNotEqual(self.profile_path, self.orig_profile_path) + self.assertIn(u"$¢€🍪", self.profile_path) + self.assertFalse(os.path.exists(self.orig_profile_path)) + + def test_new_named_profile_unicode_escape_characters(self): + """Test using escaped unicode string with 1-4 bytes encoding works.""" + self.marionette.instance.switch_profile(u"\u0024\u00A2\u20AC\u1F36A") + self.marionette.start_session() + + self.assertNotEqual(self.profile_path, self.orig_profile_path) + self.assertIn(u"\u0024\u00A2\u20AC\u1F36A", self.profile_path) + self.assertFalse(os.path.exists(self.orig_profile_path)) + + def test_clone_existing_profile(self): + self.marionette.instance.switch_profile(clone_from=self.external_profile) + self.marionette.start_session() + + self.assertIn( + os.path.basename(self.external_profile.profile), self.profile_path + ) + self.assertTrue(os.path.exists(self.external_profile.profile)) + + def test_replace_with_current_profile(self): + self.marionette.instance.profile = self.profile + self.marionette.start_session() + + self.assertEqual(self.profile_path, self.orig_profile_path) + self.assertTrue(os.path.exists(self.orig_profile_path)) + + def test_replace_with_external_profile(self): + self.marionette.instance.profile = self.external_profile + self.marionette.start_session() + + self.assertEqual(self.profile_path, self.external_profile.profile) + self.assertFalse(os.path.exists(self.orig_profile_path)) + + # Set a new profile and ensure the external profile has not been deleted + self.marionette.quit() + self.marionette.instance.profile = None + + self.assertNotEqual(self.profile_path, self.external_profile.profile) + self.assertTrue(os.path.exists(self.external_profile.profile)) + + +class TestSwitchProfileWithWorkspace(ExternalProfileMixin, WorkspaceProfileManagement): + def setUp(self): + super(TestSwitchProfileWithWorkspace, self).setUp() + + self.marionette.quit() + + def test_new_random_profile_name(self): + self.marionette.instance.switch_profile() + self.marionette.start_session() + + self.assertNotEqual(self.profile_path, self.orig_profile_path) + self.assertIn(self.workspace, self.profile_path) + self.assertFalse(os.path.exists(self.orig_profile_path)) + + def test_new_named_profile(self): + self.marionette.instance.switch_profile("foobar") + self.marionette.start_session() + + self.assertNotEqual(self.profile_path, self.orig_profile_path) + self.assertIn("foobar", self.profile_path) + self.assertIn(self.workspace, self.profile_path) + self.assertFalse(os.path.exists(self.orig_profile_path)) + + def test_clone_existing_profile(self): + self.marionette.instance.switch_profile(clone_from=self.external_profile) + self.marionette.start_session() + + self.assertNotEqual(self.profile_path, self.orig_profile_path) + self.assertIn(self.workspace, self.profile_path) + self.assertIn( + os.path.basename(self.external_profile.profile), self.profile_path + ) + self.assertTrue(os.path.exists(self.external_profile.profile)) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_proxy.py b/testing/marionette/harness/marionette_harness/tests/unit/test_proxy.py new file mode 100644 index 0000000000..3ba3a37d5d --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_proxy.py @@ -0,0 +1,164 @@ +# 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/. + +from __future__ import absolute_import + +from marionette_driver import errors + +from marionette_harness import MarionetteTestCase + + +class TestProxyCapabilities(MarionetteTestCase): + def setUp(self): + super(TestProxyCapabilities, self).setUp() + + self.marionette.delete_session() + + def tearDown(self): + if not self.marionette.session: + self.marionette.start_session() + + with self.marionette.using_context("chrome"): + self.marionette.execute_script( + """ + Cu.import("resource://gre/modules/Preferences.jsm"); + Preferences.resetBranch("network.proxy"); + """ + ) + + super(TestProxyCapabilities, self).tearDown() + + def test_proxy_object_none_by_default(self): + self.marionette.start_session() + self.assertNotIn("proxy", self.marionette.session_capabilities) + + def test_proxy_object_in_returned_capabilities(self): + capabilities = {"proxy": {"proxyType": "system"}} + + self.marionette.start_session(capabilities) + self.assertEqual( + self.marionette.session_capabilities["proxy"], capabilities["proxy"] + ) + + def test_proxy_type_autodetect(self): + capabilities = {"proxy": {"proxyType": "autodetect"}} + + self.marionette.start_session(capabilities) + self.assertEqual( + self.marionette.session_capabilities["proxy"], capabilities["proxy"] + ) + + def test_proxy_type_direct(self): + capabilities = {"proxy": {"proxyType": "direct"}} + + self.marionette.start_session(capabilities) + self.assertEqual( + self.marionette.session_capabilities["proxy"], capabilities["proxy"] + ) + + def test_proxy_type_manual_without_port(self): + proxy_hostname = "marionette.test" + capabilities = { + "proxy": { + "proxyType": "manual", + "ftpProxy": "{}:21".format(proxy_hostname), + "httpProxy": "{}:80".format(proxy_hostname), + "sslProxy": "{}:443".format(proxy_hostname), + "socksProxy": proxy_hostname, + "socksVersion": 4, + } + } + + self.marionette.start_session(capabilities) + self.assertEqual( + self.marionette.session_capabilities["proxy"], capabilities["proxy"] + ) + + def test_proxy_type_manual_socks_requires_version(self): + proxy_port = 4444 + proxy_hostname = "marionette.test" + proxy_host = "{}:{}".format(proxy_hostname, proxy_port) + capabilities = { + "proxy": { + "proxyType": "manual", + "socksProxy": proxy_host, + } + } + + with self.assertRaises(errors.SessionNotCreatedException): + self.marionette.start_session(capabilities) + + def test_proxy_type_manual_no_proxy_on(self): + capabilities = { + "proxy": { + "proxyType": "manual", + "noProxy": ["foo", "bar"], + } + } + + self.marionette.start_session(capabilities) + self.assertEqual( + self.marionette.session_capabilities["proxy"], capabilities["proxy"] + ) + + def test_proxy_type_manual_invalid_no_proxy_on(self): + capabilities = { + "proxy": { + "proxyType": "manual", + "noProxy": "foo, bar", + } + } + + with self.assertRaises(errors.SessionNotCreatedException): + self.marionette.start_session(capabilities) + + def test_proxy_type_pac(self): + pac_url = "http://marionette.test" + capabilities = {"proxy": {"proxyType": "pac", "proxyAutoconfigUrl": pac_url}} + + self.marionette.start_session(capabilities) + self.assertEqual( + self.marionette.session_capabilities["proxy"], capabilities["proxy"] + ) + + def test_proxy_type_system(self): + capabilities = {"proxy": {"proxyType": "system"}} + + self.marionette.start_session(capabilities) + self.assertEqual( + self.marionette.session_capabilities["proxy"], capabilities["proxy"] + ) + + def test_invalid_proxy_object(self): + capabilities = {"proxy": "I really should be a dictionary"} + + with self.assertRaises(errors.SessionNotCreatedException): + self.marionette.start_session(capabilities) + + def test_missing_proxy_type(self): + with self.assertRaises(errors.SessionNotCreatedException): + self.marionette.start_session({"proxy": {"proxyAutoconfigUrl": "foobar"}}) + + def test_invalid_proxy_type(self): + capabilities = {"proxy": {"proxyType": "NOPROXY"}} + + with self.assertRaises(errors.SessionNotCreatedException): + self.marionette.start_session(capabilities) + + def test_invalid_autoconfig_url_for_pac(self): + with self.assertRaises(errors.SessionNotCreatedException): + self.marionette.start_session({"proxy": {"proxyType": "pac"}}) + + with self.assertRaises(errors.SessionNotCreatedException): + self.marionette.start_session( + {"proxy": {"proxyType": "pac", "proxyAutoconfigUrl": None}} + ) + + def test_missing_socks_version_for_manual(self): + capabilities = { + "proxy": {"proxyType": "manual", "socksProxy": "marionette.test"} + } + + with self.assertRaises(errors.SessionNotCreatedException): + self.marionette.start_session(capabilities) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_quit_restart.py b/testing/marionette/harness/marionette_harness/tests/unit/test_quit_restart.py new file mode 100644 index 0000000000..8812520c85 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_quit_restart.py @@ -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/. + +from __future__ import absolute_import, print_function + +import sys +import unittest + +from six.moves.urllib.parse import quote + +from marionette_driver import errors +from marionette_driver.by import By +from marionette_harness import MarionetteTestCase + + +def inline(doc): + return "data:text/html;charset=utf-8,{}".format(quote(doc)) + + +class TestServerQuitApplication(MarionetteTestCase): + def tearDown(self): + if self.marionette.session is None: + self.marionette.start_session() + + def quit(self, flags=None): + body = None + if flags is not None: + body = {"flags": list(flags)} + + try: + resp = self.marionette._send_message("Marionette:Quit", body) + finally: + self.marionette.session_id = None + self.marionette.session = None + self.marionette.process_id = None + self.marionette.profile = None + self.marionette.window = None + + self.assertIn("cause", resp) + + self.marionette.client.close() + self.marionette.instance.runner.wait() + + return resp["cause"] + + def test_types(self): + for typ in [42, True, "foo", []]: + print("testing type {}".format(type(typ))) + with self.assertRaises(errors.InvalidArgumentException): + self.marionette._send_message("Marionette:Quit", typ) + + with self.assertRaises(errors.InvalidArgumentException): + self.quit("foo") + + def test_undefined_default(self): + cause = self.quit() + self.assertEqual("shutdown", cause) + + def test_empty_default(self): + cause = self.quit(()) + self.assertEqual("shutdown", cause) + + def test_incompatible_flags(self): + with self.assertRaises(errors.InvalidArgumentException): + self.quit(("eAttemptQuit", "eForceQuit")) + + def test_attempt_quit(self): + cause = self.quit(("eAttemptQuit",)) + self.assertEqual("shutdown", cause) + + def test_force_quit(self): + cause = self.quit(("eForceQuit",)) + self.assertEqual("shutdown", cause) + + +class TestQuitRestart(MarionetteTestCase): + def setUp(self): + MarionetteTestCase.setUp(self) + + self.pid = self.marionette.process_id + self.profile = self.marionette.profile + self.session_id = self.marionette.session_id + + # Use a preference to check that the restart was successful. If its + # value has not been forced, a restart will cause a reset of it. + self.assertNotEqual( + self.marionette.get_pref("startup.homepage_welcome_url"), "about:about" + ) + self.marionette.set_pref("startup.homepage_welcome_url", "about:about") + + def tearDown(self): + # Ensure to restart a session if none exist for clean-up + if self.marionette.session is None: + self.marionette.start_session() + + self.marionette.clear_pref("startup.homepage_welcome_url") + + MarionetteTestCase.tearDown(self) + + @property + def is_safe_mode(self): + with self.marionette.using_context("chrome"): + return self.marionette.execute_script( + """ + Cu.import("resource://gre/modules/Services.jsm"); + return Services.appinfo.inSafeMode; + """ + ) + + def shutdown(self, restart=False): + self.marionette.set_context("chrome") + self.marionette.execute_script( + """ + Components.utils.import("resource://gre/modules/Services.jsm"); + let flags = Ci.nsIAppStartup.eAttemptQuit; + if (arguments[0]) { + flags |= Ci.nsIAppStartup.eRestart; + } + Services.startup.quit(flags); + """, + script_args=(restart,), + ) + + def test_force_restart(self): + self.marionette.restart() + self.assertEqual(self.marionette.profile, self.profile) + self.assertNotEqual(self.marionette.session_id, self.session_id) + + # A forced restart will cause a new process id + self.assertNotEqual(self.marionette.process_id, self.pid) + self.assertNotEqual( + self.marionette.get_pref("startup.homepage_welcome_url"), "about:about" + ) + + def test_force_clean_restart(self): + self.marionette.restart(clean=True) + self.assertNotEqual(self.marionette.profile, self.profile) + self.assertNotEqual(self.marionette.session_id, self.session_id) + # A forced restart will cause a new process id + self.assertNotEqual(self.marionette.process_id, self.pid) + self.assertNotEqual( + self.marionette.get_pref("startup.homepage_welcome_url"), "about:about" + ) + + def test_force_quit(self): + self.marionette.quit() + + self.assertEqual(self.marionette.session, None) + with self.assertRaisesRegexp( + errors.InvalidSessionIdException, "Please start a session" + ): + self.marionette.get_url() + + def test_force_clean_quit(self): + self.marionette.quit(clean=True) + + self.assertEqual(self.marionette.session, None) + with self.assertRaisesRegexp( + errors.InvalidSessionIdException, "Please start a session" + ): + self.marionette.get_url() + + self.marionette.start_session() + self.assertNotEqual(self.marionette.profile, self.profile) + self.assertNotEqual(self.marionette.session_id, self.session_id) + self.assertNotEqual( + self.marionette.get_pref("startup.homepage_welcome_url"), "about:about" + ) + + def test_no_in_app_clean_restart(self): + # Test that in_app and clean cannot be used in combination + with self.assertRaisesRegexp( + ValueError, "cannot be triggered with the clean flag set" + ): + self.marionette.restart(in_app=True, clean=True) + + def test_in_app_restart(self): + self.marionette.restart(in_app=True) + + self.assertEqual(self.marionette.profile, self.profile) + self.assertNotEqual(self.marionette.session_id, self.session_id) + + # An in-app restart will keep the same process id only on Linux + if self.marionette.session_capabilities["platformName"] == "linux": + self.assertEqual(self.marionette.process_id, self.pid) + else: + self.assertNotEqual(self.marionette.process_id, self.pid) + + self.assertNotEqual( + self.marionette.get_pref("startup.homepage_welcome_url"), "about:about" + ) + + def test_in_app_restart_with_callback(self): + self.marionette.restart( + in_app=True, callback=lambda: self.shutdown(restart=True) + ) + + self.assertEqual(self.marionette.profile, self.profile) + self.assertNotEqual(self.marionette.session_id, self.session_id) + + # An in-app restart will keep the same process id only on Linux + if self.marionette.session_capabilities["platformName"] == "linux": + self.assertEqual(self.marionette.process_id, self.pid) + else: + self.assertNotEqual(self.marionette.process_id, self.pid) + + self.assertNotEqual( + self.marionette.get_pref("startup.homepage_welcome_url"), "about:about" + ) + + def test_in_app_restart_with_callback_not_callable(self): + with self.assertRaisesRegexp(ValueError, "is not callable"): + self.marionette.restart(in_app=True, callback=4) + + @unittest.skipIf(sys.platform.startswith("win"), "Bug 1493796") + def test_in_app_restart_with_callback_but_process_quit(self): + try: + timeout_shutdown = self.marionette.shutdown_timeout + timeout_startup = self.marionette.startup_timeout + self.marionette.shutdown_timeout = 5 + self.marionette.startup_timeout = 0 + + with self.assertRaisesRegexp( + IOError, "Process unexpectedly quit without restarting" + ): + self.marionette.restart( + in_app=True, callback=lambda: self.shutdown(restart=False) + ) + finally: + self.marionette.shutdown_timeout = timeout_shutdown + self.marionette.startup_timeout = timeout_startup + + @unittest.skipIf(sys.platform.startswith("win"), "Bug 1493796") + def test_in_app_restart_with_callback_missing_shutdown(self): + try: + timeout_shutdown = self.marionette.shutdown_timeout + timeout_startup = self.marionette.startup_timeout + self.marionette.shutdown_timeout = 5 + self.marionette.startup_timeout = 0 + + with self.assertRaisesRegexp( + IOError, "the connection to Marionette server is lost" + ): + self.marionette.restart(in_app=True, callback=lambda: False) + finally: + self.marionette.shutdown_timeout = timeout_shutdown + self.marionette.startup_timeout = timeout_startup + + def test_in_app_restart_safe_mode(self): + def restart_in_safe_mode(): + with self.marionette.using_context("chrome"): + self.marionette.execute_script( + """ + Components.utils.import("resource://gre/modules/Services.jsm"); + + let cancelQuit = Cc["@mozilla.org/supports-PRBool;1"] + .createInstance(Ci.nsISupportsPRBool); + Services.obs.notifyObservers(cancelQuit, + "quit-application-requested", null); + + if (!cancelQuit.data) { + Services.startup.restartInSafeMode(Ci.nsIAppStartup.eAttemptQuit); + } + """ + ) + + try: + self.assertFalse(self.is_safe_mode, "Safe Mode is unexpectedly enabled") + self.marionette.restart(in_app=True, callback=restart_in_safe_mode) + self.assertTrue(self.is_safe_mode, "Safe Mode is not enabled") + finally: + if self.marionette.session is None: + self.marionette.start_session() + self.marionette.quit(clean=True) + + def test_in_app_quit(self): + self.marionette.quit(in_app=True) + + self.assertEqual(self.marionette.session, None) + with self.assertRaisesRegexp( + errors.InvalidSessionIdException, "Please start a session" + ): + self.marionette.get_url() + + self.marionette.start_session() + self.assertEqual(self.marionette.profile, self.profile) + self.assertNotEqual(self.marionette.session_id, self.session_id) + self.assertNotEqual( + self.marionette.get_pref("startup.homepage_welcome_url"), "about:about" + ) + + def test_in_app_quit_with_callback(self): + self.marionette.quit(in_app=True, callback=self.shutdown) + self.assertEqual(self.marionette.session, None) + with self.assertRaisesRegexp( + errors.InvalidSessionIdException, "Please start a session" + ): + self.marionette.get_url() + + self.marionette.start_session() + self.assertEqual(self.marionette.profile, self.profile) + self.assertNotEqual(self.marionette.session_id, self.session_id) + self.assertNotEqual( + self.marionette.get_pref("startup.homepage_welcome_url"), "about:about" + ) + + def test_in_app_quit_with_callback_missing_shutdown(self): + try: + timeout = self.marionette.shutdown_timeout + self.marionette.shutdown_timeout = 5 + + with self.assertRaisesRegexp(IOError, "Process still running"): + self.marionette.quit(in_app=True, callback=lambda: False) + finally: + self.marionette.shutdown_timeout = timeout + + def test_in_app_quit_with_callback_not_callable(self): + with self.assertRaisesRegexp(ValueError, "is not callable"): + self.marionette.restart(in_app=True, callback=4) + + def test_in_app_quit_with_dismissed_beforeunload_prompt(self): + self.marionette.navigate( + inline( + """ + <input type="text"> + <script> + window.addEventListener("beforeunload", function (event) { + event.preventDefault(); + }); + </script> + """ + ) + ) + + self.marionette.find_element(By.TAG_NAME, "input").send_keys("foo") + self.marionette.quit(in_app=True) + self.marionette.start_session() + + def test_reset_context_after_quit_by_set_context(self): + # Check that we are in content context which is used by default in + # Marionette + self.assertNotIn( + "chrome://", + self.marionette.get_url(), + "Context does not default to content", + ) + + self.marionette.set_context("chrome") + self.marionette.quit(in_app=True) + self.assertEqual(self.marionette.session, None) + self.marionette.start_session() + self.assertNotIn( + "chrome://", + self.marionette.get_url(), + "Not in content context after quit with using_context", + ) + + def test_reset_context_after_quit_by_using_context(self): + # Check that we are in content context which is used by default in + # Marionette + self.assertNotIn( + "chrome://", + self.marionette.get_url(), + "Context does not default to content", + ) + + with self.marionette.using_context("chrome"): + self.marionette.quit(in_app=True) + self.assertEqual(self.marionette.session, None) + self.marionette.start_session() + self.assertNotIn( + "chrome://", + self.marionette.get_url(), + "Not in content context after quit with using_context", + ) + + def test_keep_context_after_restart_by_set_context(self): + # Check that we are in content context which is used by default in + # Marionette + self.assertNotIn( + "chrome://", self.marionette.get_url(), "Context doesn't default to content" + ) + + # restart while we are in chrome context + self.marionette.set_context("chrome") + self.marionette.restart(in_app=True) + + # An in-app restart will keep the same process id only on Linux + if self.marionette.session_capabilities["platformName"] == "linux": + self.assertEqual(self.marionette.process_id, self.pid) + else: + self.assertNotEqual(self.marionette.process_id, self.pid) + + self.assertIn( + "chrome://", + self.marionette.get_url(), + "Not in chrome context after a restart with set_context", + ) + + def test_keep_context_after_restart_by_using_context(self): + # Check that we are in content context which is used by default in + # Marionette + self.assertNotIn( + "chrome://", + self.marionette.get_url(), + "Context does not default to content", + ) + + # restart while we are in chrome context + with self.marionette.using_context("chrome"): + self.marionette.restart(in_app=True) + + # An in-app restart will keep the same process id only on Linux + if self.marionette.session_capabilities["platformName"] == "linux": + self.assertEqual(self.marionette.process_id, self.pid) + else: + self.assertNotEqual(self.marionette.process_id, self.pid) + + self.assertIn( + "chrome://", + self.marionette.get_url(), + "Not in chrome context after a restart with using_context", + ) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_reftest.py b/testing/marionette/harness/marionette_harness/tests/unit/test_reftest.py new file mode 100644 index 0000000000..f3468e11e6 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_reftest.py @@ -0,0 +1,103 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +from __future__ import absolute_import, print_function + +from marionette_driver.errors import UnsupportedOperationException +from marionette_harness import MarionetteTestCase, skip + + +class TestReftest(MarionetteTestCase): + def setUp(self): + super(TestReftest, self).setUp() + + self.original_window = self.marionette.current_window_handle + + self.marionette.set_pref("dom.send_after_paint_to_content", True) + + def tearDown(self): + try: + # make sure we've teared down any reftest context + self.marionette._send_message("reftest:teardown", {}) + except UnsupportedOperationException: + # this will throw if we aren't currently in a reftest context + pass + + self.marionette.switch_to_window(self.original_window) + + self.marionette.clear_pref("dom.send_after_paint_to_content") + + super(TestReftest, self).tearDown() + + @skip("Bug 1648444 - Unexpected page unload when refreshing about:blank") + def test_basic(self): + self.marionette._send_message("reftest:setup", {"screenshot": "unexpected"}) + rv = self.marionette._send_message( + "reftest:run", + { + "test": "about:blank", + "references": [["about:blank", [], "=="]], + "expected": "PASS", + "timeout": 10 * 1000, + }, + ) + self.marionette._send_message("reftest:teardown", {}) + expected = { + u"value": { + u"extra": {}, + u"message": u"Testing about:blank == about:blank\n", + u"stack": None, + u"status": u"PASS", + } + } + self.assertEqual(expected, rv) + + def test_url_comparison(self): + test_page = self.fixtures.where_is("test.html") + test_page_2 = self.fixtures.where_is("foo/../test.html") + + self.marionette._send_message("reftest:setup", {"screenshot": "unexpected"}) + rv = self.marionette._send_message( + "reftest:run", + { + "test": test_page, + "references": [[test_page_2, [], "=="]], + "expected": "PASS", + "timeout": 10 * 1000, + }, + ) + self.marionette._send_message("reftest:teardown", {}) + self.assertEqual(u"PASS", rv[u"value"][u"status"]) + + def test_cache_multiple_sizes(self): + teal = self.fixtures.where_is("reftest/teal-700x700.html") + mostly_teal = self.fixtures.where_is("reftest/mostly-teal-700x700.html") + + self.marionette._send_message("reftest:setup", {"screenshot": "unexpected"}) + rv = self.marionette._send_message( + "reftest:run", + { + "test": teal, + "references": [[mostly_teal, [], "=="]], + "expected": "PASS", + "timeout": 10 * 1000, + "width": 600, + "height": 600, + }, + ) + self.assertEqual(u"PASS", rv[u"value"][u"status"]) + + rv = self.marionette._send_message( + "reftest:run", + { + "test": teal, + "references": [[mostly_teal, [], "=="]], + "expected": "PASS", + "timeout": 10 * 1000, + "width": 700, + "height": 700, + }, + ) + self.assertEqual(u"FAIL", rv[u"value"][u"status"]) + self.marionette._send_message("reftest:teardown", {}) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_rendered_element.py b/testing/marionette/harness/marionette_harness/tests/unit/test_rendered_element.py new file mode 100644 index 0000000000..47e7283cc6 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_rendered_element.py @@ -0,0 +1,33 @@ +from __future__ import absolute_import + +from six.moves.urllib.parse import quote + +from marionette_driver.by import By +from marionette_harness import MarionetteTestCase + + +def inline(doc): + return "data:text/html;charset=utf-8,{}".format(quote(doc)) + + +class RenderedElementTests(MarionetteTestCase): + def test_get_computed_style_value_from_element(self): + self.marionette.navigate( + inline( + """ + <div style="color: green;" id="parent"> + <p id="green">This should be green</p> + <p id="red" style="color: red;">But this is red</p> + </div> + """ + ) + ) + + parent = self.marionette.find_element(By.ID, "parent") + self.assertEqual("rgb(0, 128, 0)", parent.value_of_css_property("color")) + + green = self.marionette.find_element(By.ID, "green") + self.assertEqual("rgb(0, 128, 0)", green.value_of_css_property("color")) + + red = self.marionette.find_element(By.ID, "red") + self.assertEqual("rgb(255, 0, 0)", red.value_of_css_property("color")) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_report.py b/testing/marionette/harness/marionette_harness/tests/unit/test_report.py new file mode 100644 index 0000000000..5ce6c5c263 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_report.py @@ -0,0 +1,30 @@ +# 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/. + +from __future__ import absolute_import + +from marionette_harness import MarionetteTestCase, expectedFailure, skip + + +class TestReport(MarionetteTestCase): + def test_pass(self): + assert True + + def test_fail(self): + assert False + + @skip("Skip Message") + def test_skip(self): + assert False + + @expectedFailure + def test_expected_fail(self): + assert False + + @expectedFailure + def test_unexpected_pass(self): + assert True + + def test_error(self): + raise Exception() diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_run_js_test.py b/testing/marionette/harness/marionette_harness/tests/unit/test_run_js_test.py new file mode 100644 index 0000000000..fdaeffe62f --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_run_js_test.py @@ -0,0 +1,12 @@ +# Any copyright is dedicated to the Public Domain. +# http://creativecommons.org/publicdomain/zero/1.0/ + +from __future__ import absolute_import + +from marionette_harness import MarionetteTestCase + + +class TestRunJSTest(MarionetteTestCase): + def test_basic(self): + self.run_js_test("test_simpletest_pass.js") + self.run_js_test("test_simpletest_fail.js") diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_screen_orientation.py b/testing/marionette/harness/marionette_harness/tests/unit/test_screen_orientation.py new file mode 100644 index 0000000000..7c65d0c84d --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_screen_orientation.py @@ -0,0 +1,77 @@ +# 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/. + +from __future__ import absolute_import + +from marionette_driver import errors +from marionette_driver.wait import Wait +from marionette_harness import ( + MarionetteTestCase, + parameterized, + skip_if_desktop, +) + + +default_orientation = "portrait-primary" +unknown_orientation = "Unknown screen orientation: {}" + + +class TestScreenOrientation(MarionetteTestCase): + def setUp(self): + MarionetteTestCase.setUp(self) + + def tearDown(self): + MarionetteTestCase.tearDown(self) + + def wait_for_orientation(self, orientation, timeout=None): + Wait(self.marionette, timeout=timeout).until( + lambda _: self.marionette.orientation == orientation + ) + + @skip_if_desktop("Not supported in Firefox") + @parameterized("landscape-primary", "landscape-primary") + @parameterized("landscape-secondary", "landscape-secondary") + @parameterized("portrait-primary", "portrait-primary") + # @parameterized("portrait-secondary", "portrait-secondary") # Bug 1533084 + def test_set_orientation(self, orientation): + self.marionette.set_orientation(orientation) + self.wait_for_orientation(orientation) + + @skip_if_desktop("Not supported in Firefox") + def test_set_orientation_to_shorthand_portrait(self): + # Set orientation to something other than portrait-primary first, + # since the default is portrait-primary. + self.marionette.set_orientation("landscape-primary") + self.wait_for_orientation("landscape-primary") + + self.marionette.set_orientation("portrait") + self.wait_for_orientation("portrait-primary") + + @skip_if_desktop("Not supported in Firefox") + def test_set_orientation_to_shorthand_landscape(self): + self.marionette.set_orientation("landscape") + self.wait_for_orientation("landscape-primary") + + @skip_if_desktop("Not supported in Firefox") + def test_set_orientation_with_mixed_casing(self): + self.marionette.set_orientation("lAnDsCaPe") + self.wait_for_orientation("landscape-primary") + + @skip_if_desktop("Not supported in Firefox") + def test_set_invalid_orientation(self): + with self.assertRaisesRegexp( + errors.MarionetteException, unknown_orientation.format("cheese") + ): + self.marionette.set_orientation("cheese") + + @skip_if_desktop("Not supported in Firefox") + def test_set_null_orientation(self): + with self.assertRaisesRegexp( + errors.MarionetteException, unknown_orientation.format("null") + ): + self.marionette.set_orientation(None) + + def test_unsupported_operation_on_desktop(self): + with self.assertRaises(errors.UnsupportedOperationException): + self.marionette.set_orientation("landscape-primary") diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_screenshot.py b/testing/marionette/harness/marionette_harness/tests/unit/test_screenshot.py new file mode 100644 index 0000000000..f69ad9fd7a --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_screenshot.py @@ -0,0 +1,393 @@ +# 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/. + +from __future__ import absolute_import + +import base64 +import hashlib +import imghdr +import struct +import tempfile +import unittest + +import six +from six.moves.urllib.parse import quote + +import mozinfo + +from marionette_driver import By +from marionette_driver.errors import NoSuchWindowException +from marionette_harness import ( + MarionetteTestCase, + skip, + WindowManagerMixin, +) + + +def decodebytes(s): + if six.PY3: + return base64.decodebytes(six.ensure_binary(s)) + return base64.decodestring(s) + + +def inline(doc, mime="text/html;charset=utf-8"): + return "data:{0},{1}".format(mime, quote(doc)) + + +box = inline( + "<body><div id='box'><p id='green' style='width: 50px; height: 50px; " + "background: silver;'></p></div></body>" +) +input = inline("<body><input id='text-input'></input></body>") +long = inline("<body style='height: 300vh'><p style='margin-top: 100vh'>foo</p></body>") +short = inline("<body style='height: 10vh'></body>") +svg = inline( + """ + <svg xmlns="http://www.w3.org/2000/svg" height="20" width="20"> + <rect height="20" width="20"/> + </svg>""", + mime="image/svg+xml", +) + + +class ScreenCaptureTestCase(MarionetteTestCase): + def setUp(self): + super(ScreenCaptureTestCase, self).setUp() + + self.maxDiff = None + + self._device_pixel_ratio = None + + # Ensure that each screenshot test runs on a blank page to avoid left + # over elements or focus which could interfer with taking screenshots + self.marionette.navigate("about:blank") + + @property + def device_pixel_ratio(self): + if self._device_pixel_ratio is None: + self._device_pixel_ratio = self.marionette.execute_script( + """ + return window.devicePixelRatio + """ + ) + return self._device_pixel_ratio + + @property + def document_element(self): + return self.marionette.find_element(By.CSS_SELECTOR, ":root") + + @property + def page_y_offset(self): + return self.marionette.execute_script("return window.pageYOffset") + + @property + def viewport_dimensions(self): + return self.marionette.execute_script( + "return [window.innerWidth, window.innerHeight];" + ) + + def assert_png(self, screenshot): + """Test that screenshot is a Base64 encoded PNG file.""" + if six.PY3 and not isinstance(screenshot, bytes): + screenshot = bytes(screenshot, encoding="utf-8") + image = decodebytes(screenshot) + self.assertEqual(imghdr.what("", image), "png") + + def assert_formats(self, element=None): + if element is None: + element = self.document_element + + screenshot_default = self.marionette.screenshot(element=element) + if six.PY3 and not isinstance(screenshot_default, bytes): + screenshot_default = bytes(screenshot_default, encoding="utf-8") + screenshot_image = self.marionette.screenshot(element=element, format="base64") + if six.PY3 and not isinstance(screenshot_image, bytes): + screenshot_image = bytes(screenshot_image, encoding="utf-8") + binary1 = self.marionette.screenshot(element=element, format="binary") + binary2 = self.marionette.screenshot(element=element, format="binary") + hash1 = self.marionette.screenshot(element=element, format="hash") + hash2 = self.marionette.screenshot(element=element, format="hash") + + # Valid data should have been returned + self.assert_png(screenshot_image) + self.assertEqual(imghdr.what("", binary1), "png") + self.assertEqual(screenshot_image, base64.b64encode(binary1)) + self.assertEqual(hash1, hashlib.sha256(screenshot_image).hexdigest()) + + # Different formats produce different data + self.assertNotEqual(screenshot_image, binary1) + self.assertNotEqual(screenshot_image, hash1) + self.assertNotEqual(binary1, hash1) + + # A second capture should be identical + self.assertEqual(screenshot_image, screenshot_default) + self.assertEqual(binary1, binary2) + self.assertEqual(hash1, hash2) + + def get_element_dimensions(self, element): + rect = element.rect + return rect["width"], rect["height"] + + def get_image_dimensions(self, screenshot): + if six.PY3 and not isinstance(screenshot, bytes): + screenshot = bytes(screenshot, encoding="utf-8") + self.assert_png(screenshot) + image = decodebytes(screenshot) + width, height = struct.unpack(">LL", image[16:24]) + return int(width), int(height) + + def scale(self, rect): + return ( + int(rect[0] * self.device_pixel_ratio), + int(rect[1] * self.device_pixel_ratio), + ) + + +class TestScreenCaptureChrome(WindowManagerMixin, ScreenCaptureTestCase): + def setUp(self): + super(TestScreenCaptureChrome, self).setUp() + self.marionette.set_context("chrome") + + def tearDown(self): + self.close_all_windows() + super(TestScreenCaptureChrome, self).tearDown() + + @property + def window_dimensions(self): + return tuple( + self.marionette.execute_script( + """ + let el = document.documentElement; + let rect = el.getBoundingClientRect(); + return [rect.width, rect.height]; + """ + ) + ) + + def open_dialog(self): + return self.open_chrome_window("chrome://marionette/content/test_dialog.xhtml") + + def test_capture_different_context(self): + """Check that screenshots in content and chrome are different.""" + with self.marionette.using_context("content"): + screenshot_content = self.marionette.screenshot() + screenshot_chrome = self.marionette.screenshot() + self.assertNotEqual(screenshot_content, screenshot_chrome) + + def test_capture_element(self): + dialog = self.open_dialog() + self.marionette.switch_to_window(dialog) + + # Ensure we only capture the element + el = self.marionette.find_element(By.ID, "test-list") + screenshot_element = self.marionette.screenshot(element=el) + self.assertEqual( + self.scale(self.get_element_dimensions(el)), + self.get_image_dimensions(screenshot_element), + ) + + # Ensure we do not capture the full window + screenshot_dialog = self.marionette.screenshot() + self.assertNotEqual(screenshot_dialog, screenshot_element) + + self.marionette.close_chrome_window() + self.marionette.switch_to_window(self.start_window) + + def test_capture_full_area(self): + dialog = self.open_dialog() + self.marionette.switch_to_window(dialog) + + root_dimensions = self.scale(self.get_element_dimensions(self.document_element)) + + # self.marionette.set_window_rect(width=100, height=100) + # A full capture is not the outer dimensions of the window, + # but instead the bounding box of the window's root node (documentElement). + screenshot_full = self.marionette.screenshot() + screenshot_root = self.marionette.screenshot(element=self.document_element) + + self.marionette.close_chrome_window() + self.marionette.switch_to_window(self.start_window) + + self.assert_png(screenshot_full) + self.assert_png(screenshot_root) + self.assertEqual(root_dimensions, self.get_image_dimensions(screenshot_full)) + self.assertEqual(screenshot_root, screenshot_full) + + def test_capture_window_already_closed(self): + dialog = self.open_dialog() + self.marionette.switch_to_window(dialog) + self.marionette.close_chrome_window() + + self.assertRaises(NoSuchWindowException, self.marionette.screenshot) + self.marionette.switch_to_window(self.start_window) + + def test_formats(self): + dialog = self.open_dialog() + self.marionette.switch_to_window(dialog) + + self.assert_formats() + + self.marionette.close_chrome_window() + self.marionette.switch_to_window(self.start_window) + + def test_format_unknown(self): + with self.assertRaises(ValueError): + self.marionette.screenshot(format="cheese") + + +class TestScreenCaptureContent(WindowManagerMixin, ScreenCaptureTestCase): + def setUp(self): + super(TestScreenCaptureContent, self).setUp() + self.marionette.set_context("content") + + def tearDown(self): + self.close_all_tabs() + super(TestScreenCaptureContent, self).tearDown() + + @property + def scroll_dimensions(self): + return tuple( + self.marionette.execute_script( + """ + return [ + document.documentElement.scrollWidth, + document.documentElement.scrollHeight + ]; + """ + ) + ) + + def test_capture_tab_already_closed(self): + new_tab = self.open_tab() + self.marionette.switch_to_window(new_tab) + self.marionette.close() + + self.assertRaises(NoSuchWindowException, self.marionette.screenshot) + self.marionette.switch_to_window(self.start_tab) + + @unittest.skipIf(mozinfo.info["bits"] == 32, "Bug 1582973 - Risk for OOM on 32bit") + def test_capture_vertical_bounds(self): + self.marionette.navigate(inline("<body style='margin-top: 32768px'>foo")) + screenshot = self.marionette.screenshot() + self.assert_png(screenshot) + + @unittest.skipIf(mozinfo.info["bits"] == 32, "Bug 1582973 - Risk for OOM on 32bit") + def test_capture_horizontal_bounds(self): + self.marionette.navigate(inline("<body style='margin-left: 32768px'>foo")) + screenshot = self.marionette.screenshot() + self.assert_png(screenshot) + + @unittest.skipIf(mozinfo.info["bits"] == 32, "Bug 1582973 - Risk for OOM on 32bit") + def test_capture_area_bounds(self): + self.marionette.navigate( + inline("<body style='margin-right: 21747px; margin-top: 21747px'>foo") + ) + screenshot = self.marionette.screenshot() + self.assert_png(screenshot) + + def test_capture_element(self): + self.marionette.navigate(box) + el = self.marionette.find_element(By.TAG_NAME, "div") + screenshot = self.marionette.screenshot(element=el) + self.assert_png(screenshot) + self.assertEqual( + self.scale(self.get_element_dimensions(el)), + self.get_image_dimensions(screenshot), + ) + + @skip("Bug 1213875") + def test_capture_element_scrolled_into_view(self): + self.marionette.navigate(long) + el = self.marionette.find_element(By.TAG_NAME, "p") + screenshot = self.marionette.screenshot(element=el) + self.assert_png(screenshot) + self.assertEqual( + self.scale(self.get_element_dimensions(el)), + self.get_image_dimensions(screenshot), + ) + self.assertGreater(self.page_y_offset, 0) + + def test_capture_full_html_document_element(self): + self.marionette.navigate(long) + screenshot = self.marionette.screenshot() + self.assert_png(screenshot) + self.assertEqual( + self.scale(self.scroll_dimensions), self.get_image_dimensions(screenshot) + ) + + def test_capture_full_svg_document_element(self): + self.marionette.navigate(svg) + screenshot = self.marionette.screenshot() + self.assert_png(screenshot) + self.assertEqual( + self.scale(self.scroll_dimensions), self.get_image_dimensions(screenshot) + ) + + def test_capture_viewport(self): + url = self.marionette.absolute_url("clicks.html") + self.marionette.navigate(short) + self.marionette.navigate(url) + screenshot = self.marionette.screenshot(full=False) + self.assert_png(screenshot) + self.assertEqual( + self.scale(self.viewport_dimensions), self.get_image_dimensions(screenshot) + ) + + def test_capture_viewport_after_scroll(self): + self.marionette.navigate(long) + before = self.marionette.screenshot() + el = self.marionette.find_element(By.TAG_NAME, "p") + self.marionette.execute_script( + "arguments[0].scrollIntoView()", script_args=[el] + ) + after = self.marionette.screenshot(full=False) + self.assertNotEqual(before, after) + self.assertGreater(self.page_y_offset, 0) + + def test_formats(self): + self.marionette.navigate(box) + + # Use a smaller region to speed up the test + element = self.marionette.find_element(By.TAG_NAME, "div") + self.assert_formats(element=element) + + def test_format_unknown(self): + with self.assertRaises(ValueError): + self.marionette.screenshot(format="cheese") + + def test_save_screenshot(self): + expected = self.marionette.screenshot(format="binary") + with tempfile.TemporaryFile("w+b") as fh: + self.marionette.save_screenshot(fh) + fh.flush() + fh.seek(0) + content = fh.read() + self.assertEqual(expected, content) + + def test_scroll_default(self): + self.marionette.navigate(long) + before = self.page_y_offset + el = self.marionette.find_element(By.TAG_NAME, "p") + self.marionette.screenshot(element=el, format="hash") + self.assertNotEqual(before, self.page_y_offset) + + def test_scroll(self): + self.marionette.navigate(long) + before = self.page_y_offset + el = self.marionette.find_element(By.TAG_NAME, "p") + self.marionette.screenshot(element=el, format="hash", scroll=True) + self.assertNotEqual(before, self.page_y_offset) + + def test_scroll_off(self): + self.marionette.navigate(long) + el = self.marionette.find_element(By.TAG_NAME, "p") + before = self.page_y_offset + self.marionette.screenshot(element=el, format="hash", scroll=False) + self.assertEqual(before, self.page_y_offset) + + def test_scroll_no_element(self): + self.marionette.navigate(long) + before = self.page_y_offset + self.marionette.screenshot(format="hash", scroll=True) + self.assertEqual(before, self.page_y_offset) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_select.py b/testing/marionette/harness/marionette_harness/tests/unit/test_select.py new file mode 100644 index 0000000000..7a84e23d1b --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_select.py @@ -0,0 +1,220 @@ +# 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/. + +from __future__ import absolute_import + +from six.moves.urllib.parse import quote + +from marionette_driver.by import By + +from marionette_harness import MarionetteTestCase + + +def inline(doc): + return "data:text/html;charset=utf-8,{}".format(quote(doc)) + + +class SelectTestCase(MarionetteTestCase): + def assertSelected(self, option_element): + self.assertTrue(option_element.is_selected(), "<option> element not selected") + self.assertTrue( + self.marionette.execute_script( + "return arguments[0].selected", + script_args=[option_element], + sandbox=None, + ), + "<option> selected attribute not updated", + ) + + def assertNotSelected(self, option_element): + self.assertFalse(option_element.is_selected(), "<option> is selected") + self.assertFalse( + self.marionette.execute_script( + "return arguments[0].selected", + script_args=[option_element], + sandbox=None, + ), + "<option> selected attribute not updated", + ) + + +class TestSelect(SelectTestCase): + def test_single(self): + self.marionette.navigate( + inline( + """ + <select> + <option>first + <option>second + </select>""" + ) + ) + select = self.marionette.find_element(By.TAG_NAME, "select") + options = self.marionette.find_elements(By.TAG_NAME, "option") + + self.assertSelected(options[0]) + options[1].click() + self.assertSelected(options[1]) + + def test_deselect_others(self): + self.marionette.navigate( + inline( + """ + <select> + <option>first + <option>second + <option>third + </select>""" + ) + ) + select = self.marionette.find_element(By.TAG_NAME, "select") + options = self.marionette.find_elements(By.TAG_NAME, "option") + + options[0].click() + self.assertSelected(options[0]) + options[1].click() + self.assertSelected(options[1]) + options[2].click() + self.assertSelected(options[2]) + options[0].click() + self.assertSelected(options[0]) + + def test_select_self(self): + self.marionette.navigate( + inline( + """ + <select> + <option>first + <option>second + </select>""" + ) + ) + select = self.marionette.find_element(By.TAG_NAME, "select") + options = self.marionette.find_elements(By.TAG_NAME, "option") + self.assertSelected(options[0]) + self.assertNotSelected(options[1]) + + options[1].click() + self.assertSelected(options[1]) + options[1].click() + self.assertSelected(options[1]) + + def test_out_of_view(self): + self.marionette.navigate( + inline( + """ + <select> + <option>1 + <option>2 + <option>3 + <option>4 + <option>5 + <option>6 + <option>7 + <option>8 + <option>9 + <option>10 + <option>11 + <option>12 + <option>13 + <option>14 + <option>15 + <option>16 + <option>17 + <option>18 + <option>19 + <option>20 + </select>""" + ) + ) + select = self.marionette.find_element(By.TAG_NAME, "select") + options = self.marionette.find_elements(By.TAG_NAME, "option") + + options[14].click() + self.assertSelected(options[14]) + + +class TestSelectMultiple(SelectTestCase): + def test_single(self): + self.marionette.navigate(inline("<select multiple> <option>first </select>")) + option = self.marionette.find_element(By.TAG_NAME, "option") + option.click() + self.assertSelected(option) + + def test_multiple(self): + self.marionette.navigate( + inline( + """ + <select multiple> + <option>first + <option>second + <option>third + </select>""" + ) + ) + select = self.marionette.find_element(By.TAG_NAME, "select") + options = select.find_elements(By.TAG_NAME, "option") + + options[1].click() + self.assertSelected(options[1]) + + options[2].click() + self.assertSelected(options[2]) + self.assertSelected(options[1]) + + def test_deselect_selected(self): + self.marionette.navigate(inline("<select multiple> <option>first </select>")) + option = self.marionette.find_element(By.TAG_NAME, "option") + option.click() + self.assertSelected(option) + option.click() + self.assertNotSelected(option) + + def test_deselect_preselected(self): + self.marionette.navigate( + inline( + """ + <select multiple> + <option selected>first + </select>""" + ) + ) + option = self.marionette.find_element(By.TAG_NAME, "option") + self.assertSelected(option) + option.click() + self.assertNotSelected(option) + + def test_out_of_view(self): + self.marionette.navigate( + inline( + """ + <select multiple> + <option>1 + <option>2 + <option>3 + <option>4 + <option>5 + <option>6 + <option>7 + <option>8 + <option>9 + <option>10 + <option>11 + <option>12 + <option>13 + <option>14 + <option>15 + <option>16 + <option>17 + <option>18 + <option>19 + <option>20 + </select>""" + ) + ) + select = self.marionette.find_element(By.TAG_NAME, "select") + options = self.marionette.find_elements(By.TAG_NAME, "option") + + options[-1].click() + self.assertSelected(options[-1]) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_sendkeys_menupopup_chrome.py b/testing/marionette/harness/marionette_harness/tests/unit/test_sendkeys_menupopup_chrome.py new file mode 100644 index 0000000000..6f24cc44d1 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_sendkeys_menupopup_chrome.py @@ -0,0 +1,115 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +from __future__ import absolute_import, division + +from six.moves.urllib.parse import quote + +from marionette_driver import By, errors, Wait +from marionette_driver.keys import Keys + +from marionette_harness import ( + MarionetteTestCase, + skip_if_framescript, + WindowManagerMixin, +) + + +class TestSendkeysMenupopup(WindowManagerMixin, MarionetteTestCase): + def setUp(self): + super(TestSendkeysMenupopup, self).setUp() + + self.marionette.set_context("chrome") + new_window = self.open_chrome_window( + "chrome://marionette/content/test_menupopup.xhtml" + ) + self.marionette.switch_to_window(new_window) + + self.click_el = self.marionette.find_element(By.ID, "options-button") + self.disabled_menuitem_el = self.marionette.find_element( + By.ID, "option-disabled" + ) + self.hidden_menuitem_el = self.marionette.find_element(By.ID, "option-hidden") + self.menuitem_el = self.marionette.find_element(By.ID, "option-enabled") + self.menupopup_el = self.marionette.find_element(By.ID, "options-menupopup") + self.testwindow_el = self.marionette.find_element(By.ID, "test-window") + + def context_menu_state(self): + return self.menupopup_el.get_property("state") + + def open_context_menu(self): + def attempt_open_context_menu(): + self.assertEqual(self.context_menu_state(), "closed") + self.click_el.click() + Wait(self.marionette).until( + lambda _: self.context_menu_state() == "open", + message="Context menu did not open", + ) + + try: + attempt_open_context_menu() + except errors.TimeoutException: + # If the first attempt timed out, try a second time. + # On Linux, the test will intermittently fail if we click too + # early on the button. Retrying fixes the issue. See Bug 1686769. + attempt_open_context_menu() + + def wait_for_context_menu_closed(self): + Wait(self.marionette).until( + lambda _: self.context_menu_state() == "closed", + message="Context menu did not close", + ) + + def tearDown(self): + try: + self.close_all_windows() + finally: + super(TestSendkeysMenupopup, self).tearDown() + + def test_sendkeys_menuitem(self): + # Try closing the context menu by sending ESCAPE to a visible context menu item. + self.open_context_menu() + + self.menuitem_el.send_keys(Keys.ESCAPE) + self.wait_for_context_menu_closed() + + def test_sendkeys_menupopup(self): + # Try closing the context menu by sending ESCAPE to the context menu. + self.open_context_menu() + + self.menupopup_el.send_keys(Keys.ESCAPE) + self.wait_for_context_menu_closed() + + def test_sendkeys_window(self): + # Try closing the context menu by sending ESCAPE to the main window. + self.open_context_menu() + + self.testwindow_el.send_keys(Keys.ESCAPE) + self.wait_for_context_menu_closed() + + @skip_if_framescript( + "Bug 1675173: Interactability is only checked with actors enabled" + ) + def test_sendkeys_closed_menu(self): + # send_keys should throw for the menupopup if the contextmenu is closed. + with self.assertRaises(errors.ElementNotInteractableException): + self.menupopup_el.send_keys(Keys.ESCAPE) + + # send_keys should throw for the menuitem if the contextmenu is closed. + with self.assertRaises(errors.ElementNotInteractableException): + self.menuitem_el.send_keys(Keys.ESCAPE) + + @skip_if_framescript( + "Bug 1675173: Interactability is only checked with actors enabled" + ) + def test_sendkeys_hidden_disabled_menuitem(self): + self.open_context_menu() + + # send_keys should throw for a disabled menuitem in an opened contextmenu. + with self.assertRaises(errors.ElementNotInteractableException): + self.disabled_menuitem_el.send_keys(Keys.ESCAPE) + + # send_keys should throw for a hidden menuitem in an opened contextmenu. + with self.assertRaises(errors.ElementNotInteractableException): + self.hidden_menuitem_el.send_keys(Keys.ESCAPE) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_session.py b/testing/marionette/harness/marionette_harness/tests/unit/test_session.py new file mode 100644 index 0000000000..86757662a1 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_session.py @@ -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/. + +from __future__ import absolute_import + +import six + +from marionette_driver import errors + +from marionette_harness import MarionetteTestCase + + +class TestSession(MarionetteTestCase): + def setUp(self): + super(TestSession, self).setUp() + + self.marionette.delete_session() + + def test_new_session_returns_capabilities(self): + # Sends newSession + caps = self.marionette.start_session() + + # Check that session was created. This implies the server + # sent us the sessionId and status fields. + self.assertIsNotNone(self.marionette.session) + + # Required capabilities mandated by WebDriver spec + self.assertIn("browserName", caps) + self.assertIn("browserVersion", caps) + self.assertIn("platformName", caps) + self.assertIn("platformVersion", caps) + + # Optional capabilities we want Marionette to support + self.assertIn("rotatable", caps) + + def test_get_session_id(self): + # Sends newSession + self.marionette.start_session() + + self.assertTrue(self.marionette.session_id is not None) + self.assertTrue(isinstance(self.marionette.session_id, six.text_type)) + + def test_session_already_started(self): + self.marionette.start_session() + self.assertTrue(isinstance(self.marionette.session_id, six.text_type)) + with self.assertRaises(errors.SessionNotCreatedException): + self.marionette._send_message("WebDriver:NewSession", {}) + + def test_no_session(self): + with self.assertRaises(errors.InvalidSessionIdException): + self.marionette.get_url() + + self.marionette.start_session() + self.marionette.get_url() diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_skip_setup.py b/testing/marionette/harness/marionette_harness/tests/unit/test_skip_setup.py new file mode 100644 index 0000000000..66b75fdd70 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_skip_setup.py @@ -0,0 +1,37 @@ +# 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/. + +from __future__ import absolute_import + +from marionette_harness import MarionetteTestCase, SkipTest + + +class TestSetUpSkipped(MarionetteTestCase): + + testVar = {"test": "SkipTest"} + + def setUp(self): + MarionetteTestCase.setUp(self) + try: + self.testVar["email"] + except KeyError: + raise SkipTest("email key not present in dict, skip ...") + + def test_assert(self): + assert True + + +class TestSetUpNotSkipped(MarionetteTestCase): + + testVar = {"test": "SkipTest"} + + def setUp(self): + try: + self.testVar["test"] + except KeyError: + raise SkipTest("email key not present in dict, skip ...") + MarionetteTestCase.setUp(self) + + def test_assert(self): + assert True diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_switch_frame.py b/testing/marionette/harness/marionette_harness/tests/unit/test_switch_frame.py new file mode 100644 index 0000000000..878423deea --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_switch_frame.py @@ -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/. + +from __future__ import absolute_import + +from marionette_driver.by import By +from marionette_driver.errors import InvalidArgumentException, NoSuchFrameException + +from marionette_harness import MarionetteTestCase + + +class TestSwitchFrame(MarionetteTestCase): + def setUp(self): + super(TestSwitchFrame, self).setUp() + + test_html = self.marionette.absolute_url("frameset.html") + self.marionette.navigate(test_html) + + def test_exceptions(self): + frame = self.marionette.find_element(By.CSS_SELECTOR, ":root") + with self.assertRaises(NoSuchFrameException): + self.marionette.switch_to_frame(frame) + + with self.assertRaises(InvalidArgumentException): + self.marionette.switch_to_frame(-1) + + def test_by_frame_element(self): + frame = self.marionette.find_element(By.NAME, "third") + self.marionette.switch_to_frame(frame) + + element = self.marionette.find_element(By.ID, "email") + self.assertEquals(element.get_attribute("type"), "email") + + def test_by_index(self): + self.marionette.switch_to_frame(2) + + element = self.marionette.find_element(By.ID, "email") + self.assertEquals(element.get_attribute("type"), "email") + + def test_back_to_top_frame(self): + frame1 = self.marionette.find_element(By.ID, "sixth") + self.marionette.switch_to_frame(frame1) + self.marionette.switch_to_frame(0) + + self.marionette.find_element(By.ID, "testDiv") + + self.marionette.switch_to_frame() + frame = self.marionette.find_element(By.ID, "sixth") + self.assertEquals(frame, frame1) + + +class TestSwitchParentFrame(MarionetteTestCase): + def test_iframe(self): + frame_html = self.marionette.absolute_url("test_iframe.html") + self.marionette.navigate(frame_html) + + frame = self.marionette.find_element(By.ID, "test_iframe") + self.marionette.switch_to_frame(frame) + self.marionette.find_element(By.ID, "testDiv") + + self.marionette.switch_to_parent_frame() + + self.marionette.find_element(By.ID, "test_iframe") + + def test_frameset(self): + frame_html = self.marionette.absolute_url("frameset.html") + self.marionette.navigate(frame_html) + frame = self.marionette.find_element(By.NAME, "third") + self.marionette.switch_to_frame(frame) + + # If we don't find the following element we aren't on the right page + self.marionette.find_element(By.ID, "checky") + + self.marionette.switch_to_parent_frame() + self.marionette.find_element(By.NAME, "third") + + def test_from_default_context_is_a_noop(self): + formpage = self.marionette.absolute_url("formPage.html") + self.marionette.navigate(formpage) + self.marionette.find_element(By.ID, "checky") + + self.marionette.switch_to_parent_frame() + self.marionette.find_element(By.ID, "checky") + + def test_from_second_level(self): + frame_html = self.marionette.absolute_url("frameset.html") + self.marionette.navigate(frame_html) + frame = self.marionette.find_element(By.NAME, "fourth") + self.marionette.switch_to_frame(frame) + + second_level = self.marionette.find_element(By.NAME, "child1") + self.marionette.switch_to_frame(second_level) + self.marionette.find_element(By.NAME, "myCheckBox") + + self.marionette.switch_to_parent_frame() + + second_level = self.marionette.find_element(By.NAME, "child1") diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_switch_frame_chrome.py b/testing/marionette/harness/marionette_harness/tests/unit/test_switch_frame_chrome.py new file mode 100644 index 0000000000..e7c842f01d --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_switch_frame_chrome.py @@ -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/. + +from __future__ import absolute_import + +from marionette_driver import By +from marionette_driver.errors import JavascriptException + +from marionette_harness import MarionetteTestCase, WindowManagerMixin + + +class TestSwitchFrameChrome(WindowManagerMixin, MarionetteTestCase): + def setUp(self): + super(TestSwitchFrameChrome, self).setUp() + self.marionette.set_context("chrome") + + new_window = self.open_chrome_window("chrome://marionette/content/test.xhtml") + self.marionette.switch_to_window(new_window) + self.assertNotEqual( + self.start_window, self.marionette.current_chrome_window_handle + ) + + def tearDown(self): + self.close_all_windows() + super(TestSwitchFrameChrome, self).tearDown() + + def test_switch_simple(self): + self.assertIn( + "test.xhtml", self.marionette.get_url(), "Initial navigation has failed" + ) + self.marionette.switch_to_frame(0) + self.assertIn( + "test.xhtml", self.marionette.get_url(), "Switching by index failed" + ) + self.marionette.find_element(By.ID, "testBox") + self.marionette.switch_to_frame() + self.assertIn( + "test.xhtml", self.marionette.get_url(), "Switching by null failed" + ) + iframe = self.marionette.find_element(By.ID, "iframe") + self.marionette.switch_to_frame(iframe) + self.assertIn( + "test.xhtml", self.marionette.get_url(), "Switching by element failed" + ) + self.marionette.find_element(By.ID, "testBox") + + def test_stack_trace(self): + self.assertIn( + "test.xhtml", self.marionette.get_url(), "Initial navigation has failed" + ) + self.marionette.switch_to_frame(0) + self.marionette.find_element(By.ID, "testBox") + try: + self.marionette.execute_async_script("foo();") + except JavascriptException as e: + self.assertIn("foo", str(e)) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_switch_window_chrome.py b/testing/marionette/harness/marionette_harness/tests/unit/test_switch_window_chrome.py new file mode 100644 index 0000000000..6e9405d332 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_switch_window_chrome.py @@ -0,0 +1,111 @@ +# 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/. + +from __future__ import absolute_import + +import os +import sys + +from unittest import skipIf + +# add this directory to the path +sys.path.append(os.path.dirname(__file__)) + +from test_switch_window_content import TestSwitchToWindowContent + + +class TestSwitchWindowChrome(TestSwitchToWindowContent): + def setUp(self): + super(TestSwitchWindowChrome, self).setUp() + + self.marionette.set_context("chrome") + + def tearDown(self): + self.close_all_windows() + + super(TestSwitchWindowChrome, self).tearDown() + + @skipIf( + sys.platform.startswith("linux"), + "Bug 1511970 - New window isn't moved to the background on Linux", + ) + def test_switch_tabs_for_new_background_window_without_focus_change(self): + # Open an additional tab in the original window so we can better check + # the selected index in thew new window to be opened. + second_tab = self.open_tab(focus=True) + self.marionette.switch_to_window(second_tab, focus=True) + second_tab_index = self.get_selected_tab_index() + self.assertNotEqual(second_tab_index, self.selected_tab_index) + + # Open a new background window, but we are interested in the tab + with self.marionette.using_context("content"): + tab_in_new_window = self.open_window() + self.assertEqual(self.marionette.current_window_handle, second_tab) + self.assertEqual( + self.marionette.current_chrome_window_handle, self.start_window + ) + self.assertEqual(self.get_selected_tab_index(), second_tab_index) + + # Switch to the tab in the new window but don't focus it + self.marionette.switch_to_window(tab_in_new_window, focus=False) + self.assertEqual(self.marionette.current_window_handle, tab_in_new_window) + self.assertNotEqual( + self.marionette.current_chrome_window_handle, self.start_window + ) + self.assertEqual(self.get_selected_tab_index(), second_tab_index) + + def test_switch_tabs_for_new_foreground_window_with_focus_change(self): + # Open an addition tab in the original window so we can better check + # the selected index in thew new window to be opened. + second_tab = self.open_tab() + self.marionette.switch_to_window(second_tab, focus=True) + second_tab_index = self.get_selected_tab_index() + self.assertNotEqual(second_tab_index, self.selected_tab_index) + + # Opens a new window, but we are interested in the tab + with self.marionette.using_context("content"): + tab_in_new_window = self.open_window(focus=True) + self.assertEqual(self.marionette.current_window_handle, second_tab) + self.assertEqual( + self.marionette.current_chrome_window_handle, self.start_window + ) + self.assertNotEqual(self.get_selected_tab_index(), second_tab_index) + + self.marionette.switch_to_window(tab_in_new_window) + self.assertEqual(self.marionette.current_window_handle, tab_in_new_window) + self.assertNotEqual( + self.marionette.current_chrome_window_handle, self.start_window + ) + self.assertNotEqual(self.get_selected_tab_index(), second_tab_index) + + self.marionette.switch_to_window(second_tab, focus=True) + self.assertEqual(self.marionette.current_window_handle, second_tab) + self.assertEqual( + self.marionette.current_chrome_window_handle, self.start_window + ) + # Bug 1335085 - The focus doesn't change even as requested so. + # self.assertEqual(self.get_selected_tab_index(), second_tab_index) + + def test_switch_tabs_for_new_foreground_window_without_focus_change(self): + # Open an addition tab in the original window so we can better check + # the selected index in thew new window to be opened. + second_tab = self.open_tab() + self.marionette.switch_to_window(second_tab, focus=True) + second_tab_index = self.get_selected_tab_index() + self.assertNotEqual(second_tab_index, self.selected_tab_index) + + self.open_window(focus=True) + self.assertEqual(self.marionette.current_window_handle, second_tab) + self.assertEqual( + self.marionette.current_chrome_window_handle, self.start_window + ) + self.assertNotEqual(self.get_selected_tab_index(), second_tab_index) + + # Switch to the second tab in the first window, but don't focus it. + self.marionette.switch_to_window(second_tab, focus=False) + self.assertEqual(self.marionette.current_window_handle, second_tab) + self.assertEqual( + self.marionette.current_chrome_window_handle, self.start_window + ) + self.assertNotEqual(self.get_selected_tab_index(), second_tab_index) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_switch_window_content.py b/testing/marionette/harness/marionette_harness/tests/unit/test_switch_window_content.py new file mode 100644 index 0000000000..653d302084 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_switch_window_content.py @@ -0,0 +1,213 @@ +# This Source Code Form is subject to the terms of the Mozilla ublic +# 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/. + +from __future__ import absolute_import + +import sys +from unittest import skipIf + +from marionette_driver import By +from marionette_driver.keys import Keys + +from marionette_harness import ( + MarionetteTestCase, + WindowManagerMixin, +) + + +class TestSwitchToWindowContent(WindowManagerMixin, MarionetteTestCase): + def setUp(self): + super(TestSwitchToWindowContent, self).setUp() + + if self.marionette.session_capabilities["platformName"] == "mac": + self.mod_key = Keys.META + else: + self.mod_key = Keys.CONTROL + + self.selected_tab_index = self.get_selected_tab_index() + + def tearDown(self): + self.close_all_tabs() + + super(TestSwitchToWindowContent, self).tearDown() + + def get_selected_tab_index(self): + with self.marionette.using_context("chrome"): + return self.marionette.execute_script( + """ + Components.utils.import("resource://gre/modules/AppConstants.jsm"); + + let win = null; + + if (AppConstants.MOZ_APP_NAME == "fennec") { + Components.utils.import("resource://gre/modules/Services.jsm"); + win = Services.wm.getMostRecentWindow("navigator:browser"); + } else { + Components.utils.import("resource:///modules/BrowserWindowTracker.jsm"); + win = BrowserWindowTracker.getTopWindow(); + } + + let tabBrowser = null; + + // Fennec + if (win.BrowserApp) { + tabBrowser = win.BrowserApp; + + // Firefox + } else if (win.gBrowser) { + tabBrowser = win.gBrowser; + + } else { + return null; + } + + for (let i = 0; i < tabBrowser.tabs.length; i++) { + if (tabBrowser.tabs[i] == tabBrowser.selectedTab) { + return i; + } + } + """ + ) + + def test_switch_tabs_with_focus_change(self): + new_tab = self.open_tab(focus=True) + self.assertEqual(self.marionette.current_window_handle, self.start_tab) + self.assertNotEqual(self.get_selected_tab_index(), self.selected_tab_index) + + # Switch to new tab first because it is already selected + self.marionette.switch_to_window(new_tab) + self.assertEqual(self.marionette.current_window_handle, new_tab) + self.assertNotEqual(self.get_selected_tab_index(), self.selected_tab_index) + + # Switch to original tab by explicitely setting the focus + self.marionette.switch_to_window(self.start_tab, focus=True) + self.assertEqual(self.marionette.current_window_handle, self.start_tab) + self.assertEqual(self.get_selected_tab_index(), self.selected_tab_index) + + self.marionette.switch_to_window(new_tab) + self.marionette.close() + + self.marionette.switch_to_window(self.start_tab) + self.assertEqual(self.marionette.current_window_handle, self.start_tab) + self.assertEqual(self.get_selected_tab_index(), self.selected_tab_index) + + @skipIf( + sys.platform.startswith("linux"), + "Bug 1557232 - Original window sometimes doesn't receive focus", + ) + def test_switch_tabs_in_different_windows_with_focus_change(self): + new_tab1 = self.open_tab(focus=True) + self.assertEqual(self.marionette.current_window_handle, self.start_tab) + self.assertEqual(self.get_selected_tab_index(), 1) + + # Switch to new tab first which is already selected + self.marionette.switch_to_window(new_tab1) + self.assertEqual(self.marionette.current_window_handle, new_tab1) + self.assertEqual(self.get_selected_tab_index(), 1) + + # Open a new browser window with a single focused tab already focused + with self.marionette.using_context("content"): + new_tab2 = self.open_window(focus=True) + self.assertEqual(self.marionette.current_window_handle, new_tab1) + self.assertEqual(self.get_selected_tab_index(), 0) + + # Switch to that tab + self.marionette.switch_to_window(new_tab2) + self.assertEqual(self.marionette.current_window_handle, new_tab2) + self.assertEqual(self.get_selected_tab_index(), 0) + + # Switch back to the 2nd tab of the original window and setting the focus + self.marionette.switch_to_window(new_tab1, focus=True) + self.assertEqual(self.marionette.current_window_handle, new_tab1) + self.assertEqual(self.get_selected_tab_index(), 1) + + self.marionette.switch_to_window(new_tab2) + self.marionette.close() + + self.marionette.switch_to_window(new_tab1) + self.marionette.close() + + self.marionette.switch_to_window(self.start_tab) + self.assertEqual(self.marionette.current_window_handle, self.start_tab) + self.assertEqual(self.get_selected_tab_index(), self.selected_tab_index) + + def test_switch_tabs_without_focus_change(self): + new_tab = self.open_tab(focus=True) + self.assertEqual(self.marionette.current_window_handle, self.start_tab) + self.assertNotEqual(self.get_selected_tab_index(), self.selected_tab_index) + + # Switch to new tab first because it is already selected + self.marionette.switch_to_window(new_tab) + self.assertEqual(self.marionette.current_window_handle, new_tab) + + # Switch to original tab by explicitely not setting the focus + self.marionette.switch_to_window(self.start_tab, focus=False) + self.assertEqual(self.marionette.current_window_handle, self.start_tab) + self.assertNotEqual(self.get_selected_tab_index(), self.selected_tab_index) + + self.marionette.switch_to_window(new_tab) + self.marionette.close() + + self.marionette.switch_to_window(self.start_tab) + self.assertEqual(self.marionette.current_window_handle, self.start_tab) + self.assertEqual(self.get_selected_tab_index(), self.selected_tab_index) + + def test_switch_from_content_to_chrome_window_should_not_change_selected_tab(self): + new_tab = self.open_tab(focus=True) + + self.marionette.switch_to_window(new_tab) + self.assertEqual(self.marionette.current_window_handle, new_tab) + new_tab_index = self.get_selected_tab_index() + + self.marionette.switch_to_window(self.start_window) + self.assertEqual(self.marionette.current_window_handle, new_tab) + self.assertEqual(self.get_selected_tab_index(), new_tab_index) + + def test_switch_to_new_private_browsing_tab(self): + # Test that tabs (browsers) are correctly registered for a newly opened + # private browsing window/tab. This has to also happen without explicitely + # switching to the tab itself before using any commands in content scope. + # + # Note: Not sure why this only affects private browsing windows only. + new_tab = self.open_tab(focus=True) + self.marionette.switch_to_window(new_tab) + + def open_private_browsing_window_firefox(): + with self.marionette.using_context("content"): + self.marionette.find_element(By.ID, "startPrivateBrowsing").click() + + def open_private_browsing_tab_fennec(): + with self.marionette.using_context("content"): + self.marionette.find_element(By.ID, "newPrivateTabLink").click() + + with self.marionette.using_context("content"): + self.marionette.navigate("about:privatebrowsing") + if self.marionette.session_capabilities["browserName"] == "fennec": + new_pb_tab = self.open_tab(open_private_browsing_tab_fennec) + else: + new_pb_tab = self.open_tab(open_private_browsing_window_firefox) + + self.marionette.switch_to_window(new_pb_tab) + self.assertEqual(self.marionette.current_window_handle, new_pb_tab) + + self.marionette.execute_script(" return true; ") + + def test_switch_to_window_after_remoteness_change(self): + # Test that after a remoteness change (and a browsing context swap) + # marionette can still switch to tabs correctly. + with self.marionette.using_context("content"): + # about:robots runs in a different process and will trigger a + # remoteness change with or without fission. + self.marionette.navigate("about:robots") + + about_robots_tab = self.marionette.current_window_handle + + # Open a new tab and switch to it before trying to switch back to the + # initial tab. + tab2 = self.open_tab(focus=True) + self.marionette.switch_to_window(tab2) + self.marionette.close() + + self.marionette.switch_to_window(about_robots_tab) + self.assertEqual(self.marionette.current_window_handle, about_robots_tab) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_teardown_context_preserved.py b/testing/marionette/harness/marionette_harness/tests/unit/test_teardown_context_preserved.py new file mode 100644 index 0000000000..21a66452e4 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_teardown_context_preserved.py @@ -0,0 +1,23 @@ +# 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/. + +from __future__ import absolute_import + +from marionette_harness import MarionetteTestCase, SkipTest + + +class TestTearDownContext(MarionetteTestCase): + def setUp(self): + MarionetteTestCase.setUp(self) + self.marionette.set_context(self.marionette.CONTEXT_CHROME) + + def tearDown(self): + self.assertEqual(self.get_context(), self.marionette.CONTEXT_CHROME) + MarionetteTestCase.tearDown(self) + + def get_context(self): + return self.marionette._send_message("Marionette:GetContext", key="value") + + def test_skipped_teardown_ok(self): + raise SkipTest("This should leave our teardown method in chrome context") diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_text.py b/testing/marionette/harness/marionette_harness/tests/unit/test_text.py new file mode 100644 index 0000000000..a3adb09496 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_text.py @@ -0,0 +1,28 @@ +# 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/. + +from __future__ import absolute_import + +from marionette_driver.by import By +from marionette_harness import MarionetteTestCase + + +class TestText(MarionetteTestCase): + def test_get_text(self): + test_html = self.marionette.absolute_url("test.html") + self.marionette.navigate(test_html) + l = self.marionette.find_element(By.ID, "mozLink") + self.assertEqual("Click me!", l.text) + + def test_clear_text(self): + test_html = self.marionette.absolute_url("test.html") + self.marionette.navigate(test_html) + l = self.marionette.find_element(By.NAME, "myInput") + self.assertEqual( + "asdf", self.marionette.execute_script("return arguments[0].value;", [l]) + ) + l.clear() + self.assertEqual( + "", self.marionette.execute_script("return arguments[0].value;", [l]) + ) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_text_chrome.py b/testing/marionette/harness/marionette_harness/tests/unit/test_text_chrome.py new file mode 100644 index 0000000000..bcf42b9fa0 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_text_chrome.py @@ -0,0 +1,37 @@ +# 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/. + +from __future__ import absolute_import + +from marionette_driver.by import By +from marionette_harness import MarionetteTestCase, WindowManagerMixin + + +class TestTextChrome(WindowManagerMixin, MarionetteTestCase): + def setUp(self): + super(TestTextChrome, self).setUp() + win = self.open_chrome_window("chrome://marionette/content/test.xhtml") + self.marionette.switch_to_window(win) + + self.marionette.set_context("chrome") + + def tearDown(self): + self.close_all_windows() + + super(TestTextChrome, self).tearDown() + + def test_get_text(self): + elem = self.marionette.find_element(By.ID, "testBox") + self.assertEqual(elem.text, "box") + + def test_clear_text(self): + input = self.marionette.find_element(By.ID, "textInput3") + self.assertEqual( + "test", + self.marionette.execute_script("return arguments[0].value;", [input]), + ) + input.clear() + self.assertEqual( + "", self.marionette.execute_script("return arguments[0].value;", [input]) + ) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_timeouts.py b/testing/marionette/harness/marionette_harness/tests/unit/test_timeouts.py new file mode 100644 index 0000000000..a67c9e756f --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_timeouts.py @@ -0,0 +1,115 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +from __future__ import absolute_import + +from marionette_driver.by import By +from marionette_driver.errors import ( + MarionetteException, + NoSuchElementException, + ScriptTimeoutException, +) +from marionette_driver.marionette import HTMLElement + +from marionette_harness import MarionetteTestCase, run_if_manage_instance + + +class TestTimeouts(MarionetteTestCase): + def tearDown(self): + self.marionette.timeout.reset() + MarionetteTestCase.tearDown(self) + + def test_get_timeout_fraction(self): + self.marionette.timeout.script = 0.5 + self.assertEqual(self.marionette.timeout.script, 0.5) + + def test_page_timeout_notdefinetimeout_pass(self): + test_html = self.marionette.absolute_url("test.html") + self.marionette.navigate(test_html) + + def test_page_timeout_fail(self): + self.marionette.timeout.page_load = 0 + test_html = self.marionette.absolute_url("slow") + with self.assertRaises(MarionetteException): + self.marionette.navigate(test_html) + + def test_page_timeout_pass(self): + self.marionette.timeout.page_load = 60 + test_html = self.marionette.absolute_url("test.html") + self.marionette.navigate(test_html) + + def test_search_timeout_notfound_settimeout(self): + test_html = self.marionette.absolute_url("test.html") + self.marionette.navigate(test_html) + self.marionette.timeout.implicit = 1 + with self.assertRaises(NoSuchElementException): + self.marionette.find_element(By.ID, "I'm not on the page") + self.marionette.timeout.implicit = 0 + with self.assertRaises(NoSuchElementException): + self.marionette.find_element(By.ID, "I'm not on the page") + + def test_search_timeout_found_settimeout(self): + test_html = self.marionette.absolute_url("test.html") + self.marionette.navigate(test_html) + button = self.marionette.find_element(By.ID, "createDivButton") + button.click() + self.marionette.timeout.implicit = 8 + self.assertEqual( + HTMLElement, type(self.marionette.find_element(By.ID, "newDiv")) + ) + + def test_search_timeout_found(self): + test_html = self.marionette.absolute_url("test.html") + self.marionette.navigate(test_html) + button = self.marionette.find_element(By.ID, "createDivButton") + button.click() + self.assertRaises( + NoSuchElementException, self.marionette.find_element, By.ID, "newDiv" + ) + + @run_if_manage_instance("Only runnable if Marionette manages the instance") + def test_reset_timeout(self): + timeouts = [ + getattr(self.marionette.timeout, f) + for f in ( + "implicit", + "page_load", + "script", + ) + ] + + def do_check(callback): + for timeout in timeouts: + timeout = 10000 + self.assertEqual(timeout, 10000) + callback() + for timeout in timeouts: + self.assertNotEqual(timeout, 10000) + + def callback_quit(): + self.marionette.quit() + self.marionette.start_session() + + do_check(self.marionette.restart) + do_check(callback_quit) + + def test_execute_async_timeout_settimeout(self): + test_html = self.marionette.absolute_url("test.html") + self.marionette.navigate(test_html) + self.marionette.timeout.script = 1 + with self.assertRaises(ScriptTimeoutException): + self.marionette.execute_async_script("var x = 1;") + + def test_no_timeout_settimeout(self): + test_html = self.marionette.absolute_url("test.html") + self.marionette.navigate(test_html) + self.marionette.timeout.script = 1 + self.assertTrue( + self.marionette.execute_async_script( + """ + var callback = arguments[arguments.length - 1]; + setTimeout(function() { callback(true); }, 500); + """ + ) + ) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_title.py b/testing/marionette/harness/marionette_harness/tests/unit/test_title.py new file mode 100644 index 0000000000..8e1f2941b6 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_title.py @@ -0,0 +1,19 @@ +# 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/. + +from __future__ import absolute_import + +from six.moves.urllib.parse import quote + +from marionette_harness import MarionetteTestCase + + +def inline(doc): + return "data:text/html;charset=utf-8,{}".format(quote(doc)) + + +class TestTitle(MarionetteTestCase): + def test_basic(self): + self.marionette.navigate(inline("<title>foo</title>")) + self.assertEqual(self.marionette.title, "foo") diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_title_chrome.py b/testing/marionette/harness/marionette_harness/tests/unit/test_title_chrome.py new file mode 100644 index 0000000000..93ecefe511 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_title_chrome.py @@ -0,0 +1,30 @@ +# 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/. + +from __future__ import absolute_import + +from marionette_harness import MarionetteTestCase, WindowManagerMixin + + +class TestTitleChrome(WindowManagerMixin, MarionetteTestCase): + def setUp(self): + super(TestTitleChrome, self).setUp() + + self.marionette.set_context("chrome") + + def tearDown(self): + self.close_all_windows() + + super(TestTitleChrome, self).tearDown() + + def test_get_chrome_title(self): + win = self.open_chrome_window("chrome://marionette/content/test.xhtml") + self.marionette.switch_to_window(win) + + expected_title = self.marionette.execute_script( + """ + return window.document.documentElement.getAttribute('title'); + """ + ) + self.assertEqual(self.marionette.title, expected_title) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_transport.py b/testing/marionette/harness/marionette_harness/tests/unit/test_transport.py new file mode 100644 index 0000000000..4b4d2665f3 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_transport.py @@ -0,0 +1,114 @@ +# 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/. + +from __future__ import absolute_import + +import json + +from marionette_driver.transport import Command, Response + +from marionette_harness import MarionetteTestCase + + +get_current_url = ("getCurrentUrl", None) +execute_script = ("executeScript", {"script": "return 42"}) + + +class TestMessageSequencing(MarionetteTestCase): + @property + def last_id(self): + return self.marionette.client.last_id + + @last_id.setter + def last_id(self, new_id): + self.marionette.client.last_id = new_id + + def send(self, name, params): + self.last_id = self.last_id + 1 + cmd = Command(self.last_id, name, params) + self.marionette.client.send(cmd) + return self.last_id + + +class MessageTestCase(MarionetteTestCase): + def assert_attr(self, obj, attr): + self.assertTrue( + hasattr(obj, attr), "object does not have attribute {}".format(attr) + ) + + +class TestCommand(MessageTestCase): + def create(self, msgid="msgid", name="name", params="params"): + return Command(msgid, name, params) + + def test_initialise(self): + cmd = self.create() + self.assert_attr(cmd, "id") + self.assert_attr(cmd, "name") + self.assert_attr(cmd, "params") + self.assertEqual("msgid", cmd.id) + self.assertEqual("name", cmd.name) + self.assertEqual("params", cmd.params) + + def test_stringify(self): + cmd = self.create() + string = str(cmd) + self.assertIn("Command", string) + self.assertIn("id=msgid", string) + self.assertIn("name=name", string) + self.assertIn("params=params", string) + + def test_to_msg(self): + cmd = self.create() + msg = json.loads(cmd.to_msg()) + self.assertEquals(msg[0], Command.TYPE) + self.assertEquals(msg[1], "msgid") + self.assertEquals(msg[2], "name") + self.assertEquals(msg[3], "params") + + def test_from_msg(self): + msg = [Command.TYPE, "msgid", "name", "params"] + payload = json.dumps(msg) + cmd = Command.from_msg(payload) + self.assertEquals(msg[1], cmd.id) + self.assertEquals(msg[2], cmd.name) + self.assertEquals(msg[3], cmd.params) + + +class TestResponse(MessageTestCase): + def create(self, msgid="msgid", error="error", result="result"): + return Response(msgid, error, result) + + def test_initialise(self): + resp = self.create() + self.assert_attr(resp, "id") + self.assert_attr(resp, "error") + self.assert_attr(resp, "result") + self.assertEqual("msgid", resp.id) + self.assertEqual("error", resp.error) + self.assertEqual("result", resp.result) + + def test_stringify(self): + resp = self.create() + string = str(resp) + self.assertIn("Response", string) + self.assertIn("id=msgid", string) + self.assertIn("error=error", string) + self.assertIn("result=result", string) + + def test_to_msg(self): + resp = self.create() + msg = json.loads(resp.to_msg()) + self.assertEquals(msg[0], Response.TYPE) + self.assertEquals(msg[1], "msgid") + self.assertEquals(msg[2], "error") + self.assertEquals(msg[3], "result") + + def test_from_msg(self): + msg = [Response.TYPE, "msgid", "error", "result"] + payload = json.dumps(msg) + resp = Response.from_msg(payload) + self.assertEquals(msg[1], resp.id) + self.assertEquals(msg[2], resp.error) + self.assertEquals(msg[3], resp.result) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_typing.py b/testing/marionette/harness/marionette_harness/tests/unit/test_typing.py new file mode 100644 index 0000000000..0ddd7a167d --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_typing.py @@ -0,0 +1,376 @@ +# 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/. + +from __future__ import absolute_import + +from six.moves.urllib.parse import quote + +from marionette_driver.by import By +from marionette_driver.errors import ElementNotInteractableException +from marionette_driver.keys import Keys + +from marionette_harness import MarionetteTestCase, skip + + +def inline(doc): + return "data:text/html;charset=utf-8,{}".format(quote(doc)) + + +class TypingTestCase(MarionetteTestCase): + def setUp(self): + super(TypingTestCase, self).setUp() + + if self.marionette.session_capabilities["platformName"] == "mac": + self.mod_key = Keys.META + else: + self.mod_key = Keys.CONTROL + + +class TestTypingChrome(TypingTestCase): + def setUp(self): + super(TestTypingChrome, self).setUp() + self.marionette.set_context("chrome") + + def test_cut_and_paste_shortcuts(self): + with self.marionette.using_context("content"): + test_html = self.marionette.absolute_url("keyboard.html") + self.marionette.navigate(test_html) + + key_reporter = self.marionette.find_element(By.ID, "keyReporter") + self.assertEqual("", key_reporter.get_property("value")) + key_reporter.send_keys("zyxwvutsr") + self.assertEqual("zyxwvutsr", key_reporter.get_property("value")) + + # select all and cut + key_reporter.send_keys(self.mod_key, "a") + key_reporter.send_keys(self.mod_key, "x") + self.assertEqual("", key_reporter.get_property("value")) + + url_bar = self.marionette.find_element(By.ID, "urlbar-input") + + # Clear contents first + url_bar.send_keys(self.mod_key, "a") + url_bar.send_keys(Keys.BACK_SPACE) + self.assertEqual("", url_bar.get_property("value")) + + url_bar.send_keys(self.mod_key, "v") + self.assertEqual("zyxwvutsr", url_bar.get_property("value")) + + +class TestTypingContent(TypingTestCase): + def test_should_fire_key_press_events(self): + test_html = self.marionette.absolute_url("keyboard.html") + self.marionette.navigate(test_html) + key_reporter = self.marionette.find_element(By.ID, "keyReporter") + key_reporter.send_keys("a") + result = self.marionette.find_element(By.ID, "result") + self.assertTrue("press:" in result.text) + + def test_should_fire_key_down_events(self): + test_html = self.marionette.absolute_url("keyboard.html") + self.marionette.navigate(test_html) + key_reporter = self.marionette.find_element(By.ID, "keyReporter") + key_reporter.send_keys("I") + result = self.marionette.find_element(By.ID, "result") + self.assertTrue("down" in result.text) + + def test_should_fire_key_up_events(self): + test_html = self.marionette.absolute_url("keyboard.html") + self.marionette.navigate(test_html) + + key_reporter = self.marionette.find_element(By.ID, "keyReporter") + key_reporter.send_keys("a") + result = self.marionette.find_element(By.ID, "result") + self.assertTrue("up:" in result.text) + + def test_should_type_lowercase_characters(self): + test_html = self.marionette.absolute_url("keyboard.html") + self.marionette.navigate(test_html) + + key_reporter = self.marionette.find_element(By.ID, "keyReporter") + key_reporter.send_keys("abc def") + self.assertEqual("abc def", key_reporter.get_property("value")) + + def test_should_type_uppercase_characters(self): + test_html = self.marionette.absolute_url("keyboard.html") + self.marionette.navigate(test_html) + + key_reporter = self.marionette.find_element(By.ID, "keyReporter") + key_reporter.send_keys("ABC DEF") + self.assertEqual("ABC DEF", key_reporter.get_property("value")) + + def test_cut_and_paste_shortcuts(self): + test_html = self.marionette.absolute_url("keyboard.html") + self.marionette.navigate(test_html) + + key_reporter = self.marionette.find_element(By.ID, "keyReporter") + self.assertEqual("", key_reporter.get_property("value")) + key_reporter.send_keys("zyxwvutsr") + self.assertEqual("zyxwvutsr", key_reporter.get_property("value")) + + # select all and cut + key_reporter.send_keys(self.mod_key, "a") + key_reporter.send_keys(self.mod_key, "x") + self.assertEqual("", key_reporter.get_property("value")) + + key_reporter.send_keys(self.mod_key, "v") + self.assertEqual("zyxwvutsr", key_reporter.get_property("value")) + + def test_should_type_a_quote_characters(self): + test_html = self.marionette.absolute_url("keyboard.html") + self.marionette.navigate(test_html) + + key_reporter = self.marionette.find_element(By.ID, "keyReporter") + key_reporter.send_keys('"') + self.assertEqual('"', key_reporter.get_property("value")) + + def test_should_type_an_at_character(self): + test_html = self.marionette.absolute_url("keyboard.html") + self.marionette.navigate(test_html) + + key_reporter = self.marionette.find_element(By.ID, "keyReporter") + key_reporter.send_keys("@") + self.assertEqual("@", key_reporter.get_property("value")) + + def test_should_type_a_mix_of_upper_and_lower_case_character(self): + test_html = self.marionette.absolute_url("keyboard.html") + self.marionette.navigate(test_html) + + key_reporter = self.marionette.find_element(By.ID, "keyReporter") + key_reporter.send_keys("me@eXample.com") + self.assertEqual("me@eXample.com", key_reporter.get_property("value")) + + def test_arrow_keys_are_not_printable(self): + test_html = self.marionette.absolute_url("keyboard.html") + self.marionette.navigate(test_html) + + key_reporter = self.marionette.find_element(By.ID, "keyReporter") + key_reporter.send_keys(Keys.ARROW_LEFT) + self.assertEqual("", key_reporter.get_property("value")) + + def test_will_simulate_a_key_up_when_entering_text_into_input_elements(self): + test_html = self.marionette.absolute_url("keyboard.html") + self.marionette.navigate(test_html) + + element = self.marionette.find_element(By.ID, "keyUp") + element.send_keys("I like cheese") + result = self.marionette.find_element(By.ID, "result") + self.assertEqual(result.text, "I like cheese") + + def test_will_simulate_a_key_down_when_entering_text_into_input_elements(self): + test_html = self.marionette.absolute_url("keyboard.html") + self.marionette.navigate(test_html) + + element = self.marionette.find_element(By.ID, "keyDown") + element.send_keys("I like cheese") + result = self.marionette.find_element(By.ID, "result") + # Because the key down gets the result before the input element is + # filled, we're a letter short here + self.assertEqual(result.text, "I like chees") + + def test_will_simulate_a_key_press_when_entering_text_into_input_elements(self): + test_html = self.marionette.absolute_url("keyboard.html") + self.marionette.navigate(test_html) + + element = self.marionette.find_element(By.ID, "keyPress") + element.send_keys("I like cheese") + result = self.marionette.find_element(By.ID, "result") + # Because the key down gets the result before the input element is + # filled, we're a letter short here + self.assertEqual(result.text, "I like chees") + + def test_will_simulate_a_keyup_when_entering_text_into_textareas(self): + test_html = self.marionette.absolute_url("keyboard.html") + self.marionette.navigate(test_html) + + element = self.marionette.find_element(By.ID, "keyUpArea") + element.send_keys("I like cheese") + result = self.marionette.find_element(By.ID, "result") + self.assertEqual("I like cheese", result.text) + + def test_will_simulate_a_keydown_when_entering_text_into_textareas(self): + test_html = self.marionette.absolute_url("keyboard.html") + self.marionette.navigate(test_html) + + element = self.marionette.find_element(By.ID, "keyDownArea") + element.send_keys("I like cheese") + result = self.marionette.find_element(By.ID, "result") + # Because the key down gets the result before the input element is + # filled, we're a letter short here + self.assertEqual(result.text, "I like chees") + + def test_will_simulate_a_keypress_when_entering_text_into_textareas(self): + test_html = self.marionette.absolute_url("keyboard.html") + self.marionette.navigate(test_html) + + element = self.marionette.find_element(By.ID, "keyPressArea") + element.send_keys("I like cheese") + result = self.marionette.find_element(By.ID, "result") + # Because the key down gets the result before the input element is + # filled, we're a letter short here + self.assertEqual(result.text, "I like chees") + + def test_should_report_key_code_of_arrow_keys_up_down_events(self): + test_html = self.marionette.absolute_url("keyboard.html") + self.marionette.navigate(test_html) + + result = self.marionette.find_element(By.ID, "result") + element = self.marionette.find_element(By.ID, "keyReporter") + + element.send_keys(Keys.ARROW_DOWN) + + self.assertIn("down: 40", result.text.strip()) + self.assertIn("up: 40", result.text.strip()) + + element.send_keys(Keys.ARROW_UP) + self.assertIn("down: 38", result.text.strip()) + self.assertIn("up: 38", result.text.strip()) + + element.send_keys(Keys.ARROW_LEFT) + self.assertIn("down: 37", result.text.strip()) + self.assertIn("up: 37", result.text.strip()) + + element.send_keys(Keys.ARROW_RIGHT) + self.assertIn("down: 39", result.text.strip()) + self.assertIn("up: 39", result.text.strip()) + + # And leave no rubbish/printable keys in the "keyReporter" + self.assertEqual("", element.get_property("value")) + + @skip("Reenable in Bug 1068728") + def test_numeric_shift_keys(self): + test_html = self.marionette.absolute_url("keyboard.html") + self.marionette.navigate(test_html) + + result = self.marionette.find_element(By.ID, "result") + element = self.marionette.find_element(By.ID, "keyReporter") + numeric_shifts_etc = '~!@#$%^&*()_+{}:i"<>?|END~' + element.send_keys(numeric_shifts_etc) + self.assertEqual(numeric_shifts_etc, element.get_property("value")) + self.assertIn(" up: 16", result.text.strip()) + + def test_numeric_non_shift_keys(self): + test_html = self.marionette.absolute_url("keyboard.html") + self.marionette.navigate(test_html) + element = self.marionette.find_element(By.ID, "keyReporter") + numeric_line_chars_non_shifted = "`1234567890-=[]\\,.'/42" + element.send_keys(numeric_line_chars_non_shifted) + self.assertEqual(numeric_line_chars_non_shifted, element.get_property("value")) + + def test_lowercase_alpha_keys(self): + test_html = self.marionette.absolute_url("keyboard.html") + self.marionette.navigate(test_html) + + element = self.marionette.find_element(By.ID, "keyReporter") + lower_alphas = "abcdefghijklmnopqrstuvwxyz" + element.send_keys(lower_alphas) + self.assertEqual(lower_alphas, element.get_property("value")) + + @skip("Reenable in Bug 1068735") + def test_uppercase_alpha_keys(self): + test_html = self.marionette.absolute_url("keyboard.html") + self.marionette.navigate(test_html) + + result = self.marionette.find_element(By.ID, "result") + element = self.marionette.find_element(By.ID, "keyReporter") + upper_alphas = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + element.send_keys(upper_alphas) + self.assertEqual(upper_alphas, element.get_property("value")) + self.assertIn(" up: 16", result.text.strip()) + + @skip("Reenable in Bug 1068726") + def test_all_printable_keys(self): + test_html = self.marionette.absolute_url("keyboard.html") + self.marionette.navigate(test_html) + + result = self.marionette.find_element(By.ID, "result") + element = self.marionette.find_element(By.ID, "keyReporter") + all_printable = ( + "!\"#$%&'()*+,-./0123456789:<=>?@ " + "ABCDEFGHIJKLMNOPQRSTUVWXYZ [\\]^_`" + "abcdefghijklmnopqrstuvwxyz{|}~" + ) + element.send_keys(all_printable) + + self.assertTrue(all_printable, element.get_property("value")) + self.assertIn(" up: 16", result.text.strip()) + + @skip("Reenable in Bug 1068733") + def test_special_space_keys(self): + test_html = self.marionette.absolute_url("keyboard.html") + self.marionette.navigate(test_html) + + element = self.marionette.find_element(By.ID, "keyReporter") + element.send_keys("abcd" + Keys.SPACE + "fgh" + Keys.SPACE + "ij") + self.assertEqual("abcd fgh ij", element.get_property("value")) + + def test_should_type_an_integer(self): + test_html = self.marionette.absolute_url("keyboard.html") + self.marionette.navigate(test_html) + + element = self.marionette.find_element(By.ID, "keyReporter") + element.send_keys(1234) + self.assertEqual("1234", element.get_property("value")) + + def test_should_send_keys_to_elements_without_the_value_attribute(self): + test_html = self.marionette.absolute_url("keyboard.html") + self.marionette.navigate(test_html) + + # If we don't get an error below we are good + self.marionette.find_element(By.TAG_NAME, "body").send_keys("foo") + + def test_appends_to_input_text(self): + self.marionette.navigate(inline("<input>")) + el = self.marionette.find_element(By.TAG_NAME, "input") + el.send_keys("foo") + el.send_keys("bar") + self.assertEqual("foobar", el.get_property("value")) + + def test_appends_to_textarea(self): + self.marionette.navigate(inline("<textarea></textarea>")) + textarea = self.marionette.find_element(By.TAG_NAME, "textarea") + textarea.send_keys("foo") + textarea.send_keys("bar") + self.assertEqual("foobar", textarea.get_property("value")) + + def test_send_keys_to_type_input(self): + test_html = self.marionette.absolute_url("html5/test_html_inputs.html") + self.marionette.navigate(test_html) + + num_input = self.marionette.find_element(By.ID, "number") + self.assertEqual( + "", self.marionette.execute_script("return arguments[0].value", [num_input]) + ) + num_input.send_keys("1234") + self.assertEqual( + "1234", + self.marionette.execute_script("return arguments[0].value", [num_input]), + ) + + def test_insert_keys(self): + l = self.marionette.find_element(By.ID, "change") + l.send_keys("abde") + self.assertEqual( + "abde", self.marionette.execute_script("return arguments[0].value;", [l]) + ) + + # Set caret position to the middle of the input text. + self.marionette.execute_script( + """var el = arguments[0]; + el.selectionStart = el.selectionEnd = el.value.length / 2;""", + script_args=[l], + ) + + l.send_keys("c") + self.assertEqual( + "abcde", self.marionette.execute_script("return arguments[0].value;", [l]) + ) + + +class TestTypingContentLegacy(TestTypingContent): + def setUp(self): + super(TestTypingContent, self).setUp() + + self.marionette.delete_session() + self.marionette.start_session({"moz:webdriverClick": False}) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_unhandled_prompt_behavior.py b/testing/marionette/harness/marionette_harness/tests/unit/test_unhandled_prompt_behavior.py new file mode 100644 index 0000000000..5c9f7cc200 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_unhandled_prompt_behavior.py @@ -0,0 +1,128 @@ +from __future__ import absolute_import + +from marionette_driver import errors +from marionette_driver.marionette import Alert +from marionette_driver.wait import Wait +from marionette_harness import MarionetteTestCase, parameterized + + +class TestUnhandledPromptBehavior(MarionetteTestCase): + def setUp(self): + super(TestUnhandledPromptBehavior, self).setUp() + + self.marionette.delete_session() + + def tearDown(self): + # Ensure to close a possible remaining tab modal dialog + try: + alert = self.marionette.switch_to_alert() + alert.dismiss() + + Wait(self.marionette).until(lambda _: not self.alert_present) + except errors.NoAlertPresentException: + pass + + super(TestUnhandledPromptBehavior, self).tearDown() + + @property + def alert_present(self): + try: + Alert(self.marionette).text + return True + except errors.NoAlertPresentException: + return False + + def perform_user_prompt_check( + self, + prompt_type, + text, + expected_result, + expected_close=True, + expected_notify=True, + ): + if prompt_type not in ["alert", "confirm", "prompt"]: + raise TypeError("Invalid dialog type: {}".format(prompt_type)) + + # No need to call resolve() because opening a prompt stops the script + self.marionette.execute_async_script( + """ + window.return_value = null; + window.return_value = window[arguments[0]](arguments[1]); + """, + script_args=(prompt_type, text), + ) + + if expected_notify: + with self.assertRaises(errors.UnexpectedAlertOpen): + self.marionette.title + # Bug 1469752 - WebDriverError misses optional data property + # self.assertEqual(ex.data.text, text) + else: + self.marionette.title + + self.assertEqual(self.alert_present, not expected_close) + + # Close an expected left-over user prompt + if not expected_close: + alert = self.marionette.switch_to_alert() + alert.dismiss() + + else: + prompt_result = self.marionette.execute_script( + "return window.return_value", new_sandbox=False + ) + self.assertEqual(prompt_result, expected_result) + + @parameterized("alert", "alert", None) + @parameterized("confirm", "confirm", True) + @parameterized("prompt", "prompt", "") + def test_accept(self, prompt_type, result): + self.marionette.start_session({"unhandledPromptBehavior": "accept"}) + self.perform_user_prompt_check( + prompt_type, "foo {}".format(prompt_type), result, expected_notify=False + ) + + @parameterized("alert", "alert", None) + @parameterized("confirm", "confirm", True) + @parameterized("prompt", "prompt", "") + def test_accept_and_notify(self, prompt_type, result): + self.marionette.start_session({"unhandledPromptBehavior": "accept and notify"}) + self.perform_user_prompt_check( + prompt_type, "foo {}".format(prompt_type), result + ) + + @parameterized("alert", "alert", None) + @parameterized("confirm", "confirm", False) + @parameterized("prompt", "prompt", None) + def test_dismiss(self, prompt_type, result): + self.marionette.start_session({"unhandledPromptBehavior": "dismiss"}) + self.perform_user_prompt_check( + prompt_type, "foo {}".format(prompt_type), result, expected_notify=False + ) + + @parameterized("alert", "alert", None) + @parameterized("confirm", "confirm", False) + @parameterized("prompt", "prompt", None) + def test_dismiss_and_notify(self, prompt_type, result): + self.marionette.start_session({"unhandledPromptBehavior": "dismiss and notify"}) + self.perform_user_prompt_check( + prompt_type, "foo {}".format(prompt_type), result + ) + + @parameterized("alert", "alert", None) + @parameterized("confirm", "confirm", None) + @parameterized("prompt", "prompt", None) + def test_ignore(self, prompt_type, result): + self.marionette.start_session({"unhandledPromptBehavior": "ignore"}) + self.perform_user_prompt_check( + prompt_type, "foo {}".format(prompt_type), result, expected_close=False + ) + + @parameterized("alert", "alert", None) + @parameterized("confirm", "confirm", False) + @parameterized("prompt", "prompt", None) + def test_default(self, prompt_type, result): + self.marionette.start_session({}) + self.perform_user_prompt_check( + prompt_type, "foo {}".format(prompt_type), result + ) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_visibility.py b/testing/marionette/harness/marionette_harness/tests/unit/test_visibility.py new file mode 100644 index 0000000000..fbe45d618b --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_visibility.py @@ -0,0 +1,177 @@ +# 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/. + +from __future__ import absolute_import + +from six.moves.urllib.parse import quote + +from marionette_driver.by import By + +from marionette_harness import MarionetteTestCase + + +def inline(doc): + return "data:text/html;charset=utf-8,{}".format(quote(doc)) + + +def element_direction_doc(direction): + return inline( + """ + <meta name="viewport" content="initial-scale=1,width=device-width"> + <style> + .element{{ + position: absolute; + {}: -50px; + background_color: red; + width: 100px; + height: 100px; + }} + </style> + <div class='element'></div>""".format( + direction + ) + ) + + +class TestVisibility(MarionetteTestCase): + def testShouldAllowTheUserToTellIfAnElementIsDisplayedOrNot(self): + test_html = self.marionette.absolute_url("visibility.html") + self.marionette.navigate(test_html) + + self.assertTrue(self.marionette.find_element(By.ID, "displayed").is_displayed()) + self.assertFalse(self.marionette.find_element(By.ID, "none").is_displayed()) + self.assertFalse( + self.marionette.find_element(By.ID, "suppressedParagraph").is_displayed() + ) + self.assertFalse(self.marionette.find_element(By.ID, "hidden").is_displayed()) + + def testVisibilityShouldTakeIntoAccountParentVisibility(self): + test_html = self.marionette.absolute_url("visibility.html") + self.marionette.navigate(test_html) + + childDiv = self.marionette.find_element(By.ID, "hiddenchild") + hiddenLink = self.marionette.find_element(By.ID, "hiddenlink") + + self.assertFalse(childDiv.is_displayed()) + self.assertFalse(hiddenLink.is_displayed()) + + def testShouldCountElementsAsVisibleIfStylePropertyHasBeenSet(self): + test_html = self.marionette.absolute_url("visibility.html") + self.marionette.navigate(test_html) + shown = self.marionette.find_element(By.ID, "visibleSubElement") + self.assertTrue(shown.is_displayed()) + + def testShouldModifyTheVisibilityOfAnElementDynamically(self): + test_html = self.marionette.absolute_url("visibility.html") + self.marionette.navigate(test_html) + element = self.marionette.find_element(By.ID, "hideMe") + self.assertTrue(element.is_displayed()) + element.click() + self.assertFalse(element.is_displayed()) + + def testHiddenInputElementsAreNeverVisible(self): + test_html = self.marionette.absolute_url("visibility.html") + self.marionette.navigate(test_html) + + shown = self.marionette.find_element(By.NAME, "hidden") + + self.assertFalse(shown.is_displayed()) + + def test_elements_not_displayed_with_negative_transform(self): + self.marionette.navigate( + inline( + """ + <div id="y" style="transform: translateY(-200%);">hidden</div> + <div id="x" style="transform: translateX(-200%);">hidden</div> + """ + ) + ) + + element_x = self.marionette.find_element(By.ID, "x") + self.assertFalse(element_x.is_displayed()) + element_y = self.marionette.find_element(By.ID, "y") + self.assertFalse(element_y.is_displayed()) + + def test_elements_not_displayed_with_parents_having_negative_transform(self): + self.marionette.navigate( + inline( + """ + <div style="transform: translateY(-200%);"><p id="y">hidden</p></div> + <div style="transform: translateX(-200%);"><p id="x">hidden</p></div> + """ + ) + ) + + element_x = self.marionette.find_element(By.ID, "x") + self.assertFalse(element_x.is_displayed()) + element_y = self.marionette.find_element(By.ID, "y") + self.assertFalse(element_y.is_displayed()) + + def test_element_displayed_with_zero_transform(self): + self.marionette.navigate( + inline( + """ + <div style="transform: translate(0px, 0px);">not hidden</div> + """ + ) + ) + element = self.marionette.find_element(By.TAG_NAME, "div") + self.assertTrue(element.is_displayed()) + + def test_element_displayed_with_negative_transform_but_in_viewport(self): + self.marionette.navigate( + inline( + """ + <div style="margin-top: 1em; transform: translateY(-75%);">not hidden</div> + """ + ) + ) + element = self.marionette.find_element(By.TAG_NAME, "div") + self.assertTrue(element.is_displayed()) + + def testShouldSayElementIsInvisibleWhenOverflowXIsHiddenAndOutOfViewport(self): + test_html = self.marionette.absolute_url("bug814037.html") + self.marionette.navigate(test_html) + overflow_x = self.marionette.find_element(By.ID, "assertMe2") + self.assertFalse(overflow_x.is_displayed()) + + def testShouldShowElementNotVisibleWithHiddenAttribute(self): + self.marionette.navigate( + inline( + """ + <p hidden>foo</p> + """ + ) + ) + singleHidden = self.marionette.find_element(By.TAG_NAME, "p") + self.assertFalse(singleHidden.is_displayed()) + + def testShouldShowElementNotVisibleWhenParentElementHasHiddenAttribute(self): + self.marionette.navigate( + inline( + """ + <div hidden> + <p>foo</p> + </div> + """ + ) + ) + child = self.marionette.find_element(By.TAG_NAME, "p") + self.assertFalse(child.is_displayed()) + + def testShouldClickOnELementPartiallyOffLeft(self): + test_html = self.marionette.navigate(element_direction_doc("left")) + self.marionette.find_element(By.CSS_SELECTOR, ".element").click() + + def testShouldClickOnELementPartiallyOffRight(self): + test_html = self.marionette.navigate(element_direction_doc("right")) + self.marionette.find_element(By.CSS_SELECTOR, ".element").click() + + def testShouldClickOnELementPartiallyOffTop(self): + test_html = self.marionette.navigate(element_direction_doc("top")) + self.marionette.find_element(By.CSS_SELECTOR, ".element").click() + + def testShouldClickOnELementPartiallyOffBottom(self): + test_html = self.marionette.navigate(element_direction_doc("bottom")) + self.marionette.find_element(By.CSS_SELECTOR, ".element").click() diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_wait.py b/testing/marionette/harness/marionette_harness/tests/unit/test_wait.py new file mode 100644 index 0000000000..fc8312b9dc --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_wait.py @@ -0,0 +1,349 @@ +# 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/. + +from __future__ import absolute_import + +import sys +import time + +import six + +from marionette_driver import errors, wait +from marionette_driver.wait import Wait + +from marionette_harness import MarionetteTestCase + + +class TickingClock(object): + def __init__(self, incr=1): + self.ticks = 0 + self.increment = incr + + def sleep(self, dur=None): + dur = dur if dur is not None else self.increment + self.ticks += dur + + @property + def now(self): + return self.ticks + + +class SequenceClock(object): + def __init__(self, times): + self.times = times + self.i = 0 + + @property + def now(self): + if len(self.times) > self.i: + self.i += 1 + return self.times[self.i - 1] + + def sleep(self, dur): + pass + + +class MockMarionette(object): + def __init__(self): + self.waited = 0 + + def exception(self, e=None, wait=1): + self.wait() + if self.waited == wait: + if e is None: + e = Exception + raise e + + def true(self, wait=1): + self.wait() + if self.waited == wait: + return True + return None + + def false(self, wait=1): + self.wait() + return False + + def none(self, wait=1): + self.wait() + return None + + def value(self, value, wait=1): + self.wait() + if self.waited == wait: + return value + return None + + def wait(self): + self.waited += 1 + + +def at_third_attempt(clock, end): + return clock.now == 2 + + +def now(clock, end): + return True + + +class SystemClockTest(MarionetteTestCase): + def setUp(self): + super(SystemClockTest, self).setUp() + self.clock = wait.SystemClock() + + def test_construction_initializes_time(self): + self.assertEqual(self.clock._time, time) + + def test_sleep(self): + start = time.time() + self.clock.sleep(0.1) + end = time.time() - start + self.assertGreater(end, 0) + + def test_time_now(self): + self.assertIsNotNone(self.clock.now) + + +class FormalWaitTest(MarionetteTestCase): + def setUp(self): + super(FormalWaitTest, self).setUp() + self.m = MockMarionette() + self.m.timeout = 123 + + def test_construction_with_custom_timeout(self): + wt = Wait(self.m, timeout=42) + self.assertEqual(wt.timeout, 42) + + def test_construction_with_custom_interval(self): + wt = Wait(self.m, interval=42) + self.assertEqual(wt.interval, 42) + + def test_construction_with_custom_clock(self): + c = TickingClock(1) + wt = Wait(self.m, clock=c) + self.assertEqual(wt.clock, c) + + def test_construction_with_custom_exception(self): + wt = Wait(self.m, ignored_exceptions=Exception) + self.assertIn(Exception, wt.exceptions) + self.assertEqual(len(wt.exceptions), 1) + + def test_construction_with_custom_exception_list(self): + exc = [Exception, ValueError] + wt = Wait(self.m, ignored_exceptions=exc) + for e in exc: + self.assertIn(e, wt.exceptions) + self.assertEqual(len(wt.exceptions), len(exc)) + + def test_construction_with_custom_exception_tuple(self): + exc = (Exception, ValueError) + wt = Wait(self.m, ignored_exceptions=exc) + for e in exc: + self.assertIn(e, wt.exceptions) + self.assertEqual(len(wt.exceptions), len(exc)) + + def test_duplicate_exceptions(self): + wt = Wait(self.m, ignored_exceptions=[Exception, Exception]) + self.assertIn(Exception, wt.exceptions) + self.assertEqual(len(wt.exceptions), 1) + + def test_default_timeout(self): + self.assertEqual(wait.DEFAULT_TIMEOUT, 5) + + def test_default_interval(self): + self.assertEqual(wait.DEFAULT_INTERVAL, 0.1) + + def test_end_property(self): + wt = Wait(self.m) + self.assertIsNotNone(wt.end) + + def test_marionette_property(self): + wt = Wait(self.m) + self.assertEqual(wt.marionette, self.m) + + def test_clock_property(self): + wt = Wait(self.m) + self.assertIsInstance(wt.clock, wait.SystemClock) + + def test_timeout_uses_default_if_marionette_timeout_is_none(self): + self.m.timeout = None + wt = Wait(self.m) + self.assertEqual(wt.timeout, wait.DEFAULT_TIMEOUT) + + +class PredicatesTest(MarionetteTestCase): + def test_until(self): + c = wait.SystemClock() + self.assertFalse(wait.until_pred(c, six.MAXSIZE)) + self.assertTrue(wait.until_pred(c, 0)) + + +class WaitUntilTest(MarionetteTestCase): + def setUp(self): + super(WaitUntilTest, self).setUp() + + self.m = MockMarionette() + self.clock = TickingClock() + self.wt = Wait(self.m, timeout=10, interval=1, clock=self.clock) + + def test_true(self): + r = self.wt.until(lambda x: x.true()) + self.assertTrue(r) + self.assertEqual(self.clock.ticks, 0) + + def test_true_within_timeout(self): + r = self.wt.until(lambda x: x.true(wait=5)) + self.assertTrue(r) + self.assertEqual(self.clock.ticks, 4) + + def test_timeout(self): + with self.assertRaises(errors.TimeoutException): + r = self.wt.until(lambda x: x.true(wait=15)) + self.assertEqual(self.clock.ticks, 10) + + def test_exception_raises_immediately(self): + with self.assertRaises(TypeError): + self.wt.until(lambda x: x.exception(e=TypeError)) + self.assertEqual(self.clock.ticks, 0) + + def test_ignored_exception(self): + self.wt.exceptions = (TypeError,) + with self.assertRaises(errors.TimeoutException): + self.wt.until(lambda x: x.exception(e=TypeError)) + + def test_ignored_exception_wrapped_in_timeoutexception(self): + self.wt.exceptions = (TypeError,) + + exc = None + try: + self.wt.until(lambda x: x.exception(e=TypeError)) + except Exception as e: + exc = e + + s = str(exc) + self.assertIsNotNone(exc) + self.assertIsInstance(exc, errors.TimeoutException) + self.assertIn(", caused by {0!r}".format(TypeError), s) + self.assertIn("self.wt.until(lambda x: x.exception(e=TypeError))", s) + + def test_ignored_exception_after_timeout_is_not_raised(self): + with self.assertRaises(errors.TimeoutException): + r = self.wt.until(lambda x: x.exception(wait=15)) + self.assertEqual(self.clock.ticks, 10) + + def test_keyboard_interrupt(self): + with self.assertRaises(KeyboardInterrupt): + self.wt.until(lambda x: x.exception(e=KeyboardInterrupt)) + + def test_system_exit(self): + with self.assertRaises(SystemExit): + self.wt.until(lambda x: x.exception(SystemExit)) + + def test_true_condition_returns_immediately(self): + r = self.wt.until(lambda x: x.true()) + self.assertIsInstance(r, bool) + self.assertTrue(r) + self.assertEqual(self.clock.ticks, 0) + + def test_value(self): + r = self.wt.until(lambda x: "foo") + self.assertEqual(r, "foo") + self.assertEqual(self.clock.ticks, 0) + + def test_custom_predicate(self): + r = self.wt.until(lambda x: x.true(wait=2), is_true=at_third_attempt) + self.assertTrue(r) + self.assertEqual(self.clock.ticks, 1) + + def test_custom_predicate_times_out(self): + with self.assertRaises(errors.TimeoutException): + self.wt.until(lambda x: x.true(wait=4), is_true=at_third_attempt) + + self.assertEqual(self.clock.ticks, 2) + + def test_timeout_elapsed_duration(self): + with self.assertRaisesRegexp( + errors.TimeoutException, "Timed out after 2.0 seconds" + ): + self.wt.until(lambda x: x.true(wait=4), is_true=at_third_attempt) + + def test_timeout_elapsed_rounding(self): + wt = Wait(self.m, clock=SequenceClock([1, 0.01, 1]), timeout=0) + with self.assertRaisesRegexp( + errors.TimeoutException, "Timed out after 1.0 seconds" + ): + wt.until(lambda x: x.true(), is_true=now) + + def test_timeout_elapsed_interval_by_delayed_condition_return(self): + def callback(mn): + self.clock.sleep(11) + return mn.false() + + with self.assertRaisesRegexp( + errors.TimeoutException, "Timed out after 11.0 seconds" + ): + self.wt.until(callback) + # With a delayed conditional return > timeout, only 1 iteration is + # possible + self.assertEqual(self.m.waited, 1) + + def test_timeout_with_delayed_condition_return(self): + def callback(mn): + self.clock.sleep(0.5) + return mn.false() + + with self.assertRaisesRegexp( + errors.TimeoutException, "Timed out after 10.0 seconds" + ): + self.wt.until(callback) + # With a delayed conditional return < interval, 10 iterations should be + # possible + self.assertEqual(self.m.waited, 10) + + def test_timeout_interval_shorter_than_delayed_condition_return(self): + def callback(mn): + self.clock.sleep(2) + return mn.false() + + with self.assertRaisesRegexp( + errors.TimeoutException, "Timed out after 10.0 seconds" + ): + self.wt.until(callback) + # With a delayed return of the conditional which takes twice that long than the interval, + # half of the iterations should be possible + self.assertEqual(self.m.waited, 5) + + def test_message(self): + self.wt.exceptions = (TypeError,) + exc = None + try: + self.wt.until(lambda x: x.exception(e=TypeError), message="hooba") + except errors.TimeoutException as e: + exc = e + + result = str(exc) + self.assertIn("seconds with message: hooba, caused by", result) + + def test_no_message(self): + self.wt.exceptions = (TypeError,) + exc = None + try: + self.wt.until(lambda x: x.exception(e=TypeError), message="") + except errors.TimeoutException as e: + exc = e + + result = str(exc) + self.assertIn("seconds, caused by", result) + + def test_message_has_none_as_its_value(self): + self.wt.exceptions = (TypeError,) + exc = None + try: + self.wt.until(False, None, None) + except errors.TimeoutException as e: + exc = e + + result = str(exc) + self.assertNotIn("with message:", result) + self.assertNotIn("secondsNone", result) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_window_close_chrome.py b/testing/marionette/harness/marionette_harness/tests/unit/test_window_close_chrome.py new file mode 100644 index 0000000000..d38e456737 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_window_close_chrome.py @@ -0,0 +1,75 @@ +# 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/. + +from __future__ import absolute_import + +from marionette_harness import MarionetteTestCase, WindowManagerMixin + + +class TestCloseWindow(WindowManagerMixin, MarionetteTestCase): + def setUp(self): + super(TestCloseWindow, self).setUp() + + self.marionette.set_context("chrome") + + def tearDown(self): + self.close_all_windows() + self.close_all_tabs() + + super(TestCloseWindow, self).tearDown() + + def test_close_chrome_window_for_browser_window(self): + new_window = self.open_window() + self.marionette.switch_to_window(new_window) + + self.assertNotIn(new_window, self.marionette.window_handles) + chrome_window_handles = self.marionette.close_chrome_window() + self.assertNotIn(new_window, chrome_window_handles) + self.assertListEqual(self.start_windows, chrome_window_handles) + self.assertNotIn(new_window, self.marionette.window_handles) + + def test_close_chrome_window_for_non_browser_window(self): + win = self.open_chrome_window("chrome://marionette/content/test.xhtml") + self.marionette.switch_to_window(win) + + self.assertIn(win, self.marionette.chrome_window_handles) + self.assertNotIn(win, self.marionette.window_handles) + chrome_window_handles = self.marionette.close_chrome_window() + self.assertNotIn(win, chrome_window_handles) + self.assertListEqual(self.start_windows, chrome_window_handles) + self.assertNotIn(win, self.marionette.chrome_window_handles) + + def test_close_chrome_window_for_last_open_window(self): + self.close_all_windows() + + self.assertListEqual([], self.marionette.close_chrome_window()) + self.assertListEqual([self.start_tab], self.marionette.window_handles) + self.assertListEqual([self.start_window], self.marionette.chrome_window_handles) + self.assertIsNotNone(self.marionette.session) + + def test_close_window_for_browser_tab(self): + new_tab = self.open_tab() + self.marionette.switch_to_window(new_tab) + + window_handles = self.marionette.close() + self.assertNotIn(new_tab, window_handles) + self.assertListEqual(self.start_tabs, window_handles) + + def test_close_window_for_browser_window_with_single_tab(self): + new_window = self.open_window() + self.marionette.switch_to_window(new_window) + + self.assertEqual(len(self.start_tabs) + 1, len(self.marionette.window_handles)) + window_handles = self.marionette.close() + self.assertNotIn(new_window, window_handles) + self.assertListEqual(self.start_tabs, window_handles) + self.assertListEqual(self.start_windows, self.marionette.chrome_window_handles) + + def test_close_window_for_last_open_tab(self): + self.close_all_tabs() + + self.assertListEqual([], self.marionette.close()) + self.assertListEqual([self.start_tab], self.marionette.window_handles) + self.assertListEqual([self.start_window], self.marionette.chrome_window_handles) + self.assertIsNotNone(self.marionette.session) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_window_close_content.py b/testing/marionette/harness/marionette_harness/tests/unit/test_window_close_content.py new file mode 100644 index 0000000000..cca97f39e6 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_window_close_content.py @@ -0,0 +1,127 @@ +# 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/. + +from __future__ import absolute_import + +from six.moves.urllib.parse import quote + +from marionette_driver.by import By +from marionette_harness import MarionetteTestCase, WindowManagerMixin + + +def inline(doc): + return "data:text/html;charset=utf-8,{}".format(quote(doc)) + + +class TestCloseWindow(WindowManagerMixin, MarionetteTestCase): + def tearDown(self): + self.close_all_windows() + self.close_all_tabs() + + super(TestCloseWindow, self).tearDown() + + def test_close_chrome_window_for_browser_window(self): + with self.marionette.using_context("chrome"): + new_window = self.open_window() + self.marionette.switch_to_window(new_window) + + self.assertIn(new_window, self.marionette.chrome_window_handles) + chrome_window_handles = self.marionette.close_chrome_window() + self.assertNotIn(new_window, chrome_window_handles) + self.assertListEqual(self.start_windows, chrome_window_handles) + self.assertNotIn(new_window, self.marionette.window_handles) + + def test_close_chrome_window_for_non_browser_window(self): + new_window = self.open_chrome_window("chrome://marionette/content/test.xhtml") + self.marionette.switch_to_window(new_window) + + self.assertIn(new_window, self.marionette.chrome_window_handles) + self.assertNotIn(new_window, self.marionette.window_handles) + chrome_window_handles = self.marionette.close_chrome_window() + self.assertNotIn(new_window, chrome_window_handles) + self.assertListEqual(self.start_windows, chrome_window_handles) + self.assertNotIn(new_window, self.marionette.window_handles) + + def test_close_chrome_window_for_last_open_window(self): + self.close_all_windows() + + self.assertListEqual([], self.marionette.close_chrome_window()) + self.assertListEqual([self.start_tab], self.marionette.window_handles) + self.assertListEqual([self.start_window], self.marionette.chrome_window_handles) + self.assertIsNotNone(self.marionette.session) + + def test_close_window_for_browser_tab(self): + new_tab = self.open_tab() + self.marionette.switch_to_window(new_tab) + + window_handles = self.marionette.close() + self.assertNotIn(new_tab, window_handles) + self.assertListEqual(self.start_tabs, window_handles) + + def test_close_window_with_dismissed_beforeunload_prompt(self): + new_tab = self.open_tab() + self.marionette.switch_to_window(new_tab) + + self.marionette.navigate( + inline( + """ + <input type="text"> + <script> + window.addEventListener("beforeunload", function (event) { + event.preventDefault(); + }); + </script> + """ + ) + ) + + self.marionette.find_element(By.TAG_NAME, "input").send_keys("foo") + self.marionette.close() + + def test_close_window_for_browser_window_with_single_tab(self): + new_tab = self.open_window() + self.marionette.switch_to_window(new_tab) + + self.assertEqual(len(self.marionette.window_handles), len(self.start_tabs) + 1) + window_handles = self.marionette.close() + self.assertNotIn(new_tab, window_handles) + self.assertListEqual(self.start_tabs, window_handles) + self.assertListEqual(self.start_windows, self.marionette.chrome_window_handles) + + def test_close_window_for_last_open_tab(self): + self.close_all_tabs() + + self.assertListEqual([], self.marionette.close()) + self.assertListEqual([self.start_tab], self.marionette.window_handles) + self.assertListEqual([self.start_window], self.marionette.chrome_window_handles) + self.assertIsNotNone(self.marionette.session) + + def test_close_browserless_tab(self): + self.close_all_tabs() + + test_page = self.marionette.absolute_url("windowHandles.html") + new_tab = self.open_tab() + self.marionette.switch_to_window(new_tab) + self.marionette.navigate(test_page) + self.marionette.switch_to_window(self.start_tab) + + with self.marionette.using_context("chrome"): + self.marionette.execute_async_script( + """ + Components.utils.import("resource:///modules/BrowserWindowTracker.jsm"); + + let win = BrowserWindowTracker.getTopWindow(); + win.addEventListener("TabBrowserDiscarded", ev => { + arguments[0](true); + }, { once: true}); + win.gBrowser.discardBrowser(win.gBrowser.tabs[1]); + """ + ) + + window_handles = self.marionette.window_handles + window_handles.remove(self.start_tab) + self.assertEqual(1, len(window_handles)) + self.marionette.switch_to_window(window_handles[0], focus=False) + self.marionette.close() + self.assertListEqual([self.start_tab], self.marionette.window_handles) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_window_handles_chrome.py b/testing/marionette/harness/marionette_harness/tests/unit/test_window_handles_chrome.py new file mode 100644 index 0000000000..bb5ab6091d --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_window_handles_chrome.py @@ -0,0 +1,255 @@ +# 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/. + +from __future__ import absolute_import + +import six + +from marionette_driver import errors +from marionette_harness import MarionetteTestCase, WindowManagerMixin + + +class TestWindowHandles(WindowManagerMixin, MarionetteTestCase): + def setUp(self): + super(TestWindowHandles, self).setUp() + + self.chrome_dialog = "chrome://marionette/content/test_dialog.xhtml" + + self.marionette.set_context("chrome") + + def tearDown(self): + self.close_all_windows() + self.close_all_tabs() + + super(TestWindowHandles, self).tearDown() + + def assert_window_handles(self): + try: + self.assertIsInstance( + self.marionette.current_chrome_window_handle, six.string_types + ) + self.assertIsInstance( + self.marionette.current_window_handle, six.string_types + ) + except errors.NoSuchWindowException: + pass + + for handle in self.marionette.chrome_window_handles: + self.assertIsInstance(handle, six.string_types) + + for handle in self.marionette.window_handles: + self.assertIsInstance(handle, six.string_types) + + def test_chrome_window_handles_with_scopes(self): + new_browser = self.open_window() + self.assert_window_handles() + self.assertEqual( + len(self.marionette.chrome_window_handles), len(self.start_windows) + 1 + ) + self.assertIn(new_browser, self.marionette.chrome_window_handles) + self.assertEqual( + self.marionette.current_chrome_window_handle, self.start_window + ) + + new_dialog = self.open_chrome_window(self.chrome_dialog) + self.assert_window_handles() + self.assertEqual( + len(self.marionette.chrome_window_handles), len(self.start_windows) + 2 + ) + self.assertIn(new_dialog, self.marionette.chrome_window_handles) + self.assertEqual( + self.marionette.current_chrome_window_handle, self.start_window + ) + + chrome_window_handles_in_chrome_scope = self.marionette.chrome_window_handles + window_handles_in_chrome_scope = self.marionette.window_handles + + with self.marionette.using_context("content"): + self.assertEqual( + self.marionette.chrome_window_handles, + chrome_window_handles_in_chrome_scope, + ) + self.assertEqual( + self.marionette.window_handles, window_handles_in_chrome_scope + ) + + def test_chrome_window_handles_after_opening_new_chrome_window(self): + new_window = self.open_chrome_window(self.chrome_dialog) + self.assert_window_handles() + self.assertEqual( + len(self.marionette.chrome_window_handles), len(self.start_windows) + 1 + ) + self.assertIn(new_window, self.marionette.chrome_window_handles) + self.assertEqual( + self.marionette.current_chrome_window_handle, self.start_window + ) + + # Check that the new chrome window has the correct URL loaded + self.marionette.switch_to_window(new_window) + self.assert_window_handles() + self.assertEqual(self.marionette.current_chrome_window_handle, new_window) + self.assertEqual(self.marionette.get_url(), self.chrome_dialog) + + # Close the chrome window, and carry on in our original window. + self.marionette.close_chrome_window() + self.assert_window_handles() + self.assertEqual( + len(self.marionette.chrome_window_handles), len(self.start_windows) + ) + self.assertNotIn(new_window, self.marionette.chrome_window_handles) + + self.marionette.switch_to_window(self.start_window) + self.assert_window_handles() + self.assertEqual( + self.marionette.current_chrome_window_handle, self.start_window + ) + + def test_chrome_window_handles_after_opening_new_window(self): + new_window = self.open_window() + self.assert_window_handles() + self.assertEqual( + len(self.marionette.chrome_window_handles), len(self.start_windows) + 1 + ) + self.assertIn(new_window, self.marionette.chrome_window_handles) + self.assertEqual( + self.marionette.current_chrome_window_handle, self.start_window + ) + + self.marionette.switch_to_window(new_window) + self.assert_window_handles() + self.assertEqual(self.marionette.current_chrome_window_handle, new_window) + + # Close the opened window and carry on in our original window. + self.marionette.close() + self.assert_window_handles() + self.assertEqual( + len(self.marionette.chrome_window_handles), len(self.start_windows) + ) + self.assertNotIn(new_window, self.marionette.chrome_window_handles) + + self.marionette.switch_to_window(self.start_window) + self.assert_window_handles() + self.assertEqual( + self.marionette.current_chrome_window_handle, self.start_window + ) + + def test_chrome_window_handles_after_session_created(self): + new_window = self.open_chrome_window(self.chrome_dialog) + self.assert_window_handles() + self.assertEqual( + len(self.marionette.chrome_window_handles), len(self.start_windows) + 1 + ) + self.assertIn(new_window, self.marionette.chrome_window_handles) + self.assertEqual( + self.marionette.current_chrome_window_handle, self.start_window + ) + + chrome_window_handles = self.marionette.chrome_window_handles + + self.marionette.delete_session() + self.marionette.start_session() + + self.assert_window_handles() + self.assertEqual(chrome_window_handles, self.marionette.chrome_window_handles) + + self.marionette.switch_to_window(new_window) + + def test_window_handles_after_opening_new_tab(self): + with self.marionette.using_context("content"): + new_tab = self.open_tab() + self.assert_window_handles() + self.assertEqual(len(self.marionette.window_handles), len(self.start_tabs) + 1) + self.assertIn(new_tab, self.marionette.window_handles) + self.assertEqual(self.marionette.current_window_handle, self.start_tab) + + self.marionette.switch_to_window(new_tab) + self.assert_window_handles() + self.assertEqual(self.marionette.current_window_handle, new_tab) + + self.marionette.switch_to_window(self.start_tab) + self.assert_window_handles() + self.assertEqual(self.marionette.current_window_handle, self.start_tab) + + self.marionette.switch_to_window(new_tab) + self.marionette.close() + self.assert_window_handles() + self.assertEqual(len(self.marionette.window_handles), len(self.start_tabs)) + self.assertNotIn(new_tab, self.marionette.window_handles) + + self.marionette.switch_to_window(self.start_tab) + self.assert_window_handles() + self.assertEqual(self.marionette.current_window_handle, self.start_tab) + + def test_window_handles_after_opening_new_foreground_tab(self): + with self.marionette.using_context("content"): + new_tab = self.open_tab(focus=True) + self.assert_window_handles() + self.assertEqual(len(self.marionette.window_handles), len(self.start_tabs) + 1) + self.assertIn(new_tab, self.marionette.window_handles) + self.assertEqual(self.marionette.current_window_handle, self.start_tab) + + # We still have the default tab set as our window handle. This + # get_url command should be sent immediately, and not be forever-queued. + with self.marionette.using_context("content"): + self.marionette.get_url() + + self.marionette.switch_to_window(new_tab) + self.assert_window_handles() + self.assertEqual(self.marionette.current_window_handle, new_tab) + + self.marionette.close() + self.assert_window_handles() + self.assertEqual(len(self.marionette.window_handles), len(self.start_tabs)) + self.assertNotIn(new_tab, self.marionette.window_handles) + + self.marionette.switch_to_window(self.start_tab) + self.assert_window_handles() + self.assertEqual(self.marionette.current_window_handle, self.start_tab) + + def test_window_handles_after_opening_new_chrome_window(self): + new_window = self.open_chrome_window(self.chrome_dialog) + self.assert_window_handles() + self.assertEqual(len(self.marionette.window_handles), len(self.start_tabs)) + self.assertNotIn(new_window, self.marionette.window_handles) + self.assertEqual(self.marionette.current_window_handle, self.start_tab) + + self.marionette.switch_to_window(new_window) + self.assert_window_handles() + self.assertEqual(self.marionette.get_url(), self.chrome_dialog) + + # Check that the opened dialog is not accessible via window handles + with self.assertRaises(errors.NoSuchWindowException): + self.marionette.current_window_handle + with self.assertRaises(errors.NoSuchWindowException): + self.marionette.close() + + # Close the dialog and carry on in our original tab. + self.marionette.close_chrome_window() + self.assert_window_handles() + self.assertEqual(len(self.marionette.window_handles), len(self.start_tabs)) + + self.marionette.switch_to_window(self.start_tab) + self.assert_window_handles() + self.assertEqual(self.marionette.current_window_handle, self.start_tab) + + def test_window_handles_after_closing_original_tab(self): + with self.marionette.using_context("content"): + new_tab = self.open_tab() + self.assert_window_handles() + self.assertEqual(len(self.marionette.window_handles), len(self.start_tabs) + 1) + self.assertIn(new_tab, self.marionette.window_handles) + self.assertEqual(self.marionette.current_window_handle, self.start_tab) + + self.marionette.close() + self.assert_window_handles() + self.assertEqual(len(self.marionette.window_handles), len(self.start_tabs)) + self.assertIn(new_tab, self.marionette.window_handles) + + self.marionette.switch_to_window(new_tab) + self.assert_window_handles() + self.assertEqual(self.marionette.current_window_handle, new_tab) + + def test_window_handles_after_closing_last_window(self): + self.close_all_windows() + self.assertEqual(self.marionette.close_chrome_window(), []) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_window_handles_content.py b/testing/marionette/harness/marionette_harness/tests/unit/test_window_handles_content.py new file mode 100644 index 0000000000..08f0641131 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_window_handles_content.py @@ -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/. + +from __future__ import absolute_import + +import six +from six.moves.urllib.parse import quote + +from marionette_driver import errors +from marionette_harness import MarionetteTestCase, WindowManagerMixin + + +def inline(doc): + return "data:text/html;charset=utf-8,{}".format(quote(doc)) + + +class TestWindowHandles(WindowManagerMixin, MarionetteTestCase): + def setUp(self): + super(TestWindowHandles, self).setUp() + + self.chrome_dialog = "chrome://marionette/content/test_dialog.xhtml" + + def tearDown(self): + self.close_all_windows() + self.close_all_tabs() + + super(TestWindowHandles, self).tearDown() + + def assert_window_handles(self): + try: + self.assertIsInstance( + self.marionette.current_window_handle, six.string_types + ) + except errors.NoSuchWindowException: + pass + + for handle in self.marionette.window_handles: + self.assertIsInstance(handle, six.string_types) + + def test_window_handles_after_opening_new_tab(self): + new_tab = self.open_tab() + self.assert_window_handles() + self.assertEqual(len(self.marionette.window_handles), len(self.start_tabs) + 1) + self.assertEqual(self.marionette.current_window_handle, self.start_tab) + + self.marionette.switch_to_window(new_tab) + self.assert_window_handles() + self.assertEqual(self.marionette.current_window_handle, new_tab) + + self.marionette.switch_to_window(self.start_tab) + self.assertEqual(self.marionette.current_window_handle, self.start_tab) + + self.marionette.switch_to_window(new_tab) + self.marionette.close() + self.assert_window_handles() + self.assertEqual(len(self.marionette.window_handles), len(self.start_tabs)) + + self.marionette.switch_to_window(self.start_tab) + self.assert_window_handles() + self.assertEqual(self.marionette.current_window_handle, self.start_tab) + + def test_window_handles_after_opening_new_browser_window(self): + new_tab = self.open_window() + self.assert_window_handles() + self.assertEqual(len(self.marionette.window_handles), len(self.start_tabs) + 1) + self.assertEqual(self.marionette.current_window_handle, self.start_tab) + + self.marionette.switch_to_window(new_tab) + self.assert_window_handles() + self.assertEqual(self.marionette.current_window_handle, new_tab) + + # Close the opened window and carry on in our original tab. + self.marionette.close() + self.assert_window_handles() + self.assertEqual(len(self.marionette.window_handles), len(self.start_tabs)) + + self.marionette.switch_to_window(self.start_tab) + self.assert_window_handles() + self.assertEqual(self.marionette.current_window_handle, self.start_tab) + + def test_window_handles_after_opening_new_non_browser_window(self): + new_window = self.open_chrome_window(self.chrome_dialog) + self.assert_window_handles() + self.assertEqual(len(self.marionette.window_handles), len(self.start_tabs)) + self.assertEqual(self.marionette.current_window_handle, self.start_tab) + self.assertNotIn(new_window, self.marionette.window_handles) + + self.marionette.switch_to_window(new_window) + self.assert_window_handles() + + # Check that the opened window is not accessible via window handles + with self.assertRaises(errors.NoSuchWindowException): + self.marionette.current_window_handle + with self.assertRaises(errors.NoSuchWindowException): + self.marionette.close() + + # Close the opened window and carry on in our original tab. + self.marionette.close_chrome_window() + self.assert_window_handles() + self.assertEqual(len(self.marionette.window_handles), len(self.start_tabs)) + + self.marionette.switch_to_window(self.start_tab) + self.assert_window_handles() + self.assertEqual(self.marionette.current_window_handle, self.start_tab) + + def test_window_handles_after_session_created(self): + new_window = self.open_chrome_window(self.chrome_dialog) + self.assert_window_handles() + self.assertEqual(len(self.marionette.window_handles), len(self.start_tabs)) + self.assertEqual(self.marionette.current_window_handle, self.start_tab) + self.assertNotIn(new_window, self.marionette.window_handles) + + window_handles = self.marionette.window_handles + + self.marionette.delete_session() + self.marionette.start_session() + + self.assert_window_handles() + self.assertEqual(window_handles, self.marionette.window_handles) + + self.marionette.switch_to_window(new_window) + + def test_window_handles_after_closing_original_tab(self): + new_tab = self.open_tab() + self.assert_window_handles() + self.assertEqual(len(self.marionette.window_handles), len(self.start_tabs) + 1) + self.assertEqual(self.marionette.current_window_handle, self.start_tab) + self.assertIn(new_tab, self.marionette.window_handles) + + self.marionette.close() + self.assert_window_handles() + self.assertEqual(len(self.marionette.window_handles), len(self.start_tabs)) + self.assertNotIn(self.start_tab, self.marionette.window_handles) + + self.marionette.switch_to_window(new_tab) + self.assert_window_handles() + self.assertEqual(self.marionette.current_window_handle, new_tab) + + def test_window_handles_after_closing_last_tab(self): + self.close_all_tabs() + self.assertEqual(self.marionette.close(), []) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_window_management.py b/testing/marionette/harness/marionette_harness/tests/unit/test_window_management.py new file mode 100644 index 0000000000..7bd98a4a72 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_window_management.py @@ -0,0 +1,141 @@ +# 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/. + +from __future__ import absolute_import + +from marionette_driver import By +from marionette_driver.errors import NoSuchWindowException + +from marionette_harness import MarionetteTestCase, WindowManagerMixin + + +class TestNoSuchWindowContent(WindowManagerMixin, MarionetteTestCase): + def setUp(self): + super(TestNoSuchWindowContent, self).setUp() + + def tearDown(self): + self.close_all_tabs() + super(TestNoSuchWindowContent, self).tearDown() + + def test_closed_chrome_window(self): + with self.marionette.using_context("chrome"): + new_window = self.open_window() + self.marionette.switch_to_window(new_window) + self.marionette.close_chrome_window() + + # When closing a browser window both handles are not available + for context in ("chrome", "content"): + with self.marionette.using_context(context): + with self.assertRaises(NoSuchWindowException): + self.marionette.current_chrome_window_handle + with self.assertRaises(NoSuchWindowException): + self.marionette.current_window_handle + + self.marionette.switch_to_window(self.start_window) + + with self.assertRaises(NoSuchWindowException): + self.marionette.switch_to_window(new_window) + + def test_closed_chrome_window_while_in_frame(self): + new_window = self.open_chrome_window("chrome://marionette/content/test.xhtml") + self.marionette.switch_to_window(new_window) + with self.marionette.using_context("chrome"): + self.marionette.switch_to_frame(0) + self.marionette.close_chrome_window() + + with self.assertRaises(NoSuchWindowException): + self.marionette.current_window_handle + with self.assertRaises(NoSuchWindowException): + self.marionette.current_chrome_window_handle + + self.marionette.switch_to_window(self.start_window) + + with self.assertRaises(NoSuchWindowException): + self.marionette.switch_to_window(new_window) + + def test_closed_tab(self): + new_tab = self.open_tab() + self.marionette.switch_to_window(new_tab) + self.marionette.close() + + # Check that only the content window is not available in both contexts + for context in ("chrome", "content"): + with self.marionette.using_context(context): + with self.assertRaises(NoSuchWindowException): + self.marionette.current_window_handle + self.marionette.current_chrome_window_handle + + self.marionette.switch_to_window(self.start_tab) + + with self.assertRaises(NoSuchWindowException): + self.marionette.switch_to_window(new_tab) + + def test_closed_tab_while_in_frame(self): + new_tab = self.open_tab() + self.marionette.switch_to_window(new_tab) + + with self.marionette.using_context("content"): + self.marionette.navigate(self.marionette.absolute_url("test_iframe.html")) + frame = self.marionette.find_element(By.ID, "test_iframe") + self.marionette.switch_to_frame(frame) + self.marionette.close() + + with self.assertRaises(NoSuchWindowException): + self.marionette.current_window_handle + self.marionette.current_chrome_window_handle + + self.marionette.switch_to_window(self.start_tab) + + with self.assertRaises(NoSuchWindowException): + self.marionette.switch_to_window(new_tab) + + +class TestNoSuchWindowChrome(TestNoSuchWindowContent): + def setUp(self): + super(TestNoSuchWindowChrome, self).setUp() + self.marionette.set_context("chrome") + + def tearDown(self): + self.close_all_windows() + super(TestNoSuchWindowChrome, self).tearDown() + + +class TestSwitchWindow(WindowManagerMixin, MarionetteTestCase): + def setUp(self): + super(TestSwitchWindow, self).setUp() + self.marionette.set_context("chrome") + + def tearDown(self): + self.close_all_windows() + super(TestSwitchWindow, self).tearDown() + + def test_switch_window_after_open_and_close(self): + with self.marionette.using_context("chrome"): + new_window = self.open_window() + self.assertEqual( + len(self.marionette.chrome_window_handles), len(self.start_windows) + 1 + ) + self.assertIn(new_window, self.marionette.chrome_window_handles) + self.assertEqual( + self.marionette.current_chrome_window_handle, self.start_window + ) + + # switch to the new chrome window and close it + self.marionette.switch_to_window(new_window) + self.assertEqual(self.marionette.current_chrome_window_handle, new_window) + self.assertNotEqual( + self.marionette.current_chrome_window_handle, self.start_window + ) + + self.marionette.close_chrome_window() + self.assertEqual( + len(self.marionette.chrome_window_handles), len(self.start_windows) + ) + self.assertNotIn(new_window, self.marionette.chrome_window_handles) + + # switch back to the original chrome window + self.marionette.switch_to_window(self.start_window) + self.assertEqual( + self.marionette.current_chrome_window_handle, self.start_window + ) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_window_maximize.py b/testing/marionette/harness/marionette_harness/tests/unit/test_window_maximize.py new file mode 100644 index 0000000000..7def35b932 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_window_maximize.py @@ -0,0 +1,38 @@ +# 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/. + +from __future__ import absolute_import + +from marionette_harness import MarionetteTestCase + + +class TestWindowMaximize(MarionetteTestCase): + def setUp(self): + MarionetteTestCase.setUp(self) + self.max = self.marionette.execute_script( + """ + return { + width: window.screen.availWidth, + height: window.screen.availHeight, + }""", + sandbox=None, + ) + + # ensure window is not maximized + self.marionette.set_window_rect( + width=self.max["width"] - 100, height=self.max["height"] - 100 + ) + actual = self.marionette.window_rect + self.assertNotEqual(actual["width"], self.max["width"]) + self.assertNotEqual(actual["height"], self.max["height"]) + + self.original_size = actual + + def tearDown(self): + self.marionette.set_window_rect( + width=self.original_size["width"], height=self.original_size["height"] + ) + + def test_maximize(self): + self.marionette.maximize_window() diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_window_rect.py b/testing/marionette/harness/marionette_harness/tests/unit/test_window_rect.py new file mode 100644 index 0000000000..844b9ec14a --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_window_rect.py @@ -0,0 +1,317 @@ +# 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/. + +from __future__ import absolute_import, print_function + +from marionette_driver.errors import InvalidArgumentException +from marionette_harness import MarionetteTestCase + + +class TestWindowRect(MarionetteTestCase): + def setUp(self): + super(TestWindowRect, self).setUp() + + self.original_rect = self.marionette.window_rect + + self.max = self.marionette.execute_script( + """ + return { + width: window.screen.availWidth, + height: window.screen.availHeight, + }""", + sandbox=None, + ) + + # WebDriver spec says a resize cannot result in window being + # maximised, an error is returned if that is the case; therefore if + # the window is maximised at the start of this test, returning to + # the original size via set_window_rect size will result in error; + # so reset to original size minus 1 pixel width + start_size = { + "height": self.original_rect["height"], + "width": self.original_rect["width"], + } + if ( + start_size["width"] == self.max["width"] + and start_size["height"] == self.max["height"] + ): + start_size["width"] -= 10 + start_size["height"] -= 10 + self.marionette.set_window_rect( + height=start_size["height"], width=start_size["width"] + ) + + def tearDown(self): + x, y = self.original_rect["x"], self.original_rect["y"] + height, width = self.original_rect["height"], self.original_rect["width"] + + self.marionette.set_window_rect(x=x, y=y, height=height, width=width) + + is_fullscreen = self.marionette.execute_script( + "return document.fullscreenElement;", sandbox=None + ) + if is_fullscreen: + self.marionette.fullscreen() + + super(TestWindowRect, self).tearDown() + + def test_get_types(self): + rect = self.marionette.window_rect + self.assertIn("x", rect) + self.assertIn("y", rect) + self.assertIn("height", rect) + self.assertIn("width", rect) + self.assertIsInstance(rect["x"], int) + self.assertIsInstance(rect["y"], int) + self.assertIsInstance(rect["height"], int) + self.assertIsInstance(rect["width"], int) + + def test_set_types(self): + invalid_rects = ( + ["a", "b", "h", "w"], + [1.2, 3.4, 4.5, 5.6], + [True, False, True, False], + [[], [], [], []], + [{}, {}, {}, {}], + ) + for x, y, h, w in invalid_rects: + print("testing invalid type position ({},{})".format(x, y)) + with self.assertRaises(InvalidArgumentException): + self.marionette.set_window_rect(x=x, y=y, height=h, width=w) + + def test_setting_window_rect_with_nulls_errors(self): + with self.assertRaises(InvalidArgumentException): + self.marionette.set_window_rect(height=None, width=None, x=None, y=None) + + def test_set_position(self): + old_position = self.marionette.window_rect + wanted_position = {"x": old_position["x"] + 10, "y": old_position["y"] + 10} + + new_position = self.marionette.set_window_rect( + x=wanted_position["x"], y=wanted_position["y"] + ) + expected_position = self.marionette.window_rect + + self.assertEqual(new_position["x"], wanted_position["x"]) + self.assertEqual(new_position["y"], wanted_position["y"]) + self.assertEqual(new_position["x"], expected_position["x"]) + self.assertEqual(new_position["y"], expected_position["y"]) + + def test_set_size(self): + old_size = self.marionette.window_rect + wanted_size = { + "height": old_size["height"] - 50, + "width": old_size["width"] - 50, + } + + new_size = self.marionette.set_window_rect( + height=wanted_size["height"], width=wanted_size["width"] + ) + expected_size = self.marionette.window_rect + + self.assertEqual( + new_size["width"], + wanted_size["width"], + "New width is {0} but should be {1}".format( + new_size["width"], wanted_size["width"] + ), + ) + self.assertEqual( + new_size["height"], + wanted_size["height"], + "New height is {0} but should be {1}".format( + new_size["height"], wanted_size["height"] + ), + ) + self.assertEqual( + new_size["width"], + expected_size["width"], + "New width is {0} but should be {1}".format( + new_size["width"], expected_size["width"] + ), + ) + self.assertEqual( + new_size["height"], + expected_size["height"], + "New height is {0} but should be {1}".format( + new_size["height"], expected_size["height"] + ), + ) + + def test_set_position_and_size(self): + old_rect = self.marionette.window_rect + wanted_rect = { + "x": old_rect["x"] + 10, + "y": old_rect["y"] + 10, + "width": old_rect["width"] - 50, + "height": old_rect["height"] - 50, + } + + new_rect = self.marionette.set_window_rect( + x=wanted_rect["x"], + y=wanted_rect["y"], + width=wanted_rect["width"], + height=wanted_rect["height"], + ) + expected_rect = self.marionette.window_rect + + self.assertEqual(new_rect["x"], wanted_rect["x"]) + self.assertEqual(new_rect["y"], wanted_rect["y"]) + self.assertEqual( + new_rect["width"], + wanted_rect["width"], + "New width is {0} but should be {1}".format( + new_rect["width"], wanted_rect["width"] + ), + ) + self.assertEqual( + new_rect["height"], + wanted_rect["height"], + "New height is {0} but should be {1}".format( + new_rect["height"], wanted_rect["height"] + ), + ) + self.assertEqual(new_rect["x"], expected_rect["x"]) + self.assertEqual(new_rect["y"], expected_rect["y"]) + self.assertEqual( + new_rect["width"], + expected_rect["width"], + "New width is {0} but should be {1}".format( + new_rect["width"], expected_rect["width"] + ), + ) + self.assertEqual( + new_rect["height"], + expected_rect["height"], + "New height is {0} but should be {1}".format( + new_rect["height"], expected_rect["height"] + ), + ) + + def test_move_to_current_position(self): + old_position = self.marionette.window_rect + new_position = self.marionette.set_window_rect( + x=old_position["x"], y=old_position["y"] + ) + + self.assertEqual(new_position["x"], old_position["x"]) + self.assertEqual(new_position["y"], old_position["y"]) + + def test_move_to_current_size(self): + old_size = self.marionette.window_rect + new_size = self.marionette.set_window_rect( + height=old_size["height"], width=old_size["width"] + ) + + self.assertEqual(new_size["height"], old_size["height"]) + self.assertEqual(new_size["width"], old_size["width"]) + + def test_move_to_current_position_and_size(self): + old_position_and_size = self.marionette.window_rect + new_position_and_size = self.marionette.set_window_rect( + x=old_position_and_size["x"], + y=old_position_and_size["y"], + height=old_position_and_size["height"], + width=old_position_and_size["width"], + ) + + self.assertEqual(new_position_and_size["x"], old_position_and_size["x"]) + self.assertEqual(new_position_and_size["y"], old_position_and_size["y"]) + self.assertEqual(new_position_and_size["width"], old_position_and_size["width"]) + self.assertEqual( + new_position_and_size["height"], old_position_and_size["height"] + ) + + def test_move_to_negative_coordinates(self): + old_position = self.marionette.window_rect + print("Current position: {}".format(old_position["x"], old_position["y"])) + new_position = self.marionette.set_window_rect(x=-8, y=-8) + print( + "Position after requesting move to negative coordinates: {}, {}".format( + new_position["x"], new_position["y"] + ) + ) + + # Different systems will report more or less than (-8,-8) + # depending on the characteristics of the window manager, since + # the screenX/screenY position measures the chrome boundaries, + # including any WM decorations. + # + # This makes this hard to reliably test across different + # environments. Generally we are happy when calling + # marionette.set_window_position with negative coordinates does + # not throw. + # + # Because we have to cater to an unknown set of environments, + # the following assertions are the most common denominator that + # make this test pass, irregardless of system characteristics. + + os = self.marionette.session_capabilities["platformName"] + + # Regardless of platform, headless always supports being positioned + # off-screen. + if self.marionette.session_capabilities["moz:headless"]: + self.assertEqual(-8, new_position["x"]) + self.assertEqual(-8, new_position["y"]) + + # Certain WMs prohibit windows from being moved off-screen, + # but we don't have this information. It should be safe to + # assume a window can be moved to (0,0) or less. + elif os == "linux": + # certain WMs prohibit windows from being moved off-screen + self.assertLessEqual(new_position["x"], 0) + self.assertLessEqual(new_position["y"], 0) + + # On macOS, windows can only be moved off the screen on the + # horizontal axis. The system menu bar also blocks windows from + # being moved to (0,0). + elif os == "mac": + self.assertEqual(-8, new_position["x"]) + self.assertEqual(23, new_position["y"]) + + # It turns out that Windows is the only platform on which the + # window can be reliably positioned off-screen. + elif os == "windows": + self.assertEqual(-8, new_position["x"]) + self.assertEqual(-8, new_position["y"]) + + def test_resize_larger_than_screen(self): + new_size = self.marionette.set_window_rect( + width=self.max["width"] * 2, height=self.max["height"] * 2 + ) + actual_size = self.marionette.window_rect + + # in X the window size may be greater than the bounds of the screen + self.assertGreaterEqual(new_size["width"], self.max["width"]) + self.assertGreaterEqual(new_size["height"], self.max["height"]) + self.assertEqual(actual_size["width"], new_size["width"]) + self.assertEqual(actual_size["height"], new_size["height"]) + + def test_resize_to_available_screen_size(self): + expected_size = self.marionette.set_window_rect( + width=self.max["width"], height=self.max["height"] + ) + result_size = self.marionette.window_rect + + self.assertGreaterEqual(expected_size["width"], self.max["width"]) + self.assertGreaterEqual(expected_size["height"], self.max["height"]) + self.assertEqual(result_size["width"], expected_size["width"]) + self.assertEqual(result_size["height"], expected_size["height"]) + + def test_resize_while_fullscreen(self): + self.marionette.fullscreen() + expected_size = self.marionette.set_window_rect( + width=self.max["width"] - 100, height=self.max["height"] - 100 + ) + result_size = self.marionette.window_rect + + self.assertTrue( + self.marionette.execute_script( + "return window.fullscreenElement == null", sandbox=None + ) + ) + self.assertEqual(self.max["width"] - 100, expected_size["width"]) + self.assertEqual(self.max["height"] - 100, expected_size["height"]) + self.assertEqual(result_size["width"], expected_size["width"]) + self.assertEqual(result_size["height"], expected_size["height"]) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_window_status_chrome.py b/testing/marionette/harness/marionette_harness/tests/unit/test_window_status_chrome.py new file mode 100644 index 0000000000..17df67ff0b --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_window_status_chrome.py @@ -0,0 +1,25 @@ +# 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/. + +from __future__ import absolute_import + +import os +import sys + +# add this directory to the path +sys.path.append(os.path.dirname(__file__)) + +from test_window_status_content import TestNoSuchWindowContent + + +class TestNoSuchWindowChrome(TestNoSuchWindowContent): + def setUp(self): + super(TestNoSuchWindowChrome, self).setUp() + + self.marionette.set_context("chrome") + + def tearDown(self): + self.close_all_windows() + + super(TestNoSuchWindowChrome, self).tearDown() diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_window_status_content.py b/testing/marionette/harness/marionette_harness/tests/unit/test_window_status_content.py new file mode 100644 index 0000000000..45f4fe4ccc --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_window_status_content.py @@ -0,0 +1,94 @@ +# 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/. + +from __future__ import absolute_import, print_function + +from marionette_driver import By +from marionette_driver.errors import NoSuchWindowException + +from marionette_harness import MarionetteTestCase, WindowManagerMixin + + +class TestNoSuchWindowContent(WindowManagerMixin, MarionetteTestCase): + def setUp(self): + super(TestNoSuchWindowContent, self).setUp() + + def tearDown(self): + self.close_all_windows() + super(TestNoSuchWindowContent, self).tearDown() + + def test_closed_chrome_window(self): + with self.marionette.using_context("chrome"): + new_window = self.open_window() + self.marionette.switch_to_window(new_window) + self.marionette.close_chrome_window() + + # When closing a browser window both handles are not available + for context in ("chrome", "content"): + print("Testing handles with context {}".format(context)) + with self.marionette.using_context(context): + with self.assertRaises(NoSuchWindowException): + self.marionette.current_chrome_window_handle + with self.assertRaises(NoSuchWindowException): + self.marionette.current_window_handle + + self.marionette.switch_to_window(self.start_window) + + with self.assertRaises(NoSuchWindowException): + self.marionette.switch_to_window(new_window) + + def test_closed_chrome_window_while_in_frame(self): + new_window = self.open_chrome_window("chrome://marionette/content/test.xhtml") + self.marionette.switch_to_window(new_window) + + with self.marionette.using_context("chrome"): + self.marionette.switch_to_frame(0) + self.marionette.close_chrome_window() + + with self.assertRaises(NoSuchWindowException): + self.marionette.current_window_handle + with self.assertRaises(NoSuchWindowException): + self.marionette.current_chrome_window_handle + + self.marionette.switch_to_window(self.start_window) + + with self.assertRaises(NoSuchWindowException): + self.marionette.switch_to_window(new_window) + + def test_closed_tab(self): + new_tab = self.open_tab(focus=True) + self.marionette.switch_to_window(new_tab) + self.marionette.close() + + # Check that only the content window is not available in both contexts + for context in ("chrome", "content"): + with self.marionette.using_context(context): + with self.assertRaises(NoSuchWindowException): + self.marionette.current_window_handle + self.marionette.current_chrome_window_handle + + self.marionette.switch_to_window(self.start_tab) + + with self.assertRaises(NoSuchWindowException): + self.marionette.switch_to_window(new_tab) + + def test_closed_tab_while_in_frame(self): + new_tab = self.open_tab() + self.marionette.switch_to_window(new_tab) + + with self.marionette.using_context("content"): + self.marionette.navigate(self.marionette.absolute_url("test_iframe.html")) + frame = self.marionette.find_element(By.ID, "test_iframe") + self.marionette.switch_to_frame(frame) + + self.marionette.close() + + with self.assertRaises(NoSuchWindowException): + self.marionette.current_window_handle + self.marionette.current_chrome_window_handle + + self.marionette.switch_to_window(self.start_tab) + + with self.assertRaises(NoSuchWindowException): + self.marionette.switch_to_window(new_tab) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/test_window_type_chrome.py b/testing/marionette/harness/marionette_harness/tests/unit/test_window_type_chrome.py new file mode 100644 index 0000000000..3320af910f --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/test_window_type_chrome.py @@ -0,0 +1,28 @@ +# 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/. + +from __future__ import absolute_import + +from marionette_harness import MarionetteTestCase, WindowManagerMixin + + +class TestWindowTypeChrome(WindowManagerMixin, MarionetteTestCase): + def setUp(self): + super(TestWindowTypeChrome, self).setUp() + + self.marionette.set_context("chrome") + + def tearDown(self): + self.close_all_windows() + + super(TestWindowTypeChrome, self).tearDown() + + def test_get_window_type(self): + win = self.open_chrome_window("chrome://marionette/content/test.xhtml") + self.marionette.switch_to_window(win) + + window_type = self.marionette.execute_script( + "return window.document.documentElement.getAttribute('windowtype');" + ) + self.assertEqual(window_type, self.marionette.get_window_type()) diff --git a/testing/marionette/harness/marionette_harness/tests/unit/unit-tests.ini b/testing/marionette/harness/marionette_harness/tests/unit/unit-tests.ini new file mode 100644 index 0000000000..b2c31b02aa --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/unit-tests.ini @@ -0,0 +1,106 @@ +[test_marionette.py] +[test_transport.py] +[test_cli_arguments.py] +skip-if = manage_instance == false +[test_geckoinstance.py] +[test_data_driven.py] +[test_session.py] +[test_capabilities.py] +[test_proxy.py] +[test_accessibility.py] +[test_expectedfail.py] +expected = fail +[test_skip_setup.py] +[test_click.py] +[test_click_chrome.py] +[test_checkbox.py] +[test_checkbox_chrome.py] +[test_element_rect.py] +[test_element_rect_chrome.py] +[test_position.py] +[test_rendered_element.py] +[test_chrome_element_css.py] +[test_element_state.py] +[test_element_state_chrome.py] +[test_text.py] +[test_text_chrome.py] + +[test_typing.py] + +[test_execute_async_script.py] +[test_execute_script.py] +[test_element_retrieval.py] +[test_findelement_chrome.py] + +[test_get_current_url_chrome.py] +[test_navigation.py] +[test_timeouts.py] + +[test_switch_frame.py] +[test_switch_frame_chrome.py] +[test_switch_window_chrome.py] +[test_switch_window_content.py] + +[test_pagesource.py] +[test_pagesource_chrome.py] + +[test_visibility.py] +[test_window_handles_chrome.py] +[test_window_handles_content.py] +[test_window_close_chrome.py] +[test_window_close_content.py] +[test_window_rect.py] + +[test_window_maximize.py] +[test_window_status_content.py] +[test_window_status_chrome.py] + +[test_screenshot.py] +[test_cookies.py] +[test_title.py] +[test_title_chrome.py] +[test_window_type_chrome.py] +[test_implicit_waits.py] +[test_wait.py] +[test_expected.py] +[test_date_time_value.py] +[test_screen_orientation.py] +[test_errors.py] + +[test_execute_isolate.py] +[test_click_scrolling.py] +[test_profile_management.py] +skip-if = manage_instance == false || (debug && ((os == 'mac') || (os == 'linux'))) # Bug 1450355 +[test_quit_restart.py] +skip-if = manage_instance == false +[test_context.py] + +[test_modal_dialogs.py] +[test_unhandled_prompt_behavior.py] + +[test_key_actions.py] +[test_mouse_action.py] +[test_chrome_action.py] + +[test_teardown_context_preserved.py] +[test_file_upload.py] +skip-if = os == "win" # http://bugs.python.org/issue14574 + +[test_execute_sandboxes.py] +[test_prefs.py] +[test_prefs_enforce.py] +skip-if = manage_instance == false + +[test_chrome.py] + +[test_addons.py] + +[test_select.py] +[test_crash.py] +skip-if = asan || manage_instance == false +[test_localization.py] + +[test_reftest.py] +skip-if = (os == 'mac' && webrender) # bug 1674411 + +[test_sendkeys_menupopup_chrome.py] diff --git a/testing/marionette/harness/marionette_harness/tests/unit/webextension-invalid.xpi b/testing/marionette/harness/marionette_harness/tests/unit/webextension-invalid.xpi Binary files differnew file mode 100644 index 0000000000..bd1177462e --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/webextension-invalid.xpi diff --git a/testing/marionette/harness/marionette_harness/tests/unit/webextension-signed.xpi b/testing/marionette/harness/marionette_harness/tests/unit/webextension-signed.xpi Binary files differnew file mode 100644 index 0000000000..5363911af1 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/webextension-signed.xpi diff --git a/testing/marionette/harness/marionette_harness/tests/unit/webextension-unsigned.xpi b/testing/marionette/harness/marionette_harness/tests/unit/webextension-unsigned.xpi Binary files differnew file mode 100644 index 0000000000..cf0fad63b5 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/tests/unit/webextension-unsigned.xpi diff --git a/testing/marionette/harness/marionette_harness/www/addons/webextension-signed.xpi b/testing/marionette/harness/marionette_harness/www/addons/webextension-signed.xpi Binary files differnew file mode 100644 index 0000000000..5363911af1 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/addons/webextension-signed.xpi diff --git a/testing/marionette/harness/marionette_harness/www/addons/webextension-unsigned.xpi b/testing/marionette/harness/marionette_harness/www/addons/webextension-unsigned.xpi Binary files differnew file mode 100644 index 0000000000..cf0fad63b5 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/addons/webextension-unsigned.xpi diff --git a/testing/marionette/harness/marionette_harness/www/black.png b/testing/marionette/harness/marionette_harness/www/black.png Binary files differnew file mode 100644 index 0000000000..b62a3a7bc8 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/black.png diff --git a/testing/marionette/harness/marionette_harness/www/bug814037.html b/testing/marionette/harness/marionette_harness/www/bug814037.html new file mode 100644 index 0000000000..47c2968163 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/bug814037.html @@ -0,0 +1,56 @@ +<html> +<head> +<meta name="viewport" content="minimum-scale=1,width=device-width"> +<style> +body { + width: 100%; + margin: 0px; + transition: transform 300ms ease; + overflow-x: hidden; +} + +body.section1 { + transform: translateX(0%); +} + +body.section2 { + transform: translateX(-100%); +} + +section { + width: 100%; + height: 100%; + position: absolute; +} + +#section1 { + left: 0px; +} + +#section2 { + left: 100%; +} +.mypossie { + position:absolute; + left: -1000px; +} +</style> + +</head> + <body class="section1"> + <section id="section1"> + <div id="assertMe1"> + <p>Section 1</p> + </div> + <button id="b1" onclick="var sect = document.getElementsByTagName('body')[0]; sect.classList.add('section2'); sect.classList.remove('section1');">Show section 2</button> + </section> + + <section id="section2"> + <div id="assertMe2"> + <p>Section 2</p> + </div> + <button id="b2" onclick="var sect = document.getElementsByTagName('body')[0]; sect.classList.add('section1'); sect.classList.remove('section2'); ">Show section 1</button> + </section> + <section class='mypossie'>out in left field!</section> + </body> +</html> diff --git a/testing/marionette/harness/marionette_harness/www/click_out_of_bounds_overflow.html b/testing/marionette/harness/marionette_harness/www/click_out_of_bounds_overflow.html new file mode 100644 index 0000000000..f0bee9b469 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/click_out_of_bounds_overflow.html @@ -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/. --> + +<!DOCTYPE html> +<html><head> + <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"> +<body> +<div style="height: 100px; overflow: auto;"> + <table> + <tbody> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td>data</td></tr> + <tr><td><a href="#clicked" id="link">click me</a></td></tr> + </tbody> + </table> +</div> +</body> +</html> diff --git a/testing/marionette/harness/marionette_harness/www/clicks.html b/testing/marionette/harness/marionette_harness/www/clicks.html new file mode 100644 index 0000000000..96e9f55171 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/clicks.html @@ -0,0 +1,57 @@ +<html> +<head> + <!-- 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/. --> + <title>Testing Clicks</title> + + <script> + function addMousedownListener() { + let el = document.getElementById('showbutton'); + + el.addEventListener('mousedown', function (evt) { + evt.target.innerText = evt.button; + }); + } + </script> +</head> + +<body> +<h1>Testing Clicks</h1> + +<div> + <p id="links">Links:</p> + <ul> + <li><a href="test.html">333333</a> + <li><a href="test.html" id="normal">Normal</a> + <li><a href="#" id="anchor">I go to an anchor</a> + <li><a href="addons/webextension-unsigned.xpi" id="install-addon">Install Add-on</a> + </ul> +</div> + +<div> + <p id="js-links">Javascript links:</p> + <ul> + <li>Navigate in history: + <a href="javascript:history.back();" id="history-back">Back</a> + <a href="javascript:history.forward();" id="history-forward">Forward</a> + <li><a href="javascript:window.open('test.html', '_blank')" id="new-window">Open a window</a> + <li><a href="javascript:window.close();" id="close-window">Close tab/window</a> + <li><a id="addbuttonlistener" href="javascript:addMousedownListener();">Click</a> to + add an event listener for: <span style="color: red;" id="showbutton">button click</span> + </ul> +</div> + +<div> + <p id="special">Special:</p> + <select id="option" onclick="window.location = '/slow?delay=1'"> + <option>Click to navigate</option> + </select> + + <p style="background-color: rgb(0, 255, 0); width: 5em;"> + <a id="overflowLink" href="test.html">looooooooooong short looooooooooong</a> + </p> +</div> + +</body> +</html> diff --git a/testing/marionette/harness/marionette_harness/www/element_outside_viewport.html b/testing/marionette/harness/marionette_harness/www/element_outside_viewport.html new file mode 100644 index 0000000000..69b66b8759 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/element_outside_viewport.html @@ -0,0 +1,41 @@ +<!DOCTYPE html> +<style> + div { + position: absolute; + width: 100px; + height: 100px; + } + .top { background-color: red; } + #top-70 { left: 80px; top: 0; } + #top-50 { left: 190px; top: 20px; } + #top-30 { left: 300px; top: 40px; } + + .right { background-color: black; } + #right-70 { top: 80px; right: -140px;} + #right-50 { top: 190px; right: -120px;} + #right-30 { top: 300px; right: -100px;} + + .bottom { background-color: blue; } + #bottom-70 { right: -50px; bottom: -140px; } + #bottom-50 { right: 60px; bottom: -120px; } + #bottom-30 { right: 170px; bottom: -100px; } + + .left { background-color: green; } + #left-70 { bottom: -50px; left: 0; } + #left-50 { bottom: 60px; left: 20px; } + #left-30 { bottom: 170px; left: 40px; } +</style> +<body onload="window.scrollTo(70, 70);"> + <div id="top-70" class="top"></div> + <div id="top-50" class="top"></div> + <div id="top-30" class="top"></div> + <div id="right-70" class="right"></div> + <div id="right-50" class="right"></div> + <div id="right-30" class="right"></div> + <div id="bottom-70" class="bottom"></div> + <div id="bottom-50" class="bottom"></div> + <div id="bottom-30" class="bottom"></div> + <div id="left-70" class="left"></div> + <div id="left-50" class="left"></div> + <div id="left-30" class="left"></div> +</body> diff --git a/testing/marionette/harness/marionette_harness/www/empty.html b/testing/marionette/harness/marionette_harness/www/empty.html new file mode 100644 index 0000000000..646edf9a72 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/empty.html @@ -0,0 +1,12 @@ +<!-- 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 html> +<html> +<head> +<title>Marionette Test</title> +</head> +<body> +</body> +</html> diff --git a/testing/marionette/harness/marionette_harness/www/formPage.html b/testing/marionette/harness/marionette_harness/www/formPage.html new file mode 100644 index 0000000000..43fde32431 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/formPage.html @@ -0,0 +1,114 @@ +<html> +<head> + <title>We Leave From Here</title> + + <script type="text/javascript"> + function changePage() { + let newLocation = '/common/page/3'; + window.location = newLocation; + } + </script> +</head> +<body> +There should be a form here: + +<form method="get" action="resultPage.html" name="login"> + <input type="email" id="email"/> + <input type="submit" id="submitButton" value="Hello there"/> +</form> + +<form method="get" action="resultPage.html" name="optional" style="display: block"> + Here's a checkbox: + <input type="checkbox" id="checky" name="checky" value="furrfu"/> + <input type="checkbox" id="checkedchecky" name="checkedchecky" checked="checked" /> + <input type="checkbox" id="disabledchecky" disabled="disabled" name="disabledchecky" /> + <input type="checkbox" id="randomly_disabled_checky" disabled="somerandomstring" checked="checked" name="randomlydisabledchecky" /> + <br/> + <select name="selectomatic"> + <option selected="selected" id="non_multi_option" value="one">One</option> + <option value="two">Two</option> + <option value="four">Four</option> + <option value="still learning how to count, apparently">Still learning how to count, apparently</option> + </select> + + <select name="multi" id="multi" multiple="multiple"> + <option selected="selected" value="eggs">Eggs</option> + <option value="ham">Ham</option> + <option selected="selected" value="sausages">Sausages</option> + <option value="onion gravy">Onion gravy</option> + </select> + + <select name="no-select" disabled="disabled"> + <option value="foo">Foo</option> + </select> + + <select name="select_empty_multiple" multiple> + <option id="multi_1" value="select_1">select_1</option> + <option id="multi_2" value="select_2">select_2</option> + <option id="multi_3" value="select_3">select_3</option> + <option id="multi_4" value="select_4">select_4</option> + </select> + + <select name="multi_true" multiple="true"> + <option id="multi_true_1" value="select_1">select_1</option> + <option id="multi_true_2" value="select_2">select_2</option> + </select> + + <select name="multi_false" multiple="false"> + <option id="multi_false_1" value="select_1">select_1</option> + <option id="multi_false_2" value="select_2">select_2</option> + </select> + + <select id="invisi_select" style="opacity:0;"> + <option selected value="apples">Apples</option> + <option value="oranges">Oranges</option> + </select> + + <select name="select-default"> + <option>One</option> + <option>Two</option> + <option>Four</option> + <option>Still learning how to count, apparently</option> + </select> + + <select name="select_with_spaces"> + <option>One</option> + <option> Two </option> + <option> + Four + </option> + <option> + Still learning how to count, + apparently + </option> + </select> + + <select> + <option id="blankOption"></option> + <option id="optionEmptyValueSet" value="">nothing</option> + </select> + + <br/> + + <input type="radio" id="cheese" name="snack" value="cheese"/>Cheese<br/> + <input type="radio" id="peas" name="snack" value="peas"/>Peas<br/> + <input type="radio" id="cheese_and_peas" name="snack" value="cheese and peas" checked/>Cheese and peas<br/> + <input type="radio" id="nothing" name="snack" value="nowt" disabled="disabled"/>Not a sausage<br/> + <input type="radio" id="randomly_disabled_nothing" name="snack" value="funny nowt" disabled="somedisablingstring"/>Not another sausage + + <input type="hidden" name="hidden" value="fromage" /> + + <p id="cheeseLiker">I like cheese</p> + <input type="submit" value="Click!"/> + + <input type="radio" id="lone_disabled_selected_radio" name="not_a_snack" value="cumberland" checked="checked" disabled="disabled" />Cumberland sausage +</form> + +<form method="get" action="formPage.html"> + <p> + <label for="checkbox-with-label" id="label-for-checkbox-with-label">Label</label><input type="checkbox" id="checkbox-with-label" /> + </p> +</form> +<input id="vsearchGadget" name="SearchableText" type="text" size="18" value="" title="Hvad søger du?" accesskey="4" class="inputLabel" /> +</body> +</html> diff --git a/testing/marionette/harness/marionette_harness/www/frameset.html b/testing/marionette/harness/marionette_harness/www/frameset.html new file mode 100644 index 0000000000..e91472c952 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/frameset.html @@ -0,0 +1,13 @@ +<html> + <head> + <title>Unique title</title> + </head> +<frameset cols="*, *, *, *, *, *, *"> + <frame name="first" src="page/1"/> + <frame name="second" src="page/2?title=Fish"/> + <frame name="third" src="formPage.html"/> + <frame name="fourth" src="framesetPage2.html"/> + <frame id="fifth" src="xhtmlTest.html"/> + <frame id="sixth" src="test_iframe.html"/> + <frame id="sixth.iframe1" src="page/3"/> +</frameset> diff --git a/testing/marionette/harness/marionette_harness/www/framesetPage2.html b/testing/marionette/harness/marionette_harness/www/framesetPage2.html new file mode 100644 index 0000000000..5190ceb6ce --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/framesetPage2.html @@ -0,0 +1,7 @@ +<html> +<head></head> +<frameset cols="*, *"> + <frame name="child1" src="test.html"/> + <frame name="child2" src="test.html"/> +</frameset> +</html> diff --git a/testing/marionette/harness/marionette_harness/www/html5/blue.jpg b/testing/marionette/harness/marionette_harness/www/html5/blue.jpg Binary files differnew file mode 100644 index 0000000000..8ea27c42fa --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/html5/blue.jpg diff --git a/testing/marionette/harness/marionette_harness/www/html5/boolean_attributes.html b/testing/marionette/harness/marionette_harness/www/html5/boolean_attributes.html new file mode 100644 index 0000000000..431e575aef --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/html5/boolean_attributes.html @@ -0,0 +1,2 @@ +<!DOCTYPE html> +<input id='disabled' disabled> diff --git a/testing/marionette/harness/marionette_harness/www/html5/geolocation.js b/testing/marionette/harness/marionette_harness/www/html5/geolocation.js new file mode 100644 index 0000000000..4fb4a4747b --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/html5/geolocation.js @@ -0,0 +1,29 @@ +/* eslint-disable no-unsanitized/property */ + +function success(position) { + let message = document.getElementById("status"); + message.innerHTML = + "<img src='http://maps.google.com/maps/api/staticmap?center=" + + position.coords.latitude + + "," + + position.coords.longitude + + "&size=300x200&maptype=roadmap&zoom=12&&markers=size:mid|color:red|" + + position.coords.latitude + + "," + + position.coords.longitude + + "&sensor=false' />"; + message.innerHTML += "<p>Longitude: " + position.coords.longitude + "</p>"; + message.innerHTML += "<p>Latitude: " + position.coords.latitude + "</p>"; + message.innerHTML += "<p>Altitude: " + position.coords.altitude + "</p>"; +} + +function error(msg) { + let message = document.getElementById("status"); + message.innerHTML = "Failed to get geolocation."; +} + +if (navigator.geolocation) { + navigator.geolocation.getCurrentPosition(success, error); +} else { + error("Geolocation is not supported."); +} diff --git a/testing/marionette/harness/marionette_harness/www/html5/green.jpg b/testing/marionette/harness/marionette_harness/www/html5/green.jpg Binary files differnew file mode 100644 index 0000000000..6a0d3bea47 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/html5/green.jpg diff --git a/testing/marionette/harness/marionette_harness/www/html5/offline.html b/testing/marionette/harness/marionette_harness/www/html5/offline.html new file mode 100644 index 0000000000..c24178b5f5 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/html5/offline.html @@ -0,0 +1 @@ +<html><head><title>Offline</title></head><body></body></html> diff --git a/testing/marionette/harness/marionette_harness/www/html5/red.jpg b/testing/marionette/harness/marionette_harness/www/html5/red.jpg Binary files differnew file mode 100644 index 0000000000..f296e27195 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/html5/red.jpg diff --git a/testing/marionette/harness/marionette_harness/www/html5/status.html b/testing/marionette/harness/marionette_harness/www/html5/status.html new file mode 100644 index 0000000000..394116a522 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/html5/status.html @@ -0,0 +1 @@ +<html><head><title>Online</title></head><body></body></html> diff --git a/testing/marionette/harness/marionette_harness/www/html5/test.appcache b/testing/marionette/harness/marionette_harness/www/html5/test.appcache new file mode 100644 index 0000000000..3bc4e00257 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/html5/test.appcache @@ -0,0 +1,11 @@ +CACHE MANIFEST + +CACHE: +# Additional items to cache. +yellow.jpg +red.jpg +blue.jpg +green.jpg + +FALLBACK: +status.html offline.html diff --git a/testing/marionette/harness/marionette_harness/www/html5/test_html_inputs.html b/testing/marionette/harness/marionette_harness/www/html5/test_html_inputs.html new file mode 100644 index 0000000000..a170ced1ab --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/html5/test_html_inputs.html @@ -0,0 +1,2 @@ +<!DOCTYPE html> +<input id='number' type=number> diff --git a/testing/marionette/harness/marionette_harness/www/html5/yellow.jpg b/testing/marionette/harness/marionette_harness/www/html5/yellow.jpg Binary files differnew file mode 100644 index 0000000000..7c609b3712 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/html5/yellow.jpg diff --git a/testing/marionette/harness/marionette_harness/www/html5Page.html b/testing/marionette/harness/marionette_harness/www/html5Page.html new file mode 100644 index 0000000000..fbd943d792 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/html5Page.html @@ -0,0 +1,46 @@ +<html manifest="html5/test.appcache"> +<!-- +Copyright 2011 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. +--> + + +<head> +<title>HTML5</title> +</head> +<body> + +<h3>Geolocation Test</h3> +<div id="status">Location unknown</div> +<script language="javascript" type="text/javascript" src="html5/geolocation.js"></script> + +<h3>Application Cache Test</h3> +<div id="images"> + <p>Current network status: <span id="state"></span></p> + <script> + const state = document.getElementById('state') + setInterval(function () { + state.className = navigator.onLine ? 'online' : 'offline'; + // eslint-disable-next-line no-unsanitized/property + state.innerHTML = navigator.onLine ? 'online' : 'offline'; + }, 250); + </script> + <img id="red" src="html5/red.jpg"> + <img id="blue" src="html5/blue.jpg"> + <img id="green" src="html5/green.jpg"> + <img id="yellow" src="html5/yellow.jpg"> +</div> + +</body> +</html> diff --git a/testing/marionette/harness/marionette_harness/www/keyboard.html b/testing/marionette/harness/marionette_harness/www/keyboard.html new file mode 100644 index 0000000000..e711b31e05 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/keyboard.html @@ -0,0 +1,99 @@ +<?xml version="1.0"?> +<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en"> + <!-- 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/. --> + +<head> + <title>Testing Javascript</title> + <meta name="viewport" content="user-scalable=no"> + <script type="text/javascript"> + const seen = {}; + + function updateResult(event) { + document.getElementById('result').innerText = event.target.value; + } + + function displayMessage(message) { + document.getElementById('result').innerText = message; + } + + function appendMessage(message) { + document.getElementById('result').innerText += " " + message + " "; + } + </script> +</head> + +<body> +<h1>Type Stuff</h1> + +<div id="resultContainer"> + Result: <p id="result"></p> +</div> + +<div> + <form action="#"> + <p> + <label>keyDown: <input type="text" id="keyDown" onkeydown="updateResult(event)"/></label> + <label>keyPress: <input type="text" id="keyPress" onkeypress="updateResult(event)"/></label> + <label>keyUp: <input type="text" id="keyUp" onkeyup="updateResult(event)"/></label> + <label>change: <input type="text" id="change" onchange="updateResult(event)"/></label> + </p> + <p> + <label>change: + <input type="checkbox" id="checkbox" value="checkbox thing" onchange="updateResult(event)"/> + </label> + </p> + <p> + <label>keyDown: + <textarea id="keyDownArea" onkeydown="updateResult(event)" rows="2" cols="15"></textarea> + </label> + <label>keyPress: + <textarea id="keyPressArea" onkeypress="updateResult(event)" rows="2" cols="15"></textarea> + </label> + <label>keyUp: + <textarea id="keyUpArea" onkeyup="updateResult(event)" rows="2" cols="15"></textarea> + </label> + </p> + <p> + <select id="selector" onchange="updateResult(event)"> + <option value="foo">Foo</option> + <option value="bar">Bar</option> + </select> + </p> + </form> +</div> + +<div id="formageddon"> + <form action="#"> + Key Up: <input type="text" id="keyUp" onkeyup="javascript:updateContent(this)"/><br/> + Key Down: <input type="text" id="keyDown" onkeydown="javascript:updateContent(this)"/><br/> + Key Press: <input type="text" id="keyPress" onkeypress="javascript:updateContent(this)"/><br/> + Change: <input type="text" id="change" onkeypress="javascript:displayMessage('change')"/><br/> + <textarea id="keyDownArea" onkeydown="javascript:updateContent(this)" rows="2" cols="15"></textarea> + <textarea id="keyPressArea" onkeypress="javascript:updateContent(this)" rows="2" cols="15"></textarea> + <textarea id="keyUpArea" onkeyup="javascript:updateContent(this)" rows="2" cols="15"></textarea> + <select id="selector" onchange="javascript:updateContent(this)"> + <option value="foo">Foo</option> + <option value="bar">Bar</option> + </select> + <input type="checkbox" id="checkbox" value="checkbox thing" onchange="javascript:updateContent(this)"/> + <input id="clickField" type="text" onclick="document.getElementById('clickField').value='Clicked';" value="Hello"/> + <input id="doubleClickField" type="text" onclick="document.getElementById('doubleClickField').value='Clicked';" ondblclick="document.getElementById('doubleClickField').value='DoubleClicked';" oncontextmenu="document.getElementById('doubleClickField').value='ContextClicked'; return false;" value="DoubleHello"/> + <input id="clearMe" value="Something" onchange="displayMessage('Cleared')"/> + <input type="text" id="notDisplayed" style="display: none"> + </form> +</div> + +<div> + <form> + <input type="text" id="keyReporter" size="80" + onkeyup="appendMessage('up: ' + event.keyCode)" + onkeypress="appendMessage('press: ' + event.keyCode)" + onkeydown="displayMessage(''); appendMessage('down: ' + event.keyCode)" /> + <input name="suppress" onkeydown="if (event.preventDefault) event.preventDefault(); event.returnValue = false; return false;" onkeypress="appendMessage('press');"/> + </form> +</div> + +</body> +</html> diff --git a/testing/marionette/harness/marionette_harness/www/layout/test_carets_columns.html b/testing/marionette/harness/marionette_harness/www/layout/test_carets_columns.html new file mode 100644 index 0000000000..bc414cfc45 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/layout/test_carets_columns.html @@ -0,0 +1,31 @@ +<!DOCTYPE html> +<!-- Any copyright is dedicated to the Public Domain. + - http://creativecommons.org/publicdomain/zero/1.0/ --> + +<html> + <head> + <meta charset="UTF-8"> + <style> + #columns { + column-count: 2; + -webkit-column-count: 2; + column-rule: 1px solid lightgray; + -webkit-column-rule: 1px solid lightgray; + border: 1px solid lightblue; + width: 450px; + } + </style> + </head> + <body> + <div id="columns"> + <div id="columns-inner" style="border: 1px solid red;" contenteditable="true"> + <p id="before-image-1">Before image 1</p> + <p><img width="100px" height="30px" src="data:image/gif;base64,R0lGODlhAQABAIABAAD/AP///ywAAAAAAQABAAACAkQBADs="></p> + <p>After image 1</p> + <p>Before image 2</p> + <p><img width="100px" height="30px" src="data:image/gif;base64,R0lGODlhAQABAIABAAD/AP///ywAAAAAAQABAAACAkQBADs="></p> + <p>After image 2</p> + </div> + </div> + </body> +</html> diff --git a/testing/marionette/harness/marionette_harness/www/layout/test_carets_cursor.html b/testing/marionette/harness/marionette_harness/www/layout/test_carets_cursor.html new file mode 100644 index 0000000000..fdbd6fe7a8 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/layout/test_carets_cursor.html @@ -0,0 +1,31 @@ +<!-- 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 html> +<html id="html"> + <head> + <title>Marionette tests for AccessibleCaret in cursor mode</title> + <style> + .block { + width: 10em; + height: 6em; + word-wrap: break-word; + overflow: auto; + } + </style> + </head> + <body> + <div> + <input id="input" value="ABCDEFGHI"> + <input id="input-padding" style="padding: 1em;" value="ABCDEFGHI"> + </div> + <br> + <div> + <textarea name="textarea" id="textarea" rows="4" cols="6">ABCDEFGHI</textarea> + <textarea id="textarea-one-line" rows="3">ABCDEFGHI</textarea> + </div> + <br> + <div class="block" contenteditable="true" id="contenteditable">ABCDEFGHI</div> + </body> +</html> diff --git a/testing/marionette/harness/marionette_harness/www/layout/test_carets_display_none.html b/testing/marionette/harness/marionette_harness/www/layout/test_carets_display_none.html new file mode 100644 index 0000000000..766f320011 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/layout/test_carets_display_none.html @@ -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/. --> + +<!DOCTYPE html> +<html id="html" style="display: none"> + <body> + <div id="content">ABC DEF GHI</div> + </body> +</html> diff --git a/testing/marionette/harness/marionette_harness/www/layout/test_carets_iframe.html b/testing/marionette/harness/marionette_harness/www/layout/test_carets_iframe.html new file mode 100644 index 0000000000..175d3c3d5c --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/layout/test_carets_iframe.html @@ -0,0 +1,15 @@ +<!-- 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 html> +<html id="html"> + <head> + <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> + <title>Marionette tests for AccessibleCaret in selection mode (iframe)</title> + </head> + <body> + <iframe id="frame" src="test_carets_longtext.html" style="width: 10em; height: 8em;"></iframe> + <input id="input" value="ABC DEF GHI"> + </body> +</html> diff --git a/testing/marionette/harness/marionette_harness/www/layout/test_carets_iframe_scroll.html b/testing/marionette/harness/marionette_harness/www/layout/test_carets_iframe_scroll.html new file mode 100644 index 0000000000..5f4b00e5bd --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/layout/test_carets_iframe_scroll.html @@ -0,0 +1,11 @@ +<!DOCTYPE html> +<html> + <title>Bug 1657256: Test select word, scroll up, and drag AccessibleCaret.</title> + <style> + :root { + font: 16px/1.25 monospace; + } + </style> + + <iframe id="iframe" src="test_carets_iframe_scroll_inner.html" style="width: 6em; height: 8em;"></iframe> +</html> diff --git a/testing/marionette/harness/marionette_harness/www/layout/test_carets_iframe_scroll_inner.html b/testing/marionette/harness/marionette_harness/www/layout/test_carets_iframe_scroll_inner.html new file mode 100644 index 0000000000..1087227007 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/layout/test_carets_iframe_scroll_inner.html @@ -0,0 +1,24 @@ +<!DOCTYPE html> +<html> + <style> + :root { + font: 16px/1.25 monospace; + } + </style> + + <body id="bd"> + AAAAAA + BBBBBB + CCCCCC + <span id="content">DDDDDD</span> + <span id="content2">EEEEEE</span> + FFFFFF + GGGGGG + HHHHHH + IIIIII + JJJJJJ + KKKKKK + LLLLLL + MMMMMM + </body> +</html> diff --git a/testing/marionette/harness/marionette_harness/www/layout/test_carets_longtext.html b/testing/marionette/harness/marionette_harness/www/layout/test_carets_longtext.html new file mode 100644 index 0000000000..7e2495509b --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/layout/test_carets_longtext.html @@ -0,0 +1,9 @@ +<html> + <head> + <title>Bug 1094072: Orientation change test for AccessibleCaret positions</title> + </head> + <body id="bd"> + <h3 id="longtext">long long text for orientation change test long long text for orientation change test long long text for orientation change test long long text for orientation change test</h3> + <div contenteditable="true" id="bottomtext">bottom text</div> +</body> +</html> diff --git a/testing/marionette/harness/marionette_harness/www/layout/test_carets_multipleline.html b/testing/marionette/harness/marionette_harness/www/layout/test_carets_multipleline.html new file mode 100644 index 0000000000..fbbefbebcb --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/layout/test_carets_multipleline.html @@ -0,0 +1,18 @@ +<!-- 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 html> +<html id="html"> + <head> + <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> + <title>Bug 1019441: Marionette tests for AccessibleCaret (multiple lines)</title> + </head> + <body> + <div><textarea id="textarea2" style="width: 10em; height: 6em; overflow: auto;">First Line Second Line Third Line</textarea></div> + <br> + <div style="width: 10em; height: 6em; overflow: auto;" id="contenteditable2" contenteditable="true">First Line<br><br>Second Line<br><br>Third Line</div> + <br> + <div style="width: 10em; height: 6em; overflow: auto;" id="content2">First Line<br><br>Second Line<br><br>Third Line</div> + </body> +</html> diff --git a/testing/marionette/harness/marionette_harness/www/layout/test_carets_multiplerange.html b/testing/marionette/harness/marionette_harness/www/layout/test_carets_multiplerange.html new file mode 100644 index 0000000000..9b9bbe9e9f --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/layout/test_carets_multiplerange.html @@ -0,0 +1,19 @@ +<html> +<style> +h4 { + user-select: none; +} +</style> +<body id=bd> +<h3 id=sel1>user can select this 1</h3> +<h3 id=sel2>user can select this 2</h3> +<h3 id=sel3>user can select this 3</h3> +<h4 id=nonsel1>user cannot select this 1</h4> +<h4 id=nonsel2>user cannot select this 2</h4> +<h3 id=sel4>user can select this 4</h3> +<h3 id=sel5>user can select this 5</h3> +<h4 id=nonsel3>user cannot select this 3</h4> +<h3 id=sel6>user can select this 6</h3> +<h3 id=sel7>user can select this 7</h3> +</body> +</html> diff --git a/testing/marionette/harness/marionette_harness/www/layout/test_carets_selection.html b/testing/marionette/harness/marionette_harness/www/layout/test_carets_selection.html new file mode 100644 index 0000000000..bd36f45b23 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/layout/test_carets_selection.html @@ -0,0 +1,42 @@ +<!-- 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 html> +<html id="html"> + <head> + <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> + <title>Marionette tests for AccessibleCaret in selection mode</title> + <style> + .block { + width: 10em; + height: 4em; + word-wrap: break-word; + overflow: auto; + } + </style> + </head> + <body> + <div> + <input id="input" value="ABC DEF GHI"> + <input id="input-padding" style="padding: 1em;" value="ABC DEF GHI"> + + <!-- To successfully select 'B's when 'A' is selected, use sufficient + spaces between 'A's and 'B's to avoid the second caret covers 'B's. --> + <input size="16" id="input-size" value="AAAAAAAA BBBBBBBB"> + </div> + <br> + <div> + <textarea id="textarea" rows="4" cols="8">ABC DEF GHI JKL MNO PQR</textarea> + <textarea id="textarea-one-line" rows="4" cols="12">ABC DEF GHI</textarea> + </div> + <br> + <div><textarea dir="rtl" id="textarea-rtl" rows="8" cols="8">موزيلا فيرفكس موزيلا فيرفكس</textarea></div> + <br> + <div class="block" contenteditable="true" id="contenteditable">ABC DEF GHI</div> + <br> + <div class="block" id="content">ABC DEF GHI</div> + <br> + <div style="user-select: none;" id="non-selectable">Non-selectable</div> + </body> +</html> diff --git a/testing/marionette/harness/marionette_harness/www/layout/test_carets_svg_shapes.html b/testing/marionette/harness/marionette_harness/www/layout/test_carets_svg_shapes.html new file mode 100644 index 0000000000..ea3ad4ecf6 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/layout/test_carets_svg_shapes.html @@ -0,0 +1,12 @@ +<!-- Any copyright is dedicated to the Public Domain. + - http://creativecommons.org/publicdomain/zero/1.0/ --> + +<!DOCTYPE html> +<html> + <body> + <svg xmlns="http://www.w3.org/2000/svg" id="svg-element" width="200" height="200"> + <rect id="rect" x="100" y="100" width="20" height="20"></rect> + </svg> + <p id="text">ABC DEF GHI</p> + </body> +</html> diff --git a/testing/marionette/harness/marionette_harness/www/navigation_pushstate.html b/testing/marionette/harness/marionette_harness/www/navigation_pushstate.html new file mode 100644 index 0000000000..fbde792d8c --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/navigation_pushstate.html @@ -0,0 +1,20 @@ +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> + +<!DOCTYPE html> +<html> +<head> + <title>Navigation by manipulating the browser history</title> + <script type="text/javascript"> + function forward() { + let stateObj = { foo: "bar" }; + history.pushState(stateObj, "", "navigation_pushstate_target.html"); + } + </script> +</head> + +<body> + <p>Navigate <a onclick="javascript:forward();" id="forward">forward</a></p> +</body> +</html> diff --git a/testing/marionette/harness/marionette_harness/www/navigation_pushstate_target.html b/testing/marionette/harness/marionette_harness/www/navigation_pushstate_target.html new file mode 100644 index 0000000000..153d0a657f --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/navigation_pushstate_target.html @@ -0,0 +1,13 @@ +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> + +<!DOCTYPE html> +<html> +<head> +</head> + +<body> + <p id="target">Pushstate target</p> +</body> +</html> diff --git a/testing/marionette/harness/marionette_harness/www/nestedElements.html b/testing/marionette/harness/marionette_harness/www/nestedElements.html new file mode 100644 index 0000000000..618bf3231b --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/nestedElements.html @@ -0,0 +1,9 @@ +<!DOCTYPE html> +<a href="1.html">hello world</a> +<a href="1.html">hello world</a><a href="1.html">hello world</a> +<div name="div1"> + <a href="2.html" name="link1">hello world</a> + <a href="2.html" name="link2">hello world</a> +</div> + +<a href="1.html">hello world</a><a href="1.html">hello world</a><a href="1.html">hello world</a> diff --git a/testing/marionette/harness/marionette_harness/www/reftest/mostly-teal-700x700.html b/testing/marionette/harness/marionette_harness/www/reftest/mostly-teal-700x700.html new file mode 100644 index 0000000000..a5aa12d0d2 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/reftest/mostly-teal-700x700.html @@ -0,0 +1,21 @@ +<!doctype html> +<style> + * { + margin: 0; + padding: 0; + } + + html { + background: teal; + } + + div { + position: absolute; + top: 600px; + left: 600px; + height: 100px; + width: 100px; + background: orange; + } +</style> +<div></div> diff --git a/testing/marionette/harness/marionette_harness/www/reftest/teal-700x700.html b/testing/marionette/harness/marionette_harness/www/reftest/teal-700x700.html new file mode 100644 index 0000000000..e441e88e6d --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/reftest/teal-700x700.html @@ -0,0 +1,21 @@ +<!doctype html> +<style> + * { + margin: 0; + padding: 0; + } + + html { + background: teal; + } + + div { + position: absolute; + top: 600px; + left: 600px; + height: 100px; + width: 100px; + background: transparent; + } +</style> +<div></div> diff --git a/testing/marionette/harness/marionette_harness/www/resultPage.html b/testing/marionette/harness/marionette_harness/www/resultPage.html new file mode 100644 index 0000000000..6e2fea9a14 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/resultPage.html @@ -0,0 +1,16 @@ +<!-- 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/. --> + +<html> +<head> + <title>We Arrive Here</title> +</head> +<body> + + +<div> + <input type='text' id='email'/> +</div> +</body> +</html> diff --git a/testing/marionette/harness/marionette_harness/www/serviceworker/install_serviceworker.html b/testing/marionette/harness/marionette_harness/www/serviceworker/install_serviceworker.html new file mode 100644 index 0000000000..572a05c2bc --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/serviceworker/install_serviceworker.html @@ -0,0 +1,11 @@ +<!DOCTYPE html> +<html> + <head> + <title>Install Service Worker</title> + </head> + <body> + <script> + navigator.serviceWorker.register("serviceworker.js"); + </script> + </body> +</html> diff --git a/testing/marionette/harness/marionette_harness/www/serviceworker/serviceworker.js b/testing/marionette/harness/marionette_harness/www/serviceworker/serviceworker.js new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/serviceworker/serviceworker.js diff --git a/testing/marionette/harness/marionette_harness/www/shim.js b/testing/marionette/harness/marionette_harness/www/shim.js new file mode 100644 index 0000000000..c304333088 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/shim.js @@ -0,0 +1,297 @@ +/** + * mouse_event_shim.js: generate mouse events from touch events. + * + * This library listens for touch events and generates mousedown, mousemove + * mouseup, and click events to match them. It captures and dicards any + * real mouse events (non-synthetic events with isTrusted true) that are + * send by gecko so that there are not duplicates. + * + * This library does emit mouseover/mouseout and mouseenter/mouseleave + * events. You can turn them off by setting MouseEventShim.trackMouseMoves to + * false. This means that mousemove events will always have the same target + * as the mousedown even that began the series. You can also call + * MouseEventShim.setCapture() from a mousedown event handler to prevent + * mouse tracking until the next mouseup event. + * + * This library does not support multi-touch but should be sufficient + * to do drags based on mousedown/mousemove/mouseup events. + * + * This library does not emit dblclick events or contextmenu events + */ + +"use strict"; + +(function() { + // Make sure we don't run more than once + if (MouseEventShim) { + return; + } + + // Bail if we're not on running on a platform that sends touch + // events. We don't need the shim code for mouse events. + try { + document.createEvent("TouchEvent"); + } catch (e) { + return; + } + + let starttouch; // The Touch object that we started with + let target; // The element the touch is currently over + let emitclick; // Will we be sending a click event after mouseup? + + // Use capturing listeners to discard all mouse events from gecko + window.addEventListener("mousedown", discardEvent, true); + window.addEventListener("mouseup", discardEvent, true); + window.addEventListener("mousemove", discardEvent, true); + window.addEventListener("click", discardEvent, true); + + function discardEvent(e) { + if (e.isTrusted) { + e.stopImmediatePropagation(); // so it goes no further + if (e.type === "click") { + e.preventDefault(); + } // so it doesn't trigger a change event + } + } + + // Listen for touch events that bubble up to the window. + // If other code has called stopPropagation on the touch events + // then we'll never see them. Also, we'll honor the defaultPrevented + // state of the event and will not generate synthetic mouse events + window.addEventListener("touchstart", handleTouchStart); + window.addEventListener("touchmove", handleTouchMove); + window.addEventListener("touchend", handleTouchEnd); + window.addEventListener("touchcancel", handleTouchEnd); // Same as touchend + + function handleTouchStart(e) { + // If we're already handling a touch, ignore this one + if (starttouch) { + return; + } + + // Ignore any event that has already been prevented + if (e.defaultPrevented) { + return; + } + + // Sometimes an unknown gecko bug causes us to get a touchstart event + // for an iframe target that we can't use because it is cross origin. + // Don't start handling a touch in that case + try { + e.changedTouches[0].target.ownerDocument; + } catch (e) { + // Ignore the event if we can't see the properties of the target + return; + } + + // If there is more than one simultaneous touch, ignore all but the first + starttouch = e.changedTouches[0]; + target = starttouch.target; + emitclick = true; + + // Move to the position of the touch + emitEvent("mousemove", target, starttouch); + + // Now send a synthetic mousedown + let result = emitEvent("mousedown", target, starttouch); + + // If the mousedown was prevented, pass that on to the touch event. + // And remember not to send a click event + if (!result) { + e.preventDefault(); + emitclick = false; + } + } + + function handleTouchEnd(e) { + if (!starttouch) { + return; + } + + // End a MouseEventShim.setCapture() call + if (MouseEventShim.capturing) { + MouseEventShim.capturing = false; + MouseEventShim.captureTarget = null; + } + + for (let i = 0; i < e.changedTouches.length; i++) { + let touch = e.changedTouches[i]; + // If the ended touch does not have the same id, skip it + if (touch.identifier !== starttouch.identifier) { + continue; + } + + emitEvent("mouseup", target, touch); + + // If target is still the same element we started and the touch did not + // move more than the threshold and if the user did not prevent + // the mousedown, then send a click event, too. + if (emitclick) { + emitEvent("click", starttouch.target, touch); + } + + starttouch = null; + return; + } + } + + function handleTouchMove(e) { + if (!starttouch) { + return; + } + + for (let i = 0; i < e.changedTouches.length; i++) { + let touch = e.changedTouches[i]; + // If the ended touch does not have the same id, skip it + if (touch.identifier !== starttouch.identifier) { + continue; + } + + // Don't send a mousemove if the touchmove was prevented + if (e.defaultPrevented) { + return; + } + + // See if we've moved too much to emit a click event + let dx = Math.abs(touch.screenX - starttouch.screenX); + let dy = Math.abs(touch.screenY - starttouch.screenY); + if ( + dx > MouseEventShim.dragThresholdX || + dy > MouseEventShim.dragThresholdY + ) { + emitclick = false; + } + + let tracking = + MouseEventShim.trackMouseMoves && !MouseEventShim.capturing; + + let oldtarget; + let newtarget; + if (tracking) { + // If the touch point moves, then the element it is over + // may have changed as well. Note that calling elementFromPoint() + // forces a layout if one is needed. + // XXX: how expensive is it to do this on each touchmove? + // Can we listen for (non-standard) touchleave events instead? + oldtarget = target; + newtarget = document.elementFromPoint(touch.clientX, touch.clientY); + if (newtarget === null) { + // this can happen as the touch is moving off of the screen, e.g. + newtarget = oldtarget; + } + if (newtarget !== oldtarget) { + leave(oldtarget, newtarget, touch); // mouseout, mouseleave + target = newtarget; + } + } else if (MouseEventShim.captureTarget) { + target = MouseEventShim.captureTarget; + } + + emitEvent("mousemove", target, touch); + + if (tracking && newtarget !== oldtarget) { + enter(newtarget, oldtarget, touch); // mouseover, mouseenter + } + } + } + + // Return true if element a contains element b + function contains(a, b) { + return (a.compareDocumentPosition(b) & 16) !== 0; + } + + // A touch has left oldtarget and entered newtarget + // Send out all the events that are required + function leave(oldtarget, newtarget, touch) { + emitEvent("mouseout", oldtarget, touch, newtarget); + + // If the touch has actually left oldtarget (and has not just moved + // into a child of oldtarget) send a mouseleave event. mouseleave + // events don't bubble, so we have to repeat this up the hierarchy. + for (let e = oldtarget; !contains(e, newtarget); e = e.parentNode) { + emitEvent("mouseleave", e, touch, newtarget); + } + } + + // A touch has entered newtarget from oldtarget + // Send out all the events that are required. + function enter(newtarget, oldtarget, touch) { + emitEvent("mouseover", newtarget, touch, oldtarget); + + // Emit non-bubbling mouseenter events if the touch actually entered + // newtarget and wasn't already in some child of it + for (let e = newtarget; !contains(e, oldtarget); e = e.parentNode) { + emitEvent("mouseenter", e, touch, oldtarget); + } + } + + function emitEvent(type, target, touch, relatedTarget) { + let synthetic = document.createEvent("MouseEvents"); + let bubbles = type !== "mouseenter" && type !== "mouseleave"; + let count = + type === "mousedown" || type === "mouseup" || type === "click" ? 1 : 0; + + synthetic.initMouseEvent( + type, + bubbles, // canBubble + true, // cancelable + window, + count, // detail: click count + touch.screenX, + touch.screenY, + touch.clientX, + touch.clientY, + false, // ctrlKey: we don't have one + false, // altKey: we don't have one + false, // shiftKey: we don't have one + false, // metaKey: we don't have one + 0, // we're simulating the left button + relatedTarget || null + ); + + try { + return target.dispatchEvent(synthetic); + } catch (e) { + console.warn("Exception calling dispatchEvent", type, e); + return true; + } + } +})(); + +const MouseEventShim = { + // It is a known gecko bug that synthetic events have timestamps measured + // in microseconds while regular events have timestamps measured in + // milliseconds. This utility function returns a the timestamp converted + // to milliseconds, if necessary. + getEventTimestamp(e) { + if (e.isTrusted) { + // XXX: Are real events always trusted? + return e.timeStamp; + } + return e.timeStamp / 1000; + }, + + // Set this to false if you don't care about mouseover/out events + // and don't want the target of mousemove events to follow the touch + trackMouseMoves: true, + + // Call this function from a mousedown event handler if you want to guarantee + // that the mousemove and mouseup events will go to the same element + // as the mousedown even if they leave the bounds of the element. This is + // like setting trackMouseMoves to false for just one drag. It is a + // substitute for event.target.setCapture(true) + setCapture(target) { + this.capturing = true; // Will be set back to false on mouseup + if (target) { + this.captureTarget = target; + } + }, + + capturing: false, + + // Keep these in sync with ui.dragThresholdX and ui.dragThresholdY prefs. + // If a touch ever moves more than this many pixels from its starting point + // then we will not synthesize a click event when the touch ends. + dragThresholdX: 25, + dragThresholdY: 25, +}; diff --git a/testing/marionette/harness/marionette_harness/www/slow_resource.html b/testing/marionette/harness/marionette_harness/www/slow_resource.html new file mode 100644 index 0000000000..b87d9f4b86 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/slow_resource.html @@ -0,0 +1,13 @@ +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> + +<!DOCTYPE html> +<html> +<head> +<title>Slow loading resource</title> +</head> +<body> + <img src="/slow?delay=4" id="slow" /> +</body> +</html> diff --git a/testing/marionette/harness/marionette_harness/www/test.html b/testing/marionette/harness/marionette_harness/www/test.html new file mode 100644 index 0000000000..70e42c2d06 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/test.html @@ -0,0 +1,38 @@ +<!-- 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 html> +<html> +<head> +<title>Marionette Test</title> +</head> +<body> + <h1 id="testh1">Test Page</h1> + <script type="text/javascript"> + window.ready = true; + function addDelayedElement() { + setTimeout(createDiv, 2000); + function createDiv() { + let newDiv = document.createElement("div"); + newDiv.id = "newDiv"; + let newContent = document.createTextNode("I am a newly created div!"); + newDiv.appendChild(newContent); + document.body.appendChild(newDiv); + } + } + function clicked() { + let link = document.getElementById("mozLink"); + link.innerHTML = "Clicked"; + } + </script> + <a href="#" id="mozLink" class="linkClass" onclick="clicked()">Click me!</a> + <div id="testDiv"> + <a href="#" id="divLink" class="linkClass" onclick="clicked()">Div click me!</a> + <a href="#" id="divLink2" class="linkClass" onclick="clicked()">Div click me!</a> + </div> + <input name="myInput" type="text" value="asdf"/> + <input name="myCheckBox" type="checkbox" /> + <input id="createDivButton" type="button" value="create a div" onclick="addDelayedElement()" /> +</body> +</html> diff --git a/testing/marionette/harness/marionette_harness/www/testAction.html b/testing/marionette/harness/marionette_harness/www/testAction.html new file mode 100644 index 0000000000..404ce9809a --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/testAction.html @@ -0,0 +1,96 @@ +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> + +<!DOCTYPE html> + +<html> +<meta charset="UTF-8"> +<head> +<title>Marionette Test</title> +</head> +<body> + <h1 id="testh1">Test Page</h1> + <button id="button1" style="position:absolute;left:0px;top:55px;" type="button" allowevents=true>button1</button> + <button id="button2" style="position:absolute;left:0px;top:355px;" type="button" allowevents=true>button2</button> + <button id="button3" style="position:absolute;left:0px;top:455px;" type="button" allowevents=true>button3</button> + <button id="button4" style="position:absolute;left:100px;top:455px;" type="button" allowevents=true>button4</button> + <button id="buttonScroll" style="position:absolute;left:100px;top:855px;" type="button" allowevents=true>buttonScroll</button> + <h2 id="hidden" style="visibility: hidden" class="linkClass">Hidden</h2> + <button id="buttonFlick" style="position:absolute;left:0px;top:255px;" type="button" allowevents=true>buttonFlick</button> + <script type="text/javascript"> + let button3Timer = null; + let button4Timer = null; + //appends passed in text to the innerHTML of the event's target + function appendText(text) { + return function(evt) { + let element; + if (evt.type.includes("touch")) { + if (evt.type == "touchstart") { + element = evt.target; + } + else { + //since the target of touchstart is the target of all subsequent events, then + //changedTouches holds the current coordinates of this touch event, so we + //use these coordinates to find the element under the touch event + let touches = evt.changedTouches; + let x = touches[0].clientX; + let y = touches[0].clientY; + element = document.elementFromPoint(x,y); + } + } + //handle mouse events or contextmenu + else { + element = evt.target; + } + // eslint-disable-next-line no-unsanitized/property + element.innerHTML += text; + }; + }; + //use this function outside of attachListeners when you want to test sendMouseOnlyEvents on a target + function attachMouseListeners(element) { + element.addEventListener("contextmenu", appendText("-contextmenu")); + element.addEventListener("mousedown", appendText("-mousedown")); + element.addEventListener("mousemove", appendText("-mousemove")); + element.addEventListener("mouseup", appendText("-mouseup")); + element.addEventListener("click", appendText("-click")); + }; + function attachListeners(id) { + let element = document.getElementById(id); + element.addEventListener("touchstart", appendText("-touchstart")); + element.addEventListener("touchmove", appendText("-touchmove")); + element.addEventListener("touchend", appendText("-touchend")); + element.addEventListener("touchcancel", appendText("-touchcancel")); + attachMouseListeners(element); + }; + //for tracking time on an element + function addTimers(id, timer) { + let element = document.getElementById(id); + element.addEventListener("touchstart", function(evt) { timer = (new Date()).getTime();}); + // eslint-disable-next-line no-unsanitized/property + element.addEventListener("touchend", function(evt) { timer = (new Date()).getTime() - timer; evt.target.innerHTML += "-" + timer;}); + } + attachListeners("button1"); + attachListeners("button2"); + attachListeners("button3"); + attachListeners("button4"); + attachListeners("buttonScroll"); + addTimers("button3"); + addTimers("button4"); + const buttonFlick = document.getElementById("buttonFlick"); + attachMouseListeners(buttonFlick); + function createDelayed() { + let newButton = document.createElement("button"); + newButton.id = "delayed"; + newButton.setAttribute("style", "position:absolute;left:220px;top:455px;"); + let content = document.createTextNode("delayed"); + newButton.appendChild(content); + document.body.appendChild(newButton); + newButton.addEventListener("mousemove", appendText("-mousemove")); + newButton.addEventListener("mouseup", appendText("-mouseup")); + newButton.addEventListener("click", appendText("-click")); + }; + window.setTimeout(createDelayed, 5000); + </script> +</body> +</html> diff --git a/testing/marionette/harness/marionette_harness/www/test_accessibility.html b/testing/marionette/harness/marionette_harness/www/test_accessibility.html new file mode 100644 index 0000000000..8cc9fd6493 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/test_accessibility.html @@ -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/. --> + +<!DOCTYPE html> + +<html> +<meta charset="UTF-8"> +<head> +<title>Marionette Test</title> +</head> +<body> + <button id="button1">button1</button> + <button id="button2" aria-label="button2"></button> + <span id="button3">I am a bad button with no accessible</span> + <h1 id="button4">I am a bad button that is actually a header</h1> + <h1 id="button5"> + I am a bad button that is actually an actionable header with a listener + </h1> + <button id="button6"></button> + <button id="button7" aria-hidden="true">button7</button> + <div aria-hidden="true"> + <button id="button8">button8</button> + </div> + <button id="button9" style="position:absolute;left:-100px;top:-455px;"> + button9 + </button> + <button id="button10" style="visibility:hidden;"> + button10 + </button> + <span id="no_accessible_but_displayed">I have no accessible object</span> + <button id="button11" disabled>button11</button> + <button id="button12" aria-disabled="true">button12</button> + <span id="no_accessible_but_disabled" disabled>I have no accessible object</span> + <span id="button13" tabindex="0" role="button" aria-label="Span button">Span button</span> + <span id="button14" role="button" aria-label="Span button">Unexplorable Span button</span> + <button id="button15" style="pointer-events:none;">button15</button> + <div style="pointer-events:none;"> + <button id="button16">button16</button> + </div> + <div style="pointer-events:none;"> + <button style="pointer-events:all;" id="button17">button17</button> + </div> + <input id="input1" title="My Input 1" name="myInput1" type="text" value="asdf"/> + <select> + <option id="option1" value="val1">Val1</option> + <option id="option2" value="val2" selected>Val2</option> + </select> + <script> + 'use strict'; + document.getElementById('button5').addEventListener('click', function() { + // A pseudo button that has a listener but is missing button semantics. + return true; + }); + </script> +</body> +</html> diff --git a/testing/marionette/harness/marionette_harness/www/test_clearing.html b/testing/marionette/harness/marionette_harness/www/test_clearing.html new file mode 100644 index 0000000000..2aa3c6a21f --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/test_clearing.html @@ -0,0 +1,24 @@ +<html> + <!-- 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/. --> + <body> + <input id="writableTextInput" type="text" value="Test"/> + + <input id="readOnlyTextInput" type="text" readonly value="Test"/> + + <input id="textInputnotenabled" type="text" disabled="true" value="Test"/> + + <textarea id="writableTextArea" rows="2" cols="20"> + This is a sample text area which is supposed to be cleared + </textarea> + + <textarea id="textAreaReadOnly" readonly rows="5" cols="20"> + text area which is not supposed to be cleared</textarea> + + <textarea rows="5" id="textAreaNotenabled" disabled="true" cols="20"> + text area which is not supposed to be cleared</textarea> + + <div id="content-editable" contentEditable="true">This is a contentEditable area</div> + </body> +</html> diff --git a/testing/marionette/harness/marionette_harness/www/test_dynamic.html b/testing/marionette/harness/marionette_harness/www/test_dynamic.html new file mode 100644 index 0000000000..504e7e74ba --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/test_dynamic.html @@ -0,0 +1,38 @@ +<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" + "http://www.w3.org/TR/html4/loose.dtd"> +<html> + <head> + <title></title> + <script type="text/javascript"> + let next = 0; + + function addMore() { + let box = document.createElement('DIV'); + box.id = 'box' + next++; + box.className = 'redbox'; + box.style.width = '150px'; + box.style.height = '150px'; + box.style.backgroundColor = 'red'; + box.style.border = '1px solid black'; + box.style.margin = '5px'; + window.setTimeout(function() { + document.body.appendChild(box); + }, 1000); + } + + function reveal() { + let elem = document.getElementById('revealed'); + window.setTimeout(function() { + elem.style.display = ''; + }, 1000); + } + </script> + </head> + <body> + <input id="adder" type="button" value="Add a box!" onclick="addMore()"/> + + <input id="reveal" type="button" value="Reveal a new input" onclick="reveal();" /> + + <input id="revealed" style="display:none;" /> + </body> + </html> diff --git a/testing/marionette/harness/marionette_harness/www/test_iframe.html b/testing/marionette/harness/marionette_harness/www/test_iframe.html new file mode 100644 index 0000000000..b323ace679 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/test_iframe.html @@ -0,0 +1,16 @@ +<!-- 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 html> +<html> +<head> +<title>Marionette IFrame Test</title> +</head> +<body> + <h1 id="iframe_page_heading">This is the heading</h1> + + <iframe src="test.html" id="test_iframe"></iframe> + <iframe src="test.html" id="test_iframe" name="test_iframe_name"></iframe> +</body> +</html> diff --git a/testing/marionette/harness/marionette_harness/www/test_inner_iframe.html b/testing/marionette/harness/marionette_harness/www/test_inner_iframe.html new file mode 100644 index 0000000000..8c9810d0bb --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/test_inner_iframe.html @@ -0,0 +1,13 @@ +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> + +<!doctype html> +<html> +<head> +<title>Inner Iframe</title> +</head> +<body> + <iframe src="test.html" id="inner_frame"></iframe> +</body> +</html> diff --git a/testing/marionette/harness/marionette_harness/www/test_nested_iframe.html b/testing/marionette/harness/marionette_harness/www/test_nested_iframe.html new file mode 100644 index 0000000000..49ac1b0ba5 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/test_nested_iframe.html @@ -0,0 +1,13 @@ +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> + +<!doctype html> +<html> +<head> +<title>Marionette IFrame Test</title> +</head> +<body> + <iframe src="test_inner_iframe.html" id="test_iframe"></iframe> +</body> +</html> diff --git a/testing/marionette/harness/marionette_harness/www/test_oop_1.html b/testing/marionette/harness/marionette_harness/www/test_oop_1.html new file mode 100644 index 0000000000..29add714cd --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/test_oop_1.html @@ -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/. --> + +<!DOCTYPE html> +<html> +<head> +<title>OOP Test Frame 1</title> +</head> +<body> + <h1 id="testh1">OOP Test Frame 1</h1> + Hello! +</body> +</html> diff --git a/testing/marionette/harness/marionette_harness/www/test_oop_2.html b/testing/marionette/harness/marionette_harness/www/test_oop_2.html new file mode 100644 index 0000000000..6e5a4962fb --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/test_oop_2.html @@ -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/. --> + +<!DOCTYPE html> +<html> +<head> +<title>OOP Test Frame 2</title> +</head> +<body> + <h1 id="testh1">OOP Test Frame 2</h1> + Hello! +</body> +</html> diff --git a/testing/marionette/harness/marionette_harness/www/test_tab_modal_dialogs.html b/testing/marionette/harness/marionette_harness/www/test_tab_modal_dialogs.html new file mode 100644 index 0000000000..b61bda5608 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/test_tab_modal_dialogs.html @@ -0,0 +1,44 @@ +<!-- 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 html> +<html> +<head> + <title>Dialog Test</title> + <script type="text/javascript"> + function setInnerText(id, value) { + // eslint-disable-next-line no-unsanitized/property + document.getElementById(id).innerHTML = "<p>" + value + "</p>"; + } + + function handleAlert () { + setInnerText("text", alert("Marionette alert")); + } + + function handleConfirm () { + setInnerText("text", confirm("Marionette confirm")); + } + + function handlePrompt () { + setInnerText("text", prompt("Marionette prompt")); + } + + function handleTwoDialogs() { + setInnerText("text1", prompt("First")); + setInnerText("text2", prompt("Second")); + } + </script> +</head> +<body> + <a href="#" id="tab-modal-alert" onclick="handleAlert()">Open an alert dialog.</a> + <a href="#" id="tab-modal-confirm" onclick="handleConfirm()">Open a confirm dialog.</a> + <a href="#" id="tab-modal-prompt" onclick="handlePrompt()">Open a prompt dialog.</a> + <a href="#" id="open-two-dialogs" onclick="handleTwoDialogs()">Open two prompts.</a> + <a href="#" id="click-handler" onclick="document.getElementById('text').innerHTML='result';">Make text appear.</a> + + <div id="text"></div> + <div id="text1"></div> + <div id="text2"></div> +</body> +</html> diff --git a/testing/marionette/harness/marionette_harness/www/test_windows.html b/testing/marionette/harness/marionette_harness/www/test_windows.html new file mode 100644 index 0000000000..f3759990c0 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/test_windows.html @@ -0,0 +1,13 @@ +<?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/. --> + +<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en"> +<head> + <title>XHTML Test Page</title> +</head> +<body> + <p><a href="resultPage.html" onClick='javascript:window.open("resultPage.html",null, "menubar=0,location=1,resizable=1,scrollbars=1,status=0,width=700,height=375");' name="windowOne">Open new window</a></p> +</body> +</html> diff --git a/testing/marionette/harness/marionette_harness/www/visibility.html b/testing/marionette/harness/marionette_harness/www/visibility.html new file mode 100644 index 0000000000..2296f3cd46 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/visibility.html @@ -0,0 +1,51 @@ +<?xml version="1.0"?> +<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en"> + <!-- 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/. --> +<head> + <title>Testing Visibility</title> +</head> + +<body> + +<div> + <span id="hideMe" onclick="this.style.display = 'none';">Click to hide me.</span> +</div> + +<div id="zero" style="width:0;height:0"> + <div> + <img src="map.png"> + </div> +</div> + +<p id="suppressedParagraph" style="display: none">A paragraph suppressed using CSS display=none</p> + +<div> + <p id="displayed">Displayed</p> + + <form action="#"><input type="hidden" name="hidden" /> </form> + + <p id="none" style="display: none;">Display set to none</p> + + <p id="hidden" style="visibility: hidden;">Hidden</p> + + <div id="hiddenparent" style="height: 2em; display: none;"> + <div id="hiddenchild"> + <a href="#" id="hiddenlink">ok</a> + </div> + </div> + + <div style="visibility: hidden;"> + <span> + <input id="unclickable" /> + <input type="checkbox" id="untogglable" checked="checked" />Check box you can't see + </span> + </div> + + <p id="outer" style="visibility: hidden">A <b id="visibleSubElement" style="visibility: visible">sub-element that is explicitly visible</b> using CSS visibility=visible</p> +</div> + +<input type='text' id='notDisplayed' style='display:none'> +</body> +</html> diff --git a/testing/marionette/harness/marionette_harness/www/white.png b/testing/marionette/harness/marionette_harness/www/white.png Binary files differnew file mode 100644 index 0000000000..8a68c11548 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/white.png diff --git a/testing/marionette/harness/marionette_harness/www/windowHandles.html b/testing/marionette/harness/marionette_harness/www/windowHandles.html new file mode 100644 index 0000000000..bcd0b08dc3 --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/windowHandles.html @@ -0,0 +1,16 @@ +<!-- 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 html> +<html> +<head> +<title>Marionette New Tab Link</title> +</head> +<body> + <a href="empty.html" id="new-tab" target="_blank">New Tab</a> + <a href="about:blank" id="new-blank-tab" target="_blank">New blank Tab</a> + + <a href="" id="new-window" onClick='javascript:window.open("empty.html", null, "location=1,toolbar=1");'>New Window</a> +</body> +</html> diff --git a/testing/marionette/harness/marionette_harness/www/xhtmlTest.html b/testing/marionette/harness/marionette_harness/www/xhtmlTest.html new file mode 100644 index 0000000000..30940c709e --- /dev/null +++ b/testing/marionette/harness/marionette_harness/www/xhtmlTest.html @@ -0,0 +1,79 @@ +<?xml version="1.0"?> +<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en"> + <!-- 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/. --> +<head> + <title>XHTML Test Page</title> +</head> +<body> +<div class="navigation"> + <p><a href="resultPage.html" target="result" name="windowOne">Open new window</a></p> + <p><a href="iframes.html" target="_blank" name="windowTwo">Create a new anonymous window</a></p> + <p><a href="test_iframe.html" name="sameWindow">Open page with iframes in same window</a></p> + <p><a href="test.html" target="result" name="windowThree">Open a window with a close button</a></p> +</div> + +<a name="notext"><b></b></a> + +<div class="content"> + <h1 class="header">XHTML Might Be The Future</h1> + + <p>If you'd like to go elsewhere then <a href="resultPage.html">click me</a>.</p> + + <p>Alternatively, <a href="resultPage.html" id="linkId">this goes to the same place</a>.</p> + + <form name="someForm"> + <input id="username" type="text" value="change"/> + </form> + + This link has the same text as another link: <a href="resultPage.html">click me</a>. +</div> + +<div class="extraDiv">Another div starts here.<p/> + <h2 class="nameA nameBnoise nameC">An H2 title</h2> + <p class="nameC">Some more text</p> +</div> + +<div> + <a id="id1" href="#">Foo</a> + <ul id="id2" /> + <span id="id3"/> +</div> + +<div> + <table id="table" ></table> +</div> + +<span id="amazing"> +<div> + <div> + <div> + <span/> + <a>I have width</a> + </div> + </div> +</div> +</span> + +<a name="text" /> +<p id="spaces"> </p> +<p id="empty"></p> +<a href="foo" id="linkWithEqualsSign">Link=equalssign</a> + +<p class=" spaceAround ">Spaced out</p> + +<span id="my_span"> + <div>first_div</div> + <div>second_div</div> + <span>first_span</span> + <span>second_span</span> +</span> + +<div id="parent">I'm a parent + <div id="child">I'm a child</div> +</div> + +<div id="only-exists-on-xhtmltest">Woo woo</div> +</body> +</html> diff --git a/testing/marionette/harness/requirements.txt b/testing/marionette/harness/requirements.txt new file mode 100644 index 0000000000..aa307b196d --- /dev/null +++ b/testing/marionette/harness/requirements.txt @@ -0,0 +1,15 @@ +browsermob-proxy >= 0.8.0 +manifestparser >= 1.1 +marionette-driver >= 3.0.0 +mozcrash >= 2.0 +mozdevice >= 4.0.0,<5 +mozinfo >= 1.0.0 +mozlog >= 6.0 +moznetwork >= 0.27 +mozprocess >= 1.0.0 +mozprofile >= 2.2.0 +mozrunner >= 7.4.0 +moztest >= 0.8 +mozversion >= 2.1.0 +six +wptserve >= 2.0.0 diff --git a/testing/marionette/harness/setup.py b/testing/marionette/harness/setup.py new file mode 100644 index 0000000000..c3fc1595a0 --- /dev/null +++ b/testing/marionette/harness/setup.py @@ -0,0 +1,61 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +from __future__ import absolute_import + +import os +import re + +from setuptools import find_packages, setup + + +THIS_DIR = os.path.dirname(os.path.realpath(__name__)) + + +def read(*parts): + with open(os.path.join(THIS_DIR, *parts)) as f: + return f.read() + + +def get_version(): + return re.findall( + '__version__ = "([\d\.]+)"', read("marionette_harness", "__init__.py"), re.M + )[0] + + +setup( + name="marionette-harness", + version=get_version(), + description="Marionette test automation harness", + long_description=open("README.rst").read(), + # Get strings from http://pypi.python.org/pypi?%3Aaction=list_classifiers + classifiers=[ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)", + "Operating System :: MacOS :: MacOS X", + "Operating System :: Microsoft :: Windows", + "Operating System :: POSIX", + "Topic :: Software Development :: Quality Assurance", + "Topic :: Software Development :: Testing", + "Topic :: Utilities", + "Programming Language :: Python", + "Programming Language :: Python :: 2.7", + ], + keywords="mozilla", + author="Auto-tools", + author_email="tools-marionette@lists.mozilla.org", + url="https://wiki.mozilla.org/Auto-tools/Projects/Marionette", + license="Mozilla Public License 2.0 (MPL 2.0)", + packages=find_packages(), + # Needed to include package data as specified in MANIFEST.in + include_package_data=True, + install_requires=read("requirements.txt").splitlines(), + zip_safe=False, + entry_points=""" + # -*- Entry points: -*- + [console_scripts] + marionette = marionette_harness.runtests:cli + """, +) diff --git a/testing/marionette/interaction.js b/testing/marionette/interaction.js new file mode 100644 index 0000000000..519d2bb1c6 --- /dev/null +++ b/testing/marionette/interaction.js @@ -0,0 +1,771 @@ +/* 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 */ + +"use strict"; + +const EXPORTED_SYMBOLS = ["interaction"]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + Preferences: "resource://gre/modules/Preferences.jsm", + + accessibility: "chrome://marionette/content/accessibility.js", + atom: "chrome://marionette/content/atom.js", + element: "chrome://marionette/content/element.js", + error: "chrome://marionette/content/error.js", + event: "chrome://marionette/content/event.js", + Log: "chrome://marionette/content/log.js", + pprint: "chrome://marionette/content/format.js", + TimedPromise: "chrome://marionette/content/sync.js", +}); + +XPCOMUtils.defineLazyGlobalGetters(this, ["File"]); + +XPCOMUtils.defineLazyGetter(this, "logger", () => Log.get()); + +/** 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 */ +this.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 = accessibility.get(strict); + if (element.isXULElement(el)) { + await chromeClick(el, a11y); + } else if (specCompat) { + await webdriverClickElement(el, a11y); + } else { + 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 error.InvalidArgumentError( + "Cannot click <input type=file> elements" + ); + } + + let containerEl = element.getContainer(el); + + // step 4 + if (!element.isInView(containerEl)) { + element.scrollIntoView(containerEl); + } + + // step 5 + // TODO(ato): wait for containerEl to be in view + + // step 6 + // if we cannot bring the container element into the viewport + // there is no point in checking if it is pointer-interactable + if (!element.isInView(containerEl)) { + throw new error.ElementNotInteractableError( + pprint`Element ${el} could not be scrolled into view` + ); + } + + // step 7 + let rects = containerEl.getClientRects(); + let clickPoint = element.getInViewCentrePoint(rects[0], win); + + if (element.isObscured(containerEl)) { + throw new error.ElementClickInterceptedError(containerEl, clickPoint); + } + + let acc = await a11y.getAccessible(el, true); + a11y.assertVisible(acc, el, true); + a11y.assertEnabled(acc, el, true); + a11y.assertActionable(acc, el); + + // step 8 + if (el.localName == "option") { + interaction.selectOption(el); + } else { + // step 9 + let clicked = interaction.flushEventLoop(containerEl); + + // Synthesize a pointerMove action. + event.synthesizeMouseAtPoint( + clickPoint.x, + clickPoint.y, + { + type: "mousemove", + // Remove buttons attribute with https://bugzilla.mozilla.org/show_bug.cgi?id=1686361 + buttons: 0, + }, + win + ); + + // Synthesize a pointerDown + pointerUp action. + event.synthesizeMouseAtPoint(clickPoint.x, clickPoint.y, {}, win); + + await clicked; + } + + // step 10 + // if the click causes navigation, the post-navigation checks are + // handled by the load listener in listener.js +} + +async function chromeClick(el, a11y) { + if (!atom.isElementEnabled(el)) { + throw new error.InvalidElementStateError("Element is not enabled"); + } + + let acc = await a11y.getAccessible(el, true); + a11y.assertVisible(acc, el, true); + a11y.assertEnabled(acc, el, true); + a11y.assertActionable(acc, el); + + if (el.localName == "option") { + interaction.selectOption(el); + } else { + el.click(); + } +} + +async function seleniumClickElement(el, a11y) { + let win = getWindow(el); + + let visibilityCheckEl = el; + if (el.localName == "option") { + visibilityCheckEl = element.getContainer(el); + } + + if (!element.isVisible(visibilityCheckEl)) { + throw new error.ElementNotInteractableError(); + } + + if (!atom.isElementEnabled(el)) { + throw new error.InvalidElementStateError("Element is not enabled"); + } + + let acc = await a11y.getAccessible(el, true); + a11y.assertVisible(acc, el, true); + a11y.assertEnabled(acc, el, true); + a11y.assertActionable(acc, el); + + if (el.localName == "option") { + interaction.selectOption(el); + } else { + let rects = el.getClientRects(); + let centre = element.getInViewCentrePoint(rects[0], win); + let opts = {}; + event.synthesizeMouseAtPoint(centre.x, centre.y, opts, win); + } +} + +/** + * Select <tt><option></tt> element in a <tt><select></tt> + * list. + * + * Because the dropdown list of select elements are implemented using + * native widget technology, our trusted synthesised events are not able + * to reach them. Dropdowns are instead handled mimicking DOM events, + * which for obvious reasons is not ideal, but at the current point in + * time considered to be good enough. + * + * @param {HTMLOptionElement} option + * Option element to select. + * + * @throws {TypeError} + * If <var>el</var> is a XUL element or not an <tt><option></tt> + * element. + * @throws {Error} + * If unable to find <var>el</var>'s parent <tt><select></tt> + * element. + */ +interaction.selectOption = function(el) { + if (element.isXULElement(el)) { + throw new TypeError("XUL dropdowns not supported"); + } + if (el.localName != "option") { + throw new TypeError(pprint`Expected <option> element, got ${el}`); + } + + let containerEl = element.getContainer(el); + + event.mouseover(containerEl); + event.mousemove(containerEl); + 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; + } + event.input(containerEl); + event.change(containerEl); + } + + event.mouseup(containerEl); + 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 (element.isDisabled(el)) { + throw new error.InvalidElementStateError( + pprint`Element is disabled: ${el}` + ); + } + if (element.isReadOnly(el)) { + throw new error.InvalidElementStateError( + pprint`Element is read-only: ${el}` + ); + } + if (!element.isEditable(el)) { + throw new error.InvalidElementStateError( + pprint`Unable to clear element that cannot be edited: ${el}` + ); + } + + if (!element.isInView(el)) { + element.scrollIntoView(el); + } + if (!element.isInView(el)) { + throw new error.ElementNotInteractableError( + pprint`Element ${el} could not be scrolled into view` + ); + } + + if (element.isEditingHost(el)) { + clearContentEditableElement(el); + } else { + clearResettableElement(el); + } +}; + +function clearContentEditableElement(el) { + if (el.innerHTML === "") { + return; + } + el.focus(); + el.innerHTML = ""; + event.change(el); + el.blur(); +} + +function clearResettableElement(el) { + if (!element.isMutableFormControl(el)) { + throw new error.InvalidElementStateError( + pprint`Not an editable form control: ${el}` + ); + } + + let isEmpty; + switch (el.type) { + case "file": + isEmpty = el.files.length == 0; + break; + + default: + isEmpty = el.value === ""; + break; + } + + if (el.validity.valid && isEmpty) { + return; + } + + el.focus(); + el.value = ""; + event.change(el); + el.blur(); +} + +/** + * Waits until the event loop has spun enough times to process the + * DOM events generated by clicking an element, or until the document + * is unloaded. + * + * @param {Element} el + * Element that is expected to receive the click. + * + * @return {Promise} + * Promise is resolved once <var>el</var> has been clicked + * (its <code>click</code> event fires), the document is unloaded, + * or a 500 ms timeout is reached. + */ +interaction.flushEventLoop = async function(el) { + const win = el.ownerGlobal; + let unloadEv, clickEv; + + let spinEventLoop = resolve => { + unloadEv = resolve; + clickEv = event => { + logger.trace(`Received DOM event click for ${event.target}`); + if (win.closed) { + resolve(); + } else { + win.setTimeout(resolve, 0); + } + }; + + win.addEventListener("unload", unloadEv, { mozSystemGroup: true }); + el.addEventListener("click", clickEv, { mozSystemGroup: true }); + }; + let removeListeners = () => { + // only one event fires + win.removeEventListener("unload", unloadEv); + el.removeEventListener("click", clickEv); + }; + + return new TimedPromise(spinEventLoop, { timeout: 500, throws: null }).then( + removeListeners + ); +}; + +/** + * If <var>el<var> is a textual form control 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 for the cursor to move + * be moved. + * + * @param {Element} el + * Element to potential move the caret in. + */ +interaction.moveCaretToEnd = function(el) { + if (!element.isDOMElement(el)) { + return; + } + + let isTextarea = el.localName == "textarea"; + let isInputText = el.localName == "input" && el.type == "text"; + + if (isTextarea || isInputText) { + if (el.selectionEnd == 0) { + let len = el.value.length; + el.setSelectionRange(len, len); + } + } +}; + +/** + * Performs checks if <var>el</var> is keyboard-interactable. + * + * To decide if an element is keyboard-interactable various properties, + * and computed CSS styles have to be evaluated. Whereby it has to be taken + * into account that the element can be part of a container (eg. option), + * and as such the container has to be checked instead. + * + * @param {Element} el + * Element to check. + * + * @return {boolean} + * True if element is keyboard-interactable, false otherwise. + */ +interaction.isKeyboardInteractable = function(el) { + const win = getWindow(el); + + // body and document element are always keyboard-interactable + if (el.localName === "body" || el === win.document.documentElement) { + return true; + } + + // context menu popups do not take the focus from the document. + const menuPopup = el.closest("menupopup"); + if (menuPopup) { + if (menuPopup.state !== "open") { + // closed menupopups are not keyboard interactable. + return false; + } + + const menuItem = el.closest("menuitem"); + if (menuItem) { + // hidden or disabled menu items are not keyboard interactable. + return !menuItem.disabled && !menuItem.hidden; + } + + return true; + } + + el.focus(); + return el === win.document.activeElement; +}; + +/** + * 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 error.InvalidArgumentError( + 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 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; + } + + event.input(el); + event.change(el); +}; + +/** + * Send keys to element. + * + * @param {DOMElement|XULElement} el + * Element to send key events to. + * @param {Array.<string>} value + * Sequence of keystrokes to send to the element. + * @param {boolean=} strictFileInteractability + * Run interactability checks on `<input type=file>` elements. + * @param {boolean=} accessibilityChecks + * Enforce strict accessibility tests. + * @param {boolean=} webdriverClick + * Use WebDriver specification compatible interactability definition. + */ +interaction.sendKeysToElement = async function( + el, + value, + { + strictFileInteractability = false, + accessibilityChecks = false, + webdriverClick = false, + } = {} +) { + const a11y = 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 = element.getContainer(el); + + // TODO: Wait for element to be keyboard-interactible + if (!interaction.isKeyboardInteractable(containerEl)) { + throw new error.ElementNotInteractableError( + pprint`Element ${el} is not reachable by keyboard` + ); + } + } + + let acc = await a11y.getAccessible(el, true); + a11y.assertActionable(acc, el); + + el.focus(); + interaction.moveCaretToEnd(el); + + if (el.type == "file") { + let paths = value.split("\n"); + await interaction.uploadFiles(el, paths); + + event.input(el); + event.change(el); + } else if (el.type == "date" || el.type == "time") { + interaction.setFormControlValue(el, value); + } else { + event.sendKeysToElement(value, el, win); + } +} + +async function legacySendKeysToElement(el, value, a11y) { + const win = getWindow(el); + + if (el.type == "file") { + el.focus(); + await interaction.uploadFiles(el, [value]); + + event.input(el); + event.change(el); + } else if (el.type == "date" || el.type == "time") { + interaction.setFormControlValue(el, value); + } else { + let visibilityCheckEl = el; + if (el.localName == "option") { + visibilityCheckEl = element.getContainer(el); + } + + if (!element.isVisible(visibilityCheckEl)) { + throw new error.ElementNotInteractableError("Element is not visible"); + } + + let acc = await a11y.getAccessible(el, true); + a11y.assertActionable(acc, el); + + interaction.moveCaretToEnd(el); + el.focus(); + event.sendKeysToElement(value, el, win); + } +} + +/** + * Determine the element displayedness of an element. + * + * @param {DOMElement|XULElement} el + * Element to determine displayedness of. + * @param {boolean=} [strict=false] strict + * Enforce strict accessibility tests. + * + * @return {boolean} + * True if element is displayed, false otherwise. + */ +interaction.isElementDisplayed = function(el, strict = false) { + let win = getWindow(el); + let displayed = atom.isElementDisplayed(el, win); + + let a11y = accessibility.get(strict); + return a11y.getAccessible(el).then(acc => { + a11y.assertVisible(acc, el, displayed); + return displayed; + }); +}; + +/** + * Check if element is enabled. + * + * @param {DOMElement|XULElement} el + * Element to test if is enabled. + * + * @return {boolean} + * True if enabled, false otherwise. + */ +interaction.isElementEnabled = function(el, strict = false) { + let enabled = true; + let win = getWindow(el); + + if (element.isXULElement(el)) { + // check if XUL element supports disabled attribute + if (DISABLED_ATTRIBUTE_SUPPORTED_XUL.has(el.tagName.toUpperCase())) { + if ( + el.hasAttribute("disabled") && + el.getAttribute("disabled") === "true" + ) { + enabled = false; + } + } + } else if ( + ["application/xml", "text/xml"].includes(win.document.contentType) + ) { + enabled = false; + } else { + enabled = atom.isElementEnabled(el, { frame: win }); + } + + let a11y = accessibility.get(strict); + return a11y.getAccessible(el).then(acc => { + a11y.assertEnabled(acc, el, enabled); + return enabled; + }); +}; + +/** + * Determines if the referenced element is selected or not, with + * an additional accessibility check if <var>strict</var> is true. + * + * This operation only makes sense on input elements of the checkbox- + * and radio button states, and option elements. + * + * @param {(DOMElement|XULElement)} el + * Element to test if is selected. + * @param {boolean=} [strict=false] strict + * Enforce strict accessibility tests. + * + * @return {boolean} + * True if element is selected, false otherwise. + * + * @throws {ElementNotAccessibleError} + * If <var>el</var> is not accessible when <var>strict</var> is true. + */ +interaction.isElementSelected = function(el, strict = false) { + let selected = element.isSelected(el); + + let a11y = accessibility.get(strict); + return a11y.getAccessible(el).then(acc => { + a11y.assertSelected(acc, el, selected); + return selected; + }); +}; + +function getWindow(el) { + return el.ownerDocument.defaultView; // eslint-disable-line +} diff --git a/testing/marionette/jar.mn b/testing/marionette/jar.mn new file mode 100644 index 0000000000..d2f4145e04 --- /dev/null +++ b/testing/marionette/jar.mn @@ -0,0 +1,60 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +marionette.jar: +% content marionette %content/ + content/accessibility.js (accessibility.js) + content/action.js (action.js) + content/actors/MarionetteCommandsChild.jsm (actors/MarionetteCommandsChild.jsm) + content/actors/MarionetteCommandsParent.jsm (actors/MarionetteCommandsParent.jsm) + content/actors/MarionetteEventsChild.jsm (actors/MarionetteEventsChild.jsm) + content/actors/MarionetteEventsParent.jsm (actors/MarionetteEventsParent.jsm) + content/actors/MarionetteReftestChild.jsm (actors/MarionetteReftestChild.jsm) + content/actors/MarionetteReftestParent.jsm (actors/MarionetteReftestParent.jsm) + content/addon.js (addon.js) + content/assert.js (assert.js) + content/atom.js (atom.js) + content/browser.js (browser.js) + content/capabilities.js (capabilities.js) + content/capture.js (capture.js) + content/cert.js (cert.js) + content/cookie.js (cookie.js) + content/dom.js (dom.js) + content/driver.js (driver.js) + content/element.js (element.js) + content/error.js (error.js) + content/evaluate.js (evaluate.js) + content/event.js (event.js) + content/format.js (format.js) + content/interaction.js (interaction.js) + content/l10n.js (l10n.js) + content/legacyaction.js (legacyaction.js) + content/listener.js (listener.js) + content/log.js (log.js) + content/message.js (message.js) + content/modal.js (modal.js) + content/navigate.js (navigate.js) + content/packets.js (packets.js) + content/prefs.js (prefs.js) + content/print.js (print.js) + content/proxy.js (proxy.js) + content/reftest.js (reftest.js) + content/reftest.xhtml (reftest.xhtml) + content/reftest-content.js (reftest-content.js) + content/server.js (server.js) + content/stream-utils.js (stream-utils.js) + content/sync.js (sync.js) + content/transport.js (transport.js) +#ifdef ENABLE_TESTS + content/test.xhtml (chrome/test.xhtml) + content/test2.xhtml (chrome/test2.xhtml) + content/test_dialog.dtd (chrome/test_dialog.dtd) + content/test_dialog.properties (chrome/test_dialog.properties) + content/test_dialog.xhtml (chrome/test_dialog.xhtml) + content/test_menupopup.xhtml (chrome/test_menupopup.xhtml) + content/test_nested_iframe.xhtml (chrome/test_nested_iframe.xhtml) +#ifdef MOZ_CODE_COVERAGE + content/PerTestCoverageUtils.jsm (../../tools/code-coverage/PerTestCoverageUtils.jsm) +#endif +#endif diff --git a/testing/marionette/l10n.js b/testing/marionette/l10n.js new file mode 100644 index 0000000000..0d3a5960d2 --- /dev/null +++ b/testing/marionette/l10n.js @@ -0,0 +1,109 @@ +/* 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 EXPORTED_SYMBOLS = ["l10n"]; + +/** + * 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 { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + error: "chrome://marionette/content/error.js", +}); + +XPCOMUtils.defineLazyGlobalGetters(this, ["DOMParser"]); +XPCOMUtils.defineLazyGetter(this, "domParser", () => { + const parser = new DOMParser(); + parser.forceEnableDTD(); + return parser; +}); + +/** @namespace */ +this.l10n = {}; + +/** + * Retrieve the localized string for the specified entity id. + * + * Example: + * localizeEntity(["chrome://branding/locale/brand.dtd"], "brandShortName") + * + * @param {Array.<string>} urls + * Array of .dtd URLs. + * @param {string} id + * The ID of the entity to retrieve the localized string for. + * + * @return {string} + * The localized string for the requested entity. + */ +l10n.localizeEntity = function(urls, id) { + // Build a string which contains all possible entity locations + let locations = []; + urls.forEach((url, index) => { + locations.push(`<!ENTITY % dtd_${index} SYSTEM "${url}">%dtd_${index};`); + }); + + // Use the DOM parser to resolve the entity and extract its real value + let header = `<?xml version="1.0"?><!DOCTYPE elem [${locations.join("")}]>`; + let elem = `<elem id="elementID">&${id};</elem>`; + let doc = domParser.parseFromString(header + elem, "text/xml"); + let element = doc.querySelector("elem[id='elementID']"); + + if (element === null) { + throw new error.NoSuchElementError( + `Entity with id='${id}' hasn't been found` + ); + } + + return element.textContent; +}; + +/** + * Retrieve the localized string for the specified property id. + * + * Example: + * + * localizeProperty( + * ["chrome://global/locale/findbar.properties"], "FastFind"); + * + * @param {Array.<string>} urls + * Array of .properties URLs. + * @param {string} id + * The ID of the property to retrieve the localized string for. + * + * @return {string} + * The localized string for the requested property. + */ +l10n.localizeProperty = function(urls, id) { + let property = null; + + for (let url of urls) { + let bundle = Services.strings.createBundle(url); + try { + property = bundle.GetStringFromName(id); + break; + } catch (e) {} + } + + if (property === null) { + throw new error.NoSuchElementError( + `Property with ID '${id}' hasn't been found` + ); + } + + return property; +}; diff --git a/testing/marionette/legacyaction.js b/testing/marionette/legacyaction.js new file mode 100644 index 0000000000..908c5929a7 --- /dev/null +++ b/testing/marionette/legacyaction.js @@ -0,0 +1,630 @@ +/* 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 */ + +"use strict"; + +const EXPORTED_SYMBOLS = ["legacyaction"]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + Preferences: "resource://gre/modules/Preferences.jsm", + + accessibility: "chrome://marionette/content/accessibility.js", + element: "chrome://marionette/content/element.js", + error: "chrome://marionette/content/error.js", + evaluate: "chrome://marionette/content/evaluate.js", + event: "chrome://marionette/content/event.js", + Log: "chrome://marionette/content/log.js", + WebElement: "chrome://marionette/content/element.js", +}); + +XPCOMUtils.defineLazyGetter(this, "logger", () => Log.get()); + +const CONTEXT_MENU_DELAY_PREF = "ui.click_hold_context_menus.delay"; +const DEFAULT_CONTEXT_MENU_DELAY = 750; // ms + +/* global action */ +/** @namespace */ +this.legacyaction = this.action = {}; + +/** + * Functionality for (single finger) action chains. + */ +action.Chain = function() { + // for assigning unique ids to all touches + this.nextTouchId = 1000; + // keep track of active Touches + this.touchIds = {}; + // last touch for each fingerId + this.lastCoordinates = null; + this.isTap = false; + this.scrolling = false; + // whether to send mouse event + this.mouseEventsOnly = false; + this.checkTimer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + + // determines if we create touch events + this.inputSource = null; +}; + +/** + * Create a touch based event. + * + * @param {Element} elem + * The Element on which the touch event should be created. + * @param {Number} x + * x coordinate relative to the viewport. + * @param {Number} y + * y coordinate relative to the viewport. + * @param {Number} touchId + * Touch event id used by legacyactions. + */ +action.Chain.prototype.createATouch = function(elem, x, y, touchId) { + const doc = elem.ownerDocument; + const win = doc.defaultView; + const [ + clientX, + clientY, + pageX, + pageY, + screenX, + screenY, + ] = this.getCoordinateInfo(elem, x, y); + const atouch = doc.createTouch( + win, + elem, + touchId, + pageX, + pageY, + screenX, + screenY, + clientX, + clientY + ); + return atouch; +}; + +action.Chain.prototype.dispatchActions = function( + args, + touchId, + container, + seenEls +) { + this.seenEls = seenEls; + this.container = container; + let commandArray = evaluate.fromJSON(args, seenEls, container.frame); + + if (touchId == null) { + touchId = this.nextTouchId++; + } + + if (!container.frame.document.createTouch) { + this.mouseEventsOnly = true; + } + + let keyModifiers = { + shiftKey: false, + ctrlKey: false, + altKey: false, + metaKey: false, + }; + + return new Promise(resolve => { + this.actions(commandArray, touchId, 0, keyModifiers, resolve); + }).catch(this.resetValues.bind(this)); +}; + +/** + * This function emit mouse event. + * + * @param {Document} doc + * Current document. + * @param {string} type + * Type of event to dispatch. + * @param {number} clickCount + * Number of clicks, button notes the mouse button. + * @param {number} elClientX + * X coordinate of the mouse relative to the viewport. + * @param {number} elClientY + * Y coordinate of the mouse relative to the viewport. + * @param {Object} modifiers + * An object of modifier keys present. + */ +action.Chain.prototype.emitMouseEvent = function( + doc, + type, + elClientX, + elClientY, + button, + clickCount, + modifiers +) { + logger.debug( + `Emitting ${type} mouse event ` + + `at coordinates (${elClientX}, ${elClientY}) ` + + `relative to the viewport, ` + + `button: ${button}, ` + + `clickCount: ${clickCount}` + ); + + let win = doc.defaultView; + let domUtils = win.windowUtils; + + let mods; + if (typeof modifiers != "undefined") { + mods = event.parseModifiers_(modifiers); + } else { + mods = 0; + } + + domUtils.sendMouseEvent( + type, + elClientX, + elClientY, + button || 0, + clickCount || 1, + mods, + false, + 0, + this.inputSource + ); +}; + +action.Chain.prototype.emitTouchEvent = function(doc, type, touch) { + logger.info( + `Emitting Touch event of type ${type} ` + + `to element with id: ${touch.target.id} ` + + `and tag name: ${touch.target.tagName} ` + + `at coordinates (${touch.clientX}), ` + + `${touch.clientY}) relative to the viewport` + ); + + const win = doc.defaultView; + if (win.docShell.asyncPanZoomEnabled && this.scrolling) { + logger.debug( + `Cannot emit touch event with asyncPanZoomEnabled and legacyactions.scrolling` + ); + return; + } + + // we get here if we're not in asyncPacZoomEnabled land, or if we're + // the main process + win.windowUtils.sendTouchEvent( + type, + [touch.identifier], + [touch.clientX], + [touch.clientY], + [touch.radiusX], + [touch.radiusY], + [touch.rotationAngle], + [touch.force], + 0 + ); +}; + +/** + * Reset any persisted values after a command completes. + */ +action.Chain.prototype.resetValues = function() { + this.container = null; + this.seenEls = null; + this.mouseEventsOnly = false; +}; + +/** + * Function that performs a single tap. + */ +action.Chain.prototype.singleTap = async function( + el, + corx, + cory, + capabilities +) { + const doc = el.ownerDocument; + // after this block, the element will be scrolled into view + let visible = element.isVisible(el, corx, cory); + if (!visible) { + throw new error.ElementNotInteractableError( + "Element is not currently visible and may not be manipulated" + ); + } + + let a11y = accessibility.get(capabilities["moz:accessibilityChecks"]); + let acc = await a11y.getAccessible(el, true); + a11y.assertVisible(acc, el, visible); + a11y.assertActionable(acc, el); + if (!doc.createTouch) { + this.mouseEventsOnly = true; + } + let c = element.coordinates(el, corx, cory); + if (!this.mouseEventsOnly) { + let touchId = this.nextTouchId++; + let touch = this.createATouch(el, c.x, c.y, touchId); + this.emitTouchEvent(doc, "touchstart", touch); + this.emitTouchEvent(doc, "touchend", touch); + } + this.mouseTap(doc, c.x, c.y); +}; + +/** + * Emit events for each action in the provided chain. + * + * To emit touch events for each finger, one might send a [["press", id], + * ["wait", 5], ["release"]] chain. + * + * @param {Array.<Array<?>>} chain + * A multi-dimensional array of actions. + * @param {Object.<string, number>} touchId + * Represents the finger ID. + * @param {number} i + * Keeps track of the current action of the chain. + * @param {Object.<string, boolean>} keyModifiers + * Keeps track of keyDown/keyUp pairs through an action chain. + * @param {function(?)} cb + * Called on success. + * + * @return {Object.<string, number>} + * Last finger ID, or an empty object. + */ +action.Chain.prototype.actions = function(chain, touchId, i, keyModifiers, cb) { + if (i == chain.length) { + cb(touchId || null); + this.resetValues(); + return; + } + + let pack = chain[i]; + let command = pack[0]; + let webEl; + let el; + let c; + i++; + + if (!["press", "wait", "keyDown", "keyUp", "click"].includes(command)) { + // if mouseEventsOnly, then touchIds isn't used + if (!(touchId in this.touchIds) && !this.mouseEventsOnly) { + this.resetValues(); + throw new error.WebDriverError("Element has not been pressed"); + } + } + + switch (command) { + case "keyDown": + event.sendKeyDown(pack[1], keyModifiers, this.container.frame); + this.actions(chain, touchId, i, keyModifiers, cb); + break; + + case "keyUp": + event.sendKeyUp(pack[1], keyModifiers, this.container.frame); + this.actions(chain, touchId, i, keyModifiers, cb); + break; + + case "click": + webEl = WebElement.fromUUID(pack[1], "content"); + el = this.seenEls.get(webEl); + let button = pack[2]; + let clickCount = pack[3]; + c = element.coordinates(el); + this.mouseTap( + el.ownerDocument, + c.x, + c.y, + button, + clickCount, + keyModifiers + ); + if (button == 2) { + this.emitMouseEvent( + el.ownerDocument, + "contextmenu", + c.x, + c.y, + button, + clickCount, + keyModifiers + ); + } + this.actions(chain, touchId, i, keyModifiers, cb); + break; + + case "press": + if (this.lastCoordinates) { + this.generateEvents( + "cancel", + this.lastCoordinates[0], + this.lastCoordinates[1], + touchId, + null, + keyModifiers + ); + this.resetValues(); + throw new error.WebDriverError( + "Invalid Command: press cannot follow an active touch event" + ); + } + + // look ahead to check if we're scrolling, + // needed for APZ touch dispatching + if (i != chain.length && chain[i][0].includes("move")) { + this.scrolling = true; + } + webEl = WebElement.fromUUID(pack[1], "content"); + el = this.seenEls.get(webEl); + c = element.coordinates(el, pack[2], pack[3]); + touchId = this.generateEvents("press", c.x, c.y, null, el, keyModifiers); + this.actions(chain, touchId, i, keyModifiers, cb); + break; + + case "release": + this.generateEvents( + "release", + this.lastCoordinates[0], + this.lastCoordinates[1], + touchId, + null, + keyModifiers + ); + this.actions(chain, null, i, keyModifiers, cb); + this.scrolling = false; + break; + + case "move": + webEl = WebElement.fromUUID(pack[1], "content"); + el = this.seenEls.get(webEl); + c = element.coordinates(el); + this.generateEvents("move", c.x, c.y, touchId, null, keyModifiers); + this.actions(chain, touchId, i, keyModifiers, cb); + break; + + case "moveByOffset": + this.generateEvents( + "move", + this.lastCoordinates[0] + pack[1], + this.lastCoordinates[1] + pack[2], + touchId, + null, + keyModifiers + ); + this.actions(chain, touchId, i, keyModifiers, cb); + break; + + case "wait": + if (pack[1] != null) { + let time = pack[1] * 1000; + + // standard waiting time to fire contextmenu + let standard = Preferences.get( + CONTEXT_MENU_DELAY_PREF, + DEFAULT_CONTEXT_MENU_DELAY + ); + + if (time >= standard && this.isTap) { + chain.splice(i, 0, ["longPress"], ["wait", (time - standard) / 1000]); + time = standard; + } + this.checkTimer.initWithCallback( + () => this.actions(chain, touchId, i, keyModifiers, cb), + time, + Ci.nsITimer.TYPE_ONE_SHOT + ); + } else { + this.actions(chain, touchId, i, keyModifiers, cb); + } + break; + + case "cancel": + this.generateEvents( + "cancel", + this.lastCoordinates[0], + this.lastCoordinates[1], + touchId, + null, + keyModifiers + ); + this.actions(chain, touchId, i, keyModifiers, cb); + this.scrolling = false; + break; + + case "longPress": + this.generateEvents( + "contextmenu", + this.lastCoordinates[0], + this.lastCoordinates[1], + touchId, + null, + keyModifiers + ); + this.actions(chain, touchId, i, keyModifiers, cb); + break; + } +}; + +/** + * Given an element and a pair of coordinates, returns an array of the + * form [clientX, clientY, pageX, pageY, screenX, screenY]. + */ +action.Chain.prototype.getCoordinateInfo = function(el, corx, cory) { + let win = el.ownerGlobal; + return [ + corx, // clientX + cory, // clientY + corx + win.pageXOffset, // pageX + cory + win.pageYOffset, // pageY + corx + win.mozInnerScreenX, // screenX + cory + win.mozInnerScreenY, // screenY + ]; +}; + +/** + * @param {number} x + * X coordinate of the location to generate the event that is relative + * to the viewport. + * @param {number} y + * Y coordinate of the location to generate the event that is relative + * to the viewport. + */ +action.Chain.prototype.generateEvents = function( + type, + x, + y, + touchId, + target, + keyModifiers +) { + this.lastCoordinates = [x, y]; + let doc = this.container.frame.document; + + switch (type) { + case "tap": + if (this.mouseEventsOnly) { + let touch = this.createATouch(target, x, y, touchId); + this.mouseTap( + touch.target.ownerDocument, + touch.clientX, + touch.clientY, + null, + null, + keyModifiers + ); + } else { + touchId = this.nextTouchId++; + let touch = this.createATouch(target, x, y, touchId); + this.emitTouchEvent(doc, "touchstart", touch); + this.emitTouchEvent(doc, "touchend", touch); + this.mouseTap( + touch.target.ownerDocument, + touch.clientX, + touch.clientY, + null, + null, + keyModifiers + ); + } + this.lastCoordinates = null; + break; + + case "press": + this.isTap = true; + if (this.mouseEventsOnly) { + this.emitMouseEvent(doc, "mousemove", x, y, null, null, keyModifiers); + this.emitMouseEvent(doc, "mousedown", x, y, null, null, keyModifiers); + } else { + touchId = this.nextTouchId++; + let touch = this.createATouch(target, x, y, touchId); + this.emitTouchEvent(doc, "touchstart", touch); + this.touchIds[touchId] = touch; + return touchId; + } + break; + + case "release": + if (this.mouseEventsOnly) { + let [x, y] = this.lastCoordinates; + this.emitMouseEvent(doc, "mouseup", x, y, null, null, keyModifiers); + } else { + let touch = this.touchIds[touchId]; + let [x, y] = this.lastCoordinates; + + touch = this.createATouch(touch.target, x, y, touchId); + this.emitTouchEvent(doc, "touchend", touch); + + if (this.isTap) { + this.mouseTap( + touch.target.ownerDocument, + touch.clientX, + touch.clientY, + null, + null, + keyModifiers + ); + } + delete this.touchIds[touchId]; + } + + this.isTap = false; + this.lastCoordinates = null; + break; + + case "cancel": + this.isTap = false; + if (this.mouseEventsOnly) { + let [x, y] = this.lastCoordinates; + this.emitMouseEvent(doc, "mouseup", x, y, null, null, keyModifiers); + } else { + this.emitTouchEvent(doc, "touchcancel", this.touchIds[touchId]); + delete this.touchIds[touchId]; + } + this.lastCoordinates = null; + break; + + case "move": + this.isTap = false; + if (this.mouseEventsOnly) { + this.emitMouseEvent(doc, "mousemove", x, y, null, null, keyModifiers); + } else { + let touch = this.createATouch( + this.touchIds[touchId].target, + x, + y, + touchId + ); + this.touchIds[touchId] = touch; + this.emitTouchEvent(doc, "touchmove", touch); + } + break; + + case "contextmenu": + this.isTap = false; + let event = this.container.frame.document.createEvent("MouseEvents"); + if (this.mouseEventsOnly) { + target = doc.elementFromPoint( + this.lastCoordinates[0], + this.lastCoordinates[1] + ); + } else { + target = this.touchIds[touchId].target; + } + + let [clientX, clientY, , , screenX, screenY] = this.getCoordinateInfo( + target, + x, + y + ); + + event.initMouseEvent( + "contextmenu", + true, + true, + target.ownerGlobal, + 1, + screenX, + screenY, + clientX, + clientY, + false, + false, + false, + false, + 0, + null + ); + target.dispatchEvent(event); + break; + + default: + throw new error.WebDriverError("Unknown event type: " + type); + } + return null; +}; + +action.Chain.prototype.mouseTap = function(doc, x, y, button, count, mod) { + this.emitMouseEvent(doc, "mousemove", x, y, button, count, mod); + this.emitMouseEvent(doc, "mousedown", x, y, button, count, mod); + this.emitMouseEvent(doc, "mouseup", x, y, button, count, mod); +}; diff --git a/testing/marionette/listener.js b/testing/marionette/listener.js new file mode 100644 index 0000000000..49c6d3a3e3 --- /dev/null +++ b/testing/marionette/listener.js @@ -0,0 +1,1069 @@ +/* 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 */ +/* global XPCNativeWrapper */ +/* eslint-disable no-restricted-globals */ + +"use strict"; + +const winUtil = content.windowUtils; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + OS: "resource://gre/modules/osfile.jsm", + + accessibility: "chrome://marionette/content/accessibility.js", + action: "chrome://marionette/content/action.js", + atom: "chrome://marionette/content/atom.js", + ContentEventObserverService: "chrome://marionette/content/dom.js", + element: "chrome://marionette/content/element.js", + error: "chrome://marionette/content/error.js", + evaluate: "chrome://marionette/content/evaluate.js", + event: "chrome://marionette/content/event.js", + interaction: "chrome://marionette/content/interaction.js", + legacyaction: "chrome://marionette/content/legacyaction.js", + Log: "chrome://marionette/content/log.js", + MarionettePrefs: "chrome://marionette/content/prefs.js", + pprint: "chrome://marionette/content/format.js", + proxy: "chrome://marionette/content/proxy.js", + sandbox: "chrome://marionette/content/evaluate.js", + Sandboxes: "chrome://marionette/content/evaluate.js", + truncate: "chrome://marionette/content/format.js", + WebElement: "chrome://marionette/content/element.js", +}); + +XPCOMUtils.defineLazyGetter(this, "logger", () => Log.getWithPrefix(contentId)); +XPCOMUtils.defineLazyGlobalGetters(this, ["URL"]); + +const contentFrameMessageManager = this; +const contentId = content.docShell.browsingContext.id; + +const curContainer = { + _frame: null, + _parentFrame: null, + + get frame() { + return this._frame; + }, + + set frame(frame) { + this._frame = frame; + this._parentFrame = frame.parent; + this.id = frame.browsingContext.id; + }, + + get parentFrame() { + return this._parentFrame; + }, +}; + +// Listen for click event to indicate one click has happened, so actions +// code can send dblclick event +addEventListener("click", event.DoubleClickTracker.setClick); +addEventListener("dblclick", event.DoubleClickTracker.resetClick); +addEventListener("unload", event.DoubleClickTracker.resetClick, true); + +const seenEls = new element.Store(); + +let legacyactions = new legacyaction.Chain(); + +// last touch for each fingerId +let multiLast = {}; + +// sandbox storage and name of the current sandbox +const sandboxes = new Sandboxes(() => curContainer.frame); + +const eventObservers = new ContentEventObserverService( + content, + sendAsyncMessage.bind(this) +); + +// Eventually we will not have a closure for every single command, +// but use a generic dispatch for all listener commands. +// +// Worth nothing that this shares many characteristics with +// server.TCPConnection#execute. Perhaps this could be generalised +// at the point. +function dispatch(fn) { + if (typeof fn != "function") { + throw new TypeError("Provided dispatch handler is not a function"); + } + + return msg => { + const id = msg.json.commandID; + + let req = new Promise(resolve => { + const args = evaluate.fromJSON(msg.json, seenEls, curContainer.frame); + + let rv; + if (typeof args == "undefined" || args instanceof Array) { + rv = fn.apply(null, args); + } else { + rv = fn(args); + } + resolve(rv); + }); + + req + .then( + rv => sendResponse(rv, id), + err => sendError(err, id) + ) + .catch(err => sendError(err, id)); + }; +} + +let clickElementFn = dispatch(clickElement); +let getActiveElementFn = dispatch(getActiveElement); +let getBrowsingContextIdFn = dispatch(getBrowsingContextId); +let getCurrentUrlFn = dispatch(getCurrentUrl); +let getElementAttributeFn = dispatch(getElementAttribute); +let getElementPropertyFn = dispatch(getElementProperty); +let getElementTextFn = dispatch(getElementText); +let getElementTagNameFn = dispatch(getElementTagName); +let getElementRectFn = dispatch(getElementRect); +let getPageSourceFn = dispatch(getPageSource); +let getScreenshotRectFn = dispatch(getScreenshotRect); +let isElementEnabledFn = dispatch(isElementEnabled); +let findElementContentFn = dispatch(findElementContent); +let findElementsContentFn = dispatch(findElementsContent); +let isElementSelectedFn = dispatch(isElementSelected); +let clearElementFn = dispatch(clearElement); +let isElementDisplayedFn = dispatch(isElementDisplayed); +let getElementValueOfCssPropertyFn = dispatch(getElementValueOfCssProperty); +let singleTapFn = dispatch(singleTap); +let performActionsFn = dispatch(performActions); +let releaseActionsFn = dispatch(releaseActions); +let actionChainFn = dispatch(actionChain); +let multiActionFn = dispatch(multiAction); +let executeScriptFn = dispatch(executeScript); +let sendKeysToElementFn = dispatch(sendKeysToElement); + +function startListeners() { + eventDispatcher.enable(); + + addMessageListener("Marionette:actionChain", actionChainFn); + addMessageListener("Marionette:clearElement", clearElementFn); + addMessageListener("Marionette:clickElement", clickElementFn); + addMessageListener("Marionette:Deregister", deregister); + addMessageListener("Marionette:DOM:AddEventListener", domAddEventListener); + addMessageListener( + "Marionette:DOM:RemoveEventListener", + domRemoveEventListener + ); + addMessageListener("Marionette:executeScript", executeScriptFn); + addMessageListener("Marionette:findElementContent", findElementContentFn); + addMessageListener("Marionette:findElementsContent", findElementsContentFn); + addMessageListener("Marionette:getActiveElement", getActiveElementFn); + addMessageListener("Marionette:getBrowsingContextId", getBrowsingContextIdFn); + addMessageListener("Marionette:getCurrentUrl", getCurrentUrlFn); + addMessageListener("Marionette:getElementAttribute", getElementAttributeFn); + addMessageListener("Marionette:getElementProperty", getElementPropertyFn); + addMessageListener("Marionette:getElementRect", getElementRectFn); + addMessageListener("Marionette:getElementTagName", getElementTagNameFn); + addMessageListener("Marionette:getElementText", getElementTextFn); + addMessageListener( + "Marionette:getElementValueOfCssProperty", + getElementValueOfCssPropertyFn + ); + addMessageListener("Marionette:getPageSource", getPageSourceFn); + addMessageListener("Marionette:getScreenshotRect", getScreenshotRectFn); + addMessageListener("Marionette:isElementDisplayed", isElementDisplayedFn); + addMessageListener("Marionette:isElementEnabled", isElementEnabledFn); + addMessageListener("Marionette:isElementSelected", isElementSelectedFn); + addMessageListener("Marionette:multiAction", multiActionFn); + addMessageListener("Marionette:performActions", performActionsFn); + addMessageListener("Marionette:releaseActions", releaseActionsFn); + addMessageListener("Marionette:sendKeysToElement", sendKeysToElementFn); + addMessageListener("Marionette:Session:Delete", deleteSession); + addMessageListener("Marionette:singleTap", singleTapFn); + addMessageListener("Marionette:switchToFrame", switchToFrame); + addMessageListener("Marionette:switchToParentFrame", switchToParentFrame); +} + +function deregister() { + eventDispatcher.disable(); + + removeMessageListener("Marionette:actionChain", actionChainFn); + removeMessageListener("Marionette:clearElement", clearElementFn); + removeMessageListener("Marionette:clickElement", clickElementFn); + removeMessageListener("Marionette:Deregister", deregister); + removeMessageListener("Marionette:executeScript", executeScriptFn); + removeMessageListener("Marionette:findElementContent", findElementContentFn); + removeMessageListener( + "Marionette:findElementsContent", + findElementsContentFn + ); + removeMessageListener("Marionette:getActiveElement", getActiveElementFn); + removeMessageListener( + "Marionette:getBrowsingContextId", + getBrowsingContextIdFn + ); + removeMessageListener("Marionette:getCurrentUrl", getCurrentUrlFn); + removeMessageListener( + "Marionette:getElementAttribute", + getElementAttributeFn + ); + removeMessageListener("Marionette:getElementProperty", getElementPropertyFn); + removeMessageListener("Marionette:getElementRect", getElementRectFn); + removeMessageListener("Marionette:getElementTagName", getElementTagNameFn); + removeMessageListener("Marionette:getElementText", getElementTextFn); + removeMessageListener( + "Marionette:getElementValueOfCssProperty", + getElementValueOfCssPropertyFn + ); + removeMessageListener("Marionette:getPageSource", getPageSourceFn); + removeMessageListener("Marionette:getScreenshotRect", getScreenshotRectFn); + removeMessageListener("Marionette:isElementDisplayed", isElementDisplayedFn); + removeMessageListener("Marionette:isElementEnabled", isElementEnabledFn); + removeMessageListener("Marionette:isElementSelected", isElementSelectedFn); + removeMessageListener("Marionette:multiAction", multiActionFn); + removeMessageListener("Marionette:performActions", performActionsFn); + removeMessageListener("Marionette:releaseActions", releaseActionsFn); + removeMessageListener("Marionette:sendKeysToElement", sendKeysToElementFn); + removeMessageListener("Marionette:Session:Delete", deleteSession); + removeMessageListener("Marionette:singleTap", singleTapFn); + removeMessageListener("Marionette:switchToFrame", switchToFrame); + removeMessageListener("Marionette:switchToParentFrame", switchToParentFrame); +} + +function deleteSession() { + seenEls.clear(); + + // reset container frame to the top-most frame + curContainer.frame = content; + curContainer.frame.focus(); + + legacyactions.touchIds = {}; +} + +/** + * Send asynchronous reply to chrome. + * + * @param {UUID} uuid + * Unique identifier of the request. + * @param {AsyncContentSender.ResponseType} type + * Type of response. + * @param {*} [Object] data + * JSON serialisable object to accompany the message. Defaults to + * an empty dictionary. + */ +let sendToServer = (uuid, data = undefined) => { + let channel = new proxy.AsyncMessageChannel(sendAsyncMessage.bind(this)); + channel.reply(uuid, data); +}; + +/** + * Send asynchronous reply with value to chrome. + * + * @param {Object} obj + * JSON serialisable object of arbitrary type and complexity. + * @param {UUID} uuid + * Unique identifier of the request. + */ +function sendResponse(obj, uuid) { + let payload = evaluate.toJSON(obj, seenEls); + sendToServer(uuid, payload); +} + +/** + * Send asynchronous reply to chrome. + * + * @param {UUID} uuid + * Unique identifier of the request. + */ +function sendOk(uuid) { + sendToServer(uuid); +} + +/** + * Send asynchronous error reply to chrome. + * + * @param {Error} err + * Error to notify chrome of. + * @param {UUID} uuid + * Unique identifier of the request. + */ +function sendError(err, uuid) { + sendToServer(uuid, err); +} + +async function executeScript(script, args, opts = {}) { + let sb; + + if (opts.useSandbox) { + sb = sandboxes.get(opts.sandboxName, opts.newSandbox); + } else { + sb = sandbox.createMutable(curContainer.frame); + } + + return evaluate.sandbox(sb, script, args, opts); +} + +/** + * Function that performs a single tap. + */ +async function singleTap(el, corx, cory, capabilities) { + return legacyactions.singleTap(el, corx, cory, capabilities); +} + +/** + * Perform a series of grouped actions at the specified points in time. + * + * @param {Object} msg + * Object with an |actions| attribute that is an Array of objects + * each of which represents an action sequence. + * @param {Object} capabilities + * Object with a list of WebDriver session capabilities. + */ +async function performActions(msg, capabilities) { + let chain = action.Chain.fromJSON(msg.actions); + await action.dispatch( + chain, + curContainer.frame, + !capabilities["moz:useNonSpecCompliantPointerOrigin"] + ); +} + +/** + * 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 function releaseActions() { + await action.dispatchTickActions( + action.inputsToCancel.reverse(), + 0, + curContainer.frame + ); + action.inputsToCancel.length = 0; + action.inputStateMap.clear(); + + event.DoubleClickTracker.resetClick(); +} + +/** + * Start action chain on one finger. + */ +function actionChain(chain, touchId) { + return legacyactions.dispatchActions(chain, touchId, curContainer, seenEls); +} + +function emitMultiEvents(type, touch, touches) { + let target = touch.target; + let doc = target.ownerDocument; + let win = doc.defaultView; + // touches that are in the same document + let documentTouches = doc.createTouchList( + touches.filter(function(t) { + return t.target.ownerDocument === doc && type != "touchcancel"; + }) + ); + // touches on the same target + let targetTouches = doc.createTouchList( + touches.filter(function(t) { + return ( + t.target === target && (type != "touchcancel" || type != "touchend") + ); + }) + ); + // Create changed touches + let changedTouches = doc.createTouchList(touch); + // Create the event object + let event = doc.createEvent("TouchEvent"); + event.initTouchEvent( + type, + true, + true, + win, + 0, + false, + false, + false, + false, + documentTouches, + targetTouches, + changedTouches + ); + target.dispatchEvent(event); +} + +function setDispatch(batches, touches, batchIndex = 0) { + // check if all the sets have been fired + if (batchIndex >= batches.length) { + multiLast = {}; + return; + } + + // a set of actions need to be done + let batch = batches[batchIndex]; + // each action for some finger + let pack; + // the touch id for the finger (pack) + let touchId; + // command for the finger + let command; + // touch that will be created for the finger + let el; + let touch; + let lastTouch; + let touchIndex; + let waitTime = 0; + let maxTime = 0; + let c; + + // loop through the batch + batchIndex++; + for (let i = 0; i < batch.length; i++) { + pack = batch[i]; + touchId = pack[0]; + command = pack[1]; + + switch (command) { + case "press": + el = seenEls.get(pack[2], curContainer.frame); + c = element.coordinates(el, pack[3], pack[4]); + touch = legacyactions.createATouch(el, c.x, c.y, touchId); + multiLast[touchId] = touch; + touches.push(touch); + emitMultiEvents("touchstart", touch, touches); + break; + + case "release": + touch = multiLast[touchId]; + // the index of the previous touch for the finger may change in + // the touches array + touchIndex = touches.indexOf(touch); + touches.splice(touchIndex, 1); + emitMultiEvents("touchend", touch, touches); + break; + + case "move": + el = seenEls.get(pack[2], curContainer.frame); + c = element.coordinates(el); + touch = legacyactions.createATouch( + multiLast[touchId].target, + c.x, + c.y, + touchId + ); + touchIndex = touches.indexOf(lastTouch); + touches[touchIndex] = touch; + multiLast[touchId] = touch; + emitMultiEvents("touchmove", touch, touches); + break; + + case "moveByOffset": + el = multiLast[touchId].target; + lastTouch = multiLast[touchId]; + touchIndex = touches.indexOf(lastTouch); + let doc = el.ownerDocument; + let win = doc.defaultView; + // since x and y are relative to the last touch, therefore, + // it's relative to the position of the last touch + let clientX = lastTouch.clientX + pack[2]; + let clientY = lastTouch.clientY + pack[3]; + let pageX = clientX + win.pageXOffset; + let pageY = clientY + win.pageYOffset; + let screenX = clientX + win.mozInnerScreenX; + let screenY = clientY + win.mozInnerScreenY; + touch = doc.createTouch( + win, + el, + touchId, + pageX, + pageY, + screenX, + screenY, + clientX, + clientY + ); + touches[touchIndex] = touch; + multiLast[touchId] = touch; + emitMultiEvents("touchmove", touch, touches); + break; + + case "wait": + if (typeof pack[2] != "undefined") { + waitTime = pack[2] * 1000; + if (waitTime > maxTime) { + maxTime = waitTime; + } + } + break; + } + } + + if (maxTime != 0) { + let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + timer.initWithCallback( + function() { + setDispatch(batches, touches, batchIndex); + }, + maxTime, + Ci.nsITimer.TYPE_ONE_SHOT + ); + } else { + setDispatch(batches, touches, batchIndex); + } +} + +/** + * Start multi-action. + * + * @param {Number} maxLen + * Longest action chain for one finger. + */ +function multiAction(args, maxLen) { + // unwrap the original nested array + let commandArray = evaluate.fromJSON(args, seenEls, curContainer.frame); + let concurrentEvent = []; + let temp; + for (let i = 0; i < maxLen; i++) { + let row = []; + for (let j = 0; j < commandArray.length; j++) { + if (typeof commandArray[j][i] != "undefined") { + // add finger id to the front of each action, + // i.e. [finger_id, action, element] + temp = commandArray[j][i]; + temp.unshift(j); + row.push(temp); + } + } + concurrentEvent.push(row); + } + + // Now concurrent event is made of sets where each set contain a list + // of actions that need to be fired. + // + // But note that each action belongs to a different finger + // pendingTouches keeps track of current touches that's on the screen. + let pendingTouches = []; + setDispatch(concurrentEvent, pendingTouches); +} + +/** + * Get source of the current browsing context's DOM. + */ +function getPageSource() { + return curContainer.frame.document.documentElement.outerHTML; +} + +/** + * Find an element in the current browsing context's document using the + * given search strategy. + */ +async function findElementContent(strategy, selector, opts = {}) { + opts.all = false; + let el = await element.find(curContainer, strategy, selector, opts); + return seenEls.add(el); +} + +/** + * Find elements in the current browsing context's document using the + * given search strategy. + */ +async function findElementsContent(strategy, selector, opts = {}) { + opts.all = true; + let els = await element.find(curContainer, strategy, selector, opts); + let webEls = seenEls.addAll(els); + return webEls; +} + +/** + * Return the active element in the document. + * + * @return {WebElement} + * 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. + */ +function getActiveElement() { + let el = curContainer.frame.document.activeElement; + if (!el) { + throw new error.NoSuchElementError(); + } + return evaluate.toJSON(el, seenEls); +} + +/** + * Return the current browsing context id. + * + * @param {boolean=} topContext + * If set to true use the window's top-level browsing context, + * otherwise the one from the currently selected frame. Defaults to false. + * + * @return {number} + * Id of the browsing context. + */ +function getBrowsingContextId(topContext = false) { + const bc = curContainer.frame.docShell.browsingContext; + + return topContext ? bc.top.id : bc.id; +} + +/** + * Return the current visible URL. + * + * @return {string} + * Current visible URL. + */ +function getCurrentUrl() { + return content.location.href; +} + +/** + * Send click event to element. + * + * @param {WebElement} el + * Element to click. + * @param {Object} capabilities + * Object with a list of WebDriver session capabilities. + */ +function clickElement(el, capabilities) { + return interaction.clickElement( + el, + capabilities["moz:accessibilityChecks"], + capabilities["moz:webdriverClick"] + ); +} + +function getElementAttribute(el, name) { + if (element.isBooleanAttribute(el, name)) { + if (el.hasAttribute(name)) { + return "true"; + } + return null; + } + return el.getAttribute(name); +} + +function getElementProperty(el, name) { + return typeof el[name] != "undefined" ? el[name] : null; +} + +/** + * Get the text of this element. This includes text from child + * elements. + */ +function getElementText(el) { + return atom.getElementText(el, curContainer.frame); +} + +/** + * Get the tag name of an element. + * + * @param {WebElement} id + * Reference to web element. + * + * @return {string} + * Tag name of element. + */ +function getElementTagName(el) { + return el.tagName.toLowerCase(); +} + +/** + * Determine the element displayedness of the given web element. + * + * Also performs additional accessibility checks if enabled by session + * capability. + */ +function isElementDisplayed(el, capabilities) { + return interaction.isElementDisplayed( + el, + capabilities["moz:accessibilityChecks"] + ); +} + +/** + * Retrieves the computed value of the given CSS property of the given + * web element. + */ +function getElementValueOfCssProperty(el, prop) { + let st = curContainer.frame.document.defaultView.getComputedStyle(el); + return st.getPropertyValue(prop); +} + +/** + * Get the position and dimensions of the element. + * + * @return {Object.<string, number>} + * The x, y, width, and height properties of the element. + */ +function getElementRect(el) { + let clientRect = el.getBoundingClientRect(); + return { + x: clientRect.x + curContainer.frame.pageXOffset, + y: clientRect.y + curContainer.frame.pageYOffset, + width: clientRect.width, + height: clientRect.height, + }; +} + +function isElementEnabled(el, capabilities) { + return interaction.isElementEnabled( + el, + capabilities["moz:accessibilityChecks"] + ); +} + +/** + * Determines if the referenced element is selected or not. + * + * This operation only makes sense on input elements of the Checkbox- + * and Radio Button states, or option elements. + */ +function isElementSelected(el, capabilities) { + return interaction.isElementSelected( + el, + capabilities["moz:accessibilityChecks"] + ); +} + +async function sendKeysToElement(el, val, capabilities) { + let opts = { + strictFileInteractability: capabilities.strictFileInteractability, + accessibilityChecks: capabilities["moz:accessibilityChecks"], + webdriverClick: capabilities["moz:webdriverClick"], + }; + await interaction.sendKeysToElement(el, val, opts); +} + +/** Clear the text of an element. */ +function clearElement(el) { + interaction.clearElement(el); +} + +/** + * Switch to the parent frame of the current frame. If the frame is the + * top most is the current frame then no action will happen. + */ +function switchToParentFrame(msg) { + curContainer.frame = curContainer.parentFrame; + + sendSyncMessage("Marionette:switchedToFrame", { + browsingContextId: curContainer.id, + }); + + sendOk(msg.json.commandID); +} + +/** + * Switch to the specified frame. + * + * @param {(string|Object)=} element + * A web element reference of the frame or its element id. + * @param {number=} id + * The index of the frame to switch to. + * If both element and id are not defined, switch to top-level frame. + */ +function switchToFrame({ json }) { + let { commandID, element, id } = json; + + let foundFrame; + let wantedFrame = null; + + // check if curContainer.frame reference is dead + let frames = []; + try { + frames = curContainer.frame.frames; + } catch (e) { + // dead comparment, redirect to top frame + id = null; + element = null; + } + + // switch to top-level frame + if (id == null && !element) { + curContainer.frame = content; + sendSyncMessage("Marionette:switchedToFrame", { + browsingContextId: curContainer.id, + }); + + sendOk(commandID); + return; + } + + let webEl; + if (typeof element != "undefined") { + webEl = WebElement.fromUUID(element, "content"); + } + + if (webEl) { + if (!seenEls.has(webEl)) { + let err = new error.NoSuchElementError( + `Unable to locate element: ${webEl}` + ); + sendError(err, commandID); + return; + } + + try { + wantedFrame = seenEls.get(webEl, curContainer.frame); + } catch (e) { + sendError(e, commandID); + return; + } + + if (frames.length > 0) { + // use XPCNativeWrapper to compare elements; see bug 834266 + let wrappedWanted = new XPCNativeWrapper(wantedFrame); + foundFrame = Array.prototype.find.call(frames, frame => { + return new XPCNativeWrapper(frame.frameElement) === wrappedWanted; + }); + } + + if (!foundFrame) { + // Either the frame has been removed or we have a OOP frame + // so lets just get all the iframes and do a quick loop before + // throwing in the towel + let iframes = curContainer.frame.document.getElementsByTagName("iframe"); + let wrappedWanted = new XPCNativeWrapper(wantedFrame); + foundFrame = Array.prototype.find.call(iframes, frame => { + return new XPCNativeWrapper(frame) === wrappedWanted; + }); + } + } + + if (!foundFrame) { + if (typeof id === "number") { + try { + let frameEl; + if (id >= 0 && id < frames.length) { + frameEl = frames[id].frameElement; + if (frameEl !== null) { + foundFrame = frameEl.contentWindow; + } else { + // If foundFrame is null at this point then we have the top + // level browsing context so should treat it accordingly. + curContainer.frame = content; + sendSyncMessage("Marionette:switchedToFrame", { + browsingContextId: curContainer.id, + }); + + sendOk(commandID); + return; + } + } + } catch (e) { + // Since window.frames does not return OOP frames it will throw + // and we land up here. Let's not give up and check if there are + // iframes and switch to the indexed frame there + let iframes = foundFrame.document.getElementsByTagName("iframe"); + if (id >= 0 && id < iframes.length) { + foundFrame = iframes[id]; + } + } + } + } + + if (!foundFrame) { + let failedFrame = id || element; + let err = new error.NoSuchFrameError( + `Unable to locate frame: ${failedFrame}` + ); + sendError(err, commandID); + return; + } + + curContainer.frame = foundFrame; + + sendSyncMessage("Marionette:switchedToFrame", { + browsingContextId: curContainer.id, + }); + + sendOk(commandID); +} + +/** + * 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.<string, ?>} opts + * Options. + * + * Accepted values for |opts|: + * + * @param {WebElement} webEl + * Optional element to take a screenshot of. + * @param {boolean=} full + * True to take a screenshot of the entire document element. Is only + * considered if <var>id</var> is not defined. Defaults to true. + * @param {boolean=} scroll + * When <var>id</var> is given, scroll it into view before taking the + * screenshot. Defaults to true. + * @param {capture.Format} format + * Format to return the screenshot in. + * @param {Object.<string, ?>} opts + * Options. + * + * @return {DOMRect} + * The area to take a snapshot from + */ +function getScreenshotRect({ el, full = true, scroll = true } = {}) { + let win = el ? curContainer.frame : content; + + let rect; + + if (el) { + if (scroll) { + element.scrollIntoView(el); + } + rect = getElementRect(el); + } 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; +} + +function domAddEventListener(msg) { + eventObservers.add(msg.json.type); +} + +function domRemoveEventListener(msg) { + eventObservers.remove(msg.json.type); +} + +const eventDispatcher = { + enabled: false, + + enable() { + if (this.enabled) { + return; + } + + addEventListener("unload", this, false); + + addEventListener("beforeunload", this, true); + addEventListener("pagehide", this, true); + addEventListener("popstate", this, true); + + addEventListener("DOMContentLoaded", this, true); + addEventListener("hashchange", this, true); + addEventListener("pageshow", this, true); + + this.enabled = true; + }, + + disable() { + if (!this.enabled) { + return; + } + + removeEventListener("unload", this, false); + + removeEventListener("beforeunload", this, true); + removeEventListener("pagehide", this, true); + removeEventListener("popstate", this, true); + + removeEventListener("DOMContentLoaded", this, true); + removeEventListener("hashchange", this, true); + removeEventListener("pageshow", this, true); + + this.enabled = false; + }, + + handleEvent(event) { + const { target, type } = event; + + // An unload event indicates that the framescript died because of a process + // change, or that the tab / window has been closed. + if (type === "unload" && target === contentFrameMessageManager) { + logger.trace(`Frame script unloaded`); + sendAsyncMessage("Marionette:Unloaded", { + browsingContext: content.docShell.browsingContext, + }); + return; + } + + // Only care about events from the currently selected browsing context, + // whereby some of those do not bubble up to the window. + if (![curContainer.frame, curContainer.frame.document].includes(target)) { + return; + } + + // Ignore invalid combinations of load events and document's readyState. + if ( + (type === "DOMContentLoaded" && target.readyState != "interactive") || + (type === "pageshow" && target.readyState != "complete") + ) { + logger.warn( + `Ignoring event '${type}' because document has an invalid ` + + `readyState of '${target.readyState}'.` + ); + return; + } + + if (type === "pagehide") { + // The content window has been replaced. Immediately register the page + // load events again so that we don't miss possible load events + addEventListener("DOMContentLoaded", this, true); + addEventListener("pageshow", this, true); + } + + sendAsyncMessage("Marionette:NavigationEvent", { + browsingContext: content.docShell.browsingContext, + documentURI: target.documentURI, + readyState: target.readyState, + type, + }); + }, +}; + +/** + * Called when listener is first started up. The listener sends its + * unique window ID and its current URI to the actor. If the actor returns + * an ID, we start the listeners. Otherwise, nothing happens. + */ +function registerSelf() { + logger.trace("Frame script loaded"); + + curContainer.frame = content; + + sandboxes.clear(); + legacyactions.mouseEventsOnly = false; + + let reply = sendSyncMessage("Marionette:Register", { + frameId: contentId, + }); + + if (reply.length == 0) { + logger.error("No reply from Marionette:Register"); + return; + } + + if (reply[0].frameId === contentId) { + startListeners(); + sendAsyncMessage("Marionette:ListenersAttached", { + frameId: contentId, + }); + } +} + +// Call register self when we get loaded +registerSelf(); diff --git a/testing/marionette/log.js b/testing/marionette/log.js new file mode 100644 index 0000000000..53ca7208b1 --- /dev/null +++ b/testing/marionette/log.js @@ -0,0 +1,66 @@ +/* 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 EXPORTED_SYMBOLS = ["Log"]; + +const StdLog = ChromeUtils.import("resource://gre/modules/Log.jsm", {}).Log; + +const PREF_LOG_LEVEL = "marionette.log.level"; + +/** + * Shorthand for accessing the Marionette logging repository. + * + * Using this class to retrieve the `Log.jsm` repository for + * Marionette will ensure the logger is set up correctly with the + * appropriate stdout dumper and with the correct log level. + * + * Unlike `Log.jsm` this logger is E10s safe, meaning repository + * configuration is communicated across processes. + */ +class Log { + /** + * Obtain the `Marionette` logger. + * + * The returned {@link Logger} instance is shared among all + * callers in the same process. + * + * @return {Logger} + */ + static get() { + let logger = StdLog.repository.getLogger("Marionette"); + if (logger.ownAppenders.length == 0) { + logger.addAppender(new StdLog.DumpAppender()); + logger.manageLevelFromPref(PREF_LOG_LEVEL); + } + return logger; + } + + /** + * Obtain a logger that logs all messages with a prefix. + * + * Unlike {@link LoggerRepository.getLoggerWithMessagePrefix()} + * this function will ensure invoke {@link #get()} first to ensure + * the logger has been properly set up. + * + * This returns a new object with a prototype chain that chains + * up the original {@link Logger} instance. The new prototype has + * log functions that prefix `prefix` to each message. + * + * @param {string} prefix + * String to prefix each logged message with. + * + * @return {Proxy.<Logger>} + */ + static getWithPrefix(prefix) { + this.get(); + return StdLog.repository.getLoggerWithMessagePrefix( + "Marionette", + `[${prefix}] ` + ); + } +} + +this.Log = Log; diff --git a/testing/marionette/mach_commands.py b/testing/marionette/mach_commands.py new file mode 100644 index 0000000000..8f3c009ff5 --- /dev/null +++ b/testing/marionette/mach_commands.py @@ -0,0 +1,112 @@ +# 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/. + +from __future__ import absolute_import, print_function, unicode_literals + +import argparse +import functools +import logging +import os +import sys + +from six import iteritems + +from mach.decorators import ( + CommandProvider, + Command, +) + +from mozbuild.base import ( + MachCommandBase, + MachCommandConditions as conditions, + BinaryNotFoundException, +) + +SUPPORTED_APPS = ["firefox", "android", "thunderbird"] + + +def create_parser_tests(): + from marionette_harness.runtests import MarionetteArguments + from mozlog.structured import commandline + + parser = MarionetteArguments() + commandline.add_logging_group(parser) + return parser + + +def run_marionette(tests, binary=None, topsrcdir=None, **kwargs): + from mozlog.structured import commandline + + from marionette_harness.runtests import MarionetteTestRunner, MarionetteHarness + + parser = create_parser_tests() + + args = argparse.Namespace(tests=tests) + + args.binary = binary + args.logger = kwargs.pop("log", None) + + for k, v in iteritems(kwargs): + setattr(args, k, v) + + parser.verify_usage(args) + + if not args.logger: + args.logger = commandline.setup_logging( + "Marionette Unit Tests", args, {"mach": sys.stdout} + ) + failed = MarionetteHarness(MarionetteTestRunner, args=vars(args)).run() + if failed > 0: + return 1 + else: + return 0 + + +@CommandProvider +class MarionetteTest(MachCommandBase): + @Command( + "marionette-test", + category="testing", + description="Remote control protocol to Gecko, used for browser automation.", + conditions=[functools.partial(conditions.is_buildapp_in, apps=SUPPORTED_APPS)], + parser=create_parser_tests, + ) + def marionette_test(self, tests, **kwargs): + if "test_objects" in kwargs: + tests = [] + for obj in kwargs["test_objects"]: + tests.append(obj["file_relpath"]) + del kwargs["test_objects"] + + if not tests: + if conditions.is_thunderbird(self): + tests = [ + os.path.join( + self.topsrcdir, "comm/testing/marionette/unit-tests.ini" + ) + ] + else: + tests = [ + os.path.join( + self.topsrcdir, + "testing/marionette/harness/marionette_harness/tests/unit-tests.ini", + ) + ] + + if not kwargs.get("binary") and ( + conditions.is_firefox(self) or conditions.is_thunderbird(self) + ): + try: + kwargs["binary"] = self.get_binary_path("app") + except BinaryNotFoundException as e: + self.log( + logging.ERROR, + "marionette-test", + {"error": str(e)}, + "ERROR: {error}", + ) + self.log(logging.INFO, "marionette-test", {"help": e.help()}, "{help}") + return 1 + + return run_marionette(tests, topsrcdir=self.topsrcdir, **kwargs) diff --git a/testing/marionette/mach_test_package_commands.py b/testing/marionette/mach_test_package_commands.py new file mode 100644 index 0000000000..ad0328b2ab --- /dev/null +++ b/testing/marionette/mach_test_package_commands.py @@ -0,0 +1,75 @@ +# 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/. + +from __future__ import absolute_import + +import argparse +import os +import sys + +from functools import partial + +from mach.decorators import ( + CommandProvider, + Command, +) +from mozbuild.base import MachCommandBase + +parser = None + + +def run_marionette(context, **kwargs): + from marionette.runtests import MarionetteTestRunner, MarionetteHarness + from mozlog.structured import commandline + + args = argparse.Namespace(**kwargs) + args.binary = args.binary or context.firefox_bin + + test_root = os.path.join(context.package_root, "marionette", "tests") + if not args.tests: + args.tests = [ + os.path.join( + test_root, + "testing", + "marionette", + "harness", + "marionette_harness", + "tests", + "unit-tests.ini", + ) + ] + + normalize = partial(context.normalize_test_path, test_root) + args.tests = list(map(normalize, args.tests)) + + commandline.add_logging_group(parser) + parser.verify_usage(args) + + args.logger = commandline.setup_logging( + "Marionette Unit Tests", args, {"mach": sys.stdout} + ) + status = MarionetteHarness(MarionetteTestRunner, args=vars(args)).run() + return 1 if status else 0 + + +def setup_marionette_argument_parser(): + from marionette.runner.base import BaseMarionetteArguments + + global parser + parser = BaseMarionetteArguments() + return parser + + +@CommandProvider +class MachCommands(MachCommandBase): + @Command( + "marionette-test", + category="testing", + description="Run a Marionette test (Check UI or the internal JavaScript " + "using marionette).", + parser=setup_marionette_argument_parser, + ) + def run_marionette_test(self, **kwargs): + self.context.activate_mozharness_venv() + return run_marionette(self.context, **kwargs) diff --git a/testing/marionette/message.js b/testing/marionette/message.js new file mode 100644 index 0000000000..9159112f27 --- /dev/null +++ b/testing/marionette/message.js @@ -0,0 +1,331 @@ +/* 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 EXPORTED_SYMBOLS = ["Command", "Message", "Response"]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + assert: "chrome://marionette/content/assert.js", + error: "chrome://marionette/content/error.js", + truncate: "chrome://marionette/content/format.js", +}); + +/** Representation of the packets transproted over the wire. */ +class Message { + /** + * @param {number} messageID + * Message ID unique identifying this message. + */ + constructor(messageID) { + this.id = assert.integer(messageID); + } + + toString() { + let content = JSON.stringify(this.toPacket()); + return truncate`${content}`; + } + + /** + * Converts a data packet into a {@link Command} or {@link Response}. + * + * @param {Array.<number, number, ?, ?>} data + * A four element array where the elements, in sequence, signifies + * message type, message ID, method name or error, and parameters + * or result. + * + * @return {Message} + * Based on the message type, a {@link Command} or {@link Response} + * instance. + * + * @throws {TypeError} + * If the message type is not recognised. + */ + static fromPacket(data) { + const [type] = data; + + switch (type) { + case Command.Type: + return Command.fromPacket(data); + + case Response.Type: + return Response.fromPacket(data); + + default: + throw new TypeError( + "Unrecognised message type in packet: " + JSON.stringify(data) + ); + } + } +} + +/** + * Messages may originate from either the server or the client. + * Because the remote protocol is full duplex, both endpoints may be + * the origin of both commands and responses. + * + * @enum + * @see {@link Message} + */ +Message.Origin = { + /** Indicates that the message originates from the client. */ + Client: 0, + /** Indicates that the message originates from the server. */ + Server: 1, +}; + +/** + * A command is a request from the client to run a series of remote end + * steps and return a fitting response. + * + * The command can be synthesised from the message passed over the + * Marionette socket using the {@link fromPacket} function. The format of + * a message is: + * + * <pre> + * [<var>type</var>, <var>id</var>, <var>name</var>, <var>params</var>] + * </pre> + * + * where + * + * <dl> + * <dt><var>type</var> (integer) + * <dd> + * Must be zero (integer). Zero means that this message is + * a command. + * + * <dt><var>id</var> (integer) + * <dd> + * Integer used as a sequence number. The server replies with + * the same ID for the response. + * + * <dt><var>name</var> (string) + * <dd> + * String representing the command name with an associated set + * of remote end steps. + * + * <dt><var>params</var> (JSON Object or null) + * <dd> + * Object of command function arguments. The keys of this object + * must be strings, but the values can be arbitrary values. + * </dl> + * + * A command has an associated message <var>id</var> that prevents + * the dispatcher from sending responses in the wrong order. + * + * The command may also have optional error- and result handlers that + * are called when the client returns with a response. These are + * <code>function onerror({Object})</code>, + * <code>function onresult({Object})</code>, and + * <code>function onresult({Response})</code>: + * + * @param {number} messageID + * Message ID unique identifying this message. + * @param {string} name + * Command name. + * @param {Object.<string, ?>} params + * Command parameters. + */ +class Command extends Message { + constructor(messageID, name, params = {}) { + super(messageID); + + this.name = assert.string(name); + this.parameters = assert.object(params); + + this.onerror = null; + this.onresult = null; + + this.origin = Message.Origin.Client; + this.sent = false; + } + + /** + * Calls the error- or result handler associated with this command. + * This function can be replaced with a custom response handler. + * + * @param {Response} resp + * The response to pass on to the result or error to the + * <code>onerror</code> or <code>onresult</code> handlers to. + */ + onresponse(resp) { + if (this.onerror && resp.error) { + this.onerror(resp.error); + } else if (this.onresult && resp.body) { + this.onresult(resp.body); + } + } + + /** + * Encodes the command to a packet. + * + * @return {Array} + * Packet. + */ + toPacket() { + return [Command.Type, this.id, this.name, this.parameters]; + } + + /** + * Converts a data packet into {@link Command}. + * + * @param {Array.<number, number, ?, ?>} data + * A four element array where the elements, in sequence, signifies + * message type, message ID, command name, and parameters. + * + * @return {Command} + * Representation of packet. + * + * @throws {TypeError} + * If the message type is not recognised. + */ + static fromPacket(payload) { + let [type, msgID, name, params] = payload; + 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. + */ +class Response extends Message { + constructor(messageID, respHandler = () => {}) { + super(messageID); + + this.respHandler_ = 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 = error.wrap(err).toJSON(); + this.body = null; + this.send(); + + // propagate errors which are implementation problems + if (!error.isWebDriverError(err)) { + throw err; + } + } + + /** + * Encodes the response to a packet. + * + * @return {Array} + * Packet. + */ + toPacket() { + return [Response.Type, this.id, this.error, this.body]; + } + + /** + * Converts a data packet into {@link Response}. + * + * @param {Array.<number, number, ?, ?>} data + * A four element array where the elements, in sequence, signifies + * message type, message ID, error, and result. + * + * @return {Response} + * Representation of packet. + * + * @throws {TypeError} + * If the message type is not recognised. + */ + static fromPacket(payload) { + let [type, msgID, err, body] = payload; + assert.that(n => n === Response.Type)(type); + + let resp = new Response(msgID); + resp.error = assert.string(err); + + resp.body = body; + return resp; + } +} +Response.Type = 1; + +this.Message = Message; +this.Command = Command; +this.Response = Response; diff --git a/testing/marionette/modal.js b/testing/marionette/modal.js new file mode 100644 index 0000000000..e277830475 --- /dev/null +++ b/testing/marionette/modal.js @@ -0,0 +1,244 @@ +/* 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 EXPORTED_SYMBOLS = ["modal"]; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + Log: "chrome://marionette/content/log.js", +}); + +XPCOMUtils.defineLazyGetter(this, "logger", () => Log.get()); + +const COMMON_DIALOG = "chrome://global/content/commonDialog.xhtml"; + +const isFirefox = () => + Services.appinfo.ID == "{ec8030f7-c20a-464f-9b0e-13a3a9e97384}"; + +/** @namespace */ +this.modal = { + ACTION_CLOSED: "closed", + ACTION_OPENED: "opened", +}; + +/** + * Check for already existing modal or tab modal dialogs + * + * @param {browser.Context} context + * Reference to the browser context to check for existent dialogs. + * + * @return {modal.Dialog} + * Returns instance of the Dialog class, or `null` if no modal dialog + * is present. + */ +modal.findModalDialogs = function(context) { + // First check if there is a modal dialog already present for the + // current browser window. + for (let win of Services.wm.getEnumerator(null)) { + // TODO: Use BrowserWindowTracker.getTopWindow for modal dialogs without + // an opener. + if ( + win.document.documentURI === COMMON_DIALOG && + win.opener && + win.opener === context.window + ) { + return new modal.Dialog(() => context, Cu.getWeakReference(win)); + } + } + + // If no modal dialog has been found, also check if there is an open + // tab modal dialog present for the current tab. + // TODO: Find an adequate implementation for Fennec. + if (context.tab && context.tabBrowser.getTabModalPromptBox) { + let contentBrowser = context.contentBrowser; + let promptManager = context.tabBrowser.getTabModalPromptBox(contentBrowser); + let prompts = promptManager.listPrompts(); + + if (prompts.length) { + return new modal.Dialog(() => context, null); + } + } + + // No dialog found yet, check the TabDialogBox. + // This is for prompts that are shown in SubDialogs in the browser chrome. + if (context.tab && context.tabBrowser.getTabDialogBox) { + let contentBrowser = context.contentBrowser; + let dialogManager = context.tabBrowser + .getTabDialogBox(contentBrowser) + .getTabDialogManager(); + let dialogs = dialogManager._dialogs.filter( + dialog => dialog._openedURL === COMMON_DIALOG + ); + + if (dialogs.length) { + return new modal.Dialog( + () => context, + Cu.getWeakReference(dialogs[0]._frame.contentWindow) + ); + } + } + + return null; +}; + +/** + * Observer for modal and tab modal dialogs. + * + * @return {modal.DialogObserver} + * Returns instance of the DialogObserver class. + */ +modal.DialogObserver = class { + constructor() { + this.callbacks = new Set(); + this.register(); + } + + register() { + Services.obs.addObserver(this, "common-dialog-loaded"); + Services.obs.addObserver(this, "tabmodal-dialog-loaded"); + Services.obs.addObserver(this, "toplevel-window-ready"); + + // Register event listener for all already open windows + for (let win of Services.wm.getEnumerator(null)) { + win.addEventListener("DOMModalDialogClosed", this); + } + } + + unregister() { + Services.obs.removeObserver(this, "common-dialog-loaded"); + Services.obs.removeObserver(this, "tabmodal-dialog-loaded"); + Services.obs.removeObserver(this, "toplevel-window-ready"); + + // Unregister event listener for all open windows + for (let win of Services.wm.getEnumerator(null)) { + win.removeEventListener("DOMModalDialogClosed", this); + } + } + + cleanup() { + this.callbacks.clear(); + this.unregister(); + } + + handleEvent(event) { + logger.trace(`Received event ${event.type}`); + + let chromeWin = event.target.opener + ? event.target.opener.ownerGlobal + : event.target.ownerGlobal; + + let targetRef = Cu.getWeakReference(event.target); + + this.callbacks.forEach(callback => { + callback(modal.ACTION_CLOSED, targetRef, chromeWin); + }); + } + + observe(subject, topic) { + logger.trace(`Received observer notification ${topic}`); + + switch (topic) { + case "common-dialog-loaded": + case "tabmodal-dialog-loaded": + let chromeWin = subject.opener + ? subject.opener.ownerGlobal + : subject.ownerGlobal; + + // Always keep a weak reference to the current dialog + let targetRef = Cu.getWeakReference(subject); + + this.callbacks.forEach(callback => { + callback(modal.ACTION_OPENED, targetRef, chromeWin); + }); + break; + + case "toplevel-window-ready": + subject.addEventListener("DOMModalDialogClosed", this); + break; + } + } + + /** + * Add dialog handler by function reference. + * + * @param {function} callback + * The handler to be added. + */ + add(callback) { + if (this.callbacks.has(callback)) { + return; + } + this.callbacks.add(callback); + } + + /** + * Remove dialog handler by function reference. + * + * @param {function} callback + * The handler to be removed. + */ + remove(callback) { + if (!this.callbacks.has(callback)) { + return; + } + this.callbacks.delete(callback); + } +}; + +/** + * Represents a modal dialog. + * + * @param {function(): browser.Context} curBrowserFn + * Function that returns the current |browser.Context|. + * @param {nsIWeakReference=} winRef + * A weak reference to the current |ChromeWindow|. + */ +modal.Dialog = class { + constructor(curBrowserFn, winRef = undefined) { + this.curBrowserFn_ = curBrowserFn; + this.win_ = winRef; + } + + get curBrowser_() { + return this.curBrowserFn_(); + } + + /** + * Returns the ChromeWindow associated with an open dialog window if + * it is currently attached to the DOM. + */ + get window() { + if (this.win_) { + let win = this.win_.get(); + if (win && win.parent) { + return win; + } + } + return null; + } + + get tabModal() { + let win = this.window; + if (win) { + return win.Dialog; + } + return this.curBrowser_.getTabModal(); + } + + get args() { + let tm = this.tabModal; + return tm ? tm.args : null; + } + + get ui() { + let tm = this.tabModal; + return tm ? tm.ui : null; + } +}; diff --git a/testing/marionette/moz.build b/testing/marionette/moz.build new file mode 100644 index 0000000000..c7d0911020 --- /dev/null +++ b/testing/marionette/moz.build @@ -0,0 +1,22 @@ +# 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/. + +DIRS += ["components"] + +JAR_MANIFESTS += ["jar.mn"] + +MARIONETTE_UNIT_MANIFESTS += ["harness/marionette_harness/tests/unit/unit-tests.ini"] +XPCSHELL_TESTS_MANIFESTS += ["test/unit/xpcshell.ini"] + +with Files("**"): + BUG_COMPONENT = ("Testing", "Marionette") + +with Files("harness/**"): + SCHEDULES.exclusive = ["marionette", "firefox-ui"] + +SPHINX_TREES["/testing/marionette"] = "doc" +SPHINX_PYTHON_PACKAGE_DIRS += ["client/marionette_driver"] + +with Files("doc/**"): + SCHEDULES.exclusive = ["docs"] diff --git a/testing/marionette/navigate.js b/testing/marionette/navigate.js new file mode 100644 index 0000000000..4392045e93 --- /dev/null +++ b/testing/marionette/navigate.js @@ -0,0 +1,414 @@ +/* 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 EXPORTED_SYMBOLS = ["navigate"]; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + error: "chrome://marionette/content/error.js", + EventDispatcher: + "chrome://marionette/content/actors/MarionetteEventsParent.jsm", + Log: "chrome://marionette/content/log.js", + MarionettePrefs: "chrome://marionette/content/prefs.js", + modal: "chrome://marionette/content/modal.js", + PageLoadStrategy: "chrome://marionette/content/capabilities.js", + TimedPromise: "chrome://marionette/content/sync.js", + truncate: "chrome://marionette/content/format.js", +}); + +XPCOMUtils.defineLazyGetter(this, "logger", () => Log.get()); + +// Timeouts used to check if a new navigation has been initiated. +const TIMEOUT_BEFOREUNLOAD_EVENT = 200; +const TIMEOUT_UNLOAD_EVENT = 5000; + +/** @namespace */ +this.navigate = {}; + +/** + * Checks the value of readyState for the current page + * load activity, and resolves the command if the load + * has been finished. It also takes care of the selected + * page load strategy. + * + * @param {PageLoadStrategy} pageLoadStrategy + * Strategy when navigation is considered as finished. + * @param {object} eventData + * @param {string} eventData.documentURI + * Current document URI of the document. + * @param {string} eventData.readyState + * Current ready state of the document. + * + * @return {boolean} + * True if the page load has been finished. + */ +function checkReadyState(pageLoadStrategy, eventData = {}) { + const { documentURI, readyState } = eventData; + + const result = { error: null, finished: false }; + + switch (readyState) { + case "interactive": + if (documentURI.startsWith("about:certerror")) { + result.error = new error.InsecureCertificateError(); + result.finished = true; + } else if (/about:.*(error)\?/.exec(documentURI)) { + result.error = new 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 === PageLoadStrategy.Eager && + documentURI != "about:blank") || + /about:blocked\?/.exec(documentURI) + ) { + result.finished = true; + } + break; + + case "complete": + result.finished = true; + break; + } + + return result; +} + +/** + * Determines if we expect to get a DOM load event (DOMContentLoaded) + * on navigating to the <code>future</code> URL. + * + * @param {URL} current + * URL the browser is currently visiting. + * @param {Object} options + * @param {BrowsingContext=} options.browsingContext + * The current browsing context. Needed for targets of _parent and _top. + * @param {URL=} options.future + * Destination URL, if known. + * @param {target=} options.target + * Link target, if known. + * + * @return {boolean} + * Full page load would be expected if future is followed. + * + * @throws TypeError + * If <code>current</code> is not defined, or any of + * <code>current</code> or <code>future</code> are invalid URLs. + */ +navigate.isLoadEventExpected = function(current, options = {}) { + const { browsingContext, future, target } = options; + + if (typeof current == "undefined") { + throw new TypeError("Expected at least one URL"); + } + + if (["_parent", "_top"].includes(target) && !browsingContext) { + throw new TypeError( + "Expected browsingContext when target is _parent or _top" + ); + } + + // Don't wait if the navigation happens in a different browsing context + if ( + target === "_blank" || + (target === "_parent" && browsingContext.parent) || + (target === "_top" && browsingContext.top != browsingContext) + ) { + return false; + } + + // Assume we will go somewhere exciting + if (typeof future == "undefined") { + return true; + } + + // Assume javascript:<whatever> will modify the current document + // but this is not an entirely safe assumption to make, + // considering it could be used to set window.location + if (future.protocol == "javascript:") { + return false; + } + + // If hashes are present and identical + if ( + current.href.includes("#") && + future.href.includes("#") && + current.hash === future.hash + ) { + return false; + } + + return true; +}; + +/** + * Load the given URL in the specified browsing context. + * + * @param {CanonicalBrowsingContext} browsingContext + * Browsing context to load the URL into. + * @param {string} url + * URL to navigate to. + */ +navigate.navigateTo = async function(browsingContext, url) { + const opts = { + loadFlags: Ci.nsIWebNavigation.LOAD_FLAGS_IS_LINK, + triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), + }; + browsingContext.loadURI(url, opts); +}; + +/** + * Reload the page. + * + * @param {CanonicalBrowsingContext} browsingContext + * Browsing context to refresh. + */ +navigate.refresh = async function(browsingContext) { + const flags = Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_CACHE; + browsingContext.reload(flags); +}; + +/** + * Execute a callback and wait for a possible navigation to complete + * + * @param {GeckoDriver} driver + * Reference to driver instance. + * @param {Function} callback + * Callback to execute that might trigger a navigation. + * @param {Object} options + * @param {BrowsingContext=} browsingContext + * Browsing context to observe. Defaults to the current browsing context. + * @param {boolean=} loadEventExpected + * If false, return immediately and don't wait for + * the navigation to be completed. Defaults to true. + * @param {boolean=} requireBeforeUnload + * If false and no beforeunload event is fired, abort waiting + * for the navigation. Defaults to true. + */ +navigate.waitForNavigationCompleted = async function waitForNavigationCompleted( + driver, + callback, + options = {} +) { + const { + browsingContextFn = driver.getBrowsingContext.bind(driver), + loadEventExpected = true, + requireBeforeUnload = true, + } = options; + + const chromeWindow = browsingContextFn().topChromeWindow; + const pageLoadStrategy = driver.capabilities.get("pageLoadStrategy"); + + // Return immediately if no load event is expected + if (!loadEventExpected || pageLoadStrategy === PageLoadStrategy.None) { + await callback(); + return Promise.resolve(); + } + + let rejectNavigation; + let resolveNavigation; + + let seenBeforeUnload = false; + let seenUnload = false; + + let unloadTimer; + + const checkDone = ({ finished, error }) => { + if (finished) { + if (error) { + rejectNavigation(error); + } else { + resolveNavigation(); + } + } + }; + + const onDialogOpened = (action, dialog, win) => { + // Only care about modals of the currently selected window. + if (win !== chromeWindow) { + return; + } + + if (action === modal.ACTION_OPENED) { + logger.trace("Canceled page load listener because a dialog opened"); + checkDone({ finished: true }); + } + }; + + const onTimer = timer => { + // In the case when a document has a beforeunload handler + // registered, the currently active command will return immediately + // due to the modal dialog observer in proxy.js. + // + // Otherwise the timeout waiting for the document to start + // navigating is increased by 5000 ms to ensure a possible load + // event is not missed. In the common case such an event should + // occur pretty soon after beforeunload, and we optimise for this. + if (seenBeforeUnload) { + seenBeforeUnload = false; + unloadTimer.initWithCallback( + onTimer, + TIMEOUT_UNLOAD_EVENT, + Ci.nsITimer.TYPE_ONE_SHOT + ); + + // If no page unload has been detected, ensure to properly stop + // the load listener, and return from the currently active command. + } else if (!seenUnload) { + logger.trace( + "Canceled page load listener because no navigation " + + "has been detected" + ); + checkDone({ finished: true }); + } + }; + + const onNavigation = ({ json }, message) => { + let data = MarionettePrefs.useActors ? message : json; + + if (MarionettePrefs.useActors) { + // Only care about navigation events from the actor of the current frame. + // Bug 1674329: Always use the currently active browsing context, + // and not the original one to not cause hangs for remoteness changes. + if (data.browsingContext != browsingContextFn()) { + return; + } + } else if ( + data.browsingContext.browserId != browsingContextFn().browserId + ) { + return; + } + + logger.trace(truncate`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": + if (!seenUnload) { + 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) => { + // With the currentWindowGlobal gone the browsing context hasn't been + // replaced due to a remoteness change but closed. + if (subject == browsingContextFn() && !subject.currentWindowGlobal) { + logger.trace( + "Canceled page load listener " + + `because browsing context with id ${subject.id} has been removed` + ); + checkDone({ finished: true }); + } + }; + + const onUnload = event => { + 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.dialogObserver.add(onDialogOpened); + Services.obs.addObserver( + onBrowsingContextDiscarded, + "browsing-context-discarded" + ); + + if (MarionettePrefs.useActors) { + EventDispatcher.on("page-load", onNavigation); + } else { + driver.mm.addMessageListener( + "Marionette:NavigationEvent", + onNavigation, + true + ); + } + + return new TimedPromise( + async (resolve, reject) => { + rejectNavigation = reject; + resolveNavigation = resolve; + + try { + await callback(); + + // Certain commands like clickElement can cause a navigation. Setup a timer + // to check if a "beforeunload" event has been emitted within the given + // time frame. If not resolve the Promise. + if (!requireBeforeUnload) { + unloadTimer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + unloadTimer.initWithCallback( + onTimer, + TIMEOUT_BEFOREUNLOAD_EVENT, + Ci.nsITimer.TYPE_ONE_SHOT + ); + } + } catch (e) { + // Executing the callback above could destroy the actor pair before the + // command returns. Such an error has to be ignored. + if (e.name !== "AbortError") { + checkDone({ finished: true, error: e }); + } + } + }, + { + timeout: driver.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.dialogObserver?.remove(onDialogOpened); + unloadTimer?.cancel(); + + if (MarionettePrefs.useActors) { + EventDispatcher.off("page-load", onNavigation); + } else { + driver.mm.removeMessageListener( + "Marionette:NavigationEvent", + onNavigation, + true + ); + } + }); +}; diff --git a/testing/marionette/packets.js b/testing/marionette/packets.js new file mode 100644 index 0000000000..0d5ad47b27 --- /dev/null +++ b/testing/marionette/packets.js @@ -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/. */ + +"use strict"; + +const EXPORTED_SYMBOLS = ["RawPacket", "Packet", "JSONPacket", "BulkPacket"]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + StreamUtils: "chrome://marionette/content/stream-utils.js", +}); + +XPCOMUtils.defineLazyGetter(this, "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 + */ +function Packet(transport) { + this._transport = transport; + this._length = 0; +} + +/** + * Attempt to initialize a new Packet based on the incoming packet header + * we've received so far. We try each of the types in succession, trying + * JSON packets first since they are much more common. + * + * @param {string} header + * Packet header string to attempt parsing. + * @param {DebuggerTransport} transport + * Transport instance that will own the packet. + * + * @return {Packet} + * Parsed packet of the matching type, or null if no types matched. + */ +Packet.fromHeader = function(header, transport) { + return ( + JSONPacket.fromHeader(header, transport) || + BulkPacket.fromHeader(header, transport) + ); +}; + +Packet.prototype = { + get length() { + return this._length; + }, + + set length(length) { + if (length > PACKET_LENGTH_MAX) { + throw new Error( + "Packet length " + + length + + " exceeds the max length of " + + PACKET_LENGTH_MAX + ); + } + this._length = length; + }, + + destroy() { + this._transport = null; + }, +}; + +/** + * With a JSON packet (the typical packet type sent via the transport), + * data is transferred as a JSON packet serialized into a string, + * with the string length prepended to the packet, followed by a colon + * ([length]:[packet]). The contents of the JSON packet are specified in + * the Remote Debugging Protocol specification. + * + * @param {DebuggerTransport} transport + * Transport instance that will own the packet. + */ +function JSONPacket(transport) { + Packet.call(this, transport); + this._data = ""; + this._done = false; +} + +/** + * Attempt to initialize a new JSONPacket based on the incoming packet + * header we've received so far. + * + * @param {string} header + * Packet header string to attempt parsing. + * @param {DebuggerTransport} transport + * Transport instance that will own the packet. + * + * @return {JSONPacket} + * Parsed packet, or null if it's not a match. + */ +JSONPacket.fromHeader = function(header, transport) { + let match = this.HEADER_PATTERN.exec(header); + + if (!match) { + return null; + } + + let packet = new JSONPacket(transport); + packet.length = +match[1]; + return packet; +}; + +JSONPacket.HEADER_PATTERN = /^(\d+):$/; + +JSONPacket.prototype = Object.create(Packet.prototype); + +Object.defineProperty(JSONPacket.prototype, "object", { + /** + * Gets the object (not the serialized string) being read or written. + */ + get() { + return this._object; + }, + + /** + * Sets the object to be sent when write() is called. + */ + set(object) { + this._object = object; + let data = JSON.stringify(object); + this._data = 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 = 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. + */ +function BulkPacket(transport) { + Packet.call(this, transport); + this._done = false; + this._readyForWriting = defer(); +} + +/** + * Attempt to initialize a new BulkPacket based on the incoming packet + * header we've received so far. + * + * @param {string} header + * Packet header string to attempt parsing. + * @param {DebuggerTransport} transport + * Transport instance that will own the packet. + * + * @return {BulkPacket} + * Parsed packet, or null if it's not a match. + */ +BulkPacket.fromHeader = function(header, transport) { + let match = this.HEADER_PATTERN.exec(header); + + if (!match) { + return null; + } + + let packet = new BulkPacket(transport); + packet.header = { + actor: match[1], + type: match[2], + length: +match[3], + }; + return packet; +}; + +BulkPacket.HEADER_PATTERN = /^bulk ([^: ]+) ([^: ]+) (\d+):$/; + +BulkPacket.prototype = Object.create(Packet.prototype); + +BulkPacket.prototype.read = function(stream) { + // Temporarily pause monitoring of the input stream + this._transport.pauseIncoming(); + + let deferred = defer(); + + this._transport._onBulkReadReady({ + actor: this.actor, + type: this.type, + length: this.length, + copyTo: output => { + let copying = 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 = StreamUtils.copyStream(input, stream, this.length); + deferred.resolve(copying); + return copying; + }, + stream, + done: deferred, + }); + + // Await the result of writing to the stream + deferred.promise.then(() => { + this._done = true; + this._transport.resumeOutgoing(); + }, this._transport.close); + + // Ensure this is only done once + this.write = () => { + throw new Error("Tried to write() a BulkPacket's stream multiple times."); + }; +}; + +Object.defineProperty(BulkPacket.prototype, "streamReadyForWriting", { + get() { + return this._readyForWriting.promise; + }, +}); + +Object.defineProperty(BulkPacket.prototype, "header", { + get() { + return { + actor: this.actor, + type: this.type, + length: this.length, + }; + }, + + set(header) { + this.actor = header.actor; + this.type = header.type; + this.length = header.length; + }, +}); + +Object.defineProperty(BulkPacket.prototype, "done", { + get() { + return this._done; + }, +}); + +BulkPacket.prototype.toString = function() { + return "Bulk: " + JSON.stringify(this.header, null, 2); +}; + +/** + * RawPacket is used to test the transport's error handling of malformed + * packets, by writing data directly onto the stream. + * @param transport DebuggerTransport + * The transport instance that will own the packet. + * @param data string + * The raw string to send out onto the stream. + */ +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/testing/marionette/prefs.js b/testing/marionette/prefs.js new file mode 100644 index 0000000000..937ec647d1 --- /dev/null +++ b/testing/marionette/prefs.js @@ -0,0 +1,280 @@ +/* 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 EXPORTED_SYMBOLS = ["Branch", "MarionettePrefs"]; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + Log: "resource://gre/modules/Log.jsm", +}); + +XPCOMUtils.defineLazyServiceGetter( + this, + "env", + "@mozilla.org/process/environment;1", + "nsIEnvironment" +); + +const { PREF_BOOL, PREF_INT, PREF_INVALID, PREF_STRING } = Ci.nsIPrefBranch; + +class Branch { + /** + * @param {string=} branch + * Preference subtree. Uses root tree given `null`. + */ + constructor(branch) { + this._branch = Services.prefs.getBranch(branch); + } + + /** + * Gets value of `pref` in its known type. + * + * @param {string} pref + * Preference name. + * @param {?=} fallback + * Fallback value to return if `pref` does not exist. + * + * @return {(string|boolean|number)} + * Value of `pref`, or the `fallback` value if `pref` does + * not exist. + * + * @throws {TypeError} + * If `pref` is not a recognised preference and no `fallback` + * value has been provided. + */ + get(pref, fallback = null) { + switch (this._branch.getPrefType(pref)) { + case PREF_STRING: + return this._branch.getStringPref(pref); + + case PREF_BOOL: + return this._branch.getBoolPref(pref); + + case PREF_INT: + return this._branch.getIntPref(pref); + + case PREF_INVALID: + default: + if (fallback != null) { + return fallback; + } + throw new TypeError(`Unrecognised preference: ${pref}`); + } + } + + /** + * Sets the value of `pref`. + * + * @param {string} pref + * Preference name. + * @param {(string|boolean|number)} value + * `pref`'s new value. + * + * @throws {TypeError} + * If `value` is not the correct type for `pref`. + */ + set(pref, value) { + let typ; + if (typeof value != "undefined" && value != null) { + typ = value.constructor.name; + } + + switch (typ) { + case "String": + // Unicode compliant + return this._branch.setStringPref(pref, value); + + case "Boolean": + return this._branch.setBoolPref(pref, value); + + case "Number": + return this._branch.setIntPref(pref, value); + + default: + throw new TypeError(`Illegal preference type value: ${typ}`); + } + } +} + +/** + * Provides shortcuts for lazily getting and setting typed Marionette + * preferences. + * + * Some of Marionette's preferences are stored using primitive values + * that internally are represented by complex types. One such example + * is `marionette.log.level` which stores a string such as `info` or + * `DEBUG`, and which is represented as `Log.Level`. + * + * Because we cannot trust the input of many of these preferences, + * this class provides abstraction that lets us safely deal with + * potentially malformed input. In the `marionette.log.level` example, + * `DEBUG`, `Debug`, and `dEbUg` are considered valid inputs and the + * `LogBranch` specialisation deserialises the string value to the + * correct `Log.Level` by sanitising the input data first. + * + * A further complication is that we cannot rely on `Preferences.jsm` + * 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.enabled` preference. When it returns true, + * this signifies that the Marionette server is running. + * + * @return {boolean} + */ + get enabled() { + return this.get("enabled", false); + } + + set enabled(isEnabled) { + this.set("enabled", isEnabled); + } + + /** + * The `marionette.debugging.clicktostart` preference delays + * server startup until a modal dialogue has been clicked to allow + * time for user to set breakpoints in the Browser Toolbox. + * + * @return {boolean} + */ + get clickToStart() { + return this.get("debugging.clicktostart", false); + } + + /** + * Whether content scripts can be safely reused. + * + * @deprecated + * @return {boolean} + */ + get contentListener() { + return this.get("contentListener", false); + } + + set contentListener(value) { + this.set("contentListener", value); + } + + /** + * The `marionette.port` preference, detailing which port + * the TCP server should listen on. + * + * @return {number} + */ + get port() { + return this.get("port", 2828); + } + + set port(newPort) { + this.set("port", newPort); + } + + /** + * Fail-safe return of the current log level from preference + * `marionette.log.level`. + * + * @return {Log.Level} + */ + get logLevel() { + // TODO: when geckodriver's minimum supported Firefox version reaches 62, + // the lower-casing here can be dropped (https://bugzil.la/1482829) + switch (this.get("log.level", "info").toLowerCase()) { + case "fatal": + return Log.Level.Fatal; + case "error": + return Log.Level.Error; + case "warn": + return Log.Level.Warn; + case "config": + return Log.Level.Config; + case "debug": + return Log.Level.Debug; + case "trace": + return Log.Level.Trace; + case "info": + default: + dump(`*** log: ${Log}\n\n`); + return Log.Level.Info; + } + } + + /** + * Certain log messages that are known to be long are truncated + * before they are dumped to stdout. The `marionette.log.truncate` + * preference indicates that the values should not be truncated. + * + * @return {boolean} + */ + get truncateLog() { + return this.get("log.truncate"); + } + + /** + * Gets the `marionette.prefs.recommended` preference, signifying + * whether recommended automation preferences will be set when + * Marionette is started. + * + * @return {boolean} + */ + get recommendedPrefs() { + return this.get("prefs.recommended", true); + } + + /** + * Temporary preference to enable the usage of the JSWindowActor + * implementation for commands that already support Fission. + */ + get useActors() { + return this.get("actors.enabled", true); + } +} + +/** Reads a JSON serialised blob stored in the environment. */ +class EnvironmentPrefs { + /** + * Reads the environment variable `key` and tries to parse it as + * JSON Object, then provides an iterator over its keys and values. + * + * If the environment variable is not set, this function returns empty. + * + * @param {string} key + * Environment variable. + * + * @return {Iterable.<string, (string|boolean|number)> + */ + static *from(key) { + if (!env.exists(key)) { + return; + } + + let prefs; + try { + prefs = JSON.parse(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]]; + } + } +} + +this.Branch = Branch; +this.EnvironmentPrefs = EnvironmentPrefs; + +// There is a future potential of exposing this as Marionette.prefs.port +// if we introduce a Marionette.jsm module. +this.MarionettePrefs = new MarionetteBranch(); diff --git a/testing/marionette/print.js b/testing/marionette/print.js new file mode 100644 index 0000000000..72f98ed77e --- /dev/null +++ b/testing/marionette/print.js @@ -0,0 +1,129 @@ +/* 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 EXPORTED_SYMBOLS = ["print"]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + clearInterval: "resource://gre/modules/Timer.jsm", + OS: "resource://gre/modules/osfile.jsm", + setInterval: "resource://gre/modules/Timer.jsm", + + assert: "chrome://marionette/content/assert.js", + Log: "chrome://marionette/content/log.js", + pprint: "chrome://marionette/content/format.js", +}); + +XPCOMUtils.defineLazyGetter(this, "logger", () => Log.get()); + +this.print = { + maxScaleValue: 2.0, + minScaleValue: 0.1, + letterPaperSizeCm: { + width: 21.59, + height: 27.94, + }, +}; + +print.addDefaultSettings = function(settings) { + const { + landscape = false, + margin = { + top: 1, + bottom: 1, + left: 1, + right: 1, + }, + page = print.letterPaperSizeCm, + shrinkToFit = true, + printBackground = false, + scale = 1.0, + } = settings; + return { landscape, margin, page, shrinkToFit, printBackground, scale }; +}; + +function getPrintSettings(settings, filePath) { + const psService = Cc["@mozilla.org/gfx/printsettings-service;1"].getService( + Ci.nsIPrintSettingsService + ); + + let cmToInches = cm => cm / 2.54; + const printSettings = psService.newPrintSettings; + printSettings.isInitializedFromPrinter = true; + printSettings.isInitializedFromPrefs = true; + printSettings.outputFormat = Ci.nsIPrintSettings.kOutputFormatPDF; + printSettings.printerName = "marionette"; + printSettings.printSilent = true; + printSettings.printToFile = true; + printSettings.showPrintProgress = false; + printSettings.toFileName = filePath; + + // Setting the paperSizeUnit to kPaperSizeMillimeters doesn't work on mac + printSettings.paperSizeUnit = Ci.nsIPrintSettings.kPaperSizeInches; + printSettings.paperWidth = cmToInches(settings.page.width); + printSettings.paperHeight = cmToInches(settings.page.height); + + printSettings.marginBottom = cmToInches(settings.margin.bottom); + printSettings.marginLeft = cmToInches(settings.margin.left); + printSettings.marginRight = cmToInches(settings.margin.right); + printSettings.marginTop = cmToInches(settings.margin.top); + + printSettings.printBGColors = settings.printBackground; + printSettings.printBGImages = settings.printBackground; + printSettings.scaling = settings.scale; + printSettings.shrinkToFit = settings.shrinkToFit; + + printSettings.headerStrCenter = ""; + printSettings.headerStrLeft = ""; + printSettings.headerStrRight = ""; + printSettings.footerStrCenter = ""; + printSettings.footerStrLeft = ""; + printSettings.footerStrRight = ""; + + // Override any os-specific unwriteable margins + printSettings.unwriteableMarginTop = 0; + printSettings.unwriteableMarginLeft = 0; + printSettings.unwriteableMarginBottom = 0; + printSettings.unwriteableMarginRight = 0; + + if (settings.landscape) { + printSettings.orientation = Ci.nsIPrintSettings.kLandscapeOrientation; + } + return printSettings; +} + +print.printToFile = async function(browser, outerWindowID, settings) { + // Create a unique filename for the temporary PDF file + const basePath = OS.Path.join(OS.Constants.Path.tmpDir, "marionette.pdf"); + const { file, path: filePath } = await OS.File.openUnique(basePath); + await file.close(); + + let printSettings = getPrintSettings(settings, filePath); + + await browser.print(outerWindowID, printSettings); + + // Bug 1603739 - With e10s enabled the promise returned by print() resolves + // too early, which means the file hasn't been completely written. + await new Promise(resolve => { + const DELAY_CHECK_FILE_COMPLETELY_WRITTEN = 100; + + let lastSize = 0; + const timerId = setInterval(async () => { + const fileInfo = await OS.File.stat(filePath); + if (lastSize > 0 && fileInfo.size == lastSize) { + clearInterval(timerId); + resolve(); + } + lastSize = fileInfo.size; + }, DELAY_CHECK_FILE_COMPLETELY_WRITTEN); + }); + + logger.debug(`PDF output written to ${filePath}`); + return filePath; +}; diff --git a/testing/marionette/proxy.js b/testing/marionette/proxy.js new file mode 100644 index 0000000000..fd16971081 --- /dev/null +++ b/testing/marionette/proxy.js @@ -0,0 +1,340 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const EXPORTED_SYMBOLS = ["proxy"]; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + Log: "chrome://marionette/content/log.js", + error: "chrome://marionette/content/error.js", + evaluate: "chrome://marionette/content/evaluate.js", + MessageManagerDestroyedPromise: "chrome://marionette/content/sync.js", + modal: "chrome://marionette/content/modal.js", +}); + +XPCOMUtils.defineLazyGetter(this, "logger", () => Log.get()); +XPCOMUtils.defineLazyServiceGetter( + this, + "uuidgen", + "@mozilla.org/uuid-generator;1", + "nsIUUIDGenerator" +); + +// Proxy handler that traps requests to get a property. Will prioritise +// properties that exist on the object's own prototype. +const ownPriorityGetterTrap = { + get: (obj, prop) => { + if (obj.hasOwnProperty(prop)) { + return obj[prop]; + } + return (...args) => obj.send(prop, args); + }, +}; + +/** @namespace */ +this.proxy = {}; + +/** + * Creates a transparent interface between the chrome- and content + * contexts. + * + * Calls to this object will be proxied via the message manager to a + * content frame script, and responses are returend as promises. + * + * The argument sequence is serialised and passed as an array, unless it + * consists of a single object type that isn't null, in which case it's + * passed literally. The latter specialisation is temporary to achieve + * backwards compatibility with listener.js. + * + * @param {function(string, Object, number)} sendAsyncFn + * Callback for sending async messages. + * @param {function(): browser.Context} browserFn + * Closure that returns the current browsing context. + */ +proxy.toListener = function(sendAsyncFn, browserFn) { + let sender = new proxy.AsyncMessageChannel(sendAsyncFn, browserFn); + return new Proxy(sender, ownPriorityGetterTrap); +}; + +/** + * Provides a transparent interface between chrome- and content space. + * + * The AsyncMessageChannel is an abstraction of the message manager + * IPC architecture allowing calls to be made to any registered message + * listener in Marionette. The <code>#send(...)</code> method + * returns a promise that gets resolved when the message handler calls + * <code>.reply(...)</code>. + */ +proxy.AsyncMessageChannel = class { + constructor(sendAsyncFn, browserFn) { + this.sendAsync = sendAsyncFn; + this.browserFn_ = browserFn; + + // TODO(ato): Bug 1242595 + this.activeMessageId = null; + + this.listeners_ = new Map(); + this.dialogHandler = null; + this.closeHandler = null; + } + + get browser() { + return this.browserFn_(); + } + + /** + * Send a message across the channel. The name of the function to + * call must be registered as a message listener. + * + * Usage: + * + * <pre><code> + * let channel = new AsyncMessageChannel( + * messageManager, sendAsyncMessage.bind(this)); + * let rv = await channel.send("remoteFunction", ["argument"]); + * </code></pre> + * + * @param {string} name + * Function to call in the listener, e.g. for the message listener + * <tt>Marionette:foo8</tt>, use <tt>foo</tt>. + * @param {Array.<?>=} args + * Argument list to pass the function. If args has a single entry + * that is an object, we assume it's an old style dispatch, and + * the object will passed literally. + * + * @return {Promise} + * A promise that resolves to the result of the command. + * @throws {TypeError} + * If an unsupported reply type is received. + * @throws {WebDriverError} + * If an error is returned over the channel. + */ + send(name, args = []) { + let uuid = uuidgen.generateUUID().toString(); + // TODO(ato): Bug 1242595 + this.activeMessageId = uuid; + + return new Promise((resolve, reject) => { + let path = proxy.AsyncMessageChannel.makePath(uuid); + let cb = msg => { + this.activeMessageId = null; + let { data, type } = msg.json; + + switch (msg.json.type) { + case proxy.AsyncMessageChannel.ReplyType.Ok: + case proxy.AsyncMessageChannel.ReplyType.Value: + let payload = evaluate.fromJSON(data); + resolve(payload); + break; + + case proxy.AsyncMessageChannel.ReplyType.Error: + let err = error.WebDriverError.fromJSON(data); + reject(err); + break; + + default: + throw new TypeError(`Unknown async response type: ${type}`); + } + }; + + // The currently selected tab or window is closing. Make sure to wait + // until it's fully gone. + this.closeHandler = async ({ type, target }) => { + logger.trace(`Received DOM event ${type} for ${target}`); + + let messageManager; + switch (type) { + case "unload": + messageManager = this.browser.window.messageManager; + break; + case "TabClose": + messageManager = this.browser.messageManager; + break; + } + + await new MessageManagerDestroyedPromise(messageManager); + this.removeHandlers(); + resolve(); + }; + + // A modal or tab modal dialog has been opened. To be able to handle it, + // the active command has to be aborted. Therefore remove all handlers, + // and cancel any ongoing requests in the listener. + this.dialogHandler = (action, dialogRef, win) => { + // Only care about modals of the currently selected window. + if (win !== this.browser.window) { + return; + } + + this.removeAllListeners_(); + // TODO(ato): It's not ideal to have listener specific behaviour here: + this.sendAsync("cancelRequest"); + + this.removeHandlers(); + resolve(); + }; + + // start content message listener, and install handlers for + // modal dialogues, and window/tab state changes. + this.addListener_(path, cb); + this.addHandlers(); + + // sendAsync is GeckoDriver#sendAsync + this.sendAsync(name, marshal(args), uuid); + }); + } + + /** + * Add all necessary handlers for events and observer notifications. + */ + addHandlers() { + this.browser.driver.dialogObserver.add(this.dialogHandler.bind(this)); + + // Register event handlers in case the command closes the current + // tab or window, and the promise has to be escaped. + if (this.browser) { + this.browser.window.addEventListener("unload", this.closeHandler); + + if (this.browser.tab) { + let node = this.browser.tab.addEventListener + ? this.browser.tab + : this.browser.contentBrowser; + node.addEventListener("TabClose", this.closeHandler); + } + } + } + + /** + * Remove all registered handlers for events and observer notifications. + */ + removeHandlers() { + this.browser.driver.dialogObserver.remove(this.dialogHandler.bind(this)); + + if (this.browser) { + this.browser.window.removeEventListener("unload", this.closeHandler); + + if (this.browser.tab) { + let node = this.browser.tab.addEventListener + ? this.browser.tab + : this.browser.contentBrowser; + if (node) { + node.removeEventListener("TabClose", this.closeHandler); + } + } + } + } + + /** + * Reply to an asynchronous request. + * + * Passing an {@link WebDriverError} prototype will cause the receiving + * channel to throw this error. + * + * Usage: + * + * <pre><code> + * let channel = proxy.AsyncMessageChannel( + * messageManager, sendAsyncMessage.bind(this)); + * + * // throws in requester: + * channel.reply(uuid, new error.WebDriverError()); + * + * // returns with value: + * channel.reply(uuid, "hello world!"); + * + * // returns with undefined: + * channel.reply(uuid); + * </pre></code> + * + * @param {UUID} uuid + * Unique identifier of the request. + * @param {*} obj + * Message data to reply with. + */ + reply(uuid, obj = undefined) { + // TODO(ato): Eventually the uuid will be hidden in the dispatcher + // in listener, and passing it explicitly to this function will be + // unnecessary. + if (typeof obj == "undefined") { + this.sendReply_(uuid, proxy.AsyncMessageChannel.ReplyType.Ok); + } else if (error.isError(obj)) { + let err = error.wrap(obj); + this.sendReply_(uuid, proxy.AsyncMessageChannel.ReplyType.Error, err); + } else { + this.sendReply_(uuid, proxy.AsyncMessageChannel.ReplyType.Value, obj); + } + } + + sendReply_(uuid, type, payload = undefined) { + const path = proxy.AsyncMessageChannel.makePath(uuid); + + let data = evaluate.toJSON(payload); + const msg = { type, data }; + + // here sendAsync is actually the content frame's + // sendAsyncMessage(path, message) global + this.sendAsync(path, msg); + } + + /** + * Produces a path, or a name, for the message listener handler that + * listens for a reply. + * + * @param {UUID} uuid + * Unique identifier of the channel request. + * + * @return {string} + * Path to be used for nsIMessageListener.addMessageListener. + */ + static makePath(uuid) { + return "Marionette:asyncReply:" + uuid; + } + + addListener_(path, callback) { + let autoRemover = msg => { + this.removeListener_(path); + this.removeHandlers(); + callback(msg); + }; + + Services.mm.addMessageListener(path, autoRemover); + this.listeners_.set(path, autoRemover); + } + + removeListener_(path) { + if (!this.listeners_.has(path)) { + return true; + } + + let l = this.listeners_.get(path); + Services.mm.removeMessageListener(path, l); + return this.listeners_.delete(path); + } + + removeAllListeners_() { + let ok = true; + for (let [p] of this.listeners_) { + ok |= this.removeListener_(p); + } + return ok; + } +}; +proxy.AsyncMessageChannel.ReplyType = { + Ok: 0, + Value: 1, + Error: 2, +}; + +function marshal(args) { + if (args.length == 1 && typeof args[0] == "object") { + return args[0]; + } + return args; +} diff --git a/testing/marionette/reftest-content.js b/testing/marionette/reftest-content.js new file mode 100644 index 0000000000..9ad2a86448 --- /dev/null +++ b/testing/marionette/reftest-content.js @@ -0,0 +1,67 @@ +/* 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 { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +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.startPrintWindow( + "window_print", + aOpenWindowInfo.parent, + { openWindowInfo: 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/testing/marionette/reftest.js b/testing/marionette/reftest.js new file mode 100644 index 0000000000..fad350b334 --- /dev/null +++ b/testing/marionette/reftest.js @@ -0,0 +1,908 @@ +/* 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 EXPORTED_SYMBOLS = ["reftest"]; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + E10SUtils: "resource://gre/modules/E10SUtils.jsm", + OS: "resource://gre/modules/osfile.jsm", + Preferences: "resource://gre/modules/Preferences.jsm", + + assert: "chrome://marionette/content/assert.js", + capture: "chrome://marionette/content/capture.js", + error: "chrome://marionette/content/error.js", + Log: "chrome://marionette/content/log.js", + navigate: "chrome://marionette/content/navigate.js", + print: "chrome://marionette/content/print.js", +}); + +XPCOMUtils.defineLazyGetter(this, "logger", () => Log.get()); + +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 + */ +this.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 = Services.appinfo.browserTabsRemoteAutostart; + this.useRemoteSubframes = Services.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; + + 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: { + moduleURI: + "chrome://marionette/content/actors/MarionetteReftestParent.jsm", + }, + child: { + moduleURI: + "chrome://marionette/content/actors/MarionetteReftestChild.jsm", + 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) { + 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; + } + logger.debug(`current: ${browserRect.width}x${browserRect.height}`); + } + + let reftestWin; + if (Services.appinfo.OS == "Android") { + logger.debug("Using current window"); + reftestWin = this.parentWindow; + await navigate.waitForNavigationCompleted(this.driver, () => { + const browsingContext = this.driver.getBrowsingContext(); + navigate.navigateTo(browsingContext, "about:blank"); + }); + } else { + 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 found = this.driver.findWindow([reftestWin], () => true); + await this.driver.setWindowHandle(found, true); + + const url = await this.driver._getCurrentURL(); + this.lastURL = url.href; + logger.debug(`loaded initial URL: ${this.lastURL}`); + + let browserRect = reftestWin.gBrowser.getBoundingClientRect(); + logger.debug(`new: ${browserRect.width}x${browserRect.height}`); + + return reftestWin; + } + + async openWindow(width, height) { + assert.positiveInteger(width); + assert.positiveInteger(height); + + let reftestWin = this.parentWindow.open( + "chrome://marionette/content/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 (Services.appinfo.OS === "Android") { + 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`; + browser.setAttribute("style", windowStyle); + + if (Services.appinfo.OS !== "Android") { + 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) { + this.driver.closeChromeWindow(); + let parentHandle = this.driver.findWindow( + [this.parentWindow], + () => true + ); + await this.driver.setWindowHandle(parentHandle); + } + this.reftestWin = null; + } + + /** + * Run a specific reftest. + * + * The assumed semantics are those of web-platform-tests where + * references form a tree and each test must meet all the conditions + * to reach one leaf node of the tree in order for the overall test + * to pass. + * + * @param {string} testUrl + * URL of the test itself. + * @param {Array.<Array>} references + * Array representing a tree of references to try. + * + * Each item in the array represents a single reference node and + * has the form <code>[referenceUrl, references, relation]</code>, + * where <var>referenceUrl</var> is a string to the URL, relation + * is either <code>==</code> or <code>!=</code> depending on the + * type of reftest, and references is another array containing + * items of the same form, representing further comparisons treated + * as AND with the current item. Sibling entries are treated as OR. + * + * For example with testUrl of T: + * + * <pre><code> + * references = [[A, [[B, [], ==]], ==]] + * Must have T == A AND A == B to pass + * + * references = [[A, [], ==], [B, [], !=] + * Must have T == A OR T != B + * + * references = [[A, [[B, [], ==], [C, [], ==]], ==], [D, [], ]] + * Must have (T == A AND A == B) OR (T == A AND A == C) OR (T == D) + * </code></pre> + * + * @param {string} expected + * Expected test outcome (e.g. <tt>PASS</tt>, <tt>FAIL</tt>). + * @param {number} timeout + * Test timeout in milliseconds. + * + * @return {Object} + * Result object with fields status, message and extra. + */ + async run( + testUrl, + references, + expected, + timeout, + pageRanges = {}, + width = DEFAULT_REFTEST_WIDTH, + height = DEFAULT_REFTEST_HEIGHT + ) { + let timeoutHandle; + + let timeoutPromise = new Promise(resolve => { + timeoutHandle = this.parentWindow.setTimeout(() => { + resolve({ status: STATUS.TIMEOUT, message: null, extra: {} }); + }, timeout); + }); + + let testRunner = (async () => { + let result; + try { + result = await this.runTest( + testUrl, + references, + expected, + timeout, + pageRanges, + width, + height + ); + } catch (e) { + result = { + status: STATUS.ERROR, + message: String(e), + stack: e.stack, + extra: {}, + }; + } + return result; + })(); + + let result = await Promise.race([testRunner, timeoutPromise]); + this.parentWindow.clearTimeout(timeoutHandle); + if (result.status === STATUS.TIMEOUT) { + await this.abort(); + } + + return result; + } + + async runTest( + testUrl, + references, + expected, + timeout, + pageRanges, + width, + height + ) { + let win = await this.ensureWindow(timeout, width, height); + + function toBase64(screenshot) { + let dataURL = screenshot.canvas.toDataURL(); + return dataURL.split(",")[1]; + } + + let result = { + status: STATUS.FAIL, + message: "", + stack: null, + extra: {}, + }; + + let screenshotData = []; + + let stack = []; + for (let i = references.length - 1; i >= 0; i--) { + let item = references[i]; + stack.push([testUrl, ...item]); + } + + let done = false; + + while (stack.length && !done) { + let [lhsUrl, rhsUrl, references, relation, extras = {}] = stack.pop(); + result.message += `Testing ${lhsUrl} ${relation} ${rhsUrl}\n`; + + let comparison; + try { + comparison = await this.compareUrls( + win, + lhsUrl, + rhsUrl, + relation, + timeout, + pageRanges, + extras + ); + } catch (e) { + comparison = { + lhs: null, + rhs: null, + passed: false, + error: e, + msg: null, + }; + } + if (comparison.msg) { + result.message += `${comparison.msg}\n`; + } + if (comparison.error !== null) { + result.status = STATUS.ERROR; + result.message += String(comparison.error); + result.stack = comparison.error.stack; + } + + function recordScreenshot() { + let encodedLHS = comparison.lhs ? toBase64(comparison.lhs) : ""; + let encodedRHS = comparison.rhs ? toBase64(comparison.rhs) : ""; + screenshotData.push([ + { url: lhsUrl, screenshot: encodedLHS }, + relation, + { url: rhsUrl, screenshot: encodedRHS }, + ]); + } + + if (this.screenshotMode === SCREENSHOT_MODE.always) { + recordScreenshot(); + } + + if (comparison.passed) { + if (references.length) { + for (let i = references.length - 1; i >= 0; i--) { + let item = references[i]; + stack.push([rhsUrl, ...item]); + } + } else { + // Reached a leaf node so all of one reference chain passed + result.status = STATUS.PASS; + if ( + this.screenshotMode <= SCREENSHOT_MODE.fail && + expected != result.status + ) { + recordScreenshot(); + } + done = true; + } + } else if (!stack.length || result.status == STATUS.ERROR) { + // If we don't have any alternatives to try then this will be + // the last iteration, so save the failing screenshots if required. + let isFail = this.screenshotMode === SCREENSHOT_MODE.fail; + let isUnexpected = this.screenshotMode === SCREENSHOT_MODE.unexpected; + if (isFail || (isUnexpected && expected != result.status)) { + recordScreenshot(); + } + } + + // Return any reusable canvases to the pool + let cacheKey = width + "x" + height; + let canvasPool = this.canvasCache.get(cacheKey).get(null); + [comparison.lhs, comparison.rhs].map(screenshot => { + if (screenshot !== null && screenshot.reuseCanvas) { + canvasPool.push(screenshot.canvas); + } + }); + 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 + ) { + 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 = false; + msg = `Got different numbers of pages; test has ${lhsCount}, ref has ${rhsCount}`; + } + + let lhs = null; + let rhs = null; + 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; + logger.debug( + `lhs canvas size ${lhs.canvas.width}x${lhs.canvas.height}` + ); + logger.debug( + `rhs canvas size ${rhs.canvas.width}x${rhs.canvas.height}` + ); + try { + pixelsDifferent = this.windowUtils.compareCanvases( + lhs.canvas, + rhs.canvas, + maxDifferences + ); + } catch (e) { + error = e; + passed = false; + break; + } + + let areEqual = this.isAcceptableDifference( + maxDifferences.value, + pixelsDifferent, + extras.fuzzy + ); + logger.debug( + `Page ${i + 1} maxDifferences: ${maxDifferences.value} ` + + `pixelsDifferent: ${pixelsDifferent}` + ); + 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) { + logger.info(`No differences allowed`); + return pixelsDifferent === 0; + } + let [allowedDiff, allowedPixels] = allowed; + 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 (Services.appinfo.OS === "Android") { + return; + } + let oa = E10SUtils.predictOriginAttributes({ browser }); + let remoteType = E10SUtils.getRemoteTypeForURI( + url, + this.useRemoteTabs, + this.useRemoteSubframes, + E10SUtils.DEFAULT_REMOTE_TYPE, + null, + oa + ); + + // Only re-construct the browser if its remote type needs to change. + if (browser.remoteType !== remoteType) { + if (remoteType === E10SUtils.NOT_REMOTE) { + browser.removeAttribute("remote"); + browser.removeAttribute("remoteType"); + } else { + browser.setAttribute("remote", "true"); + browser.setAttribute("remoteType", remoteType); + } + + browser.changeRemoteness({ remoteType }); + browser.construct(); + + // XXX: This appears to be working fine as is, should we be reinitializing + // something here? If so, what? The listener.js framescript is registered + // on the reftest.xhtml chrome window (which shouldn't be changing?), and + // driver.js uses the global message manager to listen for messages. + } + } + + async loadTestUrl(win, url, timeout) { + const browsingContext = this.driver.getBrowsingContext({ top: true }); + + logger.debug(`Starting load of ${url}`); + if (this.lastURL === url) { + logger.debug(`Refreshing page`); + await navigate.waitForNavigationCompleted(this.driver, () => { + 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); + 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 = browsingContext.currentWindowGlobal.getActor( + "MarionetteReftest" + ); + isReftestReady = await actor.reftestWait(url, this.useRemoteTabs); + } + } + + async screenshot(win, url, timeout) { + // On windows the above doesn't *actually* set the window to be the + // reftest size; but *does* set the content area to be the right size; + // the window is given some extra borders that aren't explicable from CSS + let browserRect = win.gBrowser.getBoundingClientRect(); + let canvas = null; + let remainingCount = this.urlCount.get(url) || 1; + let cache = remainingCount > 1; + let cacheKey = browserRect.width + "x" + browserRect.height; + logger.debug( + `screenshot ${url} remainingCount: ` + + `${remainingCount} cache: ${cache} cacheKey: ${cacheKey}` + ); + let reuseCanvas = false; + let sizedCache = this.canvasCache.get(cacheKey); + if (sizedCache.has(url)) { + 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) { + logger.debug("reusing canvas from canvas pool"); + canvas = canvasPool.pop(); + } else { + 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 + ) + ) { + 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 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 + ) { + logger.warn( + `Canvas dimensions changed to ${canvas.width}x${canvas.height}` + ); + reuseCanvas = false; + cache = false; + } + if (cache) { + sizedCache.set(url, canvas); + } + this.urlCount.set(url, remainingCount - 1); + return { canvas, reuseCanvas }; + } + + async screenshotPaginated(win, url, timeout, pageRanges) { + url = new URL(url).href; // normalize the URL + await this.loadTestUrl(win, url, timeout); + + const [width, height] = [DEFAULT_PAGE_WIDTH, DEFAULT_PAGE_HEIGHT]; + const margin = DEFAULT_PAGE_MARGIN; + const settings = print.addDefaultSettings({ + page: { + width, + height, + }, + margin: { + left: margin, + right: margin, + top: margin, + bottom: margin, + }, + shrinkToFit: false, + printBackground: true, + }); + + const filePath = await print.printToFile( + win.gBrowser.frameLoader, + win.gBrowser.outerWindowID, + settings + ); + + const fp = await OS.File.open(filePath, { read: true }); + try { + const pdf = await this.loadPdf(url, fp); + let pages = this.getPages(pageRanges, url, pdf.numPages); + return [this.renderPages(pdf, pages), pages.size]; + } finally { + fp.close(); + await OS.File.remove(filePath); + } + } + + async loadPdfJs() { + // Ensure pdf.js is loaded in the opener window + await new Promise((resolve, reject) => { + const doc = this.parentWindow.document; + const script = doc.createElement("script"); + script.src = "resource://pdf.js/build/pdf.js"; + script.onload = resolve; + script.onerror = () => reject(new Error("pdfjs load failed")); + doc.documentElement.appendChild(script); + }); + this.parentWindow.pdfjsLib.GlobalWorkerOptions.workerSrc = + "resource://pdf.js/build/pdf.worker.js"; + } + + async loadPdf(url, fp) { + const data = await fp.read(); + 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)) { + logger.info(`Skipping page ${pageNumber}/${pdf.numPages}`); + continue; + } + 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/testing/marionette/reftest.xhtml b/testing/marionette/reftest.xhtml new file mode 100644 index 0000000000..7135ce2862 --- /dev/null +++ b/testing/marionette/reftest.xhtml @@ -0,0 +1,6 @@ +<window id="reftest" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + hidechrome="true" + style="background-color:white; overflow:hidden"> + <script src="reftest-content.js"></script> +</window> diff --git a/testing/marionette/server.js b/testing/marionette/server.js new file mode 100644 index 0000000000..f994177829 --- /dev/null +++ b/testing/marionette/server.js @@ -0,0 +1,410 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const EXPORTED_SYMBOLS = ["TCPConnection", "TCPListener"]; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + OS: "resource://gre/modules/osfile.jsm", + + assert: "chrome://marionette/content/assert.js", + Command: "chrome://marionette/content/message.js", + DebuggerTransport: "chrome://marionette/content/transport.js", + error: "chrome://marionette/content/error.js", + GeckoDriver: "chrome://marionette/content/driver.js", + Log: "chrome://marionette/content/log.js", + MarionettePrefs: "chrome://marionette/content/prefs.js", + Message: "chrome://marionette/content/message.js", + Response: "chrome://marionette/content/message.js", + WebElement: "chrome://marionette/content/element.js", +}); + +XPCOMUtils.defineLazyGetter(this, "logger", () => Log.get()); +XPCOMUtils.defineLazyGetter(this, "ServerSocket", () => { + return Components.Constructor( + "@mozilla.org/network/server-socket;1", + "nsIServerSocket", + "initSpecialConnection" + ); +}); + +const { KeepWhenOffline, LoopbackOnly } = Ci.nsIServerSocket; + +/** @namespace */ +this.server = {}; + +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. + */ +class TCPListener { + /** + * @param {number} port + * Port for server to listen to. + */ + constructor(port) { + this.port = port; + this.socket = null; + this.conns = new Set(); + this.nextConnID = 0; + this.alive = false; + } + + /** + * Function produces a {@link GeckoDriver}. + * + * Determines the application to initialise the driver with. + * + * @return {GeckoDriver} + * A driver instance. + */ + driverFactory() { + MarionettePrefs.contentListener = false; + return new GeckoDriver(this); + } + + set acceptConnections(value) { + if (value) { + if (!this.socket) { + try { + const flags = KeepWhenOffline | LoopbackOnly; + const backlog = 1; + this.socket = new ServerSocket(this.port, flags, backlog); + } catch (e) { + throw new Error(`Could not bind to port ${this.port} (${e.name})`); + } + + this.port = this.socket.port; + + this.socket.asyncListen(this); + 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; + logger.info(`Stopped listening on port ${this.port}`); + } + } + + /** + * Bind this listener to {@link #port} and start accepting incoming + * socket connections on {@link #onSocketAccepted}. + * + * The marionette.port preference will be populated with the value + * of {@link #port}. + */ + start() { + if (this.alive) { + return; + } + + // Start socket server and listening for connection attempts + this.acceptConnections = true; + MarionettePrefs.port = this.port; + this.alive = true; + } + + stop() { + if (!this.alive) { + return; + } + + // Shutdown server socket, and no longer listen for new connections + this.acceptConnections = false; + this.alive = false; + } + + onSocketAccepted(serverSocket, clientSocket) { + let input = clientSocket.openInputStream(0, 0, 0); + let output = clientSocket.openOutputStream(0, 0, 0); + let transport = new DebuggerTransport(input, output); + + let conn = new TCPConnection( + this.nextConnID++, + transport, + this.driverFactory.bind(this) + ); + conn.onclose = this.onConnectionClosed.bind(this); + this.conns.add(conn); + + logger.debug( + `Accepted connection ${conn.id} ` + + `from ${clientSocket.host}:${clientSocket.port}` + ); + conn.sayHello(); + transport.ready(); + } + + onConnectionClosed(conn) { + logger.debug(`Closed connection ${conn.id}`); + this.conns.delete(conn); + } +} +this.TCPListener = TCPListener; + +/** + * 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}. + */ +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(); + this.driver.init(); + } + + /** + * Debugger transport callback that cleans up + * after a connection is closed. + */ + onClosed() { + this.driver.deleteSession(); + this.driver.uninit(); + 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) + ); + error.report(e); + return; + } + + // return immediately with any error trying to unmarshal message + let msg; + try { + msg = Message.fromPacket(data); + msg.origin = Message.Origin.Client; + this.log_(msg); + } catch (e) { + let resp = this.createResponse(data[1]); + resp.sendError(e); + return; + } + + // execute new command + if (msg instanceof Command) { + (async () => { + await this.execute(msg); + })(); + } else { + 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(error.report); + } + + /** + * Despatches command to appropriate Marionette service. + * + * @param {Command} cmd + * Command to run. + * @param {Response} resp + * Mutable response where the command's return value will be + * assigned. + * + * @throws {Error} + * A command's implementation may throw at any time. + */ + async despatch(cmd, resp) { + let fn = this.driver.commands[cmd.name]; + if (typeof fn == "undefined") { + throw new error.UnknownCommandError(cmd.name); + } + + if (cmd.name != "WebDriver:NewSession") { + assert.session( + this.driver, + "Tried to run command without establishing a connection" + ); + } + + let rv = await fn.bind(this.driver)(cmd); + + if (rv != null) { + if (rv instanceof WebElement || typeof rv != "object") { + resp.body = { value: rv }; + } else { + resp.body = rv; + } + } + } + + /** + * Fail-safe creation of a new instance of {@link Response}. + * + * @param {number} msgID + * Message ID to respond to. If it is not a number, -1 is used. + * + * @return {Response} + * Response to the message with `msgID`. + */ + createResponse(msgID) { + if (typeof msgID != "number") { + msgID = -1; + } + return new Response(msgID, this.send.bind(this)); + } + + sendError(err, cmdID) { + let resp = new 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 = Message.Origin.Server; + if (msg instanceof Response) { + this.sendToClient(msg); + } else { + 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); + } + + log_(msg) { + let dir = msg.origin == Message.Origin.Client ? "->" : "<-"; + logger.debug(`${this.id} ${dir} ${msg}`); + } + + toString() { + return `[object TCPConnection ${this.id}]`; + } +} +this.TCPConnection = TCPConnection; diff --git a/testing/marionette/stream-utils.js b/testing/marionette/stream-utils.js new file mode 100644 index 0000000000..ea7bdcb82a --- /dev/null +++ b/testing/marionette/stream-utils.js @@ -0,0 +1,261 @@ +/* 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 EXPORTED_SYMBOLS = ["StreamUtils"]; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + EventEmitter: "resource://gre/modules/EventEmitter.jsm", +}); + +XPCOMUtils.defineLazyServiceGetter( + this, + "IOUtil", + "@mozilla.org/io-util;1", + "nsIIOUtil" +); + +XPCOMUtils.defineLazyGetter(this, "ScriptableInputStream", () => { + return Components.Constructor( + "@mozilla.org/scriptableinputstream;1", + "nsIScriptableInputStream", + "init" + ); +}); + +const BUFFER_SIZE = 0x8000; + +/** + * This helper function (and its companion object) are used by bulk + * senders and receivers to read and write data in and out of other streams. + * Functions that make use of this tool are passed to callers when it is + * time to read or write bulk data. It is highly recommended to use these + * copier functions instead of the stream directly because the copier + * enforces the agreed upon length. Since bulk mode reuses an existing + * stream, the sender and receiver must write and read exactly the agreed + * upon amount of data, or else the entire transport will be left in a + * invalid state. Additionally, other methods of stream copying (such as + * NetUtil.asyncCopy) close the streams involved, which would terminate + * the debugging transport, and so it is avoided here. + * + * Overall, this *works*, but clearly the optimal solution would be + * able to just use the streams directly. If it were possible to fully + * implement nsIInputStream/nsIOutputStream in JS, wrapper streams could + * be created to enforce the length and avoid closing, and consumers could + * use familiar stream utilities like NetUtil.asyncCopy. + * + * The function takes two async streams and copies a precise number + * of bytes from one to the other. Copying begins immediately, but may + * complete at some future time depending on data size. Use the returned + * promise to know when it's complete. + * + * @param {nsIAsyncInputStream} input + * Stream to copy from. + * @param {nsIAsyncOutputStream} output + * Stream to copy to. + * @param {number} length + * Amount of data that needs to be copied. + * + * @return {Promise} + * Promise is resolved when copying completes or rejected if any + * (unexpected) errors occur. + */ +function copyStream(input, output, length) { + let copier = new StreamCopier(input, output, length); + return copier.copy(); +} + +/** @class */ +function StreamCopier(input, output, length) { + 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 (IOUtil.outputStreamIsBuffered(output)) { + this.output = output; + } else { + this.output = Cc[ + "@mozilla.org/network/buffered-output-stream;1" + ].createInstance(Ci.nsIBufferedOutputStream); + this.output.init(output, BUFFER_SIZE); + } + this._length = length; + this._amountLeft = length; + this._deferred = { + promise: new Promise((resolve, reject) => { + this._deferred.resolve = resolve; + this._deferred.reject = reject; + }), + }; + + this._copy = this._copy.bind(this); + this._flush = this._flush.bind(this); + this._destroy = this._destroy.bind(this); + + // Copy promise's then method up to this object. + // + // Allows the copier to offer a promise interface for the simple succeed + // or fail scenarios, but also emit events (due to the EventEmitter) + // for other states, like progress. + this.then = this._deferred.promise.then.bind(this._deferred.promise); + this.then(this._destroy, this._destroy); + + // Stream ready callback starts as |_copy|, but may switch to |_flush| + // at end if flushing would block the output stream. + this._streamReadyCallback = this._copy; +} +StreamCopier._nextId = 0; + +StreamCopier.prototype = { + copy() { + // Dispatch to the next tick so that it's possible to attach a progress + // event listener, even for extremely fast copies (like when testing). + Services.tm.currentThread.dispatch(() => { + try { + this._copy(); + } catch (e) { + this._deferred.reject(e); + } + }, 0); + return this; + }, + + _copy() { + let bytesAvailable = this.input.available(); + let amountToCopy = Math.min(bytesAvailable, this._amountLeft); + this._debug("Trying to copy: " + amountToCopy); + + let bytesCopied; + try { + bytesCopied = this.output.writeFrom(this.input, amountToCopy); + } catch (e) { + if (e.result == Cr.NS_BASE_STREAM_WOULD_BLOCK) { + this._debug("Base stream would block, will retry"); + this._debug("Waiting for output stream"); + this.baseAsyncOutput.asyncWait(this, 0, 0, Services.tm.currentThread); + return; + } + throw e; + } + + this._amountLeft -= bytesCopied; + this._debug("Copied: " + bytesCopied + ", Left: " + this._amountLeft); + this._emitProgress(); + + if (this._amountLeft === 0) { + this._debug("Copy done!"); + this._flush(); + return; + } + + this._debug("Waiting for input stream"); + this.input.asyncWait(this, 0, 0, Services.tm.currentThread); + }, + + _emitProgress() { + this.emit("progress", { + bytesSent: this._length - this._amountLeft, + totalBytes: this._length, + }); + }, + + _flush() { + try { + this.output.flush(); + } catch (e) { + if ( + e.result == Cr.NS_BASE_STREAM_WOULD_BLOCK || + e.result == Cr.NS_ERROR_FAILURE + ) { + this._debug("Flush would block, will retry"); + this._streamReadyCallback = this._flush; + this._debug("Waiting for output stream"); + this.baseAsyncOutput.asyncWait(this, 0, 0, Services.tm.currentThread); + return; + } + throw e; + } + this._deferred.resolve(); + }, + + _destroy() { + this._destroy = null; + this._copy = null; + this._flush = null; + this.input = null; + this.output = null; + }, + + // nsIInputStreamCallback + onInputStreamReady() { + this._streamReadyCallback(); + }, + + // nsIOutputStreamCallback + onOutputStreamReady() { + this._streamReadyCallback(); + }, + + _debug() {}, +}; + +/** + * Read from a stream, one byte at a time, up to the next + * <var>delimiter</var> character, but stopping if we've read |count| + * without finding it. Reading also terminates early if there are less + * than <var>count</var> bytes available on the stream. In that case, + * we only read as many bytes as the stream currently has to offer. + * + * @param {nsIInputStream} stream + * Input stream to read from. + * @param {string} delimiter + * Character we're trying to find. + * @param {number} count + * Max number of characters to read while searching. + * + * @return {string} + * Collected data. If the delimiter was found, this string will + * end with it. + */ +// TODO: This implementation could be removed if bug 984651 is fixed, +// which provides a native version of the same idea. +function delimitedRead(stream, delimiter, count) { + let scriptableStream; + if (stream instanceof Ci.nsIScriptableInputStream) { + scriptableStream = stream; + } else { + scriptableStream = new 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; +} + +this.StreamUtils = { + copyStream, + delimitedRead, +}; diff --git a/testing/marionette/sync.js b/testing/marionette/sync.js new file mode 100644 index 0000000000..4b135809c2 --- /dev/null +++ b/testing/marionette/sync.js @@ -0,0 +1,650 @@ +/* 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 EXPORTED_SYMBOLS = [ + "executeSoon", + "DebounceCallback", + "IdlePromise", + "MessageManagerDestroyedPromise", + "PollPromise", + "Sleep", + "TimedPromise", + "waitForEvent", + "waitForLoadEvent", + "waitForMessage", + "waitForObserverTopic", +]; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + AppConstants: "resource://gre/modules/AppConstants.jsm", + + error: "chrome://marionette/content/error.js", + EventDispatcher: + "chrome://marionette/content/actors/MarionetteEventsParent.jsm", + Log: "chrome://marionette/content/log.js", + truncate: "chrome://marionette/content/format.js", +}); + +XPCOMUtils.defineLazyGetter(this, "logger", () => Log.get()); + +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. + */ +function executeSoon(func) { + if (typeof func != "function") { + throw new TypeError(); + } + + Services.tm.dispatchToMainThread(func); +} + +/** + * Runs a Promise-like function off the main thread until it is resolved + * through ``resolve`` or ``rejected`` callbacks. The function is + * guaranteed to be run at least once, irregardless of the timeout. + * + * The ``func`` is evaluated every ``interval`` for as long as its + * runtime duration does not exceed ``interval``. Evaluations occur + * sequentially, meaning that evaluations of ``func`` are queued if + * the runtime evaluation duration of ``func`` is greater than ``interval``. + * + * ``func`` is given two arguments, ``resolve`` and ``reject``, + * of which one must be called for the evaluation to complete. + * Calling ``resolve`` with an argument indicates that the expected + * wait condition was met and will return the passed value to the + * caller. Conversely, calling ``reject`` will evaluate ``func`` + * again until the ``timeout`` duration has elapsed or ``func`` throws. + * The passed value to ``reject`` will also be returned to the caller + * once the wait has expired. + * + * Usage:: + * + * let els = new PollPromise((resolve, reject) => { + * let res = document.querySelectorAll("p"); + * if (res.length > 0) { + * resolve(Array.from(res)); + * } else { + * reject([]); + * } + * }, {timeout: 1000}); + * + * @param {Condition} func + * Function to run off the main thread. + * @param {number=} [timeout] timeout + * Desired timeout if wanted. If 0 or less than the runtime evaluation + * time of ``func``, ``func`` is guaranteed to run at least once. + * Defaults to using no timeout. + * @param {number=} [interval=10] interval + * Duration between each poll of ``func`` in milliseconds. + * Defaults to 10 milliseconds. + * + * @return {Promise.<*>} + * Yields the value passed to ``func``'s + * ``resolve`` or ``reject`` callbacks. + * + * @throws {*} + * If ``func`` throws, its error is propagated. + * @throws {TypeError} + * If `timeout` or `interval`` are not numbers. + * @throws {RangeError} + * If `timeout` or `interval` are not unsigned integers. + */ +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 (error.isError(rejected)) { + throw rejected; + } + + // return if there is a timeout and set to 0, + // allowing |func| to be evaluated at least once + if ( + typeof end != "undefined" && + (start == end || new Date().getTime() >= end) + ) { + resolve(rejected); + } + }) + .catch(reject); + }; + + // the repeating slack timer waits |interval| + // before invoking |evalFn| + evalFn(); + + timer.init(evalFn, interval, TYPE_REPEATING_SLACK); + }).then( + res => { + timer.cancel(); + return res; + }, + err => { + timer.cancel(); + throw err; + } + ); +} + +/** + * Represents the timed, eventual completion (or failure) of an + * asynchronous operation, and its resulting value. + * + * In contrast to a regular Promise, it times out after ``timeout``. + * + * @param {Condition} func + * Function to run, which will have its ``reject`` + * callback invoked after the ``timeout`` duration is reached. + * It is given two callbacks: ``resolve(value)`` and + * ``reject(error)``. + * @param {timeout=} 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=} [throws=TimeoutError] 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. + * + * @return {Promise.<*>} + * Timed promise. + * + * @throws {TypeError} + * If `timeout` is not a number. + * @throws {RangeError} + * If `timeout` is not an unsigned integer. + */ +function TimedPromise( + fn, + { timeout = PROMISE_TIMEOUT, throws = error.TimeoutError } = {} +) { + const timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + + if (typeof fn != "function") { + throw new TypeError(); + } + if (typeof timeout != "number") { + throw new TypeError(); + } + if (!Number.isInteger(timeout) || timeout < 0) { + throw new RangeError(); + } + + return new Promise((resolve, reject) => { + let trace; + + // Reject only if |throws| is given. Otherwise it is assumed that + // the user is OK with the promise timing out. + let bail = () => { + if (throws !== null) { + let err = new throws(); + reject(err); + } else { + logger.warn(`TimedPromise timed out after ${timeout} ms`, trace); + resolve(); + } + }; + + trace = error.stack(); + timer.initWithCallback({ notify: bail }, timeout, TYPE_ONE_SHOT); + + try { + fn(resolve, reject); + } catch (e) { + reject(e); + } + }).then( + res => { + timer.cancel(); + return res; + }, + err => { + timer.cancel(); + throw err; + } + ); +} + +/** + * Pauses for the given duration. + * + * @param {number} timeout + * Duration to wait before fulfilling promise in milliseconds. + * + * @return {Promise} + * Promise that fulfills when the `timeout` is elapsed. + * + * @throws {TypeError} + * If `timeout` is not a number. + * @throws {RangeError} + * If `timeout` is not an unsigned integer. + */ +function Sleep(timeout) { + if (typeof timeout != "number") { + throw new TypeError(); + } + if (!Number.isInteger(timeout) || timeout < 0) { + throw new RangeError(); + } + + return new Promise(resolve => { + const timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + timer.init( + () => { + // Bug 1663880 - Explicitely cancel the timer for now to prevent a hang + timer.cancel(); + resolve(); + }, + timeout, + TYPE_ONE_SHOT + ); + }); +} + +/** + * Detects when the specified message manager has been destroyed. + * + * One can observe the removal and detachment of a content browser + * (`<xul:browser>`) or a chrome window by its message manager + * disconnecting. + * + * When a browser is associated with a tab, this is safer than only + * relying on the event `TabClose` which signalises the _intent to_ + * remove a tab and consequently would lead to the destruction of + * the content browser and its browser message manager. + * + * When closing a chrome window it is safer than only relying on + * the event 'unload' which signalises the _intent to_ close the + * chrome window and consequently would lead to the destruction of + * the window and its window message manager. + * + * @param {MessageListenerManager} messageManager + * The message manager to observe for its disconnect state. + * Use the browser message manager when closing a content browser, + * and the window message manager when closing a chrome window. + * + * @return {Promise} + * A promise that resolves when the message manager has been destroyed. + */ +function MessageManagerDestroyedPromise(messageManager) { + return new Promise(resolve => { + function observe(subject, topic) { + logger.trace(`Received observer notification ${topic}`); + + if (subject == messageManager) { + Services.obs.removeObserver(this, "message-manager-disconnect"); + resolve(); + } + } + + Services.obs.addObserver(observe, "message-manager-disconnect"); + }); +} + +/** + * Throttle until the main thread is idle and `window` has performed + * an animation frame (in that order). + * + * @param {ChromeWindow} win + * Window to request the animation frame from. + * + * @return Promise + */ +function IdlePromise(win) { + const animationFramePromise = new Promise(resolve => { + executeSoon(() => { + win.requestAnimationFrame(resolve); + }); + }); + + // Abort if the underlying window gets closed + const windowClosedPromise = new PollPromise(resolve => { + if (win.closed) { + resolve(); + } + }); + + return Promise.race([animationFramePromise, windowClosedPromise]); +} + +/** + * Wraps a callback function, that, as long as it continues to be + * invoked, will not be triggered. The given function will be + * called after the timeout duration is reached, after no more + * events fire. + * + * This class implements the {@link EventListener} interface, + * which means it can be used interchangably with `addEventHandler`. + * + * Debouncing events can be useful when dealing with e.g. DOM events + * that fire at a high rate. It is generally advisable to avoid + * computationally expensive operations such as DOM modifications + * under these circumstances. + * + * One such high frequenecy event is `resize` that can fire multiple + * times before the window reaches its final dimensions. In order + * to delay an operation until the window has completed resizing, + * it is possible to use this technique to only invoke the callback + * after the last event has fired:: + * + * let cb = new DebounceCallback(event => { + * // fires after the final resize event + * console.log("resize", event); + * }); + * window.addEventListener("resize", cb); + * + * Note that it is not possible to use this synchronisation primitive + * with `addEventListener(..., {once: true})`. + * + * @param {function(Event)} fn + * Callback function that is guaranteed to be invoked once only, + * after `timeout`. + * @param {number=} [timeout = 250] timeout + * Time since last event firing, before `fn` will be invoked. + */ +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 + ); + } +} +this.DebounceCallback = DebounceCallback; + +/** + * Wait for an event to be fired on a specified element. + * + * This method has been duplicated from BrowserTestUtils.jsm. + * + * 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 event, since this is probably a bug in the test. + * + * Usage:: + * + * let promiseEvent = waitForEvent(element, "eventName"); + * // Do some processing here that will cause the event to be fired + * // ... + * // Now wait until the Promise is fulfilled + * let receivedEvent = await promiseEvent; + * + * The promise resolution/rejection handler for the returned promise is + * guaranteed not to be called until the next event tick after the event + * listener gets called, so that all other event listeners for the element + * are executed before the handler is executed:: + * + * let promiseEvent = waitForEvent(element, "eventName"); + * // Same event tick here. + * await promiseEvent; + * // Next event tick here. + * + * If some code, such like adding yet another event listener, needs to be + * executed in the same event tick, use raw addEventListener instead and + * place the code inside the event listener:: + * + * element.addEventListener("load", () => { + * // Add yet another event listener in the same event tick as the load + * // event listener. + * p = waitForEvent(element, "ready"); + * }, { once: true }); + * + * @param {Element} subject + * The element that should receive the event. + * @param {string} eventName + * Name of the event to listen to. + * @param {Object=} options + * Extra options. + * @param {boolean=} options.capture + * True to use a capturing listener. + * @param {function(Event)=} options.checkFn + * Called with the ``Event`` object as argument, should return ``true`` + * if the event is the expected one, or ``false`` if it should be + * ignored and listening should continue. If not specified, the first + * event with the specified name resolves the returned promise. + * @param {boolean=} options.wantsUntrusted + * True to receive synthetic events dispatched by web content. + * + * @return {Promise.<Event>} + * Promise which resolves to the received ``Event`` object, or rejects + * in case of a failure. + */ +function waitForEvent( + subject, + eventName, + { capture = false, checkFn = null, wantsUntrusted = false } = {} +) { + if (subject == null || !("addEventListener" in subject)) { + throw new TypeError(); + } + if (typeof eventName != "string") { + throw new TypeError(); + } + if (capture != null && typeof capture != "boolean") { + throw new TypeError(); + } + if (checkFn != null && typeof checkFn != "function") { + throw new TypeError(); + } + if (wantsUntrusted != null && typeof wantsUntrusted != "boolean") { + throw new TypeError(); + } + + return new Promise((resolve, reject) => { + subject.addEventListener( + eventName, + function listener(event) { + logger.trace(`Received DOM event ${event.type} for ${event.target}`); + try { + if (checkFn && !checkFn(event)) { + return; + } + subject.removeEventListener(eventName, listener, capture); + executeSoon(() => resolve(event)); + } catch (ex) { + try { + subject.removeEventListener(eventName, listener, capture); + } catch (ex2) { + // Maybe the provided object does not support removeEventListener. + } + executeSoon(() => reject(ex)); + } + }, + capture, + wantsUntrusted + ); + }); +} + +/** + * Wait for a load event to be fired on a specific browsing context. + * The supported events are: + * - beforeunload + * - DOMContentLoaded + * - hashchange + * - pagehide + * - pageshow + * - popstate + * + * @param {string} eventName + * The specific load event name to wait for. + * @param {function(): BrowsingContext} browsingContextFn + * A function that returns the reference to the browsing context for which + * the load event should be fired. + * + * @return {Promise.<Object>} + * Promise which resolves when the load event has been fired + */ +function waitForLoadEvent(eventName, browsingContextFn) { + let onPageLoad; + return new Promise(resolve => { + onPageLoad = (_, data) => { + logger.trace(`Received event ${data.type} for ${data.documentURI}`); + if ( + data.browsingContext === browsingContextFn() && + data.type === eventName + ) { + EventDispatcher.off("page-load", onPageLoad); + resolve(data); + } + }; + EventDispatcher.on("page-load", onPageLoad); + }); +} + +/** + * Wait for a message to be fired from a particular message manager. + * + * This method has been duplicated from BrowserTestUtils.jsm. + * + * @param {nsIMessageManager} messageManager + * The message manager that should be used. + * @param {string} messageName + * The message to wait for. + * @param {Object=} options + * Extra options. + * @param {function(Message)=} options.checkFn + * Called with the ``Message`` object as argument, should return ``true`` + * if the message is the expected one, or ``false`` if it should be + * ignored and listening should continue. If not specified, the first + * message with the specified name resolves the returned promise. + * + * @return {Promise.<Object>} + * Promise which resolves to the data property of the received + * ``Message``. + */ +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) { + 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.jsm. + * + * Because this function is intended for testing, any error in checkFn + * will cause the returned promise to be rejected instead of waiting for + * the next notification, since this is probably a bug in the test. + * + * @param {string} topic + * The topic to observe. + * @param {Object=} options + * Extra options. + * @param {function(String,Object)=} options.checkFn + * Called with ``subject``, and ``data`` as arguments, should return true + * if the notification is the expected one, or false if it should be + * ignored and listening should continue. If not specified, the first + * notification for the specified topic resolves the returned promise. + * + * @return {Promise.<Array<String, Object>>} + * Promise which resolves to an array of ``subject``, and ``data`` from + * the observed notification. + */ +function waitForObserverTopic(topic, { checkFn = null } = {}) { + if (typeof topic != "string") { + throw new TypeError(); + } + if (checkFn != null && typeof checkFn != "function") { + throw new TypeError(); + } + + return new Promise((resolve, reject) => { + Services.obs.addObserver(function observer(subject, topic, data) { + logger.trace(`Received observer notification ${topic}`); + try { + if (checkFn && !checkFn(subject, data)) { + return; + } + Services.obs.removeObserver(observer, topic); + resolve({ subject, data }); + } catch (ex) { + Services.obs.removeObserver(observer, topic); + reject(ex); + } + }, topic); + }); +} diff --git a/testing/marionette/test/README b/testing/marionette/test/README new file mode 100644 index 0000000000..9305b92cab --- /dev/null +++ b/testing/marionette/test/README @@ -0,0 +1 @@ +See ../doc/Testing.md
\ No newline at end of file diff --git a/testing/marionette/test/unit/.eslintrc.js b/testing/marionette/test/unit/.eslintrc.js new file mode 100644 index 0000000000..2ef179ab5e --- /dev/null +++ b/testing/marionette/test/unit/.eslintrc.js @@ -0,0 +1,7 @@ +"use strict"; + +module.exports = { + rules: { + camelcase: "off", + }, +}; diff --git a/testing/marionette/test/unit/README b/testing/marionette/test/unit/README new file mode 100644 index 0000000000..06eca782e7 --- /dev/null +++ b/testing/marionette/test/unit/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 testing/marionette/test/unit + +Or call out the harness specifically: + + % ./mach xpcshell-test testing/marionette/test/unit + +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/testing/marionette/test/unit/test_action.js b/testing/marionette/test/unit/test_action.js new file mode 100644 index 0000000000..1d515d6382 --- /dev/null +++ b/testing/marionette/test/unit/test_action.js @@ -0,0 +1,712 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { action } = ChromeUtils.import("chrome://marionette/content/action.js"); + +const XHTMLNS = "http://www.w3.org/1999/xhtml"; + +const domEl = { + nodeType: 1, + ELEMENT_NODE: 1, + namespaceURI: XHTMLNS, +}; + +action.inputStateMap = new Map(); + +add_test(function test_createAction() { + Assert.throws( + () => new action.Action(), + /InvalidArgumentError/, + "Missing Action constructor args" + ); + Assert.throws( + () => new action.Action(1, 2), + /InvalidArgumentError/, + "Missing Action constructor args" + ); + Assert.throws( + () => new action.Action(1, 2, "sometype"), + /Expected string/, + "Non-string arguments." + ); + ok(new action.Action("id", "sometype", "sometype")); + + run_next_test(); +}); + +add_test(function test_defaultPointerParameters() { + let defaultParameters = { pointerType: action.PointerType.Mouse }; + deepEqual(action.PointerParameters.fromJSON(), defaultParameters); + + run_next_test(); +}); + +add_test(function test_processPointerParameters() { + let check = (regex, message, arg) => + checkErrors(regex, action.PointerParameters.fromJSON, [arg], message); + let parametersData; + for (let d of ["foo", "", "get", "Get"]) { + parametersData = { pointerType: d }; + let message = `parametersData: [pointerType: ${parametersData.pointerType}]`; + check(/Unknown pointerType/, message, parametersData); + } + parametersData.pointerType = "mouse"; // TODO "pen"; + deepEqual(action.PointerParameters.fromJSON(parametersData), { + pointerType: "mouse", + }); // TODO action.PointerType.Pen}); + + run_next_test(); +}); + +add_test(function test_processPointerUpDownAction() { + let actionItem = { type: "pointerDown" }; + let actionSequence = { type: "pointer", id: "some_id" }; + for (let d of [-1, "a"]) { + actionItem.button = d; + checkErrors( + /Expected 'button' \(.*\) to be >= 0/, + action.Action.fromJSON, + [actionSequence, actionItem], + `button: ${actionItem.button}` + ); + } + actionItem.button = 5; + let act = action.Action.fromJSON(actionSequence, actionItem); + equal(act.button, actionItem.button); + + run_next_test(); +}); + +add_test(function test_validateActionDurationAndCoordinates() { + let actionItem = {}; + let actionSequence = { id: "some_id" }; + let check = function(type, subtype, message = undefined) { + message = + message || `duration: ${actionItem.duration}, subtype: ${subtype}`; + actionItem.type = subtype; + actionSequence.type = type; + checkErrors( + /Expected '.*' \(.*\) to be >= 0/, + action.Action.fromJSON, + [actionSequence, actionItem], + message + ); + }; + for (let d of [-1, "a"]) { + actionItem.duration = d; + check("none", "pause"); + check("pointer", "pointerMove"); + } + actionItem.duration = 5000; + for (let name of ["x", "y"]) { + actionItem[name] = "a"; + actionItem.type = "pointerMove"; + actionSequence.type = "pointer"; + checkErrors( + /Expected '.*' \(.*\) to be an Integer/, + action.Action.fromJSON, + [actionSequence, actionItem], + `duration: ${actionItem.duration}, subtype: pointerMove` + ); + } + run_next_test(); +}); + +add_test(function test_processPointerMoveActionOriginValidation() { + let actionSequence = { type: "pointer", id: "some_id" }; + let actionItem = { duration: 5000, type: "pointerMove" }; + for (let d of [-1, { a: "blah" }, []]) { + actionItem.origin = d; + + checkErrors( + /Expected \'origin\' to be undefined, "viewport", "pointer", or an element/, + action.Action.fromJSON, + [actionSequence, actionItem], + `actionItem.origin: (${getTypeString(d)})` + ); + } + + run_next_test(); +}); + +add_test(function test_processPointerMoveActionOriginStringValidation() { + let actionSequence = { type: "pointer", id: "some_id" }; + let actionItem = { duration: 5000, type: "pointerMove" }; + for (let d of ["a", "", "get", "Get"]) { + actionItem.origin = d; + checkErrors( + /Unknown pointer-move origin/, + action.Action.fromJSON, + [actionSequence, actionItem], + `actionItem.origin: ${d}` + ); + } + + run_next_test(); +}); + +add_test(function test_processPointerMoveActionElementOrigin() { + let actionSequence = { type: "pointer", id: "some_id" }; + let actionItem = { duration: 5000, type: "pointerMove" }; + actionItem.origin = domEl; + let a = action.Action.fromJSON(actionSequence, actionItem); + deepEqual(a.origin, actionItem.origin); + run_next_test(); +}); + +add_test(function test_processPointerMoveActionDefaultOrigin() { + let actionSequence = { type: "pointer", id: "some_id" }; + // origin left undefined + let actionItem = { duration: 5000, type: "pointerMove" }; + let a = action.Action.fromJSON(actionSequence, actionItem); + deepEqual(a.origin, action.PointerOrigin.Viewport); + run_next_test(); +}); + +add_test(function test_processPointerMoveAction() { + let actionSequence = { id: "some_id", type: "pointer" }; + let actionItems = [ + { + duration: 5000, + type: "pointerMove", + origin: undefined, + x: undefined, + y: undefined, + }, + { + duration: undefined, + type: "pointerMove", + origin: domEl, + x: undefined, + y: undefined, + }, + { + duration: 5000, + type: "pointerMove", + x: 0, + y: undefined, + origin: undefined, + }, + { + duration: 5000, + type: "pointerMove", + x: 1, + y: 2, + origin: undefined, + }, + ]; + for (let expected of actionItems) { + let actual = action.Action.fromJSON(actionSequence, expected); + ok(actual instanceof action.Action); + equal(actual.duration, expected.duration); + equal(actual.x, expected.x); + equal(actual.y, expected.y); + + let origin = expected.origin; + if (typeof origin == "undefined") { + origin = action.PointerOrigin.Viewport; + } + deepEqual(actual.origin, origin); + } + run_next_test(); +}); + +add_test(function test_computePointerDestinationViewport() { + let act = { type: "pointerMove", x: 100, y: 200, origin: "viewport" }; + let inputState = new action.InputState.Pointer(action.PointerType.Mouse); + // these values should not affect the outcome + inputState.x = "99"; + inputState.y = "10"; + let target = action.computePointerDestination(act, inputState); + equal(act.x, target.x); + equal(act.y, target.y); + + run_next_test(); +}); + +add_test(function test_computePointerDestinationPointer() { + let act = { type: "pointerMove", x: 100, y: 200, origin: "pointer" }; + let inputState = new action.InputState.Pointer(action.PointerType.Mouse); + inputState.x = 10; + inputState.y = 99; + let target = action.computePointerDestination(act, inputState); + equal(act.x + inputState.x, target.x); + equal(act.y + inputState.y, target.y); + + run_next_test(); +}); + +add_test(function test_computePointerDestinationElement() { + // origin represents a web element + // using an object literal instead to test default case in computePointerDestination + let act = { type: "pointerMove", x: 100, y: 200, origin: {} }; + let inputState = new action.InputState.Pointer(action.PointerType.Mouse); + let elementCenter = { x: 10, y: 99 }; + let target = action.computePointerDestination(act, inputState, elementCenter); + equal(act.x + elementCenter.x, target.x); + equal(act.y + elementCenter.y, target.y); + + Assert.throws( + () => action.computePointerDestination(act, inputState, { a: 1 }), + /InvalidArgumentError/, + "Invalid element center coordinates." + ); + + Assert.throws( + () => action.computePointerDestination(act, inputState, undefined), + /InvalidArgumentError/, + "Undefined element center coordinates." + ); + + run_next_test(); +}); + +add_test(function test_processPointerAction() { + let actionSequence = { + type: "pointer", + id: "some_id", + parameters: { + pointerType: "mouse", // TODO "touch" + }, + }; + let actionItems = [ + { + duration: 2000, + type: "pause", + }, + { + type: "pointerMove", + duration: 2000, + }, + { + type: "pointerUp", + button: 1, + }, + ]; + for (let expected of actionItems) { + let actual = action.Action.fromJSON(actionSequence, expected); + equal(actual.type, actionSequence.type); + equal(actual.subtype, expected.type); + equal(actual.id, actionSequence.id); + if (expected.type === "pointerUp") { + equal(actual.button, expected.button); + } else { + equal(actual.duration, expected.duration); + } + if (expected.type !== "pause") { + equal(actual.pointerType, actionSequence.parameters.pointerType); + } + } + + run_next_test(); +}); + +add_test(function test_processPauseAction() { + let actionItem = { type: "pause", duration: 5000 }; + let actionSequence = { id: "some_id" }; + for (let type of ["none", "key", "pointer"]) { + actionSequence.type = type; + let act = action.Action.fromJSON(actionSequence, actionItem); + ok(act instanceof action.Action); + equal(act.type, type); + equal(act.subtype, actionItem.type); + equal(act.id, actionSequence.id); + equal(act.duration, actionItem.duration); + } + actionItem.duration = undefined; + let act = action.Action.fromJSON(actionSequence, actionItem); + equal(act.duration, actionItem.duration); + + run_next_test(); +}); + +add_test(function test_processActionSubtypeValidation() { + let actionItem = { type: "dancing" }; + let actionSequence = { id: "some_id" }; + let check = function(regex) { + let message = `type: ${actionSequence.type}, subtype: ${actionItem.type}`; + checkErrors( + regex, + action.Action.fromJSON, + [actionSequence, actionItem], + message + ); + }; + for (let type of ["none", "key", "pointer"]) { + actionSequence.type = type; + check(new RegExp(`Unknown subtype for ${type} action`)); + } + run_next_test(); +}); + +add_test(function test_processKeyActionUpDown() { + let actionSequence = { type: "key", id: "some_id" }; + let actionItem = { type: "keyDown" }; + + for (let v of [-1, undefined, [], ["a"], { length: 1 }, null]) { + actionItem.value = v; + let message = `actionItem.value: (${getTypeString(v)})`; + Assert.throws( + () => action.Action.fromJSON(actionSequence, actionItem), + /InvalidArgumentError/, + message + ); + Assert.throws( + () => action.Action.fromJSON(actionSequence, actionItem), + /Expected 'value' to be a string that represents single code point/, + message + ); + } + + actionItem.value = "\uE004"; + let act = action.Action.fromJSON(actionSequence, actionItem); + ok(act instanceof action.Action); + equal(act.type, actionSequence.type); + equal(act.subtype, actionItem.type); + equal(act.id, actionSequence.id); + equal(act.value, actionItem.value); + + run_next_test(); +}); + +add_test(function test_processInputSourceActionSequenceValidation() { + let actionSequence = { type: "swim", id: "some id" }; + let check = (message, regex) => + checkErrors(regex, action.Sequence.fromJSON, [actionSequence], message); + check(`actionSequence.type: ${actionSequence.type}`, /Unknown action type/); + action.inputStateMap.clear(); + + actionSequence.type = "none"; + actionSequence.id = -1; + check( + `actionSequence.id: ${getTypeString(actionSequence.id)}`, + /Expected 'id' to be a string/ + ); + action.inputStateMap.clear(); + + actionSequence.id = undefined; + check( + `actionSequence.id: ${getTypeString(actionSequence.id)}`, + /Expected 'id' to be defined/ + ); + action.inputStateMap.clear(); + + actionSequence.id = "some_id"; + actionSequence.actions = -1; + check( + `actionSequence.actions: ${getTypeString(actionSequence.actions)}`, + /Expected 'actionSequence.actions' to be an array/ + ); + action.inputStateMap.clear(); + + run_next_test(); +}); + +add_test(function test_processInputSourceActionSequence() { + let actionItem = { type: "pause", duration: 5 }; + let actionSequence = { + type: "none", + id: "some id", + actions: [actionItem], + }; + let expectedAction = new action.Action( + actionSequence.id, + "none", + actionItem.type + ); + expectedAction.duration = actionItem.duration; + let actions = action.Sequence.fromJSON(actionSequence); + equal(actions.length, 1); + deepEqual(actions[0], expectedAction); + action.inputStateMap.clear(); + run_next_test(); +}); + +add_test(function test_processInputSourceActionSequencePointer() { + let actionItem = { type: "pointerDown", button: 1 }; + let actionSequence = { + type: "pointer", + id: "9", + actions: [actionItem], + parameters: { + pointerType: "mouse", // TODO "pen" + }, + }; + let expectedAction = new action.Action( + actionSequence.id, + actionSequence.type, + actionItem.type + ); + expectedAction.pointerType = actionSequence.parameters.pointerType; + expectedAction.button = actionItem.button; + let actions = action.Sequence.fromJSON(actionSequence); + equal(actions.length, 1); + deepEqual(actions[0], expectedAction); + action.inputStateMap.clear(); + run_next_test(); +}); + +add_test(function test_processInputSourceActionSequenceKey() { + let actionItem = { type: "keyUp", value: "a" }; + let actionSequence = { + type: "key", + id: "9", + actions: [actionItem], + }; + let expectedAction = new action.Action( + actionSequence.id, + actionSequence.type, + actionItem.type + ); + expectedAction.value = actionItem.value; + let actions = action.Sequence.fromJSON(actionSequence); + equal(actions.length, 1); + deepEqual(actions[0], expectedAction); + action.inputStateMap.clear(); + run_next_test(); +}); + +add_test(function test_processInputSourceActionSequenceInputStateMap() { + let id = "1"; + let actionItem = { type: "pause", duration: 5000 }; + let actionSequence = { + type: "key", + id, + actions: [actionItem], + }; + let wrongInputState = new action.InputState.Null(); + action.inputStateMap.set(actionSequence.id, wrongInputState); + checkErrors( + /to be mapped to/, + action.Sequence.fromJSON, + [actionSequence], + `${actionSequence.type} using ${wrongInputState}` + ); + action.inputStateMap.clear(); + let rightInputState = new action.InputState.Key(); + action.inputStateMap.set(id, rightInputState); + let acts = action.Sequence.fromJSON(actionSequence); + equal(acts.length, 1); + action.inputStateMap.clear(); + run_next_test(); +}); + +add_test(function test_processPointerActionInputStateMap() { + let actionItem = { type: "pointerDown" }; + let id = "1"; + let parameters = { pointerType: "mouse" }; + let a = new action.Action(id, "pointer", actionItem.type); + let wrongInputState = new action.InputState.Key(); + action.inputStateMap.set(id, wrongInputState); + checkErrors( + /to be mapped to InputState whose type is/, + action.processPointerAction, + [id, parameters, a], + `type "pointer" with ${wrongInputState.type} in inputState` + ); + action.inputStateMap.clear(); + + // TODO - uncomment once pen is supported + // wrongInputState = new action.InputState.Pointer("pen"); + // action.inputStateMap.set(id, wrongInputState); + // checkErrors( + // /to be mapped to InputState whose subtype is/, action.processPointerAction, + // [id, parameters, a], + // `subtype ${parameters.pointerType} with ${wrongInputState.subtype} in inputState`); + // action.inputStateMap.clear(); + + let rightInputState = new action.InputState.Pointer("mouse"); + action.inputStateMap.set(id, rightInputState); + action.processPointerAction(id, parameters, a); + action.inputStateMap.clear(); + run_next_test(); +}); + +add_test(function test_createInputState() { + for (let kind in action.InputState) { + let state; + if (kind == "Pointer") { + state = new action.InputState[kind]("mouse"); + } else { + state = new action.InputState[kind](); + } + ok(state); + if (kind === "Null") { + equal(state.type, "none"); + } else { + equal(state.type, kind.toLowerCase()); + } + } + Assert.throws( + () => new action.InputState.Pointer(), + /InvalidArgumentError/, + "Missing InputState.Pointer constructor arg" + ); + Assert.throws( + () => new action.InputState.Pointer("foo"), + /InvalidArgumentError/, + "Invalid InputState.Pointer constructor arg" + ); + run_next_test(); +}); + +add_test(function test_extractActionChainValidation() { + for (let actions of [-1, "a", undefined, null]) { + let message = `actions: ${getTypeString(actions)}`; + Assert.throws( + () => action.Chain.fromJSON(actions), + /InvalidArgumentError/, + message + ); + Assert.throws( + () => action.Chain.fromJSON(actions), + /Expected 'actions' to be an array/, + message + ); + } + run_next_test(); +}); + +add_test(function test_extractActionChainEmpty() { + deepEqual(action.Chain.fromJSON([]), []); + run_next_test(); +}); + +add_test(function test_extractActionChain_oneTickOneInput() { + let actionItem = { type: "pause", duration: 5000 }; + let actionSequence = { + type: "none", + id: "some id", + actions: [actionItem], + }; + let expectedAction = new action.Action( + actionSequence.id, + "none", + actionItem.type + ); + expectedAction.duration = actionItem.duration; + let actionsByTick = action.Chain.fromJSON([actionSequence]); + equal(1, actionsByTick.length); + equal(1, actionsByTick[0].length); + deepEqual(actionsByTick, [[expectedAction]]); + action.inputStateMap.clear(); + run_next_test(); +}); + +add_test(function test_extractActionChain_twoAndThreeTicks() { + let mouseActionItems = [ + { + type: "pointerDown", + button: 2, + }, + { + type: "pointerUp", + button: 2, + }, + ]; + let mouseActionSequence = { + type: "pointer", + id: "7", + actions: mouseActionItems, + parameters: { + pointerType: "mouse", // TODO "touch" + }, + }; + let keyActionItems = [ + { + type: "keyDown", + value: "a", + }, + { + type: "pause", + duration: 4, + }, + { + type: "keyUp", + value: "a", + }, + ]; + let keyActionSequence = { + type: "key", + id: "1", + actions: keyActionItems, + }; + let actionsByTick = action.Chain.fromJSON([ + keyActionSequence, + mouseActionSequence, + ]); + // number of ticks is same as longest action sequence + equal(keyActionItems.length, actionsByTick.length); + equal(2, actionsByTick[0].length); + equal(2, actionsByTick[1].length); + equal(1, actionsByTick[2].length); + let expectedAction = new action.Action( + keyActionSequence.id, + "key", + keyActionItems[2].type + ); + expectedAction.value = keyActionItems[2].value; + deepEqual(actionsByTick[2][0], expectedAction); + action.inputStateMap.clear(); + + // one empty action sequence + actionsByTick = action.Chain.fromJSON([ + keyActionSequence, + { type: "none", id: "some", actions: [] }, + ]); + equal(keyActionItems.length, actionsByTick.length); + equal(1, actionsByTick[0].length); + action.inputStateMap.clear(); + run_next_test(); +}); + +add_test(function test_computeTickDuration() { + let expected = 8000; + let tickActions = [ + { type: "none", subtype: "pause", duration: 5000 }, + { type: "key", subtype: "pause", duration: 1000 }, + { type: "pointer", subtype: "pointerMove", duration: 6000 }, + // invalid because keyDown should not have duration, so duration should be ignored. + { type: "key", subtype: "keyDown", duration: 100000 }, + { type: "pointer", subtype: "pause", duration: expected }, + { type: "pointer", subtype: "pointerUp" }, + ]; + equal(expected, action.computeTickDuration(tickActions)); + run_next_test(); +}); + +add_test(function test_computeTickDuration_empty() { + equal(0, action.computeTickDuration([])); + run_next_test(); +}); + +add_test(function test_computeTickDuration_noDurations() { + let tickActions = [ + // invalid because keyDown should not have duration, so duration should be ignored. + { type: "key", subtype: "keyDown", duration: 100000 }, + // undefined duration permitted + { type: "none", subtype: "pause" }, + { type: "pointer", subtype: "pointerMove" }, + { type: "pointer", subtype: "pointerDown" }, + { type: "key", subtype: "keyUp" }, + ]; + + equal(0, action.computeTickDuration(tickActions)); + run_next_test(); +}); + +// helpers +function getTypeString(obj) { + return Object.prototype.toString.call(obj); +} + +function checkErrors(regex, func, args, message) { + if (typeof message == "undefined") { + message = `actionFunc: ${func.name}; args: ${args}`; + } + Assert.throws(() => func.apply(this, args), /InvalidArgumentError/, message); + Assert.throws(() => func.apply(this, args), regex, message); +} diff --git a/testing/marionette/test/unit/test_actors.js b/testing/marionette/test/unit/test_actors.js new file mode 100644 index 0000000000..584533f869 --- /dev/null +++ b/testing/marionette/test/unit/test_actors.js @@ -0,0 +1,49 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + EventDispatcher: + "chrome://marionette/content/actors/MarionetteEventsParent.jsm", + registerCommandsActor: + "chrome://marionette/content/actors/MarionetteCommandsParent.jsm", + registerEventsActor: + "chrome://marionette/content/actors/MarionetteEventsParent.jsm", + unregisterCommandsActor: + "chrome://marionette/content/actors/MarionetteCommandsParent.jsm", + unregisterEventsActor: + "chrome://marionette/content/actors/MarionetteEventsParent.jsm", +}); + +registerCleanupFunction(function() { + unregisterCommandsActor(); + unregisterEventsActor(); +}); + +add_test(function test_commandsActor_register() { + registerCommandsActor(); + unregisterCommandsActor(); + + registerCommandsActor(); + registerCommandsActor(); + unregisterCommandsActor(); + + run_next_test(); +}); + +add_test(function test_eventsActor_register() { + registerEventsActor(); + unregisterEventsActor(); + + registerEventsActor(); + registerEventsActor(); + unregisterEventsActor(); + + run_next_test(); +}); diff --git a/testing/marionette/test/unit/test_assert.js b/testing/marionette/test/unit/test_assert.js new file mode 100644 index 0000000000..aa93453139 --- /dev/null +++ b/testing/marionette/test/unit/test_assert.js @@ -0,0 +1,207 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; +/* eslint-disable no-array-constructor, no-new-object */ + +const { assert } = ChromeUtils.import("chrome://marionette/content/assert.js"); +const { error } = ChromeUtils.import("chrome://marionette/content/error.js"); + +add_test(function test_acyclic() { + assert.acyclic({}); + + Assert.throws(() => { + let obj = {}; + obj.reference = obj; + assert.acyclic(obj); + }, /JavaScriptError/); + + // custom message + let cyclic = {}; + cyclic.reference = cyclic; + Assert.throws(() => assert.acyclic(cyclic, "", RangeError), RangeError); + Assert.throws(() => assert.acyclic(cyclic, "foo"), /JavaScriptError: foo/); + Assert.throws( + () => assert.acyclic(cyclic, "bar", RangeError), + /RangeError: bar/ + ); + + run_next_test(); +}); + +add_test(function test_session() { + assert.session({ sessionID: "foo" }); + for (let typ of [null, undefined, ""]) { + Assert.throws( + () => assert.session({ sessionId: typ }), + /InvalidSessionIDError/ + ); + } + + Assert.throws(() => assert.session({ sessionId: null }, "custom"), /custom/); + + run_next_test(); +}); + +add_test(function test_platforms() { + // at least one will fail + let raised; + for (let fn of [assert.firefox, assert.fennec]) { + try { + fn(); + } catch (e) { + raised = e; + } + } + ok(raised instanceof error.UnsupportedOperationError); + + run_next_test(); +}); + +add_test(function test_noUserPrompt() { + assert.noUserPrompt(null); + assert.noUserPrompt(undefined); + Assert.throws(() => assert.noUserPrompt({}), /UnexpectedAlertOpenError/); + Assert.throws(() => assert.noUserPrompt({}, "custom"), /custom/); + + run_next_test(); +}); + +add_test(function test_defined() { + assert.defined({}); + Assert.throws(() => assert.defined(undefined), /InvalidArgumentError/); + Assert.throws(() => assert.noUserPrompt({}, "custom"), /custom/); + + run_next_test(); +}); + +add_test(function test_number() { + assert.number(1); + assert.number(0); + assert.number(-1); + assert.number(1.2); + for (let i of ["foo", "1", {}, [], NaN, Infinity, undefined]) { + Assert.throws(() => assert.number(i), /InvalidArgumentError/); + } + + Assert.throws(() => assert.number("foo", "custom"), /custom/); + + run_next_test(); +}); + +add_test(function test_callable() { + assert.callable(function() {}); + assert.callable(() => {}); + + for (let typ of [undefined, "", true, {}, []]) { + Assert.throws(() => assert.callable(typ), /InvalidArgumentError/); + } + + Assert.throws(() => assert.callable("foo", "custom"), /custom/); + + run_next_test(); +}); + +add_test(function test_integer() { + assert.integer(1); + assert.integer(0); + assert.integer(-1); + Assert.throws(() => assert.integer("foo"), /InvalidArgumentError/); + Assert.throws(() => assert.integer(1.2), /InvalidArgumentError/); + + Assert.throws(() => assert.integer("foo", "custom"), /custom/); + + run_next_test(); +}); + +add_test(function test_positiveInteger() { + assert.positiveInteger(1); + assert.positiveInteger(0); + Assert.throws(() => assert.positiveInteger(-1), /InvalidArgumentError/); + Assert.throws(() => assert.positiveInteger("foo"), /InvalidArgumentError/); + Assert.throws(() => assert.positiveInteger("foo", "custom"), /custom/); + + run_next_test(); +}); + +add_test(function test_boolean() { + assert.boolean(true); + assert.boolean(false); + Assert.throws(() => assert.boolean("false"), /InvalidArgumentError/); + Assert.throws(() => assert.boolean(undefined), /InvalidArgumentError/); + Assert.throws(() => assert.boolean(undefined, "custom"), /custom/); + + run_next_test(); +}); + +add_test(function test_string() { + assert.string("foo"); + assert.string(`bar`); + Assert.throws(() => assert.string(42), /InvalidArgumentError/); + Assert.throws(() => assert.string(42, "custom"), /custom/); + + run_next_test(); +}); + +add_test(function test_open() { + assert.open({ currentWindowGlobal: {} }); + + for (let typ of [null, undefined, { currentWindowGlobal: null }]) { + Assert.throws(() => assert.open(typ), /NoSuchWindowError/); + } + + Assert.throws(() => assert.open(null, "custom"), /custom/); + + run_next_test(); +}); + +add_test(function test_object() { + assert.object({}); + assert.object(new Object()); + for (let typ of [42, "foo", true, null, undefined]) { + Assert.throws(() => assert.object(typ), /InvalidArgumentError/); + } + + Assert.throws(() => assert.object(null, "custom"), /custom/); + + run_next_test(); +}); + +add_test(function test_in() { + assert.in("foo", { foo: 42 }); + for (let typ of [{}, 42, true, null, undefined]) { + Assert.throws(() => assert.in("foo", typ), /InvalidArgumentError/); + } + + Assert.throws(() => assert.in("foo", { bar: 42 }, "custom"), /custom/); + + run_next_test(); +}); + +add_test(function test_array() { + assert.array([]); + assert.array(new Array()); + Assert.throws(() => assert.array(42), /InvalidArgumentError/); + Assert.throws(() => assert.array({}), /InvalidArgumentError/); + + Assert.throws(() => assert.array(42, "custom"), /custom/); + + run_next_test(); +}); + +add_test(function test_that() { + equal(1, assert.that(n => n + 1)(1)); + Assert.throws(() => assert.that(() => false)(), /InvalidArgumentError/); + Assert.throws(() => assert.that(val => val)(false), /InvalidArgumentError/); + Assert.throws( + () => assert.that(val => val, "foo", error.SessionNotCreatedError)(false), + /SessionNotCreatedError/ + ); + + Assert.throws(() => assert.that(() => false, "custom")(), /custom/); + + run_next_test(); +}); + +/* eslint-enable no-array-constructor, no-new-object */ diff --git a/testing/marionette/test/unit/test_browser.js b/testing/marionette/test/unit/test_browser.js new file mode 100644 index 0000000000..3f89cd0b1a --- /dev/null +++ b/testing/marionette/test/unit/test_browser.js @@ -0,0 +1,25 @@ +const { Context } = ChromeUtils.import( + "chrome://marionette/content/browser.js" +); + +add_test(function test_Context() { + ok(Context.hasOwnProperty("Chrome")); + ok(Context.hasOwnProperty("Content")); + equal(typeof Context.Chrome, "string"); + equal(typeof Context.Content, "string"); + equal(Context.Chrome, "chrome"); + equal(Context.Content, "content"); + + run_next_test(); +}); + +add_test(function test_Context_fromString() { + equal(Context.fromString("chrome"), Context.Chrome); + equal(Context.fromString("content"), Context.Content); + + for (let typ of ["", "foo", true, 42, [], {}, null, undefined]) { + Assert.throws(() => Context.fromString(typ), /TypeError/); + } + + run_next_test(); +}); diff --git a/testing/marionette/test/unit/test_capabilities.js b/testing/marionette/test/unit/test_capabilities.js new file mode 100644 index 0000000000..afc8e75d16 --- /dev/null +++ b/testing/marionette/test/unit/test_capabilities.js @@ -0,0 +1,609 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { Preferences } = ChromeUtils.import( + "resource://gre/modules/Preferences.jsm" +); +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +const { error } = ChromeUtils.import("chrome://marionette/content/error.js"); +const { + Capabilities, + PageLoadStrategy, + Proxy, + Timeouts, + UnhandledPromptBehavior, +} = ChromeUtils.import("chrome://marionette/content/capabilities.js"); + +// FTP protocol handler is needed for ftpProxy tests +registerCleanupFunction(function() { + Preferences.reset("network.ftp.enabled"); +}); +Preferences.set("network.ftp.enabled", true); + +add_test(function test_Timeouts_ctor() { + let ts = new Timeouts(); + equal(ts.implicit, 0); + equal(ts.pageLoad, 300000); + equal(ts.script, 30000); + + run_next_test(); +}); + +add_test(function test_Timeouts_toString() { + equal(new Timeouts().toString(), "[object Timeouts]"); + + run_next_test(); +}); + +add_test(function test_Timeouts_toJSON() { + let ts = new Timeouts(); + deepEqual(ts.toJSON(), { implicit: 0, pageLoad: 300000, script: 30000 }); + + run_next_test(); +}); + +add_test(function test_Timeouts_fromJSON() { + let json = { + implicit: 0, + pageLoad: 2.0, + script: Number.MAX_SAFE_INTEGER, + }; + let ts = Timeouts.fromJSON(json); + equal(ts.implicit, json.implicit); + equal(ts.pageLoad, json.pageLoad); + equal(ts.script, json.script); + + run_next_test(); +}); + +add_test(function test_Timeouts_fromJSON_unrecognised_field() { + let json = { + sessionId: "foobar", + }; + try { + Timeouts.fromJSON(json); + } catch (e) { + equal(e.name, error.InvalidArgumentError.name); + equal(e.message, "Unrecognised timeout: sessionId"); + } + + run_next_test(); +}); + +add_test(function test_Timeouts_fromJSON_invalid_types() { + for (let value of [null, [], {}, false, "10", 2.5]) { + Assert.throws( + () => Timeouts.fromJSON({ implicit: value }), + /InvalidArgumentError/ + ); + } + + run_next_test(); +}); + +add_test(function test_Timeouts_fromJSON_bounds() { + for (let value of [-1, Number.MAX_SAFE_INTEGER + 1]) { + Assert.throws( + () => Timeouts.fromJSON({ script: value }), + /InvalidArgumentError/ + ); + } + + run_next_test(); +}); + +add_test(function test_PageLoadStrategy() { + equal(PageLoadStrategy.None, "none"); + equal(PageLoadStrategy.Eager, "eager"); + equal(PageLoadStrategy.Normal, "normal"); + + run_next_test(); +}); + +add_test(function test_Proxy_ctor() { + let p = new Proxy(); + let props = [ + "proxyType", + "httpProxy", + "sslProxy", + "ftpProxy", + "socksProxy", + "socksVersion", + "proxyAutoconfigUrl", + ]; + for (let prop of props) { + ok(prop in p, `${prop} in ${JSON.stringify(props)}`); + equal(p[prop], null); + } + + run_next_test(); +}); + +add_test(function test_Proxy_init() { + let p = new Proxy(); + + // no changed made, and 5 (system) is default + equal(p.init(), false); + equal(Preferences.get("network.proxy.type"), 5); + + // pac + p.proxyType = "pac"; + p.proxyAutoconfigUrl = "http://localhost:1234"; + ok(p.init()); + + equal(Preferences.get("network.proxy.type"), 2); + equal( + Preferences.get("network.proxy.autoconfig_url"), + "http://localhost:1234" + ); + + // direct + p = new Proxy(); + p.proxyType = "direct"; + ok(p.init()); + equal(Preferences.get("network.proxy.type"), 0); + + // autodetect + p = new Proxy(); + p.proxyType = "autodetect"; + ok(p.init()); + equal(Preferences.get("network.proxy.type"), 4); + + // system + p = new Proxy(); + p.proxyType = "system"; + ok(p.init()); + equal(Preferences.get("network.proxy.type"), 5); + + // manual + for (let proxy of ["ftp", "http", "ssl", "socks"]) { + p = new Proxy(); + p.proxyType = "manual"; + p.noProxy = ["foo", "bar"]; + p[`${proxy}Proxy`] = "foo"; + p[`${proxy}ProxyPort`] = 42; + if (proxy === "socks") { + p[`${proxy}Version`] = 4; + } + + ok(p.init()); + equal(Preferences.get("network.proxy.type"), 1); + equal(Preferences.get("network.proxy.no_proxies_on"), "foo, bar"); + equal(Preferences.get(`network.proxy.${proxy}`), "foo"); + equal(Preferences.get(`network.proxy.${proxy}_port`), 42); + if (proxy === "socks") { + equal(Preferences.get(`network.proxy.${proxy}_version`), 4); + } + } + + // empty no proxy should reset default exclustions + p = new Proxy(); + p.proxyType = "manual"; + p.noProxy = []; + ok(p.init()); + equal(Preferences.get("network.proxy.no_proxies_on"), ""); + + run_next_test(); +}); + +add_test(function test_Proxy_toString() { + equal(new Proxy().toString(), "[object Proxy]"); + + run_next_test(); +}); + +add_test(function test_Proxy_toJSON() { + let p = new Proxy(); + deepEqual(p.toJSON(), {}); + + // autoconfig url + p = new Proxy(); + p.proxyType = "pac"; + p.proxyAutoconfigUrl = "foo"; + deepEqual(p.toJSON(), { proxyType: "pac", proxyAutoconfigUrl: "foo" }); + + // manual proxy + p = new Proxy(); + p.proxyType = "manual"; + deepEqual(p.toJSON(), { proxyType: "manual" }); + + for (let proxy of ["ftpProxy", "httpProxy", "sslProxy", "socksProxy"]) { + let expected = { proxyType: "manual" }; + + p = new Proxy(); + p.proxyType = "manual"; + + if (proxy == "socksProxy") { + p.socksVersion = 5; + expected.socksVersion = 5; + } + + // without port + p[proxy] = "foo"; + expected[proxy] = "foo"; + deepEqual(p.toJSON(), expected); + + // with port + p[proxy] = "foo"; + p[`${proxy}Port`] = 0; + expected[proxy] = "foo:0"; + deepEqual(p.toJSON(), expected); + + p[`${proxy}Port`] = 42; + expected[proxy] = "foo:42"; + deepEqual(p.toJSON(), expected); + + // add brackets for IPv6 address as proxy hostname + p[proxy] = "2001:db8::1"; + p[`${proxy}Port`] = 42; + expected[proxy] = "foo:42"; + expected[proxy] = "[2001:db8::1]:42"; + deepEqual(p.toJSON(), expected); + } + + // noProxy: add brackets for IPv6 address + p = new Proxy(); + p.proxyType = "manual"; + p.noProxy = ["2001:db8::1"]; + let expected = { proxyType: "manual", noProxy: "[2001:db8::1]" }; + deepEqual(p.toJSON(), expected); + + run_next_test(); +}); + +add_test(function test_Proxy_fromJSON() { + let p = new Proxy(); + deepEqual(p, Proxy.fromJSON(undefined)); + deepEqual(p, Proxy.fromJSON(null)); + + for (let typ of [true, 42, "foo", []]) { + Assert.throws(() => Proxy.fromJSON(typ), /InvalidArgumentError/); + } + + // must contain a valid proxyType + Assert.throws(() => Proxy.fromJSON({}), /InvalidArgumentError/); + Assert.throws( + () => Proxy.fromJSON({ proxyType: "foo" }), + /InvalidArgumentError/ + ); + + // autoconfig url + for (let url of [true, 42, [], {}]) { + Assert.throws( + () => Proxy.fromJSON({ proxyType: "pac", proxyAutoconfigUrl: url }), + /InvalidArgumentError/ + ); + } + + p = new Proxy(); + p.proxyType = "pac"; + p.proxyAutoconfigUrl = "foo"; + deepEqual(p, Proxy.fromJSON({ proxyType: "pac", proxyAutoconfigUrl: "foo" })); + + // manual proxy + p = new Proxy(); + p.proxyType = "manual"; + deepEqual(p, Proxy.fromJSON({ proxyType: "manual" })); + + for (let proxy of ["httpProxy", "sslProxy", "ftpProxy", "socksProxy"]) { + let manual = { proxyType: "manual" }; + + // invalid hosts + for (let host of [ + true, + 42, + [], + {}, + null, + "http://foo", + "foo:-1", + "foo:65536", + "foo/test", + "foo#42", + "foo?foo=bar", + "2001:db8::1", + ]) { + manual[proxy] = host; + Assert.throws(() => Proxy.fromJSON(manual), /InvalidArgumentError/); + } + + p = new Proxy(); + p.proxyType = "manual"; + if (proxy == "socksProxy") { + manual.socksVersion = 5; + p.socksVersion = 5; + } + + let host_map = { + "foo:1": { hostname: "foo", port: 1 }, + "foo:21": { hostname: "foo", port: 21 }, + "foo:80": { hostname: "foo", port: 80 }, + "foo:443": { hostname: "foo", port: 443 }, + "foo:65535": { hostname: "foo", port: 65535 }, + "127.0.0.1:42": { hostname: "127.0.0.1", port: 42 }, + "[2001:db8::1]:42": { hostname: "2001:db8::1", port: "42" }, + }; + + // valid proxy hosts with port + for (let host in host_map) { + manual[proxy] = host; + + p[`${proxy}`] = host_map[host].hostname; + p[`${proxy}Port`] = host_map[host].port; + + deepEqual(p, Proxy.fromJSON(manual)); + } + + // Without a port the default port of the scheme is used + for (let host of ["foo", "foo:"]) { + manual[proxy] = host; + + // For socks no default port is available + p[proxy] = `foo`; + if (proxy === "socksProxy") { + p[`${proxy}Port`] = null; + } else { + let default_ports = { ftpProxy: 21, httpProxy: 80, sslProxy: 443 }; + + p[`${proxy}Port`] = default_ports[proxy]; + } + + deepEqual(p, Proxy.fromJSON(manual)); + } + } + + // missing required socks version + Assert.throws( + () => Proxy.fromJSON({ proxyType: "manual", socksProxy: "foo:1234" }), + /InvalidArgumentError/ + ); + + // noProxy: invalid settings + for (let noProxy of [true, 42, {}, null, "foo", [true], [42], [{}], [null]]) { + Assert.throws( + () => Proxy.fromJSON({ proxyType: "manual", noProxy }), + /InvalidArgumentError/ + ); + } + + // noProxy: valid settings + p = new Proxy(); + p.proxyType = "manual"; + for (let noProxy of [[], ["foo"], ["foo", "bar"], ["127.0.0.1"]]) { + let manual = { proxyType: "manual", noProxy }; + p.noProxy = noProxy; + deepEqual(p, Proxy.fromJSON(manual)); + } + + // noProxy: IPv6 needs brackets removed + p = new Proxy(); + p.proxyType = "manual"; + p.noProxy = ["2001:db8::1"]; + let manual = { proxyType: "manual", noProxy: ["[2001:db8::1]"] }; + deepEqual(p, Proxy.fromJSON(manual)); + + run_next_test(); +}); + +add_test(function test_UnhandledPromptBehavior() { + equal(UnhandledPromptBehavior.Accept, "accept"); + equal(UnhandledPromptBehavior.AcceptAndNotify, "accept and notify"); + equal(UnhandledPromptBehavior.Dismiss, "dismiss"); + equal(UnhandledPromptBehavior.DismissAndNotify, "dismiss and notify"); + equal(UnhandledPromptBehavior.Ignore, "ignore"); + + run_next_test(); +}); + +add_test(function test_Capabilities_ctor() { + let caps = new Capabilities(); + ok(caps.has("browserName")); + ok(caps.has("browserVersion")); + ok(caps.has("platformName")); + ok(["linux", "mac", "windows", "android"].includes(caps.get("platformName"))); + ok(caps.has("platformVersion")); + equal(PageLoadStrategy.Normal, caps.get("pageLoadStrategy")); + equal(false, caps.get("acceptInsecureCerts")); + ok(caps.get("timeouts") instanceof Timeouts); + ok(caps.get("proxy") instanceof Proxy); + equal(caps.get("setWindowRect"), !Services.androidBridge); + equal(caps.get("strictFileInteractability"), false); + + ok(caps.has("rotatable")); + + equal(false, caps.get("moz:accessibilityChecks")); + ok(caps.has("moz:buildID")); + ok(caps.has("moz:debuggerAddress")); + ok(caps.has("moz:processID")); + ok(caps.has("moz:profile")); + equal(false, caps.get("moz:useNonSpecCompliantPointerOrigin")); + equal(true, caps.get("moz:webdriverClick")); + + run_next_test(); +}); + +add_test(function test_Capabilities_toString() { + equal("[object Capabilities]", new Capabilities().toString()); + + run_next_test(); +}); + +add_test(function test_Capabilities_toJSON() { + let caps = new Capabilities(); + let json = caps.toJSON(); + + equal(caps.get("browserName"), json.browserName); + equal(caps.get("browserVersion"), json.browserVersion); + equal(caps.get("platformName"), json.platformName); + equal(caps.get("platformVersion"), json.platformVersion); + equal(caps.get("pageLoadStrategy"), json.pageLoadStrategy); + equal(caps.get("acceptInsecureCerts"), json.acceptInsecureCerts); + deepEqual(caps.get("timeouts").toJSON(), json.timeouts); + equal(undefined, json.proxy); + equal(caps.get("setWindowRect"), json.setWindowRect); + equal(caps.get("strictFileInteractability"), json.strictFileInteractability); + + equal(caps.get("rotatable"), json.rotatable); + + equal(caps.get("moz:accessibilityChecks"), json["moz:accessibilityChecks"]); + equal(caps.get("moz:buildID"), json["moz:buildID"]); + equal(caps.get("moz:debuggerAddress"), json["moz:debuggerAddress"]); + equal(caps.get("moz:processID"), json["moz:processID"]); + equal(caps.get("moz:profile"), json["moz:profile"]); + equal( + caps.get("moz:useNonSpecCompliantPointerOrigin"), + json["moz:useNonSpecCompliantPointerOrigin"] + ); + equal(caps.get("moz:webdriverClick"), json["moz:webdriverClick"]); + + run_next_test(); +}); + +add_test(function test_Capabilities_fromJSON() { + const { fromJSON } = Capabilities; + + // plain + for (let typ of [{}, null, undefined]) { + ok(fromJSON(typ).has("browserName")); + } + for (let typ of [true, 42, "foo", []]) { + Assert.throws(() => fromJSON(typ), /InvalidArgumentError/); + } + + // matching + let caps = new Capabilities(); + + caps = fromJSON({ acceptInsecureCerts: true }); + equal(true, caps.get("acceptInsecureCerts")); + caps = fromJSON({ acceptInsecureCerts: false }); + equal(false, caps.get("acceptInsecureCerts")); + Assert.throws( + () => fromJSON({ acceptInsecureCerts: "foo" }), + /InvalidArgumentError/ + ); + + for (let strategy of Object.values(PageLoadStrategy)) { + caps = fromJSON({ pageLoadStrategy: strategy }); + equal(strategy, caps.get("pageLoadStrategy")); + } + Assert.throws( + () => fromJSON({ pageLoadStrategy: "foo" }), + /InvalidArgumentError/ + ); + Assert.throws( + () => fromJSON({ pageLoadStrategy: null }), + /InvalidArgumentError/ + ); + + let proxyConfig = { proxyType: "manual" }; + caps = fromJSON({ proxy: proxyConfig }); + equal("manual", caps.get("proxy").proxyType); + + let timeoutsConfig = { implicit: 123 }; + caps = fromJSON({ timeouts: timeoutsConfig }); + equal(123, caps.get("timeouts").implicit); + + if (!Services.androidBridge) { + caps = fromJSON({ setWindowRect: true }); + equal(true, caps.get("setWindowRect")); + Assert.throws( + () => fromJSON({ setWindowRect: false }), + /InvalidArgumentError/ + ); + } else { + Assert.throws( + () => fromJSON({ setWindowRect: true }), + /InvalidArgumentError/ + ); + } + + caps = fromJSON({ strictFileInteractability: false }); + equal(false, caps.get("strictFileInteractability")); + caps = fromJSON({ strictFileInteractability: true }); + equal(true, caps.get("strictFileInteractability")); + + caps = fromJSON({ "moz:accessibilityChecks": true }); + equal(true, caps.get("moz:accessibilityChecks")); + caps = fromJSON({ "moz:accessibilityChecks": false }); + equal(false, caps.get("moz:accessibilityChecks")); + Assert.throws( + () => fromJSON({ "moz:accessibilityChecks": "foo" }), + /InvalidArgumentError/ + ); + Assert.throws( + () => fromJSON({ "moz:accessibilityChecks": 1 }), + /InvalidArgumentError/ + ); + + // capability is always populated with null if remote agent is not listening + caps = fromJSON({}); + equal(null, caps.get("moz:debuggerAddress")); + caps = fromJSON({ "moz:debuggerAddress": "foo" }); + equal(null, caps.get("moz:debuggerAddress")); + caps = fromJSON({ "moz:debuggerAddress": true }); + equal(null, caps.get("moz:debuggerAddress")); + + caps = fromJSON({ "moz:useNonSpecCompliantPointerOrigin": false }); + equal(false, caps.get("moz:useNonSpecCompliantPointerOrigin")); + caps = fromJSON({ "moz:useNonSpecCompliantPointerOrigin": true }); + equal(true, caps.get("moz:useNonSpecCompliantPointerOrigin")); + Assert.throws( + () => fromJSON({ "moz:useNonSpecCompliantPointerOrigin": "foo" }), + /InvalidArgumentError/ + ); + Assert.throws( + () => fromJSON({ "moz:useNonSpecCompliantPointerOrigin": 1 }), + /InvalidArgumentError/ + ); + + caps = fromJSON({ "moz:webdriverClick": true }); + equal(true, caps.get("moz:webdriverClick")); + caps = fromJSON({ "moz:webdriverClick": false }); + equal(false, caps.get("moz:webdriverClick")); + Assert.throws( + () => fromJSON({ "moz:webdriverClick": "foo" }), + /InvalidArgumentError/ + ); + Assert.throws( + () => fromJSON({ "moz:webdriverClick": 1 }), + /InvalidArgumentError/ + ); + + run_next_test(); +}); + +// use Proxy.toJSON to test marshal +add_test(function test_marshal() { + let proxy = new Proxy(); + + // drop empty fields + deepEqual({}, proxy.toJSON()); + proxy.proxyType = "manual"; + deepEqual({ proxyType: "manual" }, proxy.toJSON()); + proxy.proxyType = null; + deepEqual({}, proxy.toJSON()); + proxy.proxyType = undefined; + deepEqual({}, proxy.toJSON()); + + // iterate over object literals + proxy.proxyType = { foo: "bar" }; + deepEqual({ proxyType: { foo: "bar" } }, proxy.toJSON()); + + // iterate over complex object that implement toJSON + proxy.proxyType = new Proxy(); + deepEqual({}, proxy.toJSON()); + proxy.proxyType.proxyType = "manual"; + deepEqual({ proxyType: { proxyType: "manual" } }, proxy.toJSON()); + + // drop objects with no entries + proxy.proxyType = { foo: {} }; + deepEqual({}, proxy.toJSON()); + proxy.proxyType = { foo: new Proxy() }; + deepEqual({}, proxy.toJSON()); + + run_next_test(); +}); diff --git a/testing/marionette/test/unit/test_cookie.js b/testing/marionette/test/unit/test_cookie.js new file mode 100644 index 0000000000..933b9f8ef8 --- /dev/null +++ b/testing/marionette/test/unit/test_cookie.js @@ -0,0 +1,368 @@ +/* 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.import("chrome://marionette/content/cookie.js"); + +/* eslint-disable mozilla/use-chromeutils-generateqi */ + +cookie.manager = { + cookies: [], + + add( + domain, + path, + name, + value, + secure, + httpOnly, + session, + expiry, + originAttributes, + sameSite + ) { + if (name === "fail") { + throw new Error("An error occurred while adding cookie"); + } + let newCookie = { + host: domain, + path, + name, + value, + isSecure: secure, + isHttpOnly: httpOnly, + isSession: session, + expiry, + originAttributes, + sameSite, + }; + cookie.manager.cookies.push(newCookie); + }, + + remove(host, name, path) { + for (let i = 0; i < this.cookies.length; ++i) { + let candidate = this.cookies[i]; + if ( + candidate.host === host && + candidate.name === name && + candidate.path === path + ) { + return this.cookies.splice(i, 1); + } + } + return false; + }, + + getCookiesFromHost(host) { + let hostCookies = this.cookies.filter( + c => c.host === host || c.host === "." + host + ); + + return hostCookies; + }, +}; + +add_test(function test_fromJSON() { + // object + for (let invalidType of ["foo", 42, true, [], null, undefined]) { + Assert.throws(() => cookie.fromJSON(invalidType), /Expected cookie object/); + } + + // name and value + for (let invalidType of [42, true, [], {}, null, undefined]) { + Assert.throws( + () => cookie.fromJSON({ name: invalidType }), + /Cookie name must be string/ + ); + Assert.throws( + () => cookie.fromJSON({ name: "foo", value: invalidType }), + /Cookie value must be string/ + ); + } + + // domain + for (let invalidType of [42, true, [], {}, null]) { + let domainTest = { + name: "foo", + value: "bar", + domain: invalidType, + }; + Assert.throws( + () => cookie.fromJSON(domainTest), + /Cookie domain must be string/ + ); + } + let domainTest = { + name: "foo", + value: "bar", + domain: "domain", + }; + let parsedCookie = cookie.fromJSON(domainTest); + equal(parsedCookie.domain, "domain"); + + // path + for (let invalidType of [42, true, [], {}, null]) { + let pathTest = { + name: "foo", + value: "bar", + path: invalidType, + }; + Assert.throws( + () => cookie.fromJSON(pathTest), + /Cookie path must be string/ + ); + } + + // secure + for (let invalidType of ["foo", 42, [], {}, null]) { + let secureTest = { + name: "foo", + value: "bar", + secure: invalidType, + }; + Assert.throws( + () => cookie.fromJSON(secureTest), + /Cookie secure flag must be boolean/ + ); + } + + // httpOnly + for (let invalidType of ["foo", 42, [], {}, null]) { + let httpOnlyTest = { + name: "foo", + value: "bar", + httpOnly: invalidType, + }; + Assert.throws( + () => cookie.fromJSON(httpOnlyTest), + /Cookie httpOnly flag must be boolean/ + ); + } + + // expiry + for (let invalidType of [ + -1, + Number.MAX_SAFE_INTEGER + 1, + "foo", + true, + [], + {}, + null, + ]) { + let expiryTest = { + name: "foo", + value: "bar", + expiry: invalidType, + }; + Assert.throws( + () => cookie.fromJSON(expiryTest), + /Cookie expiry must be a positive integer/ + ); + } + + // sameSite + for (let invalidType of ["foo", 42, [], {}, null]) { + const sameSiteTest = { + name: "foo", + value: "bar", + sameSite: invalidType, + }; + Assert.throws( + () => cookie.fromJSON(sameSiteTest), + /Cookie SameSite flag must be one of None, Lax, or Strict/ + ); + } + + // bare requirements + let bare = cookie.fromJSON({ name: "name", value: "value" }); + equal("name", bare.name); + equal("value", bare.value); + for (let missing of [ + "path", + "secure", + "httpOnly", + "session", + "expiry", + "sameSite", + ]) { + ok(!bare.hasOwnProperty(missing)); + } + + // everything + let full = cookie.fromJSON({ + name: "name", + value: "value", + domain: ".domain", + path: "path", + secure: true, + httpOnly: true, + expiry: 42, + sameSite: "Lax", + }); + equal("name", full.name); + equal("value", full.value); + equal(".domain", full.domain); + equal("path", full.path); + equal(true, full.secure); + equal(true, full.httpOnly); + equal(42, full.expiry); + equal("Lax", full.sameSite); + + run_next_test(); +}); + +add_test(function test_add() { + cookie.manager.cookies = []; + + for (let invalidType of [42, true, [], {}, null, undefined]) { + Assert.throws( + () => cookie.add({ name: invalidType }), + /Cookie name must be string/ + ); + Assert.throws( + () => cookie.add({ name: "name", value: invalidType }), + /Cookie value must be string/ + ); + Assert.throws( + () => cookie.add({ name: "name", value: "value", domain: invalidType }), + /Cookie domain must be string/ + ); + } + + cookie.add({ + name: "name", + value: "value", + domain: "domain", + }); + equal(1, cookie.manager.cookies.length); + equal("name", cookie.manager.cookies[0].name); + equal("value", cookie.manager.cookies[0].value); + equal(".domain", cookie.manager.cookies[0].host); + equal("/", cookie.manager.cookies[0].path); + ok(cookie.manager.cookies[0].expiry > new Date(Date.now()).getTime() / 1000); + + cookie.add({ + name: "name2", + value: "value2", + domain: "domain2", + }); + equal(2, cookie.manager.cookies.length); + + Assert.throws(() => { + let biscuit = { name: "name3", value: "value3", domain: "domain3" }; + cookie.add(biscuit, { restrictToHost: "other domain" }); + }, /Cookies may only be set for the current domain/); + + cookie.add({ + name: "name4", + value: "value4", + domain: "my.domain:1234", + }); + equal(".my.domain", cookie.manager.cookies[2].host); + + cookie.add({ + name: "name5", + value: "value5", + domain: "domain5", + path: "/foo/bar", + }); + equal("/foo/bar", cookie.manager.cookies[3].path); + + cookie.add({ + name: "name6", + value: "value", + domain: ".domain", + }); + equal(".domain", cookie.manager.cookies[4].host); + + const sameSiteMap = new Map([ + ["None", Ci.nsICookie.SAMESITE_NONE], + ["Lax", Ci.nsICookie.SAMESITE_LAX], + ["Strict", Ci.nsICookie.SAMESITE_STRICT], + ]); + + Array.from(sameSiteMap.keys()).forEach((entry, index) => { + cookie.add({ + name: "name" + index, + value: "value", + domain: ".domain", + sameSite: entry, + }); + equal(sameSiteMap.get(entry), cookie.manager.cookies[5 + index].sameSite); + }); + + Assert.throws(() => { + cookie.add({ name: "fail", value: "value6", domain: "domain6" }); + }, /UnableToSetCookieError/); + + run_next_test(); +}); + +add_test(function test_remove() { + cookie.manager.cookies = []; + + let crumble = { + name: "test_remove", + value: "value", + domain: "domain", + path: "/custom/path", + }; + + equal(0, cookie.manager.cookies.length); + cookie.add(crumble); + equal(1, cookie.manager.cookies.length); + + cookie.remove(crumble); + equal(0, cookie.manager.cookies.length); + equal(undefined, cookie.manager.cookies[0]); + + run_next_test(); +}); + +add_test(function test_iter() { + cookie.manager.cookies = []; + let tomorrow = new Date(); + tomorrow.setHours(tomorrow.getHours() + 24); + + cookie.add({ + expiry: tomorrow, + name: "0", + value: "", + domain: "foo.example.com", + }); + cookie.add({ + expiry: tomorrow, + name: "1", + value: "", + domain: "bar.example.com", + }); + + let fooCookies = [...cookie.iter("foo.example.com")]; + equal(1, fooCookies.length); + equal(".foo.example.com", fooCookies[0].domain); + equal(true, fooCookies[0].hasOwnProperty("expiry")); + + cookie.add({ + name: "aSessionCookie", + value: "", + domain: "session.com", + }); + + let sessionCookies = [...cookie.iter("session.com")]; + equal(1, sessionCookies.length); + equal("aSessionCookie", sessionCookies[0].name); + equal(false, sessionCookies[0].hasOwnProperty("expiry")); + + cookie.add({ + name: "2", + value: "", + domain: "samesite.example.com", + sameSite: "Lax", + }); + + let sameSiteCookies = [...cookie.iter("samesite.example.com")]; + equal(1, sameSiteCookies.length); + equal("Lax", sameSiteCookies[0].sameSite); + + run_next_test(); +}); diff --git a/testing/marionette/test/unit/test_dom.js b/testing/marionette/test/unit/test_dom.js new file mode 100644 index 0000000000..ddb1c7e30b --- /dev/null +++ b/testing/marionette/test/unit/test_dom.js @@ -0,0 +1,275 @@ +const { + ContentEventObserverService, + WebElementEventTarget, +} = ChromeUtils.import("chrome://marionette/content/dom.js"); + +class MessageSender { + constructor() { + this.listeners = {}; + this.sent = []; + } + + addMessageListener(name, listener) { + this.listeners[name] = listener; + } + + sendAsyncMessage(name, data) { + this.sent.push({ name, data }); + } +} + +class Window { + constructor() { + this.events = []; + } + + addEventListener(type) { + this.events.push(type); + } + + removeEventListener(type) { + for (let i = 0; i < this.events.length; ++i) { + if (this.events[i] === type) { + this.events.splice(i, 1); + return; + } + } + } +} + +add_test(function test_WebElementEventTarget_addEventListener_init() { + let ipc = new MessageSender(); + let eventTarget = new WebElementEventTarget(ipc); + equal(Object.keys(eventTarget.listeners).length, 0); + equal(Object.keys(ipc.listeners).length, 1); + + run_next_test(); +}); + +add_test(function test_addEventListener() { + let ipc = new MessageSender(); + let eventTarget = new WebElementEventTarget(ipc); + + let listener = () => {}; + eventTarget.addEventListener("click", listener); + + // click listener was appended + equal(Object.keys(eventTarget.listeners).length, 1); + ok("click" in eventTarget.listeners); + equal(eventTarget.listeners.click.length, 1); + equal(eventTarget.listeners.click[0], listener); + + // should have sent a registration message + deepEqual(ipc.sent[0], { + name: "Marionette:DOM:AddEventListener", + data: { type: "click" }, + }); + + run_next_test(); +}); + +add_test(function test_addEventListener_sameReference() { + let ipc = new MessageSender(); + let eventTarget = new WebElementEventTarget(ipc); + + let listener = () => {}; + eventTarget.addEventListener("click", listener); + eventTarget.addEventListener("click", listener); + equal(eventTarget.listeners.click.length, 1); + + run_next_test(); +}); + +add_test(function test_WebElementEventTarget_addEventListener_once() { + let ipc = new MessageSender(); + let eventTarget = new WebElementEventTarget(ipc); + + eventTarget.addEventListener("click", () => {}, { once: true }); + equal(eventTarget.listeners.click[0].once, true); + + eventTarget.dispatchEvent({ type: "click" }); + equal(eventTarget.listeners.click.length, 0); + deepEqual(ipc.sent[1], { + name: "Marionette:DOM:RemoveEventListener", + data: { type: "click" }, + }); + + run_next_test(); +}); + +add_test(function test_WebElementEventTarget_removeEventListener() { + let ipc = new MessageSender(); + let eventTarget = new WebElementEventTarget(ipc); + + equal(Object.keys(eventTarget.listeners).length, 0); + eventTarget.removeEventListener("click", () => {}); + equal(Object.keys(eventTarget.listeners).length, 0); + + let firstListener = () => {}; + eventTarget.addEventListener("click", firstListener); + equal(eventTarget.listeners.click.length, 1); + ok(eventTarget.listeners.click[0] === firstListener); + + let secondListener = () => {}; + eventTarget.addEventListener("click", secondListener); + equal(eventTarget.listeners.click.length, 2); + ok(eventTarget.listeners.click[1] === secondListener); + + ok(eventTarget.listeners.click[0] !== eventTarget.listeners.click[1]); + + eventTarget.removeEventListener("click", secondListener); + equal(eventTarget.listeners.click.length, 1); + ok(eventTarget.listeners.click[0] === firstListener); + + // event should not have been unregistered + // because there still exists another click event + equal(ipc.sent[ipc.sent.length - 1].name, "Marionette:DOM:AddEventListener"); + + eventTarget.removeEventListener("click", firstListener); + equal(eventTarget.listeners.click.length, 0); + deepEqual(ipc.sent[ipc.sent.length - 1], { + name: "Marionette:DOM:RemoveEventListener", + data: { type: "click" }, + }); + + run_next_test(); +}); + +add_test(function test_WebElementEventTarget_dispatchEvent() { + let ipc = new MessageSender(); + let eventTarget = new WebElementEventTarget(ipc); + + let listenerCalled = false; + let listener = () => (listenerCalled = true); + eventTarget.addEventListener("click", listener); + eventTarget.dispatchEvent({ type: "click" }); + ok(listenerCalled); + + run_next_test(); +}); + +add_test(function test_WebElementEventTarget_dispatchEvent_multipleListeners() { + let ipc = new MessageSender(); + let eventTarget = new WebElementEventTarget(ipc); + + let clicksA = 0; + let clicksB = 0; + let listenerA = () => ++clicksA; + let listenerB = () => ++clicksB; + + // the same listener should only be added, and consequently fire, once + eventTarget.addEventListener("click", listenerA); + eventTarget.addEventListener("click", listenerA); + eventTarget.addEventListener("click", listenerB); + eventTarget.dispatchEvent({ type: "click" }); + equal(clicksA, 1); + equal(clicksB, 1); + + run_next_test(); +}); + +add_test(function test_ContentEventObserverService_add() { + let ipc = new MessageSender(); + let win = new Window(); + let obs = new ContentEventObserverService( + win, + ipc.sendAsyncMessage.bind(ipc) + ); + + equal(obs.events.size, 0); + equal(win.events.length, 0); + + obs.add("foo"); + equal(obs.events.size, 1); + equal(win.events.length, 1); + equal(obs.events.values().next().value, "foo"); + equal(win.events[0], "foo"); + + obs.add("foo"); + equal(obs.events.size, 1); + equal(win.events.length, 1); + + run_next_test(); +}); + +add_test(function test_ContentEventObserverService_remove() { + let ipc = new MessageSender(); + let win = new Window(); + let obs = new ContentEventObserverService( + win, + ipc.sendAsyncMessage.bind(ipc) + ); + + obs.remove("foo"); + equal(obs.events.size, 0); + equal(win.events.length, 0); + + obs.add("bar"); + equal(obs.events.size, 1); + equal(win.events.length, 1); + + obs.remove("bar"); + equal(obs.events.size, 0); + equal(win.events.length, 0); + + obs.add("baz"); + obs.add("baz"); + equal(obs.events.size, 1); + equal(win.events.length, 1); + + obs.add("bah"); + equal(obs.events.size, 2); + equal(win.events.length, 2); + + obs.remove("baz"); + equal(obs.events.size, 1); + equal(win.events.length, 1); + + obs.remove("bah"); + equal(obs.events.size, 0); + equal(win.events.length, 0); + + run_next_test(); +}); + +add_test(function test_ContentEventObserverService_clear() { + let ipc = new MessageSender(); + let win = new Window(); + let obs = new ContentEventObserverService( + win, + ipc.sendAsyncMessage.bind(ipc) + ); + + obs.clear(); + equal(obs.events.size, 0); + equal(win.events.length, 0); + + obs.add("foo"); + obs.add("foo"); + obs.add("bar"); + equal(obs.events.size, 2); + equal(win.events.length, 2); + + obs.clear(); + equal(obs.events.size, 0); + equal(win.events.length, 0); + + run_next_test(); +}); + +add_test(function test_ContentEventObserverService_handleEvent() { + let ipc = new MessageSender(); + let win = new Window(); + let obs = new ContentEventObserverService( + win, + ipc.sendAsyncMessage.bind(ipc) + ); + + obs.handleEvent({ type: "click", target: win }); + deepEqual(ipc.sent[0], { + name: "Marionette:DOM:OnEvent", + data: { type: "click" }, + }); + + run_next_test(); +}); diff --git a/testing/marionette/test/unit/test_element.js b/testing/marionette/test/unit/test_element.js new file mode 100644 index 0000000000..1644ff9346 --- /dev/null +++ b/testing/marionette/test/unit/test_element.js @@ -0,0 +1,609 @@ +/* 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 { + ChromeWebElement, + ContentWebElement, + ContentWebFrame, + ContentWebWindow, + element, + WebElement, +} = ChromeUtils.import("chrome://marionette/content/element.js"); +const { InvalidArgumentError } = ChromeUtils.import( + "chrome://marionette/content/error.js" +); + +const SVG_NS = "http://www.w3.org/2000/svg"; +const XHTML_NS = "http://www.w3.org/1999/xhtml"; +const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; + +class Element { + constructor(tagName, attrs = {}) { + this.tagName = tagName; + this.localName = tagName; + + for (let attr in attrs) { + this[attr] = attrs[attr]; + } + } + + get nodeType() { + return 1; + } + get ELEMENT_NODE() { + return 1; + } + + // this is a severely limited CSS selector + // that only supports lists of tag names + matches(selector) { + let tags = selector.split(","); + return tags.includes(this.localName); + } +} + +class DOMElement extends Element { + constructor(tagName, attrs = {}) { + super(tagName, attrs); + + if (typeof this.namespaceURI == "undefined") { + this.namespaceURI = XHTML_NS; + } + if (typeof this.ownerDocument == "undefined") { + this.ownerDocument = { designMode: "off" }; + } + if (typeof this.ownerDocument.documentElement == "undefined") { + this.ownerDocument.documentElement = { namespaceURI: XHTML_NS }; + } + + if (typeof this.type == "undefined") { + this.type = "text"; + } + + if (this.localName == "option") { + this.selected = false; + } + + if ( + this.localName == "input" && + ["checkbox", "radio"].includes(this.type) + ) { + this.checked = false; + } + } + + getBoundingClientRect() { + return { + top: 0, + left: 0, + width: 100, + height: 100, + }; + } +} + +class SVGElement extends Element { + constructor(tagName, attrs = {}) { + super(tagName, attrs); + this.namespaceURI = SVG_NS; + } +} + +class XULElement extends Element { + constructor(tagName, attrs = {}) { + super(tagName, attrs); + this.namespaceURI = XUL_NS; + + if (typeof this.ownerDocument == "undefined") { + this.ownerDocument = {}; + } + if (typeof this.ownerDocument.documentElement == "undefined") { + this.ownerDocument.documentElement = { namespaceURI: XUL_NS }; + } + } +} + +const domEl = new DOMElement("p"); +const svgEl = new SVGElement("rect"); +const xulEl = new XULElement("browser"); +const domElInXULDocument = new DOMElement("input", { + ownerDocument: { + documentElement: { namespaceURI: XUL_NS }, + }, +}); + +class WindowProxy { + get parent() { + return this; + } + get self() { + return this; + } + toString() { + return "[object Window]"; + } +} +const domWin = new WindowProxy(); +const domFrame = new (class extends WindowProxy { + get parent() { + return domWin; + } +})(); + +add_test(function test_findClosest() { + equal(element.findClosest(domEl, "foo"), null); + + let foo = new DOMElement("foo"); + let bar = new DOMElement("bar"); + bar.parentNode = foo; + equal(element.findClosest(bar, "foo"), foo); + + run_next_test(); +}); + +add_test(function test_isSelected() { + let checkbox = new DOMElement("input", { type: "checkbox" }); + ok(!element.isSelected(checkbox)); + checkbox.checked = true; + ok(element.isSelected(checkbox)); + + // selected is not a property of <input type=checkbox> + checkbox.selected = true; + checkbox.checked = false; + ok(!element.isSelected(checkbox)); + + let option = new DOMElement("option"); + ok(!element.isSelected(option)); + option.selected = true; + ok(element.isSelected(option)); + + // checked is not a property of <option> + option.checked = true; + option.selected = false; + ok(!element.isSelected(option)); + + // anything else should not be selected + for (let typ of [domEl, undefined, null, "foo", true, [], {}]) { + ok(!element.isSelected(typ)); + } + + run_next_test(); +}); + +add_test(function test_isElement() { + ok(element.isElement(domEl)); + ok(element.isElement(svgEl)); + ok(element.isElement(xulEl)); + ok(!element.isElement(domWin)); + ok(!element.isElement(domFrame)); + for (let typ of [true, 42, {}, [], undefined, null]) { + ok(!element.isElement(typ)); + } + + run_next_test(); +}); + +add_test(function test_isDOMElement() { + ok(element.isDOMElement(domEl)); + ok(element.isDOMElement(domElInXULDocument)); + ok(element.isDOMElement(svgEl)); + ok(!element.isDOMElement(xulEl)); + ok(!element.isDOMElement(domWin)); + ok(!element.isDOMElement(domFrame)); + for (let typ of [true, 42, {}, [], undefined, null]) { + ok(!element.isDOMElement(typ)); + } + + run_next_test(); +}); + +add_test(function test_isXULElement() { + ok(element.isXULElement(xulEl)); + ok(!element.isXULElement(domElInXULDocument)); + ok(!element.isXULElement(domEl)); + ok(!element.isXULElement(svgEl)); + ok(!element.isDOMElement(domWin)); + ok(!element.isDOMElement(domFrame)); + for (let typ of [true, 42, {}, [], undefined, null]) { + ok(!element.isXULElement(typ)); + } + + run_next_test(); +}); + +add_test(function test_isDOMWindow() { + ok(element.isDOMWindow(domWin)); + ok(element.isDOMWindow(domFrame)); + ok(!element.isDOMWindow(domEl)); + ok(!element.isDOMWindow(domElInXULDocument)); + ok(!element.isDOMWindow(svgEl)); + ok(!element.isDOMWindow(xulEl)); + for (let typ of [true, 42, {}, [], undefined, null]) { + ok(!element.isDOMWindow(typ)); + } + + run_next_test(); +}); + +add_test(function test_isReadOnly() { + ok(!element.isReadOnly(null)); + ok(!element.isReadOnly(domEl)); + ok(!element.isReadOnly(new DOMElement("p", { readOnly: true }))); + ok(element.isReadOnly(new DOMElement("input", { readOnly: true }))); + ok(element.isReadOnly(new DOMElement("textarea", { readOnly: true }))); + + run_next_test(); +}); + +add_test(function test_isDisabled() { + ok(!element.isDisabled(new DOMElement("p"))); + ok(!element.isDisabled(new SVGElement("rect", { disabled: true }))); + ok(!element.isDisabled(new XULElement("browser", { disabled: true }))); + + let select = new DOMElement("select", { disabled: true }); + let option = new DOMElement("option"); + option.parentNode = select; + ok(element.isDisabled(option)); + + let optgroup = new DOMElement("optgroup", { disabled: true }); + option.parentNode = optgroup; + optgroup.parentNode = select; + select.disabled = false; + ok(element.isDisabled(option)); + + ok(element.isDisabled(new DOMElement("button", { disabled: true }))); + ok(element.isDisabled(new DOMElement("input", { disabled: true }))); + ok(element.isDisabled(new DOMElement("select", { disabled: true }))); + ok(element.isDisabled(new DOMElement("textarea", { disabled: true }))); + + run_next_test(); +}); + +add_test(function test_isEditingHost() { + ok(!element.isEditingHost(null)); + ok(element.isEditingHost(new DOMElement("p", { isContentEditable: true }))); + ok( + element.isEditingHost( + new DOMElement("p", { ownerDocument: { designMode: "on" } }) + ) + ); + + run_next_test(); +}); + +add_test(function test_isEditable() { + ok(!element.isEditable(null)); + ok(!element.isEditable(domEl)); + ok(!element.isEditable(new DOMElement("textarea", { readOnly: true }))); + ok(!element.isEditable(new DOMElement("textarea", { disabled: true }))); + + for (let type of [ + "checkbox", + "radio", + "hidden", + "submit", + "button", + "image", + ]) { + ok(!element.isEditable(new DOMElement("input", { type }))); + } + ok(element.isEditable(new DOMElement("input", { type: "text" }))); + ok(element.isEditable(new DOMElement("input"))); + + ok(element.isEditable(new DOMElement("textarea"))); + ok( + element.isEditable( + new DOMElement("p", { ownerDocument: { designMode: "on" } }) + ) + ); + ok(element.isEditable(new DOMElement("p", { isContentEditable: true }))); + + run_next_test(); +}); + +add_test(function test_isMutableFormControlElement() { + ok(!element.isMutableFormControl(null)); + ok( + !element.isMutableFormControl( + new DOMElement("textarea", { readOnly: true }) + ) + ); + ok( + !element.isMutableFormControl( + new DOMElement("textarea", { disabled: true }) + ) + ); + + const mutableStates = new Set([ + "color", + "date", + "datetime-local", + "email", + "file", + "month", + "number", + "password", + "range", + "search", + "tel", + "text", + "url", + "week", + ]); + for (let type of mutableStates) { + ok(element.isMutableFormControl(new DOMElement("input", { type }))); + } + ok(element.isMutableFormControl(new DOMElement("textarea"))); + + ok( + !element.isMutableFormControl(new DOMElement("input", { type: "hidden" })) + ); + ok(!element.isMutableFormControl(new DOMElement("p"))); + ok( + !element.isMutableFormControl( + new DOMElement("p", { isContentEditable: true }) + ) + ); + ok( + !element.isMutableFormControl( + new DOMElement("p", { ownerDocument: { designMode: "on" } }) + ) + ); + + run_next_test(); +}); + +add_test(function test_coordinates() { + let p = element.coordinates(domEl); + ok(p.hasOwnProperty("x")); + ok(p.hasOwnProperty("y")); + equal("number", typeof p.x); + equal("number", typeof p.y); + + deepEqual({ x: 50, y: 50 }, element.coordinates(domEl)); + deepEqual({ x: 10, y: 10 }, element.coordinates(domEl, 10, 10)); + deepEqual({ x: -5, y: -5 }, element.coordinates(domEl, -5, -5)); + + Assert.throws(() => element.coordinates(null), /node is null/); + + Assert.throws( + () => element.coordinates(domEl, "string", undefined), + /Offset must be a number/ + ); + Assert.throws( + () => element.coordinates(domEl, undefined, "string"), + /Offset must be a number/ + ); + Assert.throws( + () => element.coordinates(domEl, "string", "string"), + /Offset must be a number/ + ); + Assert.throws( + () => element.coordinates(domEl, {}, undefined), + /Offset must be a number/ + ); + Assert.throws( + () => element.coordinates(domEl, undefined, {}), + /Offset must be a number/ + ); + Assert.throws( + () => element.coordinates(domEl, {}, {}), + /Offset must be a number/ + ); + Assert.throws( + () => element.coordinates(domEl, [], undefined), + /Offset must be a number/ + ); + Assert.throws( + () => element.coordinates(domEl, undefined, []), + /Offset must be a number/ + ); + Assert.throws( + () => element.coordinates(domEl, [], []), + /Offset must be a number/ + ); + + run_next_test(); +}); + +add_test(function test_WebElement_ctor() { + let el = new WebElement("foo"); + equal(el.uuid, "foo"); + + for (let t of [42, true, [], {}, null, undefined]) { + Assert.throws(() => new WebElement(t), /to be a string/); + } + + run_next_test(); +}); + +add_test(function test_WebElemenet_is() { + let a = new WebElement("a"); + let b = new WebElement("b"); + + ok(a.is(a)); + ok(b.is(b)); + ok(!a.is(b)); + ok(!b.is(a)); + + ok(!a.is({})); + + run_next_test(); +}); + +add_test(function test_WebElement_from() { + ok(WebElement.from(domEl) instanceof ContentWebElement); + ok(WebElement.from(domWin) instanceof ContentWebWindow); + ok(WebElement.from(domFrame) instanceof ContentWebFrame); + ok(WebElement.from(xulEl) instanceof ChromeWebElement); + ok(WebElement.from(domElInXULDocument) instanceof ChromeWebElement); + + Assert.throws(() => WebElement.from({}), /InvalidArgumentError/); + + run_next_test(); +}); + +add_test(function test_WebElement_fromJSON_ContentWebElement() { + const { Identifier } = ContentWebElement; + + let ref = { [Identifier]: "foo" }; + let webEl = WebElement.fromJSON(ref); + ok(webEl instanceof ContentWebElement); + equal(webEl.uuid, "foo"); + + let identifierPrecedence = { + [Identifier]: "identifier-uuid", + }; + let precedenceEl = WebElement.fromJSON(identifierPrecedence); + ok(precedenceEl instanceof ContentWebElement); + equal(precedenceEl.uuid, "identifier-uuid"); + + run_next_test(); +}); + +add_test(function test_WebElement_fromJSON_ContentWebWindow() { + let ref = { [ContentWebWindow.Identifier]: "foo" }; + let win = WebElement.fromJSON(ref); + ok(win instanceof ContentWebWindow); + equal(win.uuid, "foo"); + + run_next_test(); +}); + +add_test(function test_WebElement_fromJSON_ContentWebFrame() { + let ref = { [ContentWebFrame.Identifier]: "foo" }; + let frame = WebElement.fromJSON(ref); + ok(frame instanceof ContentWebFrame); + equal(frame.uuid, "foo"); + + run_next_test(); +}); + +add_test(function test_WebElement_fromJSON_ChromeWebElement() { + let ref = { [ChromeWebElement.Identifier]: "foo" }; + let el = WebElement.fromJSON(ref); + ok(el instanceof ChromeWebElement); + equal(el.uuid, "foo"); + + run_next_test(); +}); + +add_test(function test_WebElement_fromJSON_malformed() { + Assert.throws(() => WebElement.fromJSON({}), /InvalidArgumentError/); + Assert.throws(() => WebElement.fromJSON(null), /InvalidArgumentError/); + run_next_test(); +}); + +add_test(function test_WebElement_fromUUID() { + let xulWebEl = WebElement.fromUUID("foo", "chrome"); + ok(xulWebEl instanceof ChromeWebElement); + equal(xulWebEl.uuid, "foo"); + + let domWebEl = WebElement.fromUUID("bar", "content"); + ok(domWebEl instanceof ContentWebElement); + equal(domWebEl.uuid, "bar"); + + Assert.throws( + () => WebElement.fromUUID("baz", "bah"), + /InvalidArgumentError/ + ); + + run_next_test(); +}); + +add_test(function test_WebElement_isReference() { + for (let t of [42, true, "foo", [], {}]) { + ok(!WebElement.isReference(t)); + } + + ok(WebElement.isReference({ [ContentWebElement.Identifier]: "foo" })); + ok(WebElement.isReference({ [ContentWebWindow.Identifier]: "foo" })); + ok(WebElement.isReference({ [ContentWebFrame.Identifier]: "foo" })); + ok(WebElement.isReference({ [ChromeWebElement.Identifier]: "foo" })); + + run_next_test(); +}); + +add_test(function test_WebElement_generateUUID() { + equal(typeof WebElement.generateUUID(), "string"); + run_next_test(); +}); + +add_test(function test_ContentWebElement_toJSON() { + const { Identifier } = ContentWebElement; + + let el = new ContentWebElement("foo"); + let json = el.toJSON(); + + ok(Identifier in json); + equal(json[Identifier], "foo"); + + run_next_test(); +}); + +add_test(function test_ContentWebElement_fromJSON() { + const { Identifier } = ContentWebElement; + + let el = ContentWebElement.fromJSON({ [Identifier]: "foo" }); + ok(el instanceof ContentWebElement); + equal(el.uuid, "foo"); + + Assert.throws(() => ContentWebElement.fromJSON({}), /InvalidArgumentError/); + + run_next_test(); +}); + +add_test(function test_ContentWebWindow_toJSON() { + let win = new ContentWebWindow("foo"); + let json = win.toJSON(); + ok(ContentWebWindow.Identifier in json); + equal(json[ContentWebWindow.Identifier], "foo"); + + run_next_test(); +}); + +add_test(function test_ContentWebWindow_fromJSON() { + let ref = { [ContentWebWindow.Identifier]: "foo" }; + let win = ContentWebWindow.fromJSON(ref); + ok(win instanceof ContentWebWindow); + equal(win.uuid, "foo"); + + run_next_test(); +}); + +add_test(function test_ContentWebFrame_toJSON() { + let frame = new ContentWebFrame("foo"); + let json = frame.toJSON(); + ok(ContentWebFrame.Identifier in json); + equal(json[ContentWebFrame.Identifier], "foo"); + + run_next_test(); +}); + +add_test(function test_ContentWebFrame_fromJSON() { + let ref = { [ContentWebFrame.Identifier]: "foo" }; + let win = ContentWebFrame.fromJSON(ref); + ok(win instanceof ContentWebFrame); + equal(win.uuid, "foo"); + + run_next_test(); +}); + +add_test(function test_ChromeWebElement_toJSON() { + let el = new ChromeWebElement("foo"); + let json = el.toJSON(); + ok(ChromeWebElement.Identifier in json); + equal(json[ChromeWebElement.Identifier], "foo"); + + run_next_test(); +}); + +add_test(function test_ChromeWebElement_fromJSON() { + let ref = { [ChromeWebElement.Identifier]: "foo" }; + let win = ChromeWebElement.fromJSON(ref); + ok(win instanceof ChromeWebElement); + equal(win.uuid, "foo"); + + run_next_test(); +}); diff --git a/testing/marionette/test/unit/test_error.js b/testing/marionette/test/unit/test_error.js new file mode 100644 index 0000000000..0be71dec65 --- /dev/null +++ b/testing/marionette/test/unit/test_error.js @@ -0,0 +1,477 @@ +/* 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.import("chrome://marionette/content/error.js"); + +function notok(condition) { + ok(!condition); +} + +add_test(function test_isError() { + notok(error.isError(null)); + notok(error.isError([])); + notok(error.isError(new Date())); + + ok(error.isError(new Components.Exception())); + ok(error.isError(new Error())); + ok(error.isError(new EvalError())); + ok(error.isError(new InternalError())); + ok(error.isError(new RangeError())); + ok(error.isError(new ReferenceError())); + ok(error.isError(new SyntaxError())); + ok(error.isError(new TypeError())); + ok(error.isError(new URIError())); + ok(error.isError(new error.WebDriverError())); + ok(error.isError(new error.InvalidArgumentError())); + + run_next_test(); +}); + +add_test(function test_isWebDriverError() { + notok(error.isWebDriverError(new Components.Exception())); + notok(error.isWebDriverError(new Error())); + notok(error.isWebDriverError(new EvalError())); + notok(error.isWebDriverError(new InternalError())); + notok(error.isWebDriverError(new RangeError())); + notok(error.isWebDriverError(new ReferenceError())); + notok(error.isWebDriverError(new SyntaxError())); + notok(error.isWebDriverError(new TypeError())); + notok(error.isWebDriverError(new URIError())); + + ok(error.isWebDriverError(new error.WebDriverError())); + ok(error.isWebDriverError(new error.InvalidArgumentError())); + ok(error.isWebDriverError(new error.JavaScriptError())); + + run_next_test(); +}); + +add_test(function test_wrap() { + // webdriver-derived errors should not be wrapped + equal(error.wrap(new error.WebDriverError()).name, "WebDriverError"); + ok(error.wrap(new error.WebDriverError()) instanceof error.WebDriverError); + equal( + error.wrap(new error.InvalidArgumentError()).name, + "InvalidArgumentError" + ); + ok( + error.wrap(new error.InvalidArgumentError()) instanceof error.WebDriverError + ); + ok( + error.wrap(new error.InvalidArgumentError()) instanceof + error.InvalidArgumentError + ); + + // JS errors should be wrapped in UnknownError + equal(error.wrap(new Error()).name, "UnknownError"); + ok(error.wrap(new Error()) instanceof error.UnknownError); + equal(error.wrap(new EvalError()).name, "UnknownError"); + equal(error.wrap(new InternalError()).name, "UnknownError"); + equal(error.wrap(new RangeError()).name, "UnknownError"); + equal(error.wrap(new ReferenceError()).name, "UnknownError"); + equal(error.wrap(new SyntaxError()).name, "UnknownError"); + equal(error.wrap(new TypeError()).name, "UnknownError"); + equal(error.wrap(new URIError()).name, "UnknownError"); + + // wrapped JS errors should retain their type + // as part of the message field + equal(error.wrap(new error.WebDriverError("foo")).message, "foo"); + equal(error.wrap(new TypeError("foo")).message, "TypeError: foo"); + + run_next_test(); +}); + +add_test(function test_stringify() { + equal("<unprintable error>", error.stringify()); + equal("<unprintable error>", error.stringify("foo")); + equal("[object Object]", error.stringify({})); + equal("[object Object]\nfoo", error.stringify({ stack: "foo" })); + equal("Error: foo", error.stringify(new Error("foo")).split("\n")[0]); + equal( + "WebDriverError: foo", + error.stringify(new error.WebDriverError("foo")).split("\n")[0] + ); + equal( + "InvalidArgumentError: foo", + error.stringify(new error.InvalidArgumentError("foo")).split("\n")[0] + ); + + run_next_test(); +}); + +add_test(function test_stack() { + equal("string", typeof error.stack()); + ok(error.stack().includes("test_stack")); + ok(!error.stack().includes("add_test")); + + run_next_test(); +}); + +add_test(function test_toJSON() { + let e0 = new error.WebDriverError(); + let e0s = e0.toJSON(); + equal(e0s.error, "webdriver error"); + equal(e0s.message, ""); + equal(e0s.stacktrace, e0.stack); + + let e1 = new error.WebDriverError("a"); + let e1s = e1.toJSON(); + equal(e1s.message, e1.message); + equal(e1s.stacktrace, e1.stack); + + let e2 = new error.JavaScriptError("foo"); + let e2s = e2.toJSON(); + equal(e2.status, e2s.error); + equal(e2.message, e2s.message); + + run_next_test(); +}); + +add_test(function test_fromJSON() { + Assert.throws( + () => error.WebDriverError.fromJSON({ error: "foo" }), + /Not of WebDriverError descent/ + ); + Assert.throws( + () => error.WebDriverError.fromJSON({ error: "Error" }), + /Not of WebDriverError descent/ + ); + Assert.throws( + () => error.WebDriverError.fromJSON({}), + /Undeserialisable error type/ + ); + Assert.throws(() => error.WebDriverError.fromJSON(undefined), /TypeError/); + + // stacks will be different + let e1 = new error.WebDriverError("1"); + let e1r = error.WebDriverError.fromJSON({ + error: "webdriver error", + message: "1", + }); + ok(e1r instanceof error.WebDriverError); + equal(e1r.name, e1.name); + equal(e1r.status, e1.status); + equal(e1r.message, e1.message); + + // stacks will be different + let e2 = new error.InvalidArgumentError("2"); + let e2r = error.WebDriverError.fromJSON({ + error: "invalid argument", + message: "2", + }); + ok(e2r instanceof error.WebDriverError); + ok(e2r instanceof error.InvalidArgumentError); + equal(e2r.name, e2.name); + equal(e2r.status, e2.status); + equal(e2r.message, e2.message); + + // test stacks + let e3j = { error: "no such element", message: "3", stacktrace: "4" }; + let e3r = error.WebDriverError.fromJSON(e3j); + ok(e3r instanceof error.WebDriverError); + ok(e3r instanceof error.NoSuchElementError); + equal(e3r.name, "NoSuchElementError"); + equal(e3r.status, e3j.error); + equal(e3r.message, e3j.message); + equal(e3r.stack, e3j.stacktrace); + + // parity with toJSON + let e4j = new error.JavaScriptError("foo").toJSON(); + let e4 = error.WebDriverError.fromJSON(e4j); + equal(e4j.error, e4.status); + equal(e4j.message, e4.message); + equal(e4j.stacktrace, e4.stack); + + run_next_test(); +}); + +add_test(function test_WebDriverError() { + let err = new error.WebDriverError("foo"); + equal("WebDriverError", err.name); + equal("foo", err.message); + equal("webdriver error", err.status); + ok(err instanceof error.WebDriverError); + + run_next_test(); +}); + +add_test(function test_ElementClickInterceptedError() { + let otherEl = { + hasAttribute: attr => attr in otherEl, + getAttribute: attr => (attr in otherEl ? otherEl[attr] : null), + nodeType: 1, + localName: "a", + }; + let obscuredEl = { + hasAttribute: attr => attr in obscuredEl, + getAttribute: attr => (attr in obscuredEl ? obscuredEl[attr] : null), + nodeType: 1, + localName: "b", + ownerDocument: { + elementFromPoint() { + return otherEl; + }, + }, + style: { + pointerEvents: "auto", + }, + }; + + let err1 = new error.ElementClickInterceptedError(obscuredEl, { x: 1, y: 2 }); + equal("ElementClickInterceptedError", err1.name); + equal( + "Element <b> is not clickable at point (1,2) " + + "because another element <a> obscures it", + err1.message + ); + equal("element click intercepted", err1.status); + ok(err1 instanceof error.WebDriverError); + + obscuredEl.style.pointerEvents = "none"; + let err2 = new error.ElementClickInterceptedError(obscuredEl, { x: 1, y: 2 }); + equal( + "Element <b> is not clickable at point (1,2) " + + "because it does not have pointer events enabled, " + + "and element <a> would receive the click instead", + err2.message + ); + + run_next_test(); +}); + +add_test(function test_ElementNotAccessibleError() { + let err = new error.ElementNotAccessibleError("foo"); + equal("ElementNotAccessibleError", err.name); + equal("foo", err.message); + equal("element not accessible", err.status); + ok(err instanceof error.WebDriverError); + + run_next_test(); +}); + +add_test(function test_ElementNotInteractableError() { + let err = new error.ElementNotInteractableError("foo"); + equal("ElementNotInteractableError", err.name); + equal("foo", err.message); + equal("element not interactable", err.status); + ok(err instanceof error.WebDriverError); + + run_next_test(); +}); + +add_test(function test_InsecureCertificateError() { + let err = new error.InsecureCertificateError("foo"); + equal("InsecureCertificateError", err.name); + equal("foo", err.message); + equal("insecure certificate", err.status); + ok(err instanceof error.WebDriverError); + + run_next_test(); +}); + +add_test(function test_InvalidArgumentError() { + let err = new error.InvalidArgumentError("foo"); + equal("InvalidArgumentError", err.name); + equal("foo", err.message); + equal("invalid argument", err.status); + ok(err instanceof error.WebDriverError); + + run_next_test(); +}); + +add_test(function test_InvalidCookieDomainError() { + let err = new error.InvalidCookieDomainError("foo"); + equal("InvalidCookieDomainError", err.name); + equal("foo", err.message); + equal("invalid cookie domain", err.status); + ok(err instanceof error.WebDriverError); + + run_next_test(); +}); + +add_test(function test_InvalidElementStateError() { + let err = new error.InvalidElementStateError("foo"); + equal("InvalidElementStateError", err.name); + equal("foo", err.message); + equal("invalid element state", err.status); + ok(err instanceof error.WebDriverError); + + run_next_test(); +}); + +add_test(function test_InvalidSelectorError() { + let err = new error.InvalidSelectorError("foo"); + equal("InvalidSelectorError", err.name); + equal("foo", err.message); + equal("invalid selector", err.status); + ok(err instanceof error.WebDriverError); + + run_next_test(); +}); + +add_test(function test_InvalidSessionIDError() { + let err = new error.InvalidSessionIDError("foo"); + equal("InvalidSessionIDError", err.name); + equal("foo", err.message); + equal("invalid session id", err.status); + ok(err instanceof error.WebDriverError); + + run_next_test(); +}); + +add_test(function test_JavaScriptError() { + let err = new error.JavaScriptError("foo"); + equal("JavaScriptError", err.name); + equal("foo", err.message); + equal("javascript error", err.status); + ok(err instanceof error.WebDriverError); + + equal("", new error.JavaScriptError(undefined).message); + + let superErr = new RangeError("foo"); + let inheritedErr = new error.JavaScriptError(superErr); + equal("RangeError: foo", inheritedErr.message); + equal(superErr.stack, inheritedErr.stack); + + run_next_test(); +}); + +add_test(function test_MoveTargetOutOfBoundsError() { + let err = new error.MoveTargetOutOfBoundsError("foo"); + equal("MoveTargetOutOfBoundsError", err.name); + equal("foo", err.message); + equal("move target out of bounds", err.status); + ok(err instanceof error.WebDriverError); + + run_next_test(); +}); + +add_test(function test_NoSuchAlertError() { + let err = new error.NoSuchAlertError("foo"); + equal("NoSuchAlertError", err.name); + equal("foo", err.message); + equal("no such alert", err.status); + ok(err instanceof error.WebDriverError); + + run_next_test(); +}); + +add_test(function test_NoSuchElementError() { + let err = new error.NoSuchElementError("foo"); + equal("NoSuchElementError", err.name); + equal("foo", err.message); + equal("no such element", err.status); + ok(err instanceof error.WebDriverError); + + run_next_test(); +}); + +add_test(function test_NoSuchFrameError() { + let err = new error.NoSuchFrameError("foo"); + equal("NoSuchFrameError", err.name); + equal("foo", err.message); + equal("no such frame", err.status); + ok(err instanceof error.WebDriverError); + + run_next_test(); +}); + +add_test(function test_NoSuchWindowError() { + let err = new error.NoSuchWindowError("foo"); + equal("NoSuchWindowError", err.name); + equal("foo", err.message); + equal("no such window", err.status); + ok(err instanceof error.WebDriverError); + + run_next_test(); +}); + +add_test(function test_ScriptTimeoutError() { + let err = new error.ScriptTimeoutError("foo"); + equal("ScriptTimeoutError", err.name); + equal("foo", err.message); + equal("script timeout", err.status); + ok(err instanceof error.WebDriverError); + + run_next_test(); +}); + +add_test(function test_SessionNotCreatedError() { + let err = new error.SessionNotCreatedError("foo"); + equal("SessionNotCreatedError", err.name); + equal("foo", err.message); + equal("session not created", err.status); + ok(err instanceof error.WebDriverError); + + run_next_test(); +}); + +add_test(function test_StaleElementReferenceError() { + let err = new error.StaleElementReferenceError("foo"); + equal("StaleElementReferenceError", err.name); + equal("foo", err.message); + equal("stale element reference", err.status); + ok(err instanceof error.WebDriverError); + + run_next_test(); +}); + +add_test(function test_TimeoutError() { + let err = new error.TimeoutError("foo"); + equal("TimeoutError", err.name); + equal("foo", err.message); + equal("timeout", err.status); + ok(err instanceof error.WebDriverError); + + run_next_test(); +}); + +add_test(function test_UnableToSetCookieError() { + let err = new error.UnableToSetCookieError("foo"); + equal("UnableToSetCookieError", err.name); + equal("foo", err.message); + equal("unable to set cookie", err.status); + ok(err instanceof error.WebDriverError); + + run_next_test(); +}); + +add_test(function test_UnexpectedAlertOpenError() { + let err = new error.UnexpectedAlertOpenError("foo"); + equal("UnexpectedAlertOpenError", err.name); + equal("foo", err.message); + equal("unexpected alert open", err.status); + ok(err instanceof error.WebDriverError); + + run_next_test(); +}); + +add_test(function test_UnknownCommandError() { + let err = new error.UnknownCommandError("foo"); + equal("UnknownCommandError", err.name); + equal("foo", err.message); + equal("unknown command", err.status); + ok(err instanceof error.WebDriverError); + + run_next_test(); +}); + +add_test(function test_UnknownError() { + let err = new error.UnknownError("foo"); + equal("UnknownError", err.name); + equal("foo", err.message); + equal("unknown error", err.status); + ok(err instanceof error.WebDriverError); + + run_next_test(); +}); + +add_test(function test_UnsupportedOperationError() { + let err = new error.UnsupportedOperationError("foo"); + equal("UnsupportedOperationError", err.name); + equal("foo", err.message); + equal("unsupported operation", err.status); + ok(err instanceof error.WebDriverError); + + run_next_test(); +}); diff --git a/testing/marionette/test/unit/test_evaluate.js b/testing/marionette/test/unit/test_evaluate.js new file mode 100644 index 0000000000..6b426e13fa --- /dev/null +++ b/testing/marionette/test/unit/test_evaluate.js @@ -0,0 +1,342 @@ +const { element, ReferenceStore, WebElement } = ChromeUtils.import( + "chrome://marionette/content/element.js" +); +const { evaluate } = ChromeUtils.import( + "chrome://marionette/content/evaluate.js" +); + +const SVG_NS = "http://www.w3.org/2000/svg"; +const XHTML_NS = "http://www.w3.org/1999/xhtml"; +const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; + +class Element { + constructor(tagName, attrs = {}) { + this.tagName = tagName; + this.localName = tagName; + + // Set default properties + this.isConnected = true; + this.ownerDocument = { documentElement: {} }; + this.ownerGlobal = { document: this.ownerDocument }; + + for (let attr in attrs) { + this[attr] = attrs[attr]; + } + } + + get nodeType() { + return 1; + } + get ELEMENT_NODE() { + return 1; + } +} + +class DOMElement extends Element { + constructor(tagName, attrs = {}) { + super(tagName, attrs); + this.namespaceURI = XHTML_NS; + } +} + +class SVGElement extends Element { + constructor(tagName, attrs = {}) { + super(tagName, attrs); + this.namespaceURI = SVG_NS; + } +} + +class XULElement extends Element { + constructor(tagName, attrs = {}) { + super(tagName, attrs); + this.namespaceURI = XUL_NS; + } +} + +const domEl = new DOMElement("p"); +const svgEl = new SVGElement("rect"); +const xulEl = new XULElement("browser"); + +const domWebEl = WebElement.from(domEl); +const svgWebEl = WebElement.from(svgEl); +const xulWebEl = WebElement.from(xulEl); + +const domElId = { id: 1, browsingContextId: 4, webElRef: domWebEl.toJSON() }; +const svgElId = { id: 2, browsingContextId: 5, webElRef: svgWebEl.toJSON() }; +const xulElId = { id: 3, browsingContextId: 6, webElRef: xulWebEl.toJSON() }; + +const seenEls = new element.Store(); +const elementIdCache = new element.ReferenceStore(); + +add_test(function test_toJSON_types() { + // null + equal(null, evaluate.toJSON(undefined)); + equal(null, evaluate.toJSON(null)); + + // primitives + equal(true, evaluate.toJSON(true)); + equal(42, evaluate.toJSON(42)); + equal("foo", evaluate.toJSON("foo")); + + // collections + deepEqual([], evaluate.toJSON([])); + + // elements + ok(evaluate.toJSON(domEl, seenEls) instanceof WebElement); + ok(evaluate.toJSON(svgEl, seenEls) instanceof WebElement); + ok(evaluate.toJSON(xulEl, seenEls) instanceof WebElement); + + // toJSON + equal( + "foo", + evaluate.toJSON({ + toJSON() { + return "foo"; + }, + }) + ); + + // arbitrary object + deepEqual({ foo: "bar" }, evaluate.toJSON({ foo: "bar" })); + + run_next_test(); +}); + +add_test(function test_toJSON_types_ReferenceStore() { + // Temporarily add custom elements until xpcshell tests + // have access to real DOM nodes (including the Window Proxy) + elementIdCache.add(domElId); + elementIdCache.add(svgElId); + elementIdCache.add(xulElId); + + deepEqual(evaluate.toJSON(domWebEl, elementIdCache), domElId); + deepEqual(evaluate.toJSON(svgWebEl, elementIdCache), svgElId); + deepEqual(evaluate.toJSON(xulWebEl, elementIdCache), xulElId); + + Assert.throws( + () => evaluate.toJSON(domEl, elementIdCache), + /TypeError/, + "Reference store not usable for elements" + ); + + elementIdCache.clear(); + + run_next_test(); +}); + +add_test(function test_toJSON_sequences() { + const input = [ + null, + true, + [], + domEl, + { + toJSON() { + return "foo"; + }, + }, + { bar: "baz" }, + ]; + const actual = evaluate.toJSON(input, seenEls); + + equal(null, actual[0]); + equal(true, actual[1]); + deepEqual([], actual[2]); + ok(actual[3] instanceof WebElement); + equal("foo", actual[4]); + deepEqual({ bar: "baz" }, actual[5]); + + run_next_test(); +}); + +add_test(function test_toJSON_sequences_ReferenceStore() { + const input = [ + null, + true, + [], + domWebEl, + { + toJSON() { + return "foo"; + }, + }, + { bar: "baz" }, + ]; + + Assert.throws( + () => evaluate.toJSON(input, elementIdCache), + /NoSuchElementError/, + "Expected no element" + ); + + elementIdCache.add(domElId); + + const actual = evaluate.toJSON(input, elementIdCache); + + equal(null, actual[0]); + equal(true, actual[1]); + deepEqual([], actual[2]); + deepEqual(actual[3], domElId); + equal("foo", actual[4]); + deepEqual({ bar: "baz" }, actual[5]); + + elementIdCache.clear(); + + run_next_test(); +}); + +add_test(function test_toJSON_objects() { + const input = { + null: null, + boolean: true, + array: [], + webElement: domEl, + elementId: domElId, + toJSON: { + toJSON() { + return "foo"; + }, + }, + object: { bar: "baz" }, + }; + const actual = evaluate.toJSON(input, seenEls); + + equal(null, actual.null); + equal(true, actual.boolean); + deepEqual([], actual.array); + ok(actual.webElement instanceof WebElement); + ok(actual.elementId instanceof WebElement); + equal("foo", actual.toJSON); + deepEqual({ bar: "baz" }, actual.object); + + run_next_test(); +}); + +add_test(function test_toJSON_objects_ReferenceStore() { + const input = { + null: null, + boolean: true, + array: [], + webElement: domWebEl, + elementId: domElId, + toJSON: { + toJSON() { + return "foo"; + }, + }, + object: { bar: "baz" }, + }; + + Assert.throws( + () => evaluate.toJSON(input, elementIdCache), + /NoSuchElementError/, + "Expected no element" + ); + + elementIdCache.add(domElId); + + const actual = evaluate.toJSON(input, elementIdCache); + + equal(null, actual.null); + equal(true, actual.boolean); + deepEqual([], actual.array); + deepEqual(actual.webElement, domElId); + deepEqual(actual.elementId, domElId); + equal("foo", actual.toJSON); + deepEqual({ bar: "baz" }, actual.object); + + elementIdCache.clear(); + + run_next_test(); +}); + +add_test(function test_fromJSON_ReferenceStore() { + // Add unknown element to reference store + let webEl = evaluate.fromJSON(domElId, elementIdCache); + deepEqual(webEl, domWebEl); + deepEqual(elementIdCache.get(webEl), domElId); + + // Previously seen element is associated with original web element reference + const domElId2 = { + id: 1, + browsingContextId: 4, + webElRef: WebElement.from(domEl).toJSON(), + }; + webEl = evaluate.fromJSON(domElId2, elementIdCache); + deepEqual(webEl, domWebEl); + deepEqual(elementIdCache.get(webEl), domElId); + + // Store doesn't contain ElementIdentifiers + Assert.throws( + () => evaluate.fromJSON(domElId, seenEls), + /TypeError/, + "Expected element.ReferenceStore" + ); + + elementIdCache.clear(); + + run_next_test(); +}); + +add_test(function test_fromJSON_Store() { + // Pass-through WebElements without adding it to the element store + let webEl = evaluate.fromJSON(domWebEl.toJSON()); + deepEqual(webEl, domWebEl); + ok(!seenEls.has(domWebEl)); + + // Find element in the element store + webEl = seenEls.add(domEl); + const el = evaluate.fromJSON(webEl.toJSON(), seenEls); + deepEqual(el, domEl); + + // Reference store doesn't contain web elements + Assert.throws( + () => evaluate.fromJSON(domWebEl.toJSON(), elementIdCache), + /TypeError/, + "Expected element.Store" + ); + + seenEls.clear(); + + run_next_test(); +}); + +add_test(function test_isCyclic_noncyclic() { + for (let type of [true, 42, "foo", [], {}, null, undefined]) { + ok(!evaluate.isCyclic(type)); + } + + run_next_test(); +}); + +add_test(function test_isCyclic_object() { + let obj = {}; + obj.reference = obj; + ok(evaluate.isCyclic(obj)); + + run_next_test(); +}); + +add_test(function test_isCyclic_array() { + let arr = []; + arr.push(arr); + ok(evaluate.isCyclic(arr)); + + run_next_test(); +}); + +add_test(function test_isCyclic_arrayInObject() { + let arr = []; + arr.push(arr); + ok(evaluate.isCyclic({ arr })); + + run_next_test(); +}); + +add_test(function test_isCyclic_objectInArray() { + let obj = {}; + obj.reference = obj; + ok(evaluate.isCyclic([obj])); + + run_next_test(); +}); diff --git a/testing/marionette/test/unit/test_format.js b/testing/marionette/test/unit/test_format.js new file mode 100644 index 0000000000..7cce50a231 --- /dev/null +++ b/testing/marionette/test/unit/test_format.js @@ -0,0 +1,118 @@ +/* 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 { pprint, truncate } = ChromeUtils.import( + "chrome://marionette/content/format.js" +); + +const MAX_STRING_LENGTH = 250; +const HALF = "x".repeat(MAX_STRING_LENGTH / 2); + +add_test(function test_pprint() { + equal('[object Object] {"foo":"bar"}', pprint`${{ foo: "bar" }}`); + + equal("[object Number] 42", pprint`${42}`); + equal("[object Boolean] true", pprint`${true}`); + equal("[object Undefined] undefined", pprint`${undefined}`); + equal("[object Null] null", pprint`${null}`); + + let complexObj = { toJSON: () => "foo" }; + equal('[object Object] "foo"', pprint`${complexObj}`); + + let cyclic = {}; + cyclic.me = cyclic; + equal("[object Object] <cyclic object value>", pprint`${cyclic}`); + + let el = { + hasAttribute: attr => attr in el, + getAttribute: attr => (attr in el ? el[attr] : null), + nodeType: 1, + localName: "input", + id: "foo", + class: "a b", + href: "#", + name: "bar", + src: "s", + type: "t", + }; + equal( + '<input id="foo" class="a b" href="#" name="bar" src="s" type="t">', + pprint`${el}` + ); + + run_next_test(); +}); + +add_test(function test_truncate_empty() { + equal(truncate``, ""); + run_next_test(); +}); + +add_test(function test_truncate_noFields() { + equal(truncate`foo bar`, "foo bar"); + run_next_test(); +}); + +add_test(function test_truncate_multipleFields() { + equal(truncate`${0}`, "0"); + equal(truncate`${1}${2}${3}`, "123"); + equal(truncate`a${1}b${2}c${3}`, "a1b2c3"); + run_next_test(); +}); + +add_test(function test_truncate_primitiveFields() { + equal(truncate`${123}`, "123"); + equal(truncate`${true}`, "true"); + equal(truncate`${null}`, ""); + equal(truncate`${undefined}`, ""); + run_next_test(); +}); + +add_test(function test_truncate_string() { + equal(truncate`${"foo"}`, "foo"); + equal(truncate`${"x".repeat(250)}`, "x".repeat(250)); + equal(truncate`${"x".repeat(260)}`, `${HALF} ... ${HALF}`); + run_next_test(); +}); + +add_test(function test_truncate_array() { + equal(truncate`${["foo"]}`, JSON.stringify(["foo"])); + equal(truncate`${"foo"} ${["bar"]}`, `foo ${JSON.stringify(["bar"])}`); + equal( + truncate`${["x".repeat(260)]}`, + JSON.stringify([`${HALF} ... ${HALF}`]) + ); + + run_next_test(); +}); + +add_test(function test_truncate_object() { + equal(truncate`${{}}`, JSON.stringify({})); + equal(truncate`${{ foo: "bar" }}`, JSON.stringify({ foo: "bar" })); + equal( + truncate`${{ foo: "x".repeat(260) }}`, + JSON.stringify({ foo: `${HALF} ... ${HALF}` }) + ); + equal(truncate`${{ foo: ["bar"] }}`, JSON.stringify({ foo: ["bar"] })); + equal( + truncate`${{ foo: ["bar", { baz: 42 }] }}`, + JSON.stringify({ foo: ["bar", { baz: 42 }] }) + ); + + let complex = { + toString() { + return "hello world"; + }, + }; + equal(truncate`${complex}`, "hello world"); + + let longComplex = { + toString() { + return "x".repeat(260); + }, + }; + equal(truncate`${longComplex}`, `${HALF} ... ${HALF}`); + + run_next_test(); +}); diff --git a/testing/marionette/test/unit/test_message.js b/testing/marionette/test/unit/test_message.js new file mode 100644 index 0000000000..4d3f09d2a5 --- /dev/null +++ b/testing/marionette/test/unit/test_message.js @@ -0,0 +1,277 @@ +/* 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.import("chrome://marionette/content/error.js"); +const { Command, Message, Response } = ChromeUtils.import( + "chrome://marionette/content/message.js" +); + +add_test(function test_Message_Origin() { + equal(0, Message.Origin.Client); + equal(1, Message.Origin.Server); + + run_next_test(); +}); + +add_test(function test_Message_fromPacket() { + let cmd = new Command(4, "foo"); + let resp = new Response(5, () => {}); + resp.error = "foo"; + + ok(Message.fromPacket(cmd.toPacket()) instanceof Command); + ok(Message.fromPacket(resp.toPacket()) instanceof Response); + Assert.throws( + () => Message.fromPacket([3, 4, 5, 6]), + /Unrecognised message type in packet/ + ); + + run_next_test(); +}); + +add_test(function test_Command() { + let cmd = new Command(42, "foo", { bar: "baz" }); + equal(42, cmd.id); + equal("foo", cmd.name); + deepEqual({ bar: "baz" }, cmd.parameters); + equal(null, cmd.onerror); + equal(null, cmd.onresult); + equal(Message.Origin.Client, cmd.origin); + equal(false, cmd.sent); + + run_next_test(); +}); + +add_test(function test_Command_onresponse() { + let onerrorOk = false; + let onresultOk = false; + + let cmd = new Command(7, "foo"); + cmd.onerror = () => (onerrorOk = true); + cmd.onresult = () => (onresultOk = true); + + let errorResp = new Response(8, () => {}); + errorResp.error = new error.WebDriverError("foo"); + + let bodyResp = new Response(9, () => {}); + bodyResp.body = "bar"; + + cmd.onresponse(errorResp); + equal(true, onerrorOk); + equal(false, onresultOk); + + cmd.onresponse(bodyResp); + equal(true, onresultOk); + + run_next_test(); +}); + +add_test(function test_Command_ctor() { + let cmd = new Command(42, "bar", { bar: "baz" }); + let msg = cmd.toPacket(); + + equal(Command.Type, msg[0]); + equal(cmd.id, msg[1]); + equal(cmd.name, msg[2]); + equal(cmd.parameters, msg[3]); + + run_next_test(); +}); + +add_test(function test_Command_toString() { + let cmd = new Command(42, "foo", { bar: "baz" }); + equal(JSON.stringify(cmd.toPacket()), cmd.toString()); + + run_next_test(); +}); + +add_test(function test_Command_fromPacket() { + let c1 = new Command(42, "foo", { bar: "baz" }); + + let msg = c1.toPacket(); + let c2 = Command.fromPacket(msg); + + equal(c1.id, c2.id); + equal(c1.name, c2.name); + equal(c1.parameters, c2.parameters); + + Assert.throws( + () => Command.fromPacket([null, 2, "foo", {}]), + /InvalidArgumentError/ + ); + Assert.throws( + () => Command.fromPacket([1, 2, "foo", {}]), + /InvalidArgumentError/ + ); + Assert.throws( + () => Command.fromPacket([0, null, "foo", {}]), + /InvalidArgumentError/ + ); + Assert.throws( + () => Command.fromPacket([0, 2, null, {}]), + /InvalidArgumentError/ + ); + Assert.throws( + () => Command.fromPacket([0, 2, "foo", false]), + /InvalidArgumentError/ + ); + + let nullParams = Command.fromPacket([0, 2, "foo", null]); + equal( + "[object Object]", + Object.prototype.toString.call(nullParams.parameters) + ); + + run_next_test(); +}); + +add_test(function test_Command_Type() { + equal(0, Command.Type); + run_next_test(); +}); + +add_test(function test_Response_ctor() { + let handler = () => run_next_test(); + + let resp = new Response(42, handler); + equal(42, resp.id); + equal(null, resp.error); + ok("origin" in resp); + equal(Message.Origin.Server, resp.origin); + equal(false, resp.sent); + equal(handler, resp.respHandler_); + + run_next_test(); +}); + +add_test(function test_Response_sendConditionally() { + let fired = false; + let resp = new Response(42, () => (fired = true)); + resp.sendConditionally(() => false); + equal(false, resp.sent); + equal(false, fired); + resp.sendConditionally(() => true); + equal(true, resp.sent); + equal(true, fired); + + run_next_test(); +}); + +add_test(function test_Response_send() { + let fired = false; + let resp = new Response(42, () => (fired = true)); + resp.send(); + equal(true, resp.sent); + equal(true, fired); + + run_next_test(); +}); + +add_test(function test_Response_sendError_sent() { + let resp = new Response(42, r => equal(false, r.sent)); + resp.sendError(new error.WebDriverError()); + ok(resp.sent); + Assert.throws(() => resp.send(), /already been sent/); + + run_next_test(); +}); + +add_test(function test_Response_sendError_body() { + let resp = new Response(42, r => equal(null, r.body)); + resp.sendError(new error.WebDriverError()); + + run_next_test(); +}); + +add_test(function test_Response_sendError_errorSerialisation() { + let err1 = new error.WebDriverError(); + let resp1 = new Response(42); + resp1.sendError(err1); + equal(err1.status, resp1.error.error); + deepEqual(err1.toJSON(), resp1.error); + + let err2 = new error.InvalidArgumentError(); + let resp2 = new Response(43); + resp2.sendError(err2); + equal(err2.status, resp2.error.error); + deepEqual(err2.toJSON(), resp2.error); + + run_next_test(); +}); + +add_test(function test_Response_sendError_wrapInternalError() { + let err = new ReferenceError("foo"); + + // errors that originate from JavaScript (i.e. Marionette implementation + // issues) should be converted to UnknownError for transport + let resp = new Response(42, r => { + equal("unknown error", r.error.error); + equal(false, resp.sent); + }); + + // they should also throw after being sent + Assert.throws(() => resp.sendError(err), /foo/); + equal(true, resp.sent); + + run_next_test(); +}); + +add_test(function test_Response_toPacket() { + let resp = new Response(42, () => {}); + let msg = resp.toPacket(); + + equal(Response.Type, msg[0]); + equal(resp.id, msg[1]); + equal(resp.error, msg[2]); + equal(resp.body, msg[3]); + + run_next_test(); +}); + +add_test(function test_Response_toString() { + let resp = new Response(42, () => {}); + resp.error = "foo"; + resp.body = "bar"; + + equal(JSON.stringify(resp.toPacket()), resp.toString()); + + run_next_test(); +}); + +add_test(function test_Response_fromPacket() { + let r1 = new Response(42, () => {}); + r1.error = "foo"; + r1.body = "bar"; + + let msg = r1.toPacket(); + let r2 = Response.fromPacket(msg); + + equal(r1.id, r2.id); + equal(r1.error, r2.error); + equal(r1.body, r2.body); + + Assert.throws( + () => Response.fromPacket([null, 2, "foo", {}]), + /InvalidArgumentError/ + ); + Assert.throws( + () => Response.fromPacket([0, 2, "foo", {}]), + /InvalidArgumentError/ + ); + Assert.throws( + () => Response.fromPacket([1, null, "foo", {}]), + /InvalidArgumentError/ + ); + Assert.throws( + () => Response.fromPacket([1, 2, null, {}]), + /InvalidArgumentError/ + ); + Response.fromPacket([1, 2, "foo", null]); + + run_next_test(); +}); + +add_test(function test_Response_Type() { + equal(1, Response.Type); + run_next_test(); +}); diff --git a/testing/marionette/test/unit/test_modal.js b/testing/marionette/test/unit/test_modal.js new file mode 100644 index 0000000000..0a7c365af0 --- /dev/null +++ b/testing/marionette/test/unit/test_modal.js @@ -0,0 +1,148 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { modal } = ChromeUtils.import("chrome://marionette/content/modal.js"); + +const mockModalDialog = { + opener: { + ownerGlobal: "foo", + }, +}; + +const mockTabModalDialog = { + ownerGlobal: "foo", +}; + +add_test(function test_addCallback() { + let observer = new modal.DialogObserver(); + let cb1 = () => true; + let cb2 = () => false; + + equal(observer.callbacks.size, 0); + observer.add(cb1); + equal(observer.callbacks.size, 1); + observer.add(cb1); + equal(observer.callbacks.size, 1); + observer.add(cb2); + equal(observer.callbacks.size, 2); + + run_next_test(); +}); + +add_test(function test_removeCallback() { + let observer = new modal.DialogObserver(); + let cb1 = () => true; + let cb2 = () => false; + + equal(observer.callbacks.size, 0); + observer.add(cb1); + observer.add(cb2); + + equal(observer.callbacks.size, 2); + observer.remove(cb1); + equal(observer.callbacks.size, 1); + observer.remove(cb1); + equal(observer.callbacks.size, 1); + observer.remove(cb2); + equal(observer.callbacks.size, 0); + + run_next_test(); +}); + +add_test(function test_registerDialogClosedEventHandler() { + let observer = new modal.DialogObserver(); + let mockChromeWindow = { + addEventListener(event, cb) { + equal( + event, + "DOMModalDialogClosed", + "registered event for closing modal" + ); + equal(cb, observer, "set itself as handler"); + run_next_test(); + }, + }; + + observer.observe(mockChromeWindow, "toplevel-window-ready"); +}); + +add_test(function test_handleCallbackOpenModalDialog() { + let observer = new modal.DialogObserver(); + + observer.add((action, target, win) => { + equal(action, modal.ACTION_OPENED, "'opened' action has been passed"); + equal( + target.get(), + mockModalDialog, + "weak reference has been created for target" + ); + equal( + win, + mockModalDialog.opener.ownerGlobal, + "chrome window has been passed" + ); + run_next_test(); + }); + observer.observe(mockModalDialog, "common-dialog-loaded"); +}); + +add_test(function test_handleCallbackCloseModalDialog() { + let observer = new modal.DialogObserver(); + + observer.add((action, target, win) => { + equal(action, modal.ACTION_CLOSED, "'closed' action has been passed"); + equal( + target.get(), + mockModalDialog, + "weak reference has been created for target" + ); + equal( + win, + mockModalDialog.opener.ownerGlobal, + "chrome window has been passed" + ); + run_next_test(); + }); + observer.handleEvent({ + type: "DOMModalDialogClosed", + target: mockModalDialog, + }); +}); + +add_test(function test_handleCallbackOpenTabModalDialog() { + let observer = new modal.DialogObserver(); + + observer.add((action, target, win) => { + equal(action, modal.ACTION_OPENED, "'opened' action has been passed"); + equal( + target.get(), + mockTabModalDialog, + "weak reference has been created for target" + ); + equal(win, mockTabModalDialog.ownerGlobal, "chrome window has been passed"); + run_next_test(); + }); + observer.observe(mockTabModalDialog, "tabmodal-dialog-loaded"); +}); + +add_test(function test_handleCallbackCloseTabModalDialog() { + let observer = new modal.DialogObserver(); + + observer.add((action, target, win) => { + equal(action, modal.ACTION_CLOSED, "'closed' action has been passed"); + equal( + target.get(), + mockTabModalDialog, + "weak reference has been created for target" + ); + equal(win, mockTabModalDialog.ownerGlobal, "chrome window has been passed"); + run_next_test(); + }); + observer.handleEvent({ + type: "DOMModalDialogClosed", + target: mockTabModalDialog, + }); +}); diff --git a/testing/marionette/test/unit/test_navigate.js b/testing/marionette/test/unit/test_navigate.js new file mode 100644 index 0000000000..1298d9e14b --- /dev/null +++ b/testing/marionette/test/unit/test_navigate.js @@ -0,0 +1,88 @@ +/* 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 { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +XPCOMUtils.defineLazyGlobalGetters(this, ["URL"]); + +const { navigate } = ChromeUtils.import( + "chrome://marionette/content/navigate.js" +); + +const topContext = { + id: 7, + get top() { + return this; + }, +}; + +const nestedContext = { + id: 8, + parent: topContext, + top: topContext, +}; + +add_test(function test_isLoadEventExpectedForCurrent() { + Assert.throws( + () => navigate.isLoadEventExpected(undefined), + /Expected at least one URL/ + ); + + ok(navigate.isLoadEventExpected(new URL("http://a/"))); + + run_next_test(); +}); + +add_test(function test_isLoadEventExpectedForFuture() { + const data = [ + { current: "http://a/", future: undefined, expected: true }, + { current: "http://a/", future: "http://a/", expected: true }, + { current: "http://a/", future: "http://a/#", expected: true }, + { current: "http://a/#", future: "http://a/", expected: true }, + { current: "http://a/#a", future: "http://a/#A", expected: true }, + { current: "http://a/#a", future: "http://a/#a", expected: false }, + { current: "http://a/", future: "javascript:whatever", expected: false }, + ]; + + for (const entry of data) { + const current = new URL(entry.current); + const future = entry.future ? new URL(entry.future) : undefined; + equal(navigate.isLoadEventExpected(current, { future }), entry.expected); + } + + run_next_test(); +}); + +add_test(function test_isLoadEventExpectedForTarget() { + for (const target of ["_parent", "_top"]) { + Assert.throws( + () => navigate.isLoadEventExpected(new URL("http://a"), { target }), + /Expected browsingContext when target is _parent or _top/ + ); + } + + const data = [ + { cur: "http://a/", target: "", expected: true }, + { cur: "http://a/", target: "_blank", expected: false }, + { cur: "http://a/", target: "_parent", bc: topContext, expected: true }, + { cur: "http://a/", target: "_parent", bc: nestedContext, expected: false }, + { cur: "http://a/", target: "_self", expected: true }, + { cur: "http://a/", target: "_top", bc: topContext, expected: true }, + { cur: "http://a/", target: "_top", bc: nestedContext, expected: false }, + ]; + + for (const entry of data) { + const current = entry.cur ? new URL(entry.cur) : undefined; + equal( + navigate.isLoadEventExpected(current, { + target: entry.target, + browsingContext: entry.bc, + }), + entry.expected + ); + } + + run_next_test(); +}); diff --git a/testing/marionette/test/unit/test_prefs.js b/testing/marionette/test/unit/test_prefs.js new file mode 100644 index 0000000000..cd3f38a657 --- /dev/null +++ b/testing/marionette/test/unit/test_prefs.js @@ -0,0 +1,133 @@ +/* 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 { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +const { Log } = ChromeUtils.import("resource://gre/modules/Log.jsm"); + +XPCOMUtils.defineLazyServiceGetter( + this, + "env", + "@mozilla.org/process/environment;1", + "nsIEnvironment" +); + +const { Branch, EnvironmentPrefs, MarionettePrefs } = ChromeUtils.import( + "chrome://marionette/content/prefs.js", + null +); + +function reset() { + Services.prefs.setBoolPref("test.bool", false); + Services.prefs.setStringPref("test.string", "foo"); + Services.prefs.setIntPref("test.int", 777); +} + +// Give us something to work with: +reset(); + +add_test(function test_Branch_get_root() { + let root = new Branch(null); + equal(false, root.get("test.bool")); + equal("foo", root.get("test.string")); + equal(777, root.get("test.int")); + Assert.throws(() => root.get("doesnotexist"), /TypeError/); + + run_next_test(); +}); + +add_test(function test_Branch_get_branch() { + let test = new Branch("test."); + equal(false, test.get("bool")); + equal("foo", test.get("string")); + equal(777, test.get("int")); + Assert.throws(() => test.get("doesnotexist"), /TypeError/); + + run_next_test(); +}); + +add_test(function test_Branch_set_root() { + let root = new Branch(null); + + try { + root.set("test.string", "bar"); + root.set("test.in", 777); + root.set("test.bool", true); + + equal("bar", Services.prefs.getStringPref("test.string")); + equal(777, Services.prefs.getIntPref("test.int")); + equal(true, Services.prefs.getBoolPref("test.bool")); + } finally { + reset(); + } + + run_next_test(); +}); + +add_test(function test_Branch_set_branch() { + let test = new Branch("test."); + + try { + test.set("string", "bar"); + test.set("int", 888); + test.set("bool", true); + + equal("bar", Services.prefs.getStringPref("test.string")); + equal(888, Services.prefs.getIntPref("test.int")); + equal(true, Services.prefs.getBoolPref("test.bool")); + } finally { + reset(); + } + + run_next_test(); +}); + +add_test(function test_EnvironmentPrefs_from() { + let prefsTable = { + "test.bool": true, + "test.int": 888, + "test.string": "bar", + }; + env.set("FOO", JSON.stringify(prefsTable)); + + try { + for (let [key, value] of EnvironmentPrefs.from("FOO")) { + equal(prefsTable[key], value); + } + } finally { + env.set("FOO", null); + } + + run_next_test(); +}); + +add_test(function test_MarionettePrefs_getters() { + equal(false, MarionettePrefs.enabled); + equal(false, MarionettePrefs.clickToStart); + equal(false, MarionettePrefs.contentListener); + equal(2828, MarionettePrefs.port); + equal(Log.Level.Info, MarionettePrefs.logLevel); + equal(true, MarionettePrefs.recommendedPrefs); + + run_next_test(); +}); + +add_test(function test_MarionettePrefs_setters() { + try { + MarionettePrefs.contentListener = true; + MarionettePrefs.port = 777; + equal(true, MarionettePrefs.contentListener); + equal(777, MarionettePrefs.port); + } finally { + Services.prefs.clearUserPref("marionette.contentListener"); + Services.prefs.clearUserPref("marionette.port"); + } + + run_next_test(); +}); diff --git a/testing/marionette/test/unit/test_store.js b/testing/marionette/test/unit/test_store.js new file mode 100644 index 0000000000..81a51b577c --- /dev/null +++ b/testing/marionette/test/unit/test_store.js @@ -0,0 +1,220 @@ +const { element, ReferenceStore, WebElement } = ChromeUtils.import( + "chrome://marionette/content/element.js" +); + +const SVG_NS = "http://www.w3.org/2000/svg"; +const XHTML_NS = "http://www.w3.org/1999/xhtml"; +const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; + +class Element { + constructor(tagName, attrs = {}) { + this.tagName = tagName; + this.localName = tagName; + + // Set default properties + this.isConnected = true; + this.ownerDocument = {}; + this.ownerGlobal = { document: this.ownerDocument }; + + for (let attr in attrs) { + this[attr] = attrs[attr]; + } + } + + get nodeType() { + return 1; + } + get ELEMENT_NODE() { + return 1; + } +} + +class DOMElement extends Element { + constructor(tagName, attrs = {}) { + super(tagName, attrs); + this.namespaceURI = XHTML_NS; + this.ownerDocument = { documentElement: { namespaceURI: XHTML_NS } }; + } +} + +class SVGElement extends Element { + constructor(tagName, attrs = {}) { + super(tagName, attrs); + this.namespaceURI = SVG_NS; + this.ownerDocument = { documentElement: { namespaceURI: SVG_NS } }; + } +} + +class XULElement extends Element { + constructor(tagName, attrs = {}) { + super(tagName, attrs); + this.namespaceURI = XUL_NS; + this.ownerDocument = { documentElement: { namespaceURI: XUL_NS } }; + } +} + +function makeIterator(items) { + return function*() { + for (const i of items) { + yield i; + } + }; +} + +const nestedBrowsingContext = { + id: 7, + getAllBrowsingContextsInSubtree: makeIterator([ + { id: 7 }, + { id: 71 }, + { id: 72 }, + ]), +}; + +const domEl = new DOMElement("p"); +const svgEl = new SVGElement("rect"); +const xulEl = new XULElement("browser"); +const frameEl = new DOMElement("iframe"); +const innerEl = new DOMElement("p", { id: "inner" }); + +const domWebEl = WebElement.from(domEl); +const svgWebEl = WebElement.from(svgEl); +const xulWebEl = WebElement.from(xulEl); +const frameWebEl = WebElement.from(frameEl); +const innerWebEl = WebElement.from(innerEl); + +const domElId = { id: 1, browsingContextId: 4, webElRef: domWebEl.toJSON() }; +const svgElId = { id: 2, browsingContextId: 15, webElRef: svgWebEl.toJSON() }; +const xulElId = { id: 3, browsingContextId: 15, webElRef: xulWebEl.toJSON() }; +const frameElId = { + id: 10, + browsingContextId: 7, + webElRef: frameWebEl.toJSON(), +}; +const innerElId = { + id: 11, + browsingContextId: 72, + webElRef: innerWebEl.toJSON(), +}; + +const elementIdCache = new element.ReferenceStore(); + +registerCleanupFunction(() => { + elementIdCache.clear(); +}); + +add_test(function test_add_element() { + elementIdCache.add(domElId); + equal(elementIdCache.refs.size, 1); + equal(elementIdCache.domRefs.size, 1); + deepEqual(elementIdCache.refs.get(domWebEl.uuid), domElId); + deepEqual(elementIdCache.domRefs.get(domElId.id), domWebEl.toJSON()); + + elementIdCache.add(domElId); + equal(elementIdCache.refs.size, 1); + equal(elementIdCache.domRefs.size, 1); + + elementIdCache.add(xulElId); + equal(elementIdCache.refs.size, 2); + equal(elementIdCache.domRefs.size, 2); + + elementIdCache.clear(); + equal(elementIdCache.refs.size, 0); + equal(elementIdCache.domRefs.size, 0); + + run_next_test(); +}); + +add_test(function test_get_element() { + elementIdCache.add(domElId); + deepEqual(elementIdCache.get(domWebEl), domElId); + + run_next_test(); +}); + +add_test(function test_get_no_such_element() { + throws(() => elementIdCache.get(frameWebEl), /NoSuchElementError/); + + elementIdCache.add(domElId); + throws(() => elementIdCache.get(frameWebEl), /NoSuchElementError/); + + run_next_test(); +}); + +add_test(function test_clear_by_unknown_browsing_context() { + const unknownContext = { + id: 1000, + getAllBrowsingContextsInSubtree: makeIterator([{ id: 1000 }]), + }; + elementIdCache.add(domElId); + elementIdCache.add(svgElId); + elementIdCache.add(xulElId); + elementIdCache.add(frameElId); + elementIdCache.add(innerElId); + + equal(elementIdCache.refs.size, 5); + equal(elementIdCache.domRefs.size, 5); + + elementIdCache.clear(unknownContext); + + equal(elementIdCache.refs.size, 5); + equal(elementIdCache.domRefs.size, 5); + + run_next_test(); +}); + +add_test(function test_clear_by_known_browsing_context() { + const context = { + id: 15, + getAllBrowsingContextsInSubtree: makeIterator([{ id: 15 }]), + }; + const anotherContext = { + id: 4, + getAllBrowsingContextsInSubtree: makeIterator([{ id: 4 }]), + }; + elementIdCache.add(domElId); + elementIdCache.add(svgElId); + elementIdCache.add(xulElId); + elementIdCache.add(frameElId); + elementIdCache.add(innerElId); + + equal(elementIdCache.refs.size, 5); + equal(elementIdCache.domRefs.size, 5); + + elementIdCache.clear(context); + + equal(elementIdCache.refs.size, 3); + equal(elementIdCache.domRefs.size, 3); + ok(elementIdCache.has(domWebEl)); + ok(!elementIdCache.has(svgWebEl)); + ok(!elementIdCache.has(xulWebEl)); + + elementIdCache.clear(anotherContext); + + equal(elementIdCache.refs.size, 2); + equal(elementIdCache.domRefs.size, 2); + ok(!elementIdCache.has(domWebEl)); + + run_next_test(); +}); + +add_test(function test_clear_by_nested_browsing_context() { + elementIdCache.add(domElId); + elementIdCache.add(svgElId); + elementIdCache.add(xulElId); + elementIdCache.add(frameElId); + elementIdCache.add(innerElId); + + equal(elementIdCache.refs.size, 5); + equal(elementIdCache.domRefs.size, 5); + + elementIdCache.clear(nestedBrowsingContext); + + equal(elementIdCache.refs.size, 3); + equal(elementIdCache.domRefs.size, 3); + + ok(elementIdCache.has(domWebEl)); + ok(!elementIdCache.has(frameWebEl)); + ok(!elementIdCache.has(innerWebEl)); + + run_next_test(); +}); diff --git a/testing/marionette/test/unit/test_sync.js b/testing/marionette/test/unit/test_sync.js new file mode 100644 index 0000000000..4120cafe91 --- /dev/null +++ b/testing/marionette/test/unit/test_sync.js @@ -0,0 +1,521 @@ +/* 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 { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +const { + DebounceCallback, + IdlePromise, + PollPromise, + Sleep, + TimedPromise, + waitForEvent, + waitForLoadEvent, + waitForMessage, + waitForObserverTopic, +} = ChromeUtils.import("chrome://marionette/content/sync.js"); + +const { EventDispatcher } = ChromeUtils.import( + "chrome://marionette/content/actors/MarionetteEventsParent.jsm" +); + +/** + * Mimic a DOM node for listening for events. + */ +class MockElement { + constructor() { + this.capture = false; + this.func = null; + this.eventName = null; + this.untrusted = false; + } + + addEventListener(name, func, capture, untrusted) { + this.eventName = name; + this.func = func; + if (capture != null) { + this.capture = capture; + } + if (untrusted != null) { + this.untrusted = untrusted; + } + } + + click() { + if (this.func) { + let details = { + capture: this.capture, + target: this, + type: this.eventName, + untrusted: this.untrusted, + }; + this.func(details); + } + } + + removeEventListener(name, func) { + this.capture = false; + this.func = null; + this.eventName = null; + this.untrusted = false; + } +} + +/** + * Mimic a message manager for sending messages. + */ +class MessageManager { + constructor() { + this.func = null; + this.message = null; + } + + addMessageListener(message, func) { + this.func = func; + this.message = message; + } + + removeMessageListener(message) { + this.func = null; + this.message = null; + } + + send(message, data) { + if (this.func) { + this.func({ + data, + message, + target: this, + }); + } + } +} + +/** + * Mimics nsITimer, but instead of using a system clock you can + * preprogram it to invoke the callback after a given number of ticks. + */ +class MockTimer { + constructor(ticksBeforeFiring) { + this.goal = ticksBeforeFiring; + this.ticks = 0; + this.cancelled = false; + } + + initWithCallback(cb, timeout, type) { + this.ticks++; + if (this.ticks >= this.goal) { + cb(); + } + } + + cancel() { + this.cancelled = true; + } +} + +add_test(function test_executeSoon_callback() { + // executeSoon() is already defined for xpcshell in head.js. As such import + // our implementation into a custom namespace. + let sync = {}; + ChromeUtils.import("chrome://marionette/content/sync.js", sync); + + for (let func of ["foo", null, true, [], {}]) { + Assert.throws(() => sync.executeSoon(func), /TypeError/); + } + + let a; + sync.executeSoon(() => { + a = 1; + }); + executeSoon(() => equal(1, a)); + + run_next_test(); +}); + +add_test(function test_PollPromise_funcTypes() { + for (let type of ["foo", 42, null, undefined, true, [], {}]) { + Assert.throws(() => new PollPromise(type), /TypeError/); + } + new PollPromise(() => {}); + new PollPromise(function() {}); + + run_next_test(); +}); + +add_test(function test_PollPromise_timeoutTypes() { + for (let timeout of ["foo", true, [], {}]) { + Assert.throws(() => new PollPromise(() => {}, { timeout }), /TypeError/); + } + for (let timeout of [1.2, -1]) { + Assert.throws(() => new PollPromise(() => {}, { timeout }), /RangeError/); + } + for (let timeout of [null, undefined, 42]) { + new PollPromise(resolve => resolve(1), { timeout }); + } + + run_next_test(); +}); + +add_test(function test_PollPromise_intervalTypes() { + for (let interval of ["foo", null, true, [], {}]) { + Assert.throws(() => new PollPromise(() => {}, { interval }), /TypeError/); + } + for (let interval of [1.2, -1]) { + Assert.throws(() => new PollPromise(() => {}, { interval }), /RangeError/); + } + new PollPromise(() => {}, { interval: 42 }); + + run_next_test(); +}); + +add_task(async function test_PollPromise_retvalTypes() { + for (let typ of [true, false, "foo", 42, [], {}]) { + strictEqual(typ, await new PollPromise(resolve => resolve(typ))); + } +}); + +add_task(async function test_PollPromise_rethrowError() { + let nevals = 0; + let err; + try { + await PollPromise(() => { + ++nevals; + throw new Error(); + }); + } catch (e) { + err = e; + } + equal(1, nevals); + ok(err instanceof Error); +}); + +add_task(async function test_PollPromise_noTimeout() { + let nevals = 0; + await new PollPromise((resolve, reject) => { + ++nevals; + nevals < 100 ? reject() : resolve(); + }); + equal(100, nevals); +}); + +add_task(async function test_PollPromise_zeroTimeout() { + // run at least once when timeout is 0 + let nevals = 0; + let start = new Date().getTime(); + await new PollPromise( + (resolve, reject) => { + ++nevals; + reject(); + }, + { timeout: 0 } + ); + let end = new Date().getTime(); + equal(1, nevals); + less(end - start, 500); +}); + +add_task(async function test_PollPromise_timeoutElapse() { + let nevals = 0; + let start = new Date().getTime(); + await new PollPromise( + (resolve, reject) => { + ++nevals; + reject(); + }, + { timeout: 100 } + ); + let end = new Date().getTime(); + lessOrEqual(nevals, 11); + greaterOrEqual(end - start, 100); +}); + +add_task(async function test_PollPromise_interval() { + let nevals = 0; + await new PollPromise( + (resolve, reject) => { + ++nevals; + reject(); + }, + { timeout: 100, interval: 100 } + ); + equal(2, nevals); +}); + +add_test(function test_TimedPromise_funcTypes() { + for (let type of ["foo", 42, null, undefined, true, [], {}]) { + Assert.throws(() => new TimedPromise(type), /TypeError/); + } + new TimedPromise(resolve => resolve()); + new TimedPromise(function(resolve) { + resolve(); + }); + + run_next_test(); +}); + +add_test(function test_TimedPromise_timeoutTypes() { + for (let timeout of ["foo", null, true, [], {}]) { + Assert.throws( + () => new TimedPromise(resolve => resolve(), { timeout }), + /TypeError/ + ); + } + for (let timeout of [1.2, -1]) { + Assert.throws( + () => new TimedPromise(resolve => resolve(), { timeout }), + /RangeError/ + ); + } + new TimedPromise(resolve => resolve(), { timeout: 42 }); + + run_next_test(); +}); + +add_task(async function test_Sleep() { + await Sleep(0); + for (let type of ["foo", true, null, undefined]) { + Assert.throws(() => new Sleep(type), /TypeError/); + } + Assert.throws(() => new Sleep(1.2), /RangeError/); + Assert.throws(() => new Sleep(-1), /RangeError/); +}); + +add_task(async function test_IdlePromise() { + let called = false; + let win = { + requestAnimationFrame(callback) { + called = true; + callback(); + }, + }; + await IdlePromise(win); + ok(called); +}); + +add_task(async function test_IdlePromiseAbortWhenWindowClosed() { + let win = { + closed: true, + requestAnimationFrame() {}, + }; + await IdlePromise(win); +}); + +add_test(function test_DebounceCallback_constructor() { + for (let cb of [42, "foo", true, null, undefined, [], {}]) { + Assert.throws(() => new DebounceCallback(cb), /TypeError/); + } + for (let timeout of ["foo", true, [], {}, () => {}]) { + Assert.throws( + () => new DebounceCallback(() => {}, { timeout }), + /TypeError/ + ); + } + for (let timeout of [-1, 2.3, NaN]) { + Assert.throws( + () => new DebounceCallback(() => {}, { timeout }), + /RangeError/ + ); + } + + run_next_test(); +}); + +add_task(async function test_DebounceCallback_repeatedCallback() { + let uniqueEvent = {}; + let ncalls = 0; + + let cb = ev => { + ncalls++; + equal(ev, uniqueEvent); + }; + let debouncer = new DebounceCallback(cb); + debouncer.timer = new MockTimer(3); + + // flood the debouncer with events, + // we only expect the last one to fire + debouncer.handleEvent(uniqueEvent); + debouncer.handleEvent(uniqueEvent); + debouncer.handleEvent(uniqueEvent); + + equal(ncalls, 1); + ok(debouncer.timer.cancelled); +}); + +add_task(async function test_waitForEvent_subjectAndEventNameTypes() { + let element = new MockElement(); + + for (let subject of ["foo", 42, null, undefined, true, [], {}]) { + Assert.throws(() => waitForEvent(subject, "click"), /TypeError/); + } + + for (let eventName of [42, null, undefined, true, [], {}]) { + Assert.throws(() => waitForEvent(element, eventName), /TypeError/); + } + + let clicked = waitForEvent(element, "click"); + element.click(); + let event = await clicked; + equal(element, event.target); +}); + +add_task(async function test_waitForEvent_captureTypes() { + let element = new MockElement(); + + for (let capture of ["foo", 42, [], {}]) { + Assert.throws( + () => waitForEvent(element, "click", { capture }), + /TypeError/ + ); + } + + for (let capture of [null, undefined, false, true]) { + let expected_capture = capture == null ? false : capture; + + element = new MockElement(); + let clicked = waitForEvent(element, "click", { capture }); + element.click(); + let event = await clicked; + equal(element, event.target); + equal(expected_capture, event.capture); + } +}); + +add_task(async function test_waitForEvent_checkFnTypes() { + let element = new MockElement(); + + for (let checkFn of ["foo", 42, true, [], {}]) { + Assert.throws( + () => waitForEvent(element, "click", { checkFn }), + /TypeError/ + ); + } + + let count; + for (let checkFn of [null, undefined, event => count++ > 0]) { + let expected_count = checkFn == null ? 0 : 2; + count = 0; + + element = new MockElement(); + let clicked = waitForEvent(element, "click", { checkFn }); + element.click(); + element.click(); + let event = await clicked; + equal(element, event.target); + equal(expected_count, count); + } +}); + +add_task(async function test_waitForEvent_wantsUntrustedTypes() { + let element = new MockElement(); + + for (let wantsUntrusted of ["foo", 42, [], {}]) { + Assert.throws( + () => waitForEvent(element, "click", { wantsUntrusted }), + /TypeError/ + ); + } + + for (let wantsUntrusted of [null, undefined, false, true]) { + let expected_untrusted = wantsUntrusted == null ? false : wantsUntrusted; + + element = new MockElement(); + let clicked = waitForEvent(element, "click", { wantsUntrusted }); + element.click(); + let event = await clicked; + equal(element, event.target); + equal(expected_untrusted, event.untrusted); + } +}); + +add_task(async function test_waitForLoadEvent() { + const mockBrowsingContext = {}; + const onLoad = waitForLoadEvent("pageshow", () => mockBrowsingContext); + + // Fake a page load by emitting the expected event on the EventDispatcher. + EventDispatcher.emit("page-load", { + type: "pageshow", + browsingContext: mockBrowsingContext, + }); + + const loadEvent = await onLoad; + equal(loadEvent.type, "pageshow"); + equal(loadEvent.browsingContext, mockBrowsingContext); +}); + +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(() => waitForEvent(messageManager, message), /TypeError/); + } + + let data = { foo: "bar" }; + let sent = waitForMessage(messageManager, "message"); + messageManager.send("message", data); + equal(data, await sent); +}); + +add_task(async function test_waitForMessage_checkFnTypes() { + let messageManager = new MessageManager(); + + for (let checkFn of ["foo", 42, true, [], {}]) { + Assert.throws( + () => waitForMessage(messageManager, "message", { checkFn }), + /TypeError/ + ); + } + + let data1 = { fo: "bar" }; + let data2 = { foo: "bar" }; + + for (let checkFn of [null, undefined, msg => "foo" in msg.data]) { + let expected_data = checkFn == null ? data1 : data2; + + messageManager = new MessageManager(); + let sent = waitForMessage(messageManager, "message", { checkFn }); + messageManager.send("message", data1); + messageManager.send("message", data2); + equal(expected_data, await sent); + } +}); + +add_task(async function test_waitForObserverTopic_topicTypes() { + for (let topic of [42, null, undefined, true, [], {}]) { + Assert.throws(() => waitForObserverTopic(topic), /TypeError/); + } + + let data = { foo: "bar" }; + let sent = waitForObserverTopic("message"); + Services.obs.notifyObservers(this, "message", data); + let result = await sent; + equal(this, result.subject); + equal(data, result.data); +}); + +add_task(async function test_waitForObserverTopic_checkFnTypes() { + for (let checkFn of ["foo", 42, true, [], {}]) { + Assert.throws( + () => waitForObserverTopic("message", { checkFn }), + /TypeError/ + ); + } + + let data1 = { fo: "bar" }; + let data2 = { foo: "bar" }; + + for (let checkFn of [null, undefined, (subject, data) => data == data2]) { + let expected_data = checkFn == null ? data1 : data2; + + let sent = waitForObserverTopic("message"); + Services.obs.notifyObservers(this, "message", data1); + Services.obs.notifyObservers(this, "message", data2); + let result = await sent; + equal(expected_data, result.data); + } +}); diff --git a/testing/marionette/test/unit/xpcshell.ini b/testing/marionette/test/unit/xpcshell.ini new file mode 100644 index 0000000000..d804ec6d39 --- /dev/null +++ b/testing/marionette/test/unit/xpcshell.ini @@ -0,0 +1,24 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +[DEFAULT] +skip-if = appname == "thunderbird" + +[test_action.js] +[test_actors.js] +[test_assert.js] +[test_browser.js] +[test_capabilities.js] +[test_cookie.js] +[test_dom.js] +[test_element.js] +[test_error.js] +[test_evaluate.js] +[test_format.js] +[test_message.js] +[test_modal.js] +[test_navigate.js] +[test_prefs.js] +[test_store.js] +[test_sync.js] diff --git a/testing/marionette/transport.js b/testing/marionette/transport.js new file mode 100644 index 0000000000..16e87bad79 --- /dev/null +++ b/testing/marionette/transport.js @@ -0,0 +1,537 @@ +/* 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 EXPORTED_SYMBOLS = ["DebuggerTransport"]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + EventEmitter: "resource://gre/modules/EventEmitter.jsm", + + BulkPacket: "chrome://marionette/content/packets.js", + executeSoon: "chrome://marionette/content/sync.js", + JSONPacket: "chrome://marionette/content/packets.js", + Packet: "chrome://marionette/content/packets.js", + StreamUtils: "chrome://marionette/content/stream-utils.js", +}); + +XPCOMUtils.defineLazyGetter(this, "Pipe", () => { + return Components.Constructor("@mozilla.org/pipe;1", "nsIPipe", "init"); +}); + +XPCOMUtils.defineLazyGetter(this, "ScriptableInputStream", () => { + return Components.Constructor( + "@mozilla.org/scriptableinputstream;1", + "nsIScriptableInputStream", + "init" + ); +}); + +const flags = { wantVerbose: false, wantLogging: false }; + +const dumpv = flags.wantVerbose + ? function(msg) { + dump(msg + "\n"); + } + : function() {}; + +const PACKET_HEADER_MAX = 200; + +/** + * An adapter that handles data transfers between the debugger client + * and server. It can work with both nsIPipe and nsIServerSocket + * transports so long as the properly created input and output streams + * are specified. (However, for intra-process connections, + * LocalDebuggerTransport, below, is more efficient than using an nsIPipe + * pair with DebuggerTransport.) + * + * @param {nsIAsyncInputStream} input + * The input stream. + * @param {nsIAsyncOutputStream} output + * The output stream. + * + * Given a DebuggerTransport instance dt: + * 1) Set dt.hooks to a packet handler object (described below). + * 2) Call dt.ready() to begin watching for input packets. + * 3) Call dt.send() / dt.startBulkSend() to send packets. + * 4) Call dt.close() to close the connection, and disengage from + * the event loop. + * + * A packet handler is an object with the following methods: + * + * - onPacket(packet) - called when we have received a complete packet. + * |packet| is the parsed form of the packet --- a JavaScript value, not + * a JSON-syntax string. + * + * - onBulkPacket(packet) - called when we have switched to bulk packet + * receiving mode. |packet| is an object containing: + * * actor: Name of actor that will receive the packet + * * type: Name of actor's method that should be called on receipt + * * length: Size of the data to be read + * * stream: This input stream should only be used directly if you + * can ensure that you will read exactly |length| bytes and + * will not close the stream when reading is complete + * * done: If you use the stream directly (instead of |copyTo| + * below), you must signal completion by resolving/rejecting + * this deferred. If it's rejected, the transport will + * be closed. If an Error is supplied as a rejection value, + * it will be logged via |dump|. If you do use |copyTo|, + * resolving is taken care of for you when copying completes. + * * copyTo: A helper function for getting your data out of the + * stream that meets the stream handling requirements above, + * and has the following signature: + * + * @param nsIAsyncOutputStream {output} + * The stream to copy to. + * + * @return {Promise} + * The promise is resolved when copying completes or + * rejected if any (unexpected) errors occur. This object + * also emits "progress" events for each chunk that is + * copied. See stream-utils.js. + * + * - onClosed(reason) - called when the connection is closed. |reason| + * is an optional nsresult or object, typically passed when the + * transport is closed due to some error in a underlying stream. + * + * See ./packets.js and the Remote Debugging Protocol specification for + * more details on the format of these packets. + * + * @class + */ +function DebuggerTransport(input, output) { + EventEmitter.decorate(this); + + this._input = input; + this._scriptableInput = new 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 JSONPacket(this); + packet.object = object; + this._outgoing.push(packet); + this._flushOutgoing(); + }, + + /** + * Transmit streaming data via a bulk packet. + * + * This method initiates the bulk send process by queuing up the header + * data. The caller receives eventual access to a stream for writing. + * + * N.B.: Do *not* attempt to close the stream handed to you, as it + * will continue to be used by this transport afterwards. Most users + * should instead use the provided |copyFrom| function instead. + * + * @param {Object} header + * This is modeled after the format of JSON packets above, but does + * not actually contain the data, but is instead just a routing + * header: + * + * - actor: Name of actor that will receive the packet + * - type: Name of actor's method that should be called on receipt + * - length: Size of the data to be sent + * + * @return {Promise} + * The promise will be resolved when you are allowed to write to + * the stream with an object containing: + * + * - stream: This output stream should only be used directly + * if you can ensure that you will write exactly + * |length| bytes and will not close the stream when + * writing is complete. + * - done: If you use the stream directly (instead of + * |copyFrom| below), you must signal completion by + * resolving/rejecting this deferred. If it's + * rejected, the transport will be closed. If an + * Error is supplied as a rejection value, it will + * be logged via |dump|. If you do use |copyFrom|, + * resolving is taken care of for you when copying + * completes. + * - copyFrom: A helper function for getting your data onto the + * stream that meets the stream handling requirements + * above, and has the following signature: + * + * @param {nsIAsyncInputStream} input + * The stream to copy from. + * + * @return {Promise} + * The promise is resolved when copying completes + * or rejected if any (unexpected) errors occur. + * This object also emits "progress" events for + * each chunkthat is copied. See stream-utils.js. + */ + startBulkSend(header) { + this.emit("startbulksend", header); + + let packet = new 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 > 0) { + let threadManager = Cc["@mozilla.org/thread-manager;1"].getService(); + this._output.asyncWait(this, 0, 0, threadManager.currentThread); + } + }, + + /** + * Pause this transport's attempts to write to the output stream. + * This is used when we've temporarily handed off our output stream for + * writing bulk data. + */ + pauseOutgoing() { + this._outgoingEnabled = false; + }, + + /** + * Resume this transport's attempts to write to the output stream. + */ + resumeOutgoing() { + this._outgoingEnabled = true; + this._flushOutgoing(); + }, + + // nsIOutputStreamCallback + /** + * This is called when the output stream is ready for more data to + * be written. The current outgoing packet will attempt to write some + * amount of data, but may not complete. + */ + onOutputStreamReady(stream) { + if (!this._outgoingEnabled || this._outgoing.length === 0) { + return; + } + + try { + this._currentOutgoing.write(stream); + } catch (e) { + if (e.result != Cr.NS_BASE_STREAM_WOULD_BLOCK) { + this.close(e.result); + return; + } + throw e; + } + + this._flushOutgoing(); + }, + + /** + * Remove the current outgoing packet from the queue upon completion. + */ + _finishCurrentOutgoing() { + if (this._currentOutgoing) { + this._currentOutgoing.destroy(); + this._outgoing.shift(); + } + }, + + /** + * Clear the entire outgoing queue. + */ + _destroyAllOutgoing() { + for (let packet of this._outgoing) { + packet.destroy(); + } + this._outgoing = []; + }, + + /** + * Initialize the input stream for reading. Once this method has been + * called, we watch for packets on the input stream, and pass them to + * the appropriate handlers via this.hooks. + */ + ready() { + this.active = true; + this._waitForIncoming(); + }, + + /** + * Asks the input stream to notify us (via onInputStreamReady) when it is + * ready for reading. + */ + _waitForIncoming() { + if (this._incomingEnabled) { + let threadManager = Cc["@mozilla.org/thread-manager;1"].getService(); + this._input.asyncWait(this, 0, 0, threadManager.currentThread); + } + }, + + /** + * Pause this transport's attempts to read from the input stream. + * This is used when we've temporarily handed off our input stream for + * reading bulk data. + */ + pauseIncoming() { + this._incomingEnabled = false; + }, + + /** + * Resume this transport's attempts to read from the input stream. + */ + resumeIncoming() { + this._incomingEnabled = true; + this._flushIncoming(); + this._waitForIncoming(); + }, + + // nsIInputStreamCallback + /** + * Called when the stream is either readable or closed. + */ + onInputStreamReady(stream) { + try { + while ( + stream.available() && + this._incomingEnabled && + this._processIncoming(stream, stream.available()) + ) { + // Loop until there is nothing more to process + } + this._waitForIncoming(); + } catch (e) { + if (e.result != Cr.NS_BASE_STREAM_WOULD_BLOCK) { + this.close(e.result); + } else { + throw e; + } + } + }, + + /** + * Process the incoming data. Will create a new currently incoming + * Packet if needed. Tells the incoming Packet to read as much data + * as it can, but reading may not complete. The Packet signals that + * its data is ready for delivery by calling one of this transport's + * _on*Ready methods (see ./packets.js and the _on*Ready methods below). + * + * @return {boolean} + * Whether incoming stream processing should continue for any + * remaining data. + */ + _processIncoming(stream, count) { + dumpv("Data available: " + count); + + if (!count) { + dumpv("Nothing to read, skipping"); + return false; + } + + try { + if (!this._incoming) { + dumpv("Creating a new packet from incoming"); + + if (!this._readHeader(stream)) { + // Not enough data to read packet type + return false; + } + + // Attempt to create a new Packet by trying to parse each possible + // header pattern. + this._incoming = Packet.fromHeader(this._incomingHeader, this); + if (!this._incoming) { + throw new Error( + "No packet types for header: " + this._incomingHeader + ); + } + } + + if (!this._incoming.done) { + // We have an incomplete packet, keep reading it. + dumpv("Existing packet incomplete, keep reading"); + this._incoming.read(stream, this._scriptableInput); + } + } catch (e) { + dump(`Error reading incoming packet: (${e} - ${e.stack})\n`); + + // Now in an invalid state, shut down the transport. + this.close(); + return false; + } + + if (!this._incoming.done) { + // Still not complete, we'll wait for more data. + dumpv("Packet not done, wait for more"); + return true; + } + + // Ready for next packet + this._flushIncoming(); + return true; + }, + + /** + * Read as far as we can into the incoming data, attempting to build + * up a complete packet header (which terminates with ":"). We'll only + * read up to PACKET_HEADER_MAX characters. + * + * @return {boolean} + * True if we now have a complete header. + */ + _readHeader() { + let amountToRead = PACKET_HEADER_MAX - this._incomingHeader.length; + this._incomingHeader += 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) { + 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) { + 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/testing/marionette/wm.js b/testing/marionette/wm.js new file mode 100644 index 0000000000..7389099e13 --- /dev/null +++ b/testing/marionette/wm.js @@ -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/. */ + +"use strict"; + +const EXPORTED_SYMBOLS = []; |