testaro 78.0.8 → 78.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (357) hide show
  1. package/.gitattributes +94 -0
  2. package/.github/workflows/ci.yml +58 -0
  3. package/.github/workflows/publish.yml +50 -0
  4. package/.github/workflows/typescript.yml +35 -0
  5. package/AGENTS.md +2 -2
  6. package/CLAUDE.md +24 -18
  7. package/CONTAINERS.md +9 -6
  8. package/CONTRIBUTING.md +29 -3
  9. package/Dockerfile +1 -1
  10. package/README.md +143 -19
  11. package/UPGRADES.md +4 -0
  12. package/actSpecs-doc.md +13 -1
  13. package/actSpecs.js +29 -5
  14. package/call.js +6 -5
  15. package/dist/nameComputation.js +1 -0
  16. package/docker-compose.yml +8 -2
  17. package/docs/checkpoint-scanning.md +199 -0
  18. package/docs/standard-result-outcome.md +165 -0
  19. package/env.example +49 -10
  20. package/eslint.config.mjs +101 -0
  21. package/netWatch.js +372 -222
  22. package/package.json +46 -31
  23. package/pour/README.md +47 -0
  24. package/pour/pour.min.js +8 -0
  25. package/procs/actDo.js +808 -0
  26. package/procs/catalog.d.ts +17 -0
  27. package/procs/catalog.js +312 -221
  28. package/procs/catalog.ts +398 -0
  29. package/procs/checkpoint.js +110 -0
  30. package/procs/config.d.ts +41 -0
  31. package/procs/config.js +98 -0
  32. package/procs/dateTime.js +2 -1
  33. package/procs/doActs.js +442 -849
  34. package/procs/doTestAct.js +27 -121
  35. package/procs/flow.js +221 -0
  36. package/procs/generateRuleRegistry.js +94 -0
  37. package/procs/getSource.d.ts +26 -0
  38. package/procs/job.js +115 -1
  39. package/procs/launch.d.ts +40 -0
  40. package/procs/launch.js +243 -108
  41. package/procs/nu.d.ts +48 -0
  42. package/procs/scope.js +196 -0
  43. package/procs/shoot.d.ts +33 -0
  44. package/procs/standard.d.ts +18 -0
  45. package/procs/standard.js +83 -0
  46. package/procs/standard.ts +126 -0
  47. package/procs/testAct.js +145 -0
  48. package/procs/testaro.d.ts +20 -0
  49. package/procs/testaro.js +236 -189
  50. package/procs/testaro.ts +321 -0
  51. package/procs/userPath.js +126 -0
  52. package/procs/xPath.d.ts +4 -0
  53. package/procs/xPath.js +81 -69
  54. package/procs/xPath.ts +106 -0
  55. package/procs/xPathScript.d.ts +6 -0
  56. package/procs/xPathScript.js +70 -0
  57. package/run.js +3 -2
  58. package/surea11y/README.md +44 -0
  59. package/surea11y/surea11y.browser.js +14 -0
  60. package/testaro/adbID.d.ts +3 -0
  61. package/testaro/adbID.js +38 -41
  62. package/testaro/adbID.ts +66 -0
  63. package/testaro/allCapStyle.d.ts +3 -0
  64. package/testaro/allCapStyle.js +31 -34
  65. package/testaro/allCapStyle.ts +55 -0
  66. package/testaro/allCaps.d.ts +16 -0
  67. package/testaro/allCaps.js +179 -151
  68. package/testaro/allCaps.ts +228 -0
  69. package/testaro/allHidden.d.ts +13 -0
  70. package/testaro/allHidden.js +29 -30
  71. package/testaro/allHidden.ts +50 -0
  72. package/testaro/allSlanted.d.ts +3 -0
  73. package/testaro/allSlanted.js +30 -33
  74. package/testaro/allSlanted.ts +54 -0
  75. package/testaro/altScheme.d.ts +3 -0
  76. package/testaro/altScheme.js +27 -30
  77. package/testaro/altScheme.ts +50 -0
  78. package/testaro/attVal.d.ts +3 -0
  79. package/testaro/attVal.js +20 -21
  80. package/testaro/attVal.ts +52 -0
  81. package/testaro/autocomplete.d.ts +3 -0
  82. package/testaro/autocomplete.js +59 -75
  83. package/testaro/autocomplete.ts +101 -0
  84. package/testaro/bulk.d.ts +13 -0
  85. package/testaro/bulk.js +32 -33
  86. package/testaro/bulk.ts +55 -0
  87. package/testaro/buttonMenu.d.ts +9 -0
  88. package/testaro/buttonMenu.js +317 -319
  89. package/testaro/buttonMenu.ts +391 -0
  90. package/testaro/captionLoc.d.ts +3 -0
  91. package/testaro/captionLoc.js +17 -20
  92. package/testaro/captionLoc.ts +40 -0
  93. package/testaro/datalistRef.d.ts +3 -0
  94. package/testaro/datalistRef.js +33 -36
  95. package/testaro/datalistRef.ts +55 -0
  96. package/testaro/distortion.d.ts +3 -0
  97. package/testaro/distortion.js +57 -26
  98. package/testaro/distortion.ts +81 -0
  99. package/testaro/docType.d.ts +15 -0
  100. package/testaro/docType.js +25 -25
  101. package/testaro/docType.ts +45 -0
  102. package/testaro/dupAtt.d.ts +16 -0
  103. package/testaro/dupAtt.js +113 -104
  104. package/testaro/dupAtt.ts +144 -0
  105. package/testaro/elements.d.ts +6 -0
  106. package/testaro/elements.js +153 -153
  107. package/testaro/elements.ts +215 -0
  108. package/testaro/embAc.d.ts +3 -0
  109. package/testaro/embAc.js +19 -20
  110. package/testaro/embAc.ts +40 -0
  111. package/testaro/focAll.d.ts +13 -0
  112. package/testaro/focAll.js +185 -191
  113. package/testaro/focAll.ts +217 -0
  114. package/testaro/focAndOp.d.ts +3 -0
  115. package/testaro/focAndOp.js +100 -104
  116. package/testaro/focAndOp.ts +128 -0
  117. package/testaro/focInd.d.ts +3 -0
  118. package/testaro/focInd.js +64 -66
  119. package/testaro/focInd.ts +96 -0
  120. package/testaro/focVis.d.ts +3 -0
  121. package/testaro/focVis.js +28 -30
  122. package/testaro/focVis.ts +52 -0
  123. package/testaro/headEl.d.ts +10 -0
  124. package/testaro/headEl.js +61 -62
  125. package/testaro/headEl.ts +82 -0
  126. package/testaro/headingAmb.d.ts +3 -0
  127. package/testaro/headingAmb.js +54 -63
  128. package/testaro/headingAmb.ts +72 -0
  129. package/testaro/hovInd.d.ts +12 -0
  130. package/testaro/hovInd.js +163 -130
  131. package/testaro/hovInd.ts +199 -0
  132. package/testaro/hover.d.ts +3 -0
  133. package/testaro/hover.js +155 -126
  134. package/testaro/hover.ts +154 -0
  135. package/testaro/hr.d.ts +3 -0
  136. package/testaro/hr.js +14 -17
  137. package/testaro/hr.ts +36 -0
  138. package/testaro/imageLink.d.ts +3 -0
  139. package/testaro/imageLink.js +17 -20
  140. package/testaro/imageLink.ts +42 -0
  141. package/testaro/labClash.d.ts +3 -0
  142. package/testaro/labClash.js +31 -33
  143. package/testaro/labClash.ts +54 -0
  144. package/testaro/legendLoc.d.ts +3 -0
  145. package/testaro/legendLoc.js +17 -20
  146. package/testaro/legendLoc.ts +42 -0
  147. package/testaro/lineHeight.d.ts +3 -0
  148. package/testaro/lineHeight.js +43 -48
  149. package/testaro/lineHeight.ts +70 -0
  150. package/testaro/linkAmb.d.ts +7 -0
  151. package/testaro/linkAmb.js +79 -80
  152. package/testaro/linkAmb.ts +105 -0
  153. package/testaro/linkExt.d.ts +3 -0
  154. package/testaro/linkExt.js +13 -16
  155. package/testaro/linkExt.ts +35 -0
  156. package/testaro/linkOldAtt.d.ts +3 -0
  157. package/testaro/linkOldAtt.js +25 -28
  158. package/testaro/linkOldAtt.ts +48 -0
  159. package/testaro/linkTo.d.ts +3 -0
  160. package/testaro/linkTo.js +22 -22
  161. package/testaro/linkTo.ts +43 -0
  162. package/testaro/linkUl.d.ts +3 -0
  163. package/testaro/linkUl.js +30 -34
  164. package/testaro/linkUl.ts +54 -0
  165. package/testaro/miniText.d.ts +3 -0
  166. package/testaro/miniText.js +41 -44
  167. package/testaro/miniText.ts +68 -0
  168. package/testaro/motion.d.ts +10 -0
  169. package/testaro/motion.js +92 -96
  170. package/testaro/motion.ts +125 -0
  171. package/testaro/nonTable.d.ts +3 -0
  172. package/testaro/nonTable.js +39 -45
  173. package/testaro/nonTable.ts +66 -0
  174. package/testaro/optRoleSel.d.ts +3 -0
  175. package/testaro/optRoleSel.js +16 -19
  176. package/testaro/optRoleSel.ts +41 -0
  177. package/testaro/phOnly.d.ts +3 -0
  178. package/testaro/phOnly.js +18 -21
  179. package/testaro/phOnly.ts +43 -0
  180. package/testaro/pseudoP.d.ts +3 -0
  181. package/testaro/pseudoP.js +36 -38
  182. package/testaro/pseudoP.ts +59 -0
  183. package/testaro/radioSet.d.ts +3 -0
  184. package/testaro/radioSet.js +57 -59
  185. package/testaro/radioSet.ts +79 -0
  186. package/testaro/registry.d.ts +63 -0
  187. package/testaro/registry.js +67 -0
  188. package/testaro/registry.ts +141 -0
  189. package/testaro/role.d.ts +3 -0
  190. package/testaro/role.js +27 -29
  191. package/testaro/role.ts +53 -0
  192. package/testaro/secHeading.d.ts +3 -0
  193. package/testaro/secHeading.js +30 -33
  194. package/testaro/secHeading.ts +53 -0
  195. package/testaro/styleDiff.d.ts +25 -0
  196. package/testaro/styleDiff.js +248 -252
  197. package/testaro/styleDiff.ts +303 -0
  198. package/testaro/tabNav.d.ts +33 -0
  199. package/testaro/tabNav.js +272 -342
  200. package/testaro/tabNav.ts +454 -0
  201. package/testaro/targetsNear.d.ts +9 -0
  202. package/testaro/targetsNear.js +130 -132
  203. package/testaro/targetsNear.ts +160 -0
  204. package/testaro/textNodes.d.ts +6 -0
  205. package/testaro/textNodes.js +139 -135
  206. package/testaro/textNodes.ts +185 -0
  207. package/testaro/textSem.d.ts +3 -0
  208. package/testaro/textSem.js +25 -28
  209. package/testaro/textSem.ts +47 -0
  210. package/testaro/title.d.ts +9 -0
  211. package/testaro/title.js +16 -13
  212. package/testaro/title.ts +31 -0
  213. package/testaro/titledEl.d.ts +3 -0
  214. package/testaro/titledEl.js +15 -18
  215. package/testaro/titledEl.ts +38 -0
  216. package/testaro/zIndex.d.ts +3 -0
  217. package/testaro/zIndex.js +19 -22
  218. package/testaro/zIndex.ts +42 -0
  219. package/tests/alfa.d.ts +45 -0
  220. package/tests/alfa.js +138 -141
  221. package/tests/alfa.ts +214 -0
  222. package/tests/aslint.d.ts +33 -0
  223. package/tests/aslint.js +272 -249
  224. package/tests/aslint.ts +301 -0
  225. package/tests/axe.d.ts +27 -0
  226. package/tests/axe.js +199 -200
  227. package/tests/axe.ts +277 -0
  228. package/tests/ed11y.d.ts +28 -0
  229. package/tests/ed11y.js +141 -99
  230. package/tests/ed11y.ts +178 -0
  231. package/tests/htmlcs.d.ts +21 -0
  232. package/tests/htmlcs.js +174 -140
  233. package/tests/htmlcs.ts +181 -0
  234. package/tests/ibm.d.ts +57 -0
  235. package/tests/ibm.js +182 -175
  236. package/tests/ibm.ts +287 -0
  237. package/tests/nuVal.d.ts +13 -0
  238. package/tests/nuVal.js +107 -112
  239. package/tests/nuVal.ts +145 -0
  240. package/tests/nuVnu.d.ts +15 -0
  241. package/tests/nuVnu.js +141 -111
  242. package/tests/nuVnu.ts +144 -0
  243. package/tests/pour.d.ts +31 -0
  244. package/tests/pour.js +242 -0
  245. package/tests/pour.ts +273 -0
  246. package/tests/qualWeb.d.ts +39 -0
  247. package/tests/qualWeb.js +262 -273
  248. package/tests/qualWeb.ts +344 -0
  249. package/tests/surea11y.d.ts +33 -0
  250. package/tests/surea11y.js +288 -0
  251. package/tests/surea11y.ts +334 -0
  252. package/tests/testaro.d.ts +25 -0
  253. package/tests/testaro.js +745 -652
  254. package/tests/testaro.ts +862 -0
  255. package/tests/wave.d.ts +46 -0
  256. package/tests/wave.js +166 -177
  257. package/tests/wave.ts +252 -0
  258. package/tsconfig.json +17 -0
  259. package/types.d.ts +243 -0
  260. package/types.js +10 -0
  261. package/types.ts +376 -0
  262. package/validation/act/README.md +46 -0
  263. package/validation/act/capture.js +877 -0
  264. package/validation/act/chromium-issue-draft.md +66 -0
  265. package/validation/act/fp-triage-2026-08-22.md +86 -0
  266. package/validation/act/isolation-notes.md +159 -0
  267. package/validation/act/playwright-issue-draft.md +73 -0
  268. package/validation/act/propose-mappings.js +0 -0
  269. package/validation/act/repro-cdp-raw.js +105 -0
  270. package/validation/act/repro-metarefresh.js +65 -0
  271. package/validation/act/score.js +184 -0
  272. package/validation/act/stage3a-stress-report.md +69 -0
  273. package/validation/act/stage3b-mapping-proposals.md +94 -0
  274. package/validation/act/stage3b-triage-draft.md +138 -0
  275. package/validation/act/surea11y-track-a-2026-09-01.md +50 -0
  276. package/validation/executors/netWatch.js +180 -90
  277. package/validation/executors/test.js +17 -2
  278. package/validation/executors/tests.js +118 -10
  279. package/validation/jobs/reports/raw/260901T1000-surea11y-validation.json +964 -0
  280. package/validation/jobs/todo/240101T1200-simple-example.json +14 -6
  281. package/validation/jobs/todo/240101T1300-shoot-example.json +2 -1
  282. package/validation/jobs/todo/260821T1900-pour-validation.json +45 -0
  283. package/validation/jobs/todo/260901T1000-surea11y-validation.json +45 -0
  284. package/validation/knownFailures.json +1 -0
  285. package/validation/tests/jobProperties/adbID.json +27 -2
  286. package/validation/tests/jobProperties/{focOp.json → allCapStyle.json} +56 -53
  287. package/validation/tests/jobProperties/allCaps.json +25 -0
  288. package/validation/tests/jobProperties/allHidden.json +133 -13
  289. package/validation/tests/jobProperties/allSlanted.json +3 -3
  290. package/validation/tests/jobProperties/altScheme.json +23 -8
  291. package/validation/tests/jobProperties/attVal.json +57 -57
  292. package/validation/tests/jobProperties/autocomplete.json +11 -1
  293. package/validation/tests/jobProperties/bulk.json +6 -1
  294. package/validation/tests/jobProperties/buttonMenu.json +63 -42
  295. package/validation/tests/jobProperties/captionLoc.json +1 -6
  296. package/validation/tests/jobProperties/checkpoint-browser.json +401 -0
  297. package/validation/tests/jobProperties/checkpoint-page.json +397 -0
  298. package/validation/tests/jobProperties/checkpoint.json +395 -0
  299. package/validation/tests/jobProperties/datalistRef.json +20 -5
  300. package/validation/tests/jobProperties/distortion.json +28 -3
  301. package/validation/tests/jobProperties/docType.json +2 -2
  302. package/validation/tests/jobProperties/dupAtt.json +48 -33
  303. package/validation/tests/jobProperties/elements.json +28 -28
  304. package/validation/tests/jobProperties/embAc.json +36 -31
  305. package/validation/tests/jobProperties/focAndOp.json +284 -0
  306. package/validation/tests/jobProperties/focInd.json +34 -34
  307. package/validation/tests/jobProperties/focVis.json +1 -1
  308. package/validation/tests/jobProperties/hover.json +39 -37
  309. package/validation/tests/jobProperties/hr.json +13 -3
  310. package/validation/tests/jobProperties/imageLink.json +0 -5
  311. package/validation/tests/jobProperties/labClash.json +68 -33
  312. package/validation/tests/jobProperties/legendLoc.json +1 -6
  313. package/validation/tests/jobProperties/lineHeight.json +2 -2
  314. package/validation/tests/jobProperties/linkAmb.json +13 -18
  315. package/validation/tests/jobProperties/linkExt.json +1 -1
  316. package/validation/tests/jobProperties/linkOldAtt.json +12 -2
  317. package/validation/tests/jobProperties/linkTo.json +1 -1
  318. package/validation/tests/jobProperties/linkUl.json +65 -65
  319. package/validation/tests/jobProperties/miniText.json +7 -2
  320. package/validation/tests/jobProperties/motion.json +4 -50
  321. package/validation/tests/jobProperties/nonTable.json +48 -3
  322. package/validation/tests/jobProperties/optRoleSel.json +12 -2
  323. package/validation/tests/jobProperties/phOnly.json +6 -16
  324. package/validation/tests/jobProperties/pseudoP.json +17 -2
  325. package/validation/tests/jobProperties/radioSet.json +30 -30
  326. package/validation/tests/jobProperties/role.json +25 -5
  327. package/validation/tests/jobProperties/secHeading.json +26 -21
  328. package/validation/tests/jobProperties/styleDiff.json +35 -5
  329. package/validation/tests/jobProperties/tabNav.json +3 -1
  330. package/validation/tests/jobProperties/targetsNear.json +237 -0
  331. package/validation/tests/jobProperties/textNodes.json +29 -29
  332. package/validation/tests/jobProperties/textSem.json +26 -1
  333. package/validation/tests/jobProperties/title.json +12 -2
  334. package/validation/tests/jobProperties/titledEl.json +39 -9
  335. package/validation/tests/jobProperties/userPath.json +317 -0
  336. package/validation/tests/jobProperties/zIndex.json +40 -35
  337. package/validation/tests/targets/allCapStyle/index.html +26 -0
  338. package/validation/tests/targets/checkpoint/index.html +38 -0
  339. package/validation/tests/targets/datalistRef/index.html +1 -1
  340. package/validation/tests/targets/focAndOp/bad.html +29 -0
  341. package/validation/tests/targets/{focOp → focAndOp}/good.html +3 -1
  342. package/validation/tests/targets/focInd/bad.html +2 -1
  343. package/validation/tests/targets/headEl/index.html +10 -1
  344. package/validation/tests/targets/{targetSmall → targetsNear}/index.html +15 -1
  345. package/validation/tests/targets/userPath/index.html +42 -0
  346. package/validation/validateTest.js +61 -9
  347. package/.claude/settings.local.json +0 -11
  348. package/.eslintrc.json +0 -41
  349. package/htmlcs/.eslintrc.json +0 -67
  350. package/validation/tests/jobProperties/linkTitle.json +0 -127
  351. package/validation/tests/jobProperties/opFoc.json +0 -164
  352. package/validation/tests/jobProperties/targetSmall.json +0 -152
  353. package/validation/tests/jobProperties/targetTiny.json +0 -142
  354. package/validation/tests/targets/focOp/bad.html +0 -25
  355. package/validation/tests/targets/linkTitle/index.html +0 -24
  356. package/validation/tests/targets/opFoc/bad.html +0 -26
  357. package/validation/tests/targets/opFoc/good.html +0 -23
package/testaro/focAll.js CHANGED
@@ -1,3 +1,4 @@
1
+ "use strict";
1
2
  /*
2
3
  © 2021–2023 CVS Health and/or one of its affiliates. All rights reserved.
3
4
  © 2026 Jeff Witt.
@@ -7,206 +8,199 @@
7
8
 
8
9
  SPDX-License-Identifier: MIT
9
10
  */
10
-
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.reporter = void 0;
13
+ const xPath_1 = require("../procs/xPath");
11
14
  /*
12
15
  focAll
13
16
  This test reports discrepancies between the visible focusable elements of a page and the elements actually reached with the Tab (or, in webkit, Option-Tab) key. The test first identifies and marks all the visible focusable elements, i.e. those with a nonnegative tabIndex, except elements that browsers exclude from Tab navigation (disabled elements, inert elements, and a and area elements without href attributes). Radio buttons are grouped as browsers group them: radio buttons sharing a name and a form owner belong to one group, and the group is reachable with one Tab keypress, but each radio button without a name is independently reachable. Then the test repeatedly presses the Tab (or Option-Tab) key until it has reached all the elements it can, marking each reached element. Finally, the test reports each focusable element that was never reached (for example, because keypresses cause surprising changes in content or focus) and each reached element that was not a visible focusable element when the page was loaded (for example, because it was revealed only during navigation or was invisible although Tab-focusable). Any such element can make navigation with the Tab key difficult or impossible.
17
+ Compiled to focAll.js by tsc (issue #73); edit this file, not the emitted one.
14
18
  */
15
-
16
- // IMPORTS
17
-
18
- const {getXPathCatalogIndex} = require('../procs/xPath');
19
-
20
19
  // FUNCTIONS
21
-
22
20
  // Runs the test and returns the result.
23
- exports.reporter = async (page, report, _, withItems) => {
24
- // Get locators of visible elements.
25
- const locAll = await page.locator('body *:visible');
26
- // Mark the focusable elements and get the count of Tab stops among them.
27
- const focusableCount = await locAll.evaluateAll(elements => {
28
- // Get the focusable elements, i.e. those that can be reached with the Tab key.
29
- const focusables = elements.filter(element => {
30
- // If the element has a negative tabIndex, it is not focusable.
31
- if (element.tabIndex < 0) {
32
- return false;
33
- }
34
- // If the element or an ancestor is disabled or inert, it is not focusable.
35
- if (element.matches(':disabled') || element.closest('[inert]')) {
36
- return false;
37
- }
38
- // If the element is an a or area element without an href attribute, it is not focusable.
39
- if (['A', 'AREA'].includes(element.tagName) && ! element.hasAttribute('href')) {
40
- return false;
41
- }
42
- return true;
21
+ const reporter = async (page, report, _, withItems) => {
22
+ // Get locators of visible elements.
23
+ const locAll = await page.locator('body *:visible');
24
+ // Mark the focusable elements and get the count of Tab stops among them.
25
+ const focusableCount = await locAll.evaluateAll(elements => {
26
+ // Get the focusable elements, i.e. those that can be reached with the Tab key.
27
+ const focusables = elements.filter(element => {
28
+ // If the element has a negative tabIndex, it is not focusable.
29
+ if (element.tabIndex < 0) {
30
+ return false;
31
+ }
32
+ // If the element or an ancestor is disabled or inert, it is not focusable.
33
+ if (element.matches(':disabled') || element.closest('[inert]')) {
34
+ return false;
35
+ }
36
+ // If the element is an a or area element without an href attribute, it is not focusable.
37
+ if (['A', 'AREA'].includes(element.tagName) && !element.hasAttribute('href')) {
38
+ return false;
39
+ }
40
+ return true;
41
+ });
42
+ // Mark them.
43
+ focusables.forEach(element => {
44
+ element.dataset.testarofocusable = 'true';
45
+ });
46
+ // Get the radio buttons among them that browsers group, i.e. those with names.
47
+ const namedRadios = focusables.filter((element) => element.tagName === 'INPUT' && element.type === 'radio'
48
+ && !!element.name);
49
+ // Get the identifiers (form owner and name) of their groups.
50
+ const groupIDs = new Set(namedRadios.map(radio => {
51
+ const formIndex = radio.form ? Array.prototype.indexOf.call(document.forms, radio.form) : -1;
52
+ return `${formIndex}:${radio.name}`;
53
+ }));
54
+ // Return the count of Tab stops, counting each radio-button group as only 1.
55
+ return focusables.length - namedRadios.length + groupIDs.size;
43
56
  });
44
- // Mark them.
45
- focusables.forEach(element => {
46
- element.dataset.testarofocusable = 'true';
47
- });
48
- // Get the radio buttons among them that browsers group, i.e. those with names.
49
- const namedRadios = focusables.filter(
50
- element => element.tagName === 'INPUT' && element.type === 'radio' && element.name
51
- );
52
- // Get the identifiers (form owner and name) of their groups.
53
- const groupIDs = new Set(namedRadios.map(radio => {
54
- const formIndex = radio.form ? Array.prototype.indexOf.call(document.forms, radio.form) : -1;
55
- return `${formIndex}:${radio.name}`;
56
- }));
57
- // Return the count of Tab stops, counting each radio-button group as only 1.
58
- return focusables.length - namedRadios.length + groupIDs.size;
59
- });
60
- /*
61
- Repeatedly perform a Tab or (in webkit) Opt-Tab keypress and count the focused elements.
62
- Asumptions:
63
- 0. No page has more than 2000 focusable elements.
64
- 1. No shadow root that reports itself as the active element has more than 100 focusable
65
- descendants.
66
- */
67
- let tabFocused = 0;
68
- let refocused = 0;
69
- const keyName = page.context().browser()?.browserType().name() === 'webkit' ? 'Alt+Tab' : 'Tab';
70
- while (refocused < 100 && tabFocused < 2000) {
71
- await page.keyboard.press(keyName);
72
- const isNewFocus = await page.evaluate(() => {
73
- let focus = document.activeElement;
74
- // If the focus is in a shadow DOM, get the innermost focused element.
75
- while (focus && focus.shadowRoot && focus.shadowRoot.activeElement) {
76
- focus = focus.shadowRoot.activeElement;
77
- }
78
- if (focus === null || focus.tagName === 'BODY' || focus.dataset.testarofocused) {
79
- return false;
80
- }
81
- else {
82
- focus.dataset.testarofocused = 'true';
83
- return true;
84
- }
57
+ /*
58
+ Repeatedly perform a Tab or (in webkit) Opt-Tab keypress and count the focused elements.
59
+ Asumptions:
60
+ 0. No page has more than 2000 focusable elements.
61
+ 1. No shadow root that reports itself as the active element has more than 100 focusable
62
+ descendants.
63
+ */
64
+ let tabFocused = 0;
65
+ let refocused = 0;
66
+ const keyName = page.context().browser()?.browserType().name() === 'webkit' ? 'Alt+Tab' : 'Tab';
67
+ while (refocused < 100 && tabFocused < 2000) {
68
+ await page.keyboard.press(keyName);
69
+ const isNewFocus = await page.evaluate(() => {
70
+ let focus = document.activeElement;
71
+ // If the focus is in a shadow DOM, get the innermost focused element.
72
+ while (focus && focus.shadowRoot && focus.shadowRoot.activeElement) {
73
+ focus = focus.shadowRoot.activeElement;
74
+ }
75
+ if (focus === null || focus.tagName === 'BODY' || focus.dataset.testarofocused) {
76
+ return false;
77
+ }
78
+ else {
79
+ focus.dataset.testarofocused = 'true';
80
+ return true;
81
+ }
82
+ });
83
+ if (isNewFocus) {
84
+ tabFocused++;
85
+ }
86
+ else {
87
+ refocused++;
88
+ }
89
+ }
90
+ // Get the XPaths of the elements violating the rule, in both directions.
91
+ const { unreached, unexpected } = await page.evaluate(() => {
92
+ // Get all elements, including any in open shadow roots.
93
+ const allElements = [];
94
+ const addElements = (root) => {
95
+ root.querySelectorAll('*').forEach(element => {
96
+ allElements.push(element);
97
+ if (element.shadowRoot) {
98
+ addElements(element.shadowRoot);
99
+ }
100
+ });
101
+ };
102
+ addElements(document);
103
+ // Returns the identifier of the group of a radio button.
104
+ const getGroupID = (radio) => {
105
+ const formIndex = radio.form ? Array.prototype.indexOf.call(document.forms, radio.form) : -1;
106
+ return `${formIndex}:${radio.name}`;
107
+ };
108
+ // Get the identifiers of the radio-button groups with any reached member.
109
+ const reachedGroupIDs = new Set(allElements
110
+ .filter((element) => !!element.dataset.testarofocused
111
+ && element.tagName === 'INPUT'
112
+ && element.type === 'radio'
113
+ && !!element.name)
114
+ .map(getGroupID));
115
+ // Initialize the violation data.
116
+ const unreached = [];
117
+ const unexpected = [];
118
+ // For each element:
119
+ allElements.forEach(element => {
120
+ const wasFocusable = !!element.dataset.testarofocusable;
121
+ const wasFocused = !!element.dataset.testarofocused;
122
+ // If it was focusable but was never reached:
123
+ if (wasFocusable && !wasFocused) {
124
+ const isGroupedRadio = element.tagName === 'INPUT'
125
+ && element.type === 'radio'
126
+ && element.name;
127
+ // If it is not a member of a radio-button group with another reached member:
128
+ if (!(isGroupedRadio && reachedGroupIDs.has(getGroupID(element)))) {
129
+ // Add its XPath to the violation data.
130
+ unreached.push(window.getXPath(element) ?? '/html');
131
+ }
132
+ }
133
+ // Otherwise, if it was reached but was not a visible focusable element:
134
+ else if (wasFocused && !wasFocusable) {
135
+ // Get whether it is a scrollable container, which some browsers make Tab-focusable.
136
+ const styleDec = window.getComputedStyle(element);
137
+ const isScroller = ['auto', 'scroll'].includes(styleDec.overflowY)
138
+ && element.scrollHeight > element.clientHeight
139
+ || ['auto', 'scroll'].includes(styleDec.overflowX)
140
+ && element.scrollWidth > element.clientWidth;
141
+ // If it is not a scrollable container made focusable by the browser alone:
142
+ if (!(isScroller && element.tabIndex < 0)) {
143
+ // Add its XPath to the violation data.
144
+ unexpected.push(window.getXPath(element) ?? '/html');
145
+ }
146
+ }
147
+ // Remove the marker attributes from the element.
148
+ delete element.dataset.testarofocusable;
149
+ delete element.dataset.testarofocused;
150
+ });
151
+ return { unreached, unexpected };
85
152
  });
86
- if (isNewFocus) {
87
- tabFocused++;
153
+ const count = unreached.length + unexpected.length;
154
+ const data = {
155
+ focusableCount,
156
+ tabFocused,
157
+ discrepancy: tabFocused - focusableCount,
158
+ unreachedCount: unreached.length,
159
+ unexpectedCount: unexpected.length
160
+ };
161
+ // Initialize the standard instances.
162
+ const standardInstances = [];
163
+ // If itemization is required:
164
+ if (withItems) {
165
+ // For each element that was focusable but was never reached:
166
+ unreached.forEach(xPath => {
167
+ // Add an instance to the standard instances.
168
+ standardInstances.push({
169
+ ruleID: 'focAll',
170
+ what: 'Element is focusable but was not reached by Tab navigation',
171
+ ordinalSeverity: 2,
172
+ count: 1,
173
+ catalogIndex: (0, xPath_1.getXPathCatalogIndex)(report, xPath)
174
+ });
175
+ });
176
+ // For each element that was reached but was not a visible focusable element:
177
+ unexpected.forEach(xPath => {
178
+ // Add an instance to the standard instances.
179
+ standardInstances.push({
180
+ ruleID: 'focAll',
181
+ what: 'Element was reached by Tab navigation but was not a visible focusable element',
182
+ ordinalSeverity: 2,
183
+ count: 1,
184
+ catalogIndex: (0, xPath_1.getXPathCatalogIndex)(report, xPath)
185
+ });
186
+ });
88
187
  }
89
- else {
90
- refocused++;
188
+ // Otherwise, if there were any violations:
189
+ else if (count) {
190
+ // Add a summary instance to the standard instances.
191
+ standardInstances.push({
192
+ ruleID: 'focAll',
193
+ what: 'Some focusable elements are not Tab-focusable or vice versa',
194
+ ordinalSeverity: 2,
195
+ count,
196
+ catalogIndex: (0, xPath_1.getXPathCatalogIndex)(report, '/html/body')
197
+ });
91
198
  }
92
- }
93
- // Get the XPaths of the elements violating the rule, in both directions.
94
- const {unreached, unexpected} = await page.evaluate(() => {
95
- // Get all elements, including any in open shadow roots.
96
- const allElements = [];
97
- const addElements = root => {
98
- root.querySelectorAll('*').forEach(element => {
99
- allElements.push(element);
100
- if (element.shadowRoot) {
101
- addElements(element.shadowRoot);
102
- }
103
- });
104
- };
105
- addElements(document);
106
- // Returns the identifier of the group of a radio button.
107
- const getGroupID = radio => {
108
- const formIndex = radio.form ? Array.prototype.indexOf.call(document.forms, radio.form) : -1;
109
- return `${formIndex}:${radio.name}`;
199
+ // Return the result.
200
+ return {
201
+ data,
202
+ totals: [0, 0, count, 0],
203
+ standardInstances
110
204
  };
111
- // Get the identifiers of the radio-button groups with any reached member.
112
- const reachedGroupIDs = new Set(
113
- allElements
114
- .filter(
115
- element => element.dataset.testarofocused
116
- && element.tagName === 'INPUT'
117
- && element.type === 'radio'
118
- && element.name
119
- )
120
- .map(getGroupID)
121
- );
122
- // Initialize the violation data.
123
- const unreached = [];
124
- const unexpected = [];
125
- // For each element:
126
- allElements.forEach(element => {
127
- const wasFocusable = !! element.dataset.testarofocusable;
128
- const wasFocused = !! element.dataset.testarofocused;
129
- // If it was focusable but was never reached:
130
- if (wasFocusable && ! wasFocused) {
131
- const isGroupedRadio = element.tagName === 'INPUT'
132
- && element.type === 'radio'
133
- && element.name;
134
- // If it is not a member of a radio-button group with another reached member:
135
- if (! (isGroupedRadio && reachedGroupIDs.has(getGroupID(element)))) {
136
- // Add its XPath to the violation data.
137
- unreached.push(window.getXPath(element) ?? '/html');
138
- }
139
- }
140
- // Otherwise, if it was reached but was not a visible focusable element:
141
- else if (wasFocused && ! wasFocusable) {
142
- // Get whether it is a scrollable container, which some browsers make Tab-focusable.
143
- const styleDec = window.getComputedStyle(element);
144
- const isScroller = ['auto', 'scroll'].includes(styleDec.overflowY)
145
- && element.scrollHeight > element.clientHeight
146
- || ['auto', 'scroll'].includes(styleDec.overflowX)
147
- && element.scrollWidth > element.clientWidth;
148
- // If it is not a scrollable container made focusable by the browser alone:
149
- if (! (isScroller && element.tabIndex < 0)) {
150
- // Add its XPath to the violation data.
151
- unexpected.push(window.getXPath(element) ?? '/html');
152
- }
153
- }
154
- // Remove the marker attributes from the element.
155
- delete element.dataset.testarofocusable;
156
- delete element.dataset.testarofocused;
157
- });
158
- return {unreached, unexpected};
159
- });
160
- const count = unreached.length + unexpected.length;
161
- const data = {
162
- focusableCount,
163
- tabFocused,
164
- discrepancy: tabFocused - focusableCount,
165
- unreachedCount: unreached.length,
166
- unexpectedCount: unexpected.length
167
- };
168
- // Initialize the standard instances.
169
- const standardInstances = [];
170
- // If itemization is required:
171
- if (withItems) {
172
- // For each element that was focusable but was never reached:
173
- unreached.forEach(xPath => {
174
- // Add an instance to the standard instances.
175
- standardInstances.push({
176
- ruleID: 'focAll',
177
- what: 'Element is focusable but was not reached by Tab navigation',
178
- ordinalSeverity: 2,
179
- count: 1,
180
- catalogIndex: getXPathCatalogIndex(report, xPath)
181
- });
182
- });
183
- // For each element that was reached but was not a visible focusable element:
184
- unexpected.forEach(xPath => {
185
- // Add an instance to the standard instances.
186
- standardInstances.push({
187
- ruleID: 'focAll',
188
- what: 'Element was reached by Tab navigation but was not a visible focusable element',
189
- ordinalSeverity: 2,
190
- count: 1,
191
- catalogIndex: getXPathCatalogIndex(report, xPath)
192
- });
193
- });
194
- }
195
- // Otherwise, if there were any violations:
196
- else if (count) {
197
- // Add a summary instance to the standard instances.
198
- standardInstances.push({
199
- ruleID: 'focAll',
200
- what: 'Some focusable elements are not Tab-focusable or vice versa',
201
- ordinalSeverity: 2,
202
- count,
203
- catalogIndex: getXPathCatalogIndex(report, '/html/body')
204
- });
205
- }
206
- // Return the result.
207
- return {
208
- data,
209
- totals: [0, 0, count, 0],
210
- standardInstances
211
- };
212
205
  };
206
+ exports.reporter = reporter;
@@ -0,0 +1,217 @@
1
+ /*
2
+ © 2021–2023 CVS Health and/or one of its affiliates. All rights reserved.
3
+ © 2026 Jeff Witt.
4
+ © 2025–2026 Jonathan Robert Pool.
5
+
6
+ Licensed under the MIT License. See LICENSE file at the project root or https://opensource.org/license/mit/ for details.
7
+
8
+ SPDX-License-Identifier: MIT
9
+ */
10
+
11
+ // IMPORTS
12
+
13
+ import type {Page} from 'playwright';
14
+ import {getXPathCatalogIndex} from '../procs/xPath';
15
+ import type {Report, StandardInstance} from '../types';
16
+
17
+ /*
18
+ focAll
19
+ This test reports discrepancies between the visible focusable elements of a page and the elements actually reached with the Tab (or, in webkit, Option-Tab) key. The test first identifies and marks all the visible focusable elements, i.e. those with a nonnegative tabIndex, except elements that browsers exclude from Tab navigation (disabled elements, inert elements, and a and area elements without href attributes). Radio buttons are grouped as browsers group them: radio buttons sharing a name and a form owner belong to one group, and the group is reachable with one Tab keypress, but each radio button without a name is independently reachable. Then the test repeatedly presses the Tab (or Option-Tab) key until it has reached all the elements it can, marking each reached element. Finally, the test reports each focusable element that was never reached (for example, because keypresses cause surprising changes in content or focus) and each reached element that was not a visible focusable element when the page was loaded (for example, because it was revealed only during navigation or was invisible although Tab-focusable). Any such element can make navigation with the Tab key difficult or impossible.
20
+ Compiled to focAll.js by tsc (issue #73); edit this file, not the emitted one.
21
+ */
22
+
23
+ // FUNCTIONS
24
+
25
+ // Runs the test and returns the result.
26
+ export const reporter = async (page: Page, report: Report, _: unknown, withItems: boolean) => {
27
+ // Get locators of visible elements.
28
+ const locAll = await page.locator('body *:visible');
29
+ // Mark the focusable elements and get the count of Tab stops among them.
30
+ const focusableCount = await locAll.evaluateAll(elements => {
31
+ // Get the focusable elements, i.e. those that can be reached with the Tab key.
32
+ const focusables = elements.filter(element => {
33
+ // If the element has a negative tabIndex, it is not focusable.
34
+ if (element.tabIndex < 0) {
35
+ return false;
36
+ }
37
+ // If the element or an ancestor is disabled or inert, it is not focusable.
38
+ if (element.matches(':disabled') || element.closest('[inert]')) {
39
+ return false;
40
+ }
41
+ // If the element is an a or area element without an href attribute, it is not focusable.
42
+ if (['A', 'AREA'].includes(element.tagName) && ! element.hasAttribute('href')) {
43
+ return false;
44
+ }
45
+ return true;
46
+ });
47
+ // Mark them.
48
+ focusables.forEach(element => {
49
+ element.dataset.testarofocusable = 'true';
50
+ });
51
+ // Get the radio buttons among them that browsers group, i.e. those with names.
52
+ const namedRadios = focusables.filter(
53
+ (element): element is HTMLInputElement =>
54
+ element.tagName === 'INPUT' && (element as HTMLInputElement).type === 'radio'
55
+ && !! (element as HTMLInputElement).name
56
+ );
57
+ // Get the identifiers (form owner and name) of their groups.
58
+ const groupIDs = new Set(namedRadios.map(radio => {
59
+ const formIndex = radio.form ? Array.prototype.indexOf.call(document.forms, radio.form) : -1;
60
+ return `${formIndex}:${radio.name}`;
61
+ }));
62
+ // Return the count of Tab stops, counting each radio-button group as only 1.
63
+ return focusables.length - namedRadios.length + groupIDs.size;
64
+ });
65
+ /*
66
+ Repeatedly perform a Tab or (in webkit) Opt-Tab keypress and count the focused elements.
67
+ Asumptions:
68
+ 0. No page has more than 2000 focusable elements.
69
+ 1. No shadow root that reports itself as the active element has more than 100 focusable
70
+ descendants.
71
+ */
72
+ let tabFocused = 0;
73
+ let refocused = 0;
74
+ const keyName = page.context().browser()?.browserType().name() === 'webkit' ? 'Alt+Tab' : 'Tab';
75
+ while (refocused < 100 && tabFocused < 2000) {
76
+ await page.keyboard.press(keyName);
77
+ const isNewFocus = await page.evaluate(() => {
78
+ let focus = document.activeElement;
79
+ // If the focus is in a shadow DOM, get the innermost focused element.
80
+ while (focus && focus.shadowRoot && focus.shadowRoot.activeElement) {
81
+ focus = focus.shadowRoot.activeElement;
82
+ }
83
+ if (focus === null || focus.tagName === 'BODY' || (focus as HTMLElement).dataset.testarofocused) {
84
+ return false;
85
+ }
86
+ else {
87
+ (focus as HTMLElement).dataset.testarofocused = 'true';
88
+ return true;
89
+ }
90
+ });
91
+ if (isNewFocus) {
92
+ tabFocused++;
93
+ }
94
+ else {
95
+ refocused++;
96
+ }
97
+ }
98
+ // Get the XPaths of the elements violating the rule, in both directions.
99
+ const {unreached, unexpected} = await page.evaluate(() => {
100
+ // Get all elements, including any in open shadow roots.
101
+ const allElements: HTMLElement[] = [];
102
+ const addElements = (root: Document | ShadowRoot) => {
103
+ root.querySelectorAll<HTMLElement>('*').forEach(element => {
104
+ allElements.push(element);
105
+ if (element.shadowRoot) {
106
+ addElements(element.shadowRoot);
107
+ }
108
+ });
109
+ };
110
+ addElements(document);
111
+ // Returns the identifier of the group of a radio button.
112
+ const getGroupID = (radio: HTMLInputElement) => {
113
+ const formIndex = radio.form ? Array.prototype.indexOf.call(document.forms, radio.form) : -1;
114
+ return `${formIndex}:${radio.name}`;
115
+ };
116
+ // Get the identifiers of the radio-button groups with any reached member.
117
+ const reachedGroupIDs = new Set(
118
+ allElements
119
+ .filter(
120
+ (element): element is HTMLInputElement => !! element.dataset.testarofocused
121
+ && element.tagName === 'INPUT'
122
+ && (element as HTMLInputElement).type === 'radio'
123
+ && !! (element as HTMLInputElement).name
124
+ )
125
+ .map(getGroupID)
126
+ );
127
+ // Initialize the violation data.
128
+ const unreached: string[] = [];
129
+ const unexpected: string[] = [];
130
+ // For each element:
131
+ allElements.forEach(element => {
132
+ const wasFocusable = !! element.dataset.testarofocusable;
133
+ const wasFocused = !! element.dataset.testarofocused;
134
+ // If it was focusable but was never reached:
135
+ if (wasFocusable && ! wasFocused) {
136
+ const isGroupedRadio = element.tagName === 'INPUT'
137
+ && (element as HTMLInputElement).type === 'radio'
138
+ && (element as HTMLInputElement).name;
139
+ // If it is not a member of a radio-button group with another reached member:
140
+ if (! (isGroupedRadio && reachedGroupIDs.has(getGroupID(element as HTMLInputElement)))) {
141
+ // Add its XPath to the violation data.
142
+ unreached.push(window.getXPath(element) ?? '/html');
143
+ }
144
+ }
145
+ // Otherwise, if it was reached but was not a visible focusable element:
146
+ else if (wasFocused && ! wasFocusable) {
147
+ // Get whether it is a scrollable container, which some browsers make Tab-focusable.
148
+ const styleDec = window.getComputedStyle(element);
149
+ const isScroller = ['auto', 'scroll'].includes(styleDec.overflowY)
150
+ && element.scrollHeight > element.clientHeight
151
+ || ['auto', 'scroll'].includes(styleDec.overflowX)
152
+ && element.scrollWidth > element.clientWidth;
153
+ // If it is not a scrollable container made focusable by the browser alone:
154
+ if (! (isScroller && element.tabIndex < 0)) {
155
+ // Add its XPath to the violation data.
156
+ unexpected.push(window.getXPath(element) ?? '/html');
157
+ }
158
+ }
159
+ // Remove the marker attributes from the element.
160
+ delete element.dataset.testarofocusable;
161
+ delete element.dataset.testarofocused;
162
+ });
163
+ return {unreached, unexpected};
164
+ });
165
+ const count = unreached.length + unexpected.length;
166
+ const data = {
167
+ focusableCount,
168
+ tabFocused,
169
+ discrepancy: tabFocused - focusableCount,
170
+ unreachedCount: unreached.length,
171
+ unexpectedCount: unexpected.length
172
+ };
173
+ // Initialize the standard instances.
174
+ const standardInstances: StandardInstance[] = [];
175
+ // If itemization is required:
176
+ if (withItems) {
177
+ // For each element that was focusable but was never reached:
178
+ unreached.forEach(xPath => {
179
+ // Add an instance to the standard instances.
180
+ standardInstances.push({
181
+ ruleID: 'focAll',
182
+ what: 'Element is focusable but was not reached by Tab navigation',
183
+ ordinalSeverity: 2,
184
+ count: 1,
185
+ catalogIndex: getXPathCatalogIndex(report, xPath)
186
+ });
187
+ });
188
+ // For each element that was reached but was not a visible focusable element:
189
+ unexpected.forEach(xPath => {
190
+ // Add an instance to the standard instances.
191
+ standardInstances.push({
192
+ ruleID: 'focAll',
193
+ what: 'Element was reached by Tab navigation but was not a visible focusable element',
194
+ ordinalSeverity: 2,
195
+ count: 1,
196
+ catalogIndex: getXPathCatalogIndex(report, xPath)
197
+ });
198
+ });
199
+ }
200
+ // Otherwise, if there were any violations:
201
+ else if (count) {
202
+ // Add a summary instance to the standard instances.
203
+ standardInstances.push({
204
+ ruleID: 'focAll',
205
+ what: 'Some focusable elements are not Tab-focusable or vice versa',
206
+ ordinalSeverity: 2,
207
+ count,
208
+ catalogIndex: getXPathCatalogIndex(report, '/html/body')
209
+ });
210
+ }
211
+ // Return the result.
212
+ return {
213
+ data,
214
+ totals: [0, 0, count, 0],
215
+ standardInstances
216
+ };
217
+ };
@@ -0,0 +1,3 @@
1
+ import type { Page } from 'playwright';
2
+ import type { Report } from '../types';
3
+ export declare const reporter: (page: Page, report: Report, _: unknown, withItems: boolean) => Promise<import("../procs/testaro").RuleResult>;