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 };