Accessibility.js 11 KB

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