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
@@ -0,0 +1,877 @@
1
+ /*
2
+ © 2026 Jeff Witt.
3
+
4
+ Licensed under the MIT License. See LICENSE file at the project root or
5
+ https://opensource.org/license/mit/ for details.
6
+
7
+ SPDX-License-Identifier: MIT
8
+ */
9
+
10
+ /*
11
+ capture.js
12
+ Runs tool reporters over a page set and records raw per-page findings to
13
+ JSONL. Two page sources:
14
+ - Default: the W3C ACT-Rules test cases (Track A; score with score.js).
15
+ - --urls <file>: a plain list of URLs, one per line (the Stage-3 real-world
16
+ stress/mapping capture). Rows carry per-instance element paths
17
+ (`instances`) so element-level co-occurrence can be computed downstream.
18
+ Scoring/analysis is separate and cheap, so an expensive capture never has
19
+ to repeat to try a different policy.
20
+
21
+ Usage (cwd must be the repository root, for tool assets such as aceconfig):
22
+ node validation/act/capture.js --engines pour,axe [--match regex]
23
+ [--rules id,id] [--max N] [--out path.jsonl]
24
+ [--urls path.txt] [--recycle N]
25
+
26
+ First line is a header {header: true, capturedAt, mode, engines,
27
+ engineVersions, testaro, forkCommit, playwright}; scorers skip rows without
28
+ a testcaseId. Each further line: {testcaseId, ruleId, expected, engine,
29
+ prevented, instanceCount, outcomeTotals: {failed, cantTell},
30
+ asserted: {SC: count}, review: {SC: count}, ruleIDs: {engineRuleID: count},
31
+ ruleOutcomes: {engineRuleID: {failed, cantTell}}, ms}
32
+ `asserted` counts definite failures (standard-instance outcome `failed`);
33
+ `review` counts engine-flagged uncertainty (outcome `cantTell`). Criteria
34
+ are dotted WCAG SC numbers, extracted per engine from native results;
35
+ engines without an extractor still record ruleIDs, instanceCount, and
36
+ outcomeTotals. Every standard instance must carry a valid outcome; a row
37
+ whose instances do not is marked as an error (adapter drift).
38
+ */
39
+
40
+ // IMPORTS
41
+
42
+ const fs = require('fs');
43
+ const fsp = require('fs/promises');
44
+ const path = require('path');
45
+ const {chromium} = require('playwright');
46
+ const {OUTCOMES} = require('../../procs/standard');
47
+
48
+ // CONSTANTS
49
+
50
+ const TESTCASES_URL = 'https://www.w3.org/WAI/content-assets/wcag-act-rules/testcases.json';
51
+ const CACHE_DIR = path.join(__dirname, 'cache');
52
+ const RESULTS_DIR = path.join(__dirname, 'results');
53
+ // Per-reporter time limit, matching the production limits in procs/doActs.js.
54
+ const REPORTER_TIMEOUT_MS = 45000;
55
+ /*
56
+ Failures that say nothing about the engine: the fixture host was
57
+ unreachable, or the browser wedged. Such a row is retried in place a few
58
+ times (fixtures are fetched live from w3.org, and a flaky uplink drops for
59
+ seconds at a time) and, if still failing, is recaptured by a later resumed
60
+ run instead of being treated as captured.
61
+ */
62
+ const RETRYABLE_ERROR = /ERR_INTERNET_DISCONNECTED|ERR_NAME_NOT_RESOLVED|ERR_CONNECTION|ERR_NETWORK_CHANGED|ERR_TIMED_OUT|Timeout \d+ms exceeded|newPage deadline|reporter timeout|has been closed|Protocol error/;
63
+ const NETWORK_RETRIES = 3;
64
+ const NETWORK_RETRY_DELAY_MS = 15000;
65
+ // The testaro tool runs ~45 rules (screenshots, hover, motion) in its own
66
+ // browser: seconds on a fixture, minutes on a heavy real page.
67
+ const TESTARO_TIMEOUT_MS = 180000;
68
+ const TESTARO_URL_TIMEOUT_MS = 600000;
69
+
70
+ /*
71
+ The testaro tool is not a page-injected reporter: its rules launch and
72
+ navigate their own browser through procs/launch.js and validate the job
73
+ they are given. So it is run through doJob() with a minimal valid job, as
74
+ validation/validateTest.js does, and its test act is read back.
75
+ */
76
+ let testaroJobCount = 0;
77
+ const testaroJob = (url, timeLimit = 120) => {
78
+ const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/^\d\d(\d{6})T(\d{4}).*$/, '$1T$2');
79
+ // The id names doJob's temporary directory, so it must be unique across
80
+ // fixtures and across concurrent capture processes.
81
+ testaroJobCount++;
82
+ return {
83
+ id: `${stamp}-act-${process.pid}-${testaroJobCount}`,
84
+ what: 'ACT fixture capture (testaro)',
85
+ strict: false,
86
+ standard: 'also',
87
+ observe: false,
88
+ device: {id: 'default', windowOptions: {reducedMotion: 'no-preference'}},
89
+ browserID: 'chromium',
90
+ timeLimit,
91
+ creationTimeStamp: stamp,
92
+ executionTimeStamp: stamp,
93
+ sendReportTo: '',
94
+ target: {what: 'ACT fixture', url},
95
+ sources: {script: 'act', batch: 'act', mergeID: 'act', requester: ''},
96
+ acts: [{type: 'test', which: 'testaro', withItems: true, stopOnFail: false}]
97
+ };
98
+ };
99
+
100
+ // Mirrors procs/doTestAct.js xPathNeeds for the engines this harness supports.
101
+ const XPATH_NEEDS = {
102
+ alfa: 'own',
103
+ aslint: 'own',
104
+ axe: 'attribute',
105
+ ed11y: 'script',
106
+ htmlcs: 'attribute',
107
+ ibm: 'attribute',
108
+ pour: 'script',
109
+ qualWeb: 'attribute',
110
+ surea11y: 'script'
111
+ };
112
+
113
+ // Converts a wcag### tag (axe/pour style) to a dotted criterion.
114
+ const tagToCriterion = tag => {
115
+ const digits = tag.slice(4);
116
+ return `${digits[0]}.${digits[1]}.${digits.slice(2)}`;
117
+ };
118
+
119
+ /*
120
+ Per-engine criterion extractors: nativeResult → {asserted, review}, each
121
+ {dottedSC: count}. Engines without one record ruleIDs only; add extractors
122
+ here as engines join Track A.
123
+ */
124
+ const criterionExtractors = {
125
+ // Shared by pour and surea11y: both adapters flatten to violations/incomplete
126
+ // arrays of findings carrying a dotted `wcag` criterion.
127
+ pour: nativeResult => {
128
+ const buckets = {asserted: {}, review: {}};
129
+ [['violations', 'asserted'], ['incomplete', 'review']].forEach(([source, target]) => {
130
+ (nativeResult[source] || []).forEach(finding => {
131
+ if (finding.wcag) {
132
+ buckets[target][finding.wcag] = (buckets[target][finding.wcag] || 0) + 1;
133
+ }
134
+ });
135
+ });
136
+ return buckets;
137
+ },
138
+ get surea11y() {
139
+ return this.pour;
140
+ },
141
+ axe: nativeResult => {
142
+ const buckets = {asserted: {}, review: {}};
143
+ const details = nativeResult && nativeResult.details;
144
+ [['violations', 'asserted'], ['incomplete', 'review']].forEach(([source, target]) => {
145
+ ((details && details[source]) || []).forEach(rule => {
146
+ const criteria = (rule.tags || [])
147
+ .filter(tag => /^wcag\d{3,4}$/.test(tag))
148
+ .map(tagToCriterion);
149
+ rule.nodes.forEach(() => {
150
+ criteria.forEach(criterion => {
151
+ buckets[target][criterion] = (buckets[target][criterion] || 0) + 1;
152
+ });
153
+ });
154
+ });
155
+ });
156
+ return buckets;
157
+ },
158
+ /*
159
+ QualWeb runs the ACT rules natively: each act-rules assertion carries the
160
+ exact ACT rule ID in `mapping`, so this extractor also emits actAsserted /
161
+ actReview maps keyed by ACT rule ID — score.js prefers those over the
162
+ criterion layer when present.
163
+ */
164
+ qualWeb: nativeResult => {
165
+ const buckets = {asserted: {}, review: {}, actAsserted: {}, actReview: {}};
166
+ const modules = (nativeResult && nativeResult.modules) || {};
167
+ ['act-rules', 'wcag-techniques', 'best-practices'].forEach(section => {
168
+ const assertions = (modules[section] && modules[section].assertions) || {};
169
+ Object.values(assertions).forEach(assertion => {
170
+ const criteria = ((assertion.metadata && assertion.metadata['success-criteria']) || [])
171
+ .map(criterion => criterion.name);
172
+ const actID = section === 'act-rules' ? assertion.mapping : null;
173
+ (assertion.results || []).forEach(assertionResult => {
174
+ const target = assertionResult.verdict === 'failed' ? 'asserted'
175
+ : assertionResult.verdict === 'warning' ? 'review' : null;
176
+ if (! target) {
177
+ return;
178
+ }
179
+ const count = (assertionResult.elements || []).length || 1;
180
+ criteria.forEach(criterion => {
181
+ buckets[target][criterion] = (buckets[target][criterion] || 0) + count;
182
+ });
183
+ if (actID) {
184
+ const actTarget = target === 'asserted' ? 'actAsserted' : 'actReview';
185
+ buckets[actTarget][actID] = (buckets[actTarget][actID] || 0) + count;
186
+ }
187
+ });
188
+ });
189
+ });
190
+ return buckets;
191
+ }
192
+ };
193
+
194
+ // Per-engine rule-ID counters from the standard result.
195
+ const ruleIDCounts = standardResult => {
196
+ const counts = {};
197
+ ((standardResult && standardResult.instances) || []).forEach(instance => {
198
+ counts[instance.ruleID] = (counts[instance.ruleID] || 0) + instance.count;
199
+ });
200
+ return counts;
201
+ };
202
+
203
+ // Per-rule certainty bands: {ruleID: {failed, cantTell}} from instance outcomes.
204
+ // Lets a scorer join rules to ACT rule ids without a criterion extractor.
205
+ const ruleOutcomeCounts = standardResult => {
206
+ const bands = {};
207
+ ((standardResult && standardResult.instances) || []).forEach(instance => {
208
+ const band = bands[instance.ruleID] ??= {failed: 0, cantTell: 0};
209
+ if (instance.outcome === 'failed' || instance.outcome === 'cantTell') {
210
+ band[instance.outcome] += instance.count || 1;
211
+ }
212
+ });
213
+ return bands;
214
+ };
215
+
216
+ /*
217
+ URL mode: one snapshot row per page, taken after every engine has run,
218
+ holding the DOM context of each element any engine flagged. Element
219
+ identity is the normalized XPath the engines already share, so one
220
+ snapshot serves every engine's instances (including the testaro tool,
221
+ whose own browser is gone by then). Bounded per page and per element.
222
+ */
223
+ const SNAPSHOT_MAX_ELEMENTS = 400;
224
+ const SNAPSHOT_MAX_HTML = 4000;
225
+ // Elements per page for which the (slower) accessibility-tree snapshot is taken.
226
+ const SNAPSHOT_MAX_ARIA = 200;
227
+ /*
228
+ Snapshot profile version. v1: DOM context (outerHTML, two ancestor shells,
229
+ computed style subset, box, text) plus the accessibility invariant a
230
+ reducer must preserve, captured at source rather than reconstructed: the
231
+ element's own accessibility-tree snapshot (`aria`), the elements its IDREF
232
+ attributes point at (`refs`), and the structural context outside the
233
+ element itself (`context`: ancestor roles, table/list position).
234
+ */
235
+ const SNAPSHOT_PROFILE_VERSION = 1;
236
+ const IDREF_ATTRIBUTES = [
237
+ 'aria-labelledby', 'aria-describedby', 'aria-controls', 'aria-owns', 'aria-activedescendant',
238
+ 'aria-flowto', 'aria-details', 'aria-errormessage', 'headers', 'for', 'list', 'form'
239
+ ];
240
+ const snapshotScript = ([xPaths, maxHTML, idrefAttributes]) => {
241
+ const shell = element => {
242
+ const attrs = Array.from(element.attributes)
243
+ .map(attribute => `${attribute.name}="${attribute.value.slice(0, 120)}"`)
244
+ .join(' ');
245
+ return `<${element.tagName.toLowerCase()}${attrs ? ` ${attrs}` : ''}>`;
246
+ };
247
+ const styleKeys = [
248
+ 'display', 'visibility', 'opacity', 'position', 'color', 'background-color',
249
+ 'font-size', 'font-weight', 'line-height', 'letter-spacing', 'word-spacing', 'outline-style'
250
+ ];
251
+ const snapshots = {};
252
+ for (const xPath of xPaths) {
253
+ try {
254
+ const element = document.evaluate(
255
+ xPath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null
256
+ ).singleNodeValue;
257
+ if (! element || element.nodeType !== Node.ELEMENT_NODE) {
258
+ snapshots[xPath] = {missing: true};
259
+ continue;
260
+ }
261
+ const ancestors = [];
262
+ const ancestorRoles = [];
263
+ let parent = element.parentElement;
264
+ while (parent && parent !== document.documentElement) {
265
+ if (ancestors.length < 2) {
266
+ ancestors.unshift(shell(parent));
267
+ }
268
+ const role = parent.getAttribute('role');
269
+ const tag = parent.tagName.toLowerCase();
270
+ if (role || /^(main|nav|header|footer|aside|section|article|form|table|thead|tbody|tr|ul|ol|dl|fieldset|dialog|menu|figure|details)$/.test(tag)) {
271
+ ancestorRoles.unshift(role ? `${tag}[role=${role}]` : tag);
272
+ }
273
+ parent = parent.parentElement;
274
+ }
275
+ // Elements this element points at by IDREF: the part of the invariant that lives outside it.
276
+ const refs = {};
277
+ idrefAttributes.forEach(attribute => {
278
+ const value = element.getAttribute(attribute);
279
+ if (! value) {
280
+ return;
281
+ }
282
+ refs[attribute] = value.trim().split(/\s+/).slice(0, 8).map(id => {
283
+ const target = document.getElementById(id);
284
+ return target
285
+ ? {id, html: target.outerHTML.slice(0, 400), text: (target.textContent || '').trim().slice(0, 120)}
286
+ : {id, missing: true};
287
+ });
288
+ });
289
+ // Structural context: table and list position, and whether the element labels something.
290
+ const context = {ancestorRoles};
291
+ const cell = element.closest('td, th');
292
+ if (cell) {
293
+ const row = cell.parentElement;
294
+ const table = cell.closest('table');
295
+ context.table = {
296
+ row: row ? Array.from(row.parentElement.children).indexOf(row) : -1,
297
+ col: Array.from(row ? row.children : []).indexOf(cell),
298
+ rows: table ? table.rows.length : 0,
299
+ scope: cell.getAttribute('scope') || '',
300
+ headers: cell.getAttribute('headers') || ''
301
+ };
302
+ }
303
+ const item = element.closest('li, option, [role=listitem], [role=option], [role=menuitem], [role=tab], [role=treeitem]');
304
+ if (item && item.parentElement) {
305
+ context.set = {
306
+ index: Array.from(item.parentElement.children).indexOf(item),
307
+ size: item.parentElement.children.length,
308
+ container: shell(item.parentElement)
309
+ };
310
+ }
311
+ if (element.id) {
312
+ const referrers = Array.from(document.querySelectorAll(
313
+ `[aria-labelledby~="${element.id}"], [aria-describedby~="${element.id}"], [headers~="${element.id}"], label[for="${element.id}"]`
314
+ )).slice(0, 8).map(shell);
315
+ if (referrers.length) {
316
+ context.referencedBy = referrers;
317
+ }
318
+ }
319
+ const computed = getComputedStyle(element);
320
+ const style = {};
321
+ styleKeys.forEach(key => {
322
+ style[key] = computed.getPropertyValue(key);
323
+ });
324
+ const rect = element.getBoundingClientRect();
325
+ const outerHTML = element.outerHTML;
326
+ snapshots[xPath] = {
327
+ outerHTML: outerHTML.length > maxHTML ? `${outerHTML.slice(0, maxHTML)} …` : outerHTML,
328
+ truncated: outerHTML.length > maxHTML,
329
+ ancestors,
330
+ style,
331
+ box: [rect.x, rect.y, rect.width, rect.height].map(Math.round),
332
+ text: (element.textContent || '').trim().slice(0, 200),
333
+ refs,
334
+ context
335
+ };
336
+ }
337
+ catch(error) {
338
+ snapshots[xPath] = {error: String(error && error.message || error).slice(0, 120)};
339
+ }
340
+ }
341
+ return snapshots;
342
+ };
343
+ // Adds each element's accessibility-tree snapshot (role, name, states, children) to its snapshot.
344
+ const addAriaSnapshots = async (page, snapshots, xPaths) => {
345
+ for (const xPath of xPaths.slice(0, SNAPSHOT_MAX_ARIA)) {
346
+ const snapshot = snapshots[xPath];
347
+ if (! snapshot || snapshot.missing || snapshot.error) {
348
+ continue;
349
+ }
350
+ try {
351
+ snapshot.aria = await page.locator(`xpath=${xPath}`).first().ariaSnapshot({timeout: 2000});
352
+ }
353
+ catch(error) {
354
+ snapshot.ariaError = String(error && error.message || error).split('\n')[0].slice(0, 120);
355
+ }
356
+ }
357
+ };
358
+
359
+ // Versions of the engines in play, for the capture header row.
360
+ const engineVersions = engines => {
361
+ const packages = {
362
+ alfa: '@siteimprove/alfa-rules',
363
+ aslint: 'aslint',
364
+ axe: 'axe-core',
365
+ ed11y: 'editoria11y',
366
+ htmlcs: 'html_codesniffer',
367
+ ibm: 'accessibility-checker',
368
+ qualWeb: '@qualweb/act-rules'
369
+ };
370
+ const versions = {};
371
+ const root = path.join(__dirname, '..', '..');
372
+ engines.forEach(engine => {
373
+ try {
374
+ if (packages[engine]) {
375
+ // Read from disk: some packages' `exports` maps block require() of package.json.
376
+ const packageJSON = path.join(root, 'node_modules', packages[engine], 'package.json');
377
+ versions[engine] = JSON.parse(fs.readFileSync(packageJSON, 'utf8')).version;
378
+ }
379
+ else if (['pour', 'surea11y'].includes(engine)) {
380
+ const readme = fs.readFileSync(path.join(root, engine, 'README.md'), 'utf8');
381
+ const match = readme.match(/\b(\d+\.\d+\.\d+)\b/);
382
+ versions[engine] = match ? match[1] : 'unknown';
383
+ }
384
+ else if (engine === 'testaro') {
385
+ versions[engine] = require('../../package.json').version;
386
+ }
387
+ }
388
+ catch(error) {
389
+ versions[engine] = 'unknown';
390
+ }
391
+ });
392
+ return versions;
393
+ };
394
+
395
+ /*
396
+ The ibm engine version is the one that actually runs: by default IBM's deployed rule archive
397
+ ("latest"), whose engine can be older than the package's own (4.0.27 under accessibility-checker
398
+ 4.0.33 on 2026-09-23), or with IBM_ENGINE=package the engine released with the checker
399
+ (tests/ibm.ts, ibmEngine).
400
+ */
401
+ const ibmRuleArchive = async () => {
402
+ try {
403
+ // Applies IBM_ENGINE first, so the header records the engine that will run.
404
+ const {mode, version} = await require('../../tests/ibm').ibmEngine();
405
+ const {ruleArchive} = await require('accessibility-checker').getConfig();
406
+ return {id: mode === 'package' ? 'package' : ruleArchive, version, mode};
407
+ }
408
+ catch(error) {
409
+ return null;
410
+ }
411
+ };
412
+
413
+ // FUNCTIONS
414
+
415
+ // Parses CLI arguments of the form --name value.
416
+ const parseArgs = argv => {
417
+ const args = {};
418
+ for (let i = 2; i < argv.length; i += 2) {
419
+ args[argv[i].replace(/^--/, '')] = argv[i + 1];
420
+ }
421
+ return args;
422
+ };
423
+
424
+ // Gets the testcase feed, cached on disk.
425
+ const getTestcases = async () => {
426
+ await fsp.mkdir(CACHE_DIR, {recursive: true});
427
+ const cachePath = path.join(CACHE_DIR, 'testcases.json');
428
+ if (! fs.existsSync(cachePath)) {
429
+ const response = await fetch(TESTCASES_URL);
430
+ await fsp.writeFile(cachePath, await response.text());
431
+ }
432
+ return JSON.parse(await fsp.readFile(cachePath, 'utf8')).testcases;
433
+ };
434
+
435
+ // The window.getXPath injection, as in procs/launch.js.
436
+ const getXPathScript = () => {
437
+ window.getXPath = element => {
438
+ if (! element || element.nodeType !== Node.ELEMENT_NODE) {
439
+ return '';
440
+ }
441
+ const segments = [];
442
+ while (element && element.nodeType === Node.ELEMENT_NODE) {
443
+ const tag = element.tagName.toLowerCase();
444
+ if (element === document.documentElement) {
445
+ segments.unshift('html');
446
+ break;
447
+ }
448
+ const parent = element.parentNode;
449
+ if (! parent || parent.nodeType !== Node.ELEMENT_NODE) {
450
+ segments.unshift(tag);
451
+ break;
452
+ }
453
+ const cohort = Array.from(parent.childNodes).filter(
454
+ childNode => childNode.nodeType === Node.ELEMENT_NODE
455
+ && childNode.tagName === element.tagName
456
+ );
457
+ const subscript = tag === 'body' ? '' : `[${cohort.indexOf(element) + 1}]`;
458
+ segments.unshift(`${tag}${subscript}`);
459
+ element = parent;
460
+ }
461
+ return `/${segments.join('/')}`;
462
+ };
463
+ };
464
+
465
+ // OPERATION
466
+
467
+ /*
468
+ Races a promise against a deadline and clears the timer once either settles.
469
+ An uncleared timer keeps the event loop alive: each Testaro job and engine
470
+ reporter used to leave one behind, so a finished capture lingered until the
471
+ last fired (up to 3 minutes for fixtures, 10 in URL mode). `message` keeps
472
+ the error text the resume logic matches (RETRYABLE_ERROR).
473
+ */
474
+ const withDeadline = (promise, ms, label, message = `${label} deadline ${ms}ms`) => {
475
+ let timer;
476
+ const deadline = new Promise((resolve, reject) => {
477
+ timer = setTimeout(() => reject(new Error(message)), ms);
478
+ });
479
+ return Promise.race([promise, deadline]).finally(() => clearTimeout(timer));
480
+ };
481
+
482
+ /*
483
+ Stop on SIGTERM or SIGINT. Playwright's browser launcher registers listeners
484
+ for both that close its browsers but don't end the process, so a plain kill
485
+ used to be ignored. Now the capture stops once the (testcase, engine) run in
486
+ progress has been recorded (as a retryable prevention, if its browser was
487
+ just closed under it), writes out, and exits; rerunning the same command
488
+ resumes. A second signal exits at once.
489
+ */
490
+ let stopSignal = null;
491
+ for (const signal of ['SIGTERM', 'SIGINT']) {
492
+ process.on(signal, () => {
493
+ if (stopSignal) {
494
+ console.log(`${signal} again: exiting now`);
495
+ process.exit(signal === 'SIGINT' ? 130 : 143);
496
+ }
497
+ stopSignal = signal;
498
+ console.log(`${signal}: stopping after the current engine run`);
499
+ });
500
+ }
501
+
502
+ // A tool's stray promise (e.g. a playwright-extra CDP session settling after
503
+ // the page is closed) must not abort a multi-hour capture; the row's own
504
+ // try/catch and the browser-replacement heuristics handle the page.
505
+ process.on('unhandledRejection', reason => {
506
+ const message = reason && reason.message ? reason.message : String(reason);
507
+ if (/cdpSession|Target page, context or browser has been closed/.test(message)) {
508
+ console.log(`WARNING: unhandled rejection (${message})`);
509
+ return;
510
+ }
511
+ // Anything else is a bug in the harness: fail loudly rather than hang.
512
+ console.error(`ERROR: unhandled rejection (${message})`);
513
+ process.exit(1);
514
+ });
515
+
516
+ (async () => {
517
+ const args = parseArgs(process.argv);
518
+ const engines = (args.engines || 'pour').split(',');
519
+ const outPath = args.out
520
+ || path.join(RESULTS_DIR, `act-${new Date().toISOString().replace(/[:.]/g, '-')}.jsonl`);
521
+ await fsp.mkdir(path.dirname(outPath), {recursive: true});
522
+ let testcases;
523
+ if (args.urls) {
524
+ // URL-list mode: one synthetic testcase per line; the URL is the ID.
525
+ testcases = (await fsp.readFile(args.urls, 'utf8'))
526
+ .split('\n')
527
+ .map(line => line.trim())
528
+ .filter(line => line && ! line.startsWith('#'))
529
+ .map(url => ({testcaseId: url, url}));
530
+ }
531
+ else {
532
+ testcases = await getTestcases();
533
+ }
534
+ if (args.rules) {
535
+ const wanted = new Set(args.rules.split(','));
536
+ testcases = testcases.filter(testcase => wanted.has(testcase.ruleId));
537
+ }
538
+ if (args.match) {
539
+ const matcher = new RegExp(args.match, 'i');
540
+ testcases = testcases.filter(testcase => matcher.test(testcase.ruleName));
541
+ }
542
+ if (args.max) {
543
+ testcases = testcases.slice(0, Number(args.max));
544
+ }
545
+ // Resume support: skip (testcase, engine) pairs already in the out file,
546
+ // except rows prevented for a retryable reason — those are recaptured, and
547
+ // a scorer keeps the last row per pair.
548
+ const alreadyCaptured = new Set();
549
+ // URL mode: flagged XPaths from rows captured by earlier runs, so a resumed
550
+ // page still gets its snapshot row (its engine rows are skipped).
551
+ const priorFlagged = new Map();
552
+ if (fs.existsSync(outPath)) {
553
+ fs.readFileSync(outPath, 'utf8').split('\n').filter(Boolean).forEach(line => {
554
+ try {
555
+ const row = JSON.parse(line);
556
+ if (! row.testcaseId) {
557
+ return;
558
+ }
559
+ // Fixture ids are content hashes shared across rules (e.g. the AA and
560
+ // AAA contrast rules), so the rule is part of the identity.
561
+ const key = `${row.ruleId || ''}/${row.testcaseId}:${row.engine}`;
562
+ if (row.prevented && RETRYABLE_ERROR.test(row.error || '')) {
563
+ alreadyCaptured.delete(key);
564
+ }
565
+ else {
566
+ alreadyCaptured.add(key);
567
+ }
568
+ if (Array.isArray(row.instances)) {
569
+ const flagged = priorFlagged.get(row.testcaseId) || new Set();
570
+ row.instances.forEach(instance => {
571
+ if (instance.xPath && instance.outcome === 'failed') {
572
+ flagged.add(instance.xPath);
573
+ }
574
+ });
575
+ priorFlagged.set(row.testcaseId, flagged);
576
+ }
577
+ }
578
+ catch(error) {}
579
+ });
580
+ }
581
+ console.log(
582
+ `Capturing ${testcases.length} testcases × ${engines.join('+')} → ${outPath}`
583
+ + (alreadyCaptured.size ? ` (resuming; ${alreadyCaptured.size} rows already captured)` : '')
584
+ );
585
+ const out = fs.createWriteStream(outPath, {flags: 'a'});
586
+ // Header row (no testcaseId) pinning what produced a fresh capture; scorers
587
+ // skip it, and a resumed capture keeps the original header.
588
+ if (! alreadyCaptured.size) {
589
+ let forkCommit = 'unknown';
590
+ try {
591
+ forkCommit = require('child_process')
592
+ .execFileSync('git', ['rev-parse', 'HEAD'], {cwd: path.join(__dirname, '..', '..'), encoding: 'utf8'})
593
+ .trim();
594
+ }
595
+ catch(error) {}
596
+ const versions = engineVersions(engines);
597
+ const ibmArchive = engines.includes('ibm') ? await ibmRuleArchive() : null;
598
+ if (ibmArchive) {
599
+ ibmArchive.checker = versions.ibm;
600
+ versions.ibm = ibmArchive.version;
601
+ }
602
+ out.write(`${JSON.stringify({
603
+ header: true,
604
+ capturedAt: new Date().toISOString(),
605
+ mode: args.urls ? 'urls' : 'act-fixtures',
606
+ engines,
607
+ engineVersions: versions,
608
+ ...(ibmArchive ? {ibmRuleArchive: ibmArchive} : {}),
609
+ testaro: require('../../package.json').version,
610
+ forkCommit,
611
+ playwright: require('playwright/package.json').version
612
+ })}\n`);
613
+ }
614
+ /*
615
+ The browser is recycled every RECYCLE_EVERY testcases: a 2026-08-22 full
616
+ run showed script-tag injection silently failing (tool global never
617
+ defined) after ~270 testcases in one long-lived browser, while
618
+ evaluate-based injection kept working. Fresh processes reproduce none of
619
+ it, so periodic recycling is the guard.
620
+ */
621
+ const RECYCLE_EVERY = args.recycle === undefined ? 100 : Number(args.recycle);
622
+ let browser = await chromium.launch();
623
+ let context = await browser.newContext();
624
+ let done = 0;
625
+ /*
626
+ Hang guards. page.close() can hang FOREVER (not reject — hang) on a page
627
+ that performed a meta-refresh(0) navigation (reproduced locally,
628
+ playwright 1.62.1, ~50% of rounds on such pages; see isolation-notes.md).
629
+ So: every await that touches the browser is raced against a deadline, and
630
+ if the per-testcase watchdog trips, the browser is presumed wedged and
631
+ replaced (the old one is closed with its own deadline and abandoned if
632
+ that hangs too).
633
+ */
634
+ const replaceBrowser = async () => {
635
+ const oldBrowser = browser;
636
+ withDeadline(oldBrowser.close(), 10000, 'browser.close').catch(() => {});
637
+ browser = await chromium.launch();
638
+ context = await browser.newContext();
639
+ };
640
+ for (const testcase of testcases) {
641
+ if (stopSignal) {
642
+ break;
643
+ }
644
+ if (done > 0 && RECYCLE_EVERY && done % RECYCLE_EVERY === 0) {
645
+ await replaceBrowser();
646
+ }
647
+ // XPaths flagged on this page by any engine, for the URL-mode snapshot row.
648
+ const flaggedXPaths = new Set(priorFlagged.get(testcase.testcaseId) || []);
649
+ for (const engine of engines) {
650
+ if (stopSignal) {
651
+ break;
652
+ }
653
+ if (alreadyCaptured.has(`${testcase.ruleId || ''}/${testcase.testcaseId}:${engine}`)) {
654
+ continue;
655
+ }
656
+ const row = {
657
+ testcaseId: testcase.testcaseId,
658
+ ruleId: testcase.ruleId,
659
+ ruleName: testcase.ruleName,
660
+ expected: testcase.expected,
661
+ engine
662
+ };
663
+ const started = Date.now();
664
+ let page;
665
+ let closeHung = false;
666
+ try {
667
+ page = await withDeadline(context.newPage(), 20000, 'newPage');
668
+ const xPathNeed = XPATH_NEEDS[engine] || 'none';
669
+ if (xPathNeed === 'script' || xPathNeed === 'attribute') {
670
+ await page.addInitScript(getXPathScript);
671
+ }
672
+ // Navigate, waiting out a dropped uplink rather than recording it as a result.
673
+ for (let attempt = 0; ; attempt++) {
674
+ try {
675
+ await page.goto(testcase.url, {waitUntil: 'load', timeout: args.urls ? 60000 : 20000});
676
+ break;
677
+ }
678
+ catch(error) {
679
+ if (attempt < NETWORK_RETRIES && /ERR_INTERNET_DISCONNECTED|ERR_NAME_NOT_RESOLVED|ERR_CONNECTION|ERR_NETWORK_CHANGED/.test(error.message)) {
680
+ console.log(`WARNING: network error on ${testcase.testcaseId}; retrying in ${NETWORK_RETRY_DELAY_MS / 1000}s`);
681
+ await new Promise(resolve => setTimeout(resolve, NETWORK_RETRY_DELAY_MS));
682
+ continue;
683
+ }
684
+ throw error;
685
+ }
686
+ }
687
+ /*
688
+ Chromium shows an XML file it cannot render as a document (e.g. a <math> root with no
689
+ namespace) in its XML tree viewer, an HTML page of its own; the engines would test that
690
+ page instead of the fixture.
691
+ */
692
+ const inXMLViewer = await page.evaluate(
693
+ () => !! document.getElementById('webkit-xml-viewer-source-xml')
694
+ ).catch(() => false);
695
+ if (inXMLViewer) {
696
+ throw new Error('fixture not renderable as a document in Chromium (XML viewer)');
697
+ }
698
+ if (xPathNeed === 'attribute') {
699
+ // Stamp data-xpath attributes, as procs/launch.js does.
700
+ await withDeadline(page.evaluate(() => {
701
+ document.querySelectorAll('*').forEach(element => {
702
+ element.setAttribute('data-xpath', window.getXPath(element));
703
+ });
704
+ }), 15000, 'stamping');
705
+ }
706
+ /*
707
+ Capture the page's script nonce (if its CSP uses one) so
708
+ script-injecting adapters can reuse it — mirroring the production
709
+ launch proc's jobData.lastScriptNonce.
710
+ */
711
+ const scriptNonce = await page.evaluate(() => {
712
+ const nonced = document.querySelector('script[nonce]');
713
+ return (nonced && nonced.nonce) || '';
714
+ }).catch(() => '');
715
+ let report;
716
+ let actReport;
717
+ if (engine === 'testaro') {
718
+ const {doJob} = require('../../run');
719
+ report = await withDeadline(
720
+ doJob(testaroJob(testcase.url, args.urls ? 540 : 120)),
721
+ args.urls ? TESTARO_URL_TIMEOUT_MS : TESTARO_TIMEOUT_MS,
722
+ 'testaro',
723
+ 'reporter timeout'
724
+ );
725
+ actReport = report.jobData && report.jobData.aborted
726
+ ? {data: {prevented: true, error: report.jobData.abortMessage || 'job aborted'}}
727
+ : report.acts.find(jobAct => jobAct.type === 'test')
728
+ || {data: {prevented: true, error: 'no test act in job report'}};
729
+ }
730
+ else {
731
+ report = {
732
+ standard: 'also',
733
+ jobData: scriptNonce ? {lastScriptNonce: scriptNonce} : {},
734
+ catalog: {},
735
+ target: {url: testcase.url},
736
+ acts: [{type: 'test', which: engine, withItems: true}]
737
+ };
738
+ actReport = await withDeadline(
739
+ require(`../../tests/${engine}`).reporter(page, report, 0, 40),
740
+ REPORTER_TIMEOUT_MS,
741
+ engine,
742
+ 'reporter timeout'
743
+ );
744
+ }
745
+ const {data, result} = actReport;
746
+ row.prevented = !! (data && data.prevented);
747
+ if (row.prevented) {
748
+ row.error = data.error;
749
+ }
750
+ else {
751
+ row.instanceCount = result.standardResult.instances.length;
752
+ row.outcomeTotals = result.standardResult.outcomeTotals;
753
+ row.ruleIDs = ruleIDCounts(result.standardResult);
754
+ row.ruleOutcomes = ruleOutcomeCounts(result.standardResult);
755
+ const unoutcomed = result.standardResult.instances
756
+ .filter(instance => ! OUTCOMES.includes(instance.outcome)).length;
757
+ if (unoutcomed) {
758
+ row.error = `${unoutcomed} instance(s) without a valid outcome`;
759
+ }
760
+ const extractor = criterionExtractors[engine];
761
+ if (extractor) {
762
+ Object.assign(row, extractor(result.nativeResult));
763
+ }
764
+ if (args.urls) {
765
+ // Per-instance element paths for element-level co-occurrence
766
+ // (Stage-3b mapping bootstrap). Capped: a pathological page must
767
+ // not blow up the output file.
768
+ row.instances = result.standardResult.instances
769
+ .slice(0, 2000)
770
+ .map(instance => ({
771
+ ruleID: instance.ruleID,
772
+ severity: instance.ordinalSeverity,
773
+ outcome: instance.outcome,
774
+ uncertainty: instance.uncertainty,
775
+ xPath: ((report.catalog || {})[instance.catalogIndex] || {}).pathID || ''
776
+ }));
777
+ row.instances.forEach(instance => {
778
+ if (instance.xPath && instance.outcome === 'failed') {
779
+ flaggedXPaths.add(instance.xPath);
780
+ }
781
+ });
782
+ }
783
+ }
784
+ }
785
+ catch(error) {
786
+ row.prevented = true;
787
+ row.error = error.message.slice(0, 200);
788
+ }
789
+ finally {
790
+ if (page) {
791
+ // close() can hang, not reject, after a meta-refresh navigation —
792
+ // race it and abandon the page if the deadline trips.
793
+ await withDeadline(page.close(), 5000, 'page.close').catch(error => {
794
+ closeHung = error.message.includes('deadline');
795
+ });
796
+ }
797
+ }
798
+ /*
799
+ Replace the browser on any sign of a wedged/dead browser, not just a
800
+ tripped deadline: a wedged browser's newPage first throws "Protocol
801
+ error (Target.createTarget)" and, once the process dies, "Target
802
+ page, context or browser has been closed" (observed 2026-08-22 after
803
+ a bc659a meta-refresh close-hang; a deadline-only trigger let 73
804
+ rows fail before the next scheduled recycle).
805
+ */
806
+ if (
807
+ ! stopSignal && (closeHung
808
+ || (row.error && /deadline|has been closed|Protocol error/.test(row.error))
809
+ /*
810
+ Canary: a script-tag-injecting tool reporting its global undefined
811
+ on an ordinary text/html page means browser-wide script-element
812
+ execution is wedged (the injection cliff — a probabilistic wedge
813
+ that regional replays never reproduce; it once killed 41
814
+ consecutive rows in a browser only ~36 pages old). XML pages are
815
+ excluded: script elements legitimately never execute there.
816
+ */
817
+ || (row.error && /global not defined \(contentType text\/html/.test(row.error)))
818
+ ) {
819
+ row.browserReplaced = true;
820
+ await replaceBrowser();
821
+ }
822
+ row.ms = Date.now() - started;
823
+ out.write(`${JSON.stringify(row)}\n`);
824
+ }
825
+ // A stop mid-testcase leaves its remaining engines (and snapshot) to a resumed run.
826
+ if (stopSignal) {
827
+ break;
828
+ }
829
+ // URL mode: snapshot the flagged elements once per page.
830
+ if (args.urls && flaggedXPaths.size && ! alreadyCaptured.has(`${testcase.ruleId || ''}/${testcase.testcaseId}:_snapshot`)) {
831
+ const snapshotRow = {testcaseId: testcase.testcaseId, engine: '_snapshot', prevented: false};
832
+ const started = Date.now();
833
+ let page;
834
+ try {
835
+ page = await withDeadline(context.newPage(), 20000, 'newPage');
836
+ await page.goto(testcase.url, {waitUntil: 'load', timeout: 30000});
837
+ const xPaths = [...flaggedXPaths].slice(0, SNAPSHOT_MAX_ELEMENTS);
838
+ snapshotRow.snapshots = await withDeadline(
839
+ page.evaluate(snapshotScript, [xPaths, SNAPSHOT_MAX_HTML, IDREF_ATTRIBUTES]), 30000, 'snapshot'
840
+ );
841
+ await withDeadline(addAriaSnapshots(page, snapshotRow.snapshots, xPaths), 120000, 'ariaSnapshot');
842
+ snapshotRow.profileVersion = SNAPSHOT_PROFILE_VERSION;
843
+ snapshotRow.flaggedCount = flaggedXPaths.size;
844
+ snapshotRow.capped = flaggedXPaths.size > SNAPSHOT_MAX_ELEMENTS;
845
+ }
846
+ catch(error) {
847
+ snapshotRow.prevented = true;
848
+ snapshotRow.error = error.message.slice(0, 200);
849
+ }
850
+ finally {
851
+ if (page) {
852
+ await withDeadline(page.close(), 5000, 'page.close').catch(() => {});
853
+ }
854
+ }
855
+ snapshotRow.ms = Date.now() - started;
856
+ out.write(`${JSON.stringify(snapshotRow)}\n`);
857
+ }
858
+ done++;
859
+ if (done % 25 === 0) {
860
+ console.log(`${done}/${testcases.length}`);
861
+ }
862
+ }
863
+ await withDeadline(browser.close(), 10000, 'browser.close').catch(() => {});
864
+ await new Promise(resolve => out.end(resolve));
865
+ if (stopSignal) {
866
+ console.log(`Stopped (${stopSignal}) after ${done} testcases → ${outPath}; rerun the same command to resume`);
867
+ }
868
+ else {
869
+ console.log(`Done: ${done} testcases → ${outPath}`);
870
+ }
871
+ /*
872
+ Exit explicitly: code under test (Testaro's own jobs, engine adapters) can
873
+ leave handles that would otherwise hold the process open after the output
874
+ is complete.
875
+ */
876
+ process.exit(stopSignal ? (stopSignal === 'SIGINT' ? 130 : 143) : 0);
877
+ })();