/* 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"; const {classes: Cc, interfaces: Ci, utils: Cu} = Components; Cu.import("resource://gre/modules/XPCOMUtils.jsm"); Cu.import("resource://gre/modules/Log.jsm"); Cu.import("chrome://marionette/content/error.js"); XPCOMUtils.defineLazyModuleGetter( this, "setInterval", "resource://gre/modules/Timer.jsm"); XPCOMUtils.defineLazyModuleGetter( this, "clearInterval", "resource://gre/modules/Timer.jsm"); XPCOMUtils.defineLazyGetter(this, "service", () => Cc["@mozilla.org/accessibilityService;1"].getService(Ci.nsIAccessibilityService)); this.EXPORTED_SYMBOLS = ["accessibility"]; const logger = Log.repository.getLogger("Marionette"); /** * Number of attempts to get an accessible object for an element. * We attempt more than once because accessible tree can be out of sync * with the DOM tree for a short period of time. */ const GET_ACCESSIBLE_ATTEMPTS = 100; /** * An interval between attempts to retrieve an accessible object for an * element. */ const GET_ACCESSIBLE_ATTEMPT_INTERVAL = 10; this.accessibility = {}; /** * Accessible states used to check element"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. */ accessibility.State = { get Unavailable() { return Ci.nsIAccessibleStates.STATE_UNAVAILABLE; }, get Focusable() { return Ci.nsIAccessibleStates.STATE_FOCUSABLE; }, get Selectable() { return Ci.nsIAccessibleStates.STATE_SELECTABLE; }, get Selected() { return Ci.nsIAccessibleStates.STATE_SELECTED; } }; /** * Accessible object roles that support some action. */ accessibility.ActionableRoles = new Set([ "checkbutton", "check menu item", "check rich option", "combobox", "combobox option", "entry", "key", "link", "listbox option", "listbox rich option", "menuitem", "option", "outlineitem", "pagetab", "pushbutton", "radiobutton", "radio menu item", "rowheader", "slider", "spinbutton", "switch", ]); /** * Factory function that constructs a new {@code accessibility.Checks} * object with enforced strictness or not. */ accessibility.get = function(strict = false) { return new accessibility.Checks(!!strict); }; /** * Component responsible for interacting with platform accessibility * API. * * Its methods serve as wrappers for testing content and chrome * accessibility as well as accessibility of user interactions. */ accessibility.Checks = class { /** * @param {boolean} strict * Flag indicating whether the accessibility issue should be logged * or cause an error to be thrown. Default is to log to stdout. */ constructor(strict) { this.strict = strict; } /** * Get an accessible object for an element. * * @param {DOMElement|XULElement} element * Element to get the accessible object for. * @param {boolean=} mustHaveAccessible * Flag indicating that the element must have an accessible object. * Defaults to not require this. * * @return {nsIAccessible} * Accessibility object for the given element. */ getAccessible(element, mustHaveAccessible = false) { return new Promise((resolve, reject) => { let acc = service.getAccessibleFor(element); // if accessible object is found, return it; // if it is not required, also resolve if (acc || !mustHaveAccessible) { resolve(acc); // if we must have an accessible but are strict, // reject now and avoid polling for an accessible object } else if (mustHaveAccessible && !this.strict) { reject(); // if we require an accessible object, we need to poll for it // because accessible tree might be // out of sync with DOM tree for a short time } else { let attempts = GET_ACCESSIBLE_ATTEMPTS; let intervalId = setInterval(() => { let acc = service.getAccessibleFor(element); if (acc || --attempts <= 0) { clearInterval(intervalId); if (acc) { resolve(acc); } else { reject(); } } }, GET_ACCESSIBLE_ATTEMPT_INTERVAL); } }).catch(() => this.error( "Element does not have an accessible object", element)); }; /** * Test if the accessible has a role that supports some arbitrary * action. * * @param {nsIAccessible} accessible * Accessible object. * * @return {boolean} * True if an actionable role is found on the accessible, false * otherwise. */ isActionableRole(accessible) { return accessibility.ActionableRoles.has( service.getStringRole(accessible.role)); } /** * Test if an accessible has at least one action that it supports. * * @param {nsIAccessible} accessible * Accessible object. * * @return {boolean} * True if the accessible has at least one supported action, * false otherwise. */ hasActionCount(accessible) { return accessible.actionCount > 0; } /** * Test if an accessible has a valid name. * * @param {nsIAccessible} accessible * Accessible object. * * @return {boolean} * True if the accessible has a non-empty valid name, or false if * this is not the case. */ hasValidName(accessible) { return accessible.name && accessible.name.trim(); } /** * Test if an accessible has a {@code hidden} attribute. * * @param {nsIAccessible} accessible * Accessible object. * * @return {boolean} * True if the accesible object has a {@code hidden} attribute, * false otherwise. */ hasHiddenAttribute(accessible) { let hidden = false; try { hidden = accessible.attributes.getStringProperty("hidden"); } finally { // if the property is missing, error will be thrown return hidden && hidden === "true"; } } /** * Verify if an accessible has a given state. * Test 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. */ matchState(accessible, stateToMatch) { let state = {}; accessible.getState(state, {}); return !!(state.value & stateToMatch); } /** * Test if an accessible is hidden from the user. * * @param {nsIAccessible} accessible * Accessible object. * * @return {boolean} * True if element is hidden from user, false otherwise. */ isHidden(accessible) { while (accessible) { if (this.hasHiddenAttribute(accessible)) { return true; } accessible = accessible.parent; } return false; } /** * Test if the element's visible state corresponds to its accessibility * API visibility. * * @param {nsIAccessible} accessible * Accessible object. * @param {DOMElement|XULElement} element * Element associated with |accessible|. * @param {boolean} visible * Visibility state of |element|. * * @throws ElementNotAccessibleError * If |element|'s visibility state does not correspond to * |accessible|'s. */ assertVisible(accessible, element, visible) { if (!accessible) { return; } let hiddenAccessibility = this.isHidden(accessible); 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"; } this.error(message, element); } /** * Test if the element's unavailable accessibility state matches the * enabled state. * * @param {nsIAccessible} accessible * Accessible object. * @param {DOMElement|XULElement} element * Element associated with |accessible|. * @param {boolean} enabled * Enabled state of |element|. * * @throws ElementNotAccessibleError * If |element|'s enabled state does not match |accessible|'s. */ assertEnabled(accessible, element, enabled) { if (!accessible) { return; } let win = element.ownerDocument.defaultView; let disabledAccessibility = this.matchState( accessible, accessibility.State.Unavailable); let explorable = win.getComputedStyle(element) .getPropertyValue("pointer-events") !== "none"; let message; if (!explorable && !disabledAccessibility) { message = "Element is enabled but is not explorable via the " + "accessibility API"; } else if (enabled && disabledAccessibility) { message = "Element is enabled but disabled via the accessibility API"; } else if (!enabled && !disabledAccessibility) { message = "Element is disabled but enabled via the accessibility API"; } this.error(message, element); } /** * Test if it is possible to activate an element with the accessibility * API. * * @param {nsIAccessible} accessible * Accessible object. * @param {DOMElement|XULElement} element * Element associated with |accessible|. * * @throws ElementNotAccessibleError * If it is impossible to activate |element| with |accessible|. */ assertActionable(accessible, element) { if (!accessible) { return; } let message; if (!this.hasActionCount(accessible)) { message = "Element does not support any accessible actions"; } else if (!this.isActionableRole(accessible)) { message = "Element does not have a correct accessibility role " + "and may not be manipulated via the accessibility API"; } else if (!this.hasValidName(accessible)) { message = "Element is missing an accessible name"; } else if (!this.matchState(accessible, accessibility.State.Focusable)) { message = "Element is not focusable via the accessibility API"; } this.error(message, element); } /** * Test that an element's selected state corresponds to its * accessibility API selected state. * * @param {nsIAccessible} accessible * Accessible object. * @param {DOMElement|XULElement} * Element associated with |accessible|. * @param {boolean} selected * The |element|s selected state. * * @throws ElementNotAccessibleError * If |element|'s selected state does not correspond to * |accessible|'s. */ assertSelected(accessible, element, selected) { if (!accessible) { return; } // element is not selectable via the accessibility API if (!this.matchState(accessible, accessibility.State.Selectable)) { return; } let selectedAccessibility = this.matchState(accessible, accessibility.State.Selected); let message; if (selected && !selectedAccessibility) { message = "Element is selected but not selected via the accessibility API"; } else if (!selected && selectedAccessibility) { message = "Element is not selected but selected via the accessibility API"; } this.error(message, element); } /** * Throw an error if strict accessibility checks are enforced and log * the error to the log. * * @param {string} message * @param {DOMElement|XULElement} element * Element that caused an error. * * @throws ElementNotAccessibleError * If |strict| is true. */ error(message, element) { if (!message) { return; } if (element) { let {id, tagName, className} = element; message += `: id: ${id}, tagName: ${tagName}, className: ${className}`; } if (this.strict) { throw new ElementNotAccessibleError(message); } logger.debug(message); } };