Mypal/testing/marionette/element.js

1172 lines
32 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";
const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
Cu.import("resource://gre/modules/Log.jsm");
Cu.import("chrome://marionette/content/assert.js");
Cu.import("chrome://marionette/content/atom.js");
Cu.import("chrome://marionette/content/error.js");
const logger = Log.repository.getLogger("Marionette");
/**
* 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 @code{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.
*/
this.EXPORTED_SYMBOLS = ["element"];
const DOCUMENT_POSITION_DISCONNECTED = 1;
const XMLNS = "http://www.w3.org/1999/xhtml";
const uuidGen = Cc["@mozilla.org/uuid-generator;1"]
.getService(Ci.nsIUUIDGenerator);
this.element = {};
element.Key = "element-6066-11e4-a52e-4f735466cecf";
element.LegacyKey = "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 |add(el)| or |addAll(elements)|, and
* may be queried by their web element reference using |get(element)|.
*/
element.Store = class {
constructor() {
this.els = {};
this.timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
}
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 |els|.
*/
addAll(els) {
let add = this.add.bind(this);
return [...els].map(add);
}
/**
* Make an element seen.
*
* @param {nsIDOMElement} el
* Element to add to set of seen elements.
*
* @return {string}
* Web element reference associated with element.
*/
add(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 i;
}
// cleanup reference to gc'd element
} else {
delete this.els[i];
}
}
let id = element.generateUUID();
this.els[id] = Cu.getWeakReference(el);
return id;
}
/**
* Determine if the provided web element reference has been seen
* before/is in the element store.
*
* @param {string} uuid
* Element's associated web element reference.
*
* @return {boolean}
* True if element is in the store, false otherwise.
*/
has(uuid) {
return Object.keys(this.els).includes(uuid);
}
/**
* Retrieve a DOM element by its unique web element reference/UUID.
*
* @param {string} uuid
* Web element reference, or UUID.
* @param {(nsIDOMWindow|ShadowRoot)} container
* Window and an optional shadow root that contains the element.
*
* @returns {nsIDOMElement}
* Element associated with reference.
*
* @throws {JavaScriptError}
* If the provided reference is unknown.
* @throws {StaleElementReferenceError}
* If element has gone stale, indicating it is no longer attached to
* the DOM provided in the container.
*/
get(uuid, container) {
let el = this.els[uuid];
if (!el) {
throw new JavaScriptError(`Element reference not seen before: ${uuid}`);
}
try {
el = el.get();
} catch (e) {
el = null;
delete this.els[id];
}
// use XPCNativeWrapper to compare elements (see bug 834266)
let wrappedFrame = new XPCNativeWrapper(container.frame);
let wrappedShadowRoot;
if (container.shadowRoot) {
wrappedShadowRoot = new XPCNativeWrapper(container.shadowRoot);
}
let wrappedEl = new XPCNativeWrapper(el);
let wrappedContainer = {
frame: wrappedFrame,
shadowRoot: wrappedShadowRoot,
};
if (!el ||
!(wrappedEl.ownerDocument == wrappedFrame.document) ||
element.isDisconnected(wrappedEl, wrappedContainer)) {
throw new StaleElementReferenceError(
error.pprint`The element reference of ${el} stale: ` +
"either the element is no longer attached to the DOM " +
"or the page has been refreshed");
}
return el;
}
};
/**
* Find a single element or a collection of elements starting at the
* document root or a given node.
*
* If |timeout| is above 0, an implicit search technique is used.
* This will wait for the duration of |timeout| for the element
* to appear in the DOM.
*
* See the |element.Strategy| enum for a full list of supported
* search strategies that can be passed to |strategy|.
*
* Available flags for |opts|:
*
* |all|
* If true, a multi-element search selector is used and a sequence
* of elements will be returned. Otherwise a single element.
*
* |timeout|
* Duration to wait before timing out the search. If |all| is
* false, a NoSuchElementError is thrown if unable to find
* the element within the timeout duration.
*
* |startNode|
* Element to use as the root of the search.
*
* @param {Object.<string, Window>} container
* Window object and an optional shadow root that contains the
* root shadow DOM element.
* @param {string} strategy
* Search strategy whereby to locate the element(s).
* @param {string} selector
* Selector search pattern. The selector must be compatible with
* the chosen search |strategy|.
* @param {Object.<string, ?>} opts
* Options.
*
* @return {Promise: (nsIDOMElement|Array<nsIDOMElement>)}
* Single element or a sequence of elements.
*
* @throws InvalidSelectorError
* If |strategy| is unknown.
* @throws InvalidSelectorError
* If |selector| is malformed.
* @throws NoSuchElementError
* If a single element is requested, this error will throw if the
* element is not found.
*/
element.find = function (container, strategy, selector, opts = {}) {
opts.all = !!opts.all;
opts.timeout = opts.timeout || 0;
let searchFn;
if (opts.all) {
searchFn = findElements.bind(this);
} else {
searchFn = findElement.bind(this);
}
return new Promise((resolve, reject) => {
let findElements = implicitlyWaitFor(
() => find_(container, strategy, selector, searchFn, opts),
opts.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, opts) {
let rootNode = container.shadowRoot || container.frame.document;
let startNode;
if (opts.startNode) {
startNode = opts.startNode;
} else {
switch (strategy) {
// For anonymous nodes the start node needs to be of type DOMElement, which
// will refer to :root in case of a DOMDocument.
case element.Strategy.Anon:
case element.Strategy.AnonAttribute:
if (rootNode instanceof Ci.nsIDOMDocument) {
startNode = rootNode.documentElement;
}
break;
default:
startNode = rootNode;
}
}
let res;
try {
res = searchFn(strategy, selector, rootNode, startNode);
} catch (e) {
throw new InvalidSelectorError(
`Given ${strategy} expression "${selector}" is invalid: ${e}`);
}
if (res) {
if (opts.all) {
return res;
}
return [res];
}
return [];
}
/**
* Find a single element by XPath expression.
*
* @param {DOMElement} root
* Document root
* @param {DOMElement} startNode
* Where in the DOM hiearchy to begin searching.
* @param {string} expr
* XPath search expression.
*
* @return {DOMElement}
* First element matching expression.
*/
element.findByXPath = function (root, startNode, expr) {
let iter = root.evaluate(expr, startNode, null,
Ci.nsIDOMXPathResult.FIRST_ORDERED_NODE_TYPE, null);
return iter.singleNodeValue;
};
/**
* Find elements by XPath expression.
*
* @param {DOMElement} root
* Document root.
* @param {DOMElement} startNode
* Where in the DOM hierarchy to begin searching.
* @param {string} expr
* XPath search expression.
*
* @return {Array.<DOMElement>}
* Sequence of found elements matching expression.
*/
element.findByXPathAll = function (root, startNode, expr) {
let rv = [];
let iter = root.evaluate(expr, startNode, null,
Ci.nsIDOMXPathResult.ORDERED_NODE_ITERATOR_TYPE, null);
let el = iter.iterateNext();
while (el) {
rv.push(el);
el = iter.iterateNext();
}
return rv;
};
/**
* Find all hyperlinks dscendant of |node| which link text is |s|.
*
* @param {DOMElement} node
* Where in the DOM hierarchy to being searching.
* @param {string} s
* Link text to search for.
*
* @return {Array.<DOMAnchorElement>}
* Sequence of link elements which text is |s|.
*/
element.findByLinkText = function (node, s) {
return filterLinks(node, link => link.text.trim() === s);
};
/**
* Find all hyperlinks descendant of |node| which link text contains |s|.
*
* @param {DOMElement} node
* Where in the DOM hierachy to begin searching.
* @param {string} s
* Link text to search for.
*
* @return {Array.<DOMAnchorElement>}
* Sequence of link elements which text containins |s|.
*/
element.findByPartialLinkText = function (node, s) {
return filterLinks(node, link => link.text.indexOf(s) != -1);
};
/**
* Filters all hyperlinks that are descendant of |node| by |predicate|.
*
* @param {DOMElement} node
* Where in the DOM hierarchy to begin searching.
* @param {function(DOMAnchorElement): boolean} predicate
* Function that determines if given link should be included in
* return value or filtered away.
*
* @return {Array.<DOMAnchorElement>}
* Sequence of link elements matching |predicate|.
*/
function filterLinks(node, predicate) {
let rv = [];
for (let link of node.getElementsByTagName("a")) {
if (predicate(link)) {
rv.push(link);
}
}
return rv;
}
/**
* Finds a single element.
*
* @param {element.Strategy} using
* Selector strategy to use.
* @param {string} value
* Selector expression.
* @param {DOMElement} rootNode
* Document root.
* @param {DOMElement=} startNode
* Optional node from which to start searching.
*
* @return {DOMElement}
* Found elements.
*
* @throws {InvalidSelectorError}
* If strategy |using| is not recognised.
* @throws {Error}
* If selector expression |value| is malformed.
*/
function findElement(using, value, rootNode, startNode) {
switch (using) {
case element.Strategy.ID:
if (startNode.getElementById) {
return startNode.getElementById(value);
}
return element.findByXPath(rootNode, startNode, `.//*[@id="${value}"]`);
case element.Strategy.Name:
if (startNode.getElementsByName) {
return startNode.getElementsByName(value)[0];
}
return element.findByXPath(rootNode, startNode, `.//*[@name="${value}"]`);
case element.Strategy.ClassName:
// works for >= Firefox 3
return startNode.getElementsByClassName(value)[0];
case element.Strategy.TagName:
// works for all elements
return startNode.getElementsByTagName(value)[0];
case element.Strategy.XPath:
return element.findByXPath(rootNode, startNode, value);
case element.Strategy.LinkText:
for (let link of startNode.getElementsByTagName("a")) {
if (link.text.trim() === value) {
return link;
}
}
break;
case element.Strategy.PartialLinkText:
for (let link of startNode.getElementsByTagName("a")) {
if (link.text.indexOf(value) != -1) {
return link;
}
}
break;
case element.Strategy.Selector:
try {
return startNode.querySelector(value);
} catch (e) {
throw new InvalidSelectorError(`${e.message}: "${value}"`);
}
break;
case element.Strategy.Anon:
return rootNode.getAnonymousNodes(startNode);
case element.Strategy.AnonAttribute:
let attr = Object.keys(value)[0];
return rootNode.getAnonymousElementByAttribute(startNode, attr, value[attr]);
default:
throw new InvalidSelectorError(`No such strategy: ${using}`);
}
}
/**
* Find multiple elements.
*
* @param {element.Strategy} using
* Selector strategy to use.
* @param {string} value
* Selector expression.
* @param {DOMElement} rootNode
* Document root.
* @param {DOMElement=} startNode
* Optional node from which to start searching.
*
* @return {DOMElement}
* Found elements.
*
* @throws {InvalidSelectorError}
* If strategy |using| is not recognised.
* @throws {Error}
* If selector expression |value| is malformed.
*/
function findElements(using, value, rootNode, startNode) {
switch (using) {
case element.Strategy.ID:
value = `.//*[@id="${value}"]`;
// fall through
case element.Strategy.XPath:
return element.findByXPathAll(rootNode, startNode, value);
case element.Strategy.Name:
if (startNode.getElementsByName) {
return startNode.getElementsByName(value);
}
return element.findByXPathAll(rootNode, startNode, `.//*[@name="${value}"]`);
case element.Strategy.ClassName:
return startNode.getElementsByClassName(value);
case element.Strategy.TagName:
return startNode.getElementsByTagName(value);
case element.Strategy.LinkText:
return element.findByLinkText(startNode, value);
case element.Strategy.PartialLinkText:
return element.findByPartialLinkText(startNode, value);
case element.Strategy.Selector:
return startNode.querySelectorAll(value);
case element.Strategy.Anon:
return rootNode.getAnonymousNodes(startNode);
case element.Strategy.AnonAttribute:
let attr = Object.keys(value)[0];
let el = rootNode.getAnonymousElementByAttribute(startNode, attr, value[attr]);
if (el) {
return [el];
}
return [];
default:
throw new InvalidSelectorError(`No such strategy: ${using}`);
}
}
/**
* Runs function off the main thread until its return value is truthy
* or the provided timeout is reached. The function is guaranteed to be
* run at least once, irregardless of the timeout.
*
* A truthy return value constitutes a truthful boolean, positive number,
* object, or non-empty array.
*
* The |func| is evaluated every |interval| for as long as its runtime
* duration does not exceed |interval|. If the runtime evaluation duration
* of |func| is greater than |interval|, evaluations of |func| are queued.
*
* @param {function(): ?} func
* Function to run off the main thread.
* @param {number} timeout
* Desired timeout. If 0 or less than the runtime evaluation time
* of |func|, |func| is guaranteed to run at least once.
* @param {number=} interval
* Duration between each poll of |func| in milliseconds. Defaults to
* 100 milliseconds.
*
* @return {Promise}
* Yields the return value from |func|. The promise is rejected if
* |func| throws.
*/
function implicitlyWaitFor(func, timeout, interval = 100) {
let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
return new Promise((resolve, reject) => {
let startTime = new Date().getTime();
let endTime = startTime + timeout;
let elementSearch = function() {
let res;
try {
res = func();
} catch (e) {
reject(e);
}
if (
// collections that might contain web elements
// should be checked until they are not empty
(element.isCollection(res) && res.length > 0)
// !![] (ensuring boolean type on empty array) always returns true
// and we can only use it on non-collections
|| (!element.isCollection(res) && !!res)
// return immediately if timeout is 0,
// allowing |func| to be evaluted at least once
|| startTime == endTime
// return if timeout has elapsed
|| new Date().getTime() >= endTime
) {
resolve(res);
}
};
// the repeating slack timer waits |interval|
// before invoking |elementSearch|
elementSearch();
timer.init(elementSearch, interval, Ci.nsITimer.TYPE_REPEATING_SLACK);
// cancel timer and propagate result
}).then(res => {
timer.cancel();
return res;
}, err => {
timer.cancel();
throw err;
});
}
/** Determines if |obj| is an HTML or JS 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;
}
};
element.makeWebElement = function (uuid) {
return {
[element.Key]: uuid,
[element.LegacyKey]: uuid,
};
};
/**
* Checks if |ref| has either |element.Key| or |element.LegacyKey| as properties.
*
* @param {?} ref
* Object that represents a web element reference.
* @return {boolean}
* True if |ref| has either expected property.
*/
element.isWebElementReference = function (ref) {
let properties = Object.getOwnPropertyNames(ref);
return properties.includes(element.Key) || properties.includes(element.LegacyKey);
};
element.generateUUID = function() {
let uuid = uuidGen.generateUUID().toString();
return uuid.substring(1, uuid.length - 1);
};
/**
* Convert any web elements in arbitrary objects to DOM elements by
* looking them up in the seen element store.
*
* @param {?} obj
* Arbitrary object containing web elements.
* @param {element.Store} seenEls
* Element store to use for lookup of web element references.
* @param {Window} win
* Window.
* @param {ShadowRoot} shadowRoot
* Shadow root.
*
* @return {?}
* Same object as provided by |obj| with the web elements replaced
* by DOM elements.
*/
element.fromJson = function (
obj, seenEls, win, shadowRoot = undefined) {
switch (typeof obj) {
case "boolean":
case "number":
case "string":
return obj;
case "object":
if (obj === null) {
return obj;
}
// arrays
else if (Array.isArray(obj)) {
return obj.map(e => element.fromJson(e, seenEls, win, shadowRoot));
}
// web elements
else if (Object.keys(obj).includes(element.Key) ||
Object.keys(obj).includes(element.LegacyKey)) {
let uuid = obj[element.Key] || obj[element.LegacyKey];
let el = seenEls.get(uuid, {frame: win, shadowRoot: shadowRoot});
if (!el) {
throw new WebDriverError(`Unknown element: ${uuid}`);
}
return el;
}
// arbitrary objects
else {
let rv = {};
for (let prop in obj) {
rv[prop] = element.fromJson(obj[prop], seenEls, win, shadowRoot);
}
return rv;
}
}
};
/**
* Convert arbitrary objects to JSON-safe primitives that can be
* transported over the Marionette protocol.
*
* Any DOM elements are converted to web elements by looking them up
* and/or adding them to the element store provided.
*
* @param {?} obj
* Object to be marshaled.
* @param {element.Store} seenEls
* Element store to use for lookup of web element references.
*
* @return {?}
* Same object as provided by |obj| with the elements replaced by
* web elements.
*/
element.toJson = function (obj, seenEls) {
let t = Object.prototype.toString.call(obj);
// null
if (t == "[object Undefined]" || t == "[object Null]") {
return null;
}
// literals
else if (t == "[object Boolean]" || t == "[object Number]" || t == "[object String]") {
return obj;
}
// Array, NodeList, HTMLCollection, et al.
else if (element.isCollection(obj)) {
return [...obj].map(el => element.toJson(el, seenEls));
}
// HTMLElement
else if ("nodeType" in obj && obj.nodeType == 1) {
let uuid = seenEls.add(obj);
return element.makeWebElement(uuid);
}
// arbitrary objects + files
else {
let rv = {};
for (let prop in obj) {
try {
rv[prop] = element.toJson(obj[prop], seenEls);
} catch (e if (e.result == Cr.NS_ERROR_NOT_IMPLEMENTED)) {
logger.debug(`Skipping ${prop}: ${e.message}`);
}
}
return rv;
}
};
/**
* Check if the element is detached from the current frame as well as
* the optional shadow root (when inside a Shadow DOM context).
*
* @param {nsIDOMElement} el
* Element to be checked.
* @param {Container} container
* Container with |frame|, which is the window object that contains
* the element, and an optional |shadowRoot|.
*
* @return {boolean}
* Flag indicating that the element is disconnected.
*/
element.isDisconnected = function (el, container = {}) {
const {frame, shadowRoot} = container;
assert.defined(frame);
// shadow dom
if (frame.ShadowRoot && shadowRoot) {
if (el.compareDocumentPosition(shadowRoot) &
DOCUMENT_POSITION_DISCONNECTED) {
return true;
}
// looking for next possible ShadowRoot ancestor
let parent = shadowRoot.host;
while (parent && !(parent instanceof frame.ShadowRoot)) {
parent = parent.parentNode;
}
return element.isDisconnected(
shadowRoot.host,
{frame: frame, shadowRoot: parent});
// outside shadow dom
} else {
let docEl = frame.document.documentElement;
return el.compareDocumentPosition(docEl) &
DOCUMENT_POSITION_DISCONNECTED;
}
};
/**
* 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 |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.ownerDocument.defaultView;
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
* (https://html.spec.whatwg.org/#concept-element-contexts), 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) {
if (el.localName != "option") {
return el;
}
function validContext(ctx) {
return ctx.localName == "datalist" || ctx.localName == "select";
}
// does <option> have a valid context,
// meaning is it a child of <datalist> or <select>?
let parent = el;
while (parent.parentNode && !validContext(parent)) {
parent = parent.parentNode;
}
if (!validContext(parent)) {
return el;
}
return parent;
};
/**
* 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
* |elementsFromPoint| list at |el|'s in-view centre coordinates.
*
* Before running the check, we change |el|'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 |el| 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);
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.ownerDocument.defaultView;
// Bug 1094246: webdriver's isShown doesn't work with content xul
if (!element.isXULElement(el) && !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.
*
* @param {DOMElement} el
* Element determine if is pointer-interactable.
*
* @return {boolean}
* True if interactable, false otherwise.
*/
element.isPointerInteractable = function (el) {
let tree = element.getPointerInteractablePaintTree(el);
return tree[0] === el;
};
/**
* Calculate the in-view centre point of the area of the given DOM client
* rectangle that is inside the viewport.
*
* @param {DOMRect} rect
* Element off a DOMRect sequence produced by calling |getClientRects|
* on a |DOMElement|.
* @param {nsIDOMWindow} win
* Current browsing context.
*
* @return {Map.<string, number>}
* X and Y coordinates that denotes the in-view centre point of |rect|.
*/
element.getInViewCentrePoint = function (rect, win) {
const {max, min} = Math;
let x = {
left: max(0, min(rect.x, rect.x + rect.width)),
right: min(win.innerWidth, max(rect.x, rect.x + rect.width)),
};
let y = {
top: max(0, min(rect.y, rect.y + rect.height)),
bottom: min(win.innerHeight, max(rect.y, rect.y + rect.height)),
};
return {
x: (x.left + x.right) / 2,
y: (y.top + y.bottom) / 2,
};
};
/**
* 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;
// pointer-interactable elements tree, step 1
if (element.isDisconnected(el, {frame: win})) {
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 doc.elementsFromPoint(centre.x, centre.y);
};
// TODO(ato): Not implemented.
// In fact, it's not defined in the spec.
element.isKeyboardInteractable = function (el) {
return 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"});
}
};
element.isXULElement = function (el) {
let ns = atom.getElementAttribute(el, "namespaceURI");
return ns.indexOf("there.is.only.xul") >= 0;
};
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"],
object: ["typemustmatch"],
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 |attr| 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 (el.namespaceURI !== XMLNS) {
return false;
}
// global boolean attributes that apply to all HTML elements,
// except for custom elements
if ((attr == "hidden" || attr == "itemscope") && !el.localName.includes("-")) {
return true;
}
if (!boolEls.hasOwnProperty(el.localName)) {
return false;
}
return boolEls[el.localName].includes(attr)
};