@jrpool/kilotest 46.0.7 → 46.0.11

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 (54) hide show
  1. package/.devin/config.local.json +3 -1
  2. package/.github/workflows/ci.yml +32 -0
  3. package/alerts.test.js +137 -0
  4. package/api/listDiagnoses.test.js +44 -0
  5. package/api/listIssues.js +1 -1
  6. package/api/listIssues.test.js +10 -0
  7. package/api/listReports.test.js +6 -2
  8. package/api/listViolators.test.js +35 -0
  9. package/api/requestTest.test.js +7 -0
  10. package/api/routes.test.js +63 -0
  11. package/api/util.js +8 -10
  12. package/api/util.test.js +68 -2
  13. package/apiIntegration.test.js +92 -0
  14. package/docs/CI.json +55 -0
  15. package/docs/SERVICE.md +25 -1
  16. package/index.js +109 -66
  17. package/index.test.js +1152 -0
  18. package/indexBalances.test.js +188 -0
  19. package/indexFallback.test.js +33 -0
  20. package/indexServe.test.js +59 -0
  21. package/indexWorkers.test.js +79 -0
  22. package/mcp.js +2 -2
  23. package/mcp.test.js +281 -0
  24. package/package.json +11 -5
  25. package/routes.test.js +69 -0
  26. package/routing.test.js +13 -3
  27. package/smokeTest.js +123 -0
  28. package/test/fixtures/buildFixtures.js +83 -0
  29. package/test/fixtures/db/reports/260101T0008-mul.json +137 -0
  30. package/test/fixtures/db/reports/260101T0009-brd.json +147 -0
  31. package/test/setup.cjs +14 -0
  32. package/util.js +16 -6
  33. package/util.test.js +886 -3
  34. package/web/ai0BalanceForm/ai0BalanceForm.test.js +88 -0
  35. package/web/expungeReportsForm/expungeReportsForm.test.js +114 -0
  36. package/web/hideReportForm/hideReportForm.test.js +82 -0
  37. package/web/listDiagnoses/listDiagnoses.test.js +160 -0
  38. package/web/listIssues/listIssues.test.js +109 -0
  39. package/web/listReports/listReports.test.js +139 -3
  40. package/web/listRules/listRules.test.js +103 -0
  41. package/web/listTopIssues/listTopIssues.test.js +126 -0
  42. package/web/listViolators/index.js +1 -2
  43. package/web/listViolators/listViolators.test.js +256 -0
  44. package/web/pruneReportsForm/pruneReportsForm.test.js +86 -0
  45. package/web/reannotate/reannotate.test.js +144 -0
  46. package/web/reannotateForm/reannotateForm.test.js +134 -0
  47. package/web/renewWCAG/renewWCAG.test.js +124 -0
  48. package/web/requestRetest/requestRetest.test.js +119 -0
  49. package/web/requestRetestForm/requestRetestForm.test.js +62 -0
  50. package/web/requestTest/requestTest.test.js +115 -0
  51. package/web/requestTestForm/requestTestForm.test.js +40 -0
  52. package/web/rewindReportsForm/rewindReportsForm.test.js +86 -0
  53. package/web/tutorial/tutorial.test.js +97 -0
  54. package/web/unhideReportForm/unhideReportForm.test.js +135 -0
package/docs/SERVICE.md CHANGED
@@ -203,7 +203,7 @@ kilotest.com {
203
203
  }
204
204
  @allowedPOST {
205
205
  method POST
206
- path /mcp /requestRetest.html/* /requestTest.html /recAction.html /reannotate.html /renewWCAG.html /api/* /tutorialComment.html
206
+ path /mcp /requestRetest.html/* /requestTest.html /recAction.html /reannotate.html /renewWCAG.html /api/* /tutorialComment.html /worker/job /worker/report
207
207
  }
208
208
  # Respond to OPTIONS requests.
209
209
  @allowedOPTIONS method OPTIONS
@@ -259,6 +259,30 @@ When a new version of the `kilotest` package has been published, the service can
259
259
  1. Update the Playwright browsers: `npx playwright install`
260
260
  1. Restart the service: `pm2 restart kilotest`
261
261
 
262
+ ## Branch protection
263
+
264
+ The `main` branch on GitHub is protected to prevent direct pushes and to require a passing smoke test before any pull request can merge. The settings are configured in the GitHub repository as a ruleset under Settings > Branches > Branch protection rules.
265
+
266
+ The required settings are:
267
+
268
+ 1. Require a pull request before merging.
269
+ 1. Require status checks to pass before merging.
270
+ 1. Add "Smoke Test" as a required status check.
271
+ 1. Require branches to be up to date before merging.
272
+ 1. Do not allow bypassing the above settings.
273
+
274
+ A copy of the ruleset is located at `docs/Smoke Test.json`.
275
+
276
+ The smoke-test check is defined in `.github/workflows/smoke-test.yml` and runs `smokeTest.js`, which sends HTTPS requests to all valid GET and POST paths on the deployed server and verifies that Caddy forwards each one to Kilotest rather than returning a bare 404.
277
+
278
+ When a pull request adds a new route to `index.js`, the Caddyfile at `/etc/caddy/Caddyfile` on the server must be updated to forward the new path before the pull request can merge. The workflow to follow is:
279
+
280
+ 1. Add the new route to the `routes` constant and the dispatch chain in `index.js`.
281
+ 1. Update the Caddyfile (`/etc/caddy/Caddyfile`) on the server to forward the new path.
282
+ 1. Open or update the pull request.
283
+ 1. The smoke test workflow runs and verifies that all paths, including the new one, are forwarded by Caddy.
284
+ 1. If the Caddyfile was not updated, the smoke test reports a bare 404 for the new path, the required status check fails, and the pull request cannot merge.
285
+
262
286
  ## Performance
263
287
 
264
288
  The Cloud Compute host, in initial testing, took about 2.5 as long to process an example job as an Apple M2 Pro MacBook Pro with 16GB of memory. After tuning, the ratio was reduced to about 1.7.
package/index.js CHANGED
@@ -63,10 +63,42 @@ const answer = {
63
63
 
64
64
  // CONSTANTS
65
65
 
66
+ // 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 = {
68
+ GET: [
69
+ '/mcp',
70
+ '/',
71
+ '/index.html',
72
+ '/robots.txt',
73
+ '/openapi.yaml',
74
+ '/openapi.json',
75
+ '/swagger.yaml',
76
+ '/swagger.json',
77
+ '/api-docs',
78
+ '/llms.txt',
79
+ '/llms-full.txt',
80
+ '/sitemap.xml',
81
+ '/style.css',
82
+ '/fullReport.json/*',
83
+ '/api/*',
84
+ '/tutorial/images/*',
85
+ '/favicon.*',
86
+ '*.html*'
87
+ ],
88
+ POST: [
89
+ '/mcp',
90
+ '/requestTest.html',
91
+ '/requestRetest.html/*',
92
+ '/recAction.html',
93
+ '/reannotate.html',
94
+ '/renewWCAG.html',
95
+ '/worker/job',
96
+ '/worker/report',
97
+ '/api/*',
98
+ '/tutorialComment.html'
99
+ ]
100
+ };
66
101
  const protocol = process.env.PROTOCOL || 'http';
67
- const queuePath = () => path.join(jobsPath(), 'queue');
68
- const claimedPath = () => path.join(jobsPath(), 'claimed');
69
- const failedPath = () => path.join(jobsPath(), 'failed');
70
102
  // Credentials of the Testaro workers, by worker ID, from a JSON-object environment variable.
71
103
  // Each worker ID maps to a secret (used only to authenticate the worker, never published) and a
72
104
  // name (a non-secret label safe to publish, e.g. in report data and logs).
@@ -89,10 +121,26 @@ const AI_MODEL0_OUTPUT_PRICE = Number(process.env.AI_MODEL0_OUTPUT_PRICE);
89
121
 
90
122
  // FUNCTIONS
91
123
 
124
+ const queuePath = () => path.join(jobsPath(), 'queue');
125
+ const claimedPath = () => path.join(jobsPath(), 'claimed');
126
+ const failedPath = () => path.join(jobsPath(), 'failed');
127
+ // Returns whether a pathname matches a glob-style pattern.
128
+ const matchPath = (pattern, pathname) => {
129
+ const regex = new RegExp(
130
+ '^' + pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$'
131
+ );
132
+ return regex.test(pathname);
133
+ };
134
+ // Returns whether a pathname is authorized for a method.
135
+ const isPathAllowed = exports.isPathAllowed = (method, pathname) => {
136
+ const patterns = routes[method] || [];
137
+ return patterns.some(pattern => matchPath(pattern, pathname));
138
+ };
92
139
  // Serves or sends an error message.
93
- const serveError = async (error, response, isHumanUser = true, statusCode = 400) => {
140
+ const serveError = exports.serveError = async (error, response, isHumanUser = true, statusCode = 400) => {
94
141
  const errorLines = Object.entries(error).map(pair => `${pair[0]}: ${pair[1]}`);
95
- console.log(errorLines.join('\n') || 'ERROR');
142
+ const errorSummary = errorLines.join('\n') || 'ERROR';
143
+ console.log(errorSummary);
96
144
  if (!response.writableEnded) {
97
145
  response.statusCode = statusCode;
98
146
  // If the request is from a human user:
@@ -103,7 +151,8 @@ const serveError = async (error, response, isHumanUser = true, statusCode = 400)
103
151
  response.setHeader('Access-Control-Allow-Origin', '*');
104
152
  response.setHeader('Cache-Control', 'public, max-age=300, stale-while-revalidate=3000');
105
153
  const errorTemplate = await fs.readFile('error.html', 'utf8');
106
- const errorPage = errorTemplate.replace(/__error__/, error.message || 'ERROR');
154
+ const errorMessage = error.message || 'ERROR';
155
+ const errorPage = errorTemplate.replace(/__error__/, errorMessage);
107
156
  response.end(errorPage);
108
157
  }
109
158
  // Otherwise, i.e. if it is from an agent:
@@ -159,21 +208,16 @@ const checkBalancesForAlerts = async report => {
159
208
  const balanceData = JSON.parse(balanceJSON);
160
209
  // Get an estimate of the balance after this job.
161
210
  const newBalance = balanceData.balance - cost;
162
- if (typeof newBalance === 'number') {
163
- // Update the recorded balance.
164
- await fs.writeFile(balancePath, getJSON({balance: newBalance}));
165
- console.log(`Estimated new AI Service 0 balance: $${newBalance.toFixed(2)}`);
166
- // If it is nearing exhaustion:
167
- if (newBalance < AI_SERVICE0_THRESHOLD) {
168
- // Alert a manager.
169
- await sendAlert(
170
- 'Kilotest: AI service 0 balance low',
171
- `Balance of AI service 0 account (https://console.anthropic.com) only about $${newBalance.toFixed(2)} (about $0.01 used per job)`
172
- );
173
- }
174
- }
175
- else {
176
- console.log('ERROR: AI service 0 balance is not a number');
211
+ // Update the recorded balance.
212
+ await fs.writeFile(balancePath, getJSON({balance: newBalance}));
213
+ console.log(`Estimated new AI Service 0 balance: $${newBalance.toFixed(2)}`);
214
+ // If it is nearing exhaustion:
215
+ if (newBalance < AI_SERVICE0_THRESHOLD) {
216
+ // Alert a manager.
217
+ await sendAlert(
218
+ 'Kilotest: AI service 0 balance low',
219
+ `Balance of AI service 0 account (https://console.anthropic.com) only about $${newBalance.toFixed(2)} (about $0.01 used per job)`
220
+ );
177
221
  }
178
222
  }
179
223
  catch (error) {
@@ -183,9 +227,11 @@ const checkBalancesForAlerts = async report => {
183
227
  }
184
228
  };
185
229
  // Creates an error object about a suspicious request.
186
- const getAbuseError = (request, reason) => {
230
+ const getAbuseError = exports.getAbuseError = (request, reason) => {
187
231
  const {method, url, headers} = request;
188
- const ip = headers['x-forwarded-for'] || request.socket.remoteAddress || 'unknown';
232
+ const forwardedFor = headers['x-forwarded-for'];
233
+ const remoteAddress = request.socket.remoteAddress;
234
+ const ip = forwardedFor || remoteAddress || 'unknown';
189
235
  return {
190
236
  message: 'Invalid request',
191
237
  reason,
@@ -205,13 +251,7 @@ const getBasicAuth = request => {
205
251
  if (!match) {
206
252
  return null;
207
253
  }
208
- let decoded;
209
- try {
210
- decoded = Buffer.from(match[1], 'base64').toString('utf8');
211
- }
212
- catch {
213
- return null;
214
- }
254
+ const decoded = Buffer.from(match[1], 'base64').toString('utf8');
215
255
  const sepIndex = decoded.indexOf(':');
216
256
  if (sepIndex === -1) {
217
257
  return null;
@@ -321,8 +361,12 @@ const requestHandler = async (request, response) => {
321
361
  const pathTail = pathname.split('/').slice(2).join('/');
322
362
  // If the request is a GET request:
323
363
  if (method === 'GET') {
364
+ // If the path is not authorized for GET requests:
365
+ if (!isPathAllowed('GET', pathname)) {
366
+ await serveError({message: `ERROR: Invalid GET request (${pathname})`}, response, true);
367
+ }
324
368
  // If it is for the model context protocol server:
325
- if (pathname === mcpPath) {
369
+ else if (pathname === mcpPath) {
326
370
  // Handle the MCP request.
327
371
  await handleMCP(request, response);
328
372
  }
@@ -516,7 +560,8 @@ const requestHandler = async (request, response) => {
516
560
  '.webp': 'image/webp',
517
561
  '.svg': 'image/svg+xml'
518
562
  };
519
- setHeaders(mimeTypes[ext] || 'application/octet-stream', null, 'low');
563
+ const mimeType = mimeTypes[ext] || 'application/octet-stream';
564
+ setHeaders(mimeType, null, 'low');
520
565
  response.end(img);
521
566
  }
522
567
  catch {
@@ -554,8 +599,12 @@ const requestHandler = async (request, response) => {
554
599
  }
555
600
  // Otherwise, if the request is a POST request:
556
601
  else if (method === 'POST') {
602
+ // If the path is not authorized for POST requests:
603
+ if (!isPathAllowed('POST', pathname)) {
604
+ await serveError({message: 'ERROR: Invalid POST request'}, response, true);
605
+ }
557
606
  // If it is for the model context protocol server:
558
- if (pageName === 'mcp') {
607
+ else if (pageName === 'mcp') {
559
608
  await handleMCP(request, response);
560
609
  }
561
610
  // Otherwise, i.e. if it is not for the MCP server:
@@ -731,8 +780,9 @@ const requestHandler = async (request, response) => {
731
780
  // Otherwise, if it is report acquisition:
732
781
  else if (service === 'report') {
733
782
  const {report} = postData;
734
- const {id, target} = report;
735
- const {what, url} = target;
783
+ const reportObj = report || {};
784
+ const {id, target} = reportObj;
785
+ const {what, url} = target || {};
736
786
  const [timeStamp, jobID] = id?.split('-') ?? ['', ''];
737
787
  // If the request is syntactically valid:
738
788
  if (id && isTimeStamp(timeStamp) && isJobID(jobID) && what && url) {
@@ -778,12 +828,6 @@ const requestHandler = async (request, response) => {
778
828
  await serveError({message: 'ERROR: Request invalid'}, response, false);
779
829
  }
780
830
  }
781
- // Otherwise, i.e. if the service is invalid:
782
- else {
783
- await serveError(
784
- {message: 'ERROR: Invalid service request from Testaro worker'}, response, false
785
- );
786
- }
787
831
  }
788
832
  // Otherwise, i.e. if it is not authenticated:
789
833
  else {
@@ -847,11 +891,6 @@ const requestHandler = async (request, response) => {
847
891
  response.end(JSON.stringify({status: 'error', message: answerData.message}));
848
892
  }
849
893
  }
850
- // Otherwise, i.e. if it is any other POST request:
851
- else {
852
- // Report its invalidity.
853
- await serveError({message: 'ERROR: Invalid POST request'}, response, true);
854
- }
855
894
  }
856
895
  }
857
896
  // Otherwise, i.e. if it is neither a GET nor a POST request:
@@ -872,36 +911,40 @@ const serve = async (protocolModule, options) => {
872
911
  for (const path of [queuePath(), claimedPath(), failedPath(), hiddenReportsPath(), reportsPath()]) {
873
912
  await fs.mkdir(path, {recursive: true});
874
913
  }
875
- const server = protocolModule === 'https'
914
+ const server = protocolModule === https
876
915
  ? https.createServer(options, requestHandler)
877
916
  : http.createServer(requestHandler);
878
917
  const port = process.env.PORT || '3000';
879
918
  server.listen(port, () => {
880
919
  console.log(`Kilotest server listening at ${protocol}://localhost:${port}.`);
881
920
  });
921
+ return server;
882
922
  };
883
923
 
884
- // EXECUTION
924
+ exports.serve = serve;
885
925
 
886
- if (require.main === module) {
887
- if (protocol === 'http') {
926
+ // Starts the server using the configured protocol and credentials.
927
+ exports.startServer = async () => {
928
+ const startProtocol = process.env.PROTOCOL || 'http';
929
+ if (startProtocol === 'http') {
888
930
  console.log('Starting HTTP server');
889
- serve(http, {});
931
+ return serve(http, {});
890
932
  }
891
- else if (protocol === 'https') {
933
+ else if (startProtocol === 'https') {
892
934
  console.log('Starting HTTPS server');
893
- fs.readFile(process.env.KEY, 'utf8')
894
- .then(
895
- key => {
896
- fs.readFile(process.env.CERT, 'utf8')
897
- .then(
898
- cert => {
899
- serve(https, {key, cert});
900
- },
901
- error => console.log(error.message)
902
- );
903
- },
904
- error => console.log(error.message)
905
- );
935
+ const key = await fs.readFile(process.env.KEY, 'utf8');
936
+ const cert = await fs.readFile(process.env.CERT, 'utf8');
937
+ return serve(https, {key, cert});
938
+ }
939
+ };
940
+
941
+ // Runs the server if the module was loaded directly (not required by a test).
942
+ exports.runIfMain = (mainModule, currentModule) => {
943
+ if (mainModule === currentModule) {
944
+ exports.startServer().catch(error => console.log(error.message));
906
945
  }
907
- }
946
+ };
947
+
948
+ // EXECUTION
949
+
950
+ exports.runIfMain(require.main, module);