@jrpool/kilotest 38.1.0 → 39.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.markdownlint-cli2.jsonc +12 -0
- package/.vscode/settings.json +3 -0
- package/CLAUDE.md +5 -0
- package/README.md +6 -4
- package/ai0BalanceForm/index.html +1 -1
- package/alerts.js +3 -1
- package/api/getReport.js +66 -0
- package/api/listDiagnoses.js +157 -0
- package/api/listIssues.js +225 -0
- package/api/listReports.js +87 -0
- package/api/listViolators.js +164 -0
- package/api/requestFeature.js +56 -0
- package/api/requestRetest.js +102 -0
- package/api/requestTest.js +106 -0
- package/api/routes.js +97 -0
- package/api/schemas.js +297 -0
- package/api/testSuite.js +294 -0
- package/api/util.js +149 -0
- package/diagnoses/index.js +2 -2
- package/docs/{AI-TOOL.md → AI-TOOLS.md} +54 -26
- package/docs/SERVICE.md +4 -1
- package/docs/decisions/001-api-design.md +59 -49
- package/eslint.config.mjs +13 -3
- package/index.html +1 -1
- package/index.js +202 -188
- package/issues/index.js +13 -21
- package/jsconfig.json +8 -0
- package/llms-full.txt +2 -2
- package/llms.txt +16 -11
- package/mcp.js +129 -65
- package/openapi.yaml +1234 -572
- package/package.json +18 -11
- package/reannotate/index.js +26 -15
- package/reannotateForm/index.js +15 -13
- package/redocly.yaml +4 -0
- package/reportHideForm/index.html +1 -1
- package/reportHideForm/index.js +24 -38
- package/reportIssue/index.js +3 -3
- package/reportIssues/index.js +128 -1
- package/reportUnhideForm/index.js +36 -44
- package/reportsExpungeForm/index.js +22 -16
- package/reportsPruneForm/index.js +22 -12
- package/reportsRewindForm/index.js +17 -9
- package/retestRec/index.js +8 -4
- package/retestRecForm/index.js +18 -6
- package/scripts/generate-openapi.js +83 -0
- package/targets/index.js +18 -13
- package/testOrder/index.js +11 -8
- package/tutorial/index.html +12 -12
- package/util.js +581 -539
- package/api/reportList.js +0 -83
- package/api/reportSummary.js +0 -181
- package/reportIssue/api.js +0 -183
- package/reportIssues/util.js +0 -138
- package/researchAgent.js +0 -206
- package/target/api.js +0 -126
- 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
|
|
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(
|
|
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(
|
|
24
|
+
const recsPath = exports.recsPath = path.join(jobsPath, 'recs.json');
|
|
23
25
|
// Path of the reports directory.
|
|
24
|
-
const reportsPath = exports.reportsPath = path.join(
|
|
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
|
-
//
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
// Returns
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
//
|
|
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
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
//
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
81
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
|
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
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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,
|
|
144
|
-
|
|
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
|
-
//
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
.
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
164
|
-
|
|
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
|
-
|
|
169
|
-
|
|
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
|
-
//
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
//
|
|
248
|
-
const
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
|
|
304
|
-
|
|
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
|
-
|
|
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
|
|
298
|
+
return recs;
|
|
320
299
|
};
|
|
321
|
-
//
|
|
322
|
-
exports.
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
-
//
|
|
513
|
-
exports.
|
|
514
|
-
|
|
515
|
-
.
|
|
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 !!
|
|
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
|
-
//
|
|
625
|
-
const
|
|
626
|
-
//
|
|
627
|
-
const
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
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
|
-
//
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
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
|
-
//
|
|
687
|
-
exports.
|
|
688
|
-
|
|
689
|
-
|
|
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
|
|
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
|
|
694
|
-
const
|
|
656
|
+
// Get the report.
|
|
657
|
+
const report = await getReport(timeStamp, jobID);
|
|
695
658
|
// If this failed:
|
|
696
|
-
if (
|
|
659
|
+
if (report.error) {
|
|
697
660
|
// Return why.
|
|
698
|
-
return
|
|
661
|
+
return report;
|
|
699
662
|
}
|
|
700
|
-
const {url, what} =
|
|
701
|
-
|
|
702
|
-
|
|
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 (!
|
|
677
|
+
if (!pageData) {
|
|
714
678
|
// Get them.
|
|
715
679
|
pageData = await getPageData(timeStamp, jobID);
|
|
716
680
|
}
|
|
@@ -732,56 +696,134 @@ 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
|
|
736
|
-
const
|
|
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 (!
|
|
783
|
-
return
|
|
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
|
+
await fs.mkdir(hiddenReportsPath, {recursive: true});
|
|
715
|
+
// Get the names of the hidden report files.
|
|
716
|
+
const hiddenReportFileNames = await fs.readdir(hiddenReportsPath);
|
|
717
|
+
// Return whether the report is among them.
|
|
718
|
+
return hiddenReportFileNames.includes(`${timeStamp}-${jobID}.json`);
|
|
719
|
+
};
|
|
720
|
+
// Creates, saves, and returns an extract of all available reports.
|
|
721
|
+
const makeReportsExtract = exports.makeReportsExtract = async () => {
|
|
722
|
+
// Initialize the reports data.
|
|
723
|
+
const reportsExtract = {};
|
|
724
|
+
// Get the names of all report files.
|
|
725
|
+
const reportFileNames = await fs.readdir(reportsPath);
|
|
726
|
+
// For each of them:
|
|
727
|
+
for (const reportFileName of reportFileNames) {
|
|
728
|
+
// Get its content.
|
|
729
|
+
const reportJSON = await fs.readFile(path.join(reportsPath, reportFileName), 'utf8');
|
|
730
|
+
// Get the object that it represents.
|
|
731
|
+
const report = JSON.parse(reportJSON);
|
|
732
|
+
const {target, jobData} = report;
|
|
733
|
+
const jobName = reportFileName.slice(0, -5);
|
|
734
|
+
const [timeStamp, jobID] = jobName.split('-');
|
|
735
|
+
const {what, url} = target;
|
|
736
|
+
// Get an extract from it.
|
|
737
|
+
const reportExtract = {
|
|
738
|
+
timeStamp,
|
|
739
|
+
jobID,
|
|
740
|
+
what,
|
|
741
|
+
url,
|
|
742
|
+
reportTime: new Date(`20${jobData.endTime}Z`).toISOString()
|
|
743
|
+
};
|
|
744
|
+
// Add the report extract to the reports extract.
|
|
745
|
+
reportsExtract[jobName] = reportExtract;
|
|
746
|
+
}
|
|
747
|
+
// Ensure the data directory exists.
|
|
748
|
+
await fs.mkdir(dbPath, {recursive: true});
|
|
749
|
+
// Save the extract of the reports.
|
|
750
|
+
await fs.writeFile(reportsExtractPath, getJSON(reportsExtract));
|
|
751
|
+
// Return it.
|
|
752
|
+
return reportsExtract;
|
|
753
|
+
};
|
|
754
|
+
// Gets an extract of all available reports.
|
|
755
|
+
const getReportsExtract = exports.getReportsExtract = async () => {
|
|
756
|
+
let reportsExtract;
|
|
757
|
+
try {
|
|
758
|
+
// Get the reports extract file.
|
|
759
|
+
const reportsExtractJSON = await fs.readFile(reportsExtractPath, 'utf8');
|
|
760
|
+
// Get its data.
|
|
761
|
+
reportsExtract = JSON.parse(reportsExtractJSON);
|
|
762
|
+
// If it is empty:
|
|
763
|
+
if (!Object.keys(reportsExtract).length) {
|
|
764
|
+
// Throw an error.
|
|
765
|
+
throw new Error('Reports extract file is empty');
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
// If this fails:
|
|
769
|
+
catch {
|
|
770
|
+
// Create or recreate and get the reports extract.
|
|
771
|
+
reportsExtract = await makeReportsExtract();
|
|
772
|
+
}
|
|
773
|
+
// Return the extract.
|
|
774
|
+
return reportsExtract;
|
|
775
|
+
};
|
|
776
|
+
// Returns extracts of all available reports.
|
|
777
|
+
const getReportExtracts = exports.getReportExtracts = async () => {
|
|
778
|
+
const reportsExtract = await getReportsExtract();
|
|
779
|
+
return Object.values(reportsExtract);
|
|
780
|
+
};
|
|
781
|
+
// Returns whether a report is available on a page with a description or URL.
|
|
782
|
+
exports.isReportAvailable = async (what, url) => {
|
|
783
|
+
const reportExtracts = await getReportExtracts();
|
|
784
|
+
const whats = reportExtracts.map(reportExtract => reportExtract.what);
|
|
785
|
+
const miniURLs = reportExtracts.map(reportExtract => minifyURL(reportExtract.url));
|
|
786
|
+
return whats.includes(what) || miniURLs.includes(minifyURL(url));
|
|
787
|
+
};
|
|
788
|
+
// Gets extracts of the latest available reports for all page descriptions.
|
|
789
|
+
exports.getLatestReportExtracts = async () => {
|
|
790
|
+
// Get extracts of all available reports.
|
|
791
|
+
const reportExtracts = await getReportExtracts();
|
|
792
|
+
// Sort them primarily by page description and secondarily by completion time.
|
|
793
|
+
objectSort(reportExtracts, 'reportTime', 'alpha');
|
|
794
|
+
objectSort(reportExtracts, 'what', 'alpha');
|
|
795
|
+
// Get the latest ones for all page descriptions.
|
|
796
|
+
const latestReportExtracts = reportExtracts
|
|
797
|
+
.filter((extract, index) => extract.what !== reportExtracts[index + 1]?.what);
|
|
798
|
+
// Return them.
|
|
799
|
+
return latestReportExtracts;
|
|
800
|
+
};
|
|
801
|
+
// Returns an extract of an available report.
|
|
802
|
+
exports.getReportExtract = async (timeStamp, jobID) => {
|
|
803
|
+
// Get the extract of all available reports.
|
|
804
|
+
const reportsExtract = await getReportsExtract();
|
|
805
|
+
// Get the extract of the report from it.
|
|
806
|
+
const reportExtract = reportsExtract[`${timeStamp}-${jobID}`];
|
|
807
|
+
// If it exists:
|
|
808
|
+
if (reportExtract) {
|
|
809
|
+
// Return it.
|
|
810
|
+
return reportExtract;
|
|
811
|
+
}
|
|
812
|
+
// Otherwise, i.e. if it does not exist, return this.
|
|
813
|
+
return {
|
|
814
|
+
error: `No report ${timeStamp}-${jobID} is available`
|
|
815
|
+
};
|
|
816
|
+
};
|
|
817
|
+
// Gets the descriptions of multi-report pages.
|
|
818
|
+
exports.getMultiReportWhats = async () => {
|
|
819
|
+
// Get extracts of all available reports.
|
|
820
|
+
const reportExtracts = await getReportExtracts();
|
|
821
|
+
// Get their sorted page descriptions.
|
|
822
|
+
const sortedWhats = reportExtracts.map(extract => extract.what).sort();
|
|
823
|
+
// Get those that at least 2 reports have.
|
|
824
|
+
const multiReportWhats = sortedWhats.filter(
|
|
825
|
+
(what, index) => what !== sortedWhats[index - 1] && what === sortedWhats[index + 1]
|
|
826
|
+
);
|
|
827
|
+
// Return an array of descriptions of multi-report pages.
|
|
828
|
+
return multiReportWhats;
|
|
787
829
|
};
|