summaryrefslogtreecommitdiffstats
path: root/remote/marionette/accessibility.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'remote/marionette/accessibility.sys.mjs')
-rw-r--r--remote/marionette/accessibility.sys.mjs481
1 files changed, 481 insertions, 0 deletions
diff --git a/remote/marionette/accessibility.sys.mjs b/remote/marionette/accessibility.sys.mjs
new file mode 100644
index 0000000000..f21eaf5047
--- /dev/null
+++ b/remote/marionette/accessibility.sys.mjs
@@ -0,0 +1,481 @@
+/* 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/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs",
+ Log: "chrome://remote/content/shared/Log.sys.mjs",
+ waitForObserverTopic: "chrome://remote/content/marionette/sync.sys.mjs",
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "logger", () =>
+ lazy.Log.get(lazy.Log.TYPES.MARIONETTE)
+);
+
+XPCOMUtils.defineLazyGetter(lazy, "service", () => {
+ try {
+ return Cc["@mozilla.org/accessibilityService;1"].getService(
+ Ci.nsIAccessibilityService
+ );
+ } catch (e) {
+ lazy.logger.warn("Accessibility module is not present");
+ return undefined;
+ }
+});
+
+/** @namespace */
+export const accessibility = {
+ get service() {
+ return lazy.service;
+ },
+};
+
+/**
+ * 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);
+};
+
+/**
+ * Wait for the document accessibility state to be different from STATE_BUSY.
+ *
+ * @param {Document} doc
+ * The document to wait for.
+ * @returns {Promise}
+ * A promise which resolves when the document's accessibility state is no
+ * longer busy.
+ */
+function waitForDocumentAccessibility(doc) {
+ const documentAccessible = accessibility.service.getAccessibleFor(doc);
+ const state = {};
+ documentAccessible.getState(state, {});
+ if ((state.value & Ci.nsIAccessibleStates.STATE_BUSY) == 0) {
+ return Promise.resolve();
+ }
+
+ // Accessibility for the doc is busy, so wait for the state to change.
+ return lazy.waitForObserverTopic("accessible-event", {
+ checkFn: subject => {
+ // If event type does not match expected type, skip the event.
+ // If event's accessible does not match expected accessible,
+ // skip the event.
+ const event = subject.QueryInterface(Ci.nsIAccessibleEvent);
+ return (
+ event.eventType === Ci.nsIAccessibleEvent.EVENT_STATE_CHANGE &&
+ event.accessible === documentAccessible
+ );
+ },
+ });
+}
+
+/**
+ * Retrieve the Accessible for the provided element.
+ *
+ * @param {Element} element
+ * The element for which we need to retrieve the accessible.
+ *
+ * @returns {nsIAccessible|null}
+ * The Accessible object corresponding to the provided element or null if
+ * the accessibility service is not available.
+ */
+accessibility.getAccessible = async function (element) {
+ if (!accessibility.service) {
+ return null;
+ }
+
+ // First, wait for accessibility to be ready for the element's document.
+ await waitForDocumentAccessibility(element.ownerDocument);
+
+ const acc = accessibility.service.getAccessibleFor(element);
+ if (acc) {
+ return acc;
+ }
+
+ // The Accessible doesn't exist yet. This can happen because a11y tree
+ // mutations happen during refresh driver ticks. Stop the refresh driver from
+ // doing its regular ticks and force two refresh driver ticks: the first to
+ // let layout update and notify a11y, and the second to let a11y process
+ // updates.
+ const windowUtils = element.ownerGlobal.windowUtils;
+ windowUtils.advanceTimeAndRefresh(0);
+ windowUtils.advanceTimeAndRefresh(0);
+ // Go back to normal refresh driver ticks.
+ windowUtils.restoreNormalRefresh();
+ return accessibility.service.getAccessibleFor(element);
+};
+
+/**
+ * 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;
+ }
+
+ /**
+ * Assert that the element has a corresponding accessible object, and retrieve
+ * this accessible. Note that if the accessibility.Checks component was
+ * created in non-strict mode, this helper will not attempt to resolve the
+ * accessible at all and will simply return null.
+ *
+ * @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.
+ *
+ * @returns {Promise.<nsIAccessible>}
+ * Promise with an accessibility object for the given element.
+ */
+ async assertAccessible(element, mustHaveAccessible = false) {
+ if (!this.strict) {
+ return null;
+ }
+
+ const accessible = await accessibility.getAccessible(element);
+ if (!accessible && mustHaveAccessible) {
+ this.error("Element does not have an accessible object", element);
+ }
+
+ return accessible;
+ }
+
+ /**
+ * Test if the accessible has a role that supports some arbitrary
+ * action.
+ *
+ * @param {nsIAccessible} accessible
+ * Accessible object.
+ *
+ * @returns {boolean}
+ * True if an actionable role is found on the accessible, false
+ * otherwise.
+ */
+ isActionableRole(accessible) {
+ return accessibility.ActionableRoles.has(
+ accessibility.service.getStringRole(accessible.role)
+ );
+ }
+
+ /**
+ * Test if an accessible has at least one action that it supports.
+ *
+ * @param {nsIAccessible} accessible
+ * Accessible object.
+ *
+ * @returns {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.
+ *
+ * @returns {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.
+ *
+ * @returns {boolean}
+ * True if the accessible object has a {@code hidden} attribute,
+ * false otherwise.
+ */
+ 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";
+ }
+
+ /**
+ * 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.
+ *
+ * @returns {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.
+ *
+ * @returns {boolean}
+ * True if element is hidden from user, false otherwise.
+ */
+ isHidden(accessible) {
+ if (!accessible) {
+ return true;
+ }
+
+ 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) {
+ 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.ownerGlobal;
+ 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
+ * 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 || !this.strict) {
+ return;
+ }
+ if (element) {
+ let { id, tagName, className } = element;
+ message += `: id: ${id}, tagName: ${tagName}, className: ${className}`;
+ }
+
+ throw new lazy.error.ElementNotAccessibleError(message);
+ }
+};