diff --git a/testing/mochitest/browser-test.js b/testing/mochitest/browser-test.js index 08a3f8660fcc..331130a80478 100644 --- a/testing/mochitest/browser-test.js +++ b/testing/mochitest/browser-test.js @@ -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, diff --git a/testing/mochitest/mochitest_options.py b/testing/mochitest/mochitest_options.py index 6eaf19b25aea..0284a5943eeb 100644 --- a/testing/mochitest/mochitest_options.py +++ b/testing/mochitest/mochitest_options.py @@ -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"], { diff --git a/testing/mochitest/moz.build b/testing/mochitest/moz.build index 68c100f0da98..57b6f1550d1d 100644 --- a/testing/mochitest/moz.build +++ b/testing/mochitest/moz.build @@ -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", diff --git a/testing/mochitest/runtests.py b/testing/mochitest/runtests.py index 1c9fe7f4fe6d..bc06d27a8014 100644 --- a/testing/mochitest/runtests.py +++ b/testing/mochitest/runtests.py @@ -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, diff --git a/testing/mochitest/tests/SimpleTest/AccessibilityUtils.js b/testing/mochitest/tests/SimpleTest/AccessibilityUtils.js new file mode 100644 index 000000000000..b8b722734086 --- /dev/null +++ b/testing/mochitest/tests/SimpleTest/AccessibilityUtils.js @@ -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.} + * 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; +})(); diff --git a/testing/mochitest/tests/SimpleTest/EventUtils.js b/testing/mochitest/tests/SimpleTest/EventUtils.js index b657d8900576..8ee34f4fb8ab 100644 --- a/testing/mochitest/tests/SimpleTest/EventUtils.js +++ b/testing/mochitest/tests/SimpleTest/EventUtils.js @@ -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; diff --git a/testing/mochitest/tests/SimpleTest/moz.build b/testing/mochitest/tests/SimpleTest/moz.build index d1a173986157..55c54e6255e2 100644 --- a/testing/mochitest/tests/SimpleTest/moz.build +++ b/testing/mochitest/tests/SimpleTest/moz.build @@ -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", diff --git a/tools/lint/eslint/eslint-plugin-mozilla/lib/environments/simpletest.js b/tools/lint/eslint/eslint-plugin-mozilla/lib/environments/simpletest.js index f89a1db96611..2f5dd5c33e52 100644 --- a/tools/lint/eslint/eslint-plugin-mozilla/lib/environments/simpletest.js +++ b/tools/lint/eslint/eslint-plugin-mozilla/lib/environments/simpletest.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",