testaro 1.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.
Files changed (99) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +502 -0
  3. package/aceconfig.js +7 -0
  4. package/commands.js +249 -0
  5. package/index.js +1248 -0
  6. package/package.json +39 -0
  7. package/procs/score/asp09.js +555 -0
  8. package/procs/test/allText.js +76 -0
  9. package/procs/test/allVis.js +17 -0
  10. package/procs/test/linksByType.js +90 -0
  11. package/procs/test/textOf.txt +73 -0
  12. package/scoring/correlation.js +74 -0
  13. package/scoring/correlations.json +327 -0
  14. package/scoring/data.json +26021 -0
  15. package/scoring/dupCounts.js +39 -0
  16. package/scoring/dupCounts.json +112 -0
  17. package/scoring/duplications.json +253 -0
  18. package/scoring/issues.json +304 -0
  19. package/scoring/packageData.js +171 -0
  20. package/scoring/packageIssues.js +34 -0
  21. package/scoring/rulesetData.json +15 -0
  22. package/tests/aatt.js +64 -0
  23. package/tests/alfa.js +107 -0
  24. package/tests/axe.js +109 -0
  25. package/tests/bulk.js +21 -0
  26. package/tests/embAc.js +36 -0
  27. package/tests/focAll.js +62 -0
  28. package/tests/focInd.js +99 -0
  29. package/tests/focOp.js +132 -0
  30. package/tests/hover.js +195 -0
  31. package/tests/ibm.js +89 -0
  32. package/tests/labClash.js +157 -0
  33. package/tests/linkUl.js +65 -0
  34. package/tests/menuNav.js +254 -0
  35. package/tests/motion.js +115 -0
  36. package/tests/radioSet.js +87 -0
  37. package/tests/role.js +164 -0
  38. package/tests/styleDiff.js +146 -0
  39. package/tests/tabNav.js +282 -0
  40. package/tests/wave.js +44 -0
  41. package/tests/zIndex.js +49 -0
  42. package/validation/batches/sample.json +13 -0
  43. package/validation/executors/sample.js +11 -0
  44. package/validation/scripts/app/sample.json +21 -0
  45. package/validation/scripts/test/bulk.json +39 -0
  46. package/validation/scripts/test/embAc.json +45 -0
  47. package/validation/scripts/test/focAll.json +59 -0
  48. package/validation/scripts/test/focInd.json +55 -0
  49. package/validation/scripts/test/focOp.json +53 -0
  50. package/validation/scripts/test/hover.json +47 -0
  51. package/validation/scripts/test/labClash.json +43 -0
  52. package/validation/scripts/test/linkUl.json +62 -0
  53. package/validation/scripts/test/menuNav.json +97 -0
  54. package/validation/scripts/test/motion.json +53 -0
  55. package/validation/scripts/test/radioSet.json +43 -0
  56. package/validation/scripts/test/role.json +42 -0
  57. package/validation/scripts/test/styleDiff.json +61 -0
  58. package/validation/scripts/test/tabNav.json +97 -0
  59. package/validation/scripts/test/zIndex.json +40 -0
  60. package/validation/targets/bulk/bad.html +48 -0
  61. package/validation/targets/bulk/good.html +15 -0
  62. package/validation/targets/embAc/bad.html +21 -0
  63. package/validation/targets/embAc/good.html +15 -0
  64. package/validation/targets/focAll/good.html +15 -0
  65. package/validation/targets/focAll/less.html +15 -0
  66. package/validation/targets/focAll/more.html +16 -0
  67. package/validation/targets/focInd/bad.html +31 -0
  68. package/validation/targets/focInd/good.html +22 -0
  69. package/validation/targets/focOp/bad.html +18 -0
  70. package/validation/targets/focOp/good.html +15 -0
  71. package/validation/targets/hover/bad.html +19 -0
  72. package/validation/targets/hover/good.html +15 -0
  73. package/validation/targets/labClash/bad.html +20 -0
  74. package/validation/targets/labClash/good.html +18 -0
  75. package/validation/targets/linkUl/bad.html +16 -0
  76. package/validation/targets/linkUl/good.html +30 -0
  77. package/validation/targets/linkUl/na.html +20 -0
  78. package/validation/targets/menuNav/bad.html +106 -0
  79. package/validation/targets/menuNav/bad.js +348 -0
  80. package/validation/targets/menuNav/good.html +106 -0
  81. package/validation/targets/menuNav/good.js +365 -0
  82. package/validation/targets/menuNav/style.css +22 -0
  83. package/validation/targets/motion/bad.css +15 -0
  84. package/validation/targets/motion/bad.html +16 -0
  85. package/validation/targets/motion/good.html +15 -0
  86. package/validation/targets/radioSet/bad.html +34 -0
  87. package/validation/targets/radioSet/good.html +27 -0
  88. package/validation/targets/role/bad.html +26 -0
  89. package/validation/targets/role/good.html +22 -0
  90. package/validation/targets/styleDiff/bad.html +35 -0
  91. package/validation/targets/styleDiff/good.html +36 -0
  92. package/validation/targets/tabNav/bad.html +51 -0
  93. package/validation/targets/tabNav/bad.js +35 -0
  94. package/validation/targets/tabNav/good.html +53 -0
  95. package/validation/targets/tabNav/good.js +83 -0
  96. package/validation/targets/tabNav/goodMoz.js +206 -0
  97. package/validation/targets/tabNav/style.css +34 -0
  98. package/validation/targets/zIndex/bad.html +17 -0
  99. package/validation/targets/zIndex/good.html +15 -0
package/index.js ADDED
@@ -0,0 +1,1248 @@
1
+ /*
2
+ index.js
3
+ testaro main script.
4
+ */
5
+ // ########## IMPORTS
6
+ // Module to access files.
7
+ const fs = require('fs/promises');
8
+ // Requirements for commands.
9
+ const {commands} = require('./commands');
10
+ // ########## CONSTANTS
11
+ // Set DEBUG environment variable to 'true' to add debugging features.
12
+ const debug = process.env.DEBUG === 'true';
13
+ // Set WAITS environment variable to a positive number to insert delays (in ms).
14
+ const waits = Number.parseInt(process.env.WAITS) || 0;
15
+ // CSS selectors for targets of moves.
16
+ const moves = {
17
+ button: 'button',
18
+ checkbox: 'input[type=checkbox]',
19
+ focus: true,
20
+ link: 'a',
21
+ radio: 'input[type=radio]',
22
+ select: 'select',
23
+ text: 'input[type=text]'
24
+ };
25
+ // Names and descriptions of tests.
26
+ const tests = {
27
+ aatt: 'AATT with HTML CodeSniffer WCAG 2.1 AA ruleset',
28
+ alfa: 'alfa',
29
+ axe: 'Axe',
30
+ bulk: 'count of visible elements',
31
+ embAc: 'active elements embedded in links or buttons',
32
+ focAll: 'focusable and Tab-focused elements',
33
+ focInd: 'focus indicators',
34
+ focOp: 'focusability and operability',
35
+ hover: 'hover-caused content additions',
36
+ ibm: 'IBM Accessibility Checker',
37
+ labClash: 'labeling inconsistencies',
38
+ linkUl: 'inline-link underlining',
39
+ menuNav: 'keyboard navigation between focusable menu items',
40
+ motion: 'motion',
41
+ radioSet: 'fieldset grouping of radio buttons',
42
+ role: 'roles',
43
+ styleDiff: 'style inconsistencies',
44
+ tabNav: 'keyboard navigation between tab elements',
45
+ wave: 'WAVE',
46
+ zIndex: 'z indexes'
47
+ };
48
+ // Tests that may change the DOM.
49
+ const domChangers = new Set([
50
+ 'axe', 'focAll', 'focInd', 'focOp', 'hover', 'ibm', 'menuNav', 'wave'
51
+ ]);
52
+ // Browser types available in PlayWright.
53
+ const browserTypeNames = {
54
+ 'chromium': 'Chrome',
55
+ 'webkit': 'Safari',
56
+ 'firefox': 'Firefox'
57
+ };
58
+ // Items that may be waited for.
59
+ const waitables = ['url', 'title', 'body'];
60
+ // ########## VARIABLES
61
+ // Facts about the current session.
62
+ let logCount = 0;
63
+ let logSize = 0;
64
+ let prohibitedCount = 0;
65
+ let visitTimeoutCount = 0;
66
+ let visitRejectionCount = 0;
67
+ let actCount = 0;
68
+ // Facts about the current browser.
69
+ let browserContext;
70
+ let browserTypeName;
71
+ let requestedURL = '';
72
+ // ########## VALIDATORS
73
+ // Validates a browser type.
74
+ const isBrowserType = type => ['chromium', 'firefox', 'webkit'].includes(type);
75
+ // Validates a load state.
76
+ const isState = string => ['loaded', 'idle'].includes(string);
77
+ // Validates a URL.
78
+ const isURL = string => /^(?:https?|file):\/\/[^\s]+$/.test(string);
79
+ // Validates a focusable tag name.
80
+ const isFocusable = string => ['a', 'button', 'input', 'select'].includes(string);
81
+ // Returns whether all elements of an array are strings.
82
+ const areStrings = array => array.every(element => typeof element === 'string');
83
+ // Returns whether a variable has a specified type.
84
+ const hasType = (variable, type) => {
85
+ if (type === 'string') {
86
+ return typeof variable === 'string';
87
+ }
88
+ else if (type === 'array') {
89
+ return Array.isArray(variable);
90
+ }
91
+ else if (type === 'boolean') {
92
+ return typeof variable === 'boolean';
93
+ }
94
+ else if (type === 'number') {
95
+ return typeof variable === 'number';
96
+ }
97
+ else {
98
+ return false;
99
+ }
100
+ };
101
+ // Returns whether a variable has a specified subtype.
102
+ const hasSubtype = (variable, subtype) => {
103
+ if (subtype) {
104
+ if (subtype === 'hasLength') {
105
+ return variable.length > 0;
106
+ }
107
+ else if (subtype === 'isURL') {
108
+ return isURL(variable);
109
+ }
110
+ else if (subtype === 'isBrowserType') {
111
+ return isBrowserType(variable);
112
+ }
113
+ else if (subtype === 'isFocusable') {
114
+ return isFocusable(variable);
115
+ }
116
+ else if (subtype === 'isTest') {
117
+ return tests[variable];
118
+ }
119
+ else if (subtype === 'isWaitable') {
120
+ return waitables.includes(variable);
121
+ }
122
+ else if (subtype === 'areStrings') {
123
+ return areStrings(variable);
124
+ }
125
+ else if (subtype === 'isState') {
126
+ return isState(variable);
127
+ }
128
+ else {
129
+ return false;
130
+ }
131
+ }
132
+ else {
133
+ return true;
134
+ }
135
+ };
136
+ // Validates a command.
137
+ const isValidCommand = command => {
138
+ // Identify the type of the command.
139
+ const type = command.type;
140
+ // If the type exists and is known:
141
+ if (type && commands.etc[type]) {
142
+ // Copy the validator of the type for possible expansion.
143
+ const validator = Object.assign({}, commands.etc[type][1]);
144
+ // If the type is test:
145
+ if (type === 'test') {
146
+ // Identify the test.
147
+ const testName = command.which;
148
+ // If one was specified and is known:
149
+ if (testName && tests[testName]) {
150
+ // If it has special properties:
151
+ if (commands.tests[testName]) {
152
+ // Expand the validator by adding them.
153
+ Object.assign(validator, commands.tests[testName][1]);
154
+ }
155
+ }
156
+ // Otherwise, i.e. if no or an unknown test was specified:
157
+ else {
158
+ // Return invalidity.
159
+ return false;
160
+ }
161
+ }
162
+ // Return whether the command is valid.
163
+ return Object.keys(validator).every(property => {
164
+ if (property === 'name') {
165
+ return true;
166
+ }
167
+ else {
168
+ const vP = validator[property];
169
+ const cP = command[property];
170
+ // If it is optional and omitted or present and valid:
171
+ const optAndNone = ! vP[0] && ! cP;
172
+ const isValidCommand = cP !== undefined && hasType(cP, vP[1]) && hasSubtype(cP, vP[2]);
173
+ return optAndNone || isValidCommand;
174
+ }
175
+ });
176
+ }
177
+ // Otherwise, i.e. if the command has an unknown or no type:
178
+ else {
179
+ // Return invalidity.
180
+ return false;
181
+ }
182
+ };
183
+ // Validates a script.
184
+ const isValidScript = script => {
185
+ // Get the script data.
186
+ const {what, strict, commands} = script;
187
+ // Return whether the script is valid:
188
+ return what
189
+ && typeof strict === 'boolean'
190
+ && commands
191
+ && typeof what === 'string'
192
+ && Array.isArray(commands)
193
+ && commands[0].type === 'launch'
194
+ && commands.length > 1
195
+ && commands[1].type === 'url'
196
+ && isURL(commands[1].which)
197
+ && commands.every(command => isValidCommand(command));
198
+ };
199
+ // Validates a batch.
200
+ const isValidBatch = batch => {
201
+ // Get the batch data.
202
+ const batchWhat = batch.what;
203
+ const {hosts} = batch;
204
+ // Return whether the batch is valid:
205
+ return batchWhat
206
+ && hosts
207
+ && typeof batchWhat === 'string'
208
+ && Array.isArray(hosts)
209
+ && hosts.every(host => host.which && host.what && isURL(host.which));
210
+ };
211
+ // Validates a reports directory.
212
+ const isValidReports = reports => typeof reports === 'string' && reports.length;
213
+ // Validates an options object.
214
+ const isValidOptions = async options => {
215
+ if (options) {
216
+ const {script, batch, reports} = options;
217
+ if (script) {
218
+ const scriptJSON = await fs.readFile(script, 'utf8');
219
+ const scriptObj = JSON.parse(scriptJSON);
220
+ if (isValidScript(scriptObj)) {
221
+ if (reports && isValidReports(reports)) {
222
+ if (batch) {
223
+ const batchJSON = await fs.readFile(batch, 'utf8');
224
+ const batchObj = JSON.parse(batchJSON);
225
+ if (isValidBatch(batchObj)) {
226
+ return true;
227
+ }
228
+ else {
229
+ return false;
230
+ }
231
+ }
232
+ else {
233
+ return true;
234
+ }
235
+ }
236
+ else {
237
+ return false;
238
+ }
239
+ }
240
+ else {
241
+ return false;
242
+ }
243
+ }
244
+ else {
245
+ return false;
246
+ }
247
+ }
248
+ else {
249
+ return false;
250
+ }
251
+ };
252
+ // ########## OTHER FUNCTIONS
253
+ // Closes any existing browser.
254
+ const closeBrowser = async () => {
255
+ const browser = browserContext && browserContext.browser();
256
+ if (browser) {
257
+ await browser.close();
258
+ }
259
+ };
260
+ // Launches a browser.
261
+ const launch = async typeName => {
262
+ const browserType = require('playwright')[typeName];
263
+ // If the specified browser type exists:
264
+ if (browserType) {
265
+ // Close any existing browser.
266
+ await closeBrowser();
267
+ // Launch a browser of the specified type.
268
+ const browserOptions = {};
269
+ if (debug) {
270
+ browserOptions.headless = false;
271
+ }
272
+ if (waits) {
273
+ browserOptions.slowMo = waits;
274
+ }
275
+ const browser = await browserType.launch(browserOptions);
276
+ // Create a new context (window) in it, taller if debugging is on.
277
+ const viewport = debug ? {
278
+ viewPort: {
279
+ width: 1280,
280
+ height: 1120
281
+ }
282
+ } : {};
283
+ browserContext = await browser.newContext(viewport);
284
+ // When a page is added to the browser context:
285
+ browserContext.on('page', page => {
286
+ // Make its console messages appear in the Playwright console.
287
+ page.on('console', msg => {
288
+ const msgText = msg.text();
289
+ console.log(msgText);
290
+ logCount++;
291
+ logSize += msgText.length;
292
+ const msgLC = msgText.toLowerCase();
293
+ if (msgText.includes('403') && (msgLC.includes('status') || msgLC.includes('prohibited'))) {
294
+ prohibitedCount++;
295
+ }
296
+ });
297
+ });
298
+ // Open the first page of the context.
299
+ const page = await browserContext.newPage();
300
+ if (debug) {
301
+ page.setViewportSize({
302
+ width: 1280,
303
+ height: 1120
304
+ });
305
+ }
306
+ // Wait until it is stable.
307
+ await page.waitForLoadState('domcontentloaded');
308
+ // Update the name of the current browser type and store it in the page.
309
+ page.browserTypeName = browserTypeName = typeName;
310
+ }
311
+ };
312
+ // Normalizes spacing characters and cases in a string.
313
+ const debloat = string => string.replace(/\s/g, ' ').trim().replace(/ {2,}/g, ' ').toLowerCase();
314
+ // Returns the text of an element, lower-cased.
315
+ const textOf = async (page, element) => {
316
+ if (element) {
317
+ const tagNameJSHandle = await element.getProperty('tagName');
318
+ const tagName = await tagNameJSHandle.jsonValue();
319
+ let totalText = '';
320
+ // If the element is a link, button, input, or select list:
321
+ if (['A', 'BUTTON', 'INPUT', 'SELECT'].includes(tagName)) {
322
+ // Return its visible labels, descriptions, and legend if the first input in a fieldset.
323
+ totalText = await page.evaluate(element => {
324
+ const {tagName} = element;
325
+ const ownText = ['A', 'BUTTON'].includes(tagName) ? element.textContent : '';
326
+ // HTML link elements have no labels property.
327
+ const labels = tagName !== 'A' ? Array.from(element.labels) : [];
328
+ const labelTexts = labels.map(label => label.textContent);
329
+ const refIDs = new Set([
330
+ element.getAttribute('aria-labelledby') || '',
331
+ element.getAttribute('aria-describedby') || ''
332
+ ].join(' ').split(/\s+/));
333
+ if (refIDs.size) {
334
+ refIDs.forEach(id => {
335
+ const labeler = document.getElementById(id);
336
+ if (labeler) {
337
+ const labelerText = labeler.textContent.trim();
338
+ if (labelerText.length) {
339
+ labelTexts.push(labelerText);
340
+ }
341
+ }
342
+ });
343
+ }
344
+ let legendText = '';
345
+ if (tagName === 'INPUT') {
346
+ const fieldsets = Array.from(document.body.querySelectorAll('fieldset'));
347
+ const inputFieldsets = fieldsets.filter(fieldset => {
348
+ const inputs = Array.from(fieldset.querySelectorAll('input'));
349
+ return inputs.length && inputs[0] === element;
350
+ });
351
+ const inputFieldset = inputFieldsets[0] || null;
352
+ if (inputFieldset) {
353
+ const legend = inputFieldset.querySelector('legend');
354
+ if (legend) {
355
+ legendText = legend.textContent;
356
+ }
357
+ }
358
+ }
359
+ return [legendText].concat(labelTexts, ownText).join(' ');
360
+ }, element);
361
+ }
362
+ // Otherwise, if it is an option:
363
+ else if (tagName === 'OPTION') {
364
+ // Return its text content, prefixed with the text of its select parent if the first option.
365
+ const ownText = await element.textContent();
366
+ const indexJSHandle = await element.getProperty('index');
367
+ const index = await indexJSHandle.jsonValue();
368
+ if (index) {
369
+ totalText = ownText;
370
+ }
371
+ else {
372
+ const selectJSHandle = await page.evaluateHandle(
373
+ element => element.parentElement, element
374
+ );
375
+ const select = await selectJSHandle.asElement();
376
+ if (select) {
377
+ const selectText = await textOf(page, select);
378
+ totalText = [ownText, selectText].join(' ');
379
+ }
380
+ else {
381
+ totalText = ownText;
382
+ }
383
+ }
384
+ }
385
+ // Otherwise, i.e. if it is not an input, select, or option:
386
+ else {
387
+ // Get its text content.
388
+ totalText = await element.textContent();
389
+ }
390
+ return debloat(totalText);
391
+ }
392
+ else {
393
+ return null;
394
+ }
395
+ };
396
+ // Returns an element case-insensitively matching a text.
397
+ const matchElement = async (page, selector, matchText, index = 0) => {
398
+ // If the page still exists:
399
+ if (page) {
400
+ // Wait 3 seconds until the body contains any text to be matched.
401
+ const slimText = debloat(matchText);
402
+ const bodyText = await page.textContent('body');
403
+ const slimBody = debloat(bodyText);
404
+ const textInBodyJSHandle = await page.waitForFunction(
405
+ args => {
406
+ const matchText = args[0];
407
+ const bodyText = args[1];
408
+ return ! matchText || bodyText.includes(matchText);
409
+ },
410
+ [slimText, slimBody],
411
+ {timeout: 2000}
412
+ )
413
+ .catch(async error => {
414
+ console.log(`ERROR: text to match not in body (${error.message})`);
415
+ });
416
+ // If there is no text to be matched or the body contained it:
417
+ if (textInBodyJSHandle) {
418
+ const lcText = matchText ? matchText.toLowerCase() : '';
419
+ // Identify the selected elements.
420
+ const selections = await page.$$(`body ${selector}`);
421
+ // If there are any:
422
+ if (selections.length) {
423
+ // If there are enough to make a match possible:
424
+ if (index < selections.length) {
425
+ // Return the nth one including any specified text, or the count of candidates if none.
426
+ const elementTexts = [];
427
+ let nth = 0;
428
+ for (const element of selections) {
429
+ const elementText = await textOf(page, element);
430
+ elementTexts.push(elementText);
431
+ if ((! lcText || elementText.includes(lcText)) && nth++ === index) {
432
+ return element;
433
+ }
434
+ }
435
+ return elementTexts;
436
+ }
437
+ // Otherwise, i.e. if there are too few to make a match possible:
438
+ else {
439
+ // Return the count of candidates.
440
+ return selections.length;
441
+ }
442
+ }
443
+ // Otherwise, i.e. if there are no selected elements, return 0.
444
+ else {
445
+ return 0;
446
+ }
447
+ }
448
+ // Otherwise, i.e. if the body did not contain it:
449
+ else {
450
+ // Return the failure.
451
+ return -1;
452
+ }
453
+ }
454
+ // Otherwise, i.e. if the page no longer exists:
455
+ else {
456
+ // Return null.
457
+ console.log('ERROR: Page gone');
458
+ return null;
459
+ }
460
+ };
461
+ // Returns a string with any final slash removed.
462
+ const deSlash = string => string.endsWith('/') ? string.slice(0, -1) : string;
463
+ // Tries to visit a URL.
464
+ const goto = async (page, url, timeout, waitUntil, isStrict) => {
465
+ const response = await page.goto(url, {
466
+ timeout,
467
+ waitUntil
468
+ })
469
+ .catch(error => {
470
+ console.log(`ERROR: Visit to ${url} timed out before ${waitUntil} (${error.message})`);
471
+ visitTimeoutCount++;
472
+ return 'error';
473
+ });
474
+ if (typeof response !== 'string') {
475
+ const httpStatus = response.status();
476
+ if ([200, 304].includes(httpStatus) || url.startsWith('file:')) {
477
+ const actualURL = page.url();
478
+ if (isStrict && deSlash(actualURL) !== deSlash(url)) {
479
+ console.log(`ERROR: Visit to ${url} redirected to ${actualURL}`);
480
+ return 'redirection';
481
+ }
482
+ else {
483
+ return response;
484
+ }
485
+ }
486
+ else {
487
+ console.log(`ERROR: Visit to ${url} got status ${httpStatus}`);
488
+ visitRejectionCount++;
489
+ return 'error';
490
+ }
491
+ }
492
+ else {
493
+ return 'error';
494
+ }
495
+ };
496
+ // Visits the URL that is the value of the “which” property of an act.
497
+ const visit = async (act, page, isStrict) => {
498
+ // Identify the URL.
499
+ const resolved = act.which.replace('__dirname', __dirname);
500
+ requestedURL = resolved;
501
+ // Visit it and wait 15 seconds or until the network is idle.
502
+ let response = await goto(page, requestedURL, 15000, 'networkidle', isStrict);
503
+ // If the visit fails:
504
+ if (response === 'error') {
505
+ // Try again, but waiting 10 seconds or until the DOM is loaded.
506
+ response = await goto(page, requestedURL, 10000, 'domcontentloaded', isStrict);
507
+ // If the visit fails:
508
+ if (response === 'error') {
509
+ // Launch another browser type.
510
+ const newBrowserName = Object.keys(browserTypeNames)
511
+ .find(name => name !== browserTypeName);
512
+ console.log(`>> Launching ${newBrowserName} instead`);
513
+ await launch(newBrowserName);
514
+ // Identify its only page as current.
515
+ page = browserContext.pages()[0];
516
+ // Try again, waiting 10 seconds or until the network is idle.
517
+ response = await goto(page, requestedURL, 10000, 'networkidle', isStrict);
518
+ // If the visit fails:
519
+ if (response === 'error') {
520
+ // Try again, but waiting 5 seconds or until the DOM is loaded.
521
+ response = await goto(page, requestedURL, 5000, 'domcontentloaded', isStrict);
522
+ // If the visit fails:
523
+ if (response === 'error') {
524
+ // Try again, waiting 5 seconds or until a load.
525
+ response = await goto(page, requestedURL, 5000, 'load', isStrict);
526
+ // If the visit fails:
527
+ if (response === 'error') {
528
+ // Give up.
529
+ console.log(`ERROR: Visits to ${requestedURL} failed`);
530
+ act.result = `ERROR: Visit to ${requestedURL} failed`;
531
+ await page.goto('about:blank')
532
+ .catch(error => {
533
+ console.log(`ERROR: Navigation to blank page failed (${error.message})`);
534
+ });
535
+ return null;
536
+ }
537
+ }
538
+ }
539
+ }
540
+ }
541
+ // If one of the visits succeeded:
542
+ if (response) {
543
+ // Add the resulting URL to the act.
544
+ if (isStrict && response === 'redirection') {
545
+ act.error = 'ERROR: Navigation redirected';
546
+ }
547
+ act.result = page.url();
548
+ // Return the page.
549
+ return page;
550
+ }
551
+ };
552
+ // Returns a property value and whether it satisfies an expectation.
553
+ const isTrue = (object, specs) => {
554
+ let satisfied;
555
+ const property = specs[0];
556
+ const propertyTree = property.split('.');
557
+ const relation = specs[1];
558
+ const criterion = specs[2];
559
+ let actual = property.length ? object[propertyTree[0]] : object;
560
+ // Identify the actual value of the specified property.
561
+ while (propertyTree.length > 1 && actual !== undefined) {
562
+ propertyTree.shift();
563
+ actual = actual[propertyTree[0]];
564
+ }
565
+ // Determine whether the expectation was fulfilled.
566
+ if (relation === '=') {
567
+ satisfied = actual === criterion;
568
+ }
569
+ else if (relation === '<') {
570
+ satisfied = actual < criterion;
571
+ }
572
+ else if (relation === '>') {
573
+ satisfied = actual > criterion;
574
+ }
575
+ else if (relation === '!') {
576
+ satisfied = actual !== criterion;
577
+ }
578
+ else if (! relation) {
579
+ satisfied = actual === undefined;
580
+ }
581
+ return [actual, satisfied];
582
+ };
583
+ // Recursively performs the commands in a report.
584
+ const doActs = async (report, actIndex, page) => {
585
+ const {acts} = report;
586
+ // If any more commands are to be performed:
587
+ if (actIndex > -1 && actIndex < acts.length) {
588
+ // Identify the command to be performed.
589
+ const act = acts[actIndex];
590
+ // If it is valid:
591
+ if (isValidCommand(act)) {
592
+ const whichSuffix = act.which ? ` (${act.which})` : '';
593
+ console.log(`>>>> ${act.type}${whichSuffix}`);
594
+ // Increment the count of commands performed.
595
+ actCount++;
596
+ // If the command is an index changer:
597
+ if (act.type === 'next') {
598
+ const condition = act.if;
599
+ const logSuffix = condition.length === 3 ? ` ${condition[1]} ${condition[2]}` : '';
600
+ console.log(`>> ${condition[0]}${logSuffix}`);
601
+ // Identify the act to be checked.
602
+ const ifActIndex = report.acts.map(act => act.type !== 'next').lastIndexOf(true);
603
+ // Determine whether its jump condition is true.
604
+ const truth = isTrue(report.acts[ifActIndex].result, condition);
605
+ // Add the result to the act.
606
+ act.result = {
607
+ property: condition[0],
608
+ relation: condition[1],
609
+ criterion: condition[2],
610
+ value: truth[0],
611
+ jumpRequired: truth[1]
612
+ };
613
+ // If the condition is true:
614
+ if (truth[1]) {
615
+ // If the performance of commands is to stop:
616
+ if (act.jump === 0) {
617
+ // Set the command index to cause a stop.
618
+ actIndex = -2;
619
+ }
620
+ // Otherwise, if there is a numerical jump:
621
+ else if (act.jump) {
622
+ // Set the command index accordingly.
623
+ actIndex += act.jump - 1;
624
+ }
625
+ // Otherwise, if there is a named next command:
626
+ else if (act.next) {
627
+ // Set the new index accordingly, or stop if it does not exist.
628
+ actIndex = acts.map(act => act.name).indexOf(act.next) - 1;
629
+ }
630
+ }
631
+ }
632
+ // Otherwise, if the command is a launch:
633
+ else if (act.type === 'launch') {
634
+ // Launch the specified browser, creating a browser context and a page in it.
635
+ await launch(act.which);
636
+ // Identify its only page as current.
637
+ page = browserContext.pages()[0];
638
+ }
639
+ // Otherwise, if it is a score:
640
+ else if (act.type === 'score') {
641
+ // Compute and report the score.
642
+ try {
643
+ const {scorer} = require(`./procs/score/${act.which}`);
644
+ act.result = scorer(report.acts);
645
+ }
646
+ catch (error) {
647
+ act.error = `ERROR: ${error.message}\n${error.stack}`;
648
+ }
649
+ }
650
+ // Otherwise, if a current page exists:
651
+ else if (page) {
652
+ // If the command is a url:
653
+ if (act.type === 'url') {
654
+ // Visit it and wait until it is stable.
655
+ page = await visit(act, page, report.strict);
656
+ }
657
+ // Otherwise, if the act is a wait for text:
658
+ else if (act.type === 'wait') {
659
+ const {what, which} = act;
660
+ console.log(`>> for ${what} to include “${which}”`);
661
+ const waitError = (error, what) => {
662
+ console.log(`ERROR waiting for ${what} (${error.message})`);
663
+ act.result = {url: page.url()};
664
+ act.result.error = `ERROR waiting for ${what}`;
665
+ return false;
666
+ };
667
+ // Wait 5 seconds for the specified text to appear in the specified place.
668
+ let successJSHandle;
669
+ if (act.what === 'url') {
670
+ successJSHandle = await page.waitForFunction(
671
+ text => document.URL.includes(text), act.which, {timeout: 5000}
672
+ )
673
+ .catch(error => waitError(error, 'URL'));
674
+ }
675
+ else if (act.what === 'title') {
676
+ successJSHandle = await page.waitForFunction(
677
+ text => document.title.includes(text), act.which, {timeout: 5000}
678
+ )
679
+ .catch(error => waitError(error, 'title'));
680
+ }
681
+ else if (act.what === 'body') {
682
+ successJSHandle = await page.waitForFunction(
683
+ matchText => {
684
+ const innerText = document.body.innerText;
685
+ return innerText.includes(matchText);
686
+ }, which, {timeout: 5000}
687
+ )
688
+ .catch(error => waitError(error, 'body'));
689
+ }
690
+ if (successJSHandle) {
691
+ act.result = {url: page.url()};
692
+ if (act.what === 'title') {
693
+ act.result.title = await page.title();
694
+ }
695
+ await page.waitForLoadState('networkidle', {timeout: 10000})
696
+ .catch(error => {
697
+ console.log(`ERROR waiting for stability after ${act.what} (${error.message})`);
698
+ act.result.error = `ERROR waiting for stability after ${act.what}`;
699
+ });
700
+ }
701
+ }
702
+ // Otherwise, if the act is a wait for a state:
703
+ else if (act.type === 'state') {
704
+ // If the state is valid:
705
+ const stateIndex = ['loaded', 'idle'].indexOf(act.which);
706
+ if (stateIndex !== -1) {
707
+ // Wait for it.
708
+ await page.waitForLoadState(
709
+ ['domcontentloaded', 'networkidle'][stateIndex], {timeout: [10000, 5000][stateIndex]}
710
+ )
711
+ .catch(error => {
712
+ console.log(`ERROR waiting for page to be ${act.which} (${error.message})`);
713
+ act.result = `ERROR waiting for page to be ${act.which}`;
714
+ });
715
+ }
716
+ else {
717
+ console.log('ERROR: invalid state');
718
+ act.result = 'ERROR: invalid state';
719
+ }
720
+ }
721
+ // Otherwise, if the act is a page switch:
722
+ else if (act.type === 'page') {
723
+ // Wait for a page to be created and identify it as current.
724
+ page = await browserContext.waitForEvent('page');
725
+ // Wait until it is stable and thus ready for the next act.
726
+ await page.waitForLoadState('networkidle', {timeout: 20000});
727
+ // Add the resulting URL and any description of it to the act.
728
+ const result = {
729
+ url: page.url()
730
+ };
731
+ act.result = result;
732
+ }
733
+ // Otherwise, if the page has a URL:
734
+ else if (page.url() && page.url() !== 'about:blank') {
735
+ const url = page.url();
736
+ // If redirection is permitted or did not occur:
737
+ if (! report.strict || deSlash(url) === deSlash(requestedURL)) {
738
+ // Add the URL to the act.
739
+ act.url = url;
740
+ // If the act is a revelation:
741
+ if (act.type === 'reveal') {
742
+ // Make all elements in the page visible.
743
+ await require('./procs/test/allVis').allVis(page);
744
+ act.result = 'All elements visible.';
745
+ }
746
+ // Otherwise, if it is a repetitive keyboard navigation:
747
+ else if (act.type === 'presses') {
748
+ const {navKey, what, which, withItems} = act;
749
+ const matchTexts = which ? which.map(text => debloat(text)) : [];
750
+ // Initialize the loop variables.
751
+ let status = 'more';
752
+ let presses = 0;
753
+ let amountRead = 0;
754
+ let items = [];
755
+ let matchedText;
756
+ // As long as a matching element has not been reached:
757
+ while (status === 'more') {
758
+ // Press the Escape key to dismiss any modal dialog.
759
+ await page.keyboard.press('Escape');
760
+ // Press the specified navigation key.
761
+ await page.keyboard.press(navKey);
762
+ presses++;
763
+ // Identify the newly current element or a failure.
764
+ const currentJSHandle = await page.evaluateHandle(actCount => {
765
+ // Initialize it as the focused element.
766
+ let currentElement = document.activeElement;
767
+ // If it exists in the page:
768
+ if (currentElement && currentElement.tagName !== 'BODY') {
769
+ // Change it, if necessary, to its active descendant.
770
+ if (currentElement.hasAttribute('aria-activedescendant')) {
771
+ currentElement = document.getElementById(
772
+ currentElement.getAttribute('aria-activedescendant')
773
+ );
774
+ }
775
+ // Or change it, if necessary, to its selected option.
776
+ else if (currentElement.tagName === 'SELECT') {
777
+ const currentIndex = Math.max(0, currentElement.selectedIndex);
778
+ const options = currentElement.querySelectorAll('option');
779
+ currentElement = options[currentIndex];
780
+ }
781
+ // Or change it, if necessary, to its active shadow-DOM element.
782
+ else if (currentElement.shadowRoot) {
783
+ currentElement = currentElement.shadowRoot.activeElement;
784
+ }
785
+ // If there is a current element:
786
+ if (currentElement) {
787
+ // If it was already reached within this command performance:
788
+ if (currentElement.dataset.pressesReached === actCount.toString(10)) {
789
+ // Report the error.
790
+ console.log(`ERROR: ${currentElement.tagName} element reached again`);
791
+ status = 'ERROR';
792
+ return 'ERROR: locallyExhausted';
793
+ }
794
+ // Otherwise, i.e. if it is newly reached within this act:
795
+ else {
796
+ // Mark and return it.
797
+ currentElement.dataset.pressesReached = actCount;
798
+ return currentElement;
799
+ }
800
+ }
801
+ // Otherwise, i.e. if there is no current element:
802
+ else {
803
+ // Report the error.
804
+ status = 'ERROR';
805
+ return 'noActiveElement';
806
+ }
807
+ }
808
+ // Otherwise, i.e. if there is no focus in the page:
809
+ else {
810
+ // Report the error.
811
+ status = 'ERROR';
812
+ return 'ERROR: globallyExhausted';
813
+ }
814
+ }, actCount);
815
+ // If the current element exists:
816
+ const currentElement = currentJSHandle.asElement();
817
+ if (currentElement) {
818
+ // Update the data.
819
+ const tagNameJSHandle = await currentElement.getProperty('tagName');
820
+ const tagName = await tagNameJSHandle.jsonValue();
821
+ const text = await textOf(page, currentElement);
822
+ // If the text of the current element was found:
823
+ if (text !== null) {
824
+ const textLength = text.length;
825
+ // If it is non-empty and there are texts to match:
826
+ if (matchTexts.length && textLength) {
827
+ // Identify the matching text.
828
+ matchedText = matchTexts.find(matchText => text.includes(matchText));
829
+ }
830
+ // Update the item data if required.
831
+ if (withItems) {
832
+ const itemData = {
833
+ tagName,
834
+ text,
835
+ textLength
836
+ };
837
+ if (matchedText) {
838
+ itemData.matchedText = matchedText;
839
+ }
840
+ items.push(itemData);
841
+ }
842
+ amountRead += textLength;
843
+ // If there is no text-match failure:
844
+ if (matchedText || ! matchTexts.length) {
845
+ // If the element has any specified tag name:
846
+ if (! what || tagName === what) {
847
+ // Change the status.
848
+ status = 'done';
849
+ // Perform the action.
850
+ const inputText = act.text;
851
+ if (inputText) {
852
+ await page.keyboard.type(inputText);
853
+ presses += inputText.length;
854
+ }
855
+ if (act.action) {
856
+ presses++;
857
+ await page.keyboard.press(act.action);
858
+ await page.waitForLoadState();
859
+ }
860
+ }
861
+ }
862
+ }
863
+ else {
864
+ status = 'ERROR';
865
+ }
866
+ }
867
+ // Otherwise, i.e. if there was a failure:
868
+ else {
869
+ // Update the status.
870
+ status = await currentJSHandle.jsonValue();
871
+ }
872
+ }
873
+ // Add the result to the act.
874
+ act.result = {
875
+ status,
876
+ totals: {
877
+ presses,
878
+ amountRead
879
+ }
880
+ };
881
+ if (status === 'done' && matchedText) {
882
+ act.result.matchedText = matchedText;
883
+ }
884
+ if (withItems) {
885
+ act.result.items = items;
886
+ }
887
+ // Add the totals to the report.
888
+ report.presses += presses;
889
+ report.amountRead += amountRead;
890
+ }
891
+ // Otherwise, if the act is a test:
892
+ else if (act.type === 'test') {
893
+ // Add a description of the test to the act.
894
+ act.what = tests[act.which];
895
+ // Initialize the arguments.
896
+ const args = [page];
897
+ // Identify the additional validator of the test.
898
+ const testValidator = commands.tests[act.which];
899
+ // If it exists:
900
+ if (testValidator) {
901
+ // Identify its argument properties.
902
+ const argProperties = Object.keys(testValidator[1]);
903
+ // Add their values to the arguments.
904
+ args.push(...argProperties.map(propName => act[propName]));
905
+ }
906
+ // Conduct, report, and time the test.
907
+ const startTime = Date.now();
908
+ const testReport = await require(`./tests/${act.which}`).reporter(...args);
909
+ const expectations = act.expect;
910
+ // If the test has expectations:
911
+ if (expectations) {
912
+ // Initialize whether they were fulfilled.
913
+ testReport.result.expectations = [];
914
+ let failureCount = 0;
915
+ // For each expectation:
916
+ expectations.forEach(spec => {
917
+ const truth = isTrue(testReport.result, spec);
918
+ testReport.result.expectations.push({
919
+ property: spec[0],
920
+ relation: spec[1],
921
+ criterion: spec[2],
922
+ actual: truth[0],
923
+ passed: truth[1]
924
+ });
925
+ if (! truth[1]) {
926
+ failureCount++;
927
+ }
928
+ });
929
+ testReport.result.failureCount = failureCount;
930
+ }
931
+ report.testTimes.push([act.which, Math.round((Date.now() - startTime) / 1000)]);
932
+ report.testTimes.sort((a, b) => b[1] - a[1]);
933
+ // Add the result object (possibly an array) to the act.
934
+ const resultCount = Object.keys(testReport.result).length;
935
+ act.result = resultCount ? testReport.result : 'NONE';
936
+ }
937
+ // Otherwise, if the act is a move:
938
+ else if (moves[act.type]) {
939
+ const selector = typeof moves[act.type] === 'string' ? moves[act.type] : act.what;
940
+ // Identify the element to perform the move on.
941
+ const whichElement = await matchElement(page, selector, act.which || '', act.index);
942
+ // If there were enough candidates but no text match:
943
+ if (Array.isArray(whichElement)) {
944
+ // Add the result to the act.
945
+ act.result = {
946
+ candidateCount: whichElement.length,
947
+ error: 'ERROR: no element with matching text found',
948
+ candidateTexts: whichElement
949
+ };
950
+ }
951
+ // Otherwise, if the body did not contain the text:
952
+ else if (whichElement === -1) {
953
+ // Add the failure to the act.
954
+ act.result = 'ERROR: body did not contain text to match';
955
+ }
956
+ // Otherwise, if there were not enough candidates:
957
+ else if (typeof whichElement === 'number') {
958
+ // Add the failure to the act.
959
+ act.result = {
960
+ candidateCount: whichElement,
961
+ error: 'ERROR: too few such elements to allow a match'
962
+ };
963
+ }
964
+ // Otherwise, if a match was found:
965
+ else if (whichElement !== null) {
966
+ // If the move is a button click, perform it.
967
+ if (act.type === 'button') {
968
+ await whichElement.click({timeout: 3000});
969
+ act.result = 'clicked';
970
+ }
971
+ // Otherwise, if it is checking a radio button or checkbox, perform it.
972
+ else if (['checkbox', 'radio'].includes(act.type)) {
973
+ await whichElement.waitForElementState('stable', {timeout: 2000})
974
+ .catch(error => {
975
+ console.log(`ERROR waiting for stable ${act.type} (${error.message})`);
976
+ act.result = `ERROR waiting for stable ${act.type}`;
977
+ });
978
+ if (! act.result) {
979
+ const isEnabled = await whichElement.isEnabled();
980
+ if (isEnabled) {
981
+ await whichElement.check({
982
+ force: true,
983
+ timeout: 2000
984
+ })
985
+ .catch(error => {
986
+ console.log(`ERROR checking ${act.type} (${error.message})`);
987
+ act.result = `ERROR checking ${act.type}`;
988
+ });
989
+ if (! act.result) {
990
+ act.result = 'checked';
991
+ }
992
+ }
993
+ else {
994
+ const report = `ERROR: could not check ${act.type} because disabled`;
995
+ console.log(report);
996
+ act.result = report;
997
+ }
998
+ }
999
+ }
1000
+ // Otherwise, if it is focusing the element, perform it.
1001
+ else if (act.type === 'focus') {
1002
+ await whichElement.focus({timeout: 2000});
1003
+ act.result = 'focused';
1004
+ }
1005
+ // Otherwise, if it is clicking a link, perform it.
1006
+ else if (act.type === 'link') {
1007
+ const href = await whichElement.getAttribute('href');
1008
+ const target = await whichElement.getAttribute('target');
1009
+ await whichElement.click({timeout: 2000});
1010
+ act.result = {
1011
+ href: href || 'NONE',
1012
+ target: target || 'NONE',
1013
+ move: 'clicked'
1014
+ };
1015
+ }
1016
+ // Otherwise, if it is selecting an option in a select list, perform it.
1017
+ else if (act.type === 'select') {
1018
+ await whichElement.selectOption({what: act.what});
1019
+ const optionText = await whichElement.$eval(
1020
+ 'option:selected', el => el.textContent
1021
+ );
1022
+ act.result = optionText
1023
+ ? `&ldquo;${optionText}}&rdquo; selected`
1024
+ : 'ERROR: option not found';
1025
+ }
1026
+ // Otherwise, if it is entering text on the element, perform it.
1027
+ else if (act.type === 'text') {
1028
+ await whichElement.type(act.what);
1029
+ report.presses += act.what.length;
1030
+ act.result = 'entered';
1031
+ }
1032
+ // Otherwise, i.e. if the move is unknown, add the failure to the act.
1033
+ else {
1034
+ // Return an error result.
1035
+ act.result = 'ERROR: move unknown';
1036
+ }
1037
+ }
1038
+ // Otherwise, i.e. if the page was gone:
1039
+ else {
1040
+ act.result = 'ERROR: page gone, so matching element not found';
1041
+ }
1042
+ }
1043
+ // Otherwise, if the act is a keypress:
1044
+ else if (act.type === 'press') {
1045
+ // Identify the number of times to press the key.
1046
+ let times = 1 + (act.again || 0);
1047
+ report.presses += times;
1048
+ const key = act.which;
1049
+ // Press the key.
1050
+ while (times--) {
1051
+ await page.keyboard.press(key);
1052
+ }
1053
+ const qualifier = act.again ? `${1 + act.again} times` : 'once';
1054
+ act.result = `pressed ${qualifier}`;
1055
+ }
1056
+ // Otherwise, i.e. if the act type is unknown:
1057
+ else {
1058
+ // Add the error result to the act.
1059
+ act.result = 'ERROR: invalid command type';
1060
+ }
1061
+ }
1062
+ // Otherwise, i.e. if redirection is prohibited but occurred:
1063
+ else {
1064
+ // Add the error result to the act.
1065
+ act.result = `ERROR: Page URL wrong (${url})`;
1066
+ }
1067
+ }
1068
+ // Otherwise, i.e. if the required page URL does not exist:
1069
+ else {
1070
+ // Add an error result to the act.
1071
+ act.result = 'ERROR: Page has no URL';
1072
+ }
1073
+ }
1074
+ // Otherwise, i.e. if no page exists:
1075
+ else {
1076
+ // Add an error result to the act.
1077
+ act.result = 'ERROR: No page identified';
1078
+ }
1079
+ }
1080
+ // Otherwise, i.e. if the command is invalid:
1081
+ else {
1082
+ // Add an error result to the act.
1083
+ act.result = `ERROR: Invalid command of type ${act.type}`;
1084
+ }
1085
+ // Perform the remaining acts.
1086
+ await doActs(report, actIndex + 1, page);
1087
+ }
1088
+ // Otherwise, i.e. if no more acts are to be performed:
1089
+ else {
1090
+ // Return a Promise.
1091
+ console.log('All commands performed');
1092
+ return Promise.resolve('');
1093
+ }
1094
+ };
1095
+ // Performs the commands in a script and returns a report.
1096
+ const doScript = async report => {
1097
+ // Reinitialize the log statistics.
1098
+ logCount = logSize = prohibitedCount = visitTimeoutCount = visitRejectionCount= 0;
1099
+ // Add initialized properties to the report.
1100
+ report.presses = 0;
1101
+ report.amountRead = 0;
1102
+ report.testTimes = [];
1103
+ // Perform the specified acts and add the results and exhibits to the report.
1104
+ await doActs(report, 0, null);
1105
+ // Close the browser.
1106
+ await closeBrowser();
1107
+ // Add the log statistics to the report.
1108
+ report.logCount = logCount;
1109
+ report.logSize = logSize;
1110
+ report.prohibitedCount = prohibitedCount;
1111
+ report.visitTimeoutCount = visitTimeoutCount;
1112
+ report.visitRejectionCount = visitRejectionCount;
1113
+ // If logs are to be scored, do so.
1114
+ const scoreTables = report.acts.filter(act => act.type === 'score');
1115
+ if (scoreTables.length) {
1116
+ const scoreTable = scoreTables[0];
1117
+ const {result} = scoreTable;
1118
+ if (result) {
1119
+ const {logWeights, deficit} = result;
1120
+ if (logWeights && deficit) {
1121
+ deficit.log = Math.floor(
1122
+ logWeights.count * logCount
1123
+ + logWeights.size * logSize
1124
+ + logWeights.prohibited * prohibitedCount
1125
+ + logWeights.visitTimeout * visitTimeoutCount
1126
+ + logWeights.visitRejection * visitRejectionCount
1127
+ );
1128
+ deficit.total += deficit.log;
1129
+ }
1130
+ }
1131
+ }
1132
+ // Return the report.
1133
+ console.log('Script finished');
1134
+ return report;
1135
+ };
1136
+ // Injects url commands into a report where necessary to undo DOM changes.
1137
+ const injectURLCommands = commands => {
1138
+ let injectMore = true;
1139
+ while (injectMore) {
1140
+ const injectIndex = commands.findIndex((command, index) =>
1141
+ index < commands.length - 1
1142
+ && command.type === 'test'
1143
+ && commands[index + 1].type === 'test'
1144
+ && domChangers.has(command.which)
1145
+ );
1146
+ if (injectIndex === -1) {
1147
+ injectMore = false;
1148
+ }
1149
+ else {
1150
+ const lastURL = commands.reduce((url, command, index) => {
1151
+ if (command.type === 'url' && index < injectIndex) {
1152
+ return command.which;
1153
+ }
1154
+ else {
1155
+ return url;
1156
+ }
1157
+ }, '');
1158
+ commands.splice(injectIndex + 1, 0, {
1159
+ type: 'url',
1160
+ which: lastURL,
1161
+ what: 'URL'
1162
+ });
1163
+ }
1164
+ }
1165
+ };
1166
+ // Recursively performs commands on the hosts of a batch.
1167
+ const doBatch = async (report, batch, hostIndex, reportList) => {
1168
+ const {hosts} = batch;
1169
+ const host = hosts[hostIndex];
1170
+ // If the specified host exists:
1171
+ if (host) {
1172
+ // Copy the report for it.
1173
+ const hostReport = JSON.parse(JSON.stringify(report));
1174
+ // Copy the properties of the specified host to all url acts.
1175
+ hostReport.acts.forEach(act => {
1176
+ if (act.type === 'url') {
1177
+ act.which = host.which;
1178
+ act.what = host.what;
1179
+ }
1180
+ });
1181
+ // Record the batch size in the report.
1182
+ batch.size = hosts.length;
1183
+ delete batch.hosts;
1184
+ // Perform the commands on the host and produce a report.
1185
+ const finalReport = await doScript(hostReport);
1186
+ const hostSuffix = hostIndex > -1 ? `-${hostIndex.toString().padStart(3, '0')}` : '';
1187
+ const reportName = `report-${finalReport.timeStamp}${hostSuffix}.json`;
1188
+ finalReport.reportName = reportName;
1189
+ const reportPath = `${report.options.reports}/${reportName}`;
1190
+ // Save the report.
1191
+ await fs.writeFile(reportPath, JSON.stringify(finalReport, null, 2));
1192
+ // Send the report name to the console.
1193
+ reportList.push(reportName);
1194
+ // Process the remaining hosts.
1195
+ return await doBatch(hostReport, hostIndex + 1, reportList);
1196
+ }
1197
+ // Otherwise, i.e. if the hosts have been exhausted:
1198
+ else {
1199
+ // Return the list of reports.
1200
+ return reportList;
1201
+ }
1202
+ };
1203
+ // Performs a script.
1204
+ const doScriptOrBatch = async report => {
1205
+ // If the report has an options property:
1206
+ const {options} = report;
1207
+ // If there is a batch:
1208
+ if (options.batch) {
1209
+ // Perform the script on all the hosts in the batch and return a list of the reports.
1210
+ const batchJSON = await fs.readFile(options.batch, 'utf8');
1211
+ const batch = JSON.parse(batchJSON);
1212
+ const reportList = await doBatch(report, batch, 0, []);
1213
+ console.log(reportList);
1214
+ }
1215
+ // Otherwise, i.e. if there is no batch:
1216
+ else {
1217
+ // Perform the script and save the report.
1218
+ const finalReport = await doScript(report);
1219
+ const reportName = `report-${finalReport.timeStamp}.json`;
1220
+ finalReport.reportName = reportName;
1221
+ const reportPath = `${report.options.reports}/${reportName}`;
1222
+ // Save the report.
1223
+ await fs.writeFile(reportPath, JSON.stringify(finalReport, null, 2));
1224
+ // Send the report name to the console.
1225
+ console.log(`Report ${reportName} saved`);
1226
+ }
1227
+ };
1228
+ // Handles a request.
1229
+ exports.handleRequest = async options => {
1230
+ // If the options object is valid:
1231
+ if(isValidOptions(options)) {
1232
+ // Initialize a JSON report.
1233
+ const report = {options};
1234
+ // Add a timeStamp.
1235
+ report.timeStamp = Math.floor((Date.now() - Date.UTC(2021, 4)) / 10000).toString(36);
1236
+ // Copy the commands into an array of acts.
1237
+ const script = await fs.readFile(options.script);
1238
+ report.acts = JSON.parse(script).commands;
1239
+ // Inject url acts where necessary to undo DOM changes.
1240
+ injectURLCommands(report.acts);
1241
+ // Perform the script, with or without a batch, and return the report or list of reports.
1242
+ await doScriptOrBatch(report);
1243
+ console.log('Request handled');
1244
+ }
1245
+ else {
1246
+ console.log('ERROR: options missing or invalid');
1247
+ }
1248
+ };