@jrpool/kilotest 46.1.2 → 47.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.
Files changed (147) hide show
  1. package/.claude/settings.local.json +13 -1
  2. package/.devin/config.local.json +5 -1
  3. package/.github/workflows/ci.yml +0 -3
  4. package/.github/workflows/periodic-smoke-tests.yml +30 -0
  5. package/.github/workflows/publish-mcp.yml +4 -4
  6. package/{alerts.test.js → alerts.test.ts} +17 -29
  7. package/{alerts.js → alerts.ts} +10 -13
  8. package/api/{getReport.test.js → getReport.test.ts} +9 -10
  9. package/api/{getReport.js → getReport.ts} +23 -24
  10. package/api/{listDiagnoses.test.js → listDiagnoses.test.ts} +26 -27
  11. package/api/{listDiagnoses.js → listDiagnoses.ts} +46 -70
  12. package/api/{listIssues.test.js → listIssues.test.ts} +21 -14
  13. package/api/{listIssues.js → listIssues.ts} +75 -74
  14. package/api/{listReports.test.js → listReports.test.ts} +17 -16
  15. package/api/listReports.ts +87 -0
  16. package/api/{listViolators.test.js → listViolators.test.ts} +19 -20
  17. package/api/{listViolators.js → listViolators.ts} +45 -70
  18. package/api/requestFeature.test.ts +54 -0
  19. package/api/{requestFeature.js → requestFeature.ts} +18 -15
  20. package/api/{requestRetest.test.js → requestRetest.test.ts} +33 -28
  21. package/api/{requestRetest.js → requestRetest.ts} +32 -25
  22. package/api/{requestTest.test.js → requestTest.test.ts} +35 -30
  23. package/api/{requestTest.js → requestTest.ts} +35 -28
  24. package/api/{routes.test.js → routes.test.ts} +5 -5
  25. package/api/{routes.js → routes.ts} +6 -6
  26. package/api/{schemas.js → schemas.ts} +69 -50
  27. package/api/{util.test.js → util.test.ts} +31 -11
  28. package/api/{util.js → util.ts} +61 -36
  29. package/api/version.ts +8 -0
  30. package/{apiIntegration.test.js → apiIntegration.test.ts} +12 -13
  31. package/balances.ts +87 -0
  32. package/{checkinfo.js → checkinfo.ts} +24 -20
  33. package/docs/AI-TOOLS.md +4 -4
  34. package/docs/BACKLOG.md +356 -0
  35. package/docs/SERVICE.md +144 -17
  36. package/docs/decisions/002-coverage-tool.md +40 -0
  37. package/docs/decisions/003-adr-threshold.md +34 -0
  38. package/docs/decisions/004-docs-directory.md +23 -0
  39. package/docs/decisions/005-observability.md +28 -0
  40. package/docs/decisions/006-git-workflow.md +28 -0
  41. package/docs/decisions/007-internal-testing.md +34 -0
  42. package/docs/decisions/008-dependencies.md +28 -0
  43. package/docs/decisions/009-qai-integration.md +40 -0
  44. package/docs/decisions/QAI-HISTORICAL-CONTEXT.md +42 -0
  45. package/eslint.config.mjs +27 -0
  46. package/index.html +3 -3
  47. package/{index.test.js → index.test.ts} +380 -143
  48. package/{index.js → index.ts} +382 -283
  49. package/{indexBalances.test.js → indexBalances.test.ts} +21 -17
  50. package/{indexServe.test.js → indexServe.test.ts} +9 -9
  51. package/linkValidation.test.ts +190 -0
  52. package/{mcp.test.js → mcp.test.ts} +53 -26
  53. package/{mcp.js → mcp.ts} +29 -20
  54. package/memory/MEMORY.md +7 -0
  55. package/memory/user_maintainer_context.md +24 -0
  56. package/openapi.yaml +115 -68
  57. package/package.json +35 -15
  58. package/pm2.config.cjs +29 -0
  59. package/{routes.test.js → routes.test.ts} +8 -7
  60. package/{routing.test.js → routing.test.ts} +11 -12
  61. package/scripts/{generate-openapi.js → generate-openapi.ts} +13 -13
  62. package/serve.ts +15 -0
  63. package/smokeTest.ts +234 -0
  64. package/test/dbFixture.ts +21 -0
  65. package/test/fixtures/{buildFixtures.js → buildFixtures.ts} +14 -13
  66. package/test/{setup.cjs → setup.ts} +2 -2
  67. package/tsconfig.json +16 -0
  68. package/{util.test.js → util.test.ts} +350 -257
  69. package/util.ts +921 -0
  70. package/web/ai0BalanceForm/{ai0BalanceForm.test.js → ai0BalanceForm.test.ts} +15 -15
  71. package/web/ai0BalanceForm/{index.js → index.ts} +10 -18
  72. package/web/enqueue/index.ts +56 -0
  73. package/web/enqueueForm/index.html +9 -9
  74. package/web/enqueueForm/index.ts +43 -0
  75. package/web/expungeReportsForm/{expungeReportsForm.test.js → expungeReportsForm.test.ts} +18 -20
  76. package/web/expungeReportsForm/index.ts +18 -0
  77. package/web/hideReportForm/{hideReportForm.test.js → hideReportForm.test.ts} +14 -14
  78. package/web/hideReportForm/{index.js → index.ts} +17 -21
  79. package/web/listDiagnoses/{index.js → index.ts} +48 -58
  80. package/web/listDiagnoses/{listDiagnoses.test.js → listDiagnoses.test.ts} +39 -34
  81. package/web/listIssues/{index.js → index.ts} +67 -92
  82. package/web/listIssues/{listIssues.test.js → listIssues.test.ts} +45 -51
  83. package/web/listReports/index.html +3 -3
  84. package/web/listReports/{index.js → index.ts} +48 -53
  85. package/web/listReports/{listReports.test.js → listReports.test.ts} +41 -43
  86. package/web/listRules/{index.js → index.ts} +18 -23
  87. package/web/listRules/{listRules.test.js → listRules.test.ts} +26 -19
  88. package/web/listTopIssues/{index.js → index.ts} +36 -47
  89. package/web/listTopIssues/{listTopIssues.test.js → listTopIssues.test.ts} +26 -17
  90. package/web/listViolators/{index.js → index.ts} +51 -71
  91. package/web/listViolators/{listViolators.test.js → listViolators.test.ts} +43 -36
  92. package/web/manage/index.ts +22 -0
  93. package/web/pruneReportsForm/index.ts +18 -0
  94. package/web/pruneReportsForm/{pruneReportsForm.test.js → pruneReportsForm.test.ts} +11 -11
  95. package/web/reannotate/index.ts +70 -0
  96. package/web/reannotate/{reannotate.test.js → reannotate.test.ts} +19 -22
  97. package/web/reannotateForm/{index.js → index.ts} +41 -50
  98. package/web/reannotateForm/{reannotateForm.test.js → reannotateForm.test.ts} +13 -16
  99. package/web/renewWCAG/{index.js → index.ts} +9 -9
  100. package/web/renewWCAG/{renewWCAG.test.js → renewWCAG.test.ts} +17 -20
  101. package/web/renewWCAGForm/index.ts +22 -0
  102. package/web/{expungeReportsForm/index.js → reportDeletion.ts} +56 -38
  103. package/web/requestRetest/index.html +7 -7
  104. package/web/requestRetest/index.ts +31 -0
  105. package/web/requestRetest/requestRetest.test.ts +116 -0
  106. package/web/requestRetestForm/{index.js → index.ts} +10 -16
  107. package/web/requestRetestForm/{requestRetestForm.test.js → requestRetestForm.test.ts} +11 -12
  108. package/web/requestTest/index.html +7 -7
  109. package/web/requestTest/{index.js → index.ts} +5 -5
  110. package/web/requestTest/{requestTest.test.js → requestTest.test.ts} +21 -21
  111. package/web/requestTestForm/index.html +4 -4
  112. package/web/requestTestForm/index.ts +21 -0
  113. package/web/requestTestForm/{requestTestForm.test.js → requestTestForm.test.ts} +9 -9
  114. package/web/rewindReportsForm/index.ts +18 -0
  115. package/web/rewindReportsForm/{rewindReportsForm.test.js → rewindReportsForm.test.ts} +11 -11
  116. package/web/tutorialAI/index.html +184 -0
  117. package/web/tutorialAI/index.ts +80 -0
  118. package/web/tutorialAI/tutorialAI.test.ts +192 -0
  119. package/web/{tutorial → tutorialWeb}/index.html +9 -8
  120. package/web/tutorialWeb/index.ts +78 -0
  121. package/web/tutorialWeb/tutorialWeb.test.ts +195 -0
  122. package/web/unhideReportForm/{index.js → index.ts} +17 -21
  123. package/web/unhideReportForm/{unhideReportForm.test.js → unhideReportForm.test.ts} +16 -16
  124. package/aceconfig.js +0 -28
  125. package/api/listReports.js +0 -89
  126. package/api/requestFeature.test.js +0 -58
  127. package/api/version.js +0 -8
  128. package/indexFallback.test.js +0 -33
  129. package/indexWorkers.test.js +0 -79
  130. package/jsconfig.json +0 -8
  131. package/pm2.config.js +0 -18
  132. package/smokeTest.js +0 -128
  133. package/util.js +0 -744
  134. package/web/enqueue/index.js +0 -67
  135. package/web/enqueueForm/index.js +0 -49
  136. package/web/manage/index.js +0 -22
  137. package/web/pruneReportsForm/index.js +0 -123
  138. package/web/reannotate/index.js +0 -58
  139. package/web/renewWCAGForm/index.js +0 -22
  140. package/web/requestRetest/index.js +0 -31
  141. package/web/requestRetest/requestRetest.test.js +0 -119
  142. package/web/requestTestForm/index.js +0 -21
  143. package/web/rewindReportsForm/index.js +0 -121
  144. package/web/tutorial/index.js +0 -63
  145. package/web/tutorial/tutorial.test.js +0 -97
  146. /package/test/fixtures/db/jobs/{recs.json → testRequests.json} +0 -0
  147. /package/web/{tutorial → tutorialWeb}/images/newsletter-form.png +0 -0
package/util.js DELETED
@@ -1,744 +0,0 @@
1
- /*
2
- util.js
3
- Utility functions.
4
- */
5
-
6
- // IMPORTS
7
-
8
- const {sendAlert} = require('./alerts');
9
- const {issues: issueSpecs, rules: ruleSpecs} = require('testaro-issues');
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 data directory. Read afresh on every call (rather than cached at module load) so that tests can point Kilotest at a fixture directory via the DB_DIR environment variable.
18
- const dbPath = exports.dbPath = () => process.env.DB_DIR || path.join(__dirname, 'db');
19
- // Path of the jobs directory.
20
- const jobsPath = exports.jobsPath = () => path.join(dbPath(), 'jobs');
21
- // Path of the recommendations file.
22
- const recsPath = exports.recsPath = () => path.join(jobsPath(), 'recs.json');
23
- // Path of the reports directory.
24
- const reportsPath = exports.reportsPath = () => path.join(dbPath(), 'reports');
25
- // Path of the hidden-reports directory.
26
- const hiddenReportsPath = exports.hiddenReportsPath = () => path.join(dbPath(), 'hiddenReports');
27
- // IDs, names, and sponsors of Testaro rule engines.
28
- const ruleEngines = exports.ruleEngines = {
29
- alfa: ['Alfa', 'Siteimprove'],
30
- aslint: ['ASLint', 'eSSENTIAL Accessibility'],
31
- axe: ['Axe', 'Deque'],
32
- ed11y: ['Editoria11y', 'Princeton University'],
33
- htmlcs: ['HTML CodeSniffer', 'Squiz Labs'],
34
- ibm: ['Accessibility Checker', 'IBM'],
35
- nuVal: ['Html Checker API', 'World Wide Web Consortium'],
36
- nuVnu: ['Html Checker', 'World Wide Web Consortium'],
37
- pour: ['Pour', 'David Yarham and Geoffrey Crofte'],
38
- qualWeb: ['QualWeb', 'University of Lisbon'],
39
- surea11y: ['SureA11y', 'Jorge Rumoroso'],
40
- testaro: ['Testaro', 'CVS Health'],
41
- wave: ['WAVE', 'Utah State University'],
42
- wax: ['WallyAX', 'Wally']
43
- };
44
- exports.researchAgents = {
45
- 'research-agent': 'Internal Research Agent'
46
- }
47
-
48
- // MISCELLANEOUS FUNCTIONS
49
-
50
- // Compares strings alphabetically and case-insensitively.
51
- const alphaCompare = (a, b) => a.localeCompare(b, 'en', {sensitivity: 'base'});
52
- // Sorts strings alphabetically and case-insensitively.
53
- const alphaSort = strings => strings.sort((a, b) => alphaCompare(a, b));
54
- // Returns a function that executes a function sequentially.
55
- /** @returns {<T>(fn: () => T | Promise<T>) => Promise<T>} */
56
- const createLock = exports.createLock = () => {
57
- let queue = Promise.resolve();
58
- return fn => {
59
- const result = queue.then(fn, fn);
60
- queue = result.then(() => {}, () => {});
61
- return result;
62
- };
63
- };
64
- // Returns a string encoded for use as a URL fragment.
65
- const fragmentEncode = string => {
66
- return encodeURIComponent(string).replace(/-/g, '%2D');
67
- };
68
- // Returns the time in days since a Date or time stamp, or null if the argument is invalid.
69
- const getAgoDays = exports.getAgoDays = timeArg => {
70
- let dateTime;
71
- // If the argument is a string:
72
- if (typeof timeArg === 'string') {
73
- // Convert it from a time stamp to a Date, or null if invalid.
74
- dateTime = getDateTime(timeArg);
75
- }
76
- // Otherwise, if it is a Date:
77
- else if (timeArg instanceof Date) {
78
- // Convert it to null if it is invalid.
79
- dateTime = timeArg.toString() === 'Invalid Date' ? null : timeArg;
80
- }
81
- // Otherwise, i.e. if the argument is not a string or a Date:
82
- else {
83
- // Return this.
84
- return null;
85
- }
86
- // If the argument is invalid:
87
- if (!dateTime) {
88
- // Return this.
89
- return null;
90
- }
91
- // Otherwise, i.e. if it is valid, return the elapsed days since then.
92
- return Math.round((Date.now() - dateTime.getTime()) / (1000 * 60 * 60 * 24));
93
- };
94
- // Returns a string describing the time in days since a time stamp.
95
- exports.getAgoString = timeStamp => {
96
- const agoDays = getAgoDays(timeStamp);
97
- return agoDays === 1 ? '1 day' : `${agoDays} days`;
98
- };
99
- // Returns a string describing a count.
100
- exports.getCountString = (count, singular, plural) => count === 1 ? `1 ${singular}` : `${count} ${plural}`;
101
- // Returns a date string from a time stamp.
102
- const getDateString = exports.getDateString = timeStamp => {
103
- const dateString = `20${timeStamp.slice(0, 2)}-${timeStamp.slice(2, 4)}-${timeStamp.slice(4,6)}`;
104
- // If the date part of the time stamp is valid:
105
- if (Date.parse(dateString)) {
106
- // Return a date string from it.
107
- return dateString;
108
- }
109
- // Otherwise, return a failure.
110
- return '';
111
- };
112
- // Returns the date and time represented by a time stamp.
113
- const getDateTime = exports.getDateTime = timeStamp => {
114
- const dateString
115
- = `20${timeStamp.slice(0, 2)}-${timeStamp.slice(2, 4)}-${timeStamp.slice(4,6)}T${timeStamp.slice(7,9)}:${timeStamp.slice(9,11)}Z`;
116
- const dateTime = new Date(dateString);
117
- return dateTime.toString() === 'Invalid Date' ? null : dateTime;
118
- };
119
- // Returns the issue that a rule belongs to, or null if none.
120
- const getIssue = exports.getIssue = (engineID, ruleID) => {
121
- const engineRules = ruleSpecs[engineID];
122
- // If the rule engine has no rule specifications:
123
- if (!engineRules) {
124
- // Return a failure result.
125
- return null;
126
- }
127
- const {invariant, variable} = engineRules;
128
- // If the rule ID is invariant and classified:
129
- if (invariant[ruleID]) {
130
- // Return its issue ID.
131
- return invariant[ruleID].issueID;
132
- }
133
- // Otherwise, find the first matching variable rule ID pattern.
134
- const variableRuleID = Object
135
- .keys(variable)
136
- .find(pattern => new RegExp(`^${pattern}$`).test(ruleID));
137
- // Return the issue ID if a pattern matched, or a failure result otherwise.
138
- return variableRuleID ? variable[variableRuleID].issueID : null;
139
- };
140
- // Gets the names and categories of the job files.
141
- const getJobNames = exports.getJobNames = async () => {
142
- const jobNames = {};
143
- let fileNames;
144
- for (const category of ['queue', 'claimed', 'failed']) {
145
- const categoryPath = path.join(jobsPath(), category);
146
- try {
147
- fileNames = await fs.readdir(categoryPath);
148
- }
149
- catch(error) {
150
- if (error.code === 'ENOENT') {
151
- await fs.mkdir(categoryPath, {recursive: true});
152
- fileNames = [];
153
- }
154
- else {
155
- return `ERROR: Job directory ${category} not readable (${error.message})`;
156
- }
157
- }
158
- jobNames[category] = fileNames;
159
- }
160
- return jobNames;
161
- }
162
- // Returns the JSON stringification of an object, with a final newline.
163
- const getJSON = exports.getJSON = object => `${JSON.stringify(object, null, 2)}\n`;
164
- // Returns an object from a JSON file.
165
- const getObject = exports.getObject = async filePath => {
166
- let fileContent, object;
167
- try {
168
- fileContent = await fs.readFile(filePath, 'utf8');
169
- }
170
- catch(error) {
171
- return `ERROR: File ${filePath} not readable (${error.message})`;
172
- }
173
- try {
174
- object = JSON.parse(fileContent);
175
- }
176
- catch(error) {
177
- return `ERROR: File ${filePath} not JSON (${error.message})`;
178
- }
179
- return object;
180
- };
181
- // Returns a random string.
182
- exports.getRandomString = length => {
183
- return Math.random().toString(36).slice(2, length + 2);
184
- };
185
- // Returns a time stamp from a date.
186
- const getTimeStamp = exports.getTimeStamp = date => {
187
- const timeStamp = date.toISOString().slice(2).replace(/[-:]/g, '').slice(0, 11);
188
- return timeStamp;
189
- };
190
- // Returns a time stamp for now.
191
- const getNowStamp = exports.getNowStamp = () => {
192
- return getTimeStamp(new Date());
193
- };
194
- // Returns a time string from a time stamp.
195
- const getTimeString = timeStamp => {
196
- const timeString = `${timeStamp.slice(7, 9)}:${timeStamp.slice(9, 11)}`;
197
- // Return a time string from it.
198
- return (Date.parse(`2000-01-01T${timeString}Z`)) ? timeString : null;
199
- };
200
- // Returns a date-and-time string.
201
- const getDateTimeString = exports.getDateTimeString = timeStamp => {
202
- const dateString = getDateString(timeStamp);
203
- const timeString = getTimeString(timeStamp);
204
- const dateTimeString = `${dateString} at ${timeString}`;
205
- return dateTimeString;
206
- }
207
- // Converts a string to a plain-text 1-line ASCII string.
208
- const getPlainText = exports.getPlainText = string => string
209
- .replace(/&/g, '+')
210
- .replace(/[<>"'&]/g, ' ');
211
- // Returns the data from a POST request.
212
- exports.getPOSTData = request => new Promise(resolve => {
213
- const bodyParts = [];
214
- request.on('data', chunk => {
215
- bodyParts.push(chunk);
216
- });
217
- request.on('end', () => {
218
- const {headers} = request;
219
- const contentType = headers['content-type'] || headers['body-type'] || '';
220
- if (contentType.startsWith('application/json')) {
221
- const bodyJSON = bodyParts.join('');
222
- const body = JSON.parse(bodyJSON);
223
- resolve(body);
224
- }
225
- else if (contentType.startsWith('application/x-www-form-urlencoded')) {
226
- const body = bodyParts.join('');
227
- const query = querystring.parse(body);
228
- resolve(query);
229
- }
230
- else {
231
- resolve(null);
232
- }
233
- });
234
- });
235
- // Returns the waiting test and retest recommendations.
236
- const getRecs = exports.getRecs = async () => {
237
- let recs;
238
- let recsJSON;
239
- try {
240
- recsJSON = await fs.readFile(recsPath(), 'utf8');
241
- }
242
- catch(error) {
243
- await fs.writeFile(recsPath(), '{}\n');
244
- return `ERROR: recommendations file not readable, so created an empty one (${error.message})`;
245
- }
246
- try {
247
- recs = JSON.parse(recsJSON);
248
- }
249
- catch(error) {
250
- return `ERROR: recommendations file not JSON (${error.message})`;
251
- }
252
- return recs;
253
- };
254
- // Converts a catalog item text to a text-fragment link destination.
255
- exports.getTextFragmentHref = (text, url) => {
256
- const fragmentList = text
257
- .split('\n')
258
- .map(fragment => fragmentEncode(fragment))
259
- .join(',');
260
- // Return a text-fragment link.
261
- return `${url}#:~:text=${fragmentList}`;
262
- };
263
- // Returns a +-delimited list of sorted names of rule engines.
264
- exports.getEngineList = engineIDs => Array.from(engineIDs)
265
- .map(engineID => ruleEngines[engineID][0])
266
- .sort((a, b) => a.localeCompare(b, 'en', {sensitivity: 'base'}))
267
- .join(' + ');
268
- // Returns a string of names of rule engines.
269
- exports.getEngineNamesString = engineIDSet => alphaSort(
270
- Array.from(engineIDSet).map(engineID => ruleEngines[engineID]?.[0] || engineID)
271
- ).join(' + ');
272
- // Gets the WCAG Understanding link for a numeric WCAG standard identifier.
273
- exports.getWCAGLink = numericID => {
274
- // Return the link.
275
- return `https://www.w3.org/WAI/WCAG22/Understanding/${wcagMap[numericID]}`;
276
- };
277
- // Gets the name of an issue weight.
278
- exports.getWeightName = weight => ['lowest', 'low', 'high', 'highest'][weight - 1] ?? 'unknown';
279
- // Makes a string HTML-safe.
280
- exports.htmlSafe = string => string ? string
281
- .replace(/&/g, '&amp;')
282
- .replace(/</g, '&lt;')
283
- .replace(/>/g, '&gt;')
284
- .replace(/"/g, '&quot;')
285
- .replace(/'/g, '&apos;')
286
- : '';
287
- // Returns whether a string is a job ID.
288
- exports.isJobID = string => {
289
- return /^[a-z0-9]{3}$/.test(string);
290
- };
291
- // Returns whether a job to test a target is eligible for a recommendation.
292
- exports.isRecommendable = async url => {
293
- const jobNames = await getJobNames();
294
- // For each claimed job:
295
- for (const fileName of jobNames.claimed) {
296
- const job = await getObject(path.join(jobsPath(), 'claimed', fileName));
297
- // If its URL is that of the recommended target:
298
- if (job.target.url === url) {
299
- // Return this.
300
- return 'claimed';
301
- }
302
- }
303
- // If no claimed job has the URL of the target, for each queued job:
304
- for (const fileName of jobNames.queue) {
305
- const job = await getObject(path.join(jobsPath(), 'queue', fileName));
306
- // If its URL is that of the recommended target:
307
- if (job.target.url === url) {
308
- // Return this.
309
- return 'queued';
310
- }
311
- }
312
- // If no claimed or queued job has the URL of the target, return this.
313
- return '';
314
- };
315
- // Returns whether a string is a time stamp.
316
- exports.isTimeStamp = string => {
317
- return !!getDateString(string);
318
- };
319
- // Returns whether a string is a URL.
320
- const isURL = exports.isURL = string => {
321
- try {
322
- return string.startsWith('https://') && new URL(string);
323
- } catch {
324
- return false;
325
- }
326
- };
327
- // Makes a string breakable before non-initial slashes.
328
- exports.makeBreakable = string => string.replace(/\//g, '<wbr>/').replace(/^<wbr>/, '');
329
- // Minifies a URL for duplicate detection.
330
- const minifyURL = exports.minifyURL = url => url.replace(/www\.|\/$/g, '').toLowerCase();
331
- // Sorts objects by a property value and returns the sorted array.
332
- const objectSort = exports.objectSort = (objects, property, sortType) => objects
333
- .sort((a, b) => {
334
- // If the property values are numbers to be sorted in increasing order:
335
- if (sortType === 'numericUp') {
336
- // Sort by increasing numeric value.
337
- return a[property] - b[property];
338
- }
339
- // Otherwise, if they are numbers to be sorted in decreasing order:
340
- else if (sortType === 'numericDown') {
341
- // Sort by decreasing numeric value.
342
- return b[property] - a[property];
343
- }
344
- // Otherwise, if they are strings to be sorted alphabetically:
345
- else if (sortType === 'alpha') {
346
- // Sort alphabetically.
347
- return alphaCompare(a[property], b[property]);
348
- }
349
- // Otherwise, do not sort.
350
- return 0;
351
- });
352
- // Processes a test or retest request in the UI.
353
- exports.processTestRequest = async (testType, dirName, what, url, why) => {
354
- // If the recommendation is valid:
355
- if (
356
- ['test', 'retest'].includes(testType)
357
- && ['Test', 'Retest'].some(end => dirName.endsWith(end))
358
- && what
359
- && isURL(url)
360
- && why.length > 4
361
- ) {
362
- // Make the reason display-safe.
363
- const plainWhy = getPlainText(why);
364
- // Update the waiting recommendations as a transaction.
365
- const updateResult = await updateRecs(what, url, plainWhy);
366
- // If the recommendation was a duplicate:
367
- if (updateResult.error === 'duplicate') {
368
- // Return this.
369
- return {
370
- status: 'error',
371
- message: 'Duplicate recommendation'
372
- };
373
- }
374
- // Otherwise, i.e. if it was not a duplicate:
375
- else {
376
- // Log the recommendation.
377
- console.log(`Test recommendation received for ${what}: ${plainWhy}`);
378
- // Alert a manager about it.
379
- await sendAlert(
380
- `Kilotest: new ${testType} recommendation in the UI`,
381
- `Target: ${what}\nURL: ${url}\nReason: ${plainWhy}`
382
- );
383
- // Get the template.
384
- let answerPage = await fs.readFile(path.join(dirName, 'index.html'), 'utf8');
385
- const query = {
386
- target: what,
387
- why: plainWhy
388
- };
389
- // Replace its placeholders.
390
- Object.keys(query).forEach(param => {
391
- answerPage = answerPage.replace(new RegExp(`__${param}__`, 'g'), query[param]);
392
- });
393
- // Return the populated page.
394
- return {
395
- status: 'ok',
396
- answerPage
397
- };
398
- }
399
- }
400
- return {
401
- status: 'error',
402
- message: 'Invalid recommendation'
403
- };
404
- };
405
- // Concurrency lock for the `recs.json` file.
406
- const recsLock = exports.recsLock = createLock();
407
- // Updates the test recommendations as a transaction.
408
- const updateRecs = exports.updateRecs = (what, url, why) => recsLock(async () => {
409
- // Get the data on waiting recommendations.
410
- const recs = await getRecs();
411
- recs[url] ??= [];
412
- // If any recommendation has the same description and URL:
413
- if (recs[url].some(rec => rec.what === what)) {
414
- // Return this.
415
- return {
416
- error: 'duplicate'
417
- };
418
- }
419
- // Otherwise, i.e. if the recommendation is not a duplicate, add it to those for the target.
420
- recs[url].push({
421
- timeStamp: getNowStamp(),
422
- what,
423
- why
424
- });
425
- // Save the revised recommendations.
426
- await fs.writeFile(recsPath(), getJSON(recs));
427
- // Return success.
428
- return {};
429
- });
430
-
431
- // REPORT FUNCTIONS
432
-
433
- // Returns the path ID of the element of a standard instance.
434
- exports.getPathID = (catalog, catalogIndex, pathID) => {
435
- if (catalogIndex) {
436
- const catalogItem = catalog[catalogIndex] || {};
437
- if (catalogItem.pathID) {
438
- return catalogItem.pathID;
439
- }
440
- return pathID ?? '/html';
441
- }
442
- return pathID ?? '/html';
443
- };
444
- // Returns the path of an available report file.
445
- const getReportPath = exports.getReportPath = (timeStamp, jobID) => path
446
- .join(reportsPath(), `${timeStamp}-${jobID}.json`);
447
- // Returns whether a report is valid.
448
- const isValidReport = exports.isValidReport = report => {
449
- // Return whether it has the type and properties required by Kilotest:
450
- return typeof report === 'object'
451
- && typeof report.target?.what === 'string'
452
- && typeof report.target?.url === 'string'
453
- && Array.isArray(report.acts)
454
- && report.acts.every(act =>
455
- typeof act === 'object'
456
- && typeof act.type === 'string'
457
- && act.type === 'test' ? Object.keys(ruleEngines).includes(act.which) : true
458
- )
459
- && typeof report.jobData === 'object'
460
- && report.jobData.endTime
461
- && !isNaN(new Date(`20${report.jobData.endTime}Z`).getTime())
462
- && typeof report.catalog === 'object';
463
- };
464
- // Returns a report.
465
- const getReport = exports.getReport = async (timeStamp, jobID) => {
466
- try {
467
- const reportJSON = await fs.readFile(getReportPath(timeStamp, jobID), 'utf8');
468
- const report = JSON.parse(reportJSON);
469
- // If it is valid:
470
- if (isValidReport(report)) {
471
- // Return it.
472
- return report;
473
- }
474
- // Otherwise, i.e. if it is invalid, return this.
475
- return {error: `Report ${timeStamp}-${jobID} is invalid`};
476
- } catch (error) {
477
- return {error: `Report ${timeStamp}-${jobID} is missing, unreadable, or not JSON (${error.message})`};
478
- }
479
- };
480
- // Adds issue IDs to the standard instances of a report.
481
- exports.annotateReport = async (timeStamp, jobID) => {
482
- // Get a copy of the report.
483
- const report = await getReport(timeStamp, jobID);
484
- // If this failed:
485
- if (report.error) {
486
- // Return why.
487
- return report.error;
488
- }
489
- // Otherwise, i.e. if it succeeded:
490
- const unclassifiableRules = new Set();
491
- // For each of its acts:
492
- for (const act of report.acts) {
493
- const {result, type, which} = act;
494
- // If it is a test act:
495
- if (type === 'test') {
496
- // For each standard instance of the result:
497
- for (const instance of result?.standardResult?.instances ?? []) {
498
- const {ruleID} = instance;
499
- // Classify its rule.
500
- const issueID = getIssue(which, ruleID);
501
- // If the rule was classifiable:
502
- if (issueID) {
503
- // Add the issue ID to the instance.
504
- instance.issueID = issueID;
505
- }
506
- // Otherwise, i.e. if it was not classifiable:
507
- else {
508
- // Add it to the set of unclassifiable rules.
509
- unclassifiableRules.add(`${which}:${ruleID}`);
510
- // Remove any existing issue ID from the instance.
511
- delete instance.issueID;
512
- }
513
- }
514
- }
515
- }
516
- const issuelessRules = Array.from(unclassifiableRules).sort();
517
- // Update the issueless rules in the report.
518
- report.jobData.issuelessRules = issuelessRules;
519
- // If any rules were unclassifiable:
520
- if (issuelessRules.length) {
521
- // Alert a manager about them.
522
- await sendAlert(
523
- 'Kilotest: unclassified rules violated',
524
- `Job ${timeStamp}-${jobID}: Violated rules in no issues:\n${issuelessRules.join('\n')}`
525
- );
526
- }
527
- // Save the annotated report.
528
- await fs.writeFile(getReportPath(timeStamp, jobID), getJSON(report));
529
- // Return success.
530
- return '';
531
- };
532
- // Returns basics about an available report.
533
- exports.getReportData = async (timeStamp, jobID) => {
534
- // Get the report.
535
- const report = await getReport(timeStamp, jobID);
536
- // If this failed:
537
- if (report.error) {
538
- // Return why.
539
- return {error: report.error};
540
- }
541
- // Otherwise, i.e. if it succeeded, initialize the data.
542
- const data = {
543
- what: report.target.what,
544
- url: report.target.url,
545
- jobName: report.id,
546
- creationDate: getDateTime(timeStamp),
547
- daysAgo: getAgoDays(timeStamp),
548
- issueCount: 0,
549
- engineNames: [],
550
- engineCount: 0,
551
- reporterNames: [],
552
- reporterCount: 0,
553
- violatorCount: 0,
554
- preventedEngineNames: [],
555
- preventedEngineCount: 0
556
- };
557
- const issueIDSet = new Set();
558
- const engineNameSet = new Set();
559
- const reporterIDSet = new Set();
560
- const violatorIndexSet = new Set();
561
- // For each act of the report:
562
- report.acts.forEach(act => {
563
- // If it is a test act:
564
- if (act.type === 'test') {
565
- const {result, which} = act;
566
- // Ensure that the rule engine is in the temporary data.
567
- engineNameSet.add(ruleEngines[which][0]);
568
- const instances = result?.standardResult?.instances ?? [];
569
- // For each standard instance of the act:
570
- instances.forEach(instance => {
571
- const {catalogIndex, issueID, outcome} = instance;
572
- // If it reports a violation and has a non-ignorable classified issue ID:
573
- if (outcome !== 'cantTell' && issueID && issueSpecs[issueID] && issueID !== 'ignorable') {
574
- // Ensure that the rule engine is in the temporary data.
575
- reporterIDSet.add(which);
576
- // Ensure that the issue is in the temporary data.
577
- issueIDSet.add(issueID);
578
- // If the violator has a catalog index:
579
- if (catalogIndex) {
580
- // Ensure that the violator is in the temporary data.
581
- violatorIndexSet.add(catalogIndex);
582
- }
583
- }
584
- });
585
- }
586
- });
587
- // Populate the data with the act data.
588
- data.issueCount = issueIDSet.size;
589
- data.engineNames = Array
590
- .from(engineNameSet)
591
- .sort((a, b) => a.localeCompare(b, 'en', {sensitivity: 'base'}));
592
- data.engineCount = engineNameSet.size;
593
- data.reporterNames = Array
594
- .from(reporterIDSet)
595
- .map(id => ruleEngines[id][0])
596
- .sort((a, b) => a.localeCompare(b, 'en', {sensitivity: 'base'}));
597
- data.reporterCount = data.reporterNames.length;
598
- data.violatorCount = violatorIndexSet.size;
599
- // Add the names of any prevented rule engines to the data.
600
- data.preventedEngineNames = Object.keys(report.jobData?.preventions || {})
601
- .map(engineID => ruleEngines[engineID][0])
602
- .sort((a, b) => a.localeCompare(b, 'en', {sensitivity: 'base'}));
603
- data.preventedEngineCount = data.preventedEngineNames.length;
604
- // Return the data.
605
- return data;
606
- }
607
- // Returns page data from an available report.
608
- const getPageData = exports.getPageData = async (timeStamp, jobID) => {
609
- // Get the report.
610
- const report = await getReport(timeStamp, jobID);
611
- // If this failed:
612
- if (report.error) {
613
- // Return why.
614
- return report;
615
- }
616
- const {url, what} = report.target;
617
- // Get the elapsed time in days since the report was completed, using the
618
- // report content rather than the file system birth time.
619
- const daysAgo = getAgoDays(new Date(`20${report.jobData.endTime}Z`));
620
- // Return the data.
621
- return {
622
- what,
623
- url,
624
- daysAgo
625
- };
626
- };
627
- // Gets HTML strings for page data from a report.
628
- exports.getPageDataStrings = async (timeStamp, jobID, pageData) => {
629
- // If the page data were not specified:
630
- if (!pageData) {
631
- // Get them.
632
- pageData = await getPageData(timeStamp, jobID);
633
- }
634
- const {daysAgo, error, url, what} = pageData;
635
- // If the page data are invalid:
636
- if (error) {
637
- // Return why.
638
- return {
639
- error
640
- };
641
- }
642
- // Otherwise, i.e. if they are valid, get a description of the timestamp.
643
- const when = getDateTimeString(timeStamp);
644
- // Return the HTML strings.
645
- return {
646
- what,
647
- url,
648
- urlLink: `<a href="${url}">${url}</a>`,
649
- testInfo: `Tested ${daysAgo === 1 ? '1 day' : `${daysAgo} days`} ago by job <code>${jobID}</code> on ${when}`
650
- };
651
- };
652
- // Returns the creation time and size of a report.
653
- exports.getReportStats = async (timeStamp, jobID) => {
654
- let reportStat;
655
- try {
656
- reportStat = await fs.stat(path.join(reportsPath(), `${timeStamp}-${jobID}.json`));
657
- }
658
- catch {
659
- return null;
660
- }
661
- const reportTime = reportStat.birthtime;
662
- const reportSize = reportStat.size;
663
- return {reportTime, reportSize};
664
- };
665
- // Returns whether a report is hidden.
666
- exports.isHidden = async (timeStamp, jobID) => {
667
- await fs.mkdir(hiddenReportsPath(), {recursive: true});
668
- // Get the names of the hidden report files.
669
- const hiddenReportFileNames = await fs.readdir(hiddenReportsPath());
670
- // Return whether the report is among them.
671
- return hiddenReportFileNames.includes(`${timeStamp}-${jobID}.json`);
672
- };
673
- // Returns an extract of an available report, or an error object if it cannot be read or parsed.
674
- const getReportExtract = exports.getReportExtract = async (timeStamp, jobID) => {
675
- try {
676
- // Get the report.
677
- const reportJSON = await fs.readFile(
678
- path.join(reportsPath(), `${timeStamp}-${jobID}.json`), 'utf8'
679
- );
680
- const report = JSON.parse(reportJSON);
681
- const {target, jobData} = report;
682
- const {what, url} = target;
683
- // Return an extract of it.
684
- return {
685
- timeStamp,
686
- jobID,
687
- what,
688
- url,
689
- reportTime: new Date(`20${jobData.endTime}Z`).toISOString()
690
- };
691
- }
692
- catch {
693
- return {
694
- error: `No report ${timeStamp}-${jobID} is available`
695
- };
696
- }
697
- };
698
- // Returns extracts of all available reports.
699
- const getReportExtracts = exports.getReportExtracts = async () => {
700
- // Get the names of the available report files.
701
- const reportFileNames = await fs.readdir(reportsPath());
702
- // Initialize an array of extracts.
703
- const extracts = [];
704
- // For each one:
705
- for (const reportFileName of reportFileNames) {
706
- const [timeStamp, jobID] = reportFileName.slice(0, -5).split('-');
707
- // Get an extract of it.
708
- const extract = await getReportExtract(timeStamp, jobID);
709
- if (!extract.error) {
710
- // Add the extract to the array.
711
- extracts.push(extract);
712
- }
713
- }
714
- return extracts;
715
- };
716
- // Returns whether a report with a description or URL is available.
717
- exports.isReportAvailable = async (what, url) => {
718
- const reportExtracts = await getReportExtracts();
719
- const whats = reportExtracts.map(reportExtract => reportExtract.what);
720
- const miniURLs = reportExtracts.map(reportExtract => minifyURL(reportExtract.url));
721
- return whats.includes(what) || miniURLs.includes(minifyURL(url));
722
- };
723
- // Gets extracts of the latest available reports for all page descriptions.
724
- exports.getLatestReportExtracts = async () => {
725
- // Get extracts of all available reports.
726
- const reportExtracts = await getReportExtracts();
727
- // Sort them by page description and, secondarily, completion time.
728
- objectSort(reportExtracts, 'reportTime', 'alpha');
729
- objectSort(reportExtracts, 'what', 'alpha');
730
- // Get those that are not superseded.
731
- const latestReportExtracts = reportExtracts
732
- .filter((extract, index) => extract.what !== reportExtracts[index + 1]?.what);
733
- // Return them.
734
- return latestReportExtracts;
735
- };
736
- // Gets the descriptions of multi-report pages.
737
- exports.getMultiReportWhats = async () => {
738
- const reportExtracts = await getReportExtracts();
739
- const sortedWhats = reportExtracts.map(extract => extract.what).sort();
740
- const multiReportWhats = sortedWhats.filter(
741
- (what, index) => what !== sortedWhats[index - 1] && what === sortedWhats[index + 1]
742
- );
743
- return multiReportWhats;
744
- };