@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.
- package/.devin/config.local.json +3 -1
- package/.github/workflows/ci.yml +32 -0
- package/alerts.test.js +137 -0
- package/api/listDiagnoses.test.js +44 -0
- package/api/listIssues.js +1 -1
- package/api/listIssues.test.js +10 -0
- package/api/listReports.test.js +6 -2
- package/api/listViolators.test.js +35 -0
- package/api/requestTest.test.js +7 -0
- package/api/routes.test.js +63 -0
- package/api/util.js +8 -10
- package/api/util.test.js +68 -2
- package/apiIntegration.test.js +92 -0
- package/docs/CI.json +55 -0
- package/docs/SERVICE.md +25 -1
- package/index.js +109 -66
- package/index.test.js +1152 -0
- package/indexBalances.test.js +188 -0
- package/indexFallback.test.js +33 -0
- package/indexServe.test.js +59 -0
- package/indexWorkers.test.js +79 -0
- package/mcp.js +2 -2
- package/mcp.test.js +281 -0
- package/package.json +11 -5
- package/routes.test.js +69 -0
- package/routing.test.js +13 -3
- package/smokeTest.js +123 -0
- package/test/fixtures/buildFixtures.js +83 -0
- package/test/fixtures/db/reports/260101T0008-mul.json +137 -0
- package/test/fixtures/db/reports/260101T0009-brd.json +147 -0
- package/test/setup.cjs +14 -0
- package/util.js +16 -6
- package/util.test.js +886 -3
- package/web/ai0BalanceForm/ai0BalanceForm.test.js +88 -0
- package/web/expungeReportsForm/expungeReportsForm.test.js +114 -0
- package/web/hideReportForm/hideReportForm.test.js +82 -0
- package/web/listDiagnoses/listDiagnoses.test.js +160 -0
- package/web/listIssues/listIssues.test.js +109 -0
- package/web/listReports/listReports.test.js +139 -3
- package/web/listRules/listRules.test.js +103 -0
- package/web/listTopIssues/listTopIssues.test.js +126 -0
- package/web/listViolators/index.js +1 -2
- package/web/listViolators/listViolators.test.js +256 -0
- package/web/pruneReportsForm/pruneReportsForm.test.js +86 -0
- package/web/reannotate/reannotate.test.js +144 -0
- package/web/reannotateForm/reannotateForm.test.js +134 -0
- package/web/renewWCAG/renewWCAG.test.js +124 -0
- package/web/requestRetest/requestRetest.test.js +119 -0
- package/web/requestRetestForm/requestRetestForm.test.js +62 -0
- package/web/requestTest/requestTest.test.js +115 -0
- package/web/requestTestForm/requestTestForm.test.js +40 -0
- package/web/rewindReportsForm/rewindReportsForm.test.js +86 -0
- package/web/tutorial/tutorial.test.js +97 -0
- 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
|
-
|
|
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
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
735
|
-
const {
|
|
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 ===
|
|
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
|
-
|
|
924
|
+
exports.serve = serve;
|
|
885
925
|
|
|
886
|
-
|
|
887
|
-
|
|
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 (
|
|
933
|
+
else if (startProtocol === 'https') {
|
|
892
934
|
console.log('Starting HTTPS server');
|
|
893
|
-
fs.readFile(process.env.KEY, 'utf8')
|
|
894
|
-
.
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
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);
|