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