tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

Accessibility.sys.mjs (14753B)


      1 /* This Source Code Form is subject to the terms of the Mozilla Public
      2 * License, v. 2.0. If a copy of the MPL was not distributed with this file,
      3 * You can obtain one at http://mozilla.org/MPL/2.0/. */
      4 
      5 const lazy = {};
      6 
      7 ChromeUtils.defineESModuleGetters(lazy, {
      8  error: "chrome://remote/content/shared/webdriver/Errors.sys.mjs",
      9  Log: "chrome://remote/content/shared/Log.sys.mjs",
     10  waitForObserverTopic: "chrome://remote/content/marionette/sync.sys.mjs",
     11 });
     12 
     13 ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get());
     14 
     15 ChromeUtils.defineLazyGetter(lazy, "service", () => {
     16  try {
     17    return Cc["@mozilla.org/accessibilityService;1"].getService(
     18      Ci.nsIAccessibilityService
     19    );
     20  } catch (e) {
     21    lazy.logger.warn("Accessibility module is not present");
     22    return undefined;
     23  }
     24 });
     25 
     26 /** @namespace */
     27 export const accessibility = {
     28  get service() {
     29    return lazy.service;
     30  },
     31 };
     32 
     33 /**
     34 * Accessible states used to check element"s state from the accessibility API
     35 * perspective.
     36 *
     37 * Note: if gecko is built with --disable-accessibility, the interfaces
     38 * are not defined. This is why we use getters instead to be able to use
     39 * these statically.
     40 */
     41 accessibility.State = {
     42  get Unavailable() {
     43    return Ci.nsIAccessibleStates.STATE_UNAVAILABLE;
     44  },
     45  get Focusable() {
     46    return Ci.nsIAccessibleStates.STATE_FOCUSABLE;
     47  },
     48  get Selectable() {
     49    return Ci.nsIAccessibleStates.STATE_SELECTABLE;
     50  },
     51  get Selected() {
     52    return Ci.nsIAccessibleStates.STATE_SELECTED;
     53  },
     54 };
     55 
     56 /**
     57 * Accessible object roles that support some action.
     58 */
     59 accessibility.ActionableRoles = new Set([
     60  "checkbutton",
     61  "check menu item",
     62  "check rich option",
     63  "combobox",
     64  "combobox option",
     65  "entry",
     66  "key",
     67  "link",
     68  "listbox option",
     69  "listbox rich option",
     70  "menuitem",
     71  "option",
     72  "outlineitem",
     73  "pagetab",
     74  "pushbutton",
     75  "radiobutton",
     76  "radio menu item",
     77  "rowheader",
     78  "slider",
     79  "spinbutton",
     80  "switch",
     81 ]);
     82 
     83 /**
     84 * Factory function that constructs a new `accessibility.Checks`
     85 * object with enforced strictness or not.
     86 */
     87 accessibility.get = function (strict = false) {
     88  return new accessibility.Checks(!!strict);
     89 };
     90 
     91 /**
     92 * Wait for the document accessibility state to be different from STATE_BUSY.
     93 *
     94 * @param {Document} doc
     95 *     The document to wait for.
     96 * @returns {Promise}
     97 *     A promise which resolves when the document's accessibility state is no
     98 *     longer busy.
     99 */
    100 function waitForDocumentAccessibility(doc) {
    101  const documentAccessible = accessibility.service.getAccessibleFor(doc);
    102  const state = {};
    103  documentAccessible.getState(state, {});
    104  if ((state.value & Ci.nsIAccessibleStates.STATE_BUSY) == 0) {
    105    return Promise.resolve();
    106  }
    107 
    108  // Accessibility for the doc is busy, so wait for the state to change.
    109  return lazy.waitForObserverTopic("accessible-event", {
    110    checkFn: subject => {
    111      // If event type does not match expected type, skip the event.
    112      // If event's accessible does not match expected accessible,
    113      // skip the event.
    114      const event = subject.QueryInterface(Ci.nsIAccessibleEvent);
    115      return (
    116        event.eventType === Ci.nsIAccessibleEvent.EVENT_STATE_CHANGE &&
    117        event.accessible === documentAccessible
    118      );
    119    },
    120  });
    121 }
    122 
    123 /**
    124 * Retrieve the Accessible for the provided element.
    125 *
    126 * @param {Element} element
    127 *     The element for which we need to retrieve the accessible.
    128 *
    129 * @returns {nsIAccessible|null}
    130 *     The Accessible object corresponding to the provided element or null if
    131 *     the accessibility service is not available.
    132 */
    133 accessibility.getAccessible = async function (element) {
    134  if (!accessibility.service) {
    135    return null;
    136  }
    137 
    138  // First, wait for accessibility to be ready for the element's document.
    139  await waitForDocumentAccessibility(element.ownerDocument);
    140 
    141  const acc = accessibility.service.getAccessibleFor(element);
    142  if (acc) {
    143    return acc;
    144  }
    145 
    146  // The Accessible doesn't exist yet. This can happen because a11y tree
    147  // mutations happen during refresh driver ticks. Stop the refresh driver from
    148  // doing its regular ticks and force two refresh driver ticks: the first to
    149  // let layout update and notify a11y, and the second to let a11y process
    150  // updates.
    151  const windowUtils = element.ownerGlobal.windowUtils;
    152  windowUtils.advanceTimeAndRefresh(0);
    153  windowUtils.advanceTimeAndRefresh(0);
    154  // Go back to normal refresh driver ticks.
    155  windowUtils.restoreNormalRefresh();
    156  return accessibility.service.getAccessibleFor(element);
    157 };
    158 
    159 /**
    160 * Retrieve the accessible name for the provided element.
    161 *
    162 * @param {Element} element
    163 *     The element for which we need to retrieve the accessible name.
    164 *
    165 * @returns {string}
    166 *     The accessible name.
    167 */
    168 accessibility.getAccessibleName = async function (element) {
    169  const accessible = await accessibility.getAccessible(element);
    170  if (!accessible) {
    171    return "";
    172  }
    173 
    174  // If name is null (absent), expose the empty string.
    175  if (accessible.name === null) {
    176    return "";
    177  }
    178 
    179  return accessible.name;
    180 };
    181 
    182 /**
    183 * Compute the role for the provided element.
    184 *
    185 * @param {Element} element
    186 *     The element for which we need to compute the role.
    187 *
    188 * @returns {string}
    189 *     The computed role.
    190 */
    191 accessibility.getComputedRole = async function (element) {
    192  const accessible = await accessibility.getAccessible(element);
    193  if (!accessible) {
    194    // If it's not in the a11y tree, it's probably presentational.
    195    return "none";
    196  }
    197 
    198  return accessible.computedARIARole;
    199 };
    200 
    201 /**
    202 * Component responsible for interacting with platform accessibility
    203 * API.
    204 *
    205 * Its methods serve as wrappers for testing content and chrome
    206 * accessibility as well as accessibility of user interactions.
    207 */
    208 accessibility.Checks = class {
    209  /**
    210   * @param {boolean} strict
    211   *     Flag indicating whether the accessibility issue should be logged
    212   *     or cause an error to be thrown.  Default is to log to stdout.
    213   */
    214  constructor(strict) {
    215    this.strict = strict;
    216  }
    217 
    218  /**
    219   * Assert that the element has a corresponding accessible object, and retrieve
    220   * this accessible. Note that if the accessibility.Checks component was
    221   * created in non-strict mode, this helper will not attempt to resolve the
    222   * accessible at all and will simply return null.
    223   *
    224   * @param {DOMElement|XULElement} element
    225   *     Element to get the accessible object for.
    226   * @param {boolean=} mustHaveAccessible
    227   *     Flag indicating that the element must have an accessible object.
    228   *     Defaults to not require this.
    229   *
    230   * @returns {Promise.<nsIAccessible>}
    231   *     Promise with an accessibility object for the given element.
    232   */
    233  async assertAccessible(element, mustHaveAccessible = false) {
    234    if (!this.strict) {
    235      return null;
    236    }
    237 
    238    const accessible = await accessibility.getAccessible(element);
    239    if (!accessible && mustHaveAccessible) {
    240      this.error("Element does not have an accessible object", element);
    241    }
    242 
    243    return accessible;
    244  }
    245 
    246  /**
    247   * Test if the accessible has a role that supports some arbitrary
    248   * action.
    249   *
    250   * @param {nsIAccessible} accessible
    251   *     Accessible object.
    252   *
    253   * @returns {boolean}
    254   *     True if an actionable role is found on the accessible, false
    255   *     otherwise.
    256   */
    257  isActionableRole(accessible) {
    258    return accessibility.ActionableRoles.has(
    259      accessibility.service.getStringRole(accessible.role)
    260    );
    261  }
    262 
    263  /**
    264   * Test if an accessible has at least one action that it supports.
    265   *
    266   * @param {nsIAccessible} accessible
    267   *     Accessible object.
    268   *
    269   * @returns {boolean}
    270   *     True if the accessible has at least one supported action,
    271   *     false otherwise.
    272   */
    273  hasActionCount(accessible) {
    274    return accessible.actionCount > 0;
    275  }
    276 
    277  /**
    278   * Test if an accessible has a valid name.
    279   *
    280   * @param {nsIAccessible} accessible
    281   *     Accessible object.
    282   *
    283   * @returns {boolean}
    284   *     True if the accessible has a non-empty valid name, or false if
    285   *     this is not the case.
    286   */
    287  hasValidName(accessible) {
    288    return accessible.name && accessible.name.trim();
    289  }
    290 
    291  /**
    292   * Test if an accessible has a `hidden` attribute.
    293   *
    294   * @param {nsIAccessible} accessible
    295   *     Accessible object.
    296   *
    297   * @returns {boolean}
    298   *     True if the accessible object has a `hidden` attribute,
    299   *     false otherwise.
    300   */
    301  hasHiddenAttribute(accessible) {
    302    let hidden = false;
    303    try {
    304      hidden = accessible.attributes.getStringProperty("hidden");
    305    } catch (e) {}
    306    // if the property is missing, error will be thrown
    307    return hidden && hidden === "true";
    308  }
    309 
    310  /**
    311   * Verify if an accessible has a given state.
    312   * Test if an accessible has a given state.
    313   *
    314   * @param {nsIAccessible} accessible
    315   *     Accessible object to test.
    316   * @param {number} stateToMatch
    317   *     State to match.
    318   *
    319   * @returns {boolean}
    320   *     True if |accessible| has |stateToMatch|, false otherwise.
    321   */
    322  matchState(accessible, stateToMatch) {
    323    let state = {};
    324    accessible.getState(state, {});
    325    return !!(state.value & stateToMatch);
    326  }
    327 
    328  /**
    329   * Test if an accessible is hidden from the user.
    330   *
    331   * @param {nsIAccessible} accessible
    332   *     Accessible object.
    333   *
    334   * @returns {boolean}
    335   *     True if element is hidden from user, false otherwise.
    336   */
    337  isHidden(accessible) {
    338    if (!accessible) {
    339      return true;
    340    }
    341 
    342    while (accessible) {
    343      if (this.hasHiddenAttribute(accessible)) {
    344        return true;
    345      }
    346      accessible = accessible.parent;
    347    }
    348    return false;
    349  }
    350 
    351  /**
    352   * Test if the element's visible state corresponds to its accessibility
    353   * API visibility.
    354   *
    355   * @param {nsIAccessible} accessible
    356   *     Accessible object.
    357   * @param {DOMElement|XULElement} element
    358   *     Element associated with |accessible|.
    359   * @param {boolean} visible
    360   *     Visibility state of |element|.
    361   *
    362   * @throws ElementNotAccessibleError
    363   *     If |element|'s visibility state does not correspond to
    364   *     |accessible|'s.
    365   */
    366  assertVisible(accessible, element, visible) {
    367    let hiddenAccessibility = this.isHidden(accessible);
    368 
    369    let message;
    370    if (visible && hiddenAccessibility) {
    371      message =
    372        "Element is not currently visible via the accessibility API " +
    373        "and may not be manipulated by it";
    374    } else if (!visible && !hiddenAccessibility) {
    375      message =
    376        "Element is currently only visible via the accessibility API " +
    377        "and can be manipulated by it";
    378    }
    379    this.error(message, element);
    380  }
    381 
    382  /**
    383   * Test if the element's unavailable accessibility state matches the
    384   * enabled state.
    385   *
    386   * @param {nsIAccessible} accessible
    387   *     Accessible object.
    388   * @param {DOMElement|XULElement} element
    389   *     Element associated with |accessible|.
    390   * @param {boolean} enabled
    391   *     Enabled state of |element|.
    392   *
    393   * @throws ElementNotAccessibleError
    394   *     If |element|'s enabled state does not match |accessible|'s.
    395   */
    396  assertEnabled(accessible, element, enabled) {
    397    if (!accessible) {
    398      return;
    399    }
    400 
    401    let win = element.ownerGlobal;
    402    let disabledAccessibility = this.matchState(
    403      accessible,
    404      accessibility.State.Unavailable
    405    );
    406    let explorable =
    407      win.getComputedStyle(element).getPropertyValue("pointer-events") !==
    408      "none";
    409 
    410    let message;
    411    if (!explorable && !disabledAccessibility) {
    412      message =
    413        "Element is enabled but is not explorable via the " +
    414        "accessibility API";
    415    } else if (enabled && disabledAccessibility) {
    416      message = "Element is enabled but disabled via the accessibility API";
    417    } else if (!enabled && !disabledAccessibility) {
    418      message = "Element is disabled but enabled via the accessibility API";
    419    }
    420    this.error(message, element);
    421  }
    422 
    423  /**
    424   * Test if it is possible to activate an element with the accessibility
    425   * API.
    426   *
    427   * @param {nsIAccessible} accessible
    428   *     Accessible object.
    429   * @param {DOMElement|XULElement} element
    430   *     Element associated with |accessible|.
    431   *
    432   * @throws ElementNotAccessibleError
    433   *     If it is impossible to activate |element| with |accessible|.
    434   */
    435  assertActionable(accessible, element) {
    436    if (!accessible) {
    437      return;
    438    }
    439 
    440    let message;
    441    if (!this.hasActionCount(accessible)) {
    442      message = "Element does not support any accessible actions";
    443    } else if (!this.isActionableRole(accessible)) {
    444      message =
    445        "Element does not have a correct accessibility role " +
    446        "and may not be manipulated via the accessibility API";
    447    } else if (!this.hasValidName(accessible)) {
    448      message = "Element is missing an accessible name";
    449    } else if (!this.matchState(accessible, accessibility.State.Focusable)) {
    450      message = "Element is not focusable via the accessibility API";
    451    }
    452 
    453    this.error(message, element);
    454  }
    455 
    456  /**
    457   * Test that an element's selected state corresponds to its
    458   * accessibility API selected state.
    459   *
    460   * @param {nsIAccessible} accessible
    461   *     Accessible object.
    462   * @param {DOMElement|XULElement} element
    463   *     Element associated with |accessible|.
    464   * @param {boolean} selected
    465   *     The |element|s selected state.
    466   *
    467   * @throws ElementNotAccessibleError
    468   *     If |element|'s selected state does not correspond to
    469   *     |accessible|'s.
    470   */
    471  assertSelected(accessible, element, selected) {
    472    if (!accessible) {
    473      return;
    474    }
    475 
    476    // element is not selectable via the accessibility API
    477    if (!this.matchState(accessible, accessibility.State.Selectable)) {
    478      return;
    479    }
    480 
    481    let selectedAccessibility = this.matchState(
    482      accessible,
    483      accessibility.State.Selected
    484    );
    485 
    486    let message;
    487    if (selected && !selectedAccessibility) {
    488      message =
    489        "Element is selected but not selected via the accessibility API";
    490    } else if (!selected && selectedAccessibility) {
    491      message =
    492        "Element is not selected but selected via the accessibility API";
    493    }
    494    this.error(message, element);
    495  }
    496 
    497  /**
    498   * Throw an error if strict accessibility checks are enforced and log
    499   * the error to the log.
    500   *
    501   * @param {string} message
    502   * @param {DOMElement|XULElement} element
    503   *     Element that caused an error.
    504   *
    505   * @throws ElementNotAccessibleError
    506   *     If |strict| is true.
    507   */
    508  error(message, element) {
    509    if (!message || !this.strict) {
    510      return;
    511    }
    512    if (element) {
    513      let { id, tagName, className } = element;
    514      message += `: id: ${id}, tagName: ${tagName}, className: ${className}`;
    515    }
    516 
    517    throw new lazy.error.ElementNotAccessibleError(message);
    518  }
    519 };