diff options
Diffstat (limited to 'testing/marionette/element.js')
-rw-r--r-- | testing/marionette/element.js | 1159 |
1 files changed, 1159 insertions, 0 deletions
diff --git a/testing/marionette/element.js b/testing/marionette/element.js new file mode 100644 index 000000000..8e66ee6df --- /dev/null +++ b/testing/marionette/element.js @@ -0,0 +1,1159 @@ +/* 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. + * + * @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 tree = element.getPointerInteractablePaintTree(el); + return tree.includes(el); +}; + +/** + * 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) +}; |