/* 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