testaro 67.0.0 → 68.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/LICENSE +4 -16
  2. package/README.md +10 -2
  3. package/UPGRADES.md +1 -1
  4. package/dirWatch.js +2 -3
  5. package/ed11y/editoria11y.min.js +109 -690
  6. package/ed11y/editoria11y210.min.js +747 -0
  7. package/netWatch.js +6 -6
  8. package/package.json +1 -1
  9. package/procs/aslint.js +2 -2
  10. package/procs/catalog.js +190 -0
  11. package/procs/{dateOf.js → dateTime.js} +6 -4
  12. package/procs/doActs.js +1227 -0
  13. package/procs/doTestAct.js +63 -29
  14. package/procs/error.js +53 -0
  15. package/procs/job.js +64 -38
  16. package/procs/launch.js +596 -0
  17. package/procs/nu.js +3 -18
  18. package/procs/shoot.js +18 -2
  19. package/procs/testaro.js +102 -125
  20. package/procs/xPath.js +62 -0
  21. package/run.js +42 -1938
  22. package/scratch/README.md +9 -0
  23. package/testaro/adbID.js +3 -3
  24. package/testaro/allCaps.js +4 -5
  25. package/testaro/allHidden.js +19 -18
  26. package/testaro/allSlanted.js +4 -5
  27. package/testaro/altScheme.js +3 -3
  28. package/testaro/attVal.js +19 -35
  29. package/testaro/autocomplete.js +65 -62
  30. package/testaro/bulk.js +21 -20
  31. package/testaro/buttonMenu.js +112 -33
  32. package/testaro/captionLoc.js +3 -3
  33. package/testaro/datalistRef.js +4 -5
  34. package/testaro/distortion.js +3 -3
  35. package/testaro/docType.js +6 -9
  36. package/testaro/dupAtt.js +12 -25
  37. package/testaro/elements.js +4 -3
  38. package/testaro/embAc.js +4 -2
  39. package/testaro/focAll.js +6 -13
  40. package/testaro/focAndOp.js +3 -3
  41. package/testaro/focInd.js +3 -3
  42. package/testaro/focVis.js +4 -3
  43. package/testaro/headEl.js +5 -12
  44. package/testaro/headingAmb.js +45 -88
  45. package/testaro/hovInd.js +5 -5
  46. package/testaro/hover.js +44 -8
  47. package/testaro/hr.js +4 -4
  48. package/testaro/imageLink.js +3 -3
  49. package/testaro/labClash.js +3 -3
  50. package/testaro/legendLoc.js +3 -3
  51. package/testaro/lineHeight.js +3 -3
  52. package/testaro/linkAmb.js +25 -17
  53. package/testaro/linkExt.js +5 -5
  54. package/testaro/linkOldAtt.js +4 -3
  55. package/testaro/linkTo.js +4 -3
  56. package/testaro/linkUl.js +4 -5
  57. package/testaro/miniText.js +4 -3
  58. package/testaro/motion.js +3 -22
  59. package/testaro/nonTable.js +4 -5
  60. package/testaro/optRoleSel.js +3 -3
  61. package/testaro/phOnly.js +3 -3
  62. package/testaro/pseudoP.js +5 -5
  63. package/testaro/radioSet.js +4 -5
  64. package/testaro/role.js +4 -5
  65. package/testaro/secHeading.js +4 -5
  66. package/testaro/shoot0.js +3 -2
  67. package/testaro/shoot1.js +3 -2
  68. package/testaro/styleDiff.js +5 -12
  69. package/testaro/tabNav.js +30 -118
  70. package/testaro/targetSmall.js +30 -15
  71. package/testaro/textNodes.js +3 -1
  72. package/testaro/textSem.js +4 -5
  73. package/testaro/title.js +4 -2
  74. package/testaro/titledEl.js +3 -3
  75. package/testaro/zIndex.js +3 -3
  76. package/tests/alfa.js +28 -54
  77. package/tests/aslint.js +20 -53
  78. package/tests/axe.js +76 -13
  79. package/tests/ed11y.js +69 -141
  80. package/tests/htmlcs.js +69 -38
  81. package/tests/ibm.js +54 -9
  82. package/tests/nuVal.js +65 -12
  83. package/tests/nuVnu.js +76 -26
  84. package/tests/qualWeb.js +89 -44
  85. package/tests/testaro.js +288 -273
  86. package/tests/wave.js +142 -117
  87. package/tests/wax.js +61 -42
  88. package/procs/getLocatorData.js +0 -192
  89. package/procs/identify.js +0 -250
  90. package/procs/isInlineLink.js +0 -42
  91. package/procs/screenShot.js +0 -32
  92. package/procs/standardize.js +0 -524
  93. package/procs/target.js +0 -90
  94. package/procs/tellServer.js +0 -43
  95. package/scripts/dumpAlts.js +0 -28
@@ -0,0 +1,1227 @@
1
+ /*
2
+ © 2021–2025 CVS Health and/or one of its affiliates. All rights reserved.
3
+ © 2025–2026 Jonathan Robert Pool.
4
+
5
+ Licensed under the MIT License. See LICENSE file at the project root or https://opensource.org/license/mit/ for details.
6
+
7
+ SPDX-License-Identifier: MIT
8
+ */
9
+
10
+ /*
11
+ doActs.js
12
+ Performs the acts of a job.
13
+ */
14
+
15
+ // IMPORTS
16
+
17
+ // Module to handle errors.
18
+ const {abortActs, addError} = require('./error');
19
+ // Function to close a browser and/or its context.
20
+ const {getNonce, goTo, launch, wait} = require('./launch');
21
+ // Constant describing the tools.
22
+ const {tools} = require('./job');
23
+ // Module to create child processes.
24
+ const {fork} = require('child_process');
25
+ const os = require('os');
26
+ // Function to prune a catalog.
27
+ const {pruneCatalog} = require('./catalog');
28
+ // Module to handle file system operations.
29
+ const fs = require('fs/promises');
30
+ const httpClient = require('http');
31
+ const httpsClient = require('https');
32
+ const agent = process.env.AGENT;
33
+
34
+ // CONSTANTS
35
+
36
+ // CSS selectors for targets of moves.
37
+ const moves = {
38
+ button: 'button, [role=button], input[type=submit]',
39
+ checkbox: 'input[type=checkbox]',
40
+ focus: true,
41
+ link: 'a, [role=link]',
42
+ radio: 'input[type=radio]',
43
+ search: 'input[type=search], input[aria-label*=search i], input[placeholder*=search i]',
44
+ select: 'select',
45
+ text: 'input'
46
+ };
47
+ // Seconds to wait between actions.
48
+ const waits = Number.parseInt(process.env.WAITS) || 0;
49
+ // Time limits in seconds on tools, accounting for page reloads by 6 Testaro tests.
50
+ const timeLimits = {
51
+ alfa: 30,
52
+ ed11y: 30,
53
+ ibm: 30,
54
+ testaro: 150 + Math.round(6 * waits / 1000)
55
+ };
56
+ // Timeout multiplier.
57
+ const timeoutMultiplier = Number.parseFloat(process.env.TIMEOUT_MULTIPLIER) || 1;
58
+
59
+ // FUNCTIONS
60
+
61
+ // Sends a notice to an observer.
62
+ const tellServer = (report, messageParams, logMessage) => {
63
+ const {serverID} = report.sources;
64
+ const observerURL = typeof serverID === 'number'
65
+ ? process.env[`NETWATCH_URL_${serverID}_OBSERVE`]
66
+ : '';
67
+ if (observerURL) {
68
+ const whoParams = `agent=${agent}&jobID=${report.id || ''}`;
69
+ const wholeURL = `${observerURL}?${whoParams}&${messageParams}`;
70
+ const client = wholeURL.startsWith('https://') ? httpsClient : httpClient;
71
+ client.request(wholeURL)
72
+ // If the notification threw an error:
73
+ .on('error', error => {
74
+ // Report the error.
75
+ const errorMessage = 'ERROR notifying the server';
76
+ console.log(`${errorMessage} (${error.message})`);
77
+ })
78
+ .end();
79
+ console.log(`${logMessage} (server notified)`);
80
+ }
81
+ };
82
+ // Normalizes spacing characters and cases in a string.
83
+ const debloat = string => string.replace(/\s/g, ' ').trim().replace(/ {2,}/g, ' ').toLowerCase();
84
+ // Returns the first line of an error message.
85
+ const errorStart = error => error.message.replace(/\n.+/s, '');
86
+ // Returns a property value and whether it satisfies an expectation.
87
+ const isTrue = (object, specs) => {
88
+ const property = specs[0];
89
+ const propertyTree = property.split('.');
90
+ let actual = property.length ? object[propertyTree[0]] : object;
91
+ // Identify the actual value of the specified property.
92
+ while (propertyTree.length > 1 && actual !== undefined) {
93
+ propertyTree.shift();
94
+ actual = actual[propertyTree[0]];
95
+ }
96
+ // If the expectation is that the property does not exist:
97
+ if (specs.length === 1) {
98
+ // Return whether the expectation is satisfied.
99
+ return [actual, actual === undefined];
100
+ }
101
+ // Otherwise, i.e. if the expectation is of a property value:
102
+ else if (specs.length === 3) {
103
+ // Return whether the expectation was fulfilled.
104
+ const relation = specs[1];
105
+ const criterion = specs[2];
106
+ let satisfied;
107
+ if (actual === undefined) {
108
+ return [null, false];
109
+ }
110
+ else if (relation === '=') {
111
+ satisfied = actual === criterion;
112
+ }
113
+ else if (relation === '<') {
114
+ satisfied = actual < criterion;
115
+ }
116
+ else if (relation === '>') {
117
+ satisfied = actual > criterion;
118
+ }
119
+ else if (relation === '!') {
120
+ satisfied = actual !== criterion;
121
+ }
122
+ else if (relation === 'i') {
123
+ satisfied = typeof actual === 'string' && actual.includes(criterion);
124
+ }
125
+ else if (relation === '!i') {
126
+ satisfied = typeof actual === 'string' && ! actual.includes(criterion);
127
+ }
128
+ else if (relation === 'e') {
129
+ satisfied = typeof actual === 'object'
130
+ && JSON.stringify(actual) === JSON.stringify(criterion);
131
+ }
132
+ return [actual, satisfied];
133
+ }
134
+ // Otherwise, i.e. if the specifications are invalid:
135
+ else {
136
+ // Return this.
137
+ return [null, false];
138
+ }
139
+ };
140
+ // Returns the browser ID of an act.
141
+ const getActBrowserID = (report, actIndex) => report?.acts[actIndex]?.browserID
142
+ || report?.browserID
143
+ || '';
144
+ // Returns the target URL of an act.
145
+ const getActTargetURL = (report, actIndex) => report?.acts[actIndex]?.target?.url
146
+ || report?.target?.url
147
+ || '';
148
+ // Returns the text of an element, lower-cased.
149
+ const textOf = async (page, element) => {
150
+ if (element) {
151
+ const tagNameJSHandle = await element.getProperty('tagName');
152
+ const tagName = await tagNameJSHandle.jsonValue();
153
+ let totalText = '';
154
+ // If the element is a link, button, input, or select list:
155
+ if (['A', 'BUTTON', 'INPUT', 'SELECT'].includes(tagName)) {
156
+ // Return its visible labels, descriptions, and legend if the first input in a fieldset.
157
+ totalText = await page.evaluate(element => {
158
+ const {tagName, ariaLabel} = element;
159
+ let ownText = '';
160
+ if (['A', 'BUTTON'].includes(tagName)) {
161
+ ownText = element.textContent;
162
+ }
163
+ else if (tagName === 'INPUT' && element.type === 'submit') {
164
+ ownText = element.value;
165
+ }
166
+ // HTML link elements have no labels property.
167
+ const labels = tagName !== 'A' ? Array.from(element.labels) : [];
168
+ const labelTexts = labels.map(label => label.textContent);
169
+ if (ariaLabel) {
170
+ labelTexts.push(ariaLabel);
171
+ }
172
+ const refIDs = new Set([
173
+ element.getAttribute('aria-labelledby') || '',
174
+ element.getAttribute('aria-describedby') || ''
175
+ ].join(' ').split(/\s+/));
176
+ if (refIDs.size) {
177
+ refIDs.forEach(id => {
178
+ const labeler = document.getElementById(id);
179
+ if (labeler) {
180
+ const labelerText = labeler.textContent.trim();
181
+ if (labelerText.length) {
182
+ labelTexts.push(labelerText);
183
+ }
184
+ }
185
+ });
186
+ }
187
+ let legendText = '';
188
+ if (tagName === 'INPUT') {
189
+ const fieldsets = Array.from(document.body.querySelectorAll('fieldset'));
190
+ const inputFieldsets = fieldsets.filter(fieldset => {
191
+ const inputs = Array.from(fieldset.querySelectorAll('input'));
192
+ return inputs.length && inputs[0] === element;
193
+ });
194
+ const inputFieldset = inputFieldsets[0] || null;
195
+ if (inputFieldset) {
196
+ const legend = inputFieldset.querySelector('legend');
197
+ if (legend) {
198
+ legendText = legend.textContent;
199
+ }
200
+ }
201
+ }
202
+ return [legendText].concat(labelTexts, ownText).join(' ');
203
+ }, element);
204
+ }
205
+ // Otherwise, if it is an option:
206
+ else if (tagName === 'OPTION') {
207
+ // Return its text content, prefixed with the text of its select parent if the first option.
208
+ const ownText = await element.textContent();
209
+ const indexJSHandle = await element.getProperty('index');
210
+ const index = await indexJSHandle.jsonValue();
211
+ if (index) {
212
+ totalText = ownText;
213
+ }
214
+ else {
215
+ const selectJSHandle = await page.evaluateHandle(
216
+ element => element.parentElement, element
217
+ );
218
+ const select = await selectJSHandle.asElement();
219
+ if (select) {
220
+ const selectText = await textOf(page, select);
221
+ totalText = [ownText, selectText].join(' ');
222
+ }
223
+ else {
224
+ totalText = ownText;
225
+ }
226
+ }
227
+ }
228
+ // Otherwise, i.e. if it is not an input, select, or option:
229
+ else {
230
+ // Get its text content.
231
+ totalText = await element.textContent();
232
+ }
233
+ return debloat(totalText);
234
+ }
235
+ else {
236
+ return null;
237
+ }
238
+ };
239
+ // Adds a wait error result to an act.
240
+ const waitError = (page, act, error, what) => {
241
+ console.log(`ERROR waiting for ${what} (${error.message})`);
242
+ act.result.found = false;
243
+ act.result.url = page.url();
244
+ act.result.error = `ERROR waiting for ${what}`;
245
+ return false;
246
+ };
247
+ // Performs the acts in a report and adds the results to the report.
248
+ exports.doActs = async (report, opts = {}) => {
249
+ // Make a local copy of the report.
250
+ let localReport = JSON.parse(JSON.stringify(report));
251
+ let page = null;
252
+ let {acts} = localReport;
253
+ // Get the granular observation options, if any.
254
+ const {onProgress = null, signal = null} = opts;
255
+ // Get the standardization specification.
256
+ const standard = localReport.standard || 'only';
257
+ // Set the temporary directory.
258
+ let tmpDir = `${__dirname}/../${process.env.TMPDIRNAME || 'scratch'}`;
259
+ try {
260
+ await fs.access(tmpDir, fs.constants.W_OK);
261
+ }
262
+ catch(error) {
263
+ console.log(`ERROR: ${tmpDir} is not writable`);
264
+ tmpDir = os.tmpdir();
265
+ try {
266
+ await fs.access(tmpDir, fs.constants.W_OK);
267
+ }
268
+ catch(error) {
269
+ console.log(`ERROR: ${tmpDir} is not writable`);
270
+ tmpDir = '/tmp';
271
+ try {
272
+ await fs.access(tmpDir, fs.constants.W_OK);
273
+ }
274
+ catch(error) {
275
+ console.log(`ERROR: ${tmpDir} is not writable; quitting`);
276
+ process.exit(1);
277
+ }
278
+ }
279
+ }
280
+ // Get a path for temporary reports.
281
+ const reportPath = `${tmpDir}/${localReport.id}.json`;
282
+ // Initialize the count of completed acts.
283
+ let actCount = 0;
284
+ // For each act in the local report:
285
+ for (const actIndex in acts) {
286
+ // If the job has been aborted by a signal:
287
+ if (signal && signal.aborted) {
288
+ // Report this.
289
+ throw new Error('doActs aborted');
290
+ }
291
+ // Otherwise, and if the job has not been aborted internally:
292
+ if (localReport.jobData && ! localReport.jobData.aborted) {
293
+ let act = acts[actIndex];
294
+ const {type, which} = act;
295
+ const actSuffix = type === 'test' ? ` ${which}` : '';
296
+ const message = `>>>> ${type}${actSuffix}`;
297
+ // If granular reporting has been specified:
298
+ if (localReport.observe) {
299
+ const whichParam = which ? `&which=${which}` : '';
300
+ const messageParams = `act=${type}${whichParam}`;
301
+ // If a progress callback has been provided by a caller on this host:
302
+ if (onProgress) {
303
+ // Notify the observer of the act.
304
+ try {
305
+ onProgress({
306
+ type,
307
+ which
308
+ });
309
+ console.log(`${message} (observer notified)`);
310
+ }
311
+ catch (error) {
312
+ console.log(`${message} (observer notification failed: ${errorStart(error)})`);
313
+ }
314
+ }
315
+ // Otherwise, i.e. if no progress callback has been provided:
316
+ else {
317
+ // Notify the remote observer of the act and log it.
318
+ tellServer(localReport, messageParams, message);
319
+ }
320
+ }
321
+ // Otherwise, i.e. if granular reporting has not been specified:
322
+ else {
323
+ // Log the act.
324
+ console.log(message);
325
+ }
326
+ // If the act is an index changer:
327
+ if (type === 'next') {
328
+ const condition = act.if;
329
+ const logSuffix = condition.length === 3 ? ` ${condition[1]} ${condition[2]}` : '';
330
+ console.log(`>> ${condition[0]}${logSuffix}`);
331
+ // Identify the act to be checked.
332
+ const ifActIndex = acts.map(act => act.type !== 'next').lastIndexOf(true);
333
+ // Determine whether its jump condition is true.
334
+ const truth = isTrue(acts[ifActIndex].result, condition);
335
+ // Add the result to the act.
336
+ act.result = {
337
+ property: condition[0],
338
+ relation: condition[1],
339
+ criterion: condition[2],
340
+ value: truth[0],
341
+ jumpRequired: truth[1]
342
+ };
343
+ // If the condition is true:
344
+ if (truth[1]) {
345
+ // If the performance of acts is to stop:
346
+ if (act.jump === 0) {
347
+ // Quit.
348
+ break;
349
+ }
350
+ // Otherwise, if there is a numerical jump:
351
+ else if (act.jump) {
352
+ // Set the act index accordingly.
353
+ actIndex += act.jump - 1;
354
+ }
355
+ // Otherwise, if there is a named next act:
356
+ else if (act.next) {
357
+ // Set the new index accordingly, or stop if it does not exist.
358
+ actIndex = acts.map(act => act.name).indexOf(act.next) - 1;
359
+ }
360
+ }
361
+ }
362
+ // Otherwise, if the act is a launch:
363
+ else if (type === 'launch') {
364
+ // Launch a browser, navigate to a page, and add the result to the act.
365
+ page = await launch({
366
+ localReport,
367
+ actIndex,
368
+ tempBrowserID: getActBrowserID(localReport, actIndex),
369
+ tempURL: getActTargetURL(localReport, actIndex),
370
+ xPathNeed: 'none'
371
+ });
372
+ // If this failed:
373
+ if (! page) {
374
+ // Add this to the act.
375
+ addError(false, false, localReport, actIndex, page.error ?? '');
376
+ }
377
+ }
378
+ // Otherwise, if the act is a test act:
379
+ else if (type === 'test') {
380
+ // Add a description of the tool to the act.
381
+ act.what = tools[act.which];
382
+ // Get the start time of the act.
383
+ const startTime = Date.now();
384
+ // Add it to the act.
385
+ act.startTime = startTime;
386
+ let localReportJSON = JSON.stringify(localReport);
387
+ // Save a copy of the local report, which the child process will read.
388
+ await fs.writeFile(reportPath, localReportJSON);
389
+ let timedOut = false;
390
+ const limitMs = timeoutMultiplier * 1000 * (timeLimits[act.which] || 15);
391
+ const actResult = await new Promise(resolve => {
392
+ let closed = false;
393
+ // Create a child process to perform the act.
394
+ const child = fork(`${__dirname}/doTestAct`, [reportPath, actIndex]);
395
+ let killTimer = null;
396
+ // Start a timeout timer for the child process.
397
+ const timeoutTimer = setTimeout(() => {
398
+ if (! timedOut) {
399
+ timedOut = true;
400
+ console.log(`ERROR: Timed out at ${Math.round(limitMs / 1000)} seconds`);
401
+ child.kill('SIGTERM');
402
+ killTimer = setTimeout(() => {
403
+ if (! closed) {
404
+ console.log('ERROR: Failed to exit on SIGTERM from parent')
405
+ }
406
+ child.kill('SIGKILL');
407
+ }, 2000);
408
+ }
409
+ }, limitMs);
410
+ // Clears any current timers.
411
+ const clearTimers = () => {
412
+ [timeoutTimer, killTimer].forEach(timer => {
413
+ if (timer) {
414
+ clearTimeout(timer);
415
+ }
416
+ });
417
+ };
418
+ // If the child process sends a message (normally Act completed):
419
+ child.on('message', message => {
420
+ if (! closed) {
421
+ closed = true;
422
+ clearTimers();
423
+ // Return the message.
424
+ resolve({
425
+ kind: 'message',
426
+ message
427
+ });
428
+ }
429
+ });
430
+ // If the child process sends an error:
431
+ child.on('error', error => {
432
+ if (! closed) {
433
+ closed = true;
434
+ clearTimers();
435
+ // Return the error message.
436
+ resolve({
437
+ kind: 'error',
438
+ error: error.message
439
+ });
440
+ }
441
+ });
442
+ // If the child process closes:
443
+ child.on('close', (code, signal) => {
444
+ if (! closed) {
445
+ closed = true;
446
+ clearTimers();
447
+ // Return the exit code, signal, and timeout status.
448
+ resolve({
449
+ kind: 'close',
450
+ code,
451
+ signal,
452
+ timedOut
453
+ });
454
+ }
455
+ });
456
+ });
457
+ // If the child process sent a message:
458
+ if (actResult.kind === 'message') {
459
+ // Get the revised localReport file.
460
+ localReportJSON = await fs.readFile(reportPath, 'utf8');
461
+ try {
462
+ // Reassign it to the local report.
463
+ localReport = JSON.parse(localReportJSON);
464
+ // Redefine the acts as those in the revised local report.
465
+ ({acts} = localReport);
466
+ }
467
+ // If the conversion fails, leaving the local report and its acts unchanged:
468
+ catch (error) {
469
+ // Report this.
470
+ console.log(
471
+ `ERROR: Tool sent message ${actResult.message}. Report is no longer JSON (${error.message}) but is instead a(n) ${typeof localReportJSON} of length ${localReportJSON.length}:\n${localReportJSON}`
472
+ );
473
+ // Add the error data to the act.
474
+ addError(
475
+ false,
476
+ false,
477
+ localReport,
478
+ actIndex,
479
+ `Non-JSON local report file after message ${actResult.message}`
480
+ );
481
+ }
482
+ }
483
+ // Otherwise, i.e. if the child process closed abnormally:
484
+ else {
485
+ // Add the error data to the act.
486
+ const {code, error, kind, signal} = actResult;
487
+ if (kind === 'close' && timedOut) {
488
+ addError(
489
+ false, false, localReport, actIndex, `Timed out at ${Math.round(limitMs / 1000)} seconds`
490
+ );
491
+ }
492
+ else if (kind === 'close') {
493
+ addError(
494
+ true, false, localReport, actIndex, `Closed with code ${code} and signal ${signal})`
495
+ );
496
+ }
497
+ else {
498
+ addError(
499
+ true, false, localReport, actIndex, `Terminated with error ${error}`
500
+ );
501
+ }
502
+ }
503
+ // Get the (usually revised) act.
504
+ act = acts[actIndex];
505
+ // Add the elapsed time of the tool to the local report.
506
+ const time = Math.round((Date.now() - startTime) / 1000);
507
+ const {toolTimes} = localReport.jobData;
508
+ toolTimes[act.which] ??= 0;
509
+ toolTimes[act.which] += time;
510
+ // If the act was not prevented:
511
+ if (act.data && ! act.data.prevented) {
512
+ const expectations = act.expect;
513
+ // If the act has expectations:
514
+ if (expectations) {
515
+ // Initialize whether they were fulfilled.
516
+ act.expectations = [];
517
+ let failureCount = 0;
518
+ // For each expectation:
519
+ expectations.forEach(spec => {
520
+ // Add its result to the act.
521
+ const truth = isTrue(act, spec);
522
+ act.expectations.push({
523
+ property: spec[0],
524
+ relation: spec[1],
525
+ criterion: spec[2],
526
+ actual: truth[0],
527
+ passed: truth[1]
528
+ });
529
+ if (! truth[1]) {
530
+ failureCount++;
531
+ }
532
+ });
533
+ act.expectationFailures = failureCount;
534
+ }
535
+ }
536
+ }
537
+ // Otherwise, if a current page exists:
538
+ else if (page) {
539
+ // If the act is navigation to a url:
540
+ if (type === 'url') {
541
+ // Identify the URL.
542
+ const resolved = act.which.replace('__dirname', __dirname);
543
+ requestedURL = resolved;
544
+ // Visit it and wait until the DOM is loaded.
545
+ const navResult = await goTo(localReport, page, requestedURL, 15000, 'domcontentloaded');
546
+ // If the visit succeeded:
547
+ if (navResult.success) {
548
+ // Revise the local report URL to this URL.
549
+ localReport.target.url = requestedURL;
550
+ // Add the script nonce, if any, to the act.
551
+ const {response} = navResult;
552
+ const scriptNonce = getNonce(response);
553
+ if (scriptNonce) {
554
+ localReport.jobData.lastScriptNonce = scriptNonce;
555
+ }
556
+ // Add the resulting URL to the act.
557
+ if (! act.result) {
558
+ act.result = {};
559
+ }
560
+ act.result.url = page.url();
561
+ // If a prohibited redirection occurred:
562
+ if (response.exception === 'badRedirection') {
563
+ // Report this.
564
+ addError(true, false, localReport, actIndex, 'ERROR: Navigation illicitly redirected');
565
+ }
566
+ }
567
+ // Otherwise, i.e. if the visit failed:
568
+ else {
569
+ // Report this.
570
+ addError(true, false, localReport, actIndex, 'ERROR: Visit failed');
571
+ }
572
+ }
573
+ // Otherwise, if the act is a wait for text:
574
+ else if (type === 'wait') {
575
+ const {what, which} = act;
576
+ console.log(`>> ${what}`);
577
+ const result = act.result = {};
578
+ // If the text is to be the URL:
579
+ if (what === 'url') {
580
+ // Wait for the URL to be the exact text.
581
+ try {
582
+ await page.waitForURL(which, {timeout: 15000});
583
+ result.found = true;
584
+ result.url = page.url();
585
+ }
586
+ // If the wait times out:
587
+ catch(error) {
588
+ // Quit.
589
+ abortActs(localReport, actIndex);
590
+ waitError(page, act, error, 'text in the URL');
591
+ }
592
+ }
593
+ // Otherwise, if the text is to be a substring of the page title:
594
+ else if (what === 'title') {
595
+ // Wait for the page title to include the text, case-insensitively.
596
+ try {
597
+ await page.waitForFunction(
598
+ text => document
599
+ && document.title
600
+ && document.title.toLowerCase().includes(text.toLowerCase()),
601
+ which,
602
+ {
603
+ polling: 1000,
604
+ timeout: 5000
605
+ }
606
+ );
607
+ result.found = true;
608
+ result.title = await page.title();
609
+ }
610
+ // If the wait times out:
611
+ catch(error) {
612
+ // Quit.
613
+ abortActs(localReport, actIndex);
614
+ waitError(page, act, error, 'text in the title');
615
+ }
616
+ }
617
+ // Otherwise, if the text is to be a substring of the text of the page body:
618
+ else if (what === 'body') {
619
+ // Wait for the body to include the text, case-insensitively.
620
+ try {
621
+ await page.waitForFunction(
622
+ text => document
623
+ && document.body
624
+ && document.body.innerText.toLowerCase().includes(text.toLowerCase()),
625
+ which,
626
+ {
627
+ polling: 2000,
628
+ timeout: 15000
629
+ }
630
+ );
631
+ result.found = true;
632
+ }
633
+ // If the wait times out:
634
+ catch(error) {
635
+ // Quit.
636
+ abortActs(localReport, actIndex);
637
+ waitError(page, act, error, 'text in the body');
638
+ }
639
+ }
640
+ }
641
+ // Otherwise, if the act is a wait for a state:
642
+ else if (type === 'state') {
643
+ // Wait for it.
644
+ const stateIndex = ['loaded', 'idle'].indexOf(act.which);
645
+ await page.waitForLoadState(
646
+ ['domcontentloaded', 'networkidle'][stateIndex], {timeout: [10000, 15000][stateIndex]}
647
+ )
648
+ // If the wait times out:
649
+ .catch(async error => {
650
+ // Report this and abort the job.
651
+ console.log(`ERROR waiting for page to be ${act.which} (${error.message})`);
652
+ addError(true, false, localReport, actIndex, `ERROR waiting for page to be ${act.which}`);
653
+ });
654
+ // If the wait succeeded:
655
+ if (actIndex > -2) {
656
+ // Add state data to the local report.
657
+ act.result = {
658
+ success: true,
659
+ state: act.which
660
+ };
661
+ }
662
+ }
663
+ // Otherwise, if the act is a page switch:
664
+ else if (type === 'page') {
665
+ const context = page.context();
666
+ page = await context.waitForEvent('page');
667
+ // Wait until it is idle.
668
+ await page.waitForLoadState('networkidle', {timeout: 15000});
669
+ // Add the resulting URL to the act.
670
+ const result = {
671
+ url: page.url()
672
+ };
673
+ act.result = result;
674
+ }
675
+ // Otherwise, if the page has a URL:
676
+ else if (page.url() && page.url() !== 'about:blank') {
677
+ const url = page.url();
678
+ // Add the URL to the act.
679
+ act.actualURL = url;
680
+ // If the act is a revelation:
681
+ if (type === 'reveal') {
682
+ act.result = {
683
+ success: true
684
+ };
685
+ // Make all elements in the page visible.
686
+ await page.$$eval('body *', elements => {
687
+ elements.forEach(element => {
688
+ const styleDec = window.getComputedStyle(element);
689
+ if (styleDec.display === 'none') {
690
+ element.style.display = 'initial';
691
+ }
692
+ if (['hidden', 'collapse'].includes(styleDec.visibility)) {
693
+ element.style.visibility = 'inherit';
694
+ }
695
+ });
696
+ })
697
+ .catch(error => {
698
+ console.log(`ERROR making all elements visible (${error.message})`);
699
+ act.result.success = false;
700
+ });
701
+ }
702
+ // Otherwise, if the act is a move:
703
+ else if (moves[type]) {
704
+ const selector = typeof moves[type] === 'string' ? moves[type] : act.what;
705
+ // Try up to 5 times to:
706
+ act.result = {found: false};
707
+ let selection = {};
708
+ let tries = 0;
709
+ const slimText = act.which ? debloat(act.which) : '';
710
+ while (tries++ < 5 && ! act.result.found) {
711
+ if (page) {
712
+ // Identify the elements of the specified type.
713
+ const selections = await page.$$(selector);
714
+ // If there are any:
715
+ if (selections.length) {
716
+ // If there are enough to make a match possible:
717
+ if ((act.index || 0) < selections.length) {
718
+ // For each element of the specified type:
719
+ let matchCount = 0;
720
+ const selectionTexts = [];
721
+ for (selection of selections) {
722
+ // Add its lower-case text or an empty string to the list of element texts.
723
+ const selectionText = slimText ? await textOf(page, selection) : '';
724
+ selectionTexts.push(selectionText);
725
+ // If its text includes any specified text, case-insensitively:
726
+ if (selectionText.includes(slimText)) {
727
+ // If the element has the specified index among such elements:
728
+ if (matchCount++ === (act.index || 0)) {
729
+ // Report it as the matching element and stop checking.
730
+ act.result.found = true;
731
+ act.result.textSpec = slimText;
732
+ act.result.textContent = selectionText;
733
+ break;
734
+ }
735
+ }
736
+ }
737
+ // If no element satisfied the specifications:
738
+ if (! act.result.found) {
739
+ // Add the failure data to the local report.
740
+ act.result.success = false;
741
+ act.result.error = 'exhausted';
742
+ act.result.typeElementCount = selections.length;
743
+ if (slimText) {
744
+ act.result.textElementCount = --matchCount;
745
+ }
746
+ act.result.message = 'Not enough specified elements exist';
747
+ act.result.candidateTexts = selectionTexts;
748
+ }
749
+ }
750
+ // Otherwise, i.e. if there are too few such elements to make a match possible:
751
+ else {
752
+ // Add the failure data to the local report.
753
+ act.result.success = false;
754
+ act.result.error = 'fewer';
755
+ act.result.typeElementCount = selections.length;
756
+ act.result.message = 'Elements of specified type too few';
757
+ }
758
+ }
759
+ // Otherwise, i.e. if there are no elements of the specified type:
760
+ else {
761
+ // Add the failure data to the local report.
762
+ act.result.success = false;
763
+ act.result.error = 'none';
764
+ act.result.typeElementCount = 0;
765
+ act.result.message = 'No elements of specified type found';
766
+ }
767
+ }
768
+ // Otherwise, i.e. if the page no longer exists:
769
+ else {
770
+ // Add the failure data to the local report.
771
+ act.result.success = false;
772
+ act.result.error = 'gone';
773
+ act.result.message = 'Page gone';
774
+ }
775
+ if (! act.result.found) {
776
+ await wait(2000);
777
+ }
778
+ }
779
+ // If a match was found:
780
+ if (act.result.found) {
781
+ // FUNCTION DEFINITION START
782
+ // Performs a click or Enter keypress and waits for the network to be idle.
783
+ const doAndWait = async isClick => {
784
+ // Perform and report the move.
785
+ const move = isClick ? 'click' : 'Enter keypress';
786
+ try {
787
+ await isClick
788
+ ? selection.click({timeout: 4000})
789
+ : selection.press('Enter', {timeout: 4000});
790
+ act.result.success = true;
791
+ act.result.move = move;
792
+ }
793
+ // If the move fails:
794
+ catch(error) {
795
+ // Add the error result to the act and abort the job.
796
+ addError(true, false, localReport, actIndex, `ERROR: ${move} failed`);
797
+ }
798
+ if (act.result.success) {
799
+ try {
800
+ await page.context().waitForEvent('networkidle', {timeout: 10000});
801
+ act.result.idleTimely = true;
802
+ }
803
+ catch(error) {
804
+ console.log(`ERROR: Network busy after ${move} (${errorStart(error)})`);
805
+ act.result.idleTimely = false;
806
+ }
807
+ // Add the page URL to the result.
808
+ act.result.newURL = page.url();
809
+ }
810
+ };
811
+ // FUNCTION DEFINITION END
812
+ // If the move is a button click, perform it.
813
+ if (type === 'button') {
814
+ await selection.click({timeout: 3000});
815
+ act.result.success = true;
816
+ act.result.move = 'clicked';
817
+ }
818
+ // Otherwise, if it is checking a radio button or checkbox, perform it.
819
+ else if (['checkbox', 'radio'].includes(type)) {
820
+ await selection.waitForElementState('stable', {timeout: 2000})
821
+ .catch(error => {
822
+ console.log(`ERROR waiting for stable ${type} (${error.message})`);
823
+ act.result.success = false;
824
+ act.result.error = `ERROR waiting for stable ${type}`;
825
+ });
826
+ if (! act.result.error) {
827
+ const isEnabled = await selection.isEnabled();
828
+ if (isEnabled) {
829
+ await selection.check({
830
+ force: true,
831
+ timeout: 2000
832
+ })
833
+ .catch(error => {
834
+ console.log(`ERROR checking ${type} (${error.message})`);
835
+ act.result.success = false;
836
+ act.result.error = `ERROR checking ${type}`;
837
+ });
838
+ if (! act.result.error) {
839
+ act.result.success = true;
840
+ act.result.move = 'checked';
841
+ }
842
+ }
843
+ else {
844
+ const message = `ERROR: could not check ${type} because disabled`;
845
+ act.result.success = false;
846
+ act.result.error = message;
847
+ }
848
+ }
849
+ }
850
+ // Otherwise, if it is focusing the element, perform it.
851
+ else if (type === 'focus') {
852
+ await selection.focus({timeout: 2000});
853
+ act.result.success = true;
854
+ act.result.move = 'focused';
855
+ }
856
+ // Otherwise, if it is clicking a link:
857
+ else if (type === 'link') {
858
+ const href = await selection.getAttribute('href');
859
+ const target = await selection.getAttribute('target');
860
+ act.result.href = href || 'NONE';
861
+ act.result.target = target || 'DEFAULT';
862
+ // If the destination is a new page:
863
+ if (target && target !== '_self') {
864
+ // Click the link and wait for the network to be idle.
865
+ doAndWait(true);
866
+ }
867
+ // Otherwise, i.e. if the destination is in the current page:
868
+ else {
869
+ // Click the link and wait for the resulting navigation.
870
+ try {
871
+ await selection.click({timeout: 5000});
872
+ // Wait for the new content to load.
873
+ await page.waitForLoadState('domcontentloaded', {timeout: 6000});
874
+ act.result.success = true;
875
+ act.result.move = 'clicked';
876
+ act.result.newURL = page.url();
877
+ }
878
+ // If the click or load failed:
879
+ catch(error) {
880
+ // Quit and add failure data to the local report.
881
+ console.log(`ERROR clicking link (${errorStart(error)})`);
882
+ act.result.success = false;
883
+ act.result.error = 'unclickable';
884
+ act.result.message = 'ERROR: click or load timed out';
885
+ abortActs(localReport, actIndex);
886
+ }
887
+ // If the link click succeeded:
888
+ if (! act.result.error) {
889
+ // Add success data to the local report.
890
+ act.result.success = true;
891
+ act.result.move = 'clicked';
892
+ }
893
+ }
894
+ }
895
+ // Otherwise, if it is selecting an option in a select list, perform it.
896
+ else if (type === 'select') {
897
+ const options = await selection.$$('option');
898
+ let optionText = '';
899
+ if (options && Array.isArray(options) && options.length) {
900
+ const optionTexts = [];
901
+ for (const option of options) {
902
+ const optionText = await option.textContent();
903
+ optionTexts.push(optionText);
904
+ }
905
+ const matchTexts = optionTexts.map(
906
+ (text, index) => text.includes(act.what) ? index : -1
907
+ );
908
+ const index = matchTexts.filter(text => text > -1)[act.index || 0];
909
+ if (index !== undefined) {
910
+ await selection.selectOption({index});
911
+ optionText = optionTexts[index];
912
+ }
913
+ }
914
+ act.result.success = true;
915
+ act.result.move = 'selected';
916
+ act.result.option = optionText;
917
+ }
918
+ // Otherwise, if it is entering text in an input element:
919
+ else if (['text', 'search'].includes(type)) {
920
+ act.result.attributes = {};
921
+ const {attributes} = act.result;
922
+ const type = await selection.getAttribute('type');
923
+ const label = await selection.getAttribute('aria-label');
924
+ const labelRefs = await selection.getAttribute('aria-labelledby');
925
+ attributes.type = type || '';
926
+ attributes.label = label || '';
927
+ attributes.labelRefs = labelRefs || '';
928
+ // If the text contains a placeholder for an environment variable:
929
+ let {what} = act;
930
+ if (/__[A-Z]+__/.test(what)) {
931
+ // Replace it.
932
+ const envKey = /__([A-Z]+)__/.exec(what)[1];
933
+ const envValue = process.env[envKey];
934
+ what = what.replace(/__[A-Z]+__/, envValue);
935
+ }
936
+ // Enter the text.
937
+ await selection.type(what);
938
+ localReport.jobData.presses += what.length;
939
+ act.result.success = true;
940
+ act.result.move = 'entered';
941
+ // If the input is a search input:
942
+ if (type === 'search') {
943
+ // Press the Enter key and wait for a network to be idle.
944
+ doAndWait(false);
945
+ }
946
+ }
947
+ // Otherwise, i.e. if the move is unknown, add the failure to the act.
948
+ else {
949
+ // Report the error.
950
+ const message = 'ERROR: move unknown';
951
+ act.result.success = false;
952
+ act.result.error = message;
953
+ }
954
+ }
955
+ // Otherwise, i.e. if no match was found:
956
+ else {
957
+ // Quit and add failure data to the local report.
958
+ act.result.success = false;
959
+ act.result.error = 'absent';
960
+ act.result.message = 'ERROR: specified element not found';
961
+ console.log('ERROR: Specified element not found');
962
+ abortActs(localReport, actIndex);
963
+ }
964
+ }
965
+ // Otherwise, if the act is a keypress:
966
+ else if (type === 'press') {
967
+ // Identify the number of times to press the key.
968
+ let times = 1 + (act.again || 0);
969
+ localReport.jobData.presses += times;
970
+ const key = act.which;
971
+ // Press the key.
972
+ while (times--) {
973
+ await page.keyboard.press(key);
974
+ }
975
+ const qualifier = act.again ? `${1 + act.again} times` : 'once';
976
+ act.result = {
977
+ success: true,
978
+ message: `pressed ${qualifier}`
979
+ };
980
+ }
981
+ // Otherwise, if it is a repetitive keyboard navigation:
982
+ else if (type === 'presses') {
983
+ const {navKey, what, which, withItems} = act;
984
+ const matchTexts = which ? which.map(text => debloat(text)) : [];
985
+ // Initialize the loop variables.
986
+ let status = 'more';
987
+ let presses = 0;
988
+ let amountRead = 0;
989
+ let items = [];
990
+ let matchedText;
991
+ // As long as a matching element has not been reached:
992
+ while (status === 'more') {
993
+ // Press the Escape key to dismiss any modal dialog.
994
+ await page.keyboard.press('Escape');
995
+ // Press the specified navigation key.
996
+ await page.keyboard.press(navKey);
997
+ presses++;
998
+ // Identify the newly current element or a failure.
999
+ const currentJSHandle = await page.evaluateHandle(actCount => {
1000
+ // Initialize it as the focused element.
1001
+ let currentElement = document.activeElement;
1002
+ // If it exists in the page:
1003
+ if (currentElement && currentElement.tagName !== 'BODY') {
1004
+ // Change it, if necessary, to its active descendant.
1005
+ if (currentElement.hasAttribute('aria-activedescendant')) {
1006
+ currentElement = document.getElementById(
1007
+ currentElement.getAttribute('aria-activedescendant')
1008
+ );
1009
+ }
1010
+ // Or change it, if necessary, to its selected option.
1011
+ else if (currentElement.tagName === 'SELECT') {
1012
+ const currentIndex = Math.max(0, currentElement.selectedIndex);
1013
+ const options = currentElement.querySelectorAll('option');
1014
+ currentElement = options[currentIndex];
1015
+ }
1016
+ // Or change it, if necessary, to its active shadow-DOM element.
1017
+ else if (currentElement.shadowRoot) {
1018
+ currentElement = currentElement.shadowRoot.activeElement;
1019
+ }
1020
+ // If there is a current element:
1021
+ if (currentElement) {
1022
+ // If it was already reached within this act:
1023
+ if (currentElement.dataset.pressesReached === actCount.toString(10)) {
1024
+ // Report the error.
1025
+ console.log(`ERROR: ${currentElement.tagName} element reached again`);
1026
+ status = 'ERROR';
1027
+ return 'ERROR: locallyExhausted';
1028
+ }
1029
+ // Otherwise, i.e. if it is newly reached within this act:
1030
+ else {
1031
+ // Mark and return it.
1032
+ currentElement.dataset.pressesReached = actCount;
1033
+ return currentElement;
1034
+ }
1035
+ }
1036
+ // Otherwise, i.e. if there is no current element:
1037
+ else {
1038
+ // Report the error.
1039
+ status = 'ERROR';
1040
+ return 'noActiveElement';
1041
+ }
1042
+ }
1043
+ // Otherwise, i.e. if there is no focus in the page:
1044
+ else {
1045
+ // Report the error.
1046
+ status = 'ERROR';
1047
+ return 'ERROR: globallyExhausted';
1048
+ }
1049
+ }, actCount);
1050
+ // If the current element exists:
1051
+ const currentElement = currentJSHandle.asElement();
1052
+ if (currentElement) {
1053
+ // Update the data.
1054
+ const tagNameJSHandle = await currentElement.getProperty('tagName');
1055
+ const tagName = await tagNameJSHandle.jsonValue();
1056
+ const text = await textOf(page, currentElement);
1057
+ // If the text of the current element was found:
1058
+ if (text !== null) {
1059
+ const textLength = text.length;
1060
+ // If it is non-empty and there are texts to match:
1061
+ if (matchTexts.length && textLength) {
1062
+ // Identify the matching text.
1063
+ matchedText = matchTexts.find(matchText => text.includes(matchText));
1064
+ }
1065
+ // Update the item data if required.
1066
+ if (withItems) {
1067
+ const itemData = {
1068
+ tagName,
1069
+ text,
1070
+ textLength
1071
+ };
1072
+ if (matchedText) {
1073
+ itemData.matchedText = matchedText;
1074
+ }
1075
+ items.push(itemData);
1076
+ }
1077
+ amountRead += textLength;
1078
+ // If there is no text-match failure:
1079
+ if (matchedText || ! matchTexts.length) {
1080
+ // If the element has any specified tag name:
1081
+ if (! what || tagName === what) {
1082
+ // Change the status.
1083
+ status = 'done';
1084
+ // Perform the action.
1085
+ const inputText = act.text;
1086
+ if (inputText) {
1087
+ await page.keyboard.type(inputText);
1088
+ presses += inputText.length;
1089
+ }
1090
+ if (act.action) {
1091
+ presses++;
1092
+ await page.keyboard.press(act.action);
1093
+ await page.waitForLoadState();
1094
+ }
1095
+ }
1096
+ }
1097
+ }
1098
+ else {
1099
+ status = 'ERROR';
1100
+ }
1101
+ }
1102
+ // Otherwise, i.e. if there was a failure:
1103
+ else {
1104
+ // Update the status.
1105
+ status = await currentJSHandle.jsonValue();
1106
+ }
1107
+ }
1108
+ // Add the result to the act.
1109
+ act.result = {
1110
+ success: true,
1111
+ status,
1112
+ totals: {
1113
+ presses,
1114
+ amountRead
1115
+ }
1116
+ };
1117
+ if (status === 'done' && matchedText) {
1118
+ act.result.matchedText = matchedText;
1119
+ }
1120
+ if (withItems) {
1121
+ act.result.items = items;
1122
+ }
1123
+ // Add the totals to the local report.
1124
+ localReport.jobData.presses += presses;
1125
+ localReport.jobData.amountRead += amountRead;
1126
+ }
1127
+ // Otherwise, i.e. if the act type is unknown:
1128
+ else {
1129
+ // Add the error result to the act and abort the job.
1130
+ addError(true, false, localReport, actIndex, 'ERROR: Invalid act type');
1131
+ }
1132
+ }
1133
+ // Otherwise, a page URL is required but does not exist, so:
1134
+ else {
1135
+ // Add an error result to the act and abort the job.
1136
+ addError(true, false, localReport, actIndex, 'ERROR: Page has no URL');
1137
+ }
1138
+ }
1139
+ // Otherwise, i.e. if no page exists:
1140
+ else {
1141
+ // Add an error result to the act and abort the job.
1142
+ addError(true, false, localReport, actIndex, 'ERROR: No page identified');
1143
+ }
1144
+ // Add the end time to the act.
1145
+ act.endTime = Date.now();
1146
+ // Increment the act count.
1147
+ actCount++;
1148
+ }
1149
+ }
1150
+ console.log('Acts completed');
1151
+ // If the results were standardized:
1152
+ if (['also', 'only'].includes(standard)) {
1153
+ // Reassign the element property of the catalog to the catalog, deleting the rest.
1154
+ localReport.catalog = localReport.catalog.element;
1155
+ // If the native results are not to be included in the report:
1156
+ if (standard === 'only') {
1157
+ // Remove them.
1158
+ localReport.acts.forEach(act => {
1159
+ delete act.result.nativeResult;
1160
+ });
1161
+ }
1162
+ // If a catalog was created:
1163
+ if (localReport.catalog) {
1164
+ let {catalog} = localReport;
1165
+ // Get its element count.
1166
+ const elementCount = Object.keys(catalog).length;
1167
+ // Prune it, removing elements with no reported violations.
1168
+ pruneCatalog(localReport);
1169
+ ({catalog} = localReport);
1170
+ // Get properties of the pruned catalog.
1171
+ const textCount = Object.values(catalog).filter(entry => entry.text).length;
1172
+ const linkableTextCount = Object.values(catalog).filter(entry => entry.textLinkable).length;
1173
+ const entryCount = Object.keys(catalog).length;
1174
+ // Initialize a collection of data on it.
1175
+ const catalogData = {
1176
+ elementCount,
1177
+ entryCount,
1178
+ text: {
1179
+ count: textCount,
1180
+ countPercent: Math.round(100 * textCount / entryCount),
1181
+ linkableCount: linkableTextCount,
1182
+ linkablePercent: Math.round(100 * linkableTextCount / textCount)
1183
+ },
1184
+ tools: {}
1185
+ };
1186
+ const {acts} = localReport;
1187
+ // For each act:
1188
+ for (const act of acts) {
1189
+ // If it is a test act:
1190
+ if (act.type === 'test') {
1191
+ const {which} = act;
1192
+ // Initialize an entry for it if necessary.
1193
+ catalogData.tools[which] ??= {
1194
+ instanceCount: 0,
1195
+ catalogCount: 0,
1196
+ catalogPercent: null
1197
+ };
1198
+ const actCatalogData = catalogData.tools[which];
1199
+ const instances = act.result?.standardResult?.instances ?? [];
1200
+ // For each standard instance in the act:
1201
+ for (const instance of instances) {
1202
+ // Increment the instance count.
1203
+ actCatalogData.instanceCount++;
1204
+ const {catalogIndex} = instance;
1205
+ // If the instance has a catalogIndex value:
1206
+ if (catalogIndex) {
1207
+ // Increment the catalog count.
1208
+ actCatalogData.catalogCount++;
1209
+ }
1210
+ }
1211
+ const {catalogCount, instanceCount} = actCatalogData;
1212
+ // If there are any instances:
1213
+ if (instanceCount) {
1214
+ // Add the catalog percentage to the tool data.
1215
+ actCatalogData.catalogPercent = Math.round(100 * catalogCount / instanceCount);
1216
+ }
1217
+ }
1218
+ }
1219
+ // Add the catalog data to the local report.
1220
+ localReport.jobData.catalogData = catalogData;
1221
+ }
1222
+ }
1223
+ // Delete the temporary local report file.
1224
+ await fs.rm(reportPath, {force: true});
1225
+ // Return the local report.
1226
+ return localReport;
1227
+ };