@jrpool/kilotest 55.0.0 → 57.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 +1 -1
- package/api/awaitTest.test.ts +114 -0
- package/api/awaitTest.ts +108 -0
- package/api/listReports.ts +2 -2
- package/api/orderNewTest.test.ts +213 -0
- package/api/orderNewTest.ts +145 -0
- package/api/orderRetest.test.ts +179 -0
- package/api/orderRetest.ts +119 -0
- package/api/{requestTest.test.ts → requestNewTest.test.ts} +25 -25
- package/api/{requestTest.ts → requestNewTest.ts} +16 -39
- package/api/requestRetest.test.ts +6 -6
- package/api/requestRetest.ts +10 -33
- package/api/routes.test.ts +7 -4
- package/api/routes.ts +45 -7
- package/api/schemas.ts +120 -10
- package/api/util.ts +59 -2
- package/api/version.ts +6 -2
- package/capability.md +8 -5
- package/docs/SERVICE.md +6 -8
- package/env.example +15 -0
- package/index.test.ts +95 -24
- package/index.ts +80 -16
- package/lhm.plugin.json +1 -1
- package/linkValidation.test.ts +2 -2
- package/llms-full.txt +34 -3
- package/llms.txt +5 -2
- package/mcp.test.ts +72 -12
- package/mcp.ts +81 -11
- package/memory/MEMORY.md +1 -0
- package/memory/plan_issue_111_instant_test.md +198 -0
- package/openapi.yaml +451 -4
- package/package.json +1 -1
- package/routes.test.ts +1 -1
- package/routing.test.ts +2 -2
- package/server.json +1 -1
- package/sitemap.xml +1 -1
- package/smokeTest.ts +2 -2
- package/util.test.ts +214 -2
- package/util.ts +175 -7
- package/web/listReports/index.html +1 -1
- package/web/listReports/listReports.test.ts +1 -1
- package/web/{requestTest → requestNewTest}/index.ts +1 -1
- package/web/{requestTest/requestTest.test.ts → requestNewTest/requestNewTest.test.ts} +2 -2
- package/web/{requestTestForm → requestNewTestForm}/index.html +1 -1
- package/web/{requestTestForm/requestTestForm.test.ts → requestNewTestForm/requestNewTestForm.test.ts} +6 -6
- /package/web/{requestTest → requestNewTest}/index.html +0 -0
- /package/web/{requestTestForm → requestNewTestForm}/index.ts +0 -0
package/util.test.ts
CHANGED
|
@@ -21,6 +21,7 @@ for (const key of ['MANAGER_EMAIL', 'ALERT_API_HOST', 'ALERT_API_PATH', 'ALERT_A
|
|
|
21
21
|
|
|
22
22
|
import {
|
|
23
23
|
annotateReportObject,
|
|
24
|
+
awaitJob,
|
|
24
25
|
checkCommentDuplicate,
|
|
25
26
|
checkCommentLength,
|
|
26
27
|
checkLength,
|
|
@@ -66,6 +67,7 @@ import {
|
|
|
66
67
|
makeBreakable,
|
|
67
68
|
minifyURL,
|
|
68
69
|
objectSort,
|
|
70
|
+
orderJob,
|
|
69
71
|
processTestRequest,
|
|
70
72
|
testRequestsLock,
|
|
71
73
|
testRequestsPath,
|
|
@@ -888,12 +890,12 @@ test('processTestRequest returns a duplicate error for a repeated request', {tim
|
|
|
888
890
|
await fs.writeFile(testRequestsPath(), '{}\n');
|
|
889
891
|
});
|
|
890
892
|
|
|
891
|
-
test('processTestRequest returns a
|
|
893
|
+
test('processTestRequest returns a reportExists error when a report already exists for the page', async () => {
|
|
892
894
|
// The fixture database already has a report for this description and URL.
|
|
893
895
|
const {result} = await processTestRequest(
|
|
894
896
|
'because accessibility', {description: 'Mixed Outcomes Page', url: 'https://example.com/mixed'}
|
|
895
897
|
);
|
|
896
|
-
assert.equal(result, '
|
|
898
|
+
assert.equal(result, 'reportExists');
|
|
897
899
|
});
|
|
898
900
|
|
|
899
901
|
test('processTestRequest returns a queueFull error once the pending-request queue is full', {timeout: 500}, async () => {
|
|
@@ -1852,3 +1854,213 @@ test('getMetrics backfills every category, and generates a since stamp, from a b
|
|
|
1852
1854
|
fsSync.rmSync(tmpDir, {recursive: true});
|
|
1853
1855
|
}
|
|
1854
1856
|
});
|
|
1857
|
+
|
|
1858
|
+
// TESTS FOR orderJob AND awaitJob (GitHub issue #111)
|
|
1859
|
+
|
|
1860
|
+
test('orderJob enqueues a new-page order directly into the job queue', async () => {
|
|
1861
|
+
const {result, description, url, jobID} = await orderJob(
|
|
1862
|
+
{description: 'Ordered Page', url: 'https://example.com/ordered'}, 'because accessibility matters here'
|
|
1863
|
+
) as any;
|
|
1864
|
+
assert.equal(result, 'ok');
|
|
1865
|
+
assert.equal(description, 'Ordered Page');
|
|
1866
|
+
assert.equal(url, 'https://example.com/ordered');
|
|
1867
|
+
assert.match(jobID, /^\d{6}T\d{4}-[a-z0-9]{3}$/);
|
|
1868
|
+
const queuedPath = path.join(jobsPath(), 'queue', `${jobID}.json`);
|
|
1869
|
+
try {
|
|
1870
|
+
const job = JSON.parse(await fs.readFile(queuedPath, 'utf8'));
|
|
1871
|
+
assert.equal(job.target.what, 'Ordered Page');
|
|
1872
|
+
assert.equal(job.target.url, 'https://example.com/ordered');
|
|
1873
|
+
assert.equal(job.sources.reason, 'because accessibility matters here');
|
|
1874
|
+
// No pending-approval inbox entry is created; the job goes straight to the queue.
|
|
1875
|
+
const testRequests = JSON.parse(await fs.readFile(testRequestsPath(), 'utf8'));
|
|
1876
|
+
assert.equal(testRequests['https://example.com/ordered'], undefined);
|
|
1877
|
+
}
|
|
1878
|
+
finally {
|
|
1879
|
+
await fs.unlink(queuedPath);
|
|
1880
|
+
}
|
|
1881
|
+
});
|
|
1882
|
+
|
|
1883
|
+
test('orderJob returns a reportExists error when a report already exists for the ordered page', async () => {
|
|
1884
|
+
const {result} = await orderJob(
|
|
1885
|
+
{description: 'Mixed Outcomes Page', url: 'https://example.com/mixed'}, 'because accessibility matters here'
|
|
1886
|
+
);
|
|
1887
|
+
assert.equal(result, 'reportExists');
|
|
1888
|
+
});
|
|
1889
|
+
|
|
1890
|
+
test('orderJob returns "description" for a page matching a claimed job by description', async () => {
|
|
1891
|
+
const claimedPath = path.join(jobsPath(), 'claimed', 'clm.json');
|
|
1892
|
+
await fs.writeFile(claimedPath, getJSON({
|
|
1893
|
+
target: {what: 'Claimed Order Page', url: 'https://example.com/claimed-order-job'}
|
|
1894
|
+
}));
|
|
1895
|
+
try {
|
|
1896
|
+
const {result} = await orderJob(
|
|
1897
|
+
{description: 'Claimed Order Page', url: 'https://example.com/some-other-order-url'},
|
|
1898
|
+
'because accessibility matters here'
|
|
1899
|
+
);
|
|
1900
|
+
assert.equal(result, 'description');
|
|
1901
|
+
}
|
|
1902
|
+
finally {
|
|
1903
|
+
await fs.unlink(claimedPath);
|
|
1904
|
+
}
|
|
1905
|
+
});
|
|
1906
|
+
|
|
1907
|
+
test('orderJob returns "url" for a page matching a queued job by URL', async () => {
|
|
1908
|
+
const queuedPath = path.join(jobsPath(), 'queue', 'que.json');
|
|
1909
|
+
await fs.writeFile(queuedPath, getJSON({
|
|
1910
|
+
target: {what: 'Some Queued Order Page', url: 'https://example.com/queued-order-url'}
|
|
1911
|
+
}));
|
|
1912
|
+
try {
|
|
1913
|
+
const {result} = await orderJob(
|
|
1914
|
+
{description: 'A Different Order Page', url: 'https://example.com/queued-order-url'},
|
|
1915
|
+
'because accessibility matters here'
|
|
1916
|
+
);
|
|
1917
|
+
assert.equal(result, 'url');
|
|
1918
|
+
}
|
|
1919
|
+
finally {
|
|
1920
|
+
await fs.unlink(queuedPath);
|
|
1921
|
+
}
|
|
1922
|
+
});
|
|
1923
|
+
|
|
1924
|
+
test('orderJob throws when a job file in a category directory is defective', async () => {
|
|
1925
|
+
const claimedPath = path.join(jobsPath(), 'claimed', 'bad.json');
|
|
1926
|
+
await fs.writeFile(claimedPath, 'not valid json');
|
|
1927
|
+
try {
|
|
1928
|
+
await assert.rejects(
|
|
1929
|
+
orderJob({description: 'Any Order Page', url: 'https://example.com/any-order'}, 'because accessibility matters here'),
|
|
1930
|
+
/Failed to order test/
|
|
1931
|
+
);
|
|
1932
|
+
}
|
|
1933
|
+
finally {
|
|
1934
|
+
await fs.unlink(claimedPath);
|
|
1935
|
+
}
|
|
1936
|
+
});
|
|
1937
|
+
|
|
1938
|
+
test('orderJob returns "nonreport" when a retest order cites a report that does not exist', async () => {
|
|
1939
|
+
const {result} = await orderJob({timeStamp: '999999T9999', jobID: 'xxx'}, 'because accessibility matters here');
|
|
1940
|
+
assert.equal(result, 'nonreport');
|
|
1941
|
+
});
|
|
1942
|
+
|
|
1943
|
+
test('orderJob returns "superseded" when a retest order cites a superseded report', async () => {
|
|
1944
|
+
// 260101T0000-mix is an earlier report of "Mixed Outcomes Page" than 260202T0000-new,
|
|
1945
|
+
// so it is superseded.
|
|
1946
|
+
const {result} = await orderJob(
|
|
1947
|
+
{timeStamp: '260101T0000', jobID: 'mix'}, 'because accessibility matters here'
|
|
1948
|
+
);
|
|
1949
|
+
assert.equal(result, 'superseded');
|
|
1950
|
+
});
|
|
1951
|
+
|
|
1952
|
+
test('orderJob enqueues a retest order for the latest report of a page', async () => {
|
|
1953
|
+
const {result, description, url, jobID} = await orderJob(
|
|
1954
|
+
{timeStamp: '260202T0000', jobID: 'new'}, 'because accessibility matters here'
|
|
1955
|
+
) as any;
|
|
1956
|
+
assert.equal(result, 'ok');
|
|
1957
|
+
assert.equal(description, 'Mixed Outcomes Page');
|
|
1958
|
+
assert.equal(url, 'https://example.com/mixed');
|
|
1959
|
+
const queuedPath = path.join(jobsPath(), 'queue', `${jobID}.json`);
|
|
1960
|
+
try {
|
|
1961
|
+
const job = JSON.parse(await fs.readFile(queuedPath, 'utf8'));
|
|
1962
|
+
assert.equal(job.target.what, 'Mixed Outcomes Page');
|
|
1963
|
+
}
|
|
1964
|
+
finally {
|
|
1965
|
+
await fs.unlink(queuedPath);
|
|
1966
|
+
}
|
|
1967
|
+
});
|
|
1968
|
+
|
|
1969
|
+
test('orderJob honors JOB_QUEUE_MAX when the cap is reached', async () => {
|
|
1970
|
+
process.env.JOB_QUEUE_MAX = '1';
|
|
1971
|
+
const fillerPath = path.join(jobsPath(), 'queue', 'fil.json');
|
|
1972
|
+
await fs.writeFile(fillerPath, getJSON({target: {what: 'Filler Queued Page', url: 'https://example.com/filler-queued'}}));
|
|
1973
|
+
try {
|
|
1974
|
+
const {result} = await orderJob(
|
|
1975
|
+
{description: 'One Too Many Order Page', url: 'https://example.com/one-too-many-order'},
|
|
1976
|
+
'because accessibility matters here'
|
|
1977
|
+
);
|
|
1978
|
+
assert.equal(result, 'queueFull');
|
|
1979
|
+
}
|
|
1980
|
+
finally {
|
|
1981
|
+
delete process.env.JOB_QUEUE_MAX;
|
|
1982
|
+
await fs.unlink(fillerPath);
|
|
1983
|
+
}
|
|
1984
|
+
});
|
|
1985
|
+
|
|
1986
|
+
test('orderJob never returns queueFull when JOB_QUEUE_MAX is 0 (no limit)', async () => {
|
|
1987
|
+
process.env.JOB_QUEUE_MAX = '0';
|
|
1988
|
+
const fillerPath = path.join(jobsPath(), 'queue', 'fi2.json');
|
|
1989
|
+
await fs.writeFile(fillerPath, getJSON({target: {what: 'Filler Queued Page Two', url: 'https://example.com/filler-queued-2'}}));
|
|
1990
|
+
try {
|
|
1991
|
+
const {result, jobID} = await orderJob(
|
|
1992
|
+
{description: 'One More Order Page', url: 'https://example.com/one-more-order'},
|
|
1993
|
+
'because accessibility matters here'
|
|
1994
|
+
) as any;
|
|
1995
|
+
assert.equal(result, 'ok');
|
|
1996
|
+
await fs.unlink(path.join(jobsPath(), 'queue', `${jobID}.json`));
|
|
1997
|
+
}
|
|
1998
|
+
finally {
|
|
1999
|
+
delete process.env.JOB_QUEUE_MAX;
|
|
2000
|
+
await fs.unlink(fillerPath);
|
|
2001
|
+
}
|
|
2002
|
+
});
|
|
2003
|
+
|
|
2004
|
+
test('awaitJob returns "completed" immediately when a report already exists', async () => {
|
|
2005
|
+
const outcome = await awaitJob('260101T0000', 'mix');
|
|
2006
|
+
assert.equal(outcome, 'completed');
|
|
2007
|
+
});
|
|
2008
|
+
|
|
2009
|
+
test('awaitJob returns "notFound" for an identifier naming neither a report nor a pending job', async () => {
|
|
2010
|
+
const outcome = await awaitJob('999999T9999', 'xxx');
|
|
2011
|
+
assert.equal(outcome, 'notFound');
|
|
2012
|
+
});
|
|
2013
|
+
|
|
2014
|
+
test('awaitJob returns "failed" when the job has moved to the failed directory', async () => {
|
|
2015
|
+
const failedPath = path.join(jobsPath(), 'failed', '260101T1111-flj.json');
|
|
2016
|
+
await fs.mkdir(path.dirname(failedPath), {recursive: true});
|
|
2017
|
+
await fs.writeFile(failedPath, getJSON({target: {what: 'Failed Await Page', url: 'https://example.com/failed-await'}}));
|
|
2018
|
+
try {
|
|
2019
|
+
const outcome = await awaitJob('260101T1111', 'flj');
|
|
2020
|
+
assert.equal(outcome, 'failed');
|
|
2021
|
+
}
|
|
2022
|
+
finally {
|
|
2023
|
+
await fs.unlink(failedPath);
|
|
2024
|
+
}
|
|
2025
|
+
});
|
|
2026
|
+
|
|
2027
|
+
test('awaitJob polls until a queued job completes, then returns "completed"', async () => {
|
|
2028
|
+
process.env.AWAIT_JOB_POLL_MS = '20';
|
|
2029
|
+
process.env.AWAIT_JOB_TIMEOUT_MS = '2000';
|
|
2030
|
+
const jobName = '260101T2222-plj';
|
|
2031
|
+
const queuedPath = path.join(jobsPath(), 'queue', `${jobName}.json`);
|
|
2032
|
+
const reportPath = path.join(reportsPath(), `${jobName}.json`);
|
|
2033
|
+
await fs.writeFile(queuedPath, getJSON({target: {what: 'Polled Page', url: 'https://example.com/polled'}}));
|
|
2034
|
+
// Simulate the job completing shortly after the wait begins.
|
|
2035
|
+
setTimeout(async () => {
|
|
2036
|
+
await fs.unlink(queuedPath).catch(() => {});
|
|
2037
|
+
await fs.writeFile(reportPath, getJSON({id: jobName, target: {what: 'Polled Page', url: 'https://example.com/polled'}}));
|
|
2038
|
+
}, 50);
|
|
2039
|
+
try {
|
|
2040
|
+
const outcome = await awaitJob('260101T2222', 'plj');
|
|
2041
|
+
assert.equal(outcome, 'completed');
|
|
2042
|
+
}
|
|
2043
|
+
finally {
|
|
2044
|
+
delete process.env.AWAIT_JOB_POLL_MS;
|
|
2045
|
+
delete process.env.AWAIT_JOB_TIMEOUT_MS;
|
|
2046
|
+
await fs.unlink(queuedPath).catch(() => {});
|
|
2047
|
+
await fs.unlink(reportPath).catch(() => {});
|
|
2048
|
+
}
|
|
2049
|
+
});
|
|
2050
|
+
|
|
2051
|
+
test('awaitJob returns "timedOut" when a claimed job neither completes nor fails before the deadline', async () => {
|
|
2052
|
+
process.env.AWAIT_JOB_POLL_MS = '10';
|
|
2053
|
+
process.env.AWAIT_JOB_TIMEOUT_MS = '50';
|
|
2054
|
+
const jobName = '260101T3333-toj';
|
|
2055
|
+
const claimedPath = path.join(jobsPath(), 'claimed', `${jobName}.json`);
|
|
2056
|
+
await fs.writeFile(claimedPath, getJSON({target: {what: 'Stuck Page', url: 'https://example.com/stuck'}}));
|
|
2057
|
+
try {
|
|
2058
|
+
const outcome = await awaitJob('260101T3333', 'toj');
|
|
2059
|
+
assert.equal(outcome, 'timedOut');
|
|
2060
|
+
}
|
|
2061
|
+
finally {
|
|
2062
|
+
delete process.env.AWAIT_JOB_POLL_MS;
|
|
2063
|
+
delete process.env.AWAIT_JOB_TIMEOUT_MS;
|
|
2064
|
+
await fs.unlink(claimedPath);
|
|
2065
|
+
}
|
|
2066
|
+
});
|
package/util.ts
CHANGED
|
@@ -78,9 +78,13 @@ export type TestRequest = {
|
|
|
78
78
|
// Test requests by URL.
|
|
79
79
|
export type TestRequests = Record<string, TestRequest[]>;
|
|
80
80
|
|
|
81
|
-
// Test request addition result.
|
|
81
|
+
// Test request addition result. 'reportExists' means a report already exists for a
|
|
82
|
+
// page a newTest request or order named (an outcome), distinct from the 'retest'
|
|
83
|
+
// requestType discriminant elsewhere in this file, which means the caller asked to
|
|
84
|
+
// retest a page that already has a report (a request kind); the two must not be
|
|
85
|
+
// confused with one another.
|
|
82
86
|
export type TestRequestResult
|
|
83
|
-
= 'url' | 'description' | '
|
|
87
|
+
= 'url' | 'description' | 'reportExists' | 'duplicate' | 'superseded' | 'nonreport' | 'queueFull' | 'ok';
|
|
84
88
|
|
|
85
89
|
// Target of a new-test or retest request: a description and URL for a new-test request,
|
|
86
90
|
// or the timeStamp and jobID of the cited report for a retest request.
|
|
@@ -1127,7 +1131,7 @@ export const processTestRequest = (
|
|
|
1127
1131
|
reason: string,
|
|
1128
1132
|
target: TestRequestTarget
|
|
1129
1133
|
): Promise<ProcessRequestResult> => testRequestsLock(async (): Promise<ProcessRequestResult> => {
|
|
1130
|
-
let requestType: '
|
|
1134
|
+
let requestType: 'newTest' | 'retest';
|
|
1131
1135
|
let description: string;
|
|
1132
1136
|
let url: string;
|
|
1133
1137
|
// Whether the cited report (for a retest) has been superseded by a later one.
|
|
@@ -1152,7 +1156,7 @@ export const processTestRequest = (
|
|
|
1152
1156
|
}
|
|
1153
1157
|
// Otherwise, i.e. if the target is a page to test for the first time:
|
|
1154
1158
|
else {
|
|
1155
|
-
requestType = '
|
|
1159
|
+
requestType = 'newTest';
|
|
1156
1160
|
({description, url} = target);
|
|
1157
1161
|
}
|
|
1158
1162
|
try {
|
|
@@ -1186,11 +1190,11 @@ export const processTestRequest = (
|
|
|
1186
1190
|
return {result: 'superseded', description, url};
|
|
1187
1191
|
}
|
|
1188
1192
|
// Otherwise, if the request is to test a new page for which a report already exists:
|
|
1189
|
-
if (requestType === '
|
|
1193
|
+
if (requestType === 'newTest') {
|
|
1190
1194
|
const reportExtracts = await getReportExtracts();
|
|
1191
1195
|
if (reportExtracts.some(report => report.description === description && report.url === url)) {
|
|
1192
1196
|
// Return this.
|
|
1193
|
-
return {result: '
|
|
1197
|
+
return {result: 'reportExists', description, url};
|
|
1194
1198
|
}
|
|
1195
1199
|
}
|
|
1196
1200
|
// Otherwise, i.e. if the request is genuinely new, if the queue of requests awaiting
|
|
@@ -1215,8 +1219,9 @@ export const processTestRequest = (
|
|
|
1215
1219
|
// Alert a manager, including the resulting queue size, so a maintainer who has been
|
|
1216
1220
|
// away sees at a glance how urgently the queue needs review (approval or rejection)
|
|
1217
1221
|
// rather than learning this only once it is already full.
|
|
1222
|
+
const requestTypeLabel = requestType === 'newTest' ? 'a new test' : 'a retest';
|
|
1218
1223
|
await sendAlert(
|
|
1219
|
-
`Kilotest:
|
|
1224
|
+
`Kilotest: request for ${requestTypeLabel} awaits approval`,
|
|
1220
1225
|
`Page description: ${description}\nURL: ${url}\nReason: ${plainReason}\n` +
|
|
1221
1226
|
`Requests now awaiting approval: ${getPendingTestRequestCount(testRequests)} of ${describeMax(queueMax)}`
|
|
1222
1227
|
);
|
|
@@ -1228,6 +1233,169 @@ export const processTestRequest = (
|
|
|
1228
1233
|
throw new Error('Failed to process test request', {cause: error});
|
|
1229
1234
|
}
|
|
1230
1235
|
});
|
|
1236
|
+
// Maximum number of jobs allowed in the job queue (db/jobs/queue) at once. This queue is
|
|
1237
|
+
// shared by every path that enqueues a job: manual approval of a pending test/retest
|
|
1238
|
+
// request (web/enqueue), and any tool (such as orderNewTest) that enqueues directly. The cap
|
|
1239
|
+
// therefore protects the queue itself, not any one source of jobs; a value of 0 means no
|
|
1240
|
+
// limit. Configurable via JOB_QUEUE_MAX.
|
|
1241
|
+
const jobQueueMax = () => getEnvMax('JOB_QUEUE_MAX', 20);
|
|
1242
|
+
// Result of orderJob. description and url are absent only when the result is
|
|
1243
|
+
// 'nonreport' (a retest order citing a report that does not exist), matching
|
|
1244
|
+
// ProcessRequestResult's own discriminated-union shape. There is no pending approval
|
|
1245
|
+
// inbox to compare against, so 'duplicate' (an identical pending request) never applies.
|
|
1246
|
+
// jobID (the enqueued job's own identifier, timeStamp-jobID) is present only on 'ok'.
|
|
1247
|
+
export type OrderJobResult
|
|
1248
|
+
= {result: 'nonreport'}
|
|
1249
|
+
| {result: 'ok'; description: string; url: string; jobID: string}
|
|
1250
|
+
| {result: Exclude<TestRequestResult, 'nonreport' | 'duplicate' | 'ok'>; description: string; url: string};
|
|
1251
|
+
// Orders a test or retest as a transaction: validates the target is not already queued,
|
|
1252
|
+
// claimed, or (for a new-page order) reported, then enqueues a job directly into
|
|
1253
|
+
// db/jobs/queue, bypassing the pending-approval inbox (testRequests.json) that
|
|
1254
|
+
// processTestRequest writes to. This is the auto-approval GitHub issue #111 asks for: a
|
|
1255
|
+
// lightweight gate (the same claimed/queued dedup check processTestRequest already
|
|
1256
|
+
// performs, plus a cap on the queue itself) substitutes for a maintainer's manual
|
|
1257
|
+
// review. Runs under testRequestsLock, the same lock processTestRequest uses, so an
|
|
1258
|
+
// orderJob call and a processTestRequest call can never race to enqueue duplicate jobs
|
|
1259
|
+
// for the same page. The caller's stated reason is recorded on the job (sources.reason),
|
|
1260
|
+
// which survives into the eventual report exactly as sources.worker already does: no
|
|
1261
|
+
// approval rule consults it yet, but accumulating real callers' stated reasons is
|
|
1262
|
+
// intended to inform the design of more sophisticated, reason-aware approval rules in a
|
|
1263
|
+
// later iteration.
|
|
1264
|
+
export const orderJob = (
|
|
1265
|
+
target: TestRequestTarget, reason: string
|
|
1266
|
+
): Promise<OrderJobResult> => testRequestsLock(async (): Promise<OrderJobResult> => {
|
|
1267
|
+
let description: string;
|
|
1268
|
+
let url: string;
|
|
1269
|
+
// Whether the cited report (for a retest order) has been superseded by a later one.
|
|
1270
|
+
let superseded = false;
|
|
1271
|
+
// If the target identifies a report to retest:
|
|
1272
|
+
if ('timeStamp' in target) {
|
|
1273
|
+
const {timeStamp, jobID} = target;
|
|
1274
|
+
// Get the cited report.
|
|
1275
|
+
const extract = await getReportExtract(timeStamp, jobID);
|
|
1276
|
+
// If it does not exist:
|
|
1277
|
+
if ('error' in extract) {
|
|
1278
|
+
// Return this.
|
|
1279
|
+
return {result: 'nonreport'};
|
|
1280
|
+
}
|
|
1281
|
+
({description, url} = extract);
|
|
1282
|
+
// Get whether it has been superseded by a later report.
|
|
1283
|
+
const reportExtracts = await getReportExtracts();
|
|
1284
|
+
superseded = reportExtracts.some(
|
|
1285
|
+
ex => ex.timeStamp === timeStamp && ex.jobID === jobID && ex.superseded
|
|
1286
|
+
);
|
|
1287
|
+
}
|
|
1288
|
+
// Otherwise, i.e. if the target is a page to test for the first time:
|
|
1289
|
+
else {
|
|
1290
|
+
({description, url} = target);
|
|
1291
|
+
}
|
|
1292
|
+
try {
|
|
1293
|
+
// Get any property the page shares with a claimed job.
|
|
1294
|
+
const claimedJobProperty = await getApprovedJobProperty(description, url, 'claimed');
|
|
1295
|
+
// Get any property the page shares with a queued job.
|
|
1296
|
+
const queueJobProperty = await getApprovedJobProperty(description, url, 'queue');
|
|
1297
|
+
// If the page shares a property with a claimed or queued job:
|
|
1298
|
+
if (claimedJobProperty || queueJobProperty) {
|
|
1299
|
+
// Return the property.
|
|
1300
|
+
return {
|
|
1301
|
+
result: (claimedJobProperty || queueJobProperty) as 'description' | 'url',
|
|
1302
|
+
description,
|
|
1303
|
+
url
|
|
1304
|
+
};
|
|
1305
|
+
}
|
|
1306
|
+
// Otherwise, if the order is to retest a page that has been superseded:
|
|
1307
|
+
if ('timeStamp' in target && superseded) {
|
|
1308
|
+
// Return this.
|
|
1309
|
+
return {result: 'superseded', description, url};
|
|
1310
|
+
}
|
|
1311
|
+
// Otherwise, if the order is to test a new page for which a report already exists:
|
|
1312
|
+
if (!('timeStamp' in target)) {
|
|
1313
|
+
const reportExtracts = await getReportExtracts();
|
|
1314
|
+
if (reportExtracts.some(report => report.description === description && report.url === url)) {
|
|
1315
|
+
// Return this.
|
|
1316
|
+
return {result: 'reportExists', description, url};
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
// Otherwise, if the job queue is already full (a cap of 0 means no limit, so the
|
|
1320
|
+
// queue is never full):
|
|
1321
|
+
const queueMax = jobQueueMax();
|
|
1322
|
+
const {queue: queuedJobNames} = await getJobNames();
|
|
1323
|
+
if (queueMax > 0 && queuedJobNames.length >= queueMax) {
|
|
1324
|
+
// Return this.
|
|
1325
|
+
return {result: 'queueFull', description, url};
|
|
1326
|
+
}
|
|
1327
|
+
// The page is eligible, so build a job from the template.
|
|
1328
|
+
const jobTemplateJSON = await fs.readFile(path.join(import.meta.dirname, 'job.json'), 'utf8');
|
|
1329
|
+
const job = JSON.parse(jobTemplateJSON);
|
|
1330
|
+
const nowStamp = getNowStamp();
|
|
1331
|
+
const jobIDSuffix = getRandomString(3);
|
|
1332
|
+
const jobName = `${nowStamp}-${jobIDSuffix}`;
|
|
1333
|
+
job.id = jobName;
|
|
1334
|
+
job.creationTimeStamp = nowStamp;
|
|
1335
|
+
job.executionTimeStamp = nowStamp;
|
|
1336
|
+
job.target.what = description;
|
|
1337
|
+
job.target.url = url;
|
|
1338
|
+
job.sources.reason = reason;
|
|
1339
|
+
// Save the job in the queue.
|
|
1340
|
+
await fs.writeFile(path.join(jobsPath(), 'queue', `${jobName}.json`), getJSON(job));
|
|
1341
|
+
console.log(`Test ordered for ${description} as job ${jobName}`);
|
|
1342
|
+
// Return success, with the job identifier so the caller can report it.
|
|
1343
|
+
return {result: 'ok', description, url, jobID: jobName};
|
|
1344
|
+
// If an error occurred:
|
|
1345
|
+
} catch(error) {
|
|
1346
|
+
// Throw it.
|
|
1347
|
+
throw new Error('Failed to order test', {cause: error});
|
|
1348
|
+
}
|
|
1349
|
+
});
|
|
1350
|
+
// Milliseconds between polls in awaitJob, and the maximum total milliseconds awaitJob
|
|
1351
|
+
// waits before giving up. Both are read at call time (not cached), via environment
|
|
1352
|
+
// variables, so tests can shrink them well below the real ~2-to-4-minute wait a
|
|
1353
|
+
// production caller experiences; AWAIT_JOB_TIMEOUT_MS deliberately exceeds the
|
|
1354
|
+
// documented "occasionally up to 4 minutes" a caller is told to expect, leaving margin
|
|
1355
|
+
// so a job that is genuinely still running is not mistaken for one that never will
|
|
1356
|
+
// finish.
|
|
1357
|
+
const awaitJobPollMs = () => getEnvMax('AWAIT_JOB_POLL_MS', 2000);
|
|
1358
|
+
const awaitJobTimeoutMs = () => getEnvMax('AWAIT_JOB_TIMEOUT_MS', 5 * 60 * 1000);
|
|
1359
|
+
// Result of awaitJob.
|
|
1360
|
+
export type AwaitJobResult = 'completed' | 'failed' | 'timedOut' | 'notFound';
|
|
1361
|
+
// Waits for a job ordered via orderJob (or, in principle, any job in db/jobs/queue or
|
|
1362
|
+
// db/jobs/claimed identified the same way) to leave the queue/claimed pipeline, by
|
|
1363
|
+
// polling for the outcome Kilotest already records for it: a completed report at
|
|
1364
|
+
// timeStamp-jobID.json (see index.ts's worker/report handler, which is the sole writer
|
|
1365
|
+
// of that file), or the job's own file having moved to db/jobs/failed (see
|
|
1366
|
+
// index.ts's processJobRequest and worker/report handler, which are the only
|
|
1367
|
+
// code that reclassifies a job as failed). Returns 'notFound' immediately, without
|
|
1368
|
+
// polling, if the identifier names neither a completed report nor a job anywhere in the
|
|
1369
|
+
// queue/claimed/failed pipeline, since there is then nothing to wait for. No progress
|
|
1370
|
+
// notifications are emitted during the wait; this is a pure bounded poll.
|
|
1371
|
+
export const awaitJob = async (timeStamp: string, jobID: string): Promise<AwaitJobResult> => {
|
|
1372
|
+
const jobFileName = `${timeStamp}-${jobID}.json`;
|
|
1373
|
+
const pollMs = awaitJobPollMs();
|
|
1374
|
+
const timeoutMs = awaitJobTimeoutMs();
|
|
1375
|
+
const deadline = Date.now() + timeoutMs;
|
|
1376
|
+
// Returns the job's outcome if it is now determinable, or null if it is still pending.
|
|
1377
|
+
const checkOutcome = async (): Promise<AwaitJobResult | null> => {
|
|
1378
|
+
if (await getReportStats(timeStamp, jobID)) {
|
|
1379
|
+
return 'completed';
|
|
1380
|
+
}
|
|
1381
|
+
const jobNames = await getJobNames();
|
|
1382
|
+
if (jobNames.failed.includes(jobFileName)) {
|
|
1383
|
+
return 'failed';
|
|
1384
|
+
}
|
|
1385
|
+
if (jobNames.queue.includes(jobFileName) || jobNames.claimed.includes(jobFileName)) {
|
|
1386
|
+
return null;
|
|
1387
|
+
}
|
|
1388
|
+
// The identifier names neither a report nor a job anywhere in the pipeline.
|
|
1389
|
+
return 'notFound';
|
|
1390
|
+
};
|
|
1391
|
+
// If the job is not yet determinable, poll until it is or the deadline passes.
|
|
1392
|
+
let outcome = await checkOutcome();
|
|
1393
|
+
while (outcome === null && Date.now() < deadline) {
|
|
1394
|
+
await new Promise(resolve => setTimeout(resolve, pollMs));
|
|
1395
|
+
outcome = await checkOutcome();
|
|
1396
|
+
}
|
|
1397
|
+
return outcome ?? 'timedOut';
|
|
1398
|
+
};
|
|
1231
1399
|
|
|
1232
1400
|
// METRICS FUNCTIONS
|
|
1233
1401
|
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
__requests__
|
|
19
19
|
</ol>
|
|
20
20
|
<p>__noRequests__</p>
|
|
21
|
-
<p>Should Kilotest <a href="
|
|
21
|
+
<p>Should Kilotest <a href="requestNewTestForm.html">test a page</a> that is not listed below?</p>
|
|
22
22
|
<h2>Queued for testing</h2>
|
|
23
23
|
<ol>
|
|
24
24
|
__queue__
|
|
@@ -84,7 +84,7 @@ test('listReports includes the page URLs in the report details', async () => {
|
|
|
84
84
|
test('listReports includes a link to request testing a new page', async () => {
|
|
85
85
|
const result: any = await answer();
|
|
86
86
|
const html = parse(result.answerPage);
|
|
87
|
-
const testLink = html.querySelector('a[href="
|
|
87
|
+
const testLink = html.querySelector('a[href="requestNewTestForm.html"]');
|
|
88
88
|
assert.ok(testLink);
|
|
89
89
|
});
|
|
90
90
|
|
|
@@ -34,7 +34,7 @@ export const answer = async (description: string, url: string, reason: string) =
|
|
|
34
34
|
};
|
|
35
35
|
}
|
|
36
36
|
// Otherwise, if a report is available about a page with the same description and URL:
|
|
37
|
-
else if (result === '
|
|
37
|
+
else if (result === 'reportExists') {
|
|
38
38
|
// Report this.
|
|
39
39
|
return {
|
|
40
40
|
status: 'error',
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
<main>
|
|
15
15
|
<h1><a href="/">Kilotest</a>: Test request</h1>
|
|
16
16
|
<p>You may request that a web page be tested by submitting the following form. All answers are required.</p>
|
|
17
|
-
<form action="/
|
|
17
|
+
<form action="/requestNewTest.html" method="post">
|
|
18
18
|
<p><label>
|
|
19
19
|
Propose a name for the page.<br>
|
|
20
20
|
<input size="50" maxlength="70" name="description" required>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/*
|
|
2
|
-
|
|
3
|
-
Unit tests for web/
|
|
2
|
+
requestNewTestForm.test.ts
|
|
3
|
+
Unit tests for web/requestNewTestForm/index.ts.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
// IMPORTS
|
|
@@ -12,7 +12,7 @@ import {answer} from './index.ts';
|
|
|
12
12
|
|
|
13
13
|
// TESTS
|
|
14
14
|
|
|
15
|
-
test('
|
|
15
|
+
test('requestNewTestForm returns an ok status with valid HTML', async () => {
|
|
16
16
|
const result = await answer();
|
|
17
17
|
assert.equal(result.status, 'ok');
|
|
18
18
|
assert.ok(result.answerPage);
|
|
@@ -20,16 +20,16 @@ test('requestTestForm returns an ok status with valid HTML', async () => {
|
|
|
20
20
|
assert.equal(html.querySelector('title')?.text, 'Test request | Kilotest');
|
|
21
21
|
});
|
|
22
22
|
|
|
23
|
-
test('
|
|
23
|
+
test('requestNewTestForm includes a form that posts to /requestNewTest.html', async () => {
|
|
24
24
|
const result = await answer();
|
|
25
25
|
const html = parse(result.answerPage);
|
|
26
26
|
const form = html.querySelector('form');
|
|
27
27
|
assert.ok(form);
|
|
28
|
-
assert.equal(form.getAttribute('action'), '/
|
|
28
|
+
assert.equal(form.getAttribute('action'), '/requestNewTest.html');
|
|
29
29
|
assert.equal(form.getAttribute('method'), 'post');
|
|
30
30
|
});
|
|
31
31
|
|
|
32
|
-
test('
|
|
32
|
+
test('requestNewTestForm includes required inputs for description, url, and why', async () => {
|
|
33
33
|
const result = await answer();
|
|
34
34
|
const html = parse(result.answerPage);
|
|
35
35
|
const inputs = html.querySelectorAll('input');
|
|
File without changes
|
|
File without changes
|