testaro 78.0.8 → 78.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (356) hide show
  1. package/.gitattributes +94 -0
  2. package/.github/workflows/ci.yml +58 -0
  3. package/.github/workflows/publish.yml +49 -0
  4. package/.github/workflows/typescript.yml +35 -0
  5. package/AGENTS.md +2 -2
  6. package/CLAUDE.md +24 -18
  7. package/CONTAINERS.md +9 -6
  8. package/CONTRIBUTING.md +29 -3
  9. package/Dockerfile +1 -1
  10. package/README.md +73 -19
  11. package/UPGRADES.md +4 -0
  12. package/actSpecs-doc.md +13 -1
  13. package/actSpecs.js +29 -5
  14. package/call.js +6 -5
  15. package/docker-compose.yml +8 -2
  16. package/docs/checkpoint-scanning.md +199 -0
  17. package/docs/standard-result-outcome.md +165 -0
  18. package/env.example +44 -10
  19. package/eslint.config.mjs +101 -0
  20. package/netWatch.js +372 -222
  21. package/package.json +46 -31
  22. package/pour/README.md +47 -0
  23. package/pour/pour.min.js +8 -0
  24. package/procs/actDo.js +808 -0
  25. package/procs/catalog.d.ts +17 -0
  26. package/procs/catalog.js +312 -221
  27. package/procs/catalog.ts +398 -0
  28. package/procs/checkpoint.js +110 -0
  29. package/procs/config.d.ts +35 -0
  30. package/procs/config.js +62 -0
  31. package/procs/dateTime.js +2 -1
  32. package/procs/doActs.js +442 -849
  33. package/procs/doTestAct.js +27 -121
  34. package/procs/flow.js +221 -0
  35. package/procs/generateRuleRegistry.js +94 -0
  36. package/procs/getSource.d.ts +26 -0
  37. package/procs/job.js +115 -1
  38. package/procs/launch.d.ts +40 -0
  39. package/procs/launch.js +239 -108
  40. package/procs/nu.d.ts +48 -0
  41. package/procs/scope.js +196 -0
  42. package/procs/shoot.d.ts +33 -0
  43. package/procs/standard.d.ts +18 -0
  44. package/procs/standard.js +83 -0
  45. package/procs/standard.ts +126 -0
  46. package/procs/testAct.js +145 -0
  47. package/procs/testaro.d.ts +20 -0
  48. package/procs/testaro.js +236 -189
  49. package/procs/testaro.ts +321 -0
  50. package/procs/userPath.js +126 -0
  51. package/procs/xPath.d.ts +4 -0
  52. package/procs/xPath.js +81 -69
  53. package/procs/xPath.ts +106 -0
  54. package/procs/xPathScript.d.ts +6 -0
  55. package/procs/xPathScript.js +70 -0
  56. package/run.js +3 -2
  57. package/surea11y/README.md +44 -0
  58. package/surea11y/surea11y.browser.js +14 -0
  59. package/testaro/adbID.d.ts +3 -0
  60. package/testaro/adbID.js +38 -41
  61. package/testaro/adbID.ts +66 -0
  62. package/testaro/allCapStyle.d.ts +3 -0
  63. package/testaro/allCapStyle.js +31 -34
  64. package/testaro/allCapStyle.ts +55 -0
  65. package/testaro/allCaps.d.ts +16 -0
  66. package/testaro/allCaps.js +179 -151
  67. package/testaro/allCaps.ts +228 -0
  68. package/testaro/allHidden.d.ts +13 -0
  69. package/testaro/allHidden.js +29 -30
  70. package/testaro/allHidden.ts +50 -0
  71. package/testaro/allSlanted.d.ts +3 -0
  72. package/testaro/allSlanted.js +30 -33
  73. package/testaro/allSlanted.ts +54 -0
  74. package/testaro/altScheme.d.ts +3 -0
  75. package/testaro/altScheme.js +27 -30
  76. package/testaro/altScheme.ts +50 -0
  77. package/testaro/attVal.d.ts +3 -0
  78. package/testaro/attVal.js +20 -21
  79. package/testaro/attVal.ts +52 -0
  80. package/testaro/autocomplete.d.ts +3 -0
  81. package/testaro/autocomplete.js +59 -75
  82. package/testaro/autocomplete.ts +101 -0
  83. package/testaro/bulk.d.ts +13 -0
  84. package/testaro/bulk.js +32 -33
  85. package/testaro/bulk.ts +55 -0
  86. package/testaro/buttonMenu.d.ts +9 -0
  87. package/testaro/buttonMenu.js +317 -319
  88. package/testaro/buttonMenu.ts +391 -0
  89. package/testaro/captionLoc.d.ts +3 -0
  90. package/testaro/captionLoc.js +17 -20
  91. package/testaro/captionLoc.ts +40 -0
  92. package/testaro/datalistRef.d.ts +3 -0
  93. package/testaro/datalistRef.js +33 -36
  94. package/testaro/datalistRef.ts +55 -0
  95. package/testaro/distortion.d.ts +3 -0
  96. package/testaro/distortion.js +57 -26
  97. package/testaro/distortion.ts +81 -0
  98. package/testaro/docType.d.ts +15 -0
  99. package/testaro/docType.js +25 -25
  100. package/testaro/docType.ts +45 -0
  101. package/testaro/dupAtt.d.ts +16 -0
  102. package/testaro/dupAtt.js +113 -104
  103. package/testaro/dupAtt.ts +144 -0
  104. package/testaro/elements.d.ts +6 -0
  105. package/testaro/elements.js +153 -153
  106. package/testaro/elements.ts +215 -0
  107. package/testaro/embAc.d.ts +3 -0
  108. package/testaro/embAc.js +19 -20
  109. package/testaro/embAc.ts +40 -0
  110. package/testaro/focAll.d.ts +13 -0
  111. package/testaro/focAll.js +185 -191
  112. package/testaro/focAll.ts +217 -0
  113. package/testaro/focAndOp.d.ts +3 -0
  114. package/testaro/focAndOp.js +100 -104
  115. package/testaro/focAndOp.ts +128 -0
  116. package/testaro/focInd.d.ts +3 -0
  117. package/testaro/focInd.js +64 -66
  118. package/testaro/focInd.ts +96 -0
  119. package/testaro/focVis.d.ts +3 -0
  120. package/testaro/focVis.js +28 -30
  121. package/testaro/focVis.ts +52 -0
  122. package/testaro/headEl.d.ts +10 -0
  123. package/testaro/headEl.js +61 -62
  124. package/testaro/headEl.ts +82 -0
  125. package/testaro/headingAmb.d.ts +3 -0
  126. package/testaro/headingAmb.js +54 -63
  127. package/testaro/headingAmb.ts +72 -0
  128. package/testaro/hovInd.d.ts +12 -0
  129. package/testaro/hovInd.js +163 -130
  130. package/testaro/hovInd.ts +199 -0
  131. package/testaro/hover.d.ts +3 -0
  132. package/testaro/hover.js +155 -126
  133. package/testaro/hover.ts +154 -0
  134. package/testaro/hr.d.ts +3 -0
  135. package/testaro/hr.js +14 -17
  136. package/testaro/hr.ts +36 -0
  137. package/testaro/imageLink.d.ts +3 -0
  138. package/testaro/imageLink.js +17 -20
  139. package/testaro/imageLink.ts +42 -0
  140. package/testaro/labClash.d.ts +3 -0
  141. package/testaro/labClash.js +31 -33
  142. package/testaro/labClash.ts +54 -0
  143. package/testaro/legendLoc.d.ts +3 -0
  144. package/testaro/legendLoc.js +17 -20
  145. package/testaro/legendLoc.ts +42 -0
  146. package/testaro/lineHeight.d.ts +3 -0
  147. package/testaro/lineHeight.js +43 -48
  148. package/testaro/lineHeight.ts +70 -0
  149. package/testaro/linkAmb.d.ts +7 -0
  150. package/testaro/linkAmb.js +79 -80
  151. package/testaro/linkAmb.ts +105 -0
  152. package/testaro/linkExt.d.ts +3 -0
  153. package/testaro/linkExt.js +13 -16
  154. package/testaro/linkExt.ts +35 -0
  155. package/testaro/linkOldAtt.d.ts +3 -0
  156. package/testaro/linkOldAtt.js +25 -28
  157. package/testaro/linkOldAtt.ts +48 -0
  158. package/testaro/linkTo.d.ts +3 -0
  159. package/testaro/linkTo.js +22 -22
  160. package/testaro/linkTo.ts +43 -0
  161. package/testaro/linkUl.d.ts +3 -0
  162. package/testaro/linkUl.js +30 -34
  163. package/testaro/linkUl.ts +54 -0
  164. package/testaro/miniText.d.ts +3 -0
  165. package/testaro/miniText.js +41 -44
  166. package/testaro/miniText.ts +68 -0
  167. package/testaro/motion.d.ts +10 -0
  168. package/testaro/motion.js +92 -96
  169. package/testaro/motion.ts +125 -0
  170. package/testaro/nonTable.d.ts +3 -0
  171. package/testaro/nonTable.js +39 -45
  172. package/testaro/nonTable.ts +66 -0
  173. package/testaro/optRoleSel.d.ts +3 -0
  174. package/testaro/optRoleSel.js +16 -19
  175. package/testaro/optRoleSel.ts +41 -0
  176. package/testaro/phOnly.d.ts +3 -0
  177. package/testaro/phOnly.js +18 -21
  178. package/testaro/phOnly.ts +43 -0
  179. package/testaro/pseudoP.d.ts +3 -0
  180. package/testaro/pseudoP.js +36 -38
  181. package/testaro/pseudoP.ts +59 -0
  182. package/testaro/radioSet.d.ts +3 -0
  183. package/testaro/radioSet.js +57 -59
  184. package/testaro/radioSet.ts +79 -0
  185. package/testaro/registry.d.ts +63 -0
  186. package/testaro/registry.js +67 -0
  187. package/testaro/registry.ts +141 -0
  188. package/testaro/role.d.ts +3 -0
  189. package/testaro/role.js +27 -29
  190. package/testaro/role.ts +53 -0
  191. package/testaro/secHeading.d.ts +3 -0
  192. package/testaro/secHeading.js +30 -33
  193. package/testaro/secHeading.ts +53 -0
  194. package/testaro/styleDiff.d.ts +25 -0
  195. package/testaro/styleDiff.js +248 -252
  196. package/testaro/styleDiff.ts +303 -0
  197. package/testaro/tabNav.d.ts +33 -0
  198. package/testaro/tabNav.js +272 -342
  199. package/testaro/tabNav.ts +454 -0
  200. package/testaro/targetsNear.d.ts +9 -0
  201. package/testaro/targetsNear.js +130 -132
  202. package/testaro/targetsNear.ts +160 -0
  203. package/testaro/textNodes.d.ts +6 -0
  204. package/testaro/textNodes.js +139 -135
  205. package/testaro/textNodes.ts +185 -0
  206. package/testaro/textSem.d.ts +3 -0
  207. package/testaro/textSem.js +25 -28
  208. package/testaro/textSem.ts +47 -0
  209. package/testaro/title.d.ts +9 -0
  210. package/testaro/title.js +16 -13
  211. package/testaro/title.ts +31 -0
  212. package/testaro/titledEl.d.ts +3 -0
  213. package/testaro/titledEl.js +15 -18
  214. package/testaro/titledEl.ts +38 -0
  215. package/testaro/zIndex.d.ts +3 -0
  216. package/testaro/zIndex.js +19 -22
  217. package/testaro/zIndex.ts +42 -0
  218. package/tests/alfa.d.ts +45 -0
  219. package/tests/alfa.js +138 -141
  220. package/tests/alfa.ts +214 -0
  221. package/tests/aslint.d.ts +33 -0
  222. package/tests/aslint.js +272 -249
  223. package/tests/aslint.ts +301 -0
  224. package/tests/axe.d.ts +27 -0
  225. package/tests/axe.js +199 -200
  226. package/tests/axe.ts +277 -0
  227. package/tests/ed11y.d.ts +28 -0
  228. package/tests/ed11y.js +141 -99
  229. package/tests/ed11y.ts +178 -0
  230. package/tests/htmlcs.d.ts +21 -0
  231. package/tests/htmlcs.js +174 -140
  232. package/tests/htmlcs.ts +181 -0
  233. package/tests/ibm.d.ts +52 -0
  234. package/tests/ibm.js +164 -166
  235. package/tests/ibm.ts +251 -0
  236. package/tests/nuVal.d.ts +13 -0
  237. package/tests/nuVal.js +107 -112
  238. package/tests/nuVal.ts +145 -0
  239. package/tests/nuVnu.d.ts +15 -0
  240. package/tests/nuVnu.js +141 -111
  241. package/tests/nuVnu.ts +144 -0
  242. package/tests/pour.d.ts +31 -0
  243. package/tests/pour.js +242 -0
  244. package/tests/pour.ts +273 -0
  245. package/tests/qualWeb.d.ts +39 -0
  246. package/tests/qualWeb.js +302 -272
  247. package/tests/qualWeb.ts +415 -0
  248. package/tests/surea11y.d.ts +33 -0
  249. package/tests/surea11y.js +288 -0
  250. package/tests/surea11y.ts +334 -0
  251. package/tests/testaro.d.ts +25 -0
  252. package/tests/testaro.js +745 -652
  253. package/tests/testaro.ts +862 -0
  254. package/tests/wave.d.ts +46 -0
  255. package/tests/wave.js +166 -177
  256. package/tests/wave.ts +252 -0
  257. package/tsconfig.json +17 -0
  258. package/types.d.ts +243 -0
  259. package/types.js +10 -0
  260. package/types.ts +376 -0
  261. package/validation/act/README.md +46 -0
  262. package/validation/act/capture.js +424 -0
  263. package/validation/act/chromium-issue-draft.md +66 -0
  264. package/validation/act/fp-triage-2026-08-22.md +86 -0
  265. package/validation/act/isolation-notes.md +159 -0
  266. package/validation/act/playwright-issue-draft.md +73 -0
  267. package/validation/act/propose-mappings.js +0 -0
  268. package/validation/act/repro-cdp-raw.js +105 -0
  269. package/validation/act/repro-metarefresh.js +65 -0
  270. package/validation/act/score.js +184 -0
  271. package/validation/act/stage3a-stress-report.md +69 -0
  272. package/validation/act/stage3b-mapping-proposals.md +94 -0
  273. package/validation/act/stage3b-triage-draft.md +138 -0
  274. package/validation/act/surea11y-track-a-2026-09-01.md +50 -0
  275. package/validation/executors/netWatch.js +180 -90
  276. package/validation/executors/test.js +17 -2
  277. package/validation/executors/tests.js +118 -10
  278. package/validation/jobs/reports/raw/260901T1000-surea11y-validation.json +964 -0
  279. package/validation/jobs/todo/240101T1200-simple-example.json +14 -6
  280. package/validation/jobs/todo/240101T1300-shoot-example.json +2 -1
  281. package/validation/jobs/todo/260821T1900-pour-validation.json +45 -0
  282. package/validation/jobs/todo/260901T1000-surea11y-validation.json +45 -0
  283. package/validation/knownFailures.json +1 -0
  284. package/validation/tests/jobProperties/adbID.json +27 -2
  285. package/validation/tests/jobProperties/{focOp.json → allCapStyle.json} +56 -53
  286. package/validation/tests/jobProperties/allCaps.json +25 -0
  287. package/validation/tests/jobProperties/allHidden.json +133 -13
  288. package/validation/tests/jobProperties/allSlanted.json +3 -3
  289. package/validation/tests/jobProperties/altScheme.json +23 -8
  290. package/validation/tests/jobProperties/attVal.json +57 -57
  291. package/validation/tests/jobProperties/autocomplete.json +11 -1
  292. package/validation/tests/jobProperties/bulk.json +6 -1
  293. package/validation/tests/jobProperties/buttonMenu.json +63 -42
  294. package/validation/tests/jobProperties/captionLoc.json +1 -6
  295. package/validation/tests/jobProperties/checkpoint-browser.json +401 -0
  296. package/validation/tests/jobProperties/checkpoint-page.json +397 -0
  297. package/validation/tests/jobProperties/checkpoint.json +395 -0
  298. package/validation/tests/jobProperties/datalistRef.json +20 -5
  299. package/validation/tests/jobProperties/distortion.json +28 -3
  300. package/validation/tests/jobProperties/docType.json +2 -2
  301. package/validation/tests/jobProperties/dupAtt.json +48 -33
  302. package/validation/tests/jobProperties/elements.json +28 -28
  303. package/validation/tests/jobProperties/embAc.json +36 -31
  304. package/validation/tests/jobProperties/focAndOp.json +284 -0
  305. package/validation/tests/jobProperties/focInd.json +34 -34
  306. package/validation/tests/jobProperties/focVis.json +1 -1
  307. package/validation/tests/jobProperties/hover.json +39 -37
  308. package/validation/tests/jobProperties/hr.json +13 -3
  309. package/validation/tests/jobProperties/imageLink.json +0 -5
  310. package/validation/tests/jobProperties/labClash.json +68 -33
  311. package/validation/tests/jobProperties/legendLoc.json +1 -6
  312. package/validation/tests/jobProperties/lineHeight.json +2 -2
  313. package/validation/tests/jobProperties/linkAmb.json +13 -18
  314. package/validation/tests/jobProperties/linkExt.json +1 -1
  315. package/validation/tests/jobProperties/linkOldAtt.json +12 -2
  316. package/validation/tests/jobProperties/linkTo.json +1 -1
  317. package/validation/tests/jobProperties/linkUl.json +65 -65
  318. package/validation/tests/jobProperties/miniText.json +7 -2
  319. package/validation/tests/jobProperties/motion.json +4 -50
  320. package/validation/tests/jobProperties/nonTable.json +48 -3
  321. package/validation/tests/jobProperties/optRoleSel.json +12 -2
  322. package/validation/tests/jobProperties/phOnly.json +6 -16
  323. package/validation/tests/jobProperties/pseudoP.json +17 -2
  324. package/validation/tests/jobProperties/radioSet.json +30 -30
  325. package/validation/tests/jobProperties/role.json +25 -5
  326. package/validation/tests/jobProperties/secHeading.json +26 -21
  327. package/validation/tests/jobProperties/styleDiff.json +35 -5
  328. package/validation/tests/jobProperties/tabNav.json +3 -1
  329. package/validation/tests/jobProperties/targetsNear.json +237 -0
  330. package/validation/tests/jobProperties/textNodes.json +29 -29
  331. package/validation/tests/jobProperties/textSem.json +26 -1
  332. package/validation/tests/jobProperties/title.json +12 -2
  333. package/validation/tests/jobProperties/titledEl.json +39 -9
  334. package/validation/tests/jobProperties/userPath.json +317 -0
  335. package/validation/tests/jobProperties/zIndex.json +40 -35
  336. package/validation/tests/targets/allCapStyle/index.html +26 -0
  337. package/validation/tests/targets/checkpoint/index.html +38 -0
  338. package/validation/tests/targets/datalistRef/index.html +1 -1
  339. package/validation/tests/targets/focAndOp/bad.html +29 -0
  340. package/validation/tests/targets/{focOp → focAndOp}/good.html +3 -1
  341. package/validation/tests/targets/focInd/bad.html +2 -1
  342. package/validation/tests/targets/headEl/index.html +10 -1
  343. package/validation/tests/targets/{targetSmall → targetsNear}/index.html +15 -1
  344. package/validation/tests/targets/userPath/index.html +42 -0
  345. package/validation/validateTest.js +61 -9
  346. package/.claude/settings.local.json +0 -11
  347. package/.eslintrc.json +0 -41
  348. package/htmlcs/.eslintrc.json +0 -67
  349. package/validation/tests/jobProperties/linkTitle.json +0 -127
  350. package/validation/tests/jobProperties/opFoc.json +0 -164
  351. package/validation/tests/jobProperties/targetSmall.json +0 -152
  352. package/validation/tests/jobProperties/targetTiny.json +0 -142
  353. package/validation/tests/targets/focOp/bad.html +0 -25
  354. package/validation/tests/targets/linkTitle/index.html +0 -24
  355. package/validation/tests/targets/opFoc/bad.html +0 -26
  356. package/validation/tests/targets/opFoc/good.html +0 -23
@@ -0,0 +1,398 @@
1
+ /*
2
+ © 2026 Jeff Witt.
3
+ © 2025–2026 Jonathan Robert Pool.
4
+
5
+ Licensed under the MIT License. See LICENSE file at the project root or https://opensource.org/license/mit/ for details.
6
+
7
+ SPDX-License-Identifier: MIT
8
+ */
9
+
10
+ // IMPORTS
11
+
12
+ import type {Page} from 'playwright';
13
+ // Module to close and launch browsers.
14
+ import {browserClose, launch} from './launch';
15
+ import {shoot} from './shoot';
16
+ // Function to define window.getXPath on a live page.
17
+ import {defineGetXPath} from './xPathScript';
18
+ import type {Catalog, CatalogEntry, Checkpoint, Report, StandardResult} from '../types';
19
+
20
+ /*
21
+ catalog
22
+ Creates and returns a catalog of a target. Compiled to catalog.js by tsc
23
+ (issue #73); edit this file, not the emitted one.
24
+
25
+ A catalog is an object with one property per element in the target. Each property has the element index as its key and has an object as its value, with 9 properties:
26
+ - tagName
27
+ - id
28
+ - startTag
29
+ - text
30
+ - textLinkable
31
+ - boxID
32
+ - pathID
33
+ - headingIndex
34
+ - checkpoint: the index of the checkpoint (page state) whose snapshot the entry belongs to
35
+
36
+ The catalog of checkpoint 0 (the job target as launched) is made by getCatalog before any act. A checkpoint act adds the entries of a later page state with catalogPage, on the live page, keyed from report.catalogNextIndex so indexes never collide. report.pathIDs maps each checkpoint's XPaths to catalog indexes.
37
+ */
38
+
39
+ // TYPES
40
+
41
+ // What catalogPage returns.
42
+ export interface CatalogSnapshot {
43
+ entries: Catalog;
44
+ pathIDs: Record<string, string>;
45
+ firstIndex: number;
46
+ nextIndex: number;
47
+ elementCount: number;
48
+ }
49
+
50
+ // CONSTANTS
51
+
52
+ // The ARIA snapshot of a page, for checkpoints; empty if the browser cannot make one.
53
+ export const getAriaSnapshot = async (page: Page): Promise<string> => {
54
+ try {
55
+ return await page.locator('body').ariaSnapshot();
56
+ }
57
+ catch(error) {
58
+ console.log(`ERROR: ARIA snapshot failed (${(error as Error).message})`);
59
+ return '';
60
+ }
61
+ };
62
+
63
+ // FUNCTIONS
64
+
65
+ // Catalogs the elements of a live page and returns the snapshot. Expands closed details
66
+ // elements first, so box measurements agree with a page image taken in the same state; with
67
+ // restoreDetails, closes them again afterward so a live page keeps its state.
68
+ export const catalogPage = async (
69
+ page: Page, report: Report, {checkpoint, restoreDetails}: {checkpoint: number; restoreDetails: boolean}
70
+ ): Promise<CatalogSnapshot> => {
71
+ // Ensure the page can compute XPaths (a page launched for acts lacks the script).
72
+ await defineGetXPath(page);
73
+ // Expand closed details elements before the page image and the box measurements, so
74
+ // both see the same fully disclosed state. Chromium lays out the content of a closed
75
+ // details element (content-visibility: hidden) without painting it and without
76
+ // shifting the content after it, so getBoundingClientRect returns coordinates that
77
+ // overlap unrelated visible elements, making box IDs disagree with the page image.
78
+ await page.evaluate(() => {
79
+ document.querySelectorAll('details:not([open])').forEach(details => {
80
+ details.setAttribute('open', '');
81
+ details.setAttribute('data-testaro-opened', '');
82
+ });
83
+ }).catch(error => {
84
+ console.log(`ERROR: Expanding details elements failed (${error.message})`);
85
+ });
86
+ const startIndex = report.catalogNextIndex ?? 0;
87
+ // Get a catalog of the elements in the page and a map of path IDs to catalog indexes.
88
+ console.log(`Creating catalog for checkpoint ${checkpoint}`);
89
+ const {cat: entries, pathIDs, elementCount} = await page.evaluate(({startIndex, checkpoint}) => {
90
+ // HTMLElement covers the innerText reads below; SVG-only members are guarded.
91
+ const elements = Array.from(document.querySelectorAll<HTMLElement>('*'));
92
+ // Initialize a catalog.
93
+ const cat: Record<string, CatalogEntry> = {};
94
+ // Initialize a map of path IDs to catalog indexes.
95
+ const pathIDs: Record<string, string> = {};
96
+ // Initialize a directory of text fragments.
97
+ const texts: Record<string, string[]> = {};
98
+ // Initialize the index of the current heading.
99
+ let headingIndex = '';
100
+ // For each element in the page:
101
+ // Iterate by numeric index rather than `for...in`. `for...in` over this
102
+ // array also enumerates any enumerable members added to Array.prototype
103
+ // by the TARGET page's own scripts (e.g. legacy MooTools/Prototype-style
104
+ // extensions); `element` then becomes that injected value and the
105
+ // element.closest(...) call below throws "closest is not a function",
106
+ // aborting the entire catalog and the job. The indexed loop sees only
107
+ // real elements. `index` is kept as a string so the catalog keys
108
+ // (cat[index], texts[...].push(index)) are unchanged.
109
+ for (let i = 0; i < elements.length; i++) {
110
+ const index = String(startIndex + i);
111
+ const element = elements[i];
112
+ // Get its ID and tag name.
113
+ const {id, tagName} = element;
114
+ // Get its start tag.
115
+ const startTag = element.outerHTML?.replace(/^.*?</s, '<').replace(/>.*$/s, '>') ?? '';
116
+ // Get whether it is eligible for text-fragment acquisition.
117
+ const isTextable = element.closest('body')
118
+ && ! element.closest('svg')
119
+ && ! ['SCRIPT', 'STYLE', 'svg'].includes(element.tagName);
120
+ const innerText = isTextable
121
+ ? element.innerText.trim() || (element.parentElement?.innerText?.trim() ?? '')
122
+ : '';
123
+ let text = '';
124
+ // If it is eligible and has an inner text:
125
+ if (innerText) {
126
+ const segments = innerText?.split('\n') ?? [];
127
+ const tidySegments = segments.map(segment => segment.trim().replace(/\s+/g, ' '));
128
+ const neededSegments = tidySegments.filter(segment => segment.length);
129
+ neededSegments.splice(1, neededSegments.length - 2);
130
+ // Get its text fragments.
131
+ text = neededSegments.join('\n');
132
+ // Add its index to the directory of text fragments.
133
+ texts[text] ??= [];
134
+ texts[text].push(index);
135
+ }
136
+ // Get its bounding box, but only if the element is painted. Chromium reports
137
+ // plausible nonzero boxes for laid-out but unpainted content (visibility: hidden
138
+ // and content-visibility: hidden subtrees), and such a box disagrees with the
139
+ // page image, overlapping unrelated visible elements.
140
+ const isVisible = typeof element.checkVisibility === 'function'
141
+ ? element.checkVisibility({checkVisibilityCSS: true, visibilityProperty: true})
142
+ : true;
143
+ const domRect = isVisible ? element.getBoundingClientRect() : null;
144
+ // Get its box ID.
145
+ const boxID = domRect
146
+ ? (['x', 'y', 'width', 'height'] as const).map(key => Math.round(domRect[key])).join(':')
147
+ : '';
148
+ // Get its path ID.
149
+ const pathID = window.getXPath(element) ?? '/html';
150
+ // If it is a heading that nullifies an existing current heading index:
151
+ if (
152
+ headingIndex
153
+ && ['H1', 'H2', 'H3', 'H4', 'H5', 'H6'].includes(tagName)
154
+ && cat[headingIndex].tagName >= tagName
155
+ ) {
156
+ // Nullify the current heading index.
157
+ headingIndex = '';
158
+ }
159
+ // Add an entry for it to the catalog.
160
+ cat[index] = {
161
+ tagName,
162
+ id: id || '',
163
+ startTag,
164
+ text,
165
+ textLinkable: false,
166
+ boxID,
167
+ pathID,
168
+ headingIndex,
169
+ checkpoint
170
+ };
171
+ // If the element is a heading:
172
+ if (['H1', 'H2', 'H3', 'H4', 'H5', 'H6'].includes(tagName)) {
173
+ // Assign its index to the current heading index.
174
+ headingIndex = index;
175
+ }
176
+ // Add the path ID to the map of path IDs.
177
+ pathIDs[pathID] = index;
178
+ }
179
+ // For each text in the catalog:
180
+ Object.keys(texts).forEach(text => {
181
+ const textElementIndexes = texts[text].sort((a, b) => Number(a) - Number(b));
182
+ // If every element that has it is in the same subtree, so the text is page-unique:
183
+ if (
184
+ textElementIndexes.slice(0, -1).every(
185
+ (elementIndex, index) => cat[textElementIndexes[index + 1]]
186
+ .pathID
187
+ .startsWith(cat[elementIndex].pathID)
188
+ )
189
+ ) {
190
+ // For each element that has it:
191
+ textElementIndexes.forEach(index => {
192
+ // If it is not in the head, a script, a style, or a noscript element:
193
+ if (
194
+ ! ['/head[1]', '/script[', '/style[', '/noscript[']
195
+ .some(excluder => cat[index].pathID.includes(excluder))
196
+ ) {
197
+ // Mark it as linkable in the element data in the catalog.
198
+ cat[index].textLinkable = true;
199
+ }
200
+ });
201
+ }
202
+ });
203
+ return {cat, pathIDs, elementCount: elements.length};
204
+ }, {startIndex, checkpoint});
205
+ // If the page is to keep its state, close the details elements that were opened.
206
+ if (restoreDetails) {
207
+ await page.evaluate(() => {
208
+ document.querySelectorAll('details[data-testaro-opened]').forEach(details => {
209
+ details.removeAttribute('open');
210
+ details.removeAttribute('data-testaro-opened');
211
+ });
212
+ }).catch(() => {});
213
+ }
214
+ return {
215
+ entries,
216
+ pathIDs,
217
+ firstIndex: startIndex,
218
+ nextIndex: startIndex + elementCount,
219
+ elementCount
220
+ };
221
+ };
222
+ // Creates and returns a catalog.
223
+ export const getCatalog = async (report: Report): Promise<Catalog> => {
224
+ const {browserID} = report;
225
+ const targetURL = report.target?.url;
226
+ // Image scale factor (report.imageScale). When greater than 1, the catalog context
227
+ // runs at that deviceScaleFactor and a supplemental page image is captured at device
228
+ // scale, i.e. with imageScale times the pixels of the CSS layout. Box IDs are
229
+ // CSS-pixel regardless, because getBoundingClientRect reports CSS pixels by
230
+ // definition; consumers map them onto the supplemental image by multiplying by
231
+ // imageScale. Omitted, 1, or invalid values keep the behavior identical to before
232
+ // this option existed.
233
+ const imageScale = Number.isFinite(report.imageScale) && (report.imageScale as number) > 1
234
+ ? report.imageScale as number
235
+ : 1;
236
+ // If the report specifies a global browser ID and a global target URL:
237
+ if (browserID && targetURL) {
238
+ // Launch a browser and visit the target, or abort the job on failure.
239
+ const page = await launch({
240
+ report,
241
+ actIndex: null,
242
+ tempBrowserID: browserID,
243
+ tempURL: targetURL,
244
+ contextOverrides: imageScale > 1 ? {deviceScaleFactor: imageScale} : {}
245
+ });
246
+ // If the launch and navigation succeeded:
247
+ if (page) {
248
+ const startTime = Date.now();
249
+ // If a page image is required:
250
+ if ([0, 2, 4, 6].includes(report.imageColor as number)) {
251
+ // Expand closed details elements first, so the image and the box measurements below
252
+ // see the same fully disclosed state (catalogPage expands them again, idempotently).
253
+ await page.evaluate(() => {
254
+ document.querySelectorAll('details:not([open])').forEach(details => {
255
+ details.setAttribute('open', '');
256
+ });
257
+ }).catch(error => {
258
+ console.log(`ERROR: Expanding details elements failed (${error.message})`);
259
+ });
260
+ // Create one at CSS-pixel scale and add it to the report as images[0]. This
261
+ // scale is invariant to imageScale and to the context's deviceScaleFactor, so
262
+ // the testaro motion rule, which compares its own CSS-scale screenshot with
263
+ // images[0], is unaffected by the imageScale option.
264
+ console.log('Creating page image');
265
+ await shoot(page, report, {
266
+ exclusionSelector: '',
267
+ colorType: report.imageColor,
268
+ action: 'report'
269
+ });
270
+ // If a supersampled page image is also required:
271
+ if (imageScale > 1) {
272
+ // Create one at device-pixel scale and add it to the report as images[1].
273
+ console.log(`Creating page image at ${imageScale}x device scale`);
274
+ await shoot(page, report, {
275
+ exclusionSelector: '',
276
+ colorType: report.imageColor,
277
+ action: 'report',
278
+ scale: 'device'
279
+ });
280
+ }
281
+ }
282
+ // Snapshot the page as checkpoint 0.
283
+ const snapshot = await catalogPage(page, report, {checkpoint: 0, restoreDetails: false});
284
+ report.catalog = snapshot.entries;
285
+ report.pathIDs = {0: snapshot.pathIDs};
286
+ report.catalogNextIndex = snapshot.nextIndex;
287
+ // Record the checkpoint.
288
+ const imageIndexes = (report.images ?? []).map((image, index) => index);
289
+ const checkpoint: Checkpoint = {
290
+ index: 0,
291
+ name: 'start',
292
+ implicit: false,
293
+ actIndex: null,
294
+ launchActIndex: null,
295
+ launchURL: page.url(),
296
+ replay: [],
297
+ interaction: {modality: 'efficient'},
298
+ kind: 'navigation',
299
+ url: page.url(),
300
+ title: await page.title().catch(() => ''),
301
+ imageIndexes,
302
+ catalogRange: [snapshot.firstIndex, snapshot.nextIndex - 1],
303
+ elementCount: snapshot.elementCount,
304
+ ariaSnapshot: await getAriaSnapshot(page),
305
+ elapsedMs: Date.now() - startTime,
306
+ testActs: []
307
+ };
308
+ report.checkpoints = [checkpoint];
309
+ const catalog = report.catalog;
310
+ // Close the browser and its context.
311
+ await browserClose(page);
312
+ // Return the catalog.
313
+ return catalog;
314
+ }
315
+ // Otherwise, i.e. if the launch or navigation failed, report and return this.
316
+ console.log('ERROR: Launch or navigation failure prevented cataloguing and aborted job');
317
+ return {};
318
+ }
319
+ // Otherwise, i.e. if the report specification is incomplete, report and return this.
320
+ console.log('ERROR: Job omits browser ID or target URL, preventing catalog creation');
321
+ return {};
322
+ };
323
+ // Returns the catalog indexes cited by the standard instances of the test acts (all of them,
324
+ // or those of one checkpoint), plus the heading indexes of the cited entries.
325
+ const getCitedIndexes = (report: Report, checkpoint: number | null = null): Set<string> => {
326
+ const {acts, catalog} = report as Report & {catalog: Catalog};
327
+ const citedElementIndexes = new Set<string>();
328
+ // For each act in the report:
329
+ acts.forEach(act => {
330
+ // If it is a test (of the checkpoint, if one is specified) with a standard result:
331
+ if (
332
+ act.type === 'test' && act.result?.standardResult
333
+ && (checkpoint === null || act.checkpoint === checkpoint)
334
+ ) {
335
+ // The guard above makes the fallback unreachable and instances present; the
336
+ // expression is kept verbatim from the JavaScript original.
337
+ const {instances} = (act.result?.standardResult ?? []) as Required<StandardResult>;
338
+ // For each instance of the standard result:
339
+ instances.forEach(instance => {
340
+ const catalogIndex = instance?.catalogIndex;
341
+ // If the instance has a catalog index (an index of '0' counts, so test for presence):
342
+ if (catalogIndex !== undefined && catalogIndex !== '') {
343
+ // Ensure the index is classified as cited, as a string so it matches the
344
+ // catalog keys tested below (Object.keys() yields strings).
345
+ citedElementIndexes.add(String(catalogIndex));
346
+ const {headingIndex} = catalog[catalogIndex] ?? {};
347
+ // If the catalog item has a heading index:
348
+ if (headingIndex) {
349
+ // Ensure it, too, is classified as cited.
350
+ citedElementIndexes.add(String(headingIndex));
351
+ }
352
+ }
353
+ });
354
+ }
355
+ });
356
+ return citedElementIndexes;
357
+ };
358
+ /*
359
+ Prunes the entries of one checkpoint that no test act of that checkpoint cites, and drops
360
+ its job-time XPath map. Called when the next checkpoint is created: test acts belong to the
361
+ latest checkpoint, so no later act can cite the earlier one, and pruning it then bounds the
362
+ temporary report that every test act reads. The structure diff with the next checkpoint is
363
+ computed before this runs (procs/checkpoint.js).
364
+ */
365
+ export const pruneCheckpoint = (report: Report, checkpoint: number): number => {
366
+ const {catalog} = report as Report & {catalog: Catalog};
367
+ const cited = getCitedIndexes(report, checkpoint);
368
+ let prunedCount = 0;
369
+ Object.keys(catalog).forEach(elementIndex => {
370
+ if (catalog[elementIndex].checkpoint === checkpoint && ! cited.has(elementIndex)) {
371
+ delete catalog[elementIndex];
372
+ prunedCount++;
373
+ }
374
+ });
375
+ if (report.pathIDs) {
376
+ delete report.pathIDs[checkpoint];
377
+ }
378
+ return prunedCount;
379
+ };
380
+ // Prunes a catalog.
381
+ export const pruneCatalog = (report: Report): void => {
382
+ console.log('Pruning catalog');
383
+ // The catalog always exists by pruning time (getCatalog ran at job start).
384
+ const {catalog} = report as Report & {catalog: Catalog};
385
+ const citedElementIndexes = getCitedIndexes(report);
386
+ // Delete the temporary job-time properties of the report.
387
+ delete report.pathIDs;
388
+ delete report.catalogNextIndex;
389
+ delete report.activeCheckpoint;
390
+ // For each element in the catalog:
391
+ Object.keys(catalog).forEach(elementIndex => {
392
+ // If it is not cited by any instance or by any cited element:
393
+ if (! citedElementIndexes.has(elementIndex)) {
394
+ // Delete it in the catalog.
395
+ delete catalog[elementIndex];
396
+ }
397
+ });
398
+ };
@@ -0,0 +1,110 @@
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
+ checkpoint.js
12
+ Creates checkpoints: snapshots of page states reached by a job's acts. See
13
+ docs/checkpoint-scanning.md.
14
+ */
15
+
16
+ // IMPORTS
17
+
18
+ const {createHash} = require('crypto');
19
+ const {catalogPage, getAriaSnapshot, pruneCheckpoint} = require('./catalog');
20
+ const {getStructureDiff} = require('./scope');
21
+ const {shoot} = require('./shoot');
22
+
23
+ // FUNCTIONS
24
+
25
+ // Returns a digest of the page's DOM, ignoring the attributes Testaro adds and whitespace, so
26
+ // that a replayed page can be compared with the snapshot it re-enacts.
27
+ const getDomDigest = exports.getDomDigest = async page => {
28
+ try {
29
+ const content = await page.content();
30
+ const normalized = content
31
+ .replace(/ data-xpath="[^"]*"/g, '')
32
+ .replace(/ data-testaro-opened=""/g, '')
33
+ .replace(/\s+/g, ' ');
34
+ return createHash('sha1').update(normalized).digest('hex');
35
+ }
36
+ catch(error) {
37
+ console.log(`ERROR: DOM digest failed (${error.message})`);
38
+ return '';
39
+ }
40
+ };
41
+ // Creates a checkpoint from the live page, adds it to the report, and returns it.
42
+ exports.makeCheckpoint = async ({
43
+ page, report, name, actIndex, implicit, launchActIndex, launchURL, replay, interaction
44
+ }) => {
45
+ const startTime = Date.now();
46
+ const index = report.checkpoints.length;
47
+ const imageIndexes = [];
48
+ // If page images are required:
49
+ if ([0, 2, 4, 6].includes(report.imageColor)) {
50
+ const imageScale = Number.isFinite(report.imageScale) && report.imageScale > 1
51
+ ? report.imageScale
52
+ : 1;
53
+ // Create one at CSS scale, and one at device scale if the job asks for it.
54
+ const cssIndex = await shoot(page, report, {
55
+ exclusionSelector: '',
56
+ colorType: report.imageColor,
57
+ action: 'report'
58
+ });
59
+ if (typeof cssIndex === 'number') {
60
+ imageIndexes.push(cssIndex);
61
+ }
62
+ if (imageScale > 1) {
63
+ const deviceIndex = await shoot(page, report, {
64
+ exclusionSelector: '',
65
+ colorType: report.imageColor,
66
+ action: 'report',
67
+ scale: 'device'
68
+ });
69
+ if (typeof deviceIndex === 'number') {
70
+ imageIndexes.push(deviceIndex);
71
+ }
72
+ }
73
+ }
74
+ // Catalog the page as this checkpoint, keeping the live page's state.
75
+ const snapshot = await catalogPage(page, report, {checkpoint: index, restoreDetails: true});
76
+ Object.assign(report.catalog, snapshot.entries);
77
+ report.pathIDs ??= {};
78
+ report.pathIDs[index] = snapshot.pathIDs;
79
+ report.catalogNextIndex = snapshot.nextIndex;
80
+ const checkpoint = {
81
+ index,
82
+ name,
83
+ implicit,
84
+ actIndex,
85
+ launchActIndex,
86
+ launchURL,
87
+ replay,
88
+ interaction,
89
+ kind: replay.length ? 'interaction' : 'navigation',
90
+ url: page.url(),
91
+ title: await page.title().catch(() => ''),
92
+ imageIndexes,
93
+ catalogRange: [snapshot.firstIndex, snapshot.nextIndex - 1],
94
+ elementCount: snapshot.elementCount,
95
+ ariaSnapshot: await getAriaSnapshot(page),
96
+ domDigest: await getDomDigest(page),
97
+ elapsedMs: Date.now() - startTime,
98
+ testActs: []
99
+ };
100
+ report.checkpoints.push(checkpoint);
101
+ // If there is a previous checkpoint, record what changed since it (job-time; report.flow
102
+ // takes it over at job end), then prune the previous checkpoint's uncited entries, which
103
+ // no later test act can cite.
104
+ if (index > 0) {
105
+ checkpoint.structure = getStructureDiff(report, index - 1, index);
106
+ const prunedCount = pruneCheckpoint(report, index - 1);
107
+ console.log(`Pruned ${prunedCount} uncited catalog entries of checkpoint ${index - 1}`);
108
+ }
109
+ return checkpoint;
110
+ };
@@ -0,0 +1,35 @@
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
+ config (hand-written declarations)
12
+ Minimal typings for the surface of procs/config.js that converted TypeScript
13
+ files use. Delete this file when config.js itself is converted (issue #73).
14
+ */
15
+
16
+ // Multiplies a base timeout by the configured timeout multiplier.
17
+ export function applyMultiplier(baseTimeout: number): number;
18
+ // Whether an environment variable is set to a true value, or the default if unset.
19
+ export function envFlag(name: string, defaultValue?: boolean): boolean;
20
+ // Load states a navigation can wait for, from strictest to loosest.
21
+ export const waitStates: string[];
22
+ // Navigation options of a job (its navigation property over the environment defaults).
23
+ export function getNavigation(report: unknown): {
24
+ waitUntil: 'networkidle' | 'load' | 'domcontentloaded';
25
+ timeout: number;
26
+ failFast4xx: boolean;
27
+ };
28
+ // The scanner identity header value of a job, or '' for none.
29
+ export function getScannerId(report: unknown): string;
30
+ // Whether a job scrolls the full page after navigation.
31
+ export function getScroll(report: unknown): boolean;
32
+ // Launch retries per rule of the testaro tool.
33
+ export const ruleLaunchRetries: number;
34
+ // Defaults for the qualWeb tool's browser.
35
+ export const qualWebDefaults: {stealth: boolean; adBlock: boolean};
package/procs/config.js CHANGED
@@ -17,3 +17,65 @@
17
17
  const timeoutMultiplier = Number.parseFloat(process.env.TIMEOUT_MULTIPLIER) || 1;
18
18
  // Multiplies a time limit by the configured amount.
19
19
  exports.applyMultiplier = (baseTimeout) => Math.round(baseTimeout * timeoutMultiplier);
20
+
21
+ // Returns whether an environment variable is set to a true value, or the default if unset.
22
+ const envFlag = (name, defaultValue = false) => {
23
+ const value = process.env[name];
24
+ if (value === undefined || value === '') {
25
+ return defaultValue;
26
+ }
27
+ return ['true', '1', 'yes', 'on'].includes(value.toLowerCase());
28
+ };
29
+ exports.envFlag = envFlag;
30
+
31
+ /*
32
+ Deployment options. Each is a job property when a job sets it, else an environment
33
+ variable, else the historical default, so an operator can set a fleet-wide policy and a job
34
+ can override it. See README.md (Jobs) and env.example.
35
+ */
36
+
37
+ // Load states a navigation can wait for, from strictest to loosest.
38
+ const waitStates = exports.waitStates = ['networkidle', 'load', 'domcontentloaded'];
39
+ // Defaults for navigation: the load state to wait for (NAV_WAIT_UNTIL; networkidle, the
40
+ // historical default, never arrives on pages whose network never quiets, so a deployment may
41
+ // prefer load), the time limit in ms (NAV_TIMEOUT), and whether a 4xx response ends launch
42
+ // retries at once (NAV_FAIL_FAST_4XX; a client error is a definitive refusal that retrying
43
+ // and switching browsers cannot change).
44
+ const navTimeout = Number.parseInt(process.env.NAV_TIMEOUT);
45
+ const navigationDefaults = {
46
+ waitUntil: waitStates.includes(process.env.NAV_WAIT_UNTIL) ? process.env.NAV_WAIT_UNTIL : 'networkidle',
47
+ timeout: navTimeout > 0 ? navTimeout : 10000,
48
+ failFast4xx: envFlag('NAV_FAIL_FAST_4XX')
49
+ };
50
+ // Returns the navigation options of a job: its navigation property over the defaults.
51
+ exports.getNavigation = report => {
52
+ const own = report && report.navigation && typeof report.navigation === 'object'
53
+ ? report.navigation
54
+ : {};
55
+ return {
56
+ waitUntil: waitStates.includes(own.waitUntil) ? own.waitUntil : navigationDefaults.waitUntil,
57
+ timeout: Number.isInteger(own.timeout) && own.timeout > 0 ? own.timeout : navigationDefaults.timeout,
58
+ failFast4xx: typeof own.failFast4xx === 'boolean' ? own.failFast4xx : navigationDefaults.failFast4xx
59
+ };
60
+ };
61
+ // The scanner identity sent as the X-YRA-Scanner request header, so that web application
62
+ // firewalls can recognize the scanner by a stable value (unlike the emulated User-Agent):
63
+ // the job's scannerId, else SCANNER_ID, else none.
64
+ exports.getScannerId = report => (report && report.scannerId) || process.env.SCANNER_ID || '';
65
+ // Whether to scroll the full height of a page after navigation, before cataloguing, imaging,
66
+ // and each tool, so that lazily loaded content is present: the job's scroll property, else
67
+ // PRESCAN_SCROLL, else false (the as-loaded page, comparable with the WebAIM Million).
68
+ exports.getScroll = report => (
69
+ report && typeof report.scroll === 'boolean' ? report.scroll : envFlag('PRESCAN_SCROLL')
70
+ );
71
+ // Launch retries per rule of the testaro tool, which relaunches the browser per contaminating
72
+ // rule: TESTARO_RULE_RETRIES, else 2. Non-Chromium browsers navigate flakily on relaunch, so
73
+ // a deployment testing them may raise this.
74
+ const ruleRetries = Number.parseInt(process.env.TESTARO_RULE_RETRIES);
75
+ exports.ruleLaunchRetries = ruleRetries >= 0 ? ruleRetries : 2;
76
+ // Defaults for the qualWeb tool's browser: stealth evasions (QUALWEB_STEALTH) and ad and
77
+ // tracker blocking (QUALWEB_ADBLOCK), both on by default as the tool was designed.
78
+ exports.qualWebDefaults = {
79
+ stealth: envFlag('QUALWEB_STEALTH', true),
80
+ adBlock: envFlag('QUALWEB_ADBLOCK', true)
81
+ };
package/procs/dateTime.js CHANGED
@@ -28,7 +28,8 @@ exports.dateOf = timeStamp => {
28
28
  const dateString = punctuate(timeStamp.slice(0, 6), '-', 2);
29
29
  const timeString = punctuate(timeStamp.slice(7, 11), ':', 2);
30
30
  return new Date(`20${dateString}T${timeString}Z`);
31
- } else {
31
+ }
32
+ else {
32
33
  return null;
33
34
  }
34
35
  };