UNPKG

11.7 kBJavaScriptView Raw
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
17/**
18 * @typedef {Object} SerializedAXNode
19 * @property {string} role
20 *
21 * @property {string=} name
22 * @property {string|number=} value
23 * @property {string=} description
24 *
25 * @property {string=} keyshortcuts
26 * @property {string=} roledescription
27 * @property {string=} valuetext
28 *
29 * @property {boolean=} disabled
30 * @property {boolean=} expanded
31 * @property {boolean=} focused
32 * @property {boolean=} modal
33 * @property {boolean=} multiline
34 * @property {boolean=} multiselectable
35 * @property {boolean=} readonly
36 * @property {boolean=} required
37 * @property {boolean=} selected
38 *
39 * @property {boolean|"mixed"=} checked
40 * @property {boolean|"mixed"=} pressed
41 *
42 * @property {number=} level
43 * @property {number=} valuemin
44 * @property {number=} valuemax
45 *
46 * @property {string=} autocomplete
47 * @property {string=} haspopup
48 * @property {string=} invalid
49 * @property {string=} orientation
50 *
51 * @property {Array<SerializedAXNode>=} children
52 */
53
54class Accessibility {
55 /**
56 * @param {!Puppeteer.CDPSession} client
57 */
58 constructor(client) {
59 this._client = client;
60 }
61
62 /**
63 * @param {{interestingOnly?: boolean, root?: ?Puppeteer.ElementHandle}=} options
64 * @return {!Promise<!SerializedAXNode>}
65 */
66 async snapshot(options = {}) {
67 const {
68 interestingOnly = true,
69 root = null,
70 } = options;
71 const {nodes} = await this._client.send('Accessibility.getFullAXTree');
72 let backendNodeId = null;
73 if (root) {
74 const {node} = await this._client.send('DOM.describeNode', {objectId: root._remoteObject.objectId});
75 backendNodeId = node.backendNodeId;
76 }
77 const defaultRoot = AXNode.createTree(nodes);
78 let needle = defaultRoot;
79 if (backendNodeId) {
80 needle = defaultRoot.find(node => node._payload.backendDOMNodeId === backendNodeId);
81 if (!needle)
82 return null;
83 }
84 if (!interestingOnly)
85 return serializeTree(needle)[0];
86
87 /** @type {!Set<!AXNode>} */
88 const interestingNodes = new Set();
89 collectInterestingNodes(interestingNodes, defaultRoot, false);
90 if (!interestingNodes.has(needle))
91 return null;
92 return serializeTree(needle, interestingNodes)[0];
93 }
94}
95
96/**
97 * @param {!Set<!AXNode>} collection
98 * @param {!AXNode} node
99 * @param {boolean} insideControl
100 */
101function collectInterestingNodes(collection, node, insideControl) {
102 if (node.isInteresting(insideControl))
103 collection.add(node);
104 if (node.isLeafNode())
105 return;
106 insideControl = insideControl || node.isControl();
107 for (const child of node._children)
108 collectInterestingNodes(collection, child, insideControl);
109}
110
111/**
112 * @param {!AXNode} node
113 * @param {!Set<!AXNode>=} whitelistedNodes
114 * @return {!Array<!SerializedAXNode>}
115 */
116function serializeTree(node, whitelistedNodes) {
117 /** @type {!Array<!SerializedAXNode>} */
118 const children = [];
119 for (const child of node._children)
120 children.push(...serializeTree(child, whitelistedNodes));
121
122 if (whitelistedNodes && !whitelistedNodes.has(node))
123 return children;
124
125 const serializedNode = node.serialize();
126 if (children.length)
127 serializedNode.children = children;
128 return [serializedNode];
129}
130
131
132class AXNode {
133 /**
134 * @param {!Protocol.Accessibility.AXNode} payload
135 */
136 constructor(payload) {
137 this._payload = payload;
138
139 /** @type {!Array<!AXNode>} */
140 this._children = [];
141
142 this._richlyEditable = false;
143 this._editable = false;
144 this._focusable = false;
145 this._expanded = false;
146 this._hidden = false;
147 this._name = this._payload.name ? this._payload.name.value : '';
148 this._role = this._payload.role ? this._payload.role.value : 'Unknown';
149 this._cachedHasFocusableChild;
150
151 for (const property of this._payload.properties || []) {
152 if (property.name === 'editable') {
153 this._richlyEditable = property.value.value === 'richtext';
154 this._editable = true;
155 }
156 if (property.name === 'focusable')
157 this._focusable = property.value.value;
158 if (property.name === 'expanded')
159 this._expanded = property.value.value;
160 if (property.name === 'hidden')
161 this._hidden = property.value.value;
162 }
163 }
164
165 /**
166 * @return {boolean}
167 */
168 _isPlainTextField() {
169 if (this._richlyEditable)
170 return false;
171 if (this._editable)
172 return true;
173 return this._role === 'textbox' || this._role === 'ComboBox' || this._role === 'searchbox';
174 }
175
176 /**
177 * @return {boolean}
178 */
179 _isTextOnlyObject() {
180 const role = this._role;
181 return (role === 'LineBreak' || role === 'text' ||
182 role === 'InlineTextBox');
183 }
184
185 /**
186 * @return {boolean}
187 */
188 _hasFocusableChild() {
189 if (this._cachedHasFocusableChild === undefined) {
190 this._cachedHasFocusableChild = false;
191 for (const child of this._children) {
192 if (child._focusable || child._hasFocusableChild()) {
193 this._cachedHasFocusableChild = true;
194 break;
195 }
196 }
197 }
198 return this._cachedHasFocusableChild;
199 }
200
201 /**
202 * @param {function(AXNode):boolean} predicate
203 * @return {?AXNode}
204 */
205 find(predicate) {
206 if (predicate(this))
207 return this;
208 for (const child of this._children) {
209 const result = child.find(predicate);
210 if (result)
211 return result;
212 }
213 return null;
214 }
215
216 /**
217 * @return {boolean}
218 */
219 isLeafNode() {
220 if (!this._children.length)
221 return true;
222
223 // These types of objects may have children that we use as internal
224 // implementation details, but we want to expose them as leaves to platform
225 // accessibility APIs because screen readers might be confused if they find
226 // any children.
227 if (this._isPlainTextField() || this._isTextOnlyObject())
228 return true;
229
230 // Roles whose children are only presentational according to the ARIA and
231 // HTML5 Specs should be hidden from screen readers.
232 // (Note that whilst ARIA buttons can have only presentational children, HTML5
233 // buttons are allowed to have content.)
234 switch (this._role) {
235 case 'doc-cover':
236 case 'graphics-symbol':
237 case 'img':
238 case 'Meter':
239 case 'scrollbar':
240 case 'slider':
241 case 'separator':
242 case 'progressbar':
243 return true;
244 default:
245 break;
246 }
247
248 // Here and below: Android heuristics
249 if (this._hasFocusableChild())
250 return false;
251 if (this._focusable && this._name)
252 return true;
253 if (this._role === 'heading' && this._name)
254 return true;
255 return false;
256 }
257
258 /**
259 * @return {boolean}
260 */
261 isControl() {
262 switch (this._role) {
263 case 'button':
264 case 'checkbox':
265 case 'ColorWell':
266 case 'combobox':
267 case 'DisclosureTriangle':
268 case 'listbox':
269 case 'menu':
270 case 'menubar':
271 case 'menuitem':
272 case 'menuitemcheckbox':
273 case 'menuitemradio':
274 case 'radio':
275 case 'scrollbar':
276 case 'searchbox':
277 case 'slider':
278 case 'spinbutton':
279 case 'switch':
280 case 'tab':
281 case 'textbox':
282 case 'tree':
283 return true;
284 default:
285 return false;
286 }
287 }
288
289 /**
290 * @param {boolean} insideControl
291 * @return {boolean}
292 */
293 isInteresting(insideControl) {
294 const role = this._role;
295 if (role === 'Ignored' || this._hidden)
296 return false;
297
298 if (this._focusable || this._richlyEditable)
299 return true;
300
301 // If it's not focusable but has a control role, then it's interesting.
302 if (this.isControl())
303 return true;
304
305 // A non focusable child of a control is not interesting
306 if (insideControl)
307 return false;
308
309 return this.isLeafNode() && !!this._name;
310 }
311
312 /**
313 * @return {!SerializedAXNode}
314 */
315 serialize() {
316 /** @type {!Map<string, number|string|boolean>} */
317 const properties = new Map();
318 for (const property of this._payload.properties || [])
319 properties.set(property.name.toLowerCase(), property.value.value);
320 if (this._payload.name)
321 properties.set('name', this._payload.name.value);
322 if (this._payload.value)
323 properties.set('value', this._payload.value.value);
324 if (this._payload.description)
325 properties.set('description', this._payload.description.value);
326
327 /** @type {SerializedAXNode} */
328 const node = {
329 role: this._role
330 };
331
332 /** @type {!Array<keyof SerializedAXNode>} */
333 const userStringProperties = [
334 'name',
335 'value',
336 'description',
337 'keyshortcuts',
338 'roledescription',
339 'valuetext',
340 ];
341 for (const userStringProperty of userStringProperties) {
342 if (!properties.has(userStringProperty))
343 continue;
344 node[userStringProperty] = properties.get(userStringProperty);
345 }
346
347 /** @type {!Array<keyof SerializedAXNode>} */
348 const booleanProperties = [
349 'disabled',
350 'expanded',
351 'focused',
352 'modal',
353 'multiline',
354 'multiselectable',
355 'readonly',
356 'required',
357 'selected',
358 ];
359 for (const booleanProperty of booleanProperties) {
360 // WebArea's treat focus differently than other nodes. They report whether their frame has focus,
361 // not whether focus is specifically on the root node.
362 if (booleanProperty === 'focused' && this._role === 'WebArea')
363 continue;
364 const value = properties.get(booleanProperty);
365 if (!value)
366 continue;
367 node[booleanProperty] = value;
368 }
369
370 /** @type {!Array<keyof SerializedAXNode>} */
371 const tristateProperties = [
372 'checked',
373 'pressed',
374 ];
375 for (const tristateProperty of tristateProperties) {
376 if (!properties.has(tristateProperty))
377 continue;
378 const value = properties.get(tristateProperty);
379 node[tristateProperty] = value === 'mixed' ? 'mixed' : value === 'true' ? true : false;
380 }
381 /** @type {!Array<keyof SerializedAXNode>} */
382 const numericalProperties = [
383 'level',
384 'valuemax',
385 'valuemin',
386 ];
387 for (const numericalProperty of numericalProperties) {
388 if (!properties.has(numericalProperty))
389 continue;
390 node[numericalProperty] = properties.get(numericalProperty);
391 }
392 /** @type {!Array<keyof SerializedAXNode>} */
393 const tokenProperties = [
394 'autocomplete',
395 'haspopup',
396 'invalid',
397 'orientation',
398 ];
399 for (const tokenProperty of tokenProperties) {
400 const value = properties.get(tokenProperty);
401 if (!value || value === 'false')
402 continue;
403 node[tokenProperty] = value;
404 }
405 return node;
406 }
407
408 /**
409 * @param {!Array<!Protocol.Accessibility.AXNode>} payloads
410 * @return {!AXNode}
411 */
412 static createTree(payloads) {
413 /** @type {!Map<string, !AXNode>} */
414 const nodeById = new Map();
415 for (const payload of payloads)
416 nodeById.set(payload.nodeId, new AXNode(payload));
417 for (const node of nodeById.values()) {
418 for (const childId of node._payload.childIds || [])
419 node._children.push(nodeById.get(childId));
420 }
421 return nodeById.values().next().value;
422 }
423}
424
425module.exports = {Accessibility};