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.
- package/.gitattributes +94 -0
- package/.github/workflows/ci.yml +58 -0
- package/.github/workflows/publish.yml +50 -0
- package/.github/workflows/typescript.yml +35 -0
- package/AGENTS.md +2 -2
- package/CLAUDE.md +24 -18
- package/CONTAINERS.md +9 -6
- package/CONTRIBUTING.md +29 -3
- package/Dockerfile +1 -1
- package/README.md +143 -19
- package/UPGRADES.md +4 -0
- package/actSpecs-doc.md +13 -1
- package/actSpecs.js +29 -5
- package/call.js +6 -5
- package/dist/nameComputation.js +1 -0
- package/docker-compose.yml +8 -2
- package/docs/checkpoint-scanning.md +199 -0
- package/docs/standard-result-outcome.md +165 -0
- package/env.example +49 -10
- package/eslint.config.mjs +101 -0
- package/netWatch.js +372 -222
- package/package.json +46 -31
- package/pour/README.md +47 -0
- package/pour/pour.min.js +8 -0
- package/procs/actDo.js +808 -0
- package/procs/catalog.d.ts +17 -0
- package/procs/catalog.js +312 -221
- package/procs/catalog.ts +398 -0
- package/procs/checkpoint.js +110 -0
- package/procs/config.d.ts +41 -0
- package/procs/config.js +98 -0
- package/procs/dateTime.js +2 -1
- package/procs/doActs.js +442 -849
- package/procs/doTestAct.js +27 -121
- package/procs/flow.js +221 -0
- package/procs/generateRuleRegistry.js +94 -0
- package/procs/getSource.d.ts +26 -0
- package/procs/job.js +115 -1
- package/procs/launch.d.ts +40 -0
- package/procs/launch.js +243 -108
- package/procs/nu.d.ts +48 -0
- package/procs/scope.js +196 -0
- package/procs/shoot.d.ts +33 -0
- package/procs/standard.d.ts +18 -0
- package/procs/standard.js +83 -0
- package/procs/standard.ts +126 -0
- package/procs/testAct.js +145 -0
- package/procs/testaro.d.ts +20 -0
- package/procs/testaro.js +236 -189
- package/procs/testaro.ts +321 -0
- package/procs/userPath.js +126 -0
- package/procs/xPath.d.ts +4 -0
- package/procs/xPath.js +81 -69
- package/procs/xPath.ts +106 -0
- package/procs/xPathScript.d.ts +6 -0
- package/procs/xPathScript.js +70 -0
- package/run.js +3 -2
- package/surea11y/README.md +44 -0
- package/surea11y/surea11y.browser.js +14 -0
- package/testaro/adbID.d.ts +3 -0
- package/testaro/adbID.js +38 -41
- package/testaro/adbID.ts +66 -0
- package/testaro/allCapStyle.d.ts +3 -0
- package/testaro/allCapStyle.js +31 -34
- package/testaro/allCapStyle.ts +55 -0
- package/testaro/allCaps.d.ts +16 -0
- package/testaro/allCaps.js +179 -151
- package/testaro/allCaps.ts +228 -0
- package/testaro/allHidden.d.ts +13 -0
- package/testaro/allHidden.js +29 -30
- package/testaro/allHidden.ts +50 -0
- package/testaro/allSlanted.d.ts +3 -0
- package/testaro/allSlanted.js +30 -33
- package/testaro/allSlanted.ts +54 -0
- package/testaro/altScheme.d.ts +3 -0
- package/testaro/altScheme.js +27 -30
- package/testaro/altScheme.ts +50 -0
- package/testaro/attVal.d.ts +3 -0
- package/testaro/attVal.js +20 -21
- package/testaro/attVal.ts +52 -0
- package/testaro/autocomplete.d.ts +3 -0
- package/testaro/autocomplete.js +59 -75
- package/testaro/autocomplete.ts +101 -0
- package/testaro/bulk.d.ts +13 -0
- package/testaro/bulk.js +32 -33
- package/testaro/bulk.ts +55 -0
- package/testaro/buttonMenu.d.ts +9 -0
- package/testaro/buttonMenu.js +317 -319
- package/testaro/buttonMenu.ts +391 -0
- package/testaro/captionLoc.d.ts +3 -0
- package/testaro/captionLoc.js +17 -20
- package/testaro/captionLoc.ts +40 -0
- package/testaro/datalistRef.d.ts +3 -0
- package/testaro/datalistRef.js +33 -36
- package/testaro/datalistRef.ts +55 -0
- package/testaro/distortion.d.ts +3 -0
- package/testaro/distortion.js +57 -26
- package/testaro/distortion.ts +81 -0
- package/testaro/docType.d.ts +15 -0
- package/testaro/docType.js +25 -25
- package/testaro/docType.ts +45 -0
- package/testaro/dupAtt.d.ts +16 -0
- package/testaro/dupAtt.js +113 -104
- package/testaro/dupAtt.ts +144 -0
- package/testaro/elements.d.ts +6 -0
- package/testaro/elements.js +153 -153
- package/testaro/elements.ts +215 -0
- package/testaro/embAc.d.ts +3 -0
- package/testaro/embAc.js +19 -20
- package/testaro/embAc.ts +40 -0
- package/testaro/focAll.d.ts +13 -0
- package/testaro/focAll.js +185 -191
- package/testaro/focAll.ts +217 -0
- package/testaro/focAndOp.d.ts +3 -0
- package/testaro/focAndOp.js +100 -104
- package/testaro/focAndOp.ts +128 -0
- package/testaro/focInd.d.ts +3 -0
- package/testaro/focInd.js +64 -66
- package/testaro/focInd.ts +96 -0
- package/testaro/focVis.d.ts +3 -0
- package/testaro/focVis.js +28 -30
- package/testaro/focVis.ts +52 -0
- package/testaro/headEl.d.ts +10 -0
- package/testaro/headEl.js +61 -62
- package/testaro/headEl.ts +82 -0
- package/testaro/headingAmb.d.ts +3 -0
- package/testaro/headingAmb.js +54 -63
- package/testaro/headingAmb.ts +72 -0
- package/testaro/hovInd.d.ts +12 -0
- package/testaro/hovInd.js +163 -130
- package/testaro/hovInd.ts +199 -0
- package/testaro/hover.d.ts +3 -0
- package/testaro/hover.js +155 -126
- package/testaro/hover.ts +154 -0
- package/testaro/hr.d.ts +3 -0
- package/testaro/hr.js +14 -17
- package/testaro/hr.ts +36 -0
- package/testaro/imageLink.d.ts +3 -0
- package/testaro/imageLink.js +17 -20
- package/testaro/imageLink.ts +42 -0
- package/testaro/labClash.d.ts +3 -0
- package/testaro/labClash.js +31 -33
- package/testaro/labClash.ts +54 -0
- package/testaro/legendLoc.d.ts +3 -0
- package/testaro/legendLoc.js +17 -20
- package/testaro/legendLoc.ts +42 -0
- package/testaro/lineHeight.d.ts +3 -0
- package/testaro/lineHeight.js +43 -48
- package/testaro/lineHeight.ts +70 -0
- package/testaro/linkAmb.d.ts +7 -0
- package/testaro/linkAmb.js +79 -80
- package/testaro/linkAmb.ts +105 -0
- package/testaro/linkExt.d.ts +3 -0
- package/testaro/linkExt.js +13 -16
- package/testaro/linkExt.ts +35 -0
- package/testaro/linkOldAtt.d.ts +3 -0
- package/testaro/linkOldAtt.js +25 -28
- package/testaro/linkOldAtt.ts +48 -0
- package/testaro/linkTo.d.ts +3 -0
- package/testaro/linkTo.js +22 -22
- package/testaro/linkTo.ts +43 -0
- package/testaro/linkUl.d.ts +3 -0
- package/testaro/linkUl.js +30 -34
- package/testaro/linkUl.ts +54 -0
- package/testaro/miniText.d.ts +3 -0
- package/testaro/miniText.js +41 -44
- package/testaro/miniText.ts +68 -0
- package/testaro/motion.d.ts +10 -0
- package/testaro/motion.js +92 -96
- package/testaro/motion.ts +125 -0
- package/testaro/nonTable.d.ts +3 -0
- package/testaro/nonTable.js +39 -45
- package/testaro/nonTable.ts +66 -0
- package/testaro/optRoleSel.d.ts +3 -0
- package/testaro/optRoleSel.js +16 -19
- package/testaro/optRoleSel.ts +41 -0
- package/testaro/phOnly.d.ts +3 -0
- package/testaro/phOnly.js +18 -21
- package/testaro/phOnly.ts +43 -0
- package/testaro/pseudoP.d.ts +3 -0
- package/testaro/pseudoP.js +36 -38
- package/testaro/pseudoP.ts +59 -0
- package/testaro/radioSet.d.ts +3 -0
- package/testaro/radioSet.js +57 -59
- package/testaro/radioSet.ts +79 -0
- package/testaro/registry.d.ts +63 -0
- package/testaro/registry.js +67 -0
- package/testaro/registry.ts +141 -0
- package/testaro/role.d.ts +3 -0
- package/testaro/role.js +27 -29
- package/testaro/role.ts +53 -0
- package/testaro/secHeading.d.ts +3 -0
- package/testaro/secHeading.js +30 -33
- package/testaro/secHeading.ts +53 -0
- package/testaro/styleDiff.d.ts +25 -0
- package/testaro/styleDiff.js +248 -252
- package/testaro/styleDiff.ts +303 -0
- package/testaro/tabNav.d.ts +33 -0
- package/testaro/tabNav.js +272 -342
- package/testaro/tabNav.ts +454 -0
- package/testaro/targetsNear.d.ts +9 -0
- package/testaro/targetsNear.js +130 -132
- package/testaro/targetsNear.ts +160 -0
- package/testaro/textNodes.d.ts +6 -0
- package/testaro/textNodes.js +139 -135
- package/testaro/textNodes.ts +185 -0
- package/testaro/textSem.d.ts +3 -0
- package/testaro/textSem.js +25 -28
- package/testaro/textSem.ts +47 -0
- package/testaro/title.d.ts +9 -0
- package/testaro/title.js +16 -13
- package/testaro/title.ts +31 -0
- package/testaro/titledEl.d.ts +3 -0
- package/testaro/titledEl.js +15 -18
- package/testaro/titledEl.ts +38 -0
- package/testaro/zIndex.d.ts +3 -0
- package/testaro/zIndex.js +19 -22
- package/testaro/zIndex.ts +42 -0
- package/tests/alfa.d.ts +45 -0
- package/tests/alfa.js +138 -141
- package/tests/alfa.ts +214 -0
- package/tests/aslint.d.ts +33 -0
- package/tests/aslint.js +272 -249
- package/tests/aslint.ts +301 -0
- package/tests/axe.d.ts +27 -0
- package/tests/axe.js +199 -200
- package/tests/axe.ts +277 -0
- package/tests/ed11y.d.ts +28 -0
- package/tests/ed11y.js +141 -99
- package/tests/ed11y.ts +178 -0
- package/tests/htmlcs.d.ts +21 -0
- package/tests/htmlcs.js +174 -140
- package/tests/htmlcs.ts +181 -0
- package/tests/ibm.d.ts +57 -0
- package/tests/ibm.js +182 -175
- package/tests/ibm.ts +287 -0
- package/tests/nuVal.d.ts +13 -0
- package/tests/nuVal.js +107 -112
- package/tests/nuVal.ts +145 -0
- package/tests/nuVnu.d.ts +15 -0
- package/tests/nuVnu.js +141 -111
- package/tests/nuVnu.ts +144 -0
- package/tests/pour.d.ts +31 -0
- package/tests/pour.js +242 -0
- package/tests/pour.ts +273 -0
- package/tests/qualWeb.d.ts +39 -0
- package/tests/qualWeb.js +262 -273
- package/tests/qualWeb.ts +344 -0
- package/tests/surea11y.d.ts +33 -0
- package/tests/surea11y.js +288 -0
- package/tests/surea11y.ts +334 -0
- package/tests/testaro.d.ts +25 -0
- package/tests/testaro.js +745 -652
- package/tests/testaro.ts +862 -0
- package/tests/wave.d.ts +46 -0
- package/tests/wave.js +166 -177
- package/tests/wave.ts +252 -0
- package/tsconfig.json +17 -0
- package/types.d.ts +243 -0
- package/types.js +10 -0
- package/types.ts +376 -0
- package/validation/act/README.md +46 -0
- package/validation/act/capture.js +877 -0
- package/validation/act/chromium-issue-draft.md +66 -0
- package/validation/act/fp-triage-2026-08-22.md +86 -0
- package/validation/act/isolation-notes.md +159 -0
- package/validation/act/playwright-issue-draft.md +73 -0
- package/validation/act/propose-mappings.js +0 -0
- package/validation/act/repro-cdp-raw.js +105 -0
- package/validation/act/repro-metarefresh.js +65 -0
- package/validation/act/score.js +184 -0
- package/validation/act/stage3a-stress-report.md +69 -0
- package/validation/act/stage3b-mapping-proposals.md +94 -0
- package/validation/act/stage3b-triage-draft.md +138 -0
- package/validation/act/surea11y-track-a-2026-09-01.md +50 -0
- package/validation/executors/netWatch.js +180 -90
- package/validation/executors/test.js +17 -2
- package/validation/executors/tests.js +118 -10
- package/validation/jobs/reports/raw/260901T1000-surea11y-validation.json +964 -0
- package/validation/jobs/todo/240101T1200-simple-example.json +14 -6
- package/validation/jobs/todo/240101T1300-shoot-example.json +2 -1
- package/validation/jobs/todo/260821T1900-pour-validation.json +45 -0
- package/validation/jobs/todo/260901T1000-surea11y-validation.json +45 -0
- package/validation/knownFailures.json +1 -0
- package/validation/tests/jobProperties/adbID.json +27 -2
- package/validation/tests/jobProperties/{focOp.json → allCapStyle.json} +56 -53
- package/validation/tests/jobProperties/allCaps.json +25 -0
- package/validation/tests/jobProperties/allHidden.json +133 -13
- package/validation/tests/jobProperties/allSlanted.json +3 -3
- package/validation/tests/jobProperties/altScheme.json +23 -8
- package/validation/tests/jobProperties/attVal.json +57 -57
- package/validation/tests/jobProperties/autocomplete.json +11 -1
- package/validation/tests/jobProperties/bulk.json +6 -1
- package/validation/tests/jobProperties/buttonMenu.json +63 -42
- package/validation/tests/jobProperties/captionLoc.json +1 -6
- package/validation/tests/jobProperties/checkpoint-browser.json +401 -0
- package/validation/tests/jobProperties/checkpoint-page.json +397 -0
- package/validation/tests/jobProperties/checkpoint.json +395 -0
- package/validation/tests/jobProperties/datalistRef.json +20 -5
- package/validation/tests/jobProperties/distortion.json +28 -3
- package/validation/tests/jobProperties/docType.json +2 -2
- package/validation/tests/jobProperties/dupAtt.json +48 -33
- package/validation/tests/jobProperties/elements.json +28 -28
- package/validation/tests/jobProperties/embAc.json +36 -31
- package/validation/tests/jobProperties/focAndOp.json +284 -0
- package/validation/tests/jobProperties/focInd.json +34 -34
- package/validation/tests/jobProperties/focVis.json +1 -1
- package/validation/tests/jobProperties/hover.json +39 -37
- package/validation/tests/jobProperties/hr.json +13 -3
- package/validation/tests/jobProperties/imageLink.json +0 -5
- package/validation/tests/jobProperties/labClash.json +68 -33
- package/validation/tests/jobProperties/legendLoc.json +1 -6
- package/validation/tests/jobProperties/lineHeight.json +2 -2
- package/validation/tests/jobProperties/linkAmb.json +13 -18
- package/validation/tests/jobProperties/linkExt.json +1 -1
- package/validation/tests/jobProperties/linkOldAtt.json +12 -2
- package/validation/tests/jobProperties/linkTo.json +1 -1
- package/validation/tests/jobProperties/linkUl.json +65 -65
- package/validation/tests/jobProperties/miniText.json +7 -2
- package/validation/tests/jobProperties/motion.json +4 -50
- package/validation/tests/jobProperties/nonTable.json +48 -3
- package/validation/tests/jobProperties/optRoleSel.json +12 -2
- package/validation/tests/jobProperties/phOnly.json +6 -16
- package/validation/tests/jobProperties/pseudoP.json +17 -2
- package/validation/tests/jobProperties/radioSet.json +30 -30
- package/validation/tests/jobProperties/role.json +25 -5
- package/validation/tests/jobProperties/secHeading.json +26 -21
- package/validation/tests/jobProperties/styleDiff.json +35 -5
- package/validation/tests/jobProperties/tabNav.json +3 -1
- package/validation/tests/jobProperties/targetsNear.json +237 -0
- package/validation/tests/jobProperties/textNodes.json +29 -29
- package/validation/tests/jobProperties/textSem.json +26 -1
- package/validation/tests/jobProperties/title.json +12 -2
- package/validation/tests/jobProperties/titledEl.json +39 -9
- package/validation/tests/jobProperties/userPath.json +317 -0
- package/validation/tests/jobProperties/zIndex.json +40 -35
- package/validation/tests/targets/allCapStyle/index.html +26 -0
- package/validation/tests/targets/checkpoint/index.html +38 -0
- package/validation/tests/targets/datalistRef/index.html +1 -1
- package/validation/tests/targets/focAndOp/bad.html +29 -0
- package/validation/tests/targets/{focOp → focAndOp}/good.html +3 -1
- package/validation/tests/targets/focInd/bad.html +2 -1
- package/validation/tests/targets/headEl/index.html +10 -1
- package/validation/tests/targets/{targetSmall → targetsNear}/index.html +15 -1
- package/validation/tests/targets/userPath/index.html +42 -0
- package/validation/validateTest.js +61 -9
- package/.claude/settings.local.json +0 -11
- package/.eslintrc.json +0 -41
- package/htmlcs/.eslintrc.json +0 -67
- package/validation/tests/jobProperties/linkTitle.json +0 -127
- package/validation/tests/jobProperties/opFoc.json +0 -164
- package/validation/tests/jobProperties/targetSmall.json +0 -152
- package/validation/tests/jobProperties/targetTiny.json +0 -142
- package/validation/tests/targets/focOp/bad.html +0 -25
- package/validation/tests/targets/linkTitle/index.html +0 -24
- package/validation/tests/targets/opFoc/bad.html +0 -26
- 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
|
+
})();
|