Bug 1667998 - add AccessibilityUtils for testing accessibility related issues and use it to check accessibility when EventUtils.sendMouseEvent is used to make a click. r=jmaher

Differential Revision: https://phabricator.services.mozilla.com/D100839
This commit is contained in:
Yura Zenevich 2021-01-15 18:20:37 +00:00
Родитель 4a592200d2
Коммит 09a23bc0e6
8 изменённых файлов: 599 добавлений и 1 удалений

Просмотреть файл

@ -157,6 +157,15 @@ function Tester(aTests, structuredLogger, aCallback) {
this.EventUtils
);
this._scriptLoader.loadSubScript(
"chrome://mochikit/content/tests/SimpleTest/AccessibilityUtils.js",
// AccessibilityUtils are integrated with EventUtils to perform additional
// accessibility checks for certain user interactions (clicks, etc). Load
// them into the EventUtils scope here.
this.EventUtils
);
this.AccessibilityUtils = this.EventUtils.AccessibilityUtils;
// Make sure our SpecialPowers actor is instantiated, in case it was
// registered after our DOMWindowCreated event was fired (which it
// most likely was).
@ -264,12 +273,14 @@ function Tester(aTests, structuredLogger, aCallback) {
}
Tester.prototype = {
EventUtils: {},
AccessibilityUtils: {},
SimpleTest: {},
ContentTask: null,
ExtensionTestUtils: null,
Assert: null,
repeat: 0,
a11y_checks: false,
runUntilFailure: false,
checker: null,
currentTestIndex: -1,
@ -297,6 +308,10 @@ Tester.prototype = {
this.runUntilFailure = true;
}
if (gConfig.a11y_checks != undefined) {
this.a11y_checks = gConfig.a11y_checks;
}
if (gConfig.repeat) {
this.repeat = gConfig.repeat;
}
@ -501,6 +516,7 @@ Tester.prototype = {
// Tests complete, notify the callback and return
this.callback(this.tests);
this.accService = null;
this.callback = null;
this.tests = null;
},
@ -938,6 +954,8 @@ Tester.prototype = {
this.structuredLogger.testStart(this.currentTest.path);
this.SimpleTest.reset();
// Reset accessibility environment.
this.AccessibilityUtils.reset(this.a11y_checks);
// Load the tests into a testscope
let currentScope = (this.currentTest.scope = new testScope(
@ -950,6 +968,7 @@ Tester.prototype = {
// Import utils in the test scope.
let { scope } = this.currentTest;
scope.EventUtils = this.EventUtils;
scope.AccessibilityUtils = this.AccessibilityUtils;
scope.SimpleTest = this.SimpleTest;
scope.gTestPath = this.currentTest.path;
scope.ContentTask = this.ContentTask;
@ -1543,6 +1562,7 @@ testScope.prototype = {
__expectedMaxAsserts: 0,
EventUtils: {},
AccessibilityUtils: {},
SimpleTest: {},
ContentTask: null,
BrowserTestUtils: null,

Просмотреть файл

@ -553,6 +553,15 @@ class MochitestArguments(ArgumentContainer):
"help": "Run tests with electrolysis preferences and test filtering disabled.",
},
],
[
["--enable-a11y-checks"],
{
"action": "store_true",
"default": False,
"dest": "a11y_checks",
"help": "Run tests with accessibility checks disabled.",
},
],
[
["--enable-fission"],
{

Просмотреть файл

@ -46,6 +46,7 @@ FINAL_TARGET_FILES.content.static += [
FINAL_TARGET_FILES.content.tests.SimpleTest += [
"../../docshell/test/chrome/docshell_helpers.js",
"../modules/StructuredLog.jsm",
"tests/SimpleTest/AccessibilityUtils.js",
"tests/SimpleTest/EventUtils.js",
"tests/SimpleTest/ExtensionTestUtils.js",
"tests/SimpleTest/iframe-between-tests.html",

Просмотреть файл

@ -2825,6 +2825,7 @@ toolbar#nav-bar {
# for test manifest parsing.
mozinfo.update(
{
"a11y_checks": options.a11y_checks,
"e10s": options.e10s,
"fission": self.extraPrefs.get("fission.autostart", False),
"headless": options.headless,

Просмотреть файл

@ -0,0 +1,561 @@
/* 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/. */
"use strict";
/**
* Accessible states used to check node's state from the accessiblity API
* perspective.
*
* Note: if gecko is built with --disable-accessibility, the interfaces
* are not defined. This is why we use getters instead to be able to use
* these statically.
*/
this.AccessibilityUtils = (function() {
// Duration until the accessible lookup times out.
const GET_ACCESSIBLE_TIMEOUT = 1000;
const FORCE_DISABLE_ACCESSIBILITY_PREF = "accessibility.force_disabled";
// Accessible states.
const {
STATE_FOCUSABLE,
STATE_INVISIBLE,
STATE_LINKED,
STATE_UNAVAILABLE,
} = Ci.nsIAccessibleStates;
// Accessible action for showing long description.
const CLICK_ACTION = "click";
// Roles that are considered focusable with the keyboard.
const KEYBOARD_FOCUSABLE_ROLES = new Set([
Ci.nsIAccessibleRole.ROLE_BUTTONMENU,
Ci.nsIAccessibleRole.ROLE_CHECKBUTTON,
Ci.nsIAccessibleRole.ROLE_COMBOBOX,
Ci.nsIAccessibleRole.ROLE_EDITCOMBOBOX,
Ci.nsIAccessibleRole.ROLE_ENTRY,
Ci.nsIAccessibleRole.ROLE_LINK,
Ci.nsIAccessibleRole.ROLE_LISTBOX,
Ci.nsIAccessibleRole.ROLE_PASSWORD_TEXT,
Ci.nsIAccessibleRole.ROLE_PUSHBUTTON,
Ci.nsIAccessibleRole.ROLE_RADIOBUTTON,
Ci.nsIAccessibleRole.ROLE_SLIDER,
Ci.nsIAccessibleRole.ROLE_SPINBUTTON,
Ci.nsIAccessibleRole.ROLE_SUMMARY,
Ci.nsIAccessibleRole.ROLE_SWITCH,
Ci.nsIAccessibleRole.ROLE_TOGGLE_BUTTON,
]);
// Roles that are user interactive.
const INTERACTIVE_ROLES = new Set([
...KEYBOARD_FOCUSABLE_ROLES,
Ci.nsIAccessibleRole.ROLE_CHECK_MENU_ITEM,
Ci.nsIAccessibleRole.ROLE_CHECK_RICH_OPTION,
Ci.nsIAccessibleRole.ROLE_COMBOBOX_OPTION,
Ci.nsIAccessibleRole.ROLE_MENUITEM,
Ci.nsIAccessibleRole.ROLE_OPTION,
Ci.nsIAccessibleRole.ROLE_OUTLINE,
Ci.nsIAccessibleRole.ROLE_OUTLINEITEM,
Ci.nsIAccessibleRole.ROLE_PAGETAB,
Ci.nsIAccessibleRole.ROLE_PARENT_MENUITEM,
Ci.nsIAccessibleRole.ROLE_RADIO_MENU_ITEM,
Ci.nsIAccessibleRole.ROLE_RICH_OPTION,
]);
// Roles that are considered interactive when they are focusable.
const INTERACTIVE_IF_FOCUSABLE_ROLES = new Set([
// If article is focusable, we can assume it is inside a feed.
Ci.nsIAccessibleRole.ROLE_ARTICLE,
// Column header can be focusable.
Ci.nsIAccessibleRole.ROLE_COLUMNHEADER,
Ci.nsIAccessibleRole.ROLE_GRID_CELL,
Ci.nsIAccessibleRole.ROLE_MENUBAR,
Ci.nsIAccessibleRole.ROLE_MENUPOPUP,
Ci.nsIAccessibleRole.ROLE_PAGETABLIST,
// Row header can be focusable.
Ci.nsIAccessibleRole.ROLE_ROWHEADER,
Ci.nsIAccessibleRole.ROLE_SCROLLBAR,
Ci.nsIAccessibleRole.ROLE_SEPARATOR,
Ci.nsIAccessibleRole.ROLE_TOOLBAR,
]);
// Roles that are considered form controls.
const FORM_ROLES = new Set([
Ci.nsIAccessibleRole.ROLE_CHECKBUTTON,
Ci.nsIAccessibleRole.ROLE_CHECK_RICH_OPTION,
Ci.nsIAccessibleRole.ROLE_COMBOBOX,
Ci.nsIAccessibleRole.ROLE_EDITCOMBOBOX,
Ci.nsIAccessibleRole.ROLE_ENTRY,
Ci.nsIAccessibleRole.ROLE_LISTBOX,
Ci.nsIAccessibleRole.ROLE_PASSWORD_TEXT,
Ci.nsIAccessibleRole.ROLE_PROGRESSBAR,
Ci.nsIAccessibleRole.ROLE_RADIOBUTTON,
Ci.nsIAccessibleRole.ROLE_SLIDER,
Ci.nsIAccessibleRole.ROLE_SPINBUTTON,
Ci.nsIAccessibleRole.ROLE_SWITCH,
]);
const DEFAULT_ENV = Object.freeze({
// Checks that accessible object has at least one accessible action.
actionCountRule: true,
// Checks that accessible object (and its corresponding node) is focusable
// (has focusable state and its node's tabindex is not set to -1).
focusableRule: true,
// Checks that clickable accessible object (and its corresponding node) has
// appropriate interactive semantics.
ifClickableThenInteractiveRule: true,
// Checks that accessible object has a role that is considered to be
// interactive.
interactiveRule: true,
// Checks that accessible object has a non-empty label.
labelRule: true,
// Checks that a node has a corresponging accessible object.
mustHaveAccessibleRule: true,
// Checks that accessible object (and its corresponding node) have a non-
// negative tabindex. Platform accessibility API still sets focusable state
// on tabindex=-1 nodes.
nonNegativeTabIndexRule: true,
});
let gA11YChecks = false;
let gEnv = {
...DEFAULT_ENV,
};
/**
* Get role attribute for an accessible object if specified for its
* corresponding {@code DOMNode}.
*
* @param {nsIAccessible} accessible
* Accessible for which to determine its role attribute value.
*
* @returns {String}
* Role attribute value if specified.
*/
function getAriaRoles(accessible) {
try {
return accessible.attributes.getStringProperty("xml-roles");
} catch (e) {
// No xml-roles. nsPersistentProperties throws if the attribute for a key
// is not found.
}
return "";
}
/**
* Get related accessible objects that are targets of labelled by relation e.g.
* labels.
* @param {nsIAccessible} accessible
* Accessible objects to get labels for.
*
* @returns {Array}
* A list of accessible objects that are labels for a given accessible.
*/
function getLabels(accessible) {
const relation = accessible.getRelationByType(
Ci.nsIAccessibleRelation.RELATION_LABELLED_BY
);
return [...relation.getTargets().enumerate(Ci.nsIAccessible)];
}
/**
* Test if an accessible has a {@code hidden} attribute.
*
* @param {nsIAccessible} accessible
* Accessible object.
*
* @return {boolean}
* True if the accessible object has a {@code hidden} attribute, false
* otherwise.
*/
function hasHiddenAttribute(accessible) {
let hidden = false;
try {
hidden = accessible.attributes.getStringProperty("hidden");
} catch (e) {}
// If the property is missing, error will be thrown
return hidden && hidden === "true";
}
/**
* Test if an accessible is hidden from the user.
*
* @param {nsIAccessible} accessible
* Accessible object.
*
* @return {boolean}
* True if accessible is hidden from user, false otherwise.
*/
function isHidden(accessible) {
if (!accessible) {
return true;
}
while (accessible) {
if (hasHiddenAttribute(accessible)) {
return true;
}
accessible = accessible.parent;
}
return false;
}
/**
* Check if an accessible has a given state.
*
* @param {nsIAccessible} accessible
* Accessible object to test.
* @param {number} stateToMatch
* State to match.
*
* @return {boolean}
* True if |accessible| has |stateToMatch|, false otherwise.
*/
function matchState(accessible, stateToMatch) {
const state = {};
accessible.getState(state, {});
return !!(state.value & stateToMatch);
}
/**
* Determine if accessible is focusable with the keyboard.
*
* @param {nsIAccessible} accessible
* Accessible for which to determine if it is keyboard focusable.
*
* @returns {Boolean}
* True if focusable with the keyboard.
*/
function isKeyboardFocusable(accessible) {
// State will be focusable even if the tabindex is negative.
return (
matchState(accessible, STATE_FOCUSABLE) &&
// Platform accessibility will still report STATE_FOCUSABLE even with the
// tabindex="-1" so we need to check that it is >= 0 to be considered
// keyboard focusable.
(!gEnv.nonNegativeTabIndexRule || accessible.DOMNode.tabIndex > -1)
);
}
function buildMessage(message, DOMNode) {
if (DOMNode) {
const { id, tagName, className } = DOMNode;
message += `: id: ${id}, tagName: ${tagName}, className: ${className}`;
}
return message;
}
/**
* Fail a test with a given message because of an issue with a given
* accessible object. This is used for cases where there's an actual
* accessibility failure that prevents UI from being accessible to keyboard/AT
* users.
*
* @param {String} message
* @param {nsIAccessible} accessible
* Accessible to log along with the failure message.
*/
function a11yFail(message, { DOMNode }) {
SpecialPowers.SimpleTest.ok(false, buildMessage(message, DOMNode));
}
/**
* Log a todo statement with a given message because of an issue with a given
* accessible object. This is used for cases where accessibility best
* practices are not followed or for something that is not as severe to be
* considered a failure.
* @param {String} message
* @param {nsIAccessible} accessible
* Accessible to log along with the todo message.
*/
function a11yWarn(message, { DOMNode }) {
SpecialPowers.SimpleTest.todo(false, buildMessage(message, DOMNode));
}
/**
* Test if the node's unavailable via the accessibility API.
*
* @param {nsIAccessible} accessible
* Accessible object.
*/
function assertEnabled(accessible) {
if (matchState(accessible, STATE_UNAVAILABLE)) {
a11yFail(
"Node is enabled but disabled via the accessibility API",
accessible
);
}
}
/**
* Test if it is possible to focus on a node with the keyboard. This method
* also checks for additional keyboard focus issues that might arise.
*
* @param {nsIAccessible} accessible
* Accessible object for a node.
*/
function assertFocusable(accessible) {
if (gEnv.focusableRule && !isKeyboardFocusable(accessible)) {
const ariaRoles = getAriaRoles(accessible);
// Do not force ARIA combobox or listbox to be focusable.
if (!ariaRoles.includes("combobox") && !ariaRoles.includes("listbox")) {
a11yFail("Node is not focusable via the accessibility API", accessible);
}
return;
}
if (!INTERACTIVE_IF_FOCUSABLE_ROLES.has(accessible.role)) {
// ROLE_TABLE is used for grids too which are considered interactive.
if (
accessible.role === Ci.nsIAccessibleRole.ROLE_TABLE &&
!getAriaRoles(accessible).includes("grid")
) {
a11yWarn(
"Focusable nodes should have interactive semantics",
accessible
);
return;
}
}
if (accessible.DOMNode.tabIndex > 0) {
a11yWarn("Avoid using tabindex attribute greater than zero", accessible);
}
}
/**
* Test if it is possible to interact with a node via the accessibility API.
*
* @param {nsIAccessible} accessible
* Accessible object for a node.
*/
function assertInteractive(accessible) {
if (gEnv.actionCountRule && accessible.actionCount === 0) {
a11yFail("Node does not support any accessible actions", accessible);
return;
}
if (gEnv.interactiveRule && !INTERACTIVE_ROLES.has(accessible.role)) {
if (
// Labels that have a label for relation with their target are clickable.
(accessible.role !== Ci.nsIAccessibleRole.ROLE_LABEL ||
accessible.getRelationByType(
Ci.nsIAccessibleRelation.RELATION_LABEL_FOR
).targetsCount === 0) &&
// Images that are inside an anchor (have linked state).
(accessible.role !== Ci.nsIAccessibleRole.ROLE_GRAPHIC ||
!matchState(accessible, STATE_LINKED))
) {
// Look for click action in the list of actions.
for (let i = 0; i < accessible.actionCount; i++) {
if (
gEnv.ifClickableThenInteractiveRule &&
accessible.getActionName(i) === CLICK_ACTION
) {
a11yFail(
"Clickable nodes must have interactive semantics",
accessible
);
}
}
}
a11yFail(
"Node does not have a correct interactive role and may not be " +
"manipulated via the accessibility API",
accessible
);
}
}
/**
* Test if the node is labelled appropriately for accessibility API.
*
* @param {nsIAccessible} accessible
* Accessible object for a node.
*/
function assertLabelled(accessible) {
const name = accessible.name && accessible.name.trim();
if (gEnv.labelRule && !name) {
a11yFail("Interactive elements must be labeled", accessible);
return;
}
const { DOMNode } = accessible;
if (FORM_ROLES.has(accessible.role)) {
const labels = getLabels(accessible);
const hasNameFromVisibleLabel = labels.some(
label => !matchState(label, STATE_INVISIBLE)
);
if (!hasNameFromVisibleLabel) {
a11yWarn("Form elements should have a visible text label", accessible);
}
} else if (
accessible.role === Ci.nsIAccessibleRole.ROLE_LINK &&
DOMNode.nodeName === "AREA" &&
DOMNode.hasAttribute("href")
) {
const alt = DOMNode.getAttribute("alt");
if (alt && alt.trim() !== name) {
a11yFail(
"Use alt attribute to label area elements that have the href attribute",
accessible
);
}
}
}
/**
* Test if the node's visible via accessibility API.
*
* @param {nsIAccessible} accessible
* Accessible object for a node.
*/
function assertVisible(accessible) {
if (isHidden(accessible)) {
a11yFail(
"Node is not currently visible via the accessibility API and may not " +
"be manipulated by it",
accessible
);
}
}
/**
* Get an accessible object for a node.
* Note: this method will not resolve if accessible object does not become
* available for a given node.
*
* @param {DOMNode} node
* Node to get the accessible object for.
*
* @return {Promise.<nsIAccessible>}
* Promise with an accessibility object for a given node.
*/
async function getAccessible(node) {
const accessibilityService = Cc[
"@mozilla.org/accessibilityService;1"
].getService(Ci.nsIAccessibilityService);
if (!accessibilityService) {
// This is likely a build with --disable-accessibility
return null;
}
let acc = accessibilityService.getAccessibleFor(node);
if (acc) {
return acc;
}
let resolver, timeoutID;
const accPromise = new Promise(resolve => {
resolver = resolve;
});
const observe = subject => {
acc = accessibilityService.getAccessibleFor(node);
if (acc) {
clearTimeout(timeoutID);
SpecialPowers.Services.obs.removeObserver(observe, "accessible-event");
resolver(acc);
}
};
SpecialPowers.Services.obs.addObserver(observe, "accessible-event");
timeoutID = setTimeout(() => {
// Final attempt in case the show event never fired.
SpecialPowers.Services.obs.removeObserver(observe, "accessible-event");
resolver(accessibilityService.getAccessibleFor(node));
}, GET_ACCESSIBLE_TIMEOUT);
return accPromise;
}
function runIfA11YChecks(task) {
return (...args) => (gA11YChecks ? task(...args) : null);
}
/**
* AccessibilityUtils provides utility methods for retrieving accessible objects
* and performing accessibility related checks.
* Current methods:
* assertCanBeClicked
* setEnv
* resetEnv
*
*/
const AccessibilityUtils = {
async assertCanBeClicked(node) {
const acc = await getAccessible(node);
if (!acc) {
if (gEnv.mustHaveAccessibleRule) {
a11yFail("Node is not accessible via accessibility API", {
DOMNode: node,
});
}
return;
}
assertInteractive(acc);
assertFocusable(acc);
assertVisible(acc);
assertEnabled(acc);
assertLabelled(acc);
},
setEnv(env = DEFAULT_ENV) {
gEnv = {
...DEFAULT_ENV,
...env,
};
},
resetEnv() {
gEnv = { ...DEFAULT_ENV };
},
reset(a11yChecks = false) {
gA11YChecks = a11yChecks;
const { Services } = SpecialPowers;
// Disable accessibility service if it is running and if a11y checks are
// disabled.
if (!gA11YChecks && Services.appinfo.accessibilityEnabled) {
Services.prefs.setIntPref(FORCE_DISABLE_ACCESSIBILITY_PREF, 1);
Services.prefs.clearUserPref(FORCE_DISABLE_ACCESSIBILITY_PREF);
}
// Reset accessibility environment flags that might've been set within the
// test.
this.resetEnv();
},
};
AccessibilityUtils.assertCanBeClicked = runIfA11YChecks(
AccessibilityUtils.assertCanBeClicked.bind(AccessibilityUtils)
);
AccessibilityUtils.setEnv = runIfA11YChecks(
AccessibilityUtils.setEnv.bind(AccessibilityUtils)
);
AccessibilityUtils.resetEnv = runIfA11YChecks(
AccessibilityUtils.resetEnv.bind(AccessibilityUtils)
);
return AccessibilityUtils;
})();

Просмотреть файл

@ -211,7 +211,7 @@ function computeButton(aEvent) {
return aEvent.type == "contextmenu" ? 2 : 0;
}
function sendMouseEvent(aEvent, aTarget, aWindow) {
async function sendMouseEvent(aEvent, aTarget, aWindow) {
if (
![
"click",
@ -236,6 +236,10 @@ function sendMouseEvent(aEvent, aTarget, aWindow) {
aTarget = aWindow.document.getElementById(aTarget);
}
if (aEvent.type === "click" && this.AccessibilityUtils) {
await this.AccessibilityUtils.assertCanBeClicked(aTarget);
}
var event = aWindow.document.createEvent("MouseEvent");
var typeArg = aEvent.type;

Просмотреть файл

@ -6,6 +6,7 @@
TEST_HARNESS_FILES.testing.mochitest.tests.SimpleTest += [
"/docshell/test/chrome/docshell_helpers.js",
"AccessibilityUtils.js",
"ChromeTask.js",
"EventUtils.js",
"ExtensionTestUtils.js",

Просмотреть файл

@ -19,6 +19,7 @@ var { getScriptGlobals } = require("./utils");
// When updating this list, be sure to also update the 'support-files' config
// in `tools/lint/eslint.yml`.
const simpleTestFiles = [
"AccessibilityUtils.js",
"ExtensionTestUtils.js",
"EventUtils.js",
"MockObjects.js",