@jrpool/kilotest 58.2.0 → 58.2.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jrpool/kilotest",
3
- "version": "58.2.0",
3
+ "version": "58.2.4",
4
4
  "description": "Ensemble testing for front-end web quality",
5
5
  "type": "module",
6
6
  "main": "index.ts",
package/util.test.ts CHANGED
@@ -1276,7 +1276,7 @@ test('getIssue returns null for a known engine with an unknown variable rule', (
1276
1276
  });
1277
1277
 
1278
1278
  test('getIssue returns an issue ID for a variable rule pattern match', () => {
1279
- const result = getIssue('nuVal', 'Duplicate attribute foo');
1279
+ const result = getIssue('nuVal', 'Duplicate attribute foo.');
1280
1280
  assert.ok(typeof result === 'string');
1281
1281
  assert.equal(result, 'duplicateAttribute');
1282
1282
  });
@@ -1451,6 +1451,56 @@ test('getReportExtracts with onlyLatest returns only the latest report for each
1451
1451
  assert.equal(mixReports[0]!.timeStamp, '260202T0000');
1452
1452
  });
1453
1453
 
1454
+ test('getReportExtracts reflects reports added, modified, and removed after a previous call', async () => {
1455
+ const os = await import('node:os');
1456
+ const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'kilotest-extracts-'));
1457
+ const tmpReportsDir = path.join(tmpRoot, 'db', 'reports');
1458
+ const savedDbDir = process.env.DB_DIR;
1459
+ const sourcePath = path.join(reportsPath(), '260101T0000-mix.json');
1460
+ process.env.DB_DIR = path.join(tmpRoot, 'db');
1461
+ try {
1462
+ await fs.mkdir(tmpReportsDir, {recursive: true});
1463
+ const report = JSON.parse(await fs.readFile(sourcePath, 'utf8'));
1464
+ const firstPath = path.join(tmpReportsDir, '260101T0000-aaa.json');
1465
+ await fs.writeFile(firstPath, JSON.stringify(report));
1466
+ let extracts = await getReportExtracts();
1467
+ assert.deepEqual(extracts.map(e => e.description), ['Mixed Outcomes Page']);
1468
+ // Modify the report and give it a later modification time, as reannotation would.
1469
+ report.target.what = 'Revised Page';
1470
+ await fs.writeFile(firstPath, JSON.stringify(report));
1471
+ const later = new Date(Date.now() + 60000);
1472
+ await fs.utimes(firstPath, later, later);
1473
+ extracts = await getReportExtracts();
1474
+ assert.deepEqual(extracts.map(e => e.description), ['Revised Page']);
1475
+ // Add a report.
1476
+ report.target.what = 'Added Page';
1477
+ const secondPath = path.join(tmpReportsDir, '260101T0001-bbb.json');
1478
+ await fs.writeFile(secondPath, JSON.stringify(report));
1479
+ extracts = await getReportExtracts();
1480
+ assert.deepEqual(extracts.map(e => e.description), ['Added Page', 'Revised Page']);
1481
+ // Mutating a returned extract does not affect later results.
1482
+ extracts[0]!.superseded = true;
1483
+ extracts = await getReportExtracts();
1484
+ assert.equal(extracts[0]!.superseded, undefined);
1485
+ // Make a cached report unreadable.
1486
+ await fs.writeFile(secondPath, 'not JSON');
1487
+ extracts = await getReportExtracts();
1488
+ assert.deepEqual(extracts.map(e => e.description), ['Revised Page']);
1489
+ // Restore it.
1490
+ await fs.writeFile(secondPath, JSON.stringify(report));
1491
+ extracts = await getReportExtracts();
1492
+ assert.deepEqual(extracts.map(e => e.description), ['Added Page', 'Revised Page']);
1493
+ // Remove a report, as hiding would.
1494
+ await fs.rm(firstPath);
1495
+ extracts = await getReportExtracts();
1496
+ assert.deepEqual(extracts.map(e => e.description), ['Added Page']);
1497
+ }
1498
+ finally {
1499
+ process.env.DB_DIR = savedDbDir;
1500
+ await fs.rm(tmpRoot, {recursive: true}).catch(() => {});
1501
+ }
1502
+ });
1503
+
1454
1504
  test('getExclusionCookieValue is deterministic for a given AUTH_CODE', async () => {
1455
1505
  const savedAuthCode = process.env.AUTH_CODE;
1456
1506
  process.env.AUTH_CODE = 'test-auth-code';
package/util.ts CHANGED
@@ -793,20 +793,58 @@ export const getReportExtract = async (timeStamp: string, jobID: string): Promis
793
793
  };
794
794
  }
795
795
  };
796
+ // Cache of report extracts, keyed by report file path. Each entry records the modification
797
+ // time and size of the file when it was extracted, so an entry is reused only while the file
798
+ // is unchanged. Reports are mutable (e.g., reannotation rewrites all of them) and may be
799
+ // written, hidden, or deleted by any process, so the cache is validated against the files
800
+ // before every use rather than invalidated by the code that changes them.
801
+ const reportExtractCache = new Map<string, {mtimeMs: number, size: number, extract: ReportExtract}>();
796
802
  // Returns extracts of all available reports.
797
803
  export const getReportExtracts = async (onlyLatest: boolean = false): Promise<ReportExtract[]> => {
798
- // Get the names of the available report files.
799
- const reportFileNames = await readdirOrCreate(reportsPath(), 'Reports directory');
804
+ const reportsDir = reportsPath();
805
+ // Get the names and paths of the available report files.
806
+ const reportFileNames = await readdirOrCreate(reportsDir, 'Reports directory');
807
+ const reportFilePaths = reportFileNames.map(fileName => path.join(reportsDir, fileName));
808
+ const reportFilePathSet = new Set(reportFilePaths);
809
+ // Remove from the cache any report that is no longer available (e.g., hidden or deleted).
810
+ for (const cachedPath of reportExtractCache.keys()) {
811
+ if (!reportFilePathSet.has(cachedPath)) {
812
+ reportExtractCache.delete(cachedPath);
813
+ }
814
+ }
815
+ // Get the current modification times and sizes of the report files. A file that disappeared
816
+ // after the directory was read gets values that match no cache entry, so it is reread, which
817
+ // fails and removes it from the cache.
818
+ const reportStats = await Promise.all(
819
+ reportFilePaths.map(filePath => fs.stat(filePath).catch(() => ({mtimeMs: NaN, size: NaN})))
820
+ );
800
821
  // Initialize an array of extracts.
801
822
  const extracts: ReportExtract[] = [];
802
- // For each one:
803
- for (const reportFileName of reportFileNames) {
823
+ // For each report file:
824
+ for (const [index, reportFileName] of reportFileNames.entries()) {
825
+ const filePath = reportFilePaths[index]!;
826
+ const stat = reportStats[index]!;
827
+ const cached = reportExtractCache.get(filePath);
828
+ // If its cached extract is current:
829
+ if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
830
+ // Add a copy of the cached extract to the array.
831
+ extracts.push({...cached.extract});
832
+ continue;
833
+ }
834
+ // Otherwise, get a new extract of it. The stat precedes the read, so a change between
835
+ // them leaves a stale modification time in the cache and causes a reread next time.
804
836
  const [timeStamp, jobID] = reportFileName.slice(0, -5).split('-') as [string, string];
805
- // Get an extract of it.
806
837
  const extract = await getReportExtract(timeStamp, jobID);
838
+ // If this succeeded:
807
839
  if (!('error' in extract)) {
808
- // Add the extract to the array.
809
- extracts.push(extract);
840
+ // Cache it and add a copy of it to the array.
841
+ reportExtractCache.set(filePath, {mtimeMs: stat.mtimeMs, size: stat.size, extract});
842
+ extracts.push({...extract});
843
+ }
844
+ // Otherwise, i.e. if it failed:
845
+ else {
846
+ // Forget any outdated extract of it.
847
+ reportExtractCache.delete(filePath);
810
848
  }
811
849
  }
812
850
  // Sort the extracts by page description and, secondarily, completion time.
@@ -97,7 +97,7 @@ const populateQuery = async (query: Record<string, any>) => {
97
97
  query.some = (reportCount || jobFileNames.queue.length || jobFileNames.claimed.length)
98
98
  ? 'another'
99
99
  : 'a';
100
- const multiReportWhats = await getMultiReportWhats();
100
+ const multiReportWhats = await getMultiReportWhats(reportExtracts);
101
101
  // Sort them primarily by page description and secondarily by completion time.
102
102
  let sortedExtracts = objectSort(reportExtracts, 'reportTime', 'alpha');
103
103
  sortedExtracts = objectSort(sortedExtracts, 'description', 'alpha');
package/web/util.test.ts CHANGED
@@ -293,6 +293,16 @@ test('getMultiReportWhats returns descriptions that have multiple reports', asyn
293
293
  assert.ok(whats.includes('Mixed Outcomes Page'));
294
294
  });
295
295
 
296
+ test('getMultiReportWhats uses extracts provided by the caller', async () => {
297
+ const extract = {timeStamp: 't', jobID: 'j', url: 'https://example.com/', reportTime: ''};
298
+ const whats = await getMultiReportWhats([
299
+ {...extract, description: 'B'},
300
+ {...extract, description: 'A'},
301
+ {...extract, description: 'B'}
302
+ ]);
303
+ assert.deepEqual(whats, ['B']);
304
+ });
305
+
296
306
  // Returns a minimal report for testing rule-engine identification.
297
307
  const makeEngineReport = (whiches: string[], preventions: Record<string, string>) => ({
298
308
  acts: whiches.map(which => ({type: 'test', which})),
package/web/util.ts CHANGED
@@ -21,7 +21,7 @@ import {
21
21
  } from '../util.ts';
22
22
  import {issues as issueSpecs} from 'testaro-issues';
23
23
  import type {Catalog} from 'testaro';
24
- import type {UsableReport} from '../util.ts';
24
+ import type {ReportExtract, UsableReport} from '../util.ts';
25
25
  /* c8 ignore stop */
26
26
  import wcagMap from '../wcagMap.json' with {type: 'json'};
27
27
 
@@ -372,9 +372,11 @@ export const getViolatorFactsLines = (
372
372
  lines.push(`${margin}</ul>`);
373
373
  return lines;
374
374
  };
375
- // Gets the descriptions of multi-report pages.
376
- export const getMultiReportWhats = async (): Promise<string[]> => {
377
- const reportExtracts = await getReportExtracts();
375
+ // Gets the descriptions of multi-report pages, from extracts already obtained, if provided.
376
+ export const getMultiReportWhats = async (
377
+ reportExtracts?: ReportExtract[]
378
+ ): Promise<string[]> => {
379
+ reportExtracts ??= await getReportExtracts();
378
380
  const sortedDescriptions = reportExtracts.map(extract => extract.description).sort();
379
381
  const multiReportDescriptions = sortedDescriptions.filter(
380
382
  (description, index) => description !== sortedDescriptions[index - 1] && description === sortedDescriptions[index + 1]