@jrpool/kilotest 48.0.0 → 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.
- package/.claude/scheduled_tasks.lock +1 -0
- package/api/requestFeature.test.ts +4 -2
- package/api/requestFeature.ts +1 -1
- package/docs/BACKLOG.md +220 -12
- package/docs/SERVICE.md +52 -1
- package/index.test.ts +334 -13
- package/index.ts +174 -10
- package/linkValidation.test.ts +1 -0
- package/mcp.test.ts +30 -0
- package/mcp.ts +9 -0
- package/package.json +1 -1
- package/smokeTest.ts +16 -0
- package/test/dbFixture.ts +8 -0
- package/util.test.ts +231 -0
- package/util.ts +97 -0
- package/web/ai0BalanceForm/ai0BalanceForm.test.ts +16 -10
- package/web/ai0BalanceForm/index.html +1 -1
- package/web/ai0BalanceForm/index.ts +10 -6
- package/web/enqueue/index.ts +5 -2
- package/web/expungeReportsForm/expungeReportsForm.test.ts +19 -11
- package/web/expungeReportsForm/index.html +1 -1
- package/web/expungeReportsForm/index.ts +1 -1
- package/web/hideReportForm/hideReportForm.test.ts +24 -9
- package/web/hideReportForm/index.html +1 -1
- package/web/hideReportForm/index.ts +9 -6
- package/web/listTopIssues/index.ts +1 -1
- package/web/manage/index.html +2 -1
- package/web/metrics/index.html +19 -0
- package/web/metrics/index.ts +126 -0
- package/web/metrics/metrics.test.ts +211 -0
- package/web/pruneReportsForm/index.html +1 -1
- package/web/pruneReportsForm/index.ts +1 -1
- package/web/pruneReportsForm/pruneReportsForm.test.ts +18 -10
- package/web/reannotate/index.ts +5 -2
- package/web/reannotate/reannotate.test.ts +1 -1
- package/web/renewWCAG/index.ts +4 -1
- package/web/renewWCAG/renewWCAG.test.ts +1 -1
- package/web/reportDeletion.ts +10 -5
- package/web/rewindReportsForm/index.html +1 -1
- package/web/rewindReportsForm/index.ts +1 -1
- package/web/rewindReportsForm/rewindReportsForm.test.ts +18 -10
- package/web/showHiddenReportsForm/index.html +24 -0
- package/web/showHiddenReportsForm/index.ts +45 -0
- package/web/showHiddenReportsForm/showHiddenReportsForm.test.ts +103 -0
- package/web/tutorialAI/index.ts +1 -1
- package/web/tutorialWeb/index.ts +1 -1
- package/web/unhideReportForm/index.html +1 -1
- package/web/unhideReportForm/index.ts +9 -6
- 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 = {
|
|
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
|
-
|
|
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,20 +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 and is not POST-only (a POST-only path also
|
|
537
|
-
//
|
|
538
|
-
//
|
|
539
|
-
|
|
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
|
+
) {
|
|
540
650
|
setHeaders('text/html', pathname, 'ultra');
|
|
541
|
-
// Get the answer data.
|
|
542
|
-
|
|
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);
|
|
543
654
|
// If they are valid:
|
|
544
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);
|
|
545
663
|
// Serve the answer page.
|
|
546
664
|
response.end(answerData.answerPage);
|
|
547
665
|
}
|
|
548
666
|
// Otherwise, i.e. if they are invalid:
|
|
549
667
|
else {
|
|
668
|
+
if (managerPages.has(topic)) {
|
|
669
|
+
await recordMetric('managerActivity', topic, 'error');
|
|
670
|
+
}
|
|
550
671
|
// Report the error as suspected abuse.
|
|
551
672
|
await serveError(getAbuseError(request, answerData.message), response, true);
|
|
552
673
|
}
|
|
@@ -564,6 +685,7 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
|
|
|
564
685
|
if (service === 'listReports') {
|
|
565
686
|
// Get the response body.
|
|
566
687
|
const responseBody = await apiRespond.listReports([]);
|
|
688
|
+
await recordPageMetric('apiOperations', 'listReports');
|
|
567
689
|
// Send it.
|
|
568
690
|
setHeaders('application/json', null, 'ultra');
|
|
569
691
|
response.end(JSON.stringify(responseBody));
|
|
@@ -572,6 +694,7 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
|
|
|
572
694
|
else if (service === 'listIssues') {
|
|
573
695
|
// Get the response body.
|
|
574
696
|
const responseBody = await apiRespond.listIssues(specs);
|
|
697
|
+
await recordPageMetric('apiOperations', 'listIssues');
|
|
575
698
|
// Send it.
|
|
576
699
|
setHeaders('application/json', null, 'high');
|
|
577
700
|
response.end(JSON.stringify(responseBody));
|
|
@@ -580,6 +703,7 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
|
|
|
580
703
|
else if (service === 'listViolators') {
|
|
581
704
|
// Get the response body.
|
|
582
705
|
const responseBody = await apiRespond.listViolators(specs);
|
|
706
|
+
await recordPageMetric('apiOperations', 'listViolators');
|
|
583
707
|
// Send it.
|
|
584
708
|
setHeaders('application/json', null, 'high');
|
|
585
709
|
response.end(JSON.stringify(responseBody));
|
|
@@ -588,6 +712,7 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
|
|
|
588
712
|
else if (service === 'listDiagnoses') {
|
|
589
713
|
// Get the response body.
|
|
590
714
|
const responseBody = await apiRespond.listDiagnoses(specs);
|
|
715
|
+
await recordPageMetric('apiOperations', 'listDiagnoses');
|
|
591
716
|
// Send it.
|
|
592
717
|
setHeaders('application/json', null, 'high');
|
|
593
718
|
response.end(JSON.stringify(responseBody));
|
|
@@ -596,6 +721,7 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
|
|
|
596
721
|
else if (service === 'getReport') {
|
|
597
722
|
// Get the response body.
|
|
598
723
|
const responseBody = await apiRespond.getReport(specs);
|
|
724
|
+
await recordPageMetric('apiOperations', 'getReport');
|
|
599
725
|
// Send it.
|
|
600
726
|
setHeaders('application/json', null, 'low');
|
|
601
727
|
response.end(JSON.stringify(responseBody));
|
|
@@ -762,17 +888,20 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
|
|
|
762
888
|
const answerData = await answer.enqueue(url, description, authCode);
|
|
763
889
|
// If the answer data are valid:
|
|
764
890
|
if (answerData.status === 'ok') {
|
|
891
|
+
await recordMetric('managerActivity', 'requestAction.html', 'ok');
|
|
765
892
|
// Serve the test-order page with the remaining recommendations.
|
|
766
893
|
response.end(answerData.answerPage);
|
|
767
894
|
}
|
|
768
895
|
// Otherwise, i.e. if they are invalid:
|
|
769
896
|
else {
|
|
897
|
+
await recordMetric('managerActivity', 'requestAction.html', 'error');
|
|
770
898
|
// Report the error.
|
|
771
899
|
await serveError({message: answerData.message}, response, true);
|
|
772
900
|
}
|
|
773
901
|
}
|
|
774
902
|
// Otherwise, i.e. if it is a rejection:
|
|
775
903
|
else {
|
|
904
|
+
await recordMetric('managerActivity', 'requestAction.html', 'ok');
|
|
776
905
|
// Delete the test requests for the URL.
|
|
777
906
|
await deleteTestRequests(url);
|
|
778
907
|
// Set a location header for a response.
|
|
@@ -785,8 +914,10 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
|
|
|
785
914
|
}
|
|
786
915
|
// Otherwise, i.e. if the request is invalid:
|
|
787
916
|
else {
|
|
788
|
-
|
|
789
|
-
|
|
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);
|
|
790
921
|
}
|
|
791
922
|
}
|
|
792
923
|
// Otherwise, if it is a reannotation order:
|
|
@@ -798,11 +929,13 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
|
|
|
798
929
|
const answerData = await answer.reannotate(authCode);
|
|
799
930
|
// If the answer data are valid:
|
|
800
931
|
if (answerData.status === 'ok') {
|
|
932
|
+
await recordMetric('managerActivity', 'reannotate.html', 'ok');
|
|
801
933
|
// Serve the answer page.
|
|
802
934
|
response.end(answerData.answerPage);
|
|
803
935
|
}
|
|
804
936
|
// Otherwise, i.e. if they are invalid:
|
|
805
937
|
else {
|
|
938
|
+
await recordMetric('managerActivity', 'reannotate.html', 'error');
|
|
806
939
|
// Report the error.
|
|
807
940
|
await serveError({message: answerData.message}, response, true);
|
|
808
941
|
}
|
|
@@ -816,11 +949,39 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
|
|
|
816
949
|
const answerData = await answer.renewWCAG(authCode);
|
|
817
950
|
// If the answer data are valid:
|
|
818
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);
|
|
819
979
|
// Serve the answer page.
|
|
820
980
|
response.end(answerData.answerPage);
|
|
821
981
|
}
|
|
822
982
|
// Otherwise, i.e. if they are invalid:
|
|
823
983
|
else {
|
|
984
|
+
await recordMetric('managerActivity', topic, 'error');
|
|
824
985
|
// Report the error.
|
|
825
986
|
await serveError({message: answerData.message}, response, true);
|
|
826
987
|
}
|
|
@@ -938,6 +1099,7 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
|
|
|
938
1099
|
const {description, URL, reason} = postData as {description: string; URL: string; reason: string};
|
|
939
1100
|
// Get the response body.
|
|
940
1101
|
const responseBody = await apiRespond.requestTest([description, URL, reason]);
|
|
1102
|
+
await recordPageMetric('apiOperations', 'requestTest');
|
|
941
1103
|
// Send it.
|
|
942
1104
|
setHeaders('application/json', null, 'ultra');
|
|
943
1105
|
response.end(JSON.stringify(responseBody));
|
|
@@ -947,6 +1109,7 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
|
|
|
947
1109
|
const {reason} = postData as {reason: string};
|
|
948
1110
|
// Get the response body.
|
|
949
1111
|
const responseBody = await apiRespond.requestRetest(segments.slice(1).concat(reason));
|
|
1112
|
+
await recordPageMetric('apiOperations', 'requestRetest');
|
|
950
1113
|
// Send it.
|
|
951
1114
|
setHeaders('application/json', null, 'ultra');
|
|
952
1115
|
response.end(JSON.stringify(responseBody));
|
|
@@ -956,6 +1119,7 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
|
|
|
956
1119
|
const {feature} = postData as {feature: string};
|
|
957
1120
|
// Get the response body.
|
|
958
1121
|
const responseBody = await apiRespond.requestFeature([feature]);
|
|
1122
|
+
await recordPageMetric('apiOperations', 'requestFeature');
|
|
959
1123
|
// Send it.
|
|
960
1124
|
setHeaders('application/json', null, 'ultra');
|
|
961
1125
|
response.end(JSON.stringify(responseBody));
|
package/linkValidation.test.ts
CHANGED
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
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': {}
|