testaro 78.0.8 → 78.4.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 (356) hide show
  1. package/.gitattributes +94 -0
  2. package/.github/workflows/ci.yml +58 -0
  3. package/.github/workflows/publish.yml +49 -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 +73 -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/docker-compose.yml +8 -2
  16. package/docs/checkpoint-scanning.md +199 -0
  17. package/docs/standard-result-outcome.md +165 -0
  18. package/env.example +44 -10
  19. package/eslint.config.mjs +101 -0
  20. package/netWatch.js +372 -222
  21. package/package.json +46 -31
  22. package/pour/README.md +47 -0
  23. package/pour/pour.min.js +8 -0
  24. package/procs/actDo.js +808 -0
  25. package/procs/catalog.d.ts +17 -0
  26. package/procs/catalog.js +312 -221
  27. package/procs/catalog.ts +398 -0
  28. package/procs/checkpoint.js +110 -0
  29. package/procs/config.d.ts +35 -0
  30. package/procs/config.js +62 -0
  31. package/procs/dateTime.js +2 -1
  32. package/procs/doActs.js +442 -849
  33. package/procs/doTestAct.js +27 -121
  34. package/procs/flow.js +221 -0
  35. package/procs/generateRuleRegistry.js +94 -0
  36. package/procs/getSource.d.ts +26 -0
  37. package/procs/job.js +115 -1
  38. package/procs/launch.d.ts +40 -0
  39. package/procs/launch.js +239 -108
  40. package/procs/nu.d.ts +48 -0
  41. package/procs/scope.js +196 -0
  42. package/procs/shoot.d.ts +33 -0
  43. package/procs/standard.d.ts +18 -0
  44. package/procs/standard.js +83 -0
  45. package/procs/standard.ts +126 -0
  46. package/procs/testAct.js +145 -0
  47. package/procs/testaro.d.ts +20 -0
  48. package/procs/testaro.js +236 -189
  49. package/procs/testaro.ts +321 -0
  50. package/procs/userPath.js +126 -0
  51. package/procs/xPath.d.ts +4 -0
  52. package/procs/xPath.js +81 -69
  53. package/procs/xPath.ts +106 -0
  54. package/procs/xPathScript.d.ts +6 -0
  55. package/procs/xPathScript.js +70 -0
  56. package/run.js +3 -2
  57. package/surea11y/README.md +44 -0
  58. package/surea11y/surea11y.browser.js +14 -0
  59. package/testaro/adbID.d.ts +3 -0
  60. package/testaro/adbID.js +38 -41
  61. package/testaro/adbID.ts +66 -0
  62. package/testaro/allCapStyle.d.ts +3 -0
  63. package/testaro/allCapStyle.js +31 -34
  64. package/testaro/allCapStyle.ts +55 -0
  65. package/testaro/allCaps.d.ts +16 -0
  66. package/testaro/allCaps.js +179 -151
  67. package/testaro/allCaps.ts +228 -0
  68. package/testaro/allHidden.d.ts +13 -0
  69. package/testaro/allHidden.js +29 -30
  70. package/testaro/allHidden.ts +50 -0
  71. package/testaro/allSlanted.d.ts +3 -0
  72. package/testaro/allSlanted.js +30 -33
  73. package/testaro/allSlanted.ts +54 -0
  74. package/testaro/altScheme.d.ts +3 -0
  75. package/testaro/altScheme.js +27 -30
  76. package/testaro/altScheme.ts +50 -0
  77. package/testaro/attVal.d.ts +3 -0
  78. package/testaro/attVal.js +20 -21
  79. package/testaro/attVal.ts +52 -0
  80. package/testaro/autocomplete.d.ts +3 -0
  81. package/testaro/autocomplete.js +59 -75
  82. package/testaro/autocomplete.ts +101 -0
  83. package/testaro/bulk.d.ts +13 -0
  84. package/testaro/bulk.js +32 -33
  85. package/testaro/bulk.ts +55 -0
  86. package/testaro/buttonMenu.d.ts +9 -0
  87. package/testaro/buttonMenu.js +317 -319
  88. package/testaro/buttonMenu.ts +391 -0
  89. package/testaro/captionLoc.d.ts +3 -0
  90. package/testaro/captionLoc.js +17 -20
  91. package/testaro/captionLoc.ts +40 -0
  92. package/testaro/datalistRef.d.ts +3 -0
  93. package/testaro/datalistRef.js +33 -36
  94. package/testaro/datalistRef.ts +55 -0
  95. package/testaro/distortion.d.ts +3 -0
  96. package/testaro/distortion.js +57 -26
  97. package/testaro/distortion.ts +81 -0
  98. package/testaro/docType.d.ts +15 -0
  99. package/testaro/docType.js +25 -25
  100. package/testaro/docType.ts +45 -0
  101. package/testaro/dupAtt.d.ts +16 -0
  102. package/testaro/dupAtt.js +113 -104
  103. package/testaro/dupAtt.ts +144 -0
  104. package/testaro/elements.d.ts +6 -0
  105. package/testaro/elements.js +153 -153
  106. package/testaro/elements.ts +215 -0
  107. package/testaro/embAc.d.ts +3 -0
  108. package/testaro/embAc.js +19 -20
  109. package/testaro/embAc.ts +40 -0
  110. package/testaro/focAll.d.ts +13 -0
  111. package/testaro/focAll.js +185 -191
  112. package/testaro/focAll.ts +217 -0
  113. package/testaro/focAndOp.d.ts +3 -0
  114. package/testaro/focAndOp.js +100 -104
  115. package/testaro/focAndOp.ts +128 -0
  116. package/testaro/focInd.d.ts +3 -0
  117. package/testaro/focInd.js +64 -66
  118. package/testaro/focInd.ts +96 -0
  119. package/testaro/focVis.d.ts +3 -0
  120. package/testaro/focVis.js +28 -30
  121. package/testaro/focVis.ts +52 -0
  122. package/testaro/headEl.d.ts +10 -0
  123. package/testaro/headEl.js +61 -62
  124. package/testaro/headEl.ts +82 -0
  125. package/testaro/headingAmb.d.ts +3 -0
  126. package/testaro/headingAmb.js +54 -63
  127. package/testaro/headingAmb.ts +72 -0
  128. package/testaro/hovInd.d.ts +12 -0
  129. package/testaro/hovInd.js +163 -130
  130. package/testaro/hovInd.ts +199 -0
  131. package/testaro/hover.d.ts +3 -0
  132. package/testaro/hover.js +155 -126
  133. package/testaro/hover.ts +154 -0
  134. package/testaro/hr.d.ts +3 -0
  135. package/testaro/hr.js +14 -17
  136. package/testaro/hr.ts +36 -0
  137. package/testaro/imageLink.d.ts +3 -0
  138. package/testaro/imageLink.js +17 -20
  139. package/testaro/imageLink.ts +42 -0
  140. package/testaro/labClash.d.ts +3 -0
  141. package/testaro/labClash.js +31 -33
  142. package/testaro/labClash.ts +54 -0
  143. package/testaro/legendLoc.d.ts +3 -0
  144. package/testaro/legendLoc.js +17 -20
  145. package/testaro/legendLoc.ts +42 -0
  146. package/testaro/lineHeight.d.ts +3 -0
  147. package/testaro/lineHeight.js +43 -48
  148. package/testaro/lineHeight.ts +70 -0
  149. package/testaro/linkAmb.d.ts +7 -0
  150. package/testaro/linkAmb.js +79 -80
  151. package/testaro/linkAmb.ts +105 -0
  152. package/testaro/linkExt.d.ts +3 -0
  153. package/testaro/linkExt.js +13 -16
  154. package/testaro/linkExt.ts +35 -0
  155. package/testaro/linkOldAtt.d.ts +3 -0
  156. package/testaro/linkOldAtt.js +25 -28
  157. package/testaro/linkOldAtt.ts +48 -0
  158. package/testaro/linkTo.d.ts +3 -0
  159. package/testaro/linkTo.js +22 -22
  160. package/testaro/linkTo.ts +43 -0
  161. package/testaro/linkUl.d.ts +3 -0
  162. package/testaro/linkUl.js +30 -34
  163. package/testaro/linkUl.ts +54 -0
  164. package/testaro/miniText.d.ts +3 -0
  165. package/testaro/miniText.js +41 -44
  166. package/testaro/miniText.ts +68 -0
  167. package/testaro/motion.d.ts +10 -0
  168. package/testaro/motion.js +92 -96
  169. package/testaro/motion.ts +125 -0
  170. package/testaro/nonTable.d.ts +3 -0
  171. package/testaro/nonTable.js +39 -45
  172. package/testaro/nonTable.ts +66 -0
  173. package/testaro/optRoleSel.d.ts +3 -0
  174. package/testaro/optRoleSel.js +16 -19
  175. package/testaro/optRoleSel.ts +41 -0
  176. package/testaro/phOnly.d.ts +3 -0
  177. package/testaro/phOnly.js +18 -21
  178. package/testaro/phOnly.ts +43 -0
  179. package/testaro/pseudoP.d.ts +3 -0
  180. package/testaro/pseudoP.js +36 -38
  181. package/testaro/pseudoP.ts +59 -0
  182. package/testaro/radioSet.d.ts +3 -0
  183. package/testaro/radioSet.js +57 -59
  184. package/testaro/radioSet.ts +79 -0
  185. package/testaro/registry.d.ts +63 -0
  186. package/testaro/registry.js +67 -0
  187. package/testaro/registry.ts +141 -0
  188. package/testaro/role.d.ts +3 -0
  189. package/testaro/role.js +27 -29
  190. package/testaro/role.ts +53 -0
  191. package/testaro/secHeading.d.ts +3 -0
  192. package/testaro/secHeading.js +30 -33
  193. package/testaro/secHeading.ts +53 -0
  194. package/testaro/styleDiff.d.ts +25 -0
  195. package/testaro/styleDiff.js +248 -252
  196. package/testaro/styleDiff.ts +303 -0
  197. package/testaro/tabNav.d.ts +33 -0
  198. package/testaro/tabNav.js +272 -342
  199. package/testaro/tabNav.ts +454 -0
  200. package/testaro/targetsNear.d.ts +9 -0
  201. package/testaro/targetsNear.js +130 -132
  202. package/testaro/targetsNear.ts +160 -0
  203. package/testaro/textNodes.d.ts +6 -0
  204. package/testaro/textNodes.js +139 -135
  205. package/testaro/textNodes.ts +185 -0
  206. package/testaro/textSem.d.ts +3 -0
  207. package/testaro/textSem.js +25 -28
  208. package/testaro/textSem.ts +47 -0
  209. package/testaro/title.d.ts +9 -0
  210. package/testaro/title.js +16 -13
  211. package/testaro/title.ts +31 -0
  212. package/testaro/titledEl.d.ts +3 -0
  213. package/testaro/titledEl.js +15 -18
  214. package/testaro/titledEl.ts +38 -0
  215. package/testaro/zIndex.d.ts +3 -0
  216. package/testaro/zIndex.js +19 -22
  217. package/testaro/zIndex.ts +42 -0
  218. package/tests/alfa.d.ts +45 -0
  219. package/tests/alfa.js +138 -141
  220. package/tests/alfa.ts +214 -0
  221. package/tests/aslint.d.ts +33 -0
  222. package/tests/aslint.js +272 -249
  223. package/tests/aslint.ts +301 -0
  224. package/tests/axe.d.ts +27 -0
  225. package/tests/axe.js +199 -200
  226. package/tests/axe.ts +277 -0
  227. package/tests/ed11y.d.ts +28 -0
  228. package/tests/ed11y.js +141 -99
  229. package/tests/ed11y.ts +178 -0
  230. package/tests/htmlcs.d.ts +21 -0
  231. package/tests/htmlcs.js +174 -140
  232. package/tests/htmlcs.ts +181 -0
  233. package/tests/ibm.d.ts +52 -0
  234. package/tests/ibm.js +164 -166
  235. package/tests/ibm.ts +251 -0
  236. package/tests/nuVal.d.ts +13 -0
  237. package/tests/nuVal.js +107 -112
  238. package/tests/nuVal.ts +145 -0
  239. package/tests/nuVnu.d.ts +15 -0
  240. package/tests/nuVnu.js +141 -111
  241. package/tests/nuVnu.ts +144 -0
  242. package/tests/pour.d.ts +31 -0
  243. package/tests/pour.js +242 -0
  244. package/tests/pour.ts +273 -0
  245. package/tests/qualWeb.d.ts +39 -0
  246. package/tests/qualWeb.js +302 -272
  247. package/tests/qualWeb.ts +415 -0
  248. package/tests/surea11y.d.ts +33 -0
  249. package/tests/surea11y.js +288 -0
  250. package/tests/surea11y.ts +334 -0
  251. package/tests/testaro.d.ts +25 -0
  252. package/tests/testaro.js +745 -652
  253. package/tests/testaro.ts +862 -0
  254. package/tests/wave.d.ts +46 -0
  255. package/tests/wave.js +166 -177
  256. package/tests/wave.ts +252 -0
  257. package/tsconfig.json +17 -0
  258. package/types.d.ts +243 -0
  259. package/types.js +10 -0
  260. package/types.ts +376 -0
  261. package/validation/act/README.md +46 -0
  262. package/validation/act/capture.js +424 -0
  263. package/validation/act/chromium-issue-draft.md +66 -0
  264. package/validation/act/fp-triage-2026-08-22.md +86 -0
  265. package/validation/act/isolation-notes.md +159 -0
  266. package/validation/act/playwright-issue-draft.md +73 -0
  267. package/validation/act/propose-mappings.js +0 -0
  268. package/validation/act/repro-cdp-raw.js +105 -0
  269. package/validation/act/repro-metarefresh.js +65 -0
  270. package/validation/act/score.js +184 -0
  271. package/validation/act/stage3a-stress-report.md +69 -0
  272. package/validation/act/stage3b-mapping-proposals.md +94 -0
  273. package/validation/act/stage3b-triage-draft.md +138 -0
  274. package/validation/act/surea11y-track-a-2026-09-01.md +50 -0
  275. package/validation/executors/netWatch.js +180 -90
  276. package/validation/executors/test.js +17 -2
  277. package/validation/executors/tests.js +118 -10
  278. package/validation/jobs/reports/raw/260901T1000-surea11y-validation.json +964 -0
  279. package/validation/jobs/todo/240101T1200-simple-example.json +14 -6
  280. package/validation/jobs/todo/240101T1300-shoot-example.json +2 -1
  281. package/validation/jobs/todo/260821T1900-pour-validation.json +45 -0
  282. package/validation/jobs/todo/260901T1000-surea11y-validation.json +45 -0
  283. package/validation/knownFailures.json +1 -0
  284. package/validation/tests/jobProperties/adbID.json +27 -2
  285. package/validation/tests/jobProperties/{focOp.json → allCapStyle.json} +56 -53
  286. package/validation/tests/jobProperties/allCaps.json +25 -0
  287. package/validation/tests/jobProperties/allHidden.json +133 -13
  288. package/validation/tests/jobProperties/allSlanted.json +3 -3
  289. package/validation/tests/jobProperties/altScheme.json +23 -8
  290. package/validation/tests/jobProperties/attVal.json +57 -57
  291. package/validation/tests/jobProperties/autocomplete.json +11 -1
  292. package/validation/tests/jobProperties/bulk.json +6 -1
  293. package/validation/tests/jobProperties/buttonMenu.json +63 -42
  294. package/validation/tests/jobProperties/captionLoc.json +1 -6
  295. package/validation/tests/jobProperties/checkpoint-browser.json +401 -0
  296. package/validation/tests/jobProperties/checkpoint-page.json +397 -0
  297. package/validation/tests/jobProperties/checkpoint.json +395 -0
  298. package/validation/tests/jobProperties/datalistRef.json +20 -5
  299. package/validation/tests/jobProperties/distortion.json +28 -3
  300. package/validation/tests/jobProperties/docType.json +2 -2
  301. package/validation/tests/jobProperties/dupAtt.json +48 -33
  302. package/validation/tests/jobProperties/elements.json +28 -28
  303. package/validation/tests/jobProperties/embAc.json +36 -31
  304. package/validation/tests/jobProperties/focAndOp.json +284 -0
  305. package/validation/tests/jobProperties/focInd.json +34 -34
  306. package/validation/tests/jobProperties/focVis.json +1 -1
  307. package/validation/tests/jobProperties/hover.json +39 -37
  308. package/validation/tests/jobProperties/hr.json +13 -3
  309. package/validation/tests/jobProperties/imageLink.json +0 -5
  310. package/validation/tests/jobProperties/labClash.json +68 -33
  311. package/validation/tests/jobProperties/legendLoc.json +1 -6
  312. package/validation/tests/jobProperties/lineHeight.json +2 -2
  313. package/validation/tests/jobProperties/linkAmb.json +13 -18
  314. package/validation/tests/jobProperties/linkExt.json +1 -1
  315. package/validation/tests/jobProperties/linkOldAtt.json +12 -2
  316. package/validation/tests/jobProperties/linkTo.json +1 -1
  317. package/validation/tests/jobProperties/linkUl.json +65 -65
  318. package/validation/tests/jobProperties/miniText.json +7 -2
  319. package/validation/tests/jobProperties/motion.json +4 -50
  320. package/validation/tests/jobProperties/nonTable.json +48 -3
  321. package/validation/tests/jobProperties/optRoleSel.json +12 -2
  322. package/validation/tests/jobProperties/phOnly.json +6 -16
  323. package/validation/tests/jobProperties/pseudoP.json +17 -2
  324. package/validation/tests/jobProperties/radioSet.json +30 -30
  325. package/validation/tests/jobProperties/role.json +25 -5
  326. package/validation/tests/jobProperties/secHeading.json +26 -21
  327. package/validation/tests/jobProperties/styleDiff.json +35 -5
  328. package/validation/tests/jobProperties/tabNav.json +3 -1
  329. package/validation/tests/jobProperties/targetsNear.json +237 -0
  330. package/validation/tests/jobProperties/textNodes.json +29 -29
  331. package/validation/tests/jobProperties/textSem.json +26 -1
  332. package/validation/tests/jobProperties/title.json +12 -2
  333. package/validation/tests/jobProperties/titledEl.json +39 -9
  334. package/validation/tests/jobProperties/userPath.json +317 -0
  335. package/validation/tests/jobProperties/zIndex.json +40 -35
  336. package/validation/tests/targets/allCapStyle/index.html +26 -0
  337. package/validation/tests/targets/checkpoint/index.html +38 -0
  338. package/validation/tests/targets/datalistRef/index.html +1 -1
  339. package/validation/tests/targets/focAndOp/bad.html +29 -0
  340. package/validation/tests/targets/{focOp → focAndOp}/good.html +3 -1
  341. package/validation/tests/targets/focInd/bad.html +2 -1
  342. package/validation/tests/targets/headEl/index.html +10 -1
  343. package/validation/tests/targets/{targetSmall → targetsNear}/index.html +15 -1
  344. package/validation/tests/targets/userPath/index.html +42 -0
  345. package/validation/validateTest.js +61 -9
  346. package/.claude/settings.local.json +0 -11
  347. package/.eslintrc.json +0 -41
  348. package/htmlcs/.eslintrc.json +0 -67
  349. package/validation/tests/jobProperties/linkTitle.json +0 -127
  350. package/validation/tests/jobProperties/opFoc.json +0 -164
  351. package/validation/tests/jobProperties/targetSmall.json +0 -152
  352. package/validation/tests/jobProperties/targetTiny.json +0 -142
  353. package/validation/tests/targets/focOp/bad.html +0 -25
  354. package/validation/tests/targets/linkTitle/index.html +0 -24
  355. package/validation/tests/targets/opFoc/bad.html +0 -26
  356. 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>;