@jrpool/kilotest 24.0.4
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/.claude/settings.local.json +9 -0
- package/DEVELOPMENT.md +5860 -0
- package/LICENSE +21 -0
- package/README.md +44 -0
- package/SERVICE.md +268 -0
- package/aceconfig.js +28 -0
- package/ai0BalanceForm/index.html +30 -0
- package/ai0BalanceForm/index.js +79 -0
- package/alerts.js +73 -0
- package/diagnoses/index.html +46 -0
- package/diagnoses/index.js +140 -0
- package/env.example +21 -0
- package/env.testaro +17 -0
- package/error.html +18 -0
- package/eslint.config.mjs +53 -0
- package/favicon.ico +0 -0
- package/index.html +39 -0
- package/index.js +639 -0
- package/issues/index.html +20 -0
- package/issues/index.js +173 -0
- package/job.json +100 -0
- package/manage/index.html +32 -0
- package/manage/index.js +22 -0
- package/package.json +38 -0
- package/pm2.config.js +15 -0
- package/reannotate/index.html +19 -0
- package/reannotate/index.js +39 -0
- package/reannotateForm/index.html +29 -0
- package/reannotateForm/index.js +114 -0
- package/recActionForm/index.html +33 -0
- package/recActionForm/index.js +49 -0
- package/reportHideForm/index.html +29 -0
- package/reportHideForm/index.js +89 -0
- package/reportIssue/index.html +38 -0
- package/reportIssue/index.js +181 -0
- package/reportIssues/index.html +47 -0
- package/reportIssues/index.js +259 -0
- package/reportUnhideForm/index.html +29 -0
- package/reportUnhideForm/index.js +89 -0
- package/reportsExpungeForm/index.html +29 -0
- package/reportsExpungeForm/index.js +105 -0
- package/reportsPruneForm/index.html +29 -0
- package/reportsPruneForm/index.js +105 -0
- package/reportsRewindForm/index.html +29 -0
- package/reportsRewindForm/index.js +105 -0
- package/retestRec/index.html +23 -0
- package/retestRec/index.js +19 -0
- package/retestRecForm/index.html +27 -0
- package/retestRecForm/index.js +36 -0
- package/rules/index.html +28 -0
- package/rules/index.js +71 -0
- package/style.css +196 -0
- package/targets/index.html +37 -0
- package/targets/index.js +170 -0
- package/testOrder/index.html +23 -0
- package/testOrder/index.js +62 -0
- package/testRec/index.html +23 -0
- package/testRec/index.js +25 -0
- package/testRecForm/index.html +34 -0
- package/testRecForm/index.js +22 -0
- package/tutorial/images/newsletter-form.png +0 -0
- package/tutorial/index.html +796 -0
- package/tutorial/index.js +53 -0
- package/util.js +686 -0
- package/wcagMap.json +102 -0
- package/wcagRenew/index.html +19 -0
- package/wcagRenew/index.js +70 -0
- package/wcagRenewForm/index.html +25 -0
- package/wcagRenewForm/index.js +22 -0
package/util.js
ADDED
|
@@ -0,0 +1,686 @@
|
|
|
1
|
+
/*
|
|
2
|
+
util.js
|
|
3
|
+
Utility functions.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// IMPORTS
|
|
7
|
+
|
|
8
|
+
const {sendAlert} = require('./alerts');
|
|
9
|
+
const {issues} = require('testilo/procs/score/tic');
|
|
10
|
+
const fs = require('fs/promises');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const querystring = require('querystring');
|
|
13
|
+
const wcagMap = require('./wcagMap.json');
|
|
14
|
+
|
|
15
|
+
// CONSTANTS
|
|
16
|
+
|
|
17
|
+
// 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');
|
|
21
|
+
// Path of the recommendations file.
|
|
22
|
+
const recsPath = exports.recsPath = path.join(__dirname, 'jobs', 'recs.json');
|
|
23
|
+
// Path of the reports directory.
|
|
24
|
+
const reportsPath = exports.reportsPath = path.join(__dirname, 'reports');
|
|
25
|
+
// IDs, names, and sponsors of Testaro tools.
|
|
26
|
+
const tools = exports.tools = {
|
|
27
|
+
alfa: ['Alfa', 'Siteimprove'],
|
|
28
|
+
aslint: ['ASLint', 'eSSENTIAL Accessibility'],
|
|
29
|
+
axe: ['Axe', 'Deque'],
|
|
30
|
+
ed11y: ['Editoria11y', 'Princeton University'],
|
|
31
|
+
htmlcs: ['HTML CodeSniffer', 'Squiz Labs'],
|
|
32
|
+
ibm: ['Accessibility Checker', 'IBM'],
|
|
33
|
+
nuVal: ['Html Checker API', 'World Wide Web Consortium'],
|
|
34
|
+
nuVnu: ['Html Checker', 'World Wide Web Consortium'],
|
|
35
|
+
qualWeb: ['QualWeb', 'University of Lisbon'],
|
|
36
|
+
testaro: ['Testaro', 'CVS Health'],
|
|
37
|
+
wax: ['WallyAX', 'Wally'],
|
|
38
|
+
wave: ['WAVE', 'Utah State University'],
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// FUNCTIONS
|
|
42
|
+
|
|
43
|
+
// Returns whether a report is valid.
|
|
44
|
+
exports.isValidReport = report => {
|
|
45
|
+
// Return whether it has the type and properties required by Kilotest:
|
|
46
|
+
return typeof report === 'object'
|
|
47
|
+
&& typeof report.target?.what === 'string'
|
|
48
|
+
&& typeof report.target?.url === 'string'
|
|
49
|
+
&& Array.isArray(report.acts)
|
|
50
|
+
&& report.acts.every(act =>
|
|
51
|
+
typeof act === 'object'
|
|
52
|
+
&& typeof act.type === 'string'
|
|
53
|
+
&& act.type === 'test' ? typeof act.which === 'string' && act.which : true
|
|
54
|
+
)
|
|
55
|
+
&& typeof report.jobData === 'object';
|
|
56
|
+
};
|
|
57
|
+
// Encodes a string for use as a URL fragment.
|
|
58
|
+
const fragmentEncode = string => {
|
|
59
|
+
return encodeURIComponent(string).replace(/-/g, '%2D');
|
|
60
|
+
};
|
|
61
|
+
// Returns the path of a log file.
|
|
62
|
+
const getLogPath = exports.getLogPath
|
|
63
|
+
= (timeStamp, jobID) => path.join(logsPath, `${timeStamp}-${jobID}.json`);
|
|
64
|
+
// Returns the path of a report file.
|
|
65
|
+
const getReportPath = exports.getReportPath
|
|
66
|
+
= (timeStamp, jobID) => path.join(reportsPath, `${timeStamp}-${jobID}.json`);
|
|
67
|
+
// Returns the path of a log or report file.
|
|
68
|
+
const getRecordPath = exports.getRecordPath = (recordType, timeStamp, jobID) => {
|
|
69
|
+
if (recordType === 'log') {
|
|
70
|
+
return getLogPath(timeStamp, jobID);
|
|
71
|
+
}
|
|
72
|
+
else if (recordType === 'report') {
|
|
73
|
+
return getReportPath(timeStamp, jobID);
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
// Returns a log or report or an error message.
|
|
80
|
+
const getRecord = exports.getRecord = async (recordType, timeStamp, jobID) => {
|
|
81
|
+
const recordPath = getRecordPath(recordType, timeStamp, jobID);
|
|
82
|
+
let recordJSON, record;
|
|
83
|
+
try {
|
|
84
|
+
recordJSON = await fs.readFile(recordPath, 'utf8');
|
|
85
|
+
}
|
|
86
|
+
catch(error) {
|
|
87
|
+
console.log(error.message);
|
|
88
|
+
return `ERROR: Requested ${recordType} ${timeStamp}-${jobID} not found`;
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
record = JSON.parse(recordJSON);
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
console.log(error.message);
|
|
95
|
+
return `ERROR: Requested ${recordType} ${timeStamp}-${jobID} not JSON`;
|
|
96
|
+
}
|
|
97
|
+
return record;
|
|
98
|
+
};
|
|
99
|
+
// Returns a report or an error message.
|
|
100
|
+
const getReport = exports.getReport = async (timeStamp, jobID) => await getRecord(
|
|
101
|
+
'report', timeStamp, jobID
|
|
102
|
+
);
|
|
103
|
+
// Returns the JSON stringification of an object, with a final newline.
|
|
104
|
+
const getJSON = exports.getJSON = object => `${JSON.stringify(object, null, 2)}\n`;
|
|
105
|
+
// Returns a date string from a time stamp.
|
|
106
|
+
const getDateString = exports.getDateString = timeStamp => {
|
|
107
|
+
const dateString = `20${timeStamp.slice(0, 2)}-${timeStamp.slice(2, 4)}-${timeStamp.slice(4,6)}`;
|
|
108
|
+
// If the date part of the time stamp is valid:
|
|
109
|
+
if (Date.parse(dateString)) {
|
|
110
|
+
// Return a date string from it.
|
|
111
|
+
return dateString;
|
|
112
|
+
}
|
|
113
|
+
// Otherwise, return a failure.
|
|
114
|
+
return '';
|
|
115
|
+
};
|
|
116
|
+
// Returns the date and time represented by a time stamp.
|
|
117
|
+
const getDateTime = timeStamp => {
|
|
118
|
+
const dateString
|
|
119
|
+
= `20${timeStamp.slice(0, 2)}-${timeStamp.slice(2, 4)}-${timeStamp.slice(4,6)}T${timeStamp.slice(7,9)}:${timeStamp.slice(9,11)}Z`;
|
|
120
|
+
const dateTime = new Date(dateString);
|
|
121
|
+
return dateTime;
|
|
122
|
+
};
|
|
123
|
+
// Returns the time in days since a time stamp.
|
|
124
|
+
const getAgoDays = exports.getAgoDays = timeStamp => Math.round(
|
|
125
|
+
(Date.now() - getDateTime(timeStamp)) / (1000 * 60 * 60 * 24)
|
|
126
|
+
);
|
|
127
|
+
// Returns a time string from a time stamp.
|
|
128
|
+
const getTimeString = timeStamp => {
|
|
129
|
+
const timeString = `${timeStamp.slice(7, 9)}:${timeStamp.slice(9, 11)}`;
|
|
130
|
+
// If the time part of the time stamp is valid:
|
|
131
|
+
if (Date.parse(`2000-01-01T${timeString}`)) {
|
|
132
|
+
// Return a time string from it.
|
|
133
|
+
return timeString;
|
|
134
|
+
}
|
|
135
|
+
// Otherwise, return a failure.
|
|
136
|
+
return '';
|
|
137
|
+
};
|
|
138
|
+
// Compares strings alphabetically and case-insensitively.
|
|
139
|
+
const alphaCompare = (a, b) => a.localeCompare(b, 'en', {sensitivity: 'accent'});
|
|
140
|
+
// Sorts strings alphabetically and case-insensitively.
|
|
141
|
+
const alphaSort = strings => strings.sort((a, b) => alphaCompare(a, b));
|
|
142
|
+
// Sorts objects by a property value.
|
|
143
|
+
exports.objectSort = (objects, property, sortType) => objects
|
|
144
|
+
.sort((a, b) => {
|
|
145
|
+
// If the property values are numbers to be sorted in increasing order:
|
|
146
|
+
if (sortType === 'numericUp') {
|
|
147
|
+
// Sort by increasing numeric value.
|
|
148
|
+
return a[property] - b[property];
|
|
149
|
+
}
|
|
150
|
+
// Otherwise, if they are numbers to be sorted in decreasing order:
|
|
151
|
+
else if (sortType === 'numericDown') {
|
|
152
|
+
// Sort by decreasing numeric value.
|
|
153
|
+
return b[property] - a[property];
|
|
154
|
+
}
|
|
155
|
+
// Otherwise, if they are strings to be sorted alphabetically:
|
|
156
|
+
else if (sortType === 'alpha') {
|
|
157
|
+
// Sort alphabetically.
|
|
158
|
+
return alphaCompare(a[property], b[property]);
|
|
159
|
+
}
|
|
160
|
+
// Otherwise, do not sort.
|
|
161
|
+
return 0;
|
|
162
|
+
});
|
|
163
|
+
// Compiles a directory of the issue classifications of invariant and variable rules.
|
|
164
|
+
const getRuleIDs = exports.getRuleIDs = () => {
|
|
165
|
+
// Initialize data on invariant and variable rule IDs.
|
|
166
|
+
const invariant = {};
|
|
167
|
+
const variable = {};
|
|
168
|
+
// Initialize a validity checker.
|
|
169
|
+
const validityChecker = {};
|
|
170
|
+
// For each classified issue:
|
|
171
|
+
Object.keys(issues).forEach(issueID => {
|
|
172
|
+
const {tools, weight} = issues[issueID];
|
|
173
|
+
// If the weight is invalid:
|
|
174
|
+
if (weight < 1 || weight > 4) {
|
|
175
|
+
// Report this.
|
|
176
|
+
console.log(`ERROR: Issue ${issueID} weight is invalid`);
|
|
177
|
+
}
|
|
178
|
+
// For each tool that has any rules belonging to the issue:
|
|
179
|
+
Object.keys(tools).forEach(toolID => {
|
|
180
|
+
// For each such rule:
|
|
181
|
+
Object.keys(tools[toolID]).forEach(ruleID => {
|
|
182
|
+
// If it is a duplicate:
|
|
183
|
+
if (validityChecker[toolID]?.has(ruleID)) {
|
|
184
|
+
// Report this.
|
|
185
|
+
console.log(`ERROR: Rule ${ruleID} of tool ${toolID} belongs to 2 issues`);
|
|
186
|
+
}
|
|
187
|
+
// Otherwise, i.e. if it is not a duplicate:
|
|
188
|
+
else {
|
|
189
|
+
// Add it to the classified rules of the tool.
|
|
190
|
+
validityChecker[toolID] ??= new Set();
|
|
191
|
+
validityChecker[toolID].add(ruleID);
|
|
192
|
+
}
|
|
193
|
+
const rule = tools[toolID][ruleID];
|
|
194
|
+
// If it is variable:
|
|
195
|
+
if (rule.variable) {
|
|
196
|
+
variable[toolID] ??= {};
|
|
197
|
+
// Add its ID and the issue ID to the variable rule IDs.
|
|
198
|
+
variable[toolID][ruleID] = issueID;
|
|
199
|
+
}
|
|
200
|
+
// Otherwise, i.e. if it is invariant:
|
|
201
|
+
else {
|
|
202
|
+
invariant[toolID] ??= {};
|
|
203
|
+
// Add its ID and the issue ID to the invariant rule IDs.
|
|
204
|
+
invariant[toolID][ruleID] = issueID;
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
// Return the data.
|
|
210
|
+
return {
|
|
211
|
+
invariant,
|
|
212
|
+
variable
|
|
213
|
+
};
|
|
214
|
+
};
|
|
215
|
+
// Variable and invariant rules.
|
|
216
|
+
const ruleIDs = exports.ruleIDs = getRuleIDs();
|
|
217
|
+
// Returns the issue that a rule belongs to, or null if none.
|
|
218
|
+
const getIssue = exports.getIssue = (ruleIDs, toolID, ruleID) => {
|
|
219
|
+
const {invariant, variable} = ruleIDs;
|
|
220
|
+
// Initialize the issue ID of the rule as if the rule ID is invariant.
|
|
221
|
+
let issueID = invariant[toolID]?.[ruleID];
|
|
222
|
+
// If the initialization succeeded:
|
|
223
|
+
if (issueID) {
|
|
224
|
+
// Return it.
|
|
225
|
+
return issueID;
|
|
226
|
+
}
|
|
227
|
+
// Otherwise, change the rule ID to the first matching variable rule ID of the tool.
|
|
228
|
+
ruleID = Object
|
|
229
|
+
.keys(variable[toolID] ?? {})
|
|
230
|
+
.find(variableRuleID => new RegExp(variableRuleID).test(ruleID));
|
|
231
|
+
// If the change succeeded:
|
|
232
|
+
if (ruleID) {
|
|
233
|
+
// Return the issue ID.
|
|
234
|
+
return variable[toolID][ruleID];
|
|
235
|
+
}
|
|
236
|
+
// Otherwise, i.e. if no issue was found, return a failure result.
|
|
237
|
+
return null;
|
|
238
|
+
};
|
|
239
|
+
// Adds issue IDs to the standard instances of a report.
|
|
240
|
+
const annotateReport = exports.annotateReport = async (ruleIDs, timeStamp, jobID) => {
|
|
241
|
+
// Get a copy of the report.
|
|
242
|
+
const reportOrError = await getReport(timeStamp, jobID);
|
|
243
|
+
// If this failed:
|
|
244
|
+
if (typeof reportOrError === 'string') {
|
|
245
|
+
// Return this.
|
|
246
|
+
return reportOrError;
|
|
247
|
+
}
|
|
248
|
+
const report = reportOrError;
|
|
249
|
+
const unclassifiableRules = new Set();
|
|
250
|
+
// For each of its acts:
|
|
251
|
+
for (const act of report.acts) {
|
|
252
|
+
const {result, type, which} = act;
|
|
253
|
+
// If it is a test act:
|
|
254
|
+
if (type === 'test') {
|
|
255
|
+
// For each standard instance of the result:
|
|
256
|
+
for (const instance of result?.standardResult?.instances ?? []) {
|
|
257
|
+
const {ruleID} = instance;
|
|
258
|
+
// Classify its rule.
|
|
259
|
+
const issueID = getIssue(ruleIDs, which, ruleID);
|
|
260
|
+
// If the rule was classifiable:
|
|
261
|
+
if (issueID) {
|
|
262
|
+
// Add the issue ID to the instance.
|
|
263
|
+
instance.issueID = issueID;
|
|
264
|
+
}
|
|
265
|
+
// Otherwise, i.e. if it was not classifiable:
|
|
266
|
+
else {
|
|
267
|
+
// Add it to the set of unclassifiable rules.
|
|
268
|
+
unclassifiableRules.add(`${which}:${ruleID}`);
|
|
269
|
+
// Remove any existing issue ID from the instance.
|
|
270
|
+
delete instance.issueID;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
const issuelessRules = Array.from(unclassifiableRules).sort();
|
|
276
|
+
// Update the issueless rules in the report.
|
|
277
|
+
report.jobData.issuelessRules = issuelessRules;
|
|
278
|
+
// If any rules were unclassifiable:
|
|
279
|
+
if (issuelessRules.length) {
|
|
280
|
+
// Alert a manager about them.
|
|
281
|
+
await sendAlert(
|
|
282
|
+
'Kilotest: unclassified rules violated',
|
|
283
|
+
`Job ${timeStamp}-${jobID}: Violated rules in no issues:\n${issuelessRules.join('\n')}`
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
// Save the annotated report.
|
|
287
|
+
await fs.writeFile(getReportPath(timeStamp, jobID), getJSON(report));
|
|
288
|
+
// Get a copy of the log of the report.
|
|
289
|
+
const logOrError = await getLog(timeStamp, jobID, false);
|
|
290
|
+
// If this failed:
|
|
291
|
+
if (typeof logOrError === 'string') {
|
|
292
|
+
// Return this.
|
|
293
|
+
return logOrError;
|
|
294
|
+
}
|
|
295
|
+
// Otherwise, i.e. if it succeeded:
|
|
296
|
+
else {
|
|
297
|
+
const log = logOrError;
|
|
298
|
+
// Mark the report as annotated in the log.
|
|
299
|
+
log.annotated = true;
|
|
300
|
+
// Save the revised log.
|
|
301
|
+
await fs.writeFile(getLogPath(timeStamp, jobID), getJSON(log));
|
|
302
|
+
// Return without an error message.
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
// Returns a report log after conditionally annotating it or an error message.
|
|
306
|
+
const getLog = exports.getLog = async (timeStamp, jobID, annotate = false) => {
|
|
307
|
+
const log = await getRecord('log', timeStamp, jobID);
|
|
308
|
+
if (typeof log === 'object' && annotate && ! log.annotated) {
|
|
309
|
+
annotateReport(ruleIDs, timeStamp, jobID);
|
|
310
|
+
}
|
|
311
|
+
return log;
|
|
312
|
+
};
|
|
313
|
+
// Returns whether a report is hidden or an error message.
|
|
314
|
+
exports.isHidden = async (timeStamp, jobID) => {
|
|
315
|
+
const log = await getLog(timeStamp, jobID, false);
|
|
316
|
+
if (typeof log === 'string') {
|
|
317
|
+
return log;
|
|
318
|
+
}
|
|
319
|
+
return !! log.hidden;
|
|
320
|
+
};
|
|
321
|
+
// Returns summary data on the results in a report.
|
|
322
|
+
exports.getTargetData = async (timeStamp, jobID) => {
|
|
323
|
+
// Vasidate the report and annotate it if necessary.
|
|
324
|
+
const log = await getLog(timeStamp, jobID, true);
|
|
325
|
+
// If this failed:
|
|
326
|
+
if (typeof log === 'string') {
|
|
327
|
+
// Return this.
|
|
328
|
+
return log;
|
|
329
|
+
}
|
|
330
|
+
// Initialize the data.
|
|
331
|
+
const data = {
|
|
332
|
+
what: log.what,
|
|
333
|
+
url: log.url,
|
|
334
|
+
issueSet: new Set(),
|
|
335
|
+
reporterSet: new Set(),
|
|
336
|
+
violatorSet: new Set(),
|
|
337
|
+
preventedTools: {}
|
|
338
|
+
};
|
|
339
|
+
const {issueSet, reporterSet, violatorSet} = data;
|
|
340
|
+
// Get the report.
|
|
341
|
+
const report = await getReport(timeStamp, jobID);
|
|
342
|
+
// If this failed:
|
|
343
|
+
if (typeof report === 'string') {
|
|
344
|
+
// Return this.
|
|
345
|
+
return report;
|
|
346
|
+
}
|
|
347
|
+
// For each act of the report:
|
|
348
|
+
report.acts.forEach(act => {
|
|
349
|
+
// If it is a test act:
|
|
350
|
+
if (act.type === 'test') {
|
|
351
|
+
const {result, which} = act;
|
|
352
|
+
const instances = result?.standardResult?.instances ?? [];
|
|
353
|
+
// For each standard instance:
|
|
354
|
+
instances.forEach(instance => {
|
|
355
|
+
const {catalogIndex, issueID} = instance;
|
|
356
|
+
// If it has a non-ignorable classified issue ID:
|
|
357
|
+
if (issueID && issues[issueID] && issueID !== 'ignorable') {
|
|
358
|
+
// Ensure that the tool is in the data.
|
|
359
|
+
reporterSet.add(which);
|
|
360
|
+
// Ensure that the issue is in the data.
|
|
361
|
+
issueSet.add(issueID);
|
|
362
|
+
// If it has a catalog index:
|
|
363
|
+
if (catalogIndex) {
|
|
364
|
+
// Ensure that the violator is in the data.
|
|
365
|
+
violatorSet.add(catalogIndex);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
// Add the IDs of any prevented tools to the data.
|
|
372
|
+
data.preventedTools = Object.keys(report.jobData?.preventions || {});
|
|
373
|
+
// Return the data.
|
|
374
|
+
return data;
|
|
375
|
+
}
|
|
376
|
+
// Returns a time stamp from a date.
|
|
377
|
+
const getTimeStamp = exports.getTimeStamp = date => {
|
|
378
|
+
const timeStamp = date.toISOString().slice(2).replace(/[-:]/g, '').slice(0, 11);
|
|
379
|
+
return timeStamp;
|
|
380
|
+
};
|
|
381
|
+
// Gets the names and categories of the job files.
|
|
382
|
+
const getJobNames = exports.getJobNames = async () => {
|
|
383
|
+
const jobNames = {};
|
|
384
|
+
let fileNames;
|
|
385
|
+
for (const category of ['queue', 'claimed', 'failed']) {
|
|
386
|
+
try {
|
|
387
|
+
fileNames = await fs.readdir(path.join(jobsPath, category));
|
|
388
|
+
}
|
|
389
|
+
catch(error) {
|
|
390
|
+
return `ERROR: Job directory ${category} not readable (${error.message})`;
|
|
391
|
+
}
|
|
392
|
+
jobNames[category] = fileNames;
|
|
393
|
+
}
|
|
394
|
+
return jobNames;
|
|
395
|
+
}
|
|
396
|
+
// Returns an object from a JSON file.
|
|
397
|
+
const getObject = exports.getObject = async filePath => {
|
|
398
|
+
let fileContent, object;
|
|
399
|
+
try {
|
|
400
|
+
fileContent = await fs.readFile(filePath, 'utf8');
|
|
401
|
+
}
|
|
402
|
+
catch(error) {
|
|
403
|
+
return `ERROR: File ${filePath} not readable (${error.message})`;
|
|
404
|
+
}
|
|
405
|
+
try {
|
|
406
|
+
object = JSON.parse(fileContent);
|
|
407
|
+
}
|
|
408
|
+
catch(error) {
|
|
409
|
+
return `ERROR: File ${filePath} not JSON (${error.message})`;
|
|
410
|
+
}
|
|
411
|
+
return object;
|
|
412
|
+
};
|
|
413
|
+
// Returns a string describing the time in days since a time stamp.
|
|
414
|
+
exports.getAgoString = timeStamp => {
|
|
415
|
+
const agoDays = getAgoDays(timeStamp);
|
|
416
|
+
return agoDays === 1 ? '1 day' : `${agoDays} days`;
|
|
417
|
+
};
|
|
418
|
+
// Returns a date-and-time string.
|
|
419
|
+
const getDateTimeString = exports.getDateTimeString = timeStamp => {
|
|
420
|
+
const dateString = getDateString(timeStamp);
|
|
421
|
+
const timeString = getTimeString(timeStamp);
|
|
422
|
+
const dateTimeString = `${dateString} at ${timeString}`;
|
|
423
|
+
return dateTimeString;
|
|
424
|
+
}
|
|
425
|
+
// Returns the path ID of the element of a standard instance.
|
|
426
|
+
exports.getPathID = (catalog, catalogIndex, pathID) => {
|
|
427
|
+
if (catalogIndex) {
|
|
428
|
+
const catalogItem = catalog[catalogIndex] || {};
|
|
429
|
+
if (catalogItem.pathID) {
|
|
430
|
+
return catalogItem.pathID;
|
|
431
|
+
}
|
|
432
|
+
return pathID ?? '/html';
|
|
433
|
+
}
|
|
434
|
+
return pathID ?? '/html';
|
|
435
|
+
};
|
|
436
|
+
// Returns the data from a POST request.
|
|
437
|
+
exports.getPOSTData = request => new Promise(resolve => {
|
|
438
|
+
const bodyParts = [];
|
|
439
|
+
request.on('data', chunk => {
|
|
440
|
+
bodyParts.push(chunk);
|
|
441
|
+
});
|
|
442
|
+
request.on('end', () => {
|
|
443
|
+
const contentType = request.headers['content-type'];
|
|
444
|
+
if (contentType.startsWith('application/json')) {
|
|
445
|
+
const bodyJSON = bodyParts.join('');
|
|
446
|
+
const body = JSON.parse(bodyJSON);
|
|
447
|
+
resolve(body);
|
|
448
|
+
}
|
|
449
|
+
else if (contentType.startsWith('application/x-www-form-urlencoded')) {
|
|
450
|
+
const body = bodyParts.join('');
|
|
451
|
+
const query = querystring.parse(body);
|
|
452
|
+
resolve(query);
|
|
453
|
+
}
|
|
454
|
+
});
|
|
455
|
+
});
|
|
456
|
+
// Returns the waiting test and retest recommendations.
|
|
457
|
+
const getRecs = exports.getRecs = async () => {
|
|
458
|
+
let recs = {};
|
|
459
|
+
let recsJSON;
|
|
460
|
+
try {
|
|
461
|
+
recsJSON = await fs.readFile(recsPath, 'utf8');
|
|
462
|
+
}
|
|
463
|
+
catch(error) {
|
|
464
|
+
await fs.writeFile(recsPath, '{}\n');
|
|
465
|
+
return `ERROR: recommendations file not readable, so created an empty one (${error.message})`;
|
|
466
|
+
}
|
|
467
|
+
try {
|
|
468
|
+
recs = JSON.parse(recsJSON);
|
|
469
|
+
}
|
|
470
|
+
catch(error) {
|
|
471
|
+
return `ERROR: recommendations file not JSON (${error.message})`;
|
|
472
|
+
}
|
|
473
|
+
return recs;
|
|
474
|
+
};
|
|
475
|
+
// Returns a string of tool names.
|
|
476
|
+
exports.getToolNamesString = toolIDSet =>
|
|
477
|
+
alphaSort(Array.from(toolIDSet).map(toolID => tools[toolID][0])).join(' + ');
|
|
478
|
+
// Converts a catalog item text to a text-fragment link destination.
|
|
479
|
+
exports.getTextFragmentHref = (text, url) => {
|
|
480
|
+
const fragmentList = text
|
|
481
|
+
.split('\n')
|
|
482
|
+
.map(fragment => fragmentEncode(fragment))
|
|
483
|
+
.join(',');
|
|
484
|
+
// Return a text-fragment link.
|
|
485
|
+
return `${url}#:~:text=${fragmentList}`;
|
|
486
|
+
};
|
|
487
|
+
// Returns an array of the logs of the public reports on the tested targets.
|
|
488
|
+
exports.getLogs = async () => {
|
|
489
|
+
// Initialize data on the tested targets.
|
|
490
|
+
const logs = [];
|
|
491
|
+
let logFileNames;
|
|
492
|
+
try {
|
|
493
|
+
logFileNames = await fs.readdir(logsPath);
|
|
494
|
+
}
|
|
495
|
+
catch(error) {
|
|
496
|
+
return `ERROR: logs directory not readable (${error.message})`;
|
|
497
|
+
}
|
|
498
|
+
// For each log:
|
|
499
|
+
for (const fileName of logFileNames) {
|
|
500
|
+
const logName = fileName.slice(0, -5);
|
|
501
|
+
const [timeStamp, jobID] = logName.split('-');
|
|
502
|
+
// Get it.
|
|
503
|
+
const logOrError = await getLog(timeStamp, jobID);
|
|
504
|
+
// If this failed:
|
|
505
|
+
if (typeof logOrError === 'string') {
|
|
506
|
+
// Return this.
|
|
507
|
+
return logOrError;
|
|
508
|
+
}
|
|
509
|
+
const log = logOrError;
|
|
510
|
+
// If the report is hidden:
|
|
511
|
+
if (log.hidden) {
|
|
512
|
+
// Disregard it.
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
// Add the job name to the log.
|
|
516
|
+
log.jobName = logName;
|
|
517
|
+
// Add the log to the logs.
|
|
518
|
+
logs.push(log);
|
|
519
|
+
}
|
|
520
|
+
// Sort the logs by target name and secondarily by test time.
|
|
521
|
+
logs.sort((a, b) => {
|
|
522
|
+
// During the sort, if the jobs tested the same target:
|
|
523
|
+
if (b.what === a.what) {
|
|
524
|
+
// Add to the earlier log the fact that its report has been superseded.
|
|
525
|
+
if (a.jobName < b.jobName) {
|
|
526
|
+
a.superseded = true;
|
|
527
|
+
return 1;
|
|
528
|
+
}
|
|
529
|
+
b.superseded = true;
|
|
530
|
+
return -1;
|
|
531
|
+
}
|
|
532
|
+
return a.what.localeCompare(b.what, {}, {sensitivity: 'base'});
|
|
533
|
+
});
|
|
534
|
+
// Return them.
|
|
535
|
+
return logs;
|
|
536
|
+
};
|
|
537
|
+
// Gets the name of an issue weight.
|
|
538
|
+
exports.getWeightName = weight => ['lowest', 'low', 'high', 'highest'][weight - 1] ?? 'unknown';
|
|
539
|
+
// Makes a string HTML-safe.
|
|
540
|
+
exports.htmlSafe = string => string ? string
|
|
541
|
+
.replace(/&/g, '&')
|
|
542
|
+
.replace(/</g, '<')
|
|
543
|
+
.replace(/>/g, '>')
|
|
544
|
+
.replace(/"/g, '"')
|
|
545
|
+
.replace(/'/g, ''')
|
|
546
|
+
: '';
|
|
547
|
+
// Returns whether a string is a job ID.
|
|
548
|
+
exports.isJobID = string => {
|
|
549
|
+
return /^[a-z0-9]{3}$/.test(string);
|
|
550
|
+
};
|
|
551
|
+
// Returns whether a job to test a target is eligible for a recommendation.
|
|
552
|
+
exports.isRecommendable = async url => {
|
|
553
|
+
const jobNames = await getJobNames();
|
|
554
|
+
// For each claimed job:
|
|
555
|
+
for (const fileName of jobNames.claimed) {
|
|
556
|
+
const job = await getObject(path.join(jobsPath, 'claimed', fileName));
|
|
557
|
+
// If its URL is that of the recommended target:
|
|
558
|
+
if (job.target.url === url) {
|
|
559
|
+
// Return this.
|
|
560
|
+
return 'claimed';
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
// If no claimed job has the URL of the target, for each queued job:
|
|
564
|
+
for (const fileName of jobNames.queue) {
|
|
565
|
+
const job = await getObject(path.join(jobsPath, 'queue', fileName));
|
|
566
|
+
// If its URL is that of the recommended target:
|
|
567
|
+
if (job.target.url === url) {
|
|
568
|
+
// Return this.
|
|
569
|
+
return 'queued';
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
// If no claimed or queued job has the URL of the target, return this.
|
|
573
|
+
return '';
|
|
574
|
+
};
|
|
575
|
+
// Returns whether a string is a time stamp.
|
|
576
|
+
exports.isTimeStamp = string => {
|
|
577
|
+
return !! getDateString(string);
|
|
578
|
+
};
|
|
579
|
+
// Returns whether a string is a URL.
|
|
580
|
+
const isURL = exports.isURL = string => {
|
|
581
|
+
try {
|
|
582
|
+
return string.startsWith('https://') && new URL(string);
|
|
583
|
+
} catch {
|
|
584
|
+
return false;
|
|
585
|
+
}
|
|
586
|
+
};
|
|
587
|
+
// Makes a string breakable before non-initial slashes.
|
|
588
|
+
exports.makeBreakable = string => string.replace(/\//g, '<wbr>/').replace(/^<wbr>/, '');
|
|
589
|
+
// Converts a string to a plain-text 1-line ASCII string.
|
|
590
|
+
const getPlainText = string => string.replace(/&/g, '+').replace(/[<>"'&]/g, ' ');
|
|
591
|
+
// Returns a time stamp for now.
|
|
592
|
+
const getNowStamp = exports.getNowStamp = () => {
|
|
593
|
+
return getTimeStamp(new Date());
|
|
594
|
+
};
|
|
595
|
+
// Processes a test or retest recommendation.
|
|
596
|
+
exports.processRec = async (testType, dirName, what, url, why) => {
|
|
597
|
+
// If the recommendation is valid:
|
|
598
|
+
if (
|
|
599
|
+
['test', 'retest'].includes(testType)
|
|
600
|
+
&& ['testRec', 'retestRec'].some(end => dirName.endsWith(end))
|
|
601
|
+
&& what
|
|
602
|
+
&& isURL(url)
|
|
603
|
+
&& why.length > 4
|
|
604
|
+
) {
|
|
605
|
+
// Make the reason display-safe.
|
|
606
|
+
const plainWhy = getPlainText(why);
|
|
607
|
+
// Get the data on waiting recommendations.
|
|
608
|
+
const recs = await getRecs();
|
|
609
|
+
recs[url] ??= [];
|
|
610
|
+
// Add the recommendation to those for the target.
|
|
611
|
+
recs[url].push({
|
|
612
|
+
timeStamp: getNowStamp(),
|
|
613
|
+
what,
|
|
614
|
+
why: plainWhy
|
|
615
|
+
});
|
|
616
|
+
// Save the revised recommendations.
|
|
617
|
+
await fs.writeFile(recsPath, getJSON(recs));
|
|
618
|
+
// Log the recommendation.
|
|
619
|
+
console.log(`Test recommendation received for ${what}: ${plainWhy}`);
|
|
620
|
+
// Alert a manager about it.
|
|
621
|
+
await sendAlert(
|
|
622
|
+
`Kilotest: new ${testType} recommendation`,
|
|
623
|
+
`Target: ${what}\nURL: ${url}\nReason: ${plainWhy}`
|
|
624
|
+
);
|
|
625
|
+
// Get the template.
|
|
626
|
+
let answerPage = await fs.readFile(path.join(dirName, 'index.html'), 'utf8');
|
|
627
|
+
const query = {
|
|
628
|
+
target: what,
|
|
629
|
+
why: plainWhy
|
|
630
|
+
};
|
|
631
|
+
// Replace its placeholders.
|
|
632
|
+
Object.keys(query).forEach(param => {
|
|
633
|
+
answerPage = answerPage.replace(new RegExp(`__${param}__`, 'g'), query[param]);
|
|
634
|
+
});
|
|
635
|
+
// Return the populated page.
|
|
636
|
+
return {
|
|
637
|
+
status: 'ok',
|
|
638
|
+
answerPage
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
return {
|
|
642
|
+
status: 'error',
|
|
643
|
+
error: 'Invalid recommendation'
|
|
644
|
+
};
|
|
645
|
+
};
|
|
646
|
+
// Gets the WCAG Understanding link for a numeric WCAG standard identifier.
|
|
647
|
+
exports.getWCAGLink = numericID => {
|
|
648
|
+
// Return the link.
|
|
649
|
+
return `https://www.w3.org/WAI/WCAG22/Understanding/${wcagMap[numericID]}`;
|
|
650
|
+
};
|
|
651
|
+
// Gets page data from a report.
|
|
652
|
+
const getPageData = async (timeStamp, jobID) => {
|
|
653
|
+
// Get the log of the report.
|
|
654
|
+
const log = await getLog(timeStamp, jobID, false);
|
|
655
|
+
// If this failed:
|
|
656
|
+
if (typeof log === 'string') {
|
|
657
|
+
// Return the error.
|
|
658
|
+
return log;
|
|
659
|
+
}
|
|
660
|
+
const {url, what} = log;
|
|
661
|
+
// Get the elapsed time in days since the test.
|
|
662
|
+
const daysAgo = getAgoDays(timeStamp);
|
|
663
|
+
// Return the data.
|
|
664
|
+
return {
|
|
665
|
+
what,
|
|
666
|
+
url,
|
|
667
|
+
daysAgo
|
|
668
|
+
};
|
|
669
|
+
};
|
|
670
|
+
// Gets HTML strings for page data from a report.
|
|
671
|
+
exports.getPageDataStrings = async (timeStamp, jobID, pageData) => {
|
|
672
|
+
// If the paga data were not specified:
|
|
673
|
+
if (! pageData) {
|
|
674
|
+
// Get them.
|
|
675
|
+
pageData = await getPageData(timeStamp, jobID);
|
|
676
|
+
}
|
|
677
|
+
const {what, url, daysAgo} = pageData;
|
|
678
|
+
const when = getDateTimeString(timeStamp);
|
|
679
|
+
// Return the HTML strings.
|
|
680
|
+
return {
|
|
681
|
+
what,
|
|
682
|
+
url,
|
|
683
|
+
urlLink: `<a href="${url}">${url}</a>`,
|
|
684
|
+
testInfo: `Tested ${daysAgo} days ago by job <code>${jobID}</code> on ${when}`
|
|
685
|
+
};
|
|
686
|
+
};
|