gecko-dev/testing/marionette/element.js
Andreas Tolfsen 820971ad9f Bug 1257153 - Cancel implicit wait timers on rejection; r=automatedtester
This fixes a leak where timers were not cancelled upon promise rejection.
When the function passed to implicitlyWaitFor is rejected for not finding
elements after the timeout has elapsed, the elementSearch callback is
no longer called because the timer is cancelled when it calls reject().

MozReview-Commit-ID: FR2iA8s1NjX

--HG--
extra : rebase_source : 80f4b13d15cf3497a8d4b20bca98450ad3bc1b5e
2016-03-16 12:27:36 +00:00

939 lines
27 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} = 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.<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: (WebElement|Array<WebElement>)}
* 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.<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);
};
/**
* 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.<DOMElement>}
* 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;
};