@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/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
- // 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.
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
- const categoryPath = path.join(jobsPath(), category);
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
- fileNames = await fs.readdir(categoryPath);
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
- await fs.mkdir(categoryPath, {recursive: true});
205
- fileNames = [];
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 jobNames;
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 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;
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(`Test-requests file not JSON (${errorMessage(error)})`, {cause: 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 fs.readdir(reportsPath());
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
+ });
@@ -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() as Record<string, {description: string, why: string}[]>;
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, why} = req;
42
+ const {description, reason} = req;
44
43
  // Add a line.
45
- lines.requests.push(`${margin}<li><code>${url}</code> (${description}): ${why}</li>`);
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
- // Add the status of, and if necessary a question link about, retesting to the lines.
149
- const status = await getRequestability(url);
150
- let retestString: string;
151
- if (status === 'claimed') {
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
- else if (status === 'queued') {
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
- else {
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
- lines.tested.push(`${margin} <li>${retestString}</li>`);
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', why: 'Needs retesting for accessibility'}
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));
@@ -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 fs.readdir(reportsPath()))
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) {