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.
- package/.gitattributes +94 -0
- package/.github/workflows/ci.yml +58 -0
- package/.github/workflows/publish.yml +49 -0
- package/.github/workflows/typescript.yml +35 -0
- package/AGENTS.md +2 -2
- package/CLAUDE.md +24 -18
- package/CONTAINERS.md +9 -6
- package/CONTRIBUTING.md +29 -3
- package/Dockerfile +1 -1
- package/README.md +73 -19
- package/UPGRADES.md +4 -0
- package/actSpecs-doc.md +13 -1
- package/actSpecs.js +29 -5
- package/call.js +6 -5
- package/docker-compose.yml +8 -2
- package/docs/checkpoint-scanning.md +199 -0
- package/docs/standard-result-outcome.md +165 -0
- package/env.example +44 -10
- package/eslint.config.mjs +101 -0
- package/netWatch.js +372 -222
- package/package.json +46 -31
- package/pour/README.md +47 -0
- package/pour/pour.min.js +8 -0
- package/procs/actDo.js +808 -0
- package/procs/catalog.d.ts +17 -0
- package/procs/catalog.js +312 -221
- package/procs/catalog.ts +398 -0
- package/procs/checkpoint.js +110 -0
- package/procs/config.d.ts +35 -0
- package/procs/config.js +62 -0
- package/procs/dateTime.js +2 -1
- package/procs/doActs.js +442 -849
- package/procs/doTestAct.js +27 -121
- package/procs/flow.js +221 -0
- package/procs/generateRuleRegistry.js +94 -0
- package/procs/getSource.d.ts +26 -0
- package/procs/job.js +115 -1
- package/procs/launch.d.ts +40 -0
- package/procs/launch.js +239 -108
- package/procs/nu.d.ts +48 -0
- package/procs/scope.js +196 -0
- package/procs/shoot.d.ts +33 -0
- package/procs/standard.d.ts +18 -0
- package/procs/standard.js +83 -0
- package/procs/standard.ts +126 -0
- package/procs/testAct.js +145 -0
- package/procs/testaro.d.ts +20 -0
- package/procs/testaro.js +236 -189
- package/procs/testaro.ts +321 -0
- package/procs/userPath.js +126 -0
- package/procs/xPath.d.ts +4 -0
- package/procs/xPath.js +81 -69
- package/procs/xPath.ts +106 -0
- package/procs/xPathScript.d.ts +6 -0
- package/procs/xPathScript.js +70 -0
- package/run.js +3 -2
- package/surea11y/README.md +44 -0
- package/surea11y/surea11y.browser.js +14 -0
- package/testaro/adbID.d.ts +3 -0
- package/testaro/adbID.js +38 -41
- package/testaro/adbID.ts +66 -0
- package/testaro/allCapStyle.d.ts +3 -0
- package/testaro/allCapStyle.js +31 -34
- package/testaro/allCapStyle.ts +55 -0
- package/testaro/allCaps.d.ts +16 -0
- package/testaro/allCaps.js +179 -151
- package/testaro/allCaps.ts +228 -0
- package/testaro/allHidden.d.ts +13 -0
- package/testaro/allHidden.js +29 -30
- package/testaro/allHidden.ts +50 -0
- package/testaro/allSlanted.d.ts +3 -0
- package/testaro/allSlanted.js +30 -33
- package/testaro/allSlanted.ts +54 -0
- package/testaro/altScheme.d.ts +3 -0
- package/testaro/altScheme.js +27 -30
- package/testaro/altScheme.ts +50 -0
- package/testaro/attVal.d.ts +3 -0
- package/testaro/attVal.js +20 -21
- package/testaro/attVal.ts +52 -0
- package/testaro/autocomplete.d.ts +3 -0
- package/testaro/autocomplete.js +59 -75
- package/testaro/autocomplete.ts +101 -0
- package/testaro/bulk.d.ts +13 -0
- package/testaro/bulk.js +32 -33
- package/testaro/bulk.ts +55 -0
- package/testaro/buttonMenu.d.ts +9 -0
- package/testaro/buttonMenu.js +317 -319
- package/testaro/buttonMenu.ts +391 -0
- package/testaro/captionLoc.d.ts +3 -0
- package/testaro/captionLoc.js +17 -20
- package/testaro/captionLoc.ts +40 -0
- package/testaro/datalistRef.d.ts +3 -0
- package/testaro/datalistRef.js +33 -36
- package/testaro/datalistRef.ts +55 -0
- package/testaro/distortion.d.ts +3 -0
- package/testaro/distortion.js +57 -26
- package/testaro/distortion.ts +81 -0
- package/testaro/docType.d.ts +15 -0
- package/testaro/docType.js +25 -25
- package/testaro/docType.ts +45 -0
- package/testaro/dupAtt.d.ts +16 -0
- package/testaro/dupAtt.js +113 -104
- package/testaro/dupAtt.ts +144 -0
- package/testaro/elements.d.ts +6 -0
- package/testaro/elements.js +153 -153
- package/testaro/elements.ts +215 -0
- package/testaro/embAc.d.ts +3 -0
- package/testaro/embAc.js +19 -20
- package/testaro/embAc.ts +40 -0
- package/testaro/focAll.d.ts +13 -0
- package/testaro/focAll.js +185 -191
- package/testaro/focAll.ts +217 -0
- package/testaro/focAndOp.d.ts +3 -0
- package/testaro/focAndOp.js +100 -104
- package/testaro/focAndOp.ts +128 -0
- package/testaro/focInd.d.ts +3 -0
- package/testaro/focInd.js +64 -66
- package/testaro/focInd.ts +96 -0
- package/testaro/focVis.d.ts +3 -0
- package/testaro/focVis.js +28 -30
- package/testaro/focVis.ts +52 -0
- package/testaro/headEl.d.ts +10 -0
- package/testaro/headEl.js +61 -62
- package/testaro/headEl.ts +82 -0
- package/testaro/headingAmb.d.ts +3 -0
- package/testaro/headingAmb.js +54 -63
- package/testaro/headingAmb.ts +72 -0
- package/testaro/hovInd.d.ts +12 -0
- package/testaro/hovInd.js +163 -130
- package/testaro/hovInd.ts +199 -0
- package/testaro/hover.d.ts +3 -0
- package/testaro/hover.js +155 -126
- package/testaro/hover.ts +154 -0
- package/testaro/hr.d.ts +3 -0
- package/testaro/hr.js +14 -17
- package/testaro/hr.ts +36 -0
- package/testaro/imageLink.d.ts +3 -0
- package/testaro/imageLink.js +17 -20
- package/testaro/imageLink.ts +42 -0
- package/testaro/labClash.d.ts +3 -0
- package/testaro/labClash.js +31 -33
- package/testaro/labClash.ts +54 -0
- package/testaro/legendLoc.d.ts +3 -0
- package/testaro/legendLoc.js +17 -20
- package/testaro/legendLoc.ts +42 -0
- package/testaro/lineHeight.d.ts +3 -0
- package/testaro/lineHeight.js +43 -48
- package/testaro/lineHeight.ts +70 -0
- package/testaro/linkAmb.d.ts +7 -0
- package/testaro/linkAmb.js +79 -80
- package/testaro/linkAmb.ts +105 -0
- package/testaro/linkExt.d.ts +3 -0
- package/testaro/linkExt.js +13 -16
- package/testaro/linkExt.ts +35 -0
- package/testaro/linkOldAtt.d.ts +3 -0
- package/testaro/linkOldAtt.js +25 -28
- package/testaro/linkOldAtt.ts +48 -0
- package/testaro/linkTo.d.ts +3 -0
- package/testaro/linkTo.js +22 -22
- package/testaro/linkTo.ts +43 -0
- package/testaro/linkUl.d.ts +3 -0
- package/testaro/linkUl.js +30 -34
- package/testaro/linkUl.ts +54 -0
- package/testaro/miniText.d.ts +3 -0
- package/testaro/miniText.js +41 -44
- package/testaro/miniText.ts +68 -0
- package/testaro/motion.d.ts +10 -0
- package/testaro/motion.js +92 -96
- package/testaro/motion.ts +125 -0
- package/testaro/nonTable.d.ts +3 -0
- package/testaro/nonTable.js +39 -45
- package/testaro/nonTable.ts +66 -0
- package/testaro/optRoleSel.d.ts +3 -0
- package/testaro/optRoleSel.js +16 -19
- package/testaro/optRoleSel.ts +41 -0
- package/testaro/phOnly.d.ts +3 -0
- package/testaro/phOnly.js +18 -21
- package/testaro/phOnly.ts +43 -0
- package/testaro/pseudoP.d.ts +3 -0
- package/testaro/pseudoP.js +36 -38
- package/testaro/pseudoP.ts +59 -0
- package/testaro/radioSet.d.ts +3 -0
- package/testaro/radioSet.js +57 -59
- package/testaro/radioSet.ts +79 -0
- package/testaro/registry.d.ts +63 -0
- package/testaro/registry.js +67 -0
- package/testaro/registry.ts +141 -0
- package/testaro/role.d.ts +3 -0
- package/testaro/role.js +27 -29
- package/testaro/role.ts +53 -0
- package/testaro/secHeading.d.ts +3 -0
- package/testaro/secHeading.js +30 -33
- package/testaro/secHeading.ts +53 -0
- package/testaro/styleDiff.d.ts +25 -0
- package/testaro/styleDiff.js +248 -252
- package/testaro/styleDiff.ts +303 -0
- package/testaro/tabNav.d.ts +33 -0
- package/testaro/tabNav.js +272 -342
- package/testaro/tabNav.ts +454 -0
- package/testaro/targetsNear.d.ts +9 -0
- package/testaro/targetsNear.js +130 -132
- package/testaro/targetsNear.ts +160 -0
- package/testaro/textNodes.d.ts +6 -0
- package/testaro/textNodes.js +139 -135
- package/testaro/textNodes.ts +185 -0
- package/testaro/textSem.d.ts +3 -0
- package/testaro/textSem.js +25 -28
- package/testaro/textSem.ts +47 -0
- package/testaro/title.d.ts +9 -0
- package/testaro/title.js +16 -13
- package/testaro/title.ts +31 -0
- package/testaro/titledEl.d.ts +3 -0
- package/testaro/titledEl.js +15 -18
- package/testaro/titledEl.ts +38 -0
- package/testaro/zIndex.d.ts +3 -0
- package/testaro/zIndex.js +19 -22
- package/testaro/zIndex.ts +42 -0
- package/tests/alfa.d.ts +45 -0
- package/tests/alfa.js +138 -141
- package/tests/alfa.ts +214 -0
- package/tests/aslint.d.ts +33 -0
- package/tests/aslint.js +272 -249
- package/tests/aslint.ts +301 -0
- package/tests/axe.d.ts +27 -0
- package/tests/axe.js +199 -200
- package/tests/axe.ts +277 -0
- package/tests/ed11y.d.ts +28 -0
- package/tests/ed11y.js +141 -99
- package/tests/ed11y.ts +178 -0
- package/tests/htmlcs.d.ts +21 -0
- package/tests/htmlcs.js +174 -140
- package/tests/htmlcs.ts +181 -0
- package/tests/ibm.d.ts +52 -0
- package/tests/ibm.js +164 -166
- package/tests/ibm.ts +251 -0
- package/tests/nuVal.d.ts +13 -0
- package/tests/nuVal.js +107 -112
- package/tests/nuVal.ts +145 -0
- package/tests/nuVnu.d.ts +15 -0
- package/tests/nuVnu.js +141 -111
- package/tests/nuVnu.ts +144 -0
- package/tests/pour.d.ts +31 -0
- package/tests/pour.js +242 -0
- package/tests/pour.ts +273 -0
- package/tests/qualWeb.d.ts +39 -0
- package/tests/qualWeb.js +302 -272
- package/tests/qualWeb.ts +415 -0
- package/tests/surea11y.d.ts +33 -0
- package/tests/surea11y.js +288 -0
- package/tests/surea11y.ts +334 -0
- package/tests/testaro.d.ts +25 -0
- package/tests/testaro.js +745 -652
- package/tests/testaro.ts +862 -0
- package/tests/wave.d.ts +46 -0
- package/tests/wave.js +166 -177
- package/tests/wave.ts +252 -0
- package/tsconfig.json +17 -0
- package/types.d.ts +243 -0
- package/types.js +10 -0
- package/types.ts +376 -0
- package/validation/act/README.md +46 -0
- package/validation/act/capture.js +424 -0
- package/validation/act/chromium-issue-draft.md +66 -0
- package/validation/act/fp-triage-2026-08-22.md +86 -0
- package/validation/act/isolation-notes.md +159 -0
- package/validation/act/playwright-issue-draft.md +73 -0
- package/validation/act/propose-mappings.js +0 -0
- package/validation/act/repro-cdp-raw.js +105 -0
- package/validation/act/repro-metarefresh.js +65 -0
- package/validation/act/score.js +184 -0
- package/validation/act/stage3a-stress-report.md +69 -0
- package/validation/act/stage3b-mapping-proposals.md +94 -0
- package/validation/act/stage3b-triage-draft.md +138 -0
- package/validation/act/surea11y-track-a-2026-09-01.md +50 -0
- package/validation/executors/netWatch.js +180 -90
- package/validation/executors/test.js +17 -2
- package/validation/executors/tests.js +118 -10
- package/validation/jobs/reports/raw/260901T1000-surea11y-validation.json +964 -0
- package/validation/jobs/todo/240101T1200-simple-example.json +14 -6
- package/validation/jobs/todo/240101T1300-shoot-example.json +2 -1
- package/validation/jobs/todo/260821T1900-pour-validation.json +45 -0
- package/validation/jobs/todo/260901T1000-surea11y-validation.json +45 -0
- package/validation/knownFailures.json +1 -0
- package/validation/tests/jobProperties/adbID.json +27 -2
- package/validation/tests/jobProperties/{focOp.json → allCapStyle.json} +56 -53
- package/validation/tests/jobProperties/allCaps.json +25 -0
- package/validation/tests/jobProperties/allHidden.json +133 -13
- package/validation/tests/jobProperties/allSlanted.json +3 -3
- package/validation/tests/jobProperties/altScheme.json +23 -8
- package/validation/tests/jobProperties/attVal.json +57 -57
- package/validation/tests/jobProperties/autocomplete.json +11 -1
- package/validation/tests/jobProperties/bulk.json +6 -1
- package/validation/tests/jobProperties/buttonMenu.json +63 -42
- package/validation/tests/jobProperties/captionLoc.json +1 -6
- package/validation/tests/jobProperties/checkpoint-browser.json +401 -0
- package/validation/tests/jobProperties/checkpoint-page.json +397 -0
- package/validation/tests/jobProperties/checkpoint.json +395 -0
- package/validation/tests/jobProperties/datalistRef.json +20 -5
- package/validation/tests/jobProperties/distortion.json +28 -3
- package/validation/tests/jobProperties/docType.json +2 -2
- package/validation/tests/jobProperties/dupAtt.json +48 -33
- package/validation/tests/jobProperties/elements.json +28 -28
- package/validation/tests/jobProperties/embAc.json +36 -31
- package/validation/tests/jobProperties/focAndOp.json +284 -0
- package/validation/tests/jobProperties/focInd.json +34 -34
- package/validation/tests/jobProperties/focVis.json +1 -1
- package/validation/tests/jobProperties/hover.json +39 -37
- package/validation/tests/jobProperties/hr.json +13 -3
- package/validation/tests/jobProperties/imageLink.json +0 -5
- package/validation/tests/jobProperties/labClash.json +68 -33
- package/validation/tests/jobProperties/legendLoc.json +1 -6
- package/validation/tests/jobProperties/lineHeight.json +2 -2
- package/validation/tests/jobProperties/linkAmb.json +13 -18
- package/validation/tests/jobProperties/linkExt.json +1 -1
- package/validation/tests/jobProperties/linkOldAtt.json +12 -2
- package/validation/tests/jobProperties/linkTo.json +1 -1
- package/validation/tests/jobProperties/linkUl.json +65 -65
- package/validation/tests/jobProperties/miniText.json +7 -2
- package/validation/tests/jobProperties/motion.json +4 -50
- package/validation/tests/jobProperties/nonTable.json +48 -3
- package/validation/tests/jobProperties/optRoleSel.json +12 -2
- package/validation/tests/jobProperties/phOnly.json +6 -16
- package/validation/tests/jobProperties/pseudoP.json +17 -2
- package/validation/tests/jobProperties/radioSet.json +30 -30
- package/validation/tests/jobProperties/role.json +25 -5
- package/validation/tests/jobProperties/secHeading.json +26 -21
- package/validation/tests/jobProperties/styleDiff.json +35 -5
- package/validation/tests/jobProperties/tabNav.json +3 -1
- package/validation/tests/jobProperties/targetsNear.json +237 -0
- package/validation/tests/jobProperties/textNodes.json +29 -29
- package/validation/tests/jobProperties/textSem.json +26 -1
- package/validation/tests/jobProperties/title.json +12 -2
- package/validation/tests/jobProperties/titledEl.json +39 -9
- package/validation/tests/jobProperties/userPath.json +317 -0
- package/validation/tests/jobProperties/zIndex.json +40 -35
- package/validation/tests/targets/allCapStyle/index.html +26 -0
- package/validation/tests/targets/checkpoint/index.html +38 -0
- package/validation/tests/targets/datalistRef/index.html +1 -1
- package/validation/tests/targets/focAndOp/bad.html +29 -0
- package/validation/tests/targets/{focOp → focAndOp}/good.html +3 -1
- package/validation/tests/targets/focInd/bad.html +2 -1
- package/validation/tests/targets/headEl/index.html +10 -1
- package/validation/tests/targets/{targetSmall → targetsNear}/index.html +15 -1
- package/validation/tests/targets/userPath/index.html +42 -0
- package/validation/validateTest.js +61 -9
- package/.claude/settings.local.json +0 -11
- package/.eslintrc.json +0 -41
- package/htmlcs/.eslintrc.json +0 -67
- package/validation/tests/jobProperties/linkTitle.json +0 -127
- package/validation/tests/jobProperties/opFoc.json +0 -164
- package/validation/tests/jobProperties/targetSmall.json +0 -152
- package/validation/tests/jobProperties/targetTiny.json +0 -142
- package/validation/tests/targets/focOp/bad.html +0 -25
- package/validation/tests/targets/linkTitle/index.html +0 -24
- package/validation/tests/targets/opFoc/bad.html +0 -26
- package/validation/tests/targets/opFoc/good.html +0 -23
package/procs/doActs.js
CHANGED
|
@@ -16,12 +16,22 @@
|
|
|
16
16
|
// IMPORTS
|
|
17
17
|
|
|
18
18
|
const {addError} = require('./error');
|
|
19
|
-
const {
|
|
20
|
-
|
|
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
|
|
24
|
-
const {
|
|
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
|
-
//
|
|
65
|
-
|
|
66
|
-
//
|
|
67
|
-
|
|
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
|
-
|
|
269
|
-
|
|
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:
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
343
|
-
//
|
|
344
|
-
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
const
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
//
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
//
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
-
//
|
|
424
|
-
|
|
425
|
-
// Report this.
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
)
|
|
437
|
-
|
|
438
|
-
|
|
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
|
|
591
|
+
// Otherwise, i.e. if test acts run in this process (browser or page isolation):
|
|
442
592
|
else {
|
|
443
|
-
//
|
|
444
|
-
|
|
445
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
//
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
if (
|
|
532
|
-
|
|
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
|
-
|
|
623
|
-
|
|
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
|
-
|
|
645
|
-
|
|
646
|
-
|
|
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
|
-
|
|
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
|
}
|