@jrpool/kilotest 52.0.0 → 57.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.devin/config.local.json +4 -1
- package/.github/workflows/ci.yml +3 -0
- package/README.md +1 -1
- package/api/awaitTest.test.ts +114 -0
- package/api/awaitTest.ts +108 -0
- package/api/listReports.ts +2 -2
- package/api/orderNewTest.test.ts +213 -0
- package/api/orderNewTest.ts +145 -0
- package/api/orderRetest.test.ts +179 -0
- package/api/orderRetest.ts +119 -0
- package/api/requestFeature.test.ts +36 -0
- package/api/requestFeature.ts +26 -4
- package/api/{requestTest.test.ts → requestNewTest.test.ts} +57 -23
- package/api/{requestTest.ts → requestNewTest.ts} +31 -36
- package/api/requestRetest.test.ts +24 -6
- package/api/requestRetest.ts +10 -29
- package/api/routes.test.ts +7 -4
- package/api/routes.ts +45 -7
- package/api/schemas.ts +120 -10
- package/api/util.ts +59 -2
- package/api/version.ts +6 -2
- package/capability.md +9 -6
- package/docs/AI-TOOLS.md +3 -3
- package/docs/SERVICE.md +19 -8
- package/env.example +39 -0
- package/index.test.ts +146 -24
- package/index.ts +129 -36
- package/lhm.plugin.json +1 -1
- package/linkValidation.test.ts +2 -2
- package/llms-full.txt +35 -4
- package/llms.txt +6 -3
- package/mcp.test.ts +109 -12
- package/mcp.ts +146 -26
- package/memory/MEMORY.md +1 -0
- package/memory/plan_issue_111_instant_test.md +198 -0
- package/openapi.yaml +456 -9
- package/package.json +2 -1
- package/routes.test.ts +1 -1
- package/routing.test.ts +2 -2
- package/scripts/generate-openapi.ts +1 -1
- package/server.json +1 -1
- package/sitemap.xml +1 -1
- package/smokeTest.ts +2 -2
- package/util.test.ts +486 -2
- package/util.ts +276 -45
- package/web/listReports/index.html +1 -1
- package/web/listReports/listReports.test.ts +1 -1
- package/web/{requestTest → requestNewTest}/index.ts +10 -1
- package/web/{requestTest/requestTest.test.ts → requestNewTest/requestNewTest.test.ts} +18 -2
- package/web/{requestTestForm → requestNewTestForm}/index.html +1 -1
- package/web/{requestTestForm/requestTestForm.test.ts → requestNewTestForm/requestNewTestForm.test.ts} +6 -6
- package/web/requestRetest/index.ts +9 -0
- package/web/requestRetest/requestRetest.test.ts +14 -0
- package/web/tutorialAI/index.ts +25 -3
- package/web/tutorialAI/tutorialAI.test.ts +32 -0
- package/web/tutorialWeb/index.ts +25 -3
- package/web/tutorialWeb/tutorialWeb.test.ts +32 -0
- /package/web/{requestTest → requestNewTest}/index.html +0 -0
- /package/web/{requestTestForm → requestNewTestForm}/index.ts +0 -0
|
@@ -1,19 +1,20 @@
|
|
|
1
1
|
/*
|
|
2
|
-
|
|
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';
|
|
10
|
-
import {checkLength, isAllowedTarget, isURL, processTestRequest} from '../util.ts';
|
|
11
|
-
import {
|
|
9
|
+
import {buildRequestDisposition, getResponseMetadata, getThisHost, getToolsFacts} from './util.ts';
|
|
10
|
+
import {checkLength, isAllowedRedirectTarget, isAllowedTarget, isURL, processTestRequest} from '../util.ts';
|
|
11
|
+
import {requestNewTestResponseSchema} from './schemas.ts';
|
|
12
|
+
import {sendAlert} from '../alerts.ts';
|
|
12
13
|
|
|
13
14
|
// TYPES
|
|
14
15
|
|
|
15
16
|
// The response content defined by the response schema.
|
|
16
|
-
type ResponseContent = z.infer<typeof
|
|
17
|
+
type ResponseContent = z.infer<typeof requestNewTestResponseSchema>['response content'];
|
|
17
18
|
|
|
18
19
|
// FUNCTIONS
|
|
19
20
|
|
|
@@ -49,6 +50,19 @@ export const response = async (args: string[]) => {
|
|
|
49
50
|
error: 'request invalid: the URL you specified does not resolve to a page that this deployment can test'
|
|
50
51
|
};
|
|
51
52
|
}
|
|
53
|
+
// Otherwise, i.e. if the URL redirects to a target this deployment does not allow:
|
|
54
|
+
else if (!(await isAllowedRedirectTarget(url))) {
|
|
55
|
+
requestDetails = {
|
|
56
|
+
error: 'request invalid: the URL you specified does not resolve to a page that this deployment can test'
|
|
57
|
+
};
|
|
58
|
+
// Alert a manager, since a redirect to a disallowed target on a request that
|
|
59
|
+
// itself named an allowed hostname suggests an attempted or accidental SSRF,
|
|
60
|
+
// rather than an ordinary mistake by a legitimate submitter.
|
|
61
|
+
await sendAlert(
|
|
62
|
+
'Kilotest: request rejected for redirecting to a disallowed target',
|
|
63
|
+
`A request to test ${url} (described as "${description}") was rejected because it redirects to a target this deployment does not allow (e.g. a private, loopback, or link-local address).`
|
|
64
|
+
);
|
|
65
|
+
}
|
|
52
66
|
// Otherwise, i.e. if the description and URL are valid:
|
|
53
67
|
else {
|
|
54
68
|
// Process the request.
|
|
@@ -63,34 +77,15 @@ export const response = async (args: string[]) => {
|
|
|
63
77
|
'reason why the page should be tested': reason
|
|
64
78
|
};
|
|
65
79
|
// Add information about the disposition of the request.
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
'
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
let failureReason: string;
|
|
76
|
-
if (requestResult === 'description') {
|
|
77
|
-
failureReason = 'a request to test a page with the same description is already approved.'
|
|
78
|
-
}
|
|
79
|
-
else if (requestResult === 'url') {
|
|
80
|
-
failureReason = 'a request to test a page with the same URL is already approved.'
|
|
81
|
-
}
|
|
82
|
-
else if (requestResult === 'retest') {
|
|
83
|
-
failureReason = 'a report about a page with the same description and URL is available.'
|
|
84
|
-
}
|
|
85
|
-
else {
|
|
86
|
-
failureReason = 'an identical request is already awaiting approval.'
|
|
87
|
-
}
|
|
88
|
-
requestDisposition = {
|
|
89
|
-
'what happens next': `${failureFact}${failureReason}`,
|
|
90
|
-
'how you can check for completion': 'Not applicable.',
|
|
91
|
-
'how a web user can check for completion': 'Not applicable.'
|
|
92
|
-
}
|
|
93
|
-
}
|
|
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
|
+
);
|
|
94
89
|
}
|
|
95
90
|
// Create the response content.
|
|
96
91
|
const responseContent: ResponseContent = {
|
|
@@ -100,11 +95,11 @@ export const response = async (args: string[]) => {
|
|
|
100
95
|
// Create a response body.
|
|
101
96
|
const body = {
|
|
102
97
|
'tool collection': getToolsFacts(),
|
|
103
|
-
'tool name': '
|
|
98
|
+
'tool name': 'requestNewTest',
|
|
104
99
|
'this request': {
|
|
105
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.',
|
|
106
101
|
method: 'POST',
|
|
107
|
-
URL: `${thisHost}/api/
|
|
102
|
+
URL: `${thisHost}/api/requestNewTest`,
|
|
108
103
|
body: {
|
|
109
104
|
description,
|
|
110
105
|
URL: url,
|
|
@@ -118,7 +113,7 @@ export const response = async (args: string[]) => {
|
|
|
118
113
|
}
|
|
119
114
|
},
|
|
120
115
|
'URLs of similar requests for web users': {
|
|
121
|
-
'this request': `${thisHost}/
|
|
116
|
+
'this request': `${thisHost}/requestNewTestForm.html`,
|
|
122
117
|
'closest ancestor request': `${thisHost}/listReports.html`
|
|
123
118
|
},
|
|
124
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('
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
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,8 +170,26 @@ 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:
|
|
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
|
+
// The alert reports the resulting queue size, so a maintainer who has been away can
|
|
177
|
+
// see at a glance how close the queue is to full.
|
|
178
|
+
&& line.includes('Requests now awaiting approval: 1 of 20')
|
|
176
179
|
));
|
|
177
180
|
});
|
|
181
|
+
|
|
182
|
+
test('requestRetest rejects a request with a fallback channel when the queue is full', async () => {
|
|
183
|
+
// Fill testRequests.json with 20 pending requests, all for one URL, to reach the cap.
|
|
184
|
+
const filler = Array.from({length: 20}, (_, i) => ({
|
|
185
|
+
timeStamp: '260101T0000', description: `Filler Page ${i}`, reason: 'Filler reason.'
|
|
186
|
+
}));
|
|
187
|
+
await fs.writeFile(testRequestsPath, JSON.stringify({'https://example.com/filler': filler}));
|
|
188
|
+
const body = await response(['260101T0003', 'ret', 'A reason that is long enough.']);
|
|
189
|
+
const details = body['response content']['details about your request'] as any;
|
|
190
|
+
assert.equal(details.error, undefined);
|
|
191
|
+
const disposition = body['response content']['disposition of your request'] as any;
|
|
192
|
+
assert.ok(disposition['what happens next'].includes('too many requests are awaiting approval right now.'));
|
|
193
|
+
assert.ok(disposition['what happens next'].includes('https://github.com/jrpool/kilotest/issues'));
|
|
194
|
+
assert.ok(disposition['what happens next'].includes('info@kilotest.com'));
|
|
195
|
+
});
|
package/api/requestRetest.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
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, isJobID, isTimeStamp, processTestRequest} from '../util.ts';
|
|
11
11
|
import {requestRetestResponseSchema} from './schemas.ts';
|
|
12
12
|
|
|
@@ -60,34 +60,15 @@ export const response = async (args: string[]) => {
|
|
|
60
60
|
'reason why the page should be retested': reason
|
|
61
61
|
};
|
|
62
62
|
// Add information about the disposition of the request.
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
'
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
let failureReason: string;
|
|
73
|
-
if (requestResult === 'description') {
|
|
74
|
-
failureReason = 'a request to test a page with the same description is already approved.'
|
|
75
|
-
}
|
|
76
|
-
else if (requestResult === 'url') {
|
|
77
|
-
failureReason = 'a request to test a page with the same URL is already approved.'
|
|
78
|
-
}
|
|
79
|
-
else if (requestResult === 'superseded') {
|
|
80
|
-
failureReason = 'a later report about a page with the same description is available.'
|
|
81
|
-
}
|
|
82
|
-
else {
|
|
83
|
-
failureReason = 'an identical request is already awaiting approval.'
|
|
84
|
-
}
|
|
85
|
-
requestDisposition = {
|
|
86
|
-
'what happens next': `${failureFact}${failureReason}`,
|
|
87
|
-
'how you can check for completion': 'Not applicable.',
|
|
88
|
-
'how a web user can check for completion': 'Not applicable.'
|
|
89
|
-
}
|
|
90
|
-
}
|
|
63
|
+
requestDisposition = buildRequestDisposition(
|
|
64
|
+
requestResult,
|
|
65
|
+
{
|
|
66
|
+
whatHappensNext: 'Your request is likely to be approved and processed within 1 hour to 1 day.',
|
|
67
|
+
howToCheck: 'You can call the listReports tool to learn whether the page has been retested and a new report is available.',
|
|
68
|
+
howWebUserChecks: `A web user can visit ${thisHost}/listReports.html to learn whether the page has been retested and a new report is available.`
|
|
69
|
+
},
|
|
70
|
+
{result: 'superseded', reason: 'a later report about a page with the same description is available.'}
|
|
71
|
+
);
|
|
91
72
|
}
|
|
92
73
|
}
|
|
93
74
|
// Create the response content.
|
package/api/routes.test.ts
CHANGED
|
@@ -11,22 +11,25 @@ import {routes} from './routes.ts';
|
|
|
11
11
|
|
|
12
12
|
// TESTS
|
|
13
13
|
|
|
14
|
-
test('routes exports an array with
|
|
14
|
+
test('routes exports an array with 11 operations', () => {
|
|
15
15
|
assert.ok(Array.isArray(routes));
|
|
16
|
-
assert.equal(routes.length,
|
|
16
|
+
assert.equal(routes.length, 11);
|
|
17
17
|
});
|
|
18
18
|
|
|
19
19
|
test('routes includes all expected operation IDs', () => {
|
|
20
20
|
const ids = routes.map(r => r.operationId).sort();
|
|
21
21
|
assert.deepEqual(ids, [
|
|
22
|
+
'awaitTest',
|
|
22
23
|
'getReport',
|
|
23
24
|
'listDiagnoses',
|
|
24
25
|
'listIssues',
|
|
25
26
|
'listReports',
|
|
26
27
|
'listViolators',
|
|
28
|
+
'orderNewTest',
|
|
29
|
+
'orderRetest',
|
|
27
30
|
'requestFeature',
|
|
28
|
-
'
|
|
29
|
-
'
|
|
31
|
+
'requestNewTest',
|
|
32
|
+
'requestRetest'
|
|
30
33
|
]);
|
|
31
34
|
});
|
|
32
35
|
|
package/api/routes.ts
CHANGED
|
@@ -11,17 +11,23 @@ import {
|
|
|
11
11
|
listViolatorsSchema,
|
|
12
12
|
listDiagnosesSchema,
|
|
13
13
|
getReportSchema,
|
|
14
|
-
|
|
14
|
+
requestNewTestSchema,
|
|
15
15
|
requestRetestSchema,
|
|
16
16
|
requestFeatureSchema,
|
|
17
|
+
orderNewTestSchema,
|
|
18
|
+
orderRetestSchema,
|
|
19
|
+
awaitTestSchema,
|
|
17
20
|
listReportsResponseSchema,
|
|
18
21
|
listIssuesResponseSchema,
|
|
19
22
|
listViolatorsResponseSchema,
|
|
20
23
|
listDiagnosesResponseSchema,
|
|
21
24
|
getReportResponseSchema,
|
|
22
|
-
|
|
25
|
+
requestNewTestResponseSchema,
|
|
23
26
|
requestRetestResponseSchema,
|
|
24
|
-
requestFeatureResponseSchema
|
|
27
|
+
requestFeatureResponseSchema,
|
|
28
|
+
orderNewTestResponseSchema,
|
|
29
|
+
orderRetestResponseSchema,
|
|
30
|
+
awaitTestResponseSchema
|
|
25
31
|
} from './schemas.ts';
|
|
26
32
|
|
|
27
33
|
// ROUTES
|
|
@@ -67,12 +73,12 @@ export const routes = [
|
|
|
67
73
|
responseSchema: getReportResponseSchema
|
|
68
74
|
},
|
|
69
75
|
{
|
|
70
|
-
operationId: '
|
|
76
|
+
operationId: 'requestNewTest',
|
|
71
77
|
method: 'post',
|
|
72
|
-
path: '/api/
|
|
78
|
+
path: '/api/requestNewTest',
|
|
73
79
|
summary: 'Request that a page be tested',
|
|
74
|
-
bodySchema: z.object(
|
|
75
|
-
responseSchema:
|
|
80
|
+
bodySchema: z.object(requestNewTestSchema),
|
|
81
|
+
responseSchema: requestNewTestResponseSchema
|
|
76
82
|
},
|
|
77
83
|
{
|
|
78
84
|
operationId: 'requestRetest',
|
|
@@ -93,5 +99,37 @@ export const routes = [
|
|
|
93
99
|
summary: 'Request a feature improvement or new feature',
|
|
94
100
|
bodySchema: z.object(requestFeatureSchema),
|
|
95
101
|
responseSchema: requestFeatureResponseSchema
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
operationId: 'orderNewTest',
|
|
105
|
+
method: 'post',
|
|
106
|
+
path: '/api/orderNewTest',
|
|
107
|
+
summary: 'Order that a page be tested immediately, without manual approval',
|
|
108
|
+
bodySchema: z.object(orderNewTestSchema),
|
|
109
|
+
responseSchema: orderNewTestResponseSchema
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
operationId: 'orderRetest',
|
|
113
|
+
method: 'post',
|
|
114
|
+
path: '/api/orderRetest/{timeStamp}/{jobID}',
|
|
115
|
+
summary: 'Order that a page be retested immediately, without manual approval',
|
|
116
|
+
pathParamsSchema: z.object({
|
|
117
|
+
timeStamp: orderRetestSchema.timeStamp,
|
|
118
|
+
jobID: orderRetestSchema.jobID
|
|
119
|
+
}),
|
|
120
|
+
bodySchema: z.object({reason: orderRetestSchema.reason}),
|
|
121
|
+
responseSchema: orderRetestResponseSchema
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
operationId: 'awaitTest',
|
|
125
|
+
method: 'post',
|
|
126
|
+
path: '/api/awaitTest/{timeStamp}/{jobID}',
|
|
127
|
+
summary: 'Wait for a job ordered via orderNewTest or orderRetest to complete',
|
|
128
|
+
pathParamsSchema: z.object(awaitTestSchema),
|
|
129
|
+
// No caller-supplied fields beyond the path parameters; an empty body schema (rather
|
|
130
|
+
// than omitting bodySchema) keeps this route consistent with the "every POST route
|
|
131
|
+
// has a bodySchema" invariant the rest of this table and its own test maintain.
|
|
132
|
+
bodySchema: z.object({}),
|
|
133
|
+
responseSchema: awaitTestResponseSchema
|
|
96
134
|
}
|
|
97
135
|
];
|
package/api/schemas.ts
CHANGED
|
@@ -41,8 +41,8 @@ export const getReportSchema = {
|
|
|
41
41
|
jobID: z.string().describe('Job identifier of the report (example: x9z)')
|
|
42
42
|
};
|
|
43
43
|
|
|
44
|
-
//
|
|
45
|
-
export const
|
|
44
|
+
// requestNewTest: POST /api/requestNewTest
|
|
45
|
+
export const requestNewTestSchema = {
|
|
46
46
|
description: z.string().describe('1- to 100-character description of the page conforming to the naming convention used in the listReports output'),
|
|
47
47
|
URL: z.string().describe('12- to 300-character URL of the page, including the https:// scheme and any query'),
|
|
48
48
|
reason: z.string().describe('20- to 100-character reason why the page should be tested')
|
|
@@ -60,6 +60,26 @@ export const requestFeatureSchema = {
|
|
|
60
60
|
feature: z.string().describe('20- to 1000-character description of requested feature improvement or new feature')
|
|
61
61
|
};
|
|
62
62
|
|
|
63
|
+
// orderNewTest: POST /api/orderNewTest
|
|
64
|
+
export const orderNewTestSchema = {
|
|
65
|
+
description: z.string().describe('1- to 100-character description of the page conforming to the naming convention used in the listReports output'),
|
|
66
|
+
URL: z.string().describe('12- to 300-character URL of the page, including the https:// scheme and any query'),
|
|
67
|
+
reason: z.string().describe('20- to 100-character reason why the page should be tested')
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// orderRetest: POST /api/orderRetest/{timeStamp}/{jobID}
|
|
71
|
+
export const orderRetestSchema = {
|
|
72
|
+
timeStamp: z.string().describe('Timestamp of the latest report about the page in YYMMDDTHHmm format (example: 260503T0432)'),
|
|
73
|
+
jobID: z.string().describe('Job identifier of the latest report about the page (example: x9z)'),
|
|
74
|
+
reason: z.string().describe('20- to 100-character reason why the page should be retested')
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// awaitTest: POST /api/awaitTest
|
|
78
|
+
export const awaitTestSchema = {
|
|
79
|
+
timeStamp: z.string().describe('Timestamp of the ordered job, from the report identifier returned by orderNewTest, in YYMMDDTHHmm format (example: 260503T0432)'),
|
|
80
|
+
jobID: z.string().describe('Job identifier of the ordered job, from the report identifier returned by orderNewTest (example: x9z)')
|
|
81
|
+
};
|
|
82
|
+
|
|
63
83
|
// listReports takes no input; omitted (mcp.ts already uses inputSchema: {}).
|
|
64
84
|
|
|
65
85
|
// RESPONSE SCHEMAS
|
|
@@ -93,32 +113,40 @@ const toolsFactsSchema = z.object({
|
|
|
93
113
|
'web users can obtain similar functionalities at': z.string()
|
|
94
114
|
}).meta({id: 'ToolsFacts'});
|
|
95
115
|
|
|
96
|
-
// Identifies a related request (e.g., the closest ancestor request in the drill-down
|
|
116
|
+
// Identifies a related request (e.g., the closest ancestor request in the drill-down
|
|
117
|
+
// hierarchy). method is usually GET (every drill-down tool's ancestor is a read), except
|
|
118
|
+
// for orderNewTest/orderRetest, whose own ancestor is a GET, and awaitTest, whose ancestors
|
|
119
|
+
// (orderNewTest and orderRetest) are both POST (they create a job).
|
|
97
120
|
const requestReferenceSchema = z.object({
|
|
98
121
|
'tool name': z.string(),
|
|
99
122
|
description: z.string(),
|
|
100
|
-
method: z.
|
|
123
|
+
method: z.enum(['GET', 'POST']),
|
|
101
124
|
URL: z.string()
|
|
102
125
|
}).meta({id: 'RequestReference'});
|
|
103
126
|
|
|
104
127
|
// GET endpoints have no 'body'; POST endpoints get a request-specific body schema.
|
|
105
|
-
|
|
128
|
+
// ancestorSchema overrides the default single-ancestor shape for a tool whose closest
|
|
129
|
+
// ancestor is not unique: awaitTest can be reached from either orderNewTest or orderRetest,
|
|
130
|
+
// and the identifier alone does not say which, so it reports both as an array rather
|
|
131
|
+
// than arbitrarily picking one.
|
|
132
|
+
const thisRequestSchema = (method: string, bodySchema?: ZodTypeAny, ancestorSchema: ZodTypeAny = requestReferenceSchema.nullable()) => z.object({
|
|
106
133
|
description: z.string(),
|
|
107
134
|
method: z.literal(method),
|
|
108
135
|
URL: z.string(),
|
|
109
136
|
...(bodySchema ? {body: bodySchema} : {}),
|
|
110
|
-
'closest ancestor request':
|
|
137
|
+
'closest ancestor request': ancestorSchema
|
|
111
138
|
});
|
|
112
139
|
|
|
113
140
|
const envelope = <T extends ZodTypeAny>(
|
|
114
141
|
method: string,
|
|
115
142
|
responseContentSchema: T,
|
|
116
143
|
bodySchema?: ZodTypeAny,
|
|
117
|
-
similarWebSchema: ZodTypeAny = similarWebRequestsSchema
|
|
144
|
+
similarWebSchema: ZodTypeAny = similarWebRequestsSchema,
|
|
145
|
+
ancestorSchema?: ZodTypeAny
|
|
118
146
|
) => z.object({
|
|
119
147
|
'tool collection': toolsFactsSchema,
|
|
120
148
|
'tool name': z.string(),
|
|
121
|
-
'this request': thisRequestSchema(method, bodySchema),
|
|
149
|
+
'this request': thisRequestSchema(method, bodySchema, ancestorSchema),
|
|
122
150
|
'URLs of similar requests for web users': similarWebSchema,
|
|
123
151
|
'response metadata': responseMetadataSchema,
|
|
124
152
|
'response content': responseContentSchema
|
|
@@ -268,7 +296,7 @@ export const getReportResponseSchema = envelope('GET', z.object({
|
|
|
268
296
|
'full report': z.unknown().describe('The full raw Testaro/Testilo report JSON, copied verbatim, or an error object if it could not be retrieved.')
|
|
269
297
|
}));
|
|
270
298
|
|
|
271
|
-
export const
|
|
299
|
+
export const requestNewTestResponseSchema = envelope(
|
|
272
300
|
'POST',
|
|
273
301
|
z.object({
|
|
274
302
|
'details about your request': z.union([
|
|
@@ -285,7 +313,7 @@ export const requestTestResponseSchema = envelope(
|
|
|
285
313
|
'how a web user can check for completion': z.string()
|
|
286
314
|
}).nullable()
|
|
287
315
|
}),
|
|
288
|
-
z.object(
|
|
316
|
+
z.object(requestNewTestSchema)
|
|
289
317
|
);
|
|
290
318
|
|
|
291
319
|
export const requestRetestResponseSchema = envelope(
|
|
@@ -320,3 +348,85 @@ export const requestFeatureResponseSchema = envelope(
|
|
|
320
348
|
z.object(requestFeatureSchema),
|
|
321
349
|
z.null()
|
|
322
350
|
);
|
|
351
|
+
|
|
352
|
+
// Identifies the report a successfully ordered or awaited job will produce, once complete.
|
|
353
|
+
const reportIdentifierSchema = z.object({
|
|
354
|
+
timeStamp: z.string().describe('Timestamp to pass to listIssues once the report is ready.'),
|
|
355
|
+
jobID: z.string().describe('Job identifier to pass to listIssues once the report is ready.')
|
|
356
|
+
}).meta({id: 'ReportIdentifier'});
|
|
357
|
+
|
|
358
|
+
export const orderNewTestResponseSchema = envelope(
|
|
359
|
+
'POST',
|
|
360
|
+
z.object({
|
|
361
|
+
'details about your order': z.union([
|
|
362
|
+
z.object({error: z.string()}),
|
|
363
|
+
z.object({
|
|
364
|
+
'date and time received': z.string(),
|
|
365
|
+
'page to be tested': z.object({description: z.string(), URL: z.string()}),
|
|
366
|
+
'reason why the page should be tested': z.string()
|
|
367
|
+
})
|
|
368
|
+
]),
|
|
369
|
+
'disposition of your order': z.object({
|
|
370
|
+
'what happens next': z.string(),
|
|
371
|
+
'report identifier': reportIdentifierSchema.nullable(),
|
|
372
|
+
'how you can check for completion': z.string(),
|
|
373
|
+
'do you want to wait for completion': z.string()
|
|
374
|
+
}).nullable()
|
|
375
|
+
}),
|
|
376
|
+
z.object(orderNewTestSchema),
|
|
377
|
+
z.object({
|
|
378
|
+
'this request': z.null(),
|
|
379
|
+
'closest ancestor request': z.string().nullable()
|
|
380
|
+
})
|
|
381
|
+
);
|
|
382
|
+
|
|
383
|
+
export const orderRetestResponseSchema = envelope(
|
|
384
|
+
'POST',
|
|
385
|
+
z.object({
|
|
386
|
+
'details about your order': z.union([
|
|
387
|
+
z.object({error: z.string()}),
|
|
388
|
+
z.object({
|
|
389
|
+
'date and time received': z.string(),
|
|
390
|
+
'page to be retested': z.object({description: z.string(), URL: z.string()}),
|
|
391
|
+
'reason why the page should be retested': z.string()
|
|
392
|
+
})
|
|
393
|
+
]),
|
|
394
|
+
'disposition of your order': z.object({
|
|
395
|
+
'what happens next': z.string(),
|
|
396
|
+
'report identifier': reportIdentifierSchema.nullable(),
|
|
397
|
+
'how you can check for completion': z.string(),
|
|
398
|
+
'do you want to wait for completion': z.string()
|
|
399
|
+
}).nullable()
|
|
400
|
+
}),
|
|
401
|
+
z.object({reason: orderRetestSchema.reason}),
|
|
402
|
+
z.object({
|
|
403
|
+
'this request': z.null(),
|
|
404
|
+
'closest ancestor request': z.string().nullable()
|
|
405
|
+
})
|
|
406
|
+
);
|
|
407
|
+
|
|
408
|
+
export const awaitTestResponseSchema = envelope(
|
|
409
|
+
'POST',
|
|
410
|
+
z.object({
|
|
411
|
+
'details about your wait': z.union([
|
|
412
|
+
z.object({error: z.string()}),
|
|
413
|
+
z.object({
|
|
414
|
+
'report identifier': reportIdentifierSchema
|
|
415
|
+
})
|
|
416
|
+
]),
|
|
417
|
+
'disposition of your wait': z.object({
|
|
418
|
+
outcome: z.enum(['completed', 'failed', 'timedOut']),
|
|
419
|
+
'what happened': z.string(),
|
|
420
|
+
'how you can check for completion': z.string()
|
|
421
|
+
}).nullable()
|
|
422
|
+
}),
|
|
423
|
+
undefined,
|
|
424
|
+
z.object({
|
|
425
|
+
'this request': z.null(),
|
|
426
|
+
'closest ancestor request': z.null()
|
|
427
|
+
}),
|
|
428
|
+
// awaitTest can be reached from either orderNewTest or orderRetest, and its own
|
|
429
|
+
// parameters (timeStamp/jobID) do not say which one produced them, so both possible
|
|
430
|
+
// ancestors are reported, rather than arbitrarily naming just one as "the" ancestor.
|
|
431
|
+
z.array(requestReferenceSchema).length(2)
|
|
432
|
+
);
|
package/api/util.ts
CHANGED
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
objectSort,
|
|
16
16
|
ruleEngines
|
|
17
17
|
} from '../util.ts';
|
|
18
|
-
import type {ReportExtract} from '../util.ts';
|
|
18
|
+
import type {ReportExtract, TestRequestResult} from '../util.ts';
|
|
19
19
|
import {issues as issueSpecs} from 'testaro-issues';
|
|
20
20
|
|
|
21
21
|
// TYPES
|
|
@@ -29,6 +29,15 @@ export type ReportBasics = {
|
|
|
29
29
|
'whether a later report about the same page exists': boolean;
|
|
30
30
|
};
|
|
31
31
|
|
|
32
|
+
// The disposition of a test/retest/instant-test request, as returned by
|
|
33
|
+
// buildRequestDisposition, or null when the request itself was invalid (in which case
|
|
34
|
+
// details about your request already carries the error, and no disposition applies).
|
|
35
|
+
export type RequestDisposition = {
|
|
36
|
+
'what happens next': string;
|
|
37
|
+
'how you can check for completion': string;
|
|
38
|
+
'how a web user can check for completion': string;
|
|
39
|
+
} | null;
|
|
40
|
+
|
|
32
41
|
// FUNCTIONS
|
|
33
42
|
|
|
34
43
|
// Returns the base URL of this Kilotest host.
|
|
@@ -50,7 +59,7 @@ export const getToolsFacts = () => ({
|
|
|
50
59
|
'level 4': 'Use the listDiagnoses tool to get a list of diagnoses of how one element on one page exhibited one issue in one report.',
|
|
51
60
|
},
|
|
52
61
|
'how to generate more findings': {
|
|
53
|
-
'new testing': 'If no report is available yet about a page, use the
|
|
62
|
+
'new testing': 'If no report is available yet about a page, use the requestNewTest tool to request that it be tested.',
|
|
54
63
|
'retesting': 'If the listIssues tool shows that the latest report about a page is obsolete, because the page has been revised or for another reason, use the requestRetest tool to request that the page be retested.',
|
|
55
64
|
'latency': 'Requests for testing and retesting are usually approved and fulfilled within one day.',
|
|
56
65
|
'confirmation': 'Use the listReports tool to determine whether a requested new report exists. There is currently no process for notification of the outcome of requests.'
|
|
@@ -59,6 +68,54 @@ export const getToolsFacts = () => ({
|
|
|
59
68
|
'URL': `${getThisHost()}/mcp`,
|
|
60
69
|
'web users can obtain similar functionalities at': getThisHost()
|
|
61
70
|
});
|
|
71
|
+
// Returns the human-readable reason a test/retest/instant-test request was not
|
|
72
|
+
// processed, given the result processTestRequest returned. requestResult is always a
|
|
73
|
+
// rejection reason here ('description', 'url', 'queueFull', or an unrecognized value
|
|
74
|
+
// meaning 'duplicate'); callers pass their own extra branch for a requestResult value
|
|
75
|
+
// specific to their own request type ('reportExists' for requestNewTest, 'superseded' for
|
|
76
|
+
// requestRetest), since that is the one branch not shared between them.
|
|
77
|
+
export const getRequestFailureReason = (
|
|
78
|
+
requestResult: Exclude<TestRequestResult, 'ok'>,
|
|
79
|
+
extraBranch?: {result: TestRequestResult; reason: string}
|
|
80
|
+
): string => {
|
|
81
|
+
if (requestResult === 'description') {
|
|
82
|
+
return 'a request to test a page with the same description is already approved.';
|
|
83
|
+
}
|
|
84
|
+
if (requestResult === 'url') {
|
|
85
|
+
return 'a request to test a page with the same URL is already approved.';
|
|
86
|
+
}
|
|
87
|
+
if (extraBranch && requestResult === extraBranch.result) {
|
|
88
|
+
return extraBranch.reason;
|
|
89
|
+
}
|
|
90
|
+
if (requestResult === 'queueFull') {
|
|
91
|
+
return 'too many requests are awaiting approval right now. Please try again later, ' +
|
|
92
|
+
'or post your request at https://github.com/jrpool/kilotest/issues or email info@kilotest.com.';
|
|
93
|
+
}
|
|
94
|
+
return 'an identical request is already awaiting approval.';
|
|
95
|
+
};
|
|
96
|
+
// Returns the disposition of a test/retest/instant-test request: an 'ok' disposition
|
|
97
|
+
// describing how to check for completion, or a rejection disposition naming why the
|
|
98
|
+
// request was not processed. Shared by requestNewTest, requestRetest, and orderNewTest,
|
|
99
|
+
// whose only differences are the wording of the 3 completion-related sentences and, for
|
|
100
|
+
// a rejection, the one extra requestResult branch getRequestFailureReason takes.
|
|
101
|
+
export const buildRequestDisposition = (
|
|
102
|
+
requestResult: TestRequestResult,
|
|
103
|
+
okText: {whatHappensNext: string; howToCheck: string; howWebUserChecks: string},
|
|
104
|
+
extraBranch?: {result: TestRequestResult; reason: string}
|
|
105
|
+
): RequestDisposition => {
|
|
106
|
+
if (requestResult === 'ok') {
|
|
107
|
+
return {
|
|
108
|
+
'what happens next': okText.whatHappensNext,
|
|
109
|
+
'how you can check for completion': okText.howToCheck,
|
|
110
|
+
'how a web user can check for completion': okText.howWebUserChecks
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
'what happens next': `Your request will not be processed, because ${getRequestFailureReason(requestResult, extraBranch)}`,
|
|
115
|
+
'how you can check for completion': 'Not applicable.',
|
|
116
|
+
'how a web user can check for completion': 'Not applicable.'
|
|
117
|
+
};
|
|
118
|
+
};
|
|
62
119
|
// Returns the facts about a rule engine.
|
|
63
120
|
export const getRuleEngineFacts = (ruleEngineID: string) => {
|
|
64
121
|
const ruleEngineData = ruleEngines[ruleEngineID] || [null, null];
|
package/api/version.ts
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
/*
|
|
2
2
|
version.ts
|
|
3
|
-
Single source of truth for the version of the Kilotest API and MCP tool contract, consumed by mcp.ts and scripts/generate-openapi.ts.
|
|
3
|
+
Single source of truth for the version of the Kilotest API and MCP tool contract, consumed by mcp.ts and scripts/generate-openapi.ts. Re-exports package.json's version field verbatim, rather than hand-maintaining a separate literal here, so MCP and OpenAPI consumers see package.json's version exactly, including minor/patch bumps from changes that do not affect the API contract; this is a deliberate choice, not an oversight.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
// IMPORTS
|
|
7
|
+
|
|
8
|
+
import pkg from '../package.json' with {type: 'json'};
|
|
9
|
+
|
|
6
10
|
// CONSTANTS
|
|
7
11
|
|
|
8
|
-
export const version =
|
|
12
|
+
export const version = pkg.version;
|