1 |
|
2 |
|
3 |
|
4 |
|
5 |
|
6 |
|
7 |
|
8 |
|
9 |
|
10 |
|
11 |
|
12 |
|
13 |
|
14 |
|
15 |
|
16 |
|
17 |
|
18 |
|
19 |
|
20 |
|
21 |
|
22 |
|
23 |
|
24 |
|
25 |
|
26 |
|
27 |
|
28 |
|
29 |
|
30 |
|
31 |
|
32 |
|
33 |
|
34 |
|
35 |
|
36 |
|
37 |
|
38 |
|
39 |
|
40 |
|
41 |
|
42 |
|
43 |
|
44 |
|
45 |
|
46 |
|
47 |
|
48 |
|
49 |
|
50 |
|
51 |
|
52 |
|
53 |
|
54 | class Accessibility {
|
55 | |
56 |
|
57 |
|
58 | constructor(client) {
|
59 | this._client = client;
|
60 | }
|
61 |
|
62 | |
63 |
|
64 |
|
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 |
|
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 |
|
98 |
|
99 |
|
100 |
|
101 | function 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 |
|
113 |
|
114 |
|
115 |
|
116 | function serializeTree(node, whitelistedNodes) {
|
117 |
|
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 |
|
132 | class AXNode {
|
133 | |
134 |
|
135 |
|
136 | constructor(payload) {
|
137 | this._payload = payload;
|
138 |
|
139 |
|
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 |
|
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 |
|
178 |
|
179 | _isTextOnlyObject() {
|
180 | const role = this._role;
|
181 | return (role === 'LineBreak' || role === 'text' ||
|
182 | role === 'InlineTextBox');
|
183 | }
|
184 |
|
185 | |
186 |
|
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 |
|
203 |
|
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 |
|
218 |
|
219 | isLeafNode() {
|
220 | if (!this._children.length)
|
221 | return true;
|
222 |
|
223 |
|
224 |
|
225 |
|
226 |
|
227 | if (this._isPlainTextField() || this._isTextOnlyObject())
|
228 | return true;
|
229 |
|
230 |
|
231 |
|
232 |
|
233 |
|
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 |
|
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 |
|
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 |
|
291 |
|
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 |
|
302 | if (this.isControl())
|
303 | return true;
|
304 |
|
305 |
|
306 | if (insideControl)
|
307 | return false;
|
308 |
|
309 | return this.isLeafNode() && !!this._name;
|
310 | }
|
311 |
|
312 | |
313 |
|
314 |
|
315 | serialize() {
|
316 |
|
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 |
|
328 | const node = {
|
329 | role: this._role
|
330 | };
|
331 |
|
332 |
|
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 |
|
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 |
|
361 |
|
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 |
|
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 |
|
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 |
|
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 |
|
410 |
|
411 |
|
412 | static createTree(payloads) {
|
413 |
|
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 |
|
425 | module.exports = {Accessibility};
|