testaro 22.0.0 → 23.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/README.md +27 -15
- package/actSpecs.js +4 -3
- package/dirWatch.js +1 -1
- package/package.json +1 -1
- package/procs/nav.js +220 -0
- package/procs/testaro.js +2 -2
- package/procs/visChange.js +1 -0
- package/run.js +494 -684
- package/testaro/bulk.js +7 -4
- package/tests/testaro.js +10 -8
- /package/{standardize.js → procs/standardize.js} +0 -0
package/run.js
CHANGED
|
@@ -9,10 +9,10 @@
|
|
|
9
9
|
require('dotenv').config();
|
|
10
10
|
// Requirements for acts.
|
|
11
11
|
const {actSpecs} = require('./actSpecs');
|
|
12
|
+
// Navigation.
|
|
13
|
+
const {browserClose, goTo, launch} = require('./procs/nav');
|
|
12
14
|
// Module to standardize report formats.
|
|
13
|
-
const {standardize} = require('./standardize');
|
|
14
|
-
// Playwright package.
|
|
15
|
-
const playwright = require('playwright');
|
|
15
|
+
const {standardize} = require('./procs/standardize');
|
|
16
16
|
|
|
17
17
|
// ########## CONSTANTS
|
|
18
18
|
|
|
@@ -33,7 +33,7 @@ const moves = {
|
|
|
33
33
|
select: 'select',
|
|
34
34
|
text: 'input'
|
|
35
35
|
};
|
|
36
|
-
// Names and descriptions of
|
|
36
|
+
// Names and descriptions of tools.
|
|
37
37
|
const tests = {
|
|
38
38
|
alfa: 'alfa',
|
|
39
39
|
axe: 'Axe',
|
|
@@ -45,44 +45,15 @@ const tests = {
|
|
|
45
45
|
testaro: 'Testaro',
|
|
46
46
|
wave: 'WAVE',
|
|
47
47
|
};
|
|
48
|
-
// Browser types available in PlayWright.
|
|
49
|
-
const browserTypeNames = {
|
|
50
|
-
'chromium': 'Chrome',
|
|
51
|
-
'webkit': 'Safari',
|
|
52
|
-
'firefox': 'Firefox'
|
|
53
|
-
};
|
|
54
48
|
// Items that may be waited for.
|
|
55
49
|
const waitables = ['url', 'title', 'body'];
|
|
56
|
-
// Strings in log messages indicating errors.
|
|
57
|
-
const errorWords = [
|
|
58
|
-
'but not used',
|
|
59
|
-
'content security policy',
|
|
60
|
-
'deprecated',
|
|
61
|
-
'error',
|
|
62
|
-
'exception',
|
|
63
|
-
'expected',
|
|
64
|
-
'failed',
|
|
65
|
-
'invalid',
|
|
66
|
-
'missing',
|
|
67
|
-
'non-standard',
|
|
68
|
-
'not supported',
|
|
69
|
-
'refused',
|
|
70
|
-
'requires',
|
|
71
|
-
'sorry',
|
|
72
|
-
'suspicious',
|
|
73
|
-
'unrecognized',
|
|
74
|
-
'violates',
|
|
75
|
-
'warning'
|
|
76
|
-
];
|
|
77
50
|
|
|
78
51
|
// ########## VARIABLES
|
|
79
52
|
|
|
80
53
|
// Facts about the current session.
|
|
81
54
|
let actCount = 0;
|
|
82
55
|
// Facts about the current browser.
|
|
83
|
-
let browser;
|
|
84
56
|
let browserContext;
|
|
85
|
-
let browserTypeName;
|
|
86
57
|
let currentPage;
|
|
87
58
|
let requestedURL = '';
|
|
88
59
|
|
|
@@ -238,14 +209,11 @@ const isValidReport = report => {
|
|
|
238
209
|
if (acts[0].type !== 'launch') {
|
|
239
210
|
return 'First act type not launch';
|
|
240
211
|
}
|
|
241
|
-
if (acts[1].type !== 'url') {
|
|
242
|
-
return 'Second act type not url';
|
|
243
|
-
}
|
|
244
212
|
if (! ['chromium', 'webkit', 'firefox'].includes(acts[0].which)) {
|
|
245
213
|
return 'Bad first act which';
|
|
246
214
|
}
|
|
247
|
-
if (! acts[
|
|
248
|
-
return '
|
|
215
|
+
if (! acts[0].url || typeof acts[0].url !== 'string' || ! isURL(acts[0].url)) {
|
|
216
|
+
return 'First act url not a URL';
|
|
249
217
|
}
|
|
250
218
|
const invalidAct = acts.find(act => ! isValidAct(act));
|
|
251
219
|
if (invalidAct) {
|
|
@@ -278,108 +246,8 @@ const isValidReport = report => {
|
|
|
278
246
|
|
|
279
247
|
// Returns a string representing the date and time.
|
|
280
248
|
const nowString = () => (new Date()).toISOString().slice(0, 19);
|
|
281
|
-
// Closes the current browser.
|
|
282
|
-
const browserClose = async () => {
|
|
283
|
-
if (browser) {
|
|
284
|
-
const browserType = browser.browserType().name();
|
|
285
|
-
let contexts = browser.contexts();
|
|
286
|
-
for (const context of contexts) {
|
|
287
|
-
await context.close();
|
|
288
|
-
contexts = browser.contexts();
|
|
289
|
-
}
|
|
290
|
-
await browser.close();
|
|
291
|
-
browser = null;
|
|
292
|
-
console.log(`${browserType} browser closed`);
|
|
293
|
-
}
|
|
294
|
-
};
|
|
295
249
|
// Returns the first line of an error message.
|
|
296
250
|
const errorStart = error => error.message.replace(/\n.+/s, '');
|
|
297
|
-
// Launches a browser.
|
|
298
|
-
const launch = async (report, typeName, lowMotion = false) => {
|
|
299
|
-
const browserType = playwright[typeName];
|
|
300
|
-
// If the specified browser type exists:
|
|
301
|
-
if (browserType) {
|
|
302
|
-
// Close the current browser, if any.
|
|
303
|
-
await browserClose();
|
|
304
|
-
// Launch a browser of that type.
|
|
305
|
-
const browserOptions = {
|
|
306
|
-
logger: {
|
|
307
|
-
isEnabled: () => false,
|
|
308
|
-
log: (name, severity, message) => console.log(message.slice(0, 100))
|
|
309
|
-
}
|
|
310
|
-
};
|
|
311
|
-
if (debug) {
|
|
312
|
-
browserOptions.headless = false;
|
|
313
|
-
}
|
|
314
|
-
if (waits) {
|
|
315
|
-
browserOptions.slowMo = waits;
|
|
316
|
-
}
|
|
317
|
-
let healthy = true;
|
|
318
|
-
browser = await browserType.launch(browserOptions)
|
|
319
|
-
.catch(error => {
|
|
320
|
-
healthy = false;
|
|
321
|
-
console.log(`ERROR launching browser (${errorStart(error)})`);
|
|
322
|
-
});
|
|
323
|
-
// If the launch succeeded:
|
|
324
|
-
if (healthy) {
|
|
325
|
-
// Open a context (i.e. browser tab), with reduced motion if specified.
|
|
326
|
-
const options = {reduceMotion: lowMotion ? 'reduce' : 'no-preference'};
|
|
327
|
-
browserContext = await browser.newContext(options);
|
|
328
|
-
// When a page (i.e. browser tab) is added to the browser context (i.e. browser window):
|
|
329
|
-
browserContext.on('page', async page => {
|
|
330
|
-
// Make the page current.
|
|
331
|
-
currentPage = page;
|
|
332
|
-
// If it emits a message:
|
|
333
|
-
page.on('console', msg => {
|
|
334
|
-
const msgText = msg.text();
|
|
335
|
-
let indentedMsg = '';
|
|
336
|
-
// If debugging is on:
|
|
337
|
-
if (debug) {
|
|
338
|
-
// Log a summary of the message on the console.
|
|
339
|
-
const parts = [msgText.slice(0, 75)];
|
|
340
|
-
if (msgText.length > 75) {
|
|
341
|
-
parts.push(msgText.slice(75, 150));
|
|
342
|
-
if (msgText.length > 150) {
|
|
343
|
-
const tail = msgText.slice(150).slice(-150);
|
|
344
|
-
if (msgText.length > 300) {
|
|
345
|
-
parts.push('...');
|
|
346
|
-
}
|
|
347
|
-
parts.push(tail.slice(0, 75));
|
|
348
|
-
if (tail.length > 75) {
|
|
349
|
-
parts.push(tail.slice(75));
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
indentedMsg = parts.map(part => ` | ${part}`).join('\n');
|
|
354
|
-
console.log(`\n${indentedMsg}`);
|
|
355
|
-
}
|
|
356
|
-
// Add statistics on the message to the report.
|
|
357
|
-
const msgTextLC = msgText.toLowerCase();
|
|
358
|
-
const msgLength = msgText.length;
|
|
359
|
-
report.jobData.logCount++;
|
|
360
|
-
report.jobData.logSize += msgLength;
|
|
361
|
-
if (errorWords.some(word => msgTextLC.includes(word))) {
|
|
362
|
-
report.jobData.errorLogCount++;
|
|
363
|
-
report.jobData.errorLogSize += msgLength;
|
|
364
|
-
}
|
|
365
|
-
const msgLC = msgText.toLowerCase();
|
|
366
|
-
if (
|
|
367
|
-
msgText.includes('403') && (msgLC.includes('status')
|
|
368
|
-
|| msgLC.includes('prohibited'))
|
|
369
|
-
) {
|
|
370
|
-
report.jobData.prohibitedCount++;
|
|
371
|
-
}
|
|
372
|
-
});
|
|
373
|
-
});
|
|
374
|
-
// Open the first page of the context.
|
|
375
|
-
currentPage = await browserContext.newPage();
|
|
376
|
-
// Wait until it is stable.
|
|
377
|
-
await currentPage.waitForLoadState('domcontentloaded', {timeout: 15000});
|
|
378
|
-
// Update the name of the current browser type and store it in the page.
|
|
379
|
-
currentPage.browserTypeName = browserTypeName = typeName;
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
};
|
|
383
251
|
// Normalizes spacing characters and cases in a string.
|
|
384
252
|
const debloat = string => string.replace(/\s/g, ' ').trim().replace(/ {2,}/g, ' ').toLowerCase();
|
|
385
253
|
// Returns the text of an element, lower-cased.
|
|
@@ -473,8 +341,6 @@ const textOf = async (page, element) => {
|
|
|
473
341
|
return null;
|
|
474
342
|
}
|
|
475
343
|
};
|
|
476
|
-
// Returns a string with any final slash removed.
|
|
477
|
-
const deSlash = string => string.endsWith('/') ? string.slice(0, -1) : string;
|
|
478
344
|
// Returns a property value and whether it satisfies an expectation.
|
|
479
345
|
const isTrue = (object, specs) => {
|
|
480
346
|
const property = specs[0];
|
|
@@ -557,56 +423,6 @@ const addError = (act, error, message) => {
|
|
|
557
423
|
act.result.prevented = true;
|
|
558
424
|
}
|
|
559
425
|
};
|
|
560
|
-
// Visits a URL and returns the response of the server.
|
|
561
|
-
const goTo = async (report, page, url, timeout, waitUntil, isStrict) => {
|
|
562
|
-
if (url.startsWith('file://')) {
|
|
563
|
-
url = url.replace('file://', `file://${__dirname}/`);
|
|
564
|
-
}
|
|
565
|
-
// Visit the URL.
|
|
566
|
-
const startTime = Date.now();
|
|
567
|
-
try {
|
|
568
|
-
const response = await page.goto(url, {
|
|
569
|
-
timeout,
|
|
570
|
-
waitUntil
|
|
571
|
-
});
|
|
572
|
-
report.jobData.visitLatency += Math.round((Date.now() - startTime) / 1000);
|
|
573
|
-
const httpStatus = response.status();
|
|
574
|
-
// If the response status was normal:
|
|
575
|
-
if ([200, 304].includes(httpStatus) || url.startsWith('file:')) {
|
|
576
|
-
// If the browser was redirected in violation of a strictness requirement:
|
|
577
|
-
const actualURL = page.url();
|
|
578
|
-
if (isStrict && deSlash(actualURL) !== deSlash(url)) {
|
|
579
|
-
// Return an error.
|
|
580
|
-
console.log(`ERROR: Visit to ${url} redirected to ${actualURL}`);
|
|
581
|
-
return {
|
|
582
|
-
exception: 'badRedirection'
|
|
583
|
-
};
|
|
584
|
-
}
|
|
585
|
-
// Otherwise, i.e. if no prohibited redirection occurred:
|
|
586
|
-
else {
|
|
587
|
-
// Press the Escape key to dismiss any modal dialog.
|
|
588
|
-
await page.keyboard.press('Escape');
|
|
589
|
-
// Return the response.
|
|
590
|
-
return response;
|
|
591
|
-
}
|
|
592
|
-
}
|
|
593
|
-
// Otherwise, i.e. if the response status was abnormal:
|
|
594
|
-
else {
|
|
595
|
-
// Return an error.
|
|
596
|
-
console.log(`ERROR: Visit to ${url} got status ${httpStatus}`);
|
|
597
|
-
report.jobData.visitRejectionCount++;
|
|
598
|
-
return {
|
|
599
|
-
error: 'badStatus'
|
|
600
|
-
};
|
|
601
|
-
}
|
|
602
|
-
}
|
|
603
|
-
catch(error) {
|
|
604
|
-
console.log(`ERROR visiting ${url} (${error.message.slice(0, 200)})`);
|
|
605
|
-
return {
|
|
606
|
-
error: 'noVisit'
|
|
607
|
-
};
|
|
608
|
-
}
|
|
609
|
-
};
|
|
610
426
|
// Recursively performs the acts in a report.
|
|
611
427
|
const doActs = async (report, actIndex, page) => {
|
|
612
428
|
// Quits and reports the performance being aborted.
|
|
@@ -678,53 +494,41 @@ const doActs = async (report, actIndex, page) => {
|
|
|
678
494
|
}
|
|
679
495
|
// Otherwise, if the act is a launch:
|
|
680
496
|
else if (act.type === 'launch') {
|
|
681
|
-
// Launch the specified browser
|
|
682
|
-
await launch(
|
|
683
|
-
|
|
684
|
-
|
|
497
|
+
// Launch the specified browser and navigate to the specified URL.
|
|
498
|
+
const browserData = await launch(
|
|
499
|
+
report, act.which, act.url, debug, waits, act.lowMotion ? 'reduce' : 'no-preference'
|
|
500
|
+
);
|
|
501
|
+
// If the launch and navigation succeeded:
|
|
502
|
+
if (browserData) {
|
|
503
|
+
// Save the browser data.
|
|
504
|
+
browserContext = browserData.browserContext;
|
|
505
|
+
currentPage = browserData.currentPage;
|
|
506
|
+
page = currentPage;
|
|
507
|
+
// Add the actual URL to the act.
|
|
508
|
+
act.actualURL = page.url();
|
|
509
|
+
}
|
|
510
|
+
// Otherwise, i.e. if the launch or navigation failed:
|
|
511
|
+
else {
|
|
512
|
+
// Abort the job.
|
|
513
|
+
await abortActs();
|
|
514
|
+
}
|
|
685
515
|
}
|
|
686
516
|
// Otherwise, if a current page exists:
|
|
687
517
|
else if (page) {
|
|
688
|
-
// If the act is a url:
|
|
518
|
+
// If the act is navigation to a url:
|
|
689
519
|
if (act.type === 'url') {
|
|
690
520
|
// Identify the URL.
|
|
691
521
|
const resolved = act.which.replace('__dirname', __dirname);
|
|
692
522
|
requestedURL = resolved;
|
|
693
|
-
const {strict} = report;
|
|
694
523
|
// Visit it and wait until the DOM is loaded.
|
|
695
|
-
|
|
696
|
-
// If the visit
|
|
697
|
-
if (response.error) {
|
|
698
|
-
// Launch another browser type.
|
|
699
|
-
const newBrowserName = Object.keys(browserTypeNames)
|
|
700
|
-
.find(name => name !== browserTypeName);
|
|
701
|
-
console.log(`>> Launching ${newBrowserName} instead`);
|
|
702
|
-
await launch(newBrowserName);
|
|
703
|
-
// Identify its only page as current.
|
|
704
|
-
page = browserContext.pages()[0];
|
|
705
|
-
// Visit the URL and wait until the DOM is loaded.
|
|
706
|
-
response = await goTo(report, page, requestedURL, 10000, 'domcontentloaded', strict);
|
|
707
|
-
// If the visit fails:
|
|
708
|
-
if (response.error) {
|
|
709
|
-
// Try again and wait until a load.
|
|
710
|
-
response = await goTo(report, page, requestedURL, 5000, 'load', strict);
|
|
711
|
-
// If the visit fails:
|
|
712
|
-
if (response.error) {
|
|
713
|
-
// Navigate to a blank page instead.
|
|
714
|
-
await page.goto('about:blank')
|
|
715
|
-
.catch(error => {
|
|
716
|
-
console.log(`ERROR: Navigation to blank page failed (${error.message})`);
|
|
717
|
-
});
|
|
718
|
-
}
|
|
719
|
-
}
|
|
720
|
-
}
|
|
721
|
-
// If none of the visits succeeded:
|
|
524
|
+
const response = await goTo(report, page, requestedURL, 15000, 'domcontentloaded');
|
|
525
|
+
// If the visit failed:
|
|
722
526
|
if (response.error) {
|
|
723
527
|
// Report this and quit.
|
|
724
528
|
addError(act, 'failure', 'ERROR: Visits failed');
|
|
725
529
|
await abortActs();
|
|
726
530
|
}
|
|
727
|
-
// Otherwise, i.e. if the
|
|
531
|
+
// Otherwise, i.e. if the visit succeeded:
|
|
728
532
|
else {
|
|
729
533
|
// If a prohibited redirection occurred:
|
|
730
534
|
if (response.exception === 'badRedirection') {
|
|
@@ -845,535 +649,531 @@ const doActs = async (report, actIndex, page) => {
|
|
|
845
649
|
// Otherwise, if the page has a URL:
|
|
846
650
|
else if (page.url() && page.url() !== 'about:blank') {
|
|
847
651
|
const url = page.url();
|
|
848
|
-
//
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
) {
|
|
852
|
-
//
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
652
|
+
// Add the URL to the act.
|
|
653
|
+
act.actualURL = url;
|
|
654
|
+
// If the act is a revelation:
|
|
655
|
+
if (act.type === 'reveal') {
|
|
656
|
+
// Make all elements in the page visible.
|
|
657
|
+
await require('./procs/allVis').allVis(page);
|
|
658
|
+
act.result = {
|
|
659
|
+
success: true
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
// Otherwise, if the act performs tests of a tool:
|
|
663
|
+
else if (act.type === 'test') {
|
|
664
|
+
// Add a description of the test to the act.
|
|
665
|
+
act.what = tests[act.which];
|
|
666
|
+
// Initialize the options argument.
|
|
667
|
+
const options = {};
|
|
668
|
+
// Add any specified arguments to it.
|
|
669
|
+
Object.keys(act).forEach(key => {
|
|
670
|
+
if (! ['type', 'which'].includes(key)) {
|
|
671
|
+
options[key] = act[key];
|
|
672
|
+
}
|
|
673
|
+
});
|
|
674
|
+
// Initialize the test report.
|
|
675
|
+
const startTime = Date.now();
|
|
676
|
+
let toolReport = {
|
|
677
|
+
result: {
|
|
678
|
+
success: false
|
|
679
|
+
}
|
|
680
|
+
};
|
|
681
|
+
// Perform the specified tests of the tool and get a report.
|
|
682
|
+
try {
|
|
683
|
+
const args = [page, options];
|
|
684
|
+
toolReport = await require(`./tests/${act.which}`).reporter(... args);
|
|
685
|
+
toolReport.result.success = true;
|
|
861
686
|
}
|
|
862
|
-
//
|
|
863
|
-
|
|
864
|
-
//
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
687
|
+
// If the testing failed:
|
|
688
|
+
catch(error) {
|
|
689
|
+
// Report this but do not abort the job.
|
|
690
|
+
console.log(`ERROR: Test act ${act.which} failed (${error.message.slice(0, 400)})`);
|
|
691
|
+
}
|
|
692
|
+
// Add the elapsed time to the report.
|
|
693
|
+
const time = Math.round((Date.now() - startTime) / 1000);
|
|
694
|
+
const {toolTimes} = report.jobData;
|
|
695
|
+
if (! toolTimes[act.which]) {
|
|
696
|
+
toolTimes[act.which] = 0;
|
|
697
|
+
}
|
|
698
|
+
toolTimes[act.which] += time;
|
|
699
|
+
// Add the result object (possibly an array) to the act.
|
|
700
|
+
const resultCount = Object.keys(toolReport.result).length;
|
|
701
|
+
act.result = resultCount ? toolReport.result : {success: false};
|
|
702
|
+
// If a standard-format result is to be included in the report:
|
|
703
|
+
const standard = process.env.STANDARD;
|
|
704
|
+
if (['also', 'only'].includes(standard)) {
|
|
705
|
+
// Initialize it.
|
|
706
|
+
act.standardResult = {
|
|
707
|
+
totals: [],
|
|
708
|
+
instances: []
|
|
880
709
|
};
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
710
|
+
// Populate it.
|
|
711
|
+
standardize(act);
|
|
712
|
+
// If the original-format result is not to be included in the report:
|
|
713
|
+
if (standard === 'only') {
|
|
714
|
+
// Remove it.
|
|
715
|
+
delete act.result;
|
|
885
716
|
}
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
totals: [],
|
|
902
|
-
instances: []
|
|
903
|
-
};
|
|
904
|
-
// Populate it.
|
|
905
|
-
standardize(act);
|
|
906
|
-
// If the original-format result is not to be included in the report:
|
|
907
|
-
if (standard === 'only') {
|
|
908
|
-
// Remove it.
|
|
909
|
-
delete act.result;
|
|
910
|
-
}
|
|
911
|
-
const expectations = act.expect;
|
|
912
|
-
// If the test has expectations:
|
|
913
|
-
if (expectations) {
|
|
914
|
-
// Initialize whether they were fulfilled.
|
|
915
|
-
act.expectations = [];
|
|
916
|
-
let failureCount = 0;
|
|
917
|
-
// For each expectation:
|
|
918
|
-
expectations.forEach(spec => {
|
|
919
|
-
const truth = isTrue(act, spec);
|
|
920
|
-
act.expectations.push({
|
|
921
|
-
property: spec[0],
|
|
922
|
-
relation: spec[1],
|
|
923
|
-
criterion: spec[2],
|
|
924
|
-
actual: truth[0],
|
|
925
|
-
passed: truth[1]
|
|
926
|
-
});
|
|
927
|
-
if (! truth[1]) {
|
|
928
|
-
failureCount++;
|
|
929
|
-
}
|
|
717
|
+
// If the test has expectations:
|
|
718
|
+
const expectations = act.expect;
|
|
719
|
+
if (expectations) {
|
|
720
|
+
// Initialize whether they were fulfilled.
|
|
721
|
+
act.expectations = [];
|
|
722
|
+
let failureCount = 0;
|
|
723
|
+
// For each expectation:
|
|
724
|
+
expectations.forEach(spec => {
|
|
725
|
+
const truth = isTrue(act, spec);
|
|
726
|
+
act.expectations.push({
|
|
727
|
+
property: spec[0],
|
|
728
|
+
relation: spec[1],
|
|
729
|
+
criterion: spec[2],
|
|
730
|
+
actual: truth[0],
|
|
731
|
+
passed: truth[1]
|
|
930
732
|
});
|
|
931
|
-
|
|
932
|
-
|
|
733
|
+
if (! truth[1]) {
|
|
734
|
+
failureCount++;
|
|
735
|
+
}
|
|
736
|
+
});
|
|
737
|
+
act.expectationFailures = failureCount;
|
|
933
738
|
}
|
|
934
739
|
}
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
}
|
|
969
|
-
}
|
|
970
|
-
// If no element satisfied the specifications:
|
|
971
|
-
if (! act.result.found) {
|
|
972
|
-
// Add the failure data to the report.
|
|
973
|
-
act.result.success = false;
|
|
974
|
-
act.result.error = 'exhausted';
|
|
975
|
-
act.result.typeElementCount = selections.length;
|
|
976
|
-
if (slimText) {
|
|
977
|
-
act.result.textElementCount = --matchCount;
|
|
740
|
+
}
|
|
741
|
+
// Otherwise, if the act is a move:
|
|
742
|
+
else if (moves[act.type]) {
|
|
743
|
+
const selector = typeof moves[act.type] === 'string' ? moves[act.type] : act.what;
|
|
744
|
+
// Try up to 5 times to:
|
|
745
|
+
act.result = {found: false};
|
|
746
|
+
let selection = {};
|
|
747
|
+
let tries = 0;
|
|
748
|
+
const slimText = act.which ? debloat(act.which) : '';
|
|
749
|
+
while (tries++ < 5 && ! act.result.found) {
|
|
750
|
+
if (page) {
|
|
751
|
+
// Identify the elements of the specified type.
|
|
752
|
+
const selections = await page.$$(selector);
|
|
753
|
+
// If there are any:
|
|
754
|
+
if (selections.length) {
|
|
755
|
+
// If there are enough to make a match possible:
|
|
756
|
+
if ((act.index || 0) < selections.length) {
|
|
757
|
+
// For each element of the specified type:
|
|
758
|
+
let matchCount = 0;
|
|
759
|
+
const selectionTexts = [];
|
|
760
|
+
for (selection of selections) {
|
|
761
|
+
// Add its lower-case text or an empty string to the list of element texts.
|
|
762
|
+
const selectionText = slimText ? await textOf(page, selection) : '';
|
|
763
|
+
selectionTexts.push(selectionText);
|
|
764
|
+
// If its text includes any specified text, case-insensitively:
|
|
765
|
+
if (selectionText.includes(slimText)) {
|
|
766
|
+
// If the element has the specified index among such elements:
|
|
767
|
+
if (matchCount++ === (act.index || 0)) {
|
|
768
|
+
// Report it as the matching element and stop checking.
|
|
769
|
+
act.result.found = true;
|
|
770
|
+
act.result.textSpec = slimText;
|
|
771
|
+
act.result.textContent = selectionText;
|
|
772
|
+
break;
|
|
978
773
|
}
|
|
979
|
-
act.result.message = 'Not enough specified elements exist';
|
|
980
|
-
act.result.candidateTexts = selectionTexts;
|
|
981
774
|
}
|
|
982
775
|
}
|
|
983
|
-
//
|
|
984
|
-
|
|
776
|
+
// If no element satisfied the specifications:
|
|
777
|
+
if (! act.result.found) {
|
|
985
778
|
// Add the failure data to the report.
|
|
986
779
|
act.result.success = false;
|
|
987
|
-
act.result.error = '
|
|
780
|
+
act.result.error = 'exhausted';
|
|
988
781
|
act.result.typeElementCount = selections.length;
|
|
989
|
-
|
|
782
|
+
if (slimText) {
|
|
783
|
+
act.result.textElementCount = --matchCount;
|
|
784
|
+
}
|
|
785
|
+
act.result.message = 'Not enough specified elements exist';
|
|
786
|
+
act.result.candidateTexts = selectionTexts;
|
|
990
787
|
}
|
|
991
788
|
}
|
|
992
|
-
// Otherwise, i.e. if there are
|
|
789
|
+
// Otherwise, i.e. if there are too few such elements to make a match possible:
|
|
993
790
|
else {
|
|
994
791
|
// Add the failure data to the report.
|
|
995
792
|
act.result.success = false;
|
|
996
|
-
act.result.error = '
|
|
997
|
-
act.result.typeElementCount =
|
|
998
|
-
act.result.message = '
|
|
793
|
+
act.result.error = 'fewer';
|
|
794
|
+
act.result.typeElementCount = selections.length;
|
|
795
|
+
act.result.message = 'Elements of specified type too few';
|
|
999
796
|
}
|
|
1000
797
|
}
|
|
1001
|
-
// Otherwise, i.e. if
|
|
798
|
+
// Otherwise, i.e. if there are no elements of the specified type:
|
|
1002
799
|
else {
|
|
1003
800
|
// Add the failure data to the report.
|
|
1004
801
|
act.result.success = false;
|
|
1005
|
-
act.result.error = '
|
|
1006
|
-
act.result.
|
|
1007
|
-
|
|
1008
|
-
if (! act.result.found) {
|
|
1009
|
-
await wait(2000);
|
|
802
|
+
act.result.error = 'none';
|
|
803
|
+
act.result.typeElementCount = 0;
|
|
804
|
+
act.result.message = 'No elements of specified type found';
|
|
1010
805
|
}
|
|
1011
806
|
}
|
|
1012
|
-
//
|
|
1013
|
-
|
|
1014
|
-
//
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
807
|
+
// Otherwise, i.e. if the page no longer exists:
|
|
808
|
+
else {
|
|
809
|
+
// Add the failure data to the report.
|
|
810
|
+
act.result.success = false;
|
|
811
|
+
act.result.error = 'gone';
|
|
812
|
+
act.result.message = 'Page gone';
|
|
813
|
+
}
|
|
814
|
+
if (! act.result.found) {
|
|
815
|
+
await wait(2000);
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
// If a match was found:
|
|
819
|
+
if (act.result.found) {
|
|
820
|
+
// FUNCTION DEFINITION START
|
|
821
|
+
// Performs a click or Enter keypress and waits for the network to be idle.
|
|
822
|
+
const doAndWait = async isClick => {
|
|
823
|
+
// Perform and report the move.
|
|
824
|
+
const move = isClick ? 'click' : 'Enter keypress';
|
|
825
|
+
try {
|
|
826
|
+
await isClick
|
|
827
|
+
? selection.click({timeout: 4000})
|
|
828
|
+
: selection.press('Enter', {timeout: 4000});
|
|
829
|
+
act.result.success = true;
|
|
830
|
+
act.result.move = move;
|
|
831
|
+
}
|
|
832
|
+
// If the move fails:
|
|
833
|
+
catch(error) {
|
|
834
|
+
// Quit and add failure data to the report.
|
|
835
|
+
act.result.success = false;
|
|
836
|
+
act.result.error = 'moveFailure';
|
|
837
|
+
act.result.message = `ERROR: ${move} failed`;
|
|
838
|
+
console.log(`ERROR: ${move} failed (${errorStart(error)})`);
|
|
839
|
+
await abortActs();
|
|
840
|
+
}
|
|
841
|
+
if (act.result.success) {
|
|
1019
842
|
try {
|
|
1020
|
-
await
|
|
1021
|
-
|
|
1022
|
-
: selection.press('Enter', {timeout: 4000});
|
|
1023
|
-
act.result.success = true;
|
|
1024
|
-
act.result.move = move;
|
|
843
|
+
await page.context().waitForEvent('networkidle', {timeout: 10000});
|
|
844
|
+
act.result.idleTimely = true;
|
|
1025
845
|
}
|
|
1026
|
-
// If the move fails:
|
|
1027
846
|
catch(error) {
|
|
1028
|
-
|
|
1029
|
-
act.result.
|
|
1030
|
-
act.result.error = 'moveFailure';
|
|
1031
|
-
act.result.message = `ERROR: ${move} failed`;
|
|
1032
|
-
console.log(`ERROR: ${move} failed (${errorStart(error)})`);
|
|
1033
|
-
await abortActs();
|
|
1034
|
-
}
|
|
1035
|
-
if (act.result.success) {
|
|
1036
|
-
try {
|
|
1037
|
-
await page.context().waitForEvent('networkidle', {timeout: 10000});
|
|
1038
|
-
act.result.idleTimely = true;
|
|
1039
|
-
}
|
|
1040
|
-
catch(error) {
|
|
1041
|
-
console.log(`ERROR: Network busy after ${move} (${errorStart(error)})`);
|
|
1042
|
-
act.result.idleTimely = false;
|
|
1043
|
-
}
|
|
1044
|
-
// If the move created a new page, make it current.
|
|
1045
|
-
page = currentPage;
|
|
1046
|
-
act.result.newURL = page.url();
|
|
847
|
+
console.log(`ERROR: Network busy after ${move} (${errorStart(error)})`);
|
|
848
|
+
act.result.idleTimely = false;
|
|
1047
849
|
}
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
if (act.type === 'button') {
|
|
1052
|
-
await selection.click({timeout: 3000});
|
|
1053
|
-
act.result.success = true;
|
|
1054
|
-
act.result.move = 'clicked';
|
|
850
|
+
// If the move created a new page, make it current.
|
|
851
|
+
page = currentPage;
|
|
852
|
+
act.result.newURL = page.url();
|
|
1055
853
|
}
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
else {
|
|
1082
|
-
const report = `ERROR: could not check ${act.type} because disabled`;
|
|
1083
|
-
console.log(report);
|
|
854
|
+
};
|
|
855
|
+
// FUNCTION DEFINITION END
|
|
856
|
+
// If the move is a button click, perform it.
|
|
857
|
+
if (act.type === 'button') {
|
|
858
|
+
await selection.click({timeout: 3000});
|
|
859
|
+
act.result.success = true;
|
|
860
|
+
act.result.move = 'clicked';
|
|
861
|
+
}
|
|
862
|
+
// Otherwise, if it is checking a radio button or checkbox, perform it.
|
|
863
|
+
else if (['checkbox', 'radio'].includes(act.type)) {
|
|
864
|
+
await selection.waitForElementState('stable', {timeout: 2000})
|
|
865
|
+
.catch(error => {
|
|
866
|
+
console.log(`ERROR waiting for stable ${act.type} (${error.message})`);
|
|
867
|
+
act.result.success = false;
|
|
868
|
+
act.result.error = `ERROR waiting for stable ${act.type}`;
|
|
869
|
+
});
|
|
870
|
+
if (! act.result.error) {
|
|
871
|
+
const isEnabled = await selection.isEnabled();
|
|
872
|
+
if (isEnabled) {
|
|
873
|
+
await selection.check({
|
|
874
|
+
force: true,
|
|
875
|
+
timeout: 2000
|
|
876
|
+
})
|
|
877
|
+
.catch(error => {
|
|
878
|
+
console.log(`ERROR checking ${act.type} (${error.message})`);
|
|
1084
879
|
act.result.success = false;
|
|
1085
|
-
act.result.error =
|
|
880
|
+
act.result.error = `ERROR checking ${act.type}`;
|
|
881
|
+
});
|
|
882
|
+
if (! act.result.error) {
|
|
883
|
+
act.result.success = true;
|
|
884
|
+
act.result.move = 'checked';
|
|
1086
885
|
}
|
|
1087
886
|
}
|
|
887
|
+
else {
|
|
888
|
+
const report = `ERROR: could not check ${act.type} because disabled`;
|
|
889
|
+
console.log(report);
|
|
890
|
+
act.result.success = false;
|
|
891
|
+
act.result.error = report;
|
|
892
|
+
}
|
|
1088
893
|
}
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
894
|
+
}
|
|
895
|
+
// Otherwise, if it is focusing the element, perform it.
|
|
896
|
+
else if (act.type === 'focus') {
|
|
897
|
+
await selection.focus({timeout: 2000});
|
|
898
|
+
act.result.success = true;
|
|
899
|
+
act.result.move = 'focused';
|
|
900
|
+
}
|
|
901
|
+
// Otherwise, if it is clicking a link:
|
|
902
|
+
else if (act.type === 'link') {
|
|
903
|
+
const href = await selection.getAttribute('href');
|
|
904
|
+
const target = await selection.getAttribute('target');
|
|
905
|
+
act.result.href = href || 'NONE';
|
|
906
|
+
act.result.target = target || 'DEFAULT';
|
|
907
|
+
// If the destination is a new page:
|
|
908
|
+
if (target && target !== '_self') {
|
|
909
|
+
// Click the link and wait for the network to be idle.
|
|
910
|
+
doAndWait(true);
|
|
1094
911
|
}
|
|
1095
|
-
// Otherwise, if
|
|
1096
|
-
else
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
912
|
+
// Otherwise, i.e. if the destination is in the current page:
|
|
913
|
+
else {
|
|
914
|
+
// Click the link and wait for the resulting navigation.
|
|
915
|
+
try {
|
|
916
|
+
await selection.click({timeout: 5000});
|
|
917
|
+
// Wait for the new content to load.
|
|
918
|
+
await page.waitForLoadState('domcontentloaded', {timeout: 6000});
|
|
919
|
+
act.result.success = true;
|
|
920
|
+
act.result.move = 'clicked';
|
|
921
|
+
act.result.newURL = page.url();
|
|
1105
922
|
}
|
|
1106
|
-
//
|
|
1107
|
-
|
|
1108
|
-
//
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
act.result.move = 'clicked';
|
|
1115
|
-
act.result.newURL = page.url();
|
|
1116
|
-
}
|
|
1117
|
-
// If the click or load failed:
|
|
1118
|
-
catch(error) {
|
|
1119
|
-
// Quit and add failure data to the report.
|
|
1120
|
-
console.log(`ERROR clicking link (${errorStart(error)})`);
|
|
1121
|
-
act.result.success = false;
|
|
1122
|
-
act.result.error = 'unclickable';
|
|
1123
|
-
act.result.message = 'ERROR: click or load timed out';
|
|
1124
|
-
await abortActs();
|
|
1125
|
-
}
|
|
1126
|
-
// If the link click succeeded:
|
|
1127
|
-
if (! act.result.error) {
|
|
1128
|
-
// Add success data to the report.
|
|
1129
|
-
act.result.success = true;
|
|
1130
|
-
act.result.move = 'clicked';
|
|
1131
|
-
}
|
|
923
|
+
// If the click or load failed:
|
|
924
|
+
catch(error) {
|
|
925
|
+
// Quit and add failure data to the report.
|
|
926
|
+
console.log(`ERROR clicking link (${errorStart(error)})`);
|
|
927
|
+
act.result.success = false;
|
|
928
|
+
act.result.error = 'unclickable';
|
|
929
|
+
act.result.message = 'ERROR: click or load timed out';
|
|
930
|
+
await abortActs();
|
|
1132
931
|
}
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
if (options && Array.isArray(options) && options.length) {
|
|
1139
|
-
const optionTexts = [];
|
|
1140
|
-
for (const option of options) {
|
|
1141
|
-
const optionText = await option.textContent();
|
|
1142
|
-
optionTexts.push(optionText);
|
|
1143
|
-
}
|
|
1144
|
-
const matchTexts = optionTexts.map(
|
|
1145
|
-
(text, index) => text.includes(act.what) ? index : -1
|
|
1146
|
-
);
|
|
1147
|
-
const index = matchTexts.filter(text => text > -1)[act.index || 0];
|
|
1148
|
-
if (index !== undefined) {
|
|
1149
|
-
await selection.selectOption({index});
|
|
1150
|
-
optionText = optionTexts[index];
|
|
1151
|
-
}
|
|
932
|
+
// If the link click succeeded:
|
|
933
|
+
if (! act.result.error) {
|
|
934
|
+
// Add success data to the report.
|
|
935
|
+
act.result.success = true;
|
|
936
|
+
act.result.move = 'clicked';
|
|
1152
937
|
}
|
|
1153
|
-
act.result.success = true;
|
|
1154
|
-
act.result.move = 'selected';
|
|
1155
|
-
act.result.option = optionText;
|
|
1156
938
|
}
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
const
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
// If the text contains a placeholder for an environment variable:
|
|
1168
|
-
let {what} = act;
|
|
1169
|
-
if (/__[A-Z]+__/.test(what)) {
|
|
1170
|
-
// Replace it.
|
|
1171
|
-
const envKey = /__([A-Z]+)__/.exec(what)[1];
|
|
1172
|
-
const envValue = process.env[envKey];
|
|
1173
|
-
what = what.replace(/__[A-Z]+__/, envValue);
|
|
939
|
+
}
|
|
940
|
+
// Otherwise, if it is selecting an option in a select list, perform it.
|
|
941
|
+
else if (act.type === 'select') {
|
|
942
|
+
const options = await selection.$$('option');
|
|
943
|
+
let optionText = '';
|
|
944
|
+
if (options && Array.isArray(options) && options.length) {
|
|
945
|
+
const optionTexts = [];
|
|
946
|
+
for (const option of options) {
|
|
947
|
+
const optionText = await option.textContent();
|
|
948
|
+
optionTexts.push(optionText);
|
|
1174
949
|
}
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
act.
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
// Press the Enter key and wait for a network to be idle.
|
|
1183
|
-
doAndWait(false);
|
|
950
|
+
const matchTexts = optionTexts.map(
|
|
951
|
+
(text, index) => text.includes(act.what) ? index : -1
|
|
952
|
+
);
|
|
953
|
+
const index = matchTexts.filter(text => text > -1)[act.index || 0];
|
|
954
|
+
if (index !== undefined) {
|
|
955
|
+
await selection.selectOption({index});
|
|
956
|
+
optionText = optionTexts[index];
|
|
1184
957
|
}
|
|
1185
958
|
}
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
959
|
+
act.result.success = true;
|
|
960
|
+
act.result.move = 'selected';
|
|
961
|
+
act.result.option = optionText;
|
|
962
|
+
}
|
|
963
|
+
// Otherwise, if it is entering text in an input element:
|
|
964
|
+
else if (['text', 'search'].includes(act.type)) {
|
|
965
|
+
act.result.attributes = {};
|
|
966
|
+
const {attributes} = act.result;
|
|
967
|
+
const type = await selection.getAttribute('type');
|
|
968
|
+
const label = await selection.getAttribute('aria-label');
|
|
969
|
+
const labelRefs = await selection.getAttribute('aria-labelledby');
|
|
970
|
+
attributes.type = type || '';
|
|
971
|
+
attributes.label = label || '';
|
|
972
|
+
attributes.labelRefs = labelRefs || '';
|
|
973
|
+
// If the text contains a placeholder for an environment variable:
|
|
974
|
+
let {what} = act;
|
|
975
|
+
if (/__[A-Z]+__/.test(what)) {
|
|
976
|
+
// Replace it.
|
|
977
|
+
const envKey = /__([A-Z]+)__/.exec(what)[1];
|
|
978
|
+
const envValue = process.env[envKey];
|
|
979
|
+
what = what.replace(/__[A-Z]+__/, envValue);
|
|
980
|
+
}
|
|
981
|
+
// Enter the text.
|
|
982
|
+
await selection.type(act.what);
|
|
983
|
+
report.jobData.presses += act.what.length;
|
|
984
|
+
act.result.success = true;
|
|
985
|
+
act.result.move = 'entered';
|
|
986
|
+
// If the input is a search input:
|
|
987
|
+
if (act.type === 'search') {
|
|
988
|
+
// Press the Enter key and wait for a network to be idle.
|
|
989
|
+
doAndWait(false);
|
|
1193
990
|
}
|
|
1194
991
|
}
|
|
1195
|
-
// Otherwise, i.e. if
|
|
992
|
+
// Otherwise, i.e. if the move is unknown, add the failure to the act.
|
|
1196
993
|
else {
|
|
1197
|
-
//
|
|
994
|
+
// Report the error.
|
|
995
|
+
const report = 'ERROR: move unknown';
|
|
1198
996
|
act.result.success = false;
|
|
1199
|
-
act.result.error =
|
|
1200
|
-
|
|
1201
|
-
console.log('ERROR: Specified element not found');
|
|
1202
|
-
await abortActs();
|
|
997
|
+
act.result.error = report;
|
|
998
|
+
console.log(report);
|
|
1203
999
|
}
|
|
1204
1000
|
}
|
|
1205
|
-
// Otherwise, if
|
|
1206
|
-
else
|
|
1207
|
-
//
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
await page.keyboard.press(key);
|
|
1214
|
-
}
|
|
1215
|
-
const qualifier = act.again ? `${1 + act.again} times` : 'once';
|
|
1216
|
-
act.result = {
|
|
1217
|
-
success: true,
|
|
1218
|
-
message: `pressed ${qualifier}`
|
|
1219
|
-
};
|
|
1001
|
+
// Otherwise, i.e. if no match was found:
|
|
1002
|
+
else {
|
|
1003
|
+
// Quit and add failure data to the report.
|
|
1004
|
+
act.result.success = false;
|
|
1005
|
+
act.result.error = 'absent';
|
|
1006
|
+
act.result.message = 'ERROR: specified element not found';
|
|
1007
|
+
console.log('ERROR: Specified element not found');
|
|
1008
|
+
await abortActs();
|
|
1220
1009
|
}
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1010
|
+
}
|
|
1011
|
+
// Otherwise, if the act is a keypress:
|
|
1012
|
+
else if (act.type === 'press') {
|
|
1013
|
+
// Identify the number of times to press the key.
|
|
1014
|
+
let times = 1 + (act.again || 0);
|
|
1015
|
+
report.jobData.presses += times;
|
|
1016
|
+
const key = act.which;
|
|
1017
|
+
// Press the key.
|
|
1018
|
+
while (times--) {
|
|
1019
|
+
await page.keyboard.press(key);
|
|
1020
|
+
}
|
|
1021
|
+
const qualifier = act.again ? `${1 + act.again} times` : 'once';
|
|
1022
|
+
act.result = {
|
|
1023
|
+
success: true,
|
|
1024
|
+
message: `pressed ${qualifier}`
|
|
1025
|
+
};
|
|
1026
|
+
}
|
|
1027
|
+
// Otherwise, if it is a repetitive keyboard navigation:
|
|
1028
|
+
else if (act.type === 'presses') {
|
|
1029
|
+
const {navKey, what, which, withItems} = act;
|
|
1030
|
+
const matchTexts = which ? which.map(text => debloat(text)) : [];
|
|
1031
|
+
// Initialize the loop variables.
|
|
1032
|
+
let status = 'more';
|
|
1033
|
+
let presses = 0;
|
|
1034
|
+
let amountRead = 0;
|
|
1035
|
+
let items = [];
|
|
1036
|
+
let matchedText;
|
|
1037
|
+
// As long as a matching element has not been reached:
|
|
1038
|
+
while (status === 'more') {
|
|
1039
|
+
// Press the Escape key to dismiss any modal dialog.
|
|
1040
|
+
await page.keyboard.press('Escape');
|
|
1041
|
+
// Press the specified navigation key.
|
|
1042
|
+
await page.keyboard.press(navKey);
|
|
1043
|
+
presses++;
|
|
1044
|
+
// Identify the newly current element or a failure.
|
|
1045
|
+
const currentJSHandle = await page.evaluateHandle(actCount => {
|
|
1046
|
+
// Initialize it as the focused element.
|
|
1047
|
+
let currentElement = document.activeElement;
|
|
1048
|
+
// If it exists in the page:
|
|
1049
|
+
if (currentElement && currentElement.tagName !== 'BODY') {
|
|
1050
|
+
// Change it, if necessary, to its active descendant.
|
|
1051
|
+
if (currentElement.hasAttribute('aria-activedescendant')) {
|
|
1052
|
+
currentElement = document.getElementById(
|
|
1053
|
+
currentElement.getAttribute('aria-activedescendant')
|
|
1054
|
+
);
|
|
1055
|
+
}
|
|
1056
|
+
// Or change it, if necessary, to its selected option.
|
|
1057
|
+
else if (currentElement.tagName === 'SELECT') {
|
|
1058
|
+
const currentIndex = Math.max(0, currentElement.selectedIndex);
|
|
1059
|
+
const options = currentElement.querySelectorAll('option');
|
|
1060
|
+
currentElement = options[currentIndex];
|
|
1061
|
+
}
|
|
1062
|
+
// Or change it, if necessary, to its active shadow-DOM element.
|
|
1063
|
+
else if (currentElement.shadowRoot) {
|
|
1064
|
+
currentElement = currentElement.shadowRoot.activeElement;
|
|
1065
|
+
}
|
|
1066
|
+
// If there is a current element:
|
|
1067
|
+
if (currentElement) {
|
|
1068
|
+
// If it was already reached within this act:
|
|
1069
|
+
if (currentElement.dataset.pressesReached === actCount.toString(10)) {
|
|
1278
1070
|
// Report the error.
|
|
1071
|
+
console.log(`ERROR: ${currentElement.tagName} element reached again`);
|
|
1279
1072
|
status = 'ERROR';
|
|
1280
|
-
return '
|
|
1073
|
+
return 'ERROR: locallyExhausted';
|
|
1074
|
+
}
|
|
1075
|
+
// Otherwise, i.e. if it is newly reached within this act:
|
|
1076
|
+
else {
|
|
1077
|
+
// Mark and return it.
|
|
1078
|
+
currentElement.dataset.pressesReached = actCount;
|
|
1079
|
+
return currentElement;
|
|
1281
1080
|
}
|
|
1282
1081
|
}
|
|
1283
|
-
// Otherwise, i.e. if there is no
|
|
1082
|
+
// Otherwise, i.e. if there is no current element:
|
|
1284
1083
|
else {
|
|
1285
1084
|
// Report the error.
|
|
1286
1085
|
status = 'ERROR';
|
|
1287
|
-
return '
|
|
1086
|
+
return 'noActiveElement';
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
// Otherwise, i.e. if there is no focus in the page:
|
|
1090
|
+
else {
|
|
1091
|
+
// Report the error.
|
|
1092
|
+
status = 'ERROR';
|
|
1093
|
+
return 'ERROR: globallyExhausted';
|
|
1094
|
+
}
|
|
1095
|
+
}, actCount);
|
|
1096
|
+
// If the current element exists:
|
|
1097
|
+
const currentElement = currentJSHandle.asElement();
|
|
1098
|
+
if (currentElement) {
|
|
1099
|
+
// Update the data.
|
|
1100
|
+
const tagNameJSHandle = await currentElement.getProperty('tagName');
|
|
1101
|
+
const tagName = await tagNameJSHandle.jsonValue();
|
|
1102
|
+
const text = await textOf(page, currentElement);
|
|
1103
|
+
// If the text of the current element was found:
|
|
1104
|
+
if (text !== null) {
|
|
1105
|
+
const textLength = text.length;
|
|
1106
|
+
// If it is non-empty and there are texts to match:
|
|
1107
|
+
if (matchTexts.length && textLength) {
|
|
1108
|
+
// Identify the matching text.
|
|
1109
|
+
matchedText = matchTexts.find(matchText => text.includes(matchText));
|
|
1288
1110
|
}
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
if (text !== null) {
|
|
1299
|
-
const textLength = text.length;
|
|
1300
|
-
// If it is non-empty and there are texts to match:
|
|
1301
|
-
if (matchTexts.length && textLength) {
|
|
1302
|
-
// Identify the matching text.
|
|
1303
|
-
matchedText = matchTexts.find(matchText => text.includes(matchText));
|
|
1111
|
+
// Update the item data if required.
|
|
1112
|
+
if (withItems) {
|
|
1113
|
+
const itemData = {
|
|
1114
|
+
tagName,
|
|
1115
|
+
text,
|
|
1116
|
+
textLength
|
|
1117
|
+
};
|
|
1118
|
+
if (matchedText) {
|
|
1119
|
+
itemData.matchedText = matchedText;
|
|
1304
1120
|
}
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1121
|
+
items.push(itemData);
|
|
1122
|
+
}
|
|
1123
|
+
amountRead += textLength;
|
|
1124
|
+
// If there is no text-match failure:
|
|
1125
|
+
if (matchedText || ! matchTexts.length) {
|
|
1126
|
+
// If the element has any specified tag name:
|
|
1127
|
+
if (! what || tagName === what) {
|
|
1128
|
+
// Change the status.
|
|
1129
|
+
status = 'done';
|
|
1130
|
+
// Perform the action.
|
|
1131
|
+
const inputText = act.text;
|
|
1132
|
+
if (inputText) {
|
|
1133
|
+
await page.keyboard.type(inputText);
|
|
1134
|
+
presses += inputText.length;
|
|
1314
1135
|
}
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
if (matchedText || ! matchTexts.length) {
|
|
1320
|
-
// If the element has any specified tag name:
|
|
1321
|
-
if (! what || tagName === what) {
|
|
1322
|
-
// Change the status.
|
|
1323
|
-
status = 'done';
|
|
1324
|
-
// Perform the action.
|
|
1325
|
-
const inputText = act.text;
|
|
1326
|
-
if (inputText) {
|
|
1327
|
-
await page.keyboard.type(inputText);
|
|
1328
|
-
presses += inputText.length;
|
|
1329
|
-
}
|
|
1330
|
-
if (act.action) {
|
|
1331
|
-
presses++;
|
|
1332
|
-
await page.keyboard.press(act.action);
|
|
1333
|
-
await page.waitForLoadState();
|
|
1334
|
-
}
|
|
1136
|
+
if (act.action) {
|
|
1137
|
+
presses++;
|
|
1138
|
+
await page.keyboard.press(act.action);
|
|
1139
|
+
await page.waitForLoadState();
|
|
1335
1140
|
}
|
|
1336
1141
|
}
|
|
1337
1142
|
}
|
|
1338
|
-
else {
|
|
1339
|
-
status = 'ERROR';
|
|
1340
|
-
}
|
|
1341
1143
|
}
|
|
1342
|
-
// Otherwise, i.e. if there was a failure:
|
|
1343
1144
|
else {
|
|
1344
|
-
|
|
1345
|
-
status = await currentJSHandle.jsonValue();
|
|
1145
|
+
status = 'ERROR';
|
|
1346
1146
|
}
|
|
1347
1147
|
}
|
|
1348
|
-
//
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
status
|
|
1352
|
-
totals: {
|
|
1353
|
-
presses,
|
|
1354
|
-
amountRead
|
|
1355
|
-
}
|
|
1356
|
-
};
|
|
1357
|
-
if (status === 'done' && matchedText) {
|
|
1358
|
-
act.result.matchedText = matchedText;
|
|
1148
|
+
// Otherwise, i.e. if there was a failure:
|
|
1149
|
+
else {
|
|
1150
|
+
// Update the status.
|
|
1151
|
+
status = await currentJSHandle.jsonValue();
|
|
1359
1152
|
}
|
|
1360
|
-
|
|
1361
|
-
|
|
1153
|
+
}
|
|
1154
|
+
// Add the result to the act.
|
|
1155
|
+
act.result = {
|
|
1156
|
+
success: true,
|
|
1157
|
+
status,
|
|
1158
|
+
totals: {
|
|
1159
|
+
presses,
|
|
1160
|
+
amountRead
|
|
1362
1161
|
}
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1162
|
+
};
|
|
1163
|
+
if (status === 'done' && matchedText) {
|
|
1164
|
+
act.result.matchedText = matchedText;
|
|
1366
1165
|
}
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
// Add the error result to the act.
|
|
1370
|
-
addError(act, 'badType', 'ERROR: Invalid act type');
|
|
1166
|
+
if (withItems) {
|
|
1167
|
+
act.result.items = items;
|
|
1371
1168
|
}
|
|
1169
|
+
// Add the totals to the report.
|
|
1170
|
+
report.jobData.presses += presses;
|
|
1171
|
+
report.jobData.amountRead += amountRead;
|
|
1372
1172
|
}
|
|
1373
|
-
// Otherwise, i.e. if
|
|
1173
|
+
// Otherwise, i.e. if the act type is unknown:
|
|
1374
1174
|
else {
|
|
1375
|
-
// Add
|
|
1376
|
-
addError(act, '
|
|
1175
|
+
// Add the error result to the act.
|
|
1176
|
+
addError(act, 'badType', 'ERROR: Invalid act type');
|
|
1377
1177
|
}
|
|
1378
1178
|
}
|
|
1379
1179
|
// Otherwise, a page URL is required but does not exist, so:
|
|
@@ -1434,12 +1234,22 @@ exports.doJob = async report => {
|
|
|
1434
1234
|
report.jobData.abortedAct = null;
|
|
1435
1235
|
report.jobData.presses = 0;
|
|
1436
1236
|
report.jobData.amountRead = 0;
|
|
1437
|
-
report.jobData.
|
|
1438
|
-
// Recursively perform the acts
|
|
1237
|
+
report.jobData.toolTimes = {};
|
|
1238
|
+
// Recursively perform the acts.
|
|
1439
1239
|
await doActs(report, 0, null);
|
|
1440
1240
|
// Add the end time and duration to the report.
|
|
1441
1241
|
const endTime = new Date();
|
|
1442
1242
|
report.jobData.endTime = nowString();
|
|
1443
1243
|
report.jobData.elapsedSeconds = Math.floor((endTime - startTime) / 1000);
|
|
1244
|
+
// Consolidate and sort the tool times.
|
|
1245
|
+
const {toolTimes} = report.jobData;
|
|
1246
|
+
const toolTimeData = Object
|
|
1247
|
+
.keys(toolTimes)
|
|
1248
|
+
.sort((a, b) => toolTimes[b] - toolTimes[a])
|
|
1249
|
+
.map(tool => [tool, toolTimes[tool]]);
|
|
1250
|
+
report.jobData.toolTimes = {};
|
|
1251
|
+
toolTimeData.forEach(item => {
|
|
1252
|
+
report.jobData.toolTimes[item[0]] = item[1];
|
|
1253
|
+
});
|
|
1444
1254
|
}
|
|
1445
1255
|
};
|