@jrpool/kilotest 59.0.0 → 59.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -35,7 +35,7 @@ test('getReport returns the full report for a valid report', async () => {
35
35
  assert.ok((content['size of the report in bytes'] as number) > 0);
36
36
  const report = content['full report'] as any;
37
37
  assert.equal(report.id, '260101T0000-mix');
38
- assert.equal(report.target.what, 'Mixed Outcomes Page');
38
+ assert.equal(report.target.what, 'Mixed Outcomes');
39
39
  assert.equal(report.target.url, 'https://example.com/mixed');
40
40
  assert.equal(report.acts.length, 2);
41
41
  });
@@ -38,7 +38,7 @@ test('listPages returns one entry per tested page, sorted by page description',
38
38
  'All CantTell Page',
39
39
  'Branch Coverage Page',
40
40
  'Empty Results Page',
41
- 'Mixed Outcomes Page',
41
+ 'Mixed Outcomes',
42
42
  'Multi Violator Page',
43
43
  'No Outcomes Page',
44
44
  'Prevented Page'
@@ -56,7 +56,7 @@ test('listPages does not include the page of the hidden report', async () => {
56
56
  test('listPages describes a multi-report page by its latest report and its report count', async () => {
57
57
  const body = await response();
58
58
  const pagesBasics = body['response content']['basics about all tested pages'];
59
- const mixed = pagesBasics.find(b => b['tested web page'].description === 'Mixed Outcomes Page')!;
59
+ const mixed = pagesBasics.find(b => b['tested web page'].description === 'Mixed Outcomes')!;
60
60
  assert.equal(mixed['number of reports about the page'], 2);
61
61
  const latest = mixed['basics about the latest report'];
62
62
  assert.equal(latest.identifier, '260202T0000-new');
@@ -115,7 +115,7 @@ test('orderNewTest rejects a URL that redirects to a disallowed target, and aler
115
115
  });
116
116
 
117
117
  test('orderNewTest rejects an already-tested page', async () => {
118
- const body = await response(['Mixed Outcomes Page', 'https://example.com/mixed', 'A reason that is long enough.']);
118
+ const body = await response(['Mixed Outcomes', 'https://example.com/mixed', 'A reason that is long enough.']);
119
119
  const details = body['response content']['details about your order'] as any;
120
120
  assert.equal(details.error, undefined);
121
121
  const disposition = body['response content']['disposition of your order'] as any;
@@ -87,7 +87,7 @@ test('orderRetest rejects a reason longer than 100 characters', async () => {
87
87
  });
88
88
 
89
89
  test('orderRetest rejects a superseded report', async () => {
90
- // 260101T0000-mix is an earlier report of "Mixed Outcomes Page" than 260202T0000-new,
90
+ // 260101T0000-mix is an earlier report of "Mixed Outcomes" than 260202T0000-new,
91
91
  // so it is superseded.
92
92
  const body = await response(['260101T0000', 'mix', 'A reason that is long enough.']);
93
93
  const details = body['response content']['details about your order'] as any;
@@ -110,7 +110,7 @@ test('requestNewTest rejects a URL that redirects to a disallowed target, and al
110
110
  });
111
111
 
112
112
  test('requestNewTest rejects an already-tested page', async () => {
113
- const body = await response(['Mixed Outcomes Page', 'https://example.com/mixed', 'A reason that is long enough.']);
113
+ const body = await response(['Mixed Outcomes', 'https://example.com/mixed', 'A reason that is long enough.']);
114
114
  const details = body['response content']['details about your request'] as any;
115
115
  // A report for this description and URL already exists, so processTestRequest
116
116
  // returns 'reportExists' and the failure is reported via the disposition, not details.error.
@@ -85,7 +85,7 @@ test('requestRetest rejects a malformed timestamp or job identifier', async () =
85
85
  });
86
86
 
87
87
  test('requestRetest rejects a superseded report', async () => {
88
- // 260101T0000-mix is an earlier report of "Mixed Outcomes Page" than 260202T0000-new,
88
+ // 260101T0000-mix is an earlier report of "Mixed Outcomes" than 260202T0000-new,
89
89
  // so it is superseded and processTestRequest returns 'superseded', reported via the
90
90
  // disposition rather than details.error.
91
91
  const body = await response(['260101T0000', 'mix', 'A reason that is long enough.']);
@@ -168,10 +168,10 @@ test('requestRetest accepts a valid retest request for the latest report of a pa
168
168
  const body = await response(['260202T0000', 'new', 'A reason that is long enough.']);
169
169
  const details = body['response content']['details about your request'] as any;
170
170
  assert.equal(details.error, undefined);
171
- assert.equal(details['page to be retested'].description, 'Mixed Outcomes Page');
171
+ assert.equal(details['page to be retested'].description, 'Mixed Outcomes');
172
172
  assert.ok(logged.some(line =>
173
173
  line.startsWith('WARNING (Kilotest: request for a retest awaits approval)')
174
- && line.includes('Mixed Outcomes Page')
174
+ && line.includes('Mixed Outcomes')
175
175
  && line.includes('https://example.com/mixed')
176
176
  // The alert reports the resulting queue size, so a maintainer who has been away can
177
177
  // see at a glance how close the queue is to full.
package/docs/AI-TOOLS.md CHANGED
@@ -21,7 +21,7 @@ The internal features that make Kilotest a collection of tools for language mode
21
21
 
22
22
  - An API, with specific functional and utility modules in the `api` directory.
23
23
  - Tests of the functionalities of the API.
24
- - A [`JSON-LD`](https://json-ld.org/) script in the `index.html` file, providing structured data about the Kilotest API.
24
+ - A [`JSON-LD`](https://json-ld.org/) script in the `web/home/index.html` file, providing structured data about the Kilotest API.
25
25
  - Environment variables in the `env.example` file.
26
26
  - An `llms.txt` file and an `llms-full.txt` file, documenting the use of Kilotest by language models, conforming to the [llms-txt](https://llmstxt.org/) specification.
27
27
  - A generated `openapi.yaml` file, documenting the Kilotest API, conforming to the [OpenAPI specification](https://spec.openapis.org/oas/v3.1.0).
package/index.test.ts CHANGED
@@ -471,7 +471,7 @@ test('POST /requestNewTest.html with valid data returns an HTML page', async ()
471
471
 
472
472
  test('POST /requestNewTest.html with an already-tested URL returns an error', async () => {
473
473
  const res = await formRequest('POST', '/requestNewTest.html', {
474
- description: 'Mixed Outcomes Page',
474
+ description: 'Mixed Outcomes',
475
475
  url: 'https://example.com/mixed',
476
476
  why: 'Because accessibility matters'
477
477
  });
package/index.ts CHANGED
@@ -31,7 +31,8 @@ import {
31
31
  jobsPath,
32
32
  metricsExclusionCookieName,
33
33
  recordMetric,
34
- reportsPath
34
+ reportsPath,
35
+ unusableReportsPath
35
36
  } from './util.ts';
36
37
  import {sendAlert} from './alerts.ts';
37
38
  import {checkBalancesForAlerts} from './balances.ts';
@@ -40,6 +41,7 @@ import {handleMCP, mcpPath} from './mcp.ts';
40
41
  import fs from 'node:fs/promises';
41
42
  import {handleComment as handleTutorialWebComment} from './web/tutorialWeb/index.ts';
42
43
  import {answer as tutorialWeb} from './web/tutorialWeb/index.ts';
44
+ import {answer as home} from './web/home/index.ts';
43
45
  import {answer as privacy} from './web/privacy/index.ts';
44
46
  import {answer as tutorialAI, handleComment as handleTutorialAIComment} from './web/tutorialAI/index.ts';
45
47
  import http, {type IncomingMessage, type ServerResponse} from 'node:http';
@@ -642,10 +644,10 @@ const handleRequest = async (request: IncomingMessage, response: ServerResponse)
642
644
  else if (['/', '/index.html'].includes(pathname)) {
643
645
  await recordPageMetric('pageViews', 'index');
644
646
  // Get the home page.
645
- const homePage = await fs.readFile('index.html', 'utf8');
647
+ const {answerPage} = await home();
646
648
  // Serve it.
647
649
  setHeaders('text/html', '/index.html', 'medium');
648
- response.end(homePage);
650
+ response.end(answerPage);
649
651
  }
650
652
  // Otherwise, if it is for the crawler specification:
651
653
  else if (pageName === 'robots.txt') {
@@ -1386,7 +1388,9 @@ export {requestHandler};
1386
1388
 
1387
1389
  const serve = async (protocolModule: typeof http | typeof https, options: {key?: string; cert?: string}) => {
1388
1390
  // Create any missing directories.
1389
- for (const path of [queuePath(), claimedPath(), failedPath(), hiddenReportsPath(), reportsPath()]) {
1391
+ for (const path of [
1392
+ queuePath(), claimedPath(), failedPath(), hiddenReportsPath(), reportsPath(), unusableReportsPath()
1393
+ ]) {
1390
1394
  await fs.mkdir(path, {recursive: true});
1391
1395
  }
1392
1396
  const server = protocolModule === https
package/mcp.test.ts CHANGED
@@ -225,7 +225,7 @@ test('orderRetest handler returns content and structuredContent, and enqueues a
225
225
  const {jobsPath} = await import('./util.ts');
226
226
  const server = createMCPServer();
227
227
  const countBefore = getToolCallCount('orderRetest');
228
- // 260202T0000-new is the latest report of "Mixed Outcomes Page" (260101T0000-mix is an
228
+ // 260202T0000-new is the latest report of "Mixed Outcomes" (260101T0000-mix is an
229
229
  // earlier, superseded report of the same page), so this order is accepted.
230
230
  const result = await (server as any)._registeredTools.orderRetest.handler({
231
231
  timeStamp: '260202T0000',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jrpool/kilotest",
3
- "version": "59.0.0",
3
+ "version": "59.0.4",
4
4
  "description": "Ensemble testing for front-end web quality",
5
5
  "type": "module",
6
6
  "main": "index.ts",
package/test/dbFixture.ts CHANGED
@@ -24,6 +24,7 @@ fs.cpSync(path.join(import.meta.dirname, 'fixtures', 'db'), fixtureDBDir, {recur
24
24
  for (const category of ['queue', 'claimed', 'failed']) {
25
25
  fs.mkdirSync(path.join(fixtureDBDir, 'jobs', category), {recursive: true});
26
26
  }
27
+ fs.mkdirSync(path.join(fixtureDBDir, 'unusableReports'), {recursive: true});
27
28
 
28
29
  // Remove the copy when the process exits.
29
30
  process.on('exit', () => fs.rmSync(fixtureDBDir, {recursive: true, force: true}));
@@ -114,6 +114,7 @@ const main = async () => {
114
114
  await fs.mkdir(path.join(targetDir, 'jobs', 'claimed'), {recursive: true});
115
115
  await fs.mkdir(path.join(targetDir, 'jobs', 'failed'), {recursive: true});
116
116
  await fs.mkdir(path.join(targetDir, 'hiddenReports'), {recursive: true});
117
+ await fs.mkdir(path.join(targetDir, 'unusableReports'), {recursive: true});
117
118
 
118
119
  // Fixture 1: mixedOutcomes. Two rule engines, four instances: two failed (same issue, same violator, two engines), one cantTell (excluded from counts), one failed (different issue, different violator). Two distinct issues (linkNoText weight 4, allCaps weight 1) and two distinct violator catalog indexes (0 and 1).
119
120
  const mixedCatalog = {
@@ -136,7 +137,7 @@ const main = async () => {
136
137
  ];
137
138
  await writeJSON(
138
139
  path.join(reportsDir, '260101T0000-mix.json'),
139
- report('260101T0000-mix', 'Mixed Outcomes Page',
140
+ report('260101T0000-mix', 'Mixed Outcomes',
140
141
  'https://example.com/mixed', mixedActs, mixedCatalog)
141
142
  );
142
143
 
@@ -188,7 +189,7 @@ const main = async () => {
188
189
  ];
189
190
  await writeJSON(
190
191
  path.join(reportsDir, '260202T0000-new.json'),
191
- report('260202T0000-new', 'Mixed Outcomes Page',
192
+ report('260202T0000-new', 'Mixed Outcomes',
192
193
  'https://example.com/mixed', newerActs, mixedCatalog, '26-02-02T00:10')
193
194
  );
194
195
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "260101T0000-mix",
3
- "what": "Mixed Outcomes Page",
3
+ "what": "Mixed Outcomes",
4
4
  "strict": false,
5
5
  "standard": "only",
6
6
  "device": {
@@ -10,7 +10,7 @@
10
10
  "creationTimeStamp": "260101T0000",
11
11
  "executionTimeStamp": "260101T0000",
12
12
  "target": {
13
- "what": "Mixed Outcomes Page",
13
+ "what": "Mixed Outcomes",
14
14
  "url": "https://example.com/mixed"
15
15
  },
16
16
  "sources": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "260202T0000-new",
3
- "what": "Mixed Outcomes Page",
3
+ "what": "Mixed Outcomes",
4
4
  "strict": false,
5
5
  "standard": "only",
6
6
  "device": {
@@ -10,7 +10,7 @@
10
10
  "creationTimeStamp": "260202T0000",
11
11
  "executionTimeStamp": "260202T0000",
12
12
  "target": {
13
- "what": "Mixed Outcomes Page",
13
+ "what": "Mixed Outcomes",
14
14
  "url": "https://example.com/mixed"
15
15
  },
16
16
  "sources": {
package/util.test.ts CHANGED
@@ -24,6 +24,7 @@ import {
24
24
  awaitJob,
25
25
  checkCommentDuplicate,
26
26
  checkLength,
27
+ quarantineReport,
27
28
  createLock,
28
29
  dbPath,
29
30
  describeMax,
@@ -61,7 +62,8 @@ import {
61
62
  processTestRequest,
62
63
  testRequestsLock,
63
64
  testRequestsPath,
64
- reportsPath
65
+ reportsPath,
66
+ unusableReportsPath
65
67
  } from './util.ts';
66
68
 
67
69
  // TESTS
@@ -142,7 +144,7 @@ test('dbPath honors DB_DIR when it is set', () => {
142
144
  }
143
145
  });
144
146
 
145
- test('jobsPath, testRequestsPath, reportsPath, and hiddenReportsPath derive from DB_DIR', () => {
147
+ test('jobsPath, testRequestsPath, reportsPath, hiddenReportsPath, and unusableReportsPath derive from DB_DIR', () => {
146
148
  const saved = process.env.DB_DIR;
147
149
  process.env.DB_DIR = '/tmp/kilotest-fixtures';
148
150
  try {
@@ -150,6 +152,7 @@ test('jobsPath, testRequestsPath, reportsPath, and hiddenReportsPath derive from
150
152
  assert.equal(testRequestsPath(), path.join('/tmp/kilotest-fixtures', 'jobs', 'testRequests.json'));
151
153
  assert.equal(reportsPath(), path.join('/tmp/kilotest-fixtures', 'reports'));
152
154
  assert.equal(hiddenReportsPath(), path.join('/tmp/kilotest-fixtures', 'hiddenReports'));
155
+ assert.equal(unusableReportsPath(), path.join('/tmp/kilotest-fixtures', 'unusableReports'));
153
156
  }
154
157
  finally {
155
158
  if (saved !== undefined) {
@@ -769,14 +772,14 @@ test('processTestRequest returns a duplicate error for a repeated request', {tim
769
772
  test('processTestRequest returns a reportExists error when a report already exists for the page', async () => {
770
773
  // The fixture database already has a report for this description and URL.
771
774
  const {result} = await processTestRequest(
772
- 'because accessibility', {description: 'Mixed Outcomes Page', url: 'https://example.com/mixed'}
775
+ 'because accessibility', {description: 'Mixed Outcomes', url: 'https://example.com/mixed'}
773
776
  );
774
777
  assert.equal(result, 'reportExists');
775
778
  });
776
779
 
777
780
  test('processTestRequest returns a reportExists error when a report exists about a page with the same description', async () => {
778
781
  const {result} = await processTestRequest(
779
- 'because accessibility', {description: 'Mixed Outcomes Page', url: 'https://example.com/drifted'}
782
+ 'because accessibility', {description: 'Mixed Outcomes', url: 'https://example.com/drifted'}
780
783
  );
781
784
  assert.equal(result, 'reportExists');
782
785
  });
@@ -908,7 +911,7 @@ test('annotateReportObject handles a test act with no standardResult', async ()
908
911
  });
909
912
 
910
913
  test('isReportAvailable returns true for a known page description', async () => {
911
- const result = await isReportAvailable('Mixed Outcomes Page', 'https://example.com/nonexistent');
914
+ const result = await isReportAvailable('Mixed Outcomes', 'https://example.com/nonexistent');
912
915
  assert.equal(result, true);
913
916
  });
914
917
 
@@ -1435,7 +1438,7 @@ test('getReportExtract returns an extract for a valid report', async () => {
1435
1438
  const extract = await getReportExtract('260101T0000', 'mix') as any;
1436
1439
  assert.equal(extract.timeStamp, '260101T0000');
1437
1440
  assert.equal(extract.jobID, 'mix');
1438
- assert.equal(extract.description, 'Mixed Outcomes Page');
1441
+ assert.equal(extract.description, 'Mixed Outcomes');
1439
1442
  assert.equal(extract.url, 'https://example.com/mixed');
1440
1443
  assert.ok(extract.reportTime);
1441
1444
  });
@@ -1445,6 +1448,177 @@ test('getReportExtract returns an error for a nonexistent report', async () => {
1445
1448
  assert.ok(extract.error);
1446
1449
  });
1447
1450
 
1451
+ test('getReportExtract returns an error for an unusable report', async () => {
1452
+ const os = await import('node:os');
1453
+ const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'kilotest-extract-bad-'));
1454
+ const tmpReportsDir = path.join(tmpRoot, 'db', 'reports');
1455
+ await fs.mkdir(tmpReportsDir, {recursive: true});
1456
+ const savedDbDir = process.env.DB_DIR;
1457
+ process.env.DB_DIR = path.join(tmpRoot, 'db');
1458
+ try {
1459
+ // A report with a target and a completion time but no usable acts or catalog is
1460
+ // defective, so it must produce no extract rather than be exposed in listings.
1461
+ await fs.writeFile(
1462
+ path.join(tmpReportsDir, '260101T0000-bad.json'),
1463
+ JSON.stringify({
1464
+ target: {what: 'Defective Page', url: 'https://example.com/defective'},
1465
+ jobData: {endTime: '26-01-01T00:00'}
1466
+ })
1467
+ );
1468
+ const extract = await getReportExtract('260101T0000', 'bad') as any;
1469
+ assert.ok(extract.error);
1470
+ }
1471
+ finally {
1472
+ if (savedDbDir !== undefined) {
1473
+ process.env.DB_DIR = savedDbDir;
1474
+ }
1475
+ else {
1476
+ delete process.env.DB_DIR;
1477
+ }
1478
+ await fs.rm(tmpRoot, {recursive: true, force: true});
1479
+ }
1480
+ });
1481
+
1482
+ test('quarantineReport ignores a missing report', async () => {
1483
+ const os = await import('node:os');
1484
+ const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'kilotest-quarantine-missing-'));
1485
+ const savedDbDir = process.env.DB_DIR;
1486
+ process.env.DB_DIR = path.join(tmpRoot, 'db');
1487
+ try {
1488
+ await quarantineReport('999999T9999', 'xxx', 'not usable');
1489
+ // Nothing was created or moved.
1490
+ assert.deepEqual(await fs.readdir(tmpRoot), []);
1491
+ }
1492
+ finally {
1493
+ if (savedDbDir !== undefined) {
1494
+ process.env.DB_DIR = savedDbDir;
1495
+ }
1496
+ else {
1497
+ delete process.env.DB_DIR;
1498
+ }
1499
+ await fs.rm(tmpRoot, {recursive: true, force: true});
1500
+ }
1501
+ });
1502
+
1503
+ test('quarantineReport leaves a recently modified report in place', async () => {
1504
+ const os = await import('node:os');
1505
+ const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'kilotest-quarantine-young-'));
1506
+ const tmpReportsDir = path.join(tmpRoot, 'db', 'reports');
1507
+ await fs.mkdir(tmpReportsDir, {recursive: true});
1508
+ const filePath = path.join(tmpReportsDir, '260101T0000-bad.json');
1509
+ await fs.writeFile(filePath, '{}');
1510
+ const savedDbDir = process.env.DB_DIR;
1511
+ process.env.DB_DIR = path.join(tmpRoot, 'db');
1512
+ try {
1513
+ await quarantineReport('260101T0000', 'bad', 'not usable');
1514
+ // The fresh file may still be mid-write, so it was not moved.
1515
+ await fs.access(filePath);
1516
+ }
1517
+ finally {
1518
+ if (savedDbDir !== undefined) {
1519
+ process.env.DB_DIR = savedDbDir;
1520
+ }
1521
+ else {
1522
+ delete process.env.DB_DIR;
1523
+ }
1524
+ await fs.rm(tmpRoot, {recursive: true, force: true});
1525
+ }
1526
+ });
1527
+
1528
+ test('quarantineReport quarantines a report old enough to be quarantined', async () => {
1529
+ const os = await import('node:os');
1530
+ const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'kilotest-quarantine-old-'));
1531
+ const tmpReportsDir = path.join(tmpRoot, 'db', 'reports');
1532
+ await fs.mkdir(tmpReportsDir, {recursive: true});
1533
+ const filePath = path.join(tmpReportsDir, '260101T0000-bad.json');
1534
+ await fs.writeFile(filePath, '{}');
1535
+ // Age the file beyond the quarantine threshold.
1536
+ const longAgo = new Date(Date.now() - 120000);
1537
+ await fs.utimes(filePath, longAgo, longAgo);
1538
+ const savedDbDir = process.env.DB_DIR;
1539
+ process.env.DB_DIR = path.join(tmpRoot, 'db');
1540
+ try {
1541
+ await quarantineReport('260101T0000', 'bad', 'not usable');
1542
+ // The file was moved to the unusable-reports directory.
1543
+ await fs.access(filePath).then(
1544
+ () => {throw new Error('Report should have been quarantined');},
1545
+ () => {}
1546
+ );
1547
+ const quarantined = await fs.readdir(path.join(tmpRoot, 'db', 'unusableReports'));
1548
+ assert.deepEqual(quarantined, ['260101T0000-bad.json']);
1549
+ }
1550
+ finally {
1551
+ if (savedDbDir !== undefined) {
1552
+ process.env.DB_DIR = savedDbDir;
1553
+ }
1554
+ else {
1555
+ delete process.env.DB_DIR;
1556
+ }
1557
+ await fs.rm(tmpRoot, {recursive: true, force: true});
1558
+ }
1559
+ });
1560
+
1561
+ test('quarantineReport leaves the file and logs when the move fails', async () => {
1562
+ const os = await import('node:os');
1563
+ const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'kilotest-quarantine-blocked-'));
1564
+ const tmpReportsDir = path.join(tmpRoot, 'db', 'reports');
1565
+ await fs.mkdir(tmpReportsDir, {recursive: true});
1566
+ const filePath = path.join(tmpReportsDir, '260101T0000-bad.json');
1567
+ await fs.writeFile(filePath, '{}');
1568
+ // Age the file beyond the quarantine threshold.
1569
+ const longAgo = new Date(Date.now() - 120000);
1570
+ await fs.utimes(filePath, longAgo, longAgo);
1571
+ // A directory already occupying the destination path makes the rename fail.
1572
+ await fs.mkdir(path.join(tmpRoot, 'db', 'unusableReports', '260101T0000-bad.json'), {recursive: true});
1573
+ const savedDbDir = process.env.DB_DIR;
1574
+ process.env.DB_DIR = path.join(tmpRoot, 'db');
1575
+ try {
1576
+ await quarantineReport('260101T0000', 'bad', 'not usable');
1577
+ // The report stays where it was.
1578
+ await fs.access(filePath);
1579
+ }
1580
+ finally {
1581
+ if (savedDbDir !== undefined) {
1582
+ process.env.DB_DIR = savedDbDir;
1583
+ }
1584
+ else {
1585
+ delete process.env.DB_DIR;
1586
+ }
1587
+ await fs.rm(tmpRoot, {recursive: true, force: true});
1588
+ }
1589
+ });
1590
+
1591
+ test('getReport quarantines a malformed report file old enough to be quarantined', async () => {
1592
+ const os = await import('node:os');
1593
+ const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'kilotest-getreport-bad-'));
1594
+ const tmpReportsDir = path.join(tmpRoot, 'db', 'reports');
1595
+ await fs.mkdir(tmpReportsDir, {recursive: true});
1596
+ const filePath = path.join(tmpReportsDir, '260101T0000-bad.json');
1597
+ await fs.writeFile(filePath, 'not JSON');
1598
+ // Age the file beyond the quarantine threshold.
1599
+ const longAgo = new Date(Date.now() - 120000);
1600
+ await fs.utimes(filePath, longAgo, longAgo);
1601
+ const savedDbDir = process.env.DB_DIR;
1602
+ process.env.DB_DIR = path.join(tmpRoot, 'db');
1603
+ try {
1604
+ const {getReport} = await import('./util.ts');
1605
+ const result = await getReport('260101T0000', 'bad') as any;
1606
+ assert.ok(result.error);
1607
+ // The malformed file was quarantined.
1608
+ const quarantined = await fs.readdir(path.join(tmpRoot, 'db', 'unusableReports'));
1609
+ assert.deepEqual(quarantined, ['260101T0000-bad.json']);
1610
+ }
1611
+ finally {
1612
+ if (savedDbDir !== undefined) {
1613
+ process.env.DB_DIR = savedDbDir;
1614
+ }
1615
+ else {
1616
+ delete process.env.DB_DIR;
1617
+ }
1618
+ await fs.rm(tmpRoot, {recursive: true, force: true});
1619
+ }
1620
+ });
1621
+
1448
1622
  test('getReportExtracts returns extracts of all available reports', async () => {
1449
1623
  const extracts = await getReportExtracts();
1450
1624
  assert.ok(extracts.length >= 8);
@@ -1455,7 +1629,7 @@ test('getReportExtracts returns extracts of all available reports', async () =>
1455
1629
 
1456
1630
  test('getReportExtracts with onlyLatest returns only the latest report for each page', async () => {
1457
1631
  const latest = await getReportExtracts(true);
1458
- const mixReports = latest.filter(e => e.description === 'Mixed Outcomes Page');
1632
+ const mixReports = latest.filter(e => e.description === 'Mixed Outcomes');
1459
1633
  assert.equal(mixReports.length, 1);
1460
1634
  assert.equal(mixReports[0]!.timeStamp, '260202T0000');
1461
1635
  });
@@ -1473,7 +1647,7 @@ test('getReportExtracts reflects reports added, modified, and removed after a pr
1473
1647
  const firstPath = path.join(tmpReportsDir, '260101T0000-aaa.json');
1474
1648
  await fs.writeFile(firstPath, JSON.stringify(report));
1475
1649
  let extracts = await getReportExtracts();
1476
- assert.deepEqual(extracts.map(e => e.description), ['Mixed Outcomes Page']);
1650
+ assert.deepEqual(extracts.map(e => e.description), ['Mixed Outcomes']);
1477
1651
  // Modify the report and give it a later modification time, as reannotation would.
1478
1652
  report.target.what = 'Revised Page';
1479
1653
  await fs.writeFile(firstPath, JSON.stringify(report));
@@ -1768,14 +1942,14 @@ test('orderJob enqueues a new-page order directly into the job queue', async ()
1768
1942
 
1769
1943
  test('orderJob returns a reportExists error when a report already exists for the ordered page', async () => {
1770
1944
  const {result} = await orderJob(
1771
- {description: 'Mixed Outcomes Page', url: 'https://example.com/mixed'}, 'because accessibility matters here'
1945
+ {description: 'Mixed Outcomes', url: 'https://example.com/mixed'}, 'because accessibility matters here'
1772
1946
  );
1773
1947
  assert.equal(result, 'reportExists');
1774
1948
  });
1775
1949
 
1776
1950
  test('orderJob returns a reportExists error when a report exists about a page with the same description', async () => {
1777
1951
  const {result} = await orderJob(
1778
- {description: 'Mixed Outcomes Page', url: 'https://example.com/drifted'}, 'because accessibility matters here'
1952
+ {description: 'Mixed Outcomes', url: 'https://example.com/drifted'}, 'because accessibility matters here'
1779
1953
  );
1780
1954
  assert.equal(result, 'reportExists');
1781
1955
  });
@@ -1841,7 +2015,7 @@ test('orderJob returns "nonreport" when a retest order cites a report that does
1841
2015
  });
1842
2016
 
1843
2017
  test('orderJob returns "superseded" when a retest order cites a superseded report', async () => {
1844
- // 260101T0000-mix is an earlier report of "Mixed Outcomes Page" than 260202T0000-new,
2018
+ // 260101T0000-mix is an earlier report of "Mixed Outcomes" than 260202T0000-new,
1845
2019
  // so it is superseded.
1846
2020
  const {result} = await orderJob(
1847
2021
  {timeStamp: '260101T0000', jobID: 'mix'}, 'because accessibility matters here'
@@ -1854,12 +2028,12 @@ test('orderJob enqueues a retest order for the latest report of a page', async (
1854
2028
  {timeStamp: '260202T0000', jobID: 'new'}, 'because accessibility matters here'
1855
2029
  ) as any;
1856
2030
  assert.equal(result, 'ok');
1857
- assert.equal(description, 'Mixed Outcomes Page');
2031
+ assert.equal(description, 'Mixed Outcomes');
1858
2032
  assert.equal(url, 'https://example.com/mixed');
1859
2033
  const queuedPath = path.join(jobsPath(), 'queue', `${jobID}.json`);
1860
2034
  try {
1861
2035
  const job = JSON.parse(await fs.readFile(queuedPath, 'utf8'));
1862
- assert.equal(job.target.what, 'Mixed Outcomes Page');
2036
+ assert.equal(job.target.what, 'Mixed Outcomes');
1863
2037
  }
1864
2038
  finally {
1865
2039
  await fs.unlink(queuedPath);
package/util.ts CHANGED
@@ -32,6 +32,9 @@ export const testRequestsPath = (): string => path.join(jobsPath(), 'testRequest
32
32
  export const reportsPath = (): string => path.join(dbPath(), 'reports');
33
33
  // Path of the hidden-reports directory.
34
34
  export const hiddenReportsPath = (): string => path.join(dbPath(), 'hiddenReports');
35
+ // Path of the unusable-reports directory, where reports that fail the usability
36
+ // checks are quarantined for a manager to repair or delete.
37
+ export const unusableReportsPath = (): string => path.join(dbPath(), 'unusableReports');
35
38
  // Path of the usage-metrics file.
36
39
  export const metricsPath = (): string => path.join(dbPath(), 'metrics.json');
37
40
  // IDs, names, and sponsors of Testaro rule engines.
@@ -692,7 +695,49 @@ export const isUsableReport = (report: unknown): report is UsableReport => {
692
695
  && !isNaN(getReportEndTime(r).getTime())
693
696
  && typeof r.catalog === 'object';
694
697
  };
695
- // Returns a report.
698
+ // Minimum age, in milliseconds, of the last modification of a report file for the
699
+ // file to be quarantined. A more recently modified file may still be being written
700
+ // by a worker or a reannotation, so quarantining it could mistake an incomplete write
701
+ // for a defect. Read at call time so tests can shrink it via REPORT_QUARANTINE_MIN_AGE_MS;
702
+ // a value of 0 disables the age check.
703
+ const quarantineMinAgeMs = () => getEnvMax('REPORT_QUARANTINE_MIN_AGE_MS', 60000);
704
+ // Moves a report file from the reports directory to the unusable-reports directory
705
+ // and alerts a manager, if the file exists and is old enough that it can no longer
706
+ // plausibly be mid-write. Called by getReport whenever a report proves defective, so
707
+ // that a defective report is quarantined rather than merely hidden from whichever
708
+ // listing encountered it. Never throws: a failure here must not displace the error
709
+ // that getReport is already reporting.
710
+ export const quarantineReport = async (timeStamp: string, jobID: string, defect: string): Promise<void> => {
711
+ const jobName = `${timeStamp}-${jobID}`;
712
+ // Get the modification time of the report file. A missing file needs no move.
713
+ const stat = await fs.stat(getReportPath(timeStamp, jobID)).catch(() => null);
714
+ if (!stat) {
715
+ return;
716
+ }
717
+ // Leave a recently modified file alone, since it may still be being written.
718
+ if (Date.now() - stat.mtimeMs < quarantineMinAgeMs()) {
719
+ return;
720
+ }
721
+ try {
722
+ await fs.mkdir(unusableReportsPath(), {recursive: true});
723
+ await fs.rename(
724
+ getReportPath(timeStamp, jobID), path.join(unusableReportsPath(), `${jobName}.json`)
725
+ );
726
+ }
727
+ catch (error: unknown) {
728
+ // The report stays excluded regardless, so a failed move is only logged.
729
+ console.error(`ERROR: Failed to quarantine report ${jobName} (${errorMessage(error)})`);
730
+ return;
731
+ }
732
+ // Alert a manager, who can repair the report in place or delete it.
733
+ await sendAlert(
734
+ 'Kilotest: unusable report',
735
+ `Report ${jobName} is ${defect}. It was moved to the unusableReports directory, where a manager can repair or delete it.`
736
+ );
737
+ };
738
+ // Returns a report, or an error object if it is missing, unreadable, unparsable, or
739
+ // unusable. A report that exists but cannot be read or is unusable is defective, so
740
+ // it is quarantined rather than merely rejected by whichever read encountered it.
696
741
  export const getReport = async (timeStamp: string, jobID: string): Promise<UsableReport | {error: string}> => {
697
742
  try {
698
743
  const reportJSON = await fs.readFile(getReportPath(timeStamp, jobID), 'utf8');
@@ -702,11 +747,15 @@ export const getReport = async (timeStamp: string, jobID: string): Promise<Usabl
702
747
  // Return it.
703
748
  return report;
704
749
  }
705
- // Otherwise, i.e. if it is unusable, return this.
706
- return {error: `Report ${timeStamp}-${jobID} is not usable`};
707
750
  } catch (error: unknown) {
751
+ // The file exists but cannot be read or parsed, or is already missing.
752
+ // quarantineReport ignores a missing file, so no existence check is needed here.
753
+ await quarantineReport(timeStamp, jobID, 'unreadable or not JSON');
708
754
  return {error: `Report ${timeStamp}-${jobID} is missing, unreadable, or not JSON (${errorMessage(error)})`};
709
755
  }
756
+ // The report was read but is not usable, so quarantine it.
757
+ await quarantineReport(timeStamp, jobID, 'not usable');
758
+ return {error: `Report ${timeStamp}-${jobID} is not usable`};
710
759
  };
711
760
  // Returns whether a getReport result is an error (rather than a usable report).
712
761
  // A type predicate is used instead of `if (report.error)` because UsableReport
@@ -773,30 +822,27 @@ export const getReportEndTime = (report: {jobData?: {endTime?: string}}): Date =
773
822
  `20${report.jobData?.endTime}Z`
774
823
  );
775
824
 
776
- // Returns an extract of an available report, or an error object if it cannot be read or parsed.
825
+ // Returns an extract of an available report, or an error object if the report cannot
826
+ // be used. An extract exists to expose a report in listings, so it shares getReport's
827
+ // usability bar: a report too defective to expose must produce no extract.
777
828
  export const getReportExtract = async (timeStamp: string, jobID: string): Promise<ReportExtract | {error: string}> => {
778
- try {
779
- // Get the report.
780
- const reportJSON = await fs.readFile(
781
- path.join(reportsPath(), `${timeStamp}-${jobID}.json`), 'utf8'
782
- );
783
- const report = JSON.parse(reportJSON);
784
- const {target, jobData} = report;
785
- const {what: description, url} = target;
786
- // Return an extract of it.
787
- return {
788
- timeStamp,
789
- jobID,
790
- description,
791
- url,
792
- reportTime: getReportEndTime({jobData}).toISOString()
793
- };
794
- }
795
- catch {
796
- return {
797
- error: `No report ${timeStamp}-${jobID} is available`
798
- };
829
+ const report = await getReport(timeStamp, jobID);
830
+ // If the report is not usable:
831
+ if (isReportError(report)) {
832
+ // Return the reason.
833
+ return report;
799
834
  }
835
+ const {target, jobData} = report;
836
+ const {what: description, url} = target;
837
+ // Return an extract of it. Usability has already guaranteed these properties,
838
+ // including a valid completion time.
839
+ return {
840
+ timeStamp,
841
+ jobID,
842
+ description,
843
+ url,
844
+ reportTime: getReportEndTime({jobData}).toISOString()
845
+ };
800
846
  };
801
847
  // Cache of report extracts, keyed by report file path. Each entry records the modification
802
848
  // time and size of the file when it was extracted, so an entry is reused only while the file
@@ -804,7 +850,7 @@ export const getReportExtract = async (timeStamp: string, jobID: string): Promis
804
850
  // written, hidden, or deleted by any process, so the cache is validated against the files
805
851
  // before every use rather than invalidated by the code that changes them.
806
852
  const reportExtractCache = new Map<string, {mtimeMs: number, size: number, extract: ReportExtract}>();
807
- // Returns extracts of all available reports.
853
+ // Returns extracts of all available reports, ordered by page description and completion time.
808
854
  export const getReportExtracts = async (onlyLatest: boolean = false): Promise<ReportExtract[]> => {
809
855
  const reportsDir = reportsPath();
810
856
  // Get the names and paths of the available report files.
@@ -866,8 +912,7 @@ export const getReportExtracts = async (onlyLatest: boolean = false): Promise<Re
866
912
  // Return the array, excluding extracts of superseded reports if so specified.
867
913
  return onlyLatest ? extracts.filter(extract => !extract.superseded) : extracts;
868
914
  };
869
- // Returns extracts of all available reports about the page with a description, from oldest
870
- // to latest.
915
+ // Returns extracts of all available reports about a page, from oldest to latest.
871
916
  export const getPageReportExtracts = async (description: string): Promise<ReportExtract[]> => {
872
917
  const reportExtracts = await getReportExtracts();
873
918
  return reportExtracts.filter(extract => extract.description === description);
@@ -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
+ });
@@ -90,7 +90,7 @@
90
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
  };
@@ -13,17 +13,8 @@
13
13
  <body>
14
14
  <main>
15
15
  <h1><a href="/">Kilotest</a>: Issues reported for __target__</h1>
16
-
16
+ __oldWarn__
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
19
  <h2>History</h2>
29
20
  __history__
@@ -52,6 +43,13 @@ __history__
52
43
  __lowestDetails__
53
44
  </ul>
54
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>
55
53
  </main>
56
54
  </body>
57
55
  </html>
@@ -123,46 +123,62 @@ const getIssuesData = (report: UsableReport): ResultsSummary & Record<string, an
123
123
  // Return the data.
124
124
  return final;
125
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[]> => {
126
+ // Returns data and HTML lines about the history of the reports about the page of a report.
127
+ const getHistory = async (
128
+ timeStamp: string, jobID: string, description: string, margin: string = ''
129
+ ): Promise<{isLatest: boolean, lines: string[]}> => {
130
130
  // Get extracts of all reports about the page, from oldest to latest.
131
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:
132
+ // The report's own extract may be absent if the report was quarantined between the
133
+ // report read and the extract listing, so an empty list means "not latest".
134
+ const lastExtract = pageExtracts[pageExtracts.length - 1];
135
+ const isLatest = lastExtract !== undefined
136
+ && lastExtract.timeStamp === timeStamp && lastExtract.jobID === jobID;
137
+ // Initialize the history.
138
+ const history = {
139
+ isLatest,
140
+ lines: [] as string[]
141
+ };
142
+ // For each report about the page, from oldest to latest:
137
143
  pageExtracts.forEach((extract, index) => {
144
+ // Get whether it is this report.
138
145
  const isThis = extract.timeStamp === timeStamp && extract.jobID === jobID;
139
- isLatest ||= isThis && index === lastIndex;
140
146
  // Get the tags that identify its position and whether it is this report.
141
147
  const tags = [
142
148
  index === 0 ? 'oldest' : '',
143
- index === lastIndex ? 'latest' : '',
149
+ index === pageExtracts.length - 1 ? 'latest' : '',
144
150
  isThis ? 'this report' : ''
145
151
  ]
146
152
  .filter(tag => tag);
153
+ // Get a string containing the applicable tags.
147
154
  const tagString = tags.length ? ` (${tags.join('; ')})` : '';
148
155
  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>`);
156
+ let content;
157
+ if (isThis) {
158
+ content = label;
159
+ }
160
+ else {
161
+ const href = `/listIssues.html/${extract.timeStamp}/${extract.jobID}`;
162
+ content = `<a href="${href}">${label}</a>`;
163
+ }
164
+ // Add a list item describing the report, linked if not this report, to the history.
165
+ history.lines.push(`${margin}<li>${content}</li>`);
153
166
  });
154
167
  // 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:
168
+ const lines = history.lines.length > 1
169
+ ? [`${margin}<ol>`, ...history.lines, `${margin}</ol>`]
170
+ : [`${margin}<p>This is the only available report about the page.</p>`];
171
+ // If this report is the latest about the page:
159
172
  if (isLatest) {
160
173
  // 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>`);
174
+ lines.push(
175
+ `${margin}<p><a href="/requestRetestForm.html/${timeStamp}/${jobID}">Request a retest</a></p>`
176
+ );
163
177
  }
164
- // Return the lines.
165
- return lines;
178
+ // Replace the bare list items with the assembled section lines.
179
+ history.lines = lines;
180
+ // Return the history.
181
+ return history;
166
182
  };
167
183
  // Adds parameters to a query for the answer page.
168
184
  const populateQuery = async (timeStamp: string, jobID: string, query: Record<string, any>) => {
@@ -191,10 +207,15 @@ const populateQuery = async (timeStamp: string, jobID: string, query: Record<str
191
207
  query.target = description;
192
208
  query.testInfo = testInfo;
193
209
  const margin = ' '.repeat(6);
210
+ const history = await getHistory(timeStamp, jobID, description, ' '.repeat(8));
211
+ // Add a warning if the report is not the latest about the page.
212
+ query.oldWarn = history.isLatest
213
+ ? ''
214
+ : '<p><strong>Warning: This report is not the latest about the page.</strong> See history below.</p>';
194
215
  // Add the page-facts list to the query.
195
216
  query.pageFacts = getPageFactsLines(pageInfo, issuesData, margin).join('\n');
196
217
  // Add the history of reports about the page to the query.
197
- query.history = (await getHistoryLines(timeStamp, jobID, description, margin)).join('\n');
218
+ query.history = history.lines.join('\n');
198
219
  const {issues} = issuesData;
199
220
  // Add a summary of the issues to the query.
200
221
  query.highestCount = issues[4].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
  };
@@ -231,7 +231,7 @@ test('listIssues History of an older report lists all reports, with this one unl
231
231
  assert.equal(items[0]!.href, null);
232
232
  assert.match(items[1]!.text, /^\d+ days? ago \(latest\)$/);
233
233
  assert.equal(items[1]!.href, '/listIssues.html/260202T0000/new');
234
- assert.ok(!result.answerPage.includes('Should Kilotest retest the page?'));
234
+ assert.ok(!result.answerPage.includes('Request a retest'));
235
235
  });
236
236
 
237
237
  test('listIssues History of the latest report ends with the retest question', async () => {
@@ -244,7 +244,7 @@ test('listIssues History of the latest report ends with the retest question', as
244
244
  assert.match(items[1]!.text, /^\d+ days? ago \(latest; this report\)$/);
245
245
  assert.equal(items[1]!.href, null);
246
246
  const retestLink = history.querySelector('ol + p > a');
247
- assert.equal(retestLink?.text, 'Should Kilotest retest the page?');
247
+ assert.equal(retestLink?.text, 'Request a retest');
248
248
  assert.equal(retestLink?.getAttribute('href'), '/requestRetestForm.html/260202T0000/new');
249
249
  });
250
250
 
@@ -253,7 +253,7 @@ test('listIssues History of the only report about a page says so and asks about
253
253
  const history = getHistory(result.answerPage);
254
254
  assert.equal(getHistoryItems(history).length, 0);
255
255
  const paragraphs = history.querySelectorAll('p');
256
- assert.equal(paragraphs[0]?.text, 'This is the only report about the page.');
256
+ assert.equal(paragraphs[0]?.text, 'This is the only available report about the page.');
257
257
  assert.equal(paragraphs[1]?.querySelector('a')?.getAttribute('href'), '/requestRetestForm.html/260101T0001/ct');
258
258
  });
259
259
 
@@ -58,7 +58,7 @@ test('listPages lists each of the 7 tested pages once, by description', async ()
58
58
  'All CantTell Page',
59
59
  'Branch Coverage Page',
60
60
  'Empty Results Page',
61
- 'Mixed Outcomes Page',
61
+ 'Mixed Outcomes',
62
62
  'Multi Violator Page',
63
63
  'No Outcomes Page',
64
64
  'Prevented Page'
@@ -75,7 +75,7 @@ test('listPages introduces the list of tested pages', async () => {
75
75
  test('listPages links each page to the issues in its latest report', async () => {
76
76
  const result: any = await answer();
77
77
  const links = getPageLinks(result.answerPage);
78
- const mixedLink = links.find(a => a.text === 'Mixed Outcomes Page');
78
+ const mixedLink = links.find(a => a.text === 'Mixed Outcomes');
79
79
  assert.equal(mixedLink?.getAttribute('href'), 'listIssues.html/260202T0000/new');
80
80
  const canttellLink = links.find(a => a.text === 'All CantTell Page');
81
81
  assert.equal(canttellLink?.getAttribute('href'), 'listIssues.html/260101T0001/ct');
@@ -107,7 +107,7 @@ test('listPages shows requests when testRequests.json has entries', {timeout: 50
107
107
  try {
108
108
  const testRequests = {
109
109
  'https://example.com/mixed': [
110
- {description: 'Mixed Outcomes Page', reason: 'Needs retesting for accessibility'}
110
+ {description: 'Mixed Outcomes', reason: 'Needs retesting for accessibility'}
111
111
  ]
112
112
  };
113
113
  await fs.writeFile(testRequestsPath, JSON.stringify(testRequests, null, 2));
@@ -149,12 +149,12 @@ test('listPages shows queued and claimed jobs when they exist', async () => {
149
149
  }
150
150
  });
151
151
 
152
- test('listPages lists a page whose report is not usable', {timeout: 500}, async () => {
152
+ test('listPages omits a page whose report is not usable', {timeout: 500}, async () => {
153
153
  const dbDir = (await import('../../test/dbFixture.ts')).fixtureDBDir;
154
154
  const reportsDir = path.join(dbDir, 'reports');
155
155
  const invalidReportPath = path.join(reportsDir, '260101T9999-bad.json');
156
156
  try {
157
- // Valid for getReportExtract (has target and jobData.endTime) but not usable by isUsableReport (no acts or catalog).
157
+ // Not usable by isUsableReport (no acts or catalog), so it must not be exposed.
158
158
  const invalidReport = {
159
159
  target: {what: 'Invalid Report', url: 'https://example.com/invalid'},
160
160
  jobData: {endTime: '26-01-01T00:10'}
@@ -163,7 +163,7 @@ test('listPages lists a page whose report is not usable', {timeout: 500}, async
163
163
  const result: any = await answer();
164
164
  assert.equal(result.status, 'ok');
165
165
  const links = getPageLinks(result.answerPage);
166
- assert.ok(links.some(a => a.text === 'Invalid Report'));
166
+ assert.ok(!links.some(a => a.text === 'Invalid Report'));
167
167
  }
168
168
  finally {
169
169
  await fs.unlink(invalidReportPath).catch(() => {});
@@ -5,12 +5,31 @@
5
5
 
6
6
  // IMPORTS
7
7
 
8
- import {test, before, after} from 'node:test';
8
+ import {test, before, after, mock} from 'node:test';
9
9
  import assert from 'node:assert/strict';
10
10
  import path from 'node:path';
11
11
  import fs from 'node:fs/promises';
12
12
  import {parse} from 'node-html-parser';
13
- import {answer} from './index.ts';
13
+
14
+ // Mock the util functions called by index.ts before importing it, so that
15
+ // it imports the mocked versions. Other exports delegate to the real module.
16
+ import * as realUtil from '../../util.ts';
17
+ let getReportCallCount = 0;
18
+ let failGetReportOnCall = -1;
19
+ mock.module('../../util.ts', {
20
+ exports: {
21
+ ...realUtil,
22
+ getReport: async (...args: any[]) => {
23
+ getReportCallCount++;
24
+ if (getReportCallCount === failGetReportOnCall) {
25
+ return {error: 'Report is invalid (test override)'};
26
+ }
27
+ return (realUtil.getReport as any)(...args);
28
+ }
29
+ }
30
+ } as any);
31
+
32
+ const {answer} = await import('./index.ts') as any;
14
33
 
15
34
  // SETUP AND TEARDOWN
16
35
 
@@ -54,23 +73,36 @@ test('listTopIssues excludes cantTell instances from issue counts', async () =>
54
73
  assert.ok(!result.answerPage.includes('focus indication poor'));
55
74
  });
56
75
 
57
- test('listTopIssues returns an error when a report file is invalid', async () => {
76
+ test('listTopIssues omits a report that is not usable', async () => {
58
77
  const dbDir = (await import('../../test/dbFixture.ts')).fixtureDBDir;
59
78
  const reportsDir = path.join(dbDir, 'reports');
60
79
  const invalidReportPath = path.join(reportsDir, '260101T9999-bad.json');
61
80
  try {
62
- // Valid for getReportExtract (has target and jobData.endTime) but not usable by isUsableReport (no acts or catalog).
81
+ // Not usable by isUsableReport (no acts or catalog), so it produces no extract
82
+ // and contributes nothing to the summary.
63
83
  const invalidReport = {
64
84
  target: {what: 'Invalid TopIssues Report', url: 'https://example.com/invalidtop'},
65
85
  jobData: {endTime: '26-01-01T00:10'}
66
86
  };
67
87
  await fs.writeFile(invalidReportPath, JSON.stringify(invalidReport, null, 2));
88
+ const result: any = await answer();
89
+ assert.equal(result.status, 'ok');
90
+ }
91
+ finally {
92
+ await fs.unlink(invalidReportPath).catch(() => {});
93
+ }
94
+ });
95
+
96
+ test('listTopIssues returns an error when a report read fails', async () => {
97
+ getReportCallCount = 0;
98
+ failGetReportOnCall = 1;
99
+ try {
68
100
  const result: any = await answer();
69
101
  assert.equal(result.status, 'error');
70
102
  assert.ok(result.message);
71
103
  }
72
104
  finally {
73
- await fs.unlink(invalidReportPath).catch(() => {});
105
+ failGetReportOnCall = -1;
74
106
  }
75
107
  });
76
108
 
@@ -188,7 +188,7 @@ test('listViolators includes take-me-there links for text-linkable violators', a
188
188
 
189
189
  test('listViolators returns an error when report facts are not obtained', async () => {
190
190
  pageDataStringsOverride = {
191
- what: 'Mixed Outcomes Page',
191
+ what: 'Mixed Outcomes',
192
192
  url: 'https://example.com/mixed',
193
193
  urlLink: '<a href="https://example.com/mixed">https://example.com/mixed</a>'
194
194
  };
@@ -90,8 +90,8 @@ test('answer returns an error when a report fails annotation', {timeout: 2000},
90
90
  // Back up all fixture reports, because reannotation modifies good reports
91
91
  // in place before it reaches the bad one and returns an error.
92
92
  const backups = await backupReports();
93
- // Create a temporary report file that passes getReportExtract but fails
94
- // getReport validation (acts is not an array).
93
+ // Create a temporary report file that fails getReport validation
94
+ // (acts is not an array). It is too fresh to be quarantined, so it stays put.
95
95
  const badReportName = '990101T0000-bad.json';
96
96
  const badReportPath = path.join(reportsDir, badReportName);
97
97
  const badReport = {
@@ -6,13 +6,31 @@
6
6
 
7
7
  // IMPORTS
8
8
 
9
- import {test, before, after, beforeEach} from 'node:test';
9
+ import {test, before, after, beforeEach, mock} from 'node:test';
10
10
  import assert from 'node:assert/strict';
11
11
  import fs from 'node:fs/promises';
12
12
  import os from 'node:os';
13
13
  import path from 'node:path';
14
14
  import {parse} from 'node-html-parser';
15
15
 
16
+ // Mock the util functions called by index.ts before it is ever imported, so that
17
+ // it imports the mocked versions. Other exports delegate to the real module.
18
+ import * as realUtil from '../../util.ts';
19
+ let getReportCallCount = 0;
20
+ let failGetReportOnCall = -1;
21
+ mock.module('../../util.ts', {
22
+ exports: {
23
+ ...realUtil,
24
+ getReport: async (...args: any[]) => {
25
+ getReportCallCount++;
26
+ if (getReportCallCount === failGetReportOnCall) {
27
+ return {error: 'Report is invalid (test override)'};
28
+ }
29
+ return (realUtil.getReport as any)(...args);
30
+ }
31
+ }
32
+ } as any);
33
+
16
34
  // CONSTANTS
17
35
 
18
36
  import {fixtureDBDir} from '../../test/dbFixture.ts';
@@ -101,9 +119,9 @@ test('answer returns ok with no-reclassified message when reports have no violat
101
119
  }
102
120
  });
103
121
 
104
- test('answer returns an error when a report fails to load', async () => {
105
- // Create a temporary report file that passes getReportExtract but fails
106
- // getReport validation (acts is not an array).
122
+ test('answer ignores a report that is not usable', async () => {
123
+ // A report that fails getReport validation (acts is not an array) produces no
124
+ // extract, so it is ignored rather than causing an error.
107
125
  const badReportName = '990101T0000-bad.json';
108
126
  const badReportPath = path.join(reportsDir, badReportName);
109
127
  const badReport = {
@@ -117,8 +135,7 @@ test('answer returns an error when a report fails to load', async () => {
117
135
  try {
118
136
  const {answer} = await import('./index.ts');
119
137
  const result = await answer();
120
- assert.equal(result.status, 'error');
121
- assert.ok(result.message.includes('not usable'), `Expected message about unusable report, got: ${result.message}`);
138
+ assert.equal(result.status, 'ok');
122
139
  }
123
140
  finally {
124
141
  try {
@@ -129,3 +146,17 @@ test('answer returns an error when a report fails to load', async () => {
129
146
  }
130
147
  }
131
148
  });
149
+
150
+ test('answer returns an error when a report read fails', async () => {
151
+ getReportCallCount = 0;
152
+ failGetReportOnCall = 1;
153
+ try {
154
+ const {answer} = await import('./index.ts');
155
+ const result = await answer();
156
+ assert.equal(result.status, 'error');
157
+ assert.ok(result.message);
158
+ }
159
+ finally {
160
+ failGetReportOnCall = -1;
161
+ }
162
+ });
@@ -123,7 +123,7 @@ test('answer returns an error when a report already exists for the page', {timeo
123
123
  const {answer} = await import('./index.ts');
124
124
  // The fixture database already has a report for this description and URL.
125
125
  const result: any = await answer(
126
- 'Mixed Outcomes Page', 'https://example.com/mixed', 'Because accessibility'
126
+ 'Mixed Outcomes', 'https://example.com/mixed', 'Because accessibility'
127
127
  );
128
128
  assert.equal(result.status, 'error');
129
129
  assert.equal(result.message, 'A report about a page with the same description or URL is already available');
@@ -102,7 +102,7 @@ test('answer returns an error for a duplicate retest request', async () => {
102
102
 
103
103
  test('answer returns an error when the cited report has been superseded', async () => {
104
104
  const {answer} = await import('./index.ts');
105
- // 260101T0000-mix (Mixed Outcomes Page) is superseded by 260202T0000-new.
105
+ // 260101T0000-mix (Mixed Outcomes) is superseded by 260202T0000-new.
106
106
  const result: any = await answer('260101T0000/mix', 'Because changes were made');
107
107
  assert.equal(result.status, 'error');
108
108
  assert.equal(result.message, 'A later report about the page is already available');
@@ -141,4 +141,3 @@ test('answer returns an error with a fallback channel when the request queue is
141
141
  assert.ok(result.message.includes('https://github.com/jrpool/kilotest/issues'));
142
142
  assert.ok(result.message.includes('info@kilotest.com'));
143
143
  });
144
-
@@ -35,7 +35,7 @@ test('requestRetestForm returns ok with the page name for a valid report', async
35
35
  assert.ok(result.answerPage);
36
36
  const html = parse(result.answerPage);
37
37
  const title = html.querySelector('title')?.text;
38
- assert.ok(title?.includes('Mixed Outcomes Page'));
38
+ assert.ok(title?.includes('Mixed Outcomes'));
39
39
  });
40
40
 
41
41
  test('requestRetestForm populates the form action with the timeStamp and jobID', async () => {
package/web/util.test.ts CHANGED
@@ -129,7 +129,7 @@ const getFixtureReport = async (timeStamp: string, jobID: string) => {
129
129
 
130
130
  test('getPageData returns page data for a valid report', async () => {
131
131
  const data = getPageData(await getFixtureReport('260101T0000', 'mix'));
132
- assert.equal(data.description, 'Mixed Outcomes Page');
132
+ assert.equal(data.description, 'Mixed Outcomes');
133
133
  assert.equal(data.url, 'https://example.com/mixed');
134
134
  assert.equal(typeof data.daysAgo, 'number');
135
135
  });
@@ -141,7 +141,7 @@ test('getReport returns an error for a nonexistent report', async () => {
141
141
 
142
142
  test('getPageDataStrings returns HTML strings for a valid report', async () => {
143
143
  const strings = getPageDataStrings(await getFixtureReport('260101T0000', 'mix'));
144
- assert.equal(strings.description, 'Mixed Outcomes Page');
144
+ assert.equal(strings.description, 'Mixed Outcomes');
145
145
  assert.equal(strings.url, 'https://example.com/mixed');
146
146
  assert.equal(strings.urlLink, '<a href="https://example.com/mixed">https://example.com/mixed</a>');
147
147
  assert.ok(strings.testInfo.includes('by job <code>260101T0000-mix</code>'));