@jrpool/kilotest 60.9.1 → 62.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/listDiagnoses.ts +13 -3
- package/api/listIssues.ts +8 -4
- package/api/listPages.test.ts +17 -16
- package/api/listPages.ts +13 -35
- package/api/listViolators.ts +5 -3
- package/api/reportDisagreement.test.ts +208 -0
- package/api/reportDisagreement.ts +208 -0
- package/api/routes.test.ts +3 -2
- package/api/routes.ts +20 -1
- package/api/schemas.ts +52 -7
- package/api/util.ts +10 -8
- package/apiIntegration.test.ts +22 -0
- package/docs/SERVICE.md +6 -3
- package/env.example +5 -0
- package/index.test.ts +40 -10
- package/index.ts +57 -33
- package/lhm.plugin.json +371 -72
- package/llms-full.txt +15 -2
- package/llms.txt +1 -0
- package/mcp.test.ts +23 -4
- package/mcp.ts +24 -1
- package/openapi-3.0.yaml +215 -53
- package/openapi.yaml +210 -53
- package/package.json +1 -1
- package/robots.txt +3 -0
- package/server.json +1 -1
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
|
|
@@ -195,12 +205,15 @@ const reportTimeSchema = z.object({
|
|
|
195
205
|
});
|
|
196
206
|
|
|
197
207
|
export const listPagesResponseSchema = envelope('GET', z.object({
|
|
208
|
+
'how to get test results about a page': z.object({
|
|
209
|
+
method: z.literal('GET'),
|
|
210
|
+
URL: z.string().describe('URL template; replace {identifier} with the identifier of the latest report about the page.')
|
|
211
|
+
}),
|
|
212
|
+
'web users can get test results about a page at': z.string().describe('URL template; replace {identifier} with the identifier of the latest report about the page.'),
|
|
198
213
|
'basics about all tested pages': z.array(z.object({
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
'
|
|
202
|
-
'how to get details about the latest report': z.object({method: z.literal('GET'), URL: z.string()}),
|
|
203
|
-
'web users can get details about the latest report at': z.string()
|
|
214
|
+
description: z.string(),
|
|
215
|
+
URL: z.string(),
|
|
216
|
+
identifier: z.string().describe('Identifier of the latest report about the page (example: 260503T0432-x9z).')
|
|
204
217
|
})),
|
|
205
218
|
'how to request that a page with no report be tested': z.object({
|
|
206
219
|
method: z.literal('POST'),
|
|
@@ -275,7 +288,7 @@ export const listViolatorsResponseSchema = envelope('GET', z.object({
|
|
|
275
288
|
'tag name': z.unknown().describe('Copied from the report catalog entry; type not guaranteed.'),
|
|
276
289
|
'inner text': z.unknown().describe('Copied from the report catalog entry; type not guaranteed.'),
|
|
277
290
|
'count of rule engines reporting that the element exhibited the issue': z.number(),
|
|
278
|
-
'how to get details about the element': z.object({
|
|
291
|
+
'how to get details about the element': z.object({method: z.literal('GET'), URL: z.string()}),
|
|
279
292
|
'web users can get details about the element at': z.string()
|
|
280
293
|
})).nullable()
|
|
281
294
|
}));
|
|
@@ -294,10 +307,20 @@ export const listDiagnosesResponseSchema = envelope('GET', z.object({
|
|
|
294
307
|
'x, y, width, and height of bounding box': z.unknown().describe('Copied from the report catalog entry; type not guaranteed.')
|
|
295
308
|
}).nullable(),
|
|
296
309
|
'diagnoses of how the element exhibited the issue': z.array(z.object({
|
|
310
|
+
'identifier of the rule engine': z.unknown().describe('Copied from the report act (which); type not guaranteed.'),
|
|
297
311
|
'identifier of the violated rule': z.unknown().describe('Copied from the report instance (ruleID); type not guaranteed.'),
|
|
298
312
|
'description of the violation': z.unknown().describe('Copied from the report instance (what); type not guaranteed.'),
|
|
299
313
|
'severity of the violation on a 0-to-3 scale': z.unknown().describe('Copied from the report instance (ordinalSeverity); type not guaranteed.'),
|
|
300
|
-
'count of violations of the rule by the element': z.number()
|
|
314
|
+
'count of violations of the rule by the element': z.number(),
|
|
315
|
+
'how to report that you disagree with this diagnosis': z.object({
|
|
316
|
+
method: z.literal('POST'),
|
|
317
|
+
URL: z.string(),
|
|
318
|
+
'request body': z.object({
|
|
319
|
+
engineID: z.string(),
|
|
320
|
+
ruleID: z.string(),
|
|
321
|
+
reason: z.string()
|
|
322
|
+
})
|
|
323
|
+
})
|
|
301
324
|
})).or(z.object({error: z.string()})).nullable()
|
|
302
325
|
}));
|
|
303
326
|
|
|
@@ -359,6 +382,28 @@ export const requestFeatureResponseSchema = envelope(
|
|
|
359
382
|
z.null()
|
|
360
383
|
);
|
|
361
384
|
|
|
385
|
+
export const reportDisagreementResponseSchema = envelope(
|
|
386
|
+
'POST',
|
|
387
|
+
z.object({'details about your request': z.union([
|
|
388
|
+
z.object({error: z.string()}),
|
|
389
|
+
z.object({
|
|
390
|
+
'date and time received': z.string(),
|
|
391
|
+
'diagnosis you disagree with': z.object({
|
|
392
|
+
'issue identifier': z.string(),
|
|
393
|
+
'identifier of the rule engine': z.string(),
|
|
394
|
+
'identifier of the violated rule': z.string()
|
|
395
|
+
}),
|
|
396
|
+
disposition: z.string()
|
|
397
|
+
})
|
|
398
|
+
])}),
|
|
399
|
+
z.object({
|
|
400
|
+
engineID: reportDisagreementSchema.engineID,
|
|
401
|
+
ruleID: reportDisagreementSchema.ruleID,
|
|
402
|
+
reason: reportDisagreementSchema.reason
|
|
403
|
+
}),
|
|
404
|
+
z.null()
|
|
405
|
+
);
|
|
406
|
+
|
|
362
407
|
// Identifies the report a successfully ordered or awaited job will produce, once complete.
|
|
363
408
|
const reportIdentifierSchema = z.string().describe('Identifier of the report, to pass to listIssues once the report is ready (example: 260503T0432-x9z).');
|
|
364
409
|
|
package/api/util.ts
CHANGED
|
@@ -51,18 +51,20 @@ export const getResponseMetadata = () => ({
|
|
|
51
51
|
export const getToolsFacts = () => ({
|
|
52
52
|
'name': 'Kilotest',
|
|
53
53
|
'description': {
|
|
54
|
-
'what Kilotest does': 'Kilotest tools generate
|
|
54
|
+
'what Kilotest does': 'Kilotest tools generate findings about the front-end quality (i.e. accessibility, usability, and web standards, including HTML, CSS, ARIA, and WCAG of W3C) of web pages. A Kilotest job generates findings by using Testaro to test a page against about 1300 rules defined by an ensemble of twelve rule engines. Testaro produces a report of the job. The report describes violations of the rules. Kilotest uses Testilo to enhance the report with a classification of the rule violations into about 380 issues and then saves the report for public retrieval. Kilotest makes facts about the tested pages, the issues, and the violations retrievable at four levels of granularity.',
|
|
55
55
|
'how to retrieve findings': {
|
|
56
|
-
'level 1': 'Use the listPages tool to get a list of tested pages, each with
|
|
57
|
-
'level 2': 'Use the listIssues tool to get a list of issues in one report.',
|
|
58
|
-
'level 3': 'Use the listViolators tool to get a list of elements on one page that were reported in one report as exhibiting one issue.',
|
|
56
|
+
'level 1': 'Use the listPages tool to get a list of tested pages, each with the identifier of its latest report, for use with the listIssues tool.',
|
|
57
|
+
'level 2': 'Use the listIssues tool to get a list of issues in one report, each with a link to the level-3 results for that issue. The response also includes a history of reports about the page, each with a link to its own level-2 results.',
|
|
58
|
+
'level 3': 'Use the listViolators tool to get a list of elements on one page that were reported in one report as exhibiting one issue, each with a link to the level-4 results for that violator.',
|
|
59
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.',
|
|
60
60
|
},
|
|
61
61
|
'how to generate more findings': {
|
|
62
|
-
'new testing': 'If no report is available yet about a page, use the requestNewTest tool to request that it be tested.',
|
|
63
|
-
'retesting': 'If
|
|
64
|
-
'
|
|
65
|
-
'
|
|
62
|
+
'new testing': 'If no report is available yet about a page, use the requestNewTest tool to request that it be tested, or the orderNewTest tool for immediate testing.',
|
|
63
|
+
'retesting': 'If you believe that the latest report about a page is obsolete (e.g., because the page has been revised), use the requestRetest tool to request that the page be retested, or the orderRetest tool for immediate retesting.',
|
|
64
|
+
'approval': 'Requests are manager-reviewed, usually in 1 hour to one day. Orders are automatically reviewed. Reviews result in approval or rejection.',
|
|
65
|
+
'latency': 'An approved test typically finishes in 2 to 4 minutes.',
|
|
66
|
+
'request confirmation': 'After submitting a request and waiting for manager review, you can use the listPages tool to determine whether the request has been fulfilled: a new-test request adds a page entry linking to its results, and a retest request adds a later report about the page.',
|
|
67
|
+
'order confirmation': 'After ordering a test or retest, you will receive a rejection or a report identifier, which you can use to call the awaitTest tool. That tool blocks until the test completes, fails, or a maximum wait time elapses, and then returns the outcome. If you do not call awaitTest, you can wait 2 to 4 minutes and then, if the test is complete, the listIssues tool called with the report identifier will succeed.'
|
|
66
68
|
}
|
|
67
69
|
},
|
|
68
70
|
'URL': `${getThisHost()}/mcp`,
|
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
|
@@ -981,12 +981,12 @@ test('GET /qai/comments redirects to /tutorialAI.html', async () => {
|
|
|
981
981
|
assert.equal(res.headers['location'], '/tutorialAI.html');
|
|
982
982
|
});
|
|
983
983
|
|
|
984
|
-
test('GET /diagnoses.html/<
|
|
984
|
+
test('GET /diagnoses.html/<issueID>/<timeStamp>/<jobID>/<catalogIndex> with a query string redirects in one step to the canonical /listDiagnoses.html URL', async () => {
|
|
985
985
|
const res = await request(
|
|
986
|
-
'GET', '/diagnoses.html/
|
|
986
|
+
'GET', '/diagnoses.html/linkNoText/260101T0000/mix/0?pathID=/html/body', {}, {followRedirects: false}
|
|
987
987
|
);
|
|
988
988
|
assert.equal(res.statusCode, 301);
|
|
989
|
-
assert.equal(res.headers['location'], '/listDiagnoses.html/
|
|
989
|
+
assert.equal(res.headers['location'], '/listDiagnoses.html/linkNoText/260101T0000-mix/0');
|
|
990
990
|
});
|
|
991
991
|
|
|
992
992
|
test('GET /reportIssues.html/<reportID> redirects to the equivalent /listIssues.html/<reportID>', async () => {
|
|
@@ -1015,12 +1015,12 @@ test('GET /listViolators.html/<issueID>/<timeStamp>/<jobID> redirects to the sin
|
|
|
1015
1015
|
assert.equal(res.headers['location'], '/listViolators.html/linkNoText/260101T0000-mix');
|
|
1016
1016
|
});
|
|
1017
1017
|
|
|
1018
|
-
test('GET /listDiagnoses.html/<issueID>/<timeStamp>/<jobID>/<catalogIndex> redirects to the single-reportID URL
|
|
1018
|
+
test('GET /listDiagnoses.html/<issueID>/<timeStamp>/<jobID>/<catalogIndex> with a query string redirects in one step to the canonical single-reportID URL', async () => {
|
|
1019
1019
|
const res = await request(
|
|
1020
1020
|
'GET', '/listDiagnoses.html/linkNoText/260101T0000/mix/0?pathID=/html/body', {}, {followRedirects: false}
|
|
1021
1021
|
);
|
|
1022
1022
|
assert.equal(res.statusCode, 301);
|
|
1023
|
-
assert.equal(res.headers['location'], '/listDiagnoses.html/linkNoText/260101T0000-mix/0
|
|
1023
|
+
assert.equal(res.headers['location'], '/listDiagnoses.html/linkNoText/260101T0000-mix/0');
|
|
1024
1024
|
});
|
|
1025
1025
|
|
|
1026
1026
|
test('GET /requestRetestForm.html/<timeStamp>/<jobID> redirects to the single-reportID URL', async () => {
|
|
@@ -1077,6 +1077,33 @@ test('GET /api/listReports redirects to /api/listPages', async () => {
|
|
|
1077
1077
|
assert.equal(res.headers['location'], '/api/listPages');
|
|
1078
1078
|
});
|
|
1079
1079
|
|
|
1080
|
+
// TESTS: query-string canonicalization
|
|
1081
|
+
// No GET page handler reads the query string, so a request with a query returns a
|
|
1082
|
+
// duplicate of the queryless page. These verify that such requests permanently
|
|
1083
|
+
// redirect to the queryless URL.
|
|
1084
|
+
|
|
1085
|
+
test('GET /listDiagnoses.html/<segments> with a query string redirects to the queryless URL', async () => {
|
|
1086
|
+
const res = await request(
|
|
1087
|
+
'GET', '/listDiagnoses.html/linkNoText/260101T0000-mix/0?pathID=/html/body', {}, {followRedirects: false}
|
|
1088
|
+
);
|
|
1089
|
+
assert.equal(res.statusCode, 301);
|
|
1090
|
+
assert.equal(res.headers['location'], '/listDiagnoses.html/linkNoText/260101T0000-mix/0');
|
|
1091
|
+
});
|
|
1092
|
+
|
|
1093
|
+
test('GET /listIssues.html/<reportID> with a query string redirects to the queryless URL', async () => {
|
|
1094
|
+
const res = await request(
|
|
1095
|
+
'GET', '/listIssues.html/260101T0000-mix?foo=bar', {}, {followRedirects: false}
|
|
1096
|
+
);
|
|
1097
|
+
assert.equal(res.statusCode, 301);
|
|
1098
|
+
assert.equal(res.headers['location'], '/listIssues.html/260101T0000-mix');
|
|
1099
|
+
});
|
|
1100
|
+
|
|
1101
|
+
test('GET / with a query string redirects to /', async () => {
|
|
1102
|
+
const res = await request('GET', '/?utm_source=example', {}, {followRedirects: false});
|
|
1103
|
+
assert.equal(res.statusCode, 301);
|
|
1104
|
+
assert.equal(res.headers['location'], '/');
|
|
1105
|
+
});
|
|
1106
|
+
|
|
1080
1107
|
test('GET /requestTest.html redirects to /requestNewTest.html', async () => {
|
|
1081
1108
|
const res = await request('GET', '/requestTest.html', {}, {followRedirects: false});
|
|
1082
1109
|
assert.equal(res.statusCode, 301);
|
|
@@ -1647,12 +1674,15 @@ test('POST /metrics.html with a valid authCode sets a metrics-exclusion cookie',
|
|
|
1647
1674
|
assert.ok(setCookie.includes('HttpOnly'));
|
|
1648
1675
|
});
|
|
1649
1676
|
|
|
1650
|
-
test('GET /metrics.html with a valid authCode in the query string does not set a cookie or show the counts', async () => {
|
|
1677
|
+
test('GET /metrics.html with a valid authCode in the query string redirects to the queryless URL, which does not set a cookie or show the counts', async () => {
|
|
1651
1678
|
const res = await request('GET', '/metrics.html?authCode=test-auth-code');
|
|
1652
|
-
assert.equal(res.statusCode,
|
|
1653
|
-
assert.equal(res.headers['
|
|
1654
|
-
|
|
1655
|
-
assert.
|
|
1679
|
+
assert.equal(res.statusCode, 301);
|
|
1680
|
+
assert.equal(res.headers['location'], '/metrics.html');
|
|
1681
|
+
const redirected = await request('GET', res.headers['location']);
|
|
1682
|
+
assert.equal(redirected.statusCode, 200);
|
|
1683
|
+
assert.equal(redirected.headers['set-cookie'], undefined);
|
|
1684
|
+
assert.ok(redirected.body.includes('Authorization code'));
|
|
1685
|
+
assert.ok(!redirected.body.includes('Web page views'));
|
|
1656
1686
|
});
|
|
1657
1687
|
|
|
1658
1688
|
test('a request carrying the metrics-exclusion cookie does not record a pageViews or apiOperations metric', async () => {
|
package/index.ts
CHANGED
|
@@ -88,6 +88,7 @@ import {response as requestNewTestAPI} from './api/requestNewTest.ts';
|
|
|
88
88
|
import {response as orderNewTestAPI} from './api/orderNewTest.ts';
|
|
89
89
|
import {response as orderRetestAPI} from './api/orderRetest.ts';
|
|
90
90
|
import {response as awaitTestAPI} from './api/awaitTest.ts';
|
|
91
|
+
import {response as reportDisagreementAPI} from './api/reportDisagreement.ts';
|
|
91
92
|
|
|
92
93
|
// ENVIRONMENT
|
|
93
94
|
|
|
@@ -190,6 +191,7 @@ const apiRespond: {
|
|
|
190
191
|
orderNewTest: ApiResponder;
|
|
191
192
|
orderRetest: ApiResponder;
|
|
192
193
|
awaitTest: ApiResponder;
|
|
194
|
+
reportDisagreement: ApiResponder;
|
|
193
195
|
} = {
|
|
194
196
|
getReport: getReportAPI,
|
|
195
197
|
listDiagnoses: listDiagnosesAPI,
|
|
@@ -201,7 +203,8 @@ const apiRespond: {
|
|
|
201
203
|
requestNewTest: requestNewTestAPI,
|
|
202
204
|
orderNewTest: orderNewTestAPI,
|
|
203
205
|
orderRetest: orderRetestAPI,
|
|
204
|
-
awaitTest: awaitTestAPI
|
|
206
|
+
awaitTest: awaitTestAPI,
|
|
207
|
+
reportDisagreement: reportDisagreementAPI
|
|
205
208
|
};
|
|
206
209
|
|
|
207
210
|
// CONSTANTS
|
|
@@ -347,9 +350,10 @@ export const isPathAllowed = (method: string, pathname: string): boolean => {
|
|
|
347
350
|
const patterns = (routes as Record<string, string[]>)[method] || [];
|
|
348
351
|
return patterns.some(pattern => matchPath(pattern, pathname));
|
|
349
352
|
};
|
|
350
|
-
// Returns the single-reportID
|
|
351
|
-
//
|
|
352
|
-
//
|
|
353
|
+
// Returns the single-reportID path that a legacy two-segment report path tail (one
|
|
354
|
+
// identifying a report with separate timeStamp and jobID segments) canonicalizes to
|
|
355
|
+
// for a page under its current name, or null if the page has no legacy form or the
|
|
356
|
+
// tail lacks its shape.
|
|
353
357
|
const getLegacyReportURL = (topic: string, pathTail: string): string | null => {
|
|
354
358
|
const legacy = legacyReportTails[topic];
|
|
355
359
|
if (!legacy) {
|
|
@@ -681,12 +685,21 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
|
|
|
681
685
|
}
|
|
682
686
|
// Otherwise, if it is for the home page:
|
|
683
687
|
else if (['/', '/index.html'].includes(pathname)) {
|
|
684
|
-
|
|
685
|
-
//
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
688
|
+
// If the URL has a query string, redirect the request permanently to the
|
|
689
|
+
// queryless URL (see the same redirect for .html pages below).
|
|
690
|
+
if (search) {
|
|
691
|
+
response.writeHead(301, {Location: pathname});
|
|
692
|
+
response.end();
|
|
693
|
+
}
|
|
694
|
+
// Otherwise, i.e. if the URL has no query string:
|
|
695
|
+
else {
|
|
696
|
+
await recordPageMetric('pageViews', 'index');
|
|
697
|
+
// Get the home page.
|
|
698
|
+
const {answerPage} = await home();
|
|
699
|
+
// Serve it.
|
|
700
|
+
setHeaders('text/html', '/index.html', 'medium');
|
|
701
|
+
response.end(answerPage);
|
|
702
|
+
}
|
|
690
703
|
}
|
|
691
704
|
// Otherwise, if it is for the crawler specification:
|
|
692
705
|
else if (pageName === 'robots.txt') {
|
|
@@ -766,18 +779,15 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
|
|
|
766
779
|
// If this failed:
|
|
767
780
|
if (isReportError(report)) {
|
|
768
781
|
// This is not necessarily abuse: the link may have been generated (e.g. by
|
|
769
|
-
// listIssues.html) before the report was pruned or rewound, or the
|
|
770
|
-
// may have
|
|
771
|
-
// So tell the requester only that the request was
|
|
772
|
-
//
|
|
773
|
-
//
|
|
782
|
+
// listIssues.html) before the report was pruned or rewound, or the identifier
|
|
783
|
+
// may have been scraped from the API documentation's examples, none of which
|
|
784
|
+
// is the requester's fault. So tell the requester only that the request was
|
|
785
|
+
// invalid, without accusing them. No manager alert is sent: a missing report
|
|
786
|
+
// is a routine miss, and a report that exists but is unreadable or unusable
|
|
787
|
+
// has already been quarantined and alerted on by getReport.
|
|
774
788
|
console.error(
|
|
775
789
|
`Full report ${pathTail} requested but unavailable (${report.error})`
|
|
776
790
|
);
|
|
777
|
-
await sendAlert(
|
|
778
|
-
'Kilotest: requested full report unavailable',
|
|
779
|
-
`Full report ${pathTail} was requested but could not be retrieved: ${report.error}`
|
|
780
|
-
);
|
|
781
791
|
await serveError({message: 'ERROR: Invalid request'}, response, true);
|
|
782
792
|
}
|
|
783
793
|
// Otherwise, i.e. if it succeeded:
|
|
@@ -800,20 +810,24 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
|
|
|
800
810
|
// Otherwise, if it is for an HTML page other than the home page:
|
|
801
811
|
else if (pageName.endsWith('.html')) {
|
|
802
812
|
const topic = pageName.slice(0, -5);
|
|
803
|
-
|
|
804
|
-
//
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
//
|
|
813
|
-
//
|
|
814
|
-
//
|
|
815
|
-
|
|
816
|
-
|
|
813
|
+
// Compute the path in the current request signature: the current name if the
|
|
814
|
+
// page name is a pre-rename one, and the single-reportID tail if the tail
|
|
815
|
+
// identifies a report with the legacy two-segment form (a timeStamp segment
|
|
816
|
+
// and a jobID segment).
|
|
817
|
+
const canonicalTopic = renamedPagePrefixes[topic] ?? topic;
|
|
818
|
+
const canonicalPath = getLegacyReportURL(canonicalTopic, pathTail) ??
|
|
819
|
+
`/${canonicalTopic}.html${pathTail ? `/${pathTail}` : ''}`;
|
|
820
|
+
// If the request signature contains any obsolete element (a pre-rename page
|
|
821
|
+
// name, a legacy two-segment report tail, or a query string, which no GET
|
|
822
|
+
// page handler reads, since the search argument passed to answer() is
|
|
823
|
+
// consumed only for POST requests, where it is reconstructed from the
|
|
824
|
+
// request body), convert all of them at once by redirecting the request
|
|
825
|
+
// permanently to the canonical path with no query string. A request with a
|
|
826
|
+
// query otherwise returns a byte-identical duplicate of the queryless page,
|
|
827
|
+
// which search engines report as a duplicate without a user-selected
|
|
828
|
+
// canonical (e.g. the pathID queries still requested on listDiagnoses URLs).
|
|
829
|
+
if (canonicalPath !== pathname || search) {
|
|
830
|
+
response.writeHead(301, {Location: canonicalPath});
|
|
817
831
|
response.end();
|
|
818
832
|
}
|
|
819
833
|
// Otherwise, if the page can be generated and is not POST-only (a POST-only path also matches
|
|
@@ -1385,6 +1399,16 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
|
|
|
1385
1399
|
setHeaders('application/json', null, 'ultra');
|
|
1386
1400
|
response.end(JSON.stringify(responseBody));
|
|
1387
1401
|
}
|
|
1402
|
+
// Otherwise, if the service is to receive a disagreement with a diagnosis:
|
|
1403
|
+
else if (segments[0] === 'reportDisagreement') {
|
|
1404
|
+
const {engineID, ruleID, reason} = postData as {engineID: string; ruleID: string; reason: string};
|
|
1405
|
+
// Get the response body.
|
|
1406
|
+
const responseBody = await apiRespond.reportDisagreement(segments.slice(1).concat(engineID, ruleID, reason));
|
|
1407
|
+
await recordPageMetric('apiOperations', 'reportDisagreement');
|
|
1408
|
+
// Send it.
|
|
1409
|
+
setHeaders('application/json', null, 'ultra');
|
|
1410
|
+
response.end(JSON.stringify(responseBody));
|
|
1411
|
+
}
|
|
1388
1412
|
// Otherwise, if the service is to order an immediate, automatically approved test:
|
|
1389
1413
|
else if (segments[0] === 'orderNewTest') {
|
|
1390
1414
|
const {description, URL, reason} = postData as {description: string; URL: string; reason: string};
|