gecko-dev/testing/marionette/element.js

1690 строки
46 KiB
JavaScript

/* 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 { assert } = ChromeUtils.import("chrome://marionette/content/assert.js");
const { atom } = ChromeUtils.import("chrome://marionette/content/atom.js");
const {
InvalidArgumentError,
InvalidSelectorError,
NoSuchElementError,
StaleElementReferenceError,
} = ChromeUtils.import("chrome://marionette/content/error.js");
const { pprint } = ChromeUtils.import("chrome://marionette/content/format.js");
const { PollPromise } = ChromeUtils.import(
"chrome://marionette/content/sync.js"
);
this.EXPORTED_SYMBOLS = [
"ChromeWebElement",
"ContentWebElement",
"ContentWebFrame",
"ContentWebWindow",
"element",
"WebElement",
];
const ORDERED_NODE_ITERATOR_TYPE = 5;
const FIRST_ORDERED_NODE_TYPE = 9;
const ELEMENT_NODE = 1;
const DOCUMENT_NODE = 9;
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", "toolbarbutton"]);
/** XUL elements that support selected property. */
const XUL_SELECTED_ELS = new Set([
"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.<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 = 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 <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 browsing context, which may differ from the associate
* browsing context 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 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 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 <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 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 <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;
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.nodeType == rootNode.DOCUMENT_NODE) {
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 <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)
);
};
/**
* Find anonymous nodes of <var>node</var>.
*
* @param {HTMLDocument} document
* Root node of the document.
* @param {XULElement} node
* Where in the DOM hierarchy to begin searching.
*
* @return {Iterable.<XULElement>}
* 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 <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 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.<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);
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 <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;
};
/**
* 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>window<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>&lt;iframe&gt;</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 browsing context, which may differ from the associate
* browsing context 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>&lt;input type=checkbox&gt;</tt>,
* <tt>&lt;input type=radio&gt;</tt>,
* and <tt>&gt;option&gt;</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>&lt;input&gt;</code> or <code>&lt;textarea&gt;</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>&lt;input&gt;</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>&lt;textarea&gt;</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>&lt;textarea&gt;</code> element.
*
* <li>It is an <code>&lt;input&gt;</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>&lt;option&gt;</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>&lt;datalist&gt;</tt> or <tt>&lt;select&gt;</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>el</var> is a XUL- or XBL element.
*
* @param {*} node
* Element thought to be a XUL- or XBL element.
*
* @return {boolean}
* True if <var>node</var> is a XULElement or XBLElement,
* false otherwise.
*/
element.isXULElement = function(node) {
return (
typeof node == "object" &&
node !== null &&
"nodeType" in node &&
node.nodeType === node.ELEMENT_NODE &&
[XBLNS, XULNS].includes(node.namespaceURI)
);
};
/**
* 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.isDOMElement(node)) {
return new ContentWebElement(uuid);
} else if (element.isDOMWindow(node)) {
if (node.parent === node) {
return new ContentWebWindow(uuid);
}
return new ContentWebFrame(uuid);
} else if (element.isXULElement(node)) {
return new ChromeWebElement(uuid);
}
throw new 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);
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 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 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 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 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>&lt;frame&gt;</tt> and <tt>&lt;iframe&gt;</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 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 InvalidArgumentError(
"Expected chrome element reference " +
pprint`for XUL/XBL element, got: ${json}`
);
}
let uuid = json[ChromeWebElement.Identifier];
return new ChromeWebElement(uuid);
}
}
ChromeWebElement.Identifier = "chromeelement-9fc5-4b51-a3c8-01716eedeb04";
this.ChromeWebElement = ChromeWebElement;