@jrpool/kilotest 38.0.0 → 39.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 (57) hide show
  1. package/.markdownlint-cli2.jsonc +12 -0
  2. package/.vscode/settings.json +3 -0
  3. package/CLAUDE.md +5 -0
  4. package/README.md +6 -4
  5. package/ai0BalanceForm/index.html +1 -1
  6. package/alerts.js +3 -1
  7. package/api/getReport.js +66 -0
  8. package/api/listDiagnoses.js +157 -0
  9. package/api/listIssues.js +225 -0
  10. package/api/listReports.js +87 -0
  11. package/api/listViolators.js +164 -0
  12. package/api/requestFeature.js +56 -0
  13. package/api/requestRetest.js +102 -0
  14. package/api/requestTest.js +106 -0
  15. package/api/routes.js +97 -0
  16. package/api/schemas.js +297 -0
  17. package/api/testSuite.js +294 -0
  18. package/api/util.js +149 -0
  19. package/diagnoses/index.js +2 -2
  20. package/docs/{AI-TOOL.md → AI-TOOLS.md} +54 -26
  21. package/docs/SERVICE.md +4 -1
  22. package/docs/decisions/001-api-design.md +59 -49
  23. package/eslint.config.mjs +13 -3
  24. package/index.html +1 -1
  25. package/index.js +201 -188
  26. package/issues/index.js +13 -21
  27. package/jsconfig.json +8 -0
  28. package/llms-full.txt +2 -2
  29. package/llms.txt +16 -11
  30. package/mcp.js +129 -65
  31. package/openapi.yaml +1234 -572
  32. package/package.json +18 -11
  33. package/reannotate/index.js +26 -15
  34. package/reannotateForm/index.js +15 -13
  35. package/redocly.yaml +4 -0
  36. package/reportHideForm/index.html +1 -1
  37. package/reportHideForm/index.js +24 -38
  38. package/reportIssue/index.js +3 -3
  39. package/reportIssues/index.js +128 -1
  40. package/reportUnhideForm/index.js +36 -44
  41. package/reportsExpungeForm/index.js +22 -16
  42. package/reportsPruneForm/index.js +22 -12
  43. package/reportsRewindForm/index.js +17 -9
  44. package/retestRec/index.js +8 -4
  45. package/retestRecForm/index.js +18 -6
  46. package/scripts/generate-openapi.js +83 -0
  47. package/targets/index.js +18 -13
  48. package/testOrder/index.js +11 -8
  49. package/tutorial/index.html +12 -12
  50. package/util.js +580 -539
  51. package/reportIssue/api.js +0 -183
  52. package/reportIssues/util.js +0 -138
  53. package/reportList/api.js +0 -83
  54. package/reportSummary/api.js +0 -181
  55. package/researchAgent.js +0 -206
  56. package/target/api.js +0 -126
  57. package/testRecForm/api.js +0 -55
package/util.js CHANGED
@@ -6,7 +6,9 @@
6
6
  // IMPORTS
7
7
 
8
8
  const {sendAlert} = require('./alerts');
9
- const issuesClassification = require('testilo/procs/score/tic').issues;
9
+ const issuesClassification
10
+ = exports.issuesClassification
11
+ = require('testilo/procs/score/tic').issues;
10
12
  const fs = require('fs/promises');
11
13
  const path = require('path');
12
14
  const querystring = require('querystring');
@@ -14,14 +16,18 @@ const wcagMap = require('./wcagMap.json');
14
16
 
15
17
  // CONSTANTS
16
18
 
19
+ // Path of the data directory.
20
+ const dbPath = exports.dbPath = path.join(__dirname, 'db');
17
21
  // Path of the jobs directory.
18
- const jobsPath = exports.jobsPath = path.join(__dirname, 'jobs');
19
- // Path of the logs directory.
20
- const logsPath = exports.logsPath = path.join(__dirname, 'logs');
22
+ const jobsPath = exports.jobsPath = path.join(dbPath, 'jobs');
21
23
  // Path of the recommendations file.
22
- const recsPath = exports.recsPath = path.join(__dirname, 'jobs', 'recs.json');
24
+ const recsPath = exports.recsPath = path.join(jobsPath, 'recs.json');
23
25
  // Path of the reports directory.
24
- const reportsPath = exports.reportsPath = path.join(__dirname, 'reports');
26
+ const reportsPath = exports.reportsPath = path.join(dbPath, 'reports');
27
+ // Path of the hidden-reports directory.
28
+ const hiddenReportsPath = exports.hiddenReportsPath = path.join(dbPath, 'hiddenReports');
29
+ // Path of the reports data file.
30
+ const reportsExtractPath = exports.reportsExtractPath = path.join(dbPath, 'reportsExtract.json');
25
31
  // IDs, names, and sponsors of Testaro rule engines.
26
32
  const tools = exports.tools = {
27
33
  alfa: ['Alfa', 'Siteimprove'],
@@ -42,74 +48,59 @@ exports.researchAgents = {
42
48
  'research-agent': 'Internal Research Agent'
43
49
  }
44
50
 
45
- // FUNCTIONS
51
+ // MISCELLANEOUS FUNCTIONS
46
52
 
47
- // Returns a random string.
48
- exports.getRandomString = length => {
49
- return Math.random().toString(36).slice(2, length + 2);
50
- };
51
- // Returns whether a report is valid.
52
- exports.isValidReport = report => {
53
- // Return whether it has the type and properties required by Kilotest:
54
- return typeof report === 'object'
55
- && typeof report.target?.what === 'string'
56
- && typeof report.target?.url === 'string'
57
- && Array.isArray(report.acts)
58
- && report.acts.every(act =>
59
- typeof act === 'object'
60
- && typeof act.type === 'string'
61
- && act.type === 'test' ? typeof act.which === 'string' && act.which : true
62
- )
63
- && typeof report.jobData === 'object';
53
+ // Compares strings alphabetically and case-insensitively.
54
+ const alphaCompare = (a, b) => a.localeCompare(b, 'en', {sensitivity: 'base'});
55
+ // Sorts strings alphabetically and case-insensitively.
56
+ const alphaSort = strings => strings.sort((a, b) => alphaCompare(a, b));
57
+ // Returns a function that executes a function sequentially.
58
+ /** @returns {<T>(fn: () => T | Promise<T>) => Promise<T>} */
59
+ const createLock = exports.createLock = () => {
60
+ let queue = Promise.resolve();
61
+ return fn => {
62
+ const result = queue.then(fn, fn);
63
+ queue = result.then(() => {}, () => {});
64
+ return result;
65
+ };
64
66
  };
65
- // Encodes a string for use as a URL fragment.
67
+ // Returns a string encoded for use as a URL fragment.
66
68
  const fragmentEncode = string => {
67
69
  return encodeURIComponent(string).replace(/-/g, '%2D');
68
70
  };
69
- // Returns the path of a log file.
70
- const getLogPath = exports.getLogPath
71
- = (timeStamp, jobID) => path.join(logsPath, `${timeStamp}-${jobID}.json`);
72
- // Returns the path of a report file.
73
- const getReportPath = exports.getReportPath
74
- = (timeStamp, jobID) => path.join(reportsPath, `${timeStamp}-${jobID}.json`);
75
- // Returns the path of a log or report file.
76
- const getRecordPath = exports.getRecordPath = (recordType, timeStamp, jobID) => {
77
- if (recordType === 'log') {
78
- return getLogPath(timeStamp, jobID);
71
+ // Returns the time in days since a Date or time stamp, or null if the argument is invalid.
72
+ const getAgoDays = exports.getAgoDays = timeArg => {
73
+ let dateTime;
74
+ // If the argument is a string:
75
+ if (typeof timeArg === 'string') {
76
+ // Convert it from a time stamp to a Date, or null if invalid.
77
+ dateTime = getDateTime(timeArg);
79
78
  }
80
- else if (recordType === 'report') {
81
- return getReportPath(timeStamp, jobID);
79
+ // Otherwise, if it is a Date:
80
+ else if (timeArg instanceof Date) {
81
+ // Convert it to null if it is invalid.
82
+ dateTime = timeArg.toString() === 'Invalid Date' ? null : timeArg;
82
83
  }
84
+ // Otherwise, i.e. if the argument is not a string or a Date:
83
85
  else {
86
+ // Return this.
84
87
  return null;
85
88
  }
86
- };
87
- // Returns a log or report.
88
- const getRecord = exports.getRecord = async (recordType, timeStamp, jobID) => {
89
- const recordPath = getRecordPath(recordType, timeStamp, jobID);
90
- let recordJSON, record;
91
- try {
92
- recordJSON = await fs.readFile(recordPath, 'utf8');
93
- }
94
- catch(error) {
95
- console.log(error.message);
96
- recordJSON = JSON.stringify({error: `Requested ${recordType} ${timeStamp}-${jobID} not found`});
97
- }
98
- try {
99
- record = JSON.parse(recordJSON);
100
- }
101
- catch (error) {
102
- console.log(error.message);
103
- record = {error: `Requested ${recordType} ${timeStamp}-${jobID} not JSON`};
89
+ // If the argument is invalid:
90
+ if (!dateTime) {
91
+ // Return this.
92
+ return null;
104
93
  }
105
- return record;
94
+ // Otherwise, i.e. if it is valid, return the elapsed days since then.
95
+ return Math.round((Date.now() - dateTime.getTime()) / (1000 * 60 * 60 * 24));
106
96
  };
107
- // Returns a report.
108
- const getReport = exports.getReport = async (timeStamp, jobID) => await getRecord(
109
- 'report', timeStamp, jobID
110
- );
111
- // Returns the JSON stringification of an object, with a final newline.
112
- const getJSON = exports.getJSON = object => `${JSON.stringify(object, null, 2)}\n`;
97
+ // Returns a string describing the time in days since a time stamp.
98
+ exports.getAgoString = timeStamp => {
99
+ const agoDays = getAgoDays(timeStamp);
100
+ return agoDays === 1 ? '1 day' : `${agoDays} days`;
101
+ };
102
+ // Returns a string describing a count.
103
+ exports.getCountString = (count, singular, plural) => count === 1 ? `1 ${singular}` : `${count} ${plural}`;
113
104
  // Returns a date string from a time stamp.
114
105
  const getDateString = exports.getDateString = timeStamp => {
115
106
  const dateString = `20${timeStamp.slice(0, 2)}-${timeStamp.slice(2, 4)}-${timeStamp.slice(4,6)}`;
@@ -126,48 +117,68 @@ const getDateTime = exports.getDateTime = timeStamp => {
126
117
  const dateString
127
118
  = `20${timeStamp.slice(0, 2)}-${timeStamp.slice(2, 4)}-${timeStamp.slice(4,6)}T${timeStamp.slice(7,9)}:${timeStamp.slice(9,11)}Z`;
128
119
  const dateTime = new Date(dateString);
129
- return dateTime;
120
+ return dateTime.toString() === 'Invalid Date' ? null : dateTime;
130
121
  };
131
- // Returns the time in days since a time stamp.
132
- const getAgoDays = exports.getAgoDays = timeStamp => Math.round(
133
- (Date.now() - getDateTime(timeStamp)) / (1000 * 60 * 60 * 24)
134
- );
135
- // Returns a time string from a time stamp.
136
- const getTimeString = timeStamp => {
137
- const timeString = `${timeStamp.slice(7, 9)}:${timeStamp.slice(9, 11)}`;
138
- // If the time part of the time stamp is valid:
139
- if (Date.parse(`2000-01-01T${timeString}`)) {
140
- // Return a time string from it.
141
- return timeString;
122
+ // Returns the issue that a rule belongs to, or null if none.
123
+ const getIssue = exports.getIssue = (ruleIDs, toolID, ruleID) => {
124
+ const {invariant, variable} = ruleIDs;
125
+ // Initialize the issue ID of the rule as if the rule ID is invariant.
126
+ let issueID = invariant[toolID]?.[ruleID];
127
+ // If the initialization succeeded:
128
+ if (issueID) {
129
+ // Return it.
130
+ return issueID;
142
131
  }
143
- // Otherwise, return a failure.
144
- return '';
132
+ // Otherwise, change the rule ID to the first matching variable rule ID of the tool.
133
+ ruleID = Object
134
+ .keys(variable[toolID] ?? {})
135
+ .find(variableRuleID => new RegExp(variableRuleID).test(ruleID));
136
+ // If the change succeeded:
137
+ if (ruleID) {
138
+ // Return the issue ID.
139
+ return variable[toolID][ruleID];
140
+ }
141
+ // Otherwise, i.e. if no issue was found, return a failure result.
142
+ return null;
145
143
  };
146
- // Compares strings alphabetically and case-insensitively.
147
- const alphaCompare = (a, b) => a.localeCompare(b, 'en', {sensitivity: 'base'});
148
- // Sorts strings alphabetically and case-insensitively.
149
- const alphaSort = strings => strings.sort((a, b) => alphaCompare(a, b));
150
- // Sorts objects by a property value.
151
- const objectSort = exports.objectSort = (objects, property, sortType) => objects
152
- .sort((a, b) => {
153
- // If the property values are numbers to be sorted in increasing order:
154
- if (sortType === 'numericUp') {
155
- // Sort by increasing numeric value.
156
- return a[property] - b[property];
144
+ // Gets the names and categories of the job files.
145
+ const getJobNames = exports.getJobNames = async () => {
146
+ const jobNames = {};
147
+ let fileNames;
148
+ for (const category of ['queue', 'claimed', 'failed']) {
149
+ try {
150
+ fileNames = await fs.readdir(path.join(jobsPath, category));
151
+ }
152
+ catch(error) {
153
+ return `ERROR: Job directory ${category} not readable (${error.message})`;
154
+ }
155
+ jobNames[category] = fileNames;
157
156
  }
158
- // Otherwise, if they are numbers to be sorted in decreasing order:
159
- else if (sortType === 'numericDown') {
160
- // Sort by decreasing numeric value.
161
- return b[property] - a[property];
157
+ return jobNames;
158
+ }
159
+ // Returns the JSON stringification of an object, with a final newline.
160
+ const getJSON = exports.getJSON = object => `${JSON.stringify(object, null, 2)}\n`;
161
+ // Returns an object from a JSON file.
162
+ const getObject = exports.getObject = async filePath => {
163
+ let fileContent, object;
164
+ try {
165
+ fileContent = await fs.readFile(filePath, 'utf8');
162
166
  }
163
- // Otherwise, if they are strings to be sorted alphabetically:
164
- else if (sortType === 'alpha') {
165
- // Sort alphabetically.
166
- return alphaCompare(a[property], b[property]);
167
+ catch(error) {
168
+ return `ERROR: File ${filePath} not readable (${error.message})`;
167
169
  }
168
- // Otherwise, do not sort.
169
- return 0;
170
- });
170
+ try {
171
+ object = JSON.parse(fileContent);
172
+ }
173
+ catch(error) {
174
+ return `ERROR: File ${filePath} not JSON (${error.message})`;
175
+ }
176
+ return object;
177
+ };
178
+ // Returns a random string.
179
+ exports.getRandomString = length => {
180
+ return Math.random().toString(36).slice(2, length + 2);
181
+ };
171
182
  // Compiles a directory of the issue classifications of invariant and variable rules.
172
183
  const getRuleIDs = exports.getRuleIDs = () => {
173
184
  // Initialize data on invariant and variable rule IDs.
@@ -220,354 +231,93 @@ const getRuleIDs = exports.getRuleIDs = () => {
220
231
  variable
221
232
  };
222
233
  };
223
- // Variable and invariant rules.
224
- const ruleIDs = exports.ruleIDs = getRuleIDs();
225
- // Returns the issue that a rule belongs to, or null if none.
226
- const getIssue = exports.getIssue = (ruleIDs, toolID, ruleID) => {
227
- const {invariant, variable} = ruleIDs;
228
- // Initialize the issue ID of the rule as if the rule ID is invariant.
229
- let issueID = invariant[toolID]?.[ruleID];
230
- // If the initialization succeeded:
231
- if (issueID) {
232
- // Return it.
233
- return issueID;
234
- }
235
- // Otherwise, change the rule ID to the first matching variable rule ID of the tool.
236
- ruleID = Object
237
- .keys(variable[toolID] ?? {})
238
- .find(variableRuleID => new RegExp(variableRuleID).test(ruleID));
239
- // If the change succeeded:
240
- if (ruleID) {
241
- // Return the issue ID.
242
- return variable[toolID][ruleID];
243
- }
244
- // Otherwise, i.e. if no issue was found, return a failure result.
245
- return null;
234
+ // Returns a time stamp from a date.
235
+ const getTimeStamp = exports.getTimeStamp = date => {
236
+ const timeStamp = date.toISOString().slice(2).replace(/[-:]/g, '').slice(0, 11);
237
+ return timeStamp;
246
238
  };
247
- // Adds issue IDs to the standard instances of a report.
248
- const annotateReport = exports.annotateReport = async (ruleIDs, timeStamp, jobID) => {
249
- // Get a copy of the report.
250
- const report = await getReport(timeStamp, jobID);
251
- // If this failed:
252
- if (report.error) {
253
- // Return why.
254
- return report.error;
255
- }
256
- // Otherwise, i.e. if it succeeded:
257
- const unclassifiableRules = new Set();
258
- // For each of its acts:
259
- for (const act of report.acts) {
260
- const {result, type, which} = act;
261
- // If it is a test act:
262
- if (type === 'test') {
263
- // For each standard instance of the result:
264
- for (const instance of result?.standardResult?.instances ?? []) {
265
- const {ruleID} = instance;
266
- // Classify its rule.
267
- const issueID = getIssue(ruleIDs, which, ruleID);
268
- // If the rule was classifiable:
269
- if (issueID) {
270
- // Add the issue ID to the instance.
271
- instance.issueID = issueID;
272
- }
273
- // Otherwise, i.e. if it was not classifiable:
274
- else {
275
- // Add it to the set of unclassifiable rules.
276
- unclassifiableRules.add(`${which}:${ruleID}`);
277
- // Remove any existing issue ID from the instance.
278
- delete instance.issueID;
279
- }
280
- }
239
+ // Returns a time stamp for now.
240
+ const getNowStamp = exports.getNowStamp = () => {
241
+ return getTimeStamp(new Date());
242
+ };
243
+ // Returns a time string from a time stamp.
244
+ const getTimeString = timeStamp => {
245
+ const timeString = `${timeStamp.slice(7, 9)}:${timeStamp.slice(9, 11)}`;
246
+ // Return a time string from it.
247
+ return (Date.parse(`2000-01-01T${timeString}Z`)) ? timeString : null;
248
+ };
249
+ // Returns a date-and-time string.
250
+ const getDateTimeString = exports.getDateTimeString = timeStamp => {
251
+ const dateString = getDateString(timeStamp);
252
+ const timeString = getTimeString(timeStamp);
253
+ const dateTimeString = `${dateString} at ${timeString}`;
254
+ return dateTimeString;
255
+ }
256
+ // Converts a string to a plain-text 1-line ASCII string.
257
+ const getPlainText = exports.getPlainText = string => string
258
+ .replace(/&/g, '+')
259
+ .replace(/[<>"'&]/g, ' ');
260
+ // Returns the data from a POST request.
261
+ exports.getPOSTData = request => new Promise(resolve => {
262
+ const bodyParts = [];
263
+ request.on('data', chunk => {
264
+ bodyParts.push(chunk);
265
+ });
266
+ request.on('end', () => {
267
+ const {headers} = request;
268
+ const contentType = headers['content-type'] || headers['body-type'] || '';
269
+ if (contentType.startsWith('application/json')) {
270
+ const bodyJSON = bodyParts.join('');
271
+ const body = JSON.parse(bodyJSON);
272
+ resolve(body);
281
273
  }
274
+ else if (contentType.startsWith('application/x-www-form-urlencoded')) {
275
+ const body = bodyParts.join('');
276
+ const query = querystring.parse(body);
277
+ resolve(query);
278
+ }
279
+ });
280
+ });
281
+ // Returns the waiting test and retest recommendations.
282
+ const getRecs = exports.getRecs = async () => {
283
+ let recs;
284
+ let recsJSON;
285
+ try {
286
+ recsJSON = await fs.readFile(recsPath, 'utf8');
282
287
  }
283
- const issuelessRules = Array.from(unclassifiableRules).sort();
284
- // Update the issueless rules in the report.
285
- report.jobData.issuelessRules = issuelessRules;
286
- // If any rules were unclassifiable:
287
- if (issuelessRules.length) {
288
- // Alert a manager about them.
289
- await sendAlert(
290
- 'Kilotest: unclassified rules violated',
291
- `Job ${timeStamp}-${jobID}: Violated rules in no issues:\n${issuelessRules.join('\n')}`
292
- );
293
- }
294
- // Save the annotated report.
295
- await fs.writeFile(getReportPath(timeStamp, jobID), getJSON(report));
296
- // Get a copy of the log of the report.
297
- const log = await getLog(timeStamp, jobID, false);
298
- // If this failed:
299
- if (log.error) {
300
- // Return why.
301
- return log.error;
288
+ catch(error) {
289
+ await fs.writeFile(recsPath, '{}\n');
290
+ return `ERROR: recommendations file not readable, so created an empty one (${error.message})`;
302
291
  }
303
- // Otherwise, i.e. if it succeeded:
304
- else {
305
- // Mark the report as annotated in the log.
306
- log.annotated = true;
307
- // Save the revised log.
308
- await fs.writeFile(getLogPath(timeStamp, jobID), getJSON(log));
309
- // Return success.
310
- return '';
292
+ try {
293
+ recs = JSON.parse(recsJSON);
311
294
  }
312
- };
313
- // Returns a report log after conditionally annotating it.
314
- const getLog = exports.getLog = async (timeStamp, jobID, annotate = false) => {
315
- const log = await getRecord('log', timeStamp, jobID);
316
- if (annotate && ! (log.error || log.annotated)) {
317
- await annotateReport(ruleIDs, timeStamp, jobID);
295
+ catch(error) {
296
+ return `ERROR: recommendations file not JSON (${error.message})`;
318
297
  }
319
- return log;
298
+ return recs;
320
299
  };
321
- // Returns whether a report is hidden.
322
- exports.isHidden = async (timeStamp, jobID) => {
323
- // Get the log of the report.
324
- const log = await getLog(timeStamp, jobID, false);
325
- // Return whether the log exists and its report is hidden.
326
- return !! log.hidden;
327
- };
328
- // Returns summary data on a report.
329
- exports.getReportData = async (timeStamp, jobID) => {
330
- // Validate the report and annotate it if necessary.
331
- const log = await getLog(timeStamp, jobID, true);
332
- // If this failed:
333
- if (log.error) {
334
- // Return why.
335
- return {error: log.error};
336
- }
337
- // Initialize the data.
338
- const data = {
339
- what: log.what,
340
- url: log.url,
341
- jobName: `${timeStamp}-${jobID}`,
342
- creationDate: getDateTime(timeStamp),
343
- daysAgo: getAgoDays(timeStamp),
344
- issueCount: 0,
345
- toolNames: [],
346
- toolCount: 0,
347
- reporterNames: [],
348
- reporterCount: 0,
349
- violatorCount: 0,
350
- preventedToolNames: [],
351
- preventedToolCount: 0
352
- };
353
- const issueIDSet = new Set();
354
- const toolNameSet = new Set();
355
- const reporterIDSet = new Set();
356
- const violatorIndexSet = new Set();
357
- // Get the report.
358
- const report = await getReport(timeStamp, jobID);
359
- // If this failed:
360
- if (report.error) {
361
- // Return why.
362
- return {error: report.error};
363
- }
364
- // For each act of the report:
365
- report.acts.forEach(act => {
366
- // If it is a test act:
367
- if (act.type === 'test') {
368
- const {result, which} = act;
369
- // Ensure that the tool is in the temporary data.
370
- toolNameSet.add(tools[which][0]);
371
- const instances = result?.standardResult?.instances ?? [];
372
- // For each standard instance of the act:
373
- instances.forEach(instance => {
374
- const {catalogIndex, issueID} = instance;
375
- // If it has a non-ignorable classified issue ID:
376
- if (issueID && issuesClassification[issueID] && issueID !== 'ignorable') {
377
- // Ensure that the tool is in the temporary data.
378
- reporterIDSet.add(which);
379
- // Ensure that the issue is in the temporary data.
380
- issueIDSet.add(issueID);
381
- // If the violator has a catalog index:
382
- if (catalogIndex) {
383
- // Ensure that the violator is in the temporary data.
384
- violatorIndexSet.add(catalogIndex);
385
- }
386
- }
387
- });
388
- }
389
- });
390
- // Populate the data with the act data.
391
- data.issueCount = issueIDSet.size;
392
- data.toolNames = Array
393
- .from(toolNameSet)
394
- .sort((a, b) => a.localeCompare(b, 'en', {sensitivity: 'base'}));
395
- data.toolCount = toolNameSet.size;
396
- data.reporterNames = Array
397
- .from(reporterIDSet)
398
- .map(id => tools[id][0])
399
- .sort((a, b) => a.localeCompare(b, 'en', {sensitivity: 'base'}));
400
- data.reporterCount = data.reporterNames.length;
401
- data.violatorCount = violatorIndexSet.size;
402
- // Add the names of any prevented rule engines to the data.
403
- data.preventedToolNames = Object.keys(report.jobData?.preventions || {})
404
- .map(toolID => tools[toolID][0])
405
- .sort((a, b) => a.localeCompare(b, 'en', {sensitivity: 'base'}));
406
- data.preventedToolCount = data.preventedToolNames.length;
407
- // Return the data.
408
- return data;
409
- }
410
- // Returns a time stamp from a date.
411
- const getTimeStamp = exports.getTimeStamp = date => {
412
- const timeStamp = date.toISOString().slice(2).replace(/[-:]/g, '').slice(0, 11);
413
- return timeStamp;
414
- };
415
- // Gets the names and categories of the job files.
416
- const getJobNames = exports.getJobNames = async () => {
417
- const jobNames = {};
418
- let fileNames;
419
- for (const category of ['queue', 'claimed', 'failed']) {
420
- try {
421
- fileNames = await fs.readdir(path.join(jobsPath, category));
422
- }
423
- catch(error) {
424
- return `ERROR: Job directory ${category} not readable (${error.message})`;
425
- }
426
- jobNames[category] = fileNames;
427
- }
428
- return jobNames;
429
- }
430
- // Returns an object from a JSON file.
431
- const getObject = exports.getObject = async filePath => {
432
- let fileContent, object;
433
- try {
434
- fileContent = await fs.readFile(filePath, 'utf8');
435
- }
436
- catch(error) {
437
- return `ERROR: File ${filePath} not readable (${error.message})`;
438
- }
439
- try {
440
- object = JSON.parse(fileContent);
441
- }
442
- catch(error) {
443
- return `ERROR: File ${filePath} not JSON (${error.message})`;
444
- }
445
- return object;
446
- };
447
- // Returns a string describing the time in days since a time stamp.
448
- exports.getAgoString = timeStamp => {
449
- const agoDays = getAgoDays(timeStamp);
450
- return agoDays === 1 ? '1 day' : `${agoDays} days`;
451
- };
452
- // Returns a date-and-time string.
453
- const getDateTimeString = exports.getDateTimeString = timeStamp => {
454
- const dateString = getDateString(timeStamp);
455
- const timeString = getTimeString(timeStamp);
456
- const dateTimeString = `${dateString} at ${timeString}`;
457
- return dateTimeString;
458
- }
459
- // Returns the path ID of the element of a standard instance.
460
- exports.getPathID = (catalog, catalogIndex, pathID) => {
461
- if (catalogIndex) {
462
- const catalogItem = catalog[catalogIndex] || {};
463
- if (catalogItem.pathID) {
464
- return catalogItem.pathID;
465
- }
466
- return pathID ?? '/html';
467
- }
468
- return pathID ?? '/html';
469
- };
470
- // Returns the data from a POST request.
471
- exports.getPOSTData = request => new Promise(resolve => {
472
- const bodyParts = [];
473
- request.on('data', chunk => {
474
- bodyParts.push(chunk);
475
- });
476
- request.on('end', () => {
477
- const contentType = request.headers['content-type'];
478
- if (contentType.startsWith('application/json')) {
479
- const bodyJSON = bodyParts.join('');
480
- const body = JSON.parse(bodyJSON);
481
- resolve(body);
482
- }
483
- else if (contentType.startsWith('application/x-www-form-urlencoded')) {
484
- const body = bodyParts.join('');
485
- const query = querystring.parse(body);
486
- resolve(query);
487
- }
488
- });
489
- });
490
- // Returns the waiting test and retest recommendations.
491
- const getRecs = exports.getRecs = async () => {
492
- let recs;
493
- let recsJSON;
494
- try {
495
- recsJSON = await fs.readFile(recsPath, 'utf8');
496
- }
497
- catch(error) {
498
- await fs.writeFile(recsPath, '{}\n');
499
- return `ERROR: recommendations file not readable, so created an empty one (${error.message})`;
500
- }
501
- try {
502
- recs = JSON.parse(recsJSON);
503
- }
504
- catch(error) {
505
- return `ERROR: recommendations file not JSON (${error.message})`;
506
- }
507
- return recs;
300
+ // Converts a catalog item text to a text-fragment link destination.
301
+ exports.getTextFragmentHref = (text, url) => {
302
+ const fragmentList = text
303
+ .split('\n')
304
+ .map(fragment => fragmentEncode(fragment))
305
+ .join(',');
306
+ // Return a text-fragment link.
307
+ return `${url}#:~:text=${fragmentList}`;
508
308
  };
309
+ // Returns a +-delimited list of sorted names of rule engines.
310
+ exports.getToolList = toolIDs => Array.from(toolIDs)
311
+ .map(toolID => tools[toolID][0])
312
+ .sort((a, b) => a.localeCompare(b, 'en', {sensitivity: 'base'}))
313
+ .join(' + ');
509
314
  // Returns a string of names of rule engines.
510
315
  exports.getToolNamesString = toolIDSet =>
511
316
  alphaSort(Array.from(toolIDSet).map(toolID => tools[toolID]?.[0] || toolID)).join(' + ');
512
- // Converts a catalog item text to a text-fragment link destination.
513
- exports.getTextFragmentHref = (text, url) => {
514
- const fragmentList = text
515
- .split('\n')
516
- .map(fragment => fragmentEncode(fragment))
517
- .join(',');
518
- // Return a text-fragment link.
519
- return `${url}#:~:text=${fragmentList}`;
520
- };
521
- // Returns a sorted array of the logs, with job names added, of the non-hidden reports.
522
- const getLogs = exports.getLogs = async () => {
523
- // Initialize data on the tested targets.
524
- const logs = [];
525
- let logFileNames;
526
- try {
527
- logFileNames = await fs.readdir(logsPath);
528
- }
529
- catch {
530
- console.error('Logs directory not readable');
531
- logFileNames = [];
532
- }
533
- // For each log:
534
- for (const fileName of logFileNames) {
535
- const logName = fileName.slice(0, -5);
536
- const [timeStamp, jobID] = logName.split('-');
537
- // Get it.
538
- const log = await getLog(timeStamp, jobID);
539
- // If this failed:
540
- if (log.error) {
541
- // Report why.
542
- console.error(log.error);
543
- }
544
- // Otherwise, i.e. if it succeeded:
545
- else {
546
- // If the report is not hidden:
547
- if (! log.hidden) {
548
- // Add the job name to the log.
549
- log.jobName = logName;
550
- // Add the log to the logs.
551
- logs.push(log);
552
- }
553
- }
554
- }
555
- // Sort the logs by target name and secondarily by test time.
556
- logs.sort((a, b) => {
557
- // During the sort, if the jobs tested the same target:
558
- if (b.what === a.what) {
559
- // Add to the earlier log the fact that its report has been superseded.
560
- if (a.jobName < b.jobName) {
561
- a.superseded = true;
562
- return 1;
563
- }
564
- b.superseded = true;
565
- return -1;
566
- }
567
- return a.what.localeCompare(b.what, {}, {sensitivity: 'base'});
568
- });
569
- // Return them.
570
- return logs;
317
+ // Gets the WCAG Understanding link for a numeric WCAG standard identifier.
318
+ exports.getWCAGLink = numericID => {
319
+ // Return the link.
320
+ return `https://www.w3.org/WAI/WCAG22/Understanding/${wcagMap[numericID]}`;
571
321
  };
572
322
  // Gets the name of an issue weight.
573
323
  exports.getWeightName = weight => ['lowest', 'low', 'high', 'highest'][weight - 1] ?? 'unknown';
@@ -609,7 +359,7 @@ exports.isRecommendable = async url => {
609
359
  };
610
360
  // Returns whether a string is a time stamp.
611
361
  exports.isTimeStamp = string => {
612
- return !! getDateString(string);
362
+ return !!getDateString(string);
613
363
  };
614
364
  // Returns whether a string is a URL.
615
365
  const isURL = exports.isURL = string => {
@@ -621,26 +371,29 @@ const isURL = exports.isURL = string => {
621
371
  };
622
372
  // Makes a string breakable before non-initial slashes.
623
373
  exports.makeBreakable = string => string.replace(/\//g, '<wbr>/').replace(/^<wbr>/, '');
624
- // Converts a string to a plain-text 1-line ASCII string.
625
- const getPlainText = string => string.replace(/&/g, '+').replace(/[<>"'&]/g, ' ');
626
- // Returns a time stamp for now.
627
- const getNowStamp = exports.getNowStamp = () => {
628
- return getTimeStamp(new Date());
629
- };
630
- // Updates the test recommendations.
631
- const updateRecs = exports.updateRecs = async (what, url, why) => {
632
- // Get the data on waiting recommendations.
633
- const recs = await getRecs();
634
- recs[url] ??= [];
635
- // Add the recommendation to those for the target.
636
- recs[url].push({
637
- timeStamp: getNowStamp(),
638
- what,
639
- why
640
- });
641
- // Save the revised recommendations.
642
- await fs.writeFile(recsPath, getJSON(recs));
643
- };
374
+ // Minifies a URL for duplicate detection.
375
+ const minifyURL = exports.minifyURL = url => url.replace(/www\.|\/$/g, '').toLowerCase();
376
+ // Sorts objects by a property value and returns the sorted array.
377
+ const objectSort = exports.objectSort = (objects, property, sortType) => objects
378
+ .sort((a, b) => {
379
+ // If the property values are numbers to be sorted in increasing order:
380
+ if (sortType === 'numericUp') {
381
+ // Sort by increasing numeric value.
382
+ return a[property] - b[property];
383
+ }
384
+ // Otherwise, if they are numbers to be sorted in decreasing order:
385
+ else if (sortType === 'numericDown') {
386
+ // Sort by decreasing numeric value.
387
+ return b[property] - a[property];
388
+ }
389
+ // Otherwise, if they are strings to be sorted alphabetically:
390
+ else if (sortType === 'alpha') {
391
+ // Sort alphabetically.
392
+ return alphaCompare(a[property], b[property]);
393
+ }
394
+ // Otherwise, do not sort.
395
+ return 0;
396
+ });
644
397
  // Processes a test or retest recommendation in the UI.
645
398
  exports.processRec = async (testType, dirName, what, url, why) => {
646
399
  // If the recommendation is valid:
@@ -653,53 +406,264 @@ exports.processRec = async (testType, dirName, what, url, why) => {
653
406
  ) {
654
407
  // Make the reason display-safe.
655
408
  const plainWhy = getPlainText(why);
656
- // Update the waiting recommendations.
657
- await updateRecs(what, url, plainWhy);
658
- // Log the recommendation.
659
- console.log(`Test recommendation received for ${what}: ${plainWhy}`);
660
- // Alert a manager about it.
661
- await sendAlert(
662
- `Kilotest: new ${testType} recommendation in the UI`,
663
- `Target: ${what}\nURL: ${url}\nReason: ${plainWhy}`
664
- );
665
- // Get the template.
666
- let answerPage = await fs.readFile(path.join(dirName, 'index.html'), 'utf8');
667
- const query = {
668
- target: what,
669
- why: plainWhy
670
- };
671
- // Replace its placeholders.
672
- Object.keys(query).forEach(param => {
673
- answerPage = answerPage.replace(new RegExp(`__${param}__`, 'g'), query[param]);
674
- });
675
- // Return the populated page.
676
- return {
677
- status: 'ok',
678
- answerPage
679
- };
409
+ // Update the waiting recommendations as a transaction.
410
+ const updateResult = await updateRecs(what, url, plainWhy);
411
+ // If the recommendation was a duplicate:
412
+ if (updateResult.error === 'duplicate') {
413
+ // Return this.
414
+ return {
415
+ status: 'error',
416
+ message: 'Duplicate recommendation'
417
+ };
418
+ }
419
+ // Otherwise, i.e. if it was not a duplicate:
420
+ else {
421
+ // Log the recommendation.
422
+ console.log(`Test recommendation received for ${what}: ${plainWhy}`);
423
+ // Alert a manager about it.
424
+ await sendAlert(
425
+ `Kilotest: new ${testType} recommendation in the UI`,
426
+ `Target: ${what}\nURL: ${url}\nReason: ${plainWhy}`
427
+ );
428
+ // Get the template.
429
+ let answerPage = await fs.readFile(path.join(dirName, 'index.html'), 'utf8');
430
+ const query = {
431
+ target: what,
432
+ why: plainWhy
433
+ };
434
+ // Replace its placeholders.
435
+ Object.keys(query).forEach(param => {
436
+ answerPage = answerPage.replace(new RegExp(`__${param}__`, 'g'), query[param]);
437
+ });
438
+ // Return the populated page.
439
+ return {
440
+ status: 'ok',
441
+ answerPage
442
+ };
443
+ }
680
444
  }
681
445
  return {
682
446
  status: 'error',
683
447
  message: 'Invalid recommendation'
684
448
  };
685
449
  };
686
- // Gets the WCAG Understanding link for a numeric WCAG standard identifier.
687
- exports.getWCAGLink = numericID => {
688
- // Return the link.
689
- return `https://www.w3.org/WAI/WCAG22/Understanding/${wcagMap[numericID]}`;
450
+ // Concurrency lock for the `recs.json` file.
451
+ const recsLock = exports.recsLock = createLock();
452
+ // Variable and invariant rules.
453
+ exports.ruleIDs = getRuleIDs();
454
+ // Updates the test recommendations as a transaction.
455
+ const updateRecs = exports.updateRecs = (what, url, why) => recsLock(async () => {
456
+ // Get the data on waiting recommendations.
457
+ const recs = await getRecs();
458
+ recs[url] ??= [];
459
+ // If any recommendation has the same description and URL:
460
+ if (recs[url].some(rec => rec.what === what)) {
461
+ // Return this.
462
+ return {
463
+ error: 'duplicate'
464
+ };
465
+ }
466
+ // Otherwise, i.e. if the recommendation is not a duplicate, add it to those for the target.
467
+ recs[url].push({
468
+ timeStamp: getNowStamp(),
469
+ what,
470
+ why
471
+ });
472
+ // Save the revised recommendations.
473
+ await fs.writeFile(recsPath, getJSON(recs));
474
+ // Return success.
475
+ return {};
476
+ });
477
+
478
+ // REPORT FUNCTIONS
479
+
480
+ // Returns the path ID of the element of a standard instance.
481
+ exports.getPathID = (catalog, catalogIndex, pathID) => {
482
+ if (catalogIndex) {
483
+ const catalogItem = catalog[catalogIndex] || {};
484
+ if (catalogItem.pathID) {
485
+ return catalogItem.pathID;
486
+ }
487
+ return pathID ?? '/html';
488
+ }
489
+ return pathID ?? '/html';
490
+ };
491
+ // Returns the path of an available report file.
492
+ const getReportPath = exports.getReportPath = (timeStamp, jobID) => path
493
+ .join(reportsPath, `${timeStamp}-${jobID}.json`);
494
+ // Returns whether a report is valid.
495
+ const isValidReport = exports.isValidReport = report => {
496
+ // Return whether it has the type and properties required by Kilotest:
497
+ return typeof report === 'object'
498
+ && typeof report.target?.what === 'string'
499
+ && typeof report.target?.url === 'string'
500
+ && Array.isArray(report.acts)
501
+ && report.acts.every(act =>
502
+ typeof act === 'object'
503
+ && typeof act.type === 'string'
504
+ && act.type === 'test' ? Object.keys(tools).includes(act.which) : true
505
+ )
506
+ && typeof report.jobData === 'object'
507
+ && report.jobData.endTime
508
+ && !isNaN(new Date(`20${report.jobData.endTime}Z`).getTime())
509
+ && typeof report.catalog === 'object';
510
+ };
511
+ // Returns a report.
512
+ const getReport = exports.getReport = async (timeStamp, jobID) => {
513
+ try {
514
+ const reportJSON = await fs.readFile(getReportPath(timeStamp, jobID), 'utf8');
515
+ const report = JSON.parse(reportJSON);
516
+ // If it is valid:
517
+ if (isValidReport(report)) {
518
+ // Return it.
519
+ return report;
520
+ }
521
+ // Otherwise, i.e. if it is invalid, return this.
522
+ return {error: `Report ${timeStamp}-${jobID} is invalid`};
523
+ } catch (error) {
524
+ return {error: `Report ${timeStamp}-${jobID} is missing, unreadable, or not JSON (${error.message})`};
525
+ }
526
+ };
527
+ // Adds issue IDs to the standard instances of a report.
528
+ exports.annotateReport = async (ruleIDs, timeStamp, jobID) => {
529
+ // Get a copy of the report.
530
+ const report = await getReport(timeStamp, jobID);
531
+ // If this failed:
532
+ if (report.error) {
533
+ // Return why.
534
+ return report.error;
535
+ }
536
+ // Otherwise, i.e. if it succeeded:
537
+ const unclassifiableRules = new Set();
538
+ // For each of its acts:
539
+ for (const act of report.acts) {
540
+ const {result, type, which} = act;
541
+ // If it is a test act:
542
+ if (type === 'test') {
543
+ // For each standard instance of the result:
544
+ for (const instance of result?.standardResult?.instances ?? []) {
545
+ const {ruleID} = instance;
546
+ // Classify its rule.
547
+ const issueID = getIssue(ruleIDs, which, ruleID);
548
+ // If the rule was classifiable:
549
+ if (issueID) {
550
+ // Add the issue ID to the instance.
551
+ instance.issueID = issueID;
552
+ }
553
+ // Otherwise, i.e. if it was not classifiable:
554
+ else {
555
+ // Add it to the set of unclassifiable rules.
556
+ unclassifiableRules.add(`${which}:${ruleID}`);
557
+ // Remove any existing issue ID from the instance.
558
+ delete instance.issueID;
559
+ }
560
+ }
561
+ }
562
+ }
563
+ const issuelessRules = Array.from(unclassifiableRules).sort();
564
+ // Update the issueless rules in the report.
565
+ report.jobData.issuelessRules = issuelessRules;
566
+ // If any rules were unclassifiable:
567
+ if (issuelessRules.length) {
568
+ // Alert a manager about them.
569
+ await sendAlert(
570
+ 'Kilotest: unclassified rules violated',
571
+ `Job ${timeStamp}-${jobID}: Violated rules in no issues:\n${issuelessRules.join('\n')}`
572
+ );
573
+ }
574
+ // Save the annotated report.
575
+ await fs.writeFile(getReportPath(timeStamp, jobID), getJSON(report));
576
+ // Return success.
577
+ return '';
690
578
  };
691
- // Returns page data from a report.
579
+ // Returns summary data on an available report.
580
+ exports.getReportData = async (timeStamp, jobID) => {
581
+ // Get the report.
582
+ const report = await getReport(timeStamp, jobID);
583
+ // If this failed:
584
+ if (report.error) {
585
+ // Return why.
586
+ return {error: report.error};
587
+ }
588
+ // Otherwise, i.e. if it succeeded, initialize the data.
589
+ const data = {
590
+ what: report.target.what,
591
+ url: report.target.url,
592
+ jobName: report.id,
593
+ creationDate: getDateTime(timeStamp),
594
+ daysAgo: getAgoDays(timeStamp),
595
+ issueCount: 0,
596
+ toolNames: [],
597
+ toolCount: 0,
598
+ reporterNames: [],
599
+ reporterCount: 0,
600
+ violatorCount: 0,
601
+ preventedToolNames: [],
602
+ preventedToolCount: 0
603
+ };
604
+ const issueIDSet = new Set();
605
+ const toolNameSet = new Set();
606
+ const reporterIDSet = new Set();
607
+ const violatorIndexSet = new Set();
608
+ // For each act of the report:
609
+ report.acts.forEach(act => {
610
+ // If it is a test act:
611
+ if (act.type === 'test') {
612
+ const {result, which} = act;
613
+ // Ensure that the tool is in the temporary data.
614
+ toolNameSet.add(tools[which][0]);
615
+ const instances = result?.standardResult?.instances ?? [];
616
+ // For each standard instance of the act:
617
+ instances.forEach(instance => {
618
+ const {catalogIndex, issueID} = instance;
619
+ // If it has a non-ignorable classified issue ID:
620
+ if (issueID && issuesClassification[issueID] && issueID !== 'ignorable') {
621
+ // Ensure that the tool is in the temporary data.
622
+ reporterIDSet.add(which);
623
+ // Ensure that the issue is in the temporary data.
624
+ issueIDSet.add(issueID);
625
+ // If the violator has a catalog index:
626
+ if (catalogIndex) {
627
+ // Ensure that the violator is in the temporary data.
628
+ violatorIndexSet.add(catalogIndex);
629
+ }
630
+ }
631
+ });
632
+ }
633
+ });
634
+ // Populate the data with the act data.
635
+ data.issueCount = issueIDSet.size;
636
+ data.toolNames = Array
637
+ .from(toolNameSet)
638
+ .sort((a, b) => a.localeCompare(b, 'en', {sensitivity: 'base'}));
639
+ data.toolCount = toolNameSet.size;
640
+ data.reporterNames = Array
641
+ .from(reporterIDSet)
642
+ .map(id => tools[id][0])
643
+ .sort((a, b) => a.localeCompare(b, 'en', {sensitivity: 'base'}));
644
+ data.reporterCount = data.reporterNames.length;
645
+ data.violatorCount = violatorIndexSet.size;
646
+ // Add the names of any prevented rule engines to the data.
647
+ data.preventedToolNames = Object.keys(report.jobData?.preventions || {})
648
+ .map(toolID => tools[toolID][0])
649
+ .sort((a, b) => a.localeCompare(b, 'en', {sensitivity: 'base'}));
650
+ data.preventedToolCount = data.preventedToolNames.length;
651
+ // Return the data.
652
+ return data;
653
+ }
654
+ // Returns page data from an available report.
692
655
  const getPageData = exports.getPageData = async (timeStamp, jobID) => {
693
- // Get the log of the report.
694
- const log = await getLog(timeStamp, jobID, false);
656
+ // Get the report.
657
+ const report = await getReport(timeStamp, jobID);
695
658
  // If this failed:
696
- if (log.error) {
659
+ if (report.error) {
697
660
  // Return why.
698
- return log;
661
+ return report;
699
662
  }
700
- const {url, what} = log;
701
- // Otherwise, i.e. if it succeeded, get the elapsed time in days since the test.
702
- const daysAgo = getAgoDays(timeStamp);
663
+ const {url, what} = report.target;
664
+ const reportStats = await getReportStats(timeStamp, jobID)
665
+ // Otherwise, i.e. if it succeeded, get the elapsed time in days since the report was created.
666
+ const daysAgo = getAgoDays(reportStats.reportTime);
703
667
  // Return the data.
704
668
  return {
705
669
  what,
@@ -710,7 +674,7 @@ const getPageData = exports.getPageData = async (timeStamp, jobID) => {
710
674
  // Gets HTML strings for page data from a report.
711
675
  exports.getPageDataStrings = async (timeStamp, jobID, pageData) => {
712
676
  // If the page data were not specified:
713
- if (! pageData) {
677
+ if (!pageData) {
714
678
  // Get them.
715
679
  pageData = await getPageData(timeStamp, jobID);
716
680
  }
@@ -732,56 +696,133 @@ exports.getPageDataStrings = async (timeStamp, jobID, pageData) => {
732
696
  testInfo: `Tested ${daysAgo} days ago by job <code>${jobID}</code> on ${when}`
733
697
  };
734
698
  };
735
- // Returns data about rule engines sorted by their names.
736
- const getToolsData = exports.getToolsData = toolIDs => objectSort(
737
- Array.from(toolIDs).map(toolID => {
738
- const toolData = tools[toolID];
739
- return {
740
- toolID,
741
- toolName: toolData[0],
742
- toolMaker: toolData[1]
743
- }
744
- }),
745
- 'toolName',
746
- 'alpha'
747
- );
748
- // Returns a +-delimited list of sorted names of rule engines.
749
- exports.getToolList = toolIDs => Array.from(toolIDs)
750
- .map(toolID => tools[toolID][0])
751
- .sort((a, b) => a.localeCompare(b, 'en', {sensitivity: 'base'}))
752
- .join(' + ');
753
- // Returns facts about rule engines.
754
- exports.getToolsFacts = toolIDs => {
755
- const crypticData = getToolsData(toolIDs);
756
- return crypticData.map(tool => {
757
- const {toolID, toolName, toolMaker} = tool;
758
- return {
759
- identifier: toolID,
760
- name: toolName,
761
- sponsor: toolMaker
762
- };
763
- });
764
- };
765
- // Returns a string describing a count.
766
- exports.getCountString = (count, singular, plural) => count === 1 ? `1 ${singular}` : `${count} ${plural}`;
767
- // Minifies a URL for duplicate detection.
768
- const minifyURL = exports.minifyURL = url => url.replace(/www\.|\/$/g, '').toLowerCase();
769
- // Returns whether a report about the specified page is already available.
770
- exports.isReportAvailable = async (what, url) => {
771
- const logs = await getLogs();
772
- const whats = logs.map(log => log.what);
773
- const miniURLs = logs.map(log => minifyURL(log.url));
774
- return whats.includes(what) || miniURLs.includes(minifyURL(url));
775
- };
776
- // Returns the size of a report.
777
- exports.getReportSize = async (timeStamp, jobID) => {
699
+ // Returns the creation time and size of a report.
700
+ const getReportStats = exports.getReportStats = async (timeStamp, jobID) => {
778
701
  const reportStat = await fs.stat(
779
702
  path.join(reportsPath, `${timeStamp}-${jobID}.json`),
780
703
  {throwIfNoEntry: false}
781
704
  );
782
- if (! reportStat) {
783
- return 0;
705
+ if (!reportStat) {
706
+ return null;
784
707
  }
708
+ const reportTime = reportStat.birthtime;
785
709
  const reportSize = reportStat.size;
786
- return reportSize;
710
+ return {reportTime, reportSize};
711
+ };
712
+ // Returns whether a report is hidden.
713
+ exports.isHidden = async (timeStamp, jobID) => {
714
+ // Get the names of the hidden report files.
715
+ const hiddenReportFileNames = await fs.readdir(hiddenReportsPath);
716
+ // Return whether the report is among them.
717
+ return hiddenReportFileNames.includes(`${timeStamp}-${jobID}.json`);
718
+ };
719
+ // Creates, saves, and returns an extract of all available reports.
720
+ const makeReportsExtract = exports.makeReportsExtract = async () => {
721
+ // Initialize the reports data.
722
+ const reportsExtract = {};
723
+ // Get the names of all report files.
724
+ const reportFileNames = await fs.readdir(reportsPath);
725
+ // For each of them:
726
+ for (const reportFileName of reportFileNames) {
727
+ // Get its content.
728
+ const reportJSON = await fs.readFile(path.join(reportsPath, reportFileName), 'utf8');
729
+ // Get the object that it represents.
730
+ const report = JSON.parse(reportJSON);
731
+ const {target, jobData} = report;
732
+ const jobName = reportFileName.slice(0, -5);
733
+ const [timeStamp, jobID] = jobName.split('-');
734
+ const {what, url} = target;
735
+ // Get an extract from it.
736
+ const reportExtract = {
737
+ timeStamp,
738
+ jobID,
739
+ what,
740
+ url,
741
+ reportTime: new Date(`20${jobData.endTime}Z`).toISOString()
742
+ };
743
+ // Add the report extract to the reports extract.
744
+ reportsExtract[jobName] = reportExtract;
745
+ }
746
+ // Ensure the data directory exists.
747
+ await fs.mkdir(dbPath, {recursive: true});
748
+ // Save the extract of the reports.
749
+ await fs.writeFile(reportsExtractPath, getJSON(reportsExtract));
750
+ // Return it.
751
+ return reportsExtract;
752
+ };
753
+ // Gets an extract of all available reports.
754
+ const getReportsExtract = exports.getReportsExtract = async () => {
755
+ let reportsExtract;
756
+ try {
757
+ // Get the reports extract file.
758
+ const reportsExtractJSON = await fs.readFile(reportsExtractPath, 'utf8');
759
+ // Get its data.
760
+ reportsExtract = JSON.parse(reportsExtractJSON);
761
+ // If it is empty:
762
+ if (!Object.keys(reportsExtract).length) {
763
+ // Throw an error.
764
+ throw new Error('Reports extract file is empty');
765
+ }
766
+ }
767
+ // If this fails:
768
+ catch {
769
+ // Create or recreate and get the reports extract.
770
+ reportsExtract = await makeReportsExtract();
771
+ }
772
+ // Return the extract.
773
+ return reportsExtract;
774
+ };
775
+ // Returns extracts of all available reports.
776
+ const getReportExtracts = exports.getReportExtracts = async () => {
777
+ const reportsExtract = await getReportsExtract();
778
+ return Object.values(reportsExtract);
779
+ };
780
+ // Returns whether a report is available on a page with a description or URL.
781
+ exports.isReportAvailable = async (what, url) => {
782
+ const reportExtracts = await getReportExtracts();
783
+ const whats = reportExtracts.map(reportExtract => reportExtract.what);
784
+ const miniURLs = reportExtracts.map(reportExtract => minifyURL(reportExtract.url));
785
+ return whats.includes(what) || miniURLs.includes(minifyURL(url));
786
+ };
787
+ // Gets extracts of the latest available reports for all page descriptions.
788
+ exports.getLatestReportExtracts = async () => {
789
+ // Get extracts of all available reports.
790
+ const reportExtracts = await getReportExtracts();
791
+ // Sort them primarily by page description and secondarily by completion time.
792
+ objectSort(reportExtracts, 'reportTime', 'alpha');
793
+ objectSort(reportExtracts, 'what', 'alpha');
794
+ // Get the latest ones for all page descriptions.
795
+ const latestReportExtracts = reportExtracts
796
+ .filter((extract, index) => extract.what !== reportExtracts[index + 1]?.what);
797
+ // Return them.
798
+ return latestReportExtracts;
799
+ };
800
+ // Returns an extract of an available report.
801
+ exports.getReportExtract = async (timeStamp, jobID) => {
802
+ // Get the extract of all available reports.
803
+ const reportsExtract = await getReportsExtract();
804
+ // Get the extract of the report from it.
805
+ const reportExtract = reportsExtract[`${timeStamp}-${jobID}`];
806
+ // If it exists:
807
+ if (reportExtract) {
808
+ // Return it.
809
+ return reportExtract;
810
+ }
811
+ // Otherwise, i.e. if it does not exist, return this.
812
+ return {
813
+ error: `No report ${timeStamp}-${jobID} is available`
814
+ };
815
+ };
816
+ // Gets the descriptions of multi-report pages.
817
+ exports.getMultiReportWhats = async () => {
818
+ // Get extracts of all available reports.
819
+ const reportExtracts = await getReportExtracts();
820
+ // Get their sorted page descriptions.
821
+ const sortedWhats = reportExtracts.map(extract => extract.what).sort();
822
+ // Get those that at least 2 reports have.
823
+ const multiReportWhats = sortedWhats.filter(
824
+ (what, index) => what !== sortedWhats[index - 1] && what === sortedWhats[index + 1]
825
+ );
826
+ // Return an array of descriptions of multi-report pages.
827
+ return multiReportWhats;
787
828
  };