/* 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/. */ var {utils: Cu} = Components; Cu.import("chrome://marionette/content/error.js"); Cu.import("resource://gre/modules/XPCOMUtils.jsm"); XPCOMUtils.defineLazyModuleGetter(this, 'setInterval', 'resource://gre/modules/Timer.jsm'); XPCOMUtils.defineLazyModuleGetter(this, 'clearInterval', 'resource://gre/modules/Timer.jsm'); /** * The ElementManager manages DOM references and interactions with elements. * According to the WebDriver spec (http://code.google.com/p/selenium/wiki/JsonWireProtocol), the * server sends the client an element reference, and maintains the map of reference to element. * The client uses this reference when querying/interacting with the element, and the * server uses maps this reference to the actual element when it executes the command. */ this.EXPORTED_SYMBOLS = [ "Accessibility", "ElementManager", "CLASS_NAME", "SELECTOR", "ID", "NAME", "LINK_TEXT", "PARTIAL_LINK_TEXT", "TAG", "XPATH", "ANON", "ANON_ATTRIBUTE" ]; const DOCUMENT_POSITION_DISCONNECTED = 1; const uuidGen = Components.classes["@mozilla.org/uuid-generator;1"] .getService(Components.interfaces.nsIUUIDGenerator); this.CLASS_NAME = "class name"; this.SELECTOR = "css selector"; this.ID = "id"; this.NAME = "name"; this.LINK_TEXT = "link text"; this.PARTIAL_LINK_TEXT = "partial link text"; this.TAG = "tag name"; this.XPATH = "xpath"; this.ANON= "anon"; this.ANON_ATTRIBUTE = "anon attribute"; this.Accessibility = function Accessibility() { // A flag indicating whether the accessibility issue should be logged or cause // an exception. Default: log to stdout. this.strict = false; // An interface for in-process accessibility clients // Note: we access it lazily to not enable accessibility when it is not needed Object.defineProperty(this, 'accessibleRetrieval', { configurable: true, get: function() { delete this.accessibleRetrieval; this.accessibleRetrieval = Components.classes[ '@mozilla.org/accessibleRetrieval;1'].getService( Components.interfaces.nsIAccessibleRetrieval); return this.accessibleRetrieval; } }); }; Accessibility.prototype = { /** * Number of attempts to get an accessible object for an element. We attempt * more than once because accessible tree can be out of sync with the DOM tree * for a short period of time. * @type {Number} */ GET_ACCESSIBLE_ATTEMPTS: 100, /** * An interval between attempts to retrieve an accessible object for an * element. * @type {Number} ms */ GET_ACCESSIBLE_ATTEMPT_INTERVAL: 10, /** * Accessible object roles that support some action * @type Object */ actionableRoles: new Set([ 'pushbutton', 'checkbutton', 'combobox', 'key', 'link', 'menuitem', 'check menu item', 'radio menu item', 'option', 'listbox option', 'listbox rich option', 'check rich option', 'combobox option', 'radiobutton', 'rowheader', 'switch', 'slider', 'spinbutton', 'pagetab', 'entry', 'outlineitem' ]), /** * Get an accessible object for a DOM element * @param nsIDOMElement element * @param Boolean mustHaveAccessible a flag indicating that the element must * have an accessible object * @return nsIAccessible object for the element */ getAccessibleObject(element, mustHaveAccessible = false) { return new Promise((resolve, reject) => { let acc = this.accessibleRetrieval.getAccessibleFor(element); if (acc || !mustHaveAccessible) { // If accessible object is found, return it. If it is not required, // also resolve. resolve(acc); } else { // If we require an accessible object, we need to poll for it because // accessible tree might be out of sync with DOM tree for a short time. let attempts = this.GET_ACCESSIBLE_ATTEMPTS; let intervalId = setInterval(() => { let acc = this.accessibleRetrieval.getAccessibleFor(element); if (acc || --attempts <= 0) { clearInterval(intervalId); if (acc) { resolve(acc); } else { reject(); } } }, this.GET_ACCESSIBLE_ATTEMPT_INTERVAL); } }).catch(() => this.handleErrorMessage( 'Element does not have an accessible object', element)); }, /** * Check if the accessible has a role that supports some action * @param nsIAccessible object * @return Boolean an indicator of role being actionable */ isActionableRole(accessible) { return this.actionableRoles.has( this.accessibleRetrieval.getStringRole(accessible.role)); }, /** * Determine if an accessible has at least one action that it supports * @param nsIAccessible object * @return Boolean an indicator of supporting at least one accessible action */ hasActionCount(accessible) { return accessible.actionCount > 0; }, /** * Determine if an accessible has a valid name * @param nsIAccessible object * @return Boolean an indicator that the element has a non empty valid name */ hasValidName(accessible) { return accessible.name && accessible.name.trim(); }, /** * Check if an accessible has a set hidden attribute * @param nsIAccessible object * @return Boolean an indicator that the element has a hidden accessible * attribute set to true */ hasHiddenAttribute(accessible) { let hidden; try { hidden = accessible.attributes.getStringProperty('hidden'); } finally { // If the property is missing, exception will be thrown. return hidden && hidden === 'true'; } }, /** * Verify if an accessible has a given state * @param nsIAccessible object * @param String stateName name of the state to match * @return Boolean accessible has a state */ matchState(accessible, stateName) { let stateToMatch = Components.interfaces.nsIAccessibleStates[stateName]; let state = {}; accessible.getState(state, {}); return !!(state.value & stateToMatch); }, /** * Check if an accessible is hidden from the user of the accessibility API * @param nsIAccessible object * @return Boolean an indicator that the element is hidden from the user */ isHidden(accessible) { while (accessible) { if (this.hasHiddenAttribute(accessible)) { return true; } accessible = accessible.parent; } return false; }, /** * Send an error message or log the error message in the log * @param String message * @param DOMElement element that caused an error */ handleErrorMessage(message, element) { if (!message) { return; } if (element) { message += ` -> id: ${element.id}, tagName: ${element.tagName}, className: ${element.className}\n`; } if (this.strict) { throw new ElementNotAccessibleError(message); } dump(Date.now() + " Marionette: " + message); } }; this.ElementManager = function ElementManager(notSupported) { this.seenItems = {}; this.timer = Components.classes["@mozilla.org/timer;1"].createInstance(Components.interfaces.nsITimer); this.elementKey = 'ELEMENT'; this.w3cElementKey = 'element-6066-11e4-a52e-4f735466cecf'; this.elementStrategies = [CLASS_NAME, SELECTOR, ID, NAME, LINK_TEXT, PARTIAL_LINK_TEXT, TAG, XPATH, ANON, ANON_ATTRIBUTE]; for (let i = 0; i < notSupported.length; i++) { this.elementStrategies.splice(this.elementStrategies.indexOf(notSupported[i]), 1); } } 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(element) { for (let i in this.seenItems) { let foundEl = null; try { foundEl = this.seenItems[i].get(); } catch (e) {} if (foundEl) { if (XPCNativeWrapper(foundEl) == XPCNativeWrapper(element)) { return i; } } else { // cleanup reference to GC'd element delete this.seenItems[i]; } } let uuid = uuidGen.generateUUID().toString(); let id = uuid.substring(1, uuid.length - 1); this.seenItems[id] = Components.utils.getWeakReference(element); 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 = {[this.elementKey]: elementId, [this.w3cElementKey]: 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[this.elementKey]) === 'string') && args.hasOwnProperty(this.elementKey)) || ((typeof(args[this.w3cElementKey]) === 'string') && args.hasOwnProperty(this.w3cElementKey))) { let elementUniqueIdentifier = args[this.w3cElementKey] ? args[this.w3cElementKey] : args[this.elementKey]; 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) { 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 an element or elements starting at the document root or * given node, using the given search strategy. Search * will continue until the search timelimit has been reached. * * @param nsIDOMWindow, ShadowRoot container * The window and an optional shadow root that contains the element * @param object values * The 'using' member of values will tell us which search * method to use. The 'value' member tells us the value we * are looking for. * If this object has an 'element' member, this will be used * as the start node instead of the document root * If this object has a 'time' member, this number will be * used to see if we have hit the search timelimit. * @param boolean all * If true, all found elements will be returned. * If false, only the first element will be returned. * @param function on_success * Callback used when operating is successful. * @param function on_error * Callback to invoke when an error occurs. * * @return nsIDOMElement or list of nsIDOMElements * Returns the element(s) by calling the on_success function. */ find: function EM_find(container, values, searchTimeout, all, on_success, on_error, command_id) { let startTime = values.time ? values.time : new Date().getTime(); let rootNode = container.shadowRoot || container.frame.document; let startNode = (values.element != undefined) ? this.getKnownElement(values.element, container) : rootNode; if (this.elementStrategies.indexOf(values.using) < 0) { throw new InvalidSelectorError("No such strategy: " + values.using); } let found = all ? this.findElements(values.using, values.value, rootNode, startNode) : this.findElement(values.using, values.value, rootNode, startNode); let type = Object.prototype.toString.call(found); let isArrayLike = ((type == '[object Array]') || (type == '[object HTMLCollection]') || (type == '[object NodeList]')); if (found == null || (isArrayLike && found.length <= 0)) { if (!searchTimeout || new Date().getTime() - startTime > searchTimeout) { if (all) { on_success([], command_id); // findElements should return empty list } else { // Format message depending on strategy if necessary let message = "Unable to locate element: " + values.value; if (values.using == ANON) { message = "Unable to locate anonymous children"; } else if (values.using == ANON_ATTRIBUTE) { message = "Unable to locate anonymous element: " + JSON.stringify(values.value); } on_error(new NoSuchElementError(message), command_id); } } else { values.time = startTime; this.timer.initWithCallback(this.find.bind(this, container, values, searchTimeout, all, on_success, on_error, command_id), 100, Components.interfaces.nsITimer.TYPE_ONE_SHOT); } } else { if (isArrayLike) { let ids = [] for (let i = 0 ; i < found.length ; i++) { let foundElement = this.addToKnownElements(found[i]); let returnElement = { [this.elementKey] : foundElement, [this.w3cElementKey] : foundElement, }; ids.push(returnElement); } on_success(ids, command_id); } else { let id = this.addToKnownElements(found); on_success({[this.elementKey]: id, [this.w3cElementKey]:id}, command_id); } } }, /** * 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, Components.interfaces.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, Components.interfaces.nsIDOMXPathResult.ORDERED_NODE_ITERATOR_TYPE, null); let elements = []; let element = values.iterateNext(); while (element) { elements.push(element); element = values.iterateNext(); } return elements; }, /** * Helper method to find. Finds one element using find's criteria * * @param string using * String identifying which search method to use * @param string value * Value to look for * @param nsIDOMElement rootNode * Document root * @param nsIDOMElement startNode * Node from which we start searching * * @return nsIDOMElement * Returns found element or throws Exception if not found */ findElement: function EM_findElement(using, value, rootNode, startNode) { let element; switch (using) { case ID: element = startNode.getElementById ? startNode.getElementById(value) : this.findByXPath(rootNode, './/*[@id="' + value + '"]', startNode); break; case NAME: element = startNode.getElementsByName ? startNode.getElementsByName(value)[0] : this.findByXPath(rootNode, './/*[@name="' + value + '"]', startNode); break; case CLASS_NAME: element = startNode.getElementsByClassName(value)[0]; //works for >=FF3 break; case TAG: element = startNode.getElementsByTagName(value)[0]; //works for all elements break; case XPATH: element = this.findByXPath(rootNode, value, startNode); break; case LINK_TEXT: case PARTIAL_LINK_TEXT: let allLinks = startNode.getElementsByTagName('A'); for (let i = 0; i < allLinks.length && !element; i++) { let text = allLinks[i].text; if (PARTIAL_LINK_TEXT == using) { if (text.indexOf(value) != -1) { element = allLinks[i]; } } else if (text == value) { element = allLinks[i]; } } break; case SELECTOR: element = startNode.querySelector(value); break; case ANON: element = rootNode.getAnonymousNodes(startNode); if (element != null) { element = element[0]; } break; case ANON_ATTRIBUTE: let attr = Object.keys(value)[0]; element = rootNode.getAnonymousElementByAttribute(startNode, attr, value[attr]); break; default: throw new InvalidSelectorError("No such strategy: " + using); } return element; }, /** * Helper method to find. Finds all element using find's criteria * * @param string using * String identifying which search method to use * @param string value * Value to look for * @param nsIDOMElement rootNode * Document root * @param nsIDOMElement startNode * Node from which we start searching * * @return nsIDOMElement * Returns found elements or throws Exception if not found */ findElements: function EM_findElements(using, value, rootNode, startNode) { let elements = []; switch (using) { case ID: value = './/*[@id="' + value + '"]'; case XPATH: elements = this.findByXPathAll(rootNode, value, startNode); break; case NAME: elements = startNode.getElementsByName ? startNode.getElementsByName(value) : this.findByXPathAll(rootNode, './/*[@name="' + value + '"]', startNode); break; case CLASS_NAME: elements = startNode.getElementsByClassName(value); break; case TAG: elements = startNode.getElementsByTagName(value); break; case LINK_TEXT: case PARTIAL_LINK_TEXT: let allLinks = startNode.getElementsByTagName('A'); for (let i = 0; i < allLinks.length; i++) { let text = allLinks[i].text; if (PARTIAL_LINK_TEXT == using) { if (text.indexOf(value) != -1) { elements.push(allLinks[i]); } } else if (text == value) { elements.push(allLinks[i]); } } break; case SELECTOR: elements = Array.slice(startNode.querySelectorAll(value)); break; case ANON: elements = rootNode.getAnonymousNodes(startNode) || []; break; case ANON_ATTRIBUTE: let attr = Object.keys(value)[0]; let el = rootNode.getAnonymousElementByAttribute(startNode, attr, value[attr]); if (el != null) { elements = [el]; } break; default: throw new InvalidSelectorError("No such strategy: " + using); } return elements; }, }