зеркало из https://github.com/mozilla/gecko-dev.git
Bug 1109282
- adding a accessibility checks for singleTap. r=dburns
--- testing/marionette/client/marionette/__init__.py | 1 + testing/marionette/client/marionette/errors.py | 4 + testing/marionette/client/marionette/marionette.py | 2 + .../marionette/tests/unit/test_accessibility.py | 86 +++++++++++++++ .../client/marionette/tests/unit/unit-tests.ini | 2 + .../client/marionette/www/test_accessibility.html | 38 +++++++ testing/marionette/marionette-elements.js | 122 +++++++++++++++++++++ testing/marionette/marionette-listener.js | 54 ++++++++- testing/marionette/marionette-server.js | 7 +- 9 files changed, 311 insertions(+), 5 deletions(-) create mode 100644 testing/marionette/client/marionette/tests/unit/test_accessibility.py create mode 100644 testing/marionette/client/marionette/www/test_accessibility.html
This commit is contained in:
Родитель
1b154e6ec5
Коммит
cd9abe9d0a
|
@ -8,6 +8,7 @@ from marionette import Marionette, HTMLElement, Actions, MultiActions
|
||||||
from marionette_test import MarionetteTestCase, MarionetteJSTestCase, CommonTestCase, expectedFailure, skip, SkipTest
|
from marionette_test import MarionetteTestCase, MarionetteJSTestCase, CommonTestCase, expectedFailure, skip, SkipTest
|
||||||
from errors import (
|
from errors import (
|
||||||
ElementNotVisibleException,
|
ElementNotVisibleException,
|
||||||
|
ElementNotAccessibleException,
|
||||||
ErrorCodes,
|
ErrorCodes,
|
||||||
FrameSendFailureError,
|
FrameSendFailureError,
|
||||||
FrameSendNotInitializedError,
|
FrameSendNotInitializedError,
|
||||||
|
|
|
@ -13,6 +13,7 @@ class ErrorCodes(object):
|
||||||
ELEMENT_NOT_VISIBLE = 11
|
ELEMENT_NOT_VISIBLE = 11
|
||||||
INVALID_ELEMENT_STATE = 12
|
INVALID_ELEMENT_STATE = 12
|
||||||
UNKNOWN_ERROR = 13
|
UNKNOWN_ERROR = 13
|
||||||
|
ELEMENT_NOT_ACCESSIBLE = 56
|
||||||
ELEMENT_IS_NOT_SELECTABLE = 15
|
ELEMENT_IS_NOT_SELECTABLE = 15
|
||||||
JAVASCRIPT_ERROR = 17
|
JAVASCRIPT_ERROR = 17
|
||||||
XPATH_LOOKUP_ERROR = 19
|
XPATH_LOOKUP_ERROR = 19
|
||||||
|
@ -113,6 +114,9 @@ class ElementNotVisibleException(MarionetteException):
|
||||||
super(ElementNotVisibleException, self).__init__(
|
super(ElementNotVisibleException, self).__init__(
|
||||||
message, status=status, cause=cause, stacktrace=stacktrace)
|
message, status=status, cause=cause, stacktrace=stacktrace)
|
||||||
|
|
||||||
|
class ElementNotAccessibleException(MarionetteException):
|
||||||
|
pass
|
||||||
|
|
||||||
class NoSuchFrameException(MarionetteException):
|
class NoSuchFrameException(MarionetteException):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
|
@ -677,6 +677,8 @@ class Marionette(object):
|
||||||
raise errors.StaleElementException(message=message, status=status, stacktrace=stacktrace)
|
raise errors.StaleElementException(message=message, status=status, stacktrace=stacktrace)
|
||||||
elif status == errors.ErrorCodes.ELEMENT_NOT_VISIBLE:
|
elif status == errors.ErrorCodes.ELEMENT_NOT_VISIBLE:
|
||||||
raise errors.ElementNotVisibleException(message=message, status=status, stacktrace=stacktrace)
|
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:
|
elif status == errors.ErrorCodes.INVALID_ELEMENT_STATE:
|
||||||
raise errors.InvalidElementStateException(message=message, status=status, stacktrace=stacktrace)
|
raise errors.InvalidElementStateException(message=message, status=status, stacktrace=stacktrace)
|
||||||
elif status == errors.ErrorCodes.UNKNOWN_ERROR:
|
elif status == errors.ErrorCodes.UNKNOWN_ERROR:
|
||||||
|
|
|
@ -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))
|
|
@ -16,6 +16,8 @@ skip = false
|
||||||
[test_session.py]
|
[test_session.py]
|
||||||
[test_capabilities.py]
|
[test_capabilities.py]
|
||||||
|
|
||||||
|
[test_accessibility.py]
|
||||||
|
|
||||||
[test_expectedfail.py]
|
[test_expectedfail.py]
|
||||||
expected = fail
|
expected = fail
|
||||||
[test_import_script.py]
|
[test_import_script.py]
|
||||||
|
|
|
@ -0,0 +1,38 @@
|
||||||
|
<!-- 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/. -->
|
||||||
|
|
||||||
|
<!DOCTYPE html>
|
||||||
|
|
||||||
|
<html>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<head>
|
||||||
|
<title>Marionette Test</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<button id="button1">button1</button>
|
||||||
|
<button id="button2" aria-label="button2"></button>
|
||||||
|
<span id="button3">I am a bad button with no accessible</span>
|
||||||
|
<h1 id="button4">I am a bad button that is actually a header</h1>
|
||||||
|
<h1 id="button5">
|
||||||
|
I am a bad button that is actually an actionable header with a listener
|
||||||
|
</h1>
|
||||||
|
<button id="button6"></button>
|
||||||
|
<button id="button7" aria-hidden="true">button7</button>
|
||||||
|
<div aria-hidden="true">
|
||||||
|
<button id="button8">button8</button>
|
||||||
|
</div>
|
||||||
|
<button id="button9" style="position:absolute;left:-100px;top:-455px;">
|
||||||
|
button9
|
||||||
|
</button>
|
||||||
|
<button id="button10" style="visibility:hidden;">
|
||||||
|
button10
|
||||||
|
</button>
|
||||||
|
<script>
|
||||||
|
document.getElementById('button5').addEventListener('click', function() {
|
||||||
|
// A pseudo button that has a listener but is missing button semantics.
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -12,6 +12,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
this.EXPORTED_SYMBOLS = [
|
this.EXPORTED_SYMBOLS = [
|
||||||
|
"Accessibility",
|
||||||
"ElementManager",
|
"ElementManager",
|
||||||
"CLASS_NAME",
|
"CLASS_NAME",
|
||||||
"SELECTOR",
|
"SELECTOR",
|
||||||
|
@ -47,6 +48,127 @@ function ElementException(msg, num, stack) {
|
||||||
this.stack = 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.ElementManager = function ElementManager(notSupported) {
|
||||||
this.seenItems = {};
|
this.seenItems = {};
|
||||||
this.timer = Components.classes["@mozilla.org/timer;1"].createInstance(Components.interfaces.nsITimer);
|
this.timer = Components.classes["@mozilla.org/timer;1"].createInstance(Components.interfaces.nsITimer);
|
||||||
|
|
|
@ -39,6 +39,7 @@ let listenerId = null; //unique ID of this listener
|
||||||
let curFrame = content;
|
let curFrame = content;
|
||||||
let previousFrame = null;
|
let previousFrame = null;
|
||||||
let elementManager = new ElementManager([]);
|
let elementManager = new ElementManager([]);
|
||||||
|
let accessibility = new Accessibility();
|
||||||
let importedScripts = null;
|
let importedScripts = null;
|
||||||
let inputSource = null;
|
let inputSource = null;
|
||||||
|
|
||||||
|
@ -210,6 +211,7 @@ function waitForReady() {
|
||||||
*/
|
*/
|
||||||
function newSession(msg) {
|
function newSession(msg) {
|
||||||
isB2G = msg.json.B2G;
|
isB2G = msg.json.B2G;
|
||||||
|
accessibility.strict = msg.json.raisesAccessibilityExceptions;
|
||||||
resetValues();
|
resetValues();
|
||||||
if (isB2G) {
|
if (isB2G) {
|
||||||
readyStateTimer.initWithCallback(waitForReady, 100, Ci.nsITimer.TYPE_ONE_SHOT);
|
readyStateTimer.initWithCallback(waitForReady, 100, Ci.nsITimer.TYPE_ONE_SHOT);
|
||||||
|
@ -945,11 +947,15 @@ function singleTap(msg) {
|
||||||
let command_id = msg.json.command_id;
|
let command_id = msg.json.command_id;
|
||||||
try {
|
try {
|
||||||
let el = elementManager.getKnownElement(msg.json.id, curFrame);
|
let el = elementManager.getKnownElement(msg.json.id, curFrame);
|
||||||
|
let acc = accessibility.getAccessibleObject(el, true);
|
||||||
// after this block, the element will be scrolled into view
|
// after this block, the element will be scrolled into view
|
||||||
if (!checkVisible(el, msg.json.corx, msg.json.cory)) {
|
let visible = checkVisible(el, msg.json.corx, msg.json.cory);
|
||||||
sendError("Element is not currently visible and may not be manipulated", 11, null, command_id);
|
checkVisibleAccessibility(acc, visible);
|
||||||
return;
|
if (!visible) {
|
||||||
|
sendError("Element is not currently visible and may not be manipulated", 11, null, command_id);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
checkActionableAccessibility(acc);
|
||||||
if (!curFrame.document.createTouch) {
|
if (!curFrame.document.createTouch) {
|
||||||
mouseEventsOnly = true;
|
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
|
* Given an element and a pair of coordinates, returns an array of the form
|
||||||
* [ clientX, clientY, pageX, pageY, screenX, screenY ]
|
* [ clientX, clientY, pageX, pageY, screenX, screenY ]
|
||||||
|
|
|
@ -174,6 +174,7 @@ function MarionetteServerConnection(aPrefix, aTransport, aServer)
|
||||||
// Supported features
|
// Supported features
|
||||||
"handlesAlerts": false,
|
"handlesAlerts": false,
|
||||||
"nativeEvents": false,
|
"nativeEvents": false,
|
||||||
|
"raisesAccessibilityExceptions": false,
|
||||||
"rotatable": appName == "B2G",
|
"rotatable": appName == "B2G",
|
||||||
"secureSsl": false,
|
"secureSsl": false,
|
||||||
"takesElementScreenshot": true,
|
"takesElementScreenshot": true,
|
||||||
|
@ -2899,8 +2900,10 @@ MarionetteServerConnection.prototype = {
|
||||||
this.curBrowser.elementManager.seenItems[reg.id] = Cu.getWeakReference(listenerWindow);
|
this.curBrowser.elementManager.seenItems[reg.id] = Cu.getWeakReference(listenerWindow);
|
||||||
if (nullPrevious && (this.curBrowser.curFrameId != null)) {
|
if (nullPrevious && (this.curBrowser.curFrameId != null)) {
|
||||||
if (!this.sendAsync("newSession",
|
if (!this.sendAsync("newSession",
|
||||||
{ B2G: (appName == "B2G") },
|
{ B2G: (appName == "B2G"),
|
||||||
this.newSessionCommandId)) {
|
raisesAccessibilityExceptions:
|
||||||
|
this.sessionCapabilities.raisesAccessibilityExceptions },
|
||||||
|
this.newSessionCommandId)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (this.curBrowser.newSession) {
|
if (this.curBrowser.newSession) {
|
||||||
|
|
Загрузка…
Ссылка в новой задаче