/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this file, * You can obtain one at http://mozilla.org/MPL/2.0/. */ const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { atom: "chrome://remote/content/marionette/atom.sys.mjs", error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs", PollPromise: "chrome://remote/content/marionette/sync.sys.mjs", }); const ORDERED_NODE_ITERATOR_TYPE = 5; const FIRST_ORDERED_NODE_TYPE = 9; const DOCUMENT_FRAGMENT_NODE = 11; const ELEMENT_NODE = 1; const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; /** XUL elements that support checked property. */ const XUL_CHECKED_ELS = new Set(["button", "checkbox", "toolbarbutton"]); /** XUL elements that support selected property. */ const XUL_SELECTED_ELS = new Set([ "menu", "menuitem", "menuseparator", "radio", "richlistitem", "tab", ]); /** * This module provides shared functionality for dealing with DOM- * and web elements in Marionette. * * A web element is an abstraction used to identify an element when it * is transported across the protocol, between remote- and local ends. * * Each element has an associated web element reference (a UUID) that * uniquely identifies the the element across all browsing contexts. The * web element reference for every element representing the same element * is the same. * * @namespace */ export const dom = {}; dom.Strategy = { ClassName: "class name", Selector: "css selector", ID: "id", Name: "name", LinkText: "link text", PartialLinkText: "partial link text", TagName: "tag name", XPath: "xpath", }; /** * Find a single element or a collection of elements starting at the * document root or a given node. * * If |timeout| is above 0, an implicit search technique is used. * This will wait for the duration of timeout for the * element to appear in the DOM. * * See the {@link dom.Strategy} enum for a full list of supported * search strategies that can be passed to strategy. * * @param {Object} 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 strategy. * @param {object=} options * @param {boolean=} options.all * If true, a multi-element search selector is used and a sequence of * elements will be returned, otherwise a single element. Defaults to false. * @param {Element=} options.startNode * Element to use as the root of the search. * @param {number=} options.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. * * @returns {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. */ dom.find = function (container, strategy, selector, options = {}) { const { all = false, startNode, timeout = 0 } = options; let searchFn; if (all) { searchFn = findElements.bind(this); } else { searchFn = findElement.bind(this); } return new Promise((resolve, reject) => { let findElements = new lazy.PollPromise( async (resolve, reject) => { try { let res = await find_(container, strategy, selector, searchFn, { all, startNode, }); if (res.length) { resolve(Array.from(res)); } else { reject([]); } } catch (e) { reject(e); } }, { timeout } ); findElements.then(foundEls => { // the following code ought to be moved into findElement // and findElements when bug 1254486 is addressed if (!all && (!foundEls || !foundEls.length)) { let msg = `Unable to locate element: ${selector}`; reject(new lazy.error.NoSuchElementError(msg)); } if (all) { resolve(foundEls); } resolve(foundEls[0]); }, reject); }); }; async function find_( container, strategy, selector, searchFn, { startNode = null, all = false } = {} ) { let rootNode; if (dom.isShadowRoot(startNode)) { rootNode = startNode.ownerDocument; } else { rootNode = container.frame.document; } if (!startNode) { startNode = rootNode; } let res; try { res = await searchFn(strategy, selector, rootNode, startNode); } catch (e) { throw new lazy.error.InvalidSelectorError( `Given ${strategy} expression "${selector}" is invalid: ${e}` ); } if (res) { if (all) { return res; } return [res]; } return []; } /** * Find a single element by XPath expression. * * @param {Document} document * Document root. * @param {Element} startNode * Where in the DOM hiearchy to begin searching. * @param {string} expression * XPath search expression. * * @returns {Node} * First element matching expression. */ dom.findByXPath = function (document, startNode, expression) { let iter = document.evaluate( expression, startNode, null, FIRST_ORDERED_NODE_TYPE, null ); return iter.singleNodeValue; }; /** * Find elements by XPath expression. * * @param {Document} document * Document root. * @param {Element} startNode * Where in the DOM hierarchy to begin searching. * @param {string} expression * XPath search expression. * * @returns {Iterable.} * Iterator over nodes matching expression. */ dom.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. * * @returns {Iterable.} * Sequence of link elements which text is s. */ dom.findByLinkText = function (startNode, linkText) { return filterLinks(startNode, async link => { const visibleText = await lazy.atom.getVisibleText(link, link.ownerGlobal); return visibleText.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. * * @returns {Iterable.} * Iterator of link elements which text containins * linkText. */ dom.findByPartialLinkText = function (startNode, linkText) { return filterLinks(startNode, async link => { const visibleText = await lazy.atom.getVisibleText(link, link.ownerGlobal); return visibleText.includes(linkText); }); }; /** * 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. * * @returns {Array.} * Array of link elements matching predicate. */ async function filterLinks(startNode, predicate) { const links = []; for (const link of getLinks(startNode)) { if (await predicate(link)) { links.push(link); } } return links; } /** * Finds a single element. * * @param {dom.Strategy} strategy * Selector strategy to use. * @param {string} selector * Selector expression. * @param {Document} document * Document root. * @param {Element=} startNode * Optional Element from which to start searching. * * @returns {Element} * Found element. * * @throws {InvalidSelectorError} * If strategy using is not recognised. * @throws {Error} * If selector expression selector is malformed. */ async function findElement( strategy, selector, document, startNode = undefined ) { switch (strategy) { case dom.Strategy.ID: { if (startNode.getElementById) { return startNode.getElementById(selector); } let expr = `.//*[@id="${selector}"]`; return dom.findByXPath(document, startNode, expr); } case dom.Strategy.Name: { if (startNode.getElementsByName) { return startNode.getElementsByName(selector)[0]; } let expr = `.//*[@name="${selector}"]`; return dom.findByXPath(document, startNode, expr); } case dom.Strategy.ClassName: return startNode.getElementsByClassName(selector)[0]; case dom.Strategy.TagName: return startNode.getElementsByTagName(selector)[0]; case dom.Strategy.XPath: return dom.findByXPath(document, startNode, selector); case dom.Strategy.LinkText: { const links = getLinks(startNode); for (const link of links) { const visibleText = await lazy.atom.getVisibleText( link, link.ownerGlobal ); if (visibleText.trim() === selector) { return link; } } return undefined; } case dom.Strategy.PartialLinkText: { const links = getLinks(startNode); for (const link of links) { const visibleText = await lazy.atom.getVisibleText( link, link.ownerGlobal ); if (visibleText.includes(selector)) { return link; } } return undefined; } case dom.Strategy.Selector: try { return startNode.querySelector(selector); } catch (e) { throw new lazy.error.InvalidSelectorError( `${e.message}: "${selector}"` ); } } throw new lazy.error.InvalidSelectorError(`No such strategy: ${strategy}`); } /** * Find multiple elements. * * @param {dom.Strategy} strategy * Selector strategy to use. * @param {string} selector * Selector expression. * @param {Document} document * Document root. * @param {Element=} startNode * Optional Element from which to start searching. * * @returns {Array.} * Found elements. * * @throws {InvalidSelectorError} * If strategy strategy is not recognised. * @throws {Error} * If selector expression selector is malformed. */ async function findElements( strategy, selector, document, startNode = undefined ) { switch (strategy) { case dom.Strategy.ID: selector = `.//*[@id="${selector}"]`; // fall through case dom.Strategy.XPath: return [...dom.findByXPathAll(document, startNode, selector)]; case dom.Strategy.Name: if (startNode.getElementsByName) { return startNode.getElementsByName(selector); } return [ ...dom.findByXPathAll(document, startNode, `.//*[@name="${selector}"]`), ]; case dom.Strategy.ClassName: return startNode.getElementsByClassName(selector); case dom.Strategy.TagName: return startNode.getElementsByTagName(selector); case dom.Strategy.LinkText: return [...(await dom.findByLinkText(startNode, selector))]; case dom.Strategy.PartialLinkText: return [...(await dom.findByPartialLinkText(startNode, selector))]; case dom.Strategy.Selector: return startNode.querySelectorAll(selector); default: throw new lazy.error.InvalidSelectorError( `No such strategy: ${strategy}` ); } } function getLinks(startNode) { // DocumentFragment doesn't have `getElementsByTagName` so using `querySelectorAll`. if (dom.isShadowRoot(startNode)) { return startNode.querySelectorAll("a"); } return startNode.getElementsByTagName("a"); } /** * Finds the closest parent node of startNode matching a CSS * selector expression. * * @param {Node} startNode * Cycle through startNode's parent nodes in tree-order * and return the first match to selector. * @param {string} selector * CSS selector expression. * * @returns {Node=} * First match to selector, or null if no match was found. */ dom.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 {object} seq * Type to determine. * * @returns {boolean} * True if seq is a collection. */ dom.isCollection = function (seq) { switch (Object.prototype.toString.call(seq)) { case "[object Arguments]": case "[object Array]": case "[object DOMTokenList]": 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 shadowRoot is detached. * * A ShadowRoot is detached if its node document is not the active document * or if the element node referred to as its host is stale. * * @param {ShadowRoot} shadowRoot * ShadowRoot to check for detached state. * * @returns {boolean} * True if shadowRoot is detached, false otherwise. */ dom.isDetached = function (shadowRoot) { return !shadowRoot.ownerDocument.isActive() || dom.isStale(shadowRoot.host); }; /** * Determines if el is stale. * * An element is stale if its node document is not the active document * or if it is not connected. * * @param {Element} el * Element to check for staleness. * * @returns {boolean} * True if el is stale, false otherwise. */ dom.isStale = function (el) { if (!el.ownerGlobal) { // Without a valid inner window the document is basically closed. return true; } return !el.ownerDocument.isActive() || !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 {Element} el * Element to test if selected. * * @returns {boolean} * True if element is selected, false otherwise. */ dom.isSelected = function (el) { if (!el) { return false; } if (dom.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 (dom.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. * * @returns {boolean} * True if element is read only. */ dom.isReadOnly = function (el) { return ( dom.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. * * @returns {boolean} * True if element, or its container group, is disabled. */ dom.isDisabled = function (el) { if (!dom.isDOMElement(el)) { return false; } switch (el.localName) { case "option": case "optgroup": if (el.disabled) { return true; } let parent = dom.findClosest(el, "optgroup,select"); return dom.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 <input> elements in the Text, * Search, URL, Telephone, Email, Password, Date, Month, Date and * Time Local, Number, Range, Color, and File Upload states, and * <textarea> elements. * * @param {Element} el * Element to test. * * @returns {boolean} * True if editable, false otherwise. */ dom.isMutableFormControl = function (el) { if (!dom.isDOMElement(el)) { return false; } if (dom.isReadOnly(el) || dom.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 * 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. * * @returns {boolean} * True if editing host, false otherwise. */ dom.isEditingHost = function (el) { return ( dom.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} el * Element to test if editable. * * @returns {boolean} * True if editable, false otherwise. */ dom.isEditable = function (el) { if (!dom.isDOMElement(el)) { return false; } if (dom.isReadOnly(el) || dom.isDisabled(el)) { return false; } return dom.isMutableFormControl(el) || dom.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. * * @returns {Object} * X- and Y coordinates. * * @throws TypeError * If xOffset or yOffset are not numbers. */ dom.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. * * @returns {boolean} * True if if el is in viewport, false otherwise. */ dom.inViewport = function (el, x = undefined, y = undefined) { let win = el.ownerGlobal; let c = dom.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. * * @returns {Element} * Container element of el. */ dom.getContainer = function (el) { // Does have a valid context, // meaning is it a child of or