@jrpool/kilotest 58.2.0 → 59.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/api/listIssues.test.ts +32 -0
- package/api/listIssues.ts +38 -8
- package/api/listPages.test.ts +92 -0
- package/api/listPages.ts +93 -0
- package/api/orderNewTest.test.ts +1 -1
- package/api/orderNewTest.ts +4 -4
- package/api/requestNewTest.ts +6 -6
- package/api/requestRetest.ts +2 -2
- package/api/routes.test.ts +1 -1
- package/api/routes.ts +5 -5
- package/api/schemas.ts +24 -7
- package/api/util.ts +26 -45
- package/apiIntegration.test.ts +5 -5
- package/docs/SERVICE.md +16 -5
- package/index.html +1 -1
- package/index.test.ts +30 -18
- package/index.ts +22 -11
- package/lhm.plugin.json +1433 -141
- package/linkValidation.test.ts +2 -2
- package/llms-full.txt +9 -9
- package/llms.txt +4 -4
- package/mcp.test.ts +8 -8
- package/mcp.ts +14 -14
- package/openapi.yaml +84 -30
- package/package.json +1 -1
- package/routing.test.ts +5 -5
- package/server.json +1 -1
- package/sitemap.xml +1 -1
- package/smokeTest.ts +2 -2
- package/style.css +8 -8
- package/test/fixtures/buildFixtures.ts +1 -1
- package/util.test.ts +71 -12
- package/util.ts +61 -12
- package/web/enqueueForm/index.html +1 -1
- package/web/hideReportForm/hideReportForm.test.ts +5 -0
- package/web/hideReportForm/index.ts +9 -6
- package/web/listIssues/index.html +2 -1
- package/web/listIssues/index.ts +45 -3
- package/web/listIssues/listIssues.test.ts +76 -0
- package/web/{listReports → listPages}/index.html +1 -2
- package/web/listPages/index.ts +99 -0
- package/web/listPages/listPages.test.ts +196 -0
- package/web/metrics/metrics.test.ts +2 -2
- package/web/requestRetestForm/index.ts +4 -2
- package/web/requestRetestForm/requestRetestForm.test.ts +11 -0
- package/web/unhideReportForm/index.ts +9 -6
- package/web/unhideReportForm/unhideReportForm.test.ts +8 -5
- package/web/util.test.ts +85 -11
- package/web/util.ts +26 -28
- package/api/listReports.test.ts +0 -109
- package/api/listReports.ts +0 -87
- package/web/listReports/index.ts +0 -182
- package/web/listReports/listReports.test.ts +0 -300
package/util.test.ts
CHANGED
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
getObject,
|
|
39
39
|
getPlainText,
|
|
40
40
|
getRandomString,
|
|
41
|
+
getReportEndTime,
|
|
41
42
|
getReportExtract,
|
|
42
43
|
getReportExtracts,
|
|
43
44
|
getReportPath,
|
|
@@ -1276,7 +1277,7 @@ test('getIssue returns null for a known engine with an unknown variable rule', (
|
|
|
1276
1277
|
});
|
|
1277
1278
|
|
|
1278
1279
|
test('getIssue returns an issue ID for a variable rule pattern match', () => {
|
|
1279
|
-
const result = getIssue('nuVal', 'Duplicate attribute foo');
|
|
1280
|
+
const result = getIssue('nuVal', 'Duplicate attribute foo.');
|
|
1280
1281
|
assert.ok(typeof result === 'string');
|
|
1281
1282
|
assert.equal(result, 'duplicateAttribute');
|
|
1282
1283
|
});
|
|
@@ -1408,10 +1409,18 @@ test('getReportPath returns the path of a report file', () => {
|
|
|
1408
1409
|
assert.ok(result.endsWith('260101T0000-mix.json'));
|
|
1409
1410
|
});
|
|
1410
1411
|
|
|
1411
|
-
test('
|
|
1412
|
+
test('getReportEndTime returns the completion time of a report', () => {
|
|
1413
|
+
const endTime = getReportEndTime({jobData: {endTime: '26-03-04T05:06'}});
|
|
1414
|
+
assert.equal(endTime.toISOString(), '2026-03-04T05:06:00.000Z');
|
|
1415
|
+
});
|
|
1416
|
+
|
|
1417
|
+
test('getReportEndTime returns an invalid date for a report with no completion time', () => {
|
|
1418
|
+
assert.ok(isNaN(getReportEndTime({}).getTime()));
|
|
1419
|
+
});
|
|
1420
|
+
|
|
1421
|
+
test('getReportStats returns reportSize for a valid report', async () => {
|
|
1412
1422
|
const stats = await getReportStats('260101T0000', 'mix');
|
|
1413
1423
|
assert.ok(stats);
|
|
1414
|
-
assert.ok(stats.reportTime instanceof Date);
|
|
1415
1424
|
assert.equal(typeof stats.reportSize, 'number');
|
|
1416
1425
|
assert.ok(stats.reportSize > 0);
|
|
1417
1426
|
});
|
|
@@ -1451,6 +1460,56 @@ test('getReportExtracts with onlyLatest returns only the latest report for each
|
|
|
1451
1460
|
assert.equal(mixReports[0]!.timeStamp, '260202T0000');
|
|
1452
1461
|
});
|
|
1453
1462
|
|
|
1463
|
+
test('getReportExtracts reflects reports added, modified, and removed after a previous call', async () => {
|
|
1464
|
+
const os = await import('node:os');
|
|
1465
|
+
const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'kilotest-extracts-'));
|
|
1466
|
+
const tmpReportsDir = path.join(tmpRoot, 'db', 'reports');
|
|
1467
|
+
const savedDbDir = process.env.DB_DIR;
|
|
1468
|
+
const sourcePath = path.join(reportsPath(), '260101T0000-mix.json');
|
|
1469
|
+
process.env.DB_DIR = path.join(tmpRoot, 'db');
|
|
1470
|
+
try {
|
|
1471
|
+
await fs.mkdir(tmpReportsDir, {recursive: true});
|
|
1472
|
+
const report = JSON.parse(await fs.readFile(sourcePath, 'utf8'));
|
|
1473
|
+
const firstPath = path.join(tmpReportsDir, '260101T0000-aaa.json');
|
|
1474
|
+
await fs.writeFile(firstPath, JSON.stringify(report));
|
|
1475
|
+
let extracts = await getReportExtracts();
|
|
1476
|
+
assert.deepEqual(extracts.map(e => e.description), ['Mixed Outcomes Page']);
|
|
1477
|
+
// Modify the report and give it a later modification time, as reannotation would.
|
|
1478
|
+
report.target.what = 'Revised Page';
|
|
1479
|
+
await fs.writeFile(firstPath, JSON.stringify(report));
|
|
1480
|
+
const later = new Date(Date.now() + 60000);
|
|
1481
|
+
await fs.utimes(firstPath, later, later);
|
|
1482
|
+
extracts = await getReportExtracts();
|
|
1483
|
+
assert.deepEqual(extracts.map(e => e.description), ['Revised Page']);
|
|
1484
|
+
// Add a report.
|
|
1485
|
+
report.target.what = 'Added Page';
|
|
1486
|
+
const secondPath = path.join(tmpReportsDir, '260101T0001-bbb.json');
|
|
1487
|
+
await fs.writeFile(secondPath, JSON.stringify(report));
|
|
1488
|
+
extracts = await getReportExtracts();
|
|
1489
|
+
assert.deepEqual(extracts.map(e => e.description), ['Added Page', 'Revised Page']);
|
|
1490
|
+
// Mutating a returned extract does not affect later results.
|
|
1491
|
+
extracts[0]!.superseded = true;
|
|
1492
|
+
extracts = await getReportExtracts();
|
|
1493
|
+
assert.equal(extracts[0]!.superseded, undefined);
|
|
1494
|
+
// Make a cached report unreadable.
|
|
1495
|
+
await fs.writeFile(secondPath, 'not JSON');
|
|
1496
|
+
extracts = await getReportExtracts();
|
|
1497
|
+
assert.deepEqual(extracts.map(e => e.description), ['Revised Page']);
|
|
1498
|
+
// Restore it.
|
|
1499
|
+
await fs.writeFile(secondPath, JSON.stringify(report));
|
|
1500
|
+
extracts = await getReportExtracts();
|
|
1501
|
+
assert.deepEqual(extracts.map(e => e.description), ['Added Page', 'Revised Page']);
|
|
1502
|
+
// Remove a report, as hiding would.
|
|
1503
|
+
await fs.rm(firstPath);
|
|
1504
|
+
extracts = await getReportExtracts();
|
|
1505
|
+
assert.deepEqual(extracts.map(e => e.description), ['Added Page']);
|
|
1506
|
+
}
|
|
1507
|
+
finally {
|
|
1508
|
+
process.env.DB_DIR = savedDbDir;
|
|
1509
|
+
await fs.rm(tmpRoot, {recursive: true}).catch(() => {});
|
|
1510
|
+
}
|
|
1511
|
+
});
|
|
1512
|
+
|
|
1454
1513
|
test('getExclusionCookieValue is deterministic for a given AUTH_CODE', async () => {
|
|
1455
1514
|
const savedAuthCode = process.env.AUTH_CODE;
|
|
1456
1515
|
process.env.AUTH_CODE = 'test-auth-code';
|
|
@@ -1522,11 +1581,11 @@ test('recordMetric increments an existing count rather than overwriting it', asy
|
|
|
1522
1581
|
process.env.DB_DIR = tmpDir;
|
|
1523
1582
|
try {
|
|
1524
1583
|
const {recordMetric, metricsPath} = await import('./util.ts');
|
|
1525
|
-
await recordMetric('mcpToolCalls', '
|
|
1526
|
-
await recordMetric('mcpToolCalls', '
|
|
1584
|
+
await recordMetric('mcpToolCalls', 'listPages');
|
|
1585
|
+
await recordMetric('mcpToolCalls', 'listPages');
|
|
1527
1586
|
await recordMetric('mcpToolCalls', 'getReport');
|
|
1528
1587
|
const metrics = JSON.parse(fsSync.readFileSync(metricsPath(), 'utf8'));
|
|
1529
|
-
assert.equal(metrics.mcpToolCalls.
|
|
1588
|
+
assert.equal(metrics.mcpToolCalls.listPages, 2);
|
|
1530
1589
|
assert.equal(metrics.mcpToolCalls.getReport, 1);
|
|
1531
1590
|
}
|
|
1532
1591
|
finally {
|
|
@@ -1577,11 +1636,11 @@ test('recordMetric keeps categories independent for the same name', async () =>
|
|
|
1577
1636
|
process.env.DB_DIR = tmpDir;
|
|
1578
1637
|
try {
|
|
1579
1638
|
const {recordMetric, metricsPath} = await import('./util.ts');
|
|
1580
|
-
await recordMetric('apiOperations', '
|
|
1581
|
-
await recordMetric('mcpToolCalls', '
|
|
1639
|
+
await recordMetric('apiOperations', 'listPages');
|
|
1640
|
+
await recordMetric('mcpToolCalls', 'listPages');
|
|
1582
1641
|
const metrics = JSON.parse(fsSync.readFileSync(metricsPath(), 'utf8'));
|
|
1583
|
-
assert.equal(metrics.apiOperations.
|
|
1584
|
-
assert.equal(metrics.mcpToolCalls.
|
|
1642
|
+
assert.equal(metrics.apiOperations.listPages, 1);
|
|
1643
|
+
assert.equal(metrics.mcpToolCalls.listPages, 1);
|
|
1585
1644
|
}
|
|
1586
1645
|
finally {
|
|
1587
1646
|
process.env.DB_DIR = savedDbDir;
|
|
@@ -1636,7 +1695,7 @@ test('getMetrics backfills categories missing from a metrics.json written before
|
|
|
1636
1695
|
// Simulate a metrics.json written before the managerActivity category was added.
|
|
1637
1696
|
fsSync.writeFileSync(tmpDir + '/metrics.json', JSON.stringify({
|
|
1638
1697
|
since: '260101T0000',
|
|
1639
|
-
pageViews: {
|
|
1698
|
+
pageViews: {listPages: 1},
|
|
1640
1699
|
mcpToolCalls: {},
|
|
1641
1700
|
apiOperations: {}
|
|
1642
1701
|
}));
|
|
@@ -1648,7 +1707,7 @@ test('getMetrics backfills categories missing from a metrics.json written before
|
|
|
1648
1707
|
await recordMetric('managerActivity', 'metrics', 'ok');
|
|
1649
1708
|
const metrics = JSON.parse(fsSync.readFileSync(metricsPath(), 'utf8'));
|
|
1650
1709
|
assert.equal(metrics.since, '260101T0000');
|
|
1651
|
-
assert.equal(metrics.pageViews.
|
|
1710
|
+
assert.equal(metrics.pageViews.listPages, 1);
|
|
1652
1711
|
assert.deepEqual(metrics.managerActivity.metrics, {ok: 1, error: 0});
|
|
1653
1712
|
}
|
|
1654
1713
|
finally {
|
package/util.ts
CHANGED
|
@@ -689,7 +689,7 @@ export const isUsableReport = (report: unknown): report is UsableReport => {
|
|
|
689
689
|
)
|
|
690
690
|
&& typeof r.jobData === 'object'
|
|
691
691
|
&& r.jobData.endTime
|
|
692
|
-
&& !isNaN(
|
|
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
|
|
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 {
|
|
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:
|
|
792
|
+
reportTime: getReportEndTime({jobData}).toISOString()
|
|
788
793
|
};
|
|
789
794
|
}
|
|
790
795
|
catch {
|
|
@@ -793,20 +798,58 @@ export const getReportExtract = async (timeStamp: string, jobID: string): Promis
|
|
|
793
798
|
};
|
|
794
799
|
}
|
|
795
800
|
};
|
|
801
|
+
// Cache of report extracts, keyed by report file path. Each entry records the modification
|
|
802
|
+
// time and size of the file when it was extracted, so an entry is reused only while the file
|
|
803
|
+
// is unchanged. Reports are mutable (e.g., reannotation rewrites all of them) and may be
|
|
804
|
+
// written, hidden, or deleted by any process, so the cache is validated against the files
|
|
805
|
+
// before every use rather than invalidated by the code that changes them.
|
|
806
|
+
const reportExtractCache = new Map<string, {mtimeMs: number, size: number, extract: ReportExtract}>();
|
|
796
807
|
// Returns extracts of all available reports.
|
|
797
808
|
export const getReportExtracts = async (onlyLatest: boolean = false): Promise<ReportExtract[]> => {
|
|
798
|
-
|
|
799
|
-
|
|
809
|
+
const reportsDir = reportsPath();
|
|
810
|
+
// Get the names and paths of the available report files.
|
|
811
|
+
const reportFileNames = await readdirOrCreate(reportsDir, 'Reports directory');
|
|
812
|
+
const reportFilePaths = reportFileNames.map(fileName => path.join(reportsDir, fileName));
|
|
813
|
+
const reportFilePathSet = new Set(reportFilePaths);
|
|
814
|
+
// Remove from the cache any report that is no longer available (e.g., hidden or deleted).
|
|
815
|
+
for (const cachedPath of reportExtractCache.keys()) {
|
|
816
|
+
if (!reportFilePathSet.has(cachedPath)) {
|
|
817
|
+
reportExtractCache.delete(cachedPath);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
// Get the current modification times and sizes of the report files. A file that disappeared
|
|
821
|
+
// after the directory was read gets values that match no cache entry, so it is reread, which
|
|
822
|
+
// fails and removes it from the cache.
|
|
823
|
+
const reportStats = await Promise.all(
|
|
824
|
+
reportFilePaths.map(filePath => fs.stat(filePath).catch(() => ({mtimeMs: NaN, size: NaN})))
|
|
825
|
+
);
|
|
800
826
|
// Initialize an array of extracts.
|
|
801
827
|
const extracts: ReportExtract[] = [];
|
|
802
|
-
// For each
|
|
803
|
-
for (const reportFileName of reportFileNames) {
|
|
828
|
+
// For each report file:
|
|
829
|
+
for (const [index, reportFileName] of reportFileNames.entries()) {
|
|
830
|
+
const filePath = reportFilePaths[index]!;
|
|
831
|
+
const stat = reportStats[index]!;
|
|
832
|
+
const cached = reportExtractCache.get(filePath);
|
|
833
|
+
// If its cached extract is current:
|
|
834
|
+
if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
|
|
835
|
+
// Add a copy of the cached extract to the array.
|
|
836
|
+
extracts.push({...cached.extract});
|
|
837
|
+
continue;
|
|
838
|
+
}
|
|
839
|
+
// Otherwise, get a new extract of it. The stat precedes the read, so a change between
|
|
840
|
+
// them leaves a stale modification time in the cache and causes a reread next time.
|
|
804
841
|
const [timeStamp, jobID] = reportFileName.slice(0, -5).split('-') as [string, string];
|
|
805
|
-
// Get an extract of it.
|
|
806
842
|
const extract = await getReportExtract(timeStamp, jobID);
|
|
843
|
+
// If this succeeded:
|
|
807
844
|
if (!('error' in extract)) {
|
|
808
|
-
//
|
|
809
|
-
|
|
845
|
+
// Cache it and add a copy of it to the array.
|
|
846
|
+
reportExtractCache.set(filePath, {mtimeMs: stat.mtimeMs, size: stat.size, extract});
|
|
847
|
+
extracts.push({...extract});
|
|
848
|
+
}
|
|
849
|
+
// Otherwise, i.e. if it failed:
|
|
850
|
+
else {
|
|
851
|
+
// Forget any outdated extract of it.
|
|
852
|
+
reportExtractCache.delete(filePath);
|
|
810
853
|
}
|
|
811
854
|
}
|
|
812
855
|
// Sort the extracts by page description and, secondarily, completion time.
|
|
@@ -823,6 +866,12 @@ export const getReportExtracts = async (onlyLatest: boolean = false): Promise<Re
|
|
|
823
866
|
// Return the array, excluding extracts of superseded reports if so specified.
|
|
824
867
|
return onlyLatest ? extracts.filter(extract => !extract.superseded) : extracts;
|
|
825
868
|
};
|
|
869
|
+
// Returns extracts of all available reports about the page with a description, from oldest
|
|
870
|
+
// to latest.
|
|
871
|
+
export const getPageReportExtracts = async (description: string): Promise<ReportExtract[]> => {
|
|
872
|
+
const reportExtracts = await getReportExtracts();
|
|
873
|
+
return reportExtracts.filter(extract => extract.description === description);
|
|
874
|
+
};
|
|
826
875
|
// Returns whether a report with a description or URL is available.
|
|
827
876
|
export const isReportAvailable = async (description: string, url: string): Promise<boolean> => {
|
|
828
877
|
const reportExtracts = await getReportExtracts();
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
<main>
|
|
15
15
|
<h1><a href="/">Kilotest</a>: Request approval or rejection</h1>
|
|
16
16
|
<p>To approve a test or retest request, check its requested page name.</p>
|
|
17
|
-
<p>To approve testing a page that has not yet been requested, first <a href="
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
<
|
|
28
|
+
<h2>History</h2>
|
|
29
|
+
__history__
|
|
29
30
|
<h2>Issues reported</h2>
|
|
30
31
|
<details>
|
|
31
32
|
<summary>Highest priority: __highestCount__</summary>
|
package/web/listIssues/index.ts
CHANGED
|
@@ -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
|
+
});
|
|
@@ -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
|
+
};
|