diff options
Diffstat (limited to 'testing/marionette/element.js')
-rw-r--r-- | testing/marionette/element.js | 1840 |
1 files changed, 1840 insertions, 0 deletions
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; |