1/** 2 * Copyright 2018 Google Inc. All rights reserved. 3 * 4 * Licensed under the Apache License, Version 2.0 (the 'License'); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an 'AS IS' BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17import { CDPSession } from './Connection.js'; 18import { ElementHandle } from './JSHandle.js'; 19import { Protocol } from 'devtools-protocol'; 20 21/** 22 * Represents a Node and the properties of it that are relevant to Accessibility. 23 * @public 24 */ 25export interface SerializedAXNode { 26 /** 27 * The {@link https://www.w3.org/TR/wai-aria/#usage_intro | role} of the node. 28 */ 29 role: string; 30 /** 31 * A human readable name for the node. 32 */ 33 name?: string; 34 /** 35 * The current value of the node. 36 */ 37 value?: string | number; 38 /** 39 * An additional human readable description of the node. 40 */ 41 description?: string; 42 /** 43 * Any keyboard shortcuts associated with this node. 44 */ 45 keyshortcuts?: string; 46 /** 47 * A human readable alternative to the role. 48 */ 49 roledescription?: string; 50 /** 51 * A description of the current value. 52 */ 53 valuetext?: string; 54 disabled?: boolean; 55 expanded?: boolean; 56 focused?: boolean; 57 modal?: boolean; 58 multiline?: boolean; 59 /** 60 * Whether more than one child can be selected. 61 */ 62 multiselectable?: boolean; 63 readonly?: boolean; 64 required?: boolean; 65 selected?: boolean; 66 /** 67 * Whether the checkbox is checked, or in a 68 * {@link https://www.w3.org/TR/wai-aria-practices/examples/checkbox/checkbox-2/checkbox-2.html | mixed state}. 69 */ 70 checked?: boolean | 'mixed'; 71 /** 72 * Whether the node is checked or in a mixed state. 73 */ 74 pressed?: boolean | 'mixed'; 75 /** 76 * The level of a heading. 77 */ 78 level?: number; 79 valuemin?: number; 80 valuemax?: number; 81 autocomplete?: string; 82 haspopup?: string; 83 /** 84 * Whether and in what way this node's value is invalid. 85 */ 86 invalid?: string; 87 orientation?: string; 88 /** 89 * Children of this node, if there are any. 90 */ 91 children?: SerializedAXNode[]; 92} 93 94/** 95 * @public 96 */ 97export interface SnapshotOptions { 98 /** 99 * Prune uninteresting nodes from the tree. 100 * @defaultValue true 101 */ 102 interestingOnly?: boolean; 103 /** 104 * Root node to get the accessibility tree for 105 * @defaultValue The root node of the entire page. 106 */ 107 root?: ElementHandle; 108} 109 110/** 111 * The Accessibility class provides methods for inspecting Chromium's 112 * accessibility tree. The accessibility tree is used by assistive technology 113 * such as {@link https://en.wikipedia.org/wiki/Screen_reader | screen readers} or 114 * {@link https://en.wikipedia.org/wiki/Switch_access | switches}. 115 * 116 * @remarks 117 * 118 * Accessibility is a very platform-specific thing. On different platforms, 119 * there are different screen readers that might have wildly different output. 120 * 121 * Blink - Chrome's rendering engine - has a concept of "accessibility tree", 122 * which is then translated into different platform-specific APIs. Accessibility 123 * namespace gives users access to the Blink Accessibility Tree. 124 * 125 * Most of the accessibility tree gets filtered out when converting from Blink 126 * AX Tree to Platform-specific AX-Tree or by assistive technologies themselves. 127 * By default, Puppeteer tries to approximate this filtering, exposing only 128 * the "interesting" nodes of the tree. 129 * 130 * @public 131 */ 132export class Accessibility { 133 private _client: CDPSession; 134 135 /** 136 * @internal 137 */ 138 constructor(client: CDPSession) { 139 this._client = client; 140 } 141 142 /** 143 * Captures the current state of the accessibility tree. 144 * The returned object represents the root accessible node of the page. 145 * 146 * @remarks 147 * 148 * **NOTE** The Chromium accessibility tree contains nodes that go unused on 149 * most platforms and by most screen readers. Puppeteer will discard them as 150 * well for an easier to process tree, unless `interestingOnly` is set to 151 * `false`. 152 * 153 * @example 154 * An example of dumping the entire accessibility tree: 155 * ```js 156 * const snapshot = await page.accessibility.snapshot(); 157 * console.log(snapshot); 158 * ``` 159 * 160 * @example 161 * An example of logging the focused node's name: 162 * ```js 163 * const snapshot = await page.accessibility.snapshot(); 164 * const node = findFocusedNode(snapshot); 165 * console.log(node && node.name); 166 * 167 * function findFocusedNode(node) { 168 * if (node.focused) 169 * return node; 170 * for (const child of node.children || []) { 171 * const foundNode = findFocusedNode(child); 172 * return foundNode; 173 * } 174 * return null; 175 * } 176 * ``` 177 * 178 * @returns An AXNode object representing the snapshot. 179 * 180 */ 181 public async snapshot( 182 options: SnapshotOptions = {} 183 ): Promise<SerializedAXNode> { 184 const { interestingOnly = true, root = null } = options; 185 const { nodes } = await this._client.send('Accessibility.getFullAXTree'); 186 let backendNodeId = null; 187 if (root) { 188 const { node } = await this._client.send('DOM.describeNode', { 189 objectId: root._remoteObject.objectId, 190 }); 191 backendNodeId = node.backendNodeId; 192 } 193 const defaultRoot = AXNode.createTree(nodes); 194 let needle = defaultRoot; 195 if (backendNodeId) { 196 needle = defaultRoot.find( 197 (node) => node.payload.backendDOMNodeId === backendNodeId 198 ); 199 if (!needle) return null; 200 } 201 if (!interestingOnly) return this.serializeTree(needle)[0]; 202 203 const interestingNodes = new Set<AXNode>(); 204 this.collectInterestingNodes(interestingNodes, defaultRoot, false); 205 if (!interestingNodes.has(needle)) return null; 206 return this.serializeTree(needle, interestingNodes)[0]; 207 } 208 209 private serializeTree( 210 node: AXNode, 211 interestingNodes?: Set<AXNode> 212 ): SerializedAXNode[] { 213 const children: SerializedAXNode[] = []; 214 for (const child of node.children) 215 children.push(...this.serializeTree(child, interestingNodes)); 216 217 if (interestingNodes && !interestingNodes.has(node)) return children; 218 219 const serializedNode = node.serialize(); 220 if (children.length) serializedNode.children = children; 221 return [serializedNode]; 222 } 223 224 private collectInterestingNodes( 225 collection: Set<AXNode>, 226 node: AXNode, 227 insideControl: boolean 228 ): void { 229 if (node.isInteresting(insideControl)) collection.add(node); 230 if (node.isLeafNode()) return; 231 insideControl = insideControl || node.isControl(); 232 for (const child of node.children) 233 this.collectInterestingNodes(collection, child, insideControl); 234 } 235} 236 237class AXNode { 238 public payload: Protocol.Accessibility.AXNode; 239 public children: AXNode[] = []; 240 241 private _richlyEditable = false; 242 private _editable = false; 243 private _focusable = false; 244 private _hidden = false; 245 private _name: string; 246 private _role: string; 247 private _ignored: boolean; 248 private _cachedHasFocusableChild?: boolean; 249 250 constructor(payload: Protocol.Accessibility.AXNode) { 251 this.payload = payload; 252 this._name = this.payload.name ? this.payload.name.value : ''; 253 this._role = this.payload.role ? this.payload.role.value : 'Unknown'; 254 this._ignored = this.payload.ignored; 255 256 for (const property of this.payload.properties || []) { 257 if (property.name === 'editable') { 258 this._richlyEditable = property.value.value === 'richtext'; 259 this._editable = true; 260 } 261 if (property.name === 'focusable') this._focusable = property.value.value; 262 if (property.name === 'hidden') this._hidden = property.value.value; 263 } 264 } 265 266 private _isPlainTextField(): boolean { 267 if (this._richlyEditable) return false; 268 if (this._editable) return true; 269 return this._role === 'textbox' || this._role === 'searchbox'; 270 } 271 272 private _isTextOnlyObject(): boolean { 273 const role = this._role; 274 return role === 'LineBreak' || role === 'text' || role === 'InlineTextBox'; 275 } 276 277 private _hasFocusableChild(): boolean { 278 if (this._cachedHasFocusableChild === undefined) { 279 this._cachedHasFocusableChild = false; 280 for (const child of this.children) { 281 if (child._focusable || child._hasFocusableChild()) { 282 this._cachedHasFocusableChild = true; 283 break; 284 } 285 } 286 } 287 return this._cachedHasFocusableChild; 288 } 289 290 public find(predicate: (x: AXNode) => boolean): AXNode | null { 291 if (predicate(this)) return this; 292 for (const child of this.children) { 293 const result = child.find(predicate); 294 if (result) return result; 295 } 296 return null; 297 } 298 299 public isLeafNode(): boolean { 300 if (!this.children.length) return true; 301 302 // These types of objects may have children that we use as internal 303 // implementation details, but we want to expose them as leaves to platform 304 // accessibility APIs because screen readers might be confused if they find 305 // any children. 306 if (this._isPlainTextField() || this._isTextOnlyObject()) return true; 307 308 // Roles whose children are only presentational according to the ARIA and 309 // HTML5 Specs should be hidden from screen readers. 310 // (Note that whilst ARIA buttons can have only presentational children, HTML5 311 // buttons are allowed to have content.) 312 switch (this._role) { 313 case 'doc-cover': 314 case 'graphics-symbol': 315 case 'img': 316 case 'Meter': 317 case 'scrollbar': 318 case 'slider': 319 case 'separator': 320 case 'progressbar': 321 return true; 322 default: 323 break; 324 } 325 326 // Here and below: Android heuristics 327 if (this._hasFocusableChild()) return false; 328 if (this._focusable && this._name) return true; 329 if (this._role === 'heading' && this._name) return true; 330 return false; 331 } 332 333 public isControl(): boolean { 334 switch (this._role) { 335 case 'button': 336 case 'checkbox': 337 case 'ColorWell': 338 case 'combobox': 339 case 'DisclosureTriangle': 340 case 'listbox': 341 case 'menu': 342 case 'menubar': 343 case 'menuitem': 344 case 'menuitemcheckbox': 345 case 'menuitemradio': 346 case 'radio': 347 case 'scrollbar': 348 case 'searchbox': 349 case 'slider': 350 case 'spinbutton': 351 case 'switch': 352 case 'tab': 353 case 'textbox': 354 case 'tree': 355 case 'treeitem': 356 return true; 357 default: 358 return false; 359 } 360 } 361 362 public isInteresting(insideControl: boolean): boolean { 363 const role = this._role; 364 if (role === 'Ignored' || this._hidden || this._ignored) return false; 365 366 if (this._focusable || this._richlyEditable) return true; 367 368 // If it's not focusable but has a control role, then it's interesting. 369 if (this.isControl()) return true; 370 371 // A non focusable child of a control is not interesting 372 if (insideControl) return false; 373 374 return this.isLeafNode() && !!this._name; 375 } 376 377 public serialize(): SerializedAXNode { 378 const properties = new Map<string, number | string | boolean>(); 379 for (const property of this.payload.properties || []) 380 properties.set(property.name.toLowerCase(), property.value.value); 381 if (this.payload.name) properties.set('name', this.payload.name.value); 382 if (this.payload.value) properties.set('value', this.payload.value.value); 383 if (this.payload.description) 384 properties.set('description', this.payload.description.value); 385 386 const node: SerializedAXNode = { 387 role: this._role, 388 }; 389 390 type UserStringProperty = 391 | 'name' 392 | 'value' 393 | 'description' 394 | 'keyshortcuts' 395 | 'roledescription' 396 | 'valuetext'; 397 398 const userStringProperties: UserStringProperty[] = [ 399 'name', 400 'value', 401 'description', 402 'keyshortcuts', 403 'roledescription', 404 'valuetext', 405 ]; 406 const getUserStringPropertyValue = (key: UserStringProperty): string => 407 properties.get(key) as string; 408 409 for (const userStringProperty of userStringProperties) { 410 if (!properties.has(userStringProperty)) continue; 411 412 node[userStringProperty] = getUserStringPropertyValue(userStringProperty); 413 } 414 415 type BooleanProperty = 416 | 'disabled' 417 | 'expanded' 418 | 'focused' 419 | 'modal' 420 | 'multiline' 421 | 'multiselectable' 422 | 'readonly' 423 | 'required' 424 | 'selected'; 425 const booleanProperties: BooleanProperty[] = [ 426 'disabled', 427 'expanded', 428 'focused', 429 'modal', 430 'multiline', 431 'multiselectable', 432 'readonly', 433 'required', 434 'selected', 435 ]; 436 const getBooleanPropertyValue = (key: BooleanProperty): boolean => 437 properties.get(key) as boolean; 438 439 for (const booleanProperty of booleanProperties) { 440 // RootWebArea's treat focus differently than other nodes. They report whether 441 // their frame has focus, not whether focus is specifically on the root 442 // node. 443 if (booleanProperty === 'focused' && this._role === 'RootWebArea') 444 continue; 445 const value = getBooleanPropertyValue(booleanProperty); 446 if (!value) continue; 447 node[booleanProperty] = getBooleanPropertyValue(booleanProperty); 448 } 449 450 type TristateProperty = 'checked' | 'pressed'; 451 const tristateProperties: TristateProperty[] = ['checked', 'pressed']; 452 for (const tristateProperty of tristateProperties) { 453 if (!properties.has(tristateProperty)) continue; 454 const value = properties.get(tristateProperty); 455 node[tristateProperty] = 456 value === 'mixed' ? 'mixed' : value === 'true' ? true : false; 457 } 458 459 type NumbericalProperty = 'level' | 'valuemax' | 'valuemin'; 460 const numericalProperties: NumbericalProperty[] = [ 461 'level', 462 'valuemax', 463 'valuemin', 464 ]; 465 const getNumericalPropertyValue = (key: NumbericalProperty): number => 466 properties.get(key) as number; 467 for (const numericalProperty of numericalProperties) { 468 if (!properties.has(numericalProperty)) continue; 469 node[numericalProperty] = getNumericalPropertyValue(numericalProperty); 470 } 471 472 type TokenProperty = 473 | 'autocomplete' 474 | 'haspopup' 475 | 'invalid' 476 | 'orientation'; 477 const tokenProperties: TokenProperty[] = [ 478 'autocomplete', 479 'haspopup', 480 'invalid', 481 'orientation', 482 ]; 483 const getTokenPropertyValue = (key: TokenProperty): string => 484 properties.get(key) as string; 485 for (const tokenProperty of tokenProperties) { 486 const value = getTokenPropertyValue(tokenProperty); 487 if (!value || value === 'false') continue; 488 node[tokenProperty] = getTokenPropertyValue(tokenProperty); 489 } 490 return node; 491 } 492 493 public static createTree(payloads: Protocol.Accessibility.AXNode[]): AXNode { 494 const nodeById = new Map<string, AXNode>(); 495 for (const payload of payloads) 496 nodeById.set(payload.nodeId, new AXNode(payload)); 497 for (const node of nodeById.values()) { 498 for (const childId of node.payload.childIds || []) 499 node.children.push(nodeById.get(childId)); 500 } 501 return nodeById.values().next().value; 502 } 503} 504