@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,70 +1,165 @@
1
1
  /*
2
- index.js
2
+ index.ts
3
3
  Manages Kilotest.
4
4
  */
5
5
 
6
- // ENVIRONMENT
7
-
8
- require('dotenv').config({quiet: true});
9
-
10
6
  // IMPORTS
11
- const {
12
- annotateReport,
7
+
8
+ import dotenv from 'dotenv';
9
+ import {
10
+ annotateReportObject,
13
11
  createLock,
12
+ deleteTestRequests,
13
+ errorMessage,
14
14
  getJobNames,
15
15
  getJSON,
16
16
  getObject,
17
17
  getPOSTData,
18
18
  getReport,
19
- getRecs,
19
+ isReportError,
20
20
  getReportPath,
21
21
  hiddenReportsPath,
22
- isHidden,
23
22
  isReportAvailable,
24
23
  isTimeStamp,
25
24
  isJobID,
25
+ isUsableReport,
26
26
  jobsPath,
27
- recsLock,
28
27
  reportsPath
29
- } = require('./util');
30
- const {handleMCP, mcpPath} = require('./mcp');
31
- const fs = require('fs/promises');
32
- const {handleComment} = require('./web/tutorial/index');
33
- const http = require('http');
34
- const https = require('https');
35
- const path = require('path');
36
- const {sendAlert} = require('./alerts');
37
- const answer = {
38
- ai0BalanceForm: require('./web/ai0BalanceForm/index').answer,
39
- enqueue: require('./web/enqueue/index').answer,
40
- enqueueForm: require('./web/enqueueForm/index').answer,
41
- expungeReportsForm: require('./web/expungeReportsForm/index').answer,
42
- hideReportForm: require('./web/hideReportForm/index').answer,
43
- listDiagnoses: require('./web/listDiagnoses/index').answer,
44
- listIssues: require('./web/listIssues/index').answer,
45
- listReports: require('./web/listReports/index').answer,
46
- listRules: require('./web/listRules/index').answer,
47
- listTopIssues: require('./web/listTopIssues/index').answer,
48
- listViolators: require('./web/listViolators/index').answer,
49
- manage: require('./web/manage/index').answer,
50
- pruneReportsForm: require('./web/pruneReportsForm/index').answer,
51
- reannotate: require('./web/reannotate/index').answer,
52
- reannotateForm: require('./web/reannotateForm/index').answer,
53
- renewWCAG: require('./web/renewWCAG/index').answer,
54
- renewWCAGForm: require('./web/renewWCAGForm/index').answer,
55
- requestRetest: require('./web/requestRetest/index').answer,
56
- requestRetestForm: require('./web/requestRetestForm/index').answer,
57
- requestTest: require('./web/requestTest/index').answer,
58
- requestTestForm: require('./web/requestTestForm/index').answer,
59
- rewindReportsForm: require('./web/rewindReportsForm/index').answer,
60
- unhideReportForm: require('./web/unhideReportForm/index').answer,
61
- tutorial: require('./web/tutorial/index').answer
28
+ } from './util.ts';
29
+ import {sendAlert} from './alerts.ts';
30
+ import {checkBalancesForAlerts} from './balances.ts';
31
+ import type {Report} from 'testaro';
32
+ import {handleMCP, mcpPath} from './mcp.ts';
33
+ import fs from 'node:fs/promises';
34
+ import {handleComment as handleTutorialWebComment} from './web/tutorialWeb/index.ts';
35
+ import {answer as tutorialWeb} from './web/tutorialWeb/index.ts';
36
+ import {answer as tutorialAI, handleComment as handleTutorialAIComment} from './web/tutorialAI/index.ts';
37
+ import http, {type IncomingMessage, type ServerResponse} from 'node:http';
38
+ import https from 'node:https';
39
+ import path from 'node:path';
40
+ import {answer as ai0BalanceForm} from './web/ai0BalanceForm/index.ts';
41
+ import {answer as enqueue} from './web/enqueue/index.ts';
42
+ import {answer as enqueueForm} from './web/enqueueForm/index.ts';
43
+ import {answer as expungeReportsForm} from './web/expungeReportsForm/index.ts';
44
+ import {answer as hideReportForm} from './web/hideReportForm/index.ts';
45
+ import {answer as listDiagnosesPage} from './web/listDiagnoses/index.ts';
46
+ import {answer as listIssuesPage} from './web/listIssues/index.ts';
47
+ import {answer as listReportsPage} from './web/listReports/index.ts';
48
+ import {answer as listRules} from './web/listRules/index.ts';
49
+ import {answer as listTopIssues} from './web/listTopIssues/index.ts';
50
+ import {answer as listViolatorsPage} from './web/listViolators/index.ts';
51
+ import {answer as manage} from './web/manage/index.ts';
52
+ import {answer as pruneReportsForm} from './web/pruneReportsForm/index.ts';
53
+ import {answer as reannotate} from './web/reannotate/index.ts';
54
+ import {answer as reannotateForm} from './web/reannotateForm/index.ts';
55
+ import {answer as renewWCAG} from './web/renewWCAG/index.ts';
56
+ import {answer as renewWCAGForm} from './web/renewWCAGForm/index.ts';
57
+ import {answer as requestRetestPage} from './web/requestRetest/index.ts';
58
+ import {answer as requestRetestForm} from './web/requestRetestForm/index.ts';
59
+ import {answer as requestTestPage} from './web/requestTest/index.ts';
60
+ import {answer as requestTestForm} from './web/requestTestForm/index.ts';
61
+ import {answer as rewindReportsForm} from './web/rewindReportsForm/index.ts';
62
+ import {answer as unhideReportForm} from './web/unhideReportForm/index.ts';
63
+ import {response as getReportAPI} from './api/getReport.ts';
64
+ import {response as listDiagnosesAPI} from './api/listDiagnoses.ts';
65
+ import {response as listIssuesAPI} from './api/listIssues.ts';
66
+ import {response as listReportsAPI} from './api/listReports.ts';
67
+ import {response as listViolatorsAPI} from './api/listViolators.ts';
68
+ import {response as requestFeatureAPI} from './api/requestFeature.ts';
69
+ import {response as requestRetestAPI} from './api/requestRetest.ts';
70
+ import {response as requestTestAPI} from './api/requestTest.ts';
71
+
72
+ // ENVIRONMENT
73
+
74
+ dotenv.config({quiet: true});
75
+
76
+ // CONSTANTS
77
+
78
+ // The data returned by a page-answering handler.
79
+ type AnswerData = {status: string; message?: string; answerPage?: string};
80
+ // A page-answering handler, whose parameters vary by topic.
81
+ type PageHandler = (...args: any[]) => Promise<AnswerData>;
82
+
83
+ const answer: {
84
+ ai0BalanceForm: PageHandler;
85
+ enqueue: PageHandler;
86
+ enqueueForm: PageHandler;
87
+ expungeReportsForm: PageHandler;
88
+ hideReportForm: PageHandler;
89
+ listDiagnoses: PageHandler;
90
+ listIssues: PageHandler;
91
+ listReports: PageHandler;
92
+ listRules: PageHandler;
93
+ listTopIssues: PageHandler;
94
+ listViolators: PageHandler;
95
+ manage: PageHandler;
96
+ pruneReportsForm: PageHandler;
97
+ tutorialWeb: PageHandler;
98
+ tutorialAI: PageHandler;
99
+ reannotate: PageHandler;
100
+ reannotateForm: PageHandler;
101
+ renewWCAG: PageHandler;
102
+ renewWCAGForm: PageHandler;
103
+ requestRetest: PageHandler;
104
+ requestRetestForm: PageHandler;
105
+ requestTest: PageHandler;
106
+ requestTestForm: PageHandler;
107
+ rewindReportsForm: PageHandler;
108
+ unhideReportForm: PageHandler;
109
+ [key: string]: PageHandler | undefined;
110
+ } = {
111
+ ai0BalanceForm,
112
+ enqueue,
113
+ enqueueForm,
114
+ expungeReportsForm,
115
+ hideReportForm,
116
+ listDiagnoses: listDiagnosesPage,
117
+ listIssues: listIssuesPage,
118
+ listReports: listReportsPage,
119
+ listRules,
120
+ listTopIssues,
121
+ listViolators: listViolatorsPage,
122
+ manage,
123
+ pruneReportsForm,
124
+ tutorialWeb,
125
+ tutorialAI,
126
+ reannotate,
127
+ reannotateForm,
128
+ renewWCAG,
129
+ renewWCAGForm,
130
+ requestRetest: requestRetestPage,
131
+ requestRetestForm,
132
+ requestTest: requestTestPage,
133
+ requestTestForm,
134
+ rewindReportsForm,
135
+ unhideReportForm
136
+ };
137
+ // Response functions of the API services.
138
+ type ApiResponder = (args: string[]) => Promise<unknown>;
139
+ const apiRespond: {
140
+ getReport: ApiResponder;
141
+ listDiagnoses: ApiResponder;
142
+ listIssues: ApiResponder;
143
+ listReports: ApiResponder;
144
+ listViolators: ApiResponder;
145
+ requestFeature: ApiResponder;
146
+ requestRetest: ApiResponder;
147
+ requestTest: ApiResponder;
148
+ } = {
149
+ getReport: getReportAPI,
150
+ listDiagnoses: listDiagnosesAPI,
151
+ listIssues: listIssuesAPI,
152
+ listReports: listReportsAPI,
153
+ listViolators: listViolatorsAPI,
154
+ requestFeature: requestFeatureAPI,
155
+ requestRetest: requestRetestAPI,
156
+ requestTest: requestTestAPI
62
157
  };
63
158
 
64
159
  // CONSTANTS
65
160
 
66
161
  // Paths that the application is authorized to handle, by method, as glob-style patterns where * matches any sequence of characters.
67
- const routes = exports.routes = {
162
+ export const routes = {
68
163
  GET: [
69
164
  '*.html*',
70
165
  '/',
@@ -79,46 +174,31 @@ const routes = exports.routes = {
79
174
  '/mcp',
80
175
  '/openapi.json',
81
176
  '/openapi.yaml',
177
+ '/qai',
178
+ '/qai/comments',
82
179
  '/robots.txt',
83
180
  '/sitemap.xml',
84
181
  '/style.css',
85
182
  '/swagger.json',
86
183
  '/swagger.yaml',
87
- '/tutorial/images/*'
184
+ '/tutorialWeb/images/*',
185
+ '/tutorialAI/images/*'
88
186
  ],
89
187
  POST: [
90
188
  '/api/*',
91
189
  '/mcp',
190
+ '/tutorialAIComment.html',
92
191
  '/reannotate.html',
93
- '/recAction.html',
192
+ '/requestAction.html',
94
193
  '/renewWCAG.html',
95
194
  '/requestRetest.html/*',
96
195
  '/requestTest.html',
97
- '/tutorialComment.html',
196
+ '/tutorialWebComment.html',
98
197
  '/worker/job',
99
198
  '/worker/report'
100
199
  ]
101
200
  };
102
- const protocol = process.env.PROTOCOL || 'http';
103
- // Credentials of the Testaro workers, by worker ID, from a JSON-object environment variable.
104
- // Each worker ID maps to a secret (used only to authenticate the worker, never published) and a
105
- // name (a non-secret label safe to publish, e.g. in report data and logs).
106
- const workerCredentials = (() => {
107
- try {
108
- return JSON.parse(process.env.TESTARO_WORKERS || '{}');
109
- }
110
- catch (error) {
111
- console.error(`ERROR: TESTARO_WORKERS is not valid JSON (${error.message})`);
112
- return {};
113
- }
114
- })();
115
- // Values that may require alerts.
116
- const balancePath = path.join(__dirname, 'ai0Balance.json');
117
201
  const jobLock = createLock();
118
- const WAVE_THRESHOLD = Number(process.env.WAVE_BALANCE_THRESHOLD);
119
- const AI_SERVICE0_THRESHOLD = Number(process.env.AI_SERVICE0_BALANCE_THRESHOLD);
120
- const AI_MODEL0_INPUT_PRICE = Number(process.env.AI_MODEL0_INPUT_PRICE);
121
- const AI_MODEL0_OUTPUT_PRICE = Number(process.env.AI_MODEL0_OUTPUT_PRICE);
122
202
 
123
203
  // FUNCTIONS
124
204
 
@@ -126,19 +206,19 @@ const queuePath = () => path.join(jobsPath(), 'queue');
126
206
  const claimedPath = () => path.join(jobsPath(), 'claimed');
127
207
  const failedPath = () => path.join(jobsPath(), 'failed');
128
208
  // Returns whether a pathname matches a glob-style pattern.
129
- const matchPath = (pattern, pathname) => {
209
+ const matchPath = (pattern: string, pathname: string) => {
130
210
  const regex = new RegExp(
131
211
  '^' + pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$'
132
212
  );
133
213
  return regex.test(pathname);
134
214
  };
135
215
  // Returns whether a pathname is authorized for a method.
136
- const isPathAllowed = exports.isPathAllowed = (method, pathname) => {
137
- const patterns = routes[method] || [];
216
+ export const isPathAllowed = (method: string, pathname: string): boolean => {
217
+ const patterns = (routes as Record<string, string[]>)[method] || [];
138
218
  return patterns.some(pattern => matchPath(pattern, pathname));
139
219
  };
140
220
  // Serves or sends an error message.
141
- const serveError = exports.serveError = async (error, response, isHumanUser = true, statusCode = 400) => {
221
+ export const serveError = async (error: Record<string, unknown>, response: ServerResponse, isHumanUser = true, statusCode = 400): Promise<void> => {
142
222
  const errorLines = Object.entries(error).map(pair => `${pair[0]}: ${pair[1]}`);
143
223
  const errorSummary = errorLines.join('\n') || 'ERROR';
144
224
  console.log(errorSummary);
@@ -152,7 +232,7 @@ const serveError = exports.serveError = async (error, response, isHumanUser = tr
152
232
  response.setHeader('Access-Control-Allow-Origin', '*');
153
233
  response.setHeader('Cache-Control', 'public, max-age=300, stale-while-revalidate=3000');
154
234
  const errorTemplate = await fs.readFile('error.html', 'utf8');
155
- const errorMessage = error.message || 'ERROR';
235
+ const errorMessage = typeof error.message === 'string' ? error.message : 'ERROR';
156
236
  const errorPage = errorTemplate.replace(/__error__/, errorMessage);
157
237
  response.end(errorPage);
158
238
  }
@@ -167,68 +247,8 @@ const serveError = exports.serveError = async (error, response, isHumanUser = tr
167
247
  console.log('Cannot send error response because the response has ended.');
168
248
  }
169
249
  };
170
- // Checks a report for balances nearing exhaustion.
171
- const checkBalancesForAlerts = async report => {
172
- // If the variables to be monitored for alerts are defined:
173
- if (WAVE_THRESHOLD && AI_SERVICE0_THRESHOLD && AI_MODEL0_INPUT_PRICE && AI_MODEL0_OUTPUT_PRICE) {
174
- // WAVE.
175
- const waveAct = report.acts.find(act => act.type === 'test' && act.which === 'wave');
176
- const creditsRemaining = waveAct?.data?.creditsRemaining;
177
- // If a WAVE balance nearing exhaustion is reported:
178
- if (typeof creditsRemaining === 'number' && creditsRemaining < WAVE_THRESHOLD) {
179
- // Alert a manager.
180
- await sendAlert(
181
- 'Kilotest: WAVE balance low',
182
- `Only ${creditsRemaining} WAVE credits remain (3 used per job)`
183
- );
184
- }
185
- const testaroAct = report.acts.find(act => act.type === 'test' && act.which === 'testaro');
186
- // Get the AI model token usage for the testaro allCaps test.
187
- const usage = testaroAct?.data?.ruleData?.allCaps?.aiModelUsage;
188
- let balanceJSON = null;
189
- try {
190
- // Get the recorded AI service 0 balance.
191
- balanceJSON = await fs.readFile(balancePath, 'utf8');
192
- }
193
- catch {
194
- console.error('ERROR: AI service 0 balance file missing');
195
- }
196
- // If the variables required for an AI service 0 balance alert are defined:
197
- if (usage && AI_MODEL0_INPUT_PRICE && AI_MODEL0_OUTPUT_PRICE && balanceJSON) {
198
- const inputCost = AI_MODEL0_INPUT_PRICE * usage.inputTokens;
199
- const outputCost = AI_MODEL0_OUTPUT_PRICE * usage.outputTokens;
200
- const cost = inputCost + outputCost;
201
- // If any cost was incurred:
202
- if (cost > 0) {
203
- // Warn about this.
204
- console.log(
205
- 'This job has made the production AI service 0 balance record wrong. Update it.'
206
- );
207
- }
208
- try {
209
- const balanceData = JSON.parse(balanceJSON);
210
- // Get an estimate of the balance after this job.
211
- const newBalance = balanceData.balance - cost;
212
- // Update the recorded balance.
213
- await fs.writeFile(balancePath, getJSON({balance: newBalance}));
214
- console.log(`Estimated new AI Service 0 balance: $${newBalance.toFixed(2)}`);
215
- // If it is nearing exhaustion:
216
- if (newBalance < AI_SERVICE0_THRESHOLD) {
217
- // Alert a manager.
218
- await sendAlert(
219
- 'Kilotest: AI service 0 balance low',
220
- `Balance of AI service 0 account (https://console.anthropic.com) only about $${newBalance.toFixed(2)} (about $0.01 used per job)`
221
- );
222
- }
223
- }
224
- catch (error) {
225
- console.log(`ERROR managing AI service 0 balance: ${error.message}`);
226
- }
227
- }
228
- }
229
- };
230
250
  // Creates an error object about a suspicious request.
231
- const getAbuseError = exports.getAbuseError = (request, reason) => {
251
+ export const getAbuseError = (request: IncomingMessage, reason: string | undefined) => {
232
252
  const {method, url, headers} = request;
233
253
  const forwardedFor = headers['x-forwarded-for'];
234
254
  const remoteAddress = request.socket.remoteAddress;
@@ -246,23 +266,36 @@ const getAbuseError = exports.getAbuseError = (request, reason) => {
246
266
  };
247
267
  // Gets the ID and secret from a request's HTTP Basic Authorization header, or null if the header
248
268
  // is absent or malformed.
249
- const getBasicAuth = request => {
269
+ const getBasicAuth = (request: IncomingMessage) => {
250
270
  const header = request.headers['authorization'] || '';
251
271
  const match = header.match(/^Basic\s+(\S+)$/i);
252
272
  if (!match) {
253
273
  return null;
254
274
  }
255
- const decoded = Buffer.from(match[1], 'base64').toString('utf8');
275
+ const decoded = Buffer.from(match[1]!, 'base64').toString('utf8');
256
276
  const sepIndex = decoded.indexOf(':');
257
277
  if (sepIndex === -1) {
258
278
  return null;
259
279
  }
260
280
  return {id: decoded.slice(0, sepIndex), secret: decoded.slice(sepIndex + 1)};
261
281
  };
282
+ // Returns the credentials of the Testaro workers, by worker ID, from a JSON-object
283
+ // environment variable. Each worker ID maps to a secret (used only to authenticate the worker,
284
+ // never published) and a name (a non-secret label safe to publish, e.g. in report data and logs).
285
+ const getWorkerCredentials = () => {
286
+ try {
287
+ return JSON.parse(process.env.TESTARO_WORKERS || '{}');
288
+ }
289
+ catch (error) {
290
+ console.error(`ERROR: TESTARO_WORKERS is not valid JSON (${errorMessage(error)})`);
291
+ return {};
292
+ }
293
+ };
262
294
  // Gets the published name of a Testaro worker or null if not authenticated.
263
- const getAuthorizedWorkerName = request => {
295
+ const getAuthorizedWorkerName = (request: IncomingMessage) => {
264
296
  const credentials = getBasicAuth(request);
265
297
  if (credentials) {
298
+ const workerCredentials = getWorkerCredentials();
266
299
  const worker = workerCredentials[credentials.id];
267
300
  if (worker && worker.secret === credentials.secret && worker.name) {
268
301
  return worker.name;
@@ -271,7 +304,7 @@ const getAuthorizedWorkerName = request => {
271
304
  return null;
272
305
  };
273
306
  // Processes a job request from a Testaro worker.
274
- const processJobRequest = async (request, response, workerName) => jobLock(async () => {
307
+ const processJobRequest = async (request: IncomingMessage, response: ServerResponse, workerName: string) => jobLock(async () => {
275
308
  let clean = true;
276
309
  const messageStart = `Testaro worker ${workerName} requested a job, `;
277
310
  const jobNames = await getJobNames();
@@ -279,7 +312,7 @@ const processJobRequest = async (request, response, workerName) => jobLock(async
279
312
  // For each claimed job:
280
313
  for (const jobName of claimedJobNames) {
281
314
  const job = await getObject(path.join(jobsPath(), 'claimed', jobName));
282
- const {id, sources} = job;
315
+ const {id, sources} = job as {id: string, sources: {worker: string}};
283
316
  const {worker} = sources;
284
317
  // If its assignee is the worker:
285
318
  if (worker === workerName) {
@@ -300,9 +333,9 @@ const processJobRequest = async (request, response, workerName) => jobLock(async
300
333
  const queuedJobNames = jobNames.queue;
301
334
  // If any jobs are queued:
302
335
  if (queuedJobNames.length) {
303
- const oldestJobName = queuedJobNames[0];
336
+ const oldestJobName = queuedJobNames[0]!;
304
337
  // Get the first one.
305
- const firstJob = await getObject(path.join(queuePath(), oldestJobName));
338
+ const firstJob = await getObject(path.join(queuePath(), oldestJobName)) as {id: string, sources: {worker: string}, target: {what: string}};
306
339
  // Add the public worker name to the job, in a property Testaro does not read or alter.
307
340
  firstJob.sources.worker = workerName;
308
341
  console.log(
@@ -336,15 +369,15 @@ const processJobRequest = async (request, response, workerName) => jobLock(async
336
369
  }
337
370
  });
338
371
  // Handles a request.
339
- const requestHandler = async (request, response) => {
372
+ const handleRequest = async (request: IncomingMessage, response: ServerResponse) => {
340
373
  // Sets response headers.
341
- const setHeaders = (contentType, location, volatility = 'high') => {
374
+ const setHeaders = (contentType: string, location: string | null, volatility: string = 'high') => {
342
375
  response.setHeader('content-type', `${contentType}; charset=utf-8`);
343
376
  if (location) {
344
377
  response.setHeader('content-location', location);
345
378
  }
346
379
  response.setHeader('Access-Control-Allow-Origin', '*');
347
- const lives = {
380
+ const lives: Record<string, number[]> = {
348
381
  ultra: [3, 30],
349
382
  high: [300, 3000],
350
383
  medium: [1000, 10000],
@@ -352,14 +385,20 @@ const requestHandler = async (request, response) => {
352
385
  };
353
386
  response.setHeader(
354
387
  'Cache-Control',
355
- `public, max-age=${lives[volatility][0]}, stale-while-revalidate=${lives[volatility][1]}`
388
+ `public, max-age=${lives[volatility]![0]}, stale-while-revalidate=${lives[volatility]![1]}`
356
389
  );
357
390
  };
358
391
  const {method, url} = request;
359
- const requestURL = new URL(url, 'https://localhost:3000');
392
+ const requestURL = new URL(url as string, 'https://localhost:3000');
360
393
  const {pathname, search} = requestURL;
361
- const pageName = pathname.split('/')[1];
394
+ const pageName = pathname.split('/')[1]!;
362
395
  const pathTail = pathname.split('/').slice(2).join('/');
396
+ // If the request is a smoke-test probe, respond perfunctorily without executing a handler.
397
+ if (request.headers['x-kilotest-smoke']) {
398
+ response.setHeader('content-type', 'application/json; charset=utf-8');
399
+ response.end('{}');
400
+ return;
401
+ }
363
402
  // If the request is a GET request:
364
403
  if (method === 'GET') {
365
404
  // If the path is not authorized for GET requests:
@@ -368,8 +407,19 @@ const requestHandler = async (request, response) => {
368
407
  }
369
408
  // If it is for the model context protocol server:
370
409
  else if (pathname === mcpPath) {
371
- // Handle the MCP request.
372
- await handleMCP(request, response);
410
+ // If the request does not identify itself as capable of an SSE stream: it is a human
411
+ // browsing to the endpoint directly, not an MCP client, so explain what the endpoint is
412
+ // for instead of handing it a bare protocol-level rejection.
413
+ if (!request.headers.accept?.includes('text/event-stream')) {
414
+ await serveError({
415
+ message: 'This endpoint is for AI agents, not for direct browsing. See the <a href="/tutorialAI.html">tutorial on connecting an AI platform to Kilotest</a>.'
416
+ }, response);
417
+ }
418
+ // Otherwise, i.e. if it identifies itself as capable of an SSE stream:
419
+ else {
420
+ // Handle the MCP request.
421
+ await handleMCP(request, response);
422
+ }
373
423
  }
374
424
  // Otherwise, if it is for the home page:
375
425
  else if (['/', '/index.html'].includes(pathname)) {
@@ -399,6 +449,18 @@ const requestHandler = async (request, response) => {
399
449
  response.writeHead(301, {Location: '/openapi.yaml'});
400
450
  response.end();
401
451
  }
452
+ // Otherwise, if it is for the old QAI comments path:
453
+ else if (pathname === '/qai/comments') {
454
+ // Redirect the client permanently to the new tutorial path (comments are now integrated).
455
+ response.writeHead(301, {Location: '/tutorialAI.html'});
456
+ response.end();
457
+ }
458
+ // Otherwise, if it is for the old QAI root path:
459
+ else if (pathname === '/qai') {
460
+ // Redirect the client permanently to the new tutorial path.
461
+ response.writeHead(301, {Location: '/tutorialAI.html'});
462
+ response.end();
463
+ }
402
464
  // Otherwise, if it is for the large-language-model summary guide:
403
465
  else if (pageName === 'llms.txt') {
404
466
  const llms = await fs.readFile('llms.txt', 'utf8');
@@ -429,43 +491,37 @@ const requestHandler = async (request, response) => {
429
491
  }
430
492
  // Otherwise, if it is for a full report download:
431
493
  else if (pageName === 'fullReport.json') {
432
- const [timeStamp, jobID] = pathTail.split('/');
494
+ const [timeStamp, jobID] = pathTail.split('/') as [string, string, ...string[]];
433
495
  // If the request is syntactically valid:
434
496
  if (isTimeStamp(timeStamp) && isJobID(jobID)) {
435
- const reportHidden = await isHidden(timeStamp, jobID);
436
- // If the report exists and is hidden:
437
- if (reportHidden) {
438
- console.error(`Hidden report ${timeStamp}-${jobID} requested`);
439
- // Report this as suspected abuse.
440
- await serveError(
441
- getAbuseError(request, `Hidden report ${timeStamp}-${jobID} requested`),
442
- response,
443
- true
497
+ // Get it.
498
+ const report = await getReport(timeStamp, jobID);
499
+ // If this failed:
500
+ if (isReportError(report)) {
501
+ // This is not necessarily abuse: the link may have been generated (e.g. by
502
+ // listIssues.html) before the report was pruned or rewound, or the stored report
503
+ // may have become unreadable or unusable, none of which is the requester's fault.
504
+ // So tell the requester only that the request was invalid, without accusing them,
505
+ // but alert a manager with the real reason, since a syntactically valid report ID
506
+ // should otherwise always be usable.
507
+ console.error(
508
+ `Full report ${timeStamp}-${jobID} requested but unavailable (${report.error})`
444
509
  );
510
+ await sendAlert(
511
+ 'Kilotest: requested full report unavailable',
512
+ `Full report ${timeStamp}-${jobID} was requested but could not be retrieved: ${report.error}`
513
+ );
514
+ await serveError({message: 'ERROR: Invalid request'}, response, true);
445
515
  }
446
- // Otherwise, i.e. if the report is not hidden:
516
+ // Otherwise, i.e. if it succeeded:
447
517
  else {
448
- // Get it.
449
- const report = await getReport(timeStamp, jobID);
450
- // If this failed:
451
- if (report.error) {
452
- // Report this as suspected abuse.
453
- await serveError(
454
- getAbuseError(request, `Nonexistent report ${timeStamp}-${jobID} requested`),
455
- response,
456
- true
457
- );
458
- }
459
- // Otherwise, i.e. if it succeeded:
460
- else {
461
- // Serve response headers for a JSON download.
462
- setHeaders('application/json', null, 'low');
463
- response.setHeader(
464
- 'content-disposition', `attachment; filename="${timeStamp}-${jobID}.json"`,
465
- );
466
- // Download the report.
467
- response.end(getJSON(report));
468
- }
518
+ // Serve response headers for a JSON download.
519
+ setHeaders('application/json', null, 'low');
520
+ response.setHeader(
521
+ 'content-disposition', `attachment; filename="${timeStamp}-${jobID}.json"`,
522
+ );
523
+ // Download the report.
524
+ response.end(getJSON(report));
469
525
  }
470
526
  }
471
527
  // Otherwise, i.e. if the request is syntactically invalid:
@@ -505,8 +561,7 @@ const requestHandler = async (request, response) => {
505
561
  // If the service lists the available reports:
506
562
  if (service === 'listReports') {
507
563
  // Get the response body.
508
- const responseBody = await require(path.join(__dirname, 'api', 'listReports'))
509
- .response(specs);
564
+ const responseBody = await apiRespond.listReports([]);
510
565
  // Send it.
511
566
  setHeaders('application/json', null, 'ultra');
512
567
  response.end(JSON.stringify(responseBody));
@@ -514,8 +569,7 @@ const requestHandler = async (request, response) => {
514
569
  // Otherwise, if the service lists the issues in a report:
515
570
  else if (service === 'listIssues') {
516
571
  // Get the response body.
517
- const responseBody = await require(path.join(__dirname, 'api', 'listIssues'))
518
- .response(specs);
572
+ const responseBody = await apiRespond.listIssues(specs);
519
573
  // Send it.
520
574
  setHeaders('application/json', null, 'high');
521
575
  response.end(JSON.stringify(responseBody));
@@ -523,8 +577,7 @@ const requestHandler = async (request, response) => {
523
577
  // Otherwise, if the service lists the violators of an issue in a report:
524
578
  else if (service === 'listViolators') {
525
579
  // Get the response body.
526
- const responseBody = await require(path.join(__dirname, 'api', 'listViolators'))
527
- .response(specs);
580
+ const responseBody = await apiRespond.listViolators(specs);
528
581
  // Send it.
529
582
  setHeaders('application/json', null, 'high');
530
583
  response.end(JSON.stringify(responseBody));
@@ -532,8 +585,7 @@ const requestHandler = async (request, response) => {
532
585
  // Otherwise, if the service lists the diagnoses of a violation of an issue in a report:
533
586
  else if (service === 'listDiagnoses') {
534
587
  // Get the response body.
535
- const responseBody = await require(path.join(__dirname, 'api', 'listDiagnoses'))
536
- .response(specs);
588
+ const responseBody = await apiRespond.listDiagnoses(specs);
537
589
  // Send it.
538
590
  setHeaders('application/json', null, 'high');
539
591
  response.end(JSON.stringify(responseBody));
@@ -541,8 +593,7 @@ const requestHandler = async (request, response) => {
541
593
  // Otherwise, if the service serves a report:
542
594
  else if (service === 'getReport') {
543
595
  // Get the response body.
544
- const responseBody = await require(path.join(__dirname, 'api', 'getReport'))
545
- .response(specs);
596
+ const responseBody = await apiRespond.getReport(specs);
546
597
  // Send it.
547
598
  setHeaders('application/json', null, 'low');
548
599
  response.end(JSON.stringify(responseBody));
@@ -554,13 +605,22 @@ const requestHandler = async (request, response) => {
554
605
  }
555
606
  }
556
607
  // Otherwise, if it is for a tutorial image:
557
- else if (pathname.startsWith('/tutorial/images/')) {
558
- const imgFile = pathname.slice('/tutorial/images/'.length);
559
- const imgPath = path.join(__dirname, 'web', 'tutorial', 'images', imgFile);
608
+ else if (pathname.startsWith('/tutorialWeb/images/') || pathname.startsWith('/tutorialAI/images/')) {
609
+ let imgFile: string;
610
+ let tutorialDir: string;
611
+ if (pathname.startsWith('/tutorialWeb/images/')) {
612
+ imgFile = pathname.slice('/tutorialWeb/images/'.length);
613
+ tutorialDir = 'tutorialWeb';
614
+ }
615
+ else {
616
+ imgFile = pathname.slice('/tutorialAI/images/'.length);
617
+ tutorialDir = 'tutorialAI';
618
+ }
619
+ const imgPath = path.join(import.meta.dirname, 'web', tutorialDir, 'images', imgFile);
560
620
  try {
561
621
  const img = await fs.readFile(imgPath);
562
622
  const ext = path.extname(imgFile).toLowerCase();
563
- const mimeTypes = {
623
+ const mimeTypes: Record<string, string> = {
564
624
  '.png': 'image/png',
565
625
  '.jpg': 'image/jpeg',
566
626
  '.jpeg': 'image/jpeg',
@@ -579,7 +639,7 @@ const requestHandler = async (request, response) => {
579
639
  // Otherwise, if it is for the application icon:
580
640
  else if (pathname.includes('favicon.')) {
581
641
  // Get the site icon.
582
- const icon = await fs.readFile(path.join(__dirname, 'favicon.ico'));
642
+ const icon = await fs.readFile(path.join(import.meta.dirname, 'favicon.ico'));
583
643
  // Serve it.
584
644
  setHeaders('image/x-icon', null, 'low');
585
645
  response.write(icon, 'binary');
@@ -594,7 +654,7 @@ const requestHandler = async (request, response) => {
594
654
  response.end(styleSheet);
595
655
  }
596
656
  catch (error) {
597
- await serveError({message: error.message}, response, true);
657
+ await serveError({message: errorMessage(error)}, response, true);
598
658
  }
599
659
  }
600
660
  // Otherwise, i.e. if it is any other GET request:
@@ -619,13 +679,17 @@ const requestHandler = async (request, response) => {
619
679
  else {
620
680
  // Get the data from the request body.
621
681
  const postData = await getPOSTData(request);
622
- // If the request is a test recommendation:
623
- if (pageName === 'requestTest.html') {
624
- const {what, url, why} = postData;
682
+ // If the body could not be parsed (unknown content-type or malformed JSON):
683
+ if (postData === null) {
684
+ await serveError({message: 'ERROR: Unreadable request body'}, response, true);
685
+ }
686
+ // If the request is a test request:
687
+ else if (pageName === 'requestTest.html') {
688
+ const {description, url, why} = postData as {description?: string; url: string; why?: string};
625
689
  // If the request is valid:
626
- if (what && url.startsWith('https://') && why) {
690
+ if (description && url.startsWith('https://') && why) {
627
691
  // If a report on the page is already available:
628
- if (await isReportAvailable(what, url)) {
692
+ if (await isReportAvailable(description, url)) {
629
693
  // Report the error.
630
694
  await serveError({message: 'ERROR: Page has already been tested'}, response, true);
631
695
  }
@@ -634,8 +698,7 @@ const requestHandler = async (request, response) => {
634
698
  // Serve headers for a response.
635
699
  setHeaders('text/html', pathname, 'ultra');
636
700
  // Get the answer data.
637
- const answerData = await require(path.join(__dirname, 'web', 'requestTest', 'index'))
638
- .answer(what, url, why);
701
+ const answerData = await answer.requestTest(description, url, why);
639
702
  // If they are valid:
640
703
  if (answerData.status === 'ok') {
641
704
  // Serve the answer page.
@@ -651,20 +714,19 @@ const requestHandler = async (request, response) => {
651
714
  // Otherwise, i.e. if the request is invalid:
652
715
  else {
653
716
  // Report the error.
654
- await serveError({message: 'ERROR: Invalid test recommendation'}, response, true);
717
+ await serveError({message: 'ERROR: Invalid test request'}, response, true);
655
718
  }
656
719
  }
657
- // Otherwise, if it is a retest recommendation:
720
+ // Otherwise, if it is a retest request:
658
721
  else if (pageName === 'requestRetest.html') {
659
- const {why} = postData;
660
- const [timeStamp, jobID] = pathTail.split('/');
722
+ const {why} = postData as {why?: string};
723
+ const [timeStamp, jobID] = pathTail.split('/') as [string, string, ...string[]];
661
724
  // If the request is valid:
662
725
  if (isTimeStamp(timeStamp) && isJobID(jobID) && why) {
663
726
  // Serve response headers.
664
727
  setHeaders('text/html', pathname, 'ultra');
665
728
  // Get the answer data.
666
- const answerData = await require(path.join(__dirname, 'web', 'requestRetest', 'index'))
667
- .answer(pathTail, why);
729
+ const answerData = await answer.requestRetest(pathTail, why);
668
730
  // If they are valid:
669
731
  if (answerData.status === 'ok') {
670
732
  // Serve the answer page.
@@ -679,24 +741,23 @@ const requestHandler = async (request, response) => {
679
741
  // Otherwise, i.e. if the request is invalid:
680
742
  else {
681
743
  // Report the error.
682
- await serveError({message: 'ERROR: Invalid retest recommendation'}, response, true);
744
+ await serveError({message: 'ERROR: Invalid retest request'}, response, true);
683
745
  }
684
746
  }
685
747
  // Otherwise, if it is an approval or rejection of a test request:
686
- else if (pageName === 'recAction.html') {
687
- const {target, authCode} = postData;
688
- const [url, what] = target.split('\t');
748
+ else if (pageName === 'requestAction.html') {
749
+ const {target, authCode} = postData as {target: string; authCode?: string};
750
+ const [url, description] = target.split('\t') as [string, string];
689
751
  // If the request is valid:
690
752
  if (url.startsWith('https://') && authCode === process.env.AUTH_CODE) {
691
753
  // Set the non-location headers for a response.
692
754
  setHeaders('text/html', null, 'ultra');
693
755
  // If the request is an approval:
694
- if (what) {
756
+ if (description) {
695
757
  // Set a location header for a response.
696
758
  response.setHeader('content-location', pathname);
697
- // Process the approval and get the answer data about the remaining recommendations.
698
- const answerData = await require(path.join(__dirname, 'web', 'enqueue', 'index'))
699
- .answer(url, what, authCode);
759
+ // Process the approval and get the answer data about the remaining requests.
760
+ const answerData = await answer.enqueue(url, description, authCode);
700
761
  // If the answer data are valid:
701
762
  if (answerData.status === 'ok') {
702
763
  // Serve the test-order page with the remaining recommendations.
@@ -710,20 +771,12 @@ const requestHandler = async (request, response) => {
710
771
  }
711
772
  // Otherwise, i.e. if it is a rejection:
712
773
  else {
713
- // Isolate this revision.
714
- await recsLock(async () => {
715
- // Get the recommendations.
716
- const recs = await getRecs();
717
- // Delete the rejected URL.
718
- delete recs[url];
719
- // Save the revised recommendations.
720
- await fs.writeFile(path.join(jobsPath(), 'recs.json'), getJSON(recs));
721
- });
774
+ // Delete the test requests for the URL.
775
+ await deleteTestRequests(url);
722
776
  // Set a location header for a response.
723
777
  response.setHeader('content-location', '/enqueueForm.html');
724
778
  // Get the answer data.
725
- const answerData = await require(path.join(__dirname, 'web', 'enqueueForm', 'index'))
726
- .answer();
779
+ const answerData = await answer.enqueueForm();
727
780
  // Serve the test-order form with the remaining recommendations.
728
781
  response.end(answerData.answerPage);
729
782
  }
@@ -736,12 +789,11 @@ const requestHandler = async (request, response) => {
736
789
  }
737
790
  // Otherwise, if it is a reannotation order:
738
791
  else if (pageName === 'reannotate.html') {
739
- const {authCode} = postData;
792
+ const {authCode} = postData as {authCode?: string};
740
793
  // Set headers for a response.
741
794
  setHeaders('text/html', pathname, 'ultra');
742
795
  // Get the answer data.
743
- const answerData = await require(path.join(__dirname, 'web', 'reannotate', 'index'))
744
- .answer(authCode);
796
+ const answerData = await answer.reannotate(authCode);
745
797
  // If the answer data are valid:
746
798
  if (answerData.status === 'ok') {
747
799
  // Serve the answer page.
@@ -755,12 +807,11 @@ const requestHandler = async (request, response) => {
755
807
  }
756
808
  // Otherwise, if it is a WCAG map renewal:
757
809
  else if (pageName === 'renewWCAG.html') {
758
- const {authCode} = postData;
810
+ const {authCode} = postData as {authCode?: string};
759
811
  // Set headers for a response.
760
812
  setHeaders('text/html', pathname, 'low');
761
813
  // Get the answer data.
762
- const answerData = await require(path.join(__dirname, 'web', 'renewWCAG', 'index'))
763
- .answer(authCode);
814
+ const answerData = await answer.renewWCAG(authCode);
764
815
  // If the answer data are valid:
765
816
  if (answerData.status === 'ok') {
766
817
  // Serve the answer page.
@@ -787,37 +838,68 @@ const requestHandler = async (request, response) => {
787
838
  }
788
839
  // Otherwise, if it is report acquisition:
789
840
  else if (service === 'report') {
790
- const {report} = postData;
791
- const reportObj = report || {};
841
+ const {report} = postData as {report?: Report};
842
+ const reportObj: Partial<Report> = report ?? {};
792
843
  const {id, target} = reportObj;
793
- const {what, url} = target || {};
794
- const [timeStamp, jobID] = id?.split('-') ?? ['', ''];
844
+ const {what: description, url} = (target ?? {}) as Partial<NonNullable<Report['target']>>;
845
+ const [timeStamp, jobID] = (id?.split('-') ?? ['', '']) as [string, string];
795
846
  // If the request is syntactically valid:
796
- if (id && isTimeStamp(timeStamp) && isJobID(jobID) && what && url) {
797
- const [timeStamp, jobID] = id.split('-');
798
- // Get the job the report is from.
799
- const claimedJob = await getObject(path.join(claimedPath(), `${id}.json`));
847
+ if (id && isTimeStamp(timeStamp) && isJobID(jobID) && description && url) {
848
+ // Get the job the report is from. A missing claimed-job file (the job was
849
+ // never claimed, or was already completed) is a normal outcome, handled below
850
+ // as if the job were not assigned to this worker; any other failure to read it
851
+ // should never occur and is left to propagate.
852
+ let claimedJob: unknown = null;
853
+ try {
854
+ claimedJob = await getObject(path.join(claimedPath(), `${id}.json`));
855
+ }
856
+ catch (error: unknown) {
857
+ if (!(error instanceof Error && (error.cause as NodeJS.ErrnoException | undefined)?.code === 'ENOENT')) {
858
+ throw error;
859
+ }
860
+ }
800
861
  // If the job was actually assigned to this worker:
801
- if (typeof claimedJob === 'object' && claimedJob.sources?.worker === workerName) {
802
- console.log(`Testaro report ${id} was received from worker ${workerName}`);
803
- // Add the public worker name to the report.
804
- report.sources = {...report.sources, worker: workerName};
805
- // Save the report.
806
- await fs.writeFile(getReportPath(timeStamp, jobID), getJSON(report));
807
- // Annotate the report.
808
- await annotateReport(timeStamp, jobID);
809
- console.log(`Testaro report ${id} was annotated, saved, and indexed`);
810
- // Check the monetary balances and send alerts if nearing exhaustion.
811
- await checkBalancesForAlerts(report);
812
- // Delete the job.
813
- await fs.unlink(path.join(claimedPath(), `${id}.json`));
814
- console.log(`Completed job ${id} deleted`);
815
- // Acknowledge receipt.
816
- response.setHeader('content-type', 'application/json; charset=utf-8');
817
- response.end(JSON.stringify({status: 'ok'}));
818
- console.log(
819
- `Testaro report ${id} was received from Testaro worker ${workerName}`
820
- );
862
+ if (typeof claimedJob === 'object' && claimedJob !== null && (claimedJob as {sources?: {worker?: string}}).sources?.worker === workerName) {
863
+ // If the report is usable by Kilotest (as any report of an assigned job should be):
864
+ if (isUsableReport(reportObj)) {
865
+ console.log(`Testaro report ${id} was received from worker ${workerName}`);
866
+ // Add the public worker name to the report.
867
+ report!.sources = {...report!.sources, worker: workerName};
868
+ // Annotate the report before it is ever written, rather than writing it,
869
+ // reading it back, annotating that copy, and writing it again: the read-back
870
+ // could fail even though the just-written report is fine.
871
+ await annotateReportObject(reportObj);
872
+ // Save the annotated report.
873
+ await fs.writeFile(getReportPath(timeStamp, jobID), getJSON(report));
874
+ console.log(`Testaro report ${id} was annotated and saved`);
875
+ // Check the monetary balances and send alerts if nearing exhaustion.
876
+ await checkBalancesForAlerts(report!);
877
+ // Delete the job.
878
+ await fs.unlink(path.join(claimedPath(), `${id}.json`));
879
+ console.log(`Completed job ${id} deleted`);
880
+ // Acknowledge receipt.
881
+ response.setHeader('content-type', 'application/json; charset=utf-8');
882
+ response.end(JSON.stringify({status: 'ok'}));
883
+ console.log(
884
+ `Testaro report ${id} was received from Testaro worker ${workerName}`
885
+ );
886
+ }
887
+ // Otherwise, i.e. if the report is not usable by Kilotest:
888
+ else {
889
+ console.error(`ERROR: Report ${id} from worker ${workerName} is not usable`);
890
+ // Alert a manager, since an assigned job should never produce an unusable report.
891
+ await sendAlert(
892
+ 'Kilotest: unusable report received',
893
+ `Job ${id} from worker ${workerName} produced a report that Kilotest cannot use. The job was reclassified as failed instead of being recorded.`
894
+ );
895
+ // Reclassify the job as failed, instead of recording the unusable report or
896
+ // leaving the job claimed indefinitely.
897
+ await fs.rename(
898
+ path.join(claimedPath(), `${id}.json`), path.join(failedPath(), `${id}.json`)
899
+ );
900
+ // Report the error.
901
+ await serveError({message: `ERROR: Report ${id} is not usable`}, response, false);
902
+ }
821
903
  }
822
904
  // Otherwise, i.e. if the job was not assigned to this worker:
823
905
  else {
@@ -851,30 +933,27 @@ const requestHandler = async (request, response) => {
851
933
  const segments = pathTail.split('/');
852
934
  // If the service is to receive a test request:
853
935
  if (segments[0] === 'requestTest') {
854
- const {description, URL, reason} = postData;
936
+ const {description, URL, reason} = postData as {description: string; URL: string; reason: string};
855
937
  // Get the response body.
856
- const responseBody = await require(path.join(__dirname, 'api', 'requestTest'))
857
- .response([description, URL, reason]);
938
+ const responseBody = await apiRespond.requestTest([description, URL, reason]);
858
939
  // Send it.
859
940
  setHeaders('application/json', null, 'ultra');
860
941
  response.end(JSON.stringify(responseBody));
861
942
  }
862
943
  // Otherwise, if the service is to receive a retest request:
863
944
  else if (segments[0] === 'requestRetest') {
864
- const {reason} = postData;
945
+ const {reason} = postData as {reason: string};
865
946
  // Get the response body.
866
- const responseBody = await require(path.join(__dirname, 'api', 'requestRetest'))
867
- .response(segments.slice(1).concat(reason));
947
+ const responseBody = await apiRespond.requestRetest(segments.slice(1).concat(reason));
868
948
  // Send it.
869
949
  setHeaders('application/json', null, 'ultra');
870
950
  response.end(JSON.stringify(responseBody));
871
951
  }
872
952
  // Otherwise, if the service is to receive a feature request:
873
953
  else if (segments[0] === 'requestFeature') {
874
- const {feature} = postData;
954
+ const {feature} = postData as {feature: string};
875
955
  // Get the response body.
876
- const responseBody = await require(path.join(__dirname, 'api', 'requestFeature'))
877
- .response([feature]);
956
+ const responseBody = await apiRespond.requestFeature([feature]);
878
957
  // Send it.
879
958
  setHeaders('application/json', null, 'ultra');
880
959
  response.end(JSON.stringify(responseBody));
@@ -887,10 +966,23 @@ const requestHandler = async (request, response) => {
887
966
  }
888
967
  }
889
968
  // Otherwise, if it is a tutorial comment:
890
- else if (pageName === 'tutorialComment.html') {
891
- const {content} = postData;
969
+ else if (pageName === 'tutorialWebComment.html') {
970
+ const {content} = postData as {content?: unknown};
892
971
  setHeaders('application/json', null, 'low');
893
- const answerData = await handleComment(content);
972
+ const answerData = await handleTutorialWebComment(content);
973
+ if (answerData.status === 'ok') {
974
+ response.end(JSON.stringify({status: 'ok'}));
975
+ }
976
+ else {
977
+ response.statusCode = 400;
978
+ response.end(JSON.stringify({status: 'error', message: answerData.message}));
979
+ }
980
+ }
981
+ // Otherwise, if it is an AI tutorial comment:
982
+ else if (pageName === 'tutorialAIComment.html') {
983
+ const {content} = postData as {content?: unknown};
984
+ setHeaders('application/json', null, 'low');
985
+ const answerData = await handleTutorialAIComment(content);
894
986
  if (answerData.status === 'ok') {
895
987
  response.end(JSON.stringify({status: 'ok'}));
896
988
  }
@@ -907,14 +999,26 @@ const requestHandler = async (request, response) => {
907
999
  await serveError({message: 'ERROR: Invalid request method'}, response, true);
908
1000
  }
909
1001
  };
1002
+ // Handles a request, converting any error not already handled by handleRequest (i.e. one
1003
+ // that should never occur, such as a job or report file becoming unreadable or corrupt)
1004
+ // into a safe response instead of letting it crash the process as an unhandled rejection.
1005
+ const requestHandler = async (request: IncomingMessage, response: ServerResponse) => {
1006
+ try {
1007
+ await handleRequest(request, response);
1008
+ }
1009
+ catch (error: unknown) {
1010
+ console.error(`ERROR: Unhandled request error (${errorMessage(error)})`);
1011
+ await serveError({message: 'ERROR: Internal server error'}, response, false, 500);
1012
+ }
1013
+ };
910
1014
 
911
1015
  // EXPORTS
912
1016
 
913
- exports.requestHandler = requestHandler;
1017
+ export {requestHandler};
914
1018
 
915
1019
  // SERVER
916
1020
 
917
- const serve = async (protocolModule, options) => {
1021
+ const serve = async (protocolModule: typeof http | typeof https, options: {key?: string; cert?: string}) => {
918
1022
  // Create any missing directories.
919
1023
  for (const path of [queuePath(), claimedPath(), failedPath(), hiddenReportsPath(), reportsPath()]) {
920
1024
  await fs.mkdir(path, {recursive: true});
@@ -923,16 +1027,17 @@ const serve = async (protocolModule, options) => {
923
1027
  ? https.createServer(options, requestHandler)
924
1028
  : http.createServer(requestHandler);
925
1029
  const port = process.env.PORT || '3000';
1030
+ const protocol = process.env.PROTOCOL || 'http';
926
1031
  server.listen(port, () => {
927
1032
  console.log(`Kilotest server listening at ${protocol}://localhost:${port}.`);
928
1033
  });
929
1034
  return server;
930
1035
  };
931
1036
 
932
- exports.serve = serve;
1037
+ export {serve};
933
1038
 
934
1039
  // Starts the server using the configured protocol and credentials.
935
- exports.startServer = async () => {
1040
+ export const startServer = async () => {
936
1041
  const startProtocol = process.env.PROTOCOL || 'http';
937
1042
  if (startProtocol === 'http') {
938
1043
  console.log('Starting HTTP server');
@@ -940,19 +1045,19 @@ exports.startServer = async () => {
940
1045
  }
941
1046
  else if (startProtocol === 'https') {
942
1047
  console.log('Starting HTTPS server');
943
- const key = await fs.readFile(process.env.KEY, 'utf8');
944
- const cert = await fs.readFile(process.env.CERT, 'utf8');
1048
+ const key = await fs.readFile(process.env.KEY as string, 'utf8');
1049
+ const cert = await fs.readFile(process.env.CERT as string, 'utf8');
945
1050
  return serve(https, {key, cert});
946
1051
  }
947
1052
  };
948
1053
 
949
- // Runs the server if the module was loaded directly (not required by a test).
950
- exports.runIfMain = (mainModule, currentModule) => {
1054
+ // Runs the server if the module was loaded directly (not required by a test). The starter is a parameter so tests can inject a spy, since ESM module exports cannot be monkey-patched.
1055
+ export const runIfMain = (mainModule: unknown, currentModule: unknown, starter: () => Promise<unknown> = startServer) => {
951
1056
  if (mainModule === currentModule) {
952
- exports.startServer().catch(error => console.log(error.message));
1057
+ starter().catch(error => console.log(errorMessage(error)));
953
1058
  }
954
1059
  };
955
1060
 
956
1061
  // EXECUTION
957
1062
 
958
- exports.runIfMain(require.main, module);
1063
+ runIfMain(import.meta.main, true);