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/actDo.js
ADDED
|
@@ -0,0 +1,808 @@
|
|
|
1
|
+
/*
|
|
2
|
+
© 2021–2025 CVS Health and/or one of its affiliates. All rights reserved.
|
|
3
|
+
© 2026 Jeff Witt.
|
|
4
|
+
© 2025–2026 Jonathan Robert Pool.
|
|
5
|
+
|
|
6
|
+
Licensed under the MIT License. See LICENSE file at the project root or https://opensource.org/license/mit/ for details.
|
|
7
|
+
|
|
8
|
+
SPDX-License-Identifier: MIT
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/*
|
|
12
|
+
actDo.js
|
|
13
|
+
Performs the acts that operate on a live page: navigation, waiting, revealing, screenshots,
|
|
14
|
+
moves (button, checkbox, focus, link, radio, search, select, text), and key presses. The acts
|
|
15
|
+
loop (doActs.js) calls doInteractionAct for each such act, and the launcher (launch.js) calls
|
|
16
|
+
replayActs to re-enact the acts that reached a checkpoint before a tool tests it.
|
|
17
|
+
|
|
18
|
+
The `interaction` option selects the event modality; only 'efficient' (Playwright's composed
|
|
19
|
+
clicks and key presses) exists today. Recorded on each checkpoint, it lets a replay use the
|
|
20
|
+
same modality the original acts used.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
// IMPORTS
|
|
24
|
+
|
|
25
|
+
const {addError} = require('./error');
|
|
26
|
+
// Function to take a full-page screenshot.
|
|
27
|
+
const {shoot} = require('./shoot');
|
|
28
|
+
|
|
29
|
+
// CONSTANTS
|
|
30
|
+
|
|
31
|
+
// CSS selectors for targets of moves.
|
|
32
|
+
const moves = exports.moves = {
|
|
33
|
+
button: 'button, [role=button], input[type=submit]',
|
|
34
|
+
checkbox: 'input[type=checkbox]',
|
|
35
|
+
focus: true,
|
|
36
|
+
link: 'a, [role=link]',
|
|
37
|
+
radio: 'input[type=radio]',
|
|
38
|
+
search: 'input[type=search], input[aria-label*=search i], input[placeholder*=search i]',
|
|
39
|
+
select: 'select',
|
|
40
|
+
text: 'input'
|
|
41
|
+
};
|
|
42
|
+
// Act types that doInteractionAct performs (every act type that needs a live page).
|
|
43
|
+
exports.interactionTypes = [
|
|
44
|
+
'url', 'wait', 'state', 'page', 'reveal', 'shoot', 'press', 'presses', ... Object.keys(moves)
|
|
45
|
+
];
|
|
46
|
+
// The default interaction options.
|
|
47
|
+
exports.defaultInteraction = {modality: 'efficient'};
|
|
48
|
+
|
|
49
|
+
// FUNCTIONS
|
|
50
|
+
|
|
51
|
+
// The launcher requires this module, so require it lazily to avoid a circular load.
|
|
52
|
+
const getLaunch = () => require('./launch');
|
|
53
|
+
// Normalizes spacing characters and cases in a string.
|
|
54
|
+
const debloat = exports.debloat = string => string
|
|
55
|
+
.replace(/\s/g, ' ')
|
|
56
|
+
.trim()
|
|
57
|
+
.replace(/ {2,}/g, ' ')
|
|
58
|
+
.toLowerCase();
|
|
59
|
+
// Returns the first line of an error message.
|
|
60
|
+
const errorStart = error => error.message.replace(/\n.+/s, '');
|
|
61
|
+
// Returns the text of an element, lower-cased.
|
|
62
|
+
const textOf = async (page, element) => {
|
|
63
|
+
if (element) {
|
|
64
|
+
const tagNameJSHandle = await element.getProperty('tagName');
|
|
65
|
+
const tagName = await tagNameJSHandle.jsonValue();
|
|
66
|
+
let totalText = '';
|
|
67
|
+
// If the element is a link, button, input, or select list:
|
|
68
|
+
if (['A', 'BUTTON', 'INPUT', 'SELECT'].includes(tagName)) {
|
|
69
|
+
// Return its visible labels, descriptions, and legend if the first input in a fieldset.
|
|
70
|
+
totalText = await page.evaluate(element => {
|
|
71
|
+
const {tagName, ariaLabel} = element;
|
|
72
|
+
let ownText = '';
|
|
73
|
+
if (['A', 'BUTTON'].includes(tagName)) {
|
|
74
|
+
ownText = element.textContent;
|
|
75
|
+
}
|
|
76
|
+
else if (tagName === 'INPUT' && element.type === 'submit') {
|
|
77
|
+
ownText = element.value;
|
|
78
|
+
}
|
|
79
|
+
// HTML link elements have no labels property.
|
|
80
|
+
const labels = tagName !== 'A' ? Array.from(element.labels) : [];
|
|
81
|
+
const labelTexts = labels.map(label => label.textContent);
|
|
82
|
+
if (ariaLabel) {
|
|
83
|
+
labelTexts.push(ariaLabel);
|
|
84
|
+
}
|
|
85
|
+
const refIDs = new Set([
|
|
86
|
+
element.getAttribute('aria-labelledby') || '',
|
|
87
|
+
element.getAttribute('aria-describedby') || ''
|
|
88
|
+
].join(' ').split(/\s+/));
|
|
89
|
+
if (refIDs.size) {
|
|
90
|
+
refIDs.forEach(id => {
|
|
91
|
+
const labeler = document.getElementById(id);
|
|
92
|
+
if (labeler) {
|
|
93
|
+
const labelerText = labeler.textContent.trim();
|
|
94
|
+
if (labelerText.length) {
|
|
95
|
+
labelTexts.push(labelerText);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
let legendText = '';
|
|
101
|
+
if (tagName === 'INPUT') {
|
|
102
|
+
const fieldsets = Array.from(document.body.querySelectorAll('fieldset'));
|
|
103
|
+
const inputFieldsets = fieldsets.filter(fieldset => {
|
|
104
|
+
const inputs = Array.from(fieldset.querySelectorAll('input'));
|
|
105
|
+
return inputs.length && inputs[0] === element;
|
|
106
|
+
});
|
|
107
|
+
const inputFieldset = inputFieldsets[0] || null;
|
|
108
|
+
if (inputFieldset) {
|
|
109
|
+
const legend = inputFieldset.querySelector('legend');
|
|
110
|
+
if (legend) {
|
|
111
|
+
legendText = legend.textContent;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return [legendText].concat(labelTexts, ownText).join(' ');
|
|
116
|
+
}, element);
|
|
117
|
+
}
|
|
118
|
+
// Otherwise, if it is an option:
|
|
119
|
+
else if (tagName === 'OPTION') {
|
|
120
|
+
// Return its text content, prefixed with the text of its select parent if the first option.
|
|
121
|
+
const ownText = await element.textContent();
|
|
122
|
+
const indexJSHandle = await element.getProperty('index');
|
|
123
|
+
const index = await indexJSHandle.jsonValue();
|
|
124
|
+
if (index) {
|
|
125
|
+
totalText = ownText;
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
const selectJSHandle = await page.evaluateHandle(
|
|
129
|
+
element => element.parentElement, element
|
|
130
|
+
);
|
|
131
|
+
const select = await selectJSHandle.asElement();
|
|
132
|
+
if (select) {
|
|
133
|
+
const selectText = await textOf(page, select);
|
|
134
|
+
totalText = [ownText, selectText].join(' ');
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
totalText = ownText;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
// Otherwise, i.e. if it is not an input, select, or option:
|
|
142
|
+
else {
|
|
143
|
+
// Get its text content.
|
|
144
|
+
totalText = await element.textContent();
|
|
145
|
+
}
|
|
146
|
+
return debloat(totalText);
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
// Adds a wait error result to an act.
|
|
153
|
+
const waitError = (page, act, error, what) => {
|
|
154
|
+
console.log(`ERROR waiting for ${what} (${error.message})`);
|
|
155
|
+
act.result.found = false;
|
|
156
|
+
act.result.url = page.url();
|
|
157
|
+
act.result.error = `ERROR waiting for ${what}`;
|
|
158
|
+
return false;
|
|
159
|
+
};
|
|
160
|
+
// Performs one act on a live page and returns the page (which a page act replaces).
|
|
161
|
+
exports.doInteractionAct = async ({
|
|
162
|
+
page, report, act, actIndex, actCount = 0, interaction = exports.defaultInteraction
|
|
163
|
+
}) => {
|
|
164
|
+
const {goTo, getNonce, wait} = getLaunch();
|
|
165
|
+
const {type} = act;
|
|
166
|
+
// The modality is recorded for the future modalities; only 'efficient' exists today.
|
|
167
|
+
act.modality = interaction.modality;
|
|
168
|
+
if (type === 'url') {
|
|
169
|
+
// Identify the URL.
|
|
170
|
+
const requestedURL = act.which.replace('__dirname', __dirname);
|
|
171
|
+
// Visit it and wait until the DOM is loaded.
|
|
172
|
+
const navResult = await goTo(report, page, requestedURL, 15000, 'domcontentloaded');
|
|
173
|
+
// If the visit succeeded:
|
|
174
|
+
if (navResult.success) {
|
|
175
|
+
// Revise the temporary report URL to this URL.
|
|
176
|
+
report.target.url = requestedURL;
|
|
177
|
+
// Add the script nonce, if any, to the job data.
|
|
178
|
+
const {response} = navResult;
|
|
179
|
+
const scriptNonce = await getNonce(response);
|
|
180
|
+
if (scriptNonce) {
|
|
181
|
+
report.jobData.lastScriptNonce = scriptNonce;
|
|
182
|
+
}
|
|
183
|
+
// Add the resulting URL to the act.
|
|
184
|
+
act.result = {success: true, url: page.url()};
|
|
185
|
+
}
|
|
186
|
+
// Otherwise, i.e. if the visit failed (goTo reports a prohibited redirection as a failure):
|
|
187
|
+
else {
|
|
188
|
+
// Report this.
|
|
189
|
+
addError(true, false, report, actIndex, `ERROR: Visit failed (${navResult.error})`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
// Otherwise, if the act is a wait for text:
|
|
193
|
+
else if (type === 'wait') {
|
|
194
|
+
const {what, which} = act;
|
|
195
|
+
console.log(`>> ${what}`);
|
|
196
|
+
const result = act.result = {};
|
|
197
|
+
// If the text is to be the URL:
|
|
198
|
+
if (what === 'url') {
|
|
199
|
+
// Wait for the URL to be the exact text.
|
|
200
|
+
try {
|
|
201
|
+
await page.waitForURL(which, {timeout: 15000});
|
|
202
|
+
result.found = true;
|
|
203
|
+
result.url = page.url();
|
|
204
|
+
}
|
|
205
|
+
// If the wait times out:
|
|
206
|
+
catch(error) {
|
|
207
|
+
// Report this.
|
|
208
|
+
waitError(page, act, error, 'text in the URL');
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
// Otherwise, if the text is to be a substring of the page title:
|
|
212
|
+
else if (what === 'title') {
|
|
213
|
+
// Wait for the page title to include the text, case-insensitively.
|
|
214
|
+
try {
|
|
215
|
+
await page.waitForFunction(
|
|
216
|
+
text => document
|
|
217
|
+
&& document.title
|
|
218
|
+
&& document.title.toLowerCase().includes(text.toLowerCase()),
|
|
219
|
+
which,
|
|
220
|
+
{
|
|
221
|
+
polling: 1000,
|
|
222
|
+
timeout: 5000
|
|
223
|
+
}
|
|
224
|
+
);
|
|
225
|
+
result.found = true;
|
|
226
|
+
result.title = await page.title();
|
|
227
|
+
}
|
|
228
|
+
// If the wait times out:
|
|
229
|
+
catch(error) {
|
|
230
|
+
// Report this.
|
|
231
|
+
waitError(page, act, error, 'text in the title');
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
// Otherwise, if the text is to be a substring of the text of the page body:
|
|
235
|
+
else if (what === 'body') {
|
|
236
|
+
// Wait for the body to include the text, case-insensitively.
|
|
237
|
+
try {
|
|
238
|
+
await page.waitForFunction(
|
|
239
|
+
text => document
|
|
240
|
+
&& document.body
|
|
241
|
+
&& document.body.innerText.toLowerCase().includes(text.toLowerCase()),
|
|
242
|
+
which,
|
|
243
|
+
{
|
|
244
|
+
polling: 2000,
|
|
245
|
+
timeout: 15000
|
|
246
|
+
}
|
|
247
|
+
);
|
|
248
|
+
result.found = true;
|
|
249
|
+
}
|
|
250
|
+
// If the wait times out:
|
|
251
|
+
catch(error) {
|
|
252
|
+
// Report this.
|
|
253
|
+
waitError(page, act, error, 'text in the body');
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
// Otherwise, if the act is a wait for a state:
|
|
258
|
+
else if (type === 'state') {
|
|
259
|
+
// Wait for it.
|
|
260
|
+
const stateIndex = ['loaded', 'idle'].indexOf(act.which);
|
|
261
|
+
let reached = true;
|
|
262
|
+
await page.waitForLoadState(
|
|
263
|
+
['domcontentloaded', 'networkidle'][stateIndex], {timeout: [10000, 15000][stateIndex]}
|
|
264
|
+
)
|
|
265
|
+
// If the wait times out:
|
|
266
|
+
.catch(async error => {
|
|
267
|
+
// Report this.
|
|
268
|
+
reached = false;
|
|
269
|
+
console.log(`ERROR waiting for page to be ${act.which} (${error.message})`);
|
|
270
|
+
addError(true, false, report, actIndex, `ERROR waiting for page to be ${act.which}`);
|
|
271
|
+
});
|
|
272
|
+
// Add state data to the act.
|
|
273
|
+
act.result = {
|
|
274
|
+
success: reached,
|
|
275
|
+
state: act.which
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
// Otherwise, if the act is a page switch:
|
|
279
|
+
else if (type === 'page') {
|
|
280
|
+
const context = page.context();
|
|
281
|
+
page = await context.waitForEvent('page');
|
|
282
|
+
// Wait until it is idle.
|
|
283
|
+
await page.waitForLoadState('networkidle', {timeout: 15000});
|
|
284
|
+
// Add the resulting URL to the act.
|
|
285
|
+
const result = {
|
|
286
|
+
url: page.url()
|
|
287
|
+
};
|
|
288
|
+
act.result = result;
|
|
289
|
+
}
|
|
290
|
+
// Otherwise, if the page has a URL:
|
|
291
|
+
else if (page.url() && page.url() !== 'about:blank') {
|
|
292
|
+
const url = page.url();
|
|
293
|
+
// Add the URL to the act.
|
|
294
|
+
act.actualURL = url;
|
|
295
|
+
// If the act is a revelation:
|
|
296
|
+
if (type === 'reveal') {
|
|
297
|
+
act.result = {
|
|
298
|
+
success: true
|
|
299
|
+
};
|
|
300
|
+
// Make all elements in the page visible.
|
|
301
|
+
await page.$$eval('body *', elements => {
|
|
302
|
+
elements.forEach(element => {
|
|
303
|
+
const styleDec = window.getComputedStyle(element);
|
|
304
|
+
if (styleDec.display === 'none') {
|
|
305
|
+
element.style.display = 'initial';
|
|
306
|
+
}
|
|
307
|
+
if (['hidden', 'collapse'].includes(styleDec.visibility)) {
|
|
308
|
+
element.style.visibility = 'inherit';
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
})
|
|
312
|
+
.catch(error => {
|
|
313
|
+
console.log(`ERROR making all elements visible (${error.message})`);
|
|
314
|
+
act.result.success = false;
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
// Otherwise, if the act is a screenshot:
|
|
318
|
+
else if (type === 'shoot') {
|
|
319
|
+
const {exclusionSelector, colorType, action} = act;
|
|
320
|
+
// Make and dispose of a full-page screenshot.
|
|
321
|
+
const shotInfo = await shoot(page, report, {
|
|
322
|
+
exclusion: exclusionSelector ? page.locator(exclusionSelector) : null,
|
|
323
|
+
colorType: [0, 2, 4, 6].includes(colorType) ? colorType : null,
|
|
324
|
+
action: ['return', 'report', 'file'].includes(action) ? action : 'report'
|
|
325
|
+
});
|
|
326
|
+
// Add the PNG base-64 encoding, image index, or file path to the act result.
|
|
327
|
+
act.result = shotInfo !== ''
|
|
328
|
+
? {success: true, shotInfo}
|
|
329
|
+
: {success: false, prevented: true};
|
|
330
|
+
}
|
|
331
|
+
// Otherwise, if the act is a move:
|
|
332
|
+
else if (moves[type]) {
|
|
333
|
+
// The act's own selector (any Playwright selector) replaces the type's element selector.
|
|
334
|
+
const selector = act.selector || (typeof moves[type] === 'string' ? moves[type] : act.what);
|
|
335
|
+
// Try up to 5 times to:
|
|
336
|
+
act.result = {found: false};
|
|
337
|
+
let selection = {};
|
|
338
|
+
let tries = 0;
|
|
339
|
+
const slimText = act.which ? debloat(act.which) : '';
|
|
340
|
+
while (tries++ < 5 && ! act.result.found) {
|
|
341
|
+
if (page) {
|
|
342
|
+
// Identify the elements of the specified type.
|
|
343
|
+
const selections = await page.$$(selector);
|
|
344
|
+
// If there are any:
|
|
345
|
+
if (selections.length) {
|
|
346
|
+
// If there are enough to make a match possible:
|
|
347
|
+
if ((act.index || 0) < selections.length) {
|
|
348
|
+
// For each element of the specified type:
|
|
349
|
+
let matchCount = 0;
|
|
350
|
+
const selectionTexts = [];
|
|
351
|
+
for (selection of selections) {
|
|
352
|
+
// Add its lower-case text or an empty string to the list of element texts.
|
|
353
|
+
const selectionText = slimText ? await textOf(page, selection) : '';
|
|
354
|
+
selectionTexts.push(selectionText);
|
|
355
|
+
// If its text includes any specified text, case-insensitively:
|
|
356
|
+
if (selectionText.includes(slimText)) {
|
|
357
|
+
// If the element has the specified index among such elements:
|
|
358
|
+
if (matchCount++ === (act.index || 0)) {
|
|
359
|
+
// Report it as the matching element and stop checking.
|
|
360
|
+
act.result.found = true;
|
|
361
|
+
act.result.textSpec = slimText;
|
|
362
|
+
act.result.textContent = selectionText;
|
|
363
|
+
break;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
// If no element satisfied the specifications:
|
|
368
|
+
if (! act.result.found) {
|
|
369
|
+
// Add the failure data to the temporary report.
|
|
370
|
+
act.result.success = false;
|
|
371
|
+
act.result.error = 'exhausted';
|
|
372
|
+
act.result.typeElementCount = selections.length;
|
|
373
|
+
if (slimText) {
|
|
374
|
+
act.result.textElementCount = --matchCount;
|
|
375
|
+
}
|
|
376
|
+
act.result.message = 'Not enough specified elements exist';
|
|
377
|
+
act.result.candidateTexts = selectionTexts;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
// Otherwise, i.e. if there are too few such elements to make a match possible:
|
|
381
|
+
else {
|
|
382
|
+
// Add the failure data to the temporary report.
|
|
383
|
+
act.result.success = false;
|
|
384
|
+
act.result.error = 'fewer';
|
|
385
|
+
act.result.typeElementCount = selections.length;
|
|
386
|
+
act.result.message = 'Elements of specified type too few';
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
// Otherwise, i.e. if there are no elements of the specified type:
|
|
390
|
+
else {
|
|
391
|
+
// Add the failure data to the temporary report.
|
|
392
|
+
act.result.success = false;
|
|
393
|
+
act.result.error = 'none';
|
|
394
|
+
act.result.typeElementCount = 0;
|
|
395
|
+
act.result.message = 'No elements of specified type found';
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
// Otherwise, i.e. if the page no longer exists:
|
|
399
|
+
else {
|
|
400
|
+
// Add the failure data to the temporary report.
|
|
401
|
+
act.result.success = false;
|
|
402
|
+
act.result.error = 'gone';
|
|
403
|
+
act.result.message = 'Page gone';
|
|
404
|
+
}
|
|
405
|
+
if (! act.result.found) {
|
|
406
|
+
await wait(2000);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
// If a match was found:
|
|
410
|
+
if (act.result.found) {
|
|
411
|
+
// FUNCTION DEFINITION START
|
|
412
|
+
// Performs a click or Enter keypress and waits for the network to be idle.
|
|
413
|
+
const doAndWait = async isClick => {
|
|
414
|
+
// Perform and report the move.
|
|
415
|
+
const move = isClick ? 'click' : 'Enter keypress';
|
|
416
|
+
try {
|
|
417
|
+
await isClick
|
|
418
|
+
? selection.click({timeout: 4000})
|
|
419
|
+
: selection.press('Enter', {timeout: 4000});
|
|
420
|
+
act.result.success = true;
|
|
421
|
+
act.result.move = move;
|
|
422
|
+
}
|
|
423
|
+
// If the move fails:
|
|
424
|
+
catch(error) {
|
|
425
|
+
// Report this.
|
|
426
|
+
addError(true, false, report, actIndex, `ERROR: ${move} failed`);
|
|
427
|
+
}
|
|
428
|
+
if (act.result.success) {
|
|
429
|
+
try {
|
|
430
|
+
await page.context().waitForEvent('networkidle', {timeout: 10000});
|
|
431
|
+
act.result.idleTimely = true;
|
|
432
|
+
}
|
|
433
|
+
catch(error) {
|
|
434
|
+
console.log(`ERROR: Network busy after ${move} (${errorStart(error)})`);
|
|
435
|
+
act.result.idleTimely = false;
|
|
436
|
+
}
|
|
437
|
+
// Add the page URL to the result.
|
|
438
|
+
act.result.newURL = page.url();
|
|
439
|
+
}
|
|
440
|
+
};
|
|
441
|
+
// FUNCTION DEFINITION END
|
|
442
|
+
// If the move is a button click, perform it.
|
|
443
|
+
if (type === 'button') {
|
|
444
|
+
await selection.click({timeout: 3000});
|
|
445
|
+
act.result.success = true;
|
|
446
|
+
act.result.move = 'clicked';
|
|
447
|
+
}
|
|
448
|
+
// Otherwise, if it is checking a radio button or checkbox, perform it.
|
|
449
|
+
else if (['checkbox', 'radio'].includes(type)) {
|
|
450
|
+
await selection.waitForElementState('stable', {timeout: 2000})
|
|
451
|
+
.catch(error => {
|
|
452
|
+
console.log(`ERROR waiting for stable ${type} (${error.message})`);
|
|
453
|
+
act.result.success = false;
|
|
454
|
+
act.result.error = `ERROR waiting for stable ${type}`;
|
|
455
|
+
});
|
|
456
|
+
if (! act.result.error) {
|
|
457
|
+
const isEnabled = await selection.isEnabled();
|
|
458
|
+
if (isEnabled) {
|
|
459
|
+
await selection.check({
|
|
460
|
+
force: true,
|
|
461
|
+
timeout: 2000
|
|
462
|
+
})
|
|
463
|
+
.catch(error => {
|
|
464
|
+
console.log(`ERROR checking ${type} (${error.message})`);
|
|
465
|
+
act.result.success = false;
|
|
466
|
+
act.result.error = `ERROR checking ${type}`;
|
|
467
|
+
});
|
|
468
|
+
if (! act.result.error) {
|
|
469
|
+
act.result.success = true;
|
|
470
|
+
act.result.move = 'checked';
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
else {
|
|
474
|
+
const message = `ERROR: could not check ${type} because disabled`;
|
|
475
|
+
act.result.success = false;
|
|
476
|
+
act.result.error = message;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
// Otherwise, if it is focusing the element, perform it.
|
|
481
|
+
else if (type === 'focus') {
|
|
482
|
+
await selection.focus({timeout: 2000});
|
|
483
|
+
act.result.success = true;
|
|
484
|
+
act.result.move = 'focused';
|
|
485
|
+
}
|
|
486
|
+
// Otherwise, if it is clicking a link:
|
|
487
|
+
else if (type === 'link') {
|
|
488
|
+
const href = await selection.getAttribute('href');
|
|
489
|
+
const target = await selection.getAttribute('target');
|
|
490
|
+
act.result.href = href || 'NONE';
|
|
491
|
+
act.result.target = target || 'DEFAULT';
|
|
492
|
+
// If the destination is a new page:
|
|
493
|
+
if (target && target !== '_self') {
|
|
494
|
+
// Click the link and wait for the network to be idle.
|
|
495
|
+
doAndWait(true);
|
|
496
|
+
}
|
|
497
|
+
// Otherwise, i.e. if the destination is in the current page:
|
|
498
|
+
else {
|
|
499
|
+
// Click the link and wait for the resulting navigation.
|
|
500
|
+
try {
|
|
501
|
+
await selection.click({timeout: 5000});
|
|
502
|
+
// Wait for the new content to load.
|
|
503
|
+
await page.waitForLoadState('domcontentloaded', {timeout: 6000});
|
|
504
|
+
act.result.success = true;
|
|
505
|
+
act.result.move = 'clicked';
|
|
506
|
+
act.result.newURL = page.url();
|
|
507
|
+
}
|
|
508
|
+
// If the click or load failed:
|
|
509
|
+
catch(error) {
|
|
510
|
+
// Report this.
|
|
511
|
+
console.log(`ERROR clicking link (${errorStart(error)})`);
|
|
512
|
+
act.result.success = false;
|
|
513
|
+
act.result.error = 'unclickable';
|
|
514
|
+
act.result.message = 'ERROR: click or load timed out';
|
|
515
|
+
}
|
|
516
|
+
// If the link click succeeded:
|
|
517
|
+
if (! act.result.error) {
|
|
518
|
+
// Add success data to the temporary report.
|
|
519
|
+
act.result.success = true;
|
|
520
|
+
act.result.move = 'clicked';
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
// Otherwise, if it is selecting an option in a select list, perform it.
|
|
525
|
+
else if (type === 'select') {
|
|
526
|
+
const options = await selection.$$('option');
|
|
527
|
+
let optionText = '';
|
|
528
|
+
if (options && Array.isArray(options) && options.length) {
|
|
529
|
+
const optionTexts = [];
|
|
530
|
+
for (const option of options) {
|
|
531
|
+
const optionText = await option.textContent();
|
|
532
|
+
optionTexts.push(optionText);
|
|
533
|
+
}
|
|
534
|
+
const matchTexts = optionTexts.map(
|
|
535
|
+
(text, index) => text.includes(act.what) ? index : -1
|
|
536
|
+
);
|
|
537
|
+
const index = matchTexts.filter(text => text > -1)[act.index || 0];
|
|
538
|
+
if (index !== undefined) {
|
|
539
|
+
await selection.selectOption({index});
|
|
540
|
+
optionText = optionTexts[index];
|
|
541
|
+
}
|
|
542
|
+
// If no option text matches, try the specification as an option value or label.
|
|
543
|
+
else {
|
|
544
|
+
const selected = await selection.selectOption(act.what, {timeout: 2000})
|
|
545
|
+
.catch(() => []);
|
|
546
|
+
if (selected.length) {
|
|
547
|
+
optionText = act.what;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
act.result.success = true;
|
|
552
|
+
act.result.move = 'selected';
|
|
553
|
+
act.result.option = optionText;
|
|
554
|
+
}
|
|
555
|
+
// Otherwise, if it is entering text in an input element:
|
|
556
|
+
else if (['text', 'search'].includes(type)) {
|
|
557
|
+
act.result.attributes = {};
|
|
558
|
+
const {attributes} = act.result;
|
|
559
|
+
const type = await selection.getAttribute('type');
|
|
560
|
+
const label = await selection.getAttribute('aria-label');
|
|
561
|
+
const labelRefs = await selection.getAttribute('aria-labelledby');
|
|
562
|
+
attributes.type = type || '';
|
|
563
|
+
attributes.label = label || '';
|
|
564
|
+
attributes.labelRefs = labelRefs || '';
|
|
565
|
+
// If the text contains a placeholder for an environment variable:
|
|
566
|
+
let {what} = act;
|
|
567
|
+
if (/__[A-Z]+__/.test(what)) {
|
|
568
|
+
// Replace it.
|
|
569
|
+
const envKey = /__([A-Z]+)__/.exec(what)[1];
|
|
570
|
+
const envValue = process.env[envKey];
|
|
571
|
+
what = what.replace(/__[A-Z]+__/, envValue);
|
|
572
|
+
}
|
|
573
|
+
// Enter the text, replacing any existing value if the act says so.
|
|
574
|
+
if (act.clear) {
|
|
575
|
+
await selection.fill(what);
|
|
576
|
+
}
|
|
577
|
+
else {
|
|
578
|
+
await selection.type(what);
|
|
579
|
+
}
|
|
580
|
+
report.jobData.presses += what.length;
|
|
581
|
+
act.result.success = true;
|
|
582
|
+
act.result.move = 'entered';
|
|
583
|
+
// If the input is a search input:
|
|
584
|
+
if (type === 'search') {
|
|
585
|
+
// Press the Enter key and wait for a network to be idle.
|
|
586
|
+
doAndWait(false);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
// Otherwise, i.e. if the move is unknown, add the failure to the act.
|
|
590
|
+
else {
|
|
591
|
+
// Report the error.
|
|
592
|
+
const message = 'ERROR: move unknown';
|
|
593
|
+
act.result.success = false;
|
|
594
|
+
act.result.error = message;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
// Otherwise, i.e. if no match was found:
|
|
598
|
+
else {
|
|
599
|
+
// RLeport this.
|
|
600
|
+
act.result.success = false;
|
|
601
|
+
act.result.error = 'absent';
|
|
602
|
+
act.result.message = 'ERROR: specified element not found';
|
|
603
|
+
console.log('ERROR: Specified element not found');
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
// Otherwise, if the act is a keypress:
|
|
607
|
+
else if (type === 'press') {
|
|
608
|
+
// Identify the number of times to press the key.
|
|
609
|
+
let times = 1 + (act.again || 0);
|
|
610
|
+
report.jobData.presses += times;
|
|
611
|
+
const key = act.which;
|
|
612
|
+
// Press the key.
|
|
613
|
+
while (times--) {
|
|
614
|
+
await page.keyboard.press(key);
|
|
615
|
+
}
|
|
616
|
+
const qualifier = act.again ? `${1 + act.again} times` : 'once';
|
|
617
|
+
act.result = {
|
|
618
|
+
success: true,
|
|
619
|
+
message: `pressed ${qualifier}`
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
// Otherwise, if it is a repetitive keyboard navigation:
|
|
623
|
+
else if (type === 'presses') {
|
|
624
|
+
const {navKey, what, which, withItems} = act;
|
|
625
|
+
const matchTexts = which ? which.map(text => debloat(text)) : [];
|
|
626
|
+
// Initialize the loop variables.
|
|
627
|
+
let status = 'more';
|
|
628
|
+
let presses = 0;
|
|
629
|
+
let amountRead = 0;
|
|
630
|
+
let items = [];
|
|
631
|
+
let matchedText;
|
|
632
|
+
// As long as a matching element has not been reached:
|
|
633
|
+
while (status === 'more') {
|
|
634
|
+
// Press the Escape key to dismiss any modal dialog.
|
|
635
|
+
await page.keyboard.press('Escape');
|
|
636
|
+
// Press the specified navigation key.
|
|
637
|
+
await page.keyboard.press(navKey);
|
|
638
|
+
presses++;
|
|
639
|
+
// Identify the newly current element or a failure.
|
|
640
|
+
const currentJSHandle = await page.evaluateHandle(actCount => {
|
|
641
|
+
// Initialize it as the focused element.
|
|
642
|
+
let currentElement = document.activeElement;
|
|
643
|
+
// If it exists in the page:
|
|
644
|
+
if (currentElement && currentElement.tagName !== 'BODY') {
|
|
645
|
+
// Change it, if necessary, to its active descendant.
|
|
646
|
+
if (currentElement.hasAttribute('aria-activedescendant')) {
|
|
647
|
+
currentElement = document.getElementById(
|
|
648
|
+
currentElement.getAttribute('aria-activedescendant')
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
// Or change it, if necessary, to its selected option.
|
|
652
|
+
else if (currentElement.tagName === 'SELECT') {
|
|
653
|
+
const currentIndex = Math.max(0, currentElement.selectedIndex);
|
|
654
|
+
const options = currentElement.querySelectorAll('option');
|
|
655
|
+
currentElement = options[currentIndex];
|
|
656
|
+
}
|
|
657
|
+
// Or change it, if necessary, to its active shadow-DOM element.
|
|
658
|
+
else if (currentElement.shadowRoot) {
|
|
659
|
+
currentElement = currentElement.shadowRoot.activeElement;
|
|
660
|
+
}
|
|
661
|
+
// If there is a current element:
|
|
662
|
+
if (currentElement) {
|
|
663
|
+
// If it was already reached within this act:
|
|
664
|
+
if (currentElement.dataset.pressesReached === actCount.toString(10)) {
|
|
665
|
+
// Report the error.
|
|
666
|
+
console.log(`ERROR: ${currentElement.tagName} element reached again`);
|
|
667
|
+
status = 'ERROR';
|
|
668
|
+
return 'ERROR: locallyExhausted';
|
|
669
|
+
}
|
|
670
|
+
// Otherwise, i.e. if it is newly reached within this act:
|
|
671
|
+
else {
|
|
672
|
+
// Mark and return it.
|
|
673
|
+
currentElement.dataset.pressesReached = actCount;
|
|
674
|
+
return currentElement;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
// Otherwise, i.e. if there is no current element:
|
|
678
|
+
else {
|
|
679
|
+
// Report the error.
|
|
680
|
+
status = 'ERROR';
|
|
681
|
+
return 'noActiveElement';
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
// Otherwise, i.e. if there is no focus in the page:
|
|
685
|
+
else {
|
|
686
|
+
// Report the error.
|
|
687
|
+
status = 'ERROR';
|
|
688
|
+
return 'ERROR: globallyExhausted';
|
|
689
|
+
}
|
|
690
|
+
}, actCount);
|
|
691
|
+
// If the current element exists:
|
|
692
|
+
const currentElement = currentJSHandle.asElement();
|
|
693
|
+
if (currentElement) {
|
|
694
|
+
// Update the data.
|
|
695
|
+
const tagNameJSHandle = await currentElement.getProperty('tagName');
|
|
696
|
+
const tagName = await tagNameJSHandle.jsonValue();
|
|
697
|
+
const text = await textOf(page, currentElement);
|
|
698
|
+
// If the text of the current element was found:
|
|
699
|
+
if (text !== null) {
|
|
700
|
+
const textLength = text.length;
|
|
701
|
+
// If it is non-empty and there are texts to match:
|
|
702
|
+
if (matchTexts.length && textLength) {
|
|
703
|
+
// Identify the matching text.
|
|
704
|
+
matchedText = matchTexts.find(matchText => text.includes(matchText));
|
|
705
|
+
}
|
|
706
|
+
// Update the item data if required.
|
|
707
|
+
if (withItems) {
|
|
708
|
+
const itemData = {
|
|
709
|
+
tagName,
|
|
710
|
+
text,
|
|
711
|
+
textLength
|
|
712
|
+
};
|
|
713
|
+
if (matchedText) {
|
|
714
|
+
itemData.matchedText = matchedText;
|
|
715
|
+
}
|
|
716
|
+
items.push(itemData);
|
|
717
|
+
}
|
|
718
|
+
amountRead += textLength;
|
|
719
|
+
// If there is no text-match failure:
|
|
720
|
+
if (matchedText || ! matchTexts.length) {
|
|
721
|
+
// If the element has any specified tag name:
|
|
722
|
+
if (! what || tagName === what) {
|
|
723
|
+
// Change the status.
|
|
724
|
+
status = 'done';
|
|
725
|
+
// Perform the action.
|
|
726
|
+
const inputText = act.text;
|
|
727
|
+
if (inputText) {
|
|
728
|
+
await page.keyboard.type(inputText);
|
|
729
|
+
presses += inputText.length;
|
|
730
|
+
}
|
|
731
|
+
if (act.action) {
|
|
732
|
+
presses++;
|
|
733
|
+
await page.keyboard.press(act.action);
|
|
734
|
+
await page.waitForLoadState();
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
else {
|
|
740
|
+
status = 'ERROR';
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
// Otherwise, i.e. if there was a failure:
|
|
744
|
+
else {
|
|
745
|
+
// Update the status.
|
|
746
|
+
status = await currentJSHandle.jsonValue();
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
// Add the result to the act.
|
|
750
|
+
act.result = {
|
|
751
|
+
success: true,
|
|
752
|
+
status,
|
|
753
|
+
totals: {
|
|
754
|
+
presses,
|
|
755
|
+
amountRead
|
|
756
|
+
}
|
|
757
|
+
};
|
|
758
|
+
if (status === 'done' && matchedText) {
|
|
759
|
+
act.result.matchedText = matchedText;
|
|
760
|
+
}
|
|
761
|
+
if (withItems) {
|
|
762
|
+
act.result.items = items;
|
|
763
|
+
}
|
|
764
|
+
// Add the totals to the temporary report.
|
|
765
|
+
report.jobData.presses += presses;
|
|
766
|
+
report.jobData.amountRead += amountRead;
|
|
767
|
+
}
|
|
768
|
+
// Otherwise, i.e. if the act type is unknown:
|
|
769
|
+
else {
|
|
770
|
+
// Report this.
|
|
771
|
+
addError(true, false, report, actIndex, 'ERROR: Invalid act type');
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
// Otherwise, a page URL is required but does not exist, so:
|
|
775
|
+
else {
|
|
776
|
+
// Report this.
|
|
777
|
+
addError(true, false, report, actIndex, 'ERROR: Page has no URL');
|
|
778
|
+
}
|
|
779
|
+
return page;
|
|
780
|
+
};
|
|
781
|
+
// Re-enacts the acts that reached a checkpoint, on a freshly navigated page, and returns the
|
|
782
|
+
// page. Throws if an act fails, naming the act. The recorded acts are not modified: clones are
|
|
783
|
+
// performed, against a copy of the report whose counters are discarded.
|
|
784
|
+
exports.replayActs = async (page, report, checkpoint) => {
|
|
785
|
+
const startTime = Date.now();
|
|
786
|
+
const replayReport = {
|
|
787
|
+
... report,
|
|
788
|
+
target: {... report.target},
|
|
789
|
+
jobData: {... report.jobData, presses: 0, amountRead: 0},
|
|
790
|
+
acts: report.acts.map(act => ({... act}))
|
|
791
|
+
};
|
|
792
|
+
const interaction = checkpoint.interaction || exports.defaultInteraction;
|
|
793
|
+
for (const actIndex of checkpoint.replay) {
|
|
794
|
+
const act = replayReport.acts[actIndex];
|
|
795
|
+
delete act.result;
|
|
796
|
+
delete act.actualURL;
|
|
797
|
+
page = await exports.doInteractionAct({
|
|
798
|
+
page, report: replayReport, act, actIndex, actCount: actIndex, interaction
|
|
799
|
+
});
|
|
800
|
+
const failed = act.result && (act.result.success === false || act.result.found === false);
|
|
801
|
+
if (failed || replayReport.jobData.aborted) {
|
|
802
|
+
const reason = act.result?.error || act.result?.message || replayReport.jobData.abortMessage
|
|
803
|
+
|| 'unknown';
|
|
804
|
+
throw new Error(`checkpoint replay failed at act ${actIndex} (${act.type}: ${reason})`);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
return {page, elapsedMs: Date.now() - startTime, actCount: checkpoint.replay.length};
|
|
808
|
+
};
|