@jrpool/kilotest 58.2.0 → 59.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/api/listIssues.test.ts +32 -0
  2. package/api/listIssues.ts +38 -8
  3. package/api/listPages.test.ts +92 -0
  4. package/api/listPages.ts +93 -0
  5. package/api/orderNewTest.test.ts +1 -1
  6. package/api/orderNewTest.ts +4 -4
  7. package/api/requestNewTest.ts +6 -6
  8. package/api/requestRetest.ts +2 -2
  9. package/api/routes.test.ts +1 -1
  10. package/api/routes.ts +5 -5
  11. package/api/schemas.ts +24 -7
  12. package/api/util.ts +26 -45
  13. package/apiIntegration.test.ts +5 -5
  14. package/docs/SERVICE.md +16 -5
  15. package/index.html +1 -1
  16. package/index.test.ts +30 -18
  17. package/index.ts +22 -11
  18. package/lhm.plugin.json +1433 -141
  19. package/linkValidation.test.ts +2 -2
  20. package/llms-full.txt +9 -9
  21. package/llms.txt +4 -4
  22. package/mcp.test.ts +8 -8
  23. package/mcp.ts +14 -14
  24. package/openapi.yaml +84 -30
  25. package/package.json +1 -1
  26. package/routing.test.ts +5 -5
  27. package/server.json +1 -1
  28. package/sitemap.xml +1 -1
  29. package/smokeTest.ts +2 -2
  30. package/style.css +8 -8
  31. package/test/fixtures/buildFixtures.ts +1 -1
  32. package/util.test.ts +71 -12
  33. package/util.ts +61 -12
  34. package/web/enqueueForm/index.html +1 -1
  35. package/web/hideReportForm/hideReportForm.test.ts +5 -0
  36. package/web/hideReportForm/index.ts +9 -6
  37. package/web/listIssues/index.html +2 -1
  38. package/web/listIssues/index.ts +45 -3
  39. package/web/listIssues/listIssues.test.ts +76 -0
  40. package/web/{listReports → listPages}/index.html +1 -2
  41. package/web/listPages/index.ts +99 -0
  42. package/web/listPages/listPages.test.ts +196 -0
  43. package/web/metrics/metrics.test.ts +2 -2
  44. package/web/requestRetestForm/index.ts +4 -2
  45. package/web/requestRetestForm/requestRetestForm.test.ts +11 -0
  46. package/web/unhideReportForm/index.ts +9 -6
  47. package/web/unhideReportForm/unhideReportForm.test.ts +8 -5
  48. package/web/util.test.ts +85 -11
  49. package/web/util.ts +26 -28
  50. package/api/listReports.test.ts +0 -109
  51. package/api/listReports.ts +0 -87
  52. package/web/listReports/index.ts +0 -182
  53. package/web/listReports/listReports.test.ts +0 -300
package/util.test.ts CHANGED
@@ -38,6 +38,7 @@ import {
38
38
  getObject,
39
39
  getPlainText,
40
40
  getRandomString,
41
+ getReportEndTime,
41
42
  getReportExtract,
42
43
  getReportExtracts,
43
44
  getReportPath,
@@ -1276,7 +1277,7 @@ test('getIssue returns null for a known engine with an unknown variable rule', (
1276
1277
  });
1277
1278
 
1278
1279
  test('getIssue returns an issue ID for a variable rule pattern match', () => {
1279
- const result = getIssue('nuVal', 'Duplicate attribute foo');
1280
+ const result = getIssue('nuVal', 'Duplicate attribute foo.');
1280
1281
  assert.ok(typeof result === 'string');
1281
1282
  assert.equal(result, 'duplicateAttribute');
1282
1283
  });
@@ -1408,10 +1409,18 @@ test('getReportPath returns the path of a report file', () => {
1408
1409
  assert.ok(result.endsWith('260101T0000-mix.json'));
1409
1410
  });
1410
1411
 
1411
- test('getReportStats returns reportTime and reportSize for a valid report', async () => {
1412
+ test('getReportEndTime returns the completion time of a report', () => {
1413
+ const endTime = getReportEndTime({jobData: {endTime: '26-03-04T05:06'}});
1414
+ assert.equal(endTime.toISOString(), '2026-03-04T05:06:00.000Z');
1415
+ });
1416
+
1417
+ test('getReportEndTime returns an invalid date for a report with no completion time', () => {
1418
+ assert.ok(isNaN(getReportEndTime({}).getTime()));
1419
+ });
1420
+
1421
+ test('getReportStats returns reportSize for a valid report', async () => {
1412
1422
  const stats = await getReportStats('260101T0000', 'mix');
1413
1423
  assert.ok(stats);
1414
- assert.ok(stats.reportTime instanceof Date);
1415
1424
  assert.equal(typeof stats.reportSize, 'number');
1416
1425
  assert.ok(stats.reportSize > 0);
1417
1426
  });
@@ -1451,6 +1460,56 @@ test('getReportExtracts with onlyLatest returns only the latest report for each
1451
1460
  assert.equal(mixReports[0]!.timeStamp, '260202T0000');
1452
1461
  });
1453
1462
 
1463
+ test('getReportExtracts reflects reports added, modified, and removed after a previous call', async () => {
1464
+ const os = await import('node:os');
1465
+ const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'kilotest-extracts-'));
1466
+ const tmpReportsDir = path.join(tmpRoot, 'db', 'reports');
1467
+ const savedDbDir = process.env.DB_DIR;
1468
+ const sourcePath = path.join(reportsPath(), '260101T0000-mix.json');
1469
+ process.env.DB_DIR = path.join(tmpRoot, 'db');
1470
+ try {
1471
+ await fs.mkdir(tmpReportsDir, {recursive: true});
1472
+ const report = JSON.parse(await fs.readFile(sourcePath, 'utf8'));
1473
+ const firstPath = path.join(tmpReportsDir, '260101T0000-aaa.json');
1474
+ await fs.writeFile(firstPath, JSON.stringify(report));
1475
+ let extracts = await getReportExtracts();
1476
+ assert.deepEqual(extracts.map(e => e.description), ['Mixed Outcomes Page']);
1477
+ // Modify the report and give it a later modification time, as reannotation would.
1478
+ report.target.what = 'Revised Page';
1479
+ await fs.writeFile(firstPath, JSON.stringify(report));
1480
+ const later = new Date(Date.now() + 60000);
1481
+ await fs.utimes(firstPath, later, later);
1482
+ extracts = await getReportExtracts();
1483
+ assert.deepEqual(extracts.map(e => e.description), ['Revised Page']);
1484
+ // Add a report.
1485
+ report.target.what = 'Added Page';
1486
+ const secondPath = path.join(tmpReportsDir, '260101T0001-bbb.json');
1487
+ await fs.writeFile(secondPath, JSON.stringify(report));
1488
+ extracts = await getReportExtracts();
1489
+ assert.deepEqual(extracts.map(e => e.description), ['Added Page', 'Revised Page']);
1490
+ // Mutating a returned extract does not affect later results.
1491
+ extracts[0]!.superseded = true;
1492
+ extracts = await getReportExtracts();
1493
+ assert.equal(extracts[0]!.superseded, undefined);
1494
+ // Make a cached report unreadable.
1495
+ await fs.writeFile(secondPath, 'not JSON');
1496
+ extracts = await getReportExtracts();
1497
+ assert.deepEqual(extracts.map(e => e.description), ['Revised Page']);
1498
+ // Restore it.
1499
+ await fs.writeFile(secondPath, JSON.stringify(report));
1500
+ extracts = await getReportExtracts();
1501
+ assert.deepEqual(extracts.map(e => e.description), ['Added Page', 'Revised Page']);
1502
+ // Remove a report, as hiding would.
1503
+ await fs.rm(firstPath);
1504
+ extracts = await getReportExtracts();
1505
+ assert.deepEqual(extracts.map(e => e.description), ['Added Page']);
1506
+ }
1507
+ finally {
1508
+ process.env.DB_DIR = savedDbDir;
1509
+ await fs.rm(tmpRoot, {recursive: true}).catch(() => {});
1510
+ }
1511
+ });
1512
+
1454
1513
  test('getExclusionCookieValue is deterministic for a given AUTH_CODE', async () => {
1455
1514
  const savedAuthCode = process.env.AUTH_CODE;
1456
1515
  process.env.AUTH_CODE = 'test-auth-code';
@@ -1522,11 +1581,11 @@ test('recordMetric increments an existing count rather than overwriting it', asy
1522
1581
  process.env.DB_DIR = tmpDir;
1523
1582
  try {
1524
1583
  const {recordMetric, metricsPath} = await import('./util.ts');
1525
- await recordMetric('mcpToolCalls', 'listReports');
1526
- await recordMetric('mcpToolCalls', 'listReports');
1584
+ await recordMetric('mcpToolCalls', 'listPages');
1585
+ await recordMetric('mcpToolCalls', 'listPages');
1527
1586
  await recordMetric('mcpToolCalls', 'getReport');
1528
1587
  const metrics = JSON.parse(fsSync.readFileSync(metricsPath(), 'utf8'));
1529
- assert.equal(metrics.mcpToolCalls.listReports, 2);
1588
+ assert.equal(metrics.mcpToolCalls.listPages, 2);
1530
1589
  assert.equal(metrics.mcpToolCalls.getReport, 1);
1531
1590
  }
1532
1591
  finally {
@@ -1577,11 +1636,11 @@ test('recordMetric keeps categories independent for the same name', async () =>
1577
1636
  process.env.DB_DIR = tmpDir;
1578
1637
  try {
1579
1638
  const {recordMetric, metricsPath} = await import('./util.ts');
1580
- await recordMetric('apiOperations', 'listReports');
1581
- await recordMetric('mcpToolCalls', 'listReports');
1639
+ await recordMetric('apiOperations', 'listPages');
1640
+ await recordMetric('mcpToolCalls', 'listPages');
1582
1641
  const metrics = JSON.parse(fsSync.readFileSync(metricsPath(), 'utf8'));
1583
- assert.equal(metrics.apiOperations.listReports, 1);
1584
- assert.equal(metrics.mcpToolCalls.listReports, 1);
1642
+ assert.equal(metrics.apiOperations.listPages, 1);
1643
+ assert.equal(metrics.mcpToolCalls.listPages, 1);
1585
1644
  }
1586
1645
  finally {
1587
1646
  process.env.DB_DIR = savedDbDir;
@@ -1636,7 +1695,7 @@ test('getMetrics backfills categories missing from a metrics.json written before
1636
1695
  // Simulate a metrics.json written before the managerActivity category was added.
1637
1696
  fsSync.writeFileSync(tmpDir + '/metrics.json', JSON.stringify({
1638
1697
  since: '260101T0000',
1639
- pageViews: {listReports: 1},
1698
+ pageViews: {listPages: 1},
1640
1699
  mcpToolCalls: {},
1641
1700
  apiOperations: {}
1642
1701
  }));
@@ -1648,7 +1707,7 @@ test('getMetrics backfills categories missing from a metrics.json written before
1648
1707
  await recordMetric('managerActivity', 'metrics', 'ok');
1649
1708
  const metrics = JSON.parse(fsSync.readFileSync(metricsPath(), 'utf8'));
1650
1709
  assert.equal(metrics.since, '260101T0000');
1651
- assert.equal(metrics.pageViews.listReports, 1);
1710
+ assert.equal(metrics.pageViews.listPages, 1);
1652
1711
  assert.deepEqual(metrics.managerActivity.metrics, {ok: 1, error: 0});
1653
1712
  }
1654
1713
  finally {
package/util.ts CHANGED
@@ -689,7 +689,7 @@ export const isUsableReport = (report: unknown): report is UsableReport => {
689
689
  )
690
690
  && typeof r.jobData === 'object'
691
691
  && r.jobData.endTime
692
- && !isNaN(new Date(`20${r.jobData.endTime}Z`).getTime())
692
+ && !isNaN(getReportEndTime(r).getTime())
693
693
  && typeof r.catalog === 'object';
694
694
  };
695
695
  // Returns a report.
@@ -754,7 +754,7 @@ export const annotateReportObject = async (report: UsableReport): Promise<void>
754
754
  );
755
755
  }
756
756
  };
757
- // Returns the creation time and size of a report.
757
+ // Returns the size of a report, or null if the report does not exist.
758
758
  export const getReportStats = async (timeStamp: string, jobID: string) => {
759
759
  let reportStat;
760
760
  try {
@@ -763,10 +763,15 @@ export const getReportStats = async (timeStamp: string, jobID: string) => {
763
763
  catch {
764
764
  return null;
765
765
  }
766
- const reportTime = reportStat.birthtime;
767
766
  const reportSize = reportStat.size;
768
- return {reportTime, reportSize};
767
+ return {reportSize};
769
768
  };
769
+ // Returns the completion time of a report. This, not the time stamp in the job ID nor the
770
+ // file system time of the report file, is the time of a report. The date is invalid if the
771
+ // report has no valid completion time.
772
+ export const getReportEndTime = (report: {jobData?: {endTime?: string}}): Date => new Date(
773
+ `20${report.jobData?.endTime}Z`
774
+ );
770
775
 
771
776
  // Returns an extract of an available report, or an error object if it cannot be read or parsed.
772
777
  export const getReportExtract = async (timeStamp: string, jobID: string): Promise<ReportExtract | {error: string}> => {
@@ -784,7 +789,7 @@ export const getReportExtract = async (timeStamp: string, jobID: string): Promis
784
789
  jobID,
785
790
  description,
786
791
  url,
787
- reportTime: new Date(`20${jobData.endTime}Z`).toISOString()
792
+ reportTime: getReportEndTime({jobData}).toISOString()
788
793
  };
789
794
  }
790
795
  catch {
@@ -793,20 +798,58 @@ export const getReportExtract = async (timeStamp: string, jobID: string): Promis
793
798
  };
794
799
  }
795
800
  };
801
+ // Cache of report extracts, keyed by report file path. Each entry records the modification
802
+ // time and size of the file when it was extracted, so an entry is reused only while the file
803
+ // is unchanged. Reports are mutable (e.g., reannotation rewrites all of them) and may be
804
+ // written, hidden, or deleted by any process, so the cache is validated against the files
805
+ // before every use rather than invalidated by the code that changes them.
806
+ const reportExtractCache = new Map<string, {mtimeMs: number, size: number, extract: ReportExtract}>();
796
807
  // Returns extracts of all available reports.
797
808
  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');
809
+ const reportsDir = reportsPath();
810
+ // Get the names and paths of the available report files.
811
+ const reportFileNames = await readdirOrCreate(reportsDir, 'Reports directory');
812
+ const reportFilePaths = reportFileNames.map(fileName => path.join(reportsDir, fileName));
813
+ const reportFilePathSet = new Set(reportFilePaths);
814
+ // Remove from the cache any report that is no longer available (e.g., hidden or deleted).
815
+ for (const cachedPath of reportExtractCache.keys()) {
816
+ if (!reportFilePathSet.has(cachedPath)) {
817
+ reportExtractCache.delete(cachedPath);
818
+ }
819
+ }
820
+ // Get the current modification times and sizes of the report files. A file that disappeared
821
+ // after the directory was read gets values that match no cache entry, so it is reread, which
822
+ // fails and removes it from the cache.
823
+ const reportStats = await Promise.all(
824
+ reportFilePaths.map(filePath => fs.stat(filePath).catch(() => ({mtimeMs: NaN, size: NaN})))
825
+ );
800
826
  // Initialize an array of extracts.
801
827
  const extracts: ReportExtract[] = [];
802
- // For each one:
803
- for (const reportFileName of reportFileNames) {
828
+ // For each report file:
829
+ for (const [index, reportFileName] of reportFileNames.entries()) {
830
+ const filePath = reportFilePaths[index]!;
831
+ const stat = reportStats[index]!;
832
+ const cached = reportExtractCache.get(filePath);
833
+ // If its cached extract is current:
834
+ if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
835
+ // Add a copy of the cached extract to the array.
836
+ extracts.push({...cached.extract});
837
+ continue;
838
+ }
839
+ // Otherwise, get a new extract of it. The stat precedes the read, so a change between
840
+ // them leaves a stale modification time in the cache and causes a reread next time.
804
841
  const [timeStamp, jobID] = reportFileName.slice(0, -5).split('-') as [string, string];
805
- // Get an extract of it.
806
842
  const extract = await getReportExtract(timeStamp, jobID);
843
+ // If this succeeded:
807
844
  if (!('error' in extract)) {
808
- // Add the extract to the array.
809
- extracts.push(extract);
845
+ // Cache it and add a copy of it to the array.
846
+ reportExtractCache.set(filePath, {mtimeMs: stat.mtimeMs, size: stat.size, extract});
847
+ extracts.push({...extract});
848
+ }
849
+ // Otherwise, i.e. if it failed:
850
+ else {
851
+ // Forget any outdated extract of it.
852
+ reportExtractCache.delete(filePath);
810
853
  }
811
854
  }
812
855
  // Sort the extracts by page description and, secondarily, completion time.
@@ -823,6 +866,12 @@ export const getReportExtracts = async (onlyLatest: boolean = false): Promise<Re
823
866
  // Return the array, excluding extracts of superseded reports if so specified.
824
867
  return onlyLatest ? extracts.filter(extract => !extract.superseded) : extracts;
825
868
  };
869
+ // Returns extracts of all available reports about the page with a description, from oldest
870
+ // to latest.
871
+ export const getPageReportExtracts = async (description: string): Promise<ReportExtract[]> => {
872
+ const reportExtracts = await getReportExtracts();
873
+ return reportExtracts.filter(extract => extract.description === description);
874
+ };
826
875
  // Returns whether a report with a description or URL is available.
827
876
  export const isReportAvailable = async (description: string, url: string): Promise<boolean> => {
828
877
  const reportExtracts = await getReportExtracts();
@@ -14,7 +14,7 @@
14
14
  <main>
15
15
  <h1><a href="/">Kilotest</a>: Request approval or rejection</h1>
16
16
  <p>To approve a test or retest request, check its requested page name.</p>
17
- <p>To approve testing a page that has not yet been requested, first <a href="listReports.html">request it</a>.</p>
17
+ <p>To approve testing a page that has not yet been requested, first <a href="listPages.html">request it</a>.</p>
18
18
  <p>To reject all requests for a page, check the radio button of its URL.</p>
19
19
  <form action="/requestAction.html" method="post">
20
20
  <fieldset>
@@ -53,6 +53,11 @@ test('hideReportForm displays a list of reports on a GET request', async () => {
53
53
  assert.ok(radios.length > 0);
54
54
  });
55
55
 
56
+ test('hideReportForm describes each report by its completion time', async () => {
57
+ const result: any = await answer(null, '', 'GET');
58
+ assert.ok(result.answerPage.includes('(job <code>260202T0000-new</code> completed 2026-02-02 at 00:10)'));
59
+ });
60
+
56
61
  test('hideReportForm ignores a report query string on a GET request', async () => {
57
62
  const reportPath = path.join(reportsPath(), '260101T0009-brd.json');
58
63
  const hiddenPath = path.join(hiddenReportsPath(), '260101T0009-brd.json');
@@ -8,6 +8,7 @@
8
8
  import {
9
9
  getReportExtracts, hiddenReportsPath, isValidAuthCode, populateTemplate, reportsPath
10
10
  } from '../../util.ts';
11
+ import {getDateTimeString} from '../util.ts';
11
12
  import fs from 'node:fs/promises';
12
13
  import path from 'node:path';
13
14
 
@@ -54,18 +55,19 @@ export const answer = async (_: any, search: string, method: string) => {
54
55
  const reportExtracts = await getReportExtracts();
55
56
  // For each report:
56
57
  for (const reportExtract of reportExtracts) {
57
- const {jobID, timeStamp, description} = reportExtract;
58
+ const {jobID, timeStamp, description, reportTime} = reportExtract;
58
59
  // Add the report to the array.
59
60
  reportSpecs.push({
60
61
  description,
61
62
  timeStamp,
62
- jobID
63
+ jobID,
64
+ reportTime
63
65
  });
64
66
  }
65
- // Sort the data by page name and then by time stamp.
67
+ // Sort the data by page name and then by completion time.
66
68
  reportSpecs.sort((a, b) => {
67
69
  if (a.description === b.description) {
68
- return a.timeStamp.localeCompare(b.timeStamp);
70
+ return a.reportTime.localeCompare(b.reportTime);
69
71
  }
70
72
  return a.description.localeCompare(b.description);
71
73
  });
@@ -73,8 +75,9 @@ export const answer = async (_: any, search: string, method: string) => {
73
75
  const margin = ' '.repeat(12);
74
76
  // For each available report:
75
77
  reportSpecs.forEach(spec => {
76
- const {jobID, timeStamp, description} = spec;
77
- const specString = `${description} (job <code>${jobID}</code> at ${timeStamp})`;
78
+ const {jobID, timeStamp, description, reportTime} = spec;
79
+ const when = getDateTimeString(new Date(reportTime));
80
+ const specString = `${description} (job <code>${timeStamp}-${jobID}</code> completed ${when})`;
78
81
  // Add a line with a radio button to hide it.
79
82
  lines.push(
80
83
  `${margin}<p><input type="radio" name="report" value="${timeStamp}-${jobID}"> ${specString}</p>`
@@ -25,7 +25,8 @@
25
25
  <blockquote>Kilotest reports defects on the __target__ web page. Summarize them and propose a plan to resolve the genuine ones.</blockquote>
26
26
  </details>
27
27
  __pageFacts__
28
- <p><a href="/requestRetestForm.html/__timeStamp__/__jobID__">Should Kilotest retest the page?</a></p>
28
+ <h2>History</h2>
29
+ __history__
29
30
  <h2>Issues reported</h2>
30
31
  <details>
31
32
  <summary>Highest priority: __highestCount__</summary>
@@ -6,6 +6,7 @@
6
6
  // IMPORTS
7
7
 
8
8
  import {
9
+ getPageReportExtracts,
9
10
  getReport,
10
11
  getTestActInstances,
11
12
  isReportError,
@@ -14,6 +15,7 @@ import {
14
15
  } from '../../util.ts';
15
16
  import type {UsableReport} from '../../util.ts';
16
17
  import {
18
+ getAgoString,
17
19
  getIssueFactsLines,
18
20
  getPageDataStrings,
19
21
  getPageFactsLines,
@@ -121,6 +123,47 @@ const getIssuesData = (report: UsableReport): ResultsSummary & Record<string, an
121
123
  // Return the data.
122
124
  return final;
123
125
  };
126
+ // Returns the lines of the history of the reports about the page of a report.
127
+ const getHistoryLines = async (
128
+ timeStamp: string, jobID: string, description: string, margin: string
129
+ ): Promise<string[]> => {
130
+ // Get extracts of all reports about the page, from oldest to latest.
131
+ const pageExtracts = await getPageReportExtracts(description);
132
+ const lastIndex = pageExtracts.length - 1;
133
+ // Initialize the list items and whether the report is the latest about the page.
134
+ const itemLines: string[] = [];
135
+ let isLatest = false;
136
+ // For each report about the page:
137
+ pageExtracts.forEach((extract, index) => {
138
+ const isThis = extract.timeStamp === timeStamp && extract.jobID === jobID;
139
+ isLatest ||= isThis && index === lastIndex;
140
+ // Get the tags that identify its position and whether it is this report.
141
+ const tags = [
142
+ index === 0 ? 'oldest' : '',
143
+ index === lastIndex ? 'latest' : '',
144
+ isThis ? 'this report' : ''
145
+ ]
146
+ .filter(tag => tag);
147
+ const tagString = tags.length ? ` (${tags.join('; ')})` : '';
148
+ const label = `${getAgoString(new Date(extract.reportTime))} ago${tagString}`;
149
+ // Add a list item describing it, linked unless it is this report, to the items.
150
+ const href = `/listIssues.html/${extract.timeStamp}/${extract.jobID}`;
151
+ const content = isThis ? label : `<a href="${href}">${label}</a>`;
152
+ itemLines.push(`${margin} <li>${content}</li>`);
153
+ });
154
+ // Initialize the lines with a list of the reports, or a statement that there is only one.
155
+ const lines = itemLines.length > 1
156
+ ? [`${margin}<ol>`, ...itemLines, `${margin}</ol>`]
157
+ : [`${margin}<p>This is the only report about the page.</p>`];
158
+ // If the report is the latest about the page:
159
+ if (isLatest) {
160
+ // Add a link to the form for requesting a retest to the lines.
161
+ const href = `/requestRetestForm.html/${timeStamp}/${jobID}`;
162
+ lines.push(`${margin}<p><a href="${href}">Should Kilotest retest the page?</a></p>`);
163
+ }
164
+ // Return the lines.
165
+ return lines;
166
+ };
124
167
  // Adds parameters to a query for the answer page.
125
168
  const populateQuery = async (timeStamp: string, jobID: string, query: Record<string, any>) => {
126
169
  // Get the report.
@@ -150,10 +193,9 @@ const populateQuery = async (timeStamp: string, jobID: string, query: Record<str
150
193
  const margin = ' '.repeat(6);
151
194
  // Add the page-facts list to the query.
152
195
  query.pageFacts = getPageFactsLines(pageInfo, issuesData, margin).join('\n');
196
+ // Add the history of reports about the page to the query.
197
+ query.history = (await getHistoryLines(timeStamp, jobID, description, margin)).join('\n');
153
198
  const {issues} = issuesData;
154
- // Add report data to the query.
155
- query.timeStamp = timeStamp;
156
- query.jobID = jobID;
157
199
  // Add a summary of the issues to the query.
158
200
  query.highestCount = issues[4].length;
159
201
  query.highCount = issues[3].length;
@@ -205,3 +205,79 @@ test('listIssues handles acts with no standardResult instances', async () => {
205
205
  await fs.unlink(reportPath).catch(() => {});
206
206
  }
207
207
  });
208
+
209
+ // Returns the History section of a listIssues page, from its heading to the next heading.
210
+ const getHistory = (answerPage: string) => {
211
+ const start = answerPage.indexOf('<h2>History</h2>');
212
+ const end = answerPage.indexOf('<h2>Issues reported</h2>');
213
+ assert.ok(start > -1 && end > start, 'History section exists before the issues');
214
+ return parse(answerPage.slice(start, end));
215
+ };
216
+
217
+ // Returns the text, and the link destination if any, of each item in a History list.
218
+ const getHistoryItems = (history: ReturnType<typeof parse>) => history
219
+ .querySelectorAll('ol > li')
220
+ .map(li => ({
221
+ text: li.text.trim(),
222
+ href: li.querySelector('a')?.getAttribute('href') ?? null
223
+ }));
224
+
225
+ test('listIssues History of an older report lists all reports, with this one unlinked and no retest question', async () => {
226
+ const result = await answer('260101T0000/mix');
227
+ const history = getHistory(result.answerPage);
228
+ const items = getHistoryItems(history);
229
+ assert.equal(items.length, 2);
230
+ assert.match(items[0]!.text, /^\d+ days? ago \(oldest; this report\)$/);
231
+ assert.equal(items[0]!.href, null);
232
+ assert.match(items[1]!.text, /^\d+ days? ago \(latest\)$/);
233
+ assert.equal(items[1]!.href, '/listIssues.html/260202T0000/new');
234
+ assert.ok(!result.answerPage.includes('Should Kilotest retest the page?'));
235
+ });
236
+
237
+ test('listIssues History of the latest report ends with the retest question', async () => {
238
+ const result = await answer('260202T0000/new');
239
+ const history = getHistory(result.answerPage);
240
+ const items = getHistoryItems(history);
241
+ assert.equal(items.length, 2);
242
+ assert.match(items[0]!.text, /^\d+ days? ago \(oldest\)$/);
243
+ assert.equal(items[0]!.href, '/listIssues.html/260101T0000/mix');
244
+ assert.match(items[1]!.text, /^\d+ days? ago \(latest; this report\)$/);
245
+ assert.equal(items[1]!.href, null);
246
+ const retestLink = history.querySelector('ol + p > a');
247
+ assert.equal(retestLink?.text, 'Should Kilotest retest the page?');
248
+ assert.equal(retestLink?.getAttribute('href'), '/requestRetestForm.html/260202T0000/new');
249
+ });
250
+
251
+ test('listIssues History of the only report about a page says so and asks about retesting', async () => {
252
+ const result = await answer('260101T0001/ct');
253
+ const history = getHistory(result.answerPage);
254
+ assert.equal(getHistoryItems(history).length, 0);
255
+ const paragraphs = history.querySelectorAll('p');
256
+ assert.equal(paragraphs[0]?.text, 'This is the only report about the page.');
257
+ assert.equal(paragraphs[1]?.querySelector('a')?.getAttribute('href'), '/requestRetestForm.html/260101T0001/ct');
258
+ });
259
+
260
+ test('listIssues History tags only the ends of a list of 3 reports and the current report', async () => {
261
+ const fs = await import('node:fs/promises');
262
+ const mixJSON = await fs.readFile(path.join(realUtil.reportsPath(), '260101T0000-mix.json'), 'utf8');
263
+ const report = JSON.parse(mixJSON);
264
+ report.jobData.endTime = '26-01-15T00:10';
265
+ const reportPath = path.join(realUtil.reportsPath(), '260115T0000-mid.json');
266
+ await fs.writeFile(reportPath, JSON.stringify(report));
267
+ try {
268
+ // From the middle report:
269
+ let items = getHistoryItems(getHistory((await answer('260115T0000/mid')).answerPage));
270
+ assert.equal(items.length, 3);
271
+ assert.match(items[0]!.text, /\(oldest\)$/);
272
+ assert.match(items[1]!.text, /^\d+ days? ago \(this report\)$/);
273
+ assert.equal(items[1]!.href, null);
274
+ assert.match(items[2]!.text, /\(latest\)$/);
275
+ // From the latest report:
276
+ items = getHistoryItems(getHistory((await answer('260202T0000/new')).answerPage));
277
+ assert.match(items[1]!.text, /^\d+ days? ago$/);
278
+ assert.equal(items[1]!.href, '/listIssues.html/260115T0000/mid');
279
+ }
280
+ finally {
281
+ await fs.unlink(reportPath);
282
+ }
283
+ });
@@ -30,8 +30,7 @@
30
30
  </ul>
31
31
  <p>__noClaimed__</p>
32
32
  <h2>Already tested</h2>
33
- <p>Kilotest has tested __which__ pages.</p>
34
- __testedPages__
33
+ __testedPages__
35
34
  </main>
36
35
  </body>
37
36
  </html>
@@ -0,0 +1,99 @@
1
+ /*
2
+ index.ts
3
+ Lists all tested pages, with links to the latest reports about them.
4
+ */
5
+
6
+ // IMPORTS
7
+
8
+ import {
9
+ getJobNames,
10
+ getObject,
11
+ getReportExtracts,
12
+ getTestRequests,
13
+ jobsPath,
14
+ populateTemplate
15
+ } from '../../util.ts';
16
+ import path from 'node:path';
17
+
18
+ // FUNCTIONS
19
+
20
+ // Adds parameters to a query for the answer page.
21
+ const populateQuery = async (query: Record<string, any>) => {
22
+ const margin = ' '.repeat(8);
23
+ // Initialize the classes of lines.
24
+ const lines: {requests: string[], queue: string[], claimed: string[], tested: string[]} = {
25
+ requests: [],
26
+ queue: [],
27
+ claimed: [],
28
+ tested: []
29
+ };
30
+ // Get the test requests.
31
+ const testRequests = await getTestRequests();
32
+ // For each requested URL:
33
+ Object.keys(testRequests).forEach(url => {
34
+ // For each of its requests:
35
+ testRequests[url]!.forEach((req: any) => {
36
+ const {description, reason} = req;
37
+ // Add a line.
38
+ lines.requests.push(`${margin}<li><code>${url}</code> (${description}): ${reason}</li>`);
39
+ });
40
+ });
41
+ // Sort the lines in alphabetical order by URL and secondarily by proposed name.
42
+ lines.requests.sort();
43
+ // Add the lines to the query.
44
+ query.requests = lines.requests.join('\n');
45
+ // Add a no-requests message, if applicable, to the query.
46
+ query.noRequests = lines.requests.length
47
+ ? 'Kilotest managers can <a href="enqueueForm.html">approve or reject a request</a>.'
48
+ : 'No requests await approval now.';
49
+ // Get the file names of all queued and claimed jobs.
50
+ const jobFileNames = await getJobNames();
51
+ // For each job category:
52
+ for (const category of ['queue', 'claimed'] as const) {
53
+ // For each job in the category:
54
+ for (const fileName of jobFileNames[category]) {
55
+ // Get the job.
56
+ const job = await getObject(path.join(jobsPath(), category, fileName)) as {target: {url: string, what: string}};
57
+ // Add a line.
58
+ lines[category].push(`${margin}<li><code>${job.target.url}</code> (${job.target.what})</li>`);
59
+ }
60
+ // Add the lines to the query.
61
+ query[category] = lines[category].join('\n');
62
+ }
63
+ // Add a no-queued message, if applicable, to the query.
64
+ query.noQueued = lines.queue.length ? '' : 'No pages are queued for testing.';
65
+ // Add a no-claimed message, if applicable, to the query.
66
+ query.noClaimed = lines.claimed.length ? '' : 'No pages are being tested now.';
67
+ // Get extracts of the latest reports about all tested pages, sorted by page description.
68
+ const latestExtracts = await getReportExtracts(true);
69
+ const pageCount = latestExtracts.length;
70
+ // For each tested page:
71
+ for (const extract of latestExtracts) {
72
+ const {jobID, timeStamp, description} = extract;
73
+ // Add a link to the issues in the latest report about it to the lines.
74
+ const link = `<a href="listIssues.html/${timeStamp}/${jobID}" aria-label="Get test results for ${description}">${description}</a>`;
75
+ lines.tested.push(`${margin} <li>${link}</li>`);
76
+ }
77
+ // Add the lines to the query, as an introduced list if there are any, or else a no-pages message.
78
+ query.testedPages = pageCount
79
+ ? [
80
+ `${margin}<p>Get test results for:</p>`,
81
+ `${margin}<ul class="links">`,
82
+ ...lines.tested,
83
+ `${margin}</ul>`
84
+ ].join('\n')
85
+ : `${margin}<p>No pages have been tested yet.</p>`;
86
+ };
87
+ // Returns a page listing the tested pages.
88
+ export const answer = async () => {
89
+ const query: Record<string, any> = {};
90
+ // Create a query to replace placeholders.
91
+ await populateQuery(query);
92
+ // Get the populated template.
93
+ const answerPage = await populateTemplate(import.meta.dirname, query);
94
+ // Return the populated page.
95
+ return {
96
+ status: 'ok',
97
+ answerPage
98
+ };
99
+ };