/* 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} = Components; Cu.import("resource://gre/modules/Log.jsm"); Cu.import("chrome://marionette/content/atom.js"); Cu.import("chrome://marionette/content/error.js"); const logger = Log.repository.getLogger("Marionette"); /** * The ElementManager manages DOM element references and * interactions with elements. * * 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 element manager 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", "ElementManager", ]; const DOCUMENT_POSITION_DISCONNECTED = 1; const uuidGen = Cc["@mozilla.org/uuid-generator;1"] .getService(Ci.nsIUUIDGenerator); this.element = {}; element.LegacyKey = "ELEMENT"; element.Key = "element-6066-11e4-a52e-4f735466cecf"; 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", }; element.Strategies = new Set(Object.values(element.Strategy)); this.ElementManager = function ElementManager(unsupportedStrategies = []) { this.seenItems = {}; this.timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); this.supportedStrategies = new Set(element.Strategies); for (let s of unsupportedStrategies) { this.supportedStrategies.delete(s); } }; ElementManager.prototype = { /** * Reset values */ reset: function EM_clear() { this.seenItems = {}; }, /** * Add element to list of seen elements * * @param nsIDOMElement element * The element to add * * @return string * Returns the server-assigned reference ID */ addToKnownElements: function EM_addToKnownElements(el) { for (let i in this.seenItems) { let foundEl = null; try { foundEl = this.seenItems[i].get(); } catch (e) {} if (foundEl) { if (XPCNativeWrapper(foundEl) == XPCNativeWrapper(el)) { return i; } } else { // cleanup reference to GC'd element delete this.seenItems[i]; } } let id = element.generateUUID(); this.seenItems[id] = Cu.getWeakReference(el); return id; }, /** * Retrieve element from its unique ID * * @param String id * The DOM reference ID * @param nsIDOMWindow, ShadowRoot container * The window and an optional shadow root that contains the element * * @returns nsIDOMElement * Returns the element or throws Exception if not found */ getKnownElement: function EM_getKnownElement(id, container) { let el = this.seenItems[id]; if (!el) { throw new JavaScriptError(`Element has not been seen before. Id given was ${id}`); } try { el = el.get(); } catch(e) { el = null; delete this.seenItems[id]; } // use XPCNativeWrapper to compare elements; see bug 834266 let wrappedFrame = XPCNativeWrapper(container.frame); let wrappedShadowRoot; if (container.shadowRoot) { wrappedShadowRoot = XPCNativeWrapper(container.shadowRoot); } if (!el || !(XPCNativeWrapper(el).ownerDocument == wrappedFrame.document) || this.isDisconnected(XPCNativeWrapper(el), wrappedShadowRoot, wrappedFrame)) { throw new StaleElementReferenceError( "The element reference is stale. Either the element " + "is no longer attached to the DOM or the page has been refreshed."); } return el; }, /** * 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 ShadowRoot shadowRoot * an optional shadow root containing an element * @param nsIDOMWindow frame * window that contains the element or the current host of the shadow * root. * @return {Boolean} a flag indicating that the element is disconnected */ isDisconnected: function EM_isDisconnected(el, shadowRoot, frame) { if (shadowRoot && frame.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 this.isDisconnected(shadowRoot.host, parent, frame); } else { return el.compareDocumentPosition(frame.document.documentElement) & DOCUMENT_POSITION_DISCONNECTED; } }, /** * Convert values to primitives that can be transported over the * Marionette protocol. * * This function implements the marshaling algorithm defined in the * WebDriver specification: * * https://dvcs.w3.org/hg/webdriver/raw-file/tip/webdriver-spec.html#synchronous-javascript-execution * * @param object val * object to be marshaled * * @return object * Returns a JSON primitive or Object */ wrapValue: function EM_wrapValue(val) { let result = null; switch (typeof(val)) { case "undefined": result = null; break; case "string": case "number": case "boolean": result = val; break; case "object": let type = Object.prototype.toString.call(val); if (type == "[object Array]" || type == "[object NodeList]") { result = []; for (let i = 0; i < val.length; ++i) { result.push(this.wrapValue(val[i])); } } else if (val == null) { result = null; } else if (val.nodeType == 1) { let elementId = this.addToKnownElements(val); result = {[element.LegacyKey]: elementId, [element.Key]: elementId}; } else { result = {}; for (let prop in val) { result[prop] = this.wrapValue(val[prop]); } } break; } return result; }, /** * Convert any ELEMENT references in 'args' to the actual elements * * @param object args * Arguments passed in by client * @param nsIDOMWindow, ShadowRoot container * The window and an optional shadow root that contains the element * * @returns object * Returns the objects passed in by the client, with the * reference IDs replaced by the actual elements. */ convertWrappedArguments: function EM_convertWrappedArguments(args, container) { let converted; switch (typeof(args)) { case 'number': case 'string': case 'boolean': converted = args; break; case 'object': if (args == null) { converted = null; } else if (Object.prototype.toString.call(args) == '[object Array]') { converted = []; for (let i in args) { converted.push(this.convertWrappedArguments(args[i], container)); } } else if (((typeof(args[element.LegacyKey]) === 'string') && args.hasOwnProperty(element.LegacyKey)) || ((typeof(args[element.Key]) === 'string') && args.hasOwnProperty(element.Key))) { let elementUniqueIdentifier = args[element.Key] ? args[element.Key] : args[element.LegacyKey]; converted = this.getKnownElement(elementUniqueIdentifier, container); if (converted == null) { throw new WebDriverError(`Unknown element: ${elementUniqueIdentifier}`); } } else { converted = {}; for (let prop in args) { converted[prop] = this.convertWrappedArguments(args[prop], container); } } break; } return converted; }, /* * Execute* helpers */ /** * Return an object with any namedArgs applied to it. Used * to let clients use given names when refering to arguments * in execute calls, instead of using the arguments list. * * @param object args * list of arguments being passed in * * @return object * If '__marionetteArgs' is in args, then * it will return an object with these arguments * as its members. */ applyNamedArgs: function EM_applyNamedArgs(args) { let namedArgs = {}; args.forEach(function(arg) { if (arg && typeof(arg['__marionetteArgs']) === 'object') { for (let prop in arg['__marionetteArgs']) { namedArgs[prop] = arg['__marionetteArgs'][prop]; } } }); return namedArgs; }, /** * 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.} 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.} opts * Options. * * @return {Promise: (WebElement|Array)} * Single element or a sequence of elements. * * @throws InvalidSelectorError * If |strategy| is unknown. * @throws InvalidSelectorError * If |selector| is malformed. * @throws NoSuchElementError * If a single element is requested, this error will throw if the * element is not found. */ find: function(container, strategy, selector, opts = {}) { opts.all = !!opts.all; opts.timeout = opts.timeout || 0; let searchFn; if (opts.all) { searchFn = this.findElements.bind(this); } else { searchFn = this.findElement.bind(this); } return new Promise((resolve, reject) => { let findElements = implicitlyWaitFor( () => this.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)); } // serialise elements for return let rv = []; for (let el of foundEls) { let ref = this.addToKnownElements(el); let we = element.makeWebElement(ref); rv.push(we); } if (opts.all) { resolve(rv); } resolve(rv[0]); }, reject); }); }, find_: function(container, strategy, selector, searchFn, opts) { let rootNode = container.shadowRoot || container.frame.document; let startNode; if (opts.startNode) { startNode = this.getKnownElement(opts.startNode, container); } else { startNode = rootNode; } if (!this.supportedStrategies.has(strategy)) { throw new InvalidSelectorError("Strategy not supported: " + strategy); } let res; try { res = searchFn(strategy, selector, rootNode, startNode); } catch (e) { throw new InvalidSelectorError( `Given ${strategy} expression "${selector}" is invalid`); } if (element.isElementCollection(res)) { return res; } else if (res) { return [res]; } return []; }, /** * Find a value by XPATH * * @param nsIDOMElement root * Document root * @param string value * XPATH search string * @param nsIDOMElement node * start node * * @return nsIDOMElement * returns the found element */ findByXPath: function EM_findByXPath(root, value, node) { return root.evaluate(value, node, null, Ci.nsIDOMXPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; }, /** * Find values by XPATH * * @param nsIDOMElement root * Document root * @param string value * XPATH search string * @param nsIDOMElement node * start node * * @return object * returns a list of found nsIDOMElements */ findByXPathAll: function EM_findByXPathAll(root, value, node) { let values = root.evaluate(value, node, null, Ci.nsIDOMXPathResult.ORDERED_NODE_ITERATOR_TYPE, null); let elements = []; let element = values.iterateNext(); while (element) { elements.push(element); element = values.iterateNext(); } return elements; }, /** * 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. */ findElement: function(using, value, rootNode, startNode) { switch (using) { case element.Strategy.ID: if (startNode.getElementById) { return startNode.getElementById(value); } return this.findByXPath(rootNode, `.//*[@id="${value}"]`, startNode); case element.Strategy.Name: if (startNode.getElementsByName) { return startNode.getElementsByName(value)[0]; } return this.findByXPath(rootNode, `.//*[@name="${value}"]`, startNode); 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 this.findByXPath(rootNode, value, startNode); // TODO(ato): Rewrite this, it's hairy: case element.Strategy.LinkText: case element.Strategy.PartialLinkText: let el; let allLinks = startNode.getElementsByTagName("A"); for (let i = 0; i < allLinks.length && !el; i++) { let text = allLinks[i].text; if (using == element.Strategy.PartialLinkText) { if (text.indexOf(value) != -1) { el = allLinks[i]; } } else if (text == value) { el = allLinks[i]; } } return el; case element.Strategy.Selector: try { return startNode.querySelector(value); } catch (e) { throw new InvalidSelectorError(`${e.message}: "${value}"`); } 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. */ findElements: function(using, value, rootNode, startNode) { switch (using) { case element.Strategy.ID: value = `.//*[@id="${value}"]`; // fall through case element.Strategy.XPath: return this.findByXPathAll(rootNode, value, startNode); case element.Strategy.Name: if (startNode.getElementsByName) { return startNode.getElementsByName(value); } return this.findByXPathAll(rootNode, `.//*[@name="${value}"]`, startNode); case element.Strategy.ClassName: return startNode.getElementsByClassName(value); case element.Strategy.TagName: return startNode.getElementsByTagName(value); case element.Strategy.LinkText: case element.Strategy.PartialLinkText: let els = []; let allLinks = startNode.getElementsByTagName("A"); for (let i = 0; i < allLinks.length; i++) { let text = allLinks[i].text; if (using == element.Strategy.PartialLinkText) { if (text.indexOf(value) != -1) { els.push(allLinks[i]); } } else if (text == value) { els.push(allLinks[i]); } } return els; case element.Strategy.Selector: return Array.slice(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); } // empty arrays evaluate to true in JS, // so we must first ascertan if the result is a collection // // we also return immediately if timeout is 0, // allowing |func| to be evaluated at least once let col = element.isElementCollection(res); if (((col && res.length > 0 ) || (!col && !!res)) || (startTime == endTime || 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; }); } element.isElementCollection = function(seq) { if (seq === null) { return false; } const arrayLike = { "[object Array]": 0, "[object HTMLCollection]": 1, "[object NodeList]": 2, }; let typ = Object.prototype.toString.call(seq); return typ in arrayLike; }; element.makeWebElement = function(uuid) { return { [element.Key]: uuid, [element.LegacyKey]: uuid, }; }; element.generateUUID = function() { let uuid = uuidGen.generateUUID().toString(); return uuid.substring(1, uuid.length - 1); }; /** * This function generates a pair of coordinates relative to the viewport * given a target element and coordinates relative to that element's * top-left corner. * * @param {Node} node * Target node. * @param {number=} xOffset * Horizontal offset relative to target's top-left corner. * Defaults to the centre of the target's bounding box. * @param {number=} yOffset * Vertical offset relative to target's top-left corner. Defaults to * the centre of the target's bounding box. * * @return {Object.} * X- and Y coordinates. * * @throws TypeError * If |xOffset| or |yOffset| are not numbers. */ element.coordinates = function( node, xOffset = undefined, yOffset = undefined) { let box = node.getBoundingClientRect(); if (typeof xOffset == "undefined" || xOffset === null) { xOffset = box.width / 2.0; } if (typeof yOffset == "undefined" || yOffset === null) { yOffset = box.height / 2.0; } if (typeof yOffset != "number" || typeof xOffset != "number") { throw new TypeError("Offset must be a number"); } return { x: box.left + xOffset, y: box.top + yOffset, }; }; /** * This function returns true if the node is in the viewport. * * @param {Element} el * Target element. * @param {number=} x * Horizontal offset relative to target. Defaults to the centre of * the target's bounding box. * @param {number=} y * Vertical offset relative to target. Defaults to the centre of * the target's bounding box. * * @return {boolean} * True if if |el| is in viewport, false otherwise. */ element.inViewport = function(el, x = undefined, y = undefined) { let win = el.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); }; /** * 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} element * Element to check if visible. * @param {Window} window * Window object. * @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)) { if (el.scrollIntoView) { el.scrollIntoView(false); if (!element.inViewport(el)) { return false; } } else { return false; } } return true; }; element.isInteractable = function(el) { return element.isPointerInteractable(el) || element.isKeyboardInteractable(el); }; /** * 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.getInteractableElementTree(el); return tree.length > 0; }; /** * 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.} * Sequence of non-opaque elements in paint order. */ element.getInteractableElementTree = function(el) { let doc = el.ownerDocument; let win = doc.defaultView; // step 1 // TODO // steps 2-3 let box = el.getBoundingClientRect(); let visible = { width: Math.max(box.x, box.x + box.width) - win.innerWidth, height: Math.max(box.y, box.y + box.height) - win.innerHeight, }; // steps 4-5 let offset = { vertical: visible.width / 2.0, horizontal: visible.height / 2.0, }; // step 6 let centre = { x: box.x + offset.horizontal, y: box.y + offset.vertical, }; // step 7 let tree = doc.elementsFromPoint(centre.x, centre.y); // filter out non-interactable elements let rv = []; for (let el of tree) { if (win.getComputedStyle(el).opacity === "1") { rv.push(el); } } return rv; }; // TODO(ato): Not implemented. // In fact, it's not defined in the spec. element.isKeyboardInteractable = function(el) { return true; }; element.isXULElement = function(el) { let ns = atom.getElementAttribute(el, "namespaceURI"); return ns.indexOf("there.is.only.xul") >= 0; };