testaro 78.0.8 → 78.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (356) hide show
  1. package/.gitattributes +94 -0
  2. package/.github/workflows/ci.yml +58 -0
  3. package/.github/workflows/publish.yml +49 -0
  4. package/.github/workflows/typescript.yml +35 -0
  5. package/AGENTS.md +2 -2
  6. package/CLAUDE.md +24 -18
  7. package/CONTAINERS.md +9 -6
  8. package/CONTRIBUTING.md +29 -3
  9. package/Dockerfile +1 -1
  10. package/README.md +73 -19
  11. package/UPGRADES.md +4 -0
  12. package/actSpecs-doc.md +13 -1
  13. package/actSpecs.js +29 -5
  14. package/call.js +6 -5
  15. package/docker-compose.yml +8 -2
  16. package/docs/checkpoint-scanning.md +199 -0
  17. package/docs/standard-result-outcome.md +165 -0
  18. package/env.example +44 -10
  19. package/eslint.config.mjs +101 -0
  20. package/netWatch.js +372 -222
  21. package/package.json +46 -31
  22. package/pour/README.md +47 -0
  23. package/pour/pour.min.js +8 -0
  24. package/procs/actDo.js +808 -0
  25. package/procs/catalog.d.ts +17 -0
  26. package/procs/catalog.js +312 -221
  27. package/procs/catalog.ts +398 -0
  28. package/procs/checkpoint.js +110 -0
  29. package/procs/config.d.ts +35 -0
  30. package/procs/config.js +62 -0
  31. package/procs/dateTime.js +2 -1
  32. package/procs/doActs.js +442 -849
  33. package/procs/doTestAct.js +27 -121
  34. package/procs/flow.js +221 -0
  35. package/procs/generateRuleRegistry.js +94 -0
  36. package/procs/getSource.d.ts +26 -0
  37. package/procs/job.js +115 -1
  38. package/procs/launch.d.ts +40 -0
  39. package/procs/launch.js +239 -108
  40. package/procs/nu.d.ts +48 -0
  41. package/procs/scope.js +196 -0
  42. package/procs/shoot.d.ts +33 -0
  43. package/procs/standard.d.ts +18 -0
  44. package/procs/standard.js +83 -0
  45. package/procs/standard.ts +126 -0
  46. package/procs/testAct.js +145 -0
  47. package/procs/testaro.d.ts +20 -0
  48. package/procs/testaro.js +236 -189
  49. package/procs/testaro.ts +321 -0
  50. package/procs/userPath.js +126 -0
  51. package/procs/xPath.d.ts +4 -0
  52. package/procs/xPath.js +81 -69
  53. package/procs/xPath.ts +106 -0
  54. package/procs/xPathScript.d.ts +6 -0
  55. package/procs/xPathScript.js +70 -0
  56. package/run.js +3 -2
  57. package/surea11y/README.md +44 -0
  58. package/surea11y/surea11y.browser.js +14 -0
  59. package/testaro/adbID.d.ts +3 -0
  60. package/testaro/adbID.js +38 -41
  61. package/testaro/adbID.ts +66 -0
  62. package/testaro/allCapStyle.d.ts +3 -0
  63. package/testaro/allCapStyle.js +31 -34
  64. package/testaro/allCapStyle.ts +55 -0
  65. package/testaro/allCaps.d.ts +16 -0
  66. package/testaro/allCaps.js +179 -151
  67. package/testaro/allCaps.ts +228 -0
  68. package/testaro/allHidden.d.ts +13 -0
  69. package/testaro/allHidden.js +29 -30
  70. package/testaro/allHidden.ts +50 -0
  71. package/testaro/allSlanted.d.ts +3 -0
  72. package/testaro/allSlanted.js +30 -33
  73. package/testaro/allSlanted.ts +54 -0
  74. package/testaro/altScheme.d.ts +3 -0
  75. package/testaro/altScheme.js +27 -30
  76. package/testaro/altScheme.ts +50 -0
  77. package/testaro/attVal.d.ts +3 -0
  78. package/testaro/attVal.js +20 -21
  79. package/testaro/attVal.ts +52 -0
  80. package/testaro/autocomplete.d.ts +3 -0
  81. package/testaro/autocomplete.js +59 -75
  82. package/testaro/autocomplete.ts +101 -0
  83. package/testaro/bulk.d.ts +13 -0
  84. package/testaro/bulk.js +32 -33
  85. package/testaro/bulk.ts +55 -0
  86. package/testaro/buttonMenu.d.ts +9 -0
  87. package/testaro/buttonMenu.js +317 -319
  88. package/testaro/buttonMenu.ts +391 -0
  89. package/testaro/captionLoc.d.ts +3 -0
  90. package/testaro/captionLoc.js +17 -20
  91. package/testaro/captionLoc.ts +40 -0
  92. package/testaro/datalistRef.d.ts +3 -0
  93. package/testaro/datalistRef.js +33 -36
  94. package/testaro/datalistRef.ts +55 -0
  95. package/testaro/distortion.d.ts +3 -0
  96. package/testaro/distortion.js +57 -26
  97. package/testaro/distortion.ts +81 -0
  98. package/testaro/docType.d.ts +15 -0
  99. package/testaro/docType.js +25 -25
  100. package/testaro/docType.ts +45 -0
  101. package/testaro/dupAtt.d.ts +16 -0
  102. package/testaro/dupAtt.js +113 -104
  103. package/testaro/dupAtt.ts +144 -0
  104. package/testaro/elements.d.ts +6 -0
  105. package/testaro/elements.js +153 -153
  106. package/testaro/elements.ts +215 -0
  107. package/testaro/embAc.d.ts +3 -0
  108. package/testaro/embAc.js +19 -20
  109. package/testaro/embAc.ts +40 -0
  110. package/testaro/focAll.d.ts +13 -0
  111. package/testaro/focAll.js +185 -191
  112. package/testaro/focAll.ts +217 -0
  113. package/testaro/focAndOp.d.ts +3 -0
  114. package/testaro/focAndOp.js +100 -104
  115. package/testaro/focAndOp.ts +128 -0
  116. package/testaro/focInd.d.ts +3 -0
  117. package/testaro/focInd.js +64 -66
  118. package/testaro/focInd.ts +96 -0
  119. package/testaro/focVis.d.ts +3 -0
  120. package/testaro/focVis.js +28 -30
  121. package/testaro/focVis.ts +52 -0
  122. package/testaro/headEl.d.ts +10 -0
  123. package/testaro/headEl.js +61 -62
  124. package/testaro/headEl.ts +82 -0
  125. package/testaro/headingAmb.d.ts +3 -0
  126. package/testaro/headingAmb.js +54 -63
  127. package/testaro/headingAmb.ts +72 -0
  128. package/testaro/hovInd.d.ts +12 -0
  129. package/testaro/hovInd.js +163 -130
  130. package/testaro/hovInd.ts +199 -0
  131. package/testaro/hover.d.ts +3 -0
  132. package/testaro/hover.js +155 -126
  133. package/testaro/hover.ts +154 -0
  134. package/testaro/hr.d.ts +3 -0
  135. package/testaro/hr.js +14 -17
  136. package/testaro/hr.ts +36 -0
  137. package/testaro/imageLink.d.ts +3 -0
  138. package/testaro/imageLink.js +17 -20
  139. package/testaro/imageLink.ts +42 -0
  140. package/testaro/labClash.d.ts +3 -0
  141. package/testaro/labClash.js +31 -33
  142. package/testaro/labClash.ts +54 -0
  143. package/testaro/legendLoc.d.ts +3 -0
  144. package/testaro/legendLoc.js +17 -20
  145. package/testaro/legendLoc.ts +42 -0
  146. package/testaro/lineHeight.d.ts +3 -0
  147. package/testaro/lineHeight.js +43 -48
  148. package/testaro/lineHeight.ts +70 -0
  149. package/testaro/linkAmb.d.ts +7 -0
  150. package/testaro/linkAmb.js +79 -80
  151. package/testaro/linkAmb.ts +105 -0
  152. package/testaro/linkExt.d.ts +3 -0
  153. package/testaro/linkExt.js +13 -16
  154. package/testaro/linkExt.ts +35 -0
  155. package/testaro/linkOldAtt.d.ts +3 -0
  156. package/testaro/linkOldAtt.js +25 -28
  157. package/testaro/linkOldAtt.ts +48 -0
  158. package/testaro/linkTo.d.ts +3 -0
  159. package/testaro/linkTo.js +22 -22
  160. package/testaro/linkTo.ts +43 -0
  161. package/testaro/linkUl.d.ts +3 -0
  162. package/testaro/linkUl.js +30 -34
  163. package/testaro/linkUl.ts +54 -0
  164. package/testaro/miniText.d.ts +3 -0
  165. package/testaro/miniText.js +41 -44
  166. package/testaro/miniText.ts +68 -0
  167. package/testaro/motion.d.ts +10 -0
  168. package/testaro/motion.js +92 -96
  169. package/testaro/motion.ts +125 -0
  170. package/testaro/nonTable.d.ts +3 -0
  171. package/testaro/nonTable.js +39 -45
  172. package/testaro/nonTable.ts +66 -0
  173. package/testaro/optRoleSel.d.ts +3 -0
  174. package/testaro/optRoleSel.js +16 -19
  175. package/testaro/optRoleSel.ts +41 -0
  176. package/testaro/phOnly.d.ts +3 -0
  177. package/testaro/phOnly.js +18 -21
  178. package/testaro/phOnly.ts +43 -0
  179. package/testaro/pseudoP.d.ts +3 -0
  180. package/testaro/pseudoP.js +36 -38
  181. package/testaro/pseudoP.ts +59 -0
  182. package/testaro/radioSet.d.ts +3 -0
  183. package/testaro/radioSet.js +57 -59
  184. package/testaro/radioSet.ts +79 -0
  185. package/testaro/registry.d.ts +63 -0
  186. package/testaro/registry.js +67 -0
  187. package/testaro/registry.ts +141 -0
  188. package/testaro/role.d.ts +3 -0
  189. package/testaro/role.js +27 -29
  190. package/testaro/role.ts +53 -0
  191. package/testaro/secHeading.d.ts +3 -0
  192. package/testaro/secHeading.js +30 -33
  193. package/testaro/secHeading.ts +53 -0
  194. package/testaro/styleDiff.d.ts +25 -0
  195. package/testaro/styleDiff.js +248 -252
  196. package/testaro/styleDiff.ts +303 -0
  197. package/testaro/tabNav.d.ts +33 -0
  198. package/testaro/tabNav.js +272 -342
  199. package/testaro/tabNav.ts +454 -0
  200. package/testaro/targetsNear.d.ts +9 -0
  201. package/testaro/targetsNear.js +130 -132
  202. package/testaro/targetsNear.ts +160 -0
  203. package/testaro/textNodes.d.ts +6 -0
  204. package/testaro/textNodes.js +139 -135
  205. package/testaro/textNodes.ts +185 -0
  206. package/testaro/textSem.d.ts +3 -0
  207. package/testaro/textSem.js +25 -28
  208. package/testaro/textSem.ts +47 -0
  209. package/testaro/title.d.ts +9 -0
  210. package/testaro/title.js +16 -13
  211. package/testaro/title.ts +31 -0
  212. package/testaro/titledEl.d.ts +3 -0
  213. package/testaro/titledEl.js +15 -18
  214. package/testaro/titledEl.ts +38 -0
  215. package/testaro/zIndex.d.ts +3 -0
  216. package/testaro/zIndex.js +19 -22
  217. package/testaro/zIndex.ts +42 -0
  218. package/tests/alfa.d.ts +45 -0
  219. package/tests/alfa.js +138 -141
  220. package/tests/alfa.ts +214 -0
  221. package/tests/aslint.d.ts +33 -0
  222. package/tests/aslint.js +272 -249
  223. package/tests/aslint.ts +301 -0
  224. package/tests/axe.d.ts +27 -0
  225. package/tests/axe.js +199 -200
  226. package/tests/axe.ts +277 -0
  227. package/tests/ed11y.d.ts +28 -0
  228. package/tests/ed11y.js +141 -99
  229. package/tests/ed11y.ts +178 -0
  230. package/tests/htmlcs.d.ts +21 -0
  231. package/tests/htmlcs.js +174 -140
  232. package/tests/htmlcs.ts +181 -0
  233. package/tests/ibm.d.ts +52 -0
  234. package/tests/ibm.js +164 -166
  235. package/tests/ibm.ts +251 -0
  236. package/tests/nuVal.d.ts +13 -0
  237. package/tests/nuVal.js +107 -112
  238. package/tests/nuVal.ts +145 -0
  239. package/tests/nuVnu.d.ts +15 -0
  240. package/tests/nuVnu.js +141 -111
  241. package/tests/nuVnu.ts +144 -0
  242. package/tests/pour.d.ts +31 -0
  243. package/tests/pour.js +242 -0
  244. package/tests/pour.ts +273 -0
  245. package/tests/qualWeb.d.ts +39 -0
  246. package/tests/qualWeb.js +302 -272
  247. package/tests/qualWeb.ts +415 -0
  248. package/tests/surea11y.d.ts +33 -0
  249. package/tests/surea11y.js +288 -0
  250. package/tests/surea11y.ts +334 -0
  251. package/tests/testaro.d.ts +25 -0
  252. package/tests/testaro.js +745 -652
  253. package/tests/testaro.ts +862 -0
  254. package/tests/wave.d.ts +46 -0
  255. package/tests/wave.js +166 -177
  256. package/tests/wave.ts +252 -0
  257. package/tsconfig.json +17 -0
  258. package/types.d.ts +243 -0
  259. package/types.js +10 -0
  260. package/types.ts +376 -0
  261. package/validation/act/README.md +46 -0
  262. package/validation/act/capture.js +424 -0
  263. package/validation/act/chromium-issue-draft.md +66 -0
  264. package/validation/act/fp-triage-2026-08-22.md +86 -0
  265. package/validation/act/isolation-notes.md +159 -0
  266. package/validation/act/playwright-issue-draft.md +73 -0
  267. package/validation/act/propose-mappings.js +0 -0
  268. package/validation/act/repro-cdp-raw.js +105 -0
  269. package/validation/act/repro-metarefresh.js +65 -0
  270. package/validation/act/score.js +184 -0
  271. package/validation/act/stage3a-stress-report.md +69 -0
  272. package/validation/act/stage3b-mapping-proposals.md +94 -0
  273. package/validation/act/stage3b-triage-draft.md +138 -0
  274. package/validation/act/surea11y-track-a-2026-09-01.md +50 -0
  275. package/validation/executors/netWatch.js +180 -90
  276. package/validation/executors/test.js +17 -2
  277. package/validation/executors/tests.js +118 -10
  278. package/validation/jobs/reports/raw/260901T1000-surea11y-validation.json +964 -0
  279. package/validation/jobs/todo/240101T1200-simple-example.json +14 -6
  280. package/validation/jobs/todo/240101T1300-shoot-example.json +2 -1
  281. package/validation/jobs/todo/260821T1900-pour-validation.json +45 -0
  282. package/validation/jobs/todo/260901T1000-surea11y-validation.json +45 -0
  283. package/validation/knownFailures.json +1 -0
  284. package/validation/tests/jobProperties/adbID.json +27 -2
  285. package/validation/tests/jobProperties/{focOp.json → allCapStyle.json} +56 -53
  286. package/validation/tests/jobProperties/allCaps.json +25 -0
  287. package/validation/tests/jobProperties/allHidden.json +133 -13
  288. package/validation/tests/jobProperties/allSlanted.json +3 -3
  289. package/validation/tests/jobProperties/altScheme.json +23 -8
  290. package/validation/tests/jobProperties/attVal.json +57 -57
  291. package/validation/tests/jobProperties/autocomplete.json +11 -1
  292. package/validation/tests/jobProperties/bulk.json +6 -1
  293. package/validation/tests/jobProperties/buttonMenu.json +63 -42
  294. package/validation/tests/jobProperties/captionLoc.json +1 -6
  295. package/validation/tests/jobProperties/checkpoint-browser.json +401 -0
  296. package/validation/tests/jobProperties/checkpoint-page.json +397 -0
  297. package/validation/tests/jobProperties/checkpoint.json +395 -0
  298. package/validation/tests/jobProperties/datalistRef.json +20 -5
  299. package/validation/tests/jobProperties/distortion.json +28 -3
  300. package/validation/tests/jobProperties/docType.json +2 -2
  301. package/validation/tests/jobProperties/dupAtt.json +48 -33
  302. package/validation/tests/jobProperties/elements.json +28 -28
  303. package/validation/tests/jobProperties/embAc.json +36 -31
  304. package/validation/tests/jobProperties/focAndOp.json +284 -0
  305. package/validation/tests/jobProperties/focInd.json +34 -34
  306. package/validation/tests/jobProperties/focVis.json +1 -1
  307. package/validation/tests/jobProperties/hover.json +39 -37
  308. package/validation/tests/jobProperties/hr.json +13 -3
  309. package/validation/tests/jobProperties/imageLink.json +0 -5
  310. package/validation/tests/jobProperties/labClash.json +68 -33
  311. package/validation/tests/jobProperties/legendLoc.json +1 -6
  312. package/validation/tests/jobProperties/lineHeight.json +2 -2
  313. package/validation/tests/jobProperties/linkAmb.json +13 -18
  314. package/validation/tests/jobProperties/linkExt.json +1 -1
  315. package/validation/tests/jobProperties/linkOldAtt.json +12 -2
  316. package/validation/tests/jobProperties/linkTo.json +1 -1
  317. package/validation/tests/jobProperties/linkUl.json +65 -65
  318. package/validation/tests/jobProperties/miniText.json +7 -2
  319. package/validation/tests/jobProperties/motion.json +4 -50
  320. package/validation/tests/jobProperties/nonTable.json +48 -3
  321. package/validation/tests/jobProperties/optRoleSel.json +12 -2
  322. package/validation/tests/jobProperties/phOnly.json +6 -16
  323. package/validation/tests/jobProperties/pseudoP.json +17 -2
  324. package/validation/tests/jobProperties/radioSet.json +30 -30
  325. package/validation/tests/jobProperties/role.json +25 -5
  326. package/validation/tests/jobProperties/secHeading.json +26 -21
  327. package/validation/tests/jobProperties/styleDiff.json +35 -5
  328. package/validation/tests/jobProperties/tabNav.json +3 -1
  329. package/validation/tests/jobProperties/targetsNear.json +237 -0
  330. package/validation/tests/jobProperties/textNodes.json +29 -29
  331. package/validation/tests/jobProperties/textSem.json +26 -1
  332. package/validation/tests/jobProperties/title.json +12 -2
  333. package/validation/tests/jobProperties/titledEl.json +39 -9
  334. package/validation/tests/jobProperties/userPath.json +317 -0
  335. package/validation/tests/jobProperties/zIndex.json +40 -35
  336. package/validation/tests/targets/allCapStyle/index.html +26 -0
  337. package/validation/tests/targets/checkpoint/index.html +38 -0
  338. package/validation/tests/targets/datalistRef/index.html +1 -1
  339. package/validation/tests/targets/focAndOp/bad.html +29 -0
  340. package/validation/tests/targets/{focOp → focAndOp}/good.html +3 -1
  341. package/validation/tests/targets/focInd/bad.html +2 -1
  342. package/validation/tests/targets/headEl/index.html +10 -1
  343. package/validation/tests/targets/{targetSmall → targetsNear}/index.html +15 -1
  344. package/validation/tests/targets/userPath/index.html +42 -0
  345. package/validation/validateTest.js +61 -9
  346. package/.claude/settings.local.json +0 -11
  347. package/.eslintrc.json +0 -41
  348. package/htmlcs/.eslintrc.json +0 -67
  349. package/validation/tests/jobProperties/linkTitle.json +0 -127
  350. package/validation/tests/jobProperties/opFoc.json +0 -164
  351. package/validation/tests/jobProperties/targetSmall.json +0 -152
  352. package/validation/tests/jobProperties/targetTiny.json +0 -142
  353. package/validation/tests/targets/focOp/bad.html +0 -25
  354. package/validation/tests/targets/linkTitle/index.html +0 -24
  355. package/validation/tests/targets/opFoc/bad.html +0 -26
  356. package/validation/tests/targets/opFoc/good.html +0 -23
package/procs/doActs.js CHANGED
@@ -16,12 +16,22 @@
16
16
  // IMPORTS
17
17
 
18
18
  const {addError} = require('./error');
19
- const {getNonce, goTo, launch, wait} = require('./launch');
20
- const {tools} = require('./job');
19
+ const {closeSharedBrowser, launch} = require('./launch');
20
+ // Function to perform a test act in this process (browser and page isolation).
21
+ const {performTestAct} = require('./testAct');
22
+ const {tools, toolInputs, toolScopes} = require('./job');
23
+ // Function to find the subtrees changed since the previous checkpoint.
24
+ const {getChangedRoots} = require('./scope');
25
+ // Function to build the running list of issues across checkpoints.
26
+ const {getFlow} = require('./flow');
21
27
  const {fork} = require('child_process');
22
28
  const {pruneCatalog} = require('./catalog');
23
- // Function to take a full-page screenshot.
24
- const {shoot} = require('./shoot');
29
+ // Function to perform an act on a live page.
30
+ const {doInteractionAct, defaultInteraction} = require('./actDo');
31
+ // Function to snapshot a page state as a checkpoint.
32
+ const {makeCheckpoint} = require('./checkpoint');
33
+ // Function to get an empty standard result.
34
+ const {getStandardResult} = require('./standard');
25
35
  // Module to handle file system operations.
26
36
  const {applyMultiplier} = require('./config');
27
37
  const fs = require('fs/promises');
@@ -29,17 +39,6 @@ const path = require('path');
29
39
 
30
40
  // CONSTANTS
31
41
 
32
- // CSS selectors for targets of moves.
33
- const moves = {
34
- button: 'button, [role=button], input[type=submit]',
35
- checkbox: 'input[type=checkbox]',
36
- focus: true,
37
- link: 'a, [role=link]',
38
- radio: 'input[type=radio]',
39
- search: 'input[type=search], input[aria-label*=search i], input[placeholder*=search i]',
40
- select: 'select',
41
- text: 'input'
42
- };
43
42
  // Seconds to wait between actions.
44
43
  const waits = Number.parseInt(process.env.WAITS) || 0;
45
44
  // Time limits in seconds on tools, accounting for page reloads by 6 Testaro tests.
@@ -52,7 +51,9 @@ const timeLimits = {
52
51
  ibm: 30,
53
52
  nuVal: 40,
54
53
  nuVnu: 25,
54
+ pour: 35,
55
55
  qualWeb: 45,
56
+ surea11y: 35,
56
57
  testaro: 200 + Math.round(6 * waits / 1000),
57
58
  wave: 25
58
59
  };
@@ -61,12 +62,45 @@ const abortAssertively = process.env.ABORT_ASSERTIVELY === 'true';
61
62
 
62
63
  // FUNCTIONS
63
64
 
64
- // Normalizes spacing characters and cases in a string.
65
- const debloat = string => string.replace(/\s/g, ' ').trim().replace(/ {2,}/g, ' ').toLowerCase();
66
- // Returns the first line of an error message.
67
- const errorStart = error => error.message.replace(/\n.+/s, '');
65
+ // Returns a view of a standard instance that includes the legacy element properties (tagName,
66
+ // id, excerpt, location, boxID, pathID, text) derived from its catalog entry, so that
67
+ // expectations written before the catalog existed can still be evaluated. A summary instance
68
+ // (no catalog entry) gets the empty values that legacy summary instances carried.
69
+ const getLegacyInstanceView = (instance, catalog) => {
70
+ const view = {... instance};
71
+ const entry = instance.catalogIndex === undefined ? null : catalog?.[String(instance.catalogIndex)];
72
+ if (! entry) {
73
+ view.tagName ??= '';
74
+ view.id ??= '';
75
+ view.excerpt ??= '';
76
+ view.location ??= {doc: '', type: '', spec: {}};
77
+ return view;
78
+ }
79
+ ['tagName', 'id', 'pathID', 'boxID', 'text', 'startTag'].forEach(key => {
80
+ if (view[key] === undefined && entry[key] !== undefined) {
81
+ view[key] = entry[key];
82
+ }
83
+ });
84
+ // The legacy excerpt was the element text, or its markup if it had no text.
85
+ if (view.excerpt === undefined) {
86
+ view.excerpt = entry.text || entry.startTag || '';
87
+ }
88
+ if (view.location === undefined) {
89
+ if (entry.id) {
90
+ view.location = {doc: 'dom', type: 'selector', spec: `#${entry.id}`};
91
+ }
92
+ else if (entry.boxID) {
93
+ const [x, y, width, height] = entry.boxID.split(/[:,]/).map(Number);
94
+ view.location = {doc: 'dom', type: 'box', spec: {x, y, width, height}};
95
+ }
96
+ else if (entry.pathID) {
97
+ view.location = {doc: 'dom', type: 'xpath', spec: entry.pathID};
98
+ }
99
+ }
100
+ return view;
101
+ };
68
102
  // Returns a property value and whether it satisfies an expectation.
69
- const isTrue = (object, specs) => {
103
+ const isTrue = exports.isTrue = (object, specs, catalog) => {
70
104
  const property = specs[0];
71
105
  const propertyTree = property.split('.');
72
106
  // Test-act expectations reference results by property path. Most fixtures use the
@@ -85,6 +119,13 @@ const isTrue = (object, specs) => {
85
119
  while (propertyTree.length > 1 && actual !== undefined) {
86
120
  propertyTree.shift();
87
121
  actual = actual[propertyTree[0]];
122
+ // If the value is a standard instance, include its legacy element view.
123
+ if (
124
+ actual && typeof actual === 'object' && catalog
125
+ && actual.ruleID !== undefined && actual.ordinalSeverity !== undefined
126
+ ) {
127
+ actual = getLegacyInstanceView(actual, catalog);
128
+ }
88
129
  }
89
130
  // If the expectation is that the property does not exist:
90
131
  if (specs.length === 1) {
@@ -138,105 +179,6 @@ const getActBrowserID = (report, actIndex) => report?.acts[actIndex]?.browserID
138
179
  const getActTargetURL = (report, actIndex) => report?.acts[actIndex]?.target?.url
139
180
  || report?.target?.url
140
181
  || '';
141
- // Returns the text of an element, lower-cased.
142
- const textOf = async (page, element) => {
143
- if (element) {
144
- const tagNameJSHandle = await element.getProperty('tagName');
145
- const tagName = await tagNameJSHandle.jsonValue();
146
- let totalText = '';
147
- // If the element is a link, button, input, or select list:
148
- if (['A', 'BUTTON', 'INPUT', 'SELECT'].includes(tagName)) {
149
- // Return its visible labels, descriptions, and legend if the first input in a fieldset.
150
- totalText = await page.evaluate(element => {
151
- const {tagName, ariaLabel} = element;
152
- let ownText = '';
153
- if (['A', 'BUTTON'].includes(tagName)) {
154
- ownText = element.textContent;
155
- }
156
- else if (tagName === 'INPUT' && element.type === 'submit') {
157
- ownText = element.value;
158
- }
159
- // HTML link elements have no labels property.
160
- const labels = tagName !== 'A' ? Array.from(element.labels) : [];
161
- const labelTexts = labels.map(label => label.textContent);
162
- if (ariaLabel) {
163
- labelTexts.push(ariaLabel);
164
- }
165
- const refIDs = new Set([
166
- element.getAttribute('aria-labelledby') || '',
167
- element.getAttribute('aria-describedby') || ''
168
- ].join(' ').split(/\s+/));
169
- if (refIDs.size) {
170
- refIDs.forEach(id => {
171
- const labeler = document.getElementById(id);
172
- if (labeler) {
173
- const labelerText = labeler.textContent.trim();
174
- if (labelerText.length) {
175
- labelTexts.push(labelerText);
176
- }
177
- }
178
- });
179
- }
180
- let legendText = '';
181
- if (tagName === 'INPUT') {
182
- const fieldsets = Array.from(document.body.querySelectorAll('fieldset'));
183
- const inputFieldsets = fieldsets.filter(fieldset => {
184
- const inputs = Array.from(fieldset.querySelectorAll('input'));
185
- return inputs.length && inputs[0] === element;
186
- });
187
- const inputFieldset = inputFieldsets[0] || null;
188
- if (inputFieldset) {
189
- const legend = inputFieldset.querySelector('legend');
190
- if (legend) {
191
- legendText = legend.textContent;
192
- }
193
- }
194
- }
195
- return [legendText].concat(labelTexts, ownText).join(' ');
196
- }, element);
197
- }
198
- // Otherwise, if it is an option:
199
- else if (tagName === 'OPTION') {
200
- // Return its text content, prefixed with the text of its select parent if the first option.
201
- const ownText = await element.textContent();
202
- const indexJSHandle = await element.getProperty('index');
203
- const index = await indexJSHandle.jsonValue();
204
- if (index) {
205
- totalText = ownText;
206
- }
207
- else {
208
- const selectJSHandle = await page.evaluateHandle(
209
- element => element.parentElement, element
210
- );
211
- const select = await selectJSHandle.asElement();
212
- if (select) {
213
- const selectText = await textOf(page, select);
214
- totalText = [ownText, selectText].join(' ');
215
- }
216
- else {
217
- totalText = ownText;
218
- }
219
- }
220
- }
221
- // Otherwise, i.e. if it is not an input, select, or option:
222
- else {
223
- // Get its text content.
224
- totalText = await element.textContent();
225
- }
226
- return debloat(totalText);
227
- }
228
- else {
229
- return null;
230
- }
231
- };
232
- // Adds a wait error result to an act.
233
- const waitError = (page, act, error, what) => {
234
- console.log(`ERROR waiting for ${what} (${error.message})`);
235
- act.result.found = false;
236
- act.result.url = page.url();
237
- act.result.error = `ERROR waiting for ${what}`;
238
- return false;
239
- };
240
182
  // Performs the acts in a report and adds the results to the report.
241
183
  exports.doActs = async (report, opts = {}) => {
242
184
  // Fire-and-forget progress emitter (see #44), mirroring run.js: a handler
@@ -258,6 +200,8 @@ exports.doActs = async (report, opts = {}) => {
258
200
  let {acts} = tempReport;
259
201
  // Get the standardization specification.
260
202
  const standard = tempReport.standard || 'only';
203
+ // Get the isolation level of test acts.
204
+ const isolation = tempReport.jobData.isolation || 'process';
261
205
  // Get the path to a writable temporary directory.
262
206
  const {tmpDir} = report.jobData;
263
207
  let reportPath;
@@ -265,15 +209,66 @@ exports.doActs = async (report, opts = {}) => {
265
209
  reportPath = path.join(tmpDir, `${tempReport.id}.json`);
266
210
  // Initialize the count of completed acts.
267
211
  let actCount = 0;
268
- // For each act in the temporary report:
269
- for (const actIndex in acts) {
212
+ /*
213
+ Checkpoint bookkeeping. Checkpoint mode is on when the job has a checkpoint act; then a
214
+ test act observes the latest checkpoint, and interaction acts since the last checkpoint
215
+ make an implicit one before a test act. In legacy mode (no checkpoint act) every test act
216
+ observes checkpoint 0, as before, and such interaction acts only draw a warning.
217
+ */
218
+ const checkpointMode = acts.some(act => act.type === 'checkpoint');
219
+ const interaction = defaultInteraction;
220
+ // The URL the page most recently navigated to (by a launch or url act) and that act's index.
221
+ let lastNavURL = '';
222
+ let lastLaunchActIndex = null;
223
+ // Indexes of interaction acts since the last navigation (a checkpoint's replay) and since the
224
+ // last checkpoint (what makes a checkpoint necessary).
225
+ let sinceNav = [];
226
+ let dirtySince = [];
227
+ // Adds a warning to the job data.
228
+ const addWarning = message => {
229
+ tempReport.jobData.warnings ??= [];
230
+ tempReport.jobData.warnings.push(message);
231
+ console.log(`WARNING: ${message}`);
232
+ };
233
+ // Creates a checkpoint from the live page and returns it, or null on failure.
234
+ const checkpointNow = async (name, checkpointActIndex, implicit) => {
235
+ emitProgress({event: 'checkpointStart', index: tempReport.checkpoints.length, name});
236
+ try {
237
+ const checkpoint = await makeCheckpoint({
238
+ page,
239
+ report: tempReport,
240
+ name,
241
+ actIndex: checkpointActIndex,
242
+ implicit,
243
+ launchActIndex: lastLaunchActIndex,
244
+ launchURL: lastNavURL,
245
+ replay: [... sinceNav],
246
+ interaction
247
+ });
248
+ dirtySince = [];
249
+ emitProgress({
250
+ event: 'checkpointEnd',
251
+ index: checkpoint.index,
252
+ name,
253
+ kind: checkpoint.kind,
254
+ elapsedMs: checkpoint.elapsedMs
255
+ });
256
+ return checkpoint;
257
+ }
258
+ catch(error) {
259
+ console.log(`ERROR: Checkpoint ${name} failed (${error.message})`);
260
+ return null;
261
+ }
262
+ };
263
+ // For each act in the temporary report (a numeric index, which a next act may change):
264
+ for (let actIndex = 0; actIndex < acts.length; actIndex++) {
270
265
  // If the job has not been aborted:
271
266
  if (tempReport?.jobData && ! tempReport.jobData.aborted) {
272
267
  let act = acts[actIndex];
273
268
  const {type, which} = act;
274
269
  // #44: signal act start so a consumer can heartbeat and advance a live
275
270
  // per-act (per-tool, for test acts) view while the act runs.
276
- emitProgress({event: 'actStart', index: Number(actIndex), type, which});
271
+ emitProgress({event: 'actStart', index: actIndex, type, which});
277
272
  const actSuffix = type === 'test' ? ` ${which}` : '';
278
273
  const message = `>>>> ${type}${actSuffix}`;
279
274
  // Log the act.
@@ -283,10 +278,12 @@ exports.doActs = async (report, opts = {}) => {
283
278
  const condition = act.if;
284
279
  const logSuffix = condition.length === 3 ? ` ${condition[1]} ${condition[2]}` : '';
285
280
  console.log(`>> ${condition[0]}${logSuffix}`);
286
- // Identify the act to be checked.
287
- const ifActIndex = acts.map(act => act.type !== 'next').lastIndexOf(true);
281
+ // Identify the act to be checked: the last non-next act before this one.
282
+ const ifActIndex = acts.slice(0, actIndex).map(act => act.type !== 'next').lastIndexOf(true);
288
283
  // Determine whether its jump condition is true.
289
- const truth = isTrue(acts[ifActIndex].result, condition);
284
+ const truth = ifActIndex > -1
285
+ ? isTrue(acts[ifActIndex].result, condition, tempReport.catalog)
286
+ : [null, false];
290
287
  // Add the result to the act.
291
288
  act.result = {
292
289
  property: condition[0],
@@ -304,31 +301,76 @@ exports.doActs = async (report, opts = {}) => {
304
301
  }
305
302
  // Otherwise, if there is a numerical jump:
306
303
  else if (act.jump) {
307
- // Set the act index accordingly.
304
+ // Set the act index accordingly (the loop increments it).
308
305
  actIndex += act.jump - 1;
309
306
  }
310
307
  // Otherwise, if there is a named next act:
311
308
  else if (act.next) {
312
309
  // Set the new index accordingly, or stop if it does not exist.
313
- actIndex = acts.map(act => act.name).indexOf(act.next) - 1;
310
+ const nextIndex = acts.findIndex(act => act.name === act.next);
311
+ if (nextIndex === -1) {
312
+ break;
313
+ }
314
+ actIndex = nextIndex - 1;
314
315
  }
315
316
  }
316
317
  }
317
318
  // Otherwise, if the act is a launch:
318
319
  else if (type === 'launch') {
319
320
  // Launch a browser, navigate, optionally make a screenshot, and add the result to the act.
321
+ const imageScale = Number.isFinite(tempReport.imageScale) && tempReport.imageScale > 1
322
+ ? tempReport.imageScale
323
+ : 1;
320
324
  page = await launch({
321
325
  report: tempReport,
322
326
  actIndex,
323
327
  tempBrowserID: getActBrowserID(tempReport, actIndex),
324
328
  tempURL: getActTargetURL(tempReport, actIndex),
325
329
  xPathNeed: 'none',
326
- shoot: act.shoot
330
+ contextOverrides: imageScale > 1 ? {deviceScaleFactor: imageScale} : {},
331
+ replay: false
327
332
  });
328
- // If this failed:
333
+ // If this failed (launch has already logged and, if so configured, aborted):
329
334
  if (! page) {
330
335
  // Report this.
331
- addError(false, false, tempReport, actIndex, page.error ?? '');
336
+ addError(false, false, tempReport, actIndex, 'ERROR: Launch failed');
337
+ }
338
+ // Otherwise, record the navigation for checkpoints: a launch of a page other than the
339
+ // latest checkpoint's calls for a new checkpoint.
340
+ else {
341
+ lastNavURL = page.url();
342
+ lastLaunchActIndex = actIndex;
343
+ sinceNav = [];
344
+ const latest = tempReport.checkpoints?.at(-1);
345
+ if (latest && page.url() !== latest.url) {
346
+ dirtySince.push(actIndex);
347
+ }
348
+ }
349
+ }
350
+ // Otherwise, if the act is a checkpoint:
351
+ else if (type === 'checkpoint') {
352
+ // If a page exists:
353
+ if (page && tempReport.checkpoints) {
354
+ // Snapshot it as the checkpoint.
355
+ const startTime = Date.now();
356
+ const checkpoint = await checkpointNow(act.which, actIndex, false);
357
+ act.result = checkpoint
358
+ ? {
359
+ success: true,
360
+ index: checkpoint.index,
361
+ kind: checkpoint.kind,
362
+ url: checkpoint.url,
363
+ title: checkpoint.title,
364
+ elementCount: checkpoint.elementCount,
365
+ imageIndexes: checkpoint.imageIndexes,
366
+ elapsedMs: Date.now() - startTime
367
+ }
368
+ : {success: false, error: 'Checkpoint failed'};
369
+ }
370
+ // Otherwise, i.e. if no page exists:
371
+ else {
372
+ // Report this.
373
+ addError(true, false, tempReport, actIndex, 'ERROR: No page to checkpoint');
332
374
  }
333
375
  }
334
376
  // Otherwise, if the act is a test act:
@@ -339,110 +381,236 @@ exports.doActs = async (report, opts = {}) => {
339
381
  const startTime = Date.now();
340
382
  // Add it to the act.
341
383
  act.startTime = startTime;
342
- let tempReportJSON = JSON.stringify(tempReport);
343
- // Save a copy of the temporary report, which the child process will read.
344
- await fs.writeFile(reportPath, tempReportJSON);
384
+ // Assign the act to the current checkpoint (0, the launch page, unless checkpoint acts
385
+ // have created later ones), which the child reads as report.activeCheckpoint.
386
+ if (tempReport.checkpoints) {
387
+ const ownTarget = Boolean(act.launch?.target?.url || act.target?.url);
388
+ // If interaction acts have run since the last checkpoint:
389
+ if (dirtySince.length) {
390
+ // In checkpoint mode, make an implicit checkpoint so the act tests the current state.
391
+ if (checkpointMode && page && ! ownTarget) {
392
+ addWarning(
393
+ `Implicit checkpoint before act ${actIndex}; add an explicit checkpoint act`
394
+ );
395
+ await checkpointNow(`act${actIndex}`, actIndex, true);
396
+ }
397
+ // Otherwise, warn that the act tests the launch page, not the current state.
398
+ else if (! ownTarget) {
399
+ addWarning(
400
+ `Test act ${actIndex} follows interaction acts but tests checkpoint 0; add a checkpoint act to test the current page state`
401
+ );
402
+ }
403
+ }
404
+ // A test act with its own target tests that target, outside the checkpoints.
405
+ act.checkpoint = ownTarget ? null : tempReport.checkpoints.length - 1;
406
+ tempReport.activeCheckpoint = act.checkpoint;
407
+ if (act.checkpoint !== null) {
408
+ tempReport.checkpoints[act.checkpoint].testActs.push(actIndex);
409
+ }
410
+ }
411
+ // If the tool tests a URL and the checkpoint was reached by interaction, it cannot
412
+ // observe the checkpoint: prevent the act with the reason.
413
+ const activeCheckpoint = tempReport.checkpoints?.[act.checkpoint];
414
+ if (activeCheckpoint && activeCheckpoint.kind === 'interaction') {
415
+ const testsURL = toolInputs[act.which] === 'url'
416
+ || (['nuVal', 'nuVnu'].includes(act.which) && act.withSource);
417
+ if (testsURL) {
418
+ const reason = `Tool ${act.which} tests a URL, not a page state reached by interaction (checkpoint ${act.checkpoint})`;
419
+ act.data = {prevented: true, error: reason};
420
+ act.result = standard !== 'no'
421
+ ? {standardResult: {... getStandardResult(), prevented: true}}
422
+ : {};
423
+ tempReport.jobData.preventions[act.which] = reason;
424
+ addError(true, false, tempReport, actIndex, `ERROR: ${reason}`);
425
+ act.endTime = Date.now();
426
+ actCount++;
427
+ continue;
428
+ }
429
+ }
430
+ // If the act is to test only the subtrees changed since the previous checkpoint, find
431
+ // them, and give them to the tool (report.scope) if it can restrict itself to them.
432
+ tempReport.scope = null;
433
+ if (act.scope === 'changed') {
434
+ const changed = getChangedRoots(tempReport, act.checkpoint);
435
+ const scopable = Boolean(toolScopes[act.which]);
436
+ const applied = changed.applied && scopable;
437
+ act.data = {
438
+ ... (act.data ?? {}),
439
+ scope: {
440
+ requested: 'changed',
441
+ applied,
442
+ reason: scopable ? changed.reason : `Tool ${act.which} cannot restrict its tests to subtrees`,
443
+ roots: changed.roots,
444
+ pathIDs: changed.pathIDs,
445
+ commonRoot: changed.commonRoot
446
+ }
447
+ };
448
+ if (applied) {
449
+ tempReport.scope = {roots: changed.roots, commonRoot: changed.commonRoot};
450
+ }
451
+ else {
452
+ addWarning(`Test act ${actIndex} tests the whole page (${act.data.scope.reason})`);
453
+ }
454
+ }
345
455
  let timedOut = false;
346
456
  const limitMs = applyMultiplier(1000 * (timeLimits[act.which] || 15));
347
- const actResult = await new Promise(resolve => {
348
- let closed = false;
349
- // Create a child process to perform the act.
350
- const child = fork(`${__dirname}/doTestAct`, [reportPath, actIndex]);
351
- let killTimer = null;
352
- // Start a timeout timer for the child process.
353
- const timeoutTimer = setTimeout(() => {
354
- if (! timedOut) {
355
- timedOut = true;
356
- console.log(`ERROR: Timed out at ${Math.round(limitMs / 1000)} seconds`);
357
- child.kill('SIGTERM');
358
- killTimer = setTimeout(() => {
359
- if (! closed) {
360
- console.log('ERROR: Failed to exit on SIGTERM from parent')
457
+ // If test acts run in child processes:
458
+ if (isolation === 'process') {
459
+ let tempReportJSON = JSON.stringify(tempReport);
460
+ // Save a copy of the temporary report, which the child process will read.
461
+ await fs.writeFile(reportPath, tempReportJSON);
462
+ const limitMs = applyMultiplier(1000 * (timeLimits[act.which] || 15));
463
+ const actResult = await new Promise(resolve => {
464
+ let closed = false;
465
+ // Create a child process to perform the act.
466
+ const child = fork(`${__dirname}/doTestAct`, [reportPath, actIndex]);
467
+ let killTimer = null;
468
+ // Start a timeout timer for the child process.
469
+ const timeoutTimer = setTimeout(() => {
470
+ if (! timedOut) {
471
+ timedOut = true;
472
+ console.log(`ERROR: Timed out at ${Math.round(limitMs / 1000)} seconds`);
473
+ child.kill('SIGTERM');
474
+ killTimer = setTimeout(() => {
475
+ if (! closed) {
476
+ console.log('ERROR: Failed to exit on SIGTERM from parent');
477
+ }
478
+ child.kill('SIGKILL');
479
+ }, 2000);
480
+ }
481
+ }, limitMs);
482
+ // Clears any current timers.
483
+ const clearTimers = () => {
484
+ [timeoutTimer, killTimer].forEach(timer => {
485
+ if (timer) {
486
+ clearTimeout(timer);
361
487
  }
362
- child.kill('SIGKILL');
363
- }, 2000);
364
- }
365
- }, limitMs);
366
- // Clears any current timers.
367
- const clearTimers = () => {
368
- [timeoutTimer, killTimer].forEach(timer => {
369
- if (timer) {
370
- clearTimeout(timer);
488
+ });
489
+ };
490
+ // If the child process sends a message (normally Act completed):
491
+ child.on('message', message => {
492
+ if (! closed) {
493
+ closed = true;
494
+ clearTimers();
495
+ // Return the message.
496
+ resolve({
497
+ kind: 'message',
498
+ message
499
+ });
500
+ }
501
+ });
502
+ // If the child process sends an error:
503
+ child.on('error', error => {
504
+ if (! closed) {
505
+ closed = true;
506
+ clearTimers();
507
+ // Return the error message.
508
+ resolve({
509
+ kind: 'error',
510
+ error: error.message
511
+ });
512
+ }
513
+ });
514
+ // If the child process closes:
515
+ child.on('close', (code, signal) => {
516
+ if (! closed) {
517
+ closed = true;
518
+ clearTimers();
519
+ // Return the exit code, signal, and timeout status.
520
+ resolve({
521
+ kind: 'close',
522
+ code,
523
+ signal,
524
+ timedOut
525
+ });
371
526
  }
372
527
  });
373
- };
374
- // If the child process sends a message (normally Act completed):
375
- child.on('message', message => {
376
- if (! closed) {
377
- closed = true;
378
- clearTimers();
379
- // Return the message.
380
- resolve({
381
- kind: 'message',
382
- message
383
- });
384
- }
385
528
  });
386
- // If the child process sends an error:
387
- child.on('error', error => {
388
- if (! closed) {
389
- closed = true;
390
- clearTimers();
391
- // Return the error message.
392
- resolve({
393
- kind: 'error',
394
- error: error.message
395
- });
529
+ // If the child process sent a message:
530
+ if (actResult.kind === 'message') {
531
+ // Get the revised tempReport file.
532
+ tempReportJSON = await fs.readFile(reportPath, 'utf8');
533
+ try {
534
+ // Reassign it to the temporary report.
535
+ tempReport = JSON.parse(tempReportJSON);
536
+ // Redefine the acts as those in the revised temporary report.
537
+ ({acts} = tempReport);
396
538
  }
397
- });
398
- // If the child process closes:
399
- child.on('close', (code, signal) => {
400
- if (! closed) {
401
- closed = true;
402
- clearTimers();
403
- // Return the exit code, signal, and timeout status.
404
- resolve({
405
- kind: 'close',
406
- code,
407
- signal,
408
- timedOut
409
- });
539
+ // If the reassignment fails, leaving the temporary report and its acts unchanged:
540
+ catch (error) {
541
+ // Report this.
542
+ console.log(
543
+ `ERROR: Tool sent message ${actResult.message}. Report is no longer JSON (${error.message}) but is instead a(n) ${typeof tempReportJSON} of length ${tempReportJSON.length}:\n${tempReportJSON}`
544
+ );
545
+ // Report this and that the job was aborted.
546
+ addError(
547
+ false,
548
+ true,
549
+ tempReport,
550
+ actIndex,
551
+ `Non-JSON temporary report file after message ${actResult.message}`
552
+ );
553
+ // Stop processing acts.
554
+ break;
410
555
  }
411
- });
412
- });
413
- // If the child process sent a message:
414
- if (actResult.kind === 'message') {
415
- // Get the revised tempReport file.
416
- tempReportJSON = await fs.readFile(reportPath, 'utf8');
417
- try {
418
- // Reassign it to the temporary report.
419
- tempReport = JSON.parse(tempReportJSON);
420
- // Redefine the acts as those in the revised temporary report.
421
- ({acts} = tempReport);
422
556
  }
423
- // If the reassignment fails, leaving the temporary report and its acts unchanged:
424
- catch (error) {
425
- // Report this.
426
- console.log(
427
- `ERROR: Tool sent message ${actResult.message}. Report is no longer JSON (${error.message}) but is instead a(n) ${typeof tempReportJSON} of length ${tempReportJSON.length}:\n${tempReportJSON}`
428
- );
429
- // Report this and that the job was aborted.
430
- addError(
431
- false,
432
- true,
433
- tempReport,
434
- actIndex,
435
- `Non-JSON temporary report file after message ${actResult.message}`
436
- );
437
- // Stop processing acts.
438
- break;
557
+ // Otherwise, i.e. if the child process closed abnormally:
558
+ else {
559
+ // Report this and, if so configured, that the job was aborted.
560
+ const {code, error, kind, signal} = actResult;
561
+ if (kind === 'close' && timedOut) {
562
+ addError(
563
+ false,
564
+ abortAssertively,
565
+ tempReport,
566
+ actIndex,
567
+ `Timed out at ${Math.round(limitMs / 1000)} seconds`
568
+ );
569
+ }
570
+ else if (kind === 'close') {
571
+ addError(
572
+ true,
573
+ abortAssertively,
574
+ tempReport,
575
+ actIndex,
576
+ `Closed with code ${code} and signal ${signal})`
577
+ );
578
+ }
579
+ else {
580
+ addError(
581
+ true, abortAssertively, tempReport, actIndex, `Terminated with error ${error}`
582
+ );
583
+ }
584
+ // If the job was aborted:
585
+ if (abortAssertively) {
586
+ // Stop processing acts.
587
+ break;
588
+ }
439
589
  }
440
590
  }
441
- // Otherwise, i.e. if the child process closed abnormally:
591
+ // Otherwise, i.e. if test acts run in this process (browser or page isolation):
442
592
  else {
443
- // Report this and, if so configured, that the job was aborted.
444
- const {code, error, kind, signal} = actResult;
445
- if (kind === 'close' && timedOut) {
593
+ // Perform the act on the live page (page isolation) or a fresh context, within the
594
+ // time limit. A tool that overruns cannot be killed in-process; the act is reported
595
+ // as timed out and the job continues.
596
+ let timer;
597
+ const timeout = new Promise(resolve => {
598
+ timer = setTimeout(() => {
599
+ timedOut = true;
600
+ resolve('timedOut');
601
+ }, limitMs);
602
+ });
603
+ try {
604
+ await Promise.race([
605
+ performTestAct({report: tempReport, actIndex, livePage: isolation === 'page' ? page : null}),
606
+ timeout
607
+ ]);
608
+ }
609
+ catch(error) {
610
+ addError(true, abortAssertively, tempReport, actIndex, `Tool failed (${error.message.slice(0, 200)})`);
611
+ }
612
+ clearTimeout(timer);
613
+ if (timedOut) {
446
614
  addError(
447
615
  false,
448
616
  abortAssertively,
@@ -451,28 +619,21 @@ exports.doActs = async (report, opts = {}) => {
451
619
  `Timed out at ${Math.round(limitMs / 1000)} seconds`
452
620
  );
453
621
  }
454
- else if (kind === 'close') {
455
- addError(
456
- true,
457
- abortAssertively,
458
- tempReport,
459
- actIndex,
460
- `Closed with code ${code} and signal ${signal})`
461
- );
462
- }
463
- else {
464
- addError(
465
- true, abortAssertively, tempReport, actIndex, `Terminated with error ${error}`
466
- );
467
- }
468
- // If the job was aborted:
469
- if (abortAssertively) {
470
- // Stop processing acts.
622
+ if (abortAssertively && tempReport.jobData.aborted) {
471
623
  break;
472
624
  }
473
625
  }
474
626
  // Get the (usually revised) act.
475
627
  act = acts[actIndex];
628
+ // The scope was for this act only.
629
+ delete tempReport.scope;
630
+ delete tempReport.ruleScopeRoots;
631
+ // Stamp the act's checkpoint on its standard instances.
632
+ if (act.checkpoint !== undefined) {
633
+ (act.result?.standardResult?.instances ?? []).forEach(instance => {
634
+ instance.checkpoint = act.checkpoint;
635
+ });
636
+ }
476
637
  // Add the elapsed time of the tool to the temporary report.
477
638
  const time = Math.round((Date.now() - startTime) / 1000);
478
639
  const {toolTimes} = tempReport.jobData;
@@ -489,7 +650,7 @@ exports.doActs = async (report, opts = {}) => {
489
650
  // For each expectation:
490
651
  expectations.forEach(spec => {
491
652
  // Add its result to the act.
492
- const truth = isTrue(act, spec);
653
+ const truth = isTrue(act, spec, tempReport.catalog);
493
654
  act.expectations.push({
494
655
  property: spec[0],
495
656
  relation: spec[1],
@@ -509,9 +670,10 @@ exports.doActs = async (report, opts = {}) => {
509
670
  // elapsed time, so consumers need not re-derive them from the report.
510
671
  emitProgress({
511
672
  event: 'actEnd',
512
- index: Number(actIndex),
673
+ index: actIndex,
513
674
  type,
514
675
  which,
676
+ checkpoint: act.checkpoint,
515
677
  outcome: timedOut
516
678
  ? 'timedOut'
517
679
  : act.data && act.data.prevented ? 'prevented' : 'success',
@@ -520,613 +682,24 @@ exports.doActs = async (report, opts = {}) => {
520
682
  }
521
683
  // Otherwise, if a current page exists:
522
684
  else if (page) {
523
- // If the act is navigation to a url:
524
- if (type === 'url') {
525
- // Identify the URL.
526
- const resolved = act.which.replace('__dirname', __dirname);
527
- requestedURL = resolved;
528
- // Visit it and wait until the DOM is loaded.
529
- const navResult = await goTo(tempReport, page, requestedURL, 15000, 'domcontentloaded');
530
- // If the visit succeeded:
531
- if (navResult.success) {
532
- // Revise the temporary report URL to this URL.
533
- tempReport.target.url = requestedURL;
534
- // Add the script nonce, if any, to the act.
535
- const {response} = navResult;
536
- const scriptNonce = getNonce(response);
537
- if (scriptNonce) {
538
- tempReport.jobData.lastScriptNonce = scriptNonce;
539
- }
540
- // Add the resulting URL to the act.
541
- if (! act.result) {
542
- act.result = {};
543
- }
544
- act.result.url = page.url();
545
- // If a prohibited redirection occurred:
546
- if (response.exception === 'badRedirection') {
547
- // Report this.
548
- addError(true, false, tempReport, actIndex, 'ERROR: Navigation illicitly redirected');
549
- }
550
- }
551
- // Otherwise, i.e. if the visit failed:
552
- else {
553
- // Report this.
554
- addError(true, false, tempReport, actIndex, 'ERROR: Visit failed');
555
- }
556
- }
557
- // Otherwise, if the act is a wait for text:
558
- else if (type === 'wait') {
559
- const {what, which} = act;
560
- console.log(`>> ${what}`);
561
- const result = act.result = {};
562
- // If the text is to be the URL:
563
- if (what === 'url') {
564
- // Wait for the URL to be the exact text.
565
- try {
566
- await page.waitForURL(which, {timeout: 15000});
567
- result.found = true;
568
- result.url = page.url();
569
- }
570
- // If the wait times out:
571
- catch(error) {
572
- // Report this.
573
- waitError(page, act, error, 'text in the URL');
574
- }
575
- }
576
- // Otherwise, if the text is to be a substring of the page title:
577
- else if (what === 'title') {
578
- // Wait for the page title to include the text, case-insensitively.
579
- try {
580
- await page.waitForFunction(
581
- text => document
582
- && document.title
583
- && document.title.toLowerCase().includes(text.toLowerCase()),
584
- which,
585
- {
586
- polling: 1000,
587
- timeout: 5000
588
- }
589
- );
590
- result.found = true;
591
- result.title = await page.title();
592
- }
593
- // If the wait times out:
594
- catch(error) {
595
- // Report this.
596
- waitError(page, act, error, 'text in the title');
597
- }
598
- }
599
- // Otherwise, if the text is to be a substring of the text of the page body:
600
- else if (what === 'body') {
601
- // Wait for the body to include the text, case-insensitively.
602
- try {
603
- await page.waitForFunction(
604
- text => document
605
- && document.body
606
- && document.body.innerText.toLowerCase().includes(text.toLowerCase()),
607
- which,
608
- {
609
- polling: 2000,
610
- timeout: 15000
611
- }
612
- );
613
- result.found = true;
614
- }
615
- // If the wait times out:
616
- catch(error) {
617
- // Report this.
618
- waitError(page, act, error, 'text in the body');
619
- }
685
+ // Perform the act on it (a page act replaces the page).
686
+ page = await doInteractionAct({page, report: tempReport, act, actIndex, actCount, interaction});
687
+ // Record the act for checkpoints: a successful url act is a navigation; a move, key
688
+ // press, revelation, or tab switch is an interaction the next checkpoint must replay.
689
+ if (type === 'url' && act.result?.success) {
690
+ lastNavURL = page.url();
691
+ sinceNav = [];
692
+ const latest = tempReport.checkpoints?.at(-1);
693
+ if (latest && page.url() !== latest.url) {
694
+ dirtySince.push(actIndex);
620
695
  }
621
696
  }
622
- // Otherwise, if the act is a wait for a state:
623
- else if (type === 'state') {
624
- // Wait for it.
625
- const stateIndex = ['loaded', 'idle'].indexOf(act.which);
626
- await page.waitForLoadState(
627
- ['domcontentloaded', 'networkidle'][stateIndex], {timeout: [10000, 15000][stateIndex]}
628
- )
629
- // If the wait times out:
630
- .catch(async error => {
631
- // Report this.
632
- console.log(`ERROR waiting for page to be ${act.which} (${error.message})`);
633
- addError(true, false, tempReport, actIndex, `ERROR waiting for page to be ${act.which}`);
634
- });
635
- // If the wait succeeded:
636
- if (actIndex > -2) {
637
- // Add state data to the temporary report.
638
- act.result = {
639
- success: true,
640
- state: act.which
641
- };
642
- }
697
+ else if (['wait', 'state', 'shoot'].includes(type)) {
698
+ // These observe or record; they change nothing that a replay must repeat.
643
699
  }
644
- // Otherwise, if the act is a page switch:
645
- else if (type === 'page') {
646
- const context = page.context();
647
- page = await context.waitForEvent('page');
648
- // Wait until it is idle.
649
- await page.waitForLoadState('networkidle', {timeout: 15000});
650
- // Add the resulting URL to the act.
651
- const result = {
652
- url: page.url()
653
- };
654
- act.result = result;
655
- }
656
- // Otherwise, if the page has a URL:
657
- else if (page.url() && page.url() !== 'about:blank') {
658
- const url = page.url();
659
- // Add the URL to the act.
660
- act.actualURL = url;
661
- // If the act is a revelation:
662
- if (type === 'reveal') {
663
- act.result = {
664
- success: true
665
- };
666
- // Make all elements in the page visible.
667
- await page.$$eval('body *', elements => {
668
- elements.forEach(element => {
669
- const styleDec = window.getComputedStyle(element);
670
- if (styleDec.display === 'none') {
671
- element.style.display = 'initial';
672
- }
673
- if (['hidden', 'collapse'].includes(styleDec.visibility)) {
674
- element.style.visibility = 'inherit';
675
- }
676
- });
677
- })
678
- .catch(error => {
679
- console.log(`ERROR making all elements visible (${error.message})`);
680
- act.result.success = false;
681
- });
682
- }
683
- // Otherwise, if the act is a screenshot:
684
- else if (type === 'shoot') {
685
- const {exclusionSelector, colorType, action} = act;
686
- // Make and dispose of a full-page screenshot.
687
- const shotInfo = await shoot(page, tempReport, {
688
- exclusion: exclusionSelector ? page.locator(exclusionSelector) : null,
689
- colorType: [0, 2, 4, 6].includes(colorType) ? colorType : null,
690
- action: ['return', 'report', 'file'].includes(action) ? action : 'report'
691
- });
692
- // Add the PNG base-64 encoding, image index, or file path to the act result.
693
- act.result = shotInfo !== ''
694
- ? {success: true, shotInfo}
695
- : {success: false, prevented: true};
696
- }
697
- // Otherwise, if the act is a move:
698
- else if (moves[type]) {
699
- const selector = typeof moves[type] === 'string' ? moves[type] : act.what;
700
- // Try up to 5 times to:
701
- act.result = {found: false};
702
- let selection = {};
703
- let tries = 0;
704
- const slimText = act.which ? debloat(act.which) : '';
705
- while (tries++ < 5 && ! act.result.found) {
706
- if (page) {
707
- // Identify the elements of the specified type.
708
- const selections = await page.$$(selector);
709
- // If there are any:
710
- if (selections.length) {
711
- // If there are enough to make a match possible:
712
- if ((act.index || 0) < selections.length) {
713
- // For each element of the specified type:
714
- let matchCount = 0;
715
- const selectionTexts = [];
716
- for (selection of selections) {
717
- // Add its lower-case text or an empty string to the list of element texts.
718
- const selectionText = slimText ? await textOf(page, selection) : '';
719
- selectionTexts.push(selectionText);
720
- // If its text includes any specified text, case-insensitively:
721
- if (selectionText.includes(slimText)) {
722
- // If the element has the specified index among such elements:
723
- if (matchCount++ === (act.index || 0)) {
724
- // Report it as the matching element and stop checking.
725
- act.result.found = true;
726
- act.result.textSpec = slimText;
727
- act.result.textContent = selectionText;
728
- break;
729
- }
730
- }
731
- }
732
- // If no element satisfied the specifications:
733
- if (! act.result.found) {
734
- // Add the failure data to the temporary report.
735
- act.result.success = false;
736
- act.result.error = 'exhausted';
737
- act.result.typeElementCount = selections.length;
738
- if (slimText) {
739
- act.result.textElementCount = --matchCount;
740
- }
741
- act.result.message = 'Not enough specified elements exist';
742
- act.result.candidateTexts = selectionTexts;
743
- }
744
- }
745
- // Otherwise, i.e. if there are too few such elements to make a match possible:
746
- else {
747
- // Add the failure data to the temporary report.
748
- act.result.success = false;
749
- act.result.error = 'fewer';
750
- act.result.typeElementCount = selections.length;
751
- act.result.message = 'Elements of specified type too few';
752
- }
753
- }
754
- // Otherwise, i.e. if there are no elements of the specified type:
755
- else {
756
- // Add the failure data to the temporary report.
757
- act.result.success = false;
758
- act.result.error = 'none';
759
- act.result.typeElementCount = 0;
760
- act.result.message = 'No elements of specified type found';
761
- }
762
- }
763
- // Otherwise, i.e. if the page no longer exists:
764
- else {
765
- // Add the failure data to the temporary report.
766
- act.result.success = false;
767
- act.result.error = 'gone';
768
- act.result.message = 'Page gone';
769
- }
770
- if (! act.result.found) {
771
- await wait(2000);
772
- }
773
- }
774
- // If a match was found:
775
- if (act.result.found) {
776
- // FUNCTION DEFINITION START
777
- // Performs a click or Enter keypress and waits for the network to be idle.
778
- const doAndWait = async isClick => {
779
- // Perform and report the move.
780
- const move = isClick ? 'click' : 'Enter keypress';
781
- try {
782
- await isClick
783
- ? selection.click({timeout: 4000})
784
- : selection.press('Enter', {timeout: 4000});
785
- act.result.success = true;
786
- act.result.move = move;
787
- }
788
- // If the move fails:
789
- catch(error) {
790
- // Report this.
791
- addError(true, false, tempReport, actIndex, `ERROR: ${move} failed`);
792
- }
793
- if (act.result.success) {
794
- try {
795
- await page.context().waitForEvent('networkidle', {timeout: 10000});
796
- act.result.idleTimely = true;
797
- }
798
- catch(error) {
799
- console.log(`ERROR: Network busy after ${move} (${errorStart(error)})`);
800
- act.result.idleTimely = false;
801
- }
802
- // Add the page URL to the result.
803
- act.result.newURL = page.url();
804
- }
805
- };
806
- // FUNCTION DEFINITION END
807
- // If the move is a button click, perform it.
808
- if (type === 'button') {
809
- await selection.click({timeout: 3000});
810
- act.result.success = true;
811
- act.result.move = 'clicked';
812
- }
813
- // Otherwise, if it is checking a radio button or checkbox, perform it.
814
- else if (['checkbox', 'radio'].includes(type)) {
815
- await selection.waitForElementState('stable', {timeout: 2000})
816
- .catch(error => {
817
- console.log(`ERROR waiting for stable ${type} (${error.message})`);
818
- act.result.success = false;
819
- act.result.error = `ERROR waiting for stable ${type}`;
820
- });
821
- if (! act.result.error) {
822
- const isEnabled = await selection.isEnabled();
823
- if (isEnabled) {
824
- await selection.check({
825
- force: true,
826
- timeout: 2000
827
- })
828
- .catch(error => {
829
- console.log(`ERROR checking ${type} (${error.message})`);
830
- act.result.success = false;
831
- act.result.error = `ERROR checking ${type}`;
832
- });
833
- if (! act.result.error) {
834
- act.result.success = true;
835
- act.result.move = 'checked';
836
- }
837
- }
838
- else {
839
- const message = `ERROR: could not check ${type} because disabled`;
840
- act.result.success = false;
841
- act.result.error = message;
842
- }
843
- }
844
- }
845
- // Otherwise, if it is focusing the element, perform it.
846
- else if (type === 'focus') {
847
- await selection.focus({timeout: 2000});
848
- act.result.success = true;
849
- act.result.move = 'focused';
850
- }
851
- // Otherwise, if it is clicking a link:
852
- else if (type === 'link') {
853
- const href = await selection.getAttribute('href');
854
- const target = await selection.getAttribute('target');
855
- act.result.href = href || 'NONE';
856
- act.result.target = target || 'DEFAULT';
857
- // If the destination is a new page:
858
- if (target && target !== '_self') {
859
- // Click the link and wait for the network to be idle.
860
- doAndWait(true);
861
- }
862
- // Otherwise, i.e. if the destination is in the current page:
863
- else {
864
- // Click the link and wait for the resulting navigation.
865
- try {
866
- await selection.click({timeout: 5000});
867
- // Wait for the new content to load.
868
- await page.waitForLoadState('domcontentloaded', {timeout: 6000});
869
- act.result.success = true;
870
- act.result.move = 'clicked';
871
- act.result.newURL = page.url();
872
- }
873
- // If the click or load failed:
874
- catch(error) {
875
- // Report this.
876
- console.log(`ERROR clicking link (${errorStart(error)})`);
877
- act.result.success = false;
878
- act.result.error = 'unclickable';
879
- act.result.message = 'ERROR: click or load timed out';
880
- }
881
- // If the link click succeeded:
882
- if (! act.result.error) {
883
- // Add success data to the temporary report.
884
- act.result.success = true;
885
- act.result.move = 'clicked';
886
- }
887
- }
888
- }
889
- // Otherwise, if it is selecting an option in a select list, perform it.
890
- else if (type === 'select') {
891
- const options = await selection.$$('option');
892
- let optionText = '';
893
- if (options && Array.isArray(options) && options.length) {
894
- const optionTexts = [];
895
- for (const option of options) {
896
- const optionText = await option.textContent();
897
- optionTexts.push(optionText);
898
- }
899
- const matchTexts = optionTexts.map(
900
- (text, index) => text.includes(act.what) ? index : -1
901
- );
902
- const index = matchTexts.filter(text => text > -1)[act.index || 0];
903
- if (index !== undefined) {
904
- await selection.selectOption({index});
905
- optionText = optionTexts[index];
906
- }
907
- }
908
- act.result.success = true;
909
- act.result.move = 'selected';
910
- act.result.option = optionText;
911
- }
912
- // Otherwise, if it is entering text in an input element:
913
- else if (['text', 'search'].includes(type)) {
914
- act.result.attributes = {};
915
- const {attributes} = act.result;
916
- const type = await selection.getAttribute('type');
917
- const label = await selection.getAttribute('aria-label');
918
- const labelRefs = await selection.getAttribute('aria-labelledby');
919
- attributes.type = type || '';
920
- attributes.label = label || '';
921
- attributes.labelRefs = labelRefs || '';
922
- // If the text contains a placeholder for an environment variable:
923
- let {what} = act;
924
- if (/__[A-Z]+__/.test(what)) {
925
- // Replace it.
926
- const envKey = /__([A-Z]+)__/.exec(what)[1];
927
- const envValue = process.env[envKey];
928
- what = what.replace(/__[A-Z]+__/, envValue);
929
- }
930
- // Enter the text.
931
- await selection.type(what);
932
- tempReport.jobData.presses += what.length;
933
- act.result.success = true;
934
- act.result.move = 'entered';
935
- // If the input is a search input:
936
- if (type === 'search') {
937
- // Press the Enter key and wait for a network to be idle.
938
- doAndWait(false);
939
- }
940
- }
941
- // Otherwise, i.e. if the move is unknown, add the failure to the act.
942
- else {
943
- // Report the error.
944
- const message = 'ERROR: move unknown';
945
- act.result.success = false;
946
- act.result.error = message;
947
- }
948
- }
949
- // Otherwise, i.e. if no match was found:
950
- else {
951
- // RLeport this.
952
- act.result.success = false;
953
- act.result.error = 'absent';
954
- act.result.message = 'ERROR: specified element not found';
955
- console.log('ERROR: Specified element not found');
956
- }
957
- }
958
- // Otherwise, if the act is a keypress:
959
- else if (type === 'press') {
960
- // Identify the number of times to press the key.
961
- let times = 1 + (act.again || 0);
962
- tempReport.jobData.presses += times;
963
- const key = act.which;
964
- // Press the key.
965
- while (times--) {
966
- await page.keyboard.press(key);
967
- }
968
- const qualifier = act.again ? `${1 + act.again} times` : 'once';
969
- act.result = {
970
- success: true,
971
- message: `pressed ${qualifier}`
972
- };
973
- }
974
- // Otherwise, if it is a repetitive keyboard navigation:
975
- else if (type === 'presses') {
976
- const {navKey, what, which, withItems} = act;
977
- const matchTexts = which ? which.map(text => debloat(text)) : [];
978
- // Initialize the loop variables.
979
- let status = 'more';
980
- let presses = 0;
981
- let amountRead = 0;
982
- let items = [];
983
- let matchedText;
984
- // As long as a matching element has not been reached:
985
- while (status === 'more') {
986
- // Press the Escape key to dismiss any modal dialog.
987
- await page.keyboard.press('Escape');
988
- // Press the specified navigation key.
989
- await page.keyboard.press(navKey);
990
- presses++;
991
- // Identify the newly current element or a failure.
992
- const currentJSHandle = await page.evaluateHandle(actCount => {
993
- // Initialize it as the focused element.
994
- let currentElement = document.activeElement;
995
- // If it exists in the page:
996
- if (currentElement && currentElement.tagName !== 'BODY') {
997
- // Change it, if necessary, to its active descendant.
998
- if (currentElement.hasAttribute('aria-activedescendant')) {
999
- currentElement = document.getElementById(
1000
- currentElement.getAttribute('aria-activedescendant')
1001
- );
1002
- }
1003
- // Or change it, if necessary, to its selected option.
1004
- else if (currentElement.tagName === 'SELECT') {
1005
- const currentIndex = Math.max(0, currentElement.selectedIndex);
1006
- const options = currentElement.querySelectorAll('option');
1007
- currentElement = options[currentIndex];
1008
- }
1009
- // Or change it, if necessary, to its active shadow-DOM element.
1010
- else if (currentElement.shadowRoot) {
1011
- currentElement = currentElement.shadowRoot.activeElement;
1012
- }
1013
- // If there is a current element:
1014
- if (currentElement) {
1015
- // If it was already reached within this act:
1016
- if (currentElement.dataset.pressesReached === actCount.toString(10)) {
1017
- // Report the error.
1018
- console.log(`ERROR: ${currentElement.tagName} element reached again`);
1019
- status = 'ERROR';
1020
- return 'ERROR: locallyExhausted';
1021
- }
1022
- // Otherwise, i.e. if it is newly reached within this act:
1023
- else {
1024
- // Mark and return it.
1025
- currentElement.dataset.pressesReached = actCount;
1026
- return currentElement;
1027
- }
1028
- }
1029
- // Otherwise, i.e. if there is no current element:
1030
- else {
1031
- // Report the error.
1032
- status = 'ERROR';
1033
- return 'noActiveElement';
1034
- }
1035
- }
1036
- // Otherwise, i.e. if there is no focus in the page:
1037
- else {
1038
- // Report the error.
1039
- status = 'ERROR';
1040
- return 'ERROR: globallyExhausted';
1041
- }
1042
- }, actCount);
1043
- // If the current element exists:
1044
- const currentElement = currentJSHandle.asElement();
1045
- if (currentElement) {
1046
- // Update the data.
1047
- const tagNameJSHandle = await currentElement.getProperty('tagName');
1048
- const tagName = await tagNameJSHandle.jsonValue();
1049
- const text = await textOf(page, currentElement);
1050
- // If the text of the current element was found:
1051
- if (text !== null) {
1052
- const textLength = text.length;
1053
- // If it is non-empty and there are texts to match:
1054
- if (matchTexts.length && textLength) {
1055
- // Identify the matching text.
1056
- matchedText = matchTexts.find(matchText => text.includes(matchText));
1057
- }
1058
- // Update the item data if required.
1059
- if (withItems) {
1060
- const itemData = {
1061
- tagName,
1062
- text,
1063
- textLength
1064
- };
1065
- if (matchedText) {
1066
- itemData.matchedText = matchedText;
1067
- }
1068
- items.push(itemData);
1069
- }
1070
- amountRead += textLength;
1071
- // If there is no text-match failure:
1072
- if (matchedText || ! matchTexts.length) {
1073
- // If the element has any specified tag name:
1074
- if (! what || tagName === what) {
1075
- // Change the status.
1076
- status = 'done';
1077
- // Perform the action.
1078
- const inputText = act.text;
1079
- if (inputText) {
1080
- await page.keyboard.type(inputText);
1081
- presses += inputText.length;
1082
- }
1083
- if (act.action) {
1084
- presses++;
1085
- await page.keyboard.press(act.action);
1086
- await page.waitForLoadState();
1087
- }
1088
- }
1089
- }
1090
- }
1091
- else {
1092
- status = 'ERROR';
1093
- }
1094
- }
1095
- // Otherwise, i.e. if there was a failure:
1096
- else {
1097
- // Update the status.
1098
- status = await currentJSHandle.jsonValue();
1099
- }
1100
- }
1101
- // Add the result to the act.
1102
- act.result = {
1103
- success: true,
1104
- status,
1105
- totals: {
1106
- presses,
1107
- amountRead
1108
- }
1109
- };
1110
- if (status === 'done' && matchedText) {
1111
- act.result.matchedText = matchedText;
1112
- }
1113
- if (withItems) {
1114
- act.result.items = items;
1115
- }
1116
- // Add the totals to the temporary report.
1117
- tempReport.jobData.presses += presses;
1118
- tempReport.jobData.amountRead += amountRead;
1119
- }
1120
- // Otherwise, i.e. if the act type is unknown:
1121
- else {
1122
- // Report this.
1123
- addError(true, false, tempReport, actIndex, 'ERROR: Invalid act type');
1124
- }
1125
- }
1126
- // Otherwise, a page URL is required but does not exist, so:
1127
- else {
1128
- // Report this.
1129
- addError(true, false, tempReport, actIndex, 'ERROR: Page has no URL');
700
+ else if (act.result && act.result.success !== false) {
701
+ sinceNav.push(actIndex);
702
+ dirtySince.push(actIndex);
1130
703
  }
1131
704
  }
1132
705
  // Otherwise, i.e. if no page exists:
@@ -1141,6 +714,17 @@ exports.doActs = async (report, opts = {}) => {
1141
714
  }
1142
715
  }
1143
716
  console.log('Acts completed');
717
+ // Close any browser shared by the job's launches.
718
+ await closeSharedBrowser();
719
+ // If the job has more than one checkpoint, add the running list of issues across them.
720
+ const flow = getFlow(tempReport);
721
+ if (flow) {
722
+ tempReport.flow = flow;
723
+ }
724
+ // The per-checkpoint structure diffs were job-time; flow holds them now.
725
+ (tempReport.checkpoints ?? []).forEach(checkpoint => {
726
+ delete checkpoint.structure;
727
+ });
1144
728
  // If the results were standardized:
1145
729
  if (['also', 'only'].includes(standard)) {
1146
730
  // If the native results are not to be included in the report:
@@ -1155,8 +739,9 @@ exports.doActs = async (report, opts = {}) => {
1155
739
  // If a catalog was created:
1156
740
  if (tempReport.catalog) {
1157
741
  let {catalog} = tempReport;
1158
- // Get its element count.
1159
- const elementCount = Object.keys(catalog).length;
742
+ // Get its element count: every entry ever made (checkpoint entries and stubs), since a
743
+ // checkpoint's uncited entries are pruned when the next checkpoint is made.
744
+ const elementCount = tempReport.catalogNextIndex ?? Object.keys(catalog).length;
1160
745
  // Prune it, removing elements with no reported violations.
1161
746
  pruneCatalog(tempReport);
1162
747
  ({catalog} = tempReport);
@@ -1168,6 +753,7 @@ exports.doActs = async (report, opts = {}) => {
1168
753
  const catalogData = {
1169
754
  elementCount,
1170
755
  entryCount,
756
+ checkpoints: {},
1171
757
  text: {
1172
758
  count: textCount,
1173
759
  countPercent: Math.round(100 * textCount / entryCount),
@@ -1176,6 +762,13 @@ exports.doActs = async (report, opts = {}) => {
1176
762
  },
1177
763
  tools: {}
1178
764
  };
765
+ // For each checkpoint, count its catalog entries before and after pruning.
766
+ (tempReport.checkpoints ?? []).forEach(checkpoint => {
767
+ catalogData.checkpoints[checkpoint.index] = {
768
+ elementCount: checkpoint.elementCount,
769
+ entryCount: Object.values(catalog).filter(entry => entry.checkpoint === checkpoint.index).length
770
+ };
771
+ });
1179
772
  const {acts} = tempReport;
1180
773
  // For each act:
1181
774
  for (const act of acts) {
@@ -1195,8 +788,8 @@ exports.doActs = async (report, opts = {}) => {
1195
788
  // Increment the instance count.
1196
789
  actCatalogData.instanceCount++;
1197
790
  const catalogIndex = instance?.catalogIndex;
1198
- // If the instance has a catalog index:
1199
- if (catalogIndex) {
791
+ // If the instance has a catalog index (an index of '0' counts):
792
+ if (catalogIndex !== undefined && catalogIndex !== '') {
1200
793
  // Increment the catalog count.
1201
794
  actCatalogData.catalogCount++;
1202
795
  }