@jrpool/kilotest 46.1.0 → 47.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/.claude/settings.local.json +13 -1
  2. package/.devin/config.local.json +8 -1
  3. package/.github/workflows/ci.yml +0 -3
  4. package/.github/workflows/periodic-smoke-tests.yml +30 -0
  5. package/.github/workflows/publish-mcp.yml +4 -4
  6. package/{alerts.test.js → alerts.test.ts} +17 -29
  7. package/{alerts.js → alerts.ts} +10 -13
  8. package/api/{getReport.test.js → getReport.test.ts} +9 -10
  9. package/api/{getReport.js → getReport.ts} +23 -24
  10. package/api/{listDiagnoses.test.js → listDiagnoses.test.ts} +26 -27
  11. package/api/{listDiagnoses.js → listDiagnoses.ts} +46 -70
  12. package/api/{listIssues.test.js → listIssues.test.ts} +21 -14
  13. package/api/{listIssues.js → listIssues.ts} +75 -74
  14. package/api/{listReports.test.js → listReports.test.ts} +17 -16
  15. package/api/listReports.ts +87 -0
  16. package/api/{listViolators.test.js → listViolators.test.ts} +19 -20
  17. package/api/{listViolators.js → listViolators.ts} +45 -70
  18. package/api/requestFeature.test.ts +54 -0
  19. package/api/{requestFeature.js → requestFeature.ts} +18 -15
  20. package/api/{requestRetest.test.js → requestRetest.test.ts} +33 -28
  21. package/api/{requestRetest.js → requestRetest.ts} +32 -25
  22. package/api/{requestTest.test.js → requestTest.test.ts} +35 -30
  23. package/api/{requestTest.js → requestTest.ts} +35 -28
  24. package/api/{routes.test.js → routes.test.ts} +5 -5
  25. package/api/{routes.js → routes.ts} +6 -6
  26. package/api/{schemas.js → schemas.ts} +69 -50
  27. package/api/{util.test.js → util.test.ts} +31 -11
  28. package/api/{util.js → util.ts} +75 -46
  29. package/api/version.ts +8 -0
  30. package/{apiIntegration.test.js → apiIntegration.test.ts} +13 -14
  31. package/balances.ts +87 -0
  32. package/{checkinfo.js → checkinfo.ts} +24 -20
  33. package/docs/AI-TOOLS.md +4 -4
  34. package/docs/BACKLOG.md +356 -0
  35. package/docs/SERVICE.md +144 -17
  36. package/docs/decisions/002-coverage-tool.md +40 -0
  37. package/docs/decisions/003-adr-threshold.md +34 -0
  38. package/docs/decisions/004-docs-directory.md +23 -0
  39. package/docs/decisions/005-observability.md +28 -0
  40. package/docs/decisions/006-git-workflow.md +28 -0
  41. package/docs/decisions/007-internal-testing.md +34 -0
  42. package/docs/decisions/008-dependencies.md +28 -0
  43. package/docs/decisions/009-qai-integration.md +40 -0
  44. package/docs/decisions/QAI-HISTORICAL-CONTEXT.md +42 -0
  45. package/eslint.config.mjs +27 -0
  46. package/index.html +3 -3
  47. package/{index.test.js → index.test.ts} +462 -137
  48. package/{index.js → index.ts} +388 -283
  49. package/{indexBalances.test.js → indexBalances.test.ts} +21 -17
  50. package/{indexServe.test.js → indexServe.test.ts} +9 -9
  51. package/linkValidation.test.ts +190 -0
  52. package/{mcp.test.js → mcp.test.ts} +53 -26
  53. package/{mcp.js → mcp.ts} +29 -20
  54. package/memory/MEMORY.md +7 -0
  55. package/memory/user_maintainer_context.md +24 -0
  56. package/openapi.yaml +115 -68
  57. package/package.json +35 -15
  58. package/pm2.config.cjs +29 -0
  59. package/{routes.test.js → routes.test.ts} +8 -7
  60. package/{routing.test.js → routing.test.ts} +11 -12
  61. package/scripts/{generate-openapi.js → generate-openapi.ts} +13 -13
  62. package/serve.ts +15 -0
  63. package/smokeTest.ts +234 -0
  64. package/test/dbFixture.ts +21 -0
  65. package/test/fixtures/{buildFixtures.js → buildFixtures.ts} +14 -13
  66. package/test/{setup.cjs → setup.ts} +2 -2
  67. package/tsconfig.json +16 -0
  68. package/{util.test.js → util.test.ts} +350 -257
  69. package/util.ts +921 -0
  70. package/web/ai0BalanceForm/{ai0BalanceForm.test.js → ai0BalanceForm.test.ts} +15 -15
  71. package/web/ai0BalanceForm/{index.js → index.ts} +10 -18
  72. package/web/enqueue/index.ts +56 -0
  73. package/web/enqueueForm/index.html +9 -9
  74. package/web/enqueueForm/index.ts +43 -0
  75. package/web/expungeReportsForm/{expungeReportsForm.test.js → expungeReportsForm.test.ts} +18 -20
  76. package/web/expungeReportsForm/index.ts +18 -0
  77. package/web/hideReportForm/{hideReportForm.test.js → hideReportForm.test.ts} +14 -14
  78. package/web/hideReportForm/{index.js → index.ts} +17 -21
  79. package/web/listDiagnoses/{index.js → index.ts} +48 -58
  80. package/web/listDiagnoses/{listDiagnoses.test.js → listDiagnoses.test.ts} +39 -34
  81. package/web/listIssues/{index.js → index.ts} +67 -92
  82. package/web/listIssues/{listIssues.test.js → listIssues.test.ts} +45 -51
  83. package/web/listReports/index.html +3 -3
  84. package/web/listReports/{index.js → index.ts} +48 -53
  85. package/web/listReports/{listReports.test.js → listReports.test.ts} +41 -43
  86. package/web/listRules/{index.js → index.ts} +18 -23
  87. package/web/listRules/{listRules.test.js → listRules.test.ts} +26 -19
  88. package/web/listTopIssues/{index.js → index.ts} +36 -47
  89. package/web/listTopIssues/{listTopIssues.test.js → listTopIssues.test.ts} +26 -17
  90. package/web/listViolators/{index.js → index.ts} +51 -71
  91. package/web/listViolators/{listViolators.test.js → listViolators.test.ts} +43 -36
  92. package/web/manage/index.ts +22 -0
  93. package/web/pruneReportsForm/index.ts +18 -0
  94. package/web/pruneReportsForm/{pruneReportsForm.test.js → pruneReportsForm.test.ts} +11 -11
  95. package/web/reannotate/index.ts +70 -0
  96. package/web/reannotate/{reannotate.test.js → reannotate.test.ts} +19 -22
  97. package/web/reannotateForm/{index.js → index.ts} +41 -50
  98. package/web/reannotateForm/{reannotateForm.test.js → reannotateForm.test.ts} +13 -16
  99. package/web/renewWCAG/{index.js → index.ts} +9 -9
  100. package/web/renewWCAG/{renewWCAG.test.js → renewWCAG.test.ts} +17 -20
  101. package/web/renewWCAGForm/index.ts +22 -0
  102. package/web/{expungeReportsForm/index.js → reportDeletion.ts} +56 -38
  103. package/web/requestRetest/index.html +7 -7
  104. package/web/requestRetest/index.ts +31 -0
  105. package/web/requestRetest/requestRetest.test.ts +116 -0
  106. package/web/requestRetestForm/{index.js → index.ts} +10 -16
  107. package/web/requestRetestForm/{requestRetestForm.test.js → requestRetestForm.test.ts} +11 -12
  108. package/web/requestTest/index.html +7 -7
  109. package/web/requestTest/{index.js → index.ts} +5 -5
  110. package/web/requestTest/{requestTest.test.js → requestTest.test.ts} +21 -21
  111. package/web/requestTestForm/index.html +4 -4
  112. package/web/requestTestForm/index.ts +21 -0
  113. package/web/requestTestForm/{requestTestForm.test.js → requestTestForm.test.ts} +9 -9
  114. package/web/rewindReportsForm/index.ts +18 -0
  115. package/web/rewindReportsForm/{rewindReportsForm.test.js → rewindReportsForm.test.ts} +11 -11
  116. package/web/tutorialAI/index.html +184 -0
  117. package/web/tutorialAI/index.ts +80 -0
  118. package/web/tutorialAI/tutorialAI.test.ts +192 -0
  119. package/web/{tutorial → tutorialWeb}/index.html +9 -8
  120. package/web/tutorialWeb/index.ts +78 -0
  121. package/web/tutorialWeb/tutorialWeb.test.ts +195 -0
  122. package/web/unhideReportForm/{index.js → index.ts} +17 -21
  123. package/web/unhideReportForm/{unhideReportForm.test.js → unhideReportForm.test.ts} +16 -16
  124. package/aceconfig.js +0 -28
  125. package/api/listReports.js +0 -87
  126. package/api/requestFeature.test.js +0 -58
  127. package/api/version.js +0 -8
  128. package/indexFallback.test.js +0 -33
  129. package/indexWorkers.test.js +0 -79
  130. package/jsconfig.json +0 -8
  131. package/pm2.config.js +0 -18
  132. package/smokeTest.js +0 -126
  133. package/util.js +0 -733
  134. package/web/enqueue/index.js +0 -67
  135. package/web/enqueueForm/index.js +0 -49
  136. package/web/manage/index.js +0 -22
  137. package/web/pruneReportsForm/index.js +0 -123
  138. package/web/reannotate/index.js +0 -58
  139. package/web/renewWCAGForm/index.js +0 -22
  140. package/web/requestRetest/index.js +0 -31
  141. package/web/requestRetest/requestRetest.test.js +0 -119
  142. package/web/requestTestForm/index.js +0 -21
  143. package/web/rewindReportsForm/index.js +0 -121
  144. package/web/tutorial/index.js +0 -63
  145. package/web/tutorial/tutorial.test.js +0 -97
  146. /package/test/fixtures/db/jobs/{recs.json → testRequests.json} +0 -0
  147. /package/web/{tutorial → tutorialWeb}/images/newsletter-form.png +0 -0
@@ -1,47 +1,50 @@
1
1
  /*
2
- index.test.js
2
+ index.test.ts
3
3
  Integration tests for index.js requestHandler, covering GET routes, POST routes,
4
4
  error paths, worker authentication, and helper function behavior.
5
5
  */
6
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.)
7
+ // ENVIRONMENT (the test environment configuration; all modules now read
8
+ // process.env at call time, so load order no longer matters.)
9
9
 
10
- const path = require('node:path');
11
- const fixtureDBDir = path.join(__dirname, 'test', 'fixtures', 'db');
10
+ import path from 'node:path';
11
+ import {fixtureDBDir} from './test/dbFixture.ts';
12
12
 
13
+ const testCommentsDir = path.join(import.meta.dirname, 'test/fixtures/comments');
13
14
  process.env.DB_DIR = fixtureDBDir;
14
15
  process.env.AUTH_CODE = 'test-auth-code';
16
+ process.env.TUTORIAL_WEB_COMMENTS_PATH = path.join(testCommentsDir, 'tutorialWeb.json');
17
+ process.env.TUTORIAL_AI_COMMENTS_PATH = path.join(testCommentsDir, 'tutorialAI.json');
15
18
  process.env.TESTARO_WORKERS = JSON.stringify({
16
19
  worker1: {secret: 'secret1', name: 'Worker One'}
17
20
  });
18
21
 
19
22
  // IMPORTS
20
23
 
21
- const {test, before, beforeEach, 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');
24
+ import {test, before, beforeEach, after} from 'node:test';
25
+ import assert from 'node:assert/strict';
26
+ import http from 'node:http';
27
+ import fs from 'node:fs/promises';
28
+ import * as indexModule from './index.ts';
26
29
  const {requestHandler, routes, serveError, startServer, runIfMain, isPathAllowed, getAbuseError} = indexModule;
27
30
 
28
31
  // CONSTANTS
29
32
 
30
33
  const port = 3997;
31
- const recsPath = path.join(fixtureDBDir, 'jobs', 'recs.json');
34
+ const testRequestsPath = path.join(fixtureDBDir, 'jobs', 'testRequests.json');
32
35
 
33
36
  // SETUP AND TEARDOWN
34
37
 
35
- let server;
38
+ let server: http.Server;
36
39
 
37
40
  before(async () => {
38
41
  server = http.createServer(requestHandler);
39
- await new Promise(resolve => server.listen(port, () => resolve()));
42
+ await new Promise<void>(resolve => server.listen(port, () => resolve()));
40
43
  });
41
44
 
42
- // Restore recs.json and clean job directories before each test, so tests do not depend on execution order.
45
+ // Restore testRequests.json and clean job directories before each test, so tests do not depend on execution order.
43
46
  beforeEach(async () => {
44
- await fs.writeFile(recsPath, '{}\n');
47
+ await fs.writeFile(testRequestsPath, '{}\n');
45
48
  for (const sub of ['claimed', 'queue', 'failed']) {
46
49
  const dir = path.join(fixtureDBDir, 'jobs', sub);
47
50
  await fs.mkdir(dir, {recursive: true});
@@ -50,12 +53,16 @@ beforeEach(async () => {
50
53
  await fs.unlink(path.join(dir, file)).catch(() => {});
51
54
  }
52
55
  }
56
+ // Create test comments directory and reset test comment files so tests start clean.
57
+ await fs.mkdir(testCommentsDir, {recursive: true});
58
+ await fs.writeFile(path.join(testCommentsDir, 'tutorialWeb.json'), '[]\n');
59
+ await fs.writeFile(path.join(testCommentsDir, 'tutorialAI.json'), '[]\n');
53
60
  });
54
61
 
55
62
  after(async () => {
56
63
  // Close all idle connections, then close the server with a timeout.
57
64
  server.closeAllConnections?.();
58
- await new Promise(resolve => {
65
+ await new Promise<void>(resolve => {
59
66
  const timer = setTimeout(() => {
60
67
  server.closeAllConnections?.();
61
68
  resolve();
@@ -65,8 +72,8 @@ after(async () => {
65
72
  resolve();
66
73
  });
67
74
  });
68
- // Restore recs.json and clean job directories after running.
69
- await fs.writeFile(recsPath, '{}\n');
75
+ // Restore testRequests.json and clean job directories after running.
76
+ await fs.writeFile(testRequestsPath, '{}\n');
70
77
  for (const sub of ['claimed', 'queue', 'failed']) {
71
78
  const dir = path.join(fixtureDBDir, 'jobs', sub);
72
79
  await fs.mkdir(dir, {recursive: true});
@@ -83,7 +90,7 @@ const uniqueStamp = Date.now();
83
90
 
84
91
  // HELPERS
85
92
 
86
- const request = (method, requestPath, body = null, headers = {}) => new Promise((resolve, reject) => {
93
+ const request = (method: string, requestPath: string, body: any = null, headers: any = {}): Promise<any> => new Promise((resolve, reject) => {
87
94
  const options = {method, host: 'localhost', port, path: requestPath, headers: {...headers}};
88
95
  let bodyData = '';
89
96
  if (body) {
@@ -97,7 +104,7 @@ const request = (method, requestPath, body = null, headers = {}) => new Promise(
97
104
  options.headers['content-length'] = Buffer.byteLength(bodyData);
98
105
  }
99
106
  const req = http.request(options, response => {
100
- const chunks = [];
107
+ const chunks: Buffer[] = [];
101
108
  response.on('data', chunk => chunks.push(chunk));
102
109
  response.on('end', () => {
103
110
  resolve({statusCode: response.statusCode, headers: response.headers, body: Buffer.concat(chunks).toString()});
@@ -107,7 +114,7 @@ const request = (method, requestPath, body = null, headers = {}) => new Promise(
107
114
  req.end(bodyData || '');
108
115
  });
109
116
 
110
- const formRequest = (method, requestPath, formData, headers = {}) => new Promise((resolve, reject) => {
117
+ const formRequest = (method: string, requestPath: string, formData: any, headers: any = {}): Promise<any> => new Promise((resolve, reject) => {
111
118
  const body = new URLSearchParams(formData).toString();
112
119
  const options = {
113
120
  method,
@@ -121,7 +128,7 @@ const formRequest = (method, requestPath, formData, headers = {}) => new Promise
121
128
  }
122
129
  };
123
130
  const req = http.request(options, response => {
124
- const chunks = [];
131
+ const chunks: Buffer[] = [];
125
132
  response.on('data', chunk => chunks.push(chunk));
126
133
  response.on('end', () => {
127
134
  resolve({statusCode: response.statusCode, headers: response.headers, body: Buffer.concat(chunks).toString()});
@@ -131,7 +138,7 @@ const formRequest = (method, requestPath, formData, headers = {}) => new Promise
131
138
  req.end(body);
132
139
  });
133
140
 
134
- const jsonBody = res => {
141
+ const jsonBody = (res: any) => {
135
142
  try {
136
143
  return JSON.parse(res.body);
137
144
  }
@@ -147,6 +154,26 @@ test('routes has GET and POST arrays', () => {
147
154
  assert.ok(Array.isArray(routes.POST));
148
155
  });
149
156
 
157
+ // TESTS: smoke-test short-circuit
158
+
159
+ test('GET with x-kilotest-smoke header returns a perfunctory 200', async () => {
160
+ const res = await request('GET', '/api/listReports', null, {'x-kilotest-smoke': '1'});
161
+ assert.equal(res.statusCode, 200);
162
+ assert.equal(res.body, '{}');
163
+ });
164
+
165
+ test('POST with x-kilotest-smoke header returns a perfunctory 200', async () => {
166
+ const res = await request('POST', '/api/requestFeature', {}, {'x-kilotest-smoke': '1'});
167
+ assert.equal(res.statusCode, 200);
168
+ assert.equal(res.body, '{}');
169
+ });
170
+
171
+ test('GET without x-kilotest-smoke header executes the handler', async () => {
172
+ const res = await request('GET', '/api/listReports');
173
+ assert.equal(res.statusCode, 200);
174
+ assert.notEqual(res.body, '{}');
175
+ });
176
+
150
177
  // TESTS: GET routes
151
178
 
152
179
  test('GET / serves the home page as HTML', async () => {
@@ -304,18 +331,18 @@ test('GET /fullReport.json/990101T0000/xxx returns an abuse error for a nonexist
304
331
  assert.ok(res.body.includes('Invalid request'));
305
332
  });
306
333
 
307
- test('GET /tutorial/images/newsletter-form.png serves the image', async () => {
308
- const res = await request('GET', '/tutorial/images/newsletter-form.png');
334
+ test('GET /tutorialWeb/images/newsletter-form.png serves the image', async () => {
335
+ const res = await request('GET', '/tutorialWeb/images/newsletter-form.png');
309
336
  assert.equal(res.statusCode, 200);
310
337
  assert.ok(res.headers['content-type'].includes('image/png'));
311
338
  });
312
339
 
313
- test('GET /tutorial/images with an unknown extension serves octet-stream', async () => {
340
+ test('GET /tutorialWeb/images with an unknown extension serves octet-stream', async () => {
314
341
  // Create a temporary image file with an unknown extension.
315
- const imgPath = path.join(__dirname, 'web', 'tutorial', 'images', 'test.bmp');
342
+ const imgPath = path.join(import.meta.dirname, 'web', 'tutorialWeb', 'images', 'test.bmp');
316
343
  await fs.writeFile(imgPath, 'fake bitmap data');
317
344
  try {
318
- const res = await request('GET', '/tutorial/images/test.bmp');
345
+ const res = await request('GET', '/tutorialWeb/images/test.bmp');
319
346
  assert.equal(res.statusCode, 200);
320
347
  assert.ok(res.headers['content-type'].includes('application/octet-stream'));
321
348
  }
@@ -324,13 +351,34 @@ test('GET /tutorial/images with an unknown extension serves octet-stream', async
324
351
  }
325
352
  });
326
353
 
327
- test('GET /tutorial/images/nonexistent.png returns an error page', async () => {
328
- const res = await request('GET', '/tutorial/images/nonexistent.png');
354
+ test('GET /tutorialWeb/images/nonexistent.png returns an error page', async () => {
355
+ const res = await request('GET', '/tutorialWeb/images/nonexistent.png');
356
+ assert.equal(res.statusCode, 400);
357
+ assert.ok(res.body.includes('Image not found'));
358
+ });
359
+
360
+ test('GET /tutorialAI/images/test.png serves the image', async () => {
361
+ // Create a temporary image file in tutorialAI.
362
+ const imgPath = path.join(import.meta.dirname, 'web', 'tutorialAI', 'images', 'test.png');
363
+ await fs.mkdir(path.dirname(imgPath), {recursive: true});
364
+ await fs.writeFile(imgPath, 'fake png data');
365
+ try {
366
+ const res = await request('GET', '/tutorialAI/images/test.png');
367
+ assert.equal(res.statusCode, 200);
368
+ assert.ok(res.headers['content-type'].includes('image/png'));
369
+ }
370
+ finally {
371
+ await fs.unlink(imgPath).catch(() => {});
372
+ }
373
+ });
374
+
375
+ test('GET /tutorialAI/images/nonexistent.png returns an error page', async () => {
376
+ const res = await request('GET', '/tutorialAI/images/nonexistent.png');
329
377
  assert.equal(res.statusCode, 400);
330
378
  assert.ok(res.body.includes('Image not found'));
331
379
  });
332
380
 
333
- test('GET /nonexistent.html returns an abuse error', async () => {
381
+ test('GET /nonexistent.html returns an abuse error', {timeout: 500}, async () => {
334
382
  const res = await request('GET', '/nonexistent.html');
335
383
  assert.equal(res.statusCode, 400);
336
384
  assert.ok(res.body.includes('Invalid request'));
@@ -346,7 +394,7 @@ test('GET /forbidden-path returns an invalid GET request error', async () => {
346
394
 
347
395
  test('POST /requestTest.html with valid data returns an HTML page', async () => {
348
396
  const res = await formRequest('POST', '/requestTest.html', {
349
- what: `Unique Test Page ${uniqueStamp}`,
397
+ description: `Unique Test Page ${uniqueStamp}`,
350
398
  url: `https://example.com/unique-${uniqueStamp}`,
351
399
  why: 'Because accessibility matters'
352
400
  });
@@ -356,7 +404,7 @@ test('POST /requestTest.html with valid data returns an HTML page', async () =>
356
404
 
357
405
  test('POST /requestTest.html with an already-tested URL returns an error', async () => {
358
406
  const res = await formRequest('POST', '/requestTest.html', {
359
- what: 'Mixed Outcomes Page',
407
+ description: 'Mixed Outcomes Page',
360
408
  url: 'https://example.com/mixed',
361
409
  why: 'Because accessibility matters'
362
410
  });
@@ -366,17 +414,23 @@ test('POST /requestTest.html with an already-tested URL returns an error', async
366
414
 
367
415
  test('POST /requestTest.html with invalid data returns an error', async () => {
368
416
  const res = await formRequest('POST', '/requestTest.html', {
369
- what: '',
417
+ description: '',
370
418
  url: 'not-a-url',
371
419
  why: ''
372
420
  });
373
421
  assert.equal(res.statusCode, 400);
374
- assert.ok(res.body.includes('Invalid test recommendation'));
422
+ assert.ok(res.body.includes('Invalid test request'));
423
+ });
424
+
425
+ test('POST /requestTest.html with an unreadable body returns an error', async () => {
426
+ const res = await request('POST', '/requestTest.html', 'rawbody', {'content-type': 'text/plain'});
427
+ assert.equal(res.statusCode, 400);
428
+ assert.ok(res.body.includes('Unreadable request body'));
375
429
  });
376
430
 
377
431
  test('POST /requestRetest.html/260202T0000/new with valid data returns HTML', async () => {
378
- // Reset recs.json to avoid duplicate-recommendation errors from prior tests.
379
- await fs.writeFile(recsPath, '{}\n');
432
+ // Reset testRequests.json to avoid duplicate-request errors from prior tests.
433
+ await fs.writeFile(testRequestsPath, '{}\n');
380
434
  const res = await formRequest('POST', '/requestRetest.html/260202T0000/new', {
381
435
  why: 'Because the report is obsolete and needs refreshing'
382
436
  });
@@ -389,7 +443,7 @@ test('POST /requestRetest.html/invalid/invalid with invalid data returns an erro
389
443
  why: 'Because'
390
444
  });
391
445
  assert.equal(res.statusCode, 400);
392
- assert.ok(res.body.includes('Invalid retest recommendation'));
446
+ assert.ok(res.body.includes('Invalid retest request'));
393
447
  });
394
448
 
395
449
  test('POST /api/requestTest with valid JSON returns a JSON response', {timeout: 500}, async () => {
@@ -443,6 +497,36 @@ test('POST /worker/job without authentication returns 401', async () => {
443
497
  assert.ok(body.error.message.includes('Unauthorized'));
444
498
  });
445
499
 
500
+ test('POST /worker/job when TESTARO_WORKERS is unset returns 401', async () => {
501
+ const saved = process.env.TESTARO_WORKERS;
502
+ delete process.env.TESTARO_WORKERS;
503
+ try {
504
+ const auth = Buffer.from('worker1:secret1').toString('base64');
505
+ const res = await request('POST', '/worker/job', {}, {
506
+ authorization: `Basic ${auth}`
507
+ });
508
+ assert.equal(res.statusCode, 401);
509
+ }
510
+ finally {
511
+ process.env.TESTARO_WORKERS = saved;
512
+ }
513
+ });
514
+
515
+ test('POST /worker/job when TESTARO_WORKERS is invalid JSON returns 401', async () => {
516
+ const saved = process.env.TESTARO_WORKERS;
517
+ process.env.TESTARO_WORKERS = 'not valid JSON {';
518
+ try {
519
+ const auth = Buffer.from('worker1:secret1').toString('base64');
520
+ const res = await request('POST', '/worker/job', {}, {
521
+ authorization: `Basic ${auth}`
522
+ });
523
+ assert.equal(res.statusCode, 401);
524
+ }
525
+ finally {
526
+ process.env.TESTARO_WORKERS = saved;
527
+ }
528
+ });
529
+
446
530
  test('POST /worker/job with valid authentication returns a job or no-job response', async () => {
447
531
  // Clean up any claimed jobs left by prior tests.
448
532
  const claimedDir = path.join(fixtureDBDir, 'jobs', 'claimed');
@@ -494,6 +578,26 @@ test('PUT / returns an invalid method error', async () => {
494
578
  assert.ok(res.body.includes('Invalid request method'));
495
579
  });
496
580
 
581
+ // TESTS: requestHandler catch boundary
582
+
583
+ test('GET /listReports.html returns a 500 error when an unexpected internal error occurs', {timeout: 500}, async () => {
584
+ // Corrupt testRequests.json so that getTestRequests throws, an error that should never occur in normal
585
+ // operation and so is not handled by handleRequest itself, only by requestHandler's
586
+ // outer catch boundary.
587
+ const backup = await fs.readFile(testRequestsPath, 'utf8');
588
+ await fs.writeFile(testRequestsPath, 'not valid json');
589
+ try {
590
+ const res = await request('GET', '/listReports.html');
591
+ assert.equal(res.statusCode, 500);
592
+ assert.ok(res.headers['content-type'].includes('application/json'));
593
+ const body = jsonBody(res);
594
+ assert.ok(body.error);
595
+ }
596
+ finally {
597
+ await fs.writeFile(testRequestsPath, backup);
598
+ }
599
+ });
600
+
497
601
  // TESTS: serveError behavior
498
602
 
499
603
  test('serveError sends JSON for agent requests (isHumanUser = false)', async () => {
@@ -510,10 +614,10 @@ test('serveError sends HTML for human requests (isHumanUser = true)', async () =
510
614
  assert.ok(res.headers['content-type'].includes('text/html'));
511
615
  });
512
616
 
513
- // TESTS: recAction.html
617
+ // TESTS: requestAction.html
514
618
 
515
- test('POST /recAction.html with invalid auth code returns an error', {timeout: 500}, async () => {
516
- const res = await formRequest('POST', '/recAction.html', {
619
+ test('POST /requestAction.html with invalid auth code returns an error', {timeout: 500}, async () => {
620
+ const res = await formRequest('POST', '/requestAction.html', {
517
621
  target: 'https://example.com\tTest Page',
518
622
  authCode: 'wrong-code'
519
623
  });
@@ -521,13 +625,13 @@ test('POST /recAction.html with invalid auth code returns an error', {timeout: 5
521
625
  assert.ok(res.body.includes('Invalid test order'));
522
626
  });
523
627
 
524
- test('POST /recAction.html with valid auth code and rejection (no what) returns HTML', async () => {
628
+ test('POST /requestAction.html with valid auth code and rejection (no description) returns HTML', async () => {
525
629
  await formRequest('POST', '/requestTest.html', {
526
- what: `Reject Test Page ${uniqueStamp}`,
630
+ description: `Reject Test Page ${uniqueStamp}`,
527
631
  url: `https://example.com/reject-${uniqueStamp}`,
528
632
  why: 'Because accessibility matters'
529
633
  });
530
- const res = await formRequest('POST', '/recAction.html', {
634
+ const res = await formRequest('POST', '/requestAction.html', {
531
635
  target: `https://example.com/reject-${uniqueStamp}`,
532
636
  authCode: 'test-auth-code'
533
637
  });
@@ -535,31 +639,38 @@ test('POST /recAction.html with valid auth code and rejection (no what) returns
535
639
  assert.ok(res.headers['content-type'].includes('text/html'));
536
640
  });
537
641
 
538
- // TESTS: tutorialComment
642
+ // TESTS: tutorialWeb (web user tutorial)
539
643
 
540
- test('POST /tutorialComment.html with content returns JSON', async () => {
541
- const res = await request('POST', '/tutorialComment.html', {
542
- content: 'This is a test comment'
644
+ test('GET /tutorialWeb.html returns HTML', async () => {
645
+ const res = await request('GET', '/tutorialWeb.html');
646
+ assert.equal(res.statusCode, 200);
647
+ assert.ok(res.headers['content-type'].includes('text/html'));
648
+ });
649
+
650
+ test('POST /tutorialWebComment.html with content returns JSON', async () => {
651
+ const res = await request('POST', '/tutorialWebComment.html', {
652
+ content: `This is a test comment ${uniqueStamp}`
543
653
  });
544
654
  assert.ok(res.headers['content-type'].includes('application/json'));
655
+ assert.deepEqual(JSON.parse(res.body), {status: 'ok'});
545
656
  });
546
657
 
547
658
  // TESTS: additional branch coverage
548
659
 
549
- test('GET /fullReport.json/260101T0007/hid returns an abuse error for a hidden report', async () => {
660
+ test('GET /fullReport.json/260101T0007/hid returns an error for an unavailable report', async () => {
550
661
  const res = await request('GET', '/fullReport.json/260101T0007/hid');
551
662
  assert.equal(res.statusCode, 400);
552
663
  assert.ok(res.body.includes('Invalid request'));
553
664
  });
554
665
 
555
- test('POST /recAction.html with valid auth code and approval returns HTML', {timeout: 500}, async () => {
556
- await fs.writeFile(recsPath, '{}\n');
666
+ test('POST /requestAction.html with valid auth code and approval returns HTML', {timeout: 500}, async () => {
667
+ await fs.writeFile(testRequestsPath, '{}\n');
557
668
  await formRequest('POST', '/requestTest.html', {
558
- what: `Approval Test Page ${uniqueStamp}`,
669
+ description: `Approval Test Page ${uniqueStamp}`,
559
670
  url: `https://example.com/approval-${uniqueStamp}`,
560
671
  why: 'Because accessibility matters'
561
672
  });
562
- const res = await formRequest('POST', '/recAction.html', {
673
+ const res = await formRequest('POST', '/requestAction.html', {
563
674
  target: `https://example.com/approval-${uniqueStamp}\tApproval Test Page ${uniqueStamp}`,
564
675
  authCode: 'test-auth-code'
565
676
  });
@@ -575,10 +686,10 @@ test('POST /reannotate.html with invalid auth code returns an error page', async
575
686
  });
576
687
 
577
688
  test('POST /reannotate.html with valid auth code serves the answer page', async () => {
578
- // Back up all fixture reports, because annotateReport modifies them in place.
689
+ // Back up all fixture reports, because reannotation modifies them in place.
579
690
  const reportsDir = path.join(fixtureDBDir, 'reports');
580
691
  const reportFiles = await fs.readdir(reportsDir);
581
- const backups = {};
692
+ const backups: Record<string, string> = {};
582
693
  for (const file of reportFiles) {
583
694
  backups[file] = await fs.readFile(path.join(reportsDir, file), 'utf8');
584
695
  }
@@ -593,7 +704,7 @@ test('POST /reannotate.html with valid auth code serves the answer page', async
593
704
  finally {
594
705
  // Restore all fixture reports.
595
706
  for (const file of reportFiles) {
596
- await fs.writeFile(path.join(reportsDir, file), backups[file]);
707
+ await fs.writeFile(path.join(reportsDir, file), backups[file]!);
597
708
  }
598
709
  }
599
710
  });
@@ -608,7 +719,7 @@ test('POST /renewWCAG.html with invalid auth code returns an error page', async
608
719
  test('POST /renewWCAG.html with valid auth code serves the answer page', async () => {
609
720
  // Mock fetch to avoid a network dependency.
610
721
  const originalFetch = global.fetch;
611
- const wcagMapPath = path.join(__dirname, 'wcagMap.json');
722
+ const wcagMapPath = path.join(import.meta.dirname, 'wcagMap.json');
612
723
  const wcagMapBackup = await fs.readFile(wcagMapPath, 'utf8');
613
724
  // @ts-expect-error: Replacing the real function with a mock for testing.
614
725
  global.fetch = async () => ({
@@ -629,21 +740,56 @@ test('POST /renewWCAG.html with valid auth code serves the answer page', async (
629
740
  }
630
741
  });
631
742
 
632
- test('POST /tutorialComment.html with empty content returns a JSON error', {timeout: 500}, async () => {
633
- const res = await request('POST', '/tutorialComment.html', {
743
+ test('POST /tutorialWebComment.html with empty content returns a JSON error', {timeout: 500}, async () => {
744
+ const res = await request('POST', '/tutorialWebComment.html', {
745
+ content: ''
746
+ });
747
+ assert.ok(res.headers['content-type'].includes('application/json'));
748
+ });
749
+
750
+ // TESTS: tutorialAI (AI agent configuration tutorial)
751
+
752
+ test('GET /tutorialAI.html returns HTML', async () => {
753
+ const res = await request('GET', '/tutorialAI.html');
754
+ assert.equal(res.statusCode, 200);
755
+ assert.ok(res.headers['content-type'].includes('text/html'));
756
+ });
757
+
758
+ test('POST /tutorialAIComment.html with content returns JSON', async () => {
759
+ const res = await request('POST', '/tutorialAIComment.html', {
760
+ content: `This is a test AI tutorial comment ${uniqueStamp}`
761
+ });
762
+ assert.ok(res.headers['content-type'].includes('application/json'));
763
+ assert.deepEqual(JSON.parse(res.body), {status: 'ok'});
764
+ });
765
+
766
+ test('POST /tutorialAIComment.html with empty content returns a JSON error', {timeout: 500}, async () => {
767
+ const res = await request('POST', '/tutorialAIComment.html', {
634
768
  content: ''
635
769
  });
636
770
  assert.ok(res.headers['content-type'].includes('application/json'));
637
771
  });
638
772
 
773
+ test('GET /qai redirects to /tutorialAI.html', async () => {
774
+ const res = await request('GET', '/qai', {}, {followRedirects: false});
775
+ assert.equal(res.statusCode, 301);
776
+ assert.equal(res.headers['location'], '/tutorialAI.html');
777
+ });
778
+
779
+ test('GET /qai/comments redirects to /tutorialAI.html', async () => {
780
+ const res = await request('GET', '/qai/comments', {}, {followRedirects: false});
781
+ assert.equal(res.statusCode, 301);
782
+ assert.equal(res.headers['location'], '/tutorialAI.html');
783
+ });
784
+
639
785
  test('POST /requestTest.html with non-https URL returns an error', async () => {
640
786
  const res = await formRequest('POST', '/requestTest.html', {
641
- what: 'Test Page',
787
+ description: 'Test Page',
642
788
  url: 'http://example.com',
643
789
  why: 'Because accessibility matters'
644
790
  });
645
791
  assert.equal(res.statusCode, 400);
646
- assert.ok(res.body.includes('Invalid test recommendation'));
792
+ assert.ok(res.body.includes('Invalid test request'));
647
793
  });
648
794
 
649
795
  test('POST /requestRetest.html/260202T0000/new with missing why returns an error', async () => {
@@ -651,7 +797,7 @@ test('POST /requestRetest.html/260202T0000/new with missing why returns an error
651
797
  why: ''
652
798
  });
653
799
  assert.equal(res.statusCode, 400);
654
- assert.ok(res.body.includes('Invalid retest recommendation'));
800
+ assert.ok(res.body.includes('Invalid retest request'));
655
801
  });
656
802
 
657
803
  test('POST /worker/report with valid authentication and wrong worker returns an error', async () => {
@@ -669,6 +815,31 @@ test('POST /worker/report with valid authentication and wrong worker returns an
669
815
  assert.ok(body.error);
670
816
  });
671
817
 
818
+ test('POST /worker/report with a claimed job file that is not valid JSON returns a 500 error', {timeout: 500}, async () => {
819
+ // A corrupt claimed-job file is not the normal "no such claim" outcome (ENOENT), so it
820
+ // should propagate as an unexpected error rather than being treated as an unassigned job.
821
+ const jobID = '990101T0002-bad';
822
+ const claimedDir = path.join(fixtureDBDir, 'jobs', 'claimed');
823
+ const jobPath = path.join(claimedDir, `${jobID}.json`);
824
+ await fs.writeFile(jobPath, 'not valid json');
825
+ const auth = Buffer.from('worker1:secret1').toString('base64');
826
+ const report = {
827
+ id: jobID,
828
+ target: {what: 'Test', url: 'https://example.com/test'}
829
+ };
830
+ try {
831
+ const res = await request('POST', '/worker/report', {report}, {
832
+ authorization: `Basic ${auth}`
833
+ });
834
+ assert.equal(res.statusCode, 500);
835
+ const body = jsonBody(res);
836
+ assert.ok(body.error);
837
+ }
838
+ finally {
839
+ await fs.unlink(jobPath).catch(() => {});
840
+ }
841
+ });
842
+
672
843
  test('POST /worker/job with wrong secret returns 401', async () => {
673
844
  const auth = Buffer.from('worker1:wrongsecret').toString('base64');
674
845
  const res = await request('POST', '/worker/job', {}, {
@@ -723,7 +894,7 @@ test('POST /worker/job with a claimed job assigned to the worker returns an erro
723
894
  const body = jsonBody(res);
724
895
  assert.ok(body.error.message.includes('has not completed job'));
725
896
  // Wait for the async rename to complete.
726
- await new Promise(resolve => setTimeout(resolve, 100));
897
+ await new Promise<void>(resolve => setTimeout(resolve, 100));
727
898
  // The job should have been moved to failed.
728
899
  const failedExists = await fs.access(failedJobPath).then(() => true).catch(() => false);
729
900
  assert.ok(failedExists, 'Job should be moved to failed directory');
@@ -758,7 +929,7 @@ test('POST /worker/job with a queued job assigns it to the worker', {timeout: 50
758
929
  assert.equal(body.id, '260101T0000-mix');
759
930
  assert.equal(body.sources.worker, 'Worker One');
760
931
  // Wait for the async unlink to complete.
761
- await new Promise(resolve => setTimeout(resolve, 100));
932
+ await new Promise<void>(resolve => setTimeout(resolve, 100));
762
933
  // The job should have been moved from queue to claimed.
763
934
  const queueExists = await fs.access(path.join(queueDir, jobFile)).then(() => true).catch(() => false);
764
935
  assert.equal(queueExists, false, 'Job should be removed from queue');
@@ -803,12 +974,57 @@ test('POST /worker/report with valid authentication and valid claimed job proces
803
974
  assert.equal(body.status, 'ok');
804
975
  });
805
976
 
977
+ test('POST /worker/report with a claimed job but an unusable report returns an error and reclassifies the job', {timeout: 500}, async () => {
978
+ // Use a unique job ID that does not conflict with existing fixtures.
979
+ const jobID = '990101T0001-unu';
980
+ const reportPath = path.join(fixtureDBDir, 'reports', `${jobID}.json`);
981
+ const claimedDir = path.join(fixtureDBDir, 'jobs', 'claimed');
982
+ const failedDir = path.join(fixtureDBDir, 'jobs', 'failed');
983
+ const jobFile = `${jobID}.json`;
984
+ const claimedJobPath = path.join(claimedDir, jobFile);
985
+ const failedJobPath = path.join(failedDir, jobFile);
986
+ // Clean up any leftover files.
987
+ await fs.unlink(reportPath).catch(() => {});
988
+ await fs.unlink(failedJobPath).catch(() => {});
989
+ // Create a claimed job assigned to Worker One.
990
+ await fs.writeFile(claimedJobPath, JSON.stringify({
991
+ id: jobID,
992
+ target: {what: 'Test', url: 'https://example.com/test'},
993
+ sources: {worker: 'Worker One'}
994
+ }));
995
+ const auth = Buffer.from('worker1:secret1').toString('base64');
996
+ // A report that is syntactically valid (has id, target.what, target.url) but is not
997
+ // usable by Kilotest, because it has no acts, jobData, or catalog.
998
+ const report = {
999
+ id: jobID,
1000
+ target: {what: 'Test', url: 'https://example.com/test'}
1001
+ };
1002
+ const res = await request('POST', '/worker/report', {report}, {
1003
+ authorization: `Basic ${auth}`
1004
+ });
1005
+ assert.equal(res.statusCode, 400);
1006
+ const body = jsonBody(res);
1007
+ assert.ok(body.error.message.includes('not usable'));
1008
+ // The report should not have been recorded.
1009
+ const reportExists = await fs.access(reportPath).then(() => true).catch(() => false);
1010
+ assert.equal(reportExists, false, 'Unusable report should not be saved');
1011
+ // Wait for the async rename to complete.
1012
+ await new Promise<void>(resolve => setTimeout(resolve, 100));
1013
+ // The job should have been reclassified as failed rather than left claimed.
1014
+ const failedExists = await fs.access(failedJobPath).then(() => true).catch(() => false);
1015
+ assert.ok(failedExists, 'Job should be moved to the failed directory');
1016
+ const claimedExists = await fs.access(claimedJobPath).then(() => true).catch(() => false);
1017
+ assert.equal(claimedExists, false, 'Job should be removed from the claimed directory');
1018
+ // Clean up.
1019
+ await fs.unlink(failedJobPath).catch(() => {});
1020
+ });
1021
+
806
1022
  // TESTS: remaining error branches and web pages
807
1023
 
808
1024
  test('GET /style.css with a read error returns an error page', async () => {
809
1025
  // Temporarily rename style.css to trigger a read error.
810
- const stylePath = path.join(__dirname, 'style.css');
811
- const tempPath = path.join(__dirname, 'style.css.bak');
1026
+ const stylePath = path.join(import.meta.dirname, 'style.css');
1027
+ const tempPath = path.join(import.meta.dirname, 'style.css.bak');
812
1028
  await fs.rename(stylePath, tempPath);
813
1029
  try {
814
1030
  const res = await request('GET', '/style.css');
@@ -835,7 +1051,7 @@ test('GET /requestRetest.html/260202T0000/new returns an error page when called
835
1051
  // TESTS: GET HTML page routing
836
1052
  // These verify that the HTTP routing layer connects each URL to the correct
837
1053
  // handler and returns HTML with a 200 status. The handler logic itself is
838
- // tested by the unit tests in web/*/*.test.js.
1054
+ // tested by the unit tests in web/*/*.test.cjs.
839
1055
 
840
1056
  const htmlPagePaths = [
841
1057
  '/listReports.html',
@@ -843,7 +1059,8 @@ const htmlPagePaths = [
843
1059
  '/listDiagnoses.html/linkNoText/260101T0000/mix/0',
844
1060
  '/enqueueForm.html',
845
1061
  '/manage.html',
846
- '/tutorial.html',
1062
+ '/tutorialWeb.html',
1063
+ '/tutorialAI.html',
847
1064
  '/listTopIssues.html',
848
1065
  '/reannotateForm.html',
849
1066
  '/renewWCAGForm.html',
@@ -856,17 +1073,17 @@ const htmlPagePaths = [
856
1073
  ];
857
1074
 
858
1075
  for (const pagePath of htmlPagePaths) {
859
- test(`GET ${pagePath} serves a generated HTML page`, async () => {
1076
+ test(`GET ${pagePath} serves a generated HTML page`, {timeout: 500}, async () => {
860
1077
  const res = await request('GET', pagePath);
861
1078
  assert.equal(res.statusCode, 200);
862
1079
  assert.ok(res.headers['content-type'].includes('text/html'));
863
1080
  });
864
1081
  }
865
1082
 
866
- test('GET /enqueueForm.html shows recommendations when recs.json has entries', async () => {
867
- await fs.writeFile(recsPath, JSON.stringify({
1083
+ test('GET /enqueueForm.html shows requests when testRequests.json has entries', async () => {
1084
+ await fs.writeFile(testRequestsPath, JSON.stringify({
868
1085
  'https://example.com/enqueue-test': [
869
- {what: 'Enqueue Test Page', why: 'Needs testing for accessibility'}
1086
+ {description: 'Enqueue Test Page', why: 'Needs testing for accessibility'}
870
1087
  ]
871
1088
  }));
872
1089
  const res = await request('GET', '/enqueueForm.html');
@@ -889,52 +1106,104 @@ test('GET /test.html.bak matches isPathAllowed but falls through to catch-all',
889
1106
  assert.ok(res.body.includes('Invalid GET request'));
890
1107
  });
891
1108
 
892
- test('GET /mcp returns a response from the MCP handler', async () => {
893
- const res = await request('GET', '/mcp');
894
- // The MCP handler responds to GET requests, typically with an error
895
- // about acceptable content types or a similar MCP protocol message.
896
- assert.ok(res.statusCode >= 400);
1109
+ test('GET /mcp with an SSE-capable Accept header returns a JSON-RPC 405 error instead of opening an SSE stream', async () => {
1110
+ const res = await request('GET', '/mcp', null, {accept: 'application/json, text/event-stream'});
1111
+ assert.equal(res.statusCode, 405);
1112
+ const body = jsonBody(res);
1113
+ assert.equal(body.jsonrpc, '2.0');
1114
+ assert.equal(body.error.code, -32000);
1115
+ });
1116
+
1117
+ test('GET /mcp without an SSE-capable Accept header serves an HTML explanation page instead of the MCP protocol response', async () => {
1118
+ const res = await request('GET', '/mcp', null, {accept: 'text/html'});
1119
+ assert.equal(res.statusCode, 400);
1120
+ assert.ok(res.headers['content-type'].includes('text/html'));
1121
+ assert.ok(res.body.includes('/tutorialAI.html'));
1122
+ });
1123
+
1124
+ test('POST /mcp without an SSE-capable Accept header returns a JSON-RPC 406 error', async () => {
1125
+ const res = await request('POST', '/mcp', {jsonrpc: '2.0', method: 'initialize', id: 1, params: {}});
1126
+ assert.equal(res.statusCode, 406);
1127
+ assert.equal(jsonBody(res).error.code, -32000);
1128
+ });
1129
+
1130
+ test('POST /mcp with malformed JSON returns a JSON-RPC 400 parse error', async () => {
1131
+ const res = await request('POST', '/mcp', '{not json', {
1132
+ 'content-type': 'application/json',
1133
+ accept: 'application/json, text/event-stream'
1134
+ });
1135
+ assert.equal(res.statusCode, 400);
1136
+ assert.equal(jsonBody(res).error.code, -32700);
1137
+ });
1138
+
1139
+ test('POST /mcp with a non-JSON-RPC body returns a JSON-RPC 400 error', async () => {
1140
+ const res = await request('POST', '/mcp', {foo: 'bar'}, {accept: 'application/json, text/event-stream'});
1141
+ assert.equal(res.statusCode, 400);
1142
+ assert.equal(jsonBody(res).error.code, -32700);
1143
+ });
1144
+
1145
+ test('POST /mcp with a non-JSON content type returns a JSON-RPC 415 error', async () => {
1146
+ const res = await request('POST', '/mcp', 'x=1', {
1147
+ 'content-type': 'application/x-www-form-urlencoded',
1148
+ accept: 'application/json, text/event-stream'
1149
+ });
1150
+ assert.equal(res.statusCode, 415);
1151
+ assert.equal(jsonBody(res).error.code, -32000);
897
1152
  });
898
1153
 
899
- test('POST /mcp returns a response from the MCP handler', async () => {
1154
+ test('POST /mcp with an unknown JSON-RPC method returns a -32601 error', async () => {
1155
+ const res = await request('POST', '/mcp', {jsonrpc: '2.0', method: 'bogus/method', id: 9}, {
1156
+ accept: 'application/json, text/event-stream'
1157
+ });
1158
+ assert.equal(res.statusCode, 200);
1159
+ const dataLine = res.body.split('\n').find((line: string) => line.startsWith('data: '));
1160
+ assert.ok(dataLine);
1161
+ const message = JSON.parse(dataLine.slice(6));
1162
+ assert.equal(message.error.code, -32601);
1163
+ });
1164
+
1165
+ test('POST /mcp with a valid initialize request returns server info via SSE', async () => {
900
1166
  const res = await request('POST', '/mcp', {
901
1167
  jsonrpc: '2.0',
902
1168
  method: 'initialize',
903
1169
  id: 1,
904
1170
  params: {
905
- protocolVersion: '2024-11-05',
1171
+ protocolVersion: '2025-06-18',
906
1172
  capabilities: {},
907
1173
  clientInfo: {name: 'test-client', version: '1.0'}
908
1174
  }
909
1175
  }, {
910
1176
  accept: 'application/json, text/event-stream'
911
1177
  });
912
- // The MCP handler should process the initialize request.
913
- assert.ok(res.statusCode === 200 || res.statusCode >= 400);
1178
+ assert.equal(res.statusCode, 200);
1179
+ const dataLine = res.body.split('\n').find((line: string) => line.startsWith('data: '));
1180
+ assert.ok(dataLine);
1181
+ const message = JSON.parse(dataLine.slice(6));
1182
+ assert.equal(message.result.serverInfo.name, 'Kilotest');
914
1183
  });
915
1184
 
916
1185
  // TESTS: answer error branches
917
1186
 
918
1187
  test('POST /requestTest.html with valid format but duplicate URL returns an answer error', {timeout: 500}, async () => {
919
- await fs.writeFile(recsPath, '{}\n');
920
- // First request to create the recommendation.
1188
+ await fs.writeFile(testRequestsPath, '{}\n');
1189
+ // First request to create the request.
921
1190
  await formRequest('POST', '/requestTest.html', {
922
- what: `Dup Test Page ${uniqueStamp}`,
1191
+ description: `Dup Test Page ${uniqueStamp}`,
923
1192
  url: `https://example.com/dup-${uniqueStamp}`,
924
1193
  why: 'Because accessibility matters'
925
1194
  });
926
1195
  // Second request with the same URL should get a duplicate error.
927
1196
  const res = await formRequest('POST', '/requestTest.html', {
928
- what: `Dup Test Page ${uniqueStamp}`,
1197
+ description: `Dup Test Page ${uniqueStamp}`,
929
1198
  url: `https://example.com/dup-${uniqueStamp}`,
930
1199
  why: 'Because accessibility matters again'
931
1200
  });
932
1201
  assert.equal(res.statusCode, 400);
933
- assert.ok(res.body.includes('Duplicate recommendation'));
1202
+ assert.ok(res.body.includes('Duplicate request'));
934
1203
  });
935
1204
 
936
1205
  test('POST /requestRetest.html with valid format but duplicate retest returns an answer error', {timeout: 500}, async () => {
937
- await fs.writeFile(recsPath, '{}\n');
1206
+ await fs.writeFile(testRequestsPath, '{}\n');
938
1207
  // First retest to create the recommendation.
939
1208
  await formRequest('POST', '/requestRetest.html/260202T0000/new', {
940
1209
  why: 'Because the report is obsolete and needs refreshing'
@@ -944,26 +1213,26 @@ test('POST /requestRetest.html with valid format but duplicate retest returns an
944
1213
  why: 'Because the report is obsolete and needs refreshing again'
945
1214
  });
946
1215
  assert.equal(res.statusCode, 400);
947
- assert.ok(res.body.includes('Duplicate recommendation'));
1216
+ assert.ok(res.body.includes('Duplicate request'));
948
1217
  });
949
1218
 
950
- test('POST /recAction.html with valid auth code and approval of a duplicate returns an error', {timeout: 500}, async () => {
951
- await fs.writeFile(recsPath, '{}\n');
952
- // Create a recommendation.
1219
+ test('POST /requestAction.html with valid auth code and approval of a duplicate returns an error', {timeout: 500}, async () => {
1220
+ await fs.writeFile(testRequestsPath, '{}\n');
1221
+ // Create a request.
953
1222
  await formRequest('POST', '/requestTest.html', {
954
- what: `Action Dup Page ${uniqueStamp}`,
1223
+ description: `Action Dup Page ${uniqueStamp}`,
955
1224
  url: `https://example.com/action-dup-${uniqueStamp}`,
956
1225
  why: 'Because accessibility matters'
957
1226
  });
958
1227
  // Approve it once.
959
- await formRequest('POST', '/recAction.html', {
1228
+ await formRequest('POST', '/requestAction.html', {
960
1229
  target: `https://example.com/action-dup-${uniqueStamp}\tAction Dup Page ${uniqueStamp}`,
961
1230
  authCode: 'test-auth-code',
962
1231
  what: 'yes'
963
1232
  });
964
- // Approve it again (the recs may have been cleared, so this may succeed or fail).
965
- // This test covers the recAction answer error branch.
966
- const res = await formRequest('POST', '/recAction.html', {
1233
+ // Approve it again (the requests may have been cleared, so this may succeed or fail).
1234
+ // This test covers the requestAction answer error branch.
1235
+ const res = await formRequest('POST', '/requestAction.html', {
967
1236
  target: `https://example.com/action-dup-${uniqueStamp}\tAction Dup Page ${uniqueStamp}`,
968
1237
  authCode: 'test-auth-code',
969
1238
  what: 'yes'
@@ -972,10 +1241,10 @@ test('POST /recAction.html with valid auth code and approval of a duplicate retu
972
1241
  assert.ok(res.statusCode === 200 || res.statusCode === 400);
973
1242
  });
974
1243
 
975
- test('POST /recAction.html with valid auth code and approval of an invalid URL returns an error', async () => {
1244
+ test('POST /requestAction.html with valid auth code and approval of an invalid URL returns an error', async () => {
976
1245
  // A URL that starts with https:// but is not a valid URL causes
977
1246
  // enqueue.answer to return status error.
978
- const res = await formRequest('POST', '/recAction.html', {
1247
+ const res = await formRequest('POST', '/requestAction.html', {
979
1248
  target: 'https://\tTest Page',
980
1249
  authCode: 'test-auth-code',
981
1250
  what: 'yes'
@@ -986,22 +1255,22 @@ test('POST /recAction.html with valid auth code and approval of an invalid URL r
986
1255
 
987
1256
  // UNIT TESTS FOR serveError
988
1257
 
989
- const mockRes = () => {
990
- const state = {statusCode: null, headers: {}, body: null, writableEnded: false};
1258
+ const mockRes = (): any => {
1259
+ const state: any = {statusCode: null, headers: {}, body: null, writableEnded: false};
991
1260
  return {
992
1261
  get statusCode() {return state.statusCode;},
993
- set statusCode(value) {state.statusCode = value;},
994
- setHeader(name, value) {state.headers[name] = value;},
995
- end(data) {state.body = data; state.writableEnded = true;},
1262
+ set statusCode(value: any) {state.statusCode = value;},
1263
+ setHeader(name: any, value: any) {state.headers[name] = value;},
1264
+ end(data: any) {state.body = data; state.writableEnded = true;},
996
1265
  _state: state
997
1266
  };
998
1267
  };
999
1268
 
1000
1269
  test('serveError does not write to a response that has already ended', async () => {
1001
1270
  let wrote = false;
1002
- const mockResponse = {
1271
+ const mockResponse: any = {
1003
1272
  writableEnded: true,
1004
- set statusCode(value) {
1273
+ set statusCode(value: any) {
1005
1274
  wrote = true;
1006
1275
  },
1007
1276
  setHeader() {
@@ -1043,29 +1312,80 @@ test('getAbuseError uses unknown IP when no forwarding header or remote address'
1043
1312
  headers: {},
1044
1313
  socket: {remoteAddress: undefined}
1045
1314
  };
1046
- const result = getAbuseError(mockRequest, 'test reason');
1315
+ const result = getAbuseError(mockRequest as any, 'test reason');
1047
1316
  assert.equal(result['IP address'], 'unknown');
1048
1317
  assert.equal(result.reason, 'test reason');
1049
1318
  });
1050
1319
 
1051
1320
  // UNIT TESTS FOR startServer
1052
1321
 
1053
- test('startServer starts an HTTP server when protocol is http', async () => {
1322
+ test('startServer starts an HTTP server when protocol is http', {timeout: 500}, async () => {
1054
1323
  // Delete PROTOCOL to exercise the 'http' fallback in startServer.
1055
1324
  const savedProtocol = process.env.PROTOCOL;
1325
+ // Use port 0 so the OS assigns an ephemeral port, avoiding collisions with production.
1326
+ const savedPort = process.env.PORT;
1327
+ process.env.PORT = '0';
1056
1328
  delete process.env.PROTOCOL;
1057
1329
  const server = await startServer();
1058
1330
  process.env.PROTOCOL = savedProtocol;
1331
+ if (savedPort !== undefined) {
1332
+ process.env.PORT = savedPort;
1333
+ }
1334
+ else {
1335
+ delete process.env.PORT;
1336
+ }
1059
1337
  try {
1060
1338
  assert.ok(server);
1061
1339
  assert.equal(typeof server.listen, 'function');
1062
1340
  // Verify the server is listening by making a request.
1063
1341
  const address = server.address();
1064
- assert.ok(typeof address === 'object' && address.port > 0);
1342
+ assert.ok(typeof address === 'object' && address !== null && address.port > 0);
1065
1343
  }
1066
1344
  finally {
1345
+ server?.closeAllConnections?.();
1346
+ await new Promise<void>(resolve => {
1347
+ const timer = setTimeout(() => {
1348
+ server?.closeAllConnections?.();
1349
+ resolve();
1350
+ }, 1000);
1351
+ server?.close(() => {
1352
+ clearTimeout(timer);
1353
+ resolve();
1354
+ });
1355
+ });
1356
+ }
1357
+ });
1358
+
1359
+ test('serve uses the default port 3000 when PORT is not set', {timeout: 500}, async () => {
1360
+ // Exercise the '|| 3000' fallback branch in serve by deleting PORT and calling
1361
+ // serve directly. The server will attempt to bind to port 3000. On a system
1362
+ // where port 3000 is free, it succeeds and we close it. Where it is occupied,
1363
+ // the error event fires and we accept that as proof the fallback was reached.
1364
+ const savedPort = process.env.PORT;
1365
+ delete process.env.PORT;
1366
+ try {
1367
+ const server = await indexModule.serve(http, {});
1368
+ let bindError: any = null;
1369
+ server.on('error', error => {
1370
+ bindError = error;
1371
+ });
1372
+ // Wait briefly for either successful binding or an EADDRINUSE error.
1373
+ await new Promise<void>(resolve => {
1374
+ const timer = setTimeout(resolve, 200);
1375
+ server.on('listening', () => {
1376
+ clearTimeout(timer);
1377
+ resolve();
1378
+ });
1379
+ });
1380
+ if (bindError) {
1381
+ assert.equal(bindError.code, 'EADDRINUSE', `Expected EADDRINUSE, got: ${bindError.message}`);
1382
+ }
1383
+ else {
1384
+ const address = server.address();
1385
+ assert.ok(typeof address === 'object' && address !== null && address.port === 3000);
1386
+ }
1067
1387
  server.closeAllConnections?.();
1068
- await new Promise(resolve => {
1388
+ await new Promise<void>(resolve => {
1069
1389
  const timer = setTimeout(() => {
1070
1390
  server.closeAllConnections?.();
1071
1391
  resolve();
@@ -1076,13 +1396,21 @@ test('startServer starts an HTTP server when protocol is http', async () => {
1076
1396
  });
1077
1397
  });
1078
1398
  }
1399
+ finally {
1400
+ if (savedPort !== undefined) {
1401
+ process.env.PORT = savedPort;
1402
+ }
1403
+ else {
1404
+ delete process.env.PORT;
1405
+ }
1406
+ }
1079
1407
  });
1080
1408
 
1081
1409
  test('startServer starts an HTTPS server when protocol is https', {timeout: 2000}, async () => {
1082
1410
  // Generate a self-signed certificate for the test using openssl.
1083
- const {execSync} = require('node:child_process');
1084
- const os = require('node:os');
1085
- const fsSync = require('node:fs');
1411
+ const {execSync} = await import('node:child_process');
1412
+ const os = await import('node:os');
1413
+ const fsSync = await import('node:fs');
1086
1414
  const tmpDir = fsSync.mkdtempSync(path.join(os.tmpdir(), 'kilotest-https-'));
1087
1415
  const keyPath = path.join(tmpDir, 'key.pem');
1088
1416
  const certPath = path.join(tmpDir, 'cert.pem');
@@ -1094,6 +1422,9 @@ test('startServer starts an HTTPS server when protocol is https', {timeout: 2000
1094
1422
  const savedProtocol = process.env.PROTOCOL;
1095
1423
  const savedKey = process.env.KEY;
1096
1424
  const savedCert = process.env.CERT;
1425
+ // Use port 0 so the OS assigns an ephemeral port, avoiding collisions with production.
1426
+ const savedPort = process.env.PORT;
1427
+ process.env.PORT = '0';
1097
1428
  process.env.PROTOCOL = 'https';
1098
1429
  process.env.KEY = keyPath;
1099
1430
  process.env.CERT = certPath;
@@ -1102,9 +1433,9 @@ test('startServer starts an HTTPS server when protocol is https', {timeout: 2000
1102
1433
  assert.ok(server);
1103
1434
  assert.equal(typeof server.listen, 'function');
1104
1435
  const address = server.address();
1105
- assert.ok(typeof address === 'object' && address.port > 0);
1436
+ assert.ok(typeof address === 'object' && address !== null && address.port > 0);
1106
1437
  server.closeAllConnections?.();
1107
- await new Promise(resolve => {
1438
+ await new Promise<void>(resolve => {
1108
1439
  const timer = setTimeout(() => {
1109
1440
  server.closeAllConnections?.();
1110
1441
  resolve();
@@ -1134,43 +1465,37 @@ test('startServer starts an HTTPS server when protocol is https', {timeout: 2000
1134
1465
  else {
1135
1466
  delete process.env.CERT;
1136
1467
  }
1468
+ if (savedPort !== undefined) {
1469
+ process.env.PORT = savedPort;
1470
+ }
1471
+ else {
1472
+ delete process.env.PORT;
1473
+ }
1137
1474
  fsSync.rmSync(tmpDir, {recursive: true, force: true});
1138
1475
  }
1139
1476
  });
1140
1477
 
1141
1478
  test('runIfMain calls startServer when mainModule matches currentModule', async () => {
1142
1479
  let called = false;
1143
- const originalStartServer = indexModule.startServer;
1144
- indexModule.startServer = async () => {
1480
+ const starter = async () => {
1145
1481
  called = true;
1146
1482
  return null;
1147
1483
  };
1148
- try {
1149
- // Pass the same object for both arguments so the guard is true.
1150
- const fakeModule = {};
1151
- runIfMain(fakeModule, fakeModule);
1152
- // Wait for the microtask queue to flush the promise.
1153
- await new Promise(resolve => setImmediate(resolve));
1154
- assert.equal(called, true);
1155
- }
1156
- finally {
1157
- indexModule.startServer = originalStartServer;
1158
- }
1484
+ // Pass the same object for both arguments so the guard is true.
1485
+ const fakeModule = {};
1486
+ runIfMain(fakeModule, fakeModule, starter);
1487
+ // Wait for the microtask queue to flush the promise.
1488
+ await new Promise<void>(resolve => setImmediate(resolve));
1489
+ assert.equal(called, true);
1159
1490
  });
1160
1491
 
1161
1492
  test('runIfMain does not call startServer when mainModule differs from currentModule', async () => {
1162
1493
  let called = false;
1163
- const originalStartServer = indexModule.startServer;
1164
- indexModule.startServer = async () => {
1494
+ const starter = async () => {
1165
1495
  called = true;
1166
1496
  return null;
1167
1497
  };
1168
- try {
1169
- runIfMain({}, {});
1170
- await new Promise(resolve => setImmediate(resolve));
1171
- assert.equal(called, false);
1172
- }
1173
- finally {
1174
- indexModule.startServer = originalStartServer;
1175
- }
1498
+ runIfMain({}, {}, starter);
1499
+ await new Promise<void>(resolve => setImmediate(resolve));
1500
+ assert.equal(called, false);
1176
1501
  });