@jrpool/kilotest 59.1.4 → 60.0.1
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/README.md +2 -5
- package/api/awaitTest.test.ts +9 -9
- package/api/awaitTest.ts +12 -12
- package/api/getReport.test.ts +4 -4
- package/api/getReport.ts +9 -9
- package/api/listDiagnoses.test.ts +14 -14
- package/api/listDiagnoses.ts +8 -8
- package/api/listIssues.test.ts +15 -15
- package/api/listIssues.ts +18 -20
- package/api/listPages.test.ts +2 -2
- package/api/listPages.ts +4 -4
- package/api/listViolators.test.ts +10 -10
- package/api/listViolators.ts +10 -10
- package/api/orderNewTest.test.ts +1 -2
- package/api/orderNewTest.ts +3 -4
- package/api/orderRetest.test.ts +10 -10
- package/api/orderRetest.ts +13 -14
- package/api/requestRetest.test.ts +11 -11
- package/api/requestRetest.ts +11 -11
- package/api/routes.ts +9 -11
- package/api/schemas.ts +18 -28
- package/api/util.test.ts +2 -2
- package/api/util.ts +7 -7
- package/capability.md +4 -4
- package/docs/AI-TOOLS.md +43 -1
- package/docs/decisions/009-report-id.md +16 -0
- package/index.test.ts +91 -41
- package/index.ts +66 -15
- package/lhm.plugin.json +33 -119
- package/llms-full.txt +18 -24
- package/llms.txt +12 -12
- package/mcp.test.ts +11 -20
- package/mcp.ts +18 -18
- package/openapi-3.0.yaml +1847 -0
- package/openapi.yaml +38 -97
- package/package.json +1 -1
- package/routing.test.ts +1 -1
- package/scripts/generate-openapi.ts +83 -1
- package/server.json +1 -1
- package/util.test.ts +32 -33
- package/util.ts +47 -43
- package/web/hideReportForm/index.ts +5 -6
- package/web/listDiagnoses/index.ts +4 -5
- package/web/listDiagnoses/listDiagnoses.test.ts +13 -13
- package/web/listIssues/index.ts +11 -12
- package/web/listIssues/listIssues.test.ts +24 -24
- package/web/listPages/index.ts +2 -2
- package/web/listPages/listPages.test.ts +2 -2
- package/web/listTopIssues/index.ts +2 -2
- package/web/listViolators/index.ts +5 -6
- package/web/listViolators/listViolators.test.ts +12 -12
- package/web/reannotate/index.ts +3 -3
- package/web/reannotateForm/index.ts +1 -2
- package/web/reportDeletion.ts +9 -12
- package/web/requestRetest/index.ts +2 -2
- package/web/requestRetest/requestRetest.test.ts +7 -7
- package/web/requestRetestForm/index.html +2 -2
- package/web/requestRetestForm/index.ts +3 -4
- package/web/requestRetestForm/requestRetestForm.test.ts +7 -8
- package/web/unhideReportForm/index.ts +4 -6
- package/web/util.test.ts +29 -29
- package/web/util.ts +1 -1
package/util.test.ts
CHANGED
|
@@ -1009,8 +1009,8 @@ test('processTestRequest returns "url" for a page matching a queued job by an eq
|
|
|
1009
1009
|
});
|
|
1010
1010
|
|
|
1011
1011
|
test('processTestRequest returns "nonreport" when the cited report does not exist', async () => {
|
|
1012
|
-
// Cite a
|
|
1013
|
-
const {result} = await processTestRequest('because', {
|
|
1012
|
+
// Cite a reportID that does not correspond to any available report.
|
|
1013
|
+
const {result} = await processTestRequest('because', {reportID: '999999T9999-xxx'});
|
|
1014
1014
|
assert.equal(result, 'nonreport');
|
|
1015
1015
|
});
|
|
1016
1016
|
|
|
@@ -1259,7 +1259,7 @@ test('getReport returns an error for an invalid report', async () => {
|
|
|
1259
1259
|
process.env.DB_DIR = tmpDir;
|
|
1260
1260
|
try {
|
|
1261
1261
|
const {getReport} = await import('./util.ts');
|
|
1262
|
-
const result = await getReport('260101T0000
|
|
1262
|
+
const result = await getReport('260101T0000-bad') as any;
|
|
1263
1263
|
assert.ok(result.error);
|
|
1264
1264
|
assert.ok(result.error.includes('not usable'));
|
|
1265
1265
|
}
|
|
@@ -1408,7 +1408,7 @@ test('testRequestsLock is a function (the lock returned by createLock)', () => {
|
|
|
1408
1408
|
});
|
|
1409
1409
|
|
|
1410
1410
|
test('getReportPath returns the path of a report file', () => {
|
|
1411
|
-
const result = getReportPath('260101T0000
|
|
1411
|
+
const result = getReportPath('260101T0000-mix');
|
|
1412
1412
|
assert.ok(result.endsWith('260101T0000-mix.json'));
|
|
1413
1413
|
});
|
|
1414
1414
|
|
|
@@ -1422,29 +1422,28 @@ test('getReportEndTime returns an invalid date for a report with no completion t
|
|
|
1422
1422
|
});
|
|
1423
1423
|
|
|
1424
1424
|
test('getReportStats returns reportSize for a valid report', async () => {
|
|
1425
|
-
const stats = await getReportStats('260101T0000
|
|
1425
|
+
const stats = await getReportStats('260101T0000-mix');
|
|
1426
1426
|
assert.ok(stats);
|
|
1427
1427
|
assert.equal(typeof stats.reportSize, 'number');
|
|
1428
1428
|
assert.ok(stats.reportSize > 0);
|
|
1429
1429
|
});
|
|
1430
1430
|
|
|
1431
1431
|
test('getReportStats returns null for a nonexistent report', async () => {
|
|
1432
|
-
const stats = await getReportStats('999999T9999
|
|
1432
|
+
const stats = await getReportStats('999999T9999-xxx');
|
|
1433
1433
|
assert.equal(stats, null);
|
|
1434
1434
|
});
|
|
1435
1435
|
|
|
1436
1436
|
|
|
1437
1437
|
test('getReportExtract returns an extract for a valid report', async () => {
|
|
1438
|
-
const extract = await getReportExtract('260101T0000
|
|
1439
|
-
assert.equal(extract.
|
|
1440
|
-
assert.equal(extract.jobID, 'mix');
|
|
1438
|
+
const extract = await getReportExtract('260101T0000-mix') as any;
|
|
1439
|
+
assert.equal(extract.id, '260101T0000-mix');
|
|
1441
1440
|
assert.equal(extract.description, 'Mixed Outcomes');
|
|
1442
1441
|
assert.equal(extract.url, 'https://example.com/mixed');
|
|
1443
1442
|
assert.ok(extract.reportTime);
|
|
1444
1443
|
});
|
|
1445
1444
|
|
|
1446
1445
|
test('getReportExtract returns an error for a nonexistent report', async () => {
|
|
1447
|
-
const extract = await getReportExtract('999999T9999
|
|
1446
|
+
const extract = await getReportExtract('999999T9999-xxx') as any;
|
|
1448
1447
|
assert.ok(extract.error);
|
|
1449
1448
|
});
|
|
1450
1449
|
|
|
@@ -1465,7 +1464,7 @@ test('getReportExtract returns an error for an unusable report', async () => {
|
|
|
1465
1464
|
jobData: {endTime: '26-01-01T00:00'}
|
|
1466
1465
|
})
|
|
1467
1466
|
);
|
|
1468
|
-
const extract = await getReportExtract('260101T0000
|
|
1467
|
+
const extract = await getReportExtract('260101T0000-bad') as any;
|
|
1469
1468
|
assert.ok(extract.error);
|
|
1470
1469
|
}
|
|
1471
1470
|
finally {
|
|
@@ -1485,7 +1484,7 @@ test('quarantineReport ignores a missing report', async () => {
|
|
|
1485
1484
|
const savedDbDir = process.env.DB_DIR;
|
|
1486
1485
|
process.env.DB_DIR = path.join(tmpRoot, 'db');
|
|
1487
1486
|
try {
|
|
1488
|
-
await quarantineReport('999999T9999
|
|
1487
|
+
await quarantineReport('999999T9999-xxx', 'not usable');
|
|
1489
1488
|
// Nothing was created or moved.
|
|
1490
1489
|
assert.deepEqual(await fs.readdir(tmpRoot), []);
|
|
1491
1490
|
}
|
|
@@ -1510,7 +1509,7 @@ test('quarantineReport leaves a recently modified report in place', async () =>
|
|
|
1510
1509
|
const savedDbDir = process.env.DB_DIR;
|
|
1511
1510
|
process.env.DB_DIR = path.join(tmpRoot, 'db');
|
|
1512
1511
|
try {
|
|
1513
|
-
await quarantineReport('260101T0000
|
|
1512
|
+
await quarantineReport('260101T0000-bad', 'not usable');
|
|
1514
1513
|
// The fresh file may still be mid-write, so it was not moved.
|
|
1515
1514
|
await fs.access(filePath);
|
|
1516
1515
|
}
|
|
@@ -1538,7 +1537,7 @@ test('quarantineReport quarantines a report old enough to be quarantined', async
|
|
|
1538
1537
|
const savedDbDir = process.env.DB_DIR;
|
|
1539
1538
|
process.env.DB_DIR = path.join(tmpRoot, 'db');
|
|
1540
1539
|
try {
|
|
1541
|
-
await quarantineReport('260101T0000
|
|
1540
|
+
await quarantineReport('260101T0000-bad', 'not usable');
|
|
1542
1541
|
// The file was moved to the unusable-reports directory.
|
|
1543
1542
|
await fs.access(filePath).then(
|
|
1544
1543
|
() => {throw new Error('Report should have been quarantined');},
|
|
@@ -1573,7 +1572,7 @@ test('quarantineReport leaves the file and logs when the move fails', async () =
|
|
|
1573
1572
|
const savedDbDir = process.env.DB_DIR;
|
|
1574
1573
|
process.env.DB_DIR = path.join(tmpRoot, 'db');
|
|
1575
1574
|
try {
|
|
1576
|
-
await quarantineReport('260101T0000
|
|
1575
|
+
await quarantineReport('260101T0000-bad', 'not usable');
|
|
1577
1576
|
// The report stays where it was.
|
|
1578
1577
|
await fs.access(filePath);
|
|
1579
1578
|
}
|
|
@@ -1602,7 +1601,7 @@ test('getReport quarantines a malformed report file old enough to be quarantined
|
|
|
1602
1601
|
process.env.DB_DIR = path.join(tmpRoot, 'db');
|
|
1603
1602
|
try {
|
|
1604
1603
|
const {getReport} = await import('./util.ts');
|
|
1605
|
-
const result = await getReport('260101T0000
|
|
1604
|
+
const result = await getReport('260101T0000-bad') as any;
|
|
1606
1605
|
assert.ok(result.error);
|
|
1607
1606
|
// The malformed file was quarantined.
|
|
1608
1607
|
const quarantined = await fs.readdir(path.join(tmpRoot, 'db', 'unusableReports'));
|
|
@@ -1622,7 +1621,7 @@ test('getReport quarantines a malformed report file old enough to be quarantined
|
|
|
1622
1621
|
test('getReportExtracts returns extracts of all available reports', async () => {
|
|
1623
1622
|
const extracts = await getReportExtracts();
|
|
1624
1623
|
assert.ok(extracts.length >= 8);
|
|
1625
|
-
const ids = extracts.map(e =>
|
|
1624
|
+
const ids = extracts.map(e => e.id);
|
|
1626
1625
|
assert.ok(ids.includes('260101T0000-mix'));
|
|
1627
1626
|
assert.ok(ids.includes('260101T0001-ct'));
|
|
1628
1627
|
});
|
|
@@ -1631,7 +1630,7 @@ test('getReportExtracts with onlyLatest returns only the latest report for each
|
|
|
1631
1630
|
const latest = await getReportExtracts(true);
|
|
1632
1631
|
const mixReports = latest.filter(e => e.description === 'Mixed Outcomes');
|
|
1633
1632
|
assert.equal(mixReports.length, 1);
|
|
1634
|
-
assert.equal(mixReports[0]!.
|
|
1633
|
+
assert.equal(mixReports[0]!.id, '260202T0000-new');
|
|
1635
1634
|
});
|
|
1636
1635
|
|
|
1637
1636
|
test('getReportExtracts reflects reports added, modified, and removed after a previous call', async () => {
|
|
@@ -1918,14 +1917,14 @@ test('getMetrics backfills every category, and generates a since stamp, from a b
|
|
|
1918
1917
|
// TESTS FOR orderJob AND awaitJob (GitHub issue #111)
|
|
1919
1918
|
|
|
1920
1919
|
test('orderJob enqueues a new-page order directly into the job queue', async () => {
|
|
1921
|
-
const {result, description, url,
|
|
1920
|
+
const {result, description, url, reportID} = await orderJob(
|
|
1922
1921
|
{description: 'Ordered Page', url: 'https://example.com/ordered'}, 'because accessibility matters here'
|
|
1923
1922
|
) as any;
|
|
1924
1923
|
assert.equal(result, 'ok');
|
|
1925
1924
|
assert.equal(description, 'Ordered Page');
|
|
1926
1925
|
assert.equal(url, 'https://example.com/ordered');
|
|
1927
|
-
assert.match(
|
|
1928
|
-
const queuedPath = path.join(jobsPath(), 'queue', `${
|
|
1926
|
+
assert.match(reportID, /^\d{6}T\d{4}-[a-z0-9]{3}$/);
|
|
1927
|
+
const queuedPath = path.join(jobsPath(), 'queue', `${reportID}.json`);
|
|
1929
1928
|
try {
|
|
1930
1929
|
const job = JSON.parse(await fs.readFile(queuedPath, 'utf8'));
|
|
1931
1930
|
assert.equal(job.target.what, 'Ordered Page');
|
|
@@ -2010,7 +2009,7 @@ test('orderJob throws when a job file in a category directory is defective', asy
|
|
|
2010
2009
|
});
|
|
2011
2010
|
|
|
2012
2011
|
test('orderJob returns "nonreport" when a retest order cites a report that does not exist', async () => {
|
|
2013
|
-
const {result} = await orderJob({
|
|
2012
|
+
const {result} = await orderJob({reportID: '999999T9999-xxx'}, 'because accessibility matters here');
|
|
2014
2013
|
assert.equal(result, 'nonreport');
|
|
2015
2014
|
});
|
|
2016
2015
|
|
|
@@ -2018,19 +2017,19 @@ test('orderJob returns "superseded" when a retest order cites a superseded repor
|
|
|
2018
2017
|
// 260101T0000-mix is an earlier report of "Mixed Outcomes" than 260202T0000-new,
|
|
2019
2018
|
// so it is superseded.
|
|
2020
2019
|
const {result} = await orderJob(
|
|
2021
|
-
{
|
|
2020
|
+
{reportID: '260101T0000-mix'}, 'because accessibility matters here'
|
|
2022
2021
|
);
|
|
2023
2022
|
assert.equal(result, 'superseded');
|
|
2024
2023
|
});
|
|
2025
2024
|
|
|
2026
2025
|
test('orderJob enqueues a retest order for the latest report of a page', async () => {
|
|
2027
|
-
const {result, description, url,
|
|
2028
|
-
{
|
|
2026
|
+
const {result, description, url, reportID} = await orderJob(
|
|
2027
|
+
{reportID: '260202T0000-new'}, 'because accessibility matters here'
|
|
2029
2028
|
) as any;
|
|
2030
2029
|
assert.equal(result, 'ok');
|
|
2031
2030
|
assert.equal(description, 'Mixed Outcomes');
|
|
2032
2031
|
assert.equal(url, 'https://example.com/mixed');
|
|
2033
|
-
const queuedPath = path.join(jobsPath(), 'queue', `${
|
|
2032
|
+
const queuedPath = path.join(jobsPath(), 'queue', `${reportID}.json`);
|
|
2034
2033
|
try {
|
|
2035
2034
|
const job = JSON.parse(await fs.readFile(queuedPath, 'utf8'));
|
|
2036
2035
|
assert.equal(job.target.what, 'Mixed Outcomes');
|
|
@@ -2062,12 +2061,12 @@ test('orderJob never returns queueFull when JOB_QUEUE_MAX is 0 (no limit)', asyn
|
|
|
2062
2061
|
const fillerPath = path.join(jobsPath(), 'queue', 'fi2.json');
|
|
2063
2062
|
await fs.writeFile(fillerPath, getJSON({target: {what: 'Filler Queued Page Two', url: 'https://example.com/filler-queued-2'}}));
|
|
2064
2063
|
try {
|
|
2065
|
-
const {result,
|
|
2064
|
+
const {result, reportID} = await orderJob(
|
|
2066
2065
|
{description: 'One More Order Page', url: 'https://example.com/one-more-order'},
|
|
2067
2066
|
'because accessibility matters here'
|
|
2068
2067
|
) as any;
|
|
2069
2068
|
assert.equal(result, 'ok');
|
|
2070
|
-
await fs.unlink(path.join(jobsPath(), 'queue', `${
|
|
2069
|
+
await fs.unlink(path.join(jobsPath(), 'queue', `${reportID}.json`));
|
|
2071
2070
|
}
|
|
2072
2071
|
finally {
|
|
2073
2072
|
delete process.env.JOB_QUEUE_MAX;
|
|
@@ -2076,12 +2075,12 @@ test('orderJob never returns queueFull when JOB_QUEUE_MAX is 0 (no limit)', asyn
|
|
|
2076
2075
|
});
|
|
2077
2076
|
|
|
2078
2077
|
test('awaitJob returns "completed" immediately when a report already exists', async () => {
|
|
2079
|
-
const outcome = await awaitJob('260101T0000
|
|
2078
|
+
const outcome = await awaitJob('260101T0000-mix');
|
|
2080
2079
|
assert.equal(outcome, 'completed');
|
|
2081
2080
|
});
|
|
2082
2081
|
|
|
2083
2082
|
test('awaitJob returns "notFound" for an identifier naming neither a report nor a pending job', async () => {
|
|
2084
|
-
const outcome = await awaitJob('999999T9999
|
|
2083
|
+
const outcome = await awaitJob('999999T9999-xxx');
|
|
2085
2084
|
assert.equal(outcome, 'notFound');
|
|
2086
2085
|
});
|
|
2087
2086
|
|
|
@@ -2090,7 +2089,7 @@ test('awaitJob returns "failed" when the job has moved to the failed directory',
|
|
|
2090
2089
|
await fs.mkdir(path.dirname(failedPath), {recursive: true});
|
|
2091
2090
|
await fs.writeFile(failedPath, getJSON({target: {what: 'Failed Await Page', url: 'https://example.com/failed-await'}}));
|
|
2092
2091
|
try {
|
|
2093
|
-
const outcome = await awaitJob('260101T1111
|
|
2092
|
+
const outcome = await awaitJob('260101T1111-flj');
|
|
2094
2093
|
assert.equal(outcome, 'failed');
|
|
2095
2094
|
}
|
|
2096
2095
|
finally {
|
|
@@ -2111,7 +2110,7 @@ test('awaitJob polls until a queued job completes, then returns "completed"', as
|
|
|
2111
2110
|
await fs.writeFile(reportPath, getJSON({id: jobName, target: {what: 'Polled Page', url: 'https://example.com/polled'}}));
|
|
2112
2111
|
}, 50);
|
|
2113
2112
|
try {
|
|
2114
|
-
const outcome = await awaitJob('260101T2222
|
|
2113
|
+
const outcome = await awaitJob('260101T2222-plj');
|
|
2115
2114
|
assert.equal(outcome, 'completed');
|
|
2116
2115
|
}
|
|
2117
2116
|
finally {
|
|
@@ -2129,7 +2128,7 @@ test('awaitJob returns "timedOut" when a claimed job neither completes nor fails
|
|
|
2129
2128
|
const claimedPath = path.join(jobsPath(), 'claimed', `${jobName}.json`);
|
|
2130
2129
|
await fs.writeFile(claimedPath, getJSON({target: {what: 'Stuck Page', url: 'https://example.com/stuck'}}));
|
|
2131
2130
|
try {
|
|
2132
|
-
const outcome = await awaitJob('260101T3333
|
|
2131
|
+
const outcome = await awaitJob('260101T3333-toj');
|
|
2133
2132
|
assert.equal(outcome, 'timedOut');
|
|
2134
2133
|
}
|
|
2135
2134
|
finally {
|
package/util.ts
CHANGED
|
@@ -89,9 +89,9 @@ export type TestRequestResult
|
|
|
89
89
|
= 'url' | 'description' | 'reportExists' | 'duplicate' | 'superseded' | 'nonreport' | 'queueFull' | 'ok';
|
|
90
90
|
|
|
91
91
|
// Target of a new-test or retest request: a description and URL for a new-test request,
|
|
92
|
-
// or the
|
|
92
|
+
// or the identifier of the cited report for a retest request.
|
|
93
93
|
export type TestRequestTarget
|
|
94
|
-
= {description: string; url: string} | {
|
|
94
|
+
= {description: string; url: string} | {reportID: string};
|
|
95
95
|
|
|
96
96
|
// Result of processTestRequest. description and url are absent only when the result
|
|
97
97
|
// is 'nonreport', i.e. when the target page could not be resolved; this is a
|
|
@@ -134,8 +134,7 @@ export type UsableReport = Omit<Report, 'target' | 'catalog' | 'jobData' | 'acts
|
|
|
134
134
|
|
|
135
135
|
// An extract of an available report.
|
|
136
136
|
export type ReportExtract = {
|
|
137
|
-
|
|
138
|
-
jobID: string;
|
|
137
|
+
id: string;
|
|
139
138
|
description: string;
|
|
140
139
|
url: string;
|
|
141
140
|
reportTime: string;
|
|
@@ -451,6 +450,13 @@ export const isJobID = (string: string): boolean => {
|
|
|
451
450
|
export const isTimeStamp = (string: string): boolean => {
|
|
452
451
|
return !!getDateString(string);
|
|
453
452
|
};
|
|
453
|
+
// Returns whether a string is a report identifier: the identifier of the job
|
|
454
|
+
// that produced the report, composed of the job's creation time stamp and its
|
|
455
|
+
// random suffix, joined with a hyphen (example: 260503T0432-x9z).
|
|
456
|
+
export const isReportID = (string: string): boolean => {
|
|
457
|
+
const match = /^(\d{6}T\d{4})-([a-z0-9]{3})$/.exec(string);
|
|
458
|
+
return !!match && isTimeStamp(match[1]!) && isJobID(match[2]!);
|
|
459
|
+
};
|
|
454
460
|
// Returns whether a string is a URL.
|
|
455
461
|
export const isURL = (string: string): boolean => {
|
|
456
462
|
try {
|
|
@@ -672,8 +678,8 @@ export const getTestActInstances = (
|
|
|
672
678
|
return pairs;
|
|
673
679
|
};
|
|
674
680
|
// Returns the path of an available report file.
|
|
675
|
-
export const getReportPath = (
|
|
676
|
-
.join(reportsPath(), `${
|
|
681
|
+
export const getReportPath = (reportID: string): string => path
|
|
682
|
+
.join(reportsPath(), `${reportID}.json`);
|
|
677
683
|
// Returns whether a report is usable by Kilotest.
|
|
678
684
|
export const isUsableReport = (report: unknown): report is UsableReport => {
|
|
679
685
|
// Cast to any for the property checks inside the guard — this is appropriate
|
|
@@ -707,10 +713,9 @@ const quarantineMinAgeMs = () => getEnvMax('REPORT_QUARANTINE_MIN_AGE_MS', 60000
|
|
|
707
713
|
// that a defective report is quarantined rather than merely hidden from whichever
|
|
708
714
|
// listing encountered it. Never throws: a failure here must not displace the error
|
|
709
715
|
// that getReport is already reporting.
|
|
710
|
-
export const quarantineReport = async (
|
|
711
|
-
const jobName = `${timeStamp}-${jobID}`;
|
|
716
|
+
export const quarantineReport = async (reportID: string, defect: string): Promise<void> => {
|
|
712
717
|
// Get the modification time of the report file. A missing file needs no move.
|
|
713
|
-
const stat = await fs.stat(getReportPath(
|
|
718
|
+
const stat = await fs.stat(getReportPath(reportID)).catch(() => null);
|
|
714
719
|
if (!stat) {
|
|
715
720
|
return;
|
|
716
721
|
}
|
|
@@ -721,26 +726,26 @@ export const quarantineReport = async (timeStamp: string, jobID: string, defect:
|
|
|
721
726
|
try {
|
|
722
727
|
await fs.mkdir(unusableReportsPath(), {recursive: true});
|
|
723
728
|
await fs.rename(
|
|
724
|
-
getReportPath(
|
|
729
|
+
getReportPath(reportID), path.join(unusableReportsPath(), `${reportID}.json`)
|
|
725
730
|
);
|
|
726
731
|
}
|
|
727
732
|
catch (error: unknown) {
|
|
728
733
|
// The report stays excluded regardless, so a failed move is only logged.
|
|
729
|
-
console.error(`ERROR: Failed to quarantine report ${
|
|
734
|
+
console.error(`ERROR: Failed to quarantine report ${reportID} (${errorMessage(error)})`);
|
|
730
735
|
return;
|
|
731
736
|
}
|
|
732
737
|
// Alert a manager, who can repair the report in place or delete it.
|
|
733
738
|
await sendAlert(
|
|
734
739
|
'Kilotest: unusable report',
|
|
735
|
-
`Report ${
|
|
740
|
+
`Report ${reportID} is ${defect}. It was moved to the unusableReports directory, where a manager can repair or delete it.`
|
|
736
741
|
);
|
|
737
742
|
};
|
|
738
743
|
// Returns a report, or an error object if it is missing, unreadable, unparsable, or
|
|
739
744
|
// unusable. A report that exists but cannot be read or is unusable is defective, so
|
|
740
745
|
// it is quarantined rather than merely rejected by whichever read encountered it.
|
|
741
|
-
export const getReport = async (
|
|
746
|
+
export const getReport = async (reportID: string): Promise<UsableReport | {error: string}> => {
|
|
742
747
|
try {
|
|
743
|
-
const reportJSON = await fs.readFile(getReportPath(
|
|
748
|
+
const reportJSON = await fs.readFile(getReportPath(reportID), 'utf8');
|
|
744
749
|
const report = JSON.parse(reportJSON);
|
|
745
750
|
// If it is usable:
|
|
746
751
|
if (isUsableReport(report)) {
|
|
@@ -750,12 +755,12 @@ export const getReport = async (timeStamp: string, jobID: string): Promise<Usabl
|
|
|
750
755
|
} catch (error: unknown) {
|
|
751
756
|
// The file exists but cannot be read or parsed, or is already missing.
|
|
752
757
|
// quarantineReport ignores a missing file, so no existence check is needed here.
|
|
753
|
-
await quarantineReport(
|
|
754
|
-
return {error: `Report ${
|
|
758
|
+
await quarantineReport(reportID, 'unreadable or not JSON');
|
|
759
|
+
return {error: `Report ${reportID} is missing, unreadable, or not JSON (${errorMessage(error)})`};
|
|
755
760
|
}
|
|
756
761
|
// The report was read but is not usable, so quarantine it.
|
|
757
|
-
await quarantineReport(
|
|
758
|
-
return {error: `Report ${
|
|
762
|
+
await quarantineReport(reportID, 'not usable');
|
|
763
|
+
return {error: `Report ${reportID} is not usable`};
|
|
759
764
|
};
|
|
760
765
|
// Returns whether a getReport result is an error (rather than a usable report).
|
|
761
766
|
// A type predicate is used instead of `if (report.error)` because UsableReport
|
|
@@ -804,10 +809,10 @@ export const annotateReportObject = async (report: UsableReport): Promise<void>
|
|
|
804
809
|
}
|
|
805
810
|
};
|
|
806
811
|
// Returns the size of a report, or null if the report does not exist.
|
|
807
|
-
export const getReportStats = async (
|
|
812
|
+
export const getReportStats = async (reportID: string) => {
|
|
808
813
|
let reportStat;
|
|
809
814
|
try {
|
|
810
|
-
reportStat = await fs.stat(path.join(reportsPath(), `${
|
|
815
|
+
reportStat = await fs.stat(path.join(reportsPath(), `${reportID}.json`));
|
|
811
816
|
}
|
|
812
817
|
catch {
|
|
813
818
|
return null;
|
|
@@ -825,8 +830,8 @@ export const getReportEndTime = (report: {jobData?: {endTime?: string}}): Date =
|
|
|
825
830
|
// Returns an extract of an available report, or an error object if the report cannot
|
|
826
831
|
// be used. An extract exists to expose a report in listings, so it shares getReport's
|
|
827
832
|
// usability bar: a report too defective to expose must produce no extract.
|
|
828
|
-
export const getReportExtract = async (
|
|
829
|
-
const report = await getReport(
|
|
833
|
+
export const getReportExtract = async (reportID: string): Promise<ReportExtract | {error: string}> => {
|
|
834
|
+
const report = await getReport(reportID);
|
|
830
835
|
// If the report is not usable:
|
|
831
836
|
if (isReportError(report)) {
|
|
832
837
|
// Return the reason.
|
|
@@ -837,8 +842,7 @@ export const getReportExtract = async (timeStamp: string, jobID: string): Promis
|
|
|
837
842
|
// Return an extract of it. Usability has already guaranteed these properties,
|
|
838
843
|
// including a valid completion time.
|
|
839
844
|
return {
|
|
840
|
-
|
|
841
|
-
jobID,
|
|
845
|
+
id: reportID,
|
|
842
846
|
description,
|
|
843
847
|
url,
|
|
844
848
|
reportTime: getReportEndTime({jobData}).toISOString()
|
|
@@ -884,8 +888,7 @@ export const getReportExtracts = async (onlyLatest: boolean = false): Promise<Re
|
|
|
884
888
|
}
|
|
885
889
|
// Otherwise, get a new extract of it. The stat precedes the read, so a change between
|
|
886
890
|
// them leaves a stale modification time in the cache and causes a reread next time.
|
|
887
|
-
const
|
|
888
|
-
const extract = await getReportExtract(timeStamp, jobID);
|
|
891
|
+
const extract = await getReportExtract(reportFileName.slice(0, -5));
|
|
889
892
|
// If this succeeded:
|
|
890
893
|
if (!('error' in extract)) {
|
|
891
894
|
// Cache it and add a copy of it to the array.
|
|
@@ -950,7 +953,7 @@ const getApprovedJobProperty = async (
|
|
|
950
953
|
}
|
|
951
954
|
};
|
|
952
955
|
// Processes a new-test or retest request as a transaction and returns the result.
|
|
953
|
-
// For a retest request (target has a
|
|
956
|
+
// For a retest request (target has a reportID), resolves the description
|
|
954
957
|
// and URL of the cited report itself, so a caller never needs its own
|
|
955
958
|
// getReportExtract call. Approvability (whether the request may proceed) and, if so,
|
|
956
959
|
// its addition to testRequests.json are both decided inside the lock, as a single
|
|
@@ -995,11 +998,11 @@ export const processTestRequest = (
|
|
|
995
998
|
// Whether the cited report (for a retest) has been superseded by a later one.
|
|
996
999
|
let superseded = false;
|
|
997
1000
|
// If the target identifies a report to retest:
|
|
998
|
-
if ('
|
|
1001
|
+
if ('reportID' in target) {
|
|
999
1002
|
requestType = 'retest';
|
|
1000
|
-
const {
|
|
1003
|
+
const {reportID} = target;
|
|
1001
1004
|
// Get the cited report.
|
|
1002
|
-
const extract = await getReportExtract(
|
|
1005
|
+
const extract = await getReportExtract(reportID);
|
|
1003
1006
|
// If it does not exist:
|
|
1004
1007
|
if ('error' in extract) {
|
|
1005
1008
|
// Return this.
|
|
@@ -1009,7 +1012,7 @@ export const processTestRequest = (
|
|
|
1009
1012
|
// Get whether it has been superseded by a later report.
|
|
1010
1013
|
const reportExtracts = await getReportExtracts();
|
|
1011
1014
|
superseded = reportExtracts.some(
|
|
1012
|
-
ex => ex.
|
|
1015
|
+
ex => ex.id === reportID && ex.superseded
|
|
1013
1016
|
);
|
|
1014
1017
|
}
|
|
1015
1018
|
// Otherwise, i.e. if the target is a page to test for the first time:
|
|
@@ -1099,10 +1102,11 @@ const jobQueueMax = () => getEnvMax('JOB_QUEUE_MAX', 20);
|
|
|
1099
1102
|
// 'nonreport' (a retest order citing a report that does not exist), matching
|
|
1100
1103
|
// ProcessRequestResult's own discriminated-union shape. There is no pending approval
|
|
1101
1104
|
// inbox to compare against, so 'duplicate' (an identical pending request) never applies.
|
|
1102
|
-
//
|
|
1105
|
+
// reportID (the enqueued job's own identifier, and eventually its report's) is
|
|
1106
|
+
// present only on 'ok'.
|
|
1103
1107
|
export type OrderJobResult
|
|
1104
1108
|
= {result: 'nonreport'}
|
|
1105
|
-
| {result: 'ok'; description: string; url: string;
|
|
1109
|
+
| {result: 'ok'; description: string; url: string; reportID: string}
|
|
1106
1110
|
| {result: Exclude<TestRequestResult, 'nonreport' | 'duplicate' | 'ok'>; description: string; url: string};
|
|
1107
1111
|
// Orders a test or retest as a transaction: validates the target is not already queued,
|
|
1108
1112
|
// claimed, or (for a new-page order) reported, then enqueues a job directly into
|
|
@@ -1125,10 +1129,10 @@ export const orderJob = (
|
|
|
1125
1129
|
// Whether the cited report (for a retest order) has been superseded by a later one.
|
|
1126
1130
|
let superseded = false;
|
|
1127
1131
|
// If the target identifies a report to retest:
|
|
1128
|
-
if ('
|
|
1129
|
-
const {
|
|
1132
|
+
if ('reportID' in target) {
|
|
1133
|
+
const {reportID} = target;
|
|
1130
1134
|
// Get the cited report.
|
|
1131
|
-
const extract = await getReportExtract(
|
|
1135
|
+
const extract = await getReportExtract(reportID);
|
|
1132
1136
|
// If it does not exist:
|
|
1133
1137
|
if ('error' in extract) {
|
|
1134
1138
|
// Return this.
|
|
@@ -1138,7 +1142,7 @@ export const orderJob = (
|
|
|
1138
1142
|
// Get whether it has been superseded by a later report.
|
|
1139
1143
|
const reportExtracts = await getReportExtracts();
|
|
1140
1144
|
superseded = reportExtracts.some(
|
|
1141
|
-
ex => ex.
|
|
1145
|
+
ex => ex.id === reportID && ex.superseded
|
|
1142
1146
|
);
|
|
1143
1147
|
}
|
|
1144
1148
|
// Otherwise, i.e. if the target is a page to test for the first time:
|
|
@@ -1160,13 +1164,13 @@ export const orderJob = (
|
|
|
1160
1164
|
};
|
|
1161
1165
|
}
|
|
1162
1166
|
// Otherwise, if the order is to retest a page that has been superseded:
|
|
1163
|
-
if ('
|
|
1167
|
+
if ('reportID' in target && superseded) {
|
|
1164
1168
|
// Return this.
|
|
1165
1169
|
return {result: 'superseded', description, url};
|
|
1166
1170
|
}
|
|
1167
1171
|
// Otherwise, if the order is to test a new page and a report already exists about a
|
|
1168
1172
|
// page with the same description or URL:
|
|
1169
|
-
if (!('
|
|
1173
|
+
if (!('reportID' in target) && await isReportAvailable(description, url)) {
|
|
1170
1174
|
// Return this.
|
|
1171
1175
|
return {result: 'reportExists', description, url};
|
|
1172
1176
|
}
|
|
@@ -1194,7 +1198,7 @@ export const orderJob = (
|
|
|
1194
1198
|
await fs.writeFile(path.join(jobsPath(), 'queue', `${jobName}.json`), getJSON(job));
|
|
1195
1199
|
console.log(`Test ordered for ${description} as job ${jobName}`);
|
|
1196
1200
|
// Return success, with the job identifier so the caller can report it.
|
|
1197
|
-
return {result: 'ok', description, url,
|
|
1201
|
+
return {result: 'ok', description, url, reportID: jobName};
|
|
1198
1202
|
// If an error occurred:
|
|
1199
1203
|
} catch(error) {
|
|
1200
1204
|
// Throw it.
|
|
@@ -1222,14 +1226,14 @@ export type AwaitJobResult = 'completed' | 'failed' | 'timedOut' | 'notFound';
|
|
|
1222
1226
|
// polling, if the identifier names neither a completed report nor a job anywhere in the
|
|
1223
1227
|
// queue/claimed/failed pipeline, since there is then nothing to wait for. No progress
|
|
1224
1228
|
// notifications are emitted during the wait; this is a pure bounded poll.
|
|
1225
|
-
export const awaitJob = async (
|
|
1226
|
-
const jobFileName = `${
|
|
1229
|
+
export const awaitJob = async (reportID: string): Promise<AwaitJobResult> => {
|
|
1230
|
+
const jobFileName = `${reportID}.json`;
|
|
1227
1231
|
const pollMs = awaitJobPollMs();
|
|
1228
1232
|
const timeoutMs = awaitJobTimeoutMs();
|
|
1229
1233
|
const deadline = Date.now() + timeoutMs;
|
|
1230
1234
|
// Returns the job's outcome if it is now determinable, or null if it is still pending.
|
|
1231
1235
|
const checkOutcome = async (): Promise<AwaitJobResult | null> => {
|
|
1232
|
-
if (await getReportStats(
|
|
1236
|
+
if (await getReportStats(reportID)) {
|
|
1233
1237
|
return 'completed';
|
|
1234
1238
|
}
|
|
1235
1239
|
const jobNames = await getJobNames();
|
|
@@ -55,12 +55,11 @@ export const answer = async (_: any, search: string, method: string) => {
|
|
|
55
55
|
const reportExtracts = await getReportExtracts();
|
|
56
56
|
// For each report:
|
|
57
57
|
for (const reportExtract of reportExtracts) {
|
|
58
|
-
const {
|
|
58
|
+
const {id, description, reportTime} = reportExtract;
|
|
59
59
|
// Add the report to the array.
|
|
60
60
|
reportSpecs.push({
|
|
61
61
|
description,
|
|
62
|
-
|
|
63
|
-
jobID,
|
|
62
|
+
id,
|
|
64
63
|
reportTime
|
|
65
64
|
});
|
|
66
65
|
}
|
|
@@ -75,12 +74,12 @@ export const answer = async (_: any, search: string, method: string) => {
|
|
|
75
74
|
const margin = ' '.repeat(12);
|
|
76
75
|
// For each available report:
|
|
77
76
|
reportSpecs.forEach(spec => {
|
|
78
|
-
const {
|
|
77
|
+
const {id, description, reportTime} = spec;
|
|
79
78
|
const when = getDateTimeString(new Date(reportTime));
|
|
80
|
-
const specString = `${description} (job <code>${
|
|
79
|
+
const specString = `${description} (job <code>${id}</code> completed ${when})`;
|
|
81
80
|
// Add a line with a radio button to hide it.
|
|
82
81
|
lines.push(
|
|
83
|
-
`${margin}<p><input type="radio" name="report" value="${
|
|
82
|
+
`${margin}<p><input type="radio" name="report" value="${id}"> ${specString}</p>`
|
|
84
83
|
);
|
|
85
84
|
});
|
|
86
85
|
const query: Record<string, string> = {
|
|
@@ -28,14 +28,13 @@ import {issues as issueSpecs} from 'testaro-issues';
|
|
|
28
28
|
// Adds parameters to a query for the answer page.
|
|
29
29
|
const populateQuery = async (
|
|
30
30
|
issueID: string,
|
|
31
|
-
|
|
32
|
-
jobID: string,
|
|
31
|
+
reportID: string,
|
|
33
32
|
catalogIndex: string,
|
|
34
33
|
pathID: string | null,
|
|
35
34
|
query: Record<string, any>
|
|
36
35
|
) => {
|
|
37
36
|
// Get the report.
|
|
38
|
-
const report = await getReport(
|
|
37
|
+
const report = await getReport(reportID);
|
|
39
38
|
// If this failed:
|
|
40
39
|
if (isReportError(report)) {
|
|
41
40
|
// Populate the query with the reason.
|
|
@@ -113,12 +112,12 @@ const populateQuery = async (
|
|
|
113
112
|
};
|
|
114
113
|
// Returns a page answering the diagnoses question.
|
|
115
114
|
export const answer = async (pageArgs: string, search: string) => {
|
|
116
|
-
const [issueID,
|
|
115
|
+
const [issueID, reportID, catalogIndex] = pageArgs.split('/') as [string, string, string];
|
|
117
116
|
const params = new URLSearchParams(search);
|
|
118
117
|
const pathID = params.get('pathID');
|
|
119
118
|
const query: Record<string, any> = {};
|
|
120
119
|
// Create a query to replace the placeholders.
|
|
121
|
-
await populateQuery(issueID,
|
|
120
|
+
await populateQuery(issueID, reportID, catalogIndex, pathID, query);
|
|
122
121
|
// If this failed:
|
|
123
122
|
if (query.error) {
|
|
124
123
|
// Return why.
|