@jrpool/kilotest 39.0.3 → 41.0.0

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.
@@ -0,0 +1,7 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Exec(git status)"
5
+ ]
6
+ }
7
+ }
package/README.md CHANGED
@@ -18,7 +18,7 @@ Testing for Kilotest is performed by one or more testing agents that obtain jobs
18
18
 
19
19
  An active production instance of Kilotest may require multiple testing agents to handle the load, because testing one web page typically takes about 3 minutes and agents test only one page at a time.
20
20
 
21
- ## Getting started locally with 1 testing agent
21
+ ## Getting started locally with 1 testing worker
22
22
 
23
23
  ### Installation
24
24
 
@@ -30,7 +30,7 @@ In the steps below, hosts `T` and `K` may be the same host or two different host
30
30
  1. Clone the Kilotest repository into a new directory on host `K`.
31
31
  1. In that directory, install the Kilotest dependencies: `npm install`.
32
32
  1. Copy the `env.testaro` file from the `kilotest` directory to `.env` in the `testaro` directory and replace the `__placeholder__` values in `.env` with actual values.
33
- 1. Copy the `env.example` file in the `kilotest` directory to a new `.env` file in the same directory and replace the `__placeholder__` values in `.env` with actual values.
33
+ 1. Copy the `env.example` file in the `kilotest` directory to a new `.env` file in the same directory and replace the `__placeholder__` values in `.env` with actual values. `TESTARO_WORKERS` is a JSON object mapping each Testaro worker's ID to an object with its `secret` and its `name`; the ID and secret for a worker must match the `NETWATCH_URL_WORKER_ID` and `NETWATCH_URL_AUTH` values in that worker's `.env`. A worker authenticates each request with an HTTP Basic `Authorization` header (`id:secret`, base64-encoded); as of this writing that header is not yet sent by the published Testaro package, so this scheme requires a matching update to Testaro's `netWatch.js` request code (only to send the header; no change to Testaro's handling of jobs or reports is needed). The ID is a credential and is never published; the `name` is written by Kilotest into a job's and report's `sources.worker` property, which Testaro does not read or alter, so it is a distinct property from `sources.agent` (Testaro's own, untrusted, self-reported label, which Kilotest ignores). Choose a `name` that is safe to appear in every report Kilotest serves.
34
34
 
35
35
  ### Usage
36
36
 
package/alerts.js CHANGED
@@ -28,6 +28,7 @@ exports.sendAlert = (subject, body) => new Promise(resolve => {
28
28
  subject,
29
29
  text: body
30
30
  });
31
+ // Ask the alerting host to send an alert to the manager.
31
32
  const req = https.request({
32
33
  hostname: ALERT_API_HOST,
33
34
  path: ALERT_API_PATH,
@@ -43,10 +44,14 @@ exports.sendAlert = (subject, body) => new Promise(resolve => {
43
44
  data += chunk;
44
45
  });
45
46
  res.on('end', () => {
47
+ // If this succeeded:
46
48
  if (res.statusCode >= 200 && res.statusCode < 300) {
49
+ // Report this.
47
50
  console.log(`Alert sent (${subject})`);
48
51
  }
52
+ // Otherwise, i.e. if it failed:
49
53
  else {
54
+ // Report this.
50
55
  console.log(`ERROR: Alert API responded ${res.statusCode}: ${data}`);
51
56
  }
52
57
  resolve();
@@ -12,13 +12,13 @@ const {sendAlert} = require('../alerts');
12
12
 
13
13
  // Returns the response body.
14
14
  exports.response = async args => {
15
- const [request = ''] = args;
15
+ const [feature = ''] = args;
16
16
  // Initialize the response content.
17
17
  const responseContent = {
18
18
  'details about your request': null
19
19
  };
20
- // If the request is empty:
21
- if (!request) {
20
+ // If the requested feature or improvement is empty:
21
+ if (!feature) {
22
22
  // Add this to the response content.
23
23
  responseContent['details about your request'] = {
24
24
  error: 'request invalid: request is empty'
@@ -27,7 +27,7 @@ exports.response = async args => {
27
27
  // Otherwise, i.e. if it exists:
28
28
  else {
29
29
  // Notify the manager.
30
- await sendAlert('MCP feature request received', request);
30
+ await sendAlert('MCP feature request received', feature);
31
31
  // Add the disposition to the response content.
32
32
  responseContent['details about your request'] = {
33
33
  'date and time received': new Date().toISOString(),
@@ -43,7 +43,7 @@ exports.response = async args => {
43
43
  method: 'POST',
44
44
  URL: `${thisHost}/api/requestFeature`,
45
45
  body: {
46
- request
46
+ feature
47
47
  },
48
48
  'closest ancestor request': null
49
49
  },
package/api/routes.js CHANGED
@@ -90,7 +90,7 @@ module.exports = [
90
90
  operationId: 'requestFeature',
91
91
  method: 'post',
92
92
  path: '/api/requestFeature',
93
- summary: 'Request a new feature or feature improvement',
93
+ summary: 'Request a feature improvement or new feature',
94
94
  bodySchema: z.object(requestFeatureSchema),
95
95
  responseSchema: requestFeatureResponseSchema
96
96
  }
package/api/schemas.js CHANGED
@@ -52,7 +52,7 @@ exports.requestRetestSchema = {
52
52
 
53
53
  // requestFeature: POST /api/requestFeature
54
54
  exports.requestFeatureSchema = {
55
- request: z.string().describe('description of requested new feature or feature improvement')
55
+ feature: z.string().describe('description of requested feature improvement or new feature')
56
56
  };
57
57
 
58
58
  // listReports takes no input; omitted (mcp.js already uses inputSchema: {}).
@@ -105,11 +105,16 @@ const thisRequestSchema = (method, bodySchema) => z.object({
105
105
  'closest ancestor request': requestReferenceSchema.nullable()
106
106
  });
107
107
 
108
- const envelope = (method, responseContentSchema, bodySchema) => z.object({
108
+ const envelope = (
109
+ method,
110
+ responseContentSchema,
111
+ bodySchema,
112
+ /** @type {z.ZodType} */ similarWebSchema = similarWebRequestsSchema
113
+ ) => z.object({
109
114
  'tool collection': toolsFactsSchema,
110
115
  'tool name': z.string(),
111
116
  'this request': thisRequestSchema(method, bodySchema),
112
- 'URLs of similar requests for web users': similarWebRequestsSchema,
117
+ 'URLs of similar requests for web users': similarWebSchema,
113
118
  'response metadata': responseMetadataSchema,
114
119
  'response content': responseContentSchema
115
120
  });
@@ -293,5 +298,6 @@ exports.requestFeatureResponseSchema = envelope(
293
298
  disposition: z.string()
294
299
  })
295
300
  ])}),
296
- z.object(exports.requestFeatureSchema)
301
+ z.object(exports.requestFeatureSchema),
302
+ z.null()
297
303
  );
package/api/testSuite.js CHANGED
@@ -272,13 +272,17 @@ const requestService = async () => {
272
272
  return;
273
273
  }
274
274
  console.log('======================\nRequest: Make a feature request');
275
- path = `/api/requestFeature/${timeStamp}/${jobID}`;
275
+ path = '/api/requestFeature';
276
+ const featureRequest = 'Do the impossible';
276
277
  body = await submitRequest(path, method, {
277
- feature: 'Do the impossible'
278
+ feature: featureRequest
278
279
  });
279
- responseContent = body?.['response content'] ?? {};
280
- requestDetails = responseContent['details about your request'] ?? {};
281
- if (!requestDetails.disposition?.includes('received and logged')) {
280
+ const feature = body?.['this request']?.body?.feature ?? {};
281
+ requestDetails = body?.['response content']?.['details about your request'] ?? {};
282
+ if (
283
+ feature !== featureRequest
284
+ || !requestDetails.disposition?.includes('received and logged')
285
+ ) {
282
286
  console.log(`requestDetails: ${JSON.stringify(requestDetails, null, 2)}`);
283
287
  return;
284
288
  }
package/docs/SERVICE.md CHANGED
@@ -203,7 +203,7 @@ kilotest.com {
203
203
  }
204
204
  @allowedPOST {
205
205
  method POST
206
- path /mcp /retestRec.html/* /testRec.html /recAction.html /reannotate.html /wcagRenew.html /api/* /tutorialComment.html
206
+ path /mcp /retestRec.html/* /testRec.html /recAction.html /reannotate.html /wcagRenew.html /api/* /worker/* /tutorialComment.html
207
207
  }
208
208
  # Respond to OPTIONS requests.
209
209
  @allowedOPTIONS method OPTIONS
@@ -244,7 +244,7 @@ kilotest.com {
244
244
 
245
245
  This configuration can be futher tightened if experience warrants.
246
246
 
247
- The `flush_interval` setting prevents granular reporting by Testaro agents from being buffered, so the updates reach the browser without delay.
247
+ The `flush_interval` setting prevents granular reporting by Testaro workers from being buffered, so the updates reach the browser without delay.
248
248
 
249
249
  ## Version management
250
250
 
@@ -326,7 +326,7 @@ After that the maintainer can inspect the original and restored directories and
326
326
 
327
327
  ### Possible future Testaro integration
328
328
 
329
- Kilotest uses Testaro to run jobs. In previous versions of Kilotest, Testaro was a dependency. It is currently not a dependenc. Instead, Testaro instances are installed on one or more other hosts, and each instance polls Kilotest to ask for jobs to run.
329
+ Kilotest uses Testaro to run jobs. In previous versions of Kilotest, Testaro was a dependency. It is currently not a dependency. Instead, Testaro instances are installed on one or more other hosts, and each instance polls Kilotest to ask for jobs to run.
330
330
 
331
331
  In case Testaro again becomes a dependency of Kilotest, the notes below on security issues will be useful.
332
332
 
package/env.example CHANGED
@@ -1,32 +1,29 @@
1
- # ENVIRONMENT=test for a dev/test host, =production for a deployed host.
1
+ # test (local) or production (deployed host).
2
2
  ENVIRONMENT=__placeholder__
3
- # PROTOCOL=http for a local dev/test host or reverse-proxied server, otherwise =https.
3
+ # http (local or reverse-proxied server) or https.
4
4
  PROTOCOL=__placeholder__
5
- # AUTH_CODE for manager operations; must be 3 characters long.
5
+ # 3-character password for manager operations.
6
6
  AUTH_CODE=__placeholder__
7
- # Name of Testaro agent.
8
- TESTARO_AGENT=testaro-agent
9
- # Testaro agent password (same as NETWATCH_AUTH in the Testaro .env.
10
- TESTARO_AGENT_PW=__placeholder__
11
- # Name of research agent (AI agent calling Kilotest as a tool).
12
- RESEARCH_AGENT=research-agent
13
- # Research agent password.
14
- RESEARCH_AGENT_PW=__placeholder__
15
- # URL of this Kilotest host.
7
+ # ID of each Testaro worker and, for each, its secret and published name.
8
+ TESTARO_WORKERS={"__placeholder__":{"secret":"__placeholder__","name":"__placeholder__"}}
9
+ # URL of this host.
16
10
  THIS_KILOTEST_HOST=__placeholder__
17
- # URL of a dev/test Kilotest host.
11
+ # URL of a dev/test host.
18
12
  LOCAL_KILOTEST_HOST=http://localhost:3000
19
- # URL of the deployed Kilotest host.
13
+ # URL of the deployed host.
20
14
  DEPLOYED_KILOTEST_HOST=https://kilotest.com
21
- # Alert email configuration:
15
+
16
+ # Alert configuration:
17
+
22
18
  MANAGER_EMAIL=__placeholder__
23
19
  ALERT_API_HOST=api.resend.com
24
20
  ALERT_API_PATH=/emails
25
21
  ALERT_API_KEY=__placeholder__
26
22
  ALERT_FROM=__placeholder__
27
- # Alert thresholds:
28
- WAVE_BALANCE_THRESHOLD=30
29
- AI_SERVICE0_BALANCE_THRESHOLD=10
23
+ # Threshold in credits.
24
+ WAVE_BALANCE_THRESHOLD=__placeholder__
25
+ # Threshold in dollars.
26
+ AI_SERVICE0_BALANCE_THRESHOLD=__placeholder__
30
27
  # Per-million-token prices (Claude Haiku 4.5) on 2026-05-01 were $1 in, $5 out.
31
28
  AI_MODEL0_INPUT_PRICE=0.000001
32
29
  AI_MODEL0_OUTPUT_PRICE=0.000005
package/env.testaro CHANGED
@@ -1,17 +1,43 @@
1
1
  # You can get a WAVE API key at https://wave.webaim.org/api.
2
2
  WAVE_KEY=__placeholder__
3
- AGENT=testaro-agent
3
+ # You can get an Anthropic API key at https://console.anthropic.com/.
4
+ ANTHROPIC_API_KEY=__placeholder__
5
+ # Any message from this instance to the server (optional).
6
+ AGENT=__placeholder__
7
+ # Whether to make the browser visible (normally false).
4
8
  HEADED_BROWSER=false
9
+ # Whether to output even forked-job logging to the primary log (normally false).
5
10
  DEBUG=false
11
+ # Number of seconds to wait between actions (normally 0).
6
12
  WAITS=0
13
+ # See https://nodejs.org/api/cli.html#options for all permitted options.
7
14
  NODE_OPTIONS='--trace-uncaught --trace-warnings'
15
+ # Whether to suppress a routine warning from QualWeb.
8
16
  PUPPETEER_DISABLE_HEADLESS_WARNING=true
9
- # Replace the next 2 placeholders with:
10
- # http://localhost:3000 if Kilotest and Testaro are on the same host.
11
- # the URL of the Kilotest server (e.g., https://kilotest.com) if they are on different hosts.
12
- NETWATCH_JOB=__placeholder__/api/testaro-agent/job
13
- NETWATCH_REPORT=__placeholder__/api/testaro-agent/report
14
- # NETWATCH_AUTH must be identical to TESTARO_AGENT_PW in the Kilotest .env.
15
- NETWATCH_AUTH=__placeholder__
17
+ # Directory (relative to project root) to watch for jobs.
18
+ JOBDIR=__placeholder__
19
+ # Directory (relative to project root) to write reports to when watching a directory for jobs.
20
+ REPORTDIR=__placeholder__
21
+ # Multiplier for time limits (normally 1).
16
22
  TIMEOUT_MULTIPLIER=1
17
- TMPDIRNAME=scratch
23
+ # Whether to abort the job when launch retries are exhausted or a test act crashes (default false).
24
+ ABORT_ASSERTIVELY=false
25
+ # URL of any non-default (e.g., self-hosted) Nu Html Checker API (see ghcr.io/validator/validator).
26
+ TESTARO_NU_URL=__placeholder__
27
+ # Whether to launch the Playwright chromium browser without its sandbox (normally false).
28
+ # The sandbox requires unprivileged user-namespace cloning, which default container
29
+ # seccomp policies and some hardened hosts prohibit. Set this to true in containers,
30
+ # or run containers with a seccomp profile that permits user-namespace cloning.
31
+ TESTARO_CHROMIUM_NO_SANDBOX=false
32
+
33
+ # netWatch variables
34
+
35
+ # Replace the next 2 placeholders with the hostname of the server (e.g., http://localhost:3000 or https://kilotest.com).
36
+ # URL to poll for available jobs.
37
+ NETWATCH_URL_JOB=__placeholder__/worker/job
38
+ # URL to send a job report.
39
+ NETWATCH_URL_REPORT=__placeholder__/worker/report
40
+
41
+ # The server requires an authorization header. Identifier and secret of this worker.
42
+ WORKER_ID=__placeholder__
43
+ WORKER_SECRET=__placeholder__
package/index.js CHANGED
@@ -69,8 +69,18 @@ const protocol = process.env.PROTOCOL || 'http';
69
69
  const queuePath = path.join(jobsPath, 'queue');
70
70
  const claimedPath = path.join(jobsPath, 'claimed');
71
71
  const failedPath = path.join(jobsPath, 'failed');
72
- const testaroAgent = process.env.TESTARO_AGENT;
73
- const testaroAgentPW = process.env.TESTARO_AGENT_PW;
72
+ // Credentials of the Testaro workers, by worker ID, from a JSON-object environment variable.
73
+ // Each worker ID maps to a secret (used only to authenticate the worker, never published) and a
74
+ // name (a non-secret label safe to publish, e.g. in report data and logs).
75
+ const workerCredentials = (() => {
76
+ try {
77
+ return JSON.parse(process.env.TESTARO_WORKERS || '{}');
78
+ }
79
+ catch (error) {
80
+ console.error(`ERROR: TESTARO_WORKERS is not valid JSON (${error.message})`);
81
+ return {};
82
+ }
83
+ })();
74
84
  // Values that may require alerts.
75
85
  const balancePath = path.join(__dirname, 'ai0Balance.json');
76
86
  const jobLock = createLock();
@@ -82,11 +92,11 @@ const AI_MODEL0_OUTPUT_PRICE = Number(process.env.AI_MODEL0_OUTPUT_PRICE);
82
92
  // FUNCTIONS
83
93
 
84
94
  // Serves or sends an error message.
85
- const serveError = async (error, response, isHumanUser = true) => {
95
+ const serveError = async (error, response, isHumanUser = true, statusCode = 400) => {
86
96
  const errorLines = Object.entries(error).map(pair => `${pair[0]}: ${pair[1]}`);
87
97
  console.log(errorLines.join('\n') || 'ERROR');
88
98
  if (!response.writableEnded) {
89
- response.statusCode = 400;
99
+ response.statusCode = statusCode;
90
100
  // If the request is from a human user:
91
101
  if (isHumanUser) {
92
102
  // Serve an HTML page containing the message property of the error.
@@ -189,19 +199,53 @@ const getAbuseError = (request, reason) => {
189
199
  time: new Date().toISOString()
190
200
  };
191
201
  };
192
- // Processes a job request from a Testaro agent.
193
- const processJobRequest = async (request, response, agentID) => jobLock(async () => {
202
+ // Gets the ID and secret from a request's HTTP Basic Authorization header, or null if the header
203
+ // is absent or malformed.
204
+ const getBasicAuth = request => {
205
+ const header = request.headers['authorization'] || '';
206
+ const match = header.match(/^Basic\s+(\S+)$/i);
207
+ if (!match) {
208
+ return null;
209
+ }
210
+ let decoded;
211
+ try {
212
+ decoded = Buffer.from(match[1], 'base64').toString('utf8');
213
+ }
214
+ catch {
215
+ return null;
216
+ }
217
+ const sepIndex = decoded.indexOf(':');
218
+ if (sepIndex === -1) {
219
+ return null;
220
+ }
221
+ return {id: decoded.slice(0, sepIndex), secret: decoded.slice(sepIndex + 1)};
222
+ };
223
+ // Gets the published name of the Testaro worker that made a request, or null if it is not
224
+ // authenticated. The ID authenticates the worker and is never published; the name is a
225
+ // non-secret label safe to write into job/report data and log messages.
226
+ const getAuthorizedWorkerName = request => {
227
+ const credentials = getBasicAuth(request);
228
+ if (credentials) {
229
+ const worker = workerCredentials[credentials.id];
230
+ if (worker && worker.secret === credentials.secret && worker.name) {
231
+ return worker.name;
232
+ }
233
+ }
234
+ return null;
235
+ };
236
+ // Processes a job request from a Testaro worker.
237
+ const processJobRequest = async (request, response, workerName) => jobLock(async () => {
194
238
  let clean = true;
195
- const messageStart = `Testaro agent ${agentID} requested a job, `;
239
+ const messageStart = `Testaro worker ${workerName} requested a job, `;
196
240
  const jobNames = await getJobNames();
197
241
  const claimedJobNames = jobNames.claimed;
198
242
  // For each claimed job:
199
243
  for (const jobName of claimedJobNames) {
200
244
  const job = await getObject(path.join(jobsPath, 'claimed', jobName));
201
245
  const {id, sources} = job;
202
- const {agent} = sources;
203
- // If its assignee is the agent:
204
- if (agent === agentID) {
246
+ const {worker} = sources;
247
+ // If its assignee is the worker:
248
+ if (worker === workerName) {
205
249
  const messageEnd = `but has not completed job ${id}`;
206
250
  // Report this.
207
251
  await serveError({message: `${messageStart}${messageEnd}`}, response, false);
@@ -214,7 +258,7 @@ const processJobRequest = async (request, response, agentID) => jobLock(async ()
214
258
  break;
215
259
  }
216
260
  }
217
- // If no aborted-job error was found for the agent:
261
+ // If no aborted-job error was found for the worker:
218
262
  if (clean) {
219
263
  const queuedJobNames = jobNames.queue;
220
264
  // If any jobs are queued:
@@ -222,18 +266,18 @@ const processJobRequest = async (request, response, agentID) => jobLock(async ()
222
266
  const oldestJobName = queuedJobNames[0];
223
267
  // Get the first one.
224
268
  const firstJob = await getObject(path.join(queuePath, oldestJobName));
225
- // Add the agent ID to the job.
226
- firstJob.sources.agent = agentID;
269
+ // Add the public worker name to the job, in a property Testaro does not read or alter.
270
+ firstJob.sources.worker = workerName;
227
271
  console.log(
228
- `Job ${firstJob.id} (${firstJob.target.what}) is being sent to the agent.`
272
+ `Job ${firstJob.id} (${firstJob.target.what}) is being sent to the worker.`
229
273
  );
230
- // Assign the job to the agent.
274
+ // Assign the job to the worker.
231
275
  response.writeHead(200, {
232
276
  'content-type': 'application/json; charset=utf-8'
233
277
  });
234
278
  response.end(JSON.stringify(firstJob));
235
279
  const messageEnd
236
- = `and job ${firstJob.id} (${firstJob.target.what}) was assigned to the agent`;
280
+ = `and job ${firstJob.id} (${firstJob.target.what}) was assigned to the worker`;
237
281
  console.log(`${messageStart}${messageEnd}`);
238
282
  // Save the job in the claimed-jobs directory.
239
283
  await fs.writeFile(
@@ -247,7 +291,7 @@ const processJobRequest = async (request, response, agentID) => jobLock(async ()
247
291
  response.writeHead(200, {
248
292
  'content-type': 'application/json; charset=utf-8'
249
293
  });
250
- // Send a no-jobs response to the agent.
294
+ // Send a no-jobs response to the worker.
251
295
  response.end(JSON.stringify({}));
252
296
  const messageEnd = 'but no job was in the queue';
253
297
  console.log(`${messageStart}${messageEnd}`);
@@ -674,47 +718,67 @@ const requestHandler = async (request, response) => {
674
718
  await serveError({message: answerData.message}, response, true);
675
719
  }
676
720
  }
677
- // Otherwise, if it is a request from an agent:
678
- else if (pageName === 'api') {
679
- // Get the segments of the path after api.
680
- const segments = pathTail.split('/');
681
- // If the first segment is the ID of the Testaro agent and the agent is authenticated:
682
- if (segments[0] === testaroAgent && postData.agentPW === testaroAgentPW) {
683
- const agentID = segments[0];
721
+ // Otherwise, if it is a request from a Testaro worker:
722
+ else if (pageName === 'worker') {
723
+ // Authenticate the worker and get its public name.
724
+ const workerName = getAuthorizedWorkerName(request);
725
+ // If this succeeded:
726
+ if (workerName) {
684
727
  // Get the requested service from the path.
685
- const service = segments[1];
728
+ const service = pathTail;
686
729
  // If the service is job assignment:
687
730
  if (service === 'job') {
688
731
  // Process the job request as a transaction.
689
- await processJobRequest(request, response, agentID);
732
+ await processJobRequest(request, response, workerName);
690
733
  }
691
- // Otherwise, if the service is report acquisition:
734
+ // Otherwise, if it is report acquisition:
692
735
  else if (service === 'report') {
693
736
  const {report} = postData;
694
737
  const {id, target} = report;
695
738
  const {what, url} = target;
696
739
  const [timeStamp, jobID] = id?.split('-') ?? ['', ''];
697
- // If the request is valid:
740
+ // If the request is syntactically valid:
698
741
  if (id && isTimeStamp(timeStamp) && isJobID(jobID) && what && url) {
699
742
  const [timeStamp, jobID] = id.split('-');
700
- // Save the report.
701
- await fs.writeFile(getReportPath(timeStamp, jobID), getJSON(report));
702
- // Update the data on the available reports.
703
- await makeReportsExtract();
704
- // Annotate the report.
705
- await annotateReport(ruleIDs, timeStamp, jobID);
706
- console.log(`Testaro report ${id} was annotated, saved, and indexed`);
707
- // Check the monetary balances and send alerts if nearing exhaustion.
708
- await checkBalancesForAlerts(report);
709
- // Delete the job.
710
- await fs.unlink(path.join(claimedPath, `${id}.json`));
711
- console.log(`Completed job ${id} deleted`);
712
- // Acknowledge receipt.
713
- response.setHeader('content-type', 'application/json; charset=utf-8');
714
- response.end(JSON.stringify({status: 'ok'}));
715
- console.log(`Testaro report ${id} was received from Testaro agent ${agentID}`);
743
+ // Get the job the report is from.
744
+ const claimedJob = await getObject(path.join(claimedPath, `${id}.json`));
745
+ // If the job was actually assigned to this worker:
746
+ if (typeof claimedJob === 'object' && claimedJob.sources?.worker === workerName) {
747
+ console.log(`Testaro report ${id} was received from worker ${workerName}`);
748
+ // Add the public worker name to the report.
749
+ report.sources = {...report.sources, worker: workerName};
750
+ // Save the report.
751
+ await fs.writeFile(getReportPath(timeStamp, jobID), getJSON(report));
752
+ // Update the data on the available reports.
753
+ await makeReportsExtract();
754
+ // Annotate the report.
755
+ await annotateReport(ruleIDs, timeStamp, jobID);
756
+ console.log(`Testaro report ${id} was annotated, saved, and indexed`);
757
+ // Check the monetary balances and send alerts if nearing exhaustion.
758
+ await checkBalancesForAlerts(report);
759
+ // Delete the job.
760
+ await fs.unlink(path.join(claimedPath, `${id}.json`));
761
+ console.log(`Completed job ${id} deleted`);
762
+ // Acknowledge receipt.
763
+ response.setHeader('content-type', 'application/json; charset=utf-8');
764
+ response.end(JSON.stringify({status: 'ok'}));
765
+ console.log(
766
+ `Testaro report ${id} was received from Testaro worker ${workerName}`
767
+ );
768
+ }
769
+ // Otherwise, i.e. if the job was not assigned to this worker:
770
+ else {
771
+ await serveError(
772
+ getAbuseError(
773
+ request,
774
+ `Worker ${workerName} submitted a report for job ${id}, which was not currently assigned to it`
775
+ ),
776
+ response,
777
+ false
778
+ );
779
+ }
716
780
  }
717
- // Otherwise, i.e. if the request is invalid:
781
+ // Otherwise, i.e. if the request is syntactically invalid:
718
782
  else {
719
783
  await serveError({message: 'ERROR: Request invalid'}, response, false);
720
784
  }
@@ -722,54 +786,56 @@ const requestHandler = async (request, response) => {
722
786
  // Otherwise, i.e. if the service is invalid:
723
787
  else {
724
788
  await serveError(
725
- {message: 'ERROR: Invalid service request from Testaro agent'}, response, false
726
- );
727
- }
728
- }
729
- // Otherwise, if the first segment is not the ID of the Testaro agent:
730
- else if (segments[0] !== testaroAgent) {
731
- // If the service is to receive a test request:
732
- if (segments[0] === 'requestTest') {
733
- const {description, URL, reason} = postData;
734
- // Get the response body.
735
- const responseBody = await require(path.join(__dirname, 'api', 'requestTest'))
736
- .response([description, URL, reason]);
737
- // Send it.
738
- setHeaders('application/json', null, 'ultra');
739
- response.end(JSON.stringify(responseBody));
740
- }
741
- // Otherwise, if the service is to receive a retest request:
742
- else if (segments[0] === 'requestRetest') {
743
- const {reason} = postData;
744
- // Get the response body.
745
- const responseBody = await require(path.join(__dirname, 'api', 'requestRetest'))
746
- .response(segments.slice(1).concat(reason));
747
- // Send it.
748
- setHeaders('application/json', null, 'ultra');
749
- response.end(JSON.stringify(responseBody));
750
- }
751
- // Otherwise, if the service is to receive a feature request:
752
- else if (segments[0] === 'requestFeature') {
753
- const {request} = postData;
754
- // Get the response body.
755
- const responseBody = await require(path.join(__dirname, 'api', 'requestFeature'))
756
- .response(segments.slice(1).concat(request));
757
- // Send it.
758
- setHeaders('application/json', null, 'ultra');
759
- response.end(JSON.stringify(responseBody));
760
- }
761
- // Otherwise, i.e. if the service is invalid:
762
- else {
763
- await serveError(
764
- {message: 'ERROR: Invalid request'}, response, false
789
+ {message: 'ERROR: Invalid service request from Testaro worker'}, response, false
765
790
  );
766
791
  }
767
792
  }
768
- // Otherwise, i.e. if the request is invalid:
793
+ // Otherwise, i.e. if it is not authenticated:
769
794
  else {
770
795
  // Report this.
771
796
  await serveError(
772
- {message: 'ERROR: Invalid API request'}, response, false
797
+ {message: 'ERROR: Unauthorized worker request'}, response, false, 401
798
+ );
799
+ }
800
+ }
801
+ // Otherwise, if it is a request from an API consumer:
802
+ else if (pageName === 'api') {
803
+ // Get the segments of the path after api.
804
+ const segments = pathTail.split('/');
805
+ // If the service is to receive a test request:
806
+ if (segments[0] === 'requestTest') {
807
+ const {description, URL, reason} = postData;
808
+ // Get the response body.
809
+ const responseBody = await require(path.join(__dirname, 'api', 'requestTest'))
810
+ .response([description, URL, reason]);
811
+ // Send it.
812
+ setHeaders('application/json', null, 'ultra');
813
+ response.end(JSON.stringify(responseBody));
814
+ }
815
+ // Otherwise, if the service is to receive a retest request:
816
+ else if (segments[0] === 'requestRetest') {
817
+ const {reason} = postData;
818
+ // Get the response body.
819
+ const responseBody = await require(path.join(__dirname, 'api', 'requestRetest'))
820
+ .response(segments.slice(1).concat(reason));
821
+ // Send it.
822
+ setHeaders('application/json', null, 'ultra');
823
+ response.end(JSON.stringify(responseBody));
824
+ }
825
+ // Otherwise, if the service is to receive a feature request:
826
+ else if (segments[0] === 'requestFeature') {
827
+ const {feature} = postData;
828
+ // Get the response body.
829
+ const responseBody = await require(path.join(__dirname, 'api', 'requestFeature'))
830
+ .response([feature]);
831
+ // Send it.
832
+ setHeaders('application/json', null, 'ultra');
833
+ response.end(JSON.stringify(responseBody));
834
+ }
835
+ // Otherwise, i.e. if the service is invalid:
836
+ else {
837
+ await serveError(
838
+ {message: 'ERROR: Invalid request'}, response, false
773
839
  );
774
840
  }
775
841
  }
package/openapi.yaml CHANGED
@@ -2,7 +2,7 @@ openapi: 3.1.0
2
2
  info:
3
3
  title: Kilotest API
4
4
  description: 'Kilotest is an ensemble testing and reporting service for front-end web quality. It uses an ensemble of 10 rule engines to test public web pages for front-end quality (accessibility, usability, and standards conformity). Kilotest acts as a web server for human users, an MCP server for AI platforms, and an API for programmatic access. With this API you can request that a page be tested or retested, discover available reports, incrementally retrieve facts from a report: from what reports are available, to what issues were reported in one report, to what elements violated one issue, to what diagnoses rule engines gave for one violation.'
5
- version: 39.0.0
5
+ version: 39.0.3
6
6
  license:
7
7
  name: MIT
8
8
  identifier: MIT
@@ -1045,21 +1045,21 @@ paths:
1045
1045
  /api/requestFeature:
1046
1046
  post:
1047
1047
  operationId: requestFeature
1048
- summary: Request a new feature or feature improvement
1048
+ summary: Request a feature improvement or new feature
1049
1049
  requestBody:
1050
1050
  content:
1051
1051
  application/json:
1052
1052
  schema:
1053
1053
  type: object
1054
1054
  properties:
1055
- request:
1055
+ feature:
1056
1056
  type: string
1057
- description: description of requested new feature or feature improvement
1057
+ description: description of requested feature improvement or new feature
1058
1058
  required:
1059
- - request
1059
+ - feature
1060
1060
  responses:
1061
1061
  '200':
1062
- description: Request a new feature or feature improvement
1062
+ description: Request a feature improvement or new feature
1063
1063
  content:
1064
1064
  application/json:
1065
1065
  schema:
@@ -1082,11 +1082,11 @@ paths:
1082
1082
  body:
1083
1083
  type: object
1084
1084
  properties:
1085
- request:
1085
+ feature:
1086
1086
  type: string
1087
- description: description of requested new feature or feature improvement
1087
+ description: description of requested feature improvement or new feature
1088
1088
  required:
1089
- - request
1089
+ - feature
1090
1090
  additionalProperties: false
1091
1091
  closest ancestor request:
1092
1092
  anyOf:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jrpool/kilotest",
3
- "version": "39.0.3",
3
+ "version": "41.0.0",
4
4
  "description": "Ensemble testing for front-end web quality",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -20,6 +20,7 @@ const outputPath = path.join(__dirname, '..', 'openapi.yaml');
20
20
 
21
21
  // Builds the paths object for createDocument from the route table.
22
22
  const buildPaths = () => {
23
+ /** @type {import('zod-openapi').ZodOpenApiPathsObject} */
23
24
  const paths = {};
24
25
  routes.forEach(route => {
25
26
  const {method, path: routePath, summary, pathParamsSchema, bodySchema, responseSchema} = route;
package/CLAUDE.md DELETED
@@ -1,5 +0,0 @@
1
- # Cascade Rules
2
-
3
- - Ask mode: If the current mode is Ask, answer questions and provide code, diffs, or edit suggestions in code blocks or as plain text. Do not invite the user to switch to Normal or Code mode or any mode that permits writing files.
4
- - Comply with the current convention to omit line breaks in long lines in `.md` files and in code comments.
5
- - In conversation responses (not files), when a Markdown table cell would contain a line longer than 120 characters divided by the number of columns, insert `<br>` tags to keep displayed line lengths shorter than that maximum, since raw newlines are not valid inside table cells. If this rule makes any table cumbersome, use a non-tabular format, such as a list, instead.