@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/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
|
-
//
|
|
188
|
-
// recoverable condition (e.g. on first run
|
|
189
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
205
|
-
|
|
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
|
|
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
|
|
315
|
-
|
|
316
|
-
|
|
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(
|
|
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
|
|
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
|
+
});
|