diff --git a/testing/marionette/accessibility.js b/testing/marionette/accessibility.js new file mode 100644 index 000000000000..1cd204479558 --- /dev/null +++ b/testing/marionette/accessibility.js @@ -0,0 +1,335 @@ +/* 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/. */ + +/* global Accessibility, Components, Log, ElementNotAccessibleError, + XPCOMUtils */ + +var {classes: Cc, interfaces: Ci, utils: Cu} = Components; + +Cu.import('resource://gre/modules/XPCOMUtils.jsm'); +Cu.import('resource://gre/modules/Log.jsm'); + +XPCOMUtils.defineLazyModuleGetter(this, 'setInterval', + 'resource://gre/modules/Timer.jsm'); +XPCOMUtils.defineLazyModuleGetter(this, 'clearInterval', + 'resource://gre/modules/Timer.jsm'); +XPCOMUtils.defineLazyModuleGetter(this, 'ElementNotAccessibleError', + 'chrome://marionette/content/error.js'); + +this.EXPORTED_SYMBOLS = ['Accessibility']; + +/** + * Accessible states used to check element's state from the accessiblity API + * perspective. + */ +const states = { + unavailable: Ci.nsIAccessibleStates.STATE_UNAVAILABLE, + focusable: Ci.nsIAccessibleStates.STATE_FOCUSABLE, + selectable: Ci.nsIAccessibleStates.STATE_SELECTABLE, + selected: Ci.nsIAccessibleStates.STATE_SELECTED +}; + +var logger = Log.repository.getLogger('Marionette'); + +/** + * Component responsible for interacting with platform accessibility API. Its + * methods serve as wrappers for testing content and chrome accessibility as + * well as accessibility of user interactions. + * + * @param {Function} getCapabilies + * Session capabilities getter. + */ +this.Accessibility = function Accessibility(getCapabilies = () => {}) { + // A flag indicating whether the accessibility issue should be logged or cause + // an exception. Default: log to stdout. + Object.defineProperty(this, 'strict', { + configurable: true, + get: function() { + let capabilies = getCapabilies(); + return !!capabilies.raisesAccessibilityExceptions; + } + }); + // An interface for in-process accessibility clients + // Note: we access it lazily to not enable accessibility when it is not needed + Object.defineProperty(this, 'retrieval', { + configurable: true, + get: function() { + delete this.retrieval; + this.retrieval = Cc[ + '@mozilla.org/accessibleRetrieval;1'].getService( + Ci.nsIAccessibleRetrieval); + return this.retrieval; + } + }); +}; + +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 + */ + ACTIONABLE_ROLES: 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.retrieval.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.retrieval.getAccessibleFor(element); + if (acc || --attempts <= 0) { + clearInterval(intervalId); + if (acc) { resolve(acc); } + else { reject(); } + } + }, this.GET_ACCESSIBLE_ATTEMPT_INTERVAL); + } + }).catch(() => this.error( + '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.ACTIONABLE_ROLES.has( + this.retrieval.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 = false; + 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 Number stateToMatch the state to match + * @return Boolean accessible has a state + */ + matchState(accessible, stateToMatch) { + 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 + */ + error(message, element) { + if (!message) { + return; + } + if (element) { + let {id, tagName, className} = element; + message += `: id: ${id}, tagName: ${tagName}, className: ${className}`; + } + if (this.strict) { + throw new ElementNotAccessibleError(message); + } + logger.error(message); + }, + + /** + * Check if the element's visible state corresponds to its accessibility API + * visibility + * @param nsIAccessible object + * @param WebElement corresponding to nsIAccessible object + * @param Boolean visible element's visibility state + */ + checkVisible(accessible, element, visible) { + if (!accessible) { + return; + } + let hiddenAccessibility = this.isHidden(accessible); + let message; + if (visible && hiddenAccessibility) { + message = 'Element is not currently visible via the accessibility API ' + + 'and may not be manipulated by it'; + } else if (!visible && !hiddenAccessibility) { + message = 'Element is currently only visible via the accessibility API ' + + 'and can be manipulated by it'; + } + this.error(message, element); + }, + + /** + * Check if the element's unavailable accessibility state matches the enabled + * state + * @param nsIAccessible object + * @param WebElement corresponding to nsIAccessible object + * @param Boolean enabled element's enabled state + * @param Object container frame and optional ShadowDOM + */ + checkEnabled(accessible, element, enabled, container) { + if (!accessible) { + return; + } + let disabledAccessibility = this.matchState(accessible, states.unavailable); + let explorable = container.frame.document.defaultView.getComputedStyle( + element).getPropertyValue('pointer-events') !== 'none'; + let message; + + if (!explorable && !disabledAccessibility) { + message = 'Element is enabled but is not explorable via the ' + + 'accessibility API'; + } else if (enabled && disabledAccessibility) { + message = 'Element is enabled but disabled via the accessibility API'; + } else if (!enabled && !disabledAccessibility) { + message = 'Element is disabled but enabled via the accessibility API'; + } + this.error(message, element); + }, + + /** + * Check if it is possible to activate an element with the accessibility API + * @param nsIAccessible object + * @param WebElement corresponding to nsIAccessible object + */ + checkActionable(accessible, element) { + if (!accessible) { + return; + } + let message; + if (!this.hasActionCount(accessible)) { + message = 'Element does not support any accessible actions'; + } else if (!this.isActionableRole(accessible)) { + message = 'Element does not have a correct accessibility role ' + + 'and may not be manipulated via the accessibility API'; + } else if (!this.hasValidName(accessible)) { + message = 'Element is missing an accessible name'; + } else if (!this.matchState(accessible, states.focusable)) { + message = 'Element is not focusable via the accessibility API'; + } + this.error(message, element); + }, + + /** + * Check if element's selected state corresponds to its accessibility API + * selected state. + * @param nsIAccessible object + * @param WebElement corresponding to nsIAccessible object + * @param Boolean selected element's selected state + */ + checkSelected(accessible, element, selected) { + if (!accessible) { + return; + } + if (!this.matchState(accessible, states.selectable)) { + // Element is not selectable via the accessibility API + return; + } + + let selectedAccessibility = this.matchState(accessible, states.selected); + let message; + if (selected && !selectedAccessibility) { + message = + 'Element is selected but not selected via the accessibility API'; + } else if (!selected && selectedAccessibility) { + message = + 'Element is not selected but selected via the accessibility API'; + } + this.error(message, element); + } +}; diff --git a/testing/marionette/driver.js b/testing/marionette/driver.js index 74278d6775c7..589cf7fb7424 100644 --- a/testing/marionette/driver.js +++ b/testing/marionette/driver.js @@ -23,6 +23,7 @@ XPCOMUtils.defineLazyServiceGetter( this, "cookieManager", "@mozilla.org/cookiemanager;1", "nsICookieManager2"); Cu.import("chrome://marionette/content/actions.js"); +Cu.import("chrome://marionette/content/interactions.js"); Cu.import("chrome://marionette/content/elements.js"); Cu.import("chrome://marionette/content/error.js"); Cu.import("chrome://marionette/content/modal.js"); @@ -164,6 +165,8 @@ this.GeckoDriver = function(appName, device, stopSignal, emulator) { "version": Services.appinfo.version, }; + this.interactions = new Interactions(utils, () => this.sessionCapabilities); + this.mm = globalMessageManager; this.listener = proxy.toListener(() => this.mm, this.sendAsync.bind(this)); @@ -1981,10 +1984,9 @@ GeckoDriver.prototype.clickElement = function(cmd, resp) { switch (this.context) { case Context.CHROME: - // click atom fails, fall back to click() action let win = this.getCurrentWindow(); - let el = this.curBrowser.elementManager.getKnownElement(id, { frame: win }); - el.click(); + yield this.interactions.clickElement({ frame: win }, + this.curBrowser.elementManager, id) break; case Context.CONTENT: @@ -2082,8 +2084,8 @@ GeckoDriver.prototype.isElementDisplayed = function(cmd, resp) { switch (this.context) { case Context.CHROME: let win = this.getCurrentWindow(); - let el = this.curBrowser.elementManager.getKnownElement(id, {frame: win}); - resp.body.value = utils.isElementDisplayed(el); + resp.body.value = yield this.interactions.isElementDisplayed( + {frame: win}, this.curBrowser.elementManager, id); break; case Context.CONTENT: @@ -2130,8 +2132,8 @@ GeckoDriver.prototype.isElementEnabled = function(cmd, resp) { case Context.CHROME: // Selenium atom doesn't quite work here let win = this.getCurrentWindow(); - let el = this.curBrowser.elementManager.getKnownElement(id, {frame: win}); - resp.body.value = !(!!el.disabled); + resp.body.value = yield this.interactions.isElementEnabled( + {frame: win}, this.curBrowser.elementManager, id); break; case Context.CONTENT: @@ -2153,14 +2155,8 @@ GeckoDriver.prototype.isElementSelected = function(cmd, resp) { case Context.CHROME: // Selenium atom doesn't quite work here let win = this.getCurrentWindow(); - let el = this.curBrowser.elementManager.getKnownElement(id, { frame: win }); - if (typeof el.checked != "undefined") { - resp.body.value = !!el.checked; - } else if (typeof el.selected != "undefined") { - resp.body.value = !!el.selected; - } else { - resp.body.value = true; - } + resp.body.value = yield this.interactions.isElementSelected( + { frame: win }, this.curBrowser.elementManager, id); break; case Context.CONTENT: @@ -2209,15 +2205,8 @@ GeckoDriver.prototype.sendKeysToElement = function(cmd, resp) { switch (this.context) { case Context.CHROME: let win = this.getCurrentWindow(); - let el = this.curBrowser.elementManager.getKnownElement(id, { frame: win }); - utils.sendKeysToElement( - win, - el, - value, - () => {}, - e => { throw e; }, - cmd.id, - true /* ignore visibility check */); + yield this.interactions.sendKeysToElement( + { frame: win }, this.curBrowser.elementManager, id, value, true); break; case Context.CONTENT: @@ -2806,9 +2795,6 @@ GeckoDriver.prototype.sendKeysToDialog = function(cmd, resp) { win, loginTextbox, cmd.parameters.value, - () => {}, - e => { throw e; }, - this.command_id, true /* ignore visibility check */); }; diff --git a/testing/marionette/elements.js b/testing/marionette/elements.js index 07589ea8aede..e5dc6242d1c4 100644 --- a/testing/marionette/elements.js +++ b/testing/marionette/elements.js @@ -5,12 +5,6 @@ let {classes: Cc, interfaces: Ci, 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 element references and @@ -30,7 +24,6 @@ XPCOMUtils.defineLazyModuleGetter(this, 'clearInterval', */ this.EXPORTED_SYMBOLS = [ - "Accessibility", "elements", "ElementManager", "CLASS_NAME", @@ -47,8 +40,8 @@ this.EXPORTED_SYMBOLS = [ const DOCUMENT_POSITION_DISCONNECTED = 1; -const uuidGen = Components.classes["@mozilla.org/uuid-generator;1"] - .getService(Components.interfaces.nsIUUIDGenerator); +const uuidGen = Cc["@mozilla.org/uuid-generator;1"] + .getService(Ci.nsIUUIDGenerator); this.CLASS_NAME = "class name"; this.SELECTOR = "css selector"; @@ -61,192 +54,6 @@ 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 = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); @@ -291,7 +98,7 @@ ElementManager.prototype = { } } let id = elements.generateUUID(); - this.seenItems[id] = Components.utils.getWeakReference(element); + this.seenItems[id] = Cu.getWeakReference(element); return id; }, @@ -562,7 +369,7 @@ ElementManager.prototype = { on_success, on_error, command_id), 100, - Components.interfaces.nsITimer.TYPE_ONE_SHOT); + Ci.nsITimer.TYPE_ONE_SHOT); } } else { if (isArrayLike) { @@ -598,7 +405,7 @@ ElementManager.prototype = { */ findByXPath: function EM_findByXPath(root, value, node) { return root.evaluate(value, node, null, - Components.interfaces.nsIDOMXPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; + Ci.nsIDOMXPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; }, /** @@ -616,7 +423,7 @@ ElementManager.prototype = { */ findByXPathAll: function EM_findByXPathAll(root, value, node) { let values = root.evaluate(value, node, null, - Components.interfaces.nsIDOMXPathResult.ORDERED_NODE_ITERATOR_TYPE, null); + Ci.nsIDOMXPathResult.ORDERED_NODE_ITERATOR_TYPE, null); let elements = []; let element = values.iterateNext(); while (element) { diff --git a/testing/marionette/interactions.js b/testing/marionette/interactions.js new file mode 100644 index 000000000000..1679fb9e2f23 --- /dev/null +++ b/testing/marionette/interactions.js @@ -0,0 +1,317 @@ +/* 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/. */ + +/* global Components, Accessibility, ElementNotVisibleError, + InvalidElementStateError, Interactions */ + +var {utils: Cu} = Components; + +this.EXPORTED_SYMBOLS = ['Interactions']; + +Cu.import('chrome://marionette/content/accessibility.js'); +Cu.import('chrome://marionette/content/error.js'); + +/** + * XUL elements that support disabled attribtue. + */ +const DISABLED_ATTRIBUTE_SUPPORTED_XUL = new Set([ + 'ARROWSCROLLBOX', + 'BUTTON', + 'CHECKBOX', + 'COLORPICKER', + 'COMMAND', + 'DATEPICKER', + 'DESCRIPTION', + 'KEY', + 'KEYSET', + 'LABEL', + 'LISTBOX', + 'LISTCELL', + 'LISTHEAD', + 'LISTHEADER', + 'LISTITEM', + 'MENU', + 'MENUITEM', + 'MENULIST', + 'MENUSEPARATOR', + 'PREFERENCE', + 'RADIO', + 'RADIOGROUP', + 'RICHLISTBOX', + 'RICHLISTITEM', + 'SCALE', + 'TAB', + 'TABS', + 'TEXTBOX', + 'TIMEPICKER', + 'TOOLBARBUTTON', + 'TREE' +]); + +/** + * XUL elements that support checked property. + */ +const CHECKED_PROPERTY_SUPPORTED_XUL = new Set([ + 'BUTTON', + 'CHECKBOX', + 'LISTITEM', + 'TOOLBARBUTTON' +]); + +/** + * XUL elements that support selected property. + */ +const SELECTED_PROPERTY_SUPPORTED_XUL = new Set([ + 'LISTITEM', + 'MENU', + 'MENUITEM', + 'MENUSEPARATOR', + 'RADIO', + 'RICHLISTITEM', + 'TAB' +]); + +/** + * 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 'x', and 'y' are the relative to the target. + * If they are not specified, then the center of the target is used. + */ +function coordinates(target, x, y) { + let box = target.getBoundingClientRect(); + if (typeof x === 'undefined') { + x = box.width / 2; + } + if (typeof y === 'undefined') { + y = box.height / 2; + } + return { + x: box.left + x, + y: box.top + y + }; +} + +/** + * A collection of interactions available in marionette. + * @type {Object} + */ +this.Interactions = function(utils, getCapabilies) { + this.utils = utils; + this.accessibility = new Accessibility(getCapabilies); +}; + +Interactions.prototype = { + /** + * Send click event to element. + * + * @param nsIDOMWindow, ShadowRoot container + * The window and an optional shadow root that contains the element + * + * @param ElementManager elementManager + * + * @param String id + * The DOM reference ID + */ + clickElement(container, elementManager, id) { + let el = elementManager.getKnownElement(id, container); + let visible = this.checkVisible(container, el); + if (!visible) { + throw new ElementNotVisibleError('Element is not visible'); + } + return this.accessibility.getAccessibleObject(el, true).then(acc => { + this.accessibility.checkVisible(acc, el, visible); + if (this.utils.isElementEnabled(el)) { + this.accessibility.checkEnabled(acc, el, true, container); + this.accessibility.checkActionable(acc, el); + if (this.isXULElement(el)) { + el.click(); + } else { + let rects = el.getClientRects(); + this.utils.synthesizeMouseAtPoint(rects[0].left + rects[0].width/2, + rects[0].top + rects[0].height/2, + {}, el.ownerDocument.defaultView); + } + } else { + throw new InvalidElementStateError('Element is not enabled'); + } + }); + }, + + /** + * Send keys to element + * + * @param nsIDOMWindow, ShadowRoot container + * The window and an optional shadow root that contains the element + * + * @param ElementManager elementManager + * + * @param String id + * The DOM reference ID + * + * @param String?Array value + * Value to send to an element + * + * @param Boolean ignoreVisibility + * A flag to check element visibility + */ + sendKeysToElement(container, elementManager, id, value, ignoreVisibility) { + let el = elementManager.getKnownElement(id, container); + return this.accessibility.getAccessibleObject(el, true).then(acc => { + this.accessibility.checkActionable(acc, el); + this.utils.sendKeysToElement( + container.frame, el, value, ignoreVisibility); + }); + }, + + /** + * Determine the element displayedness of the given web element. + * + * @param nsIDOMWindow, ShadowRoot container + * The window and an optional shadow root that contains the element + * + * @param ElementManager elementManager + * + * @param {WebElement} id + * Reference to web element. + * + * Also performs additional accessibility checks if enabled by session + * capability. + */ + isElementDisplayed(container, elementManager, id) { + let el = elementManager.getKnownElement(id, container); + let displayed = this.utils.isElementDisplayed(el); + return this.accessibility.getAccessibleObject(el).then(acc => { + this.accessibility.checkVisible(acc, el, displayed); + return displayed; + }); + }, + + /** + * Check if element is enabled. + * + * @param nsIDOMWindow, ShadowRoot container + * The window and an optional shadow root that contains the element + * + * @param ElementManager elementManager + * + * @param {WebElement} id + * Reference to web element. + * + * @return {boolean} + * True if enabled, false otherwise. + */ + isElementEnabled(container, elementManager, id) { + let el = elementManager.getKnownElement(id, container); + let enabled = true; + if (this.isXULElement(el)) { + // Check if XUL element supports disabled attribute + if (DISABLED_ATTRIBUTE_SUPPORTED_XUL.has(el.tagName.toUpperCase())) { + let disabled = this.utils.getElementAttribute(el, 'disabled'); + if (disabled && disabled === 'true') { + enabled = false; + } + } + } else { + enabled = this.utils.isElementEnabled(el); + } + return this.accessibility.getAccessibleObject(el).then(acc => { + this.accessibility.checkEnabled(acc, el, enabled, container); + return enabled; + }); + }, + + /** + * Determines if the referenced element is selected or not. + * + * This operation only makes sense on input elements of the Checkbox- + * and Radio Button states, or option elements. + * + * @param nsIDOMWindow, ShadowRoot container + * The window and an optional shadow root that contains the element + * + * @param ElementManager elementManager + * + * @param {WebElement} id + * Reference to web element. + */ + isElementSelected(container, elementManager, id) { + let el = elementManager.getKnownElement(id, container); + let selected = true; + if (this.isXULElement(el)) { + let tagName = el.tagName.toUpperCase(); + if (CHECKED_PROPERTY_SUPPORTED_XUL.has(tagName)) { + selected = el.checked; + } + if (SELECTED_PROPERTY_SUPPORTED_XUL.has(tagName)) { + selected = el.selected; + } + } else { + selected = this.utils.isElementSelected(el); + } + return this.accessibility.getAccessibleObject(el).then(acc => { + this.accessibility.checkSelected(acc, el, selected); + return selected; + }); + }, + + /** + * 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 'x', and 'y' are the coordinates relative to the target. + * If they are not specified, then the center of the target is used. + */ + checkVisible(container, el, x, y) { + // Bug 1094246 - Webdriver's isShown doesn't work with content xul + if (!this.isXULElement(el)) { + //check if the element is visible + let visible = this.utils.isElementDisplayed(el); + if (!visible) { + return false; + } + } + + if (el.tagName.toLowerCase() === 'body') { + return true; + } + if (!this.elementInViewport(container, el, x, y)) { + //check if scroll function exist. If so, call it. + if (el.scrollIntoView) { + el.scrollIntoView(false); + if (!this.elementInViewport(container, el)) { + return false; + } + } + else { + return false; + } + } + return true; + }, + + isXULElement(el) { + return this.utils.getElementAttribute(el, 'namespaceURI').indexOf( + 'there.is.only.xul') >= 0; + }, + + /** + * This function returns true if the given coordinates are in the viewport. + * @param 'x', and 'y' are the coordinates relative to the target. + * If they are not specified, then the center of the target is used. + */ + elementInViewport(container, el, x, y) { + let c = coordinates(el, x, y); + let win = container.frame; + let viewPort = { + top: win.pageYOffset, + left: win.pageXOffset, + bottom: win.pageYOffset + win.innerHeight, + right: win.pageXOffset + win.innerWidth + }; + return (viewPort.left <= c.x + win.pageXOffset && + c.x + win.pageXOffset <= viewPort.right && + viewPort.top <= c.y + win.pageYOffset && + c.y + win.pageYOffset <= viewPort.bottom); + } +}; diff --git a/testing/marionette/jar.mn b/testing/marionette/jar.mn index c41414f21d55..3d3b34358c82 100644 --- a/testing/marionette/jar.mn +++ b/testing/marionette/jar.mn @@ -7,6 +7,8 @@ marionette.jar: content/server.js (server.js) content/driver.js (driver.js) content/actions.js (actions.js) + content/interactions.js (interactions.js) + content/accessibility.js (accessibility.js) content/listener.js (listener.js) content/elements.js (elements.js) content/sendkeys.js (sendkeys.js) diff --git a/testing/marionette/listener.js b/testing/marionette/listener.js index 80837a30762d..6189f465caba 100644 --- a/testing/marionette/listener.js +++ b/testing/marionette/listener.js @@ -18,6 +18,7 @@ Cu.import("chrome://marionette/content/cookies.js"); Cu.import("chrome://marionette/content/elements.js"); Cu.import("chrome://marionette/content/error.js"); Cu.import("chrome://marionette/content/proxy.js"); +Cu.import("chrome://marionette/content/interactions.js"); Cu.import("resource://gre/modules/FileUtils.jsm"); Cu.import("resource://gre/modules/NetUtil.jsm"); @@ -43,7 +44,11 @@ var curContainer = { frame: content, shadowRoot: null }; var isRemoteBrowser = () => curContainer.frame.contentWindow !== null; var previousContainer = null; var elementManager = new ElementManager([]); -var accessibility = new Accessibility(); + +// Holds session capabilities. +var capabilities = {}; +var interactions = new Interactions(utils, () => capabilities); + var actions = new ActionChain(utils, checkForInterrupted); var importedScripts = null; @@ -108,9 +113,8 @@ function registerSelf() { if (register[0]) { let {id, remotenessChange} = register[0][0]; - let {B2G, raisesAccessibilityExceptions} = register[0][2]; - isB2G = B2G; - accessibility.strict = raisesAccessibilityExceptions; + capabilities = register[0][2]; + isB2G = capabilities.B2G; listenerId = id; if (typeof id != "undefined") { // check if we're the main process @@ -299,8 +303,8 @@ function waitForReady() { * current environment, and resets all values */ function newSession(msg) { - isB2G = msg.json.B2G; - accessibility.strict = msg.json.raisesAccessibilityExceptions; + capabilities = msg.json; + isB2G = capabilities.B2G; resetValues(); if (isB2G) { readyStateTimer.initWithCallback(waitForReady, 100, Ci.nsITimer.TYPE_ONE_SHOT); @@ -938,9 +942,9 @@ function singleTap(id, corx, cory) { if (!visible) { throw new ElementNotVisibleError("Element is not currently visible and may not be manipulated"); } - return accessibility.getAccessibleObject(el, true).then(acc => { - checkVisibleAccessibility(acc, el, visible); - checkActionableAccessibility(acc, el); + return interactions.accessibility.getAccessibleObject(el, true).then(acc => { + interactions.accessibility.checkVisible(acc, el, visible); + interactions.accessibility.checkActionable(acc, el); if (!curContainer.frame.document.createTouch) { actions.mouseEventsOnly = true; } @@ -955,108 +959,6 @@ function singleTap(id, corx, cory) { }); } -/** - * Check if the element's unavailable accessibility state matches the enabled - * state - * @param nsIAccessible object - * @param WebElement corresponding to nsIAccessible object - * @param Boolean enabled element's enabled state - */ -function checkEnabledAccessibility(accesible, element, enabled) { - if (!accesible) { - return; - } - let disabledAccessibility = accessibility.matchState( - accesible, 'STATE_UNAVAILABLE'); - let explorable = curContainer.frame.document.defaultView.getComputedStyle( - element, null).getPropertyValue('pointer-events') !== 'none'; - let message; - - if (!explorable && !disabledAccessibility) { - message = 'Element is enabled but is not explorable via the ' + - 'accessibility API'; - } else if (enabled && disabledAccessibility) { - message = 'Element is enabled but disabled via the accessibility API'; - } else if (!enabled && !disabledAccessibility) { - message = 'Element is disabled but enabled via the accessibility API'; - } - accessibility.handleErrorMessage(message, element); -} - -/** - * Check if the element's visible state corresponds to its accessibility API - * visibility - * @param nsIAccessible object - * @param WebElement corresponding to nsIAccessible object - * @param Boolean visible element's visibility state - */ -function checkVisibleAccessibility(accesible, element, visible) { - if (!accesible) { - return; - } - let hiddenAccessibility = accessibility.isHidden(accesible); - let message; - if (visible && hiddenAccessibility) { - message = 'Element is not currently visible via the accessibility API ' + - 'and may not be manipulated by it'; - } else if (!visible && !hiddenAccessibility) { - message = 'Element is currently only visible via the accessibility API ' + - 'and can be manipulated by it'; - } - accessibility.handleErrorMessage(message, element); -} - -/** - * Check if it is possible to activate an element with the accessibility API - * @param nsIAccessible object - * @param WebElement corresponding to nsIAccessible object - */ -function checkActionableAccessibility(accesible, element) { - if (!accesible) { - return; - } - let message; - if (!accessibility.hasActionCount(accesible)) { - message = 'Element does not support any accessible actions'; - } else if (!accessibility.isActionableRole(accesible)) { - message = 'Element does not have a correct accessibility role ' + - 'and may not be manipulated via the accessibility API'; - } else if (!accessibility.hasValidName(accesible)) { - message = 'Element is missing an accesible name'; - } else if (!accessibility.matchState(accesible, 'STATE_FOCUSABLE')) { - message = 'Element is not focusable via the accessibility API'; - } - accessibility.handleErrorMessage(message, element); -} - -/** - * Check if element's selected state corresponds to its accessibility API - * selected state. - * @param nsIAccessible object - * @param WebElement corresponding to nsIAccessible object - * @param Boolean selected element's selected state - */ -function checkSelectedAccessibility(accessible, element, selected) { - if (!accessible) { - return; - } - if (!accessibility.matchState(accessible, 'STATE_SELECTABLE')) { - // Element is not selectable via the accessibility API - return; - } - - let selectedAccessibility = accessibility.matchState( - accessible, 'STATE_SELECTED'); - let message; - if (selected && !selectedAccessibility) { - message = 'Element is selected but not selected via the accessibility API'; - } else if (!selected && selectedAccessibility) { - message = 'Element is not selected but selected via the accessibility API'; - } - accessibility.handleErrorMessage(message, element); -} - - /** * Function to create a touch based on the element * corx and cory are relative to the viewport, id is the touchId @@ -1460,24 +1362,7 @@ function getActiveElement() { * Reference to the web element to click. */ function clickElement(id) { - let el = elementManager.getKnownElement(id, curContainer); - let visible = checkVisible(el); - if (!visible) { - throw new ElementNotVisibleError("Element is not visible"); - } - return accessibility.getAccessibleObject(el, true).then(acc => { - checkVisibleAccessibility(acc, el, visible); - if (utils.isElementEnabled(el)) { - checkEnabledAccessibility(acc, el, true); - checkActionableAccessibility(acc, el); - let rects = el.getClientRects(); - utils.synthesizeMouseAtPoint(rects[0].left + rects[0].width/2, - rects[0].top + rects[0].height/2, - {}, el.ownerDocument.defaultView); - } else { - throw new InvalidElementStateError("Element is not Enabled"); - } - }); + return interactions.clickElement(curContainer, elementManager, id); } /** @@ -1531,12 +1416,7 @@ function getElementTagName(id) { * capability. */ function isElementDisplayed(id) { - let el = elementManager.getKnownElement(id, curContainer); - let displayed = utils.isElementDisplayed(el); - return accessibility.getAccessibleObject(el).then(acc => { - checkVisibleAccessibility(acc, el, displayed); - return displayed; - }); + return interactions.isElementDisplayed(curContainer, elementManager, id); } /** @@ -1587,12 +1467,7 @@ function getElementRect(id) { * True if enabled, false otherwise. */ function isElementEnabled(id) { - let el = elementManager.getKnownElement(id, curContainer); - let enabled = utils.isElementEnabled(el); - return accessibility.getAccessibleObject(el).then(acc => { - checkEnabledAccessibility(acc, el, enabled); - return enabled; - }); + return interactions.isElementEnabled(curContainer, elementManager, id); } /** @@ -1602,12 +1477,7 @@ function isElementEnabled(id) { * and Radio Button states, or option elements. */ function isElementSelected(id) { - let el = elementManager.getKnownElement(id, curContainer); - let selected = utils.isElementSelected(el); - return accessibility.getAccessibleObject(el).then(acc => { - checkSelectedAccessibility(acc, el, selected); - return selected; - }); + return interactions.isElementSelected(curContainer, elementManager, id); } /** @@ -1616,28 +1486,22 @@ function isElementSelected(id) { function sendKeysToElement(msg) { let command_id = msg.json.command_id; let val = msg.json.value; - let el; + let id = msg.json.id; + let el = elementManager.getKnownElement(id, curContainer); - return Promise.resolve(elementManager.getKnownElement(msg.json.id, curContainer)) - .then(knownEl => { - el = knownEl; - // Element should be actionable from the accessibility standpoint to be able - // to send keys to it. - return accessibility.getAccessibleObject(el, true) - }).then(acc => { - checkActionableAccessibility(acc, el); - if (el.type == "file") { - let p = val.join(""); + if (el.type == "file") { + let p = val.join(""); fileInputElement = el; // In e10s, we can only construct File objects in the parent process, // so pass the filename to driver.js, which in turn passes them back // to this frame script in receiveFiles. sendSyncMessage("Marionette:getFiles", {value: p, command_id: command_id}); - } else { - utils.sendKeysToElement(curContainer.frame, el, val, sendOk, sendError, command_id); - } - }).catch(e => sendError(e, command_id)); + } else { + interactions.sendKeysToElement(curContainer, elementManager, id, val) + .then(() => sendOk(command_id)) + .catch(e => sendError(e, command_id)); + } } /** diff --git a/testing/marionette/sendkeys.js b/testing/marionette/sendkeys.js index 163a091a9b60..2475fd7839aa 100644 --- a/testing/marionette/sendkeys.js +++ b/testing/marionette/sendkeys.js @@ -144,7 +144,7 @@ function focusElement(el) { el.focus(); } -function sendKeysToElement(document, element, keysToSend, successCallback, errorCallback, command_id, ignoreVisibility) { +function sendKeysToElement(document, element, keysToSend, ignoreVisibility) { if (ignoreVisibility || checkVisible(element)) { focusElement(element); @@ -159,9 +159,7 @@ function sendKeysToElement(document, element, keysToSend, successCallback, error var c = value.charAt(i); sendSingleKey(c, modifiers, document); } - - successCallback(command_id); } else { - errorCallback(new ElementNotVisibleError("Element is not visible"), command_id); + throw new ElementNotVisibleError("Element is not visible"); } };