@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.
- package/.claude/scheduled_tasks.lock +1 -0
- package/api/requestFeature.test.ts +4 -2
- package/api/requestFeature.ts +1 -1
- package/api/requestRetest.test.ts +66 -7
- package/api/requestRetest.ts +55 -39
- package/api/requestTest.test.ts +63 -7
- package/api/requestTest.ts +36 -26
- package/api/util.test.ts +6 -5
- package/api/util.ts +2 -24
- package/docs/BACKLOG.md +228 -16
- package/docs/SERVICE.md +84 -7
- package/index.test.ts +354 -25
- package/index.ts +174 -8
- 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 +426 -127
- package/util.ts +389 -196
- 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/listReports/index.ts +33 -12
- package/web/listReports/listReports.test.ts +1 -1
- 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 +7 -3
- 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 +12 -7
- package/web/requestRetest/index.html +3 -3
- package/web/requestRetest/index.ts +47 -16
- package/web/requestRetest/requestRetest.test.ts +38 -24
- package/web/requestTest/index.html +4 -4
- package/web/requestTest/index.ts +37 -9
- package/web/requestTest/requestTest.test.ts +21 -6
- 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 +11 -8
- 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,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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
787
|
-
|
|
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));
|
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': {}
|