@jrpool/kilotest 61.0.0 → 63.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.devin/config.local.json +2 -1
- package/.markdownlint-cli2.jsonc +0 -1
- package/api/awaitTest.test.ts +9 -0
- package/api/getReport.test.ts +9 -0
- package/api/listDiagnoses.test.ts +9 -0
- package/api/listDiagnoses.ts +13 -3
- package/api/listIssues.test.ts +9 -0
- package/api/listIssues.ts +8 -4
- package/api/listPages.test.ts +9 -0
- package/api/listViolators.test.ts +9 -0
- package/api/listViolators.ts +5 -3
- package/api/orderNewTest.test.ts +9 -0
- package/api/orderRetest.test.ts +9 -0
- package/api/reportDisagreement.test.ts +217 -0
- package/api/reportDisagreement.ts +208 -0
- package/api/requestFeature.test.ts +9 -0
- package/api/requestNewTest.test.ts +9 -0
- package/api/requestRetest.test.ts +9 -0
- package/api/routes.test.ts +3 -2
- package/api/routes.ts +20 -1
- package/api/schemas.ts +48 -4
- package/api/util.test.ts +9 -0
- package/apiIntegration.test.ts +22 -0
- package/docs/SERVICE.md +6 -3
- package/env.example +5 -0
- package/index.test.ts +66 -0
- package/index.ts +30 -2
- package/lhm.plugin.json +455 -29
- package/llms-full.txt +13 -0
- package/llms.txt +1 -0
- package/mcp.test.ts +23 -4
- package/mcp.ts +24 -1
- package/openapi-3.0.yaml +195 -7
- package/openapi.yaml +191 -7
- package/package.json +1 -1
- package/server.json +1 -1
- package/test/setup.ts +4 -0
- package/web/tutorialAI/index.html +29 -1
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/*
|
|
2
|
+
reportDisagreement.ts
|
|
3
|
+
Processes a report that the requester disagrees with a diagnosis and returns an acknowledgement.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// IMPORTS
|
|
7
|
+
|
|
8
|
+
import {z} from 'zod';
|
|
9
|
+
import {getResponseMetadata, getThisHost, getToolsFacts} from './util.ts';
|
|
10
|
+
import {sendAlert} from '../alerts.ts';
|
|
11
|
+
import {
|
|
12
|
+
checkLength,
|
|
13
|
+
describeMax,
|
|
14
|
+
getEnvMax,
|
|
15
|
+
getJSON,
|
|
16
|
+
getNowStamp,
|
|
17
|
+
getReport,
|
|
18
|
+
getTestActInstances,
|
|
19
|
+
isReportError
|
|
20
|
+
} from '../util.ts';
|
|
21
|
+
import {reportDisagreementResponseSchema} from './schemas.ts';
|
|
22
|
+
import fs from 'node:fs/promises';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
|
|
25
|
+
// TYPES
|
|
26
|
+
|
|
27
|
+
// The response content defined by the response schema.
|
|
28
|
+
type ResponseContent = z.infer<typeof reportDisagreementResponseSchema>['response content'];
|
|
29
|
+
|
|
30
|
+
// A stored disagreement with a diagnosis.
|
|
31
|
+
type Disagreement = {
|
|
32
|
+
timeStamp: string;
|
|
33
|
+
issueID: string;
|
|
34
|
+
reportID: string;
|
|
35
|
+
catalogIndex: string;
|
|
36
|
+
engineID: string;
|
|
37
|
+
ruleID: string;
|
|
38
|
+
reason: string;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// FUNCTIONS (helpers)
|
|
42
|
+
|
|
43
|
+
const getDisagreementsPath = () => process.env.DISAGREEMENTS_PATH || path.join(import.meta.dirname, '../db/disagreements.json');
|
|
44
|
+
// Maximum number of diagnosis disagreements held at once. Once reached, a new, distinct
|
|
45
|
+
// disagreement is rejected rather than stored, so the disagreements file and the manager
|
|
46
|
+
// alert emails it generates cannot grow without bound (modeled on the feature-request
|
|
47
|
+
// cap in requestFeature.ts). Configurable via DISAGREEMENTS_MAX; a value of 0 means no
|
|
48
|
+
// limit.
|
|
49
|
+
const getDisagreementsMax = () => getEnvMax('DISAGREEMENTS_MAX', 20);
|
|
50
|
+
|
|
51
|
+
// FUNCTIONS
|
|
52
|
+
|
|
53
|
+
// Returns the response body.
|
|
54
|
+
export const response = async (args: string[]) => {
|
|
55
|
+
const [issueID = '', reportID = '', catalogIndex = '', engineID = '', ruleID = '', reason = ''] = args;
|
|
56
|
+
const thisHost = getThisHost();
|
|
57
|
+
const reasonCheck = checkLength(reason, 20, 1000, 'reason');
|
|
58
|
+
// Initialize the response content.
|
|
59
|
+
let responseContent: ResponseContent;
|
|
60
|
+
// If the reason is too short or too long:
|
|
61
|
+
if (reasonCheck.status === 'error') {
|
|
62
|
+
responseContent = {
|
|
63
|
+
'details about your request': {
|
|
64
|
+
error: `request invalid: your ${reasonCheck.message}`
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
// Otherwise, i.e. if it is valid:
|
|
69
|
+
else {
|
|
70
|
+
// Get the report.
|
|
71
|
+
const report = await getReport(reportID);
|
|
72
|
+
// If the report was not retrieved successfully:
|
|
73
|
+
if (isReportError(report)) {
|
|
74
|
+
responseContent = {
|
|
75
|
+
'details about your request': {
|
|
76
|
+
error: 'request invalid: the specified report does not exist or is not usable'
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
// Otherwise, if the element is not in the report's catalog:
|
|
81
|
+
else if (!report.catalog[catalogIndex]) {
|
|
82
|
+
responseContent = {
|
|
83
|
+
'details about your request': {
|
|
84
|
+
error: 'request invalid: the specified element is not in the report'
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
// Otherwise, i.e. if the report and element exist:
|
|
89
|
+
else {
|
|
90
|
+
// Get the reported diagnoses of the element for the issue matching the engine and
|
|
91
|
+
// rule (a ruleID is only unique within a rule engine: axe and alfa, for example,
|
|
92
|
+
// both report rules numbered like r11 for the same element and issue).
|
|
93
|
+
const matches = getTestActInstances(report, {violationsOnly: true, issueID, catalogIndex})
|
|
94
|
+
.filter(({act, instance}) => act.which === engineID && instance.ruleID === ruleID);
|
|
95
|
+
// If no reported diagnosis matches:
|
|
96
|
+
if (!matches.length) {
|
|
97
|
+
responseContent = {
|
|
98
|
+
'details about your request': {
|
|
99
|
+
error: 'request invalid: no such diagnosis was reported for the specified element and issue'
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
// Otherwise, i.e. if the diagnosis exists:
|
|
104
|
+
else {
|
|
105
|
+
// Get the disagreements already on file.
|
|
106
|
+
const disagreementsPath = getDisagreementsPath();
|
|
107
|
+
let disagreements: Disagreement[] = [];
|
|
108
|
+
try {
|
|
109
|
+
const existing = await fs.readFile(disagreementsPath, 'utf8');
|
|
110
|
+
disagreements = JSON.parse(existing);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
// Initialize an empty disagreements array.
|
|
114
|
+
}
|
|
115
|
+
// If the disagreement is identical to one already on file:
|
|
116
|
+
if (disagreements.some(d =>
|
|
117
|
+
d.issueID === issueID
|
|
118
|
+
&& d.reportID === reportID
|
|
119
|
+
&& d.catalogIndex === catalogIndex
|
|
120
|
+
&& d.engineID === engineID
|
|
121
|
+
&& d.ruleID === ruleID
|
|
122
|
+
&& d.reason === reason
|
|
123
|
+
)) {
|
|
124
|
+
responseContent = {
|
|
125
|
+
'details about your request': {
|
|
126
|
+
error: 'request invalid: your disagreement is identical to one already submitted, ' +
|
|
127
|
+
'but you are welcome to submit a different one'
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
// Otherwise, if the disagreements already on file have reached the cap (a cap of 0
|
|
132
|
+
// means no limit, so the queue is never full):
|
|
133
|
+
else if (getDisagreementsMax() > 0 && disagreements.length >= getDisagreementsMax()) {
|
|
134
|
+
responseContent = {
|
|
135
|
+
'details about your request': {
|
|
136
|
+
error: 'request invalid: too many disagreements are awaiting review right now. ' +
|
|
137
|
+
'Please try again later, or post your report at https://github.com/jrpool/kilotest/issues ' +
|
|
138
|
+
'or email info@kilotest.com.'
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
// Otherwise, i.e. if it is not a duplicate and the queue is not full:
|
|
143
|
+
else {
|
|
144
|
+
// Record the disagreement alongside the existing ones.
|
|
145
|
+
disagreements.push({
|
|
146
|
+
timeStamp: getNowStamp(),
|
|
147
|
+
issueID,
|
|
148
|
+
reportID,
|
|
149
|
+
catalogIndex,
|
|
150
|
+
engineID,
|
|
151
|
+
ruleID,
|
|
152
|
+
reason
|
|
153
|
+
});
|
|
154
|
+
await fs.mkdir(path.dirname(disagreementsPath), {recursive: true});
|
|
155
|
+
await fs.writeFile(disagreementsPath, getJSON(disagreements));
|
|
156
|
+
// Notify the manager, including the resulting count, so a maintainer who has been
|
|
157
|
+
// away sees at a glance how urgently the disagreements need review. The ruleID is
|
|
158
|
+
// truncated because some rule engines (nuVal, nuVnu) use entire validator messages
|
|
159
|
+
// as rule identifiers, which can be arbitrarily long.
|
|
160
|
+
await sendAlert(
|
|
161
|
+
'Kilotest: diagnosis disagreement received',
|
|
162
|
+
`Disputed diagnosis: ${engineID} rule ${ruleID.slice(0, 500)} classified as issue ${issueID} ` +
|
|
163
|
+
`on element ${catalogIndex} of report ${reportID}\nReason: ${reason}\n` +
|
|
164
|
+
`Disagreements now awaiting review: ${disagreements.length} of ${describeMax(getDisagreementsMax())}`
|
|
165
|
+
);
|
|
166
|
+
// Add the disposition to the response content.
|
|
167
|
+
responseContent = {
|
|
168
|
+
'details about your request': {
|
|
169
|
+
'date and time received': new Date().toISOString(),
|
|
170
|
+
'diagnosis you disagree with': {
|
|
171
|
+
'issue identifier': issueID,
|
|
172
|
+
'identifier of the rule engine': engineID,
|
|
173
|
+
'identifier of the violated rule': ruleID
|
|
174
|
+
},
|
|
175
|
+
disposition: 'received and logged; manager notified'
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
// Create a response body.
|
|
183
|
+
const body = {
|
|
184
|
+
'tool collection': getToolsFacts(),
|
|
185
|
+
'tool name': 'reportDisagreement',
|
|
186
|
+
'this request': {
|
|
187
|
+
description: 'Process my report that I disagree with a diagnosis. The issueID, reportID, and catalogIndex parameters identify the issue, report, and element of the diagnosis. They were in the response to my earlier listDiagnoses request. The engineID and ruleID properties of the request body identify which diagnosis I disagree with, and the reason property explains why I disagree with it.',
|
|
188
|
+
method: 'POST',
|
|
189
|
+
URL: `${thisHost}/api/reportDisagreement/${issueID}/${reportID}/${catalogIndex}`,
|
|
190
|
+
body: {
|
|
191
|
+
engineID,
|
|
192
|
+
ruleID,
|
|
193
|
+
reason
|
|
194
|
+
},
|
|
195
|
+
'closest ancestor request': {
|
|
196
|
+
'tool name': 'listDiagnoses',
|
|
197
|
+
description: 'Provide details about one element reported as exhibiting one issue in one report, including the diagnoses provided by rule engines about how the element exhibited the issue.',
|
|
198
|
+
method: 'GET',
|
|
199
|
+
URL: `${thisHost}/api/listDiagnoses/${catalogIndex}/${issueID}/${reportID}`
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
'URLs of similar requests for web users': null,
|
|
203
|
+
'response metadata': getResponseMetadata(),
|
|
204
|
+
'response content': responseContent
|
|
205
|
+
};
|
|
206
|
+
// Return it.
|
|
207
|
+
return body;
|
|
208
|
+
};
|
|
@@ -37,6 +37,7 @@ beforeEach(async () => {
|
|
|
37
37
|
});
|
|
38
38
|
|
|
39
39
|
import {response} from './requestFeature.ts';
|
|
40
|
+
import {requestFeatureResponseSchema} from './schemas.ts';
|
|
40
41
|
|
|
41
42
|
after(async () => {
|
|
42
43
|
console.log = originalLog;
|
|
@@ -186,3 +187,11 @@ test('requestFeature accepts a feature request that differs from an existing one
|
|
|
186
187
|
const featureRequests = JSON.parse(await fs.readFile(process.env.FEATURE_REQUESTS_PATH!, 'utf8'));
|
|
187
188
|
assert.equal(featureRequests.length, 2);
|
|
188
189
|
});
|
|
190
|
+
|
|
191
|
+
test('requestFeature response conforms to its response schema', async () => {
|
|
192
|
+
// parse() catches missing or mistyped keys; comparing the parsed result to the
|
|
193
|
+
// original catches extra keys that zod strips but that the strict JSON schema
|
|
194
|
+
// advertised to MCP clients (additionalProperties: false) rejects.
|
|
195
|
+
const body = await response(['A feature request submitted only for schema conformance.']);
|
|
196
|
+
assert.deepEqual(requestFeatureResponseSchema.parse(body), body);
|
|
197
|
+
});
|
|
@@ -42,6 +42,7 @@ beforeEach(async () => {
|
|
|
42
42
|
});
|
|
43
43
|
|
|
44
44
|
import {response} from './requestNewTest.ts';
|
|
45
|
+
import {requestNewTestResponseSchema} from './schemas.ts';
|
|
45
46
|
|
|
46
47
|
after(() => {
|
|
47
48
|
console.log = originalLog;
|
|
@@ -208,3 +209,11 @@ test('requestNewTest rejects a request with a fallback channel when the queue is
|
|
|
208
209
|
assert.ok(disposition['what happens next'].includes('https://github.com/jrpool/kilotest/issues'));
|
|
209
210
|
assert.ok(disposition['what happens next'].includes('info@kilotest.com'));
|
|
210
211
|
});
|
|
212
|
+
|
|
213
|
+
test('requestNewTest response conforms to its response schema', async () => {
|
|
214
|
+
// parse() catches missing or mistyped keys; comparing the parsed result to the
|
|
215
|
+
// original catches extra keys that zod strips but that the strict JSON schema
|
|
216
|
+
// advertised to MCP clients (additionalProperties: false) rejects.
|
|
217
|
+
const body = await response(['Schema Conformance Page', 'https://example.com/schema-conformance', 'A reason that is long enough.']);
|
|
218
|
+
assert.deepEqual(requestNewTestResponseSchema.parse(body), body);
|
|
219
|
+
});
|
|
@@ -56,6 +56,7 @@ beforeEach(async () => {
|
|
|
56
56
|
});
|
|
57
57
|
|
|
58
58
|
import {response} from './requestRetest.ts';
|
|
59
|
+
import {requestRetestResponseSchema} from './schemas.ts';
|
|
59
60
|
|
|
60
61
|
after(async () => {
|
|
61
62
|
console.log = originalLog;
|
|
@@ -193,3 +194,11 @@ test('requestRetest rejects a request with a fallback channel when the queue is
|
|
|
193
194
|
assert.ok(disposition['what happens next'].includes('https://github.com/jrpool/kilotest/issues'));
|
|
194
195
|
assert.ok(disposition['what happens next'].includes('info@kilotest.com'));
|
|
195
196
|
});
|
|
197
|
+
|
|
198
|
+
test('requestRetest response conforms to its response schema', async () => {
|
|
199
|
+
// parse() catches missing or mistyped keys; comparing the parsed result to the
|
|
200
|
+
// original catches extra keys that zod strips but that the strict JSON schema
|
|
201
|
+
// advertised to MCP clients (additionalProperties: false) rejects.
|
|
202
|
+
const body = await response(['260101T0003-ret', 'A reason that is long enough.']);
|
|
203
|
+
assert.deepEqual(requestRetestResponseSchema.parse(body), body);
|
|
204
|
+
});
|
package/api/routes.test.ts
CHANGED
|
@@ -11,9 +11,9 @@ 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 12 operations', () => {
|
|
15
15
|
assert.ok(Array.isArray(routes));
|
|
16
|
-
assert.equal(routes.length,
|
|
16
|
+
assert.equal(routes.length, 12);
|
|
17
17
|
});
|
|
18
18
|
|
|
19
19
|
test('routes includes all expected operation IDs', () => {
|
|
@@ -27,6 +27,7 @@ test('routes includes all expected operation IDs', () => {
|
|
|
27
27
|
'listViolators',
|
|
28
28
|
'orderNewTest',
|
|
29
29
|
'orderRetest',
|
|
30
|
+
'reportDisagreement',
|
|
30
31
|
'requestFeature',
|
|
31
32
|
'requestNewTest',
|
|
32
33
|
'requestRetest'
|
package/api/routes.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
orderNewTestSchema,
|
|
18
18
|
orderRetestSchema,
|
|
19
19
|
awaitTestSchema,
|
|
20
|
+
reportDisagreementSchema,
|
|
20
21
|
listPagesResponseSchema,
|
|
21
22
|
listIssuesResponseSchema,
|
|
22
23
|
listViolatorsResponseSchema,
|
|
@@ -27,7 +28,8 @@ import {
|
|
|
27
28
|
requestFeatureResponseSchema,
|
|
28
29
|
orderNewTestResponseSchema,
|
|
29
30
|
orderRetestResponseSchema,
|
|
30
|
-
awaitTestResponseSchema
|
|
31
|
+
awaitTestResponseSchema,
|
|
32
|
+
reportDisagreementResponseSchema
|
|
31
33
|
} from './schemas.ts';
|
|
32
34
|
|
|
33
35
|
// ROUTES
|
|
@@ -99,6 +101,23 @@ export const routes = [
|
|
|
99
101
|
bodySchema: z.object(requestFeatureSchema),
|
|
100
102
|
responseSchema: requestFeatureResponseSchema
|
|
101
103
|
},
|
|
104
|
+
{
|
|
105
|
+
operationId: 'reportDisagreement',
|
|
106
|
+
method: 'post',
|
|
107
|
+
path: '/api/reportDisagreement/{issueID}/{reportID}/{catalogIndex}',
|
|
108
|
+
summary: 'Report disagreement with a diagnosis',
|
|
109
|
+
pathParamsSchema: z.object({
|
|
110
|
+
issueID: reportDisagreementSchema.issueID,
|
|
111
|
+
reportID: reportDisagreementSchema.reportID,
|
|
112
|
+
catalogIndex: reportDisagreementSchema.catalogIndex
|
|
113
|
+
}),
|
|
114
|
+
bodySchema: z.object({
|
|
115
|
+
engineID: reportDisagreementSchema.engineID,
|
|
116
|
+
ruleID: reportDisagreementSchema.ruleID,
|
|
117
|
+
reason: reportDisagreementSchema.reason
|
|
118
|
+
}),
|
|
119
|
+
responseSchema: reportDisagreementResponseSchema
|
|
120
|
+
},
|
|
102
121
|
{
|
|
103
122
|
operationId: 'orderNewTest',
|
|
104
123
|
method: 'post',
|
package/api/schemas.ts
CHANGED
|
@@ -73,6 +73,16 @@ export const awaitTestSchema = {
|
|
|
73
73
|
reportID: z.string().describe('Report identifier returned by orderNewTest or orderRetest (example: 260503T0432-x9z)')
|
|
74
74
|
};
|
|
75
75
|
|
|
76
|
+
// reportDisagreement: POST /api/reportDisagreement/{issueID}/{reportID}/{catalogIndex}
|
|
77
|
+
export const reportDisagreementSchema = {
|
|
78
|
+
issueID: z.string().describe('Issue identifier (example: contrastPoor)'),
|
|
79
|
+
reportID: z.string().describe('Identifier of the report (example: 260503T0432-x9z)'),
|
|
80
|
+
catalogIndex: z.string().describe('Identifier of the issue-exhibiting element in the catalog of elements on the page (example: 372)'),
|
|
81
|
+
engineID: z.string().describe('Identifier of the rule engine that produced the disputed diagnosis (example: axe), as shown in the listDiagnoses output'),
|
|
82
|
+
ruleID: z.string().describe('Identifier of the rule whose diagnosis is disputed, as shown in the listDiagnoses output (example: color-contrast); if that output shows null for the rule identifier, use its description of the violation instead'),
|
|
83
|
+
reason: z.string().describe('20- to 1000-character explanation of why the diagnosis is disputed')
|
|
84
|
+
};
|
|
85
|
+
|
|
76
86
|
// listPages takes no input; omitted (mcp.ts already uses inputSchema: {}).
|
|
77
87
|
|
|
78
88
|
// RESPONSE SCHEMAS
|
|
@@ -85,7 +95,7 @@ const ruleEngineFactsSchema = z.object({
|
|
|
85
95
|
}).meta({id: 'RuleEngineFacts'});
|
|
86
96
|
|
|
87
97
|
// Facts about the Kilotest tool collection, included in every response.
|
|
88
|
-
const toolsFactsSchema = z.object({
|
|
98
|
+
export const toolsFactsSchema = z.object({
|
|
89
99
|
name: z.string(),
|
|
90
100
|
description: z.object({
|
|
91
101
|
'what Kilotest does': z.string(),
|
|
@@ -98,8 +108,10 @@ const toolsFactsSchema = z.object({
|
|
|
98
108
|
'how to generate more findings': z.object({
|
|
99
109
|
'new testing': z.string(),
|
|
100
110
|
'retesting': z.string(),
|
|
111
|
+
'approval': z.string(),
|
|
101
112
|
'latency': z.string(),
|
|
102
|
-
'confirmation': z.string()
|
|
113
|
+
'request confirmation': z.string(),
|
|
114
|
+
'order confirmation': z.string()
|
|
103
115
|
})
|
|
104
116
|
}).describe('Explanation of what Kilotest does and how to use its tools.'),
|
|
105
117
|
URL: z.string().describe('URL of the Kilotest MCP server.'),
|
|
@@ -278,7 +290,7 @@ export const listViolatorsResponseSchema = envelope('GET', z.object({
|
|
|
278
290
|
'tag name': z.unknown().describe('Copied from the report catalog entry; type not guaranteed.'),
|
|
279
291
|
'inner text': z.unknown().describe('Copied from the report catalog entry; type not guaranteed.'),
|
|
280
292
|
'count of rule engines reporting that the element exhibited the issue': z.number(),
|
|
281
|
-
'how to get details about the element': z.object({
|
|
293
|
+
'how to get details about the element': z.object({method: z.literal('GET'), URL: z.string()}),
|
|
282
294
|
'web users can get details about the element at': z.string()
|
|
283
295
|
})).nullable()
|
|
284
296
|
}));
|
|
@@ -297,10 +309,20 @@ export const listDiagnosesResponseSchema = envelope('GET', z.object({
|
|
|
297
309
|
'x, y, width, and height of bounding box': z.unknown().describe('Copied from the report catalog entry; type not guaranteed.')
|
|
298
310
|
}).nullable(),
|
|
299
311
|
'diagnoses of how the element exhibited the issue': z.array(z.object({
|
|
312
|
+
'identifier of the rule engine': z.unknown().describe('Copied from the report act (which); type not guaranteed.'),
|
|
300
313
|
'identifier of the violated rule': z.unknown().describe('Copied from the report instance (ruleID); type not guaranteed.'),
|
|
301
314
|
'description of the violation': z.unknown().describe('Copied from the report instance (what); type not guaranteed.'),
|
|
302
315
|
'severity of the violation on a 0-to-3 scale': z.unknown().describe('Copied from the report instance (ordinalSeverity); type not guaranteed.'),
|
|
303
|
-
'count of violations of the rule by the element': z.number()
|
|
316
|
+
'count of violations of the rule by the element': z.number(),
|
|
317
|
+
'how to report that you disagree with this diagnosis': z.object({
|
|
318
|
+
method: z.literal('POST'),
|
|
319
|
+
URL: z.string(),
|
|
320
|
+
'request body': z.object({
|
|
321
|
+
engineID: z.string(),
|
|
322
|
+
ruleID: z.string(),
|
|
323
|
+
reason: z.string()
|
|
324
|
+
})
|
|
325
|
+
})
|
|
304
326
|
})).or(z.object({error: z.string()})).nullable()
|
|
305
327
|
}));
|
|
306
328
|
|
|
@@ -362,6 +384,28 @@ export const requestFeatureResponseSchema = envelope(
|
|
|
362
384
|
z.null()
|
|
363
385
|
);
|
|
364
386
|
|
|
387
|
+
export const reportDisagreementResponseSchema = envelope(
|
|
388
|
+
'POST',
|
|
389
|
+
z.object({'details about your request': z.union([
|
|
390
|
+
z.object({error: z.string()}),
|
|
391
|
+
z.object({
|
|
392
|
+
'date and time received': z.string(),
|
|
393
|
+
'diagnosis you disagree with': z.object({
|
|
394
|
+
'issue identifier': z.string(),
|
|
395
|
+
'identifier of the rule engine': z.string(),
|
|
396
|
+
'identifier of the violated rule': z.string()
|
|
397
|
+
}),
|
|
398
|
+
disposition: z.string()
|
|
399
|
+
})
|
|
400
|
+
])}),
|
|
401
|
+
z.object({
|
|
402
|
+
engineID: reportDisagreementSchema.engineID,
|
|
403
|
+
ruleID: reportDisagreementSchema.ruleID,
|
|
404
|
+
reason: reportDisagreementSchema.reason
|
|
405
|
+
}),
|
|
406
|
+
z.null()
|
|
407
|
+
);
|
|
408
|
+
|
|
365
409
|
// Identifies the report a successfully ordered or awaited job will produce, once complete.
|
|
366
410
|
const reportIdentifierSchema = z.string().describe('Identifier of the report, to pass to listIssues once the report is ready (example: 260503T0432-x9z).');
|
|
367
411
|
|
package/api/util.test.ts
CHANGED
|
@@ -17,6 +17,7 @@ for (const key of ['MANAGER_EMAIL', 'ALERT_API_HOST', 'ALERT_API_PATH', 'ALERT_A
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
import {getReportBasics, getResponseMetadata, getRuleEngineFacts, getRuleEnginesFacts, getIssueSpec, getToolsFacts, getRequestFailureReason} from './util.ts';
|
|
20
|
+
import {toolsFactsSchema} from './schemas.ts';
|
|
20
21
|
import {processTestRequest} from '../util.ts';
|
|
21
22
|
|
|
22
23
|
// SETUP
|
|
@@ -137,6 +138,14 @@ test('getToolsFacts returns facts about the Kilotest tool collection', () => {
|
|
|
137
138
|
assert.ok(facts.description['how to generate more findings']);
|
|
138
139
|
});
|
|
139
140
|
|
|
141
|
+
test('getToolsFacts output conforms to toolsFactsSchema', () => {
|
|
142
|
+
// parse() catches missing or mistyped keys, and comparing the parsed result to
|
|
143
|
+
// the original catches extra keys that zod strips but that the strict JSON
|
|
144
|
+
// schema advertised to MCP clients (additionalProperties: false) rejects.
|
|
145
|
+
const facts = getToolsFacts();
|
|
146
|
+
assert.deepEqual(toolsFactsSchema.parse(facts), facts);
|
|
147
|
+
});
|
|
148
|
+
|
|
140
149
|
test('getRequestFailureReason returns the URL-length reason for a urlLength result', () => {
|
|
141
150
|
// processTestRequest can return urlLength for a new-test request whose URL is
|
|
142
151
|
// outside the 12-to-300-character bound. No current caller reaches this
|
package/apiIntegration.test.ts
CHANGED
|
@@ -16,11 +16,13 @@ import {requestHandler} from './index.ts';
|
|
|
16
16
|
|
|
17
17
|
const port = 3998;
|
|
18
18
|
const featureRequestsPath = path.join(import.meta.dirname, 'test/fixtures/apiIntegration/featureRequests.json');
|
|
19
|
+
const disagreementsPath = path.join(import.meta.dirname, 'test/fixtures/apiIntegration/disagreements.json');
|
|
19
20
|
|
|
20
21
|
// SETUP AND TEARDOWN
|
|
21
22
|
|
|
22
23
|
const savedDBDir = process.env.DB_DIR;
|
|
23
24
|
const savedFeatureRequestsPath = process.env.FEATURE_REQUESTS_PATH;
|
|
25
|
+
const savedDisagreementsPath = process.env.DISAGREEMENTS_PATH;
|
|
24
26
|
let server: http.Server;
|
|
25
27
|
|
|
26
28
|
before(async () => {
|
|
@@ -31,6 +33,9 @@ before(async () => {
|
|
|
31
33
|
process.env.FEATURE_REQUESTS_PATH = featureRequestsPath;
|
|
32
34
|
await fs.mkdir(path.dirname(featureRequestsPath), {recursive: true});
|
|
33
35
|
await fs.writeFile(featureRequestsPath, '[]\n');
|
|
36
|
+
// The same for diagnosis disagreements.
|
|
37
|
+
process.env.DISAGREEMENTS_PATH = disagreementsPath;
|
|
38
|
+
await fs.writeFile(disagreementsPath, '[]\n');
|
|
34
39
|
server = http.createServer(requestHandler);
|
|
35
40
|
await new Promise<void>(resolve => server.listen(port, () => resolve()));
|
|
36
41
|
});
|
|
@@ -48,6 +53,12 @@ after(async () => {
|
|
|
48
53
|
else {
|
|
49
54
|
delete process.env.FEATURE_REQUESTS_PATH;
|
|
50
55
|
}
|
|
56
|
+
if (savedDisagreementsPath !== undefined) {
|
|
57
|
+
process.env.DISAGREEMENTS_PATH = savedDisagreementsPath;
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
delete process.env.DISAGREEMENTS_PATH;
|
|
61
|
+
}
|
|
51
62
|
await fs.rm(path.dirname(featureRequestsPath), {recursive: true, force: true});
|
|
52
63
|
server.closeAllConnections?.();
|
|
53
64
|
await new Promise<void>(resolve => {
|
|
@@ -106,3 +117,14 @@ test('POST /api/requestFeature parses a JSON body and returns a JSON response th
|
|
|
106
117
|
assert.equal(details.error, undefined);
|
|
107
118
|
assert.equal(details.disposition, 'received and logged; manager notified');
|
|
108
119
|
});
|
|
120
|
+
|
|
121
|
+
test('POST /api/reportDisagreement parses path parameters and a JSON body and returns a JSON response through the HTTP routing layer', async () => {
|
|
122
|
+
const {body} = await request('POST', '/api/reportDisagreement/linkNoText/260101T0000-mix/0', {
|
|
123
|
+
engineID: 'axe',
|
|
124
|
+
ruleID: 'r11',
|
|
125
|
+
reason: 'The element does have an accessible name'
|
|
126
|
+
});
|
|
127
|
+
const details = body['response content']['details about your request'];
|
|
128
|
+
assert.equal(details.error, undefined);
|
|
129
|
+
assert.equal(details.disposition, 'received and logged; manager notified');
|
|
130
|
+
});
|
package/docs/SERVICE.md
CHANGED
|
@@ -409,9 +409,11 @@ Alerts are sent to the maintainer in the following conditions:
|
|
|
409
409
|
|
|
410
410
|
7. **MCP feature request received**: When a user submits a feature request via the MCP server's `requestFeature` tool, an alert is sent with subject `"MCP feature request received"`, including the text of the feature request, and the request is also appended to `db/featureRequests.json`, the same way tutorial comments are appended to their own files (see item 1), so that a manager can review or delete it later via `/deleteNotesForm.html`.
|
|
411
411
|
|
|
412
|
-
8. **
|
|
412
|
+
8. **Diagnosis disagreement received**: When a user reports disagreement with a diagnosis via the API’s or MCP server’s `reportDisagreement` operation, an alert is sent with subject `"Kilotest: diagnosis disagreement received"`, including the reason, the disputed diagnosis (rule engine, rule, issue, element, and report), and the disagreement is also appended to `db/disagreements.json`, so that a manager can review it and, when warranted, propose a correction to the `testaro-issues` classification (see item 3 for how such corrections take effect).
|
|
413
413
|
|
|
414
|
-
9. **
|
|
414
|
+
9. **WAVE balance low**: When Kilotest detects that the account balance for the WAVE API service (used for accessibility testing) has fallen below a threshold, an alert is sent with subject `"Kilotest: WAVE balance low"`, indicating the number of credits remaining.
|
|
415
|
+
|
|
416
|
+
10. **AI service balance low**: When Kilotest detects that the account balance for an AI service account (such as Anthropic) has fallen below a threshold, an alert is sent with subject `"Kilotest: AI service [N] balance low"`, indicating the approximate remaining balance in dollars and the estimated cost per job.
|
|
415
417
|
|
|
416
418
|
### Alert configuration
|
|
417
419
|
|
|
@@ -427,7 +429,7 @@ If any of these five variables is missing or empty at alert time, the alert is n
|
|
|
427
429
|
|
|
428
430
|
### Inbox size limits
|
|
429
431
|
|
|
430
|
-
|
|
432
|
+
Five submission types (test requests and retest requests together, web tutorial comments, AI tutorial comments, MCP feature requests, and diagnosis disagreements) are each held in a pending "inbox" (`db/jobs/testRequests.json`, `db/comments/tutorialWeb.json`, `db/comments/tutorialAI.json`, `db/featureRequests.json`, and `db/disagreements.json` respectively) awaiting manual review by the maintainer. Each inbox has a maximum size; once reached, a new, distinct submission is rejected (with a message suggesting the requester instead post to the [GitHub issues page](https://github.com/jrpool/kilotest/issues) or email `info@kilotest.com`) rather than added, so neither the inbox file nor the manager alert emails it generates can grow without bound (see GitHub issue #3, abuse type 3, repeated/automated submissions). This is modeled on a full mailbox rejecting new mail, rather than on a submission rate limit: it is the backlog size itself, not how quickly it grows, that costs the maintainer review time and risks unbounded storage. Every alert sent for an accepted submission reports the resulting inbox count against its cap, so a maintainer returning after an absence can immediately see how close an inbox is to full.
|
|
431
433
|
|
|
432
434
|
Each cap defaults to 20 and can be set independently via an environment variable:
|
|
433
435
|
|
|
@@ -435,6 +437,7 @@ Each cap defaults to 20 and can be set independently via an environment variable
|
|
|
435
437
|
2. **`TUTORIAL_WEB_COMMENTS_MAX`**: The maximum number of comments held on the web tutorial page (`/tutorialWeb.html`).
|
|
436
438
|
3. **`TUTORIAL_AI_COMMENTS_MAX`**: The maximum number of comments held on the AI agent tutorial page (`/tutorialAI.html`).
|
|
437
439
|
4. **`FEATURE_REQUESTS_MAX`**: The maximum number of feature requests held via the MCP server's `requestFeature` tool.
|
|
440
|
+
5. **`DISAGREEMENTS_MAX`**: The maximum number of disagreements with diagnoses held via the API’s or MCP server’s `reportDisagreement` operation.
|
|
438
441
|
|
|
439
442
|
Each variable, if set, must be a non-negative integer; if unset, empty, or not a non-negative integer, the default of 20 is used instead. A value of `0` means no limit: that inbox is never treated as full, regardless of size. Unlike the alert-configuration variables above, these are not required: an unset value never disables the limit, it only selects the default size.
|
|
440
443
|
|
package/env.example
CHANGED
|
@@ -35,6 +35,8 @@ TUTORIAL_WEB_COMMENTS_PATH=__placeholder__
|
|
|
35
35
|
TUTORIAL_AI_COMMENTS_PATH=__placeholder__
|
|
36
36
|
# Path of the feature requests file (defaults to db/featureRequests.json).
|
|
37
37
|
FEATURE_REQUESTS_PATH=__placeholder__
|
|
38
|
+
# Path of the diagnosis disagreements file (defaults to db/disagreements.json).
|
|
39
|
+
DISAGREEMENTS_PATH=__placeholder__
|
|
38
40
|
|
|
39
41
|
# Inbox size limits: each defaults to 20 if unset, empty, or not a non-negative integer.
|
|
40
42
|
# A value of 0 means no limit.
|
|
@@ -47,6 +49,9 @@ TUTORIAL_WEB_COMMENTS_MAX=__placeholder__
|
|
|
47
49
|
TUTORIAL_AI_COMMENTS_MAX=__placeholder__
|
|
48
50
|
# Maximum number of feature requests held via the MCP server's requestFeature tool.
|
|
49
51
|
FEATURE_REQUESTS_MAX=__placeholder__
|
|
52
|
+
# Maximum number of disagreements with diagnoses held via the MCP server's
|
|
53
|
+
# reportDisagreement tool.
|
|
54
|
+
DISAGREEMENTS_MAX=__placeholder__
|
|
50
55
|
# Maximum number of jobs allowed in the job queue (db/jobs/queue) at once. Shared by every
|
|
51
56
|
# path that enqueues a job: manager approval of a pending test/retest request, and the
|
|
52
57
|
# orderNewTest/orderRetest MCP tools, which enqueue directly.
|
package/index.test.ts
CHANGED
|
@@ -2334,6 +2334,50 @@ test('startServer starts an HTTPS server when protocol is https', {timeout: 2000
|
|
|
2334
2334
|
}
|
|
2335
2335
|
});
|
|
2336
2336
|
|
|
2337
|
+
test('startServer throws when THIS_KILOTEST_HOST is unset', async () => {
|
|
2338
|
+
const savedHost = process.env.THIS_KILOTEST_HOST;
|
|
2339
|
+
const savedProtocol = process.env.PROTOCOL;
|
|
2340
|
+
delete process.env.THIS_KILOTEST_HOST;
|
|
2341
|
+
delete process.env.PROTOCOL;
|
|
2342
|
+
try {
|
|
2343
|
+
await assert.rejects(startServer(), /THIS_KILOTEST_HOST/);
|
|
2344
|
+
}
|
|
2345
|
+
finally {
|
|
2346
|
+
if (savedHost !== undefined) {
|
|
2347
|
+
process.env.THIS_KILOTEST_HOST = savedHost;
|
|
2348
|
+
}
|
|
2349
|
+
if (savedProtocol !== undefined) {
|
|
2350
|
+
process.env.PROTOCOL = savedProtocol;
|
|
2351
|
+
}
|
|
2352
|
+
}
|
|
2353
|
+
});
|
|
2354
|
+
|
|
2355
|
+
test('startServer throws when PROTOCOL is https and KEY and CERT are unset', async () => {
|
|
2356
|
+
const savedProtocol = process.env.PROTOCOL;
|
|
2357
|
+
const savedKey = process.env.KEY;
|
|
2358
|
+
const savedCert = process.env.CERT;
|
|
2359
|
+
process.env.PROTOCOL = 'https';
|
|
2360
|
+
delete process.env.KEY;
|
|
2361
|
+
delete process.env.CERT;
|
|
2362
|
+
try {
|
|
2363
|
+
await assert.rejects(startServer(), /KEY.*CERT|CERT.*KEY/);
|
|
2364
|
+
}
|
|
2365
|
+
finally {
|
|
2366
|
+
if (savedProtocol !== undefined) {
|
|
2367
|
+
process.env.PROTOCOL = savedProtocol;
|
|
2368
|
+
}
|
|
2369
|
+
else {
|
|
2370
|
+
delete process.env.PROTOCOL;
|
|
2371
|
+
}
|
|
2372
|
+
if (savedKey !== undefined) {
|
|
2373
|
+
process.env.KEY = savedKey;
|
|
2374
|
+
}
|
|
2375
|
+
if (savedCert !== undefined) {
|
|
2376
|
+
process.env.CERT = savedCert;
|
|
2377
|
+
}
|
|
2378
|
+
}
|
|
2379
|
+
});
|
|
2380
|
+
|
|
2337
2381
|
test('runIfMain calls startServer when mainModule matches currentModule', async () => {
|
|
2338
2382
|
let called = false;
|
|
2339
2383
|
const starter = async () => {
|
|
@@ -2358,3 +2402,25 @@ test('runIfMain does not call startServer when mainModule differs from currentMo
|
|
|
2358
2402
|
await new Promise<void>(resolve => setImmediate(resolve));
|
|
2359
2403
|
assert.equal(called, false);
|
|
2360
2404
|
});
|
|
2405
|
+
|
|
2406
|
+
test('runIfMain logs the error and sets a failing exit code when the starter rejects', async () => {
|
|
2407
|
+
const savedExitCode = process.exitCode;
|
|
2408
|
+
const logged: string[] = [];
|
|
2409
|
+
const originalLog = console.log;
|
|
2410
|
+
console.log = (line: string) => logged.push(line);
|
|
2411
|
+
const starter = async () => {
|
|
2412
|
+
throw new Error('injected startup failure');
|
|
2413
|
+
};
|
|
2414
|
+
const fakeModule = {};
|
|
2415
|
+
try {
|
|
2416
|
+
runIfMain(fakeModule, fakeModule, starter);
|
|
2417
|
+
// Wait for the rejection handler's microtasks to run.
|
|
2418
|
+
await new Promise<void>(resolve => setImmediate(resolve));
|
|
2419
|
+
assert.ok(logged.some(line => line.includes('injected startup failure')));
|
|
2420
|
+
assert.equal(process.exitCode, 1);
|
|
2421
|
+
}
|
|
2422
|
+
finally {
|
|
2423
|
+
console.log = originalLog;
|
|
2424
|
+
process.exitCode = savedExitCode;
|
|
2425
|
+
}
|
|
2426
|
+
});
|