From 43a97878ce14b72f0981164f87f2e35e14151312 Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Sun, 7 Apr 2024 11:22:09 +0200 Subject: Adding upstream version 110.0.1. Signed-off-by: Daniel Baumann --- remote/marionette/element.sys.mjs | 1524 +++++++++++++++++++++++++++++++++++++ 1 file changed, 1524 insertions(+) create mode 100644 remote/marionette/element.sys.mjs (limited to 'remote/marionette/element.sys.mjs') diff --git a/remote/marionette/element.sys.mjs b/remote/marionette/element.sys.mjs new file mode 100644 index 0000000000..c344f7005b --- /dev/null +++ b/remote/marionette/element.sys.mjs @@ -0,0 +1,1524 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + assert: "chrome://remote/content/shared/webdriver/Assert.sys.mjs", + 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", + pprint: "chrome://remote/content/shared/Format.sys.mjs", +}); + +const ORDERED_NODE_ITERATOR_TYPE = 5; +const FIRST_ORDERED_NODE_TYPE = 9; + +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 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", +}; + +/** + * 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. + * + * @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=} 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=} startNode + * Element to use as the root of the search. + * @param {number=} 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. + * + * @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, 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( + (resolve, reject) => { + let res = find_(container, strategy, selector, searchFn, { + all, + startNode, + }); + if (res.length) { + 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 (!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); + }); +}; + +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 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. + * + * @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 {Document} document + * Document root. + * @param {Element} startNode + * Where in the DOM hierarchy to begin searching. + * @param {string} expression + * XPath search expression. + * + * @return {Iterable.} + * Iterator over nodes 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 => lazy.atom.getElementText(link).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 => + lazy.atom.getElementText(link).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. + * + * @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 {Document} document + * Document root. + * @param {Element=} startNode + * Optional Element from which to start searching. + * + * @return {Element} + * Found element. + * + * @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 (lazy.atom.getElementText(link).trim() === selector) { + return link; + } + } + return undefined; + + case element.Strategy.PartialLinkText: + for (let link of startNode.getElementsByTagName("a")) { + if (lazy.atom.getElementText(link).includes(selector)) { + return link; + } + } + return undefined; + + case element.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 {element.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. + * + * @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); + + default: + throw new lazy.error.InvalidSelectorError( + `No such strategy: ${strategy}` + ); + } +} + +/** + * 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. + * + * @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; +}; + +/** + * Resolve element from specified web element reference. + * + * @param {ElementIdentifier} id + * The WebElement reference identifier for a DOM element. + * @param {NodeCache} nodeCache + * Node cache that holds already seen WebElement and ShadowRoot references. + * @param {WindowProxy} win + * Current window, which may differ from the associated + * window of el. + * + * @return {Element|null} 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 id doesn't exist + * in the current browsing context. + * @throws {StaleElementReferenceError} + * If the element has gone stale, indicating its node document is no + * longer the active document or it is no longer attached to the DOM. + */ +element.resolveElement = function(id, nodeCache, win) { + const el = nodeCache.resolve(id); + + // For WebDriver classic only elements from the same browsing context + // are allowed to be accessed. + if (el?.ownerGlobal) { + if (win === undefined) { + throw new TypeError( + "Expected a valid window to resolve the element reference of " + + lazy.pprint`${el || JSON.stringify(id.webElRef)}` + ); + } + + const elementBrowsingContext = el.ownerGlobal.browsingContext; + let sameBrowsingContext = true; + + if (elementBrowsingContext.top === elementBrowsingContext) { + // Cross-group navigations cause a swap of the current top-level browsing + // context. The only unique identifier is the browser id the browsing + // context actually lives in. If it's equal also treat the browsing context + // as the same (bug 1690308). + // If the element's browsing context is a top-level browsing context, + sameBrowsingContext = + elementBrowsingContext.browserId == win.browsingContext.browserId; + } else { + // For non top-level browsing contexts check for equality directly. + sameBrowsingContext = elementBrowsingContext.id == win.browsingContext.id; + } + + if (!sameBrowsingContext) { + throw new lazy.error.NoSuchElementError( + lazy.pprint`The element reference of ${el || + JSON.stringify(id.webElRef)} ` + + "is not known in the current browsing context" + ); + } + } + + if (element.isStale(el)) { + throw new lazy.error.StaleElementReferenceError( + lazy.pprint`The element reference of ${el || + JSON.stringify(id.webElRef)} ` + + "is stale; either its node document is not the active document, " + + "or it is no longer connected to the DOM" + ); + } + + return el; +}; + +/** + * Determines if obj is an HTML or JS collection. + * + * @param {Object} seq + * Type to determine. + * + * @return {boolean} + * True if seq is a 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. + * + * 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. If null, which may be + * the case if the element has been unwrapped from a weak + * reference, it is always considered stale. + * + * @return {boolean} + * True if el is stale, false otherwise. + */ +element.isStale = function(el) { + if (el == null || !el.ownerGlobal) { + // Without a valid inner window the document is basically closed. + return true; + } + + return !el.ownerGlobal.document.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. + * + * @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; + } +}; + +/** + * 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. + * + * @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 + * 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 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.} + * 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