diff --git a/testing/marionette/client/marionette/__init__.py b/testing/marionette/client/marionette/__init__.py index 0f42de010171..67a263367d59 100644 --- a/testing/marionette/client/marionette/__init__.py +++ b/testing/marionette/client/marionette/__init__.py @@ -8,6 +8,7 @@ from marionette import Marionette, HTMLElement, Actions, MultiActions from marionette_test import MarionetteTestCase, MarionetteJSTestCase, CommonTestCase, expectedFailure, skip, SkipTest from errors import ( ElementNotVisibleException, + ElementNotAccessibleException, ErrorCodes, FrameSendFailureError, FrameSendNotInitializedError, diff --git a/testing/marionette/client/marionette/errors.py b/testing/marionette/client/marionette/errors.py index aee6aa24c52f..81ca613bfc4b 100644 --- a/testing/marionette/client/marionette/errors.py +++ b/testing/marionette/client/marionette/errors.py @@ -13,6 +13,7 @@ class ErrorCodes(object): ELEMENT_NOT_VISIBLE = 11 INVALID_ELEMENT_STATE = 12 UNKNOWN_ERROR = 13 + ELEMENT_NOT_ACCESSIBLE = 56 ELEMENT_IS_NOT_SELECTABLE = 15 JAVASCRIPT_ERROR = 17 XPATH_LOOKUP_ERROR = 19 @@ -113,6 +114,9 @@ class ElementNotVisibleException(MarionetteException): super(ElementNotVisibleException, self).__init__( message, status=status, cause=cause, stacktrace=stacktrace) +class ElementNotAccessibleException(MarionetteException): + pass + class NoSuchFrameException(MarionetteException): pass diff --git a/testing/marionette/client/marionette/marionette.py b/testing/marionette/client/marionette/marionette.py index 35a7fee5a0b6..75a8ee91ccbd 100644 --- a/testing/marionette/client/marionette/marionette.py +++ b/testing/marionette/client/marionette/marionette.py @@ -677,6 +677,8 @@ class Marionette(object): raise errors.StaleElementException(message=message, status=status, stacktrace=stacktrace) elif status == errors.ErrorCodes.ELEMENT_NOT_VISIBLE: raise errors.ElementNotVisibleException(message=message, status=status, stacktrace=stacktrace) + elif status == errors.ErrorCodes.ELEMENT_NOT_ACCESSIBLE: + raise errors.ElementNotAccessibleException(message=message, status=status, stacktrace=stacktrace) elif status == errors.ErrorCodes.INVALID_ELEMENT_STATE: raise errors.InvalidElementStateException(message=message, status=status, stacktrace=stacktrace) elif status == errors.ErrorCodes.UNKNOWN_ERROR: diff --git a/testing/marionette/client/marionette/tests/unit/test_accessibility.py b/testing/marionette/client/marionette/tests/unit/test_accessibility.py new file mode 100644 index 000000000000..1326181dc07e --- /dev/null +++ b/testing/marionette/client/marionette/tests/unit/test_accessibility.py @@ -0,0 +1,86 @@ +# 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/. + +from marionette_test import MarionetteTestCase +from errors import ElementNotAccessibleException +from errors import ElementNotVisibleException + + +class TestAccessibility(MarionetteTestCase): + + # Elements that are accessible with and without the accessibliity API + valid_elementIDs = [ + # Button1 is an accessible button with a valid accessible name + # computed from subtree + "button1", + # Button2 is an accessible button with a valid accessible name + # computed from aria-label + "button2" + ] + + # Elements that are not accessible with the accessibility API + invalid_elementIDs = [ + # Button3 does not have an accessible object + "button3", + # Button4 does not support any accessible actions + "button4", + # Button5 does not have a correct accessibility role and may not be + # manipulated via the accessibility API + "button5", + # Button6 is missing an accesible name + "button6", + # Button7 is not currently visible via the accessibility API and may + # not be manipulated by it + "button7", + # Button8 is not currently visible via the accessibility API and may + # not be manipulated by it (in hidden subtree) + "button8" + ] + + # Elements that are either accessible to accessibility API or not accessible + # at all + falsy_elements = [ + # Element is only visible to the accessibility API and may be + # manipulated by it + "button9", + # Element is not currently visible + "button10" + ] + + def run_element_test(self, ids, testFn): + for id in ids: + element = self.marionette.find_element("id", id) + testFn(element) + + def setup_accessibility(self, raisesAccessibilityExceptions=True, navigate=True): + self.marionette.delete_session() + self.marionette.start_session( + {"raisesAccessibilityExceptions": raisesAccessibilityExceptions}) + # Navigate to test_accessibility.html + if navigate: + test_accessibility = self.marionette.absolute_url("test_accessibility.html") + self.marionette.navigate(test_accessibility) + + def test_valid_single_tap(self): + self.setup_accessibility() + # No exception should be raised + self.run_element_test(self.valid_elementIDs, lambda button: button.tap()) + + def test_invalid_single_tap(self): + self.setup_accessibility() + self.run_element_test(self.invalid_elementIDs, + lambda button: self.assertRaises(ElementNotAccessibleException, + button.tap)) + self.run_element_test(self.falsy_elements, + lambda button: self.assertRaises(ElementNotAccessibleException, + button.tap)) + + def test_invalid_single_tap_no_exceptions(self): + self.setup_accessibility(False, True) + # No exception should be raised + self.run_element_test(self.invalid_elementIDs, lambda button: button.tap()) + # Elements are invisible + self.run_element_test(self.falsy_elements, + lambda button: self.assertRaises(ElementNotVisibleException, + button.tap)) diff --git a/testing/marionette/client/marionette/tests/unit/unit-tests.ini b/testing/marionette/client/marionette/tests/unit/unit-tests.ini index 149c5dd450d2..e47ac7bbea75 100644 --- a/testing/marionette/client/marionette/tests/unit/unit-tests.ini +++ b/testing/marionette/client/marionette/tests/unit/unit-tests.ini @@ -16,6 +16,8 @@ skip = false [test_session.py] [test_capabilities.py] +[test_accessibility.py] + [test_expectedfail.py] expected = fail [test_import_script.py] diff --git a/testing/marionette/client/marionette/www/test_accessibility.html b/testing/marionette/client/marionette/www/test_accessibility.html new file mode 100644 index 000000000000..c91be89819b8 --- /dev/null +++ b/testing/marionette/client/marionette/www/test_accessibility.html @@ -0,0 +1,38 @@ + + + + + + + +Marionette Test + + + + + I am a bad button with no accessible +

I am a bad button that is actually a header

+

+ I am a bad button that is actually an actionable header with a listener +

+ + + + + + + + diff --git a/testing/marionette/marionette-elements.js b/testing/marionette/marionette-elements.js index 323466bbf310..1f0281d12f6a 100644 --- a/testing/marionette/marionette-elements.js +++ b/testing/marionette/marionette-elements.js @@ -12,6 +12,7 @@ */ this.EXPORTED_SYMBOLS = [ + "Accessibility", "ElementManager", "CLASS_NAME", "SELECTOR", @@ -47,6 +48,127 @@ function ElementException(msg, num, stack) { this.stack = stack; } +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; + this.accessibleRetrieval = Components.classes[ + '@mozilla.org/accessibleRetrieval;1'].getService( + Components.interfaces.nsIAccessibleRetrieval); +}; + +Accessibility.prototype = { + /** + * 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', + 'radiobutton', + '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) { + let acc = this.accessibleRetrieval.getAccessibleFor(element); + if (!acc && mustHaveAccessible) { + this.handleErrorMessage('Element does not have an accessible object'); + } + return acc; + }, + + /** + * 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'; + } + }, + + /** + * 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 + */ + handleErrorMessage(message) { + if (!message) { + return; + } + if (this.strict) { + throw new ElementException(message, 56, null); + } + dump(Date.now() + " Marionette: " + message); + } +}; + this.ElementManager = function ElementManager(notSupported) { this.seenItems = {}; this.timer = Components.classes["@mozilla.org/timer;1"].createInstance(Components.interfaces.nsITimer); diff --git a/testing/marionette/marionette-listener.js b/testing/marionette/marionette-listener.js index 926ee2a3fe4c..4e2ad8318fce 100644 --- a/testing/marionette/marionette-listener.js +++ b/testing/marionette/marionette-listener.js @@ -39,6 +39,7 @@ let listenerId = null; //unique ID of this listener let curFrame = content; let previousFrame = null; let elementManager = new ElementManager([]); +let accessibility = new Accessibility(); let importedScripts = null; let inputSource = null; @@ -210,6 +211,7 @@ function waitForReady() { */ function newSession(msg) { isB2G = msg.json.B2G; + accessibility.strict = msg.json.raisesAccessibilityExceptions; resetValues(); if (isB2G) { readyStateTimer.initWithCallback(waitForReady, 100, Ci.nsITimer.TYPE_ONE_SHOT); @@ -945,11 +947,15 @@ function singleTap(msg) { let command_id = msg.json.command_id; try { let el = elementManager.getKnownElement(msg.json.id, curFrame); + let acc = accessibility.getAccessibleObject(el, true); // after this block, the element will be scrolled into view - if (!checkVisible(el, msg.json.corx, msg.json.cory)) { - sendError("Element is not currently visible and may not be manipulated", 11, null, command_id); - return; + let visible = checkVisible(el, msg.json.corx, msg.json.cory); + checkVisibleAccessibility(acc, visible); + if (!visible) { + sendError("Element is not currently visible and may not be manipulated", 11, null, command_id); + return; } + checkActionableAccessibility(acc); if (!curFrame.document.createTouch) { mouseEventsOnly = true; } @@ -962,6 +968,48 @@ function singleTap(msg) { } } +/** + * Check if the element's visible state corresponds to its accessibility API + * visibility + * @param nsIAccessible object + * @param Boolean visible element's visibility state + */ +function checkVisibleAccessibility(accesible, 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); +} + +/** + * Check if it is possible to activate an element with the accessibility API + * @param nsIAccessible object + */ +function checkActionableAccessibility(accesible) { + 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'; + } + accessibility.handleErrorMessage(message); +} + /** * Given an element and a pair of coordinates, returns an array of the form * [ clientX, clientY, pageX, pageY, screenX, screenY ] diff --git a/testing/marionette/marionette-server.js b/testing/marionette/marionette-server.js index 5bb98fa334d5..0d479af522b6 100644 --- a/testing/marionette/marionette-server.js +++ b/testing/marionette/marionette-server.js @@ -174,6 +174,7 @@ function MarionetteServerConnection(aPrefix, aTransport, aServer) // Supported features "handlesAlerts": false, "nativeEvents": false, + "raisesAccessibilityExceptions": false, "rotatable": appName == "B2G", "secureSsl": false, "takesElementScreenshot": true, @@ -2899,8 +2900,10 @@ MarionetteServerConnection.prototype = { this.curBrowser.elementManager.seenItems[reg.id] = Cu.getWeakReference(listenerWindow); if (nullPrevious && (this.curBrowser.curFrameId != null)) { if (!this.sendAsync("newSession", - { B2G: (appName == "B2G") }, - this.newSessionCommandId)) { + { B2G: (appName == "B2G"), + raisesAccessibilityExceptions: + this.sessionCapabilities.raisesAccessibilityExceptions }, + this.newSessionCommandId)) { return; } if (this.curBrowser.newSession) {