testaro 43.0.2 → 44.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/package.json +1 -1
- package/run-main.js +1405 -0
- package/run.js +46 -17
- package/tests/testaro.js +1 -1
package/run-main.js
ADDED
|
@@ -0,0 +1,1405 @@
|
|
|
1
|
+
/*
|
|
2
|
+
© 2021–2024 CVS Health and/or one of its affiliates. All rights reserved.
|
|
3
|
+
|
|
4
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
5
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
6
|
+
in the Software without restriction, including without limitation the rights
|
|
7
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
8
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
9
|
+
furnished to do so, subject to the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be included in all
|
|
12
|
+
copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
15
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
16
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
17
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
18
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
19
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
20
|
+
SOFTWARE.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/*
|
|
24
|
+
run.js
|
|
25
|
+
Testaro main utility module.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
// IMPORTS
|
|
29
|
+
|
|
30
|
+
// Module to keep secrets.
|
|
31
|
+
require('dotenv').config();
|
|
32
|
+
// Module to validate jobs.
|
|
33
|
+
const {isBrowserID, isValidJob, tools} = require('./procs/job');
|
|
34
|
+
// Module to standardize report formats.
|
|
35
|
+
const {standardize} = require('./procs/standardize');
|
|
36
|
+
// Module to identify element bounding boxes.
|
|
37
|
+
const {identify} = require('./procs/identify');
|
|
38
|
+
// Module to send a notice to an observer.
|
|
39
|
+
const {tellServer} = require('./procs/tellServer');
|
|
40
|
+
|
|
41
|
+
// CONSTANTS
|
|
42
|
+
|
|
43
|
+
// Set DEBUG environment variable to 'true' to add debugging features.
|
|
44
|
+
const debug = process.env.DEBUG === 'true';
|
|
45
|
+
// Set WAITS environment variable to a positive number to insert delays (in ms).
|
|
46
|
+
const waits = Number.parseInt(process.env.WAITS) || 0;
|
|
47
|
+
// CSS selectors for targets of moves.
|
|
48
|
+
const moves = {
|
|
49
|
+
button: 'button, [role=button], input[type=submit]',
|
|
50
|
+
checkbox: 'input[type=checkbox]',
|
|
51
|
+
focus: true,
|
|
52
|
+
link: 'a, [role=link]',
|
|
53
|
+
radio: 'input[type=radio]',
|
|
54
|
+
search: 'input[type=search], input[aria-label*=search i], input[placeholder*=search i]',
|
|
55
|
+
select: 'select',
|
|
56
|
+
text: 'input'
|
|
57
|
+
};
|
|
58
|
+
// Strings in log messages indicating errors.
|
|
59
|
+
const errorWords = [
|
|
60
|
+
'but not used',
|
|
61
|
+
'content security policy',
|
|
62
|
+
'deprecated',
|
|
63
|
+
'error',
|
|
64
|
+
'exception',
|
|
65
|
+
'expected',
|
|
66
|
+
'failed',
|
|
67
|
+
'invalid',
|
|
68
|
+
'missing',
|
|
69
|
+
'non-standard',
|
|
70
|
+
'not supported',
|
|
71
|
+
'refused',
|
|
72
|
+
'requires',
|
|
73
|
+
'sorry',
|
|
74
|
+
'suspicious',
|
|
75
|
+
'unrecognized',
|
|
76
|
+
'violates',
|
|
77
|
+
'warning'
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
// ########## VARIABLES
|
|
81
|
+
|
|
82
|
+
// Facts about the current session.
|
|
83
|
+
let actCount = 0;
|
|
84
|
+
// Facts about the current browser.
|
|
85
|
+
let browser;
|
|
86
|
+
let browserContext;
|
|
87
|
+
let currentPage;
|
|
88
|
+
let requestedURL = '';
|
|
89
|
+
|
|
90
|
+
// FUNCTIONS
|
|
91
|
+
|
|
92
|
+
// Returns a string with any final slash removed.
|
|
93
|
+
const deSlash = string => string.endsWith('/') ? string.slice(0, -1) : string;
|
|
94
|
+
// Gets the script nonce from a response.
|
|
95
|
+
const getNonce = async response => {
|
|
96
|
+
let nonce = '';
|
|
97
|
+
// If the response includes a content security policy:
|
|
98
|
+
const headers = await response.allHeaders();
|
|
99
|
+
const cspWithQuotes = headers && headers['content-security-policy'];
|
|
100
|
+
if (cspWithQuotes) {
|
|
101
|
+
// If it requires scripts to have a nonce:
|
|
102
|
+
const csp = cspWithQuotes.replace(/'/g, '');
|
|
103
|
+
const directives = csp.split(/ *; */).map(directive => directive.split(/ +/));
|
|
104
|
+
const scriptDirective = directives.find(dir => dir[0] === 'script-src');
|
|
105
|
+
if (scriptDirective) {
|
|
106
|
+
const nonceSpec = scriptDirective.find(valPart => valPart.startsWith('nonce-'));
|
|
107
|
+
if (nonceSpec) {
|
|
108
|
+
// Return the nonce.
|
|
109
|
+
nonce = nonceSpec.replace(/^nonce-/, '');
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
// Return the nonce, if any.
|
|
114
|
+
return nonce;
|
|
115
|
+
};
|
|
116
|
+
// Visits a URL and returns the response of the server.
|
|
117
|
+
const goTo = async (report, page, url, timeout, waitUntil) => {
|
|
118
|
+
// If the URL is a file path:
|
|
119
|
+
if (url.startsWith('file://')) {
|
|
120
|
+
// Make it absolute.
|
|
121
|
+
url = url.replace('file://', `file://${__dirname}/`);
|
|
122
|
+
}
|
|
123
|
+
// Visit the URL.
|
|
124
|
+
const startTime = Date.now();
|
|
125
|
+
try {
|
|
126
|
+
const response = await page.goto(url, {
|
|
127
|
+
timeout,
|
|
128
|
+
waitUntil
|
|
129
|
+
});
|
|
130
|
+
report.jobData.visitLatency += Math.round((Date.now() - startTime) / 1000);
|
|
131
|
+
const httpStatus = response.status();
|
|
132
|
+
// If the response status was normal:
|
|
133
|
+
if ([200, 304].includes(httpStatus) || url.startsWith('file:')) {
|
|
134
|
+
// If the browser was redirected in violation of a strictness requirement:
|
|
135
|
+
const actualURL = page.url();
|
|
136
|
+
if (report.strict && deSlash(actualURL) !== deSlash(url)) {
|
|
137
|
+
// Return an error.
|
|
138
|
+
console.log(`ERROR: Visit to ${url} redirected to ${actualURL}`);
|
|
139
|
+
return {
|
|
140
|
+
success: false,
|
|
141
|
+
error: 'badRedirection'
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
// Otherwise, i.e. if no prohibited redirection occurred:
|
|
145
|
+
else {
|
|
146
|
+
// Press the Escape key to dismiss any modal dialog.
|
|
147
|
+
await page.keyboard.press('Escape');
|
|
148
|
+
// Return the result of the navigation.
|
|
149
|
+
return {
|
|
150
|
+
success: true,
|
|
151
|
+
response
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
// Otherwise, i.e. if the response status was abnormal:
|
|
156
|
+
else {
|
|
157
|
+
// Return an error.
|
|
158
|
+
console.log(`ERROR: Visit to ${url} got status ${httpStatus}`);
|
|
159
|
+
report.jobData.visitRejectionCount++;
|
|
160
|
+
return {
|
|
161
|
+
success: false,
|
|
162
|
+
error: 'badStatus'
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
catch(error) {
|
|
167
|
+
console.log(`ERROR visiting ${url} (${error.message.slice(0, 200)})`);
|
|
168
|
+
return {
|
|
169
|
+
success: false,
|
|
170
|
+
error: 'noVisit'
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
// Closes the current browser.
|
|
175
|
+
const browserClose = async () => {
|
|
176
|
+
if (browser) {
|
|
177
|
+
let contexts = browser.contexts();
|
|
178
|
+
for (const context of contexts) {
|
|
179
|
+
await context.close();
|
|
180
|
+
contexts = browser.contexts();
|
|
181
|
+
}
|
|
182
|
+
await browser.close();
|
|
183
|
+
browser = null;
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
// Launches a browser, navigates to a URL, and returns browser data.
|
|
187
|
+
const launch = async (report, debug, waits, tempBrowserID, tempTarget) => {
|
|
188
|
+
const {browserID, device, target} = report;
|
|
189
|
+
// Get the default arguments if not overridden.
|
|
190
|
+
browserID ??= browserID;
|
|
191
|
+
const {url} = (tempTarget || target);
|
|
192
|
+
// If the specified browser type exists:
|
|
193
|
+
if (isBrowserID(browserID)) {
|
|
194
|
+
// Create a browser of the specified or default type.
|
|
195
|
+
const browserType = require('playwright')[tempBrowserID || browserID];
|
|
196
|
+
// Close the current browser, if any.
|
|
197
|
+
await browserClose();
|
|
198
|
+
// Define browser options.
|
|
199
|
+
const browserOptions = {
|
|
200
|
+
logger: {
|
|
201
|
+
isEnabled: () => false,
|
|
202
|
+
log: (name, severity, message) => console.log(message.slice(0, 100))
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
browserOptions.headless = ! debug;
|
|
206
|
+
browserOptions.slowMo = waits || 0;
|
|
207
|
+
// Launch the browser.
|
|
208
|
+
browser = await browserType.launch(browserOptions)
|
|
209
|
+
// If the launch failed:
|
|
210
|
+
.catch(async error => {
|
|
211
|
+
console.log(`ERROR launching browser (${error.message.slice(0, 200)})`);
|
|
212
|
+
// Return this.
|
|
213
|
+
return {
|
|
214
|
+
success: false,
|
|
215
|
+
error: 'Browser launch failed'
|
|
216
|
+
};
|
|
217
|
+
});
|
|
218
|
+
// Open a context (i.e. browser tab).
|
|
219
|
+
const browserContext = await browser.newContext(device.windowOptions);
|
|
220
|
+
// Prevent default timeouts.
|
|
221
|
+
browserContext.setDefaultTimeout(0);
|
|
222
|
+
// When a page (i.e. browser tab) is added to the browser context (i.e. browser window):
|
|
223
|
+
browserContext.on('page', async page => {
|
|
224
|
+
// Ensure the report has a jobData property.
|
|
225
|
+
report.jobData ??= {};
|
|
226
|
+
const {jobData} = report;
|
|
227
|
+
jobData.logCount ??= 0;
|
|
228
|
+
jobData.logSize ??= 0;
|
|
229
|
+
jobData.errorLogCount ??= 0;
|
|
230
|
+
// Add any error events to the count of logging errors.
|
|
231
|
+
page.on('crash', () => {
|
|
232
|
+
jobData.errorLogCount++;
|
|
233
|
+
console.log('Page crashed');
|
|
234
|
+
});
|
|
235
|
+
page.on('pageerror', () => {
|
|
236
|
+
jobData.errorLogCount++;
|
|
237
|
+
});
|
|
238
|
+
page.on('requestfailed', () => {
|
|
239
|
+
jobData.errorLogCount++;
|
|
240
|
+
});
|
|
241
|
+
// If the page emits a message:
|
|
242
|
+
page.on('console', msg => {
|
|
243
|
+
const msgText = msg.text();
|
|
244
|
+
let indentedMsg = '';
|
|
245
|
+
// If debugging is on:
|
|
246
|
+
if (debug) {
|
|
247
|
+
// Log a summary of the message on the console.
|
|
248
|
+
const parts = [msgText.slice(0, 75)];
|
|
249
|
+
if (msgText.length > 75) {
|
|
250
|
+
parts.push(msgText.slice(75, 150));
|
|
251
|
+
if (msgText.length > 150) {
|
|
252
|
+
const tail = msgText.slice(150).slice(-150);
|
|
253
|
+
if (msgText.length > 300) {
|
|
254
|
+
parts.push('...');
|
|
255
|
+
}
|
|
256
|
+
parts.push(tail.slice(0, 75));
|
|
257
|
+
if (tail.length > 75) {
|
|
258
|
+
parts.push(tail.slice(75));
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
indentedMsg = parts.map(part => ` | ${part}`).join('\n');
|
|
263
|
+
console.log(`\n${indentedMsg}`);
|
|
264
|
+
}
|
|
265
|
+
// Add statistics on the message to the report.
|
|
266
|
+
const msgTextLC = msgText.toLowerCase();
|
|
267
|
+
const msgLength = msgText.length;
|
|
268
|
+
jobData.logCount++;
|
|
269
|
+
jobData.logSize += msgLength;
|
|
270
|
+
if (errorWords.some(word => msgTextLC.includes(word))) {
|
|
271
|
+
jobData.errorLogCount++;
|
|
272
|
+
jobData.errorLogSize += msgLength;
|
|
273
|
+
}
|
|
274
|
+
const msgLC = msgText.toLowerCase();
|
|
275
|
+
if (
|
|
276
|
+
msgText.includes('403') && (msgLC.includes('status')
|
|
277
|
+
|| msgLC.includes('prohibited'))
|
|
278
|
+
) {
|
|
279
|
+
jobData.prohibitedCount++;
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
// Open the first page (tab) of the context (window).
|
|
284
|
+
const page = await browserContext.newPage();
|
|
285
|
+
try {
|
|
286
|
+
// Wait until it is stable.
|
|
287
|
+
await page.waitForLoadState('domcontentloaded', {timeout: 5000});
|
|
288
|
+
// Navigate to the specified URL.
|
|
289
|
+
const navResult = await goTo(report, page, url, 15000, 'domcontentloaded');
|
|
290
|
+
// If the navigation succeeded:
|
|
291
|
+
if (navResult.success) {
|
|
292
|
+
// Update the name of the current browser type and store it in the page.
|
|
293
|
+
page.browserID = browserID;
|
|
294
|
+
// Return the response of the target server, the browser context, and the page.
|
|
295
|
+
return {
|
|
296
|
+
success: true,
|
|
297
|
+
response: navResult.response,
|
|
298
|
+
browserContext,
|
|
299
|
+
page
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
// Otherwise, if the navigation failed:
|
|
303
|
+
else {
|
|
304
|
+
// Return the error.
|
|
305
|
+
return {
|
|
306
|
+
success: false,
|
|
307
|
+
error: navResult.error
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
// If it fails to become stable after load:
|
|
312
|
+
catch(error) {
|
|
313
|
+
// Return this.
|
|
314
|
+
console.log(`ERROR: Blank page load in new tab timed out (${error.message})`);
|
|
315
|
+
return {
|
|
316
|
+
success: false,
|
|
317
|
+
error: 'Blank page load in new tab timed out'
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
// Otherwise, i.e. if it does not exist:
|
|
322
|
+
else {
|
|
323
|
+
// Return this.
|
|
324
|
+
console.log(`ERROR: Browser of type ${browserID} could not be launched`);
|
|
325
|
+
return {
|
|
326
|
+
success: false,
|
|
327
|
+
error: `${browserID} browser launch failed`
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
// Returns a string representing the date and time.
|
|
332
|
+
const nowString = () => (new Date()).toISOString().slice(2, 16);
|
|
333
|
+
// Returns the first line of an error message.
|
|
334
|
+
const errorStart = error => error.message.replace(/\n.+/s, '');
|
|
335
|
+
// Normalizes spacing characters and cases in a string.
|
|
336
|
+
const debloat = string => string.replace(/\s/g, ' ').trim().replace(/ {2,}/g, ' ').toLowerCase();
|
|
337
|
+
// Returns the text of an element, lower-cased.
|
|
338
|
+
const textOf = async (page, element) => {
|
|
339
|
+
if (element) {
|
|
340
|
+
const tagNameJSHandle = await element.getProperty('tagName');
|
|
341
|
+
const tagName = await tagNameJSHandle.jsonValue();
|
|
342
|
+
let totalText = '';
|
|
343
|
+
// If the element is a link, button, input, or select list:
|
|
344
|
+
if (['A', 'BUTTON', 'INPUT', 'SELECT'].includes(tagName)) {
|
|
345
|
+
// Return its visible labels, descriptions, and legend if the first input in a fieldset.
|
|
346
|
+
totalText = await page.evaluate(element => {
|
|
347
|
+
const {tagName, ariaLabel} = element;
|
|
348
|
+
let ownText = '';
|
|
349
|
+
if (['A', 'BUTTON'].includes(tagName)) {
|
|
350
|
+
ownText = element.textContent;
|
|
351
|
+
}
|
|
352
|
+
else if (tagName === 'INPUT' && element.type === 'submit') {
|
|
353
|
+
ownText = element.value;
|
|
354
|
+
}
|
|
355
|
+
// HTML link elements have no labels property.
|
|
356
|
+
const labels = tagName !== 'A' ? Array.from(element.labels) : [];
|
|
357
|
+
const labelTexts = labels.map(label => label.textContent);
|
|
358
|
+
if (ariaLabel) {
|
|
359
|
+
labelTexts.push(ariaLabel);
|
|
360
|
+
}
|
|
361
|
+
const refIDs = new Set([
|
|
362
|
+
element.getAttribute('aria-labelledby') || '',
|
|
363
|
+
element.getAttribute('aria-describedby') || ''
|
|
364
|
+
].join(' ').split(/\s+/));
|
|
365
|
+
if (refIDs.size) {
|
|
366
|
+
refIDs.forEach(id => {
|
|
367
|
+
const labeler = document.getElementById(id);
|
|
368
|
+
if (labeler) {
|
|
369
|
+
const labelerText = labeler.textContent.trim();
|
|
370
|
+
if (labelerText.length) {
|
|
371
|
+
labelTexts.push(labelerText);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
let legendText = '';
|
|
377
|
+
if (tagName === 'INPUT') {
|
|
378
|
+
const fieldsets = Array.from(document.body.querySelectorAll('fieldset'));
|
|
379
|
+
const inputFieldsets = fieldsets.filter(fieldset => {
|
|
380
|
+
const inputs = Array.from(fieldset.querySelectorAll('input'));
|
|
381
|
+
return inputs.length && inputs[0] === element;
|
|
382
|
+
});
|
|
383
|
+
const inputFieldset = inputFieldsets[0] || null;
|
|
384
|
+
if (inputFieldset) {
|
|
385
|
+
const legend = inputFieldset.querySelector('legend');
|
|
386
|
+
if (legend) {
|
|
387
|
+
legendText = legend.textContent;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
return [legendText].concat(labelTexts, ownText).join(' ');
|
|
392
|
+
}, element);
|
|
393
|
+
}
|
|
394
|
+
// Otherwise, if it is an option:
|
|
395
|
+
else if (tagName === 'OPTION') {
|
|
396
|
+
// Return its text content, prefixed with the text of its select parent if the first option.
|
|
397
|
+
const ownText = await element.textContent();
|
|
398
|
+
const indexJSHandle = await element.getProperty('index');
|
|
399
|
+
const index = await indexJSHandle.jsonValue();
|
|
400
|
+
if (index) {
|
|
401
|
+
totalText = ownText;
|
|
402
|
+
}
|
|
403
|
+
else {
|
|
404
|
+
const selectJSHandle = await page.evaluateHandle(
|
|
405
|
+
element => element.parentElement, element
|
|
406
|
+
);
|
|
407
|
+
const select = await selectJSHandle.asElement();
|
|
408
|
+
if (select) {
|
|
409
|
+
const selectText = await textOf(page, select);
|
|
410
|
+
totalText = [ownText, selectText].join(' ');
|
|
411
|
+
}
|
|
412
|
+
else {
|
|
413
|
+
totalText = ownText;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
// Otherwise, i.e. if it is not an input, select, or option:
|
|
418
|
+
else {
|
|
419
|
+
// Get its text content.
|
|
420
|
+
totalText = await element.textContent();
|
|
421
|
+
}
|
|
422
|
+
return debloat(totalText);
|
|
423
|
+
}
|
|
424
|
+
else {
|
|
425
|
+
return null;
|
|
426
|
+
}
|
|
427
|
+
};
|
|
428
|
+
// Returns a property value and whether it satisfies an expectation.
|
|
429
|
+
const isTrue = (object, specs) => {
|
|
430
|
+
const property = specs[0];
|
|
431
|
+
const propertyTree = property.split('.');
|
|
432
|
+
let actual = property.length ? object[propertyTree[0]] : object;
|
|
433
|
+
// Identify the actual value of the specified property.
|
|
434
|
+
while (propertyTree.length > 1 && actual !== undefined) {
|
|
435
|
+
propertyTree.shift();
|
|
436
|
+
actual = actual[propertyTree[0]];
|
|
437
|
+
}
|
|
438
|
+
// If the expectation is that the property does not exist:
|
|
439
|
+
if (specs.length === 1) {
|
|
440
|
+
// Return whether the expectation is satisfied.
|
|
441
|
+
return [actual, actual === undefined];
|
|
442
|
+
}
|
|
443
|
+
// Otherwise, i.e. if the expectation is of a property value:
|
|
444
|
+
else if (specs.length === 3) {
|
|
445
|
+
// Return whether the expectation was fulfilled.
|
|
446
|
+
const relation = specs[1];
|
|
447
|
+
const criterion = specs[2];
|
|
448
|
+
let satisfied;
|
|
449
|
+
if (actual === undefined) {
|
|
450
|
+
return [null, false];
|
|
451
|
+
}
|
|
452
|
+
else if (relation === '=') {
|
|
453
|
+
satisfied = actual === criterion;
|
|
454
|
+
}
|
|
455
|
+
else if (relation === '<') {
|
|
456
|
+
satisfied = actual < criterion;
|
|
457
|
+
}
|
|
458
|
+
else if (relation === '>') {
|
|
459
|
+
satisfied = actual > criterion;
|
|
460
|
+
}
|
|
461
|
+
else if (relation === '!') {
|
|
462
|
+
satisfied = actual !== criterion;
|
|
463
|
+
}
|
|
464
|
+
else if (relation === 'i') {
|
|
465
|
+
satisfied = typeof actual === 'string' && actual.includes(criterion);
|
|
466
|
+
}
|
|
467
|
+
else if (relation === '!i') {
|
|
468
|
+
satisfied = typeof actual === 'string' && ! actual.includes(criterion);
|
|
469
|
+
}
|
|
470
|
+
else if (relation === 'e') {
|
|
471
|
+
satisfied = typeof actual === 'object'
|
|
472
|
+
&& JSON.stringify(actual) === JSON.stringify(criterion);
|
|
473
|
+
}
|
|
474
|
+
return [actual, satisfied];
|
|
475
|
+
}
|
|
476
|
+
// Otherwise, i.e. if the specifications are invalid:
|
|
477
|
+
else {
|
|
478
|
+
// Return this.
|
|
479
|
+
return [null, false];
|
|
480
|
+
}
|
|
481
|
+
};
|
|
482
|
+
// Adds a wait error result to an act.
|
|
483
|
+
const waitError = (page, act, error, what) => {
|
|
484
|
+
console.log(`ERROR waiting for ${what} (${error.message})`);
|
|
485
|
+
act.result.found = false;
|
|
486
|
+
act.result.url = page.url();
|
|
487
|
+
act.result.error = `ERROR waiting for ${what}`;
|
|
488
|
+
return false;
|
|
489
|
+
};
|
|
490
|
+
// Waits.
|
|
491
|
+
const wait = ms => {
|
|
492
|
+
return new Promise(resolve => {
|
|
493
|
+
setTimeout(() => {
|
|
494
|
+
resolve('');
|
|
495
|
+
}, ms);
|
|
496
|
+
});
|
|
497
|
+
};
|
|
498
|
+
// Reports a job being aborted and returns an abortive act index.
|
|
499
|
+
const abortActs = async (report, actIndex) => {
|
|
500
|
+
// Add data on the aborted act to the report.
|
|
501
|
+
report.jobData.abortTime = nowString();
|
|
502
|
+
report.jobData.abortedAct = actIndex;
|
|
503
|
+
report.jobData.aborted = true;
|
|
504
|
+
// Report that the job is aborted.
|
|
505
|
+
console.log(`ERROR: Job aborted on act ${actIndex}`);
|
|
506
|
+
// Return an abortive act index.
|
|
507
|
+
return -2;
|
|
508
|
+
};
|
|
509
|
+
// Adds an error result to an act.
|
|
510
|
+
const addError = async(alsoLog, alsoAbort, report, actIndex, message) => {
|
|
511
|
+
// If the error is to be logged:
|
|
512
|
+
if (alsoLog) {
|
|
513
|
+
// Log it.
|
|
514
|
+
console.log(message);
|
|
515
|
+
}
|
|
516
|
+
// Add error data to the result.
|
|
517
|
+
const act = report.acts[actIndex];
|
|
518
|
+
act.result ??= {};
|
|
519
|
+
act.result.success ??= false;
|
|
520
|
+
act.result.error ??= message;
|
|
521
|
+
if (act.type === 'test') {
|
|
522
|
+
act.data.success = false;
|
|
523
|
+
act.data.prevented = true;
|
|
524
|
+
act.data.error = message;
|
|
525
|
+
// Add prevention data to the job data.
|
|
526
|
+
report.jobData.preventions[act.which] = message;
|
|
527
|
+
}
|
|
528
|
+
// If the job is to be aborted:
|
|
529
|
+
if (alsoAbort) {
|
|
530
|
+
// Return an abortive act index.
|
|
531
|
+
return await abortActs(report, actIndex);
|
|
532
|
+
}
|
|
533
|
+
// Otherwise, i.e. if the job is not to be aborted:
|
|
534
|
+
else {
|
|
535
|
+
// Return the current act index.
|
|
536
|
+
return actIndex;
|
|
537
|
+
}
|
|
538
|
+
};
|
|
539
|
+
// Recursively performs the acts in a report.
|
|
540
|
+
const doActs = async (report, actIndex, page) => {
|
|
541
|
+
const {acts} = report;
|
|
542
|
+
// If any more acts are to be performed:
|
|
543
|
+
if (actIndex > -1 && actIndex < acts.length) {
|
|
544
|
+
// Identify the act to be performed.
|
|
545
|
+
const act = acts[actIndex];
|
|
546
|
+
const {type, which} = act;
|
|
547
|
+
const actSuffix = type === 'test' ? ` ${which}` : '';
|
|
548
|
+
const message = `>>>> ${type}${actSuffix}`;
|
|
549
|
+
// If granular reporting has been specified:
|
|
550
|
+
if (report.observe) {
|
|
551
|
+
// Notify the observer of the act and log it.
|
|
552
|
+
const whichParam = which ? `&which=${which}` : '';
|
|
553
|
+
const messageParams = `act=${type}${whichParam}`;
|
|
554
|
+
tellServer(report, messageParams, message);
|
|
555
|
+
}
|
|
556
|
+
// Otherwise, i.e. if granular reporting has not been specified:
|
|
557
|
+
else {
|
|
558
|
+
// Log the act.
|
|
559
|
+
console.log(message);
|
|
560
|
+
}
|
|
561
|
+
// Increment the count of acts performed.
|
|
562
|
+
actCount++;
|
|
563
|
+
act.startTime = Date.now();
|
|
564
|
+
// If the act is an index changer:
|
|
565
|
+
if (type === 'next') {
|
|
566
|
+
const condition = act.if;
|
|
567
|
+
const logSuffix = condition.length === 3 ? ` ${condition[1]} ${condition[2]}` : '';
|
|
568
|
+
console.log(`>> ${condition[0]}${logSuffix}`);
|
|
569
|
+
// Identify the act to be checked.
|
|
570
|
+
const ifActIndex = report.acts.map(act => act.type !== 'next').lastIndexOf(true);
|
|
571
|
+
// Determine whether its jump condition is true.
|
|
572
|
+
const truth = isTrue(report.acts[ifActIndex].result, condition);
|
|
573
|
+
// Add the result to the act.
|
|
574
|
+
act.result = {
|
|
575
|
+
property: condition[0],
|
|
576
|
+
relation: condition[1],
|
|
577
|
+
criterion: condition[2],
|
|
578
|
+
value: truth[0],
|
|
579
|
+
jumpRequired: truth[1]
|
|
580
|
+
};
|
|
581
|
+
// If the condition is true:
|
|
582
|
+
if (truth[1]) {
|
|
583
|
+
// If the performance of acts is to stop:
|
|
584
|
+
if (act.jump === 0) {
|
|
585
|
+
// Quit.
|
|
586
|
+
actIndex = -2;
|
|
587
|
+
}
|
|
588
|
+
// Otherwise, if there is a numerical jump:
|
|
589
|
+
else if (act.jump) {
|
|
590
|
+
// Set the act index accordingly.
|
|
591
|
+
actIndex += act.jump - 1;
|
|
592
|
+
}
|
|
593
|
+
// Otherwise, if there is a named next act:
|
|
594
|
+
else if (act.next) {
|
|
595
|
+
// Set the new index accordingly, or stop if it does not exist.
|
|
596
|
+
actIndex = acts.map(act => act.name).indexOf(act.next) - 1;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
// Otherwise, if the act is a launch:
|
|
601
|
+
else if (type === 'launch') {
|
|
602
|
+
// Launch the specified browser on the specified device and navigate to the specified URL.
|
|
603
|
+
const launchResult = await launch(
|
|
604
|
+
report,
|
|
605
|
+
debug,
|
|
606
|
+
waits,
|
|
607
|
+
act.browserID || report.browserID,
|
|
608
|
+
act.target || report.target
|
|
609
|
+
);
|
|
610
|
+
// If the launch and navigation succeeded:
|
|
611
|
+
if (launchResult && launchResult.success) {
|
|
612
|
+
// Get the response of the target server.
|
|
613
|
+
const {response} = launchResult;
|
|
614
|
+
// Get the target page.
|
|
615
|
+
page = launchResult.page;
|
|
616
|
+
// Add the actual URL to the act.
|
|
617
|
+
act.actualURL = page.url();
|
|
618
|
+
// Add the script nonce, if any, to the act.
|
|
619
|
+
const scriptNonce = await getNonce(response);
|
|
620
|
+
if (scriptNonce) {
|
|
621
|
+
report.jobData.lastScriptNonce = scriptNonce;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
// Otherwise, i.e. if the launch or navigation failed:
|
|
625
|
+
else {
|
|
626
|
+
// Add an error result to the act and abort the job.
|
|
627
|
+
actIndex = await addError(
|
|
628
|
+
true, true, report, actIndex, `ERROR: Launch failed (${launchResult.error})`
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
// Otherwise, if a current page exists:
|
|
633
|
+
else if (page) {
|
|
634
|
+
// If the act is navigation to a url:
|
|
635
|
+
if (act.type === 'url') {
|
|
636
|
+
// Identify the URL.
|
|
637
|
+
const resolved = act.which.replace('__dirname', __dirname);
|
|
638
|
+
requestedURL = resolved;
|
|
639
|
+
// Visit it and wait until the DOM is loaded.
|
|
640
|
+
const navResult = await goTo(report, page, requestedURL, 15000, 'domcontentloaded');
|
|
641
|
+
// If the visit succeeded:
|
|
642
|
+
if (navResult.success) {
|
|
643
|
+
// Add the script nonce, if any, to the act.
|
|
644
|
+
const {response} = navResult;
|
|
645
|
+
const scriptNonce = getNonce(response);
|
|
646
|
+
if (scriptNonce) {
|
|
647
|
+
report.jobData.lastScriptNonce = scriptNonce;
|
|
648
|
+
}
|
|
649
|
+
// Add the resulting URL to the act.
|
|
650
|
+
if (! act.result) {
|
|
651
|
+
act.result = {};
|
|
652
|
+
}
|
|
653
|
+
act.result.url = page.url();
|
|
654
|
+
// If a prohibited redirection occurred:
|
|
655
|
+
if (response.exception === 'badRedirection') {
|
|
656
|
+
// Report this and abort the job.
|
|
657
|
+
actIndex = await addError(
|
|
658
|
+
true, true, report, actIndex, 'ERROR: Navigation illicitly redirected'
|
|
659
|
+
);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
// Otherwise, i.e. if the visit failed:
|
|
663
|
+
else {
|
|
664
|
+
// Report this and abort the job.
|
|
665
|
+
actIndex = await addError(true, true, report, actIndex, 'ERROR: Visit failed');
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
// Otherwise, if the act is a wait for text:
|
|
669
|
+
else if (act.type === 'wait') {
|
|
670
|
+
const {what, which} = act;
|
|
671
|
+
console.log(`>> ${what}`);
|
|
672
|
+
const result = act.result = {};
|
|
673
|
+
// If the text is to be the URL:
|
|
674
|
+
if (what === 'url') {
|
|
675
|
+
// Wait for the URL to be the exact text.
|
|
676
|
+
try {
|
|
677
|
+
await page.waitForURL(which, {timeout: 15000});
|
|
678
|
+
result.found = true;
|
|
679
|
+
result.url = page.url();
|
|
680
|
+
}
|
|
681
|
+
// If the wait times out:
|
|
682
|
+
catch(error) {
|
|
683
|
+
// Quit.
|
|
684
|
+
actIndex = await abortActs(report, actIndex);
|
|
685
|
+
waitError(page, act, error, 'text in the URL');
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
// Otherwise, if the text is to be a substring of the page title:
|
|
689
|
+
else if (what === 'title') {
|
|
690
|
+
// Wait for the page title to include the text, case-insensitively.
|
|
691
|
+
try {
|
|
692
|
+
await page.waitForFunction(
|
|
693
|
+
text => document
|
|
694
|
+
&& document.title
|
|
695
|
+
&& document.title.toLowerCase().includes(text.toLowerCase()),
|
|
696
|
+
which,
|
|
697
|
+
{
|
|
698
|
+
polling: 1000,
|
|
699
|
+
timeout: 5000
|
|
700
|
+
}
|
|
701
|
+
);
|
|
702
|
+
result.found = true;
|
|
703
|
+
result.title = await page.title();
|
|
704
|
+
}
|
|
705
|
+
// If the wait times out:
|
|
706
|
+
catch(error) {
|
|
707
|
+
// Quit.
|
|
708
|
+
actIndex = await abortActs(report, actIndex);
|
|
709
|
+
waitError(page, act, error, 'text in the title');
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
// Otherwise, if the text is to be a substring of the text of the page body:
|
|
713
|
+
else if (what === 'body') {
|
|
714
|
+
// Wait for the body to include the text, case-insensitively.
|
|
715
|
+
try {
|
|
716
|
+
await page.waitForFunction(
|
|
717
|
+
text => document
|
|
718
|
+
&& document.body
|
|
719
|
+
&& document.body.innerText.toLowerCase().includes(text.toLowerCase()),
|
|
720
|
+
which,
|
|
721
|
+
{
|
|
722
|
+
polling: 2000,
|
|
723
|
+
timeout: 15000
|
|
724
|
+
}
|
|
725
|
+
);
|
|
726
|
+
result.found = true;
|
|
727
|
+
}
|
|
728
|
+
// If the wait times out:
|
|
729
|
+
catch(error) {
|
|
730
|
+
// Quit.
|
|
731
|
+
actIndex = await abortActs(report, actIndex);
|
|
732
|
+
waitError(page, act, error, 'text in the body');
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
// Otherwise, if the act is a wait for a state:
|
|
737
|
+
else if (act.type === 'state') {
|
|
738
|
+
// Wait for it.
|
|
739
|
+
const stateIndex = ['loaded', 'idle'].indexOf(act.which);
|
|
740
|
+
await page.waitForLoadState(
|
|
741
|
+
['domcontentloaded', 'networkidle'][stateIndex], {timeout: [10000, 15000][stateIndex]}
|
|
742
|
+
)
|
|
743
|
+
// If the wait times out:
|
|
744
|
+
.catch(async error => {
|
|
745
|
+
// Report this and abort the job.
|
|
746
|
+
console.log(`ERROR waiting for page to be ${act.which} (${error.message})`);
|
|
747
|
+
actIndex = await addError(
|
|
748
|
+
true, true, report, actIndex, `ERROR waiting for page to be ${act.which}`
|
|
749
|
+
);
|
|
750
|
+
});
|
|
751
|
+
// If the wait succeeded:
|
|
752
|
+
if (actIndex > -2) {
|
|
753
|
+
// Add state data to the report.
|
|
754
|
+
act.result = {
|
|
755
|
+
success: true,
|
|
756
|
+
state: act.which
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
// Otherwise, if the act is a page switch:
|
|
761
|
+
else if (act.type === 'page') {
|
|
762
|
+
// Wait for a page to be created and identify it as current.
|
|
763
|
+
page = await browserContext.waitForEvent('page');
|
|
764
|
+
// Wait until it is idle.
|
|
765
|
+
await page.waitForLoadState('networkidle', {timeout: 15000});
|
|
766
|
+
// Add the resulting URL to the act.
|
|
767
|
+
const result = {
|
|
768
|
+
url: page.url()
|
|
769
|
+
};
|
|
770
|
+
act.result = result;
|
|
771
|
+
}
|
|
772
|
+
// Otherwise, if the page has a URL:
|
|
773
|
+
else if (page.url() && page.url() !== 'about:blank') {
|
|
774
|
+
const url = page.url();
|
|
775
|
+
// Add the URL to the act.
|
|
776
|
+
act.actualURL = url;
|
|
777
|
+
// If the act is a revelation:
|
|
778
|
+
if (act.type === 'reveal') {
|
|
779
|
+
// Make all elements in the page visible.
|
|
780
|
+
await page.$$eval('body *', elements => {
|
|
781
|
+
elements.forEach(element => {
|
|
782
|
+
const styleDec = window.getComputedStyle(element);
|
|
783
|
+
if (styleDec.display === 'none') {
|
|
784
|
+
element.style.display = 'initial';
|
|
785
|
+
}
|
|
786
|
+
if (['hidden', 'collapse'].includes(styleDec.visibility)) {
|
|
787
|
+
element.style.visibility = 'inherit';
|
|
788
|
+
}
|
|
789
|
+
});
|
|
790
|
+
act.result = {
|
|
791
|
+
success: true
|
|
792
|
+
};
|
|
793
|
+
})
|
|
794
|
+
.catch(error => {
|
|
795
|
+
console.log(`ERROR making all elements visible (${error.message})`);
|
|
796
|
+
act.result = {
|
|
797
|
+
success: false
|
|
798
|
+
};
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
// Otherwise, if the act performs tests of a tool:
|
|
802
|
+
else if (act.type === 'test') {
|
|
803
|
+
// Add a description of the tool to the act.
|
|
804
|
+
act.what = tools[act.which];
|
|
805
|
+
// Initialize the options argument.
|
|
806
|
+
const options = {
|
|
807
|
+
report,
|
|
808
|
+
act
|
|
809
|
+
};
|
|
810
|
+
// Add any specified arguments to it.
|
|
811
|
+
Object.keys(act).forEach(key => {
|
|
812
|
+
if (! ['type', 'which'].includes(key)) {
|
|
813
|
+
options[key] = act[key];
|
|
814
|
+
}
|
|
815
|
+
});
|
|
816
|
+
// Get the start time of the act.
|
|
817
|
+
const startTime = Date.now();
|
|
818
|
+
// Perform the specified tests of the tool and get a report.
|
|
819
|
+
try {
|
|
820
|
+
const actReport = await require(`./tests/${act.which}`).reporter(page, options);
|
|
821
|
+
// Import its test results and process data into the act.
|
|
822
|
+
act.result = actReport && actReport.result || {};
|
|
823
|
+
act.data = actReport && actReport.data || {};
|
|
824
|
+
// If the page prevented the tool from operating:
|
|
825
|
+
if (act.data.prevented) {
|
|
826
|
+
// Add prevention data to the job data.
|
|
827
|
+
report.jobData.preventions[act.which] = act.data.error;
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
// If the testing failed:
|
|
831
|
+
catch(error) {
|
|
832
|
+
// Report this.
|
|
833
|
+
const message = error.message.slice(0, 400);
|
|
834
|
+
console.log(`ERROR: Test act ${act.which} failed (${message})`);
|
|
835
|
+
act.data.error = act.data.error ? `${act.data.error}; ${message}` : message;
|
|
836
|
+
}
|
|
837
|
+
// Add the elapsed time of the tool to the report.
|
|
838
|
+
const time = Math.round((Date.now() - startTime) / 1000);
|
|
839
|
+
const {toolTimes} = report.jobData;
|
|
840
|
+
if (! toolTimes[act.which]) {
|
|
841
|
+
toolTimes[act.which] = 0;
|
|
842
|
+
}
|
|
843
|
+
toolTimes[act.which] += time;
|
|
844
|
+
// If a standard-format result is to be included in the report:
|
|
845
|
+
const standard = report.standard || 'only';
|
|
846
|
+
if (['also', 'only'].includes(standard)) {
|
|
847
|
+
// Initialize it.
|
|
848
|
+
act.standardResult = {
|
|
849
|
+
totals: [0, 0, 0, 0],
|
|
850
|
+
instances: []
|
|
851
|
+
};
|
|
852
|
+
// Populate it.
|
|
853
|
+
standardize(act);
|
|
854
|
+
// Add a box ID and a path ID to each of its standard instances if missing.
|
|
855
|
+
for (const instance of act.standardResult.instances) {
|
|
856
|
+
const elementID = await identify(instance, page);
|
|
857
|
+
if (! instance.boxID) {
|
|
858
|
+
instance.boxID = elementID ? elementID.boxID : '';
|
|
859
|
+
}
|
|
860
|
+
if (! instance.pathID) {
|
|
861
|
+
instance.pathID = elementID ? elementID.pathID : '';
|
|
862
|
+
}
|
|
863
|
+
};
|
|
864
|
+
// If the original-format result is not to be included in the report:
|
|
865
|
+
if (standard === 'only') {
|
|
866
|
+
// Remove it.
|
|
867
|
+
delete act.result;
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
// If the act has expectations:
|
|
871
|
+
const expectations = act.expect;
|
|
872
|
+
if (expectations) {
|
|
873
|
+
// Initialize whether they were fulfilled.
|
|
874
|
+
act.expectations = [];
|
|
875
|
+
let failureCount = 0;
|
|
876
|
+
// For each expectation:
|
|
877
|
+
expectations.forEach(spec => {
|
|
878
|
+
const truth = isTrue(act, spec);
|
|
879
|
+
act.expectations.push({
|
|
880
|
+
property: spec[0],
|
|
881
|
+
relation: spec[1],
|
|
882
|
+
criterion: spec[2],
|
|
883
|
+
actual: truth[0],
|
|
884
|
+
passed: truth[1]
|
|
885
|
+
});
|
|
886
|
+
if (! truth[1]) {
|
|
887
|
+
failureCount++;
|
|
888
|
+
}
|
|
889
|
+
});
|
|
890
|
+
act.expectationFailures = failureCount;
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
// Otherwise, if the act is a move:
|
|
894
|
+
else if (moves[act.type]) {
|
|
895
|
+
const selector = typeof moves[act.type] === 'string' ? moves[act.type] : act.what;
|
|
896
|
+
// Try up to 5 times to:
|
|
897
|
+
act.result = {found: false};
|
|
898
|
+
let selection = {};
|
|
899
|
+
let tries = 0;
|
|
900
|
+
const slimText = act.which ? debloat(act.which) : '';
|
|
901
|
+
while (tries++ < 5 && ! act.result.found) {
|
|
902
|
+
if (page) {
|
|
903
|
+
// Identify the elements of the specified type.
|
|
904
|
+
const selections = await page.$$(selector);
|
|
905
|
+
// If there are any:
|
|
906
|
+
if (selections.length) {
|
|
907
|
+
// If there are enough to make a match possible:
|
|
908
|
+
if ((act.index || 0) < selections.length) {
|
|
909
|
+
// For each element of the specified type:
|
|
910
|
+
let matchCount = 0;
|
|
911
|
+
const selectionTexts = [];
|
|
912
|
+
for (selection of selections) {
|
|
913
|
+
// Add its lower-case text or an empty string to the list of element texts.
|
|
914
|
+
const selectionText = slimText ? await textOf(page, selection) : '';
|
|
915
|
+
selectionTexts.push(selectionText);
|
|
916
|
+
// If its text includes any specified text, case-insensitively:
|
|
917
|
+
if (selectionText.includes(slimText)) {
|
|
918
|
+
// If the element has the specified index among such elements:
|
|
919
|
+
if (matchCount++ === (act.index || 0)) {
|
|
920
|
+
// Report it as the matching element and stop checking.
|
|
921
|
+
act.result.found = true;
|
|
922
|
+
act.result.textSpec = slimText;
|
|
923
|
+
act.result.textContent = selectionText;
|
|
924
|
+
break;
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
// If no element satisfied the specifications:
|
|
929
|
+
if (! act.result.found) {
|
|
930
|
+
// Add the failure data to the report.
|
|
931
|
+
act.result.success = false;
|
|
932
|
+
act.result.error = 'exhausted';
|
|
933
|
+
act.result.typeElementCount = selections.length;
|
|
934
|
+
if (slimText) {
|
|
935
|
+
act.result.textElementCount = --matchCount;
|
|
936
|
+
}
|
|
937
|
+
act.result.message = 'Not enough specified elements exist';
|
|
938
|
+
act.result.candidateTexts = selectionTexts;
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
// Otherwise, i.e. if there are too few such elements to make a match possible:
|
|
942
|
+
else {
|
|
943
|
+
// Add the failure data to the report.
|
|
944
|
+
act.result.success = false;
|
|
945
|
+
act.result.error = 'fewer';
|
|
946
|
+
act.result.typeElementCount = selections.length;
|
|
947
|
+
act.result.message = 'Elements of specified type too few';
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
// Otherwise, i.e. if there are no elements of the specified type:
|
|
951
|
+
else {
|
|
952
|
+
// Add the failure data to the report.
|
|
953
|
+
act.result.success = false;
|
|
954
|
+
act.result.error = 'none';
|
|
955
|
+
act.result.typeElementCount = 0;
|
|
956
|
+
act.result.message = 'No elements of specified type found';
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
// Otherwise, i.e. if the page no longer exists:
|
|
960
|
+
else {
|
|
961
|
+
// Add the failure data to the report.
|
|
962
|
+
act.result.success = false;
|
|
963
|
+
act.result.error = 'gone';
|
|
964
|
+
act.result.message = 'Page gone';
|
|
965
|
+
}
|
|
966
|
+
if (! act.result.found) {
|
|
967
|
+
await wait(2000);
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
// If a match was found:
|
|
971
|
+
if (act.result.found) {
|
|
972
|
+
// FUNCTION DEFINITION START
|
|
973
|
+
// Performs a click or Enter keypress and waits for the network to be idle.
|
|
974
|
+
const doAndWait = async isClick => {
|
|
975
|
+
// Perform and report the move.
|
|
976
|
+
const move = isClick ? 'click' : 'Enter keypress';
|
|
977
|
+
try {
|
|
978
|
+
await isClick
|
|
979
|
+
? selection.click({timeout: 4000})
|
|
980
|
+
: selection.press('Enter', {timeout: 4000});
|
|
981
|
+
act.result.success = true;
|
|
982
|
+
act.result.move = move;
|
|
983
|
+
}
|
|
984
|
+
// If the move fails:
|
|
985
|
+
catch(error) {
|
|
986
|
+
// Add the error result to the act and abort the job.
|
|
987
|
+
actIndex = await addError(true, true, report, actIndex, `ERROR: ${move} failed`);
|
|
988
|
+
}
|
|
989
|
+
if (act.result.success) {
|
|
990
|
+
try {
|
|
991
|
+
await page.context().waitForEvent('networkidle', {timeout: 10000});
|
|
992
|
+
act.result.idleTimely = true;
|
|
993
|
+
}
|
|
994
|
+
catch(error) {
|
|
995
|
+
console.log(`ERROR: Network busy after ${move} (${errorStart(error)})`);
|
|
996
|
+
act.result.idleTimely = false;
|
|
997
|
+
}
|
|
998
|
+
// If the move created a new page, make it current.
|
|
999
|
+
page = currentPage;
|
|
1000
|
+
act.result.newURL = page.url();
|
|
1001
|
+
}
|
|
1002
|
+
};
|
|
1003
|
+
// FUNCTION DEFINITION END
|
|
1004
|
+
// If the move is a button click, perform it.
|
|
1005
|
+
if (act.type === 'button') {
|
|
1006
|
+
await selection.click({timeout: 3000});
|
|
1007
|
+
act.result.success = true;
|
|
1008
|
+
act.result.move = 'clicked';
|
|
1009
|
+
}
|
|
1010
|
+
// Otherwise, if it is checking a radio button or checkbox, perform it.
|
|
1011
|
+
else if (['checkbox', 'radio'].includes(act.type)) {
|
|
1012
|
+
await selection.waitForElementState('stable', {timeout: 2000})
|
|
1013
|
+
.catch(error => {
|
|
1014
|
+
console.log(`ERROR waiting for stable ${act.type} (${error.message})`);
|
|
1015
|
+
act.result.success = false;
|
|
1016
|
+
act.result.error = `ERROR waiting for stable ${act.type}`;
|
|
1017
|
+
});
|
|
1018
|
+
if (! act.result.error) {
|
|
1019
|
+
const isEnabled = await selection.isEnabled();
|
|
1020
|
+
if (isEnabled) {
|
|
1021
|
+
await selection.check({
|
|
1022
|
+
force: true,
|
|
1023
|
+
timeout: 2000
|
|
1024
|
+
})
|
|
1025
|
+
.catch(error => {
|
|
1026
|
+
console.log(`ERROR checking ${act.type} (${error.message})`);
|
|
1027
|
+
act.result.success = false;
|
|
1028
|
+
act.result.error = `ERROR checking ${act.type}`;
|
|
1029
|
+
});
|
|
1030
|
+
if (! act.result.error) {
|
|
1031
|
+
act.result.success = true;
|
|
1032
|
+
act.result.move = 'checked';
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
else {
|
|
1036
|
+
const report = `ERROR: could not check ${act.type} because disabled`;
|
|
1037
|
+
console.log(report);
|
|
1038
|
+
act.result.success = false;
|
|
1039
|
+
act.result.error = report;
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
// Otherwise, if it is focusing the element, perform it.
|
|
1044
|
+
else if (act.type === 'focus') {
|
|
1045
|
+
await selection.focus({timeout: 2000});
|
|
1046
|
+
act.result.success = true;
|
|
1047
|
+
act.result.move = 'focused';
|
|
1048
|
+
}
|
|
1049
|
+
// Otherwise, if it is clicking a link:
|
|
1050
|
+
else if (act.type === 'link') {
|
|
1051
|
+
const href = await selection.getAttribute('href');
|
|
1052
|
+
const target = await selection.getAttribute('target');
|
|
1053
|
+
act.result.href = href || 'NONE';
|
|
1054
|
+
act.result.target = target || 'DEFAULT';
|
|
1055
|
+
// If the destination is a new page:
|
|
1056
|
+
if (target && target !== '_self') {
|
|
1057
|
+
// Click the link and wait for the network to be idle.
|
|
1058
|
+
doAndWait(true);
|
|
1059
|
+
}
|
|
1060
|
+
// Otherwise, i.e. if the destination is in the current page:
|
|
1061
|
+
else {
|
|
1062
|
+
// Click the link and wait for the resulting navigation.
|
|
1063
|
+
try {
|
|
1064
|
+
await selection.click({timeout: 5000});
|
|
1065
|
+
// Wait for the new content to load.
|
|
1066
|
+
await page.waitForLoadState('domcontentloaded', {timeout: 6000});
|
|
1067
|
+
act.result.success = true;
|
|
1068
|
+
act.result.move = 'clicked';
|
|
1069
|
+
act.result.newURL = page.url();
|
|
1070
|
+
}
|
|
1071
|
+
// If the click or load failed:
|
|
1072
|
+
catch(error) {
|
|
1073
|
+
// Quit and add failure data to the report.
|
|
1074
|
+
console.log(`ERROR clicking link (${errorStart(error)})`);
|
|
1075
|
+
act.result.success = false;
|
|
1076
|
+
act.result.error = 'unclickable';
|
|
1077
|
+
act.result.message = 'ERROR: click or load timed out';
|
|
1078
|
+
actIndex = await abortActs(report, actIndex);
|
|
1079
|
+
}
|
|
1080
|
+
// If the link click succeeded:
|
|
1081
|
+
if (! act.result.error) {
|
|
1082
|
+
// Add success data to the report.
|
|
1083
|
+
act.result.success = true;
|
|
1084
|
+
act.result.move = 'clicked';
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
// Otherwise, if it is selecting an option in a select list, perform it.
|
|
1089
|
+
else if (act.type === 'select') {
|
|
1090
|
+
const options = await selection.$$('option');
|
|
1091
|
+
let optionText = '';
|
|
1092
|
+
if (options && Array.isArray(options) && options.length) {
|
|
1093
|
+
const optionTexts = [];
|
|
1094
|
+
for (const option of options) {
|
|
1095
|
+
const optionText = await option.textContent();
|
|
1096
|
+
optionTexts.push(optionText);
|
|
1097
|
+
}
|
|
1098
|
+
const matchTexts = optionTexts.map(
|
|
1099
|
+
(text, index) => text.includes(act.what) ? index : -1
|
|
1100
|
+
);
|
|
1101
|
+
const index = matchTexts.filter(text => text > -1)[act.index || 0];
|
|
1102
|
+
if (index !== undefined) {
|
|
1103
|
+
await selection.selectOption({index});
|
|
1104
|
+
optionText = optionTexts[index];
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
act.result.success = true;
|
|
1108
|
+
act.result.move = 'selected';
|
|
1109
|
+
act.result.option = optionText;
|
|
1110
|
+
}
|
|
1111
|
+
// Otherwise, if it is entering text in an input element:
|
|
1112
|
+
else if (['text', 'search'].includes(act.type)) {
|
|
1113
|
+
act.result.attributes = {};
|
|
1114
|
+
const {attributes} = act.result;
|
|
1115
|
+
const type = await selection.getAttribute('type');
|
|
1116
|
+
const label = await selection.getAttribute('aria-label');
|
|
1117
|
+
const labelRefs = await selection.getAttribute('aria-labelledby');
|
|
1118
|
+
attributes.type = type || '';
|
|
1119
|
+
attributes.label = label || '';
|
|
1120
|
+
attributes.labelRefs = labelRefs || '';
|
|
1121
|
+
// If the text contains a placeholder for an environment variable:
|
|
1122
|
+
let {what} = act;
|
|
1123
|
+
if (/__[A-Z]+__/.test(what)) {
|
|
1124
|
+
// Replace it.
|
|
1125
|
+
const envKey = /__([A-Z]+)__/.exec(what)[1];
|
|
1126
|
+
const envValue = process.env[envKey];
|
|
1127
|
+
what = what.replace(/__[A-Z]+__/, envValue);
|
|
1128
|
+
}
|
|
1129
|
+
// Enter the text.
|
|
1130
|
+
await selection.type(what);
|
|
1131
|
+
report.jobData.presses += what.length;
|
|
1132
|
+
act.result.success = true;
|
|
1133
|
+
act.result.move = 'entered';
|
|
1134
|
+
// If the input is a search input:
|
|
1135
|
+
if (act.type === 'search') {
|
|
1136
|
+
// Press the Enter key and wait for a network to be idle.
|
|
1137
|
+
doAndWait(false);
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
// Otherwise, i.e. if the move is unknown, add the failure to the act.
|
|
1141
|
+
else {
|
|
1142
|
+
// Report the error.
|
|
1143
|
+
const report = 'ERROR: move unknown';
|
|
1144
|
+
act.result.success = false;
|
|
1145
|
+
act.result.error = report;
|
|
1146
|
+
console.log(report);
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
// Otherwise, i.e. if no match was found:
|
|
1150
|
+
else {
|
|
1151
|
+
// Quit and add failure data to the report.
|
|
1152
|
+
act.result.success = false;
|
|
1153
|
+
act.result.error = 'absent';
|
|
1154
|
+
act.result.message = 'ERROR: specified element not found';
|
|
1155
|
+
console.log('ERROR: Specified element not found');
|
|
1156
|
+
actIndex = await abortActs(report, actIndex);
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
// Otherwise, if the act is a keypress:
|
|
1160
|
+
else if (act.type === 'press') {
|
|
1161
|
+
// Identify the number of times to press the key.
|
|
1162
|
+
let times = 1 + (act.again || 0);
|
|
1163
|
+
report.jobData.presses += times;
|
|
1164
|
+
const key = act.which;
|
|
1165
|
+
// Press the key.
|
|
1166
|
+
while (times--) {
|
|
1167
|
+
await page.keyboard.press(key);
|
|
1168
|
+
}
|
|
1169
|
+
const qualifier = act.again ? `${1 + act.again} times` : 'once';
|
|
1170
|
+
act.result = {
|
|
1171
|
+
success: true,
|
|
1172
|
+
message: `pressed ${qualifier}`
|
|
1173
|
+
};
|
|
1174
|
+
}
|
|
1175
|
+
// Otherwise, if it is a repetitive keyboard navigation:
|
|
1176
|
+
else if (act.type === 'presses') {
|
|
1177
|
+
const {navKey, what, which, withItems} = act;
|
|
1178
|
+
const matchTexts = which ? which.map(text => debloat(text)) : [];
|
|
1179
|
+
// Initialize the loop variables.
|
|
1180
|
+
let status = 'more';
|
|
1181
|
+
let presses = 0;
|
|
1182
|
+
let amountRead = 0;
|
|
1183
|
+
let items = [];
|
|
1184
|
+
let matchedText;
|
|
1185
|
+
// As long as a matching element has not been reached:
|
|
1186
|
+
while (status === 'more') {
|
|
1187
|
+
// Press the Escape key to dismiss any modal dialog.
|
|
1188
|
+
await page.keyboard.press('Escape');
|
|
1189
|
+
// Press the specified navigation key.
|
|
1190
|
+
await page.keyboard.press(navKey);
|
|
1191
|
+
presses++;
|
|
1192
|
+
// Identify the newly current element or a failure.
|
|
1193
|
+
const currentJSHandle = await page.evaluateHandle(actCount => {
|
|
1194
|
+
// Initialize it as the focused element.
|
|
1195
|
+
let currentElement = document.activeElement;
|
|
1196
|
+
// If it exists in the page:
|
|
1197
|
+
if (currentElement && currentElement.tagName !== 'BODY') {
|
|
1198
|
+
// Change it, if necessary, to its active descendant.
|
|
1199
|
+
if (currentElement.hasAttribute('aria-activedescendant')) {
|
|
1200
|
+
currentElement = document.getElementById(
|
|
1201
|
+
currentElement.getAttribute('aria-activedescendant')
|
|
1202
|
+
);
|
|
1203
|
+
}
|
|
1204
|
+
// Or change it, if necessary, to its selected option.
|
|
1205
|
+
else if (currentElement.tagName === 'SELECT') {
|
|
1206
|
+
const currentIndex = Math.max(0, currentElement.selectedIndex);
|
|
1207
|
+
const options = currentElement.querySelectorAll('option');
|
|
1208
|
+
currentElement = options[currentIndex];
|
|
1209
|
+
}
|
|
1210
|
+
// Or change it, if necessary, to its active shadow-DOM element.
|
|
1211
|
+
else if (currentElement.shadowRoot) {
|
|
1212
|
+
currentElement = currentElement.shadowRoot.activeElement;
|
|
1213
|
+
}
|
|
1214
|
+
// If there is a current element:
|
|
1215
|
+
if (currentElement) {
|
|
1216
|
+
// If it was already reached within this act:
|
|
1217
|
+
if (currentElement.dataset.pressesReached === actCount.toString(10)) {
|
|
1218
|
+
// Report the error.
|
|
1219
|
+
console.log(`ERROR: ${currentElement.tagName} element reached again`);
|
|
1220
|
+
status = 'ERROR';
|
|
1221
|
+
return 'ERROR: locallyExhausted';
|
|
1222
|
+
}
|
|
1223
|
+
// Otherwise, i.e. if it is newly reached within this act:
|
|
1224
|
+
else {
|
|
1225
|
+
// Mark and return it.
|
|
1226
|
+
currentElement.dataset.pressesReached = actCount;
|
|
1227
|
+
return currentElement;
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
// Otherwise, i.e. if there is no current element:
|
|
1231
|
+
else {
|
|
1232
|
+
// Report the error.
|
|
1233
|
+
status = 'ERROR';
|
|
1234
|
+
return 'noActiveElement';
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
// Otherwise, i.e. if there is no focus in the page:
|
|
1238
|
+
else {
|
|
1239
|
+
// Report the error.
|
|
1240
|
+
status = 'ERROR';
|
|
1241
|
+
return 'ERROR: globallyExhausted';
|
|
1242
|
+
}
|
|
1243
|
+
}, actCount);
|
|
1244
|
+
// If the current element exists:
|
|
1245
|
+
const currentElement = currentJSHandle.asElement();
|
|
1246
|
+
if (currentElement) {
|
|
1247
|
+
// Update the data.
|
|
1248
|
+
const tagNameJSHandle = await currentElement.getProperty('tagName');
|
|
1249
|
+
const tagName = await tagNameJSHandle.jsonValue();
|
|
1250
|
+
const text = await textOf(page, currentElement);
|
|
1251
|
+
// If the text of the current element was found:
|
|
1252
|
+
if (text !== null) {
|
|
1253
|
+
const textLength = text.length;
|
|
1254
|
+
// If it is non-empty and there are texts to match:
|
|
1255
|
+
if (matchTexts.length && textLength) {
|
|
1256
|
+
// Identify the matching text.
|
|
1257
|
+
matchedText = matchTexts.find(matchText => text.includes(matchText));
|
|
1258
|
+
}
|
|
1259
|
+
// Update the item data if required.
|
|
1260
|
+
if (withItems) {
|
|
1261
|
+
const itemData = {
|
|
1262
|
+
tagName,
|
|
1263
|
+
text,
|
|
1264
|
+
textLength
|
|
1265
|
+
};
|
|
1266
|
+
if (matchedText) {
|
|
1267
|
+
itemData.matchedText = matchedText;
|
|
1268
|
+
}
|
|
1269
|
+
items.push(itemData);
|
|
1270
|
+
}
|
|
1271
|
+
amountRead += textLength;
|
|
1272
|
+
// If there is no text-match failure:
|
|
1273
|
+
if (matchedText || ! matchTexts.length) {
|
|
1274
|
+
// If the element has any specified tag name:
|
|
1275
|
+
if (! what || tagName === what) {
|
|
1276
|
+
// Change the status.
|
|
1277
|
+
status = 'done';
|
|
1278
|
+
// Perform the action.
|
|
1279
|
+
const inputText = act.text;
|
|
1280
|
+
if (inputText) {
|
|
1281
|
+
await page.keyboard.type(inputText);
|
|
1282
|
+
presses += inputText.length;
|
|
1283
|
+
}
|
|
1284
|
+
if (act.action) {
|
|
1285
|
+
presses++;
|
|
1286
|
+
await page.keyboard.press(act.action);
|
|
1287
|
+
await page.waitForLoadState();
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
else {
|
|
1293
|
+
status = 'ERROR';
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
// Otherwise, i.e. if there was a failure:
|
|
1297
|
+
else {
|
|
1298
|
+
// Update the status.
|
|
1299
|
+
status = await currentJSHandle.jsonValue();
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
// Add the result to the act.
|
|
1303
|
+
act.result = {
|
|
1304
|
+
success: true,
|
|
1305
|
+
status,
|
|
1306
|
+
totals: {
|
|
1307
|
+
presses,
|
|
1308
|
+
amountRead
|
|
1309
|
+
}
|
|
1310
|
+
};
|
|
1311
|
+
if (status === 'done' && matchedText) {
|
|
1312
|
+
act.result.matchedText = matchedText;
|
|
1313
|
+
}
|
|
1314
|
+
if (withItems) {
|
|
1315
|
+
act.result.items = items;
|
|
1316
|
+
}
|
|
1317
|
+
// Add the totals to the report.
|
|
1318
|
+
report.jobData.presses += presses;
|
|
1319
|
+
report.jobData.amountRead += amountRead;
|
|
1320
|
+
}
|
|
1321
|
+
// Otherwise, i.e. if the act type is unknown:
|
|
1322
|
+
else {
|
|
1323
|
+
// Add the error result to the act and abort the job.
|
|
1324
|
+
actIndex = await addError(true, true, report, actIndex, 'ERROR: Invalid act type');
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
// Otherwise, a page URL is required but does not exist, so:
|
|
1328
|
+
else {
|
|
1329
|
+
// Add an error result to the act and abort the job.
|
|
1330
|
+
actIndex = await addError(true, true, report, actIndex, 'ERROR: Page has no URL');
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
// Otherwise, i.e. if no page exists:
|
|
1334
|
+
else {
|
|
1335
|
+
// Add an error result to the act and abort the job.
|
|
1336
|
+
actIndex = await addError(true, true, report, actIndex, 'ERROR: No page identified');
|
|
1337
|
+
}
|
|
1338
|
+
act.endTime = Date.now();
|
|
1339
|
+
// Perform any remaining acts if not aborted.
|
|
1340
|
+
await doActs(report, actIndex + 1, page);
|
|
1341
|
+
}
|
|
1342
|
+
// Otherwise, if all acts have been performed and the job succeeded:
|
|
1343
|
+
else if (! report.jobData.abortTime) {
|
|
1344
|
+
console.log('Acts completed');
|
|
1345
|
+
await browserClose();
|
|
1346
|
+
}
|
|
1347
|
+
};
|
|
1348
|
+
/*
|
|
1349
|
+
Returns whether an initialized job report is valid and, if so, runs the job and adds the results
|
|
1350
|
+
to the report.
|
|
1351
|
+
*/
|
|
1352
|
+
exports.doJob = async report => {
|
|
1353
|
+
// If the report is valid:
|
|
1354
|
+
report.jobData = {};
|
|
1355
|
+
const {jobData} = report;
|
|
1356
|
+
const reportInvalidity = isValidJob(report);
|
|
1357
|
+
if (reportInvalidity) {
|
|
1358
|
+
console.log(`ERROR: ${reportInvalidity}`);
|
|
1359
|
+
jobData.aborted = true;
|
|
1360
|
+
jobData.abortedAct = null;
|
|
1361
|
+
jobData.abortError = reportInvalidity;
|
|
1362
|
+
}
|
|
1363
|
+
else {
|
|
1364
|
+
// Add initialized job data to the report.
|
|
1365
|
+
const startTime = new Date();
|
|
1366
|
+
report.jobData.startTime = nowString();
|
|
1367
|
+
report.jobData.endTime = '';
|
|
1368
|
+
report.jobData.elapsedSeconds = 0;
|
|
1369
|
+
report.jobData.visitLatency = 0;
|
|
1370
|
+
report.jobData.logCount = 0;
|
|
1371
|
+
report.jobData.logSize = 0;
|
|
1372
|
+
report.jobData.errorLogCount = 0;
|
|
1373
|
+
report.jobData.errorLogSize = 0;
|
|
1374
|
+
report.jobData.prohibitedCount = 0;
|
|
1375
|
+
report.jobData.visitRejectionCount = 0;
|
|
1376
|
+
report.jobData.aborted = false;
|
|
1377
|
+
report.jobData.abortedAct = null;
|
|
1378
|
+
report.jobData.presses = 0;
|
|
1379
|
+
report.jobData.amountRead = 0;
|
|
1380
|
+
report.jobData.toolTimes = {};
|
|
1381
|
+
report.jobData.preventions = {};
|
|
1382
|
+
process.on('message', message => {
|
|
1383
|
+
if (message === 'interrupt') {
|
|
1384
|
+
console.log('ERROR: Terminal interrupted the job');
|
|
1385
|
+
process.exit();
|
|
1386
|
+
}
|
|
1387
|
+
});
|
|
1388
|
+
// Recursively perform the acts.
|
|
1389
|
+
await doActs(report, 0, null);
|
|
1390
|
+
// Add the end time and duration to the report.
|
|
1391
|
+
const endTime = new Date();
|
|
1392
|
+
report.jobData.endTime = nowString();
|
|
1393
|
+
report.jobData.elapsedSeconds = Math.floor((endTime - startTime) / 1000);
|
|
1394
|
+
// Consolidate and sort the tool times.
|
|
1395
|
+
const {toolTimes} = report.jobData;
|
|
1396
|
+
const toolTimeData = Object
|
|
1397
|
+
.keys(toolTimes)
|
|
1398
|
+
.sort((a, b) => toolTimes[b] - toolTimes[a])
|
|
1399
|
+
.map(tool => [tool, toolTimes[tool]]);
|
|
1400
|
+
report.jobData.toolTimes = {};
|
|
1401
|
+
toolTimeData.forEach(item => {
|
|
1402
|
+
report.jobData.toolTimes[item[0]] = item[1];
|
|
1403
|
+
});
|
|
1404
|
+
}
|
|
1405
|
+
};
|