/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.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 {classes: Cc, interfaces: Ci, utils: Cu} = Components; Cu.import("chrome://marionette/content/assert.js"); Cu.import("chrome://marionette/content/atom.js"); const { InvalidArgumentError, InvalidSelectorError, NoSuchElementError, StaleElementReferenceError, } = Cu.import("chrome://marionette/content/error.js", {}); const {pprint} = Cu.import("chrome://marionette/content/format.js", {}); const {PollPromise} = Cu.import("chrome://marionette/content/sync.js", {}); this.EXPORTED_SYMBOLS = [ "ChromeWebElement", "ContentWebElement", "ContentWebFrame", "ContentWebWindow", "element", "WebElement", ]; const { FIRST_ORDERED_NODE_TYPE, ORDERED_NODE_ITERATOR_TYPE, } = Ci.nsIDOMXPathResult; const ELEMENT_NODE = 1; const DOCUMENT_NODE = 9; const UNEDITABLE_INPUTS = new Set([ "checkbox", "radio", "hidden", "submit", "button", "image", ]); const XBLNS = "http://www.mozilla.org/xbl"; const XULNS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; /** XUL elements that support checked property. */ const XUL_CHECKED_ELS = new Set([ "button", "checkbox", "listitem", "toolbarbutton", ]); /** XUL elements that support selected property. */ const XUL_SELECTED_ELS = new Set([ "listitem", "menu", "menuitem", "menuseparator", "radio", "richlistitem", "tab", ]); const uuidGen = Cc["@mozilla.org/uuid-generator;1"] .getService(Ci.nsIUUIDGenerator); /** * 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", Anon: "anon", AnonAttribute: "anon attribute", }; /** * 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 oder 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.} * List of the web element references associated with each element * from els. */ 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 el 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 = isXULElement ? "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 webEl 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} window * Current browsing context, which may differ from the associate * browsing context of el. * * @returns {(Element|XULElement)} * Element associated with reference. * * @throws {TypeError} * If webEl is not a {@link WebElement}. * @throws {NoSuchElementError} * If the web element reference uuid 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, window) { if (!(webEl instanceof WebElement)) { throw new TypeError( pprint`Expected web element, got: ${webEl}`); } if (!this.has(webEl)) { throw new 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, window)) { throw new 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; } }; /** * 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 timeout 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 strategy. * * Available flags for opts: * *
*
all *
* If true, a multi-element search selector is used and a sequence * of elements will be returned. Otherwise a single element. * *
timeout *
* Duration to wait before timing out the search. If all * is false, a {@link NoSuchElementError} is thrown if unable to * find the element within the timeout duration. * *
startNode *
Element to use as the root of the search. * * @param {Object.} container * Window object and an optional shadow root that contains the * root shadow DOM element. * @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 strategy. * @param {Object.} opts * Options. * * @return {Promise.<(Element|Array.)>} * Single element or a sequence of elements. * * @throws InvalidSelectorError * If strategy is unknown. * @throws InvalidSelectorError * If selector 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; switch (strategy) { case element.Strategy.AnonAttribute: msg = "Unable to locate anonymous element: " + JSON.stringify(selector); break; default: msg = "Unable to locate element: " + selector; } reject(new NoSuchElementError(msg)); } if (opts.all) { resolve(foundEls); } resolve(foundEls[0]); }, reject); }); }; function find_(container, strategy, selector, searchFn, {startNode = null, all = false} = {}) { let rootNode = container.shadowRoot || container.frame.document; if (!startNode) { switch (strategy) { // For anonymous nodes the start node needs to be of type // DOMElement, which will refer to :root in case of a DOMDocument. case element.Strategy.Anon: case element.Strategy.AnonAttribute: if (rootNode instanceof Ci.nsIDOMDocument) { startNode = rootNode.documentElement; } break; default: startNode = rootNode; } } let res; try { res = searchFn(strategy, selector, rootNode, startNode); } catch (e) { throw new 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 expression. */ 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.} * Iterator over elements matching expression. */ 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 startNode which * link text is linkText. * * @param {Element} startNode * Where in the DOM hierarchy to begin searching. * @param {string} linkText * Link text to search for. * * @return {Iterable.} * Sequence of link elements which text is s. */ element.findByLinkText = function(startNode, linkText) { return filterLinks(startNode, link => link.text.trim() === linkText); }; /** * Find all hyperlinks descendant of startNode which * link text contains linkText. * * @param {Element} startNode * Where in the DOM hierachy to begin searching. * @param {string} linkText * Link text to search for. * * @return {Iterable.} * Iterator of link elements which text containins * linkText. */ element.findByPartialLinkText = function(startNode, linkText) { return filterLinks(startNode, link => link.text.includes(linkText)); }; /** * Find anonymous nodes of node. * * @param {XULDocument} document * Root node of the document. * @param {XULElement} node * Where in the DOM hierarchy to begin searching. * * @return {Iterable.} * Iterator over anonymous elements. */ element.findAnonymousNodes = function* (document, node) { let anons = document.getAnonymousNodes(node) || []; for (let node of anons) { yield node; } }; /** * Filters all hyperlinks that are descendant of startNode * by predicate. * * @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.} * Iterator of link elements matching predicate. */ 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 using is not recognised. * @throws {Error} * If selector expression selector 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 (link.text.trim() === selector) { return link; } } return undefined; case element.Strategy.PartialLinkText: for (let link of startNode.getElementsByTagName("a")) { if (link.text.includes(selector)) { return link; } } return undefined; case element.Strategy.Selector: try { return startNode.querySelector(selector); } catch (e) { throw new InvalidSelectorError(`${e.message}: "${selector}"`); } case element.Strategy.Anon: return element.findAnonymousNodes(document, startNode).next().value; case element.Strategy.AnonAttribute: let attr = Object.keys(selector)[0]; return document.getAnonymousElementByAttribute( startNode, attr, selector[attr]); } throw new 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.} * Found elements. * * @throws {InvalidSelectorError} * If strategy strategy is not recognised. * @throws {Error} * If selector expression selector 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); case element.Strategy.Anon: return [...element.findAnonymousNodes(document, startNode)]; case element.Strategy.AnonAttribute: let attr = Object.keys(selector)[0]; let el = document.getAnonymousElementByAttribute( startNode, attr, selector[attr]); if (el) { return [el]; } return []; default: throw new InvalidSelectorError(`No such strategy: ${strategy}`); } } /** * Finds the closest parent node of startNode by CSS a * selector expression. * * @param {Node} startNode * Cyce through startNode's parent nodes in tree-order * and return the first match to selector. * @param {string} selector * CSS selector expression. * * @return {Node=} * First match to selector, 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; }; /** * Determines if obj is an HTML or JS collection. * * @param {*} seq * Type to determine. * * @return {boolean} * True if seq 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 el 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 * window, is a WebDriver concept defining the target * against which commands will run. As the current browsing context * may differ from el's associated context, an element is * considered stale even if it is connected to a living (not discarded) * browsing context such as an <iframe>. * * @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=} window * Current browsing context, which may differ from the associate * browsing context of el. When retrieving XUL * elements, this is optional. * * @return {boolean} * True if el is stale, false otherwise. */ element.isStale = function(el, window = undefined) { if (typeof window == "undefined") { window = el.ownerGlobal; } if (el === null || !el.ownerGlobal || el.ownerDocument !== window.document) { return true; } return !el.isConnected; }; /** * Determine if el is selected or not. * * This operation only makes sense on * <input type=checkbox>, * <input type=radio>, * and >option> 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 * <input> or <textarea> * element whose readOnly 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 * disabled 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; } }; /** * An editing host is a node that is either an HTML element with a * contenteditable attribute, or the HTML element child * of a document whose designMode 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: * *
    *
  • It is a <textarea> element. * *
  • It is an <input> element that is not of * the checkbox, radio, hidden, * submit, button, or image types. * *
  • It is content-editable. * *
  • It belongs to a document in design mode. *
* * @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 (el.localName == "input" && !UNEDITABLE_INPUTS.has(el.type)) || el.localName == "textarea" || 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.} * X- and Y coordinates. * * @throws TypeError * If xOffset or yOffset 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 el 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 <option> element in a * valid * element context, meaning that it has an ancestral element * that is either <datalist> or <select>. * * 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 el. */ element.getContainer = function(el) { // Does have a valid context, // meaning is it a child of or