@jrpool/kilotest 47.0.1 → 49.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.
Files changed (63) hide show
  1. package/.claude/scheduled_tasks.lock +1 -0
  2. package/api/requestFeature.test.ts +4 -2
  3. package/api/requestFeature.ts +1 -1
  4. package/api/requestRetest.test.ts +66 -7
  5. package/api/requestRetest.ts +55 -39
  6. package/api/requestTest.test.ts +63 -7
  7. package/api/requestTest.ts +36 -26
  8. package/api/util.test.ts +6 -5
  9. package/api/util.ts +2 -24
  10. package/docs/BACKLOG.md +228 -16
  11. package/docs/SERVICE.md +84 -7
  12. package/index.test.ts +354 -25
  13. package/index.ts +174 -8
  14. package/linkValidation.test.ts +1 -0
  15. package/mcp.test.ts +30 -0
  16. package/mcp.ts +9 -0
  17. package/package.json +1 -1
  18. package/smokeTest.ts +16 -0
  19. package/test/dbFixture.ts +8 -0
  20. package/util.test.ts +426 -127
  21. package/util.ts +389 -196
  22. package/web/ai0BalanceForm/ai0BalanceForm.test.ts +16 -10
  23. package/web/ai0BalanceForm/index.html +1 -1
  24. package/web/ai0BalanceForm/index.ts +10 -6
  25. package/web/enqueue/index.ts +5 -2
  26. package/web/expungeReportsForm/expungeReportsForm.test.ts +19 -11
  27. package/web/expungeReportsForm/index.html +1 -1
  28. package/web/expungeReportsForm/index.ts +1 -1
  29. package/web/hideReportForm/hideReportForm.test.ts +24 -9
  30. package/web/hideReportForm/index.html +1 -1
  31. package/web/hideReportForm/index.ts +9 -6
  32. package/web/listReports/index.ts +33 -12
  33. package/web/listReports/listReports.test.ts +1 -1
  34. package/web/listTopIssues/index.ts +1 -1
  35. package/web/manage/index.html +2 -1
  36. package/web/metrics/index.html +19 -0
  37. package/web/metrics/index.ts +126 -0
  38. package/web/metrics/metrics.test.ts +211 -0
  39. package/web/pruneReportsForm/index.html +1 -1
  40. package/web/pruneReportsForm/index.ts +1 -1
  41. package/web/pruneReportsForm/pruneReportsForm.test.ts +18 -10
  42. package/web/reannotate/index.ts +7 -3
  43. package/web/reannotate/reannotate.test.ts +1 -1
  44. package/web/renewWCAG/index.ts +4 -1
  45. package/web/renewWCAG/renewWCAG.test.ts +1 -1
  46. package/web/reportDeletion.ts +12 -7
  47. package/web/requestRetest/index.html +3 -3
  48. package/web/requestRetest/index.ts +47 -16
  49. package/web/requestRetest/requestRetest.test.ts +38 -24
  50. package/web/requestTest/index.html +4 -4
  51. package/web/requestTest/index.ts +37 -9
  52. package/web/requestTest/requestTest.test.ts +21 -6
  53. package/web/rewindReportsForm/index.html +1 -1
  54. package/web/rewindReportsForm/index.ts +1 -1
  55. package/web/rewindReportsForm/rewindReportsForm.test.ts +18 -10
  56. package/web/showHiddenReportsForm/index.html +24 -0
  57. package/web/showHiddenReportsForm/index.ts +45 -0
  58. package/web/showHiddenReportsForm/showHiddenReportsForm.test.ts +103 -0
  59. package/web/tutorialAI/index.ts +1 -1
  60. package/web/tutorialWeb/index.ts +1 -1
  61. package/web/unhideReportForm/index.html +1 -1
  62. package/web/unhideReportForm/index.ts +11 -8
  63. package/web/unhideReportForm/unhideReportForm.test.ts +19 -11
package/index.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  createLock,
12
12
  deleteTestRequests,
13
13
  errorMessage,
14
+ getExclusionCookieValue,
14
15
  getJobNames,
15
16
  getJSON,
16
17
  getObject,
@@ -24,6 +25,8 @@ import {
24
25
  isJobID,
25
26
  isUsableReport,
26
27
  jobsPath,
28
+ metricsExclusionCookieName,
29
+ recordMetric,
27
30
  reportsPath
28
31
  } from './util.ts';
29
32
  import {sendAlert} from './alerts.ts';
@@ -49,6 +52,7 @@ import {answer as listRules} from './web/listRules/index.ts';
49
52
  import {answer as listTopIssues} from './web/listTopIssues/index.ts';
50
53
  import {answer as listViolatorsPage} from './web/listViolators/index.ts';
51
54
  import {answer as manage} from './web/manage/index.ts';
55
+ import {answer as metrics} from './web/metrics/index.ts';
52
56
  import {answer as pruneReportsForm} from './web/pruneReportsForm/index.ts';
53
57
  import {answer as reannotate} from './web/reannotate/index.ts';
54
58
  import {answer as reannotateForm} from './web/reannotateForm/index.ts';
@@ -59,6 +63,7 @@ import {answer as requestRetestForm} from './web/requestRetestForm/index.ts';
59
63
  import {answer as requestTestPage} from './web/requestTest/index.ts';
60
64
  import {answer as requestTestForm} from './web/requestTestForm/index.ts';
61
65
  import {answer as rewindReportsForm} from './web/rewindReportsForm/index.ts';
66
+ import {answer as showHiddenReportsForm} from './web/showHiddenReportsForm/index.ts';
62
67
  import {answer as unhideReportForm} from './web/unhideReportForm/index.ts';
63
68
  import {response as getReportAPI} from './api/getReport.ts';
64
69
  import {response as listDiagnosesAPI} from './api/listDiagnoses.ts';
@@ -76,7 +81,15 @@ dotenv.config({quiet: true});
76
81
  // CONSTANTS
77
82
 
78
83
  // The data returned by a page-answering handler.
79
- type AnswerData = {status: string; message?: string; answerPage?: string};
84
+ type AnswerData = {
85
+ status: string;
86
+ message?: string;
87
+ answerPage?: string;
88
+ // A cookie an answer() handler wants set on the response. Generic, not metrics-specific:
89
+ // handlers return data and never touch the response object directly, so this is how one
90
+ // asks index.ts, which owns the response, to set a cookie on its behalf.
91
+ setCookie?: {name: string; value: string; maxAgeSeconds: number};
92
+ };
80
93
  // A page-answering handler, whose parameters vary by topic.
81
94
  type PageHandler = (...args: any[]) => Promise<AnswerData>;
82
95
 
@@ -93,6 +106,7 @@ const answer: {
93
106
  listTopIssues: PageHandler;
94
107
  listViolators: PageHandler;
95
108
  manage: PageHandler;
109
+ metrics: PageHandler;
96
110
  pruneReportsForm: PageHandler;
97
111
  tutorialWeb: PageHandler;
98
112
  tutorialAI: PageHandler;
@@ -105,6 +119,7 @@ const answer: {
105
119
  requestTest: PageHandler;
106
120
  requestTestForm: PageHandler;
107
121
  rewindReportsForm: PageHandler;
122
+ showHiddenReportsForm: PageHandler;
108
123
  unhideReportForm: PageHandler;
109
124
  [key: string]: PageHandler | undefined;
110
125
  } = {
@@ -120,6 +135,7 @@ const answer: {
120
135
  listTopIssues,
121
136
  listViolators: listViolatorsPage,
122
137
  manage,
138
+ metrics,
123
139
  pruneReportsForm,
124
140
  tutorialWeb,
125
141
  tutorialAI,
@@ -132,6 +148,7 @@ const answer: {
132
148
  requestTest: requestTestPage,
133
149
  requestTestForm,
134
150
  rewindReportsForm,
151
+ showHiddenReportsForm,
135
152
  unhideReportForm
136
153
  };
137
154
  // Response functions of the API services.
@@ -187,6 +204,13 @@ export const routes = {
187
204
  POST: [
188
205
  '/api/*',
189
206
  '/mcp',
207
+ '/ai0BalanceForm.html',
208
+ '/expungeReportsForm.html',
209
+ '/hideReportForm.html',
210
+ '/metrics.html',
211
+ '/pruneReportsForm.html',
212
+ '/rewindReportsForm.html',
213
+ '/showHiddenReportsForm.html',
190
214
  '/tutorialAIComment.html',
191
215
  '/reannotate.html',
192
216
  '/requestAction.html',
@@ -194,10 +218,48 @@ export const routes = {
194
218
  '/requestRetest.html/*',
195
219
  '/requestTest.html',
196
220
  '/tutorialWebComment.html',
221
+ '/unhideReportForm.html',
197
222
  '/worker/job',
198
223
  '/worker/report'
199
224
  ]
200
225
  };
226
+ // The set of page topics (answer{} keys) that are manager-only pages, linked from
227
+ // manage.html. Their views and submissions are recorded under the managerActivity
228
+ // metrics category instead of pageViews, since they are the maintainer operating
229
+ // Kilotest rather than the usage the observability plan's questions are about.
230
+ const managerPages = new Set([
231
+ 'enqueueForm',
232
+ 'reannotateForm',
233
+ 'pruneReportsForm',
234
+ 'rewindReportsForm',
235
+ 'expungeReportsForm',
236
+ 'hideReportForm',
237
+ 'showHiddenReportsForm',
238
+ 'unhideReportForm',
239
+ 'ai0BalanceForm',
240
+ 'renewWCAGForm',
241
+ 'metrics'
242
+ ]);
243
+ // The set of manager pages (a subset of managerPages) whose single answer() function both
244
+ // displays a form on GET and processes that form's own submission on POST, as opposed to
245
+ // enqueueForm/reannotateForm, whose submissions post to a separate action page. This
246
+ // governs POST dispatch for all of these pages, and GET dispatch for all except
247
+ // unhideReportForm (see noDirectGetPages below).
248
+ const selfSubmittingManagerPages = new Set([
249
+ 'pruneReportsForm',
250
+ 'rewindReportsForm',
251
+ 'expungeReportsForm',
252
+ 'hideReportForm',
253
+ 'showHiddenReportsForm',
254
+ 'unhideReportForm',
255
+ 'ai0BalanceForm',
256
+ 'metrics'
257
+ ]);
258
+ // The subset of selfSubmittingManagerPages that must never be served on a direct GET
259
+ // request, only as the response to another page's successful submission (here,
260
+ // unhideReportForm is served only by showHiddenReportsForm, once a valid authorization code
261
+ // has been submitted), so that the names of hidden reports are never disclosed without one.
262
+ const noDirectGetPages = new Set(['unhideReportForm']);
201
263
  const jobLock = createLock();
202
264
 
203
265
  // FUNCTIONS
@@ -264,6 +326,18 @@ export const getAbuseError = (request: IncomingMessage, reason: string | undefin
264
326
  time: new Date().toISOString()
265
327
  };
266
328
  };
329
+ // Returns whether a request carries the metrics-exclusion cookie with the value that
330
+ // proves it. A request without a valid AUTH_CODE configured can never match, since
331
+ // getExclusionCookieValue then hashes an empty string, which no cookie should equal.
332
+ const isMetricsExcluded = (request: IncomingMessage): boolean => {
333
+ const header = request.headers.cookie;
334
+ if (!header || !process.env.AUTH_CODE) {
335
+ return false;
336
+ }
337
+ const cookies = header.split(';').map(pair => pair.trim().split('='));
338
+ const cookieValue = cookies.find(([name]) => name === metricsExclusionCookieName)?.[1];
339
+ return cookieValue === getExclusionCookieValue();
340
+ };
267
341
  // Gets the ID and secret from a request's HTTP Basic Authorization header, or null if the header
268
342
  // is absent or malformed.
269
343
  const getBasicAuth = (request: IncomingMessage) => {
@@ -311,7 +385,21 @@ const processJobRequest = async (request: IncomingMessage, response: ServerRespo
311
385
  const claimedJobNames = jobNames.claimed;
312
386
  // For each claimed job:
313
387
  for (const jobName of claimedJobNames) {
314
- const job = await getObject(path.join(jobsPath(), 'claimed', jobName));
388
+ // A claimed-job file can disappear between the directory listing above and this
389
+ // read, because report submission (which deletes or reclassifies claimed jobs) is
390
+ // not serialized by jobLock. That race is a normal, recoverable condition, so the
391
+ // job is skipped rather than treated as defective; any other failure to read it
392
+ // should never occur and is left to propagate.
393
+ let job: unknown;
394
+ try {
395
+ job = await getObject(path.join(jobsPath(), 'claimed', jobName));
396
+ }
397
+ catch (error: unknown) {
398
+ if (error instanceof Error && (error.cause as NodeJS.ErrnoException | undefined)?.code === 'ENOENT') {
399
+ continue;
400
+ }
401
+ throw error;
402
+ }
315
403
  const {id, sources} = job as {id: string, sources: {worker: string}};
316
404
  const {worker} = sources;
317
405
  // If its assignee is the worker:
@@ -399,6 +487,20 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
399
487
  response.end('{}');
400
488
  return;
401
489
  }
490
+ // Records a pageViews or apiOperations metric, unless this request's browser has proven,
491
+ // via a valid authCode submitted earlier on metrics.html, that it belongs to the
492
+ // maintainer testing Kilotest manually rather than to real usage.
493
+ const recordPageMetric = (category: 'pageViews' | 'apiOperations', name: string): Promise<void> =>
494
+ isMetricsExcluded(request) ? Promise.resolve() : recordMetric(category, name);
495
+ // Applies a cookie an answer() handler asked to have set on the response.
496
+ const applySetCookie = (answerData: AnswerData): void => {
497
+ if (answerData.setCookie) {
498
+ const {name, value, maxAgeSeconds} = answerData.setCookie;
499
+ response.setHeader(
500
+ 'Set-Cookie', `${name}=${value}; Max-Age=${maxAgeSeconds}; Path=/; HttpOnly; SameSite=Strict`
501
+ );
502
+ }
503
+ };
402
504
  // If the request is a GET request:
403
505
  if (method === 'GET') {
404
506
  // If the path is not authorized for GET requests:
@@ -423,6 +525,7 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
423
525
  }
424
526
  // Otherwise, if it is for the home page:
425
527
  else if (['/', '/index.html'].includes(pathname)) {
528
+ await recordPageMetric('pageViews', 'index');
426
529
  // Get the home page.
427
530
  const homePage = await fs.readFile('index.html', 'utf8');
428
531
  // Serve it.
@@ -533,18 +636,38 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
533
636
  // Otherwise, if it is for an HTML page other than the home page:
534
637
  else if (pageName.endsWith('.html')) {
535
638
  const topic = pageName.slice(0, -5);
536
- // If the page can be generated:
537
- if (answer[topic]) {
639
+ // If the page can be generated and is not POST-only (a POST-only path also matches
640
+ // the '*.html*' GET pattern, but its handler expects POST's argument list and
641
+ // performs no GET-appropriate rendering; a self-submitting manager page is POST-allowed
642
+ // too, but its handler serves both methods the same way, so it is not excluded here,
643
+ // except for a page in noDirectGetPages, which stays excluded from GET even though
644
+ // it is self-submitting, since it must be reachable only via another page's POST):
645
+ if (
646
+ answer[topic] &&
647
+ !noDirectGetPages.has(topic) &&
648
+ (!isPathAllowed('POST', pathname) || selfSubmittingManagerPages.has(topic))
649
+ ) {
538
650
  setHeaders('text/html', pathname, 'ultra');
539
- // Get the answer data.
540
- const answerData = await answer[topic](pathTail, search);
651
+ // Get the answer data. The method is passed so that a self-submitting manager
652
+ // page's handler can tell a form-display GET from its own submission POST.
653
+ const answerData = await answer[topic](pathTail, search, method);
541
654
  // If they are valid:
542
655
  if (answerData.status === 'ok') {
656
+ if (managerPages.has(topic)) {
657
+ await recordMetric('managerActivity', topic, 'ok');
658
+ }
659
+ else {
660
+ await recordPageMetric('pageViews', topic);
661
+ }
662
+ applySetCookie(answerData);
543
663
  // Serve the answer page.
544
664
  response.end(answerData.answerPage);
545
665
  }
546
666
  // Otherwise, i.e. if they are invalid:
547
667
  else {
668
+ if (managerPages.has(topic)) {
669
+ await recordMetric('managerActivity', topic, 'error');
670
+ }
548
671
  // Report the error as suspected abuse.
549
672
  await serveError(getAbuseError(request, answerData.message), response, true);
550
673
  }
@@ -562,6 +685,7 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
562
685
  if (service === 'listReports') {
563
686
  // Get the response body.
564
687
  const responseBody = await apiRespond.listReports([]);
688
+ await recordPageMetric('apiOperations', 'listReports');
565
689
  // Send it.
566
690
  setHeaders('application/json', null, 'ultra');
567
691
  response.end(JSON.stringify(responseBody));
@@ -570,6 +694,7 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
570
694
  else if (service === 'listIssues') {
571
695
  // Get the response body.
572
696
  const responseBody = await apiRespond.listIssues(specs);
697
+ await recordPageMetric('apiOperations', 'listIssues');
573
698
  // Send it.
574
699
  setHeaders('application/json', null, 'high');
575
700
  response.end(JSON.stringify(responseBody));
@@ -578,6 +703,7 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
578
703
  else if (service === 'listViolators') {
579
704
  // Get the response body.
580
705
  const responseBody = await apiRespond.listViolators(specs);
706
+ await recordPageMetric('apiOperations', 'listViolators');
581
707
  // Send it.
582
708
  setHeaders('application/json', null, 'high');
583
709
  response.end(JSON.stringify(responseBody));
@@ -586,6 +712,7 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
586
712
  else if (service === 'listDiagnoses') {
587
713
  // Get the response body.
588
714
  const responseBody = await apiRespond.listDiagnoses(specs);
715
+ await recordPageMetric('apiOperations', 'listDiagnoses');
589
716
  // Send it.
590
717
  setHeaders('application/json', null, 'high');
591
718
  response.end(JSON.stringify(responseBody));
@@ -594,6 +721,7 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
594
721
  else if (service === 'getReport') {
595
722
  // Get the response body.
596
723
  const responseBody = await apiRespond.getReport(specs);
724
+ await recordPageMetric('apiOperations', 'getReport');
597
725
  // Send it.
598
726
  setHeaders('application/json', null, 'low');
599
727
  response.end(JSON.stringify(responseBody));
@@ -760,17 +888,20 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
760
888
  const answerData = await answer.enqueue(url, description, authCode);
761
889
  // If the answer data are valid:
762
890
  if (answerData.status === 'ok') {
891
+ await recordMetric('managerActivity', 'requestAction.html', 'ok');
763
892
  // Serve the test-order page with the remaining recommendations.
764
893
  response.end(answerData.answerPage);
765
894
  }
766
895
  // Otherwise, i.e. if they are invalid:
767
896
  else {
897
+ await recordMetric('managerActivity', 'requestAction.html', 'error');
768
898
  // Report the error.
769
899
  await serveError({message: answerData.message}, response, true);
770
900
  }
771
901
  }
772
902
  // Otherwise, i.e. if it is a rejection:
773
903
  else {
904
+ await recordMetric('managerActivity', 'requestAction.html', 'ok');
774
905
  // Delete the test requests for the URL.
775
906
  await deleteTestRequests(url);
776
907
  // Set a location header for a response.
@@ -783,8 +914,10 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
783
914
  }
784
915
  // Otherwise, i.e. if the request is invalid:
785
916
  else {
786
- // Report the error.
787
- await serveError({message: 'ERROR: Invalid test order'}, response, true);
917
+ await recordMetric('managerActivity', 'requestAction.html', 'error');
918
+ // Report the error, deliberately vague so as not to confirm to an attacker
919
+ // which part of the request (the URL, or the authorization code) was wrong.
920
+ await serveError({message: 'Invalid request'}, response, true);
788
921
  }
789
922
  }
790
923
  // Otherwise, if it is a reannotation order:
@@ -796,11 +929,13 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
796
929
  const answerData = await answer.reannotate(authCode);
797
930
  // If the answer data are valid:
798
931
  if (answerData.status === 'ok') {
932
+ await recordMetric('managerActivity', 'reannotate.html', 'ok');
799
933
  // Serve the answer page.
800
934
  response.end(answerData.answerPage);
801
935
  }
802
936
  // Otherwise, i.e. if they are invalid:
803
937
  else {
938
+ await recordMetric('managerActivity', 'reannotate.html', 'error');
804
939
  // Report the error.
805
940
  await serveError({message: answerData.message}, response, true);
806
941
  }
@@ -814,11 +949,39 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
814
949
  const answerData = await answer.renewWCAG(authCode);
815
950
  // If the answer data are valid:
816
951
  if (answerData.status === 'ok') {
952
+ await recordMetric('managerActivity', 'renewWCAG.html', 'ok');
953
+ // Serve the answer page.
954
+ response.end(answerData.answerPage);
955
+ }
956
+ // Otherwise, i.e. if they are invalid:
957
+ else {
958
+ await recordMetric('managerActivity', 'renewWCAG.html', 'error');
959
+ // Report the error.
960
+ await serveError({message: answerData.message}, response, true);
961
+ }
962
+ }
963
+ // Otherwise, if it is a self-submitting manager page (one page serves the form on
964
+ // GET and processes its own submission on POST, unlike enqueueForm.html/reannotateForm.html,
965
+ // whose submissions go to a separate action page):
966
+ else if (pageName.endsWith('.html') && selfSubmittingManagerPages.has(pageName.slice(0, -5))) {
967
+ const topic = pageName.slice(0, -5);
968
+ setHeaders('text/html', pathname, 'ultra');
969
+ // Reconstruct a query string from the POST body, so the page's answer() function
970
+ // can read its submitted parameters the same way it reads a GET query string.
971
+ const search = `?${new URLSearchParams(postData as Record<string, string>).toString()}`;
972
+ // Get the answer data. The method is passed so the handler only processes a
973
+ // submission for POST, never for GET, regardless of what its query string contains.
974
+ const answerData = await answer[topic]!(pathTail, search, method);
975
+ // If they are valid:
976
+ if (answerData.status === 'ok') {
977
+ await recordMetric('managerActivity', topic, 'ok');
978
+ applySetCookie(answerData);
817
979
  // Serve the answer page.
818
980
  response.end(answerData.answerPage);
819
981
  }
820
982
  // Otherwise, i.e. if they are invalid:
821
983
  else {
984
+ await recordMetric('managerActivity', topic, 'error');
822
985
  // Report the error.
823
986
  await serveError({message: answerData.message}, response, true);
824
987
  }
@@ -936,6 +1099,7 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
936
1099
  const {description, URL, reason} = postData as {description: string; URL: string; reason: string};
937
1100
  // Get the response body.
938
1101
  const responseBody = await apiRespond.requestTest([description, URL, reason]);
1102
+ await recordPageMetric('apiOperations', 'requestTest');
939
1103
  // Send it.
940
1104
  setHeaders('application/json', null, 'ultra');
941
1105
  response.end(JSON.stringify(responseBody));
@@ -945,6 +1109,7 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
945
1109
  const {reason} = postData as {reason: string};
946
1110
  // Get the response body.
947
1111
  const responseBody = await apiRespond.requestRetest(segments.slice(1).concat(reason));
1112
+ await recordPageMetric('apiOperations', 'requestRetest');
948
1113
  // Send it.
949
1114
  setHeaders('application/json', null, 'ultra');
950
1115
  response.end(JSON.stringify(responseBody));
@@ -954,6 +1119,7 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
954
1119
  const {feature} = postData as {feature: string};
955
1120
  // Get the response body.
956
1121
  const responseBody = await apiRespond.requestFeature([feature]);
1122
+ await recordPageMetric('apiOperations', 'requestFeature');
957
1123
  // Send it.
958
1124
  setHeaders('application/json', null, 'ultra');
959
1125
  response.end(JSON.stringify(responseBody));
@@ -55,6 +55,7 @@ const htmlRoutes = [
55
55
  '/reannotate.html',
56
56
  '/reannotateForm.html',
57
57
  '/hideReportForm.html',
58
+ '/showHiddenReportsForm.html',
58
59
  '/unhideReportForm.html',
59
60
  '/expungeReportsForm.html',
60
61
  '/pruneReportsForm.html',
package/mcp.test.ts CHANGED
@@ -9,12 +9,26 @@ import {test} from 'node:test';
9
9
  import assert from 'node:assert/strict';
10
10
  import http from 'node:http';
11
11
  import {mcpPath, createMCPServer, handleMCP} from './mcp.ts';
12
+ import {metricsPath} from './util.ts';
13
+ import fs from 'node:fs';
12
14
 
13
15
  // CONSTANTS
14
16
 
15
17
  // Set DB_DIR to the fixture database for all tests.
16
18
  process.env.DB_DIR = (await import('./test/dbFixture.ts')).fixtureDBDir;
17
19
 
20
+ // FUNCTIONS
21
+
22
+ // Returns the current count for an MCP tool in the metrics file, or 0 if the file or the
23
+ // tool's entry is absent (the file does not exist until the first metric is recorded).
24
+ const getToolCallCount = (toolName: string): number => {
25
+ if (!fs.existsSync(metricsPath())) {
26
+ return 0;
27
+ }
28
+ const metrics = JSON.parse(fs.readFileSync(metricsPath(), 'utf8'));
29
+ return metrics.mcpToolCalls[toolName] ?? 0;
30
+ };
31
+
18
32
  // TESTS
19
33
 
20
34
  test('mcpPath is /mcp', () => {
@@ -48,14 +62,17 @@ test('each tool has a description and a handler function', () => {
48
62
 
49
63
  test('listReports handler returns content and structuredContent', async () => {
50
64
  const server = createMCPServer();
65
+ const countBefore = getToolCallCount('listReports');
51
66
  const result = await (server as any)._registeredTools.listReports.handler({});
52
67
  assert.ok(result.content);
53
68
  assert.equal(result.content[0].type, 'text');
54
69
  assert.ok(result.structuredContent);
70
+ assert.equal(getToolCallCount('listReports'), countBefore + 1);
55
71
  });
56
72
 
57
73
  test('listIssues handler returns content and structuredContent for a valid report', async () => {
58
74
  const server = createMCPServer();
75
+ const countBefore = getToolCallCount('listIssues');
59
76
  const result = await (server as any)._registeredTools.listIssues.handler({
60
77
  timeStamp: '260101T0000',
61
78
  jobID: 'mix'
@@ -63,10 +80,12 @@ test('listIssues handler returns content and structuredContent for a valid repor
63
80
  assert.ok(result.content);
64
81
  assert.equal(result.content[0].type, 'text');
65
82
  assert.ok(result.structuredContent);
83
+ assert.equal(getToolCallCount('listIssues'), countBefore + 1);
66
84
  });
67
85
 
68
86
  test('listViolators handler returns content and structuredContent for a valid issue', async () => {
69
87
  const server = createMCPServer();
88
+ const countBefore = getToolCallCount('listViolators');
70
89
  const result = await (server as any)._registeredTools.listViolators.handler({
71
90
  issueID: 'linkNoText',
72
91
  timeStamp: '260101T0000',
@@ -75,10 +94,12 @@ test('listViolators handler returns content and structuredContent for a valid is
75
94
  assert.ok(result.content);
76
95
  assert.equal(result.content[0].type, 'text');
77
96
  assert.ok(result.structuredContent);
97
+ assert.equal(getToolCallCount('listViolators'), countBefore + 1);
78
98
  });
79
99
 
80
100
  test('listDiagnoses handler returns content and structuredContent for a valid diagnosis', async () => {
81
101
  const server = createMCPServer();
102
+ const countBefore = getToolCallCount('listDiagnoses');
82
103
  const result = await (server as any)._registeredTools.listDiagnoses.handler({
83
104
  catalogIndex: '0',
84
105
  issueID: 'linkNoText',
@@ -88,10 +109,12 @@ test('listDiagnoses handler returns content and structuredContent for a valid di
88
109
  assert.ok(result.content);
89
110
  assert.equal(result.content[0].type, 'text');
90
111
  assert.ok(result.structuredContent);
112
+ assert.equal(getToolCallCount('listDiagnoses'), countBefore + 1);
91
113
  });
92
114
 
93
115
  test('getReport handler returns content and structuredContent for a valid report', async () => {
94
116
  const server = createMCPServer();
117
+ const countBefore = getToolCallCount('getReport');
95
118
  const result = await (server as any)._registeredTools.getReport.handler({
96
119
  timeStamp: '260101T0000',
97
120
  jobID: 'mix'
@@ -99,10 +122,12 @@ test('getReport handler returns content and structuredContent for a valid report
99
122
  assert.ok(result.content);
100
123
  assert.equal(result.content[0].type, 'text');
101
124
  assert.ok(result.structuredContent);
125
+ assert.equal(getToolCallCount('getReport'), countBefore + 1);
102
126
  });
103
127
 
104
128
  test('requestTest handler returns content and structuredContent', async () => {
105
129
  const server = createMCPServer();
130
+ const countBefore = getToolCallCount('requestTest');
106
131
  const result = await (server as any)._registeredTools.requestTest.handler({
107
132
  description: 'Test Page',
108
133
  URL: 'https://example.com/test',
@@ -111,10 +136,12 @@ test('requestTest handler returns content and structuredContent', async () => {
111
136
  assert.ok(result.content);
112
137
  assert.equal(result.content[0].type, 'text');
113
138
  assert.ok(result.structuredContent);
139
+ assert.equal(getToolCallCount('requestTest'), countBefore + 1);
114
140
  });
115
141
 
116
142
  test('requestRetest handler returns content and structuredContent for a valid report', async () => {
117
143
  const server = createMCPServer();
144
+ const countBefore = getToolCallCount('requestRetest');
118
145
  const result = await (server as any)._registeredTools.requestRetest.handler({
119
146
  timeStamp: '260101T0000',
120
147
  jobID: 'mix',
@@ -123,16 +150,19 @@ test('requestRetest handler returns content and structuredContent for a valid re
123
150
  assert.ok(result.content);
124
151
  assert.equal(result.content[0].type, 'text');
125
152
  assert.ok(result.structuredContent);
153
+ assert.equal(getToolCallCount('requestRetest'), countBefore + 1);
126
154
  });
127
155
 
128
156
  test('requestFeature handler returns content and structuredContent', async () => {
129
157
  const server = createMCPServer();
158
+ const countBefore = getToolCallCount('requestFeature');
130
159
  const result = await (server as any)._registeredTools.requestFeature.handler({
131
160
  feature: 'A new feature idea'
132
161
  });
133
162
  assert.ok(result.content);
134
163
  assert.equal(result.content[0].type, 'text');
135
164
  assert.ok(result.structuredContent);
165
+ assert.equal(getToolCallCount('requestFeature'), countBefore + 1);
136
166
  });
137
167
 
138
168
  test('listIssues handler returns an error for a nonexistent report', async () => {
package/mcp.ts CHANGED
@@ -17,6 +17,7 @@ import * as requestTestAPI from './api/requestTest.ts';
17
17
  import * as requestRetestAPI from './api/requestRetest.ts';
18
18
  import * as requestFeatureAPI from './api/requestFeature.ts';
19
19
  import {version} from './api/version.ts';
20
+ import {recordMetric} from './util.ts';
20
21
 
21
22
  import {
22
23
  getReportSchema,
@@ -68,6 +69,7 @@ export const createMCPServer = (): McpServer => {
68
69
  },
69
70
  async () => {
70
71
  const result = await listReportsAPI.response();
72
+ await recordMetric('mcpToolCalls', 'listReports');
71
73
  return {content: [{type: 'text', text: JSON.stringify(result)}], structuredContent: result};
72
74
  }
73
75
  );
@@ -87,6 +89,7 @@ export const createMCPServer = (): McpServer => {
87
89
  },
88
90
  async ({timeStamp, jobID}) => {
89
91
  const result = await listIssuesAPI.response([timeStamp, jobID]);
92
+ await recordMetric('mcpToolCalls', 'listIssues');
90
93
  return {content: [{type: 'text', text: JSON.stringify(result)}], structuredContent: result};
91
94
  }
92
95
  );
@@ -106,6 +109,7 @@ export const createMCPServer = (): McpServer => {
106
109
  },
107
110
  async ({issueID, timeStamp, jobID}) => {
108
111
  const result = await listViolatorsAPI.response([issueID, timeStamp, jobID]);
112
+ await recordMetric('mcpToolCalls', 'listViolators');
109
113
  return {content: [{type: 'text', text: JSON.stringify(result)}], structuredContent: result};
110
114
  }
111
115
  );
@@ -125,6 +129,7 @@ export const createMCPServer = (): McpServer => {
125
129
  },
126
130
  async ({catalogIndex, issueID, timeStamp, jobID}) => {
127
131
  const result = await listDiagnosesAPI.response([catalogIndex, issueID, timeStamp, jobID]);
132
+ await recordMetric('mcpToolCalls', 'listDiagnoses');
128
133
  return {content: [{type: 'text', text: JSON.stringify(result)}], structuredContent: result};
129
134
  }
130
135
  );
@@ -144,6 +149,7 @@ export const createMCPServer = (): McpServer => {
144
149
  },
145
150
  async ({timeStamp, jobID}) => {
146
151
  const result = await getReportAPI.response([timeStamp, jobID]);
152
+ await recordMetric('mcpToolCalls', 'getReport');
147
153
  return {content: [{type: 'text', text: JSON.stringify(result)}], structuredContent: result};
148
154
  }
149
155
  );
@@ -163,6 +169,7 @@ export const createMCPServer = (): McpServer => {
163
169
  },
164
170
  async ({description, URL, reason}) => {
165
171
  const result = await requestTestAPI.response([description, URL, reason]);
172
+ await recordMetric('mcpToolCalls', 'requestTest');
166
173
  return {content: [{type: 'text', text: JSON.stringify(result)}], structuredContent: result};
167
174
  }
168
175
  );
@@ -182,6 +189,7 @@ export const createMCPServer = (): McpServer => {
182
189
  },
183
190
  async ({timeStamp, jobID, reason}) => {
184
191
  const result = await requestRetestAPI.response([timeStamp, jobID, reason]);
192
+ await recordMetric('mcpToolCalls', 'requestRetest');
185
193
  return {content: [{type: 'text', text: JSON.stringify(result)}], structuredContent: result};
186
194
  }
187
195
  );
@@ -201,6 +209,7 @@ export const createMCPServer = (): McpServer => {
201
209
  },
202
210
  async ({feature}) => {
203
211
  const result = await requestFeatureAPI.response([feature]);
212
+ await recordMetric('mcpToolCalls', 'requestFeature');
204
213
  return {content: [{type: 'text', text: JSON.stringify(result)}], structuredContent: result};
205
214
  }
206
215
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jrpool/kilotest",
3
- "version": "47.0.1",
3
+ "version": "49.0.0",
4
4
  "description": "Ensemble testing for front-end web quality",
5
5
  "type": "module",
6
6
  "main": "index.ts",
package/smokeTest.ts CHANGED
@@ -40,13 +40,21 @@ const concretePaths: Record<string, Record<string, string>> = {
40
40
  POST: {
41
41
  '/api/*': '/api/requestFeature',
42
42
  '/mcp': '/mcp',
43
+ '/ai0BalanceForm.html': '/ai0BalanceForm.html',
44
+ '/expungeReportsForm.html': '/expungeReportsForm.html',
45
+ '/hideReportForm.html': '/hideReportForm.html',
46
+ '/metrics.html': '/metrics.html',
47
+ '/pruneReportsForm.html': '/pruneReportsForm.html',
48
+ '/rewindReportsForm.html': '/rewindReportsForm.html',
43
49
  '/reannotate.html': '/reannotate.html',
44
50
  '/requestAction.html': '/requestAction.html',
45
51
  '/renewWCAG.html': '/renewWCAG.html',
46
52
  '/requestRetest.html/*': '/requestRetest.html/260101T0001/ct',
47
53
  '/requestTest.html': '/requestTest.html',
54
+ '/showHiddenReportsForm.html': '/showHiddenReportsForm.html',
48
55
  '/tutorialAIComment.html': '/tutorialAIComment.html',
49
56
  '/tutorialWebComment.html': '/tutorialWebComment.html',
57
+ '/unhideReportForm.html': '/unhideReportForm.html',
50
58
  '/worker/job': '/worker/job',
51
59
  '/worker/report': '/worker/report'
52
60
  }
@@ -55,6 +63,12 @@ const concretePaths: Record<string, Record<string, string>> = {
55
63
  const postBodies: Record<string, object> = {
56
64
  '/api/requestFeature': {feature: 'smoke test'},
57
65
  '/mcp': {},
66
+ '/ai0BalanceForm.html': {authCode: 'invalid'},
67
+ '/expungeReportsForm.html': {authCode: 'invalid'},
68
+ '/hideReportForm.html': {authCode: 'invalid'},
69
+ '/metrics.html': {authCode: 'invalid'},
70
+ '/pruneReportsForm.html': {authCode: 'invalid'},
71
+ '/rewindReportsForm.html': {authCode: 'invalid'},
58
72
  '/reannotate.html': {authCode: 'invalid'},
59
73
  '/requestAction.html': {
60
74
  target: 'https://smoketest.example.com\tSmoke Test Page', authCode: 'invalid'
@@ -62,7 +76,9 @@ const postBodies: Record<string, object> = {
62
76
  '/renewWCAG.html': {authCode: 'invalid'},
63
77
  '/requestRetest.html/260101T0001/ct': {why: 'smoke test'},
64
78
  '/requestTest.html': {description: 'Smoke Test Page', url: 'https://smoketest.example.com', why: 'smoke test'},
79
+ '/showHiddenReportsForm.html': {authCode: 'invalid'},
65
80
  '/tutorialAIComment.html': {content: 'smoke test'},
81
+ '/unhideReportForm.html': {authCode: 'invalid'},
66
82
  '/tutorialWebComment.html': {content: 'smoke test'},
67
83
  '/worker/job': {},
68
84
  '/worker/report': {}