@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.test.ts ADDED
@@ -0,0 +1,1129 @@
1
+ /*
2
+ util.test.ts
3
+ Tests for util.ts data-path injection (Phase 1) and utility functions.
4
+ */
5
+
6
+ // IMPORTS
7
+
8
+ import {test, before, after} from 'node:test';
9
+ import assert from 'node:assert/strict';
10
+ import path from 'node:path';
11
+ import fs from 'node:fs/promises';
12
+ import {
13
+ annotateReportObject,
14
+ checkCommentDuplicate,
15
+ checkCommentLength,
16
+ createLock,
17
+ dbPath,
18
+ getAgoDays,
19
+ getAgoString,
20
+ getCountString,
21
+ getDateString,
22
+ getDateTime,
23
+ getEngineNamesString,
24
+ getIssue,
25
+ errorMessage,
26
+ getJSON,
27
+ getMultiReportWhats,
28
+ getNowStamp,
29
+ getObject,
30
+ getPageData,
31
+ getPageDataStrings,
32
+ getPlainText,
33
+ getRandomString,
34
+ getReportExtract,
35
+ getReportExtracts,
36
+ getReportPath,
37
+ getReportStats,
38
+ getTextFragmentHref,
39
+ getTimeStamp,
40
+ getWCAGLink,
41
+ getWeightName,
42
+ hiddenReportsPath,
43
+ htmlSafe,
44
+ isJobID,
45
+ isReportAvailable,
46
+ isTimeStamp,
47
+ isURL,
48
+ jobsPath,
49
+ makeBreakable,
50
+ minifyURL,
51
+ objectSort,
52
+ processTestRequest,
53
+ testRequestsLock,
54
+ testRequestsPath,
55
+ reportsPath
56
+ } from './util.ts';
57
+
58
+ // TESTS
59
+
60
+ test('checkCommentLength accepts a comment of exactly 20 characters', () => {
61
+ const result = checkCommentLength('a'.repeat(20));
62
+ assert.deepEqual(result, {status: 'ok'});
63
+ });
64
+
65
+ test('checkCommentLength accepts a comment of exactly 1000 characters', () => {
66
+ const result = checkCommentLength('a'.repeat(1000));
67
+ assert.deepEqual(result, {status: 'ok'});
68
+ });
69
+
70
+ test('checkCommentLength rejects a comment shorter than 20 characters', () => {
71
+ const result = checkCommentLength('a'.repeat(19));
72
+ assert.deepEqual(result, {
73
+ status: 'error',
74
+ message: 'Your comment was shorter than 20 characters'
75
+ });
76
+ });
77
+
78
+ test('checkCommentLength rejects a comment longer than 1000 characters', () => {
79
+ const result = checkCommentLength('a'.repeat(1001));
80
+ assert.deepEqual(result, {
81
+ status: 'error',
82
+ message: 'Your comment was longer than 1000 characters'
83
+ });
84
+ });
85
+
86
+ test('checkCommentDuplicate accepts a comment with no existing comments', () => {
87
+ const result = checkCommentDuplicate([], 'a'.repeat(20));
88
+ assert.deepEqual(result, {status: 'ok'});
89
+ });
90
+
91
+ test('checkCommentDuplicate rejects a comment repeating one submitted within the last 1000 seconds', () => {
92
+ const content = 'a'.repeat(20);
93
+ const comments = [{timeStamp: getNowStamp(), content}];
94
+ const result = checkCommentDuplicate(comments, content);
95
+ assert.deepEqual(result, {
96
+ status: 'error',
97
+ message: 'Your comment repeats a recently submitted one, but you are welcome to submit a different comment'
98
+ });
99
+ });
100
+
101
+ test('checkCommentDuplicate accepts a comment repeating one submitted more than 1000 seconds ago', () => {
102
+ const content = 'a'.repeat(20);
103
+ const oldTimeStamp = getTimeStamp(new Date(Date.now() - 2000000));
104
+ const comments = [{timeStamp: oldTimeStamp, content}];
105
+ const result = checkCommentDuplicate(comments, content);
106
+ assert.deepEqual(result, {status: 'ok'});
107
+ });
108
+
109
+ test('checkCommentDuplicate accepts a comment that differs from a recent one', () => {
110
+ const comments = [{timeStamp: getNowStamp(), content: 'a'.repeat(20)}];
111
+ const result = checkCommentDuplicate(comments, 'b'.repeat(20));
112
+ assert.deepEqual(result, {status: 'ok'});
113
+ });
114
+
115
+ test('dbPath defaults to the project db directory when DB_DIR is unset', () => {
116
+ const saved = process.env.DB_DIR;
117
+ delete process.env.DB_DIR;
118
+ try {
119
+ assert.equal(dbPath(), path.join(import.meta.dirname, 'db'));
120
+ }
121
+ finally {
122
+ if (saved !== undefined) {
123
+ process.env.DB_DIR = saved;
124
+ }
125
+ }
126
+ });
127
+
128
+ test('dbPath honors DB_DIR when it is set', () => {
129
+ const saved = process.env.DB_DIR;
130
+ process.env.DB_DIR = '/tmp/kilotest-fixtures';
131
+ try {
132
+ assert.equal(dbPath(), '/tmp/kilotest-fixtures');
133
+ }
134
+ finally {
135
+ if (saved !== undefined) {
136
+ process.env.DB_DIR = saved;
137
+ }
138
+ else {
139
+ delete process.env.DB_DIR;
140
+ }
141
+ }
142
+ });
143
+
144
+ test('jobsPath, testRequestsPath, reportsPath, and hiddenReportsPath derive from DB_DIR', () => {
145
+ const saved = process.env.DB_DIR;
146
+ process.env.DB_DIR = '/tmp/kilotest-fixtures';
147
+ try {
148
+ assert.equal(jobsPath(), path.join('/tmp/kilotest-fixtures', 'jobs'));
149
+ assert.equal(testRequestsPath(), path.join('/tmp/kilotest-fixtures', 'jobs', 'testRequests.json'));
150
+ assert.equal(reportsPath(), path.join('/tmp/kilotest-fixtures', 'reports'));
151
+ assert.equal(hiddenReportsPath(), path.join('/tmp/kilotest-fixtures', 'hiddenReports'));
152
+ }
153
+ finally {
154
+ if (saved !== undefined) {
155
+ process.env.DB_DIR = saved;
156
+ }
157
+ else {
158
+ delete process.env.DB_DIR;
159
+ }
160
+ }
161
+ });
162
+
163
+ test('changing DB_DIR between calls is reflected by the path functions', () => {
164
+ const saved = process.env.DB_DIR;
165
+ try {
166
+ process.env.DB_DIR = '/tmp/a';
167
+ assert.equal(reportsPath(), path.join('/tmp/a', 'reports'));
168
+ process.env.DB_DIR = '/tmp/b';
169
+ assert.equal(reportsPath(), path.join('/tmp/b', 'reports'));
170
+ }
171
+ finally {
172
+ if (saved !== undefined) {
173
+ process.env.DB_DIR = saved;
174
+ }
175
+ else {
176
+ delete process.env.DB_DIR;
177
+ }
178
+ }
179
+ });
180
+
181
+ test('getAgoDays returns a positive number for a valid time stamp', () => {
182
+ const days = getAgoDays('260101T0000');
183
+ assert.ok(typeof days === 'number');
184
+ assert.ok(days > 0);
185
+ });
186
+
187
+ test('getAgoDays returns null for an invalid string', () => {
188
+ assert.equal(getAgoDays('invalid'), null);
189
+ });
190
+
191
+ test('getAgoDays returns null for a non-string non-Date argument', () => {
192
+ assert.equal(getAgoDays(42 as any), null);
193
+ });
194
+
195
+ test('getAgoDays returns 0 for the current time', () => {
196
+ assert.equal(getAgoDays(new Date()), 0);
197
+ });
198
+
199
+ test('getAgoString returns "1 day" for a 1-day-old timestamp', () => {
200
+ const date = new Date(Date.now() - 1 * 24 * 60 * 60 * 1000);
201
+ assert.equal(
202
+ getAgoString(date.toISOString().slice(2).replace(/[-:]/g, '').slice(0, 11)), '1 day'
203
+ );
204
+ });
205
+
206
+ test('getAgoString returns "3 days" for a 3-day-old timestamp', () => {
207
+ const date = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000);
208
+ assert.equal(
209
+ getAgoString(date.toISOString().slice(2).replace(/[-:]/g, '').slice(0, 11)), '3 days'
210
+ );
211
+ });
212
+
213
+ test('getCountString returns singular for count 1', () => {
214
+ assert.equal(getCountString(1, 'report', 'reports'), '1 report');
215
+ });
216
+
217
+ test('getCountString returns plural for count 0', () => {
218
+ assert.equal(getCountString(0, 'report', 'reports'), '0 reports');
219
+ });
220
+
221
+ test('getDateString returns a valid date string for a valid time stamp', () => {
222
+ assert.equal(getDateString('260101T0000'), '2026-01-01');
223
+ });
224
+
225
+ test('getDateString returns empty string for an invalid time stamp', () => {
226
+ assert.equal(getDateString('999999T9999'), '');
227
+ });
228
+
229
+ test('getDateTime returns a Date for a valid time stamp', () => {
230
+ const date = getDateTime('260101T0000');
231
+ assert.ok(date instanceof Date);
232
+ assert.equal(date.toISOString(), '2026-01-01T00:00:00.000Z');
233
+ });
234
+
235
+ test('getDateTime returns null for an invalid time stamp', () => {
236
+ assert.equal(getDateTime('invalid'), null);
237
+ });
238
+
239
+ test('getIssue returns an issue ID for a known engine and rule', () => {
240
+ const issueID = getIssue('axe', 'link-name');
241
+ assert.ok(typeof issueID === 'string' || issueID === null);
242
+ });
243
+
244
+ test('getIssue returns null for an unknown engine', () => {
245
+ assert.equal(getIssue('nonexistentEngine', 'anyRule'), null);
246
+ });
247
+
248
+ test('errorMessage returns the message of an Error instance', () => {
249
+ assert.equal(errorMessage(new Error('something went wrong')), 'something went wrong');
250
+ });
251
+
252
+ test('errorMessage returns the string representation of a non-Error value', () => {
253
+ assert.equal(errorMessage('bare string'), 'bare string');
254
+ });
255
+
256
+ test('getJSON returns a JSON string with a trailing newline', () => {
257
+ assert.equal(getJSON({a: 1}), '{\n "a": 1\n}\n');
258
+ });
259
+
260
+ test('getObject returns the parsed object for a valid JSON file', async () => {
261
+ const result = await getObject(path.join(import.meta.dirname, 'package.json'));
262
+ assert.ok(typeof result === 'object' && result !== null);
263
+ assert.equal((result as {name: unknown}).name, '@jrpool/kilotest');
264
+ });
265
+
266
+ test('getObject throws for a nonexistent file', async () => {
267
+ await assert.rejects(getObject('/tmp/nonexistent-file.json'), /not readable/);
268
+ });
269
+
270
+ test('getNowStamp returns an 11-character time stamp', () => {
271
+ const stamp = getNowStamp();
272
+ assert.equal(stamp.length, 11);
273
+ assert.ok(/^\d{6}T\d{4}$/.test(stamp));
274
+ });
275
+
276
+ test('getPlainText replaces special characters', () => {
277
+ assert.equal(getPlainText('a<b>c&d'), 'a b c+d');
278
+ });
279
+
280
+ test('getRandomString returns a string of the requested length', () => {
281
+ const s = getRandomString(10);
282
+ assert.equal(s.length, 10);
283
+ });
284
+
285
+ test('getTextFragmentHref returns a text-fragment URL', () => {
286
+ const href = getTextFragmentHref('Hello', 'https://example.com/page');
287
+ assert.ok(href.startsWith('https://example.com/page#:~:text='));
288
+ });
289
+
290
+ test('getWCAGLink returns a URL for a numeric WCAG ID', () => {
291
+ const link = getWCAGLink('4.1.2');
292
+ assert.ok(link.startsWith('https://www.w3.org/WAI/WCAG22/Understanding/'));
293
+ });
294
+
295
+ test('getWeightName returns the correct name for each weight', () => {
296
+ assert.equal(getWeightName(1), 'lowest');
297
+ assert.equal(getWeightName(2), 'low');
298
+ assert.equal(getWeightName(3), 'high');
299
+ assert.equal(getWeightName(4), 'highest');
300
+ assert.equal(getWeightName(5), 'unknown');
301
+ });
302
+
303
+ test('htmlSafe escapes HTML special characters', () => {
304
+ assert.equal(htmlSafe('<script>alert("x")</script>'), '&lt;script&gt;alert(&quot;x&quot;)&lt;/script&gt;');
305
+ });
306
+
307
+ test('htmlSafe returns empty string for falsy input', () => {
308
+ assert.equal(htmlSafe(''), '');
309
+ });
310
+
311
+ test('isJobID returns true for a valid job ID', () => {
312
+ assert.equal(isJobID('abc'), true);
313
+ });
314
+
315
+ test('isJobID returns false for an invalid job ID', () => {
316
+ assert.equal(isJobID('ABCD'), false);
317
+ assert.equal(isJobID('ab'), false);
318
+ });
319
+
320
+ test('isTimeStamp returns true for a valid time stamp', () => {
321
+ assert.equal(isTimeStamp('260101T0000'), true);
322
+ });
323
+
324
+ test('isTimeStamp returns false for an invalid time stamp', () => {
325
+ assert.equal(isTimeStamp('invalid'), false);
326
+ });
327
+
328
+ test('isURL returns true for a valid HTTPS URL', () => {
329
+ assert.equal(isURL('https://example.com/page'), true);
330
+ });
331
+
332
+ test('isURL returns false for a non-HTTPS URL', () => {
333
+ assert.equal(isURL('http://example.com/page'), false);
334
+ });
335
+
336
+ test('isURL returns false for an invalid URL', () => {
337
+ assert.equal(isURL('not-a-url'), false);
338
+ });
339
+
340
+ test('makeBreakable inserts wbr before non-initial slashes', () => {
341
+ assert.equal(makeBreakable('/api/listReports'), '/api<wbr>/listReports');
342
+ });
343
+
344
+ test('minifyURL removes www. and trailing slash and lowercases', () => {
345
+ assert.equal(minifyURL('https://www.Example.com/'), 'https://example.com');
346
+ });
347
+
348
+ test('objectSort sorts objects alphabetically by a string property', () => {
349
+ const items = [{name: 'banana'}, {name: 'apple'}, {name: 'cherry'}];
350
+ const sorted = objectSort(items, 'name', 'alpha');
351
+ assert.deepEqual(sorted.map(i => i.name), ['apple', 'banana', 'cherry']);
352
+ });
353
+
354
+ test('objectSort sorts objects numerically ascending', () => {
355
+ const items = [{count: 3}, {count: 1}, {count: 2}];
356
+ const sorted = objectSort(items, 'count', 'numericUp');
357
+ assert.deepEqual(sorted.map(i => i.count), [1, 2, 3]);
358
+ });
359
+
360
+ test('objectSort sorts objects numerically descending', () => {
361
+ const items = [{count: 1}, {count: 3}, {count: 2}];
362
+ const sorted = objectSort(items, 'count', 'numericDown');
363
+ assert.deepEqual(sorted.map(i => i.count), [3, 2, 1]);
364
+ });
365
+
366
+ // TESTS FOR processTestRequest AND isReportAvailable
367
+
368
+ // These tests use the fixture database directory.
369
+ import {fixtureDBDir as fixtureDbDir} from './test/dbFixture.ts';
370
+ const savedDbDir = process.env.DB_DIR;
371
+
372
+ before(() => {
373
+ process.env.DB_DIR = fixtureDbDir;
374
+ });
375
+
376
+ after(() => {
377
+ if (savedDbDir !== undefined) {
378
+ process.env.DB_DIR = savedDbDir;
379
+ }
380
+ else {
381
+ delete process.env.DB_DIR;
382
+ }
383
+ });
384
+
385
+ test('getPageData returns page data for a valid report', async () => {
386
+ const data = await getPageData('260101T0000', 'mix') as any;
387
+ assert.equal(data.description, 'Mixed Outcomes Page');
388
+ assert.equal(data.url, 'https://example.com/mixed');
389
+ assert.equal(typeof data.daysAgo, 'number');
390
+ });
391
+
392
+ test('getPageData returns an error for a nonexistent report', async () => {
393
+ const data = await getPageData('999999T9999', 'xxx') as any;
394
+ assert.ok(data.error);
395
+ });
396
+
397
+ test('getPageDataStrings returns HTML strings for a valid report', async () => {
398
+ const strings = await getPageDataStrings('260101T0000', 'mix') as any;
399
+ assert.equal(strings.description, 'Mixed Outcomes Page');
400
+ assert.equal(strings.url, 'https://example.com/mixed');
401
+ assert.equal(strings.urlLink, '<a href="https://example.com/mixed">https://example.com/mixed</a>');
402
+ assert.ok(strings.testInfo.includes('by job <code>mix</code>'));
403
+ assert.ok(strings.testInfo.includes('2026-01-01 at 00:00'));
404
+ });
405
+
406
+ test('getPageDataStrings returns different testInfo for a different timeStamp', async () => {
407
+ const strings = await getPageDataStrings('260101T0001', 'ct') as any;
408
+ assert.equal(strings.description, 'All CantTell Page');
409
+ assert.ok(strings.testInfo.includes('by job <code>ct</code>'));
410
+ assert.ok(strings.testInfo.includes('2026-01-01 at 00:01'));
411
+ });
412
+
413
+ test('getPageDataStrings returns an error for a nonexistent report', async () => {
414
+ const strings = await getPageDataStrings('999999T9999', 'xxx');
415
+ assert.ok(strings.error);
416
+ });
417
+
418
+ test('getPageDataStrings uses provided pageData instead of reading the report', async () => {
419
+ const strings = await getPageDataStrings('260101T0000', 'mix', {
420
+ description: 'Custom Page',
421
+ url: 'https://custom.com',
422
+ daysAgo: 1
423
+ }) as any;
424
+ assert.equal(strings.description, 'Custom Page');
425
+ assert.equal(strings.url, 'https://custom.com');
426
+ assert.ok(strings.testInfo.includes('1 day ago'));
427
+ });
428
+
429
+ test('processTestRequest succeeds for a valid new-test request', {timeout: 500}, async () => {
430
+ // Reset testRequests.json to empty before the test.
431
+ await fs.writeFile(testRequestsPath(), '{}\n');
432
+ const {result} = await processTestRequest(
433
+ 'because accessibility', {description: 'Example Page', url: 'https://example.com'}
434
+ );
435
+ assert.equal(result, 'ok');
436
+ const testRequests = JSON.parse(await fs.readFile(testRequestsPath(), 'utf8'));
437
+ assert.equal(testRequests['https://example.com'][0].description, 'Example Page');
438
+ // Clean up testRequests.json.
439
+ await fs.writeFile(testRequestsPath(), '{}\n');
440
+ });
441
+
442
+ test('processTestRequest returns a duplicate error for a repeated request', {timeout: 500}, async () => {
443
+ // Reset testRequests.json to empty, then make a successful request.
444
+ await fs.writeFile(testRequestsPath(), '{}\n');
445
+ const target = {description: 'Example Page', url: 'https://example.com'};
446
+ await processTestRequest('because accessibility', target);
447
+ // Repeat the same request (same description, URL, and reason).
448
+ const {result} = await processTestRequest('because accessibility', target);
449
+ assert.equal(result, 'duplicate');
450
+ // Clean up testRequests.json.
451
+ await fs.writeFile(testRequestsPath(), '{}\n');
452
+ });
453
+
454
+ test('processTestRequest returns a retest error when a report already exists for the page', async () => {
455
+ // The fixture database already has a report for this description and URL.
456
+ const {result} = await processTestRequest(
457
+ 'because accessibility', {description: 'Mixed Outcomes Page', url: 'https://example.com/mixed'}
458
+ );
459
+ assert.equal(result, 'retest');
460
+ });
461
+
462
+ test('annotateReportObject annotates a report object in place without reading or writing a file', async () => {
463
+ // A report object that was never written to disk (e.g. one just received from a worker,
464
+ // as in index.ts's worker/report handler, which annotates before the first write).
465
+ const report: any = {
466
+ id: '990101T0099-obj',
467
+ target: {what: 'Object Page', url: 'https://example.com/object'},
468
+ acts: [
469
+ {
470
+ type: 'test',
471
+ which: 'alfa',
472
+ result: {
473
+ standardResult: {
474
+ instances: [
475
+ // A classifiable rule.
476
+ {ruleID: 'r11', what: 'The link does not have an accessible name', outcome: 'failed', catalogIndex: '0'},
477
+ // An unclassifiable rule.
478
+ {ruleID: 'unknownRule123', what: 'Unknown', outcome: 'failed', catalogIndex: '0'}
479
+ ]
480
+ }
481
+ }
482
+ }
483
+ ],
484
+ jobData: {endTime: '26-01-01T00:00'},
485
+ catalog: {}
486
+ };
487
+ await annotateReportObject(report);
488
+ const instances = report.acts[0].result.standardResult.instances;
489
+ assert.equal(instances[0].issueID, 'linkNoText');
490
+ assert.equal(instances[1].issueID, undefined);
491
+ assert.deepEqual(report.jobData.issuelessRules, ['alfa:unknownRule123']);
492
+ });
493
+
494
+ test('annotateReportObject handles a test act with no standardResult instances', async () => {
495
+ const report: any = {
496
+ id: '990101T0098-ni',
497
+ target: {what: 'Test', url: 'https://example.com'},
498
+ acts: [
499
+ {type: 'test', which: 'axe', result: {standardResult: {instances: []}}},
500
+ {type: 'other'}
501
+ ],
502
+ jobData: {endTime: '26-01-01T00:00'},
503
+ catalog: {}
504
+ };
505
+ await annotateReportObject(report);
506
+ assert.deepEqual(report.jobData.issuelessRules, []);
507
+ });
508
+
509
+ test('annotateReportObject handles a test act with no standardResult', async () => {
510
+ const report: any = {
511
+ id: '990101T0097-ns',
512
+ target: {what: 'Test', url: 'https://example.com'},
513
+ acts: [
514
+ {type: 'test', which: 'axe', result: {}}
515
+ ],
516
+ jobData: {endTime: '26-01-01T00:00'},
517
+ catalog: {}
518
+ };
519
+ await annotateReportObject(report);
520
+ assert.deepEqual(report.jobData.issuelessRules, []);
521
+ });
522
+
523
+ test('isReportAvailable returns true for a known page description', async () => {
524
+ const result = await isReportAvailable('Mixed Outcomes Page', 'https://example.com/nonexistent');
525
+ assert.equal(result, true);
526
+ });
527
+
528
+ test('isReportAvailable returns true for a known URL', async () => {
529
+ const result = await isReportAvailable('Nonexistent Page', 'https://example.com/mixed');
530
+ assert.equal(result, true);
531
+ });
532
+
533
+ test('isReportAvailable returns false for an unknown page and URL', async () => {
534
+ const result = await isReportAvailable('Nonexistent Page', 'https://nonexistent.example.com/');
535
+ assert.equal(result, false);
536
+ });
537
+
538
+ // TESTS FOR processTestRequest's claimed/queued-job detection
539
+
540
+ test('processTestRequest returns "description" for a page matching a claimed job by description', async () => {
541
+ const claimedPath = path.join(jobsPath(), 'claimed', 'clm.json');
542
+ await fs.writeFile(claimedPath, getJSON({
543
+ target: {what: 'Claimed Page', url: 'https://example.com/claimed-job'}
544
+ }));
545
+ try {
546
+ const {result} = await processTestRequest(
547
+ 'because', {description: 'Claimed Page', url: 'https://example.com/some-other-url'}
548
+ );
549
+ assert.equal(result, 'description');
550
+ }
551
+ finally {
552
+ await fs.unlink(claimedPath);
553
+ }
554
+ });
555
+
556
+ test('processTestRequest returns "url" for a page matching a claimed job by URL', async () => {
557
+ const claimedPath = path.join(jobsPath(), 'claimed', 'clm.json');
558
+ await fs.writeFile(claimedPath, getJSON({
559
+ target: {what: 'Some Claimed Page', url: 'https://example.com/claimed-url'}
560
+ }));
561
+ try {
562
+ const {result} = await processTestRequest(
563
+ 'because', {description: 'A Different Page', url: 'https://example.com/claimed-url'}
564
+ );
565
+ assert.equal(result, 'url');
566
+ }
567
+ finally {
568
+ await fs.unlink(claimedPath);
569
+ }
570
+ });
571
+
572
+ test('processTestRequest returns "description" for a page matching a queued job by description', async () => {
573
+ const queuedPath = path.join(jobsPath(), 'queue', 'que.json');
574
+ await fs.writeFile(queuedPath, getJSON({
575
+ target: {what: 'Queued Page', url: 'https://example.com/queued-job'}
576
+ }));
577
+ try {
578
+ const {result} = await processTestRequest(
579
+ 'because', {description: 'Queued Page', url: 'https://example.com/some-other-url'}
580
+ );
581
+ assert.equal(result, 'description');
582
+ }
583
+ finally {
584
+ await fs.unlink(queuedPath);
585
+ }
586
+ });
587
+
588
+ test('processTestRequest returns "url" for a page matching a queued job by URL', async () => {
589
+ const queuedPath = path.join(jobsPath(), 'queue', 'que.json');
590
+ await fs.writeFile(queuedPath, getJSON({
591
+ target: {what: 'Some Queued Page', url: 'https://example.com/queued-url'}
592
+ }));
593
+ try {
594
+ const {result} = await processTestRequest(
595
+ 'because', {description: 'A Different Page', url: 'https://example.com/queued-url'}
596
+ );
597
+ assert.equal(result, 'url');
598
+ }
599
+ finally {
600
+ await fs.unlink(queuedPath);
601
+ }
602
+ });
603
+
604
+ test('processTestRequest returns "nonreport" when the cited report does not exist', async () => {
605
+ // Cite a timeStamp/jobID that does not correspond to any available report.
606
+ const {result} = await processTestRequest('because', {timeStamp: '999999T9999', jobID: 'xxx'});
607
+ assert.equal(result, 'nonreport');
608
+ });
609
+
610
+ test('processTestRequest throws when a job file in a category directory is defective', async () => {
611
+ const claimedPath = path.join(jobsPath(), 'claimed', 'bad.json');
612
+ await fs.writeFile(claimedPath, 'not valid json');
613
+ try {
614
+ await assert.rejects(
615
+ processTestRequest('because', {description: 'Any Page', url: 'https://example.com/any'}),
616
+ /Failed to process test request/
617
+ );
618
+ }
619
+ finally {
620
+ await fs.unlink(claimedPath);
621
+ }
622
+ });
623
+
624
+ // TESTS FOR REMAINING BRANCH COVERAGE
625
+
626
+ test('getJobNames creates missing job directories and returns empty arrays', async () => {
627
+ const os = await import('node:os');
628
+ const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'kilotest-jobnames-'));
629
+ const tmpDbDir = path.join(tmpRoot, 'db');
630
+ const savedDbDir = process.env.DB_DIR;
631
+ process.env.DB_DIR = tmpDbDir;
632
+ try {
633
+ const {getJobNames} = await import('./util.ts');
634
+ const result = await getJobNames();
635
+ assert.ok(typeof result === 'object' && result !== null);
636
+ const jobNames = result as Record<string, string[]>;
637
+ assert.deepEqual(jobNames.queue, []);
638
+ assert.deepEqual(jobNames.claimed, []);
639
+ assert.deepEqual(jobNames.failed, []);
640
+ // Verify the directories were created.
641
+ for (const category of ['queue', 'claimed', 'failed']) {
642
+ const stat = await fs.stat(path.join(tmpDbDir, 'jobs', category));
643
+ assert.ok(stat.isDirectory());
644
+ }
645
+ }
646
+ finally {
647
+ process.env.DB_DIR = savedDbDir;
648
+ await fs.rm(tmpRoot, {recursive: true}).catch(() => {});
649
+ }
650
+ });
651
+
652
+ test('getJobNames throws when a job directory is a file, not a directory', async () => {
653
+ const os = await import('node:os');
654
+ const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'kilotest-jobnames-err-'));
655
+ const tmpJobsDir = path.join(tmpRoot, 'db', 'jobs');
656
+ await fs.mkdir(tmpJobsDir, {recursive: true});
657
+ // Create a file where the queue directory should be, causing ENOTDIR.
658
+ await fs.writeFile(path.join(tmpJobsDir, 'queue'), 'not a directory');
659
+ const savedDbDir = process.env.DB_DIR;
660
+ process.env.DB_DIR = path.join(tmpRoot, 'db');
661
+ try {
662
+ const {getJobNames} = await import('./util.ts');
663
+ await assert.rejects(getJobNames(), /not readable/);
664
+ }
665
+ finally {
666
+ process.env.DB_DIR = savedDbDir;
667
+ await fs.rm(tmpRoot, {recursive: true}).catch(() => {});
668
+ }
669
+ });
670
+
671
+ test('getJobsData returns an empty array, instead of throwing ENOENT, when its category directory does not yet exist', async () => {
672
+ // Regression test: on a fresh deployment, jobs/claimed and jobs/queue may never
673
+ // have been created yet (no job has ever been claimed or queued there). getJobsData
674
+ // must tolerate that the same way getJobNames does, rather than reading the
675
+ // category directory directly and throwing ENOENT.
676
+ const os = await import('node:os');
677
+ const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'kilotest-jobsdata-'));
678
+ const savedDbDir = process.env.DB_DIR;
679
+ process.env.DB_DIR = path.join(tmpRoot, 'db');
680
+ try {
681
+ const {getJobsData} = await import('./util.ts');
682
+ const result = await getJobsData('claimed');
683
+ assert.deepEqual(result, []);
684
+ }
685
+ finally {
686
+ process.env.DB_DIR = savedDbDir;
687
+ await fs.rm(tmpRoot, {recursive: true}).catch(() => {});
688
+ }
689
+ });
690
+
691
+ test('getJobsData skips a job file that is removed between the directory listing and its read', async (t) => {
692
+ // Regression test: on the deployed server, a worker can complete or reclaim a
693
+ // claimed job (moving or deleting its file) between getJobsData listing the
694
+ // claimed directory and reading a specific file it found there. That race must
695
+ // result in the vanished job simply being skipped, not an uncaught ENOENT.
696
+ const claimedPath = path.join(jobsPath(), 'claimed', 'raced.json');
697
+ await fs.writeFile(claimedPath, getJSON({
698
+ target: {what: 'Raced Page', url: 'https://example.com/raced'}
699
+ }));
700
+ const originalReadFile = fs.readFile;
701
+ t.mock.method(fs, 'readFile', async (filePath: any, ...rest: any[]) => {
702
+ // Simulate the file having been removed by a worker just before this read.
703
+ if (String(filePath) === claimedPath) {
704
+ await fs.unlink(claimedPath);
705
+ const error: any = new Error('ENOENT: no such file or directory');
706
+ error.code = 'ENOENT';
707
+ throw error;
708
+ }
709
+ return (originalReadFile as any)(filePath, ...rest);
710
+ });
711
+ try {
712
+ const {getJobsData} = await import('./util.ts');
713
+ const result = await getJobsData('claimed');
714
+ assert.ok(!result.some(job => job.description === 'Raced Page'));
715
+ }
716
+ finally {
717
+ t.mock.reset();
718
+ await fs.unlink(claimedPath).catch(() => {});
719
+ }
720
+ });
721
+
722
+ test('getJobsData throws when a job file cannot be read for a reason other than its removal', async () => {
723
+ // A directory where a job file is expected causes fs.readFile to fail with
724
+ // EISDIR, not ENOENT, so getJobsData must still treat it as defective (unlike
725
+ // the ENOENT case above, which is skipped as a normal race with a worker).
726
+ const claimedDirAsFile = path.join(jobsPath(), 'claimed', 'notAFile.json');
727
+ await fs.mkdir(claimedDirAsFile);
728
+ try {
729
+ const {getJobsData} = await import('./util.ts');
730
+ await assert.rejects(getJobsData('claimed'), /defective/);
731
+ }
732
+ finally {
733
+ await fs.rm(claimedDirAsFile, {recursive: true}).catch(() => {});
734
+ }
735
+ });
736
+
737
+ test('processTestRequest succeeds on a fresh deployment with no job directories yet', async () => {
738
+ // Regression test for the same ENOENT scenario, exercised through the actual
739
+ // public interface: a brand-new DB_DIR with no jobs/claimed or jobs/queue
740
+ // directories must not prevent a new-test request from being approved.
741
+ const os = await import('node:os');
742
+ const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'kilotest-freshdeploy-'));
743
+ const savedDbDir = process.env.DB_DIR;
744
+ process.env.DB_DIR = path.join(tmpRoot, 'db');
745
+ try {
746
+ const {processTestRequest: freshProcessTestRequest} = await import('./util.ts');
747
+ const {result} = await freshProcessTestRequest(
748
+ 'because accessibility', {description: 'Fresh Deploy Page', url: 'https://example.com/fresh'}
749
+ );
750
+ assert.equal(result, 'ok');
751
+ }
752
+ finally {
753
+ process.env.DB_DIR = savedDbDir;
754
+ await fs.rm(tmpRoot, {recursive: true}).catch(() => {});
755
+ }
756
+ });
757
+
758
+ test('getObject throws for a file that is not valid JSON', async () => {
759
+ const tmpFile = path.join((await import('node:os')).tmpdir(), 'kilotest-test-invalid.json');
760
+ (await import('node:fs')).writeFileSync(tmpFile, 'not json');
761
+ try {
762
+ await assert.rejects(getObject(tmpFile), /not JSON/);
763
+ }
764
+ finally {
765
+ (await import('node:fs')).unlinkSync(tmpFile);
766
+ }
767
+ });
768
+
769
+ test('getTestRequests creates and returns an empty test-requests object when the file is missing', async () => {
770
+ const tmpDir = (await import('node:os')).tmpdir() + '/kilotest-missing-testrequests-test';
771
+ const fsSync = await import('node:fs');
772
+ fsSync.mkdirSync(tmpDir + '/jobs', {recursive: true});
773
+ const savedDbDir = process.env.DB_DIR;
774
+ process.env.DB_DIR = tmpDir;
775
+ try {
776
+ const {getTestRequests} = await import('./util.ts');
777
+ const result = await getTestRequests();
778
+ assert.deepEqual(result, {});
779
+ // Verify the empty file was created.
780
+ assert.ok(fsSync.existsSync(tmpDir + '/jobs/testRequests.json'));
781
+ }
782
+ finally {
783
+ process.env.DB_DIR = savedDbDir;
784
+ fsSync.rmSync(tmpDir, {recursive: true});
785
+ }
786
+ });
787
+
788
+ test('getTestRequests throws when the test-requests file is not readable for a reason other than being missing', async () => {
789
+ const tmpDir = (await import('node:os')).tmpdir() + '/kilotest-testrequests-unreadable-test';
790
+ const fsSync = await import('node:fs');
791
+ // Create a directory where the test-requests file should be, causing EISDIR rather than ENOENT.
792
+ fsSync.mkdirSync(tmpDir + '/jobs/testRequests.json', {recursive: true});
793
+ const savedDbDir = process.env.DB_DIR;
794
+ process.env.DB_DIR = tmpDir;
795
+ try {
796
+ const {getTestRequests} = await import('./util.ts');
797
+ await assert.rejects(getTestRequests(), /not readable/);
798
+ }
799
+ finally {
800
+ process.env.DB_DIR = savedDbDir;
801
+ fsSync.rmSync(tmpDir, {recursive: true});
802
+ }
803
+ });
804
+
805
+ test('getTestRequests throws when the test-requests file is not JSON', async () => {
806
+ const tmpDir = (await import('node:os')).tmpdir() + '/kilotest-testrequests-test';
807
+ (await import('node:fs')).mkdirSync(tmpDir + '/jobs', {recursive: true});
808
+ (await import('node:fs')).writeFileSync(tmpDir + '/jobs/testRequests.json', 'not json');
809
+ const savedDbDir = process.env.DB_DIR;
810
+ process.env.DB_DIR = tmpDir;
811
+ try {
812
+ const {getTestRequests} = await import('./util.ts');
813
+ await assert.rejects(getTestRequests(), /not JSON/);
814
+ }
815
+ finally {
816
+ process.env.DB_DIR = savedDbDir;
817
+ (await import('node:fs')).rmSync(tmpDir, {recursive: true});
818
+ }
819
+ });
820
+
821
+ test('getPOSTData resolves with parsed query for form-urlencoded requests', async () => {
822
+ const {Readable} = await import('node:stream');
823
+ const {getPOSTData} = await import('./util.ts');
824
+ const body = 'target=Page&why=Because';
825
+ const req = Object.assign(new Readable({
826
+ read() {
827
+ this.push(Buffer.from(body));
828
+ this.push(null);
829
+ }
830
+ }), {headers: {'content-type': 'application/x-www-form-urlencoded'}});
831
+ const result: any = await getPOSTData(req as any);
832
+ assert.equal(result.target, 'Page');
833
+ assert.equal(result.why, 'Because');
834
+ });
835
+
836
+ test('isURL returns false for a malformed URL', () => {
837
+ assert.equal(isURL('https://[invalid'), false);
838
+ });
839
+
840
+ test('objectSort returns 0 for an unknown sort type', () => {
841
+ const items = [{name: 'a'}, {name: 'b'}];
842
+ const sorted = objectSort(items, 'name', 'unknownType' as any);
843
+ assert.equal(sorted.length, 2);
844
+ });
845
+
846
+ test('getReport returns an error for an invalid report', async () => {
847
+ const tmpDir = (await import('node:os')).tmpdir() + '/kilotest-invalid-report-test';
848
+ const fsSync = await import('node:fs');
849
+ fsSync.mkdirSync(tmpDir + '/reports', {recursive: true});
850
+ fsSync.writeFileSync(tmpDir + '/reports/260101T0000-bad.json', JSON.stringify({notValid: true}));
851
+ const savedDbDir = process.env.DB_DIR;
852
+ process.env.DB_DIR = tmpDir;
853
+ try {
854
+ const {getReport} = await import('./util.ts');
855
+ const result = await getReport('260101T0000', 'bad') as any;
856
+ assert.ok(result.error);
857
+ assert.ok(result.error.includes('not usable'));
858
+ }
859
+ finally {
860
+ process.env.DB_DIR = savedDbDir;
861
+ fsSync.rmSync(tmpDir, {recursive: true});
862
+ }
863
+ });
864
+
865
+ test('getReportData returns an error for a nonexistent report', async () => {
866
+ const {getReportData} = await import('./util.ts');
867
+ const result: any = await getReportData('990101T0000', 'xxx');
868
+ assert.ok(result.error);
869
+ });
870
+
871
+ test('getReportData falls back to the engine ID for an unknown prevented engine', async () => {
872
+ const {getReportData} = await import('./util.ts');
873
+ const prvJSON = await fs.readFile(path.join(reportsPath(), '260101T0006-prv.json'), 'utf8');
874
+ const report = JSON.parse(prvJSON);
875
+ report.jobData.preventions.unknownEngine = 'mystery failure';
876
+ const reportPath = path.join(reportsPath(), '260103T0000-unk.json');
877
+ await fs.writeFile(reportPath, JSON.stringify(report));
878
+ try {
879
+ const result: any = await getReportData('260103T0000', 'unk');
880
+ assert.ok(result.preventedEngineNames.includes('unknownEngine'));
881
+ }
882
+ finally {
883
+ await fs.unlink(reportPath);
884
+ }
885
+ });
886
+
887
+ // TESTS FOR REMAINING BRANCH COVERAGE IN util.js
888
+
889
+ test('getAgoDays returns null for an invalid Date object', () => {
890
+ assert.equal(getAgoDays(new Date('invalid')), null);
891
+ });
892
+
893
+ test('getAgoString returns "1 day" for exactly 1 day ago', () => {
894
+ // Construct a time stamp 1 day and 1 hour ago, so Math.round gives exactly 1.
895
+ const date = new Date(Date.now() - (86400000 + 3600000));
896
+ const stamp = date.toISOString().slice(2).replace(/[-:]/g, '').slice(0, 11);
897
+ assert.equal(getAgoString(stamp), '1 day');
898
+ });
899
+
900
+ test('getIssue returns null for a known engine with an unknown variable rule', () => {
901
+ assert.equal(getIssue('axe', 'nonexistent-rule-id'), null);
902
+ });
903
+
904
+ test('getIssue returns an issue ID for a variable rule pattern match', () => {
905
+ const result = getIssue('nuVal', 'Duplicate attribute foo');
906
+ assert.ok(typeof result === 'string');
907
+ assert.equal(result, 'duplicateAttribute');
908
+ });
909
+
910
+ test('getDateTimeString describes an invalid time stamp as unknown', async () => {
911
+ const {getDateTimeString} = await import('./util.ts');
912
+ const result = getDateTimeString('999999T9999');
913
+ assert.equal(result, 'an unknown date at an unknown time');
914
+ });
915
+
916
+ test('getPOSTData resolves with parsed JSON for application/json requests', async () => {
917
+ const {Readable} = await import('node:stream');
918
+ const {getPOSTData} = await import('./util.ts');
919
+ const body = JSON.stringify({target: 'Page', why: 'Because'});
920
+ const req = Object.assign(new Readable({
921
+ read() {
922
+ this.push(Buffer.from(body));
923
+ this.push(null);
924
+ }
925
+ }), {headers: {'content-type': 'application/json'}});
926
+ const result: any = await getPOSTData(req as any);
927
+ assert.equal(result.target, 'Page');
928
+ });
929
+
930
+ test('getPOSTData resolves with parsed query for body-type form-urlencoded requests', async () => {
931
+ const {Readable} = await import('node:stream');
932
+ const {getPOSTData} = await import('./util.ts');
933
+ const body = 'target=Page&why=Because';
934
+ const req = Object.assign(new Readable({
935
+ read() {
936
+ this.push(Buffer.from(body));
937
+ this.push(null);
938
+ }
939
+ }), {headers: {'body-type': 'application/x-www-form-urlencoded'}});
940
+ const result: any = await getPOSTData(req as any);
941
+ assert.equal(result.target, 'Page');
942
+ assert.equal(result.why, 'Because');
943
+ });
944
+
945
+ test('getPOSTData resolves with null for an unknown content type', async () => {
946
+ const {Readable} = await import('node:stream');
947
+ const {getPOSTData} = await import('./util.ts');
948
+ const req = Object.assign(new Readable({
949
+ read() {
950
+ this.push(Buffer.from('data'));
951
+ this.push(null);
952
+ }
953
+ }), {headers: {}});
954
+ const result: any = await getPOSTData(req as any);
955
+ assert.equal(result, null);
956
+ });
957
+
958
+ test('getPOSTData resolves with null for a malformed JSON body', async () => {
959
+ const {Readable} = await import('node:stream');
960
+ const {getPOSTData} = await import('./util.ts');
961
+ const req = Object.assign(new Readable({
962
+ read() {
963
+ this.push(Buffer.from('{not valid json'));
964
+ this.push(null);
965
+ }
966
+ }), {headers: {'content-type': 'application/json'}});
967
+ const result: any = await getPOSTData(req as any);
968
+ assert.equal(result, null);
969
+ });
970
+
971
+ test('getEngineNamesString falls back to the engine ID for an unknown engine', async () => {
972
+ const {getEngineNamesString} = await import('./util.ts');
973
+ const result = getEngineNamesString(new Set(['unknownEngine']));
974
+ assert.equal(result, 'unknownEngine');
975
+ });
976
+
977
+ test('getPathID returns the catalog pathID when catalogIndex is truthy', async () => {
978
+ const {getPathID} = await import('./util.ts');
979
+ const catalog = {'0': {tagName: 'div', pathID: '/html/body/div'}};
980
+ assert.equal(getPathID(catalog, '0', '/fallback'), '/html/body/div');
981
+ });
982
+
983
+ test('getPathID returns the fallback pathID when catalogIndex is truthy but catalogItem has no pathID', async () => {
984
+ const {getPathID} = await import('./util.ts');
985
+ const catalog = {'0': {tagName: 'div', pathID: ''}};
986
+ assert.equal(getPathID(catalog, '0', '/fallback'), '/fallback');
987
+ });
988
+
989
+ test('getPathID returns /html when catalogIndex is truthy but catalogItem and pathID are both missing', async () => {
990
+ const {getPathID} = await import('./util.ts');
991
+ const catalog = {};
992
+ assert.equal(getPathID(catalog, '0', null as any), '/html');
993
+ });
994
+
995
+ test('getPathID returns /html when catalogIndex is falsy and pathID is null', async () => {
996
+ const {getPathID} = await import('./util.ts');
997
+ assert.equal(getPathID({}, null as any, null as any), '/html');
998
+ });
999
+
1000
+ test('isUsableReport returns false for a report with a test act using an unknown engine', async () => {
1001
+ const {isUsableReport} = await import('./util.ts');
1002
+ const report = {
1003
+ target: {what: 'Test', url: 'https://example.com'},
1004
+ acts: [{type: 'test', which: 'unknownEngine'}],
1005
+ jobData: {endTime: '26-01-01T00:00'},
1006
+ catalog: {}
1007
+ };
1008
+ assert.equal(isUsableReport(report), false);
1009
+ });
1010
+
1011
+ test('isUsableReport returns true for a report with a non-test act', async () => {
1012
+ const {isUsableReport} = await import('./util.ts');
1013
+ const report = {
1014
+ target: {what: 'Test', url: 'https://example.com'},
1015
+ acts: [{type: 'other'}],
1016
+ jobData: {endTime: '26-01-01T00:00'},
1017
+ catalog: {}
1018
+ };
1019
+ assert.equal(isUsableReport(report), true);
1020
+ });
1021
+
1022
+ // UNIT TESTS FOR PREVIOUSLY UNTESTED EXPORTED FUNCTIONS
1023
+
1024
+ test('createLock returns a function that runs tasks sequentially', async () => {
1025
+ const lock = createLock();
1026
+ const order: string[] = [];
1027
+ const p1 = lock(async () => {
1028
+ order.push('start 1');
1029
+ await new Promise(r => setTimeout(r, 10));
1030
+ order.push('end 1');
1031
+ return 1;
1032
+ });
1033
+ const p2 = lock(async () => {
1034
+ order.push('start 2');
1035
+ return 2;
1036
+ });
1037
+ const [r1, r2] = await Promise.all([p1, p2]);
1038
+ assert.equal(r1, 1);
1039
+ assert.equal(r2, 2);
1040
+ assert.deepEqual(order, ['start 1', 'end 1', 'start 2']);
1041
+ });
1042
+
1043
+ test('createLock propagates errors without blocking subsequent tasks', async () => {
1044
+ const lock = createLock();
1045
+ const p1 = lock(async () => {
1046
+ throw new Error('first failed');
1047
+ });
1048
+ const p2 = lock(async () => 'second succeeded');
1049
+ await p1.catch(() => {});
1050
+ const r2 = await p2;
1051
+ assert.equal(r2, 'second succeeded');
1052
+ });
1053
+
1054
+ test('getTimeStamp returns an 11-character stamp from a Date', () => {
1055
+ const date = new Date('2026-03-15T14:30:00.000Z');
1056
+ const stamp = getTimeStamp(date);
1057
+ assert.equal(stamp.length, 11);
1058
+ assert.equal(stamp.slice(0, 6), '260315');
1059
+ assert.equal(stamp.slice(6, 7), 'T');
1060
+ assert.equal(stamp.slice(7), '1430');
1061
+ });
1062
+
1063
+ test('getEngineNamesString returns a sorted +-delimited list of engine names', () => {
1064
+ const result = getEngineNamesString(new Set(['axe', 'wave', 'nuVal']));
1065
+ const names = result.split(' + ');
1066
+ assert.ok(names.length === 3);
1067
+ assert.ok(names.includes('WAVE'));
1068
+ });
1069
+
1070
+ test('getEngineNamesString falls back to the ID for an unknown engine', () => {
1071
+ assert.equal(getEngineNamesString(new Set(['unknownEngine'])), 'unknownEngine');
1072
+ });
1073
+
1074
+ test('testRequestsLock is a function (the lock returned by createLock)', () => {
1075
+ assert.equal(typeof testRequestsLock, 'function');
1076
+ });
1077
+
1078
+ test('getReportPath returns the path of a report file', () => {
1079
+ const result = getReportPath('260101T0000', 'mix');
1080
+ assert.ok(result.endsWith('260101T0000-mix.json'));
1081
+ });
1082
+
1083
+ test('getReportStats returns reportTime and reportSize for a valid report', async () => {
1084
+ const stats = await getReportStats('260101T0000', 'mix');
1085
+ assert.ok(stats);
1086
+ assert.ok(stats.reportTime instanceof Date);
1087
+ assert.equal(typeof stats.reportSize, 'number');
1088
+ assert.ok(stats.reportSize > 0);
1089
+ });
1090
+
1091
+ test('getReportStats returns null for a nonexistent report', async () => {
1092
+ const stats = await getReportStats('999999T9999', 'xxx');
1093
+ assert.equal(stats, null);
1094
+ });
1095
+
1096
+
1097
+ test('getReportExtract returns an extract for a valid report', async () => {
1098
+ const extract = await getReportExtract('260101T0000', 'mix') as any;
1099
+ assert.equal(extract.timeStamp, '260101T0000');
1100
+ assert.equal(extract.jobID, 'mix');
1101
+ assert.equal(extract.description, 'Mixed Outcomes Page');
1102
+ assert.equal(extract.url, 'https://example.com/mixed');
1103
+ assert.ok(extract.reportTime);
1104
+ });
1105
+
1106
+ test('getReportExtract returns an error for a nonexistent report', async () => {
1107
+ const extract = await getReportExtract('999999T9999', 'xxx') as any;
1108
+ assert.ok(extract.error);
1109
+ });
1110
+
1111
+ test('getReportExtracts returns extracts of all available reports', async () => {
1112
+ const extracts = await getReportExtracts();
1113
+ assert.ok(extracts.length >= 8);
1114
+ const ids = extracts.map(e => `${e.timeStamp}-${e.jobID}`);
1115
+ assert.ok(ids.includes('260101T0000-mix'));
1116
+ assert.ok(ids.includes('260101T0001-ct'));
1117
+ });
1118
+
1119
+ test('getReportExtracts with onlyLatest returns only the latest report for each page', async () => {
1120
+ const latest = await getReportExtracts(true);
1121
+ const mixReports = latest.filter(e => e.description === 'Mixed Outcomes Page');
1122
+ assert.equal(mixReports.length, 1);
1123
+ assert.equal(mixReports[0]!.timeStamp, '260202T0000');
1124
+ });
1125
+
1126
+ test('getMultiReportWhats returns descriptions that have multiple reports', async () => {
1127
+ const whats = await getMultiReportWhats();
1128
+ assert.ok(whats.includes('Mixed Outcomes Page'));
1129
+ });