@jrpool/kilotest 47.0.1 → 48.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/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 +12 -8
- package/docs/SERVICE.md +32 -6
- package/index.test.ts +20 -12
- package/index.ts +4 -2
- package/package.json +1 -1
- package/util.test.ts +195 -127
- package/util.ts +292 -196
- package/web/listReports/index.ts +33 -12
- package/web/listReports/listReports.test.ts +1 -1
- package/web/reannotate/index.ts +2 -1
- package/web/reportDeletion.ts +2 -2
- 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/unhideReportForm/index.ts +2 -2
package/util.ts
CHANGED
|
@@ -48,6 +48,110 @@ const ruleEngines: Record<string, [string, string]> = {
|
|
|
48
48
|
};
|
|
49
49
|
export {ruleEngines};
|
|
50
50
|
|
|
51
|
+
// TYPES
|
|
52
|
+
|
|
53
|
+
// Test request.
|
|
54
|
+
export type TestRequest = {
|
|
55
|
+
timeStamp: string;
|
|
56
|
+
description: string;
|
|
57
|
+
reason: string;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// Test requests by URL.
|
|
61
|
+
export type TestRequests = Record<string, TestRequest[]>;
|
|
62
|
+
|
|
63
|
+
// Test request addition result.
|
|
64
|
+
export type TestRequestResult
|
|
65
|
+
= 'url' | 'description' | 'retest' | 'duplicate' | 'superseded' | 'nonreport' | 'ok';
|
|
66
|
+
|
|
67
|
+
// Target of a new-test or retest request: a description and URL for a new-test request,
|
|
68
|
+
// or the timeStamp and jobID of the cited report for a retest request.
|
|
69
|
+
export type TestRequestTarget
|
|
70
|
+
= {description: string; url: string} | {timeStamp: string; jobID: string};
|
|
71
|
+
|
|
72
|
+
// Result of processTestRequest. description and url are absent only when the result
|
|
73
|
+
// is 'nonreport', i.e. when the target page could not be resolved; this is a
|
|
74
|
+
// discriminated union on result so that a check of result narrows their presence.
|
|
75
|
+
export type ProcessRequestResult
|
|
76
|
+
= {result: 'nonreport'} | {result: Exclude<TestRequestResult, 'nonreport'>; description: string; url: string};
|
|
77
|
+
|
|
78
|
+
// A StandardInstance extended with the issueID that Kilotest's annotateReportObject adds.
|
|
79
|
+
export interface AnnotatedInstance extends StandardInstance {
|
|
80
|
+
issueID?: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// An Act whose standardResult instances (if any) are AnnotatedInstances.
|
|
84
|
+
// An intersection is used because Omit<Act, 'result'> would collapse Act's
|
|
85
|
+
// string index signature and lose its named properties.
|
|
86
|
+
export type AnnotatedAct = Act & {
|
|
87
|
+
result?: {
|
|
88
|
+
nativeResult?: unknown;
|
|
89
|
+
standardResult?: {
|
|
90
|
+
instances?: AnnotatedInstance[];
|
|
91
|
+
[key: string]: unknown;
|
|
92
|
+
};
|
|
93
|
+
[key: string]: unknown;
|
|
94
|
+
};
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
// The shape of a Report that has passed isUsableReport: optional fields that
|
|
98
|
+
// the guard verifies are narrowed to their required/non-null forms.
|
|
99
|
+
// error?: never is a discriminant: UsableReport.error is always undefined/never (falsy),
|
|
100
|
+
// so if (report.error) narrows a UsableReport | {error: string} union to {error: string},
|
|
101
|
+
// and the else/after-return branch is narrowed to UsableReport.
|
|
102
|
+
export type UsableReport = Omit<Report, 'target' | 'catalog' | 'jobData' | 'acts'> & {
|
|
103
|
+
error?: never;
|
|
104
|
+
target: {what: string; url: string};
|
|
105
|
+
catalog: Catalog;
|
|
106
|
+
jobData: NonNullable<Report['jobData']> & {endTime: string; issuelessRules?: string[]};
|
|
107
|
+
acts: AnnotatedAct[];
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
// An extract of an available report.
|
|
111
|
+
export type ReportExtract = {
|
|
112
|
+
timeStamp: string;
|
|
113
|
+
jobID: string;
|
|
114
|
+
description: string;
|
|
115
|
+
url: string;
|
|
116
|
+
reportTime: string;
|
|
117
|
+
superseded?: boolean;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
// Page data from an available report.
|
|
121
|
+
export type PageData = {
|
|
122
|
+
description: string;
|
|
123
|
+
url: string;
|
|
124
|
+
daysAgo: number | null;
|
|
125
|
+
error?: never;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
// HTML strings describing the page data of an available report.
|
|
129
|
+
export type PageDataStrings = {
|
|
130
|
+
description: string;
|
|
131
|
+
url: string;
|
|
132
|
+
urlLink: string;
|
|
133
|
+
testInfo: string;
|
|
134
|
+
error?: never;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
// Basics about an available report.
|
|
138
|
+
export type ReportData = {
|
|
139
|
+
description: string;
|
|
140
|
+
url: string;
|
|
141
|
+
jobName: unknown;
|
|
142
|
+
creationDate: Date | null;
|
|
143
|
+
daysAgo: number | null;
|
|
144
|
+
issueCount: number;
|
|
145
|
+
engineNames: string[];
|
|
146
|
+
engineCount: number;
|
|
147
|
+
reporterNames: string[];
|
|
148
|
+
reporterCount: number;
|
|
149
|
+
violatorCount: number;
|
|
150
|
+
preventedEngineNames: string[];
|
|
151
|
+
preventedEngineCount: number;
|
|
152
|
+
error?: never;
|
|
153
|
+
};
|
|
154
|
+
|
|
51
155
|
// MISCELLANEOUS FUNCTIONS
|
|
52
156
|
|
|
53
157
|
// Compares strings alphabetically and case-insensitively.
|
|
@@ -184,34 +288,76 @@ export const getIssue = (engineID: string, ruleID: string): string | null => {
|
|
|
184
288
|
// Return the issue ID if a pattern matched, or a failure result otherwise.
|
|
185
289
|
return variableRuleID ? variable[variableRuleID]!.issueID : null;
|
|
186
290
|
};
|
|
187
|
-
//
|
|
188
|
-
// recoverable condition (e.g. on first run
|
|
189
|
-
//
|
|
291
|
+
// Returns the names of the files in a directory. A missing directory is a normal,
|
|
292
|
+
// recoverable condition (e.g. on first run, before anything has ever been written to
|
|
293
|
+
// it) and is created empty; any other failure to read it should never occur, so it is
|
|
294
|
+
// thrown rather than returned. label identifies the directory in the thrown message.
|
|
295
|
+
export const readdirOrCreate = async (dirPath: string, label: string): Promise<string[]> => {
|
|
296
|
+
try {
|
|
297
|
+
return await fs.readdir(dirPath);
|
|
298
|
+
}
|
|
299
|
+
catch(error: unknown) {
|
|
300
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
301
|
+
await fs.mkdir(dirPath, {recursive: true});
|
|
302
|
+
return [];
|
|
303
|
+
}
|
|
304
|
+
throw new Error(`${label} not readable (${errorMessage(error)})`, {cause: error});
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
// Gets the names and categories of the job files.
|
|
190
308
|
export const getJobNames = async (): Promise<{queue: string[], claimed: string[], failed: string[]}> => {
|
|
191
309
|
const jobNames: {queue: string[], claimed: string[], failed: string[]} = {
|
|
192
310
|
queue: [],
|
|
193
311
|
claimed: [],
|
|
194
312
|
failed: []
|
|
195
313
|
};
|
|
196
|
-
let fileNames: string[];
|
|
197
314
|
for (const category of ['queue', 'claimed', 'failed'] as const) {
|
|
198
|
-
|
|
315
|
+
jobNames[category] = await readdirOrCreate(
|
|
316
|
+
path.join(jobsPath(), category), `Job directory ${category}`
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
return jobNames;
|
|
320
|
+
}
|
|
321
|
+
// Gets the descriptions and URLs of the pages of all jobs of a category. Uses
|
|
322
|
+
// getJobNames rather than reading the category directory directly, so that a
|
|
323
|
+
// missing directory (e.g. because no job has ever been claimed or queued yet on
|
|
324
|
+
// this deployment) is created empty instead of throwing ENOENT. A job file that
|
|
325
|
+
// disappears between the directory listing and the read of that specific file is
|
|
326
|
+
// likewise a normal, recoverable condition (a worker completed or reclaimed that
|
|
327
|
+
// job in the interim), so it is skipped rather than treated as defective.
|
|
328
|
+
export const getJobsData = async (category: 'queue' | 'claimed'): Promise<{description: string, url: string}[]> => {
|
|
329
|
+
const jobsDir = path.join(jobsPath(), category);
|
|
330
|
+
const jobFileNames = (await getJobNames())[category];
|
|
331
|
+
// For each job in the category:
|
|
332
|
+
const data: {description: string, url: string}[] = [];
|
|
333
|
+
for (const jobFileName of jobFileNames) {
|
|
334
|
+
const jobPath = path.join(jobsDir, jobFileName);
|
|
335
|
+
let jobData: string;
|
|
199
336
|
try {
|
|
200
|
-
|
|
337
|
+
jobData = await fs.readFile(jobPath, 'utf8');
|
|
201
338
|
}
|
|
202
339
|
catch(error: unknown) {
|
|
340
|
+
// If the job file has been removed since the directory was listed:
|
|
203
341
|
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});
|
|
342
|
+
// Skip it.
|
|
343
|
+
continue;
|
|
209
344
|
}
|
|
345
|
+
throw new Error(`Job file ${jobPath} defective`, {cause: error});
|
|
346
|
+
}
|
|
347
|
+
try {
|
|
348
|
+
const job = JSON.parse(jobData);
|
|
349
|
+
const {target} = job;
|
|
350
|
+
data.push({
|
|
351
|
+
description: target.what,
|
|
352
|
+
url: target.url
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
catch(error: unknown) {
|
|
356
|
+
throw new Error(`Job file ${jobPath} defective`, {cause: error});
|
|
210
357
|
}
|
|
211
|
-
jobNames[category] = fileNames;
|
|
212
358
|
}
|
|
213
|
-
return
|
|
214
|
-
}
|
|
359
|
+
return data;
|
|
360
|
+
};
|
|
215
361
|
// Returns the JSON stringification of an object, with a final newline.
|
|
216
362
|
export const getJSON = (object: unknown): string => `${JSON.stringify(object, null, 2)}\n`;
|
|
217
363
|
// Returns the message of an error, or its string representation if it is not an Error instance.
|
|
@@ -311,11 +457,9 @@ export const getPOSTData = (request: import('node:http').IncomingMessage): Promi
|
|
|
311
457
|
}
|
|
312
458
|
});
|
|
313
459
|
});
|
|
314
|
-
// Returns the
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
export const getTestRequests = async (): Promise<unknown> => {
|
|
318
|
-
let testRequestsJSON;
|
|
460
|
+
// Returns the test and retest requests awaiting approval.
|
|
461
|
+
export const getTestRequests = async (): Promise<TestRequests> => {
|
|
462
|
+
let testRequestsJSON: string;
|
|
319
463
|
try {
|
|
320
464
|
testRequestsJSON = await fs.readFile(testRequestsPath(), 'utf8');
|
|
321
465
|
}
|
|
@@ -327,10 +471,10 @@ export const getTestRequests = async (): Promise<unknown> => {
|
|
|
327
471
|
throw new Error(`Test-requests file not readable (${errorMessage(error)})`, {cause: error});
|
|
328
472
|
}
|
|
329
473
|
try {
|
|
330
|
-
return JSON.parse(testRequestsJSON);
|
|
474
|
+
return JSON.parse(testRequestsJSON) as TestRequests;
|
|
331
475
|
}
|
|
332
476
|
catch(error: unknown) {
|
|
333
|
-
throw new Error(
|
|
477
|
+
throw new Error('Test-requests file not JSON', {cause: error});
|
|
334
478
|
}
|
|
335
479
|
};
|
|
336
480
|
// Converts a catalog item text to a text-fragment link destination.
|
|
@@ -365,30 +509,6 @@ export const htmlSafe = (string: string): string => string ? string
|
|
|
365
509
|
export const isJobID = (string: string): boolean => {
|
|
366
510
|
return /^[a-z0-9]{3}$/.test(string);
|
|
367
511
|
};
|
|
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
512
|
// Returns whether a string is a time stamp.
|
|
393
513
|
export const isTimeStamp = (string: string): boolean => {
|
|
394
514
|
return !!getDateString(string);
|
|
@@ -430,79 +550,8 @@ export const objectSort = <T extends Record<string, unknown>>(
|
|
|
430
550
|
// Otherwise, do not sort.
|
|
431
551
|
return 0;
|
|
432
552
|
});
|
|
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
553
|
// Concurrency lock for the `testRequests.json` file.
|
|
482
554
|
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
555
|
// Deletes the test requests for a URL as a transaction.
|
|
507
556
|
export const deleteTestRequests = (url: string) => testRequestsLock(async (): Promise<void> => {
|
|
508
557
|
// Get the test requests.
|
|
@@ -513,85 +562,6 @@ export const deleteTestRequests = (url: string) => testRequestsLock(async (): Pr
|
|
|
513
562
|
await fs.writeFile(testRequestsPath(), getJSON(testRequests));
|
|
514
563
|
});
|
|
515
564
|
|
|
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
565
|
// REPORT FUNCTIONS
|
|
596
566
|
|
|
597
567
|
// Returns the test acts of a report.
|
|
@@ -876,7 +846,7 @@ export const getReportExtract = async (timeStamp: string, jobID: string): Promis
|
|
|
876
846
|
// Returns extracts of all available reports.
|
|
877
847
|
export const getReportExtracts = async (onlyLatest: boolean = false): Promise<ReportExtract[]> => {
|
|
878
848
|
// Get the names of the available report files.
|
|
879
|
-
const reportFileNames = await
|
|
849
|
+
const reportFileNames = await readdirOrCreate(reportsPath(), 'Reports directory');
|
|
880
850
|
// Initialize an array of extracts.
|
|
881
851
|
const extracts: ReportExtract[] = [];
|
|
882
852
|
// For each one:
|
|
@@ -919,3 +889,129 @@ export const getMultiReportWhats = async (): Promise<string[]> => {
|
|
|
919
889
|
);
|
|
920
890
|
return multiReportDescriptions;
|
|
921
891
|
};
|
|
892
|
+
// Returns the property that an approved job in a category has.
|
|
893
|
+
const getApprovedJobProperty = async (
|
|
894
|
+
description: string, url: string, category: 'claimed' | 'queue'
|
|
895
|
+
): Promise<'description' | 'url' | ''> => {
|
|
896
|
+
try {
|
|
897
|
+
// Get the descriptions and URLs of all jobs in the category.
|
|
898
|
+
const jobsData = await getJobsData(category);
|
|
899
|
+
// If a job with the description is in the category:
|
|
900
|
+
if (jobsData.some(job => job.description === description)) {
|
|
901
|
+
// Return this.
|
|
902
|
+
return 'description';
|
|
903
|
+
}
|
|
904
|
+
// Otherwise, if a job with the URL is in the category:
|
|
905
|
+
if (jobsData.some(job => job.url === url)) {
|
|
906
|
+
// Return this.
|
|
907
|
+
return 'url';
|
|
908
|
+
}
|
|
909
|
+
// Otherwise, return this.
|
|
910
|
+
return '';
|
|
911
|
+
}
|
|
912
|
+
catch(error) {
|
|
913
|
+
throw new Error('Failed to get approved job property', {cause: error});
|
|
914
|
+
}
|
|
915
|
+
};
|
|
916
|
+
// Processes a new-test or retest request as a transaction and returns the result.
|
|
917
|
+
// For a retest request (target has a timeStamp and jobID), resolves the description
|
|
918
|
+
// and URL of the cited report itself, so a caller never needs its own
|
|
919
|
+
// getReportExtract call. Approvability (whether the request may proceed) and, if so,
|
|
920
|
+
// its addition to testRequests.json are both decided inside the lock, as a single
|
|
921
|
+
// atomic operation, so no caller-visible seam exists where an approvability decision
|
|
922
|
+
// could be based on stale information about testRequests.json.
|
|
923
|
+
export const processTestRequest = (
|
|
924
|
+
reason: string,
|
|
925
|
+
target: TestRequestTarget
|
|
926
|
+
): Promise<ProcessRequestResult> => testRequestsLock(async (): Promise<ProcessRequestResult> => {
|
|
927
|
+
let requestType: 'test' | 'retest';
|
|
928
|
+
let description: string;
|
|
929
|
+
let url: string;
|
|
930
|
+
// Whether the cited report (for a retest) has been superseded by a later one.
|
|
931
|
+
let superseded = false;
|
|
932
|
+
// If the target identifies a report to retest:
|
|
933
|
+
if ('timeStamp' in target) {
|
|
934
|
+
requestType = 'retest';
|
|
935
|
+
const {timeStamp, jobID} = target;
|
|
936
|
+
// Get the cited report.
|
|
937
|
+
const extract = await getReportExtract(timeStamp, jobID);
|
|
938
|
+
// If it does not exist:
|
|
939
|
+
if ('error' in extract) {
|
|
940
|
+
// Return this.
|
|
941
|
+
return {result: 'nonreport'};
|
|
942
|
+
}
|
|
943
|
+
({description, url} = extract);
|
|
944
|
+
// Get whether it has been superseded by a later report.
|
|
945
|
+
const reportExtracts = await getReportExtracts();
|
|
946
|
+
superseded = reportExtracts.some(
|
|
947
|
+
ex => ex.timeStamp === timeStamp && ex.jobID === jobID && ex.superseded
|
|
948
|
+
);
|
|
949
|
+
}
|
|
950
|
+
// Otherwise, i.e. if the target is a page to test for the first time:
|
|
951
|
+
else {
|
|
952
|
+
requestType = 'test';
|
|
953
|
+
({description, url} = target);
|
|
954
|
+
}
|
|
955
|
+
try {
|
|
956
|
+
// Get any property the page shares with a claimed job.
|
|
957
|
+
const claimedJobProperty = await getApprovedJobProperty(description, url, 'claimed');
|
|
958
|
+
// Get any property the page shares with a queued job.
|
|
959
|
+
const queueJobProperty = await getApprovedJobProperty(description, url, 'queue');
|
|
960
|
+
// If the page shares a property with a claimed or queued job:
|
|
961
|
+
if (claimedJobProperty || queueJobProperty) {
|
|
962
|
+
// Return the property.
|
|
963
|
+
return {
|
|
964
|
+
result: (claimedJobProperty || queueJobProperty) as 'description' | 'url',
|
|
965
|
+
description,
|
|
966
|
+
url
|
|
967
|
+
};
|
|
968
|
+
}
|
|
969
|
+
// Otherwise, get the requests awaiting approval.
|
|
970
|
+
const requests = await getTestRequests() as Record<string, {
|
|
971
|
+
description: string, reason: string, timeStamp: string
|
|
972
|
+
}[]>;
|
|
973
|
+
// If the request has the same URL, description and reason as an existing request:
|
|
974
|
+
if (requests[url]?.some(
|
|
975
|
+
request => request.description === description && request.reason === reason
|
|
976
|
+
)) {
|
|
977
|
+
// Return this.
|
|
978
|
+
return {result: 'duplicate', description, url};
|
|
979
|
+
}
|
|
980
|
+
// If the request is to retest a page that has been superseded:
|
|
981
|
+
if (requestType === 'retest' && superseded) {
|
|
982
|
+
// Return this.
|
|
983
|
+
return {result: 'superseded', description, url};
|
|
984
|
+
}
|
|
985
|
+
// Otherwise, if the request is to test a new page for which a report already exists:
|
|
986
|
+
if (requestType === 'test') {
|
|
987
|
+
const reportExtracts = await getReportExtracts();
|
|
988
|
+
if (reportExtracts.some(report => report.description === description && report.url === url)) {
|
|
989
|
+
// Return this.
|
|
990
|
+
return {result: 'retest', description, url};
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
// The request is eligible, so get the requests awaiting approval.
|
|
994
|
+
const testRequests = await getTestRequests();
|
|
995
|
+
// Add the request to them, initializing them with the URL if necessary.
|
|
996
|
+
(testRequests[url] ??= []).push({
|
|
997
|
+
timeStamp: getNowStamp(),
|
|
998
|
+
description,
|
|
999
|
+
reason
|
|
1000
|
+
});
|
|
1001
|
+
// Save the revised test requests.
|
|
1002
|
+
await fs.writeFile(testRequestsPath(), getJSON(testRequests));
|
|
1003
|
+
// Get an email-safe version of the reason.
|
|
1004
|
+
const plainReason = getPlainText(reason);
|
|
1005
|
+
// Alert a manager.
|
|
1006
|
+
await sendAlert(
|
|
1007
|
+
`Kilotest: new ${requestType} request awaits approval`,
|
|
1008
|
+
`Page description: ${description}\nURL: ${url}\nReason: ${plainReason}`
|
|
1009
|
+
);
|
|
1010
|
+
// Return success.
|
|
1011
|
+
return {result: 'ok', description, url};
|
|
1012
|
+
// If an error occurred:
|
|
1013
|
+
} catch(error) {
|
|
1014
|
+
// Throw it.
|
|
1015
|
+
throw new Error('Failed to process test request', {cause: error});
|
|
1016
|
+
}
|
|
1017
|
+
});
|
package/web/listReports/index.ts
CHANGED
|
@@ -15,7 +15,6 @@ import {
|
|
|
15
15
|
getTestRequests,
|
|
16
16
|
getReportData,
|
|
17
17
|
getReportExtracts,
|
|
18
|
-
getRequestability,
|
|
19
18
|
jobsPath,
|
|
20
19
|
objectSort,
|
|
21
20
|
populateTemplate
|
|
@@ -35,14 +34,14 @@ const populateQuery = async (query: Record<string, any>) => {
|
|
|
35
34
|
tested: []
|
|
36
35
|
};
|
|
37
36
|
// Get the test requests.
|
|
38
|
-
const testRequests = await getTestRequests()
|
|
37
|
+
const testRequests = await getTestRequests();
|
|
39
38
|
// For each requested URL:
|
|
40
39
|
Object.keys(testRequests).forEach(url => {
|
|
41
40
|
// For each of its requests:
|
|
42
41
|
testRequests[url]!.forEach((req: any) => {
|
|
43
|
-
const {description,
|
|
42
|
+
const {description, reason} = req;
|
|
44
43
|
// Add a line.
|
|
45
|
-
lines.requests.push(`${margin}<li><code>${url}</code> (${description}): ${
|
|
44
|
+
lines.requests.push(`${margin}<li><code>${url}</code> (${description}): ${reason}</li>`);
|
|
46
45
|
});
|
|
47
46
|
});
|
|
48
47
|
// Sort the lines in alphabetical order by URL and secondarily by proposed name.
|
|
@@ -55,12 +54,29 @@ const populateQuery = async (query: Record<string, any>) => {
|
|
|
55
54
|
: 'No requests await approval now.';
|
|
56
55
|
// Get the file names of all queued and claimed jobs.
|
|
57
56
|
const jobFileNames = await getJobNames();
|
|
57
|
+
// Initialize sets of the page descriptions and URLs of jobs in both categories.
|
|
58
|
+
const jobsData = {
|
|
59
|
+
queue: {
|
|
60
|
+
descriptions: new Set(),
|
|
61
|
+
urls: new Set()
|
|
62
|
+
},
|
|
63
|
+
claimed: {
|
|
64
|
+
descriptions: new Set(),
|
|
65
|
+
urls: new Set()
|
|
66
|
+
}
|
|
67
|
+
};
|
|
58
68
|
// For each job category:
|
|
59
69
|
for (const category of ['queue', 'claimed'] as const) {
|
|
60
70
|
// For each job in the category:
|
|
61
71
|
for (const fileName of jobFileNames[category]) {
|
|
62
72
|
// Get the job.
|
|
63
73
|
const job = await getObject(path.join(jobsPath(), category, fileName)) as {target: {url: string, what: string}};
|
|
74
|
+
// Get the description and URL of its page.
|
|
75
|
+
const {target} = job;
|
|
76
|
+
const {what, url} = target;
|
|
77
|
+
// Ensure they are in the sets of properties of the category.
|
|
78
|
+
jobsData[category].descriptions.add(what);
|
|
79
|
+
jobsData[category].urls.add(url);
|
|
64
80
|
// Add a line.
|
|
65
81
|
lines[category].push(`${margin}<li><code>${job.target.url}</code> (${job.target.what})</li>`);
|
|
66
82
|
}
|
|
@@ -84,7 +100,7 @@ const populateQuery = async (query: Record<string, any>) => {
|
|
|
84
100
|
sortedExtracts = objectSort(sortedExtracts, 'description', 'alpha');
|
|
85
101
|
// For each report:
|
|
86
102
|
for (const extract of sortedExtracts) {
|
|
87
|
-
const {jobID, timeStamp, url, description} = extract;
|
|
103
|
+
const {jobID, timeStamp, url, description, superseded} = extract;
|
|
88
104
|
// Get data about it.
|
|
89
105
|
const reportData = await getReportData(timeStamp, jobID);
|
|
90
106
|
// If this failed:
|
|
@@ -145,21 +161,26 @@ const populateQuery = async (query: Record<string, any>) => {
|
|
|
145
161
|
const link = `<a ${href} ${label}>What ${questionString}?</a>`;
|
|
146
162
|
lines.tested.push(`${margin} <li>${link}</li>`);
|
|
147
163
|
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
164
|
+
let retestString: string = '';
|
|
165
|
+
// If a page with the same description or URL is being tested:
|
|
166
|
+
if (jobsData.claimed.descriptions.has(description) || jobsData.claimed.urls.has(url)) {
|
|
167
|
+
// Report this.
|
|
152
168
|
retestString = 'Currently being retested';
|
|
153
169
|
}
|
|
154
|
-
|
|
170
|
+
// Otherwise, if such a page is queued:
|
|
171
|
+
else if (jobsData.queue.descriptions.has(description) || jobsData.queue.urls.has(url)) {
|
|
155
172
|
retestString = 'Currently in the queue for retesting';
|
|
156
173
|
}
|
|
157
|
-
|
|
174
|
+
// Otherwise, if no such page is being tested or queued and the report is not superseded:
|
|
175
|
+
else if (!superseded) {
|
|
176
|
+
// Make the page available for retesting.
|
|
158
177
|
const href = `/requestRetestForm.html/${timeStamp}/${jobID}`;
|
|
159
178
|
const retestContent = 'Should Kilotest retest the page?';
|
|
160
179
|
retestString = `<a href="${href}">${retestContent}</a>`;
|
|
161
180
|
}
|
|
162
|
-
|
|
181
|
+
if (retestString) {
|
|
182
|
+
lines.tested.push(`${margin} <li>${retestString}</li>`);
|
|
183
|
+
}
|
|
163
184
|
lines.tested.push(`${margin} </ul>`);
|
|
164
185
|
lines.tested.push(`${margin}</details>`);
|
|
165
186
|
}
|
|
@@ -95,7 +95,7 @@ test('listReports shows requests when testRequests.json has entries', {timeout:
|
|
|
95
95
|
try {
|
|
96
96
|
const testRequests = {
|
|
97
97
|
'https://example.com/mixed': [
|
|
98
|
-
{description: 'Mixed Outcomes Page',
|
|
98
|
+
{description: 'Mixed Outcomes Page', reason: 'Needs retesting for accessibility'}
|
|
99
99
|
]
|
|
100
100
|
};
|
|
101
101
|
await fs.writeFile(testRequestsPath, JSON.stringify(testRequests, null, 2));
|
package/web/reannotate/index.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
getReport,
|
|
12
12
|
getReportPath,
|
|
13
13
|
isReportError,
|
|
14
|
+
readdirOrCreate,
|
|
14
15
|
reportsPath
|
|
15
16
|
} from '../../util.ts';
|
|
16
17
|
import fs from 'node:fs/promises';
|
|
@@ -23,7 +24,7 @@ export const answer = async (authCode: string) => {
|
|
|
23
24
|
// If the authorization code is valid:
|
|
24
25
|
if (authCode === process.env.AUTH_CODE) {
|
|
25
26
|
// Get the names of the stored report files.
|
|
26
|
-
const reportFileNames = (await
|
|
27
|
+
const reportFileNames = (await readdirOrCreate(reportsPath(), 'Reports directory'))
|
|
27
28
|
.filter(fileName => fileName.endsWith('.json'));
|
|
28
29
|
// If any exist:
|
|
29
30
|
if (reportFileNames.length) {
|