@jrpool/kilotest 58.2.4 → 59.0.2

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 (66) hide show
  1. package/api/getReport.test.ts +1 -1
  2. package/api/listIssues.test.ts +32 -0
  3. package/api/listIssues.ts +38 -8
  4. package/api/listPages.test.ts +92 -0
  5. package/api/listPages.ts +93 -0
  6. package/api/orderNewTest.test.ts +2 -2
  7. package/api/orderNewTest.ts +4 -4
  8. package/api/orderRetest.test.ts +1 -1
  9. package/api/requestNewTest.test.ts +1 -1
  10. package/api/requestNewTest.ts +6 -6
  11. package/api/requestRetest.test.ts +3 -3
  12. package/api/requestRetest.ts +2 -2
  13. package/api/routes.test.ts +1 -1
  14. package/api/routes.ts +5 -5
  15. package/api/schemas.ts +24 -7
  16. package/api/util.ts +26 -45
  17. package/apiIntegration.test.ts +5 -5
  18. package/docs/AI-TOOLS.md +1 -1
  19. package/docs/SERVICE.md +16 -5
  20. package/index.test.ts +31 -19
  21. package/index.ts +25 -13
  22. package/lhm.plugin.json +1433 -141
  23. package/linkValidation.test.ts +2 -2
  24. package/llms-full.txt +9 -9
  25. package/llms.txt +4 -4
  26. package/mcp.test.ts +9 -9
  27. package/mcp.ts +14 -14
  28. package/openapi.yaml +84 -30
  29. package/package.json +1 -1
  30. package/routing.test.ts +5 -5
  31. package/server.json +1 -1
  32. package/sitemap.xml +1 -1
  33. package/smokeTest.ts +2 -2
  34. package/style.css +8 -8
  35. package/test/fixtures/buildFixtures.ts +3 -3
  36. package/test/fixtures/db/reports/260101T0000-mix.json +2 -2
  37. package/test/fixtures/db/reports/260202T0000-new.json +2 -2
  38. package/util.test.ts +31 -22
  39. package/util.ts +16 -5
  40. package/web/enqueueForm/index.html +1 -1
  41. package/web/hideReportForm/hideReportForm.test.ts +5 -0
  42. package/web/hideReportForm/index.ts +9 -6
  43. package/web/home/home.test.ts +19 -0
  44. package/{index.html → web/home/index.html} +2 -2
  45. package/web/home/index.ts +20 -0
  46. package/web/listDiagnoses/listDiagnoses.test.ts +1 -1
  47. package/web/listIssues/index.html +9 -10
  48. package/web/listIssues/index.ts +45 -3
  49. package/web/listIssues/listIssues.test.ts +82 -6
  50. package/web/{listReports → listPages}/index.html +1 -2
  51. package/web/listPages/index.ts +99 -0
  52. package/web/listPages/listPages.test.ts +196 -0
  53. package/web/listViolators/listViolators.test.ts +1 -1
  54. package/web/metrics/metrics.test.ts +2 -2
  55. package/web/requestNewTest/requestNewTest.test.ts +1 -1
  56. package/web/requestRetest/requestRetest.test.ts +1 -2
  57. package/web/requestRetestForm/index.ts +4 -2
  58. package/web/requestRetestForm/requestRetestForm.test.ts +12 -1
  59. package/web/unhideReportForm/index.ts +9 -6
  60. package/web/unhideReportForm/unhideReportForm.test.ts +8 -5
  61. package/web/util.test.ts +87 -23
  62. package/web/util.ts +27 -31
  63. package/api/listReports.test.ts +0 -109
  64. package/api/listReports.ts +0 -87
  65. package/web/listReports/index.ts +0 -182
  66. 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,
@@ -768,14 +769,14 @@ test('processTestRequest returns a duplicate error for a repeated request', {tim
768
769
  test('processTestRequest returns a reportExists error when a report already exists for the page', async () => {
769
770
  // The fixture database already has a report for this description and URL.
770
771
  const {result} = await processTestRequest(
771
- 'because accessibility', {description: 'Mixed Outcomes Page', url: 'https://example.com/mixed'}
772
+ 'because accessibility', {description: 'Mixed Outcomes', url: 'https://example.com/mixed'}
772
773
  );
773
774
  assert.equal(result, 'reportExists');
774
775
  });
775
776
 
776
777
  test('processTestRequest returns a reportExists error when a report exists about a page with the same description', async () => {
777
778
  const {result} = await processTestRequest(
778
- 'because accessibility', {description: 'Mixed Outcomes Page', url: 'https://example.com/drifted'}
779
+ 'because accessibility', {description: 'Mixed Outcomes', url: 'https://example.com/drifted'}
779
780
  );
780
781
  assert.equal(result, 'reportExists');
781
782
  });
@@ -907,7 +908,7 @@ test('annotateReportObject handles a test act with no standardResult', async ()
907
908
  });
908
909
 
909
910
  test('isReportAvailable returns true for a known page description', async () => {
910
- const result = await isReportAvailable('Mixed Outcomes Page', 'https://example.com/nonexistent');
911
+ const result = await isReportAvailable('Mixed Outcomes', 'https://example.com/nonexistent');
911
912
  assert.equal(result, true);
912
913
  });
913
914
 
@@ -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
  });
@@ -1426,7 +1435,7 @@ test('getReportExtract returns an extract for a valid report', async () => {
1426
1435
  const extract = await getReportExtract('260101T0000', 'mix') as any;
1427
1436
  assert.equal(extract.timeStamp, '260101T0000');
1428
1437
  assert.equal(extract.jobID, 'mix');
1429
- assert.equal(extract.description, 'Mixed Outcomes Page');
1438
+ assert.equal(extract.description, 'Mixed Outcomes');
1430
1439
  assert.equal(extract.url, 'https://example.com/mixed');
1431
1440
  assert.ok(extract.reportTime);
1432
1441
  });
@@ -1446,7 +1455,7 @@ test('getReportExtracts returns extracts of all available reports', async () =>
1446
1455
 
1447
1456
  test('getReportExtracts with onlyLatest returns only the latest report for each page', async () => {
1448
1457
  const latest = await getReportExtracts(true);
1449
- const mixReports = latest.filter(e => e.description === 'Mixed Outcomes Page');
1458
+ const mixReports = latest.filter(e => e.description === 'Mixed Outcomes');
1450
1459
  assert.equal(mixReports.length, 1);
1451
1460
  assert.equal(mixReports[0]!.timeStamp, '260202T0000');
1452
1461
  });
@@ -1464,7 +1473,7 @@ test('getReportExtracts reflects reports added, modified, and removed after a pr
1464
1473
  const firstPath = path.join(tmpReportsDir, '260101T0000-aaa.json');
1465
1474
  await fs.writeFile(firstPath, JSON.stringify(report));
1466
1475
  let extracts = await getReportExtracts();
1467
- assert.deepEqual(extracts.map(e => e.description), ['Mixed Outcomes Page']);
1476
+ assert.deepEqual(extracts.map(e => e.description), ['Mixed Outcomes']);
1468
1477
  // Modify the report and give it a later modification time, as reannotation would.
1469
1478
  report.target.what = 'Revised Page';
1470
1479
  await fs.writeFile(firstPath, JSON.stringify(report));
@@ -1572,11 +1581,11 @@ test('recordMetric increments an existing count rather than overwriting it', asy
1572
1581
  process.env.DB_DIR = tmpDir;
1573
1582
  try {
1574
1583
  const {recordMetric, metricsPath} = await import('./util.ts');
1575
- await recordMetric('mcpToolCalls', 'listReports');
1576
- await recordMetric('mcpToolCalls', 'listReports');
1584
+ await recordMetric('mcpToolCalls', 'listPages');
1585
+ await recordMetric('mcpToolCalls', 'listPages');
1577
1586
  await recordMetric('mcpToolCalls', 'getReport');
1578
1587
  const metrics = JSON.parse(fsSync.readFileSync(metricsPath(), 'utf8'));
1579
- assert.equal(metrics.mcpToolCalls.listReports, 2);
1588
+ assert.equal(metrics.mcpToolCalls.listPages, 2);
1580
1589
  assert.equal(metrics.mcpToolCalls.getReport, 1);
1581
1590
  }
1582
1591
  finally {
@@ -1627,11 +1636,11 @@ test('recordMetric keeps categories independent for the same name', async () =>
1627
1636
  process.env.DB_DIR = tmpDir;
1628
1637
  try {
1629
1638
  const {recordMetric, metricsPath} = await import('./util.ts');
1630
- await recordMetric('apiOperations', 'listReports');
1631
- await recordMetric('mcpToolCalls', 'listReports');
1639
+ await recordMetric('apiOperations', 'listPages');
1640
+ await recordMetric('mcpToolCalls', 'listPages');
1632
1641
  const metrics = JSON.parse(fsSync.readFileSync(metricsPath(), 'utf8'));
1633
- assert.equal(metrics.apiOperations.listReports, 1);
1634
- assert.equal(metrics.mcpToolCalls.listReports, 1);
1642
+ assert.equal(metrics.apiOperations.listPages, 1);
1643
+ assert.equal(metrics.mcpToolCalls.listPages, 1);
1635
1644
  }
1636
1645
  finally {
1637
1646
  process.env.DB_DIR = savedDbDir;
@@ -1686,7 +1695,7 @@ test('getMetrics backfills categories missing from a metrics.json written before
1686
1695
  // Simulate a metrics.json written before the managerActivity category was added.
1687
1696
  fsSync.writeFileSync(tmpDir + '/metrics.json', JSON.stringify({
1688
1697
  since: '260101T0000',
1689
- pageViews: {listReports: 1},
1698
+ pageViews: {listPages: 1},
1690
1699
  mcpToolCalls: {},
1691
1700
  apiOperations: {}
1692
1701
  }));
@@ -1698,7 +1707,7 @@ test('getMetrics backfills categories missing from a metrics.json written before
1698
1707
  await recordMetric('managerActivity', 'metrics', 'ok');
1699
1708
  const metrics = JSON.parse(fsSync.readFileSync(metricsPath(), 'utf8'));
1700
1709
  assert.equal(metrics.since, '260101T0000');
1701
- assert.equal(metrics.pageViews.listReports, 1);
1710
+ assert.equal(metrics.pageViews.listPages, 1);
1702
1711
  assert.deepEqual(metrics.managerActivity.metrics, {ok: 1, error: 0});
1703
1712
  }
1704
1713
  finally {
@@ -1759,14 +1768,14 @@ test('orderJob enqueues a new-page order directly into the job queue', async ()
1759
1768
 
1760
1769
  test('orderJob returns a reportExists error when a report already exists for the ordered page', async () => {
1761
1770
  const {result} = await orderJob(
1762
- {description: 'Mixed Outcomes Page', url: 'https://example.com/mixed'}, 'because accessibility matters here'
1771
+ {description: 'Mixed Outcomes', url: 'https://example.com/mixed'}, 'because accessibility matters here'
1763
1772
  );
1764
1773
  assert.equal(result, 'reportExists');
1765
1774
  });
1766
1775
 
1767
1776
  test('orderJob returns a reportExists error when a report exists about a page with the same description', async () => {
1768
1777
  const {result} = await orderJob(
1769
- {description: 'Mixed Outcomes Page', url: 'https://example.com/drifted'}, 'because accessibility matters here'
1778
+ {description: 'Mixed Outcomes', url: 'https://example.com/drifted'}, 'because accessibility matters here'
1770
1779
  );
1771
1780
  assert.equal(result, 'reportExists');
1772
1781
  });
@@ -1832,7 +1841,7 @@ test('orderJob returns "nonreport" when a retest order cites a report that does
1832
1841
  });
1833
1842
 
1834
1843
  test('orderJob returns "superseded" when a retest order cites a superseded report', async () => {
1835
- // 260101T0000-mix is an earlier report of "Mixed Outcomes Page" than 260202T0000-new,
1844
+ // 260101T0000-mix is an earlier report of "Mixed Outcomes" than 260202T0000-new,
1836
1845
  // so it is superseded.
1837
1846
  const {result} = await orderJob(
1838
1847
  {timeStamp: '260101T0000', jobID: 'mix'}, 'because accessibility matters here'
@@ -1845,12 +1854,12 @@ test('orderJob enqueues a retest order for the latest report of a page', async (
1845
1854
  {timeStamp: '260202T0000', jobID: 'new'}, 'because accessibility matters here'
1846
1855
  ) as any;
1847
1856
  assert.equal(result, 'ok');
1848
- assert.equal(description, 'Mixed Outcomes Page');
1857
+ assert.equal(description, 'Mixed Outcomes');
1849
1858
  assert.equal(url, 'https://example.com/mixed');
1850
1859
  const queuedPath = path.join(jobsPath(), 'queue', `${jobID}.json`);
1851
1860
  try {
1852
1861
  const job = JSON.parse(await fs.readFile(queuedPath, 'utf8'));
1853
- assert.equal(job.target.what, 'Mixed Outcomes Page');
1862
+ assert.equal(job.target.what, 'Mixed Outcomes');
1854
1863
  }
1855
1864
  finally {
1856
1865
  await fs.unlink(queuedPath);
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 {
@@ -861,6 +866,12 @@ export const getReportExtracts = async (onlyLatest: boolean = false): Promise<Re
861
866
  // Return the array, excluding extracts of superseded reports if so specified.
862
867
  return onlyLatest ? extracts.filter(extract => !extract.superseded) : extracts;
863
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
+ };
864
875
  // Returns whether a report with a description or URL is available.
865
876
  export const isReportAvailable = async (description: string, url: string): Promise<boolean> => {
866
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>`
@@ -0,0 +1,19 @@
1
+ /*
2
+ home.test.ts
3
+ Unit tests for web/home/index.ts, covering the answer export.
4
+ */
5
+
6
+ // IMPORTS
7
+
8
+ import {test} from 'node:test';
9
+ import assert from 'node:assert/strict';
10
+ import {answer} from './index.ts';
11
+
12
+ // TESTS
13
+
14
+ test('answer returns the home page', async () => {
15
+ const result = await answer();
16
+ assert.equal(result.status, 'ok');
17
+ assert.ok(result.answerPage.includes('<h1>Kilotest</h1>'));
18
+ assert.ok(result.answerPage.includes('application/ld+json'));
19
+ });
@@ -87,10 +87,10 @@
87
87
  </details>
88
88
  <h2>What do you want to know?</h2>
89
89
  <ul class="nav">
90
- <li><a href="listReports.html">Which web pages have been tested?</a></li>
90
+ <li><a href="listPages.html">Which web pages have been tested?</a></li>
91
91
  <li><a href="listTopIssues.html">Which issues have been reported?</a></li>
92
92
  <li><a href="manage.html">What can Kilotest managers do?</a></li>
93
- <li><a href="privacy.html">What is the privacy policy?</a></li>
93
+ <li><a href="privacy.html">Privacy policy</a></li>
94
94
  </ul>
95
95
  </main>
96
96
  </body>
@@ -0,0 +1,20 @@
1
+ /*
2
+ index.ts
3
+ Serves the home page.
4
+ */
5
+
6
+ // IMPORTS
7
+
8
+ import fs from 'node:fs/promises';
9
+ import path from 'node:path';
10
+
11
+ // FUNCTIONS
12
+
13
+ // Returns the home page.
14
+ export const answer = async () => {
15
+ const answerPage = await fs.readFile(path.join(import.meta.dirname, 'index.html'), 'utf8');
16
+ return {
17
+ status: 'ok',
18
+ answerPage
19
+ };
20
+ };
@@ -200,7 +200,7 @@ test('listDiagnoses returns an error for an unknown issue with a valid report',
200
200
 
201
201
  test('listDiagnoses returns an error when report facts are not obtained', async () => {
202
202
  pageDataStringsOverride = {
203
- what: 'Mixed Outcomes Page',
203
+ what: 'Mixed Outcomes',
204
204
  url: 'https://example.com/mixed',
205
205
  urlLink: '<a href="https://example.com/mixed">https://example.com/mixed</a>'
206
206
  };
@@ -15,17 +15,9 @@
15
15
  <h1><a href="/">Kilotest</a>: Issues reported for __target__</h1>
16
16
 
17
17
  <h2>About the tests</h2>
18
- <details>
19
- <summary>How to deal with the issues</summary>
20
- <h3>Do it yourself</h3>
21
- <p>Explore the issues below in as much detail as you want. Decide which ones are actual defects. Plan and implement corrections.</p>
22
- <h3>Or use AI</h3>
23
- <p>Make Kilotest available to your AI assistant. There are <a href="/tutorialAI.html">step-by-step instructions</a> for this.</p>
24
- <p>After that, you can ask:</p>
25
- <blockquote>Kilotest reports defects on the __target__ web page. Summarize them and propose a plan to resolve the genuine ones.</blockquote>
26
- </details>
27
18
  __pageFacts__
28
- <p><a href="/requestRetestForm.html/__timeStamp__/__jobID__">Should Kilotest retest the page?</a></p>
19
+ <h2>History</h2>
20
+ __history__
29
21
  <h2>Issues reported</h2>
30
22
  <details>
31
23
  <summary>Highest priority: __highestCount__</summary>
@@ -51,6 +43,13 @@ __pageFacts__
51
43
  __lowestDetails__
52
44
  </ul>
53
45
  </details>
46
+ <h2>What you can do</h2>
47
+ <p><strong>Choose how</strong> to address these issues:</p>
48
+ <h3>Do it yourself</h3>
49
+ <p>Explore the issues, following the links above. Decide which issues are actual defects. Plan and implement corrections. Then retest the page.</p>
50
+ <h3>Use artificial intelligence</h3>
51
+ <p><a href="/tutorialAI.html">Make Kilotest available to an AI assistant</a>. Then give it a prompt like this:</p>
52
+ <blockquote><q>Kilotest reports issues on the __target__ web page. Describe them and propose a plan to resolve the genuine defects.</q></blockquote>
54
53
  </main>
55
54
  </body>
56
55
  </html>
@@ -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;
@@ -58,17 +58,17 @@ test('listIssues returns an ok status with valid HTML for the mixed report', asy
58
58
 
59
59
  test('listIssues includes the page description in the HTML for the mixed report', async () => {
60
60
  const result = await answer('260101T0000/mix');
61
- assert.ok(result.answerPage.includes('Mixed Outcomes Page'));
61
+ assert.ok(result.answerPage.includes('Mixed Outcomes'));
62
62
  });
63
63
 
64
64
  test('listIssues interpolates the page description into the AI prompt block quote', async () => {
65
65
  const result = await answer('260101T0000/mix');
66
66
  const html = parse(result.answerPage);
67
- const blockQuote = html.querySelector('details blockquote');
67
+ const blockQuote = html.querySelector('blockquote');
68
68
  assert.ok(blockQuote);
69
- assert.ok(
70
- blockQuote.textContent.startsWith('Kilotest reports defects on the Mixed Outcomes Page web page.')
71
- );
69
+ assert.ok(blockQuote.textContent.startsWith(
70
+ 'Kilotest reports issues on the Mixed Outcomes web page.'
71
+ ));
72
72
  // No placeholder may remain uninterpolated.
73
73
  assert.ok(!blockQuote.textContent.includes('__target__'));
74
74
  });
@@ -153,7 +153,7 @@ test('listIssues returns an error when getPageDataStrings fails after getReport
153
153
 
154
154
  test('listIssues returns an error when report facts are not obtained', async () => {
155
155
  pageDataStringsOverride = {
156
- what: 'Mixed Outcomes Page',
156
+ what: 'Mixed Outcomes',
157
157
  url: 'https://example.com/mixed',
158
158
  urlLink: '<a href="https://example.com/mixed">https://example.com/mixed</a>'
159
159
  };
@@ -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>