testaro 67.0.0 → 68.0.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/LICENSE +4 -16
- package/README.md +10 -2
- package/UPGRADES.md +1 -1
- package/dirWatch.js +2 -3
- package/ed11y/editoria11y.min.js +109 -690
- package/ed11y/editoria11y210.min.js +747 -0
- package/netWatch.js +6 -6
- package/package.json +1 -1
- package/procs/aslint.js +2 -2
- package/procs/catalog.js +190 -0
- package/procs/{dateOf.js → dateTime.js} +6 -4
- package/procs/doActs.js +1227 -0
- package/procs/doTestAct.js +63 -29
- package/procs/error.js +53 -0
- package/procs/job.js +64 -38
- package/procs/launch.js +596 -0
- package/procs/nu.js +3 -18
- package/procs/shoot.js +18 -2
- package/procs/testaro.js +102 -125
- package/procs/xPath.js +62 -0
- package/run.js +42 -1938
- package/scratch/README.md +9 -0
- package/testaro/adbID.js +3 -3
- package/testaro/allCaps.js +4 -5
- package/testaro/allHidden.js +19 -18
- package/testaro/allSlanted.js +4 -5
- package/testaro/altScheme.js +3 -3
- package/testaro/attVal.js +19 -35
- package/testaro/autocomplete.js +65 -62
- package/testaro/bulk.js +21 -20
- package/testaro/buttonMenu.js +112 -33
- package/testaro/captionLoc.js +3 -3
- package/testaro/datalistRef.js +4 -5
- package/testaro/distortion.js +3 -3
- package/testaro/docType.js +6 -9
- package/testaro/dupAtt.js +12 -25
- package/testaro/elements.js +4 -3
- package/testaro/embAc.js +4 -2
- package/testaro/focAll.js +6 -13
- package/testaro/focAndOp.js +3 -3
- package/testaro/focInd.js +3 -3
- package/testaro/focVis.js +4 -3
- package/testaro/headEl.js +5 -12
- package/testaro/headingAmb.js +45 -88
- package/testaro/hovInd.js +5 -5
- package/testaro/hover.js +44 -8
- package/testaro/hr.js +4 -4
- package/testaro/imageLink.js +3 -3
- package/testaro/labClash.js +3 -3
- package/testaro/legendLoc.js +3 -3
- package/testaro/lineHeight.js +3 -3
- package/testaro/linkAmb.js +25 -17
- package/testaro/linkExt.js +5 -5
- package/testaro/linkOldAtt.js +4 -3
- package/testaro/linkTo.js +4 -3
- package/testaro/linkUl.js +4 -5
- package/testaro/miniText.js +4 -3
- package/testaro/motion.js +3 -22
- package/testaro/nonTable.js +4 -5
- package/testaro/optRoleSel.js +3 -3
- package/testaro/phOnly.js +3 -3
- package/testaro/pseudoP.js +5 -5
- package/testaro/radioSet.js +4 -5
- package/testaro/role.js +4 -5
- package/testaro/secHeading.js +4 -5
- package/testaro/shoot0.js +3 -2
- package/testaro/shoot1.js +3 -2
- package/testaro/styleDiff.js +5 -12
- package/testaro/tabNav.js +30 -118
- package/testaro/targetSmall.js +30 -15
- package/testaro/textNodes.js +3 -1
- package/testaro/textSem.js +4 -5
- package/testaro/title.js +4 -2
- package/testaro/titledEl.js +3 -3
- package/testaro/zIndex.js +3 -3
- package/tests/alfa.js +28 -54
- package/tests/aslint.js +20 -53
- package/tests/axe.js +76 -13
- package/tests/ed11y.js +69 -141
- package/tests/htmlcs.js +69 -38
- package/tests/ibm.js +54 -9
- package/tests/nuVal.js +65 -12
- package/tests/nuVnu.js +76 -26
- package/tests/qualWeb.js +89 -44
- package/tests/testaro.js +288 -273
- package/tests/wave.js +142 -117
- package/tests/wax.js +61 -42
- package/procs/getLocatorData.js +0 -192
- package/procs/identify.js +0 -250
- package/procs/isInlineLink.js +0 -42
- package/procs/screenShot.js +0 -32
- package/procs/standardize.js +0 -524
- package/procs/target.js +0 -90
- package/procs/tellServer.js +0 -43
- package/scripts/dumpAlts.js +0 -28
package/run.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/*
|
|
2
2
|
© 2021–2025 CVS Health and/or one of its affiliates. All rights reserved.
|
|
3
|
-
© 2025 Jonathan Robert Pool.
|
|
3
|
+
© 2025–2026 Jonathan Robert Pool.
|
|
4
4
|
|
|
5
5
|
Licensed under the MIT License. See LICENSE file at the project root or
|
|
6
6
|
https://opensource.org/license/mit/ for details.
|
|
@@ -15,1926 +15,75 @@
|
|
|
15
15
|
|
|
16
16
|
// IMPORTS
|
|
17
17
|
|
|
18
|
-
// Module to perform
|
|
19
|
-
const
|
|
18
|
+
// Module to perform acts.
|
|
19
|
+
const {doActs} = require('./procs/doActs');
|
|
20
20
|
// Module to keep secrets.
|
|
21
21
|
require('dotenv').config({quiet: true});
|
|
22
|
-
//
|
|
23
|
-
const {
|
|
24
|
-
// Module to
|
|
25
|
-
const {
|
|
26
|
-
// Module to
|
|
22
|
+
// Function to validate jobs.
|
|
23
|
+
const {isValidJob} = require('./procs/job');
|
|
24
|
+
// Module to create catalogs.
|
|
25
|
+
const {getCatalog} = require('./procs/catalog');
|
|
26
|
+
// Module to process dates and times.
|
|
27
|
+
const {nowString} = require('./procs/dateTime');
|
|
28
|
+
// Module to create browsers.
|
|
27
29
|
const {chromium, webkit, firefox} = require('playwright-extra');
|
|
30
|
+
// Module to evade automation detection.
|
|
28
31
|
const StealthPlugin = require('puppeteer-extra-plugin-stealth');
|
|
29
32
|
chromium.use(StealthPlugin());
|
|
30
33
|
webkit.use(StealthPlugin());
|
|
31
34
|
firefox.use(StealthPlugin());
|
|
32
|
-
const playwrightBrowsers = {chromium, webkit, firefox};
|
|
33
|
-
// Module to standardize report formats.
|
|
34
|
-
const {standardize} = require('./procs/standardize');
|
|
35
|
-
// Module to identify element bounding boxes.
|
|
36
|
-
const {identify} = require('./procs/identify');
|
|
37
|
-
// Module to send a notice to an observer.
|
|
38
|
-
const {tellServer} = require('./procs/tellServer');
|
|
39
|
-
// Module to create child processes.
|
|
40
|
-
const {fork} = require('child_process');
|
|
41
|
-
// Module to set operating-system constants.
|
|
42
|
-
const os = require('os');
|
|
43
|
-
|
|
44
|
-
// CONSTANTS
|
|
45
|
-
|
|
46
|
-
const headedBrowser = process.env.HEADED_BROWSER === 'true';
|
|
47
|
-
const debug = process.env.DEBUG === 'true';
|
|
48
|
-
const waits = Number.parseInt(process.env.WAITS) || 0;
|
|
49
|
-
// CSS selectors for targets of moves.
|
|
50
|
-
const moves = {
|
|
51
|
-
button: 'button, [role=button], input[type=submit]',
|
|
52
|
-
checkbox: 'input[type=checkbox]',
|
|
53
|
-
focus: true,
|
|
54
|
-
link: 'a, [role=link]',
|
|
55
|
-
radio: 'input[type=radio]',
|
|
56
|
-
search: 'input[type=search], input[aria-label*=search i], input[placeholder*=search i]',
|
|
57
|
-
select: 'select',
|
|
58
|
-
text: 'input'
|
|
59
|
-
};
|
|
60
|
-
// Strings in log messages indicating errors.
|
|
61
|
-
const errorWords = [
|
|
62
|
-
'but not used',
|
|
63
|
-
'content security policy',
|
|
64
|
-
'deprecated',
|
|
65
|
-
'error',
|
|
66
|
-
'exception',
|
|
67
|
-
'expected',
|
|
68
|
-
'failed',
|
|
69
|
-
'invalid',
|
|
70
|
-
'missing',
|
|
71
|
-
'non-standard',
|
|
72
|
-
'not supported',
|
|
73
|
-
'refused',
|
|
74
|
-
'requires',
|
|
75
|
-
'sorry',
|
|
76
|
-
'suspicious',
|
|
77
|
-
'unrecognized',
|
|
78
|
-
'violates',
|
|
79
|
-
'warning'
|
|
80
|
-
];
|
|
81
|
-
// Time limits on tools, accounting for page reloads by 6 Testaro tests.
|
|
82
|
-
const timeLimits = {
|
|
83
|
-
alfa: 20,
|
|
84
|
-
ed11y: 30,
|
|
85
|
-
ibm: 30,
|
|
86
|
-
testaro: 150 + Math.round(6 * waits / 1000)
|
|
87
|
-
};
|
|
88
|
-
// Timeout multiplier.
|
|
89
|
-
const timeoutMultiplier = Number.parseFloat(process.env.TIMEOUT_MULTIPLIER) || 1;
|
|
90
|
-
|
|
91
|
-
// ########## VARIABLES
|
|
92
|
-
|
|
93
|
-
// Facts about the current session.
|
|
94
|
-
let actCount = 0;
|
|
95
|
-
// Facts about the current act.
|
|
96
|
-
let browser;
|
|
97
|
-
let cleanupInProgress = false;
|
|
98
|
-
let browserCloseIntentional = false;
|
|
99
|
-
let browserContext;
|
|
100
|
-
let page;
|
|
101
|
-
let report;
|
|
102
|
-
let requestedURL = '';
|
|
103
35
|
|
|
104
36
|
// FUNCTIONS
|
|
105
37
|
|
|
106
|
-
// Returns a string with any final slash removed.
|
|
107
|
-
const deSlash = string => string.endsWith('/') ? string.slice(0, -1) : string;
|
|
108
|
-
// Gets the script nonce from a response.
|
|
109
|
-
const getNonce = async response => {
|
|
110
|
-
let nonce = '';
|
|
111
|
-
// If the response includes a content security policy:
|
|
112
|
-
const headers = await response.allHeaders();
|
|
113
|
-
const cspWithQuotes = headers && headers['content-security-policy'];
|
|
114
|
-
if (cspWithQuotes) {
|
|
115
|
-
// If it requires scripts to have a nonce:
|
|
116
|
-
const csp = cspWithQuotes.replace(/'/g, '');
|
|
117
|
-
const directives = csp.split(/ *; */).map(directive => directive.split(/ +/));
|
|
118
|
-
const scriptDirective = directives.find(dir => dir[0] === 'script-src');
|
|
119
|
-
if (scriptDirective) {
|
|
120
|
-
const nonceSpec = scriptDirective.find(valPart => valPart.startsWith('nonce-'));
|
|
121
|
-
if (nonceSpec) {
|
|
122
|
-
// Return the nonce.
|
|
123
|
-
nonce = nonceSpec.replace(/^nonce-/, '');
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
// Return the nonce, if any.
|
|
128
|
-
return nonce;
|
|
129
|
-
};
|
|
130
|
-
// Normalizes a file URL in case it has the Windows path format.
|
|
131
|
-
const normalizeFile = u => {
|
|
132
|
-
if (!u) return u;
|
|
133
|
-
if (!u.toLowerCase().startsWith('file:')) return u;
|
|
134
|
-
// Ensure forward slashes and three slashes after file:
|
|
135
|
-
let path = u.replace(/^file:\/+/i, '');
|
|
136
|
-
path = path.replace(/\\/g, '/');
|
|
137
|
-
return 'file:///' + path.replace(/^\//, '');
|
|
138
|
-
};
|
|
139
|
-
// Visits a URL and returns the response of the server.
|
|
140
|
-
const goTo = async (report, page, url, timeout, waitUntil) => {
|
|
141
|
-
// If the URL is a file path:
|
|
142
|
-
if (url.startsWith('file://')) {
|
|
143
|
-
// Make it absolute.
|
|
144
|
-
url = url.replace('file://', `file://${__dirname}/`);
|
|
145
|
-
}
|
|
146
|
-
// Visit the URL.
|
|
147
|
-
const startTime = Date.now();
|
|
148
|
-
try {
|
|
149
|
-
const response = await page.goto(url, {
|
|
150
|
-
timeout,
|
|
151
|
-
waitUntil
|
|
152
|
-
});
|
|
153
|
-
report.jobData.visitLatency += Math.round((Date.now() - startTime) / 1000);
|
|
154
|
-
const httpStatus = response.status();
|
|
155
|
-
// If the response status was normal:
|
|
156
|
-
if ([200, 304].includes(httpStatus) || url.startsWith('file:')) {
|
|
157
|
-
const actualURL = page.url();
|
|
158
|
-
const actualNorm = actualURL.startsWith('file:') ? normalizeFile(actualURL) : actualURL;
|
|
159
|
-
const urlNorm = url.startsWith('file:') ? normalizeFile(url) : url;
|
|
160
|
-
// If the browser was redirected in violation of a strictness requirement:
|
|
161
|
-
if (report.strict && deSlash(actualNorm) !== deSlash(urlNorm)) {
|
|
162
|
-
// Return an error.
|
|
163
|
-
console.log(`ERROR: Visit to ${url} redirected to ${actualURL}`);
|
|
164
|
-
return {
|
|
165
|
-
success: false,
|
|
166
|
-
error: 'badRedirection'
|
|
167
|
-
};
|
|
168
|
-
}
|
|
169
|
-
// Otherwise, i.e. if no prohibited redirection occurred:
|
|
170
|
-
else {
|
|
171
|
-
// Press the Escape key to dismiss any modal dialog.
|
|
172
|
-
await page.keyboard.press('Escape');
|
|
173
|
-
// Return the result of the navigation.
|
|
174
|
-
return {
|
|
175
|
-
success: true,
|
|
176
|
-
response
|
|
177
|
-
};
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
// Otherwise, if the response status was prohibition:
|
|
181
|
-
else if (httpStatus === 403) {
|
|
182
|
-
// Return this.
|
|
183
|
-
console.log(`ERROR: Visit to ${url} prohibited (status 403)`);
|
|
184
|
-
return {
|
|
185
|
-
success: false,
|
|
186
|
-
error: 'status403'
|
|
187
|
-
};
|
|
188
|
-
}
|
|
189
|
-
// Otherwise, if the response status was rejection of excessive requests:
|
|
190
|
-
else if (httpStatus === 429) {
|
|
191
|
-
const retryHeader = response.headers()['retry-after'];
|
|
192
|
-
let waitSeconds = 5;
|
|
193
|
-
if (retryHeader) {
|
|
194
|
-
waitSeconds = Number.isNaN(Number(retryHeader))
|
|
195
|
-
? Math.ceil((new Date(retryHeader) - new Date()) / 1000)
|
|
196
|
-
: Number(retryHeader);
|
|
197
|
-
}
|
|
198
|
-
// Return this.
|
|
199
|
-
console.log(
|
|
200
|
-
`ERROR: Visit to ${url} rate-limited (status 429); retry after ${waitSeconds} sec.`
|
|
201
|
-
);
|
|
202
|
-
return {
|
|
203
|
-
success: false,
|
|
204
|
-
error: `status429/retryAfterSeconds=${waitSeconds}`
|
|
205
|
-
};
|
|
206
|
-
}
|
|
207
|
-
// Otherwise, i.e. if the response status was otherwise abnormal:
|
|
208
|
-
else {
|
|
209
|
-
// Return an error.
|
|
210
|
-
console.log(`ERROR: Visit to ${url} got status ${httpStatus}`);
|
|
211
|
-
report.jobData.visitRejectionCount++;
|
|
212
|
-
return {
|
|
213
|
-
success: false,
|
|
214
|
-
error: 'badStatus'
|
|
215
|
-
};
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
catch(error) {
|
|
219
|
-
if (debug) {
|
|
220
|
-
console.log(`ERROR visiting ${url} (${error.message.slice(0, 200)})`);
|
|
221
|
-
}
|
|
222
|
-
return {
|
|
223
|
-
success: false,
|
|
224
|
-
error: 'noVisit'
|
|
225
|
-
};
|
|
226
|
-
}
|
|
227
|
-
};
|
|
228
|
-
// Adds an error result to an act.
|
|
229
|
-
const addError = (alsoLog, alsoAbort, report, actIndex, message) => {
|
|
230
|
-
// If the error is to be logged:
|
|
231
|
-
if (alsoLog) {
|
|
232
|
-
// Log it.
|
|
233
|
-
console.log(message);
|
|
234
|
-
}
|
|
235
|
-
// Add error data to the result.
|
|
236
|
-
const act = report.acts[actIndex];
|
|
237
|
-
act.result ??= {};
|
|
238
|
-
act.result.success ??= false;
|
|
239
|
-
act.result.error ??= message;
|
|
240
|
-
if (act.type === 'test') {
|
|
241
|
-
act.data ??= {};
|
|
242
|
-
act.data.prevented = true;
|
|
243
|
-
act.data.error = message;
|
|
244
|
-
// Add prevention data to the job data.
|
|
245
|
-
report.jobData.preventions[act.which] = message;
|
|
246
|
-
}
|
|
247
|
-
// If the job is to be aborted:
|
|
248
|
-
if (alsoAbort) {
|
|
249
|
-
// Add this to the report.
|
|
250
|
-
abortActs(report, actIndex);
|
|
251
|
-
}
|
|
252
|
-
};
|
|
253
|
-
// Closes any current browser.
|
|
254
|
-
const browserClose = exports.browserClose = async () => {
|
|
255
|
-
// If a browser exists:
|
|
256
|
-
if (browser) {
|
|
257
|
-
browserCloseIntentional = true;
|
|
258
|
-
// Try to close all its contexts and ignore any messages that they are already closed.
|
|
259
|
-
for (const context of browser.contexts()) {
|
|
260
|
-
try {
|
|
261
|
-
await context.close();
|
|
262
|
-
}
|
|
263
|
-
catch(error) {
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
// Close the browser.
|
|
267
|
-
await browser.close();
|
|
268
|
-
browserCloseIntentional = false;
|
|
269
|
-
browser = null;
|
|
270
|
-
}
|
|
271
|
-
};
|
|
272
|
-
// Launches a browser and navigates to a URL.
|
|
273
|
-
const launch = exports.launch = async (
|
|
274
|
-
report, actIndex, headEmulation, tempBrowserID, tempURL, retries = 2
|
|
275
|
-
) => {
|
|
276
|
-
const act = report.acts[actIndex] || {};
|
|
277
|
-
const {device} = report;
|
|
278
|
-
const deviceID = device && device.id;
|
|
279
|
-
const browserID = tempBrowserID || report.browserID || '';
|
|
280
|
-
const url = tempURL || report.target && report.target.url || '';
|
|
281
|
-
// If the specified browser and device types and URL exist:
|
|
282
|
-
if (isBrowserID(browserID) && isDeviceID(deviceID) && isURL(url)) {
|
|
283
|
-
// Replace the report target URL with this URL.
|
|
284
|
-
report.target.url = url;
|
|
285
|
-
// Create a browser of the specified or default type.
|
|
286
|
-
const browserType = playwrightBrowsers[browserID];
|
|
287
|
-
// Close any current browser.
|
|
288
|
-
await browserClose();
|
|
289
|
-
// Define the browser-option args, depending on the browser type and head-emulation level.
|
|
290
|
-
const browserOptionArgs = [];
|
|
291
|
-
if (browserID === 'chromium') {
|
|
292
|
-
browserOptionArgs.push(
|
|
293
|
-
'--disable-dev-shm-usage', '--disable-blink-features=AutomationControlled'
|
|
294
|
-
);
|
|
295
|
-
if (headEmulation === 'high') {
|
|
296
|
-
browserOptionArgs.push(
|
|
297
|
-
'--disable-gpu',
|
|
298
|
-
'--disable-software-rasterizer',
|
|
299
|
-
'--force-device-scale-factor=1',
|
|
300
|
-
'--disable-default-apps',
|
|
301
|
-
'--disable-extensions',
|
|
302
|
-
'--disable-sync',
|
|
303
|
-
'--disable-background-timer-throttling',
|
|
304
|
-
'--disable-backgrounding-occluded-windows',
|
|
305
|
-
'--disable-renderer-backgrounding',
|
|
306
|
-
'--disable-background-networking',
|
|
307
|
-
'--force-color-profile=srgb',
|
|
308
|
-
'--disable-features=TranslateUI,VizDisplayCompositor',
|
|
309
|
-
'--disable-ipc-flooding-protection',
|
|
310
|
-
'--disable-logging',
|
|
311
|
-
'--disable-permissions-api',
|
|
312
|
-
'--disable-notifications',
|
|
313
|
-
'--disable-popup-blocking'
|
|
314
|
-
);
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
// Define the browser options.
|
|
318
|
-
const browserOptions = {
|
|
319
|
-
logger: {
|
|
320
|
-
isEnabled: () => false,
|
|
321
|
-
log: (name, severity, message) => {
|
|
322
|
-
if (['warning', 'error'].includes(severity)) {
|
|
323
|
-
console.log(`${severity.toUpperCase()}: ${message.slice(0, 200)}`);
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
},
|
|
327
|
-
headless: ! headedBrowser,
|
|
328
|
-
slowMo: waits || 0,
|
|
329
|
-
args: browserOptionArgs
|
|
330
|
-
};
|
|
331
|
-
try {
|
|
332
|
-
// Replace the browser with a new one.
|
|
333
|
-
browser = await browserType.launch(browserOptions);
|
|
334
|
-
// Redefine the context (i.e. browser window).
|
|
335
|
-
const contextOptions = {
|
|
336
|
-
...device.windowOptions,
|
|
337
|
-
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
|
338
|
-
viewport: device.windowOptions.viewport || {width: 1920, height: 1080},
|
|
339
|
-
locale: 'en-US',
|
|
340
|
-
timezoneId: 'America/Los_Angeles',
|
|
341
|
-
extraHTTPHeaders: {
|
|
342
|
-
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
|
|
343
|
-
'Accept-Language': 'en-US,en;q=0.9',
|
|
344
|
-
'Accept-Encoding': 'gzip, deflate, br',
|
|
345
|
-
'DNT': '1',
|
|
346
|
-
'Upgrade-Insecure-Requests': '1'
|
|
347
|
-
}
|
|
348
|
-
};
|
|
349
|
-
browserContext = await browser.newContext(contextOptions);
|
|
350
|
-
// Prevent default timeouts.
|
|
351
|
-
browserContext.setDefaultTimeout(0);
|
|
352
|
-
// When a page (i.e. tab) is added to the browser context (i.e. browser window):
|
|
353
|
-
browserContext.on('page', async page => {
|
|
354
|
-
// Ensure the report has a jobData property.
|
|
355
|
-
report.jobData ??= {};
|
|
356
|
-
const {jobData} = report;
|
|
357
|
-
jobData.logCount ??= 0;
|
|
358
|
-
jobData.logSize ??= 0;
|
|
359
|
-
jobData.errorLogCount ??= 0;
|
|
360
|
-
// Add any error events to the count of logging errors.
|
|
361
|
-
page.on('crash', () => {
|
|
362
|
-
jobData.errorLogCount++;
|
|
363
|
-
console.log('Page crashed');
|
|
364
|
-
});
|
|
365
|
-
page.on('pageerror', () => {
|
|
366
|
-
jobData.errorLogCount++;
|
|
367
|
-
});
|
|
368
|
-
page.on('requestfailed', () => {
|
|
369
|
-
jobData.errorLogCount++;
|
|
370
|
-
});
|
|
371
|
-
// If the page emits a message:
|
|
372
|
-
page.on('console', msg => {
|
|
373
|
-
const msgText = msg.text();
|
|
374
|
-
// If debugging is on:
|
|
375
|
-
if (debug) {
|
|
376
|
-
// Log the start of the message on the console.
|
|
377
|
-
console.log(`\n${msgText.slice(0, 300)}`);
|
|
378
|
-
}
|
|
379
|
-
// Add statistics on the message to the report.
|
|
380
|
-
const msgTextLC = msgText.toLowerCase();
|
|
381
|
-
const msgLength = msgText.length;
|
|
382
|
-
jobData.logCount++;
|
|
383
|
-
jobData.logSize += msgLength;
|
|
384
|
-
if (errorWords.some(word => msgTextLC.includes(word))) {
|
|
385
|
-
jobData.errorLogCount++;
|
|
386
|
-
jobData.errorLogSize += msgLength;
|
|
387
|
-
}
|
|
388
|
-
const msgLC = msgText.toLowerCase();
|
|
389
|
-
if (
|
|
390
|
-
msgText.includes('403') && (msgLC.includes('status')
|
|
391
|
-
|| msgLC.includes('prohibited'))
|
|
392
|
-
) {
|
|
393
|
-
jobData.prohibitedCount++;
|
|
394
|
-
}
|
|
395
|
-
});
|
|
396
|
-
});
|
|
397
|
-
// Reassign the page variable to a new page (tab) of the context (window).
|
|
398
|
-
page = await browserContext.newPage();
|
|
399
|
-
// Wait until it is stable.
|
|
400
|
-
await page.waitForLoadState('domcontentloaded', {timeout: 5000});
|
|
401
|
-
// Add a script to the page to mask automation detection.
|
|
402
|
-
await page.addInitScript(() => {
|
|
403
|
-
Object.defineProperty(navigator, 'webdriver', {get: () => undefined});
|
|
404
|
-
window.chrome = {runtime: {}};
|
|
405
|
-
Object.defineProperty(navigator, 'plugins', {
|
|
406
|
-
get: () => [1, 2, 3, 4, 5]
|
|
407
|
-
});
|
|
408
|
-
Object.defineProperty(navigator, 'languages', {
|
|
409
|
-
get: () => ['en-US', 'en']
|
|
410
|
-
});
|
|
411
|
-
});
|
|
412
|
-
const xPathNeeders = ['aslint', 'htmlcs', 'nuVal', 'nuVnu', 'qualWeb', 'testaro', 'wax'];
|
|
413
|
-
const needsXPath = act.type === 'test' && xPathNeeders.includes(act.which);
|
|
414
|
-
// If the launch is for a test act that requires XPaths:
|
|
415
|
-
if (needsXPath) {
|
|
416
|
-
// Add a script to the page to add a window method to get the XPath of an element.
|
|
417
|
-
await page.addInitScript(() => {
|
|
418
|
-
window.getXPath = element => {
|
|
419
|
-
if (! element || element.nodeType !== Node.ELEMENT_NODE) {
|
|
420
|
-
return '';
|
|
421
|
-
}
|
|
422
|
-
const segments = [];
|
|
423
|
-
// As long as the current node is an element:
|
|
424
|
-
while (element && element.nodeType === Node.ELEMENT_NODE) {
|
|
425
|
-
const tag = element.tagName.toLowerCase();
|
|
426
|
-
// If it is the html element:
|
|
427
|
-
if (element === document.documentElement) {
|
|
428
|
-
// Prepend it to the segment array
|
|
429
|
-
segments.unshift('html');
|
|
430
|
-
// Stop traversing.
|
|
431
|
-
break;
|
|
432
|
-
}
|
|
433
|
-
// Otherwise, get its parent node.
|
|
434
|
-
const parent = element.parentNode;
|
|
435
|
-
// If (abnormally) the parent node is not an element:
|
|
436
|
-
if (! parent || parent.nodeType !== Node.ELEMENT_NODE) {
|
|
437
|
-
// Prepend the element (not the parent) to the segment array.
|
|
438
|
-
segments.unshift(tag);
|
|
439
|
-
// Stop traversing, leaving the segment array partial.
|
|
440
|
-
break;
|
|
441
|
-
}
|
|
442
|
-
// Get the subscript of the element if it is not the body element.
|
|
443
|
-
const cohort = Array
|
|
444
|
-
.from(parent.childNodes)
|
|
445
|
-
.filter(
|
|
446
|
-
childNode => childNode.nodeType === Node.ELEMENT_NODE
|
|
447
|
-
&& childNode.tagName === element.tagName
|
|
448
|
-
);
|
|
449
|
-
const subscript = tag === 'body' ? '' : `[${cohort.indexOf(element) + 1}]`;
|
|
450
|
-
// Prepend the element identifier to the segment array.
|
|
451
|
-
segments.unshift(`${tag}${subscript}`);
|
|
452
|
-
// Continue the traversal with the parent of the current element.
|
|
453
|
-
element = parent;
|
|
454
|
-
}
|
|
455
|
-
// Return the XPath.
|
|
456
|
-
return `/${segments.join('/')}`;
|
|
457
|
-
};
|
|
458
|
-
});
|
|
459
|
-
}
|
|
460
|
-
// If the launch is for a testaro test act:
|
|
461
|
-
if (act.type === 'test' && act.which === 'testaro') {
|
|
462
|
-
// Add a script to the page to compute the accessible name of an element.
|
|
463
|
-
await page.addInitScript({path: require.resolve('./dist/nameComputation.js')});
|
|
464
|
-
// Add a script to the page to:
|
|
465
|
-
await page.addInitScript(() => {
|
|
466
|
-
// Add a window method to compute the accessible name of an element.
|
|
467
|
-
window.getAccessibleName = element => {
|
|
468
|
-
const nameIsComputable = element
|
|
469
|
-
&& element.nodeType === Node.ELEMENT_NODE
|
|
470
|
-
&& typeof window.computeAccessibleName === 'function';
|
|
471
|
-
return nameIsComputable ? window.computeAccessibleName(element) : '';
|
|
472
|
-
};
|
|
473
|
-
// Add a window method to return a standard instance.
|
|
474
|
-
window.getInstance = (
|
|
475
|
-
element, ruleID, what, count = 1, ordinalSeverity, summaryTagName = ''
|
|
476
|
-
) => {
|
|
477
|
-
// If an element has been specified:
|
|
478
|
-
if (element) {
|
|
479
|
-
// Get its properties.
|
|
480
|
-
const boxData = element.getBoundingClientRect();
|
|
481
|
-
['x', 'y', 'width', 'height'].forEach(dimension => {
|
|
482
|
-
boxData[dimension] = Math.round(boxData[dimension]);
|
|
483
|
-
});
|
|
484
|
-
const {x, y, width, height} = boxData;
|
|
485
|
-
const {tagName, id = ''} = element;
|
|
486
|
-
const rawExcerpt = (element.textContent.trim() || element.outerHTML.trim())
|
|
487
|
-
.replace(/\s+/g, ' ');
|
|
488
|
-
const excerpt = rawExcerpt.slice(0, 200);
|
|
489
|
-
// Return an itemized standard instance.
|
|
490
|
-
return {
|
|
491
|
-
ruleID,
|
|
492
|
-
what,
|
|
493
|
-
count,
|
|
494
|
-
ordinalSeverity,
|
|
495
|
-
tagName,
|
|
496
|
-
id,
|
|
497
|
-
location: {
|
|
498
|
-
doc: 'dom',
|
|
499
|
-
type: 'box',
|
|
500
|
-
spec: {
|
|
501
|
-
x,
|
|
502
|
-
y,
|
|
503
|
-
width,
|
|
504
|
-
height
|
|
505
|
-
}
|
|
506
|
-
},
|
|
507
|
-
excerpt,
|
|
508
|
-
boxID: [x, y, width, height].join(':'),
|
|
509
|
-
pathID: window.getXPath(element)
|
|
510
|
-
};
|
|
511
|
-
}
|
|
512
|
-
// Otherwise, i.e. if no element has been specified, return a summary instance.
|
|
513
|
-
return {
|
|
514
|
-
ruleID,
|
|
515
|
-
what,
|
|
516
|
-
count,
|
|
517
|
-
ordinalSeverity,
|
|
518
|
-
tagName: summaryTagName,
|
|
519
|
-
id: '',
|
|
520
|
-
location: {
|
|
521
|
-
doc: '',
|
|
522
|
-
type: '',
|
|
523
|
-
spec: ''
|
|
524
|
-
},
|
|
525
|
-
excerpt: '',
|
|
526
|
-
boxID: '',
|
|
527
|
-
pathID: ''
|
|
528
|
-
};
|
|
529
|
-
};
|
|
530
|
-
});
|
|
531
|
-
}
|
|
532
|
-
// Navigate to the specified URL.
|
|
533
|
-
const navResult = await goTo(report, page, url, 15000, 'domcontentloaded');
|
|
534
|
-
// If the navigation succeeded:
|
|
535
|
-
if (navResult.success) {
|
|
536
|
-
// Update the name of the current browser type and store it in the page.
|
|
537
|
-
page.browserID = browserID;
|
|
538
|
-
// Add the actual URL to the act.
|
|
539
|
-
act.actualURL = page.url();
|
|
540
|
-
// Get the response of the target server.
|
|
541
|
-
const {response} = navResult;
|
|
542
|
-
// Add the script nonce, if any, to the act.
|
|
543
|
-
const scriptNonce = await getNonce(response);
|
|
544
|
-
if (scriptNonce) {
|
|
545
|
-
report.jobData.lastScriptNonce = scriptNonce;
|
|
546
|
-
}
|
|
547
|
-
}
|
|
548
|
-
// Otherwise, i.e. if the launch or navigation failed for another reason:
|
|
549
|
-
else {
|
|
550
|
-
// Cause another attempt to launch and navigate, if retries remain.
|
|
551
|
-
throw new Error(`Navigation failed (${navResult.error})`);
|
|
552
|
-
}
|
|
553
|
-
}
|
|
554
|
-
// If an error occurred:
|
|
555
|
-
catch(error) {
|
|
556
|
-
// If retries remain:
|
|
557
|
-
if (retries > 0) {
|
|
558
|
-
// Prepare to wait 1 second before a retry.
|
|
559
|
-
let waitSeconds = 1;
|
|
560
|
-
// If the error was a visit failure due to rate limiting:
|
|
561
|
-
if (error.message.includes('status429/retryAfterSeconds=')) {
|
|
562
|
-
// Change the wait to the requested time, if less than 10 seconds.
|
|
563
|
-
const waitSecondsRequest = Number(error.message.replace(/^.+=|\)$/g, ''));
|
|
564
|
-
if (! Number.isNaN(waitSecondsRequest) && waitSecondsRequest < 10) {
|
|
565
|
-
waitSeconds = waitSecondsRequest;
|
|
566
|
-
}
|
|
567
|
-
}
|
|
568
|
-
console.log(
|
|
569
|
-
`WARNING: Waiting ${waitSeconds} sec. before retrying (retries left: ${retries})`
|
|
570
|
-
);
|
|
571
|
-
await wait(1000 * waitSeconds + 100);
|
|
572
|
-
// Then retry the launch and navigation.
|
|
573
|
-
return launch(report, actIndex, headEmulation, tempBrowserID, tempURL, retries - 1);
|
|
574
|
-
}
|
|
575
|
-
// Otherwise, i.e. if no retries remain:
|
|
576
|
-
else {
|
|
577
|
-
// Report this.
|
|
578
|
-
addError(
|
|
579
|
-
true, false, report, actIndex, `FINAL ERROR launching or navigating (${error.message})`
|
|
580
|
-
);
|
|
581
|
-
// If the browser was created, and thus not a context of it:
|
|
582
|
-
if (browser) {
|
|
583
|
-
// Report this.
|
|
584
|
-
console.log('ERROR: Browser was created but context creation failed');
|
|
585
|
-
// Close the browser.
|
|
586
|
-
await browser.close().catch(() => {
|
|
587
|
-
console.log('ERROR: Could not close browser after context creation failure');
|
|
588
|
-
});
|
|
589
|
-
browser = null;
|
|
590
|
-
}
|
|
591
|
-
page = null;
|
|
592
|
-
}
|
|
593
|
-
};
|
|
594
|
-
}
|
|
595
|
-
// Otherwise, i.e. if the browser or device ID is invalid:
|
|
596
|
-
else {
|
|
597
|
-
// Report this and abort the job.
|
|
598
|
-
addError(
|
|
599
|
-
true,
|
|
600
|
-
true,
|
|
601
|
-
report,
|
|
602
|
-
actIndex,
|
|
603
|
-
`ERROR: Browser ${browserID}, device ${deviceID}, or URL ${url} invalid`
|
|
604
|
-
);
|
|
605
|
-
}
|
|
606
|
-
exports.page = page;
|
|
607
|
-
};
|
|
608
|
-
// Returns a string representing the date and time.
|
|
609
|
-
const nowString = () => (new Date()).toISOString().slice(2, 16);
|
|
610
|
-
// Returns the first line of an error message.
|
|
611
|
-
const errorStart = error => error.message.replace(/\n.+/s, '');
|
|
612
|
-
// Normalizes spacing characters and cases in a string.
|
|
613
|
-
const debloat = string => string.replace(/\s/g, ' ').trim().replace(/ {2,}/g, ' ').toLowerCase();
|
|
614
|
-
// Returns the text of an element, lower-cased.
|
|
615
|
-
const textOf = async (page, element) => {
|
|
616
|
-
if (element) {
|
|
617
|
-
const tagNameJSHandle = await element.getProperty('tagName');
|
|
618
|
-
const tagName = await tagNameJSHandle.jsonValue();
|
|
619
|
-
let totalText = '';
|
|
620
|
-
// If the element is a link, button, input, or select list:
|
|
621
|
-
if (['A', 'BUTTON', 'INPUT', 'SELECT'].includes(tagName)) {
|
|
622
|
-
// Return its visible labels, descriptions, and legend if the first input in a fieldset.
|
|
623
|
-
totalText = await page.evaluate(element => {
|
|
624
|
-
const {tagName, ariaLabel} = element;
|
|
625
|
-
let ownText = '';
|
|
626
|
-
if (['A', 'BUTTON'].includes(tagName)) {
|
|
627
|
-
ownText = element.textContent;
|
|
628
|
-
}
|
|
629
|
-
else if (tagName === 'INPUT' && element.type === 'submit') {
|
|
630
|
-
ownText = element.value;
|
|
631
|
-
}
|
|
632
|
-
// HTML link elements have no labels property.
|
|
633
|
-
const labels = tagName !== 'A' ? Array.from(element.labels) : [];
|
|
634
|
-
const labelTexts = labels.map(label => label.textContent);
|
|
635
|
-
if (ariaLabel) {
|
|
636
|
-
labelTexts.push(ariaLabel);
|
|
637
|
-
}
|
|
638
|
-
const refIDs = new Set([
|
|
639
|
-
element.getAttribute('aria-labelledby') || '',
|
|
640
|
-
element.getAttribute('aria-describedby') || ''
|
|
641
|
-
].join(' ').split(/\s+/));
|
|
642
|
-
if (refIDs.size) {
|
|
643
|
-
refIDs.forEach(id => {
|
|
644
|
-
const labeler = document.getElementById(id);
|
|
645
|
-
if (labeler) {
|
|
646
|
-
const labelerText = labeler.textContent.trim();
|
|
647
|
-
if (labelerText.length) {
|
|
648
|
-
labelTexts.push(labelerText);
|
|
649
|
-
}
|
|
650
|
-
}
|
|
651
|
-
});
|
|
652
|
-
}
|
|
653
|
-
let legendText = '';
|
|
654
|
-
if (tagName === 'INPUT') {
|
|
655
|
-
const fieldsets = Array.from(document.body.querySelectorAll('fieldset'));
|
|
656
|
-
const inputFieldsets = fieldsets.filter(fieldset => {
|
|
657
|
-
const inputs = Array.from(fieldset.querySelectorAll('input'));
|
|
658
|
-
return inputs.length && inputs[0] === element;
|
|
659
|
-
});
|
|
660
|
-
const inputFieldset = inputFieldsets[0] || null;
|
|
661
|
-
if (inputFieldset) {
|
|
662
|
-
const legend = inputFieldset.querySelector('legend');
|
|
663
|
-
if (legend) {
|
|
664
|
-
legendText = legend.textContent;
|
|
665
|
-
}
|
|
666
|
-
}
|
|
667
|
-
}
|
|
668
|
-
return [legendText].concat(labelTexts, ownText).join(' ');
|
|
669
|
-
}, element);
|
|
670
|
-
}
|
|
671
|
-
// Otherwise, if it is an option:
|
|
672
|
-
else if (tagName === 'OPTION') {
|
|
673
|
-
// Return its text content, prefixed with the text of its select parent if the first option.
|
|
674
|
-
const ownText = await element.textContent();
|
|
675
|
-
const indexJSHandle = await element.getProperty('index');
|
|
676
|
-
const index = await indexJSHandle.jsonValue();
|
|
677
|
-
if (index) {
|
|
678
|
-
totalText = ownText;
|
|
679
|
-
}
|
|
680
|
-
else {
|
|
681
|
-
const selectJSHandle = await page.evaluateHandle(
|
|
682
|
-
element => element.parentElement, element
|
|
683
|
-
);
|
|
684
|
-
const select = await selectJSHandle.asElement();
|
|
685
|
-
if (select) {
|
|
686
|
-
const selectText = await textOf(page, select);
|
|
687
|
-
totalText = [ownText, selectText].join(' ');
|
|
688
|
-
}
|
|
689
|
-
else {
|
|
690
|
-
totalText = ownText;
|
|
691
|
-
}
|
|
692
|
-
}
|
|
693
|
-
}
|
|
694
|
-
// Otherwise, i.e. if it is not an input, select, or option:
|
|
695
|
-
else {
|
|
696
|
-
// Get its text content.
|
|
697
|
-
totalText = await element.textContent();
|
|
698
|
-
}
|
|
699
|
-
return debloat(totalText);
|
|
700
|
-
}
|
|
701
|
-
else {
|
|
702
|
-
return null;
|
|
703
|
-
}
|
|
704
|
-
};
|
|
705
|
-
// Returns a property value and whether it satisfies an expectation.
|
|
706
|
-
const isTrue = (object, specs) => {
|
|
707
|
-
const property = specs[0];
|
|
708
|
-
const propertyTree = property.split('.');
|
|
709
|
-
let actual = property.length ? object[propertyTree[0]] : object;
|
|
710
|
-
// Identify the actual value of the specified property.
|
|
711
|
-
while (propertyTree.length > 1 && actual !== undefined) {
|
|
712
|
-
propertyTree.shift();
|
|
713
|
-
actual = actual[propertyTree[0]];
|
|
714
|
-
}
|
|
715
|
-
// If the expectation is that the property does not exist:
|
|
716
|
-
if (specs.length === 1) {
|
|
717
|
-
// Return whether the expectation is satisfied.
|
|
718
|
-
return [actual, actual === undefined];
|
|
719
|
-
}
|
|
720
|
-
// Otherwise, i.e. if the expectation is of a property value:
|
|
721
|
-
else if (specs.length === 3) {
|
|
722
|
-
// Return whether the expectation was fulfilled.
|
|
723
|
-
const relation = specs[1];
|
|
724
|
-
const criterion = specs[2];
|
|
725
|
-
let satisfied;
|
|
726
|
-
if (actual === undefined) {
|
|
727
|
-
return [null, false];
|
|
728
|
-
}
|
|
729
|
-
else if (relation === '=') {
|
|
730
|
-
satisfied = actual === criterion;
|
|
731
|
-
}
|
|
732
|
-
else if (relation === '<') {
|
|
733
|
-
satisfied = actual < criterion;
|
|
734
|
-
}
|
|
735
|
-
else if (relation === '>') {
|
|
736
|
-
satisfied = actual > criterion;
|
|
737
|
-
}
|
|
738
|
-
else if (relation === '!') {
|
|
739
|
-
satisfied = actual !== criterion;
|
|
740
|
-
}
|
|
741
|
-
else if (relation === 'i') {
|
|
742
|
-
satisfied = typeof actual === 'string' && actual.includes(criterion);
|
|
743
|
-
}
|
|
744
|
-
else if (relation === '!i') {
|
|
745
|
-
satisfied = typeof actual === 'string' && ! actual.includes(criterion);
|
|
746
|
-
}
|
|
747
|
-
else if (relation === 'e') {
|
|
748
|
-
satisfied = typeof actual === 'object'
|
|
749
|
-
&& JSON.stringify(actual) === JSON.stringify(criterion);
|
|
750
|
-
}
|
|
751
|
-
return [actual, satisfied];
|
|
752
|
-
}
|
|
753
|
-
// Otherwise, i.e. if the specifications are invalid:
|
|
754
|
-
else {
|
|
755
|
-
// Return this.
|
|
756
|
-
return [null, false];
|
|
757
|
-
}
|
|
758
|
-
};
|
|
759
|
-
// Adds a wait error result to an act.
|
|
760
|
-
const waitError = (page, act, error, what) => {
|
|
761
|
-
console.log(`ERROR waiting for ${what} (${error.message})`);
|
|
762
|
-
act.result.found = false;
|
|
763
|
-
act.result.url = page.url();
|
|
764
|
-
act.result.error = `ERROR waiting for ${what}`;
|
|
765
|
-
return false;
|
|
766
|
-
};
|
|
767
|
-
// Waits.
|
|
768
|
-
const wait = ms => {
|
|
769
|
-
return new Promise(resolve => {
|
|
770
|
-
setTimeout(() => {
|
|
771
|
-
resolve('');
|
|
772
|
-
}, ms);
|
|
773
|
-
});
|
|
774
|
-
};
|
|
775
|
-
// Reports a job being aborted.
|
|
776
|
-
const abortActs = (report, actIndex) => {
|
|
777
|
-
// Add data on the aborted act to the report.
|
|
778
|
-
report.jobData.abortTime = nowString();
|
|
779
|
-
report.jobData.abortedAct = actIndex;
|
|
780
|
-
report.jobData.aborted = true;
|
|
781
|
-
// Report that the job is aborted.
|
|
782
|
-
console.log(`ERROR: Job aborted on act ${actIndex}`);
|
|
783
|
-
};
|
|
784
|
-
// Returns the combination of browser ID and target URL of an act.
|
|
785
|
-
const launchSpecs = (act, report) => [
|
|
786
|
-
act.browserID || report.browserID || '',
|
|
787
|
-
act.target && act.target.url || report.target && report.target.url || ''
|
|
788
|
-
];
|
|
789
|
-
// Performs the acts in a report and adds the results to the report.
|
|
790
|
-
const doActs = async (report, opts = {}) => {
|
|
791
|
-
let {acts} = report;
|
|
792
|
-
// Get the granular observation options, if any.
|
|
793
|
-
const {onProgress = null, signal = null} = opts;
|
|
794
|
-
// Get the standardization specification.
|
|
795
|
-
const standard = report.standard || 'only';
|
|
796
|
-
// Set the temporary directory.
|
|
797
|
-
let tmpDir = `${__dirname}/${process.env.TMPDIRNAME || 'scratch'}`;
|
|
798
|
-
try {
|
|
799
|
-
await fs.access(tmpDir, fs.constants.W_OK);
|
|
800
|
-
}
|
|
801
|
-
catch(error) {
|
|
802
|
-
console.log(`ERROR: ${tmpDir} is not writable`);
|
|
803
|
-
tmpDir = os.tmpdir();
|
|
804
|
-
try {
|
|
805
|
-
await fs.access(tmpDir, fs.constants.W_OK);
|
|
806
|
-
}
|
|
807
|
-
catch(error) {
|
|
808
|
-
console.log(`ERROR: ${tmpDir} is not writable`);
|
|
809
|
-
tmpDir = '/tmp';
|
|
810
|
-
try {
|
|
811
|
-
await fs.access(tmpDir, fs.constants.W_OK);
|
|
812
|
-
}
|
|
813
|
-
catch(error) {
|
|
814
|
-
console.log(`ERROR: ${tmpDir} is not writable; quitting`);
|
|
815
|
-
process.exit(1);
|
|
816
|
-
}
|
|
817
|
-
}
|
|
818
|
-
}
|
|
819
|
-
// Get a path for temporary reports.
|
|
820
|
-
const reportPath = `${tmpDir}/${report.id}.json`;
|
|
821
|
-
// For each act in the report.
|
|
822
|
-
for (const actIndex in acts) {
|
|
823
|
-
if (signal && signal.aborted) throw new Error('doActs aborted');
|
|
824
|
-
// If the job has not been aborted:
|
|
825
|
-
if (report.jobData && ! report.jobData.aborted) {
|
|
826
|
-
let act = acts[actIndex];
|
|
827
|
-
const {type, which} = act;
|
|
828
|
-
const actSuffix = type === 'test' ? ` ${which}` : '';
|
|
829
|
-
const message = `>>>> ${type}${actSuffix}`;
|
|
830
|
-
// If granular reporting has been specified:
|
|
831
|
-
if (report.observe) {
|
|
832
|
-
const whichParam = which ? `&which=${which}` : '';
|
|
833
|
-
const messageParams = `act=${type}${whichParam}`;
|
|
834
|
-
// If a progress callback has been provided by a caller on this host:
|
|
835
|
-
if (onProgress) {
|
|
836
|
-
// Notify the observer of the act.
|
|
837
|
-
try {
|
|
838
|
-
onProgress({
|
|
839
|
-
type,
|
|
840
|
-
which
|
|
841
|
-
});
|
|
842
|
-
console.log(`${message} (observer notified)`);
|
|
843
|
-
}
|
|
844
|
-
catch (error) {
|
|
845
|
-
console.log(`${message} (observer notification failed: ${errorStart(error)})`);
|
|
846
|
-
}
|
|
847
|
-
}
|
|
848
|
-
// Otherwise, i.e. if no progress callback has been provided:
|
|
849
|
-
else {
|
|
850
|
-
// Notify the remote observer of the act and log it.
|
|
851
|
-
tellServer(report, messageParams, message);
|
|
852
|
-
}
|
|
853
|
-
}
|
|
854
|
-
// Otherwise, i.e. if granular reporting has not been specified:
|
|
855
|
-
else {
|
|
856
|
-
// Log the act.
|
|
857
|
-
console.log(message);
|
|
858
|
-
}
|
|
859
|
-
// If the act is an index changer:
|
|
860
|
-
if (type === 'next') {
|
|
861
|
-
const condition = act.if;
|
|
862
|
-
const logSuffix = condition.length === 3 ? ` ${condition[1]} ${condition[2]}` : '';
|
|
863
|
-
console.log(`>> ${condition[0]}${logSuffix}`);
|
|
864
|
-
// Identify the act to be checked.
|
|
865
|
-
const ifActIndex = acts.map(act => act.type !== 'next').lastIndexOf(true);
|
|
866
|
-
// Determine whether its jump condition is true.
|
|
867
|
-
const truth = isTrue(acts[ifActIndex].result, condition);
|
|
868
|
-
// Add the result to the act.
|
|
869
|
-
act.result = {
|
|
870
|
-
property: condition[0],
|
|
871
|
-
relation: condition[1],
|
|
872
|
-
criterion: condition[2],
|
|
873
|
-
value: truth[0],
|
|
874
|
-
jumpRequired: truth[1]
|
|
875
|
-
};
|
|
876
|
-
// If the condition is true:
|
|
877
|
-
if (truth[1]) {
|
|
878
|
-
// If the performance of acts is to stop:
|
|
879
|
-
if (act.jump === 0) {
|
|
880
|
-
// Quit.
|
|
881
|
-
break;
|
|
882
|
-
}
|
|
883
|
-
// Otherwise, if there is a numerical jump:
|
|
884
|
-
else if (act.jump) {
|
|
885
|
-
// Set the act index accordingly.
|
|
886
|
-
actIndex += act.jump - 1;
|
|
887
|
-
}
|
|
888
|
-
// Otherwise, if there is a named next act:
|
|
889
|
-
else if (act.next) {
|
|
890
|
-
// Set the new index accordingly, or stop if it does not exist.
|
|
891
|
-
actIndex = acts.map(act => act.name).indexOf(act.next) - 1;
|
|
892
|
-
}
|
|
893
|
-
}
|
|
894
|
-
}
|
|
895
|
-
// Otherwise, if the act is a launch:
|
|
896
|
-
else if (type === 'launch') {
|
|
897
|
-
const actLaunchSpecs = launchSpecs(act, report);
|
|
898
|
-
// Launch a browser, navigate to a page, and add the result to the act.
|
|
899
|
-
await launch(
|
|
900
|
-
report,
|
|
901
|
-
actIndex,
|
|
902
|
-
'high',
|
|
903
|
-
actLaunchSpecs[0],
|
|
904
|
-
actLaunchSpecs[1]
|
|
905
|
-
);
|
|
906
|
-
// If this failed:
|
|
907
|
-
if (! page) {
|
|
908
|
-
// Add this to the act.
|
|
909
|
-
addError(false, false, report, actIndex, page.error || '');
|
|
910
|
-
}
|
|
911
|
-
}
|
|
912
|
-
// Otherwise, if the act is a test act:
|
|
913
|
-
else if (type === 'test') {
|
|
914
|
-
// Add a description of the tool to the act.
|
|
915
|
-
act.what = tools[act.which];
|
|
916
|
-
// Get the start time of the act.
|
|
917
|
-
const startTime = Date.now();
|
|
918
|
-
// Add it to the act.
|
|
919
|
-
act.startTime = startTime;
|
|
920
|
-
let reportJSON = JSON.stringify(report);
|
|
921
|
-
// Save a copy of the report.
|
|
922
|
-
await fs.writeFile(reportPath, reportJSON);
|
|
923
|
-
let timedOut = false;
|
|
924
|
-
const limitMs = timeoutMultiplier * 1000 * (timeLimits[act.which] || 15);
|
|
925
|
-
// Create a child process to perform the act and add the result to the saved report.
|
|
926
|
-
const actResult = await new Promise(resolve => {
|
|
927
|
-
let closed = false;
|
|
928
|
-
const child = fork(`${__dirname}/procs/doTestAct`, [reportPath, actIndex]);
|
|
929
|
-
let killTimer = null;
|
|
930
|
-
// Start a timeout timer for the child process.
|
|
931
|
-
const timeoutTimer = setTimeout(() => {
|
|
932
|
-
if (! timedOut) {
|
|
933
|
-
timedOut = true;
|
|
934
|
-
console.log(`ERROR: Timed out at ${Math.round(limitMs / 1000)} seconds`);
|
|
935
|
-
child.kill('SIGTERM');
|
|
936
|
-
killTimer = setTimeout(() => {
|
|
937
|
-
if (! closed) {
|
|
938
|
-
console.log('ERROR: Failed to exit on SIGTERM from parent')
|
|
939
|
-
}
|
|
940
|
-
child.kill('SIGKILL');
|
|
941
|
-
}, 2000);
|
|
942
|
-
}
|
|
943
|
-
}, limitMs);
|
|
944
|
-
// Clears any current timers.
|
|
945
|
-
const clearTimers = () => {
|
|
946
|
-
[timeoutTimer, killTimer].forEach(timer => {
|
|
947
|
-
if (timer) {
|
|
948
|
-
clearTimeout(timer);
|
|
949
|
-
}
|
|
950
|
-
});
|
|
951
|
-
};
|
|
952
|
-
// If the child process sends a message (normally Act completed):
|
|
953
|
-
child.on('message', message => {
|
|
954
|
-
if (! closed) {
|
|
955
|
-
closed = true;
|
|
956
|
-
clearTimers();
|
|
957
|
-
// Return the message.
|
|
958
|
-
resolve({
|
|
959
|
-
kind: 'message',
|
|
960
|
-
message
|
|
961
|
-
});
|
|
962
|
-
}
|
|
963
|
-
});
|
|
964
|
-
// If the child process sends an error:
|
|
965
|
-
child.on('error', error => {
|
|
966
|
-
if (! closed) {
|
|
967
|
-
closed = true;
|
|
968
|
-
clearTimers();
|
|
969
|
-
// Return the error message.
|
|
970
|
-
resolve({
|
|
971
|
-
kind: 'error',
|
|
972
|
-
error: error.message
|
|
973
|
-
});
|
|
974
|
-
}
|
|
975
|
-
});
|
|
976
|
-
// If the child process closes:
|
|
977
|
-
child.on('close', (code, signal) => {
|
|
978
|
-
if (! closed) {
|
|
979
|
-
closed = true;
|
|
980
|
-
clearTimers();
|
|
981
|
-
// Return the exit code, signal, and timeout status.
|
|
982
|
-
resolve({
|
|
983
|
-
kind: 'close',
|
|
984
|
-
code,
|
|
985
|
-
signal,
|
|
986
|
-
timedOut
|
|
987
|
-
});
|
|
988
|
-
}
|
|
989
|
-
});
|
|
990
|
-
});
|
|
991
|
-
// If the child process sent a message:
|
|
992
|
-
if (actResult.kind === 'message') {
|
|
993
|
-
// Get the revised report file.
|
|
994
|
-
reportJSON = await fs.readFile(reportPath, 'utf8');
|
|
995
|
-
try {
|
|
996
|
-
// Convert it from JSON to an object and replace the report with the object.
|
|
997
|
-
report = JSON.parse(reportJSON);
|
|
998
|
-
// Redefine the acts as those in the revised report.
|
|
999
|
-
acts = report.acts;
|
|
1000
|
-
}
|
|
1001
|
-
// If the conversion fails, leaving the report and its acts unchanged:
|
|
1002
|
-
catch (error) {
|
|
1003
|
-
// Report this.
|
|
1004
|
-
console.log(
|
|
1005
|
-
`ERROR: Tool sent message ${actResult.message}. Report is no longer JSON (${error.message}) but is instead a(n) ${typeof reportJSON} of length ${reportJSON.length}:\n${reportJSON}`
|
|
1006
|
-
);
|
|
1007
|
-
// Add the error data to the act.
|
|
1008
|
-
addError(
|
|
1009
|
-
false,
|
|
1010
|
-
false,
|
|
1011
|
-
report,
|
|
1012
|
-
actIndex,
|
|
1013
|
-
`Non-JSON report file after message ${actResult.message}`
|
|
1014
|
-
);
|
|
1015
|
-
}
|
|
1016
|
-
}
|
|
1017
|
-
// Otherwise, i.e. if the child process closed abnormally:
|
|
1018
|
-
else {
|
|
1019
|
-
// Add the error data to the act.
|
|
1020
|
-
const {code, error, kind, signal} = actResult;
|
|
1021
|
-
if (kind === 'close' && timedOut) {
|
|
1022
|
-
addError(
|
|
1023
|
-
false, false, report, actIndex, `Timed out at ${Math.round(limitMs / 1000)} seconds`
|
|
1024
|
-
);
|
|
1025
|
-
}
|
|
1026
|
-
else if (kind === 'close') {
|
|
1027
|
-
addError(
|
|
1028
|
-
true, false, report, actIndex, `Closed with code ${code} and signal ${signal})`
|
|
1029
|
-
);
|
|
1030
|
-
}
|
|
1031
|
-
else {
|
|
1032
|
-
addError(
|
|
1033
|
-
true, false, report, actIndex, `Terminated with error ${error}`
|
|
1034
|
-
);
|
|
1035
|
-
}
|
|
1036
|
-
}
|
|
1037
|
-
// Get the (usually revised) act.
|
|
1038
|
-
act = acts[actIndex];
|
|
1039
|
-
// Add the elapsed time of the tool to the report.
|
|
1040
|
-
const time = Math.round((Date.now() - startTime) / 1000);
|
|
1041
|
-
const {toolTimes} = report.jobData;
|
|
1042
|
-
toolTimes[act.which] ??= 0;
|
|
1043
|
-
toolTimes[act.which] += time;
|
|
1044
|
-
// If the act was not prevented:
|
|
1045
|
-
if (act.data && ! act.data.prevented) {
|
|
1046
|
-
const expectations = act.expect;
|
|
1047
|
-
// If the act has expectations:
|
|
1048
|
-
if (expectations) {
|
|
1049
|
-
// Initialize whether they were fulfilled.
|
|
1050
|
-
act.expectations = [];
|
|
1051
|
-
let failureCount = 0;
|
|
1052
|
-
// For each expectation:
|
|
1053
|
-
expectations.forEach(spec => {
|
|
1054
|
-
// Add its result to the act.
|
|
1055
|
-
const truth = isTrue(act, spec);
|
|
1056
|
-
act.expectations.push({
|
|
1057
|
-
property: spec[0],
|
|
1058
|
-
relation: spec[1],
|
|
1059
|
-
criterion: spec[2],
|
|
1060
|
-
actual: truth[0],
|
|
1061
|
-
passed: truth[1]
|
|
1062
|
-
});
|
|
1063
|
-
if (! truth[1]) {
|
|
1064
|
-
failureCount++;
|
|
1065
|
-
}
|
|
1066
|
-
});
|
|
1067
|
-
act.expectationFailures = failureCount;
|
|
1068
|
-
}
|
|
1069
|
-
}
|
|
1070
|
-
}
|
|
1071
|
-
// Otherwise, if a current page exists:
|
|
1072
|
-
else if (page) {
|
|
1073
|
-
// If the act is navigation to a url:
|
|
1074
|
-
if (type === 'url') {
|
|
1075
|
-
// Identify the URL.
|
|
1076
|
-
const resolved = act.which.replace('__dirname', __dirname);
|
|
1077
|
-
requestedURL = resolved;
|
|
1078
|
-
// Visit it and wait until the DOM is loaded.
|
|
1079
|
-
const navResult = await goTo(report, page, requestedURL, 15000, 'domcontentloaded');
|
|
1080
|
-
// If the visit succeeded:
|
|
1081
|
-
if (navResult.success) {
|
|
1082
|
-
// Revise the report URL to this URL.
|
|
1083
|
-
report.target.url = requestedURL;
|
|
1084
|
-
// Add the script nonce, if any, to the act.
|
|
1085
|
-
const {response} = navResult;
|
|
1086
|
-
const scriptNonce = getNonce(response);
|
|
1087
|
-
if (scriptNonce) {
|
|
1088
|
-
report.jobData.lastScriptNonce = scriptNonce;
|
|
1089
|
-
}
|
|
1090
|
-
// Add the resulting URL to the act.
|
|
1091
|
-
if (! act.result) {
|
|
1092
|
-
act.result = {};
|
|
1093
|
-
}
|
|
1094
|
-
act.result.url = page.url();
|
|
1095
|
-
// If a prohibited redirection occurred:
|
|
1096
|
-
if (response.exception === 'badRedirection') {
|
|
1097
|
-
// Report this.
|
|
1098
|
-
addError(true, false, report, actIndex, 'ERROR: Navigation illicitly redirected');
|
|
1099
|
-
}
|
|
1100
|
-
}
|
|
1101
|
-
// Otherwise, i.e. if the visit failed:
|
|
1102
|
-
else {
|
|
1103
|
-
// Report this.
|
|
1104
|
-
addError(true, false, report, actIndex, 'ERROR: Visit failed');
|
|
1105
|
-
}
|
|
1106
|
-
}
|
|
1107
|
-
// Otherwise, if the act is a wait for text:
|
|
1108
|
-
else if (type === 'wait') {
|
|
1109
|
-
const {what, which} = act;
|
|
1110
|
-
console.log(`>> ${what}`);
|
|
1111
|
-
const result = act.result = {};
|
|
1112
|
-
// If the text is to be the URL:
|
|
1113
|
-
if (what === 'url') {
|
|
1114
|
-
// Wait for the URL to be the exact text.
|
|
1115
|
-
try {
|
|
1116
|
-
await page.waitForURL(which, {timeout: 15000});
|
|
1117
|
-
result.found = true;
|
|
1118
|
-
result.url = page.url();
|
|
1119
|
-
}
|
|
1120
|
-
// If the wait times out:
|
|
1121
|
-
catch(error) {
|
|
1122
|
-
// Quit.
|
|
1123
|
-
abortActs(report, actIndex);
|
|
1124
|
-
waitError(page, act, error, 'text in the URL');
|
|
1125
|
-
}
|
|
1126
|
-
}
|
|
1127
|
-
// Otherwise, if the text is to be a substring of the page title:
|
|
1128
|
-
else if (what === 'title') {
|
|
1129
|
-
// Wait for the page title to include the text, case-insensitively.
|
|
1130
|
-
try {
|
|
1131
|
-
await page.waitForFunction(
|
|
1132
|
-
text => document
|
|
1133
|
-
&& document.title
|
|
1134
|
-
&& document.title.toLowerCase().includes(text.toLowerCase()),
|
|
1135
|
-
which,
|
|
1136
|
-
{
|
|
1137
|
-
polling: 1000,
|
|
1138
|
-
timeout: 5000
|
|
1139
|
-
}
|
|
1140
|
-
);
|
|
1141
|
-
result.found = true;
|
|
1142
|
-
result.title = await page.title();
|
|
1143
|
-
}
|
|
1144
|
-
// If the wait times out:
|
|
1145
|
-
catch(error) {
|
|
1146
|
-
// Quit.
|
|
1147
|
-
abortActs(report, actIndex);
|
|
1148
|
-
waitError(page, act, error, 'text in the title');
|
|
1149
|
-
}
|
|
1150
|
-
}
|
|
1151
|
-
// Otherwise, if the text is to be a substring of the text of the page body:
|
|
1152
|
-
else if (what === 'body') {
|
|
1153
|
-
// Wait for the body to include the text, case-insensitively.
|
|
1154
|
-
try {
|
|
1155
|
-
await page.waitForFunction(
|
|
1156
|
-
text => document
|
|
1157
|
-
&& document.body
|
|
1158
|
-
&& document.body.innerText.toLowerCase().includes(text.toLowerCase()),
|
|
1159
|
-
which,
|
|
1160
|
-
{
|
|
1161
|
-
polling: 2000,
|
|
1162
|
-
timeout: 15000
|
|
1163
|
-
}
|
|
1164
|
-
);
|
|
1165
|
-
result.found = true;
|
|
1166
|
-
}
|
|
1167
|
-
// If the wait times out:
|
|
1168
|
-
catch(error) {
|
|
1169
|
-
// Quit.
|
|
1170
|
-
abortActs(report, actIndex);
|
|
1171
|
-
waitError(page, act, error, 'text in the body');
|
|
1172
|
-
}
|
|
1173
|
-
}
|
|
1174
|
-
}
|
|
1175
|
-
// Otherwise, if the act is a wait for a state:
|
|
1176
|
-
else if (type === 'state') {
|
|
1177
|
-
// Wait for it.
|
|
1178
|
-
const stateIndex = ['loaded', 'idle'].indexOf(act.which);
|
|
1179
|
-
await page.waitForLoadState(
|
|
1180
|
-
['domcontentloaded', 'networkidle'][stateIndex], {timeout: [10000, 15000][stateIndex]}
|
|
1181
|
-
)
|
|
1182
|
-
// If the wait times out:
|
|
1183
|
-
.catch(async error => {
|
|
1184
|
-
// Report this and abort the job.
|
|
1185
|
-
console.log(`ERROR waiting for page to be ${act.which} (${error.message})`);
|
|
1186
|
-
addError(true, false, report, actIndex, `ERROR waiting for page to be ${act.which}`);
|
|
1187
|
-
});
|
|
1188
|
-
// If the wait succeeded:
|
|
1189
|
-
if (actIndex > -2) {
|
|
1190
|
-
// Add state data to the report.
|
|
1191
|
-
act.result = {
|
|
1192
|
-
success: true,
|
|
1193
|
-
state: act.which
|
|
1194
|
-
};
|
|
1195
|
-
}
|
|
1196
|
-
}
|
|
1197
|
-
// Otherwise, if the act is a page switch:
|
|
1198
|
-
else if (type === 'page') {
|
|
1199
|
-
// Wait for a page to be created and identify it as current.
|
|
1200
|
-
page = await browserContext.waitForEvent('page');
|
|
1201
|
-
// Wait until it is idle.
|
|
1202
|
-
await page.waitForLoadState('networkidle', {timeout: 15000});
|
|
1203
|
-
// Add the resulting URL to the act.
|
|
1204
|
-
const result = {
|
|
1205
|
-
url: page.url()
|
|
1206
|
-
};
|
|
1207
|
-
act.result = result;
|
|
1208
|
-
}
|
|
1209
|
-
// Otherwise, if the page has a URL:
|
|
1210
|
-
else if (page.url() && page.url() !== 'about:blank') {
|
|
1211
|
-
const url = page.url();
|
|
1212
|
-
// Add the URL to the act.
|
|
1213
|
-
act.actualURL = url;
|
|
1214
|
-
// If the act is a revelation:
|
|
1215
|
-
if (type === 'reveal') {
|
|
1216
|
-
act.result = {
|
|
1217
|
-
success: true
|
|
1218
|
-
};
|
|
1219
|
-
// Make all elements in the page visible.
|
|
1220
|
-
await page.$$eval('body *', elements => {
|
|
1221
|
-
elements.forEach(element => {
|
|
1222
|
-
const styleDec = window.getComputedStyle(element);
|
|
1223
|
-
if (styleDec.display === 'none') {
|
|
1224
|
-
element.style.display = 'initial';
|
|
1225
|
-
}
|
|
1226
|
-
if (['hidden', 'collapse'].includes(styleDec.visibility)) {
|
|
1227
|
-
element.style.visibility = 'inherit';
|
|
1228
|
-
}
|
|
1229
|
-
});
|
|
1230
|
-
})
|
|
1231
|
-
.catch(error => {
|
|
1232
|
-
console.log(`ERROR making all elements visible (${error.message})`);
|
|
1233
|
-
act.result.success = false;
|
|
1234
|
-
});
|
|
1235
|
-
}
|
|
1236
|
-
// Otherwise, if the act is a move:
|
|
1237
|
-
else if (moves[type]) {
|
|
1238
|
-
const selector = typeof moves[type] === 'string' ? moves[type] : act.what;
|
|
1239
|
-
// Try up to 5 times to:
|
|
1240
|
-
act.result = {found: false};
|
|
1241
|
-
let selection = {};
|
|
1242
|
-
let tries = 0;
|
|
1243
|
-
const slimText = act.which ? debloat(act.which) : '';
|
|
1244
|
-
while (tries++ < 5 && ! act.result.found) {
|
|
1245
|
-
if (page) {
|
|
1246
|
-
// Identify the elements of the specified type.
|
|
1247
|
-
const selections = await page.$$(selector);
|
|
1248
|
-
// If there are any:
|
|
1249
|
-
if (selections.length) {
|
|
1250
|
-
// If there are enough to make a match possible:
|
|
1251
|
-
if ((act.index || 0) < selections.length) {
|
|
1252
|
-
// For each element of the specified type:
|
|
1253
|
-
let matchCount = 0;
|
|
1254
|
-
const selectionTexts = [];
|
|
1255
|
-
for (selection of selections) {
|
|
1256
|
-
// Add its lower-case text or an empty string to the list of element texts.
|
|
1257
|
-
const selectionText = slimText ? await textOf(page, selection) : '';
|
|
1258
|
-
selectionTexts.push(selectionText);
|
|
1259
|
-
// If its text includes any specified text, case-insensitively:
|
|
1260
|
-
if (selectionText.includes(slimText)) {
|
|
1261
|
-
// If the element has the specified index among such elements:
|
|
1262
|
-
if (matchCount++ === (act.index || 0)) {
|
|
1263
|
-
// Report it as the matching element and stop checking.
|
|
1264
|
-
act.result.found = true;
|
|
1265
|
-
act.result.textSpec = slimText;
|
|
1266
|
-
act.result.textContent = selectionText;
|
|
1267
|
-
break;
|
|
1268
|
-
}
|
|
1269
|
-
}
|
|
1270
|
-
}
|
|
1271
|
-
// If no element satisfied the specifications:
|
|
1272
|
-
if (! act.result.found) {
|
|
1273
|
-
// Add the failure data to the report.
|
|
1274
|
-
act.result.success = false;
|
|
1275
|
-
act.result.error = 'exhausted';
|
|
1276
|
-
act.result.typeElementCount = selections.length;
|
|
1277
|
-
if (slimText) {
|
|
1278
|
-
act.result.textElementCount = --matchCount;
|
|
1279
|
-
}
|
|
1280
|
-
act.result.message = 'Not enough specified elements exist';
|
|
1281
|
-
act.result.candidateTexts = selectionTexts;
|
|
1282
|
-
}
|
|
1283
|
-
}
|
|
1284
|
-
// Otherwise, i.e. if there are too few such elements to make a match possible:
|
|
1285
|
-
else {
|
|
1286
|
-
// Add the failure data to the report.
|
|
1287
|
-
act.result.success = false;
|
|
1288
|
-
act.result.error = 'fewer';
|
|
1289
|
-
act.result.typeElementCount = selections.length;
|
|
1290
|
-
act.result.message = 'Elements of specified type too few';
|
|
1291
|
-
}
|
|
1292
|
-
}
|
|
1293
|
-
// Otherwise, i.e. if there are no elements of the specified type:
|
|
1294
|
-
else {
|
|
1295
|
-
// Add the failure data to the report.
|
|
1296
|
-
act.result.success = false;
|
|
1297
|
-
act.result.error = 'none';
|
|
1298
|
-
act.result.typeElementCount = 0;
|
|
1299
|
-
act.result.message = 'No elements of specified type found';
|
|
1300
|
-
}
|
|
1301
|
-
}
|
|
1302
|
-
// Otherwise, i.e. if the page no longer exists:
|
|
1303
|
-
else {
|
|
1304
|
-
// Add the failure data to the report.
|
|
1305
|
-
act.result.success = false;
|
|
1306
|
-
act.result.error = 'gone';
|
|
1307
|
-
act.result.message = 'Page gone';
|
|
1308
|
-
}
|
|
1309
|
-
if (! act.result.found) {
|
|
1310
|
-
await wait(2000);
|
|
1311
|
-
}
|
|
1312
|
-
}
|
|
1313
|
-
// If a match was found:
|
|
1314
|
-
if (act.result.found) {
|
|
1315
|
-
// FUNCTION DEFINITION START
|
|
1316
|
-
// Performs a click or Enter keypress and waits for the network to be idle.
|
|
1317
|
-
const doAndWait = async isClick => {
|
|
1318
|
-
// Perform and report the move.
|
|
1319
|
-
const move = isClick ? 'click' : 'Enter keypress';
|
|
1320
|
-
try {
|
|
1321
|
-
await isClick
|
|
1322
|
-
? selection.click({timeout: 4000})
|
|
1323
|
-
: selection.press('Enter', {timeout: 4000});
|
|
1324
|
-
act.result.success = true;
|
|
1325
|
-
act.result.move = move;
|
|
1326
|
-
}
|
|
1327
|
-
// If the move fails:
|
|
1328
|
-
catch(error) {
|
|
1329
|
-
// Add the error result to the act and abort the job.
|
|
1330
|
-
addError(true, false, report, actIndex, `ERROR: ${move} failed`);
|
|
1331
|
-
}
|
|
1332
|
-
if (act.result.success) {
|
|
1333
|
-
try {
|
|
1334
|
-
await page.context().waitForEvent('networkidle', {timeout: 10000});
|
|
1335
|
-
act.result.idleTimely = true;
|
|
1336
|
-
}
|
|
1337
|
-
catch(error) {
|
|
1338
|
-
console.log(`ERROR: Network busy after ${move} (${errorStart(error)})`);
|
|
1339
|
-
act.result.idleTimely = false;
|
|
1340
|
-
}
|
|
1341
|
-
// Add the page URL to the result.
|
|
1342
|
-
act.result.newURL = page.url();
|
|
1343
|
-
}
|
|
1344
|
-
};
|
|
1345
|
-
// FUNCTION DEFINITION END
|
|
1346
|
-
// If the move is a button click, perform it.
|
|
1347
|
-
if (type === 'button') {
|
|
1348
|
-
await selection.click({timeout: 3000});
|
|
1349
|
-
act.result.success = true;
|
|
1350
|
-
act.result.move = 'clicked';
|
|
1351
|
-
}
|
|
1352
|
-
// Otherwise, if it is checking a radio button or checkbox, perform it.
|
|
1353
|
-
else if (['checkbox', 'radio'].includes(type)) {
|
|
1354
|
-
await selection.waitForElementState('stable', {timeout: 2000})
|
|
1355
|
-
.catch(error => {
|
|
1356
|
-
console.log(`ERROR waiting for stable ${type} (${error.message})`);
|
|
1357
|
-
act.result.success = false;
|
|
1358
|
-
act.result.error = `ERROR waiting for stable ${type}`;
|
|
1359
|
-
});
|
|
1360
|
-
if (! act.result.error) {
|
|
1361
|
-
const isEnabled = await selection.isEnabled();
|
|
1362
|
-
if (isEnabled) {
|
|
1363
|
-
await selection.check({
|
|
1364
|
-
force: true,
|
|
1365
|
-
timeout: 2000
|
|
1366
|
-
})
|
|
1367
|
-
.catch(error => {
|
|
1368
|
-
console.log(`ERROR checking ${type} (${error.message})`);
|
|
1369
|
-
act.result.success = false;
|
|
1370
|
-
act.result.error = `ERROR checking ${type}`;
|
|
1371
|
-
});
|
|
1372
|
-
if (! act.result.error) {
|
|
1373
|
-
act.result.success = true;
|
|
1374
|
-
act.result.move = 'checked';
|
|
1375
|
-
}
|
|
1376
|
-
}
|
|
1377
|
-
else {
|
|
1378
|
-
const report = `ERROR: could not check ${type} because disabled`;
|
|
1379
|
-
act.result.success = false;
|
|
1380
|
-
act.result.error = report;
|
|
1381
|
-
}
|
|
1382
|
-
}
|
|
1383
|
-
}
|
|
1384
|
-
// Otherwise, if it is focusing the element, perform it.
|
|
1385
|
-
else if (type === 'focus') {
|
|
1386
|
-
await selection.focus({timeout: 2000});
|
|
1387
|
-
act.result.success = true;
|
|
1388
|
-
act.result.move = 'focused';
|
|
1389
|
-
}
|
|
1390
|
-
// Otherwise, if it is clicking a link:
|
|
1391
|
-
else if (type === 'link') {
|
|
1392
|
-
const href = await selection.getAttribute('href');
|
|
1393
|
-
const target = await selection.getAttribute('target');
|
|
1394
|
-
act.result.href = href || 'NONE';
|
|
1395
|
-
act.result.target = target || 'DEFAULT';
|
|
1396
|
-
// If the destination is a new page:
|
|
1397
|
-
if (target && target !== '_self') {
|
|
1398
|
-
// Click the link and wait for the network to be idle.
|
|
1399
|
-
doAndWait(true);
|
|
1400
|
-
}
|
|
1401
|
-
// Otherwise, i.e. if the destination is in the current page:
|
|
1402
|
-
else {
|
|
1403
|
-
// Click the link and wait for the resulting navigation.
|
|
1404
|
-
try {
|
|
1405
|
-
await selection.click({timeout: 5000});
|
|
1406
|
-
// Wait for the new content to load.
|
|
1407
|
-
await page.waitForLoadState('domcontentloaded', {timeout: 6000});
|
|
1408
|
-
act.result.success = true;
|
|
1409
|
-
act.result.move = 'clicked';
|
|
1410
|
-
act.result.newURL = page.url();
|
|
1411
|
-
}
|
|
1412
|
-
// If the click or load failed:
|
|
1413
|
-
catch(error) {
|
|
1414
|
-
// Quit and add failure data to the report.
|
|
1415
|
-
console.log(`ERROR clicking link (${errorStart(error)})`);
|
|
1416
|
-
act.result.success = false;
|
|
1417
|
-
act.result.error = 'unclickable';
|
|
1418
|
-
act.result.message = 'ERROR: click or load timed out';
|
|
1419
|
-
abortActs(report, actIndex);
|
|
1420
|
-
}
|
|
1421
|
-
// If the link click succeeded:
|
|
1422
|
-
if (! act.result.error) {
|
|
1423
|
-
// Add success data to the report.
|
|
1424
|
-
act.result.success = true;
|
|
1425
|
-
act.result.move = 'clicked';
|
|
1426
|
-
}
|
|
1427
|
-
}
|
|
1428
|
-
}
|
|
1429
|
-
// Otherwise, if it is selecting an option in a select list, perform it.
|
|
1430
|
-
else if (type === 'select') {
|
|
1431
|
-
const options = await selection.$$('option');
|
|
1432
|
-
let optionText = '';
|
|
1433
|
-
if (options && Array.isArray(options) && options.length) {
|
|
1434
|
-
const optionTexts = [];
|
|
1435
|
-
for (const option of options) {
|
|
1436
|
-
const optionText = await option.textContent();
|
|
1437
|
-
optionTexts.push(optionText);
|
|
1438
|
-
}
|
|
1439
|
-
const matchTexts = optionTexts.map(
|
|
1440
|
-
(text, index) => text.includes(act.what) ? index : -1
|
|
1441
|
-
);
|
|
1442
|
-
const index = matchTexts.filter(text => text > -1)[act.index || 0];
|
|
1443
|
-
if (index !== undefined) {
|
|
1444
|
-
await selection.selectOption({index});
|
|
1445
|
-
optionText = optionTexts[index];
|
|
1446
|
-
}
|
|
1447
|
-
}
|
|
1448
|
-
act.result.success = true;
|
|
1449
|
-
act.result.move = 'selected';
|
|
1450
|
-
act.result.option = optionText;
|
|
1451
|
-
}
|
|
1452
|
-
// Otherwise, if it is entering text in an input element:
|
|
1453
|
-
else if (['text', 'search'].includes(type)) {
|
|
1454
|
-
act.result.attributes = {};
|
|
1455
|
-
const {attributes} = act.result;
|
|
1456
|
-
const type = await selection.getAttribute('type');
|
|
1457
|
-
const label = await selection.getAttribute('aria-label');
|
|
1458
|
-
const labelRefs = await selection.getAttribute('aria-labelledby');
|
|
1459
|
-
attributes.type = type || '';
|
|
1460
|
-
attributes.label = label || '';
|
|
1461
|
-
attributes.labelRefs = labelRefs || '';
|
|
1462
|
-
// If the text contains a placeholder for an environment variable:
|
|
1463
|
-
let {what} = act;
|
|
1464
|
-
if (/__[A-Z]+__/.test(what)) {
|
|
1465
|
-
// Replace it.
|
|
1466
|
-
const envKey = /__([A-Z]+)__/.exec(what)[1];
|
|
1467
|
-
const envValue = process.env[envKey];
|
|
1468
|
-
what = what.replace(/__[A-Z]+__/, envValue);
|
|
1469
|
-
}
|
|
1470
|
-
// Enter the text.
|
|
1471
|
-
await selection.type(what);
|
|
1472
|
-
report.jobData.presses += what.length;
|
|
1473
|
-
act.result.success = true;
|
|
1474
|
-
act.result.move = 'entered';
|
|
1475
|
-
// If the input is a search input:
|
|
1476
|
-
if (type === 'search') {
|
|
1477
|
-
// Press the Enter key and wait for a network to be idle.
|
|
1478
|
-
doAndWait(false);
|
|
1479
|
-
}
|
|
1480
|
-
}
|
|
1481
|
-
// Otherwise, i.e. if the move is unknown, add the failure to the act.
|
|
1482
|
-
else {
|
|
1483
|
-
// Report the error.
|
|
1484
|
-
const report = 'ERROR: move unknown';
|
|
1485
|
-
act.result.success = false;
|
|
1486
|
-
act.result.error = report;
|
|
1487
|
-
}
|
|
1488
|
-
}
|
|
1489
|
-
// Otherwise, i.e. if no match was found:
|
|
1490
|
-
else {
|
|
1491
|
-
// Quit and add failure data to the report.
|
|
1492
|
-
act.result.success = false;
|
|
1493
|
-
act.result.error = 'absent';
|
|
1494
|
-
act.result.message = 'ERROR: specified element not found';
|
|
1495
|
-
console.log('ERROR: Specified element not found');
|
|
1496
|
-
abortActs(report, actIndex);
|
|
1497
|
-
}
|
|
1498
|
-
}
|
|
1499
|
-
// Otherwise, if the act is a keypress:
|
|
1500
|
-
else if (type === 'press') {
|
|
1501
|
-
// Identify the number of times to press the key.
|
|
1502
|
-
let times = 1 + (act.again || 0);
|
|
1503
|
-
report.jobData.presses += times;
|
|
1504
|
-
const key = act.which;
|
|
1505
|
-
// Press the key.
|
|
1506
|
-
while (times--) {
|
|
1507
|
-
await page.keyboard.press(key);
|
|
1508
|
-
}
|
|
1509
|
-
const qualifier = act.again ? `${1 + act.again} times` : 'once';
|
|
1510
|
-
act.result = {
|
|
1511
|
-
success: true,
|
|
1512
|
-
message: `pressed ${qualifier}`
|
|
1513
|
-
};
|
|
1514
|
-
}
|
|
1515
|
-
// Otherwise, if it is a repetitive keyboard navigation:
|
|
1516
|
-
else if (type === 'presses') {
|
|
1517
|
-
const {navKey, what, which, withItems} = act;
|
|
1518
|
-
const matchTexts = which ? which.map(text => debloat(text)) : [];
|
|
1519
|
-
// Initialize the loop variables.
|
|
1520
|
-
let status = 'more';
|
|
1521
|
-
let presses = 0;
|
|
1522
|
-
let amountRead = 0;
|
|
1523
|
-
let items = [];
|
|
1524
|
-
let matchedText;
|
|
1525
|
-
// As long as a matching element has not been reached:
|
|
1526
|
-
while (status === 'more') {
|
|
1527
|
-
// Press the Escape key to dismiss any modal dialog.
|
|
1528
|
-
await page.keyboard.press('Escape');
|
|
1529
|
-
// Press the specified navigation key.
|
|
1530
|
-
await page.keyboard.press(navKey);
|
|
1531
|
-
presses++;
|
|
1532
|
-
// Identify the newly current element or a failure.
|
|
1533
|
-
const currentJSHandle = await page.evaluateHandle(actCount => {
|
|
1534
|
-
// Initialize it as the focused element.
|
|
1535
|
-
let currentElement = document.activeElement;
|
|
1536
|
-
// If it exists in the page:
|
|
1537
|
-
if (currentElement && currentElement.tagName !== 'BODY') {
|
|
1538
|
-
// Change it, if necessary, to its active descendant.
|
|
1539
|
-
if (currentElement.hasAttribute('aria-activedescendant')) {
|
|
1540
|
-
currentElement = document.getElementById(
|
|
1541
|
-
currentElement.getAttribute('aria-activedescendant')
|
|
1542
|
-
);
|
|
1543
|
-
}
|
|
1544
|
-
// Or change it, if necessary, to its selected option.
|
|
1545
|
-
else if (currentElement.tagName === 'SELECT') {
|
|
1546
|
-
const currentIndex = Math.max(0, currentElement.selectedIndex);
|
|
1547
|
-
const options = currentElement.querySelectorAll('option');
|
|
1548
|
-
currentElement = options[currentIndex];
|
|
1549
|
-
}
|
|
1550
|
-
// Or change it, if necessary, to its active shadow-DOM element.
|
|
1551
|
-
else if (currentElement.shadowRoot) {
|
|
1552
|
-
currentElement = currentElement.shadowRoot.activeElement;
|
|
1553
|
-
}
|
|
1554
|
-
// If there is a current element:
|
|
1555
|
-
if (currentElement) {
|
|
1556
|
-
// If it was already reached within this act:
|
|
1557
|
-
if (currentElement.dataset.pressesReached === actCount.toString(10)) {
|
|
1558
|
-
// Report the error.
|
|
1559
|
-
console.log(`ERROR: ${currentElement.tagName} element reached again`);
|
|
1560
|
-
status = 'ERROR';
|
|
1561
|
-
return 'ERROR: locallyExhausted';
|
|
1562
|
-
}
|
|
1563
|
-
// Otherwise, i.e. if it is newly reached within this act:
|
|
1564
|
-
else {
|
|
1565
|
-
// Mark and return it.
|
|
1566
|
-
currentElement.dataset.pressesReached = actCount;
|
|
1567
|
-
return currentElement;
|
|
1568
|
-
}
|
|
1569
|
-
}
|
|
1570
|
-
// Otherwise, i.e. if there is no current element:
|
|
1571
|
-
else {
|
|
1572
|
-
// Report the error.
|
|
1573
|
-
status = 'ERROR';
|
|
1574
|
-
return 'noActiveElement';
|
|
1575
|
-
}
|
|
1576
|
-
}
|
|
1577
|
-
// Otherwise, i.e. if there is no focus in the page:
|
|
1578
|
-
else {
|
|
1579
|
-
// Report the error.
|
|
1580
|
-
status = 'ERROR';
|
|
1581
|
-
return 'ERROR: globallyExhausted';
|
|
1582
|
-
}
|
|
1583
|
-
}, actCount);
|
|
1584
|
-
// If the current element exists:
|
|
1585
|
-
const currentElement = currentJSHandle.asElement();
|
|
1586
|
-
if (currentElement) {
|
|
1587
|
-
// Update the data.
|
|
1588
|
-
const tagNameJSHandle = await currentElement.getProperty('tagName');
|
|
1589
|
-
const tagName = await tagNameJSHandle.jsonValue();
|
|
1590
|
-
const text = await textOf(page, currentElement);
|
|
1591
|
-
// If the text of the current element was found:
|
|
1592
|
-
if (text !== null) {
|
|
1593
|
-
const textLength = text.length;
|
|
1594
|
-
// If it is non-empty and there are texts to match:
|
|
1595
|
-
if (matchTexts.length && textLength) {
|
|
1596
|
-
// Identify the matching text.
|
|
1597
|
-
matchedText = matchTexts.find(matchText => text.includes(matchText));
|
|
1598
|
-
}
|
|
1599
|
-
// Update the item data if required.
|
|
1600
|
-
if (withItems) {
|
|
1601
|
-
const itemData = {
|
|
1602
|
-
tagName,
|
|
1603
|
-
text,
|
|
1604
|
-
textLength
|
|
1605
|
-
};
|
|
1606
|
-
if (matchedText) {
|
|
1607
|
-
itemData.matchedText = matchedText;
|
|
1608
|
-
}
|
|
1609
|
-
items.push(itemData);
|
|
1610
|
-
}
|
|
1611
|
-
amountRead += textLength;
|
|
1612
|
-
// If there is no text-match failure:
|
|
1613
|
-
if (matchedText || ! matchTexts.length) {
|
|
1614
|
-
// If the element has any specified tag name:
|
|
1615
|
-
if (! what || tagName === what) {
|
|
1616
|
-
// Change the status.
|
|
1617
|
-
status = 'done';
|
|
1618
|
-
// Perform the action.
|
|
1619
|
-
const inputText = act.text;
|
|
1620
|
-
if (inputText) {
|
|
1621
|
-
await page.keyboard.type(inputText);
|
|
1622
|
-
presses += inputText.length;
|
|
1623
|
-
}
|
|
1624
|
-
if (act.action) {
|
|
1625
|
-
presses++;
|
|
1626
|
-
await page.keyboard.press(act.action);
|
|
1627
|
-
await page.waitForLoadState();
|
|
1628
|
-
}
|
|
1629
|
-
}
|
|
1630
|
-
}
|
|
1631
|
-
}
|
|
1632
|
-
else {
|
|
1633
|
-
status = 'ERROR';
|
|
1634
|
-
}
|
|
1635
|
-
}
|
|
1636
|
-
// Otherwise, i.e. if there was a failure:
|
|
1637
|
-
else {
|
|
1638
|
-
// Update the status.
|
|
1639
|
-
status = await currentJSHandle.jsonValue();
|
|
1640
|
-
}
|
|
1641
|
-
}
|
|
1642
|
-
// Add the result to the act.
|
|
1643
|
-
act.result = {
|
|
1644
|
-
success: true,
|
|
1645
|
-
status,
|
|
1646
|
-
totals: {
|
|
1647
|
-
presses,
|
|
1648
|
-
amountRead
|
|
1649
|
-
}
|
|
1650
|
-
};
|
|
1651
|
-
if (status === 'done' && matchedText) {
|
|
1652
|
-
act.result.matchedText = matchedText;
|
|
1653
|
-
}
|
|
1654
|
-
if (withItems) {
|
|
1655
|
-
act.result.items = items;
|
|
1656
|
-
}
|
|
1657
|
-
// Add the totals to the report.
|
|
1658
|
-
report.jobData.presses += presses;
|
|
1659
|
-
report.jobData.amountRead += amountRead;
|
|
1660
|
-
}
|
|
1661
|
-
// Otherwise, i.e. if the act type is unknown:
|
|
1662
|
-
else {
|
|
1663
|
-
// Add the error result to the act and abort the job.
|
|
1664
|
-
addError(true, false, report, actIndex, 'ERROR: Invalid act type');
|
|
1665
|
-
}
|
|
1666
|
-
}
|
|
1667
|
-
// Otherwise, a page URL is required but does not exist, so:
|
|
1668
|
-
else {
|
|
1669
|
-
// Add an error result to the act and abort the job.
|
|
1670
|
-
addError(true, false, report, actIndex, 'ERROR: Page has no URL');
|
|
1671
|
-
}
|
|
1672
|
-
}
|
|
1673
|
-
// Otherwise, i.e. if no page exists:
|
|
1674
|
-
else {
|
|
1675
|
-
// Add an error result to the act and abort the job.
|
|
1676
|
-
addError(true, false, report, actIndex, 'ERROR: No page identified');
|
|
1677
|
-
}
|
|
1678
|
-
// Add the end time to the act.
|
|
1679
|
-
act.endTime = Date.now();
|
|
1680
|
-
}
|
|
1681
|
-
}
|
|
1682
|
-
console.log('Acts completed');
|
|
1683
|
-
// If standardization is required:
|
|
1684
|
-
if (['also', 'only'].includes(standard)) {
|
|
1685
|
-
// If granular reporting has been specified:
|
|
1686
|
-
if (report.observe) {
|
|
1687
|
-
// If a progress callback has been provided:
|
|
1688
|
-
if (onProgress) {
|
|
1689
|
-
// Notify the observer of the start of standardization.
|
|
1690
|
-
try {
|
|
1691
|
-
onProgress({
|
|
1692
|
-
type: 'standardization',
|
|
1693
|
-
which: 'start'
|
|
1694
|
-
});
|
|
1695
|
-
console.log(`${'Standardization started'} (observer notified)`);
|
|
1696
|
-
}
|
|
1697
|
-
catch (error) {
|
|
1698
|
-
console.log(`${message} (observer notification failed: ${errorStart(error)})`);
|
|
1699
|
-
}
|
|
1700
|
-
}
|
|
1701
|
-
// Otherwise, i.e. if no progress callback has been provided:
|
|
1702
|
-
else {
|
|
1703
|
-
// Notify the observer of the act and log it.
|
|
1704
|
-
tellServer(report, messageParams, message);
|
|
1705
|
-
}
|
|
1706
|
-
}
|
|
1707
|
-
// Notify the observer and log the start of standardization.
|
|
1708
|
-
tellServer(report, '', 'Starting result standardization');
|
|
1709
|
-
const launchSpecActs = {};
|
|
1710
|
-
// For each act:
|
|
1711
|
-
report.acts.forEach((act, index) => {
|
|
1712
|
-
// If it is a test act:
|
|
1713
|
-
if (act.type === 'test') {
|
|
1714
|
-
// Classify it by its browser ID and target URL.
|
|
1715
|
-
const specs = launchSpecs(act, report);
|
|
1716
|
-
const specString = `${specs[0]}>${specs[1]}`;
|
|
1717
|
-
if (launchSpecActs[specString]) {
|
|
1718
|
-
launchSpecActs[specString].push(index);
|
|
1719
|
-
}
|
|
1720
|
-
else {
|
|
1721
|
-
launchSpecActs[specString] = [index];
|
|
1722
|
-
}
|
|
1723
|
-
}
|
|
1724
|
-
});
|
|
1725
|
-
// For each browser ID/target URL class:
|
|
1726
|
-
for (const specString of Object.keys(launchSpecActs)) {
|
|
1727
|
-
const specs = specString.split('>');
|
|
1728
|
-
// Replace the browser and navigate to the URL.
|
|
1729
|
-
await launch(
|
|
1730
|
-
report,
|
|
1731
|
-
'standardization',
|
|
1732
|
-
'high',
|
|
1733
|
-
specs[0],
|
|
1734
|
-
specs[1]
|
|
1735
|
-
);
|
|
1736
|
-
// If the launch and navigation succeeded:
|
|
1737
|
-
if (page) {
|
|
1738
|
-
// For each test act in the class:
|
|
1739
|
-
for (const specActIndex of launchSpecActs[specString]) {
|
|
1740
|
-
const act = report.acts[specActIndex];
|
|
1741
|
-
// Initialize the standard result.
|
|
1742
|
-
act.standardResult = {
|
|
1743
|
-
totals: [0, 0, 0, 0],
|
|
1744
|
-
instances: []
|
|
1745
|
-
};
|
|
1746
|
-
// Populate it.
|
|
1747
|
-
standardize(act);
|
|
1748
|
-
// If the original-format result is not to be included in the report:
|
|
1749
|
-
if (standard === 'only') {
|
|
1750
|
-
// Remove it.
|
|
1751
|
-
delete act.result;
|
|
1752
|
-
}
|
|
1753
|
-
// Notify the observer and log the start of identification.
|
|
1754
|
-
tellServer(report, '', 'Starting element identification');
|
|
1755
|
-
// For each of the standard instances of the act:
|
|
1756
|
-
for (const instance of act.standardResult.instances) {
|
|
1757
|
-
let {boxID, pathID} = instance;
|
|
1758
|
-
// If the instance does not have both a box ID and a valid path ID:
|
|
1759
|
-
if (! boxID && (! pathID || pathID.includes(' '))) {
|
|
1760
|
-
const elementID = await identify(instance, page);
|
|
1761
|
-
// If it has no box ID but the element has a bounding box:
|
|
1762
|
-
if (elementID.boxID && ! boxID) {
|
|
1763
|
-
// Add a box ID to the instance.
|
|
1764
|
-
instance.boxID = elementID.boxID;
|
|
1765
|
-
}
|
|
1766
|
-
// If it has no valid path ID:
|
|
1767
|
-
if (! pathID || pathID.includes(' ')) {
|
|
1768
|
-
// If the element has a valid path ID:
|
|
1769
|
-
if (elementID.pathID && ! elementID.pathID.includes(' ')) {
|
|
1770
|
-
// Add or replace the path ID of the instance.
|
|
1771
|
-
instance.pathID = elementID.pathID;
|
|
1772
|
-
}
|
|
1773
|
-
// Otherwise, if the instance has an invalid but uncorrectable path ID:
|
|
1774
|
-
else if (pathID) {
|
|
1775
|
-
// Delete it.
|
|
1776
|
-
delete instance.pathID;
|
|
1777
|
-
}
|
|
1778
|
-
}
|
|
1779
|
-
}
|
|
1780
|
-
// If the instance excerpt contains a unique Testaro identifier attribute:
|
|
1781
|
-
if (instance.excerpt.includes(' data-testaro-id="')) {
|
|
1782
|
-
// Delete the attribute.
|
|
1783
|
-
instance.excerpt = instance
|
|
1784
|
-
.excerpt
|
|
1785
|
-
.replace(/ data-testaro-id="[^" ]*("|$)/g, '')
|
|
1786
|
-
.replace(/ data-testaro-id="[^" ]* /g, ' ');
|
|
1787
|
-
}
|
|
1788
|
-
pathID = instance.pathID;
|
|
1789
|
-
// If the instance has no or an empty text property:
|
|
1790
|
-
if (! instance.text) {
|
|
1791
|
-
const {excerpt} = instance;
|
|
1792
|
-
// If the instance has a markup-free non-empty excerpt:
|
|
1793
|
-
if (
|
|
1794
|
-
excerpt && ! ['<', '>', '=', '#'].some(markupChar => excerpt.includes(markupChar))
|
|
1795
|
-
) {
|
|
1796
|
-
// Add the excerpt (up to any ellipsis) to the text property.
|
|
1797
|
-
instance.text = excerpt.split(/ … | *\.\.\./)[0];
|
|
1798
|
-
}
|
|
1799
|
-
// Otherwise, i.e. if it has no markup-free excerpt but has a non-empty path ID:
|
|
1800
|
-
else if (pathID) {
|
|
1801
|
-
// Initialize a text string.
|
|
1802
|
-
let text = '';
|
|
1803
|
-
// Get the element if it has text content.
|
|
1804
|
-
const elementLoc = page.locator(`xpath=${pathID}`, {hasText: /.+/});
|
|
1805
|
-
// If it exists and is unique:
|
|
1806
|
-
if (await elementLoc.count() === 1) {
|
|
1807
|
-
// If it contains any noscript elements:
|
|
1808
|
-
if (await elementLoc.locator('noscript').count()) {
|
|
1809
|
-
// Change the text string to the text content without noscript elements.
|
|
1810
|
-
text = await elementLoc.evaluate(node => {
|
|
1811
|
-
const elementClone = node.cloneNode(true);
|
|
1812
|
-
elementClone
|
|
1813
|
-
.querySelectorAll('noscript')
|
|
1814
|
-
.forEach(noscript => noscript.remove());
|
|
1815
|
-
return elementClone.textContent;
|
|
1816
|
-
});
|
|
1817
|
-
}
|
|
1818
|
-
// Otherwise, i.e. if it contains no noscript element:
|
|
1819
|
-
else {
|
|
1820
|
-
// Change the text string to the text content of the element.
|
|
1821
|
-
text = await elementLoc.textContent();
|
|
1822
|
-
}
|
|
1823
|
-
}
|
|
1824
|
-
// Add the text string, truncated if necessary, to the instance.
|
|
1825
|
-
instance.text = text.trim().replace(/\s+/g, ' ').slice(0, 300);
|
|
1826
|
-
}
|
|
1827
|
-
}
|
|
1828
|
-
};
|
|
1829
|
-
};
|
|
1830
|
-
}
|
|
1831
|
-
// Otherwise, i.e. if the launch or navigation failed:
|
|
1832
|
-
else {
|
|
1833
|
-
console.log(`ERROR: Launch or navigation to standardize ${specString} acts failed`);
|
|
1834
|
-
}
|
|
1835
|
-
};
|
|
1836
|
-
// Close the last browser launched for standardization and element identification.
|
|
1837
|
-
await browserClose();
|
|
1838
|
-
console.log('Standardization and element identification completed');
|
|
1839
|
-
const {acts} = report;
|
|
1840
|
-
const idData = {};
|
|
1841
|
-
// For each act:
|
|
1842
|
-
for (const act of acts) {
|
|
1843
|
-
// If it is a test act:
|
|
1844
|
-
if (act.type === 'test') {
|
|
1845
|
-
const {which} = act;
|
|
1846
|
-
// Initialize an idData property for the tool if necessary.
|
|
1847
|
-
idData[which] ??= {
|
|
1848
|
-
instanceCount: 0,
|
|
1849
|
-
boxIDCount: 0,
|
|
1850
|
-
pathIDCount: 0,
|
|
1851
|
-
textCount: 0,
|
|
1852
|
-
boxIDPercent: null,
|
|
1853
|
-
pathIDPercent: null,
|
|
1854
|
-
textPercent: null
|
|
1855
|
-
};
|
|
1856
|
-
const actIDData = idData[which];
|
|
1857
|
-
const {standardResult} = act;
|
|
1858
|
-
const {instances} = standardResult;
|
|
1859
|
-
// For each standard instance in the act:
|
|
1860
|
-
for (const instance of instances) {
|
|
1861
|
-
const {boxID, pathID, text} = instance;
|
|
1862
|
-
// Increment the instance count.
|
|
1863
|
-
actIDData.instanceCount++;
|
|
1864
|
-
// If the instance has a box ID:
|
|
1865
|
-
if (boxID) {
|
|
1866
|
-
// Increment the box ID count.
|
|
1867
|
-
actIDData.boxIDCount++;
|
|
1868
|
-
}
|
|
1869
|
-
// If the instance has a path ID:
|
|
1870
|
-
if (pathID) {
|
|
1871
|
-
// Increment the path ID count.
|
|
1872
|
-
actIDData.pathIDCount++;
|
|
1873
|
-
}
|
|
1874
|
-
// If the instance has any text segments:
|
|
1875
|
-
if (text?.[0]?.length) {
|
|
1876
|
-
// Increment the text count.
|
|
1877
|
-
actIDData.textCount++;
|
|
1878
|
-
}
|
|
1879
|
-
}
|
|
1880
|
-
const {instanceCount, boxIDCount, pathIDCount, textCount} = actIDData;
|
|
1881
|
-
// If there are any instances:
|
|
1882
|
-
if (instanceCount) {
|
|
1883
|
-
// Add the box ID path ID, and text percentages to the iData property.
|
|
1884
|
-
actIDData.boxIDPercent = Math.round(100 * boxIDCount / instanceCount);
|
|
1885
|
-
actIDData.pathIDPercent = Math.round(100 * pathIDCount / instanceCount);
|
|
1886
|
-
actIDData.textPercent = Math.round(100 * textCount / instanceCount);
|
|
1887
|
-
}
|
|
1888
|
-
}
|
|
1889
|
-
}
|
|
1890
|
-
report.jobData.idData = idData;
|
|
1891
|
-
}
|
|
1892
|
-
// Delete the temporary report file.
|
|
1893
|
-
await fs.rm(reportPath, {force: true});
|
|
1894
|
-
return report;
|
|
1895
|
-
};
|
|
1896
38
|
// Runs a job and returns a report.
|
|
1897
39
|
exports.doJob = async (job, opts = {}) => {
|
|
1898
|
-
//
|
|
1899
|
-
report = JSON.parse(JSON.stringify(job));
|
|
40
|
+
// Initialize a report as a copy of the job.
|
|
41
|
+
let report = JSON.parse(JSON.stringify(job));
|
|
1900
42
|
const jobData = report.jobData = {};
|
|
1901
|
-
// Get whether the job is valid and, if not, why.
|
|
43
|
+
// Get whether the job is valid and, if not, why not.
|
|
1902
44
|
const jobInvalidity = isValidJob(job);
|
|
1903
45
|
// If it is invalid:
|
|
1904
|
-
if (jobInvalidity) {
|
|
46
|
+
if (! jobInvalidity.isValid) {
|
|
1905
47
|
// Report this.
|
|
1906
|
-
console.log(`ERROR: ${jobInvalidity}`);
|
|
48
|
+
console.log(`ERROR: ${jobInvalidity.error}`);
|
|
1907
49
|
jobData.aborted = true;
|
|
1908
50
|
jobData.abortedAct = null;
|
|
1909
|
-
jobData.abortError = jobInvalidity;
|
|
51
|
+
jobData.abortError = jobInvalidity.error;
|
|
1910
52
|
}
|
|
1911
53
|
// Otherwise, i.e. if it is valid:
|
|
1912
54
|
else {
|
|
1913
55
|
// Add initialized job data to the report.
|
|
1914
56
|
const startTime = new Date();
|
|
1915
|
-
report.jobData
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
57
|
+
report.jobData = {
|
|
58
|
+
startTime: nowString(),
|
|
59
|
+
endTime: '',
|
|
60
|
+
elapsedSeconds: 0,
|
|
61
|
+
visitLatency: 0,
|
|
62
|
+
logCount: 0,
|
|
63
|
+
logSize: 0,
|
|
64
|
+
errorLogCount: 0,
|
|
65
|
+
errorLogSize: 0,
|
|
66
|
+
prohibitedCount: 0,
|
|
67
|
+
visitRejectionCount: 0,
|
|
68
|
+
aborted: false,
|
|
69
|
+
abortedAct: null,
|
|
70
|
+
presses: 0,
|
|
71
|
+
amountRead: 0,
|
|
72
|
+
toolTimes: {},
|
|
73
|
+
preventions: {}
|
|
74
|
+
};
|
|
1931
75
|
process.on('message', message => {
|
|
1932
76
|
if (message === 'interrupt') {
|
|
1933
77
|
console.log('ERROR: Terminal interrupted the job');
|
|
1934
78
|
process.exit();
|
|
1935
79
|
}
|
|
1936
80
|
});
|
|
1937
|
-
//
|
|
81
|
+
// If the job specifies a browser ID and a target and requires standardization:
|
|
82
|
+
if (job.browserID && job.target && job.standard !== 'no') {
|
|
83
|
+
// Create a catalog of the target and add it to the report.
|
|
84
|
+
report.catalog = await getCatalog(report);
|
|
85
|
+
}
|
|
86
|
+
// Perform the acts and revise the report.
|
|
1938
87
|
report = await doActs(report, opts);
|
|
1939
88
|
// Add the end time and duration to the report.
|
|
1940
89
|
const endTime = new Date();
|
|
@@ -1942,7 +91,7 @@ exports.doJob = async (job, opts = {}) => {
|
|
|
1942
91
|
const elapsedSeconds = Math.floor((endTime - startTime) / 1000);
|
|
1943
92
|
report.jobData.elapsedSeconds = elapsedSeconds;
|
|
1944
93
|
console.log(`Elapsed seconds: ${elapsedSeconds}`);
|
|
1945
|
-
// Consolidate and sort the tool times.
|
|
94
|
+
// Consolidate and sort the tool times, if any.
|
|
1946
95
|
const {toolTimes} = report.jobData;
|
|
1947
96
|
const toolTimeData = Object
|
|
1948
97
|
.keys(toolTimes)
|
|
@@ -1956,48 +105,3 @@ exports.doJob = async (job, opts = {}) => {
|
|
|
1956
105
|
// Return the report.
|
|
1957
106
|
return report;
|
|
1958
107
|
};
|
|
1959
|
-
|
|
1960
|
-
// CLEANUP HANDLERS
|
|
1961
|
-
|
|
1962
|
-
// Force-kills any Playwright browser processes synchronously.
|
|
1963
|
-
const forceKillBrowsers = () => {
|
|
1964
|
-
if (cleanupInProgress) {
|
|
1965
|
-
return;
|
|
1966
|
-
}
|
|
1967
|
-
cleanupInProgress = true;
|
|
1968
|
-
try {
|
|
1969
|
-
// Kill any Chromium headless shell processes.
|
|
1970
|
-
execSync('pkill -9 -f "chromium_headless_shell.*headless_shell"', {stdio: 'ignore'});
|
|
1971
|
-
}
|
|
1972
|
-
catch(error) {}
|
|
1973
|
-
};
|
|
1974
|
-
// Force-kills any headless shell processes synchronously on process exit.
|
|
1975
|
-
process.on('exit', () => {
|
|
1976
|
-
forceKillBrowsers();
|
|
1977
|
-
});
|
|
1978
|
-
// Force-kills any headless shell processes synchronously on beforeExit.
|
|
1979
|
-
process.on('beforeExit', async () => {
|
|
1980
|
-
if (!browserCloseIntentional) {
|
|
1981
|
-
await browserClose();
|
|
1982
|
-
}
|
|
1983
|
-
forceKillBrowsers();
|
|
1984
|
-
});
|
|
1985
|
-
// Force-kills any headless shell processes synchronously on uncaught exceptions.
|
|
1986
|
-
process.on('uncaughtException', async error => {
|
|
1987
|
-
console.error('Uncaught exception:', error);
|
|
1988
|
-
await browserClose();
|
|
1989
|
-
forceKillBrowsers();
|
|
1990
|
-
process.exit(1);
|
|
1991
|
-
});
|
|
1992
|
-
// Force-kills any headless shell processes synchronously on SIGINT.
|
|
1993
|
-
process.on('SIGINT', async () => {
|
|
1994
|
-
await browserClose();
|
|
1995
|
-
forceKillBrowsers();
|
|
1996
|
-
process.exit(0);
|
|
1997
|
-
});
|
|
1998
|
-
// Force-kills any headless shell processes synchronously on SIGTERM.
|
|
1999
|
-
process.on('SIGTERM', async () => {
|
|
2000
|
-
await browserClose();
|
|
2001
|
-
forceKillBrowsers();
|
|
2002
|
-
process.exit(0);
|
|
2003
|
-
});
|