accessible.js (17160B)
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 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 "use strict"; 6 7 const { Actor } = require("resource://devtools/shared/protocol.js"); 8 const { 9 accessibleSpec, 10 } = require("resource://devtools/shared/specs/accessibility.js"); 11 12 const { 13 accessibility: { AUDIT_TYPE }, 14 } = require("resource://devtools/shared/constants.js"); 15 16 loader.lazyRequireGetter( 17 this, 18 "getContrastRatioFor", 19 "resource://devtools/server/actors/accessibility/audit/contrast.js", 20 true 21 ); 22 loader.lazyRequireGetter( 23 this, 24 "auditKeyboard", 25 "resource://devtools/server/actors/accessibility/audit/keyboard.js", 26 true 27 ); 28 loader.lazyRequireGetter( 29 this, 30 "auditTextLabel", 31 "resource://devtools/server/actors/accessibility/audit/text-label.js", 32 true 33 ); 34 loader.lazyRequireGetter( 35 this, 36 "isDefunct", 37 "resource://devtools/server/actors/utils/accessibility.js", 38 true 39 ); 40 loader.lazyRequireGetter( 41 this, 42 "findCssSelector", 43 "resource://devtools/shared/inspector/css-logic.js", 44 true 45 ); 46 loader.lazyRequireGetter( 47 this, 48 "getBounds", 49 "resource://devtools/server/actors/highlighters/utils/accessibility.js", 50 true 51 ); 52 loader.lazyRequireGetter( 53 this, 54 "isFrameWithChildTarget", 55 "resource://devtools/shared/layout/utils.js", 56 true 57 ); 58 const lazy = {}; 59 loader.lazyGetter( 60 lazy, 61 "ContentDOMReference", 62 () => 63 ChromeUtils.importESModule( 64 "resource://gre/modules/ContentDOMReference.sys.mjs", 65 // ContentDOMReference needs to be retrieved from the shared global 66 // since it is a shared singleton. 67 { global: "shared" } 68 ).ContentDOMReference 69 ); 70 71 const RELATIONS_TO_IGNORE = new Set([ 72 Ci.nsIAccessibleRelation.RELATION_CONTAINING_APPLICATION, 73 Ci.nsIAccessibleRelation.RELATION_CONTAINING_TAB_PANE, 74 Ci.nsIAccessibleRelation.RELATION_CONTAINING_WINDOW, 75 Ci.nsIAccessibleRelation.RELATION_PARENT_WINDOW_OF, 76 Ci.nsIAccessibleRelation.RELATION_SUBWINDOW_OF, 77 ]); 78 79 const nsIAccessibleRole = Ci.nsIAccessibleRole; 80 const TEXT_ROLES = new Set([ 81 nsIAccessibleRole.ROLE_TEXT_LEAF, 82 nsIAccessibleRole.ROLE_STATICTEXT, 83 ]); 84 85 const STATE_DEFUNCT = Ci.nsIAccessibleStates.EXT_STATE_DEFUNCT; 86 const CSS_TEXT_SELECTOR = "#text"; 87 88 /** 89 * Get node inforamtion such as nodeType and the unique CSS selector for the node. 90 * 91 * @param {DOMNode} node 92 * Node for which to get the information. 93 * @return {object} 94 * Information about the type of the node and how to locate it. 95 */ 96 function getNodeDescription(node) { 97 if (!node || Cu.isDeadWrapper(node)) { 98 return { nodeType: undefined, nodeCssSelector: "" }; 99 } 100 101 const { nodeType } = node; 102 return { 103 nodeType, 104 // If node is a text node, we find a unique CSS selector for its parent and add a 105 // CSS_TEXT_SELECTOR postfix to indicate that it's a text node. 106 nodeCssSelector: 107 nodeType === Node.TEXT_NODE 108 ? `${findCssSelector(node.parentNode)}${CSS_TEXT_SELECTOR}` 109 : findCssSelector(node), 110 }; 111 } 112 113 /** 114 * Get a snapshot of the nsIAccessible object including its subtree. None of the subtree 115 * queried here is cached via accessible walker's refMap. 116 * 117 * @param {nsIAccessible} acc 118 * Accessible object to take a snapshot of. 119 * @param {nsIAccessibilityService} a11yService 120 * Accessibility service instance in the current process, used to get localized 121 * string representation of various accessible properties. 122 * @param {WindowGlobalTargetActor} targetActor 123 * @return {JSON} 124 * JSON snapshot of the accessibility tree with root at current accessible. 125 */ 126 function getSnapshot(acc, a11yService, targetActor) { 127 if (isDefunct(acc)) { 128 return { 129 states: [a11yService.getStringStates(0, STATE_DEFUNCT)], 130 }; 131 } 132 133 const actions = []; 134 for (let i = 0; i < acc.actionCount; i++) { 135 actions.push(acc.getActionDescription(i)); 136 } 137 138 const attributes = {}; 139 if (acc.attributes) { 140 for (const { key, value } of acc.attributes.enumerate()) { 141 attributes[key] = value; 142 } 143 } 144 145 const state = {}; 146 const extState = {}; 147 acc.getState(state, extState); 148 const states = [...a11yService.getStringStates(state.value, extState.value)]; 149 150 const children = []; 151 for (let child = acc.firstChild; child; child = child.nextSibling) { 152 // Ignore children from different documents when we have targets for every documents. 153 if ( 154 targetActor.ignoreSubFrames && 155 child.DOMNode.ownerDocument !== targetActor.contentDocument 156 ) { 157 continue; 158 } 159 children.push(getSnapshot(child, a11yService, targetActor)); 160 } 161 162 const { nodeType, nodeCssSelector } = getNodeDescription(acc.DOMNode); 163 const snapshot = { 164 name: acc.name, 165 role: getStringRole(acc, a11yService), 166 actions, 167 value: acc.value, 168 nodeCssSelector, 169 nodeType, 170 description: acc.description, 171 keyboardShortcut: acc.accessKey || acc.keyboardShortcut, 172 childCount: acc.childCount, 173 indexInParent: acc.indexInParent, 174 states, 175 children, 176 attributes, 177 }; 178 const useChildTargetToFetchChildren = 179 acc.role === Ci.nsIAccessibleRole.ROLE_INTERNAL_FRAME && 180 isFrameWithChildTarget(targetActor, acc.DOMNode); 181 if (useChildTargetToFetchChildren) { 182 snapshot.useChildTargetToFetchChildren = useChildTargetToFetchChildren; 183 snapshot.childCount = 1; 184 snapshot.contentDOMReference = lazy.ContentDOMReference.get(acc.DOMNode); 185 } 186 187 return snapshot; 188 } 189 190 /** 191 * Get a string indicating the role of the nsIAccessible object. 192 * An ARIA role token will be returned unless the role can't be mapped to an 193 * ARIA role (e.g. <iframe>), in which case a Gecko role string will be 194 * returned. 195 * 196 * @param {nsIAccessible} acc 197 * Accessible object to take a snapshot of. 198 * @param {nsIAccessibilityService} a11yService 199 * Accessibility service instance in the current process, used to get localized 200 * string representation of various accessible properties. 201 * @return String 202 */ 203 function getStringRole(acc, a11yService) { 204 let role = acc.computedARIARole; 205 if (!role) { 206 // We couldn't map to an ARIA role, so use a Gecko role string. 207 role = a11yService.getStringRole(acc.role); 208 } 209 return role; 210 } 211 212 /** 213 * The AccessibleActor provides information about a given accessible object: its 214 * role, name, states, etc. 215 */ 216 class AccessibleActor extends Actor { 217 constructor(walker, rawAccessible) { 218 super(walker.conn, accessibleSpec); 219 this.walker = walker; 220 this.rawAccessible = rawAccessible; 221 222 /** 223 * Indicates if the raw accessible is no longer alive. 224 * 225 * @return Boolean 226 */ 227 Object.defineProperty(this, "isDefunct", { 228 get() { 229 const defunct = isDefunct(this.rawAccessible); 230 if (defunct) { 231 delete this.isDefunct; 232 this.isDefunct = true; 233 return this.isDefunct; 234 } 235 236 return defunct; 237 }, 238 configurable: true, 239 }); 240 } 241 242 destroy() { 243 super.destroy(); 244 this.walker = null; 245 this.rawAccessible = null; 246 } 247 248 get role() { 249 if (this.isDefunct) { 250 return null; 251 } 252 return getStringRole(this.rawAccessible, this.walker.a11yService); 253 } 254 255 get name() { 256 if (this.isDefunct) { 257 return null; 258 } 259 return this.rawAccessible.name; 260 } 261 262 get value() { 263 if (this.isDefunct) { 264 return null; 265 } 266 return this.rawAccessible.value; 267 } 268 269 get description() { 270 if (this.isDefunct) { 271 return null; 272 } 273 return this.rawAccessible.description; 274 } 275 276 get keyboardShortcut() { 277 if (this.isDefunct) { 278 return null; 279 } 280 // Gecko accessibility exposes two key bindings: Accessible::AccessKey and 281 // Accessible::KeyboardShortcut. The former is used for accesskey, where the latter 282 // is used for global shortcuts defined by XUL menu items, etc. Here - do what the 283 // Windows implementation does: try AccessKey first, and if that's empty, use 284 // KeyboardShortcut. 285 return this.rawAccessible.accessKey || this.rawAccessible.keyboardShortcut; 286 } 287 288 get childCount() { 289 if (this.isDefunct) { 290 return 0; 291 } 292 // In case of a remote frame declare at least one child (the #document 293 // element) so that they can be expanded. 294 if (this.useChildTargetToFetchChildren) { 295 return 1; 296 } 297 298 return this.rawAccessible.childCount; 299 } 300 301 get domNodeType() { 302 if (this.isDefunct) { 303 return 0; 304 } 305 return this.rawAccessible.DOMNode ? this.rawAccessible.DOMNode.nodeType : 0; 306 } 307 308 get parentAcc() { 309 if (this.isDefunct) { 310 return null; 311 } 312 return this.walker.addRef(this.rawAccessible.parent); 313 } 314 315 children() { 316 const children = []; 317 if (this.isDefunct) { 318 return children; 319 } 320 321 for ( 322 let child = this.rawAccessible.firstChild; 323 child; 324 child = child.nextSibling 325 ) { 326 children.push(this.walker.addRef(child)); 327 } 328 return children; 329 } 330 331 get indexInParent() { 332 if (this.isDefunct) { 333 return -1; 334 } 335 336 try { 337 return this.rawAccessible.indexInParent; 338 } catch (e) { 339 // Accessible is dead. 340 return -1; 341 } 342 } 343 344 get actions() { 345 const actions = []; 346 if (this.isDefunct) { 347 return actions; 348 } 349 350 for (let i = 0; i < this.rawAccessible.actionCount; i++) { 351 actions.push(this.rawAccessible.getActionDescription(i)); 352 } 353 return actions; 354 } 355 356 get states() { 357 if (this.isDefunct) { 358 return []; 359 } 360 361 const state = {}; 362 const extState = {}; 363 this.rawAccessible.getState(state, extState); 364 return [ 365 ...this.walker.a11yService.getStringStates(state.value, extState.value), 366 ]; 367 } 368 369 get attributes() { 370 if (this.isDefunct || !this.rawAccessible.attributes) { 371 return {}; 372 } 373 374 const attributes = {}; 375 for (const { key, value } of this.rawAccessible.attributes.enumerate()) { 376 attributes[key] = value; 377 } 378 379 return attributes; 380 } 381 382 get bounds() { 383 if (this.isDefunct) { 384 return null; 385 } 386 387 let x = {}, 388 y = {}, 389 w = {}, 390 h = {}; 391 try { 392 this.rawAccessible.getBoundsInCSSPixels(x, y, w, h); 393 x = x.value; 394 y = y.value; 395 w = w.value; 396 h = h.value; 397 } catch (e) { 398 return null; 399 } 400 401 // Check if accessible bounds are invalid. 402 const left = x, 403 right = x + w, 404 top = y, 405 bottom = y + h; 406 if (left === right || top === bottom) { 407 return null; 408 } 409 410 return { x, y, w, h }; 411 } 412 413 async getRelations() { 414 const relationObjects = []; 415 if (this.isDefunct) { 416 return relationObjects; 417 } 418 419 const relations = [ 420 ...this.rawAccessible.getRelations().enumerate(Ci.nsIAccessibleRelation), 421 ]; 422 if (relations.length === 0) { 423 return relationObjects; 424 } 425 426 const doc = await this.walker.getDocument(); 427 if (this.isDestroyed()) { 428 // This accessible actor is destroyed. 429 return relationObjects; 430 } 431 relations.forEach(relation => { 432 if (RELATIONS_TO_IGNORE.has(relation.relationType)) { 433 return; 434 } 435 436 const type = this.walker.a11yService.getStringRelationType( 437 relation.relationType 438 ); 439 const targets = [...relation.getTargets().enumerate(Ci.nsIAccessible)]; 440 let relationObject; 441 for (const target of targets) { 442 let targetAcc; 443 try { 444 targetAcc = this.walker.attachAccessible(target, doc.rawAccessible); 445 } catch (e) { 446 // Target is not available. 447 } 448 449 if (targetAcc) { 450 if (!relationObject) { 451 relationObject = { type, targets: [] }; 452 } 453 454 relationObject.targets.push(targetAcc); 455 } 456 } 457 458 if (relationObject) { 459 relationObjects.push(relationObject); 460 } 461 }); 462 463 return relationObjects; 464 } 465 466 get useChildTargetToFetchChildren() { 467 if (this.isDefunct) { 468 return false; 469 } 470 471 return ( 472 this.rawAccessible.role === Ci.nsIAccessibleRole.ROLE_INTERNAL_FRAME && 473 isFrameWithChildTarget( 474 this.walker.targetActor, 475 this.rawAccessible.DOMNode 476 ) 477 ); 478 } 479 480 form() { 481 return { 482 actor: this.actorID, 483 role: this.role, 484 name: this.name, 485 useChildTargetToFetchChildren: this.useChildTargetToFetchChildren, 486 childCount: this.childCount, 487 checks: this._lastAudit, 488 }; 489 } 490 491 /** 492 * Provide additional (full) information about the accessible object that is 493 * otherwise missing from the form. 494 * 495 * @return {object} 496 * Object that contains accessible object information such as states, 497 * actions, attributes, etc. 498 */ 499 hydrate() { 500 return { 501 value: this.value, 502 description: this.description, 503 keyboardShortcut: this.keyboardShortcut, 504 domNodeType: this.domNodeType, 505 indexInParent: this.indexInParent, 506 states: this.states, 507 actions: this.actions, 508 attributes: this.attributes, 509 }; 510 } 511 512 _isValidTextLeaf(rawAccessible) { 513 return ( 514 !isDefunct(rawAccessible) && 515 TEXT_ROLES.has(rawAccessible.role) && 516 rawAccessible.name && 517 !!rawAccessible.name.trim().length 518 ); 519 } 520 521 /** 522 * Calculate the contrast ratio of the given accessible. 523 */ 524 async _getContrastRatio() { 525 if (!this._isValidTextLeaf(this.rawAccessible)) { 526 return null; 527 } 528 529 const { bounds } = this; 530 if (!bounds) { 531 return null; 532 } 533 534 const { DOMNode: rawNode } = this.rawAccessible; 535 const win = rawNode.ownerGlobal; 536 537 // Keep the reference to the walker actor in case the actor gets destroyed 538 // during the colour contrast ratio calculation. 539 const { walker } = this; 540 await walker.clearStyles(win); 541 const contrastRatio = await getContrastRatioFor(rawNode.parentNode, { 542 bounds: getBounds(win, bounds), 543 win, 544 appliedColorMatrix: this.walker.colorMatrix, 545 }); 546 547 if (this.isDestroyed()) { 548 // This accessible actor is destroyed. 549 return null; 550 } 551 await walker.restoreStyles(win); 552 553 return contrastRatio; 554 } 555 556 /** 557 * Run an accessibility audit for a given audit type. 558 * 559 * @param {string} type 560 * Type of an audit (Check AUDIT_TYPE in devtools/shared/constants 561 * to see available audit types). 562 * 563 * @return {null | object} 564 * Object that contains accessible audit data for a given type or null 565 * if there's nothing to report for this accessible. 566 */ 567 _getAuditByType(type) { 568 switch (type) { 569 case AUDIT_TYPE.CONTRAST: 570 return this._getContrastRatio(); 571 case AUDIT_TYPE.KEYBOARD: 572 // Determine if keyboard accessibility is lacking where it is necessary. 573 return auditKeyboard(this.rawAccessible); 574 case AUDIT_TYPE.TEXT_LABEL: 575 // Determine if text alternative is missing for an accessible where it 576 // is necessary. 577 return auditTextLabel(this.rawAccessible); 578 default: 579 return null; 580 } 581 } 582 583 /** 584 * Audit the state of the accessible object. 585 * 586 * @param {object} options 587 * Options for running audit, may include: 588 * - types: Array of audit types to be performed during audit. 589 * 590 * @return {object | null} 591 * Audit results for the accessible object. 592 */ 593 audit(options = {}) { 594 if (this._auditing) { 595 return this._auditing; 596 } 597 598 const { types } = options; 599 let auditTypes = Object.values(AUDIT_TYPE); 600 if (types && types.length) { 601 auditTypes = auditTypes.filter(auditType => types.includes(auditType)); 602 } 603 604 this._auditing = (async () => { 605 const results = []; 606 for (const auditType of auditTypes) { 607 // For some reason keyboard checks for focus styling affect values (that are 608 // used by other types of checks (text names and values)) returned by 609 // accessible objects. This happens only when multiple checks are run at the 610 // same time (asynchronously) and the audit might return unexpected 611 // failures. We thus run checks sequentially to avoid this. 612 // See bug 1594743 for more detail. 613 const audit = await this._getAuditByType(auditType); 614 results.push(audit); 615 } 616 return results; 617 })() 618 .then(results => { 619 if (this.isDefunct || this.isDestroyed()) { 620 return null; 621 } 622 623 const audit = results.reduce((auditResults, result, index) => { 624 auditResults[auditTypes[index]] = result; 625 return auditResults; 626 }, {}); 627 this._lastAudit = this._lastAudit || {}; 628 Object.assign(this._lastAudit, audit); 629 this.emit("audited", audit); 630 631 return audit; 632 }) 633 .catch(error => { 634 if (!this.isDefunct && !this.isDestroyed()) { 635 throw error; 636 } 637 return null; 638 }) 639 .finally(() => { 640 this._auditing = null; 641 }); 642 643 return this._auditing; 644 } 645 646 snapshot() { 647 return getSnapshot( 648 this.rawAccessible, 649 this.walker.a11yService, 650 this.walker.targetActor 651 ); 652 } 653 } 654 655 exports.AccessibleActor = AccessibleActor;