@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
@@ -0,0 +1,179 @@
1
+ /*
2
+ orderRetest.test.ts
3
+ Tests for api/orderRetest.ts using the fixture corpus.
4
+ */
5
+
6
+ // IMPORTS
7
+
8
+ import {test, before, beforeEach, after} from 'node:test';
9
+ import assert from 'node:assert/strict';
10
+ import fs from 'node:fs/promises';
11
+ import path from 'node:path';
12
+ import {fixtureDBDir} from '../test/dbFixture.ts';
13
+
14
+ for (const key of ['MANAGER_EMAIL', 'ALERT_API_HOST', 'ALERT_API_PATH', 'ALERT_API_KEY', 'ALERT_FROM']) {
15
+ process.env[key] = '';
16
+ }
17
+
18
+ // SETUP AND TEARDOWN
19
+
20
+ const savedDBDir = process.env.DB_DIR;
21
+ const queuePath = path.join(fixtureDBDir, 'jobs', 'queue');
22
+ // A report added only to this test's disposable copy of the fixture database (never to
23
+ // the tracked test/fixtures/db corpus), matching the fixture requestRetest.test.ts uses
24
+ // for the same reason: a 3-character job ID satisfying isJobID without perturbing
25
+ // report-count tests elsewhere that copy the whole corpus.
26
+ const retestFixturePath = path.join(fixtureDBDir, 'reports', '260101T0004-ord.json');
27
+ const retestFixtureReport = {
28
+ id: '260101T0004-ord',
29
+ what: 'Order Retest Fixture Page',
30
+ target: {what: 'Order Retest Fixture Page', url: 'https://example.com/orderretestfixture'},
31
+ sources: {worker: 'test-worker'},
32
+ acts: [],
33
+ jobData: {startTime: '26-01-01T00:00', endTime: '26-01-01T00:10', elapsedSeconds: 600, preventions: {}, issuelessRules: []},
34
+ catalog: {},
35
+ images: {},
36
+ checkpoints: []
37
+ };
38
+
39
+ before(async () => {
40
+ process.env.DB_DIR = fixtureDBDir;
41
+ await fs.writeFile(retestFixturePath, JSON.stringify(retestFixtureReport));
42
+ });
43
+
44
+ beforeEach(async () => {
45
+ for (const fileName of await fs.readdir(queuePath)) {
46
+ await fs.unlink(path.join(queuePath, fileName));
47
+ }
48
+ });
49
+
50
+ import {response} from './orderRetest.ts';
51
+
52
+ after(async () => {
53
+ await fs.unlink(retestFixturePath);
54
+ if (savedDBDir !== undefined) {
55
+ process.env.DB_DIR = savedDBDir;
56
+ }
57
+ else {
58
+ delete process.env.DB_DIR;
59
+ }
60
+ });
61
+
62
+ // TESTS
63
+
64
+ test('orderRetest rejects a nonexistent report', async () => {
65
+ const body = await response(['251231T0000', 'zzz', 'A reason that is long enough.']);
66
+ const details = body['response content']['details about your order'] as any;
67
+ assert.ok(details.error.includes('does not exist'));
68
+ });
69
+
70
+ test('orderRetest rejects a malformed timestamp or job identifier', async () => {
71
+ const body = await response(['999999T9999', 'xyz', 'A reason that is long enough.']);
72
+ const details = body['response content']['details about your order'] as any;
73
+ assert.ok(details.error.includes('malformed'));
74
+ });
75
+
76
+ test('orderRetest rejects a reason shorter than 20 characters', async () => {
77
+ const body = await response(['260101T0004', 'ord', 'short']);
78
+ const details = body['response content']['details about your order'] as any;
79
+ assert.ok(details.error.includes('reason'));
80
+ });
81
+
82
+ test('orderRetest rejects a reason longer than 100 characters', async () => {
83
+ const longReason = 'x'.repeat(101);
84
+ const body = await response(['260101T0004', 'ord', longReason]);
85
+ const details = body['response content']['details about your order'] as any;
86
+ assert.ok(details.error.includes('reason'));
87
+ });
88
+
89
+ test('orderRetest rejects a superseded report', async () => {
90
+ // 260101T0000-mix is an earlier report of "Mixed Outcomes Page" than 260202T0000-new,
91
+ // so it is superseded.
92
+ const body = await response(['260101T0000', 'mix', 'A reason that is long enough.']);
93
+ const details = body['response content']['details about your order'] as any;
94
+ assert.equal(details.error, undefined);
95
+ const disposition = body['response content']['disposition of your order'] as any;
96
+ assert.ok(
97
+ disposition['what happens next'].includes('a later report about a page with the same description is available.')
98
+ );
99
+ assert.equal(disposition['report identifier'], null);
100
+ });
101
+
102
+ test('orderRetest rejects a report matching a claimed job by description', async () => {
103
+ const claimedPath = path.join(fixtureDBDir, 'jobs', 'claimed', 'clm.json');
104
+ await fs.writeFile(claimedPath, JSON.stringify({
105
+ target: {what: 'Order Retest Fixture Page', url: 'https://example.com/unrelated'}
106
+ }));
107
+ try {
108
+ const body = await response(['260101T0004', 'ord', 'A reason that is long enough.']);
109
+ const details = body['response content']['details about your order'] as any;
110
+ assert.equal(details.error, undefined);
111
+ const disposition = body['response content']['disposition of your order'] as any;
112
+ assert.ok(
113
+ disposition['what happens next']
114
+ .includes('a request to test a page with the same description is already approved.')
115
+ );
116
+ }
117
+ finally {
118
+ await fs.unlink(claimedPath);
119
+ }
120
+ });
121
+
122
+ test('orderRetest rejects a report matching a queued job by URL', async () => {
123
+ const queuedFilePath = path.join(queuePath, 'que.json');
124
+ await fs.writeFile(queuedFilePath, JSON.stringify({
125
+ target: {what: 'Some Other Page', url: 'https://example.com/orderretestfixture'}
126
+ }));
127
+ try {
128
+ const body = await response(['260101T0004', 'ord', 'A reason that is long enough.']);
129
+ const details = body['response content']['details about your order'] as any;
130
+ assert.equal(details.error, undefined);
131
+ const disposition = body['response content']['disposition of your order'] as any;
132
+ assert.ok(
133
+ disposition['what happens next']
134
+ .includes('a request to test a page with the same URL is already approved.')
135
+ );
136
+ }
137
+ finally {
138
+ await fs.unlink(queuedFilePath);
139
+ }
140
+ });
141
+
142
+ test('orderRetest rejects an order once the job queue is full', async () => {
143
+ const fillerPath = path.join(queuePath, 'fil.json');
144
+ await fs.writeFile(fillerPath, JSON.stringify({target: {what: 'Filler', url: 'https://example.com/filler'}}));
145
+ process.env.JOB_QUEUE_MAX = '1';
146
+ try {
147
+ const body = await response(['260101T0004', 'ord', 'A reason that is long enough.']);
148
+ const details = body['response content']['details about your order'] as any;
149
+ assert.equal(details.error, undefined);
150
+ const disposition = body['response content']['disposition of your order'] as any;
151
+ assert.ok(disposition['what happens next'].includes('too many requests are awaiting approval right now.'));
152
+ } finally {
153
+ delete process.env.JOB_QUEUE_MAX;
154
+ await fs.unlink(fillerPath);
155
+ }
156
+ });
157
+
158
+ test('orderRetest accepts a valid retest order and enqueues a job directly', async () => {
159
+ const body = await response(['260101T0004', 'ord', 'A reason that is long enough.']);
160
+ const details = body['response content']['details about your order'] as any;
161
+ assert.equal(details.error, undefined);
162
+ assert.equal(details['page to be retested'].description, 'Order Retest Fixture Page');
163
+ const disposition = body['response content']['disposition of your order'] as any;
164
+ assert.ok(disposition['report identifier']);
165
+ assert.ok(disposition['what happens next'].includes('Testing has begun.'));
166
+ assert.ok(disposition['do you want to wait for completion'].includes('awaitTest'));
167
+ const queuedFiles = await fs.readdir(queuePath);
168
+ assert.equal(queuedFiles.length, 1);
169
+ const job = JSON.parse(await fs.readFile(path.join(queuePath, queuedFiles[0]!), 'utf8'));
170
+ assert.equal(job.target.what, 'Order Retest Fixture Page');
171
+ assert.equal(job.sources.reason, 'A reason that is long enough.');
172
+ });
173
+
174
+ test('orderRetest response omits a web UI URL for this request but includes the ancestor', async () => {
175
+ const body = await response(['260101T0004', 'ord', 'A reason that is long enough.']);
176
+ const similarWeb = body['URLs of similar requests for web users'] as any;
177
+ assert.equal(similarWeb['this request'], null);
178
+ assert.ok(similarWeb['closest ancestor request'].includes('listIssues.html'));
179
+ });
@@ -0,0 +1,119 @@
1
+ /*
2
+ orderRetest.ts
3
+ Processes an order to retest a page immediately, without manual approval, and returns an acknowledgement.
4
+ */
5
+
6
+ // IMPORTS
7
+
8
+ import {z} from 'zod';
9
+ import {getRequestFailureReason, getResponseMetadata, getThisHost, getToolsFacts} from './util.ts';
10
+ import {checkLength, isJobID, isTimeStamp, orderJob} from '../util.ts';
11
+ import {orderRetestResponseSchema} from './schemas.ts';
12
+
13
+ // TYPES
14
+
15
+ // The response content defined by the response schema.
16
+ type ResponseContent = z.infer<typeof orderRetestResponseSchema>['response content'];
17
+
18
+ // FUNCTIONS
19
+
20
+ // Returns the response body.
21
+ export const response = async (args: string[]) => {
22
+ const [timeStamp = '', jobID = '', reason = ''] = args;
23
+ const thisHost = getThisHost();
24
+ const reasonCheck = checkLength(reason, 20, 100, 'reason');
25
+ // Initialize the response-content properties.
26
+ let requestDetails: ResponseContent['details about your order'];
27
+ let requestDisposition: ResponseContent['disposition of your order'] = null;
28
+ // If the timestamp or job ID is not syntactically valid:
29
+ if (!isTimeStamp(timeStamp) || !isJobID(jobID)) {
30
+ requestDetails = {
31
+ error: 'order invalid: the report timestamp or job identifier is malformed'
32
+ };
33
+ }
34
+ // Otherwise, if the encoded reason is too short or too long:
35
+ else if (reasonCheck.status === 'error') {
36
+ requestDetails = {
37
+ error: `order invalid: your ${reasonCheck.message}`
38
+ };
39
+ }
40
+ // Otherwise, i.e. if the order is facially valid:
41
+ else {
42
+ // Order the retest. Unlike processTestRequest, this enqueues the job directly, with
43
+ // no manual-approval step, gated only by the lightweight checks orderJob itself
44
+ // performs (the target is not already claimed or queued, and the job queue is not
45
+ // already full). This resolves the cited report's description and URL itself.
46
+ const orderResult = await orderJob({timeStamp, jobID}, reason);
47
+ // If the cited report does not exist:
48
+ if (orderResult.result === 'nonreport') {
49
+ requestDetails = {
50
+ error: 'order invalid: the specified report does not exist'
51
+ };
52
+ }
53
+ // Otherwise, i.e. if the cited report was found:
54
+ else {
55
+ // Add details about the order.
56
+ requestDetails = {
57
+ 'date and time received': new Date().toISOString(),
58
+ 'page to be retested': {
59
+ description: orderResult.description,
60
+ URL: orderResult.url
61
+ },
62
+ 'reason why the page should be retested': reason
63
+ };
64
+ // Add information about the disposition of the order.
65
+ if (orderResult.result === 'ok') {
66
+ const [reportTimeStamp, reportJobID] = orderResult.jobID.split('-') as [string, string];
67
+ requestDisposition = {
68
+ 'what happens next': 'Testing has begun. Once it finishes, the report will be available via the listIssues tool.',
69
+ 'report identifier': {timeStamp: reportTimeStamp, jobID: reportJobID},
70
+ 'how you can check for completion': `Call the listIssues tool with timeStamp "${reportTimeStamp}" and jobID "${reportJobID}" to learn whether the report is ready.`,
71
+ 'do you want to wait for completion': 'Testing typically takes about 2 minutes and occasionally up to 4 minutes. Do you want to wait (and be notified of completion) instead of checking back later? If so, call the awaitTest tool with the same timeStamp and jobID.'
72
+ };
73
+ }
74
+ else {
75
+ requestDisposition = {
76
+ 'what happens next': `Your order will not be processed, because ${getRequestFailureReason(
77
+ orderResult.result,
78
+ {result: 'superseded', reason: 'a later report about a page with the same description is available.'}
79
+ )}`,
80
+ 'report identifier': null,
81
+ 'how you can check for completion': 'Not applicable.',
82
+ 'do you want to wait for completion': 'Not applicable.'
83
+ };
84
+ }
85
+ }
86
+ }
87
+ // Create the response content.
88
+ const responseContent: ResponseContent = {
89
+ 'details about your order': requestDetails,
90
+ 'disposition of your order': requestDisposition
91
+ };
92
+ // Create a response body.
93
+ const body = {
94
+ 'tool collection': getToolsFacts(),
95
+ 'tool name': 'orderRetest',
96
+ 'this request': {
97
+ description: 'Process my order to retest a page, immediately and without manual approval. The timeStamp and jobID parameters identify the latest available report about the page. Those parameters were in the response to my earlier listIssues request. The reason property of the request body is the reason why the page should be retested.',
98
+ method: 'POST',
99
+ URL: `${thisHost}/api/orderRetest/${timeStamp}/${jobID}`,
100
+ body: {
101
+ reason
102
+ },
103
+ 'closest ancestor request': {
104
+ 'tool name': 'listIssues',
105
+ description: 'Provide details about one report, including basics about the issues reported in it.',
106
+ method: 'GET',
107
+ URL: `${thisHost}/api/listIssues/${timeStamp}/${jobID}`
108
+ }
109
+ },
110
+ 'URLs of similar requests for web users': {
111
+ 'this request': null,
112
+ 'closest ancestor request': `${thisHost}/listIssues.html/${timeStamp}/${jobID}`
113
+ },
114
+ 'response metadata': getResponseMetadata(),
115
+ 'response content': responseContent
116
+ };
117
+ // Return it.
118
+ return body;
119
+ };
@@ -1,6 +1,6 @@
1
1
  /*
2
- requestTest.test.ts
3
- Tests for api/requestTest.ts using the fixture corpus.
2
+ requestNewTest.test.ts
3
+ Tests for api/requestNewTest.ts using the fixture corpus.
4
4
  */
5
5
 
6
6
  // IMPORTS
@@ -11,7 +11,7 @@ import fs from 'node:fs/promises';
11
11
  import path from 'node:path';
12
12
  import {fixtureDBDir} from '../test/dbFixture.ts';
13
13
 
14
- // Blank the alert configuration unconditionally, before ./requestTest.ts (whose
14
+ // Blank the alert configuration unconditionally, before ./requestNewTest.ts (whose
15
15
  // processTestRequest calls sendAlert) is ever imported below, so this file sends no
16
16
  // real alert emails even when run directly rather than via `npm test`.
17
17
  for (const key of ['MANAGER_EMAIL', 'ALERT_API_HOST', 'ALERT_API_PATH', 'ALERT_API_KEY', 'ALERT_FROM']) {
@@ -41,7 +41,7 @@ beforeEach(async () => {
41
41
  console.log = (...args) => logged.push(args.join(' '));
42
42
  });
43
43
 
44
- import {response} from './requestTest.ts';
44
+ import {response} from './requestNewTest.ts';
45
45
 
46
46
  after(() => {
47
47
  console.log = originalLog;
@@ -55,45 +55,45 @@ after(() => {
55
55
 
56
56
  // TESTS
57
57
 
58
- test('requestTest rejects an empty description', async () => {
58
+ test('requestNewTest rejects an empty description', async () => {
59
59
  const body = await response(['', 'https://example.com/test', 'A reason that is long enough.']);
60
60
  assert.ok((body['response content']['details about your request'] as any).error);
61
- assert.ok(!logged.some(line => line.includes('new test request awaits approval')));
61
+ assert.ok(!logged.some(line => line.includes('request for a new test awaits approval')));
62
62
  });
63
63
 
64
- test('requestTest rejects a description longer than 100 characters', async () => {
64
+ test('requestNewTest rejects a description longer than 100 characters', async () => {
65
65
  const longWhat = 'x'.repeat(101);
66
66
  const body = await response([longWhat, 'https://example.com/test', 'A reason that is long enough.']);
67
67
  assert.ok((body['response content']['details about your request'] as any).error);
68
- assert.ok(!logged.some(line => line.includes('new test request awaits approval')));
68
+ assert.ok(!logged.some(line => line.includes('request for a new test awaits approval')));
69
69
  });
70
70
 
71
- test('requestTest rejects a URL shorter than 12 characters', async () => {
71
+ test('requestNewTest rejects a URL shorter than 12 characters', async () => {
72
72
  const body = await response(['Test Page', 'short', 'A reason that is long enough.']);
73
73
  assert.ok((body['response content']['details about your request'] as any).error);
74
- assert.ok(!logged.some(line => line.includes('new test request awaits approval')));
74
+ assert.ok(!logged.some(line => line.includes('request for a new test awaits approval')));
75
75
  });
76
76
 
77
- test('requestTest rejects a syntactically invalid URL with the correct length', async () => {
77
+ test('requestNewTest rejects a syntactically invalid URL with the correct length', async () => {
78
78
  const body = await response(['Test Page', 'not-a-valid-url', 'A reason that is long enough.']);
79
79
  const details = body['response content']['details about your request'] as any;
80
80
  assert.ok(details.error.includes('invalid URL'));
81
- assert.ok(!logged.some(line => line.includes('new test request awaits approval')));
81
+ assert.ok(!logged.some(line => line.includes('request for a new test awaits approval')));
82
82
  });
83
83
 
84
- test('requestTest rejects a private-address URL unless internal targets are allowed', async () => {
84
+ test('requestNewTest rejects a private-address URL unless internal targets are allowed', async () => {
85
85
  delete process.env.ALLOW_INTERNAL_TARGETS;
86
86
  try {
87
87
  const body = await response(['Internal Page', 'https://192.168.1.1/page', 'A reason that is long enough.']);
88
88
  const details = body['response content']['details about your request'] as any;
89
89
  assert.ok(details.error.includes('does not resolve to a page that this deployment can test'));
90
- assert.ok(!logged.some(line => line.includes('new test request awaits approval')));
90
+ assert.ok(!logged.some(line => line.includes('request for a new test awaits approval')));
91
91
  } finally {
92
92
  process.env.ALLOW_INTERNAL_TARGETS = 'true';
93
93
  }
94
94
  });
95
95
 
96
- test('requestTest rejects a URL that redirects to a disallowed target, and alerts a manager', async (t) => {
96
+ test('requestNewTest rejects a URL that redirects to a disallowed target, and alerts a manager', async (t) => {
97
97
  delete process.env.ALLOW_INTERNAL_TARGETS;
98
98
  t.mock.method(globalThis, 'fetch', async () => ({url: 'https://10.0.0.5/page'}) as Response);
99
99
  try {
@@ -109,21 +109,21 @@ test('requestTest rejects a URL that redirects to a disallowed target, and alert
109
109
  }
110
110
  });
111
111
 
112
- test('requestTest rejects an already-tested page', async () => {
112
+ test('requestNewTest rejects an already-tested page', async () => {
113
113
  const body = await response(['Mixed Outcomes Page', 'https://example.com/mixed', 'A reason that is long enough.']);
114
114
  const details = body['response content']['details about your request'] as any;
115
115
  // A report for this description and URL already exists, so processTestRequest
116
- // returns 'retest' and the failure is reported via the disposition, not details.error.
116
+ // returns 'reportExists' and the failure is reported via the disposition, not details.error.
117
117
  assert.equal(details.error, undefined);
118
118
  const disposition = body['response content']['disposition of your request'] as any;
119
119
  assert.ok(
120
120
  disposition['what happens next']
121
121
  .includes('a report about a page with the same description and URL is available.')
122
122
  );
123
- assert.ok(!logged.some(line => line.includes('new test request awaits approval')));
123
+ assert.ok(!logged.some(line => line.includes('request for a new test awaits approval')));
124
124
  });
125
125
 
126
- test('requestTest rejects a page matching a claimed job by description', async () => {
126
+ test('requestNewTest rejects a page matching a claimed job by description', async () => {
127
127
  const claimedPath = path.join(fixtureDBDir, 'jobs', 'claimed', 'clm.json');
128
128
  await fs.writeFile(claimedPath, JSON.stringify({
129
129
  target: {what: 'Claimed Test Page', url: 'https://example.com/claimed-job'}
@@ -143,7 +143,7 @@ test('requestTest rejects a page matching a claimed job by description', async (
143
143
  }
144
144
  });
145
145
 
146
- test('requestTest rejects a page matching a queued job by URL', async () => {
146
+ test('requestNewTest rejects a page matching a queued job by URL', async () => {
147
147
  const queuedPath = path.join(fixtureDBDir, 'jobs', 'queue', 'que.json');
148
148
  await fs.writeFile(queuedPath, JSON.stringify({
149
149
  target: {what: 'Some Other Page', url: 'https://example.com/queued-url'}
@@ -163,7 +163,7 @@ test('requestTest rejects a page matching a queued job by URL', async () => {
163
163
  }
164
164
  });
165
165
 
166
- test('requestTest rejects a duplicate request', async () => {
166
+ test('requestNewTest rejects a duplicate request', async () => {
167
167
  await response(['Duplicate API Page', 'https://example.com/dup-api', 'A reason that is long enough.']);
168
168
  const body = await response(['Duplicate API Page', 'https://example.com/dup-api', 'A reason that is long enough.']);
169
169
  const details = body['response content']['details about your request'] as any;
@@ -172,13 +172,13 @@ test('requestTest rejects a duplicate request', async () => {
172
172
  assert.ok(disposition['what happens next'].includes('an identical request is already awaiting approval.'));
173
173
  });
174
174
 
175
- test('requestTest accepts a valid new page request', async () => {
175
+ test('requestNewTest accepts a valid new page request', async () => {
176
176
  const body = await response(['Brand New Page', 'https://example.com/brandnew', 'A reason that is long enough.']);
177
177
  const details = body['response content']['details about your request'] as any;
178
178
  assert.equal(details.error, undefined);
179
179
  assert.equal(details['page to be tested'].description, 'Brand New Page');
180
180
  assert.ok(logged.some(line =>
181
- line.startsWith('WARNING (Kilotest: new test request awaits approval)')
181
+ line.startsWith('WARNING (Kilotest: request for a new test awaits approval)')
182
182
  && line.includes('Brand New Page')
183
183
  && line.includes('https://example.com/brandnew')
184
184
  // The alert reports the resulting queue size, so a maintainer who has been away can
@@ -187,14 +187,14 @@ test('requestTest accepts a valid new page request', async () => {
187
187
  ));
188
188
  });
189
189
 
190
- test('requestTest includes disposition information for a valid request', async () => {
190
+ test('requestNewTest includes disposition information for a valid request', async () => {
191
191
  const body = await response(['Brand New Page', 'https://example.com/brandnew', 'A reason that is long enough.']);
192
192
  const disposition = body['response content']['disposition of your request'];
193
193
  assert.ok(disposition);
194
194
  assert.ok(disposition['what happens next']);
195
195
  });
196
196
 
197
- test('requestTest rejects a request with a fallback channel when the queue is full', async () => {
197
+ test('requestNewTest rejects a request with a fallback channel when the queue is full', async () => {
198
198
  // Fill testRequests.json with 20 pending requests, all for one URL, to reach the cap.
199
199
  const filler = Array.from({length: 20}, (_, i) => ({
200
200
  timeStamp: '260101T0000', description: `Filler Page ${i}`, reason: 'Filler reason.'
@@ -1,20 +1,20 @@
1
1
  /*
2
- requestTest.ts
2
+ requestNewTest.ts
3
3
  Processes a request to test an untested page and returns an acknowledgement.
4
4
  */
5
5
 
6
6
  // IMPORTS
7
7
 
8
8
  import {z} from 'zod';
9
- import {getResponseMetadata, getThisHost, getToolsFacts} from './util.ts';
9
+ import {buildRequestDisposition, getResponseMetadata, getThisHost, getToolsFacts} from './util.ts';
10
10
  import {checkLength, isAllowedRedirectTarget, isAllowedTarget, isURL, processTestRequest} from '../util.ts';
11
- import {requestTestResponseSchema} from './schemas.ts';
11
+ import {requestNewTestResponseSchema} from './schemas.ts';
12
12
  import {sendAlert} from '../alerts.ts';
13
13
 
14
14
  // TYPES
15
15
 
16
16
  // The response content defined by the response schema.
17
- type ResponseContent = z.infer<typeof requestTestResponseSchema>['response content'];
17
+ type ResponseContent = z.infer<typeof requestNewTestResponseSchema>['response content'];
18
18
 
19
19
  // FUNCTIONS
20
20
 
@@ -77,38 +77,15 @@ export const response = async (args: string[]) => {
77
77
  'reason why the page should be tested': reason
78
78
  };
79
79
  // Add information about the disposition of the request.
80
- if (requestResult === 'ok') {
81
- requestDisposition = {
82
- 'what happens next': 'Your request is likely to be approved and processed within 1 hour to 1 day.',
83
- 'how you can check for completion': 'You can call the listReports tool to learn whether the page has been tested and a report is available.',
84
- 'how a web user can check for completion': `A web user can visit ${thisHost}/listReports.html to learn whether the page has been tested and a report is available.`
85
- };
86
- }
87
- else {
88
- const failureFact = 'Your request will not be processed, because ';
89
- let failureReason: string;
90
- if (requestResult === 'description') {
91
- failureReason = 'a request to test a page with the same description is already approved.'
92
- }
93
- else if (requestResult === 'url') {
94
- failureReason = 'a request to test a page with the same URL is already approved.'
95
- }
96
- else if (requestResult === 'retest') {
97
- failureReason = 'a report about a page with the same description and URL is available.'
98
- }
99
- else if (requestResult === 'queueFull') {
100
- failureReason = 'too many requests are awaiting approval right now. Please try again later, ' +
101
- 'or post your request at https://github.com/jrpool/kilotest/issues or email info@kilotest.com.'
102
- }
103
- else {
104
- failureReason = 'an identical request is already awaiting approval.'
105
- }
106
- requestDisposition = {
107
- 'what happens next': `${failureFact}${failureReason}`,
108
- 'how you can check for completion': 'Not applicable.',
109
- 'how a web user can check for completion': 'Not applicable.'
110
- }
111
- }
80
+ requestDisposition = buildRequestDisposition(
81
+ requestResult,
82
+ {
83
+ whatHappensNext: 'Your request is likely to be approved and processed within 1 hour to 1 day.',
84
+ howToCheck: 'You can call the listReports tool to learn whether the page has been tested and a report is available.',
85
+ howWebUserChecks: `A web user can visit ${thisHost}/listReports.html to learn whether the page has been tested and a report is available.`
86
+ },
87
+ {result: 'reportExists', reason: 'a report about a page with the same description and URL is available.'}
88
+ );
112
89
  }
113
90
  // Create the response content.
114
91
  const responseContent: ResponseContent = {
@@ -118,11 +95,11 @@ export const response = async (args: string[]) => {
118
95
  // Create a response body.
119
96
  const body = {
120
97
  'tool collection': getToolsFacts(),
121
- 'tool name': 'requestTest',
98
+ 'tool name': 'requestNewTest',
122
99
  'this request': {
123
100
  description: 'Process my request to test a page about which no report is available yet. I have provided a description and the URL of the page and a reason why it should be tested.',
124
101
  method: 'POST',
125
- URL: `${thisHost}/api/requestTest`,
102
+ URL: `${thisHost}/api/requestNewTest`,
126
103
  body: {
127
104
  description,
128
105
  URL: url,
@@ -136,7 +113,7 @@ export const response = async (args: string[]) => {
136
113
  }
137
114
  },
138
115
  'URLs of similar requests for web users': {
139
- 'this request': `${thisHost}/requestTestForm.html`,
116
+ 'this request': `${thisHost}/requestNewTestForm.html`,
140
117
  'closest ancestor request': `${thisHost}/listReports.html`
141
118
  },
142
119
  'response metadata': getResponseMetadata(),
@@ -74,14 +74,14 @@ test('requestRetest rejects a nonexistent report', async () => {
74
74
  const body = await response(['251231T0000', 'zzz', 'A reason that is long enough.']);
75
75
  const details = body['response content']['details about your request'] as any;
76
76
  assert.ok(details.error.includes('does not exist'));
77
- assert.ok(!logged.some(line => line.includes('new retest request awaits approval')));
77
+ assert.ok(!logged.some(line => line.includes('request for a retest awaits approval')));
78
78
  });
79
79
 
80
80
  test('requestRetest rejects a malformed timestamp or job identifier', async () => {
81
81
  const body = await response(['999999T9999', 'xyz', 'A reason that is long enough.']);
82
82
  const details = body['response content']['details about your request'] as any;
83
83
  assert.ok(details.error.includes('malformed'));
84
- assert.ok(!logged.some(line => line.includes('new retest request awaits approval')));
84
+ assert.ok(!logged.some(line => line.includes('request for a retest awaits approval')));
85
85
  });
86
86
 
87
87
  test('requestRetest rejects a superseded report', async () => {
@@ -95,14 +95,14 @@ test('requestRetest rejects a superseded report', async () => {
95
95
  assert.ok(
96
96
  disposition['what happens next'].includes('a later report about a page with the same description is available.')
97
97
  );
98
- assert.ok(!logged.some(line => line.includes('new retest request awaits approval')));
98
+ assert.ok(!logged.some(line => line.includes('request for a retest awaits approval')));
99
99
  });
100
100
 
101
101
  test('requestRetest rejects a reason shorter than 20 characters', async () => {
102
102
  const body = await response(['260101T0003', 'ret', 'short']);
103
103
  const details = body['response content']['details about your request'] as any;
104
104
  assert.ok(details.error.includes('reason'));
105
- assert.ok(!logged.some(line => line.includes('new retest request awaits approval')));
105
+ assert.ok(!logged.some(line => line.includes('request for a retest awaits approval')));
106
106
  });
107
107
 
108
108
  test('requestRetest rejects a reason longer than 100 characters', async () => {
@@ -110,7 +110,7 @@ test('requestRetest rejects a reason longer than 100 characters', async () => {
110
110
  const body = await response(['260101T0003', 'ret', longReason]);
111
111
  const details = body['response content']['details about your request'] as any;
112
112
  assert.ok(details.error.includes('reason'));
113
- assert.ok(!logged.some(line => line.includes('new retest request awaits approval')));
113
+ assert.ok(!logged.some(line => line.includes('request for a retest awaits approval')));
114
114
  });
115
115
 
116
116
  test('requestRetest rejects a report matching a claimed job by description', async () => {
@@ -170,7 +170,7 @@ test('requestRetest accepts a valid retest request for the latest report of a pa
170
170
  assert.equal(details.error, undefined);
171
171
  assert.equal(details['page to be retested'].description, 'Mixed Outcomes Page');
172
172
  assert.ok(logged.some(line =>
173
- line.startsWith('WARNING (Kilotest: new retest request awaits approval)')
173
+ line.startsWith('WARNING (Kilotest: request for a retest awaits approval)')
174
174
  && line.includes('Mixed Outcomes Page')
175
175
  && line.includes('https://example.com/mixed')
176
176
  // The alert reports the resulting queue size, so a maintainer who has been away can