diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
commit | 26a029d407be480d791972afb5975cf62c9360a6 (patch) | |
tree | f435a8308119effd964b339f76abb83a57c29483 /testing/mochitest/tests/SimpleTest/AccessibilityUtils.js | |
parent | Initial commit. (diff) | |
download | firefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz firefox-26a029d407be480d791972afb5975cf62c9360a6.zip |
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'testing/mochitest/tests/SimpleTest/AccessibilityUtils.js')
-rw-r--r-- | testing/mochitest/tests/SimpleTest/AccessibilityUtils.js | 1073 |
1 files changed, 1073 insertions, 0 deletions
diff --git a/testing/mochitest/tests/SimpleTest/AccessibilityUtils.js b/testing/mochitest/tests/SimpleTest/AccessibilityUtils.js new file mode 100644 index 0000000000..0e95565019 --- /dev/null +++ b/testing/mochitest/tests/SimpleTest/AccessibilityUtils.js @@ -0,0 +1,1073 @@ +/* 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 () { + 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 is enabled and is expected to be enabled via + // the accessibility API. + mustBeEnabled: true, + // Checks that a node has a corresponding 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 an accessible is a keyboard focusable browser toolbar button. + * Browser toolbar buttons aren't keyboard focusable in the usual way. + * Instead, focus is managed by JS code which sets tabindex on a single + * button at a time. Thus, we need to special case the focusable check for + * these buttons. + */ + function isKeyboardFocusableBrowserToolbarButton(accessible) { + const node = accessible.DOMNode; + if (!node || !node.ownerGlobal) { + return false; + } + const toolbar = + node.closest("toolbar") || + node.flattenedTreeParentNode.closest("toolbar"); + if (!toolbar || toolbar.getAttribute("keyNav") != "true") { + return false; + } + // The Go button in the Url Bar is an example of a purposefully + // non-focusable image toolbar button that provides an mouse/touch-only + // control for the search query submission, while a keyboard user could + // press `Enter` to do it. Similarly, two scroll buttons that appear when + // toolbar is overflowing, and keyboard-only users would actually scroll + // tabs in the toolbar while trying to navigate to these controls. When + // toolbarbuttons are redundant for keyboard users, we do not want to + // create an extra tab stop for such controls, thus we are expecting the + // button markup to include `keyNav="false"` attribute to flag it. + if (node.getAttribute("keyNav") == "false") { + const ariaRoles = getAriaRoles(accessible); + return ( + ariaRoles.includes("button") || + accessible.role == Ci.nsIAccessibleRole.ROLE_PUSHBUTTON + ); + } + return node.ownerGlobal.ToolbarKeyboardNavigator._isButton(node); + } + + /** + * Determine if an accessible is a keyboard focusable control within a Firefox + * View list. The main landmark of the Firefox View has role="application" for + * users to expect a custom keyboard navigation pattern. Controls within this + * area aren't keyboard focusable in the usual way. Instead, focus is managed + * by JS code which sets tabindex on a single control within each list at a + * time. Thus, we need to special case the focusable check for these controls. + */ + function isKeyboardFocusableFxviewControlInApplication(accessible) { + const node = accessible.DOMNode; + if (!node || !node.ownerGlobal) { + return false; + } + // Firefox View application rows currently include only buttons and links: + if ( + !node.className.includes("fxview-tab-row-") || + (accessible.role != Ci.nsIAccessibleRole.ROLE_PUSHBUTTON && + accessible.role != Ci.nsIAccessibleRole.ROLE_LINK) + ) { + return false; // Not a button or a link in a Firefox View app. + } + // ToDo: We may eventually need to support intervening generics between + // a list and its listitem here and/or aria-owns lists. + const listitemAcc = accessible.parent; + const listAcc = listitemAcc.parent; + if ( + (!listAcc || listAcc.role != Ci.nsIAccessibleRole.ROLE_LIST) && + (!listitemAcc || listitemAcc.role != Ci.nsIAccessibleRole.ROLE_LISTITEM) + ) { + return false; // This button/link isn't inside a listitem within a list. + } + // All listitems should be not focusable while both a button and a link + // within each list item might have tabindex="-1". + if ( + node.tabIndex && + matchState(accessible, STATE_FOCUSABLE) && + !matchState(listitemAcc, STATE_FOCUSABLE) + ) { + // ToDo: We may eventually need to support lists which use aria-owns here. + // Check that there is only one keyboard reachable control within the list. + const childCount = listAcc.childCount; + let foundFocusable = false; + for (let c = 0; c < childCount; c++) { + const listitem = listAcc.getChildAt(c); + const listitemChildCount = listitem.childCount; + for (let i = 0; i < listitemChildCount; i++) { + const listitemControl = listitem.getChildAt(i); + // Use tabIndex rather than a11y focusable state because all controls + // within the listitem might have tabindex="-1". + if (listitemControl.DOMNode.tabIndex == 0) { + if (foundFocusable) { + // Only one control within a list should be focusable. + // ToDo: Fine-tune the a11y-check error message generated in this case. + // Strictly speaking, it's not ideal that we're performing an action + // from an is function, which normally only queries something without + // any externally observable behaviour. That said, fixing that would + // involve different return values for different cases (not a list, + // too many focusable listitem controls, etc) so we could move the + // a11yFail call to the caller. + a11yFail( + "Only one control should be focusable in a list", + accessible + ); + return false; + } + foundFocusable = true; + } + } + } + return foundFocusable; + } + return false; + } + + /** + * Determine if an accessible is a keyboard focusable option within a listbox. + * We use it in the Url bar results - these controls are't keyboard focusable + * in the usual way. Instead, focus is managed by JS code which sets tabindex + * on a single option at a time. Thus, we need to special case the focusable + * check for these option items. + */ + function isKeyboardFocusableOption(accessible) { + const node = accessible.DOMNode; + if (!node || !node.ownerGlobal) { + return false; + } + const urlbarListbox = node.closest(".urlbarView-results"); + if (!urlbarListbox || urlbarListbox.getAttribute("role") != "listbox") { + return false; + } + return node.getAttribute("role") == "option"; + } + + /** + * Determine if an accessible is a keyboard focusable PanelMultiView control. + * These controls aren't keyboard focusable in the usual way. Instead, focus + * is managed by JS code which sets tabindex dynamically. Thus, we need to + * special case the focusable check for these controls. + */ + function isKeyboardFocusablePanelMultiViewControl(accessible) { + const node = accessible.DOMNode; + if (!node || !node.ownerGlobal) { + return false; + } + const panelview = node.closest("panelview"); + if (!panelview || panelview.hasAttribute("disablekeynav")) { + return false; + } + return ( + node.ownerGlobal.PanelView.forNode(panelview)._tabNavigableWalker.filter( + node + ) == NodeFilter.FILTER_ACCEPT + ); + } + + /** + * Determine if an accessible is a keyboard focusable tab within a tablist. + * Per the ARIA design pattern, these controls aren't keyboard focusable in + * the usual way. Instead, focus is managed by JS code which sets tabindex on + * a single tab at a time. Thus, we need to special case the focusable check + * for these tab controls. + */ + function isKeyboardFocusableTabInTablist(accessible) { + const node = accessible.DOMNode; + if (!node || !node.ownerGlobal) { + return false; + } + if (accessible.role != Ci.nsIAccessibleRole.ROLE_PAGETAB) { + return false; // Not a tab. + } + // ToDo: We may eventually need to support intervening generics between + // a tab and its tablist here. + const tablist = accessible.parent; + if (!tablist || tablist.role != Ci.nsIAccessibleRole.ROLE_PAGETABLIST) { + return false; // The tab isn't inside a tablist. + } + // ToDo: We may eventually need to support tablists which use + // aria-activedescendant here. + // Check that there is only one keyboard reachable tab. + const childCount = tablist.childCount; + let foundFocusable = false; + for (let c = 0; c < childCount; c++) { + const tab = tablist.getChildAt(c); + // Use tabIndex rather than a11y focusable state because all tabs might + // have tabindex="-1". + if (tab.DOMNode.tabIndex == 0) { + if (foundFocusable) { + // Only one tab within a tablist should be focusable. + // ToDo: Fine-tune the a11y-check error message generated in this case. + // Strictly speaking, it's not ideal that we're performing an action + // from an is function, which normally only queries something without + // any externally observable behaviour. That said, fixing that would + // involve different return values for different cases (not a tab, + // too many focusable tabs, etc) so we could move the a11yFail call + // to the caller. + a11yFail("Only one tab should be focusable in a tablist", accessible); + return false; + } + foundFocusable = true; + } + } + return foundFocusable; + } + + /** + * Determine if an accessible is a keyboard focusable button in the url bar. + * Url bar buttons aren't keyboard focusable in the usual way. Instead, + * focus is managed by JS code which sets tabindex on a single button at a + * time. Thus, we need to special case the focusable check for these buttons. + * This also applies to the search bar buttons that reuse the same pattern. + */ + function isKeyboardFocusableUrlbarButton(accessible) { + const node = accessible.DOMNode; + if (!node || !node.ownerGlobal) { + return false; + } + const isUrlBar = + node + .closest(".urlbarView > .search-one-offs") + ?.getAttribute("disabletab") == "true"; + const isSearchBar = + node + .closest("#PopupSearchAutoComplete > .search-one-offs") + ?.getAttribute("is_searchbar") == "true"; + return ( + (isUrlBar || isSearchBar) && + node.getAttribute("tabindex") == "-1" && + node.tagName == "button" && + node.classList.contains("searchbar-engine-one-off-item") + ); + } + + /** + * Determine if an accessible is a keyboard focusable XUL tab. + * Only one tab is focusable at a time, but after focusing it, you can use + * the keyboard to focus other tabs. + */ + function isKeyboardFocusableXULTab(accessible) { + const node = accessible.DOMNode; + return node && XULElement.isInstance(node) && node.tagName == "tab"; + } + + /** + * XUL treecol elements currently aren't focusable, making them inaccessible. + * For now, we don't flag these as a failure to avoid breaking multiple tests. + * ToDo: We should remove this exception after this is fixed in bug 1848397. + */ + function isInaccessibleXulTreecol(node) { + if (!node || !node.ownerGlobal) { + return false; + } + const listheader = node.flattenedTreeParentNode; + if (listheader.tagName !== "listheader" || node.tagName !== "treecol") { + return false; + } + return true; + } + + /** + * Determine if an accessible is a combobox container of the url bar. We + * intentionally leave this element unlabeled, because its child is a search + * input that is the target and main control of this component. In general, we + * want to avoid duplication in the label announcement when a user focuses the + * input. Both NVDA and VO ignore the label on at least one of these controls + * if both have a label. But the bigger concern here is that it's very + * difficult to keep the accessible name synchronized between the combobox and + * the input. Thus, we need to special case the label check for this control. + */ + function isUnlabeledUrlBarCombobox(accessible) { + const node = accessible.DOMNode; + if (!node || !node.ownerGlobal) { + return false; + } + const ariaRoles = getAriaRoles(accessible); + // There are only two cases of this pattern: <moz-input-box> and <searchbar> + const isMozInputBox = + node.tagName == "moz-input-box" && + node.classList.contains("urlbar-input-box"); + const isSearchbar = node.tagName == "searchbar" && node.id == "searchbar"; + return (isMozInputBox || isSearchbar) && ariaRoles.includes("combobox"); + } + + /** + * Determine if an accessible is an option within the url bar. We know each + * url bar option is accessible, but it disappears as soon as it is clicked + * during tests and the a11y-checks do not have time to test the label, + * because the Fluent localization is not yet completed by then. Thus, we + * need to special case the label check for these controls. + */ + function isUnlabeledUrlBarOption(accessible) { + const node = accessible.DOMNode; + if (!node || !node.ownerGlobal) { + return false; + } + const ariaRoles = getAriaRoles(accessible); + return ( + node.tagName == "span" && + ariaRoles.includes("option") && + node.classList.contains("urlbarView-row-inner") && + node.hasAttribute("data-l10n-id") + ); + } + + /** + * Determine if an accessible is a menuitem within the XUL menu. We know each + * menuitem is accessible, but it disappears as soon as it is clicked during + * tests and the a11y-checks do not have time to test the label, because the + * Fluent localization is not yet completed by then. Thus, we need to special + * case the label check for these controls. + */ + function isUnlabeledMenuitem(accessible) { + const node = accessible.DOMNode; + if (!node || !node.ownerGlobal) { + return false; + } + let hasLabel = false; + for (const child of node.childNodes) { + if (child.tagName == "label") { + hasLabel = true; + } + } + return ( + accessible.role == Ci.nsIAccessibleRole.ROLE_MENUITEM && + accessible.parent.role == Ci.nsIAccessibleRole.ROLE_MENUPOPUP && + hasLabel && + node.hasAttribute("data-l10n-id") + ); + } + + /** + * Determine if a node is a XUL element for which tabIndex should be ignored. + * Some XUL elements report -1 for the .tabIndex property, even though they + * are in fact keyboard focusable. + */ + function shouldIgnoreTabIndex(node) { + if (!XULElement.isInstance(node)) { + return false; + } + return node.tagName == "label" && node.getAttribute("is") == "text-link"; + } + + /** + * 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) { + if ( + isKeyboardFocusableBrowserToolbarButton(accessible) || + isKeyboardFocusableOption(accessible) || + isKeyboardFocusablePanelMultiViewControl(accessible) || + isKeyboardFocusableUrlbarButton(accessible) || + isKeyboardFocusableXULTab(accessible) || + isKeyboardFocusableTabInTablist(accessible) || + isKeyboardFocusableFxviewControlInApplication(accessible) + ) { + return true; + } + // State will be focusable even if the tabindex is negative. + const node = accessible.DOMNode; + const role = accessible.role; + 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 || + node.tabIndex > -1 || + node.closest('[aria-activedescendant][tabindex="0"]') || + // If an ARIA toolbar uses a roving tabindex, some controls on the + // toolbar might not currently be focusable even though they can be + // reached with arrow keys and become focusable at that point. + ((role == Ci.nsIAccessibleRole.ROLE_PUSHBUTTON || + role == Ci.nsIAccessibleRole.ROLE_TOGGLE_BUTTON) && + node.closest('[role="toolbar"]')) || + shouldIgnoreTabIndex(node)) + ); + } + + 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 (gEnv.mustBeEnabled && matchState(accessible, STATE_UNAVAILABLE)) { + a11yFail( + "Node expected to be enabled but is 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.mustBeEnabled && + 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.mustBeEnabled && + gEnv.actionCountRule && + accessible.actionCount === 0 + ) { + a11yFail("Node does not support any accessible actions", accessible); + + return; + } + + if ( + gEnv.mustBeEnabled && + 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, allowRecurse = true) { + const { DOMNode } = accessible; + let name = accessible.name; + if (!name) { + if ( + isUnlabeledUrlBarCombobox(accessible) || + isUnlabeledUrlBarOption(accessible) || + isUnlabeledMenuitem(accessible) + ) { + return; + } + // If text has just been inserted into the tree, the a11y engine might not + // have picked it up yet. + forceRefreshDriverTick(DOMNode); + try { + name = accessible.name; + } catch (e) { + // The Accessible died because the DOM node was removed or hidden. + if (gEnv.labelRule) { + a11yWarn("Unlabeled element removed before l10n finished", { + DOMNode, + }); + } + return; + } + const doc = DOMNode.ownerDocument; + if ( + !name && + allowRecurse && + gEnv.labelRule && + doc.hasPendingL10nMutations + ) { + // There are pending async l10n mutations which might result in a valid + // accessible name. Try this check again once l10n is finished. + doc.addEventListener( + "L10nMutationsFinished", + () => { + try { + accessible.name; + } catch (e) { + // The Accessible died because the DOM node was removed or hidden. + a11yWarn("Unlabeled element removed before l10n finished", { + DOMNode, + }); + return; + } + assertLabelled(accessible, false); + }, + { once: true } + ); + return; + } + } + if (name) { + name = name.trim(); + } + if (gEnv.labelRule && !name) { + a11yFail("Interactive elements must be labeled", accessible); + + return; + } + + 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 + ); + } + } + + /** + * Walk node ancestry and force refresh driver tick in every document. + * @param {DOMNode} node + * Node for traversing the ancestry. + */ + function forceRefreshDriverTick(node) { + const wins = []; + let bc = BrowsingContext.getFromWindow(node.ownerDocument.defaultView); // eslint-disable-line + while (bc) { + wins.push(bc.associatedWindow); + bc = bc.embedderWindowGlobal?.browsingContext; + } + + let win = wins.pop(); + while (win) { + // Stop the refresh driver from doing its regular ticks and force two + // refresh driver ticks: first to let layout update and notify a11y, and + // the second to let a11y process updates. + win.windowUtils.advanceTimeAndRefresh(100); + win.windowUtils.advanceTimeAndRefresh(100); + // Go back to normal refresh driver ticks. + win.windowUtils.restoreNormalRefresh(); + win = wins.pop(); + } + } + + /** + * 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 {nsIAccessible} + * Accessibility object for a given node. + */ + 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; + } + + // Force refresh tick throughout document hierarchy + forceRefreshDriverTick(node); + return accessibilityService.getAccessibleFor(node); + } + + /** + * Find the nearest interactive accessible ancestor for a node. + */ + function findInteractiveAccessible(node) { + let acc; + // Walk DOM ancestors until we find one with an accessible. + for (; node && !acc; node = node.flattenedTreeParentNode) { + acc = getAccessible(node); + } + if (!acc) { + // No accessible ancestor. + return acc; + } + // Walk a11y ancestors until we find one which is interactive. + for (; acc; acc = acc.parent) { + if (INTERACTIVE_ROLES.has(acc.role)) { + return acc; + } + } + // No interactive ancestor. + return null; + } + + 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 = { + assertCanBeClicked(node) { + // Click events might fire on an inaccessible or non-interactive + // descendant, even if the test author targeted them at an interactive + // element. For example, if there's a button with an image inside it, + // node might be the image. + const acc = findInteractiveAccessible(node); + if (!acc) { + if (isInaccessibleXulTreecol(node)) { + return; + } + 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, testPath = "") { + gA11YChecks = a11yChecks; + + const { Services } = SpecialPowers; + // Disable accessibility service if it is running and if a11y checks are + // disabled. However, don't do this for accessibility engine tests. + if ( + !gA11YChecks && + Services.appinfo.accessibilityEnabled && + !testPath.startsWith("chrome://mochitests/content/browser/accessible/") + ) { + 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(); + }, + + init() { + this._shouldHandleClicks = true; + // A top level xul window's DocShell doesn't have a chromeEventHandler + // attribute. In that case, the chrome event handler is just the global + // window object. + this._handler ??= + window.docShell.chromeEventHandler ?? window.docShell.domWindow; + this._handler.addEventListener("click", this, true, true); + }, + + uninit() { + this._handler?.removeEventListener("click", this, true); + this._handler = null; + }, + + /** + * Suppress (or disable suppression of) handling of captured click events. + * This should only be called by EventUtils, etc. when a click event will + * be generated but we know it is not actually a click intended to activate + * a control; e.g. drag/drop. Tests that wish to disable specific checks + * should use setEnv instead. + */ + suppressClickHandling(shouldSuppress) { + this._shouldHandleClicks = !shouldSuppress; + }, + + handleEvent({ composedTarget }) { + if (!this._shouldHandleClicks) { + return; + } + if (composedTarget.tagName.toLowerCase() == "slot") { + // The click occurred on a text node inside a slot. Since events don't + // target text nodes, the event was retargeted to the slot. However, a + // slot isn't itself rendered. To deal with this, use the slot's parent + // instead. + composedTarget = composedTarget.flattenedTreeParentNode; + } + const bounds = + composedTarget.ownerGlobal?.windowUtils?.getBoundsWithoutFlushing( + composedTarget + ); + if (bounds && (bounds.width == 0 || bounds.height == 0)) { + // Some tests click hidden nodes. These clearly aren't testing the UI + // for the node itself (and presumably there is a test somewhere else + // that does). Therefore, we can't (and shouldn't) do a11y checks. + return; + } + this.assertCanBeClicked(composedTarget); + }, + }; + + AccessibilityUtils.assertCanBeClicked = runIfA11YChecks( + AccessibilityUtils.assertCanBeClicked.bind(AccessibilityUtils) + ); + + AccessibilityUtils.setEnv = runIfA11YChecks( + AccessibilityUtils.setEnv.bind(AccessibilityUtils) + ); + + AccessibilityUtils.resetEnv = runIfA11YChecks( + AccessibilityUtils.resetEnv.bind(AccessibilityUtils) + ); + + return AccessibilityUtils; +})(); |