@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.
@@ -11,9 +11,9 @@ import {routes} from './routes.ts';
11
11
 
12
12
  // TESTS
13
13
 
14
- test('routes exports an array with 11 operations', () => {
14
+ test('routes exports an array with 12 operations', () => {
15
15
  assert.ok(Array.isArray(routes));
16
- assert.equal(routes.length, 11);
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
- 'tested web page': z.object({description: z.string(), URL: z.string()}),
200
- 'number of reports about the page': z.number(),
201
- 'basics about the latest report': reportTimeSchema,
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({URL: z.string(), 'request method': z.literal('GET')}),
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 and make available findings about the front-end quality (i.e. accessibility, usability, and web standards, including HTML, CSS, ARIA, and WCAG) 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. Kilotest makes facts about the tested pages, the issues, and the violations retrievable at four levels of granularity.',
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 its latest report. The listIssues output about any report lists all reports about the same page.',
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 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.',
64
- 'latency': 'Requests for testing and retesting are usually approved and fulfilled within one day.',
65
- 'confirmation': 'Use the listPages tool to determine whether a requested new report exists. There is currently no process for notification of the outcome of requests.'
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`,
@@ -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. **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.
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. **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.
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
- Four submission types (test requests and retest requests together, web tutorial comments, AI tutorial comments, and MCP feature requests) are each held in a pending "inbox" (`db/jobs/testRequests.json`, `db/comments/tutorialWeb.json`, `db/comments/tutorialAI.json`, and `db/featureRequests.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.
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/<segments> redirects to the equivalent /listDiagnoses.html/<segments>, preserving the query string', async () => {
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/duplicateID/260426T1741-77w/718?pathID=/html/body', {}, {followRedirects: false}
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/duplicateID/260426T1741-77w/718?pathID=/html/body');
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, preserving the query string', async () => {
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?pathID=/html/body');
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, 200);
1653
- assert.equal(res.headers['set-cookie'], undefined);
1654
- assert.ok(res.body.includes('Authorization code'));
1655
- assert.ok(!res.body.includes('Web page views'));
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 URL that a legacy two-segment report URL (one whose
351
- // path tail identifies a report with separate timeStamp and jobID segments)
352
- // redirects to, or null if the page has no legacy form or the tail lacks its shape.
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
- await recordPageMetric('pageViews', 'index');
685
- // Get the home page.
686
- const {answerPage} = await home();
687
- // Serve it.
688
- setHeaders('text/html', '/index.html', 'medium');
689
- response.end(answerPage);
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 stored report
770
- // may have become unreadable or unusable, none of which is the requester's fault.
771
- // So tell the requester only that the request was invalid, without accusing them,
772
- // but alert a manager with the real reason, since a syntactically valid report ID
773
- // should otherwise always be usable.
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
- const legacyReportURL = getLegacyReportURL(topic, pathTail);
804
- // If it is for a page's old, pre-rename name:
805
- if (renamedPagePrefixes[topic]) {
806
- // Redirect the client permanently to the current page name, preserving any
807
- // further path segments and the query string.
808
- const newLocation = `/${renamedPagePrefixes[topic]}.html${pathTail ? `/${pathTail}` : ''}${search}`;
809
- response.writeHead(301, {Location: newLocation});
810
- response.end();
811
- }
812
- // Otherwise, if the URL identifies a report with the legacy two-segment form
813
- // (a timeStamp segment and a jobID segment), redirect the request permanently
814
- // to the URL with the single reportID segment, preserving the query string.
815
- else if (legacyReportURL !== null) {
816
- response.writeHead(301, {Location: `${legacyReportURL}${search}`});
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};