@jrpool/kilotest 55.0.0 → 57.1.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.
Files changed (52) hide show
  1. package/README.md +1 -1
  2. package/api/awaitTest.test.ts +114 -0
  3. package/api/awaitTest.ts +108 -0
  4. package/api/listReports.ts +2 -2
  5. package/api/orderNewTest.test.ts +213 -0
  6. package/api/orderNewTest.ts +145 -0
  7. package/api/orderRetest.test.ts +179 -0
  8. package/api/orderRetest.ts +119 -0
  9. package/api/{requestTest.test.ts → requestNewTest.test.ts} +25 -25
  10. package/api/{requestTest.ts → requestNewTest.ts} +16 -39
  11. package/api/requestRetest.test.ts +6 -6
  12. package/api/requestRetest.ts +10 -33
  13. package/api/routes.test.ts +7 -4
  14. package/api/routes.ts +45 -7
  15. package/api/schemas.ts +120 -10
  16. package/api/util.ts +59 -2
  17. package/api/version.ts +6 -2
  18. package/capability.md +12 -5
  19. package/docs/SERVICE.md +6 -8
  20. package/env.example +15 -0
  21. package/index.html +1 -0
  22. package/index.test.ts +95 -24
  23. package/index.ts +83 -16
  24. package/lhm.plugin.json +1 -1
  25. package/linkValidation.test.ts +2 -2
  26. package/llms-full.txt +34 -3
  27. package/llms.txt +6 -2
  28. package/mcp.test.ts +72 -12
  29. package/mcp.ts +81 -11
  30. package/memory/MEMORY.md +1 -0
  31. package/memory/plan_issue_111_instant_test.md +198 -0
  32. package/openapi.yaml +451 -4
  33. package/package.json +1 -1
  34. package/routes.test.ts +1 -1
  35. package/routing.test.ts +2 -2
  36. package/server.json +1 -1
  37. package/sitemap.xml +2 -1
  38. package/smokeTest.ts +2 -2
  39. package/util.test.ts +214 -2
  40. package/util.ts +175 -7
  41. package/web/listReports/index.html +1 -1
  42. package/web/listReports/listReports.test.ts +1 -1
  43. package/web/privacy/index.html +37 -0
  44. package/web/privacy/index.ts +20 -0
  45. package/web/privacy/privacy.test.ts +18 -0
  46. package/web/{requestTest → requestNewTest}/index.ts +1 -1
  47. package/web/{requestTest/requestTest.test.ts → requestNewTest/requestNewTest.test.ts} +2 -2
  48. package/web/{requestTestForm → requestNewTestForm}/index.html +1 -1
  49. package/web/{requestTestForm/requestTestForm.test.ts → requestNewTestForm/requestNewTestForm.test.ts} +6 -6
  50. package/web/tutorialAI/index.html +26 -39
  51. /package/web/{requestTest → requestNewTest}/index.html +0 -0
  52. /package/web/{requestTestForm → requestNewTestForm}/index.ts +0 -0
package/mcp.test.ts CHANGED
@@ -37,10 +37,10 @@ test('mcpPath is /mcp', () => {
37
37
  assert.equal(mcpPath, '/mcp');
38
38
  });
39
39
 
40
- test('createMCPServer registers all 9 tools', () => {
40
+ test('createMCPServer registers all 12 tools', () => {
41
41
  const server = createMCPServer();
42
42
  const toolNames = Object.keys((server as any)._registeredTools);
43
- assert.equal(toolNames.length, 9);
43
+ assert.equal(toolNames.length, 12);
44
44
  assert.deepEqual(toolNames, [
45
45
  'getKilotestOverview',
46
46
  'listReports',
@@ -48,9 +48,12 @@ test('createMCPServer registers all 9 tools', () => {
48
48
  'listViolators',
49
49
  'listDiagnoses',
50
50
  'getReport',
51
- 'requestTest',
51
+ 'requestNewTest',
52
52
  'requestRetest',
53
- 'requestFeature'
53
+ 'requestFeature',
54
+ 'orderNewTest',
55
+ 'orderRetest',
56
+ 'awaitTest'
54
57
  ]);
55
58
  });
56
59
 
@@ -159,10 +162,10 @@ test('getReport handler returns content and structuredContent for a valid report
159
162
  assert.equal(getToolCallCount('getReport'), countBefore + 1);
160
163
  });
161
164
 
162
- test('requestTest handler returns content and structuredContent', async () => {
165
+ test('requestNewTest handler returns content and structuredContent', async () => {
163
166
  const server = createMCPServer();
164
- const countBefore = getToolCallCount('requestTest');
165
- const result = await (server as any)._registeredTools.requestTest.handler({
167
+ const countBefore = getToolCallCount('requestNewTest');
168
+ const result = await (server as any)._registeredTools.requestNewTest.handler({
166
169
  description: 'Test Page',
167
170
  URL: 'https://example.com/test',
168
171
  reason: 'Because accessibility matters'
@@ -170,7 +173,7 @@ test('requestTest handler returns content and structuredContent', async () => {
170
173
  assert.ok(result.content);
171
174
  assert.equal(result.content[0].type, 'text');
172
175
  assert.ok(result.structuredContent);
173
- assert.equal(getToolCallCount('requestTest'), countBefore + 1);
176
+ assert.equal(getToolCallCount('requestNewTest'), countBefore + 1);
174
177
  });
175
178
 
176
179
  test('requestRetest handler returns content and structuredContent for a valid report', async () => {
@@ -199,6 +202,60 @@ test('requestFeature handler returns content and structuredContent', async () =>
199
202
  assert.equal(getToolCallCount('requestFeature'), countBefore + 1);
200
203
  });
201
204
 
205
+ test('orderNewTest handler returns content and structuredContent, and enqueues a job', async () => {
206
+ const {jobsPath} = await import('./util.ts');
207
+ const server = createMCPServer();
208
+ const countBefore = getToolCallCount('orderNewTest');
209
+ const result = await (server as any)._registeredTools.orderNewTest.handler({
210
+ description: 'MCP Ordered Page',
211
+ URL: 'https://example.com/mcp-ordered',
212
+ reason: 'Because accessibility matters here'
213
+ });
214
+ assert.ok(result.content);
215
+ assert.equal(result.content[0].type, 'text');
216
+ assert.ok(result.structuredContent);
217
+ assert.equal(getToolCallCount('orderNewTest'), countBefore + 1);
218
+ const reportIdentifier = result.structuredContent['response content']['disposition of your order']['report identifier'];
219
+ assert.ok(reportIdentifier);
220
+ const queuedPath = `${jobsPath()}/queue/${reportIdentifier.timeStamp}-${reportIdentifier.jobID}.json`;
221
+ fs.unlinkSync(queuedPath);
222
+ });
223
+
224
+ test('orderRetest handler returns content and structuredContent, and enqueues a job', async () => {
225
+ const {jobsPath} = await import('./util.ts');
226
+ const server = createMCPServer();
227
+ const countBefore = getToolCallCount('orderRetest');
228
+ // 260202T0000-new is the latest report of "Mixed Outcomes Page" (260101T0000-mix is an
229
+ // earlier, superseded report of the same page), so this order is accepted.
230
+ const result = await (server as any)._registeredTools.orderRetest.handler({
231
+ timeStamp: '260202T0000',
232
+ jobID: 'new',
233
+ reason: 'Because the report is obsolete'
234
+ });
235
+ assert.ok(result.content);
236
+ assert.equal(result.content[0].type, 'text');
237
+ assert.ok(result.structuredContent);
238
+ assert.equal(getToolCallCount('orderRetest'), countBefore + 1);
239
+ const reportIdentifier = result.structuredContent['response content']['disposition of your order']['report identifier'];
240
+ assert.ok(reportIdentifier);
241
+ const queuedPath = `${jobsPath()}/queue/${reportIdentifier.timeStamp}-${reportIdentifier.jobID}.json`;
242
+ fs.unlinkSync(queuedPath);
243
+ });
244
+
245
+ test('awaitTest handler returns content and structuredContent for an already-completed report', async () => {
246
+ const server = createMCPServer();
247
+ const countBefore = getToolCallCount('awaitTest');
248
+ const result = await (server as any)._registeredTools.awaitTest.handler({
249
+ timeStamp: '260101T0000',
250
+ jobID: 'mix'
251
+ });
252
+ assert.ok(result.content);
253
+ assert.equal(result.content[0].type, 'text');
254
+ assert.ok(result.structuredContent);
255
+ assert.equal(result.structuredContent['response content']['disposition of your wait'].outcome, 'completed');
256
+ assert.equal(getToolCallCount('awaitTest'), countBefore + 1);
257
+ });
258
+
202
259
  test('listIssues handler returns an error for a nonexistent report', async () => {
203
260
  const server = createMCPServer();
204
261
  const result = await (server as any)._registeredTools.listIssues.handler({
@@ -301,7 +358,7 @@ test('handleMCP responds to initialize with server info', async () => {
301
358
  }
302
359
  });
303
360
 
304
- test('handleMCP lists all 9 tools via tools/list', async () => {
361
+ test('handleMCP lists all 12 tools via tools/list', async () => {
305
362
  const server = await startMCPServer();
306
363
  try {
307
364
  const port = server.address().port;
@@ -309,7 +366,7 @@ test('handleMCP lists all 9 tools via tools/list', async () => {
309
366
  assert.equal(res.statusCode, 200);
310
367
  const result = parseSSEResult(res.body);
311
368
  const toolNames = result.result.tools.map((t: any) => t.name);
312
- assert.equal(toolNames.length, 9);
369
+ assert.equal(toolNames.length, 12);
313
370
  assert.deepEqual(toolNames, [
314
371
  'getKilotestOverview',
315
372
  'listReports',
@@ -317,9 +374,12 @@ test('handleMCP lists all 9 tools via tools/list', async () => {
317
374
  'listViolators',
318
375
  'listDiagnoses',
319
376
  'getReport',
320
- 'requestTest',
377
+ 'requestNewTest',
321
378
  'requestRetest',
322
- 'requestFeature'
379
+ 'requestFeature',
380
+ 'orderNewTest',
381
+ 'orderRetest',
382
+ 'awaitTest'
323
383
  ]);
324
384
  }
325
385
  finally {
package/mcp.ts CHANGED
@@ -13,9 +13,12 @@ import * as listReportsAPI from './api/listReports.ts';
13
13
  import * as listIssuesAPI from './api/listIssues.ts';
14
14
  import * as listViolatorsAPI from './api/listViolators.ts';
15
15
  import * as listDiagnosesAPI from './api/listDiagnoses.ts';
16
- import * as requestTestAPI from './api/requestTest.ts';
16
+ import * as requestNewTestAPI from './api/requestNewTest.ts';
17
17
  import * as requestRetestAPI from './api/requestRetest.ts';
18
18
  import * as requestFeatureAPI from './api/requestFeature.ts';
19
+ import * as orderNewTestAPI from './api/orderNewTest.ts';
20
+ import * as orderRetestAPI from './api/orderRetest.ts';
21
+ import * as awaitTestAPI from './api/awaitTest.ts';
19
22
  import {version} from './api/version.ts';
20
23
  import {recordMetric} from './util.ts';
21
24
 
@@ -24,17 +27,23 @@ import {
24
27
  listIssuesSchema,
25
28
  listViolatorsSchema,
26
29
  listDiagnosesSchema,
27
- requestTestSchema,
30
+ requestNewTestSchema,
28
31
  requestRetestSchema,
29
32
  requestFeatureSchema,
33
+ orderNewTestSchema,
34
+ orderRetestSchema,
35
+ awaitTestSchema,
30
36
  listReportsResponseSchema,
31
37
  listIssuesResponseSchema,
32
38
  listViolatorsResponseSchema,
33
39
  listDiagnosesResponseSchema,
34
40
  getReportResponseSchema,
35
- requestTestResponseSchema,
41
+ requestNewTestResponseSchema,
36
42
  requestRetestResponseSchema,
37
- requestFeatureResponseSchema
43
+ requestFeatureResponseSchema,
44
+ orderNewTestResponseSchema,
45
+ orderRetestResponseSchema,
46
+ awaitTestResponseSchema
38
47
  } from './api/schemas.ts';
39
48
 
40
49
  // CONSTANTS
@@ -53,7 +62,8 @@ const kilotestOverview = [
53
62
  'Kilotest tests web pages for front-end quality: accessibility, usability, and standards conformity.',
54
63
  'Kilotest integrates an ensemble of twelve independent rule engines to test a web page and stores the results as a structured report.',
55
64
  'Results are organized as a hierarchy. A report contains issues. An issue has violators: elements of the tested page reported as exhibiting the issue. A violator has diagnoses: explanations from rule engines of how the element exhibited the issue.',
56
- 'Typical workflow: call the listReports tool to learn whether a report about the page already exists. If it does, drill down with the listIssues, listViolators, and listDiagnoses tools, or retrieve the entire report with the getReport tool. If no report exists, request a test with the requestTest tool. If the latest report is obsolete, request a retest with the requestRetest tool.',
65
+ 'Typical workflow: call the listReports tool to learn whether a report about the page already exists. If it does, drill down with the listIssues, listViolators, and listDiagnoses tools, or retrieve the entire report with the getReport tool. If no report exists, request a test with the requestNewTest tool. If the latest report is obsolete, request a retest with the requestRetest tool.',
66
+ 'For immediate, automatically approved testing, use the orderNewTest tool (for a new page) or the orderRetest tool (for a page with an obsolete report) instead of requestNewTest or requestRetest. Each enqueues the job right away, with no manual approval step, and returns a report identifier along with a question about whether to wait for completion. To wait, call the awaitTest tool with that identifier; it blocks until the report is ready, the job fails, or a maximum wait time elapses. To check later instead, call the listIssues tool with the same identifier.',
57
67
  'More documentation is available at https://kilotest.com/llms.txt and https://kilotest.com/llms-full.txt.'
58
68
  ].join('\n\n');
59
69
 
@@ -71,7 +81,7 @@ export const createMCPServer = (): McpServer => {
71
81
  description: 'Tools that test web pages for front-end quality (accessibility, usability, and standards conformity) and make test results available'
72
82
  },
73
83
  {
74
- instructions: `${sharedContext} Use the listReports tool to start. If it shows that there is a report available about the page you want facts about, drill down with the listIssues, listViolators, and listDiagnoses tools. If not, use the requestTest tool to request that the page be tested. If the latest report about the page is obsolete, use the requestRetest tool to request that the page be retested. For detailed documentation, call the getKilotestOverview tool.`
84
+ instructions: `${sharedContext} Use the listReports tool to start. If it shows that there is a report available about the page you want facts about, drill down with the listIssues, listViolators, and listDiagnoses tools. If not, use the requestNewTest tool to request that the page be tested, or the orderNewTest tool for immediate, automatically approved testing. If the latest report about the page is obsolete, use the requestRetest tool to request that the page be retested, or the orderRetest tool for immediate, automatically approved retesting. For detailed documentation, call the getKilotestOverview tool.`
75
85
  });
76
86
  server.registerTool(
77
87
  'getKilotestOverview',
@@ -192,11 +202,11 @@ export const createMCPServer = (): McpServer => {
192
202
  }
193
203
  );
194
204
  server.registerTool(
195
- 'requestTest',
205
+ 'requestNewTest',
196
206
  {
197
207
  description: toolDoc('Process my request to test a page about which no report is available yet.'),
198
- inputSchema: requestTestSchema,
199
- outputSchema: requestTestResponseSchema,
208
+ inputSchema: requestNewTestSchema,
209
+ outputSchema: requestNewTestResponseSchema,
200
210
  annotations: {
201
211
  title: toolDoc('Process my request to test a page about which no report is available yet.'),
202
212
  readOnlyHint: false,
@@ -206,8 +216,8 @@ export const createMCPServer = (): McpServer => {
206
216
  }
207
217
  },
208
218
  async ({description, URL, reason}) => {
209
- const result = await requestTestAPI.response([description, URL, reason]);
210
- await recordMetric('mcpToolCalls', 'requestTest');
219
+ const result = await requestNewTestAPI.response([description, URL, reason]);
220
+ await recordMetric('mcpToolCalls', 'requestNewTest');
211
221
  return {content: [{type: 'text', text: JSON.stringify(result)}], structuredContent: result};
212
222
  }
213
223
  );
@@ -251,6 +261,66 @@ export const createMCPServer = (): McpServer => {
251
261
  return {content: [{type: 'text', text: JSON.stringify(result)}], structuredContent: result};
252
262
  }
253
263
  );
264
+ server.registerTool(
265
+ 'orderNewTest',
266
+ {
267
+ description: toolDoc('Process my order to test a page about which no report is available yet, immediately and automatically approved, instead of waiting for manual approval. Testing typically takes about 2 minutes and occasionally up to 4 minutes.'),
268
+ inputSchema: orderNewTestSchema,
269
+ outputSchema: orderNewTestResponseSchema,
270
+ annotations: {
271
+ title: toolDoc('Process my order to test a page about which no report is available yet, immediately and automatically approved.'),
272
+ readOnlyHint: false,
273
+ idempotentHint: false,
274
+ destructiveHint: false,
275
+ openWorldHint: false
276
+ }
277
+ },
278
+ async ({description, URL, reason}) => {
279
+ const result = await orderNewTestAPI.response([description, URL, reason]);
280
+ await recordMetric('mcpToolCalls', 'orderNewTest');
281
+ return {content: [{type: 'text', text: JSON.stringify(result)}], structuredContent: result};
282
+ }
283
+ );
284
+ server.registerTool(
285
+ 'orderRetest',
286
+ {
287
+ description: toolDoc('Process my order to retest a page immediately and automatically approved, instead of waiting for manual approval. Testing typically takes about 2 minutes and occasionally up to 4 minutes.'),
288
+ inputSchema: orderRetestSchema,
289
+ outputSchema: orderRetestResponseSchema,
290
+ annotations: {
291
+ title: toolDoc('Process my order to retest a page immediately and automatically approved.'),
292
+ readOnlyHint: false,
293
+ idempotentHint: false,
294
+ destructiveHint: false,
295
+ openWorldHint: false
296
+ }
297
+ },
298
+ async ({timeStamp, jobID, reason}) => {
299
+ const result = await orderRetestAPI.response([timeStamp, jobID, reason]);
300
+ await recordMetric('mcpToolCalls', 'orderRetest');
301
+ return {content: [{type: 'text', text: JSON.stringify(result)}], structuredContent: result};
302
+ }
303
+ );
304
+ server.registerTool(
305
+ 'awaitTest',
306
+ {
307
+ description: toolDoc('Wait for a job ordered via the orderNewTest or orderRetest tool to complete, using the report identifier either tool returned. Blocks until the report is ready, the job fails, or a maximum wait time elapses; sends no interim notices.'),
308
+ inputSchema: awaitTestSchema,
309
+ outputSchema: awaitTestResponseSchema,
310
+ annotations: {
311
+ title: toolDoc('Wait for a job ordered via the orderNewTest or orderRetest tool to complete.'),
312
+ readOnlyHint: true,
313
+ idempotentHint: false,
314
+ destructiveHint: false,
315
+ openWorldHint: false
316
+ }
317
+ },
318
+ async ({timeStamp, jobID}) => {
319
+ const result = await awaitTestAPI.response([timeStamp, jobID]);
320
+ await recordMetric('mcpToolCalls', 'awaitTest');
321
+ return {content: [{type: 'text', text: JSON.stringify(result)}], structuredContent: result};
322
+ }
323
+ );
254
324
  server.registerResource(
255
325
  'kilotest-overview',
256
326
  'docs://kilotest/overview',
package/memory/MEMORY.md CHANGED
@@ -5,3 +5,4 @@ This directory holds persistent context files for the Kilotest project. Each fil
5
5
  ## Index
6
6
 
7
7
  - [user_maintainer_context.md](user_maintainer_context.md) — user: Maintainer is 84 years old, sole contributor, prioritizes legibility for future maintainers over short-term velocity
8
+ - [plan_issue_111_instant_test.md](plan_issue_111_instant_test.md) — project: orderTest/orderRetest/awaitTest (issue #111), implemented on orderTest branch with full REST+MCP parity
@@ -0,0 +1,198 @@
1
+ ---
2
+ type: project
3
+ name: Plan for issue #111 (instant testing)
4
+ description: Design and implementation of orderTest/orderRetest/awaitTest, the three-tool MCP addition (with REST parity) for immediate, auto-approved testing with an optional wait for completion. Implemented on the orderTest branch.
5
+ ---
6
+
7
+ # Issue #111: instant-testing MCP tools (implemented)
8
+
9
+ Issue: [jrpool/kilotest#111](https://github.com/jrpool/kilotest/issues/111). The issue's own comment already
10
+ narrowed scope to a minimal increment: keep the existing single-worker Testaro polling
11
+ model, add new MCP tools, skip cost recovery, add only a lightweight anti-abuse gate.
12
+ This was implemented on the `orderTest` branch as three tools, `orderTest`, `orderRetest`,
13
+ and `awaitTest`, each with REST (`/api/*`) and MCP exposure, at the user's request for
14
+ full parity with the existing `requestTest`/`requestRetest` tools.
15
+
16
+ ## Why two tools, not one
17
+
18
+ MCP `tools/call` is one request, one response; a tool cannot pose a question mid-call
19
+ and get an answer in the same call. The natural interaction is: order the test (fast,
20
+ always returns quickly), then, as a genuinely separate second call, decide whether to
21
+ wait for the result. Splitting into two tools avoids needing any new MCP machinery
22
+ (progress tokens, progress notifications) to solve the timeout problem below.
23
+
24
+ ### The problem that killed the single-tool, blocking-wait design
25
+
26
+ An earlier design considered one tool with a `waitForCompletion` flag, where a `true`
27
+ call would enqueue and then block up to ~4 minutes for the report. This breaks if the
28
+ MCP client times out client-side before Kilotest responds: the client has stopped
29
+ listening, so nothing the server does (a final response, a progress notification) can
30
+ reach it, unless the client supplied a `progressToken` up front and progress
31
+ notifications were emitted early enough to carry the job identifier before the
32
+ timeout hit. That makes the wait's safety depend on client capabilities the caller may
33
+ not have, with no way for the server to detect or work around the gap.
34
+
35
+ The fix is not "notify them anyway", it is: never let identifier delivery depend on a
36
+ wait completing. `orderTest` returns immediately, always, with the identifier already
37
+ in hand, before any wait is ever proposed. This makes the whole progress-token /
38
+ client-timeout question moot for the ordering step; the identifier is guaranteed
39
+ delivered before any risky wait begins.
40
+
41
+ ### Why a caller could never misuse awaitTest on a manually-approved job
42
+
43
+ Considered and rejected: restricting `awaitTest` to jobs specifically created by
44
+ `orderTest`, versus letting it work generically on any pending job by `timeStamp`/
45
+ `jobID`. This turned out to be moot: `requestTest`/`requestRetest` never hand the
46
+ caller a `timeStamp`/`jobID` at all (nothing exists yet), only instructions to poll
47
+ `listReports` later by description/URL. An identifier for a report only becomes
48
+ visible once the report is complete. So no caller can ever construct an `awaitTest`
49
+ call naming a manually-approved, still-pending job; the scenario that would need a
50
+ restriction cannot arise. No state-check or restriction is needed in `awaitTest`.
51
+ `orderTest` is the only path that exposes a pre-completion identifier at all, because
52
+ it enqueues synchronously and returns the job's own identifier immediately.
53
+
54
+ ## Implementation notes
55
+
56
+ - `orderJob(target, reason)` in `util.ts` is the shared enqueue function backing both
57
+ `orderTest` (target `{description, url}`) and `orderRetest` (target `{timeStamp,
58
+ jobID}`), mirroring `processTestRequest`'s own dual-target design. It shares
59
+ `testRequestsLock` with `processTestRequest` so the two can never race to enqueue
60
+ duplicate jobs for the same page.
61
+ - `awaitJob(timeStamp, jobID)` in `util.ts` is the shared poll function backing
62
+ `awaitTest`; it works for jobs from either `orderTest` or `orderRetest` (or, in
63
+ principle, any job in the queue/claimed pipeline), since it only inspects
64
+ `getJobNames()`/`getReportStats()`, not job origin. Poll interval and timeout are
65
+ injectable via `AWAIT_JOB_POLL_MS`/`AWAIT_JOB_TIMEOUT_MS` so tests do not need to
66
+ wait the real ~2-4 minutes.
67
+ - Full REST+MCP parity: `api/routes.ts`, `index.ts`'s `/api/*` POST dispatch, and
68
+ `openapi.yaml` (regenerated via `npm run generate:openapi`) all cover the three new
69
+ operations, alongside the MCP tool registrations in `mcp.ts`. `smokeTest.ts` needed
70
+ no change, since it already smoke-tests the `/api/*` wildcard generically via one
71
+ representative path, not every individual operation.
72
+ - Web-UI-equivalent URL fields (`'URLs of similar requests for web users'`) are `null`
73
+ for `orderTest`/`orderRetest`/`awaitTest`'s own request, per explicit instruction:
74
+ "web users will not be offered an order option." The *ancestor* request's web URL
75
+ (e.g. `listReports.html`, `listIssues.html`) is still populated where one exists.
76
+ - `requestReferenceSchema` (`api/schemas.ts`) had to be widened from `method:
77
+ z.literal('GET')` to `z.enum(['GET', 'POST'])`, since `awaitTest`'s ancestors,
78
+ `orderTest`/`orderRetest`, are POSTs, unlike every other tool's GET ancestor.
79
+ - **`awaitTest` has two possible ancestors, not one**: a bug in the first cut had
80
+ `awaitTest.ts` unconditionally naming `orderTest` as `'closest ancestor request'`,
81
+ wrong for any identifier that actually came from `orderRetest`, and `awaitTest`
82
+ cannot tell which one produced a given `timeStamp`/`jobID`, nor does it need to (its
83
+ poll logic is origin-agnostic, per the earlier finding above). Considered and
84
+ rejected: adding an `orderType: 'test' | 'retest'` parameter so `awaitTest` could
85
+ report a single correct ancestor; rejected because it would force every caller to
86
+ track and pass through a fact `awaitTest`'s own behavior never uses, reintroducing
87
+ exactly the origin-coupling the generic design deliberately avoided. Adopted instead:
88
+ `'closest ancestor request'` reports both `orderTest` and `orderRetest` as an array,
89
+ only for `awaitTest` (every other tool keeps a single nullable ancestor object).
90
+ Implemented by giving `thisRequestSchema`/`envelope` (`api/schemas.ts`) an optional
91
+ `ancestorSchema` override parameter, mirroring the existing `similarWebSchema`
92
+ override pattern, rather than widening the shared schema for every tool.
93
+ - The caller's stated `reason` is persisted onto the job itself (`job.sources.reason`),
94
+ surviving into the eventual report exactly as `sources.worker` already does. No
95
+ approval rule consults it yet; the user's rationale for keeping the field even though
96
+ there is no human review is to accumulate real callers' stated reasons as data to
97
+ inform more sophisticated, reason-aware approval rules in a later iteration.
98
+ - Shared response-shaping helpers landed in `api/util.ts`: `getRequestFailureReason`
99
+ and `buildRequestDisposition`, extracted from `requestTest.ts`/`requestRetest.ts`
100
+ (now refactored to use them) before `orderTest.ts`/`orderRetest.ts` were written
101
+ against the same helpers.
102
+
103
+ ## Tool 1: orderTest
104
+
105
+ - Reuses `requestTest.ts`'s existing validation (length/URL/target-safety checks
106
+ already in top-level `util.ts`: `checkLength`, `isURL`, `isAllowedTarget`,
107
+ `isAllowedRedirectTarget`).
108
+ - On success, enqueues directly into `db/jobs/queue` (bypassing the manual-approval
109
+ inbox `db/jobs/testRequests.json` entirely), following the job-template population
110
+ logic already in `web/enqueue/index.ts` (`jobName = ${nowStamp}-${jobIDSuffix}`,
111
+ etc.).
112
+ - Approval gate (lightweight, per the issue's own comment, no human-in-the-loop):
113
+ - Queue-size cap: env var **`JOB_QUEUE_MAX`** (not "instant-test"-scoped; `db/jobs/queue`
114
+ is a shared resource fed by manual approvals, the web UI, and this tool, so the cap
115
+ name must not imply a per-source quota). Checked against the live count of
116
+ `db/jobs/queue` at call time via the existing `queuePath()` helper, not a
117
+ self-maintained counter. Parallels the existing `TEST_REQUEST_QUEUE_MAX` (which caps
118
+ the earlier-stage pending-approval inbox), as the sibling cap one stage further down
119
+ the same pipeline.
120
+ - Dedup against `queue`/`claimed`, reusing/extending `processTestRequest`'s existing
121
+ per-URL/description dedup logic.
122
+ - Response, in all cases (accepted or rejected), returns immediately:
123
+ - If rejected: says why (queue full, duplicate), no identifier.
124
+ - If accepted: includes the job's `timeStamp`/`jobID` (or equivalent identifier),
125
+ states that testing has begun and the report will be available via `listIssues`
126
+ with those parameters, and asks the literal question: "Do you want to wait
127
+ (typically about 2 minutes, occasionally up to 4 minutes) and be notified of
128
+ completion?" The calling model decides and, if yes, makes a separate `awaitTest`
129
+ call.
130
+
131
+ ## Tool 2: awaitTest
132
+
133
+ - Takes the identifier (`timeStamp`/`jobID`) `orderTest` returned.
134
+ - Blocks, polling for report completion, until the report exists or a ~4-minute cap
135
+ elapses.
136
+ - No progress notifications during the wait ("no notices until job completion or job
137
+ timeout", per explicit instruction). This is a pure bounded poll.
138
+ - On completion or timeout, returns a response pointing at the same `listIssues`
139
+ parameters already given, so both outcomes converge on the same follow-up action.
140
+ - Needs zero Testaro changes and zero MCP progress-notification plumbing: it derives
141
+ status purely from Kilotest's own already-tracked state (`db/jobs/claimed/` vs. a
142
+ completed report existing), not from any per-rule-engine signal.
143
+
144
+ ## Deferred: detailed (per-rule-engine) progress reporting
145
+
146
+ Explicitly postponed; "the work is substantial even with generic progress reporting."
147
+ Findings, kept for whenever this is revisited:
148
+
149
+ - Testaro's `doActs.js` already has the needed hook, `opts.onProgress`, firing
150
+ `actStart`/`actEnd` per act (including per rule-engine `test` act, with `which`
151
+ naming the tool and `actEnd` carrying `outcome`/`elapsedMs`), built for Testaro's own
152
+ issue #44.
153
+ - However, **no current Testaro entry point supplies `opts`**: `call.js`, `dirWatch.js`,
154
+ and `netWatch.js` (the one a Kilotest Testaro worker actually runs) all call
155
+ `doJob(job)` with a single argument. `opts.onProgress` is dead code from any shipped
156
+ Testaro CLI/watch surface today.
157
+ - Consuming detailed progress therefore requires a **Testaro-side revision** to
158
+ `netWatch.js` (to accept/forward progress, plausibly keyed off a job property rather
159
+ than a runtime `opts` argument, since `netWatch.js`'s call site has no second argument
160
+ to attach a callback to) plus a **new worker-to-Kilotest channel** (the worker
161
+ currently only talks to Kilotest at claim-time and report-time; no mid-job channel
162
+ exists). This is real, separate scope: a Testaro release plus a worker deployment
163
+ update, before Kilotest's side could even be attempted. File as its own Testaro issue
164
+ when picked up, not bundled into this work.
165
+ - Generic progress (this plan) needed none of this, since Kilotest already knows
166
+ claimed-vs-reported from its own state.
167
+
168
+ ## Tool 3: orderRetest
169
+
170
+ Same shape as `orderTest`, but for retesting a page with an existing report: target is
171
+ `{timeStamp, jobID}` of the report to retest, matching `requestRetest`'s own parameters.
172
+ Added at the user's explicit request, alongside full REST/MCP parity, for symmetry with
173
+ `orderTest`. Reuses the same `orderJob` function (see Implementation notes above) and the
174
+ same `awaitTest` tool for waiting; no separate wait tool was needed.
175
+
176
+ ## Out of scope for this increment
177
+
178
+ - Cost recovery (explicit in the issue's own comment).
179
+ - Any change to the Testaro worker or its single-worker polling model.
180
+ - Any human-approval step for `orderTest`/`orderRetest`-originated jobs.
181
+
182
+ ## Likely shared-code extraction
183
+
184
+ `requestTest.ts` and `requestRetest.ts` already duplicate a failure-reason mapping
185
+ (`requestResult` → human-readable string) and a `requestDisposition` envelope shape
186
+ (`'what happens next'` / `'how you can check for completion'` / `'how a web user can
187
+ check for completion'`). A third near-identical need in `orderTest.ts` makes this real
188
+ duplication (three call sites), worth extracting into small helpers in `api/util.ts`
189
+ before or alongside writing `orderTest.ts`. The outer response-body envelope
190
+ (`'tool collection'`, `'this request'`, etc.) is NOT worth extracting further; it is
191
+ already assembled from `getToolsFacts()`/`getResponseMetadata()` and the remaining
192
+ per-tool differences (method, URL, body shape, ancestor request) are genuinely
193
+ tool-specific.
194
+
195
+ See also `user_maintainer_context` for the legibility-for-future-maintainers priority
196
+ this design tries to respect (explicit state checks over implicit invariants, e.g. why
197
+ `awaitTest` needed no special-casing once the actual constraint was understood, rather
198
+ than adding a defensive check for a scenario that cannot occur).