@jrpool/kilotest 46.1.2 → 48.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) 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.ts +144 -0
  21. package/api/requestRetest.ts +119 -0
  22. package/api/requestTest.test.ts +153 -0
  23. package/api/requestTest.ts +123 -0
  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} +35 -14
  28. package/api/{util.js → util.ts} +54 -51
  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 +360 -0
  35. package/docs/SERVICE.md +170 -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} +397 -152
  48. package/{index.js → index.ts} +386 -285
  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.ts +1129 -0
  69. package/util.ts +1017 -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} +74 -58
  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 +71 -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} +57 -39
  103. package/web/requestRetest/index.html +7 -7
  104. package/web/requestRetest/index.ts +62 -0
  105. package/web/requestRetest/requestRetest.test.ts +130 -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.ts +52 -0
  110. package/web/requestTest/{requestTest.test.js → requestTest.test.ts} +39 -24
  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} +18 -22
  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/requestRetest.js +0 -96
  128. package/api/requestRetest.test.js +0 -80
  129. package/api/requestTest.js +0 -106
  130. package/api/requestTest.test.js +0 -92
  131. package/api/version.js +0 -8
  132. package/indexFallback.test.js +0 -33
  133. package/indexWorkers.test.js +0 -79
  134. package/jsconfig.json +0 -8
  135. package/pm2.config.js +0 -18
  136. package/smokeTest.js +0 -128
  137. package/util.js +0 -744
  138. package/util.test.js +0 -968
  139. package/web/enqueue/index.js +0 -67
  140. package/web/enqueueForm/index.js +0 -49
  141. package/web/manage/index.js +0 -22
  142. package/web/pruneReportsForm/index.js +0 -123
  143. package/web/reannotate/index.js +0 -58
  144. package/web/renewWCAGForm/index.js +0 -22
  145. package/web/requestRetest/index.js +0 -31
  146. package/web/requestRetest/requestRetest.test.js +0 -119
  147. package/web/requestTest/index.js +0 -24
  148. package/web/requestTestForm/index.js +0 -21
  149. package/web/rewindReportsForm/index.js +0 -121
  150. package/web/tutorial/index.js +0 -63
  151. package/web/tutorial/tutorial.test.js +0 -97
  152. /package/test/fixtures/db/jobs/{recs.json → testRequests.json} +0 -0
  153. /package/web/{tutorial → tutorialWeb}/images/newsletter-form.png +0 -0
package/util.ts ADDED
@@ -0,0 +1,1017 @@
1
+ /*
2
+ util.ts
3
+ Utility functions.
4
+ */
5
+
6
+ // IMPORTS
7
+
8
+ /* c8 ignore start */
9
+ // c8 intermittently reports import lines as uncovered due to a range-merge
10
+ // artifact in its remapping of Node's type-stripped source.
11
+ import {sendAlert} from './alerts.ts';
12
+ import {issues as issueSpecs, rules as ruleSpecs} from 'testaro-issues';
13
+ import type {Act, Catalog, Report, StandardInstance} from 'testaro';
14
+ import fs from 'node:fs/promises';
15
+ import path from 'node:path';
16
+ import querystring from 'node:querystring';
17
+ /* c8 ignore stop */
18
+ import wcagMap from './wcagMap.json' with {type: 'json'};
19
+
20
+ // CONSTANTS
21
+
22
+ // 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.
23
+ export const dbPath = (): string => process.env.DB_DIR || path.join(import.meta.dirname, 'db');
24
+ // Path of the jobs directory.
25
+ export const jobsPath = (): string => path.join(dbPath(), 'jobs');
26
+ // Path of the test requests file.
27
+ export const testRequestsPath = (): string => path.join(jobsPath(), 'testRequests.json');
28
+ // Path of the reports directory.
29
+ export const reportsPath = (): string => path.join(dbPath(), 'reports');
30
+ // Path of the hidden-reports directory.
31
+ export const hiddenReportsPath = (): string => path.join(dbPath(), 'hiddenReports');
32
+ // IDs, names, and sponsors of Testaro rule engines.
33
+ const ruleEngines: Record<string, [string, string]> = {
34
+ alfa: ['Alfa', 'Siteimprove'],
35
+ aslint: ['ASLint', 'eSSENTIAL Accessibility'],
36
+ axe: ['Axe', 'Deque'],
37
+ ed11y: ['Editoria11y', 'Princeton University'],
38
+ htmlcs: ['HTML CodeSniffer', 'Squiz Labs'],
39
+ ibm: ['Accessibility Checker', 'IBM'],
40
+ nuVal: ['Html Checker API', 'World Wide Web Consortium'],
41
+ nuVnu: ['Html Checker', 'World Wide Web Consortium'],
42
+ pour: ['Pour', 'David Yarham and Geoffrey Crofte'],
43
+ qualWeb: ['QualWeb', 'University of Lisbon'],
44
+ surea11y: ['SureA11y', 'Jorge Rumoroso'],
45
+ testaro: ['Testaro', 'CVS Health'],
46
+ wave: ['WAVE', 'Utah State University'],
47
+ wax: ['WallyAX', 'Wally']
48
+ };
49
+ export {ruleEngines};
50
+
51
+ // TYPES
52
+
53
+ // Test request.
54
+ export type TestRequest = {
55
+ timeStamp: string;
56
+ description: string;
57
+ reason: string;
58
+ };
59
+
60
+ // Test requests by URL.
61
+ export type TestRequests = Record<string, TestRequest[]>;
62
+
63
+ // Test request addition result.
64
+ export type TestRequestResult
65
+ = 'url' | 'description' | 'retest' | 'duplicate' | 'superseded' | 'nonreport' | 'ok';
66
+
67
+ // Target of a new-test or retest request: a description and URL for a new-test request,
68
+ // or the timeStamp and jobID of the cited report for a retest request.
69
+ export type TestRequestTarget
70
+ = {description: string; url: string} | {timeStamp: string; jobID: string};
71
+
72
+ // Result of processTestRequest. description and url are absent only when the result
73
+ // is 'nonreport', i.e. when the target page could not be resolved; this is a
74
+ // discriminated union on result so that a check of result narrows their presence.
75
+ export type ProcessRequestResult
76
+ = {result: 'nonreport'} | {result: Exclude<TestRequestResult, 'nonreport'>; description: string; url: string};
77
+
78
+ // A StandardInstance extended with the issueID that Kilotest's annotateReportObject adds.
79
+ export interface AnnotatedInstance extends StandardInstance {
80
+ issueID?: string;
81
+ }
82
+
83
+ // An Act whose standardResult instances (if any) are AnnotatedInstances.
84
+ // An intersection is used because Omit<Act, 'result'> would collapse Act's
85
+ // string index signature and lose its named properties.
86
+ export type AnnotatedAct = Act & {
87
+ result?: {
88
+ nativeResult?: unknown;
89
+ standardResult?: {
90
+ instances?: AnnotatedInstance[];
91
+ [key: string]: unknown;
92
+ };
93
+ [key: string]: unknown;
94
+ };
95
+ };
96
+
97
+ // The shape of a Report that has passed isUsableReport: optional fields that
98
+ // the guard verifies are narrowed to their required/non-null forms.
99
+ // error?: never is a discriminant: UsableReport.error is always undefined/never (falsy),
100
+ // so if (report.error) narrows a UsableReport | {error: string} union to {error: string},
101
+ // and the else/after-return branch is narrowed to UsableReport.
102
+ export type UsableReport = Omit<Report, 'target' | 'catalog' | 'jobData' | 'acts'> & {
103
+ error?: never;
104
+ target: {what: string; url: string};
105
+ catalog: Catalog;
106
+ jobData: NonNullable<Report['jobData']> & {endTime: string; issuelessRules?: string[]};
107
+ acts: AnnotatedAct[];
108
+ };
109
+
110
+ // An extract of an available report.
111
+ export type ReportExtract = {
112
+ timeStamp: string;
113
+ jobID: string;
114
+ description: string;
115
+ url: string;
116
+ reportTime: string;
117
+ superseded?: boolean;
118
+ };
119
+
120
+ // Page data from an available report.
121
+ export type PageData = {
122
+ description: string;
123
+ url: string;
124
+ daysAgo: number | null;
125
+ error?: never;
126
+ };
127
+
128
+ // HTML strings describing the page data of an available report.
129
+ export type PageDataStrings = {
130
+ description: string;
131
+ url: string;
132
+ urlLink: string;
133
+ testInfo: string;
134
+ error?: never;
135
+ };
136
+
137
+ // Basics about an available report.
138
+ export type ReportData = {
139
+ description: string;
140
+ url: string;
141
+ jobName: unknown;
142
+ creationDate: Date | null;
143
+ daysAgo: number | null;
144
+ issueCount: number;
145
+ engineNames: string[];
146
+ engineCount: number;
147
+ reporterNames: string[];
148
+ reporterCount: number;
149
+ violatorCount: number;
150
+ preventedEngineNames: string[];
151
+ preventedEngineCount: number;
152
+ error?: never;
153
+ };
154
+
155
+ // MISCELLANEOUS FUNCTIONS
156
+
157
+ // Compares strings alphabetically and case-insensitively.
158
+ const alphaCompare = (a: string, b: string) => a.localeCompare(b, 'en', {sensitivity: 'base'});
159
+ // Sorts strings alphabetically and case-insensitively.
160
+ const alphaSort = (strings: string[]) => strings.sort((a, b) => alphaCompare(a, b));
161
+ // Returns whether a comment's raw length is within the 20-to-1000-character bounds.
162
+ export const checkCommentLength = (
163
+ content: string
164
+ ): {status: 'ok'} | {status: 'error'; message: string} => {
165
+ const contentLength = content.length;
166
+ // If the length is invalid:
167
+ if (contentLength < 20 || contentLength > 1000) {
168
+ // Return the applicable error message.
169
+ const messageSpec = contentLength < 20 ? 'shorter than 20' : 'longer than 1000';
170
+ return {
171
+ status: 'error',
172
+ message: `Your comment was ${messageSpec} characters`
173
+ };
174
+ }
175
+ return {status: 'ok'};
176
+ };
177
+ // Returns whether a comment repeats, verbatim, one of the given comments submitted
178
+ // within the last 1000 seconds. Comment time stamps use Kilotest's own timeStamp
179
+ // format (see getTimeStamp), not an ISO date-time string, so this integrates with
180
+ // the same comments.json shape that web/tutorial/index.ts already uses. The content
181
+ // passed in should be the content as it will be stored (e.g. after sanitization),
182
+ // so that it is compared on the same basis as the stored comments.
183
+ export const checkCommentDuplicate = (
184
+ comments: {timeStamp: string; content: string}[], content: string
185
+ ): {status: 'ok'} | {status: 'error'; message: string} => {
186
+ // Get the comments submitted within the last 1000 seconds with the same content.
187
+ // Comment time stamps are always valid, because this codebase is the sole writer
188
+ // of comments.json.
189
+ const duplicates = comments
190
+ .filter(comment => getDateTime(comment.timeStamp)!.getTime() > Date.now() - 1000000)
191
+ .filter(comment => comment.content === content);
192
+ // If the comment duplicates a recent one:
193
+ if (duplicates.length) {
194
+ // Return this.
195
+ return {
196
+ status: 'error',
197
+ message: 'Your comment repeats a recently submitted one, but you are welcome to submit a different comment'
198
+ };
199
+ }
200
+ // Otherwise, the comment is not a duplicate.
201
+ return {status: 'ok'};
202
+ };
203
+ // Returns a function that executes a function sequentially.
204
+ export const createLock = (): (<T>(fn: () => T | Promise<T>) => Promise<T>) => {
205
+ let queue: Promise<void> = Promise.resolve();
206
+ return <T>(fn: () => T | Promise<T>): Promise<T> => {
207
+ const result = queue.then(fn, fn);
208
+ queue = result.then(() => {}, () => {});
209
+ return result;
210
+ };
211
+ };
212
+ // Returns a string encoded for use as a URL fragment.
213
+ const fragmentEncode = (string: string) => {
214
+ return encodeURIComponent(string).replace(/-/g, '%2D');
215
+ };
216
+ // Returns the time in days since a Date or time stamp, or null if the argument is invalid.
217
+ export const getAgoDays = (timeArg: string | Date): number | null => {
218
+ let dateTime;
219
+ // If the argument is a string:
220
+ if (typeof timeArg === 'string') {
221
+ // Convert it from a time stamp to a Date, or null if invalid.
222
+ dateTime = getDateTime(timeArg);
223
+ }
224
+ // Otherwise, if it is a Date:
225
+ else if (timeArg instanceof Date) {
226
+ // Convert it to null if it is invalid.
227
+ dateTime = timeArg.toString() === 'Invalid Date' ? null : timeArg;
228
+ }
229
+ // Otherwise, i.e. if the argument is not a string or a Date:
230
+ else {
231
+ // Return this.
232
+ return null;
233
+ }
234
+ // If the argument is invalid:
235
+ if (!dateTime) {
236
+ // Return this.
237
+ return null;
238
+ }
239
+ // Otherwise, i.e. if it is valid, return the elapsed days since then.
240
+ return Math.round((Date.now() - dateTime.getTime()) / (1000 * 60 * 60 * 24));
241
+ };
242
+ // Returns a string describing the time in days since a time stamp.
243
+ export const getAgoString = (timeStamp: string): string => {
244
+ const agoDays = getAgoDays(timeStamp);
245
+ if (agoDays === null) {
246
+ return 'an unknown number of days';
247
+ }
248
+ return agoDays === 1 ? '1 day' : `${agoDays} days`;
249
+ };
250
+ // Returns a string describing a count.
251
+ export const getCountString = (count: number, singular: string, plural: string): string => count === 1 ? `1 ${singular}` : `${count} ${plural}`;
252
+ // Returns a date string from a time stamp.
253
+ export const getDateString = (timeStamp: string): string => {
254
+ const dateString = `20${timeStamp.slice(0, 2)}-${timeStamp.slice(2, 4)}-${timeStamp.slice(4,6)}`;
255
+ // If the date part of the time stamp is valid:
256
+ if (!isNaN(Date.parse(dateString))) {
257
+ // Return a date string from it.
258
+ return dateString;
259
+ }
260
+ // Otherwise, return a failure.
261
+ return '';
262
+ };
263
+ // Returns the date and time represented by a time stamp.
264
+ export const getDateTime = (timeStamp: string): Date | null => {
265
+ const dateString
266
+ = `20${timeStamp.slice(0, 2)}-${timeStamp.slice(2, 4)}-${timeStamp.slice(4,6)}T${timeStamp.slice(7,9)}:${timeStamp.slice(9,11)}Z`;
267
+ const dateTime = new Date(dateString);
268
+ return dateTime.toString() === 'Invalid Date' ? null : dateTime;
269
+ };
270
+ // Returns the issue that a rule belongs to, or null if none.
271
+ export const getIssue = (engineID: string, ruleID: string): string | null => {
272
+ const engineRules = ruleSpecs[engineID];
273
+ // If the rule engine has no rule specifications:
274
+ if (!engineRules) {
275
+ // Return a failure result.
276
+ return null;
277
+ }
278
+ const {invariant, variable} = engineRules;
279
+ // If the rule ID is invariant and classified:
280
+ if (invariant[ruleID]) {
281
+ // Return its issue ID.
282
+ return invariant[ruleID].issueID;
283
+ }
284
+ // Otherwise, find the first matching variable rule ID pattern.
285
+ const variableRuleID = Object
286
+ .keys(variable)
287
+ .find(pattern => new RegExp(`^${pattern}$`).test(ruleID));
288
+ // Return the issue ID if a pattern matched, or a failure result otherwise.
289
+ return variableRuleID ? variable[variableRuleID]!.issueID : null;
290
+ };
291
+ // Returns the names of the files in a directory. A missing directory is a normal,
292
+ // recoverable condition (e.g. on first run, before anything has ever been written to
293
+ // it) and is created empty; any other failure to read it should never occur, so it is
294
+ // thrown rather than returned. label identifies the directory in the thrown message.
295
+ export const readdirOrCreate = async (dirPath: string, label: string): Promise<string[]> => {
296
+ try {
297
+ return await fs.readdir(dirPath);
298
+ }
299
+ catch(error: unknown) {
300
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
301
+ await fs.mkdir(dirPath, {recursive: true});
302
+ return [];
303
+ }
304
+ throw new Error(`${label} not readable (${errorMessage(error)})`, {cause: error});
305
+ }
306
+ };
307
+ // Gets the names and categories of the job files.
308
+ export const getJobNames = async (): Promise<{queue: string[], claimed: string[], failed: string[]}> => {
309
+ const jobNames: {queue: string[], claimed: string[], failed: string[]} = {
310
+ queue: [],
311
+ claimed: [],
312
+ failed: []
313
+ };
314
+ for (const category of ['queue', 'claimed', 'failed'] as const) {
315
+ jobNames[category] = await readdirOrCreate(
316
+ path.join(jobsPath(), category), `Job directory ${category}`
317
+ );
318
+ }
319
+ return jobNames;
320
+ }
321
+ // Gets the descriptions and URLs of the pages of all jobs of a category. Uses
322
+ // getJobNames rather than reading the category directory directly, so that a
323
+ // missing directory (e.g. because no job has ever been claimed or queued yet on
324
+ // this deployment) is created empty instead of throwing ENOENT. A job file that
325
+ // disappears between the directory listing and the read of that specific file is
326
+ // likewise a normal, recoverable condition (a worker completed or reclaimed that
327
+ // job in the interim), so it is skipped rather than treated as defective.
328
+ export const getJobsData = async (category: 'queue' | 'claimed'): Promise<{description: string, url: string}[]> => {
329
+ const jobsDir = path.join(jobsPath(), category);
330
+ const jobFileNames = (await getJobNames())[category];
331
+ // For each job in the category:
332
+ const data: {description: string, url: string}[] = [];
333
+ for (const jobFileName of jobFileNames) {
334
+ const jobPath = path.join(jobsDir, jobFileName);
335
+ let jobData: string;
336
+ try {
337
+ jobData = await fs.readFile(jobPath, 'utf8');
338
+ }
339
+ catch(error: unknown) {
340
+ // If the job file has been removed since the directory was listed:
341
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
342
+ // Skip it.
343
+ continue;
344
+ }
345
+ throw new Error(`Job file ${jobPath} defective`, {cause: error});
346
+ }
347
+ try {
348
+ const job = JSON.parse(jobData);
349
+ const {target} = job;
350
+ data.push({
351
+ description: target.what,
352
+ url: target.url
353
+ });
354
+ }
355
+ catch(error: unknown) {
356
+ throw new Error(`Job file ${jobPath} defective`, {cause: error});
357
+ }
358
+ }
359
+ return data;
360
+ };
361
+ // Returns the JSON stringification of an object, with a final newline.
362
+ export const getJSON = (object: unknown): string => `${JSON.stringify(object, null, 2)}\n`;
363
+ // Returns the message of an error, or its string representation if it is not an Error instance.
364
+ export const errorMessage = (error: unknown): string => error instanceof Error ? error.message : String(error);
365
+ // Returns an object from a JSON file. A missing, unreadable, or non-JSON file should never
366
+ // occur for the files this is called on (job and report files Kilotest itself wrote), so
367
+ // failure is thrown rather than returned.
368
+ export const getObject = async (filePath: string): Promise<unknown> => {
369
+ let fileContent;
370
+ try {
371
+ fileContent = await fs.readFile(filePath, 'utf8');
372
+ }
373
+ catch(error: unknown) {
374
+ // The cause is preserved so that a caller for whom a missing file is a normal,
375
+ // expected outcome (rather than one that should never occur) can distinguish it,
376
+ // by its code, from any other (thrown) read failure.
377
+ throw new Error(`File ${filePath} not readable (${errorMessage(error)})`, {cause: error});
378
+ }
379
+ try {
380
+ return JSON.parse(fileContent);
381
+ }
382
+ catch(error: unknown) {
383
+ throw new Error(`File ${filePath} not JSON (${errorMessage(error)})`, {cause: error});
384
+ }
385
+ };
386
+ // Returns a random string.
387
+ export const getRandomString = (length: number): string => {
388
+ let result = '';
389
+ while (result.length < length) {
390
+ result += Math.floor(Math.random() * 36).toString(36);
391
+ }
392
+ return result;
393
+ };
394
+ // Returns a time stamp from a date.
395
+ export const getTimeStamp = (date: Date): string => {
396
+ const timeStamp = date.toISOString().slice(2).replace(/[-:]/g, '').slice(0, 11);
397
+ return timeStamp;
398
+ };
399
+ // Returns a time stamp for now.
400
+ export const getNowStamp = (): string => {
401
+ return getTimeStamp(new Date());
402
+ };
403
+ // Returns a time string from a time stamp.
404
+ const getTimeString = (timeStamp: string) => {
405
+ const timeString = `${timeStamp.slice(7, 9)}:${timeStamp.slice(9, 11)}`;
406
+ // Return the time string if valid, or null if not.
407
+ return (!isNaN(Date.parse(`2000-01-01T${timeString}Z`))) ? timeString : null;
408
+ };
409
+ // Returns a date-and-time string.
410
+ export const getDateTimeString = (timeStamp: string): string => {
411
+ const dateString = getDateString(timeStamp) || 'an unknown date';
412
+ const timeString = getTimeString(timeStamp) || 'an unknown time';
413
+ const dateTimeString = `${dateString} at ${timeString}`;
414
+ return dateTimeString;
415
+ }
416
+ // Converts a string to a plain-text 1-line ASCII string.
417
+ export const getPlainText = (string: string): string => string
418
+ .replace(/&/g, '+')
419
+ .replace(/[<>"']/g, ' ');
420
+ // Populates an index.html template in a directory with named values.
421
+ export const populateTemplate = async (dirName: string, query: Record<string, string>): Promise<string> => {
422
+ // Get the template.
423
+ let answerPage = await fs.readFile(path.join(dirName, 'index.html'), 'utf8');
424
+ // Replace its placeholders.
425
+ Object.keys(query).forEach(param => {
426
+ // A replacer function keeps $-patterns in a value from being interpreted.
427
+ answerPage = answerPage.replace(new RegExp(`__${param}__`, 'g'), () => query[param]!);
428
+ });
429
+ return answerPage;
430
+ };
431
+ // Returns the data from a POST request. Resolves null for an unrecognized content-type or a
432
+ // malformed JSON body; callers treat null as an unreadable request.
433
+ export const getPOSTData = (request: import('node:http').IncomingMessage): Promise<unknown> => new Promise(resolve => {
434
+ const bodyParts: Buffer[] = [];
435
+ request.on('data', chunk => {
436
+ bodyParts.push(chunk);
437
+ });
438
+ request.on('end', () => {
439
+ const {headers} = request;
440
+ const contentType = String(headers['content-type'] || headers['body-type'] || '');
441
+ if (contentType.startsWith('application/json')) {
442
+ const bodyJSON = bodyParts.join('');
443
+ try {
444
+ resolve(JSON.parse(bodyJSON));
445
+ }
446
+ catch {
447
+ resolve(null);
448
+ }
449
+ }
450
+ else if (contentType.startsWith('application/x-www-form-urlencoded')) {
451
+ const body = bodyParts.join('');
452
+ const query = querystring.parse(body);
453
+ resolve(query);
454
+ }
455
+ else {
456
+ resolve(null);
457
+ }
458
+ });
459
+ });
460
+ // Returns the test and retest requests awaiting approval.
461
+ export const getTestRequests = async (): Promise<TestRequests> => {
462
+ let testRequestsJSON: string;
463
+ try {
464
+ testRequestsJSON = await fs.readFile(testRequestsPath(), 'utf8');
465
+ }
466
+ catch(error: unknown) {
467
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
468
+ await fs.writeFile(testRequestsPath(), '{}\n');
469
+ return {};
470
+ }
471
+ throw new Error(`Test-requests file not readable (${errorMessage(error)})`, {cause: error});
472
+ }
473
+ try {
474
+ return JSON.parse(testRequestsJSON) as TestRequests;
475
+ }
476
+ catch(error: unknown) {
477
+ throw new Error('Test-requests file not JSON', {cause: error});
478
+ }
479
+ };
480
+ // Converts a catalog item text to a text-fragment link destination.
481
+ export const getTextFragmentHref = (text: string, url: string): string => {
482
+ const fragmentList = text
483
+ .split('\n')
484
+ .map(fragment => fragmentEncode(fragment))
485
+ .join(',');
486
+ // Return a text-fragment link.
487
+ return `${url}#:~:text=${fragmentList}`;
488
+ };
489
+ // Returns a +-delimited list of sorted names of rule engines.
490
+ export const getEngineNamesString = (engineIDSet: Iterable<string>): string => alphaSort(
491
+ Array.from(engineIDSet).map(engineID => ruleEngines[engineID]?.[0] || engineID)
492
+ ).join(' + ');
493
+ // Gets the WCAG Understanding link for a numeric WCAG standard identifier.
494
+ export const getWCAGLink = (numericID: string): string => {
495
+ // Return the link.
496
+ return `https://www.w3.org/WAI/WCAG22/Understanding/${(wcagMap as Record<string, string>)[numericID]}`;
497
+ };
498
+ // Gets the name of an issue weight.
499
+ export const getWeightName = (weight: number): string => ['lowest', 'low', 'high', 'highest'][weight - 1] ?? 'unknown';
500
+ // Makes a string HTML-safe.
501
+ export const htmlSafe = (string: string): string => string ? string
502
+ .replace(/&/g, '&amp;')
503
+ .replace(/</g, '&lt;')
504
+ .replace(/>/g, '&gt;')
505
+ .replace(/"/g, '&quot;')
506
+ .replace(/'/g, '&apos;')
507
+ : '';
508
+ // Returns whether a string is a job ID.
509
+ export const isJobID = (string: string): boolean => {
510
+ return /^[a-z0-9]{3}$/.test(string);
511
+ };
512
+ // Returns whether a string is a time stamp.
513
+ export const isTimeStamp = (string: string): boolean => {
514
+ return !!getDateString(string);
515
+ };
516
+ // Returns whether a string is a URL.
517
+ export const isURL = (string: string): boolean => {
518
+ try {
519
+ return string.startsWith('https://') && !!new URL(string);
520
+ } catch {
521
+ return false;
522
+ }
523
+ };
524
+ // Makes a string breakable before non-initial slashes.
525
+ export const makeBreakable = (string: string): string => string.replace(/\//g, '<wbr>/').replace(/^<wbr>/, '');
526
+ // Minifies a URL for duplicate detection.
527
+ export const minifyURL = (url: string): string => url.replace(/www\.|\/$/g, '').toLowerCase();
528
+ // Sorts objects by a property value and returns the sorted array.
529
+ export const objectSort = <T extends Record<string, unknown>>(
530
+ objects: T[],
531
+ property: string,
532
+ sortType: 'numericUp' | 'numericDown' | 'alpha'
533
+ ): T[] => objects
534
+ .sort((a, b) => {
535
+ // If the property values are numbers to be sorted in increasing order:
536
+ if (sortType === 'numericUp') {
537
+ // Sort by increasing numeric value.
538
+ return Number(a[property]) - Number(b[property]);
539
+ }
540
+ // Otherwise, if they are numbers to be sorted in decreasing order:
541
+ else if (sortType === 'numericDown') {
542
+ // Sort by decreasing numeric value.
543
+ return Number(b[property]) - Number(a[property]);
544
+ }
545
+ // Otherwise, if they are strings to be sorted alphabetically:
546
+ else if (sortType === 'alpha') {
547
+ // Sort alphabetically.
548
+ return alphaCompare(String(a[property]), String(b[property]));
549
+ }
550
+ // Otherwise, do not sort.
551
+ return 0;
552
+ });
553
+ // Concurrency lock for the `testRequests.json` file.
554
+ export const testRequestsLock = createLock();
555
+ // Deletes the test requests for a URL as a transaction.
556
+ export const deleteTestRequests = (url: string) => testRequestsLock(async (): Promise<void> => {
557
+ // Get the test requests.
558
+ const testRequests = await getTestRequests() as Record<string, unknown>;
559
+ // Delete the requests to test the URL.
560
+ delete testRequests[url];
561
+ // Save the revised test requests.
562
+ await fs.writeFile(testRequestsPath(), getJSON(testRequests));
563
+ });
564
+
565
+ // REPORT FUNCTIONS
566
+
567
+ // Returns the test acts of a report.
568
+ export const getTestActs = (report: UsableReport): AnnotatedAct[] =>
569
+ report.acts.filter(act => act.type === 'test');
570
+ // Returns the standard instances of a report's test acts, optionally filtered.
571
+ export const getTestActInstances = (
572
+ report: UsableReport,
573
+ filter: {violationsOnly?: boolean; issueID?: string; catalogIndex?: string | number} = {}
574
+ ): {act: AnnotatedAct; instance: AnnotatedInstance}[] => {
575
+ const pairs: {act: AnnotatedAct; instance: AnnotatedInstance}[] = [];
576
+ // For each act of the report:
577
+ for (const act of report.acts) {
578
+ // If it is a test act:
579
+ if (act.type === 'test') {
580
+ // For each standard instance of its result:
581
+ for (const instance of act.result?.standardResult?.instances ?? []) {
582
+ if (
583
+ (filter.violationsOnly && instance.outcome === 'cantTell')
584
+ || (filter.issueID !== undefined && instance.issueID !== filter.issueID)
585
+ || (filter.catalogIndex !== undefined && instance.catalogIndex !== filter.catalogIndex)
586
+ ) {
587
+ continue;
588
+ }
589
+ pairs.push({act, instance});
590
+ }
591
+ }
592
+ }
593
+ return pairs;
594
+ };
595
+ // Returns the path ID of the element of a standard instance.
596
+ export const getPathID = (catalog: Catalog, catalogIndex: string, pathID?: string) => {
597
+ if (catalogIndex) {
598
+ const pathIDFromCatalog = (catalog[catalogIndex] || {}).pathID;
599
+ if (pathIDFromCatalog) {
600
+ return pathIDFromCatalog;
601
+ }
602
+ }
603
+ return pathID ?? '/html';
604
+ };
605
+ // Returns the path of an available report file.
606
+ export const getReportPath = (timeStamp: string, jobID: string): string => path
607
+ .join(reportsPath(), `${timeStamp}-${jobID}.json`);
608
+ // Returns whether a report is usable by Kilotest.
609
+ export const isUsableReport = (report: unknown): report is UsableReport => {
610
+ // Cast to any for the property checks inside the guard — this is appropriate
611
+ // for a type predicate that validates an unknown value at runtime.
612
+ const r = report as any;
613
+ // Return whether it has the type and properties required by Kilotest:
614
+ return typeof r === 'object'
615
+ && r !== null
616
+ && typeof r.target?.what === 'string'
617
+ && typeof r.target?.url === 'string'
618
+ && Array.isArray(r.acts)
619
+ && r.acts.every((act: any) =>
620
+ typeof act === 'object'
621
+ && typeof act.type === 'string'
622
+ && act.type === 'test' ? Object.keys(ruleEngines).includes(act.which) : true
623
+ )
624
+ && typeof r.jobData === 'object'
625
+ && r.jobData.endTime
626
+ && !isNaN(new Date(`20${r.jobData.endTime}Z`).getTime())
627
+ && typeof r.catalog === 'object';
628
+ };
629
+ // Returns a report.
630
+ export const getReport = async (timeStamp: string, jobID: string): Promise<UsableReport | {error: string}> => {
631
+ try {
632
+ const reportJSON = await fs.readFile(getReportPath(timeStamp, jobID), 'utf8');
633
+ const report = JSON.parse(reportJSON);
634
+ // If it is usable:
635
+ if (isUsableReport(report)) {
636
+ // Return it.
637
+ return report;
638
+ }
639
+ // Otherwise, i.e. if it is unusable, return this.
640
+ return {error: `Report ${timeStamp}-${jobID} is not usable`};
641
+ } catch (error: unknown) {
642
+ return {error: `Report ${timeStamp}-${jobID} is missing, unreadable, or not JSON (${errorMessage(error)})`};
643
+ }
644
+ };
645
+ // Returns whether a getReport result is an error (rather than a usable report).
646
+ // A type predicate is used instead of `if (report.error)` because UsableReport
647
+ // inherits [key: string]: unknown from Report, making report.error type unknown
648
+ // and preventing TypeScript from narrowing based on truthiness.
649
+ export const isReportError = (r: UsableReport | {error: string}): r is {error: string} => {
650
+ return typeof (r as any).error === 'string';
651
+ };
652
+ // Adds issue IDs to the standard instances of a report object, in place, and alerts a
653
+ // manager about any rules that could not be classified into an issue. Operates on an
654
+ // already-obtained report; a caller that has one only by identifier (e.g. one already
655
+ // stored) should read it with getReport first, while a caller that has a report object
656
+ // directly (e.g. one just received and not yet stored) can annotate it before ever
657
+ // writing it.
658
+ export const annotateReportObject = async (report: UsableReport): Promise<void> => {
659
+ const unclassifiableRules = new Set<string>();
660
+ // For each standard instance of each of its test acts (all outcomes, including cantTell —
661
+ // classification maps rules to issues regardless of outcome):
662
+ for (const {act, instance} of getTestActInstances(report)) {
663
+ const {ruleID} = instance;
664
+ // Classify its rule.
665
+ const issueID = getIssue(act.which!, ruleID);
666
+ // If the rule was classifiable:
667
+ if (issueID) {
668
+ // Add the issue ID to the instance.
669
+ instance.issueID = issueID;
670
+ }
671
+ // Otherwise, i.e. if it was not classifiable:
672
+ else {
673
+ // Add it to the set of unclassifiable rules.
674
+ unclassifiableRules.add(`${act.which!}:${ruleID}`);
675
+ // Remove any existing issue ID from the instance.
676
+ delete instance.issueID;
677
+ }
678
+ }
679
+ const issuelessRules = Array.from(unclassifiableRules).sort();
680
+ // Update the issueless rules in the report.
681
+ report.jobData.issuelessRules = issuelessRules;
682
+ // If any rules were unclassifiable:
683
+ if (issuelessRules.length) {
684
+ // Alert a manager about them.
685
+ await sendAlert(
686
+ 'Kilotest: unclassified rules violated',
687
+ `Report ${report.id}: Violated rules in no issues:\n${issuelessRules.join('\n')}`
688
+ );
689
+ }
690
+ };
691
+ // Returns basics about an available report.
692
+ export const getReportData = async (timeStamp: string, jobID: string): Promise<ReportData | {error: string}> => {
693
+ // Get the report.
694
+ const report = await getReport(timeStamp, jobID);
695
+ // If this failed:
696
+ if (isReportError(report)) {
697
+ // Return why.
698
+ return {error: report.error};
699
+ }
700
+ // Otherwise, i.e. if it succeeded, initialize the data.
701
+ const data = {
702
+ description: report.target.what,
703
+ url: report.target.url,
704
+ jobName: report.id,
705
+ creationDate: getDateTime(timeStamp),
706
+ daysAgo: getAgoDays(timeStamp),
707
+ issueCount: 0,
708
+ engineNames: [] as string[],
709
+ engineCount: 0,
710
+ reporterNames: [] as string[],
711
+ reporterCount: 0,
712
+ violatorCount: 0,
713
+ preventedEngineNames: [] as string[],
714
+ preventedEngineCount: 0
715
+ };
716
+ const issueIDSet = new Set<string>();
717
+ const engineNameSet = new Set<string>();
718
+ const reporterIDSet = new Set<string>();
719
+ const violatorIndexSet = new Set<string>();
720
+ // For each test act of the report:
721
+ getTestActs(report).forEach(act => {
722
+ // Ensure that the rule engine is in the temporary data.
723
+ engineNameSet.add(ruleEngines[act.which!]![0]);
724
+ });
725
+ // For each violating standard instance of each test act:
726
+ getTestActInstances(report, {violationsOnly: true}).forEach(({act, instance}) => {
727
+ const {catalogIndex, issueID} = instance;
728
+ // If it has a non-ignorable classified issue ID:
729
+ if (issueID && issueSpecs[issueID] && issueID !== 'ignorable') {
730
+ // Ensure that the rule engine is in the temporary data.
731
+ reporterIDSet.add(act.which!);
732
+ // Ensure that the issue is in the temporary data.
733
+ issueIDSet.add(issueID);
734
+ // If the violator has a catalog index:
735
+ if (catalogIndex) {
736
+ // Ensure that the violator is in the temporary data.
737
+ violatorIndexSet.add(String(catalogIndex));
738
+ }
739
+ }
740
+ });
741
+ // Populate the data with the act data.
742
+ data.issueCount = issueIDSet.size;
743
+ data.engineNames = Array
744
+ .from(engineNameSet)
745
+ .sort((a, b) => a.localeCompare(b, 'en', {sensitivity: 'base'}));
746
+ data.engineCount = engineNameSet.size;
747
+ data.reporterNames = Array
748
+ .from(reporterIDSet)
749
+ .map(id => ruleEngines[id]![0])
750
+ .sort((a, b) => a.localeCompare(b, 'en', {sensitivity: 'base'}));
751
+ data.reporterCount = data.reporterNames.length;
752
+ data.violatorCount = violatorIndexSet.size;
753
+ // Add the names of any prevented rule engines to the data.
754
+ data.preventedEngineNames = Object.keys(report.jobData?.preventions || {})
755
+ .map(engineID => ruleEngines[engineID]?.[0] || engineID)
756
+ .sort((a, b) => a.localeCompare(b, 'en', {sensitivity: 'base'}));
757
+ data.preventedEngineCount = data.preventedEngineNames.length;
758
+ // Return the data.
759
+ return data;
760
+ }
761
+ // Returns page data from an available report.
762
+ export const getPageData = async (timeStamp: string, jobID: string): Promise<PageData | {error: string}> => {
763
+ // Get the report.
764
+ const report = await getReport(timeStamp, jobID);
765
+ // If this failed:
766
+ if (isReportError(report)) {
767
+ // Return why.
768
+ return report;
769
+ }
770
+ const {what: description, url} = report.target;
771
+ // Get the elapsed time in days since the report was completed, using the
772
+ // report content rather than the file system birth time.
773
+ const daysAgo = getAgoDays(new Date(`20${report.jobData.endTime}Z`));
774
+ // Return the data.
775
+ return {
776
+ description,
777
+ url,
778
+ daysAgo
779
+ };
780
+ };
781
+ // Gets HTML strings for page data from a report.
782
+ export const getPageDataStrings = async (
783
+ timeStamp: string,
784
+ jobID: string,
785
+ pageData?: PageData | {error: string}
786
+ ): Promise<PageDataStrings | {error: string}> => {
787
+ // Get the page data if they were not specified.
788
+ const data = pageData ?? await getPageData(timeStamp, jobID);
789
+ // If the page data are invalid:
790
+ if (data.error !== undefined) {
791
+ // Return why.
792
+ return {
793
+ error: data.error
794
+ };
795
+ }
796
+ const {daysAgo, url, description} = data;
797
+ // Otherwise, i.e. if they are valid, get a description of the timestamp.
798
+ const when = getDateTimeString(timeStamp);
799
+ // Return the HTML strings.
800
+ return {
801
+ description,
802
+ url,
803
+ urlLink: `<a href="${url}">${url}</a>`,
804
+ testInfo: `Tested ${daysAgo === 1 ? '1 day' : `${daysAgo} days`} ago by job <code>${jobID}</code> on ${when}`
805
+ };
806
+ };
807
+ // Returns the creation time and size of a report.
808
+ export const getReportStats = async (timeStamp: string, jobID: string) => {
809
+ let reportStat;
810
+ try {
811
+ reportStat = await fs.stat(path.join(reportsPath(), `${timeStamp}-${jobID}.json`));
812
+ }
813
+ catch {
814
+ return null;
815
+ }
816
+ const reportTime = reportStat.birthtime;
817
+ const reportSize = reportStat.size;
818
+ return {reportTime, reportSize};
819
+ };
820
+
821
+ // Returns an extract of an available report, or an error object if it cannot be read or parsed.
822
+ export const getReportExtract = async (timeStamp: string, jobID: string): Promise<ReportExtract | {error: string}> => {
823
+ try {
824
+ // Get the report.
825
+ const reportJSON = await fs.readFile(
826
+ path.join(reportsPath(), `${timeStamp}-${jobID}.json`), 'utf8'
827
+ );
828
+ const report = JSON.parse(reportJSON);
829
+ const {target, jobData} = report;
830
+ const {what: description, url} = target;
831
+ // Return an extract of it.
832
+ return {
833
+ timeStamp,
834
+ jobID,
835
+ description,
836
+ url,
837
+ reportTime: new Date(`20${jobData.endTime}Z`).toISOString()
838
+ };
839
+ }
840
+ catch {
841
+ return {
842
+ error: `No report ${timeStamp}-${jobID} is available`
843
+ };
844
+ }
845
+ };
846
+ // Returns extracts of all available reports.
847
+ export const getReportExtracts = async (onlyLatest: boolean = false): Promise<ReportExtract[]> => {
848
+ // Get the names of the available report files.
849
+ const reportFileNames = await readdirOrCreate(reportsPath(), 'Reports directory');
850
+ // Initialize an array of extracts.
851
+ const extracts: ReportExtract[] = [];
852
+ // For each one:
853
+ for (const reportFileName of reportFileNames) {
854
+ const [timeStamp, jobID] = reportFileName.slice(0, -5).split('-') as [string, string];
855
+ // Get an extract of it.
856
+ const extract = await getReportExtract(timeStamp, jobID);
857
+ if (!('error' in extract)) {
858
+ // Add the extract to the array.
859
+ extracts.push(extract);
860
+ }
861
+ }
862
+ // Sort the extracts by page description and, secondarily, completion time.
863
+ objectSort(extracts, 'reportTime', 'alpha');
864
+ objectSort(extracts, 'description', 'alpha');
865
+ // For each extract:
866
+ extracts.forEach((extract, index) => {
867
+ // If it is superseded:
868
+ if (extract.description === extracts[index + 1]?.description) {
869
+ // Mark it as such.
870
+ extract.superseded = true;
871
+ }
872
+ });
873
+ // Return the array, excluding extracts of superseded reports if so specified.
874
+ return onlyLatest ? extracts.filter(extract => !extract.superseded) : extracts;
875
+ };
876
+ // Returns whether a report with a description or URL is available.
877
+ export const isReportAvailable = async (description: string, url: string): Promise<boolean> => {
878
+ const reportExtracts = await getReportExtracts();
879
+ const descriptions = reportExtracts.map(reportExtract => reportExtract.description);
880
+ const miniURLs = reportExtracts.map(reportExtract => minifyURL(reportExtract.url));
881
+ return descriptions.includes(description) || miniURLs.includes(minifyURL(url));
882
+ };
883
+ // Gets the descriptions of multi-report pages.
884
+ export const getMultiReportWhats = async (): Promise<string[]> => {
885
+ const reportExtracts = await getReportExtracts();
886
+ const sortedDescriptions = reportExtracts.map(extract => extract.description).sort();
887
+ const multiReportDescriptions = sortedDescriptions.filter(
888
+ (description, index) => description !== sortedDescriptions[index - 1] && description === sortedDescriptions[index + 1]
889
+ );
890
+ return multiReportDescriptions;
891
+ };
892
+ // Returns the property that an approved job in a category has.
893
+ const getApprovedJobProperty = async (
894
+ description: string, url: string, category: 'claimed' | 'queue'
895
+ ): Promise<'description' | 'url' | ''> => {
896
+ try {
897
+ // Get the descriptions and URLs of all jobs in the category.
898
+ const jobsData = await getJobsData(category);
899
+ // If a job with the description is in the category:
900
+ if (jobsData.some(job => job.description === description)) {
901
+ // Return this.
902
+ return 'description';
903
+ }
904
+ // Otherwise, if a job with the URL is in the category:
905
+ if (jobsData.some(job => job.url === url)) {
906
+ // Return this.
907
+ return 'url';
908
+ }
909
+ // Otherwise, return this.
910
+ return '';
911
+ }
912
+ catch(error) {
913
+ throw new Error('Failed to get approved job property', {cause: error});
914
+ }
915
+ };
916
+ // Processes a new-test or retest request as a transaction and returns the result.
917
+ // For a retest request (target has a timeStamp and jobID), resolves the description
918
+ // and URL of the cited report itself, so a caller never needs its own
919
+ // getReportExtract call. Approvability (whether the request may proceed) and, if so,
920
+ // its addition to testRequests.json are both decided inside the lock, as a single
921
+ // atomic operation, so no caller-visible seam exists where an approvability decision
922
+ // could be based on stale information about testRequests.json.
923
+ export const processTestRequest = (
924
+ reason: string,
925
+ target: TestRequestTarget
926
+ ): Promise<ProcessRequestResult> => testRequestsLock(async (): Promise<ProcessRequestResult> => {
927
+ let requestType: 'test' | 'retest';
928
+ let description: string;
929
+ let url: string;
930
+ // Whether the cited report (for a retest) has been superseded by a later one.
931
+ let superseded = false;
932
+ // If the target identifies a report to retest:
933
+ if ('timeStamp' in target) {
934
+ requestType = 'retest';
935
+ const {timeStamp, jobID} = target;
936
+ // Get the cited report.
937
+ const extract = await getReportExtract(timeStamp, jobID);
938
+ // If it does not exist:
939
+ if ('error' in extract) {
940
+ // Return this.
941
+ return {result: 'nonreport'};
942
+ }
943
+ ({description, url} = extract);
944
+ // Get whether it has been superseded by a later report.
945
+ const reportExtracts = await getReportExtracts();
946
+ superseded = reportExtracts.some(
947
+ ex => ex.timeStamp === timeStamp && ex.jobID === jobID && ex.superseded
948
+ );
949
+ }
950
+ // Otherwise, i.e. if the target is a page to test for the first time:
951
+ else {
952
+ requestType = 'test';
953
+ ({description, url} = target);
954
+ }
955
+ try {
956
+ // Get any property the page shares with a claimed job.
957
+ const claimedJobProperty = await getApprovedJobProperty(description, url, 'claimed');
958
+ // Get any property the page shares with a queued job.
959
+ const queueJobProperty = await getApprovedJobProperty(description, url, 'queue');
960
+ // If the page shares a property with a claimed or queued job:
961
+ if (claimedJobProperty || queueJobProperty) {
962
+ // Return the property.
963
+ return {
964
+ result: (claimedJobProperty || queueJobProperty) as 'description' | 'url',
965
+ description,
966
+ url
967
+ };
968
+ }
969
+ // Otherwise, get the requests awaiting approval.
970
+ const requests = await getTestRequests() as Record<string, {
971
+ description: string, reason: string, timeStamp: string
972
+ }[]>;
973
+ // If the request has the same URL, description and reason as an existing request:
974
+ if (requests[url]?.some(
975
+ request => request.description === description && request.reason === reason
976
+ )) {
977
+ // Return this.
978
+ return {result: 'duplicate', description, url};
979
+ }
980
+ // If the request is to retest a page that has been superseded:
981
+ if (requestType === 'retest' && superseded) {
982
+ // Return this.
983
+ return {result: 'superseded', description, url};
984
+ }
985
+ // Otherwise, if the request is to test a new page for which a report already exists:
986
+ if (requestType === 'test') {
987
+ const reportExtracts = await getReportExtracts();
988
+ if (reportExtracts.some(report => report.description === description && report.url === url)) {
989
+ // Return this.
990
+ return {result: 'retest', description, url};
991
+ }
992
+ }
993
+ // The request is eligible, so get the requests awaiting approval.
994
+ const testRequests = await getTestRequests();
995
+ // Add the request to them, initializing them with the URL if necessary.
996
+ (testRequests[url] ??= []).push({
997
+ timeStamp: getNowStamp(),
998
+ description,
999
+ reason
1000
+ });
1001
+ // Save the revised test requests.
1002
+ await fs.writeFile(testRequestsPath(), getJSON(testRequests));
1003
+ // Get an email-safe version of the reason.
1004
+ const plainReason = getPlainText(reason);
1005
+ // Alert a manager.
1006
+ await sendAlert(
1007
+ `Kilotest: new ${requestType} request awaits approval`,
1008
+ `Page description: ${description}\nURL: ${url}\nReason: ${plainReason}`
1009
+ );
1010
+ // Return success.
1011
+ return {result: 'ok', description, url};
1012
+ // If an error occurred:
1013
+ } catch(error) {
1014
+ // Throw it.
1015
+ throw new Error('Failed to process test request', {cause: error});
1016
+ }
1017
+ });