@jrpool/kilotest 58.2.4 → 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 +20 -11
  33. package/util.ts +16 -5
  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 -21
  49. package/web/util.ts +27 -31
  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.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>`
@@ -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
+ };
@@ -0,0 +1,196 @@
1
+ /*
2
+ listPages.test.ts
3
+ UI tests for web/listPages/index.ts using the fixture corpus.
4
+ */
5
+
6
+ // IMPORTS
7
+
8
+ import {test, before, after} from 'node:test';
9
+ import assert from 'node:assert/strict';
10
+ import path from 'node:path';
11
+ import fs from 'node:fs/promises';
12
+ import {parse} from 'node-html-parser';
13
+ import {answer} from './index.ts';
14
+
15
+ // SETUP AND TEARDOWN
16
+
17
+ const savedDBDir = process.env.DB_DIR;
18
+
19
+ before(async () => {
20
+ process.env.DB_DIR = (await import('../../test/dbFixture.ts')).fixtureDBDir;
21
+ // Ensure job subdirectories exist (they are empty and not tracked by Git).
22
+ for (const sub of ['queue', 'claimed', 'failed']) {
23
+ await fs.mkdir(path.join(process.env.DB_DIR, 'jobs', sub), {recursive: true});
24
+ }
25
+ });
26
+
27
+ after(() => {
28
+ if (savedDBDir !== undefined) {
29
+ process.env.DB_DIR = savedDBDir;
30
+ }
31
+ else {
32
+ delete process.env.DB_DIR;
33
+ }
34
+ });
35
+
36
+ // TESTS
37
+
38
+ test('listPages returns an ok status with valid HTML', async () => {
39
+ const result: any = await answer();
40
+ assert.equal(result.status, 'ok');
41
+ assert.ok(result.answerPage);
42
+ const html = parse(result.answerPage);
43
+ assert.equal(html.querySelector('title')?.text, 'Pages tested | Kilotest');
44
+ });
45
+
46
+ // Returns the links in the list of tested pages.
47
+ const getPageLinks = (answerPage: string) => {
48
+ const html = parse(answerPage);
49
+ return html.querySelectorAll('main > ul > li > a');
50
+ };
51
+
52
+ test('listPages lists each of the 7 tested pages once, by description', async () => {
53
+ const result: any = await answer();
54
+ const links = getPageLinks(result.answerPage);
55
+ assert.deepEqual(
56
+ links.map(a => a.text),
57
+ [
58
+ 'All CantTell Page',
59
+ 'Branch Coverage Page',
60
+ 'Empty Results Page',
61
+ 'Mixed Outcomes Page',
62
+ 'Multi Violator Page',
63
+ 'No Outcomes Page',
64
+ 'Prevented Page'
65
+ ]
66
+ );
67
+ });
68
+
69
+ test('listPages introduces the list of tested pages', async () => {
70
+ const result: any = await answer();
71
+ assert.ok(result.answerPage.includes('<p>Get test results for:</p>'));
72
+ assert.ok(!result.answerPage.includes('No pages have been tested yet.'));
73
+ });
74
+
75
+ test('listPages links each page to the issues in its latest report', async () => {
76
+ const result: any = await answer();
77
+ const links = getPageLinks(result.answerPage);
78
+ const mixedLink = links.find(a => a.text === 'Mixed Outcomes Page');
79
+ assert.equal(mixedLink?.getAttribute('href'), 'listIssues.html/260202T0000/new');
80
+ const canttellLink = links.find(a => a.text === 'All CantTell Page');
81
+ assert.equal(canttellLink?.getAttribute('href'), 'listIssues.html/260101T0001/ct');
82
+ });
83
+
84
+ test('listPages contains no report summaries', async () => {
85
+ const result: any = await answer();
86
+ const html = parse(result.answerPage);
87
+ assert.equal(html.querySelectorAll('details').length, 0);
88
+ assert.ok(!result.answerPage.includes('https://example.com/mixed'));
89
+ });
90
+
91
+ test('listPages does not include the hidden report', async () => {
92
+ const result: any = await answer();
93
+ assert.ok(!result.answerPage.includes('Hidden Page'));
94
+ });
95
+
96
+ test('listPages includes a link to request testing a new page', async () => {
97
+ const result: any = await answer();
98
+ const html = parse(result.answerPage);
99
+ const testLink = html.querySelector('a[href="requestNewTestForm.html"]');
100
+ assert.ok(testLink);
101
+ });
102
+
103
+ test('listPages shows requests when testRequests.json has entries', {timeout: 500}, async () => {
104
+ const dbDir = (await import('../../test/dbFixture.ts')).fixtureDBDir;
105
+ const testRequestsPath = path.join(dbDir, 'jobs', 'testRequests.json');
106
+ const originalTestRequests = await fs.readFile(testRequestsPath, 'utf8');
107
+ try {
108
+ const testRequests = {
109
+ 'https://example.com/mixed': [
110
+ {description: 'Mixed Outcomes Page', reason: 'Needs retesting for accessibility'}
111
+ ]
112
+ };
113
+ await fs.writeFile(testRequestsPath, JSON.stringify(testRequests, null, 2));
114
+ const result: any = await answer();
115
+ assert.equal(result.status, 'ok');
116
+ assert.ok(result.answerPage.includes('https://example.com/mixed'));
117
+ assert.ok(result.answerPage.includes('Needs retesting for accessibility'));
118
+ }
119
+ finally {
120
+ await fs.writeFile(testRequestsPath, originalTestRequests);
121
+ }
122
+ });
123
+
124
+ test('listPages shows queued and claimed jobs when they exist', async () => {
125
+ const dbDir = (await import('../../test/dbFixture.ts')).fixtureDBDir;
126
+ const queueDir = path.join(dbDir, 'jobs', 'queue');
127
+ const claimedDir = path.join(dbDir, 'jobs', 'claimed');
128
+ const queueFile = path.join(queueDir, 'queuedJob.json');
129
+ const claimedFile = path.join(claimedDir, 'claimedJob.json');
130
+ try {
131
+ const queuedJob = {
132
+ target: {url: 'https://example.com/queued', what: 'Queued Page'}
133
+ };
134
+ const claimedJob = {
135
+ target: {url: 'https://example.com/claimed', what: 'Claimed Page'}
136
+ };
137
+ await fs.writeFile(queueFile, JSON.stringify(queuedJob, null, 2));
138
+ await fs.writeFile(claimedFile, JSON.stringify(claimedJob, null, 2));
139
+ const result: any = await answer();
140
+ assert.equal(result.status, 'ok');
141
+ assert.ok(result.answerPage.includes('https://example.com/queued'));
142
+ assert.ok(result.answerPage.includes('Queued Page'));
143
+ assert.ok(result.answerPage.includes('https://example.com/claimed'));
144
+ assert.ok(result.answerPage.includes('Claimed Page'));
145
+ }
146
+ finally {
147
+ await fs.unlink(queueFile).catch(() => {});
148
+ await fs.unlink(claimedFile).catch(() => {});
149
+ }
150
+ });
151
+
152
+ test('listPages lists a page whose report is not usable', {timeout: 500}, async () => {
153
+ const dbDir = (await import('../../test/dbFixture.ts')).fixtureDBDir;
154
+ const reportsDir = path.join(dbDir, 'reports');
155
+ const invalidReportPath = path.join(reportsDir, '260101T9999-bad.json');
156
+ try {
157
+ // Valid for getReportExtract (has target and jobData.endTime) but not usable by isUsableReport (no acts or catalog).
158
+ const invalidReport = {
159
+ target: {what: 'Invalid Report', url: 'https://example.com/invalid'},
160
+ jobData: {endTime: '26-01-01T00:10'}
161
+ };
162
+ await fs.writeFile(invalidReportPath, JSON.stringify(invalidReport, null, 2));
163
+ const result: any = await answer();
164
+ assert.equal(result.status, 'ok');
165
+ const links = getPageLinks(result.answerPage);
166
+ assert.ok(links.some(a => a.text === 'Invalid Report'));
167
+ }
168
+ finally {
169
+ await fs.unlink(invalidReportPath).catch(() => {});
170
+ }
171
+ });
172
+
173
+ test('listPages shows no-reports message when the database is empty', async () => {
174
+ // Create a temp DB with empty reports and jobs directories.
175
+ const os = await import('node:os');
176
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'listpages-empty-'));
177
+ await fs.mkdir(path.join(tmpDir, 'reports'), {recursive: true});
178
+ await fs.mkdir(path.join(tmpDir, 'jobs', 'queue'), {recursive: true});
179
+ await fs.mkdir(path.join(tmpDir, 'jobs', 'claimed'), {recursive: true});
180
+ await fs.mkdir(path.join(tmpDir, 'jobs', 'failed'), {recursive: true});
181
+ await fs.writeFile(path.join(tmpDir, 'jobs', 'testRequests.json'), '{}\n');
182
+ const savedDBDir = process.env.DB_DIR;
183
+ process.env.DB_DIR = tmpDir;
184
+ try {
185
+ const {answer} = await import('./index.ts');
186
+ const result: any = await answer();
187
+ assert.equal(result.status, 'ok');
188
+ assert.ok(result.answerPage.includes('<p>No pages have been tested yet.</p>'));
189
+ assert.ok(!result.answerPage.includes('Get test results for:'));
190
+ assert.equal(getPageLinks(result.answerPage).length, 0);
191
+ }
192
+ finally {
193
+ process.env.DB_DIR = savedDBDir;
194
+ await fs.rm(tmpDir, {recursive: true}).catch(() => {});
195
+ }
196
+ });
@@ -103,7 +103,7 @@ test('metrics reports recorded counts with a valid auth code on a POST request',
103
103
  await fs.writeFile(metricsPath(), getJSON({
104
104
  since: '260101T0000',
105
105
  pageViews: {tutorialWeb: 3},
106
- mcpToolCalls: {listReports: 5},
106
+ mcpToolCalls: {listPages: 5},
107
107
  apiOperations: {},
108
108
  managerActivity: {}
109
109
  }));
@@ -177,7 +177,7 @@ test('metrics clears all counts and shows a confirmation when clearCounts is sub
177
177
  await fs.writeFile(metricsPath(), getJSON({
178
178
  since: '260101T0000',
179
179
  pageViews: {tutorialWeb: 9},
180
- mcpToolCalls: {listReports: 3},
180
+ mcpToolCalls: {listPages: 3},
181
181
  apiOperations: {getReport: 1},
182
182
  managerActivity: {reannotate: {ok: 1, error: 2}}
183
183
  }));
@@ -31,12 +31,14 @@ export const answer = async (pageArgs: string) => {
31
31
  // Make the form report the failure.
32
32
  target = 'The specified page is not available for retesting';
33
33
  }
34
+ // Get the completion time of the report, or an invalid time if the report is unavailable.
35
+ const reportTime = new Date(reportExtract?.reportTime ?? NaN);
34
36
  const query: Record<string, string> = {
35
37
  target,
36
38
  timeStamp,
37
39
  jobID,
38
- ago: getAgoString(timeStamp),
39
- dateTime: getDateTimeString(timeStamp)
40
+ ago: getAgoString(reportTime),
41
+ dateTime: getDateTimeString(reportTime)
40
42
  };
41
43
  // Get the recommendation form template.
42
44
  const answerPage = await populateTemplate(import.meta.dirname, query);
@@ -51,6 +51,17 @@ test('requestRetestForm shows the fallback message for a nonexistent report', as
51
51
  assert.ok(result.answerPage.includes('The specified page is not available for retesting'));
52
52
  });
53
53
 
54
+ test('requestRetestForm dates the report by its completion time', async () => {
55
+ const result = await answer('260202T0000/new');
56
+ assert.ok(result.answerPage.includes('on 2026-02-02 at 00:10'));
57
+ });
58
+
59
+ test('requestRetestForm describes the time of a nonexistent report as unknown', async () => {
60
+ const result = await answer('999999T9999/nope');
61
+ assert.ok(result.answerPage.includes('an unknown number of days ago'));
62
+ assert.ok(result.answerPage.includes('on an unknown date at an unknown time'));
63
+ });
64
+
54
65
  test('requestRetestForm includes the ago and dateTime placeholders replaced', async () => {
55
66
  const result = await answer('260202T0000/new');
56
67
  assert.ok(!result.answerPage.includes('__ago__'));