@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/util.ts CHANGED
@@ -11,6 +11,7 @@
11
11
  import {sendAlert} from './alerts.ts';
12
12
  import {issues as issueSpecs, rules as ruleSpecs} from 'testaro-issues';
13
13
  import type {Act, Catalog, Report, StandardInstance} from 'testaro';
14
+ import crypto from 'node:crypto';
14
15
  import fs from 'node:fs/promises';
15
16
  import path from 'node:path';
16
17
  import querystring from 'node:querystring';
@@ -29,6 +30,8 @@ export const testRequestsPath = (): string => path.join(jobsPath(), 'testRequest
29
30
  export const reportsPath = (): string => path.join(dbPath(), 'reports');
30
31
  // Path of the hidden-reports directory.
31
32
  export const hiddenReportsPath = (): string => path.join(dbPath(), 'hiddenReports');
33
+ // Path of the usage-metrics file.
34
+ export const metricsPath = (): string => path.join(dbPath(), 'metrics.json');
32
35
  // IDs, names, and sponsors of Testaro rule engines.
33
36
  const ruleEngines: Record<string, [string, string]> = {
34
37
  alfa: ['Alfa', 'Siteimprove'],
@@ -48,6 +51,122 @@ const ruleEngines: Record<string, [string, string]> = {
48
51
  };
49
52
  export {ruleEngines};
50
53
 
54
+ // TYPES
55
+
56
+ // Usage-metrics categories, each a map from an event name (page name, MCP tool name, or
57
+ // API operation name) to a count of how many times it has occurred. managerActivity
58
+ // counts manager-only pages separately from pageViews, by outcome, since a spike in
59
+ // failed authCode attempts against a manager page is a signal of suspected abuse.
60
+ export type Metrics = {
61
+ since: string;
62
+ pageViews: Record<string, number>;
63
+ mcpToolCalls: Record<string, number>;
64
+ apiOperations: Record<string, number>;
65
+ managerActivity: Record<string, {ok: number; error: number}>;
66
+ };
67
+
68
+ // Test request.
69
+ export type TestRequest = {
70
+ timeStamp: string;
71
+ description: string;
72
+ reason: string;
73
+ };
74
+
75
+ // Test requests by URL.
76
+ export type TestRequests = Record<string, TestRequest[]>;
77
+
78
+ // Test request addition result.
79
+ export type TestRequestResult
80
+ = 'url' | 'description' | 'retest' | 'duplicate' | 'superseded' | 'nonreport' | 'ok';
81
+
82
+ // Target of a new-test or retest request: a description and URL for a new-test request,
83
+ // or the timeStamp and jobID of the cited report for a retest request.
84
+ export type TestRequestTarget
85
+ = {description: string; url: string} | {timeStamp: string; jobID: string};
86
+
87
+ // Result of processTestRequest. description and url are absent only when the result
88
+ // is 'nonreport', i.e. when the target page could not be resolved; this is a
89
+ // discriminated union on result so that a check of result narrows their presence.
90
+ export type ProcessRequestResult
91
+ = {result: 'nonreport'} | {result: Exclude<TestRequestResult, 'nonreport'>; description: string; url: string};
92
+
93
+ // A StandardInstance extended with the issueID that Kilotest's annotateReportObject adds.
94
+ export interface AnnotatedInstance extends StandardInstance {
95
+ issueID?: string;
96
+ }
97
+
98
+ // An Act whose standardResult instances (if any) are AnnotatedInstances.
99
+ // An intersection is used because Omit<Act, 'result'> would collapse Act's
100
+ // string index signature and lose its named properties.
101
+ export type AnnotatedAct = Act & {
102
+ result?: {
103
+ nativeResult?: unknown;
104
+ standardResult?: {
105
+ instances?: AnnotatedInstance[];
106
+ [key: string]: unknown;
107
+ };
108
+ [key: string]: unknown;
109
+ };
110
+ };
111
+
112
+ // The shape of a Report that has passed isUsableReport: optional fields that
113
+ // the guard verifies are narrowed to their required/non-null forms.
114
+ // error?: never is a discriminant: UsableReport.error is always undefined/never (falsy),
115
+ // so if (report.error) narrows a UsableReport | {error: string} union to {error: string},
116
+ // and the else/after-return branch is narrowed to UsableReport.
117
+ export type UsableReport = Omit<Report, 'target' | 'catalog' | 'jobData' | 'acts'> & {
118
+ error?: never;
119
+ target: {what: string; url: string};
120
+ catalog: Catalog;
121
+ jobData: NonNullable<Report['jobData']> & {endTime: string; issuelessRules?: string[]};
122
+ acts: AnnotatedAct[];
123
+ };
124
+
125
+ // An extract of an available report.
126
+ export type ReportExtract = {
127
+ timeStamp: string;
128
+ jobID: string;
129
+ description: string;
130
+ url: string;
131
+ reportTime: string;
132
+ superseded?: boolean;
133
+ };
134
+
135
+ // Page data from an available report.
136
+ export type PageData = {
137
+ description: string;
138
+ url: string;
139
+ daysAgo: number | null;
140
+ error?: never;
141
+ };
142
+
143
+ // HTML strings describing the page data of an available report.
144
+ export type PageDataStrings = {
145
+ description: string;
146
+ url: string;
147
+ urlLink: string;
148
+ testInfo: string;
149
+ error?: never;
150
+ };
151
+
152
+ // Basics about an available report.
153
+ export type ReportData = {
154
+ description: string;
155
+ url: string;
156
+ jobName: unknown;
157
+ creationDate: Date | null;
158
+ daysAgo: number | null;
159
+ issueCount: number;
160
+ engineNames: string[];
161
+ engineCount: number;
162
+ reporterNames: string[];
163
+ reporterCount: number;
164
+ violatorCount: number;
165
+ preventedEngineNames: string[];
166
+ preventedEngineCount: number;
167
+ error?: never;
168
+ };
169
+
51
170
  // MISCELLANEOUS FUNCTIONS
52
171
 
53
172
  // Compares strings alphabetically and case-insensitively.
@@ -184,34 +303,76 @@ export const getIssue = (engineID: string, ruleID: string): string | null => {
184
303
  // Return the issue ID if a pattern matched, or a failure result otherwise.
185
304
  return variableRuleID ? variable[variableRuleID]!.issueID : null;
186
305
  };
187
- // Gets the names and categories of the job files. Missing job directories are a normal,
188
- // recoverable condition (e.g. on first run) and are created empty; any other failure to
189
- // read a job directory should never occur, so it is thrown rather than returned.
306
+ // Returns the names of the files in a directory. A missing directory is a normal,
307
+ // recoverable condition (e.g. on first run, before anything has ever been written to
308
+ // it) and is created empty; any other failure to read it should never occur, so it is
309
+ // thrown rather than returned. label identifies the directory in the thrown message.
310
+ export const readdirOrCreate = async (dirPath: string, label: string): Promise<string[]> => {
311
+ try {
312
+ return await fs.readdir(dirPath);
313
+ }
314
+ catch(error: unknown) {
315
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
316
+ await fs.mkdir(dirPath, {recursive: true});
317
+ return [];
318
+ }
319
+ throw new Error(`${label} not readable (${errorMessage(error)})`, {cause: error});
320
+ }
321
+ };
322
+ // Gets the names and categories of the job files.
190
323
  export const getJobNames = async (): Promise<{queue: string[], claimed: string[], failed: string[]}> => {
191
324
  const jobNames: {queue: string[], claimed: string[], failed: string[]} = {
192
325
  queue: [],
193
326
  claimed: [],
194
327
  failed: []
195
328
  };
196
- let fileNames: string[];
197
329
  for (const category of ['queue', 'claimed', 'failed'] as const) {
198
- const categoryPath = path.join(jobsPath(), category);
330
+ jobNames[category] = await readdirOrCreate(
331
+ path.join(jobsPath(), category), `Job directory ${category}`
332
+ );
333
+ }
334
+ return jobNames;
335
+ }
336
+ // Gets the descriptions and URLs of the pages of all jobs of a category. Uses
337
+ // getJobNames rather than reading the category directory directly, so that a
338
+ // missing directory (e.g. because no job has ever been claimed or queued yet on
339
+ // this deployment) is created empty instead of throwing ENOENT. A job file that
340
+ // disappears between the directory listing and the read of that specific file is
341
+ // likewise a normal, recoverable condition (a worker completed or reclaimed that
342
+ // job in the interim), so it is skipped rather than treated as defective.
343
+ export const getJobsData = async (category: 'queue' | 'claimed'): Promise<{description: string, url: string}[]> => {
344
+ const jobsDir = path.join(jobsPath(), category);
345
+ const jobFileNames = (await getJobNames())[category];
346
+ // For each job in the category:
347
+ const data: {description: string, url: string}[] = [];
348
+ for (const jobFileName of jobFileNames) {
349
+ const jobPath = path.join(jobsDir, jobFileName);
350
+ let jobData: string;
199
351
  try {
200
- fileNames = await fs.readdir(categoryPath);
352
+ jobData = await fs.readFile(jobPath, 'utf8');
201
353
  }
202
354
  catch(error: unknown) {
355
+ // If the job file has been removed since the directory was listed:
203
356
  if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
204
- await fs.mkdir(categoryPath, {recursive: true});
205
- fileNames = [];
206
- }
207
- else {
208
- throw new Error(`Job directory ${category} not readable (${errorMessage(error)})`, {cause: error});
357
+ // Skip it.
358
+ continue;
209
359
  }
360
+ throw new Error(`Job file ${jobPath} defective`, {cause: error});
361
+ }
362
+ try {
363
+ const job = JSON.parse(jobData);
364
+ const {target} = job;
365
+ data.push({
366
+ description: target.what,
367
+ url: target.url
368
+ });
369
+ }
370
+ catch(error: unknown) {
371
+ throw new Error(`Job file ${jobPath} defective`, {cause: error});
210
372
  }
211
- jobNames[category] = fileNames;
212
373
  }
213
- return jobNames;
214
- }
374
+ return data;
375
+ };
215
376
  // Returns the JSON stringification of an object, with a final newline.
216
377
  export const getJSON = (object: unknown): string => `${JSON.stringify(object, null, 2)}\n`;
217
378
  // Returns the message of an error, or its string representation if it is not an Error instance.
@@ -311,11 +472,9 @@ export const getPOSTData = (request: import('node:http').IncomingMessage): Promi
311
472
  }
312
473
  });
313
474
  });
314
- // Returns the waiting test and retest requests. A missing test-requests file is a
315
- // normal, recoverable condition (e.g. on first run) and is replaced with an empty one; a
316
- // present but unreadable or non-JSON file should never occur, so that failure is thrown.
317
- export const getTestRequests = async (): Promise<unknown> => {
318
- let testRequestsJSON;
475
+ // Returns the test and retest requests awaiting approval.
476
+ export const getTestRequests = async (): Promise<TestRequests> => {
477
+ let testRequestsJSON: string;
319
478
  try {
320
479
  testRequestsJSON = await fs.readFile(testRequestsPath(), 'utf8');
321
480
  }
@@ -327,10 +486,10 @@ export const getTestRequests = async (): Promise<unknown> => {
327
486
  throw new Error(`Test-requests file not readable (${errorMessage(error)})`, {cause: error});
328
487
  }
329
488
  try {
330
- return JSON.parse(testRequestsJSON);
489
+ return JSON.parse(testRequestsJSON) as TestRequests;
331
490
  }
332
491
  catch(error: unknown) {
333
- throw new Error(`Test-requests file not JSON (${errorMessage(error)})`, {cause: error});
492
+ throw new Error('Test-requests file not JSON', {cause: error});
334
493
  }
335
494
  };
336
495
  // Converts a catalog item text to a text-fragment link destination.
@@ -365,30 +524,6 @@ export const htmlSafe = (string: string): string => string ? string
365
524
  export const isJobID = (string: string): boolean => {
366
525
  return /^[a-z0-9]{3}$/.test(string);
367
526
  };
368
- // Returns whether a job to test a target is eligible for a request.
369
- export const getRequestability = async (url: string): Promise<string> => {
370
- const jobNames = await getJobNames();
371
- // For each claimed job:
372
- for (const fileName of jobNames.claimed) {
373
- const job = await getObject(path.join(jobsPath(), 'claimed', fileName));
374
- // If its URL is that of the requested target:
375
- if ((job as {target: {url: string}}).target.url === url) {
376
- // Return this.
377
- return 'claimed';
378
- }
379
- }
380
- // If no claimed job has the URL of the target, for each queued job:
381
- for (const fileName of jobNames.queue) {
382
- const job = await getObject(path.join(jobsPath(), 'queue', fileName));
383
- // If its URL is that of the requested target:
384
- if ((job as {target: {url: string}}).target.url === url) {
385
- // Return this.
386
- return 'queued';
387
- }
388
- }
389
- // If no claimed or queued job has the URL of the target, return this.
390
- return '';
391
- };
392
527
  // Returns whether a string is a time stamp.
393
528
  export const isTimeStamp = (string: string): boolean => {
394
529
  return !!getDateString(string);
@@ -430,79 +565,8 @@ export const objectSort = <T extends Record<string, unknown>>(
430
565
  // Otherwise, do not sort.
431
566
  return 0;
432
567
  });
433
- // Processes a test or retest request in the UI.
434
- export const processTestRequest = async (testType: string, dirName: string, description: string, url: string, why: string): Promise<{status: string, message?: string, answerPage?: string}> => {
435
- // If the request is valid:
436
- if (
437
- ['test', 'retest'].includes(testType)
438
- && ['Test', 'Retest'].some(end => dirName.endsWith(end))
439
- && description
440
- && isURL(url)
441
- && why.length > 4
442
- ) {
443
- // Make the reason display-safe.
444
- const plainWhy = getPlainText(why);
445
- // Update the waiting test requests as a transaction.
446
- const updateResult = await addTestRequest(description, url, plainWhy);
447
- // If the request was a duplicate:
448
- if (updateResult.error === 'duplicate') {
449
- // Return this.
450
- return {
451
- status: 'error',
452
- message: 'Duplicate request'
453
- };
454
- }
455
- // Otherwise, i.e. if it was not a duplicate:
456
- else {
457
- // Log the request.
458
- console.log(`Test request received for ${description}: ${plainWhy}`);
459
- // Alert a manager about it.
460
- await sendAlert(
461
- `Kilotest: new ${testType} request in the UI`,
462
- `Target: ${description}\nURL: ${url}\nReason: ${plainWhy}`
463
- );
464
- // Get the populated template.
465
- const answerPage = await populateTemplate(dirName, {
466
- target: description,
467
- why: plainWhy
468
- });
469
- // Return the populated page.
470
- return {
471
- status: 'ok',
472
- answerPage
473
- };
474
- }
475
- }
476
- return {
477
- status: 'error',
478
- message: 'Invalid request'
479
- };
480
- };
481
568
  // Concurrency lock for the `testRequests.json` file.
482
569
  export const testRequestsLock = createLock();
483
- // Adds a test request as a transaction.
484
- export const addTestRequest = (description: string, url: string, why: string) => testRequestsLock(async (): Promise<{error?: string}> => {
485
- // Get the data on waiting test requests.
486
- const testRequests = await getTestRequests() as Record<string, {description: string, why: string, timeStamp: string}[]>;
487
- testRequests[url] ??= [];
488
- // If any request has the same description and URL:
489
- if (testRequests[url].some(req => req.description === description)) {
490
- // Return this.
491
- return {
492
- error: 'duplicate'
493
- };
494
- }
495
- // Otherwise, i.e. if the request is not a duplicate, add it to those for the target.
496
- testRequests[url].push({
497
- timeStamp: getNowStamp(),
498
- description,
499
- why
500
- });
501
- // Save the revised test requests.
502
- await fs.writeFile(testRequestsPath(), getJSON(testRequests));
503
- // Return success.
504
- return {};
505
- });
506
570
  // Deletes the test requests for a URL as a transaction.
507
571
  export const deleteTestRequests = (url: string) => testRequestsLock(async (): Promise<void> => {
508
572
  // Get the test requests.
@@ -513,85 +577,6 @@ export const deleteTestRequests = (url: string) => testRequestsLock(async (): Pr
513
577
  await fs.writeFile(testRequestsPath(), getJSON(testRequests));
514
578
  });
515
579
 
516
- // REPORT TYPES
517
-
518
- // A StandardInstance extended with the issueID that Kilotest's annotateReportObject adds.
519
- export interface AnnotatedInstance extends StandardInstance {
520
- issueID?: string;
521
- }
522
-
523
- // An Act whose standardResult instances (if any) are AnnotatedInstances.
524
- // An intersection is used because Omit<Act, 'result'> would collapse Act's
525
- // string index signature and lose its named properties.
526
- export type AnnotatedAct = Act & {
527
- result?: {
528
- nativeResult?: unknown;
529
- standardResult?: {
530
- instances?: AnnotatedInstance[];
531
- [key: string]: unknown;
532
- };
533
- [key: string]: unknown;
534
- };
535
- };
536
-
537
- // The shape of a Report that has passed isUsableReport: optional fields that
538
- // the guard verifies are narrowed to their required/non-null forms.
539
- // error?: never is a discriminant: UsableReport.error is always undefined/never (falsy),
540
- // so if (report.error) narrows a UsableReport | {error: string} union to {error: string},
541
- // and the else/after-return branch is narrowed to UsableReport.
542
- export type UsableReport = Omit<Report, 'target' | 'catalog' | 'jobData' | 'acts'> & {
543
- error?: never;
544
- target: {what: string; url: string};
545
- catalog: Catalog;
546
- jobData: NonNullable<Report['jobData']> & {endTime: string; issuelessRules?: string[]};
547
- acts: AnnotatedAct[];
548
- };
549
-
550
- // An extract of an available report.
551
- export type ReportExtract = {
552
- timeStamp: string;
553
- jobID: string;
554
- description: string;
555
- url: string;
556
- reportTime: string;
557
- superseded?: boolean;
558
- };
559
-
560
- // Page data from an available report.
561
- export type PageData = {
562
- description: string;
563
- url: string;
564
- daysAgo: number | null;
565
- error?: never;
566
- };
567
-
568
- // HTML strings describing the page data of an available report.
569
- export type PageDataStrings = {
570
- description: string;
571
- url: string;
572
- urlLink: string;
573
- testInfo: string;
574
- error?: never;
575
- };
576
-
577
- // Basics about an available report.
578
- export type ReportData = {
579
- description: string;
580
- url: string;
581
- jobName: unknown;
582
- creationDate: Date | null;
583
- daysAgo: number | null;
584
- issueCount: number;
585
- engineNames: string[];
586
- engineCount: number;
587
- reporterNames: string[];
588
- reporterCount: number;
589
- violatorCount: number;
590
- preventedEngineNames: string[];
591
- preventedEngineCount: number;
592
- error?: never;
593
- };
594
-
595
580
  // REPORT FUNCTIONS
596
581
 
597
582
  // Returns the test acts of a report.
@@ -876,7 +861,7 @@ export const getReportExtract = async (timeStamp: string, jobID: string): Promis
876
861
  // Returns extracts of all available reports.
877
862
  export const getReportExtracts = async (onlyLatest: boolean = false): Promise<ReportExtract[]> => {
878
863
  // Get the names of the available report files.
879
- const reportFileNames = await fs.readdir(reportsPath());
864
+ const reportFileNames = await readdirOrCreate(reportsPath(), 'Reports directory');
880
865
  // Initialize an array of extracts.
881
866
  const extracts: ReportExtract[] = [];
882
867
  // For each one:
@@ -919,3 +904,211 @@ export const getMultiReportWhats = async (): Promise<string[]> => {
919
904
  );
920
905
  return multiReportDescriptions;
921
906
  };
907
+ // Returns the property that an approved job in a category has.
908
+ const getApprovedJobProperty = async (
909
+ description: string, url: string, category: 'claimed' | 'queue'
910
+ ): Promise<'description' | 'url' | ''> => {
911
+ try {
912
+ // Get the descriptions and URLs of all jobs in the category.
913
+ const jobsData = await getJobsData(category);
914
+ // If a job with the description is in the category:
915
+ if (jobsData.some(job => job.description === description)) {
916
+ // Return this.
917
+ return 'description';
918
+ }
919
+ // Otherwise, if a job with the URL is in the category:
920
+ if (jobsData.some(job => job.url === url)) {
921
+ // Return this.
922
+ return 'url';
923
+ }
924
+ // Otherwise, return this.
925
+ return '';
926
+ }
927
+ catch(error) {
928
+ throw new Error('Failed to get approved job property', {cause: error});
929
+ }
930
+ };
931
+ // Processes a new-test or retest request as a transaction and returns the result.
932
+ // For a retest request (target has a timeStamp and jobID), resolves the description
933
+ // and URL of the cited report itself, so a caller never needs its own
934
+ // getReportExtract call. Approvability (whether the request may proceed) and, if so,
935
+ // its addition to testRequests.json are both decided inside the lock, as a single
936
+ // atomic operation, so no caller-visible seam exists where an approvability decision
937
+ // could be based on stale information about testRequests.json.
938
+ export const processTestRequest = (
939
+ reason: string,
940
+ target: TestRequestTarget
941
+ ): Promise<ProcessRequestResult> => testRequestsLock(async (): Promise<ProcessRequestResult> => {
942
+ let requestType: 'test' | 'retest';
943
+ let description: string;
944
+ let url: string;
945
+ // Whether the cited report (for a retest) has been superseded by a later one.
946
+ let superseded = false;
947
+ // If the target identifies a report to retest:
948
+ if ('timeStamp' in target) {
949
+ requestType = 'retest';
950
+ const {timeStamp, jobID} = target;
951
+ // Get the cited report.
952
+ const extract = await getReportExtract(timeStamp, jobID);
953
+ // If it does not exist:
954
+ if ('error' in extract) {
955
+ // Return this.
956
+ return {result: 'nonreport'};
957
+ }
958
+ ({description, url} = extract);
959
+ // Get whether it has been superseded by a later report.
960
+ const reportExtracts = await getReportExtracts();
961
+ superseded = reportExtracts.some(
962
+ ex => ex.timeStamp === timeStamp && ex.jobID === jobID && ex.superseded
963
+ );
964
+ }
965
+ // Otherwise, i.e. if the target is a page to test for the first time:
966
+ else {
967
+ requestType = 'test';
968
+ ({description, url} = target);
969
+ }
970
+ try {
971
+ // Get any property the page shares with a claimed job.
972
+ const claimedJobProperty = await getApprovedJobProperty(description, url, 'claimed');
973
+ // Get any property the page shares with a queued job.
974
+ const queueJobProperty = await getApprovedJobProperty(description, url, 'queue');
975
+ // If the page shares a property with a claimed or queued job:
976
+ if (claimedJobProperty || queueJobProperty) {
977
+ // Return the property.
978
+ return {
979
+ result: (claimedJobProperty || queueJobProperty) as 'description' | 'url',
980
+ description,
981
+ url
982
+ };
983
+ }
984
+ // Otherwise, get the requests awaiting approval.
985
+ const requests = await getTestRequests() as Record<string, {
986
+ description: string, reason: string, timeStamp: string
987
+ }[]>;
988
+ // If the request has the same URL, description and reason as an existing request:
989
+ if (requests[url]?.some(
990
+ request => request.description === description && request.reason === reason
991
+ )) {
992
+ // Return this.
993
+ return {result: 'duplicate', description, url};
994
+ }
995
+ // If the request is to retest a page that has been superseded:
996
+ if (requestType === 'retest' && superseded) {
997
+ // Return this.
998
+ return {result: 'superseded', description, url};
999
+ }
1000
+ // Otherwise, if the request is to test a new page for which a report already exists:
1001
+ if (requestType === 'test') {
1002
+ const reportExtracts = await getReportExtracts();
1003
+ if (reportExtracts.some(report => report.description === description && report.url === url)) {
1004
+ // Return this.
1005
+ return {result: 'retest', description, url};
1006
+ }
1007
+ }
1008
+ // The request is eligible, so get the requests awaiting approval.
1009
+ const testRequests = await getTestRequests();
1010
+ // Add the request to them, initializing them with the URL if necessary.
1011
+ (testRequests[url] ??= []).push({
1012
+ timeStamp: getNowStamp(),
1013
+ description,
1014
+ reason
1015
+ });
1016
+ // Save the revised test requests.
1017
+ await fs.writeFile(testRequestsPath(), getJSON(testRequests));
1018
+ // Get an email-safe version of the reason.
1019
+ const plainReason = getPlainText(reason);
1020
+ // Alert a manager.
1021
+ await sendAlert(
1022
+ `Kilotest: new ${requestType} request awaits approval`,
1023
+ `Page description: ${description}\nURL: ${url}\nReason: ${plainReason}`
1024
+ );
1025
+ // Return success.
1026
+ return {result: 'ok', description, url};
1027
+ // If an error occurred:
1028
+ } catch(error) {
1029
+ // Throw it.
1030
+ throw new Error('Failed to process test request', {cause: error});
1031
+ }
1032
+ });
1033
+
1034
+ // METRICS FUNCTIONS
1035
+
1036
+ // Name of the cookie that excludes a browser's requests from usage metrics.
1037
+ export const metricsExclusionCookieName = 'kilotestExclude';
1038
+ // Returns the value that the metrics-exclusion cookie must have to be honored: a SHA-256
1039
+ // hash of AUTH_CODE, not AUTH_CODE itself, so the secret is never placed in a long-lived
1040
+ // browser cookie, and not a fixed value, since Kilotest's source is public and a fixed
1041
+ // value would let anyone read it and exclude themselves from metrics at no cost.
1042
+ export const getExclusionCookieValue = (): string =>
1043
+ crypto.createHash('sha256').update(process.env.AUTH_CODE ?? '').digest('hex');
1044
+ // Concurrency lock for the `metrics.json` file.
1045
+ const metricsLock = createLock();
1046
+ // Returns the usage metrics, creating the file with zeroed counts if it does not yet exist.
1047
+ export const getMetrics = async (): Promise<Metrics> => {
1048
+ let metricsJSON: string;
1049
+ try {
1050
+ metricsJSON = await fs.readFile(metricsPath(), 'utf8');
1051
+ }
1052
+ catch(error: unknown) {
1053
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
1054
+ const metrics: Metrics = {
1055
+ since: getNowStamp(), pageViews: {}, mcpToolCalls: {}, apiOperations: {}, managerActivity: {}
1056
+ };
1057
+ await fs.writeFile(metricsPath(), getJSON(metrics));
1058
+ return metrics;
1059
+ }
1060
+ throw new Error(`Metrics file not readable (${errorMessage(error)})`, {cause: error});
1061
+ }
1062
+ let metrics: Partial<Metrics>;
1063
+ try {
1064
+ metrics = JSON.parse(metricsJSON) as Partial<Metrics>;
1065
+ }
1066
+ catch(error: unknown) {
1067
+ throw new Error(`Metrics file not JSON (${errorMessage(error)})`, {cause: error});
1068
+ }
1069
+ // Backfill any category absent from a file written before that category existed,
1070
+ // so an older metrics.json (e.g. from before a schema change) remains readable.
1071
+ return {
1072
+ since: metrics.since ?? getNowStamp(),
1073
+ pageViews: metrics.pageViews ?? {},
1074
+ mcpToolCalls: metrics.mcpToolCalls ?? {},
1075
+ apiOperations: metrics.apiOperations ?? {},
1076
+ managerActivity: metrics.managerActivity ?? {}
1077
+ };
1078
+ };
1079
+ // Records an occurrence of a named event (a web page view, an MCP tool call, or an API
1080
+ // operation call) in the usage metrics, creating the category and name if not yet present.
1081
+ export function recordMetric(
1082
+ category: 'pageViews' | 'mcpToolCalls' | 'apiOperations', name: string
1083
+ ): Promise<void>;
1084
+ // Records an outcome of a manager-page visit or submission (a distinct category from
1085
+ // pageViews, so a spike in failed authCode attempts against a manager page is visible
1086
+ // as a signal of suspected abuse, rather than being folded into ordinary page views).
1087
+ export function recordMetric(
1088
+ category: 'managerActivity', name: string, outcome: 'ok' | 'error'
1089
+ ): Promise<void>;
1090
+ export function recordMetric(
1091
+ category: 'pageViews' | 'mcpToolCalls' | 'apiOperations' | 'managerActivity',
1092
+ name: string,
1093
+ outcome?: 'ok' | 'error'
1094
+ ): Promise<void> {
1095
+ return metricsLock(async (): Promise<void> => {
1096
+ const metrics = await getMetrics();
1097
+ if (category === 'managerActivity') {
1098
+ const entry = metrics.managerActivity[name] ??= {ok: 0, error: 0};
1099
+ entry[outcome!]++;
1100
+ }
1101
+ else {
1102
+ metrics[category][name] = (metrics[category][name] ?? 0) + 1;
1103
+ }
1104
+ await fs.writeFile(metricsPath(), getJSON(metrics));
1105
+ });
1106
+ }
1107
+ // Resets all usage metrics to empty and since to now.
1108
+ export const clearMetrics = (): Promise<Metrics> => metricsLock(async (): Promise<Metrics> => {
1109
+ const metrics: Metrics = {
1110
+ since: getNowStamp(), pageViews: {}, mcpToolCalls: {}, apiOperations: {}, managerActivity: {}
1111
+ };
1112
+ await fs.writeFile(metricsPath(), getJSON(metrics));
1113
+ return metrics;
1114
+ });