Accessibility.ts (17583B)
1 /** 2 * @license 3 * Copyright 2018 Google Inc. 4 * SPDX-License-Identifier: Apache-2.0 5 */ 6 7 import type {Protocol} from 'devtools-protocol'; 8 9 import type {ElementHandle} from '../api/ElementHandle.js'; 10 import type {Realm} from '../api/Realm.js'; 11 12 /** 13 * Represents a Node and the properties of it that are relevant to Accessibility. 14 * @public 15 */ 16 export interface SerializedAXNode { 17 /** 18 * The {@link https://www.w3.org/TR/wai-aria/#usage_intro | role} of the node. 19 */ 20 role: string; 21 /** 22 * A human readable name for the node. 23 */ 24 name?: string; 25 /** 26 * The current value of the node. 27 */ 28 value?: string | number; 29 /** 30 * An additional human readable description of the node. 31 */ 32 description?: string; 33 /** 34 * Any keyboard shortcuts associated with this node. 35 */ 36 keyshortcuts?: string; 37 /** 38 * A human readable alternative to the role. 39 */ 40 roledescription?: string; 41 /** 42 * A description of the current value. 43 */ 44 valuetext?: string; 45 disabled?: boolean; 46 expanded?: boolean; 47 focused?: boolean; 48 modal?: boolean; 49 multiline?: boolean; 50 /** 51 * Whether more than one child can be selected. 52 */ 53 multiselectable?: boolean; 54 readonly?: boolean; 55 required?: boolean; 56 selected?: boolean; 57 /** 58 * Whether the checkbox is checked, or in a 59 * {@link https://www.w3.org/TR/wai-aria-practices/examples/checkbox/checkbox-2/checkbox-2.html | mixed state}. 60 */ 61 checked?: boolean | 'mixed'; 62 /** 63 * Whether the node is checked or in a mixed state. 64 */ 65 pressed?: boolean | 'mixed'; 66 /** 67 * The level of a heading. 68 */ 69 level?: number; 70 valuemin?: number; 71 valuemax?: number; 72 autocomplete?: string; 73 haspopup?: string; 74 /** 75 * Whether and in what way this node's value is invalid. 76 */ 77 invalid?: string; 78 orientation?: string; 79 /** 80 * Children of this node, if there are any. 81 */ 82 children?: SerializedAXNode[]; 83 84 /** 85 * Get an ElementHandle for this AXNode if available. 86 * 87 * If the underlying DOM element has been disposed, the method might return an 88 * error. 89 */ 90 elementHandle(): Promise<ElementHandle | null>; 91 } 92 93 /** 94 * @public 95 */ 96 export interface SnapshotOptions { 97 /** 98 * Prune uninteresting nodes from the tree. 99 * @defaultValue `true` 100 */ 101 interestingOnly?: boolean; 102 /** 103 * If true, gets accessibility trees for each of the iframes in the frame 104 * subtree. 105 * 106 * @defaultValue `false` 107 */ 108 includeIframes?: boolean; 109 /** 110 * Root node to get the accessibility tree for 111 * @defaultValue The root node of the entire page. 112 */ 113 root?: ElementHandle<Node>; 114 } 115 116 /** 117 * The Accessibility class provides methods for inspecting the browser's 118 * accessibility tree. The accessibility tree is used by assistive technology 119 * such as {@link https://en.wikipedia.org/wiki/Screen_reader | screen readers} or 120 * {@link https://en.wikipedia.org/wiki/Switch_access | switches}. 121 * 122 * @remarks 123 * 124 * Accessibility is a very platform-specific thing. On different platforms, 125 * there are different screen readers that might have wildly different output. 126 * 127 * Blink - Chrome's rendering engine - has a concept of "accessibility tree", 128 * which is then translated into different platform-specific APIs. Accessibility 129 * namespace gives users access to the Blink Accessibility Tree. 130 * 131 * Most of the accessibility tree gets filtered out when converting from Blink 132 * AX Tree to Platform-specific AX-Tree or by assistive technologies themselves. 133 * By default, Puppeteer tries to approximate this filtering, exposing only 134 * the "interesting" nodes of the tree. 135 * 136 * @public 137 */ 138 export class Accessibility { 139 #realm: Realm; 140 #frameId: string; 141 142 /** 143 * @internal 144 */ 145 constructor(realm: Realm, frameId = '') { 146 this.#realm = realm; 147 this.#frameId = frameId; 148 } 149 150 /** 151 * Captures the current state of the accessibility tree. 152 * The returned object represents the root accessible node of the page. 153 * 154 * @remarks 155 * 156 * **NOTE** The Chrome accessibility tree contains nodes that go unused on 157 * most platforms and by most screen readers. Puppeteer will discard them as 158 * well for an easier to process tree, unless `interestingOnly` is set to 159 * `false`. 160 * 161 * @example 162 * An example of dumping the entire accessibility tree: 163 * 164 * ```ts 165 * const snapshot = await page.accessibility.snapshot(); 166 * console.log(snapshot); 167 * ``` 168 * 169 * @example 170 * An example of logging the focused node's name: 171 * 172 * ```ts 173 * const snapshot = await page.accessibility.snapshot(); 174 * const node = findFocusedNode(snapshot); 175 * console.log(node && node.name); 176 * 177 * function findFocusedNode(node) { 178 * if (node.focused) return node; 179 * for (const child of node.children || []) { 180 * const foundNode = findFocusedNode(child); 181 * return foundNode; 182 * } 183 * return null; 184 * } 185 * ``` 186 * 187 * @returns An AXNode object representing the snapshot. 188 */ 189 public async snapshot( 190 options: SnapshotOptions = {}, 191 ): Promise<SerializedAXNode | null> { 192 const { 193 interestingOnly = true, 194 root = null, 195 includeIframes = false, 196 } = options; 197 const {nodes} = await this.#realm.environment.client.send( 198 'Accessibility.getFullAXTree', 199 { 200 frameId: this.#frameId, 201 }, 202 ); 203 let backendNodeId: number | undefined; 204 if (root) { 205 const {node} = await this.#realm.environment.client.send( 206 'DOM.describeNode', 207 { 208 objectId: root.id, 209 }, 210 ); 211 backendNodeId = node.backendNodeId; 212 } 213 const defaultRoot = AXNode.createTree(this.#realm, nodes); 214 const populateIframes = async (root: AXNode): Promise<void> => { 215 if (root.payload.role?.value === 'Iframe') { 216 if (!root.payload.backendDOMNodeId) { 217 return; 218 } 219 using handle = (await this.#realm.adoptBackendNode( 220 root.payload.backendDOMNodeId, 221 )) as ElementHandle<Element>; 222 if (!handle || !('contentFrame' in handle)) { 223 return; 224 } 225 const frame = await handle.contentFrame(); 226 if (!frame) { 227 return; 228 } 229 const iframeSnapshot = await frame.accessibility.snapshot(options); 230 root.iframeSnapshot = iframeSnapshot ?? undefined; 231 } 232 for (const child of root.children) { 233 await populateIframes(child); 234 } 235 }; 236 237 let needle: AXNode | null = defaultRoot; 238 if (!defaultRoot) { 239 return null; 240 } 241 242 if (includeIframes) { 243 await populateIframes(defaultRoot); 244 } 245 246 if (backendNodeId) { 247 needle = defaultRoot.find(node => { 248 return node.payload.backendDOMNodeId === backendNodeId; 249 }); 250 } 251 252 if (!needle) { 253 return null; 254 } 255 256 if (!interestingOnly) { 257 return this.serializeTree(needle)[0] ?? null; 258 } 259 260 const interestingNodes = new Set<AXNode>(); 261 this.collectInterestingNodes(interestingNodes, defaultRoot, false); 262 if (!interestingNodes.has(needle)) { 263 return null; 264 } 265 return this.serializeTree(needle, interestingNodes)[0] ?? null; 266 } 267 268 private serializeTree( 269 node: AXNode, 270 interestingNodes?: Set<AXNode>, 271 ): SerializedAXNode[] { 272 const children: SerializedAXNode[] = []; 273 for (const child of node.children) { 274 children.push(...this.serializeTree(child, interestingNodes)); 275 } 276 277 if (interestingNodes && !interestingNodes.has(node)) { 278 return children; 279 } 280 281 const serializedNode = node.serialize(); 282 if (children.length) { 283 serializedNode.children = children; 284 } 285 if (node.iframeSnapshot) { 286 if (!serializedNode.children) { 287 serializedNode.children = []; 288 } 289 serializedNode.children.push(node.iframeSnapshot); 290 } 291 return [serializedNode]; 292 } 293 294 private collectInterestingNodes( 295 collection: Set<AXNode>, 296 node: AXNode, 297 insideControl: boolean, 298 ): void { 299 if (node.isInteresting(insideControl) || node.iframeSnapshot) { 300 collection.add(node); 301 } 302 if (node.isLeafNode()) { 303 return; 304 } 305 insideControl = insideControl || node.isControl(); 306 for (const child of node.children) { 307 this.collectInterestingNodes(collection, child, insideControl); 308 } 309 } 310 } 311 312 class AXNode { 313 public payload: Protocol.Accessibility.AXNode; 314 public children: AXNode[] = []; 315 public iframeSnapshot?: SerializedAXNode; 316 317 #richlyEditable = false; 318 #editable = false; 319 #focusable = false; 320 #hidden = false; 321 #name: string; 322 #role: string; 323 #ignored: boolean; 324 #cachedHasFocusableChild?: boolean; 325 #realm: Realm; 326 327 constructor(realm: Realm, payload: Protocol.Accessibility.AXNode) { 328 this.payload = payload; 329 this.#name = this.payload.name ? this.payload.name.value : ''; 330 this.#role = this.payload.role ? this.payload.role.value : 'Unknown'; 331 this.#ignored = this.payload.ignored; 332 this.#realm = realm; 333 for (const property of this.payload.properties || []) { 334 if (property.name === 'editable') { 335 this.#richlyEditable = property.value.value === 'richtext'; 336 this.#editable = true; 337 } 338 if (property.name === 'focusable') { 339 this.#focusable = property.value.value; 340 } 341 if (property.name === 'hidden') { 342 this.#hidden = property.value.value; 343 } 344 } 345 } 346 347 #isPlainTextField(): boolean { 348 if (this.#richlyEditable) { 349 return false; 350 } 351 if (this.#editable) { 352 return true; 353 } 354 return this.#role === 'textbox' || this.#role === 'searchbox'; 355 } 356 357 #isTextOnlyObject(): boolean { 358 const role = this.#role; 359 return ( 360 role === 'LineBreak' || 361 role === 'text' || 362 role === 'InlineTextBox' || 363 role === 'StaticText' 364 ); 365 } 366 367 #hasFocusableChild(): boolean { 368 if (this.#cachedHasFocusableChild === undefined) { 369 this.#cachedHasFocusableChild = false; 370 for (const child of this.children) { 371 if (child.#focusable || child.#hasFocusableChild()) { 372 this.#cachedHasFocusableChild = true; 373 break; 374 } 375 } 376 } 377 return this.#cachedHasFocusableChild; 378 } 379 380 public find(predicate: (x: AXNode) => boolean): AXNode | null { 381 if (predicate(this)) { 382 return this; 383 } 384 for (const child of this.children) { 385 const result = child.find(predicate); 386 if (result) { 387 return result; 388 } 389 } 390 return null; 391 } 392 393 public isLeafNode(): boolean { 394 if (!this.children.length) { 395 return true; 396 } 397 398 // These types of objects may have children that we use as internal 399 // implementation details, but we want to expose them as leaves to platform 400 // accessibility APIs because screen readers might be confused if they find 401 // any children. 402 if (this.#isPlainTextField() || this.#isTextOnlyObject()) { 403 return true; 404 } 405 406 // Roles whose children are only presentational according to the ARIA and 407 // HTML5 Specs should be hidden from screen readers. 408 // (Note that whilst ARIA buttons can have only presentational children, HTML5 409 // buttons are allowed to have content.) 410 switch (this.#role) { 411 case 'doc-cover': 412 case 'graphics-symbol': 413 case 'img': 414 case 'image': 415 case 'Meter': 416 case 'scrollbar': 417 case 'slider': 418 case 'separator': 419 case 'progressbar': 420 return true; 421 default: 422 break; 423 } 424 425 // Here and below: Android heuristics 426 if (this.#hasFocusableChild()) { 427 return false; 428 } 429 if (this.#focusable && this.#name) { 430 return true; 431 } 432 if (this.#role === 'heading' && this.#name) { 433 return true; 434 } 435 return false; 436 } 437 438 public isControl(): boolean { 439 switch (this.#role) { 440 case 'button': 441 case 'checkbox': 442 case 'ColorWell': 443 case 'combobox': 444 case 'DisclosureTriangle': 445 case 'listbox': 446 case 'menu': 447 case 'menubar': 448 case 'menuitem': 449 case 'menuitemcheckbox': 450 case 'menuitemradio': 451 case 'radio': 452 case 'scrollbar': 453 case 'searchbox': 454 case 'slider': 455 case 'spinbutton': 456 case 'switch': 457 case 'tab': 458 case 'textbox': 459 case 'tree': 460 case 'treeitem': 461 return true; 462 default: 463 return false; 464 } 465 } 466 467 public isInteresting(insideControl: boolean): boolean { 468 const role = this.#role; 469 if (role === 'Ignored' || this.#hidden || this.#ignored) { 470 return false; 471 } 472 473 if (this.#focusable || this.#richlyEditable) { 474 return true; 475 } 476 477 // If it's not focusable but has a control role, then it's interesting. 478 if (this.isControl()) { 479 return true; 480 } 481 482 // A non focusable child of a control is not interesting 483 if (insideControl) { 484 return false; 485 } 486 487 return this.isLeafNode() && !!this.#name; 488 } 489 490 public serialize(): SerializedAXNode { 491 const properties = new Map<string, number | string | boolean>(); 492 for (const property of this.payload.properties || []) { 493 properties.set(property.name.toLowerCase(), property.value.value); 494 } 495 if (this.payload.name) { 496 properties.set('name', this.payload.name.value); 497 } 498 if (this.payload.value) { 499 properties.set('value', this.payload.value.value); 500 } 501 if (this.payload.description) { 502 properties.set('description', this.payload.description.value); 503 } 504 505 const node: SerializedAXNode = { 506 role: this.#role, 507 elementHandle: async (): Promise<ElementHandle | null> => { 508 if (!this.payload.backendDOMNodeId) { 509 return null; 510 } 511 return (await this.#realm.adoptBackendNode( 512 this.payload.backendDOMNodeId, 513 )) as ElementHandle<Element>; 514 }, 515 }; 516 517 type UserStringProperty = 518 | 'name' 519 | 'value' 520 | 'description' 521 | 'keyshortcuts' 522 | 'roledescription' 523 | 'valuetext'; 524 525 const userStringProperties: UserStringProperty[] = [ 526 'name', 527 'value', 528 'description', 529 'keyshortcuts', 530 'roledescription', 531 'valuetext', 532 ]; 533 const getUserStringPropertyValue = (key: UserStringProperty): string => { 534 return properties.get(key) as string; 535 }; 536 537 for (const userStringProperty of userStringProperties) { 538 if (!properties.has(userStringProperty)) { 539 continue; 540 } 541 542 node[userStringProperty] = getUserStringPropertyValue(userStringProperty); 543 } 544 545 type BooleanProperty = 546 | 'disabled' 547 | 'expanded' 548 | 'focused' 549 | 'modal' 550 | 'multiline' 551 | 'multiselectable' 552 | 'readonly' 553 | 'required' 554 | 'selected'; 555 const booleanProperties: BooleanProperty[] = [ 556 'disabled', 557 'expanded', 558 'focused', 559 'modal', 560 'multiline', 561 'multiselectable', 562 'readonly', 563 'required', 564 'selected', 565 ]; 566 const getBooleanPropertyValue = (key: BooleanProperty): boolean => { 567 return properties.get(key) as boolean; 568 }; 569 570 for (const booleanProperty of booleanProperties) { 571 // RootWebArea's treat focus differently than other nodes. They report whether 572 // their frame has focus, not whether focus is specifically on the root 573 // node. 574 if (booleanProperty === 'focused' && this.#role === 'RootWebArea') { 575 continue; 576 } 577 const value = getBooleanPropertyValue(booleanProperty); 578 if (!value) { 579 continue; 580 } 581 node[booleanProperty] = getBooleanPropertyValue(booleanProperty); 582 } 583 584 type TristateProperty = 'checked' | 'pressed'; 585 const tristateProperties: TristateProperty[] = ['checked', 'pressed']; 586 for (const tristateProperty of tristateProperties) { 587 if (!properties.has(tristateProperty)) { 588 continue; 589 } 590 const value = properties.get(tristateProperty); 591 node[tristateProperty] = 592 value === 'mixed' ? 'mixed' : value === 'true' ? true : false; 593 } 594 595 type NumbericalProperty = 'level' | 'valuemax' | 'valuemin'; 596 const numericalProperties: NumbericalProperty[] = [ 597 'level', 598 'valuemax', 599 'valuemin', 600 ]; 601 const getNumericalPropertyValue = (key: NumbericalProperty): number => { 602 return properties.get(key) as number; 603 }; 604 for (const numericalProperty of numericalProperties) { 605 if (!properties.has(numericalProperty)) { 606 continue; 607 } 608 node[numericalProperty] = getNumericalPropertyValue(numericalProperty); 609 } 610 611 type TokenProperty = 612 | 'autocomplete' 613 | 'haspopup' 614 | 'invalid' 615 | 'orientation'; 616 const tokenProperties: TokenProperty[] = [ 617 'autocomplete', 618 'haspopup', 619 'invalid', 620 'orientation', 621 ]; 622 const getTokenPropertyValue = (key: TokenProperty): string => { 623 return properties.get(key) as string; 624 }; 625 for (const tokenProperty of tokenProperties) { 626 const value = getTokenPropertyValue(tokenProperty); 627 if (!value || value === 'false') { 628 continue; 629 } 630 node[tokenProperty] = getTokenPropertyValue(tokenProperty); 631 } 632 return node; 633 } 634 635 public static createTree( 636 realm: Realm, 637 payloads: Protocol.Accessibility.AXNode[], 638 ): AXNode | null { 639 const nodeById = new Map<string, AXNode>(); 640 for (const payload of payloads) { 641 nodeById.set(payload.nodeId, new AXNode(realm, payload)); 642 } 643 for (const node of nodeById.values()) { 644 for (const childId of node.payload.childIds || []) { 645 const child = nodeById.get(childId); 646 if (child) { 647 node.children.push(child); 648 } 649 } 650 } 651 return nodeById.values().next().value ?? null; 652 } 653 }