@jrpool/kilotest 46.0.8 → 46.0.14

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 (60) hide show
  1. package/.devin/config.local.json +4 -1
  2. package/.github/workflows/{smoke-test.yml → ci.yml} +8 -2
  3. package/README.md +14 -8
  4. package/alerts.test.js +137 -0
  5. package/api/listDiagnoses.test.js +44 -0
  6. package/api/listIssues.js +1 -1
  7. package/api/listIssues.test.js +10 -0
  8. package/api/listReports.test.js +6 -2
  9. package/api/listViolators.test.js +35 -0
  10. package/api/requestTest.test.js +7 -0
  11. package/api/routes.test.js +63 -0
  12. package/api/util.js +9 -11
  13. package/api/util.test.js +68 -2
  14. package/apiIntegration.test.js +13 -3
  15. package/docs/AI-TOOLS.md +41 -158
  16. package/docs/{Smoke Test.json → CI.json} +4 -3
  17. package/index.html +3 -3
  18. package/index.js +64 -77
  19. package/index.test.js +1152 -0
  20. package/indexBalances.test.js +188 -0
  21. package/indexFallback.test.js +33 -0
  22. package/indexServe.test.js +59 -0
  23. package/indexWorkers.test.js +79 -0
  24. package/lhm.plugin.json +3218 -0
  25. package/llms-full.txt +6 -7
  26. package/llms.txt +1 -1
  27. package/mcp.js +2 -2
  28. package/mcp.test.js +281 -0
  29. package/openapi.yaml +1 -1
  30. package/package.json +10 -5
  31. package/routing.test.js +13 -3
  32. package/scripts/generate-openapi.js +1 -1
  33. package/test/fixtures/buildFixtures.js +83 -0
  34. package/test/fixtures/db/reports/260101T0008-mul.json +137 -0
  35. package/test/fixtures/db/reports/260101T0009-brd.json +147 -0
  36. package/test/setup.cjs +14 -0
  37. package/util.js +16 -6
  38. package/util.test.js +886 -3
  39. package/web/ai0BalanceForm/ai0BalanceForm.test.js +88 -0
  40. package/web/expungeReportsForm/expungeReportsForm.test.js +114 -0
  41. package/web/hideReportForm/hideReportForm.test.js +82 -0
  42. package/web/listDiagnoses/listDiagnoses.test.js +160 -0
  43. package/web/listIssues/listIssues.test.js +109 -0
  44. package/web/listReports/listReports.test.js +139 -3
  45. package/web/listRules/listRules.test.js +103 -0
  46. package/web/listTopIssues/listTopIssues.test.js +126 -0
  47. package/web/listViolators/index.js +1 -2
  48. package/web/listViolators/listViolators.test.js +256 -0
  49. package/web/pruneReportsForm/pruneReportsForm.test.js +86 -0
  50. package/web/reannotate/reannotate.test.js +144 -0
  51. package/web/reannotateForm/reannotateForm.test.js +134 -0
  52. package/web/renewWCAG/renewWCAG.test.js +124 -0
  53. package/web/requestRetest/requestRetest.test.js +119 -0
  54. package/web/requestRetestForm/requestRetestForm.test.js +62 -0
  55. package/web/requestTest/requestTest.test.js +115 -0
  56. package/web/requestTestForm/requestTestForm.test.js +40 -0
  57. package/web/rewindReportsForm/rewindReportsForm.test.js +86 -0
  58. package/web/tutorial/index.html +3 -3
  59. package/web/tutorial/tutorial.test.js +97 -0
  60. package/web/unhideReportForm/unhideReportForm.test.js +135 -0
package/index.test.js ADDED
@@ -0,0 +1,1152 @@
1
+ /*
2
+ index.test.js
3
+ Integration tests for index.js requestHandler, covering GET routes, POST routes,
4
+ error paths, worker authentication, and helper function behavior.
5
+ */
6
+
7
+ // ENVIRONMENT (must be set before requiring index.js, because index.js reads
8
+ // TESTARO_WORKERS and AUTH_CODE at module load time, and util.js reads DB_DIR.)
9
+
10
+ const path = require('node:path');
11
+ const fixtureDBDir = path.join(__dirname, 'test', 'fixtures', 'db');
12
+
13
+ process.env.DB_DIR = fixtureDBDir;
14
+ process.env.AUTH_CODE = 'test-auth-code';
15
+ process.env.TESTARO_WORKERS = JSON.stringify({
16
+ worker1: {secret: 'secret1', name: 'Worker One'}
17
+ });
18
+
19
+ // IMPORTS
20
+
21
+ const {test, before, after} = require('node:test');
22
+ const assert = require('node:assert/strict');
23
+ const http = require('node:http');
24
+ const fs = require('node:fs/promises');
25
+ const indexModule = require('./index');
26
+ const {requestHandler, routes, serveError, startServer, runIfMain, isPathAllowed, getAbuseError} = indexModule;
27
+
28
+ // CONSTANTS
29
+
30
+ const port = 3997;
31
+ const recsPath = path.join(fixtureDBDir, 'jobs', 'recs.json');
32
+
33
+ // SETUP AND TEARDOWN
34
+
35
+ let server;
36
+
37
+ before(async () => {
38
+ // Restore recs.json and clean job directories before running.
39
+ await fs.writeFile(recsPath, '{}\n');
40
+ for (const sub of ['claimed', 'queue', 'failed']) {
41
+ const dir = path.join(fixtureDBDir, 'jobs', sub);
42
+ await fs.mkdir(dir, {recursive: true});
43
+ const files = await fs.readdir(dir).catch(() => []);
44
+ for (const file of files) {
45
+ await fs.unlink(path.join(dir, file)).catch(() => {});
46
+ }
47
+ }
48
+ server = http.createServer(requestHandler);
49
+ await new Promise(resolve => server.listen(port, () => resolve()));
50
+ });
51
+
52
+ after(async () => {
53
+ // Close all idle connections, then close the server with a timeout.
54
+ server.closeAllConnections?.();
55
+ await new Promise(resolve => {
56
+ const timer = setTimeout(() => {
57
+ server.closeAllConnections?.();
58
+ resolve();
59
+ }, 1000);
60
+ server.close(() => {
61
+ clearTimeout(timer);
62
+ resolve();
63
+ });
64
+ });
65
+ // Restore recs.json and clean job directories after running.
66
+ await fs.writeFile(recsPath, '{}\n');
67
+ for (const sub of ['claimed', 'queue', 'failed']) {
68
+ const dir = path.join(fixtureDBDir, 'jobs', sub);
69
+ await fs.mkdir(dir, {recursive: true});
70
+ const files = await fs.readdir(dir).catch(() => []);
71
+ for (const file of files) {
72
+ await fs.unlink(path.join(dir, file)).catch(() => {});
73
+ }
74
+ }
75
+ });
76
+
77
+ // CONSTANTS
78
+
79
+ const uniqueStamp = Date.now();
80
+
81
+ // HELPERS
82
+
83
+ const request = (method, requestPath, body = null, headers = {}) => new Promise((resolve, reject) => {
84
+ const options = {method, host: 'localhost', port, path: requestPath, headers: {...headers}};
85
+ let bodyData = '';
86
+ if (body) {
87
+ if (typeof body === 'object') {
88
+ bodyData = JSON.stringify(body);
89
+ options.headers['content-type'] = options.headers['content-type'] || 'application/json';
90
+ }
91
+ else {
92
+ bodyData = body;
93
+ }
94
+ options.headers['content-length'] = Buffer.byteLength(bodyData);
95
+ }
96
+ const req = http.request(options, response => {
97
+ const chunks = [];
98
+ response.on('data', chunk => chunks.push(chunk));
99
+ response.on('end', () => {
100
+ resolve({statusCode: response.statusCode, headers: response.headers, body: Buffer.concat(chunks).toString()});
101
+ });
102
+ });
103
+ req.on('error', reject);
104
+ req.end(bodyData || '');
105
+ });
106
+
107
+ const formRequest = (method, requestPath, formData, headers = {}) => new Promise((resolve, reject) => {
108
+ const body = new URLSearchParams(formData).toString();
109
+ const options = {
110
+ method,
111
+ host: 'localhost',
112
+ port,
113
+ path: requestPath,
114
+ headers: {
115
+ ...headers,
116
+ 'content-type': 'application/x-www-form-urlencoded',
117
+ 'content-length': Buffer.byteLength(body)
118
+ }
119
+ };
120
+ const req = http.request(options, response => {
121
+ const chunks = [];
122
+ response.on('data', chunk => chunks.push(chunk));
123
+ response.on('end', () => {
124
+ resolve({statusCode: response.statusCode, headers: response.headers, body: Buffer.concat(chunks).toString()});
125
+ });
126
+ });
127
+ req.on('error', reject);
128
+ req.end(body);
129
+ });
130
+
131
+ const jsonBody = res => {
132
+ try {
133
+ return JSON.parse(res.body);
134
+ }
135
+ catch {
136
+ return null;
137
+ }
138
+ };
139
+
140
+ // TESTS: routes table
141
+
142
+ test('routes has GET and POST arrays', () => {
143
+ assert.ok(Array.isArray(routes.GET));
144
+ assert.ok(Array.isArray(routes.POST));
145
+ });
146
+
147
+ // TESTS: GET routes
148
+
149
+ test('GET / serves the home page as HTML', async () => {
150
+ const res = await request('GET', '/');
151
+ assert.equal(res.statusCode, 200);
152
+ assert.ok(res.headers['content-type'].includes('text/html'));
153
+ assert.ok(res.body.includes('<html'));
154
+ });
155
+
156
+ test('GET /index.html serves the home page', async () => {
157
+ const res = await request('GET', '/index.html');
158
+ assert.equal(res.statusCode, 200);
159
+ assert.ok(res.headers['content-type'].includes('text/html'));
160
+ });
161
+
162
+ test('GET /robots.txt serves the robots file as text', async () => {
163
+ const res = await request('GET', '/robots.txt');
164
+ assert.equal(res.statusCode, 200);
165
+ assert.ok(res.headers['content-type'].includes('text/plain'));
166
+ assert.ok(res.body.length > 0);
167
+ });
168
+
169
+ test('GET /openapi.yaml serves the OpenAPI spec as YAML', async () => {
170
+ const res = await request('GET', '/openapi.yaml');
171
+ assert.equal(res.statusCode, 200);
172
+ assert.ok(res.headers['content-type'].includes('text/yaml'));
173
+ assert.ok(res.body.includes('openapi'));
174
+ });
175
+
176
+ test('GET /openapi.json redirects to /openapi.yaml', async () => {
177
+ const res = await request('GET', '/openapi.json');
178
+ assert.equal(res.statusCode, 301);
179
+ assert.equal(res.headers.location, '/openapi.yaml');
180
+ });
181
+
182
+ test('GET /swagger.yaml redirects to /openapi.yaml', async () => {
183
+ const res = await request('GET', '/swagger.yaml');
184
+ assert.equal(res.statusCode, 301);
185
+ assert.equal(res.headers.location, '/openapi.yaml');
186
+ });
187
+
188
+ test('GET /swagger.json redirects to /openapi.yaml', async () => {
189
+ const res = await request('GET', '/swagger.json');
190
+ assert.equal(res.statusCode, 301);
191
+ assert.equal(res.headers.location, '/openapi.yaml');
192
+ });
193
+
194
+ test('GET /api-docs redirects to /openapi.yaml', async () => {
195
+ const res = await request('GET', '/api-docs');
196
+ assert.equal(res.statusCode, 301);
197
+ assert.equal(res.headers.location, '/openapi.yaml');
198
+ });
199
+
200
+ test('GET /llms.txt serves the LLM summary as text', async () => {
201
+ const res = await request('GET', '/llms.txt');
202
+ assert.equal(res.statusCode, 200);
203
+ assert.ok(res.headers['content-type'].includes('text/plain'));
204
+ assert.ok(res.body.length > 0);
205
+ });
206
+
207
+ test('GET /llms-full.txt serves the LLM detailed guide as text', async () => {
208
+ const res = await request('GET', '/llms-full.txt');
209
+ assert.equal(res.statusCode, 200);
210
+ assert.ok(res.headers['content-type'].includes('text/plain'));
211
+ assert.ok(res.body.length > 0);
212
+ });
213
+
214
+ test('GET /sitemap.xml serves the sitemap as XML', async () => {
215
+ const res = await request('GET', '/sitemap.xml');
216
+ assert.equal(res.statusCode, 200);
217
+ assert.ok(res.headers['content-type'].includes('application/xml'));
218
+ assert.ok(res.body.includes('<urlset') || res.body.includes('<?xml'));
219
+ });
220
+
221
+ test('GET /style.css serves the stylesheet as CSS', async () => {
222
+ const res = await request('GET', '/style.css');
223
+ assert.equal(res.statusCode, 200);
224
+ assert.ok(res.headers['content-type'].includes('text/css'));
225
+ });
226
+
227
+ test('GET /favicon.ico serves the favicon as an icon', async () => {
228
+ const res = await request('GET', '/favicon.ico');
229
+ assert.equal(res.statusCode, 200);
230
+ assert.ok(res.headers['content-type'].includes('image/x-icon'));
231
+ });
232
+
233
+ test('GET /api/listReports returns JSON with report data', async () => {
234
+ const res = await request('GET', '/api/listReports');
235
+ assert.equal(res.statusCode, 200);
236
+ const body = jsonBody(res);
237
+ assert.equal(body['tool name'], 'listReports');
238
+ });
239
+
240
+ test('GET /api/listIssues/260101T0000/mix returns JSON with issue data', async () => {
241
+ const res = await request('GET', '/api/listIssues/260101T0000/mix');
242
+ assert.equal(res.statusCode, 200);
243
+ const body = jsonBody(res);
244
+ assert.equal(body['tool name'], 'listIssues');
245
+ });
246
+
247
+ test('GET /api/listViolators/linkNoText/260101T0000/mix returns JSON', async () => {
248
+ const res = await request('GET', '/api/listViolators/linkNoText/260101T0000/mix');
249
+ assert.equal(res.statusCode, 200);
250
+ const body = jsonBody(res);
251
+ assert.equal(body['tool name'], 'listViolators');
252
+ });
253
+
254
+ test('GET /api/listDiagnoses/0/linkNoText/260101T0000/mix returns JSON', async () => {
255
+ const res = await request('GET', '/api/listDiagnoses/0/linkNoText/260101T0000/mix');
256
+ assert.equal(res.statusCode, 200);
257
+ const body = jsonBody(res);
258
+ assert.equal(body['tool name'], 'listDiagnoses');
259
+ });
260
+
261
+ test('GET /api/getReport/260101T0000/mix returns JSON with the report', async () => {
262
+ const res = await request('GET', '/api/getReport/260101T0000/mix');
263
+ assert.equal(res.statusCode, 200);
264
+ const body = jsonBody(res);
265
+ assert.equal(body['tool name'], 'getReport');
266
+ });
267
+
268
+ test('GET /api/invalidService returns an error', async () => {
269
+ const res = await request('GET', '/api/invalidService');
270
+ assert.equal(res.statusCode, 400);
271
+ const body = jsonBody(res);
272
+ assert.ok(body.error);
273
+ });
274
+
275
+ test('GET /fullReport.json/260101T0000/mix downloads the report as JSON', async () => {
276
+ const res = await request('GET', '/fullReport.json/260101T0000/mix');
277
+ assert.equal(res.statusCode, 200);
278
+ assert.ok(res.headers['content-type'].includes('application/json'));
279
+ assert.ok(res.headers['content-disposition'].includes('attachment'));
280
+ const body = jsonBody(res);
281
+ assert.ok(body.target);
282
+ });
283
+
284
+ test('GET /fullReport.json/invalid/invalid returns an error page', async () => {
285
+ const res = await request('GET', '/fullReport.json/invalid/invalid');
286
+ assert.equal(res.statusCode, 400);
287
+ assert.ok(res.headers['content-type'].includes('text/html'));
288
+ assert.ok(res.body.includes('Invalid report request'));
289
+ });
290
+
291
+ test('GET /fullReport.json/990101T0000/xxx returns an abuse error for a nonexistent report', async () => {
292
+ const res = await request('GET', '/fullReport.json/990101T0000/xxx');
293
+ assert.equal(res.statusCode, 400);
294
+ assert.ok(res.body.includes('Invalid request'));
295
+ });
296
+
297
+ test('GET /tutorial/images/newsletter-form.png serves the image', async () => {
298
+ const res = await request('GET', '/tutorial/images/newsletter-form.png');
299
+ assert.equal(res.statusCode, 200);
300
+ assert.ok(res.headers['content-type'].includes('image/png'));
301
+ });
302
+
303
+ test('GET /tutorial/images with an unknown extension serves octet-stream', async () => {
304
+ // Create a temporary image file with an unknown extension.
305
+ const imgPath = path.join(__dirname, 'web', 'tutorial', 'images', 'test.bmp');
306
+ await fs.writeFile(imgPath, 'fake bitmap data');
307
+ try {
308
+ const res = await request('GET', '/tutorial/images/test.bmp');
309
+ assert.equal(res.statusCode, 200);
310
+ assert.ok(res.headers['content-type'].includes('application/octet-stream'));
311
+ }
312
+ finally {
313
+ await fs.unlink(imgPath).catch(() => {});
314
+ }
315
+ });
316
+
317
+ test('GET /tutorial/images/nonexistent.png returns an error page', async () => {
318
+ const res = await request('GET', '/tutorial/images/nonexistent.png');
319
+ assert.equal(res.statusCode, 400);
320
+ assert.ok(res.body.includes('Image not found'));
321
+ });
322
+
323
+ test('GET /nonexistent.html returns an abuse error', async () => {
324
+ const res = await request('GET', '/nonexistent.html');
325
+ assert.equal(res.statusCode, 400);
326
+ assert.ok(res.body.includes('Invalid request'));
327
+ });
328
+
329
+ test('GET /forbidden-path returns an invalid GET request error', async () => {
330
+ const res = await request('GET', '/forbidden-path');
331
+ assert.equal(res.statusCode, 400);
332
+ assert.ok(res.body.includes('Invalid GET request'));
333
+ });
334
+
335
+ // TESTS: POST routes
336
+
337
+ test('POST /requestTest.html with valid data returns an HTML page', async () => {
338
+ const res = await formRequest('POST', '/requestTest.html', {
339
+ what: `Unique Test Page ${uniqueStamp}`,
340
+ url: `https://example.com/unique-${uniqueStamp}`,
341
+ why: 'Because accessibility matters'
342
+ });
343
+ assert.equal(res.statusCode, 200);
344
+ assert.ok(res.headers['content-type'].includes('text/html'));
345
+ });
346
+
347
+ test('POST /requestTest.html with an already-tested URL returns an error', async () => {
348
+ const res = await formRequest('POST', '/requestTest.html', {
349
+ what: 'Mixed Outcomes Page',
350
+ url: 'https://example.com/mixed',
351
+ why: 'Because accessibility matters'
352
+ });
353
+ assert.equal(res.statusCode, 400);
354
+ assert.ok(res.body.includes('already been tested'));
355
+ });
356
+
357
+ test('POST /requestTest.html with invalid data returns an error', async () => {
358
+ const res = await formRequest('POST', '/requestTest.html', {
359
+ what: '',
360
+ url: 'not-a-url',
361
+ why: ''
362
+ });
363
+ assert.equal(res.statusCode, 400);
364
+ assert.ok(res.body.includes('Invalid test recommendation'));
365
+ });
366
+
367
+ test('POST /requestRetest.html/260202T0000/new with valid data returns HTML', async () => {
368
+ // Reset recs.json to avoid duplicate-recommendation errors from prior tests.
369
+ await fs.writeFile(recsPath, '{}\n');
370
+ const res = await formRequest('POST', '/requestRetest.html/260202T0000/new', {
371
+ why: 'Because the report is obsolete and needs refreshing'
372
+ });
373
+ assert.equal(res.statusCode, 200);
374
+ assert.ok(res.headers['content-type'].includes('text/html'));
375
+ });
376
+
377
+ test('POST /requestRetest.html/invalid/invalid with invalid data returns an error', async () => {
378
+ const res = await formRequest('POST', '/requestRetest.html/invalid/invalid', {
379
+ why: 'Because'
380
+ });
381
+ assert.equal(res.statusCode, 400);
382
+ assert.ok(res.body.includes('Invalid retest recommendation'));
383
+ });
384
+
385
+ test('POST /api/requestTest with valid JSON returns a JSON response', async () => {
386
+ const res = await request('POST', '/api/requestTest', {
387
+ description: `API Test Page ${uniqueStamp}`,
388
+ URL: `https://example.com/api-test-${uniqueStamp}`,
389
+ reason: 'Because accessibility matters'
390
+ });
391
+ assert.equal(res.statusCode, 200);
392
+ const body = jsonBody(res);
393
+ assert.ok(body['tool name'] || body['response content']);
394
+ });
395
+
396
+ test('POST /api/requestRetest/260202T0000/new with valid JSON returns a JSON response', async () => {
397
+ const res = await request('POST', '/api/requestRetest/260202T0000/new', {
398
+ reason: 'Because the report is obsolete and needs refreshing'
399
+ });
400
+ assert.equal(res.statusCode, 200);
401
+ const body = jsonBody(res);
402
+ assert.ok(body['tool name'] || body['response content']);
403
+ });
404
+
405
+ test('POST /api/requestFeature with valid JSON returns a JSON response', async () => {
406
+ const res = await request('POST', '/api/requestFeature', {
407
+ feature: 'Add a dark mode toggle'
408
+ });
409
+ assert.equal(res.statusCode, 200);
410
+ const body = jsonBody(res);
411
+ assert.ok(body['tool name'] || body['response content']);
412
+ });
413
+
414
+ test('POST /api/invalidService returns an error', async () => {
415
+ const res = await request('POST', '/api/invalidService', {data: 'test'});
416
+ assert.equal(res.statusCode, 400);
417
+ const body = jsonBody(res);
418
+ assert.ok(body.error);
419
+ });
420
+
421
+ test('POST /forbidden-path returns an invalid POST request error', async () => {
422
+ const res = await request('POST', '/forbidden-path', {data: 'test'});
423
+ assert.equal(res.statusCode, 400);
424
+ assert.ok(res.body.includes('Invalid POST request'));
425
+ });
426
+
427
+ // TESTS: worker routes
428
+
429
+ test('POST /worker/job without authentication returns 401', async () => {
430
+ const res = await request('POST', '/worker/job', {});
431
+ assert.equal(res.statusCode, 401);
432
+ const body = jsonBody(res);
433
+ assert.ok(body.error.message.includes('Unauthorized'));
434
+ });
435
+
436
+ test('POST /worker/job with valid authentication returns a job or no-job response', async () => {
437
+ // Clean up any claimed jobs left by prior tests.
438
+ const claimedDir = path.join(fixtureDBDir, 'jobs', 'claimed');
439
+ const claimedFiles = await fs.readdir(claimedDir).catch(() => []);
440
+ for (const file of claimedFiles) {
441
+ await fs.unlink(path.join(claimedDir, file)).catch(() => {});
442
+ }
443
+ const auth = Buffer.from('worker1:secret1').toString('base64');
444
+ const res = await request('POST', '/worker/job', {}, {
445
+ authorization: `Basic ${auth}`
446
+ });
447
+ assert.equal(res.statusCode, 200);
448
+ const body = jsonBody(res);
449
+ assert.ok(typeof body === 'object');
450
+ });
451
+
452
+ test('POST /worker/report with invalid authentication returns 401', async () => {
453
+ const res = await request('POST', '/worker/report', {report: {}}, {
454
+ authorization: 'Basic invalid'
455
+ });
456
+ assert.equal(res.statusCode, 401);
457
+ });
458
+
459
+ test('POST /worker/report with valid authentication but invalid report returns an error', async () => {
460
+ const auth = Buffer.from('worker1:secret1').toString('base64');
461
+ const res = await request('POST', '/worker/report', {report: {}}, {
462
+ authorization: `Basic ${auth}`
463
+ });
464
+ assert.equal(res.statusCode, 400);
465
+ const body = jsonBody(res);
466
+ assert.ok(body.error);
467
+ });
468
+
469
+ test('POST /worker/report with valid authentication but no report field returns an error', async () => {
470
+ const auth = Buffer.from('worker1:secret1').toString('base64');
471
+ const res = await request('POST', '/worker/report', {}, {
472
+ authorization: `Basic ${auth}`
473
+ });
474
+ assert.equal(res.statusCode, 400);
475
+ const body = jsonBody(res);
476
+ assert.ok(body.error);
477
+ });
478
+
479
+ // TESTS: error handling
480
+
481
+ test('PUT / returns an invalid method error', async () => {
482
+ const res = await request('PUT', '/');
483
+ assert.equal(res.statusCode, 400);
484
+ assert.ok(res.body.includes('Invalid request method'));
485
+ });
486
+
487
+ // TESTS: serveError behavior
488
+
489
+ test('serveError sends JSON for agent requests (isHumanUser = false)', async () => {
490
+ const res = await request('GET', '/api/invalidService');
491
+ assert.equal(res.statusCode, 400);
492
+ assert.ok(res.headers['content-type'].includes('application/json'));
493
+ const body = jsonBody(res);
494
+ assert.ok(body.error);
495
+ });
496
+
497
+ test('serveError sends HTML for human requests (isHumanUser = true)', async () => {
498
+ const res = await request('GET', '/forbidden-path');
499
+ assert.equal(res.statusCode, 400);
500
+ assert.ok(res.headers['content-type'].includes('text/html'));
501
+ });
502
+
503
+ // TESTS: recAction.html
504
+
505
+ test('POST /recAction.html with invalid auth code returns an error', async () => {
506
+ const res = await formRequest('POST', '/recAction.html', {
507
+ target: 'https://example.com\tTest Page',
508
+ authCode: 'wrong-code'
509
+ });
510
+ assert.equal(res.statusCode, 400);
511
+ assert.ok(res.body.includes('Invalid test order'));
512
+ });
513
+
514
+ test('POST /recAction.html with valid auth code and rejection (no what) returns HTML', async () => {
515
+ await formRequest('POST', '/requestTest.html', {
516
+ what: `Reject Test Page ${uniqueStamp}`,
517
+ url: `https://example.com/reject-${uniqueStamp}`,
518
+ why: 'Because accessibility matters'
519
+ });
520
+ const res = await formRequest('POST', '/recAction.html', {
521
+ target: `https://example.com/reject-${uniqueStamp}`,
522
+ authCode: 'test-auth-code'
523
+ });
524
+ assert.equal(res.statusCode, 200);
525
+ assert.ok(res.headers['content-type'].includes('text/html'));
526
+ });
527
+
528
+ // TESTS: tutorialComment
529
+
530
+ test('POST /tutorialComment.html with content returns JSON', async () => {
531
+ const res = await request('POST', '/tutorialComment.html', {
532
+ content: 'This is a test comment'
533
+ });
534
+ assert.ok(res.headers['content-type'].includes('application/json'));
535
+ });
536
+
537
+ // TESTS: additional branch coverage
538
+
539
+ test('GET /fullReport.json/260101T0007/hid returns an abuse error for a hidden report', async () => {
540
+ const res = await request('GET', '/fullReport.json/260101T0007/hid');
541
+ assert.equal(res.statusCode, 400);
542
+ assert.ok(res.body.includes('Invalid request'));
543
+ });
544
+
545
+ test('POST /recAction.html with valid auth code and approval returns HTML', async () => {
546
+ await fs.writeFile(recsPath, '{}\n');
547
+ await formRequest('POST', '/requestTest.html', {
548
+ what: `Approval Test Page ${uniqueStamp}`,
549
+ url: `https://example.com/approval-${uniqueStamp}`,
550
+ why: 'Because accessibility matters'
551
+ });
552
+ const res = await formRequest('POST', '/recAction.html', {
553
+ target: `https://example.com/approval-${uniqueStamp}\tApproval Test Page ${uniqueStamp}`,
554
+ authCode: 'test-auth-code'
555
+ });
556
+ assert.equal(res.statusCode, 200);
557
+ assert.ok(res.headers['content-type'].includes('text/html'));
558
+ });
559
+
560
+ test('POST /reannotate.html with invalid auth code returns an error page', async () => {
561
+ const res = await formRequest('POST', '/reannotate.html', {
562
+ authCode: 'wrong-code'
563
+ });
564
+ assert.ok(res.headers['content-type'].includes('text/html'));
565
+ });
566
+
567
+ test('POST /reannotate.html with valid auth code serves the answer page', async () => {
568
+ // Back up all fixture reports, because annotateReport modifies them in place.
569
+ const reportsDir = path.join(fixtureDBDir, 'reports');
570
+ const reportFiles = await fs.readdir(reportsDir);
571
+ const backups = {};
572
+ for (const file of reportFiles) {
573
+ backups[file] = await fs.readFile(path.join(reportsDir, file), 'utf8');
574
+ }
575
+ try {
576
+ const res = await formRequest('POST', '/reannotate.html', {
577
+ authCode: 'test-auth-code'
578
+ });
579
+ assert.equal(res.statusCode, 200);
580
+ assert.ok(res.headers['content-type'].includes('text/html'));
581
+ assert.ok(res.body.includes('Reannotation order'));
582
+ }
583
+ finally {
584
+ // Restore all fixture reports.
585
+ for (const file of reportFiles) {
586
+ await fs.writeFile(path.join(reportsDir, file), backups[file]);
587
+ }
588
+ }
589
+ });
590
+
591
+ test('POST /renewWCAG.html with invalid auth code returns an error page', async () => {
592
+ const res = await formRequest('POST', '/renewWCAG.html', {
593
+ authCode: 'wrong-code'
594
+ });
595
+ assert.ok(res.headers['content-type'].includes('text/html'));
596
+ });
597
+
598
+ test('POST /renewWCAG.html with valid auth code serves the answer page', async () => {
599
+ // Mock fetch to avoid a network dependency.
600
+ const originalFetch = global.fetch;
601
+ const wcagMapPath = path.join(__dirname, 'wcagMap.json');
602
+ const wcagMapBackup = await fs.readFile(wcagMapPath, 'utf8');
603
+ // @ts-expect-error: Replacing the real function with a mock for testing.
604
+ global.fetch = async () => ({
605
+ status: 200,
606
+ text: async () => '<a href="understanding/contrast-minimum"><span class="secno">1.4.3 </span>'
607
+ });
608
+ try {
609
+ const res = await formRequest('POST', '/renewWCAG.html', {
610
+ authCode: 'test-auth-code'
611
+ });
612
+ assert.equal(res.statusCode, 200);
613
+ assert.ok(res.headers['content-type'].includes('text/html'));
614
+ assert.ok(res.body.includes('WCAG map renewed'));
615
+ }
616
+ finally {
617
+ global.fetch = originalFetch;
618
+ await fs.writeFile(wcagMapPath, wcagMapBackup);
619
+ }
620
+ });
621
+
622
+ test('POST /tutorialComment.html with empty content returns a JSON error', async () => {
623
+ const res = await request('POST', '/tutorialComment.html', {
624
+ content: ''
625
+ });
626
+ assert.ok(res.headers['content-type'].includes('application/json'));
627
+ });
628
+
629
+ test('POST /requestTest.html with non-https URL returns an error', async () => {
630
+ const res = await formRequest('POST', '/requestTest.html', {
631
+ what: 'Test Page',
632
+ url: 'http://example.com',
633
+ why: 'Because accessibility matters'
634
+ });
635
+ assert.equal(res.statusCode, 400);
636
+ assert.ok(res.body.includes('Invalid test recommendation'));
637
+ });
638
+
639
+ test('POST /requestRetest.html/260202T0000/new with missing why returns an error', async () => {
640
+ const res = await formRequest('POST', '/requestRetest.html/260202T0000/new', {
641
+ why: ''
642
+ });
643
+ assert.equal(res.statusCode, 400);
644
+ assert.ok(res.body.includes('Invalid retest recommendation'));
645
+ });
646
+
647
+ test('POST /worker/report with valid authentication and wrong worker returns an error', async () => {
648
+ const auth = Buffer.from('worker1:secret1').toString('base64');
649
+ const report = {
650
+ id: '260101T0000-mix',
651
+ target: {what: 'Test', url: 'https://example.com/test'},
652
+ acts: []
653
+ };
654
+ const res = await request('POST', '/worker/report', {report}, {
655
+ authorization: `Basic ${auth}`
656
+ });
657
+ assert.equal(res.statusCode, 400);
658
+ const body = jsonBody(res);
659
+ assert.ok(body.error);
660
+ });
661
+
662
+ test('POST /worker/job with wrong secret returns 401', async () => {
663
+ const auth = Buffer.from('worker1:wrongsecret').toString('base64');
664
+ const res = await request('POST', '/worker/job', {}, {
665
+ authorization: `Basic ${auth}`
666
+ });
667
+ assert.equal(res.statusCode, 401);
668
+ });
669
+
670
+ test('POST /worker/job with malformed authorization header returns 401', async () => {
671
+ const res = await request('POST', '/worker/job', {}, {
672
+ authorization: 'NotBasic abc'
673
+ });
674
+ assert.equal(res.statusCode, 401);
675
+ });
676
+
677
+ test('POST /worker/job with malformed base64 returns 401', async () => {
678
+ const res = await request('POST', '/worker/job', {}, {
679
+ authorization: 'Basic !!!'
680
+ });
681
+ assert.equal(res.statusCode, 401);
682
+ });
683
+
684
+ test('POST /worker/job with no colon in decoded credentials returns 401', async () => {
685
+ const auth = Buffer.from('nocolonhere').toString('base64');
686
+ const res = await request('POST', '/worker/job', {}, {
687
+ authorization: `Basic ${auth}`
688
+ });
689
+ assert.equal(res.statusCode, 401);
690
+ });
691
+
692
+ // TESTS: processJobRequest branches
693
+
694
+ test('POST /worker/job with a claimed job assigned to the worker returns an error and reclassifies the job', async () => {
695
+ // Create a claimed job assigned to Worker One.
696
+ const claimedDir = path.join(fixtureDBDir, 'jobs', 'claimed');
697
+ const failedDir = path.join(fixtureDBDir, 'jobs', 'failed');
698
+ const jobFile = '260101T0000-mix.json';
699
+ const claimedJobPath = path.join(claimedDir, jobFile);
700
+ const failedJobPath = path.join(failedDir, jobFile);
701
+ // Clean up any existing files.
702
+ await fs.unlink(failedJobPath).catch(() => {});
703
+ await fs.writeFile(claimedJobPath, JSON.stringify({
704
+ id: '260101T0000-mix',
705
+ target: {what: 'Test', url: 'https://example.com/test'},
706
+ sources: {worker: 'Worker One'}
707
+ }));
708
+ const auth = Buffer.from('worker1:secret1').toString('base64');
709
+ const res = await request('POST', '/worker/job', {}, {
710
+ authorization: `Basic ${auth}`
711
+ });
712
+ // The worker should get an error about the incomplete job.
713
+ const body = jsonBody(res);
714
+ assert.ok(body.error.message.includes('has not completed job'));
715
+ // The job should have been moved to failed.
716
+ const failedExists = await fs.access(failedJobPath).then(() => true).catch(() => false);
717
+ assert.ok(failedExists, 'Job should be moved to failed directory');
718
+ // Clean up.
719
+ await fs.unlink(failedJobPath).catch(() => {});
720
+ });
721
+
722
+ test('POST /worker/job with a queued job assigns it to the worker', {timeout: 500}, async () => {
723
+ // Clean up claimed and queue directories.
724
+ const claimedDir = path.join(fixtureDBDir, 'jobs', 'claimed');
725
+ const queueDir = path.join(fixtureDBDir, 'jobs', 'queue');
726
+ for (const dir of [claimedDir, queueDir]) {
727
+ await fs.mkdir(dir, {recursive: true});
728
+ const files = await fs.readdir(dir).catch(() => []);
729
+ for (const file of files) {
730
+ await fs.unlink(path.join(dir, file)).catch(() => {});
731
+ }
732
+ }
733
+ // Create a queued job.
734
+ const jobFile = '260101T0000-mix.json';
735
+ await fs.writeFile(path.join(queueDir, jobFile), JSON.stringify({
736
+ id: '260101T0000-mix',
737
+ target: {what: 'Test Page', url: 'https://example.com/test'},
738
+ sources: {}
739
+ }));
740
+ const auth = Buffer.from('worker1:secret1').toString('base64');
741
+ const res = await request('POST', '/worker/job', {}, {
742
+ authorization: `Basic ${auth}`
743
+ });
744
+ assert.equal(res.statusCode, 200);
745
+ const body = jsonBody(res);
746
+ assert.equal(body.id, '260101T0000-mix');
747
+ assert.equal(body.sources.worker, 'Worker One');
748
+ // Wait for the async unlink to complete.
749
+ await new Promise(resolve => setTimeout(resolve, 100));
750
+ // The job should have been moved from queue to claimed.
751
+ const queueExists = await fs.access(path.join(queueDir, jobFile)).then(() => true).catch(() => false);
752
+ assert.equal(queueExists, false, 'Job should be removed from queue');
753
+ const claimedExists = await fs.access(path.join(claimedDir, jobFile)).then(() => true).catch(() => false);
754
+ assert.ok(claimedExists, 'Job should be moved to claimed directory');
755
+ // Clean up.
756
+ await fs.unlink(path.join(claimedDir, jobFile)).catch(() => {});
757
+ });
758
+
759
+ // TESTS: worker/report valid submission
760
+
761
+ test('POST /worker/report with valid authentication and valid claimed job processes the report', async () => {
762
+ // Use a unique job ID that does not conflict with existing fixtures.
763
+ const jobID = '990101T0000-tst';
764
+ const reportPath = path.join(fixtureDBDir, 'reports', `${jobID}.json`);
765
+ // Clean up any leftover report file.
766
+ await fs.unlink(reportPath).catch(() => {});
767
+ // Create a claimed job assigned to Worker One.
768
+ const claimedDir = path.join(fixtureDBDir, 'jobs', 'claimed');
769
+ const jobPath = path.join(claimedDir, `${jobID}.json`);
770
+ await fs.writeFile(jobPath, JSON.stringify({
771
+ id: jobID,
772
+ target: {what: 'Test', url: 'https://example.com/test'},
773
+ sources: {worker: 'Worker One'}
774
+ }));
775
+ const auth = Buffer.from('worker1:secret1').toString('base64');
776
+ const report = {
777
+ id: jobID,
778
+ target: {what: 'Test', url: 'https://example.com/test'},
779
+ acts: [{type: 'test', which: 'axe', result: {standardResult: {instances: []}}}],
780
+ jobData: {endTime: '26-01-01T00:00'},
781
+ catalog: {}
782
+ };
783
+ const res = await request('POST', '/worker/report', {report}, {
784
+ authorization: `Basic ${auth}`
785
+ });
786
+ // Clean up the report file and any remaining claimed job.
787
+ await fs.unlink(reportPath).catch(() => {});
788
+ await fs.unlink(jobPath).catch(() => {});
789
+ assert.equal(res.statusCode, 200);
790
+ const body = jsonBody(res);
791
+ assert.equal(body.status, 'ok');
792
+ });
793
+
794
+ // TESTS: remaining error branches and web pages
795
+
796
+ test('GET /style.css with a read error returns an error page', async () => {
797
+ // Temporarily rename style.css to trigger a read error.
798
+ const stylePath = path.join(__dirname, 'style.css');
799
+ const tempPath = path.join(__dirname, 'style.css.bak');
800
+ await fs.rename(stylePath, tempPath);
801
+ try {
802
+ const res = await request('GET', '/style.css');
803
+ assert.equal(res.statusCode, 400);
804
+ assert.ok(res.headers['content-type'].includes('text/html'));
805
+ }
806
+ finally {
807
+ await fs.rename(tempPath, stylePath);
808
+ }
809
+ });
810
+
811
+ test('GET /requestTest.html returns an error page when called without arguments', async () => {
812
+ const res = await request('GET', '/requestTest.html');
813
+ assert.equal(res.statusCode, 400);
814
+ assert.ok(res.headers['content-type'].includes('text/html'));
815
+ });
816
+
817
+ test('GET /requestRetest.html/260202T0000/new returns an error page when called as GET', async () => {
818
+ const res = await request('GET', '/requestRetest.html/260202T0000/new');
819
+ assert.equal(res.statusCode, 400);
820
+ assert.ok(res.headers['content-type'].includes('text/html'));
821
+ });
822
+
823
+ // TESTS: GET HTML page routing
824
+ // These verify that the HTTP routing layer connects each URL to the correct
825
+ // handler and returns HTML with a 200 status. The handler logic itself is
826
+ // tested by the unit tests in web/*/*.test.js.
827
+
828
+ const htmlPagePaths = [
829
+ '/listReports.html',
830
+ '/listViolators.html/linkNoText/260101T0000/mix',
831
+ '/listDiagnoses.html/linkNoText/260101T0000/mix/0',
832
+ '/enqueueForm.html',
833
+ '/manage.html',
834
+ '/tutorial.html',
835
+ '/listTopIssues.html',
836
+ '/reannotateForm.html',
837
+ '/renewWCAGForm.html',
838
+ '/hideReportForm.html',
839
+ '/unhideReportForm.html',
840
+ '/expungeReportsForm.html',
841
+ '/pruneReportsForm.html',
842
+ '/rewindReportsForm.html',
843
+ '/ai0BalanceForm.html'
844
+ ];
845
+
846
+ for (const pagePath of htmlPagePaths) {
847
+ test(`GET ${pagePath} serves a generated HTML page`, async () => {
848
+ const res = await request('GET', pagePath);
849
+ assert.equal(res.statusCode, 200);
850
+ assert.ok(res.headers['content-type'].includes('text/html'));
851
+ });
852
+ }
853
+
854
+ test('GET /listRules.html returns an error page when called without arguments', async () => {
855
+ const res = await request('GET', '/listRules.html');
856
+ assert.equal(res.statusCode, 400);
857
+ assert.ok(res.headers['content-type'].includes('text/html'));
858
+ });
859
+
860
+ // TESTS: catch-all and MCP branches
861
+
862
+ test('GET /test.html.bak matches isPathAllowed but falls through to catch-all', async () => {
863
+ const res = await request('GET', '/test.html.bak');
864
+ assert.equal(res.statusCode, 400);
865
+ assert.ok(res.body.includes('Invalid GET request'));
866
+ });
867
+
868
+ test('GET /mcp returns a response from the MCP handler', async () => {
869
+ const res = await request('GET', '/mcp');
870
+ // The MCP handler responds to GET requests, typically with an error
871
+ // about acceptable content types or a similar MCP protocol message.
872
+ assert.ok(res.statusCode >= 400);
873
+ });
874
+
875
+ test('POST /mcp returns a response from the MCP handler', async () => {
876
+ const res = await request('POST', '/mcp', {
877
+ jsonrpc: '2.0',
878
+ method: 'initialize',
879
+ id: 1,
880
+ params: {
881
+ protocolVersion: '2024-11-05',
882
+ capabilities: {},
883
+ clientInfo: {name: 'test-client', version: '1.0'}
884
+ }
885
+ }, {
886
+ accept: 'application/json, text/event-stream'
887
+ });
888
+ // The MCP handler should process the initialize request.
889
+ assert.ok(res.statusCode === 200 || res.statusCode >= 400);
890
+ });
891
+
892
+ // TESTS: answer error branches
893
+
894
+ test('POST /requestTest.html with valid format but duplicate URL returns an answer error', async () => {
895
+ await fs.writeFile(recsPath, '{}\n');
896
+ // First request to create the recommendation.
897
+ await formRequest('POST', '/requestTest.html', {
898
+ what: `Dup Test Page ${uniqueStamp}`,
899
+ url: `https://example.com/dup-${uniqueStamp}`,
900
+ why: 'Because accessibility matters'
901
+ });
902
+ // Second request with the same URL should get a duplicate error.
903
+ const res = await formRequest('POST', '/requestTest.html', {
904
+ what: `Dup Test Page ${uniqueStamp}`,
905
+ url: `https://example.com/dup-${uniqueStamp}`,
906
+ why: 'Because accessibility matters again'
907
+ });
908
+ assert.equal(res.statusCode, 400);
909
+ assert.ok(res.body.includes('Duplicate recommendation'));
910
+ });
911
+
912
+ test('POST /requestRetest.html with valid format but duplicate retest returns an answer error', async () => {
913
+ await fs.writeFile(recsPath, '{}\n');
914
+ // First retest to create the recommendation.
915
+ await formRequest('POST', '/requestRetest.html/260202T0000/new', {
916
+ why: 'Because the report is obsolete and needs refreshing'
917
+ });
918
+ // Second retest with the same report should get a duplicate error.
919
+ const res = await formRequest('POST', '/requestRetest.html/260202T0000/new', {
920
+ why: 'Because the report is obsolete and needs refreshing again'
921
+ });
922
+ assert.equal(res.statusCode, 400);
923
+ assert.ok(res.body.includes('Duplicate recommendation'));
924
+ });
925
+
926
+ test('POST /recAction.html with valid auth code and approval of a duplicate returns an error', async () => {
927
+ await fs.writeFile(recsPath, '{}\n');
928
+ // Create a recommendation.
929
+ await formRequest('POST', '/requestTest.html', {
930
+ what: `Action Dup Page ${uniqueStamp}`,
931
+ url: `https://example.com/action-dup-${uniqueStamp}`,
932
+ why: 'Because accessibility matters'
933
+ });
934
+ // Approve it once.
935
+ await formRequest('POST', '/recAction.html', {
936
+ target: `https://example.com/action-dup-${uniqueStamp}\tAction Dup Page ${uniqueStamp}`,
937
+ authCode: 'test-auth-code',
938
+ what: 'yes'
939
+ });
940
+ // Approve it again (the recs may have been cleared, so this may succeed or fail).
941
+ // This test covers the recAction answer error branch.
942
+ const res = await formRequest('POST', '/recAction.html', {
943
+ target: `https://example.com/action-dup-${uniqueStamp}\tAction Dup Page ${uniqueStamp}`,
944
+ authCode: 'test-auth-code',
945
+ what: 'yes'
946
+ });
947
+ // Either it succeeds (200) or returns an error (400).
948
+ assert.ok(res.statusCode === 200 || res.statusCode === 400);
949
+ });
950
+
951
+ test('POST /recAction.html with valid auth code and approval of an invalid URL returns an error', async () => {
952
+ // A URL that starts with https:// but is not a valid URL causes
953
+ // enqueue.answer to return status error.
954
+ const res = await formRequest('POST', '/recAction.html', {
955
+ target: 'https://\tTest Page',
956
+ authCode: 'test-auth-code',
957
+ what: 'yes'
958
+ });
959
+ assert.equal(res.statusCode, 400);
960
+ assert.ok(res.body.includes('Invalid authorization code'));
961
+ });
962
+
963
+ // UNIT TESTS FOR serveError
964
+
965
+ const mockRes = () => {
966
+ const state = {statusCode: null, headers: {}, body: null, writableEnded: false};
967
+ return {
968
+ get statusCode() {return state.statusCode;},
969
+ set statusCode(value) {state.statusCode = value;},
970
+ setHeader(name, value) {state.headers[name] = value;},
971
+ end(data) {state.body = data; state.writableEnded = true;},
972
+ _state: state
973
+ };
974
+ };
975
+
976
+ test('serveError does not write to a response that has already ended', async () => {
977
+ let wrote = false;
978
+ const mockResponse = {
979
+ writableEnded: true,
980
+ set statusCode(value) {
981
+ wrote = true;
982
+ },
983
+ setHeader() {
984
+ wrote = true;
985
+ },
986
+ end() {
987
+ wrote = true;
988
+ }
989
+ };
990
+ await serveError({message: 'test error'}, mockResponse);
991
+ assert.equal(wrote, false);
992
+ });
993
+
994
+ test('serveError logs ERROR when the error object has no entries', async () => {
995
+ const res = mockRes();
996
+ await serveError({}, res, true);
997
+ // The response should still be sent with the fallback message.
998
+ assert.ok(res._state.body);
999
+ });
1000
+
1001
+ test('serveError uses ERROR fallback when error has no message property', async () => {
1002
+ const res = mockRes();
1003
+ await serveError({code: 500}, res, true);
1004
+ assert.ok(res._state.body.includes('ERROR'));
1005
+ });
1006
+
1007
+ // UNIT TESTS FOR isPathAllowed
1008
+
1009
+ test('isPathAllowed returns false for a method with no routes', () => {
1010
+ assert.equal(isPathAllowed('DELETE', '/'), false);
1011
+ });
1012
+
1013
+ // UNIT TESTS FOR getAbuseError
1014
+
1015
+ test('getAbuseError uses unknown IP when no forwarding header or remote address', () => {
1016
+ const mockRequest = {
1017
+ method: 'GET',
1018
+ url: '/suspicious',
1019
+ headers: {},
1020
+ socket: {remoteAddress: undefined}
1021
+ };
1022
+ const result = getAbuseError(mockRequest, 'test reason');
1023
+ assert.equal(result['IP address'], 'unknown');
1024
+ assert.equal(result.reason, 'test reason');
1025
+ });
1026
+
1027
+ // UNIT TESTS FOR startServer
1028
+
1029
+ test('startServer starts an HTTP server when protocol is http', async () => {
1030
+ // Delete PROTOCOL to exercise the 'http' fallback in startServer.
1031
+ const savedProtocol = process.env.PROTOCOL;
1032
+ delete process.env.PROTOCOL;
1033
+ const server = await startServer();
1034
+ process.env.PROTOCOL = savedProtocol;
1035
+ try {
1036
+ assert.ok(server);
1037
+ assert.equal(typeof server.listen, 'function');
1038
+ // Verify the server is listening by making a request.
1039
+ const address = server.address();
1040
+ assert.ok(typeof address === 'object' && address.port > 0);
1041
+ }
1042
+ finally {
1043
+ server.closeAllConnections?.();
1044
+ await new Promise(resolve => {
1045
+ const timer = setTimeout(() => {
1046
+ server.closeAllConnections?.();
1047
+ resolve();
1048
+ }, 1000);
1049
+ server.close(() => {
1050
+ clearTimeout(timer);
1051
+ resolve();
1052
+ });
1053
+ });
1054
+ }
1055
+ });
1056
+
1057
+ test('startServer starts an HTTPS server when protocol is https', {timeout: 500}, async () => {
1058
+ // Generate a self-signed certificate for the test using openssl.
1059
+ const {execSync} = require('node:child_process');
1060
+ const os = require('node:os');
1061
+ const fsSync = require('node:fs');
1062
+ const tmpDir = fsSync.mkdtempSync(path.join(os.tmpdir(), 'kilotest-https-'));
1063
+ const keyPath = path.join(tmpDir, 'key.pem');
1064
+ const certPath = path.join(tmpDir, 'cert.pem');
1065
+ // Generate a self-signed certificate valid for 1 day.
1066
+ execSync(
1067
+ `openssl req -x509 -newkey rsa:2048 -keyout "${keyPath}" -out "${certPath}" -days 1 -nodes -subj "/CN=localhost"`,
1068
+ {stdio: 'pipe'}
1069
+ );
1070
+ const savedProtocol = process.env.PROTOCOL;
1071
+ const savedKey = process.env.KEY;
1072
+ const savedCert = process.env.CERT;
1073
+ process.env.PROTOCOL = 'https';
1074
+ process.env.KEY = keyPath;
1075
+ process.env.CERT = certPath;
1076
+ try {
1077
+ const server = await startServer();
1078
+ assert.ok(server);
1079
+ assert.equal(typeof server.listen, 'function');
1080
+ const address = server.address();
1081
+ assert.ok(typeof address === 'object' && address.port > 0);
1082
+ server.closeAllConnections?.();
1083
+ await new Promise(resolve => {
1084
+ const timer = setTimeout(() => {
1085
+ server.closeAllConnections?.();
1086
+ resolve();
1087
+ }, 1000);
1088
+ server.close(() => {
1089
+ clearTimeout(timer);
1090
+ resolve();
1091
+ });
1092
+ });
1093
+ }
1094
+ finally {
1095
+ if (savedProtocol !== undefined) {
1096
+ process.env.PROTOCOL = savedProtocol;
1097
+ }
1098
+ else {
1099
+ delete process.env.PROTOCOL;
1100
+ }
1101
+ if (savedKey !== undefined) {
1102
+ process.env.KEY = savedKey;
1103
+ }
1104
+ else {
1105
+ delete process.env.KEY;
1106
+ }
1107
+ if (savedCert !== undefined) {
1108
+ process.env.CERT = savedCert;
1109
+ }
1110
+ else {
1111
+ delete process.env.CERT;
1112
+ }
1113
+ fsSync.rmSync(tmpDir, {recursive: true, force: true});
1114
+ }
1115
+ });
1116
+
1117
+ test('runIfMain calls startServer when mainModule matches currentModule', async () => {
1118
+ let called = false;
1119
+ const originalStartServer = indexModule.startServer;
1120
+ indexModule.startServer = async () => {
1121
+ called = true;
1122
+ return null;
1123
+ };
1124
+ try {
1125
+ // Pass the same object for both arguments so the guard is true.
1126
+ const fakeModule = {};
1127
+ runIfMain(fakeModule, fakeModule);
1128
+ // Wait for the microtask queue to flush the promise.
1129
+ await new Promise(resolve => setImmediate(resolve));
1130
+ assert.equal(called, true);
1131
+ }
1132
+ finally {
1133
+ indexModule.startServer = originalStartServer;
1134
+ }
1135
+ });
1136
+
1137
+ test('runIfMain does not call startServer when mainModule differs from currentModule', async () => {
1138
+ let called = false;
1139
+ const originalStartServer = indexModule.startServer;
1140
+ indexModule.startServer = async () => {
1141
+ called = true;
1142
+ return null;
1143
+ };
1144
+ try {
1145
+ runIfMain({}, {});
1146
+ await new Promise(resolve => setImmediate(resolve));
1147
+ assert.equal(called, false);
1148
+ }
1149
+ finally {
1150
+ indexModule.startServer = originalStartServer;
1151
+ }
1152
+ });