testaro 78.0.8 → 78.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (356) hide show
  1. package/.gitattributes +94 -0
  2. package/.github/workflows/ci.yml +58 -0
  3. package/.github/workflows/publish.yml +49 -0
  4. package/.github/workflows/typescript.yml +35 -0
  5. package/AGENTS.md +2 -2
  6. package/CLAUDE.md +24 -18
  7. package/CONTAINERS.md +9 -6
  8. package/CONTRIBUTING.md +29 -3
  9. package/Dockerfile +1 -1
  10. package/README.md +73 -19
  11. package/UPGRADES.md +4 -0
  12. package/actSpecs-doc.md +13 -1
  13. package/actSpecs.js +29 -5
  14. package/call.js +6 -5
  15. package/docker-compose.yml +8 -2
  16. package/docs/checkpoint-scanning.md +199 -0
  17. package/docs/standard-result-outcome.md +165 -0
  18. package/env.example +44 -10
  19. package/eslint.config.mjs +101 -0
  20. package/netWatch.js +372 -222
  21. package/package.json +46 -31
  22. package/pour/README.md +47 -0
  23. package/pour/pour.min.js +8 -0
  24. package/procs/actDo.js +808 -0
  25. package/procs/catalog.d.ts +17 -0
  26. package/procs/catalog.js +312 -221
  27. package/procs/catalog.ts +398 -0
  28. package/procs/checkpoint.js +110 -0
  29. package/procs/config.d.ts +35 -0
  30. package/procs/config.js +62 -0
  31. package/procs/dateTime.js +2 -1
  32. package/procs/doActs.js +442 -849
  33. package/procs/doTestAct.js +27 -121
  34. package/procs/flow.js +221 -0
  35. package/procs/generateRuleRegistry.js +94 -0
  36. package/procs/getSource.d.ts +26 -0
  37. package/procs/job.js +115 -1
  38. package/procs/launch.d.ts +40 -0
  39. package/procs/launch.js +239 -108
  40. package/procs/nu.d.ts +48 -0
  41. package/procs/scope.js +196 -0
  42. package/procs/shoot.d.ts +33 -0
  43. package/procs/standard.d.ts +18 -0
  44. package/procs/standard.js +83 -0
  45. package/procs/standard.ts +126 -0
  46. package/procs/testAct.js +145 -0
  47. package/procs/testaro.d.ts +20 -0
  48. package/procs/testaro.js +236 -189
  49. package/procs/testaro.ts +321 -0
  50. package/procs/userPath.js +126 -0
  51. package/procs/xPath.d.ts +4 -0
  52. package/procs/xPath.js +81 -69
  53. package/procs/xPath.ts +106 -0
  54. package/procs/xPathScript.d.ts +6 -0
  55. package/procs/xPathScript.js +70 -0
  56. package/run.js +3 -2
  57. package/surea11y/README.md +44 -0
  58. package/surea11y/surea11y.browser.js +14 -0
  59. package/testaro/adbID.d.ts +3 -0
  60. package/testaro/adbID.js +38 -41
  61. package/testaro/adbID.ts +66 -0
  62. package/testaro/allCapStyle.d.ts +3 -0
  63. package/testaro/allCapStyle.js +31 -34
  64. package/testaro/allCapStyle.ts +55 -0
  65. package/testaro/allCaps.d.ts +16 -0
  66. package/testaro/allCaps.js +179 -151
  67. package/testaro/allCaps.ts +228 -0
  68. package/testaro/allHidden.d.ts +13 -0
  69. package/testaro/allHidden.js +29 -30
  70. package/testaro/allHidden.ts +50 -0
  71. package/testaro/allSlanted.d.ts +3 -0
  72. package/testaro/allSlanted.js +30 -33
  73. package/testaro/allSlanted.ts +54 -0
  74. package/testaro/altScheme.d.ts +3 -0
  75. package/testaro/altScheme.js +27 -30
  76. package/testaro/altScheme.ts +50 -0
  77. package/testaro/attVal.d.ts +3 -0
  78. package/testaro/attVal.js +20 -21
  79. package/testaro/attVal.ts +52 -0
  80. package/testaro/autocomplete.d.ts +3 -0
  81. package/testaro/autocomplete.js +59 -75
  82. package/testaro/autocomplete.ts +101 -0
  83. package/testaro/bulk.d.ts +13 -0
  84. package/testaro/bulk.js +32 -33
  85. package/testaro/bulk.ts +55 -0
  86. package/testaro/buttonMenu.d.ts +9 -0
  87. package/testaro/buttonMenu.js +317 -319
  88. package/testaro/buttonMenu.ts +391 -0
  89. package/testaro/captionLoc.d.ts +3 -0
  90. package/testaro/captionLoc.js +17 -20
  91. package/testaro/captionLoc.ts +40 -0
  92. package/testaro/datalistRef.d.ts +3 -0
  93. package/testaro/datalistRef.js +33 -36
  94. package/testaro/datalistRef.ts +55 -0
  95. package/testaro/distortion.d.ts +3 -0
  96. package/testaro/distortion.js +57 -26
  97. package/testaro/distortion.ts +81 -0
  98. package/testaro/docType.d.ts +15 -0
  99. package/testaro/docType.js +25 -25
  100. package/testaro/docType.ts +45 -0
  101. package/testaro/dupAtt.d.ts +16 -0
  102. package/testaro/dupAtt.js +113 -104
  103. package/testaro/dupAtt.ts +144 -0
  104. package/testaro/elements.d.ts +6 -0
  105. package/testaro/elements.js +153 -153
  106. package/testaro/elements.ts +215 -0
  107. package/testaro/embAc.d.ts +3 -0
  108. package/testaro/embAc.js +19 -20
  109. package/testaro/embAc.ts +40 -0
  110. package/testaro/focAll.d.ts +13 -0
  111. package/testaro/focAll.js +185 -191
  112. package/testaro/focAll.ts +217 -0
  113. package/testaro/focAndOp.d.ts +3 -0
  114. package/testaro/focAndOp.js +100 -104
  115. package/testaro/focAndOp.ts +128 -0
  116. package/testaro/focInd.d.ts +3 -0
  117. package/testaro/focInd.js +64 -66
  118. package/testaro/focInd.ts +96 -0
  119. package/testaro/focVis.d.ts +3 -0
  120. package/testaro/focVis.js +28 -30
  121. package/testaro/focVis.ts +52 -0
  122. package/testaro/headEl.d.ts +10 -0
  123. package/testaro/headEl.js +61 -62
  124. package/testaro/headEl.ts +82 -0
  125. package/testaro/headingAmb.d.ts +3 -0
  126. package/testaro/headingAmb.js +54 -63
  127. package/testaro/headingAmb.ts +72 -0
  128. package/testaro/hovInd.d.ts +12 -0
  129. package/testaro/hovInd.js +163 -130
  130. package/testaro/hovInd.ts +199 -0
  131. package/testaro/hover.d.ts +3 -0
  132. package/testaro/hover.js +155 -126
  133. package/testaro/hover.ts +154 -0
  134. package/testaro/hr.d.ts +3 -0
  135. package/testaro/hr.js +14 -17
  136. package/testaro/hr.ts +36 -0
  137. package/testaro/imageLink.d.ts +3 -0
  138. package/testaro/imageLink.js +17 -20
  139. package/testaro/imageLink.ts +42 -0
  140. package/testaro/labClash.d.ts +3 -0
  141. package/testaro/labClash.js +31 -33
  142. package/testaro/labClash.ts +54 -0
  143. package/testaro/legendLoc.d.ts +3 -0
  144. package/testaro/legendLoc.js +17 -20
  145. package/testaro/legendLoc.ts +42 -0
  146. package/testaro/lineHeight.d.ts +3 -0
  147. package/testaro/lineHeight.js +43 -48
  148. package/testaro/lineHeight.ts +70 -0
  149. package/testaro/linkAmb.d.ts +7 -0
  150. package/testaro/linkAmb.js +79 -80
  151. package/testaro/linkAmb.ts +105 -0
  152. package/testaro/linkExt.d.ts +3 -0
  153. package/testaro/linkExt.js +13 -16
  154. package/testaro/linkExt.ts +35 -0
  155. package/testaro/linkOldAtt.d.ts +3 -0
  156. package/testaro/linkOldAtt.js +25 -28
  157. package/testaro/linkOldAtt.ts +48 -0
  158. package/testaro/linkTo.d.ts +3 -0
  159. package/testaro/linkTo.js +22 -22
  160. package/testaro/linkTo.ts +43 -0
  161. package/testaro/linkUl.d.ts +3 -0
  162. package/testaro/linkUl.js +30 -34
  163. package/testaro/linkUl.ts +54 -0
  164. package/testaro/miniText.d.ts +3 -0
  165. package/testaro/miniText.js +41 -44
  166. package/testaro/miniText.ts +68 -0
  167. package/testaro/motion.d.ts +10 -0
  168. package/testaro/motion.js +92 -96
  169. package/testaro/motion.ts +125 -0
  170. package/testaro/nonTable.d.ts +3 -0
  171. package/testaro/nonTable.js +39 -45
  172. package/testaro/nonTable.ts +66 -0
  173. package/testaro/optRoleSel.d.ts +3 -0
  174. package/testaro/optRoleSel.js +16 -19
  175. package/testaro/optRoleSel.ts +41 -0
  176. package/testaro/phOnly.d.ts +3 -0
  177. package/testaro/phOnly.js +18 -21
  178. package/testaro/phOnly.ts +43 -0
  179. package/testaro/pseudoP.d.ts +3 -0
  180. package/testaro/pseudoP.js +36 -38
  181. package/testaro/pseudoP.ts +59 -0
  182. package/testaro/radioSet.d.ts +3 -0
  183. package/testaro/radioSet.js +57 -59
  184. package/testaro/radioSet.ts +79 -0
  185. package/testaro/registry.d.ts +63 -0
  186. package/testaro/registry.js +67 -0
  187. package/testaro/registry.ts +141 -0
  188. package/testaro/role.d.ts +3 -0
  189. package/testaro/role.js +27 -29
  190. package/testaro/role.ts +53 -0
  191. package/testaro/secHeading.d.ts +3 -0
  192. package/testaro/secHeading.js +30 -33
  193. package/testaro/secHeading.ts +53 -0
  194. package/testaro/styleDiff.d.ts +25 -0
  195. package/testaro/styleDiff.js +248 -252
  196. package/testaro/styleDiff.ts +303 -0
  197. package/testaro/tabNav.d.ts +33 -0
  198. package/testaro/tabNav.js +272 -342
  199. package/testaro/tabNav.ts +454 -0
  200. package/testaro/targetsNear.d.ts +9 -0
  201. package/testaro/targetsNear.js +130 -132
  202. package/testaro/targetsNear.ts +160 -0
  203. package/testaro/textNodes.d.ts +6 -0
  204. package/testaro/textNodes.js +139 -135
  205. package/testaro/textNodes.ts +185 -0
  206. package/testaro/textSem.d.ts +3 -0
  207. package/testaro/textSem.js +25 -28
  208. package/testaro/textSem.ts +47 -0
  209. package/testaro/title.d.ts +9 -0
  210. package/testaro/title.js +16 -13
  211. package/testaro/title.ts +31 -0
  212. package/testaro/titledEl.d.ts +3 -0
  213. package/testaro/titledEl.js +15 -18
  214. package/testaro/titledEl.ts +38 -0
  215. package/testaro/zIndex.d.ts +3 -0
  216. package/testaro/zIndex.js +19 -22
  217. package/testaro/zIndex.ts +42 -0
  218. package/tests/alfa.d.ts +45 -0
  219. package/tests/alfa.js +138 -141
  220. package/tests/alfa.ts +214 -0
  221. package/tests/aslint.d.ts +33 -0
  222. package/tests/aslint.js +272 -249
  223. package/tests/aslint.ts +301 -0
  224. package/tests/axe.d.ts +27 -0
  225. package/tests/axe.js +199 -200
  226. package/tests/axe.ts +277 -0
  227. package/tests/ed11y.d.ts +28 -0
  228. package/tests/ed11y.js +141 -99
  229. package/tests/ed11y.ts +178 -0
  230. package/tests/htmlcs.d.ts +21 -0
  231. package/tests/htmlcs.js +174 -140
  232. package/tests/htmlcs.ts +181 -0
  233. package/tests/ibm.d.ts +52 -0
  234. package/tests/ibm.js +164 -166
  235. package/tests/ibm.ts +251 -0
  236. package/tests/nuVal.d.ts +13 -0
  237. package/tests/nuVal.js +107 -112
  238. package/tests/nuVal.ts +145 -0
  239. package/tests/nuVnu.d.ts +15 -0
  240. package/tests/nuVnu.js +141 -111
  241. package/tests/nuVnu.ts +144 -0
  242. package/tests/pour.d.ts +31 -0
  243. package/tests/pour.js +242 -0
  244. package/tests/pour.ts +273 -0
  245. package/tests/qualWeb.d.ts +39 -0
  246. package/tests/qualWeb.js +302 -272
  247. package/tests/qualWeb.ts +415 -0
  248. package/tests/surea11y.d.ts +33 -0
  249. package/tests/surea11y.js +288 -0
  250. package/tests/surea11y.ts +334 -0
  251. package/tests/testaro.d.ts +25 -0
  252. package/tests/testaro.js +745 -652
  253. package/tests/testaro.ts +862 -0
  254. package/tests/wave.d.ts +46 -0
  255. package/tests/wave.js +166 -177
  256. package/tests/wave.ts +252 -0
  257. package/tsconfig.json +17 -0
  258. package/types.d.ts +243 -0
  259. package/types.js +10 -0
  260. package/types.ts +376 -0
  261. package/validation/act/README.md +46 -0
  262. package/validation/act/capture.js +424 -0
  263. package/validation/act/chromium-issue-draft.md +66 -0
  264. package/validation/act/fp-triage-2026-08-22.md +86 -0
  265. package/validation/act/isolation-notes.md +159 -0
  266. package/validation/act/playwright-issue-draft.md +73 -0
  267. package/validation/act/propose-mappings.js +0 -0
  268. package/validation/act/repro-cdp-raw.js +105 -0
  269. package/validation/act/repro-metarefresh.js +65 -0
  270. package/validation/act/score.js +184 -0
  271. package/validation/act/stage3a-stress-report.md +69 -0
  272. package/validation/act/stage3b-mapping-proposals.md +94 -0
  273. package/validation/act/stage3b-triage-draft.md +138 -0
  274. package/validation/act/surea11y-track-a-2026-09-01.md +50 -0
  275. package/validation/executors/netWatch.js +180 -90
  276. package/validation/executors/test.js +17 -2
  277. package/validation/executors/tests.js +118 -10
  278. package/validation/jobs/reports/raw/260901T1000-surea11y-validation.json +964 -0
  279. package/validation/jobs/todo/240101T1200-simple-example.json +14 -6
  280. package/validation/jobs/todo/240101T1300-shoot-example.json +2 -1
  281. package/validation/jobs/todo/260821T1900-pour-validation.json +45 -0
  282. package/validation/jobs/todo/260901T1000-surea11y-validation.json +45 -0
  283. package/validation/knownFailures.json +1 -0
  284. package/validation/tests/jobProperties/adbID.json +27 -2
  285. package/validation/tests/jobProperties/{focOp.json → allCapStyle.json} +56 -53
  286. package/validation/tests/jobProperties/allCaps.json +25 -0
  287. package/validation/tests/jobProperties/allHidden.json +133 -13
  288. package/validation/tests/jobProperties/allSlanted.json +3 -3
  289. package/validation/tests/jobProperties/altScheme.json +23 -8
  290. package/validation/tests/jobProperties/attVal.json +57 -57
  291. package/validation/tests/jobProperties/autocomplete.json +11 -1
  292. package/validation/tests/jobProperties/bulk.json +6 -1
  293. package/validation/tests/jobProperties/buttonMenu.json +63 -42
  294. package/validation/tests/jobProperties/captionLoc.json +1 -6
  295. package/validation/tests/jobProperties/checkpoint-browser.json +401 -0
  296. package/validation/tests/jobProperties/checkpoint-page.json +397 -0
  297. package/validation/tests/jobProperties/checkpoint.json +395 -0
  298. package/validation/tests/jobProperties/datalistRef.json +20 -5
  299. package/validation/tests/jobProperties/distortion.json +28 -3
  300. package/validation/tests/jobProperties/docType.json +2 -2
  301. package/validation/tests/jobProperties/dupAtt.json +48 -33
  302. package/validation/tests/jobProperties/elements.json +28 -28
  303. package/validation/tests/jobProperties/embAc.json +36 -31
  304. package/validation/tests/jobProperties/focAndOp.json +284 -0
  305. package/validation/tests/jobProperties/focInd.json +34 -34
  306. package/validation/tests/jobProperties/focVis.json +1 -1
  307. package/validation/tests/jobProperties/hover.json +39 -37
  308. package/validation/tests/jobProperties/hr.json +13 -3
  309. package/validation/tests/jobProperties/imageLink.json +0 -5
  310. package/validation/tests/jobProperties/labClash.json +68 -33
  311. package/validation/tests/jobProperties/legendLoc.json +1 -6
  312. package/validation/tests/jobProperties/lineHeight.json +2 -2
  313. package/validation/tests/jobProperties/linkAmb.json +13 -18
  314. package/validation/tests/jobProperties/linkExt.json +1 -1
  315. package/validation/tests/jobProperties/linkOldAtt.json +12 -2
  316. package/validation/tests/jobProperties/linkTo.json +1 -1
  317. package/validation/tests/jobProperties/linkUl.json +65 -65
  318. package/validation/tests/jobProperties/miniText.json +7 -2
  319. package/validation/tests/jobProperties/motion.json +4 -50
  320. package/validation/tests/jobProperties/nonTable.json +48 -3
  321. package/validation/tests/jobProperties/optRoleSel.json +12 -2
  322. package/validation/tests/jobProperties/phOnly.json +6 -16
  323. package/validation/tests/jobProperties/pseudoP.json +17 -2
  324. package/validation/tests/jobProperties/radioSet.json +30 -30
  325. package/validation/tests/jobProperties/role.json +25 -5
  326. package/validation/tests/jobProperties/secHeading.json +26 -21
  327. package/validation/tests/jobProperties/styleDiff.json +35 -5
  328. package/validation/tests/jobProperties/tabNav.json +3 -1
  329. package/validation/tests/jobProperties/targetsNear.json +237 -0
  330. package/validation/tests/jobProperties/textNodes.json +29 -29
  331. package/validation/tests/jobProperties/textSem.json +26 -1
  332. package/validation/tests/jobProperties/title.json +12 -2
  333. package/validation/tests/jobProperties/titledEl.json +39 -9
  334. package/validation/tests/jobProperties/userPath.json +317 -0
  335. package/validation/tests/jobProperties/zIndex.json +40 -35
  336. package/validation/tests/targets/allCapStyle/index.html +26 -0
  337. package/validation/tests/targets/checkpoint/index.html +38 -0
  338. package/validation/tests/targets/datalistRef/index.html +1 -1
  339. package/validation/tests/targets/focAndOp/bad.html +29 -0
  340. package/validation/tests/targets/{focOp → focAndOp}/good.html +3 -1
  341. package/validation/tests/targets/focInd/bad.html +2 -1
  342. package/validation/tests/targets/headEl/index.html +10 -1
  343. package/validation/tests/targets/{targetSmall → targetsNear}/index.html +15 -1
  344. package/validation/tests/targets/userPath/index.html +42 -0
  345. package/validation/validateTest.js +61 -9
  346. package/.claude/settings.local.json +0 -11
  347. package/.eslintrc.json +0 -41
  348. package/htmlcs/.eslintrc.json +0 -67
  349. package/validation/tests/jobProperties/linkTitle.json +0 -127
  350. package/validation/tests/jobProperties/opFoc.json +0 -164
  351. package/validation/tests/jobProperties/targetSmall.json +0 -152
  352. package/validation/tests/jobProperties/targetTiny.json +0 -142
  353. package/validation/tests/targets/focOp/bad.html +0 -25
  354. package/validation/tests/targets/linkTitle/index.html +0 -24
  355. package/validation/tests/targets/opFoc/bad.html +0 -26
  356. package/validation/tests/targets/opFoc/good.html +0 -23
@@ -0,0 +1,424 @@
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
+ Each output line: {testcaseId, ruleId, expected, engine, prevented,
27
+ instanceCount, outcomeTotals: {failed, cantTell}, asserted: {SC: count},
28
+ review: {SC: count}, ruleIDs: {engineRuleID: count}, ms}
29
+ `asserted` counts definite failures (standard-instance outcome `failed`);
30
+ `review` counts engine-flagged uncertainty (outcome `cantTell`). Criteria
31
+ are dotted WCAG SC numbers, extracted per engine from native results;
32
+ engines without an extractor still record ruleIDs, instanceCount, and
33
+ outcomeTotals. Every standard instance must carry a valid outcome; a row
34
+ whose instances do not is marked as an error (adapter drift).
35
+ */
36
+
37
+ // IMPORTS
38
+
39
+ const fs = require('fs');
40
+ const fsp = require('fs/promises');
41
+ const path = require('path');
42
+ const {chromium} = require('playwright');
43
+ const {OUTCOMES} = require('../../procs/standard');
44
+
45
+ // CONSTANTS
46
+
47
+ const TESTCASES_URL = 'https://www.w3.org/WAI/content-assets/wcag-act-rules/testcases.json';
48
+ const CACHE_DIR = path.join(__dirname, 'cache');
49
+ const RESULTS_DIR = path.join(__dirname, 'results');
50
+ // Per-reporter time limit, matching the production limits in procs/doActs.js.
51
+ const REPORTER_TIMEOUT_MS = 45000;
52
+
53
+ // Mirrors procs/doTestAct.js xPathNeeds for the engines this harness supports.
54
+ const XPATH_NEEDS = {
55
+ alfa: 'own',
56
+ aslint: 'own',
57
+ axe: 'attribute',
58
+ ed11y: 'script',
59
+ htmlcs: 'attribute',
60
+ ibm: 'attribute',
61
+ pour: 'script',
62
+ qualWeb: 'attribute',
63
+ surea11y: 'script'
64
+ };
65
+
66
+ // Converts a wcag### tag (axe/pour style) to a dotted criterion.
67
+ const tagToCriterion = tag => {
68
+ const digits = tag.slice(4);
69
+ return `${digits[0]}.${digits[1]}.${digits.slice(2)}`;
70
+ };
71
+
72
+ /*
73
+ Per-engine criterion extractors: nativeResult → {asserted, review}, each
74
+ {dottedSC: count}. Engines without one record ruleIDs only; add extractors
75
+ here as engines join Track A.
76
+ */
77
+ const criterionExtractors = {
78
+ // Shared by pour and surea11y: both adapters flatten to violations/incomplete
79
+ // arrays of findings carrying a dotted `wcag` criterion.
80
+ pour: nativeResult => {
81
+ const buckets = {asserted: {}, review: {}};
82
+ [['violations', 'asserted'], ['incomplete', 'review']].forEach(([source, target]) => {
83
+ (nativeResult[source] || []).forEach(finding => {
84
+ if (finding.wcag) {
85
+ buckets[target][finding.wcag] = (buckets[target][finding.wcag] || 0) + 1;
86
+ }
87
+ });
88
+ });
89
+ return buckets;
90
+ },
91
+ get surea11y() {
92
+ return this.pour;
93
+ },
94
+ axe: nativeResult => {
95
+ const buckets = {asserted: {}, review: {}};
96
+ const details = nativeResult && nativeResult.details;
97
+ [['violations', 'asserted'], ['incomplete', 'review']].forEach(([source, target]) => {
98
+ ((details && details[source]) || []).forEach(rule => {
99
+ const criteria = (rule.tags || [])
100
+ .filter(tag => /^wcag\d{3,4}$/.test(tag))
101
+ .map(tagToCriterion);
102
+ rule.nodes.forEach(() => {
103
+ criteria.forEach(criterion => {
104
+ buckets[target][criterion] = (buckets[target][criterion] || 0) + 1;
105
+ });
106
+ });
107
+ });
108
+ });
109
+ return buckets;
110
+ },
111
+ /*
112
+ QualWeb runs the ACT rules natively: each act-rules assertion carries the
113
+ exact ACT rule ID in `mapping`, so this extractor also emits actAsserted /
114
+ actReview maps keyed by ACT rule ID — score.js prefers those over the
115
+ criterion layer when present.
116
+ */
117
+ qualWeb: nativeResult => {
118
+ const buckets = {asserted: {}, review: {}, actAsserted: {}, actReview: {}};
119
+ const modules = (nativeResult && nativeResult.modules) || {};
120
+ ['act-rules', 'wcag-techniques', 'best-practices'].forEach(section => {
121
+ const assertions = (modules[section] && modules[section].assertions) || {};
122
+ Object.values(assertions).forEach(assertion => {
123
+ const criteria = ((assertion.metadata && assertion.metadata['success-criteria']) || [])
124
+ .map(criterion => criterion.name);
125
+ const actID = section === 'act-rules' ? assertion.mapping : null;
126
+ (assertion.results || []).forEach(assertionResult => {
127
+ const target = assertionResult.verdict === 'failed' ? 'asserted'
128
+ : assertionResult.verdict === 'warning' ? 'review' : null;
129
+ if (! target) {
130
+ return;
131
+ }
132
+ const count = (assertionResult.elements || []).length || 1;
133
+ criteria.forEach(criterion => {
134
+ buckets[target][criterion] = (buckets[target][criterion] || 0) + count;
135
+ });
136
+ if (actID) {
137
+ const actTarget = target === 'asserted' ? 'actAsserted' : 'actReview';
138
+ buckets[actTarget][actID] = (buckets[actTarget][actID] || 0) + count;
139
+ }
140
+ });
141
+ });
142
+ });
143
+ return buckets;
144
+ }
145
+ };
146
+
147
+ // Per-engine rule-ID counters from the standard result.
148
+ const ruleIDCounts = standardResult => {
149
+ const counts = {};
150
+ ((standardResult && standardResult.instances) || []).forEach(instance => {
151
+ counts[instance.ruleID] = (counts[instance.ruleID] || 0) + instance.count;
152
+ });
153
+ return counts;
154
+ };
155
+
156
+ // FUNCTIONS
157
+
158
+ // Parses CLI arguments of the form --name value.
159
+ const parseArgs = argv => {
160
+ const args = {};
161
+ for (let i = 2; i < argv.length; i += 2) {
162
+ args[argv[i].replace(/^--/, '')] = argv[i + 1];
163
+ }
164
+ return args;
165
+ };
166
+
167
+ // Gets the testcase feed, cached on disk.
168
+ const getTestcases = async () => {
169
+ await fsp.mkdir(CACHE_DIR, {recursive: true});
170
+ const cachePath = path.join(CACHE_DIR, 'testcases.json');
171
+ if (! fs.existsSync(cachePath)) {
172
+ const response = await fetch(TESTCASES_URL);
173
+ await fsp.writeFile(cachePath, await response.text());
174
+ }
175
+ return JSON.parse(await fsp.readFile(cachePath, 'utf8')).testcases;
176
+ };
177
+
178
+ // The window.getXPath injection, as in procs/launch.js.
179
+ const getXPathScript = () => {
180
+ window.getXPath = element => {
181
+ if (! element || element.nodeType !== Node.ELEMENT_NODE) {
182
+ return '';
183
+ }
184
+ const segments = [];
185
+ while (element && element.nodeType === Node.ELEMENT_NODE) {
186
+ const tag = element.tagName.toLowerCase();
187
+ if (element === document.documentElement) {
188
+ segments.unshift('html');
189
+ break;
190
+ }
191
+ const parent = element.parentNode;
192
+ if (! parent || parent.nodeType !== Node.ELEMENT_NODE) {
193
+ segments.unshift(tag);
194
+ break;
195
+ }
196
+ const cohort = Array.from(parent.childNodes).filter(
197
+ childNode => childNode.nodeType === Node.ELEMENT_NODE
198
+ && childNode.tagName === element.tagName
199
+ );
200
+ const subscript = tag === 'body' ? '' : `[${cohort.indexOf(element) + 1}]`;
201
+ segments.unshift(`${tag}${subscript}`);
202
+ element = parent;
203
+ }
204
+ return `/${segments.join('/')}`;
205
+ };
206
+ };
207
+
208
+ // OPERATION
209
+
210
+ (async () => {
211
+ const args = parseArgs(process.argv);
212
+ const engines = (args.engines || 'pour').split(',');
213
+ const outPath = args.out
214
+ || path.join(RESULTS_DIR, `act-${new Date().toISOString().replace(/[:.]/g, '-')}.jsonl`);
215
+ await fsp.mkdir(path.dirname(outPath), {recursive: true});
216
+ let testcases;
217
+ if (args.urls) {
218
+ // URL-list mode: one synthetic testcase per line; the URL is the ID.
219
+ testcases = (await fsp.readFile(args.urls, 'utf8'))
220
+ .split('\n')
221
+ .map(line => line.trim())
222
+ .filter(line => line && ! line.startsWith('#'))
223
+ .map(url => ({testcaseId: url, url}));
224
+ }
225
+ else {
226
+ testcases = await getTestcases();
227
+ }
228
+ if (args.rules) {
229
+ const wanted = new Set(args.rules.split(','));
230
+ testcases = testcases.filter(testcase => wanted.has(testcase.ruleId));
231
+ }
232
+ if (args.match) {
233
+ const matcher = new RegExp(args.match, 'i');
234
+ testcases = testcases.filter(testcase => matcher.test(testcase.ruleName));
235
+ }
236
+ if (args.max) {
237
+ testcases = testcases.slice(0, Number(args.max));
238
+ }
239
+ // Resume support: skip (testcase, engine) pairs already in the out file.
240
+ const alreadyCaptured = new Set();
241
+ if (fs.existsSync(outPath)) {
242
+ fs.readFileSync(outPath, 'utf8').split('\n').filter(Boolean).forEach(line => {
243
+ try {
244
+ const row = JSON.parse(line);
245
+ alreadyCaptured.add(`${row.testcaseId}:${row.engine}`);
246
+ }
247
+ catch(error) {}
248
+ });
249
+ }
250
+ console.log(
251
+ `Capturing ${testcases.length} testcases × ${engines.join('+')} → ${outPath}`
252
+ + (alreadyCaptured.size ? ` (resuming; ${alreadyCaptured.size} rows already captured)` : '')
253
+ );
254
+ const out = fs.createWriteStream(outPath, {flags: 'a'});
255
+ /*
256
+ The browser is recycled every RECYCLE_EVERY testcases: a 2026-08-22 full
257
+ run showed script-tag injection silently failing (tool global never
258
+ defined) after ~270 testcases in one long-lived browser, while
259
+ evaluate-based injection kept working. Fresh processes reproduce none of
260
+ it, so periodic recycling is the guard.
261
+ */
262
+ const RECYCLE_EVERY = args.recycle === undefined ? 100 : Number(args.recycle);
263
+ let browser = await chromium.launch();
264
+ let context = await browser.newContext();
265
+ let done = 0;
266
+ /*
267
+ Hang guards. page.close() can hang FOREVER (not reject — hang) on a page
268
+ that performed a meta-refresh(0) navigation (reproduced locally,
269
+ playwright 1.62.1, ~50% of rounds on such pages; see isolation-notes.md).
270
+ So: every await that touches the browser is raced against a deadline, and
271
+ if the per-testcase watchdog trips, the browser is presumed wedged and
272
+ replaced (the old one is closed with its own deadline and abandoned if
273
+ that hangs too).
274
+ */
275
+ const withDeadline = (promise, ms, label) => Promise.race([
276
+ promise,
277
+ new Promise((resolve, reject) => setTimeout(
278
+ () => reject(new Error(`${label} deadline ${ms}ms`)), ms
279
+ ))
280
+ ]);
281
+ const replaceBrowser = async () => {
282
+ const oldBrowser = browser;
283
+ withDeadline(oldBrowser.close(), 10000, 'browser.close').catch(() => {});
284
+ browser = await chromium.launch();
285
+ context = await browser.newContext();
286
+ };
287
+ for (const testcase of testcases) {
288
+ if (done > 0 && RECYCLE_EVERY && done % RECYCLE_EVERY === 0) {
289
+ await replaceBrowser();
290
+ }
291
+ for (const engine of engines) {
292
+ if (alreadyCaptured.has(`${testcase.testcaseId}:${engine}`)) {
293
+ continue;
294
+ }
295
+ const row = {
296
+ testcaseId: testcase.testcaseId,
297
+ ruleId: testcase.ruleId,
298
+ ruleName: testcase.ruleName,
299
+ expected: testcase.expected,
300
+ engine
301
+ };
302
+ const started = Date.now();
303
+ let page;
304
+ let closeHung = false;
305
+ try {
306
+ page = await withDeadline(context.newPage(), 20000, 'newPage');
307
+ const xPathNeed = XPATH_NEEDS[engine] || 'none';
308
+ if (xPathNeed === 'script' || xPathNeed === 'attribute') {
309
+ await page.addInitScript(getXPathScript);
310
+ }
311
+ await page.goto(testcase.url, {waitUntil: 'load', timeout: args.urls ? 30000 : 20000});
312
+ if (xPathNeed === 'attribute') {
313
+ // Stamp data-xpath attributes, as procs/launch.js does.
314
+ await withDeadline(page.evaluate(() => {
315
+ document.querySelectorAll('*').forEach(element => {
316
+ element.setAttribute('data-xpath', window.getXPath(element));
317
+ });
318
+ }), 15000, 'stamping');
319
+ }
320
+ /*
321
+ Capture the page's script nonce (if its CSP uses one) so
322
+ script-injecting adapters can reuse it — mirroring the production
323
+ launch proc's jobData.lastScriptNonce.
324
+ */
325
+ const scriptNonce = await page.evaluate(() => {
326
+ const nonced = document.querySelector('script[nonce]');
327
+ return (nonced && nonced.nonce) || '';
328
+ }).catch(() => '');
329
+ const report = {
330
+ standard: 'also',
331
+ jobData: scriptNonce ? {lastScriptNonce: scriptNonce} : {},
332
+ catalog: {},
333
+ target: {url: testcase.url},
334
+ acts: [{type: 'test', which: engine, withItems: true}]
335
+ };
336
+ const actReport = await Promise.race([
337
+ require(`../../tests/${engine}`).reporter(page, report, 0, 40),
338
+ new Promise((resolve, reject) => setTimeout(
339
+ () => reject(new Error('reporter timeout')), REPORTER_TIMEOUT_MS
340
+ ))
341
+ ]);
342
+ const {data, result} = actReport;
343
+ row.prevented = !! (data && data.prevented);
344
+ if (row.prevented) {
345
+ row.error = data.error;
346
+ }
347
+ else {
348
+ row.instanceCount = result.standardResult.instances.length;
349
+ row.outcomeTotals = result.standardResult.outcomeTotals;
350
+ row.ruleIDs = ruleIDCounts(result.standardResult);
351
+ const unoutcomed = result.standardResult.instances
352
+ .filter(instance => ! OUTCOMES.includes(instance.outcome)).length;
353
+ if (unoutcomed) {
354
+ row.error = `${unoutcomed} instance(s) without a valid outcome`;
355
+ }
356
+ const extractor = criterionExtractors[engine];
357
+ if (extractor) {
358
+ Object.assign(row, extractor(result.nativeResult));
359
+ }
360
+ if (args.urls) {
361
+ // Per-instance element paths for element-level co-occurrence
362
+ // (Stage-3b mapping bootstrap). Capped: a pathological page must
363
+ // not blow up the output file.
364
+ row.instances = result.standardResult.instances
365
+ .slice(0, 2000)
366
+ .map(instance => ({
367
+ ruleID: instance.ruleID,
368
+ severity: instance.ordinalSeverity,
369
+ outcome: instance.outcome,
370
+ uncertainty: instance.uncertainty,
371
+ xPath: (report.catalog[instance.catalogIndex] || {}).pathID || ''
372
+ }));
373
+ }
374
+ }
375
+ }
376
+ catch(error) {
377
+ row.prevented = true;
378
+ row.error = error.message.slice(0, 200);
379
+ }
380
+ finally {
381
+ if (page) {
382
+ // close() can hang, not reject, after a meta-refresh navigation —
383
+ // race it and abandon the page if the deadline trips.
384
+ await withDeadline(page.close(), 5000, 'page.close').catch(error => {
385
+ closeHung = error.message.includes('deadline');
386
+ });
387
+ }
388
+ }
389
+ /*
390
+ Replace the browser on any sign of a wedged/dead browser, not just a
391
+ tripped deadline: a wedged browser's newPage first throws "Protocol
392
+ error (Target.createTarget)" and, once the process dies, "Target
393
+ page, context or browser has been closed" (observed 2026-08-22 after
394
+ a bc659a meta-refresh close-hang; a deadline-only trigger let 73
395
+ rows fail before the next scheduled recycle).
396
+ */
397
+ if (
398
+ closeHung
399
+ || (row.error && /deadline|has been closed|Protocol error/.test(row.error))
400
+ /*
401
+ Canary: a script-tag-injecting tool reporting its global undefined
402
+ on an ordinary text/html page means browser-wide script-element
403
+ execution is wedged (the injection cliff — a probabilistic wedge
404
+ that regional replays never reproduce; it once killed 41
405
+ consecutive rows in a browser only ~36 pages old). XML pages are
406
+ excluded: script elements legitimately never execute there.
407
+ */
408
+ || (row.error && /global not defined \(contentType text\/html/.test(row.error))
409
+ ) {
410
+ row.browserReplaced = true;
411
+ await replaceBrowser();
412
+ }
413
+ row.ms = Date.now() - started;
414
+ out.write(`${JSON.stringify(row)}\n`);
415
+ }
416
+ done++;
417
+ if (done % 25 === 0) {
418
+ console.log(`${done}/${testcases.length}`);
419
+ }
420
+ }
421
+ await browser.close();
422
+ out.end();
423
+ console.log(`Done: ${done} testcases → ${outPath}`);
424
+ })();
@@ -0,0 +1,66 @@
1
+ # Chromium bug report — Target.closeTarget acks success without closing during navigation commit
2
+
3
+ **NOT FILED as a new issue — already tracked upstream.** Pre-filing search (2026-08-22) found
4
+ <https://issues.chromium.org/issues/536385539> (filed 2026-07-19 by a Google engineer, P2/S2,
5
+ pending code change 8251379): `Target.closeTarget` is lost when a navigation commit swaps the
6
+ main RenderFrameHost right after `ClosePage` — the pending `ClosePage` callback dies with the
7
+ old RFH and is never re-issued. Same defect; their trigger was back-to-back failing navigations,
8
+ ours (meta-refresh 0) is an additional ordinary-page trigger of the same race.
9
+
10
+ **Actions taken instead:** corroborating comment posted as comment #2 on 536385539 (meta-refresh
11
+ trigger, raw-CDP hit rates, workaround matrix, link to
12
+ <https://github.com/microsoft/playwright/issues/42366>); issue upvoted; cross-link comment added
13
+ on the Playwright issue. Repro retained here: `repro-cdp-raw.js`.
14
+
15
+ The draft below is preserved for reference (its evidence went into the comment).
16
+
17
+ ---
18
+
19
+ **Title:** Target.closeTarget returns success:true without closing the target when the close
20
+ races a client-side (meta refresh) navigation commit; no Target.targetDestroyed is ever emitted
21
+
22
+ ## Environment
23
+
24
+ - Chromium 151.0.7922.34, `--headless=new`, Linux
25
+ - Raw CDP over WebSocket (no client library — reproduces identically through Playwright 1.62.1,
26
+ where it strands `page.close()` forever)
27
+ - Not reproducible in Firefox or WebKit equivalents
28
+
29
+ ## Steps
30
+
31
+ 1. Create a target and navigate it to a page carrying
32
+ `<meta http-equiv="refresh" content="0; URL='/target.html'">` (immediate client-side
33
+ redirect).
34
+ 2. Leave a `Runtime.evaluate` (`awaitPromise: true`) in flight for the refresh navigation to
35
+ destroy.
36
+ 3. ~45–60 ms after the navigate (during the refresh navigation's commit window), send
37
+ `Target.closeTarget {targetId}` on the browser session.
38
+
39
+ Reproduction rate ~3/10 rounds with the attached script (a timing race; the script sweeps the
40
+ window).
41
+
42
+ ## Expected
43
+
44
+ Either the target closes — `Target.targetDestroyed` fires and the target disappears from
45
+ `Target.getTargets` — or `closeTarget` reports `success: false`/an error.
46
+
47
+ ## Actual
48
+
49
+ `closeTarget` returns `{"result":{"success":true}}`, but:
50
+
51
+ - no `Target.targetDestroyed` is ever emitted for the target;
52
+ - the target remains listed in `Target.getTargets`;
53
+ - the target proceeds to fully load the redirect destination (frameNavigated,
54
+ DOMContentLoaded, load, paint lifecycle events all arrive *after* the successful-looking
55
+ close acknowledgment).
56
+
57
+ A second `Target.closeTarget` for the same target afterwards does close it, as does closing its
58
+ browser context — the target is not stuck, the close was simply dropped.
59
+
60
+ ## Impact
61
+
62
+ Any CDP client that treats `success: true` as "the target will be destroyed" waits forever.
63
+ Playwright's `page.close()` does exactly this: its promise resolves on target destruction, so
64
+ automation against pages using meta-refresh (common in the wild; the W3C ACT-Rules bc659a
65
+ "passed" fixtures are examples) hangs nondeterministically with no error. Observed at ~70–80%
66
+ per attempt via Playwright's timing on real fixture pages.
@@ -0,0 +1,86 @@
1
+ # Pour FP triage — 2026-08-22 capture (440-testcase sample, asserted band)
2
+
3
+ Manual read of every pour false-positive fixture from `results/merged-2026-08-22.jsonl`.
4
+ Verdicts: **real** (pour asserts a failure ACT deems passed/inapplicable — upstream candidate) or
5
+ **artifact** (correct finding counted against the wrong ACT rule by the criterion-level scoring
6
+ policy — not a pour defect).
7
+
8
+ ## Real — upstream filing candidates
9
+
10
+ 1. **Contrast asserted on ACT-exempt text** (3 FPs, `afw4f7`/`09o5cg`). Symbol-only text
11
+ (`09o5cg` Passed Ex 6: a paragraph of `----====++++…` at 3.66:1) and letter-as-icon glyphs
12
+ (`afw4f7` Passed Ex 7 / `09o5cg` Passed Ex 6: `<button aria-label="Close">X</button>`) are
13
+ expected *passed* by the ACT contrast rules; pour's `color-contrast` /
14
+ `color-contrast-enhanced` assert failures. Axe returns no finding on all three (TN). Given
15
+ pour's "report what it cannot prove" ethos, asserting on ACT-consensus-exempt text is worth an
16
+ upstream issue.
17
+ 2. **`button-name` on `<button role="none" disabled>`** (1 FP, `97a4e1` Inapplicable Ex 5). With
18
+ the element disabled (not focusable), `role="none"` holds and the element is out of scope for
19
+ the button-name rule; pour flags it.
20
+ 3. **`link-name` misses descendant `aria-labelledby`** (1 FP, `c487ae` Passed Ex 8):
21
+ `<a href><img aria-labelledby="id1"></a>` — the link's name-from-content includes the image's
22
+ aria-labelledby name, so the link is correctly named; pour flags it empty. Accessible-name
23
+ computation gap.
24
+ 4. **Language rules skip ACT applicability conditions** (4 FPs, `de46e4`/`off6ek`/`ucwvc8`).
25
+ `valid-lang-parts` flags invalid `lang` values on elements whose text is overridden by a
26
+ descendant `lang` or entirely `display:none` (ACT: passed/inapplicable — the SC concerns text
27
+ that *uses* the language); `html-lang` flags a completely empty `<html></html>` page (ACT:
28
+ inapplicable without text). Defensible strictness, but a divergence from ACT consensus worth
29
+ raising upstream.
30
+
31
+ ## Artifact — scoring policy, not pour
32
+
33
+ - **`c487ae` Inapplicable Ex 4** (`<a aria-hidden="true" href>…</a>`): pour's `aria-hidden-focus`
34
+ finding is a *correct* 4.1.2 issue (focusable content hidden from AT); it counts as an FP only
35
+ because the fixture tests a different ACT rule on the same criterion. This is the documented
36
+ generosity of criterion-level matching; an exact engine-rule → ACT-rule map would reclassify it.
37
+
38
+ ## Tally
39
+
40
+ Of 15 criterion-level FPs in the sample, ~9 fixtures inspected covering all hotspots: 9 real
41
+ across 4 defect classes, 1 artifact. The 4 classes are small, well-defined applicability/name-
42
+ computation gaps — exactly the kind of finding the candidacy pipeline is meant to hand upstream
43
+ (engine-candidacy-pipeline.md, Stage 5 / benchmarks §6 stewardship).
44
+
45
+ ## Recheck 2026-09-01 (pour 1.37.0, surea11y 1.7.0)
46
+
47
+ Re-ran every fixture of the eight ACT rules above with the current bundles
48
+ (`--engines pour,surea11y --rules afw4f7,09o5cg,97a4e1,c487ae,de46e4,off6ek,ucwvc8,4b1c6c`).
49
+
50
+ - Pour classes 1–3 persist unchanged in 1.37.0 (contrast on `afw4f7`/`09o5cg` Passed Example 7,
51
+ `button-name` on `97a4e1` Inapplicable Example 5, `link-name` on `c487ae` Passed Example 8).
52
+ Contrast also asserts on `09o5cg` Inapplicable Examples 6 and 7 — label text of a disabled
53
+ control, which ACT's "disabled label" applicability exemption excludes — a third sub-case of
54
+ class 1. Class 4 narrows to `valid-lang-parts` (`de46e4` Passed Example 4, Inapplicable 3–5,
55
+ `off6ek` Inapplicable 5): the `html-lang` hits on empty documents (`ucwvc8` Inapplicable 2–3,
56
+ `97a4e1` Passed 6, `c487ae` Passed 9) are correct under ACT `b5c3f8`, whose applicability has
57
+ no content condition, and count as FPs only through criterion-level scoring. Same for
58
+ surea11y's `html-lang-attr-present` on those fixtures.
59
+ - **surea11y `4b1c6c` (11 FP)** is `iframe-title-unique`, not the 1.7.0 rule
60
+ `identical-iframes-same-purpose` (which already reports differing resources as `cantTell`):
61
+ it fails any two frames sharing a title attribute, including Passed Examples 1, 4–9 where the
62
+ frames embed the same or equivalent resource, and Inapplicable Example 5.
63
+ - Upstream filings prepared from this recheck: pour — presentational-role conflict ignoring
64
+ `disabled`, name-from-content ignoring descendant `aria-labelledby`, contrast on
65
+ non-human-language text / icon glyphs / disabled labels, `valid-lang-parts` applicability;
66
+ surea11y — `iframe-title-unique` vs ACT 4b1c6c.
67
+
68
+ ### Filed upstream 2026-09-01 (pour-engine)
69
+
70
+ | Class | Issue | Pull request |
71
+ | --- | --- | --- |
72
+ | button-name on `<button role="none" disabled>` (presentational-role conflict ignores `:disabled`) | [#3](https://github.com/pourdev/pour-engine/issues/3) | [#7](https://github.com/pourdev/pour-engine/pull/7) |
73
+ | link-name: name from content ignores a descendant's `aria-labelledby` | [#4](https://github.com/pourdev/pour-engine/issues/4) | [#8](https://github.com/pourdev/pour-engine/pull/8) |
74
+ | contrast on symbol-only text, single-letter icons, labels of disabled widgets | [#5](https://github.com/pourdev/pour-engine/issues/5) | [#9](https://github.com/pourdev/pour-engine/pull/9) |
75
+ | valid-lang-parts on `lang` attributes that govern no text | [#6](https://github.com/pourdev/pour-engine/issues/6) | [#10](https://github.com/pourdev/pour-engine/pull/10) |
76
+
77
+ ### Filed upstream 2026-09-01 (surea11y core)
78
+
79
+ | Class | Issue | Pull request |
80
+ | --- | --- | --- |
81
+ | `iframe-title-unique` fails frames sharing a title that embed the same resource (ACT 4b1c6c) | [#16](https://github.com/SureA11y/core/issues/16) | [#17](https://github.com/SureA11y/core/pull/17) |
82
+
83
+ The maintainers document the strict behaviour as deliberate (`docs/ACT_RULE_MAPPING.md`), so the
84
+ issue asks them to choose between aligning the rule with 4b1c6c (the PR) and deprecating it in
85
+ favour of `identical-iframes-same-purpose`. Fixture branches live on the forks
86
+ `wittjeff/pour-engine` and `wittjeff/core`.