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