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) {