@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
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/*
|
|
2
|
+
orderRetest.test.ts
|
|
3
|
+
Tests for api/orderRetest.ts using the fixture corpus.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// IMPORTS
|
|
7
|
+
|
|
8
|
+
import {test, before, beforeEach, after} from 'node:test';
|
|
9
|
+
import assert from 'node:assert/strict';
|
|
10
|
+
import fs from 'node:fs/promises';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import {fixtureDBDir} from '../test/dbFixture.ts';
|
|
13
|
+
|
|
14
|
+
for (const key of ['MANAGER_EMAIL', 'ALERT_API_HOST', 'ALERT_API_PATH', 'ALERT_API_KEY', 'ALERT_FROM']) {
|
|
15
|
+
process.env[key] = '';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// SETUP AND TEARDOWN
|
|
19
|
+
|
|
20
|
+
const savedDBDir = process.env.DB_DIR;
|
|
21
|
+
const queuePath = path.join(fixtureDBDir, 'jobs', 'queue');
|
|
22
|
+
// A report added only to this test's disposable copy of the fixture database (never to
|
|
23
|
+
// the tracked test/fixtures/db corpus), matching the fixture requestRetest.test.ts uses
|
|
24
|
+
// for the same reason: a 3-character job ID satisfying isJobID without perturbing
|
|
25
|
+
// report-count tests elsewhere that copy the whole corpus.
|
|
26
|
+
const retestFixturePath = path.join(fixtureDBDir, 'reports', '260101T0004-ord.json');
|
|
27
|
+
const retestFixtureReport = {
|
|
28
|
+
id: '260101T0004-ord',
|
|
29
|
+
what: 'Order Retest Fixture Page',
|
|
30
|
+
target: {what: 'Order Retest Fixture Page', url: 'https://example.com/orderretestfixture'},
|
|
31
|
+
sources: {worker: 'test-worker'},
|
|
32
|
+
acts: [],
|
|
33
|
+
jobData: {startTime: '26-01-01T00:00', endTime: '26-01-01T00:10', elapsedSeconds: 600, preventions: {}, issuelessRules: []},
|
|
34
|
+
catalog: {},
|
|
35
|
+
images: {},
|
|
36
|
+
checkpoints: []
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
before(async () => {
|
|
40
|
+
process.env.DB_DIR = fixtureDBDir;
|
|
41
|
+
await fs.writeFile(retestFixturePath, JSON.stringify(retestFixtureReport));
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
beforeEach(async () => {
|
|
45
|
+
for (const fileName of await fs.readdir(queuePath)) {
|
|
46
|
+
await fs.unlink(path.join(queuePath, fileName));
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
import {response} from './orderRetest.ts';
|
|
51
|
+
|
|
52
|
+
after(async () => {
|
|
53
|
+
await fs.unlink(retestFixturePath);
|
|
54
|
+
if (savedDBDir !== undefined) {
|
|
55
|
+
process.env.DB_DIR = savedDBDir;
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
delete process.env.DB_DIR;
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// TESTS
|
|
63
|
+
|
|
64
|
+
test('orderRetest rejects a nonexistent report', async () => {
|
|
65
|
+
const body = await response(['251231T0000', 'zzz', 'A reason that is long enough.']);
|
|
66
|
+
const details = body['response content']['details about your order'] as any;
|
|
67
|
+
assert.ok(details.error.includes('does not exist'));
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('orderRetest rejects a malformed timestamp or job identifier', async () => {
|
|
71
|
+
const body = await response(['999999T9999', 'xyz', 'A reason that is long enough.']);
|
|
72
|
+
const details = body['response content']['details about your order'] as any;
|
|
73
|
+
assert.ok(details.error.includes('malformed'));
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('orderRetest rejects a reason shorter than 20 characters', async () => {
|
|
77
|
+
const body = await response(['260101T0004', 'ord', 'short']);
|
|
78
|
+
const details = body['response content']['details about your order'] as any;
|
|
79
|
+
assert.ok(details.error.includes('reason'));
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('orderRetest rejects a reason longer than 100 characters', async () => {
|
|
83
|
+
const longReason = 'x'.repeat(101);
|
|
84
|
+
const body = await response(['260101T0004', 'ord', longReason]);
|
|
85
|
+
const details = body['response content']['details about your order'] as any;
|
|
86
|
+
assert.ok(details.error.includes('reason'));
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('orderRetest rejects a superseded report', async () => {
|
|
90
|
+
// 260101T0000-mix is an earlier report of "Mixed Outcomes Page" than 260202T0000-new,
|
|
91
|
+
// so it is superseded.
|
|
92
|
+
const body = await response(['260101T0000', 'mix', 'A reason that is long enough.']);
|
|
93
|
+
const details = body['response content']['details about your order'] as any;
|
|
94
|
+
assert.equal(details.error, undefined);
|
|
95
|
+
const disposition = body['response content']['disposition of your order'] as any;
|
|
96
|
+
assert.ok(
|
|
97
|
+
disposition['what happens next'].includes('a later report about a page with the same description is available.')
|
|
98
|
+
);
|
|
99
|
+
assert.equal(disposition['report identifier'], null);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('orderRetest rejects a report matching a claimed job by description', async () => {
|
|
103
|
+
const claimedPath = path.join(fixtureDBDir, 'jobs', 'claimed', 'clm.json');
|
|
104
|
+
await fs.writeFile(claimedPath, JSON.stringify({
|
|
105
|
+
target: {what: 'Order Retest Fixture Page', url: 'https://example.com/unrelated'}
|
|
106
|
+
}));
|
|
107
|
+
try {
|
|
108
|
+
const body = await response(['260101T0004', 'ord', 'A reason that is long enough.']);
|
|
109
|
+
const details = body['response content']['details about your order'] as any;
|
|
110
|
+
assert.equal(details.error, undefined);
|
|
111
|
+
const disposition = body['response content']['disposition of your order'] as any;
|
|
112
|
+
assert.ok(
|
|
113
|
+
disposition['what happens next']
|
|
114
|
+
.includes('a request to test a page with the same description is already approved.')
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
finally {
|
|
118
|
+
await fs.unlink(claimedPath);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('orderRetest rejects a report matching a queued job by URL', async () => {
|
|
123
|
+
const queuedFilePath = path.join(queuePath, 'que.json');
|
|
124
|
+
await fs.writeFile(queuedFilePath, JSON.stringify({
|
|
125
|
+
target: {what: 'Some Other Page', url: 'https://example.com/orderretestfixture'}
|
|
126
|
+
}));
|
|
127
|
+
try {
|
|
128
|
+
const body = await response(['260101T0004', 'ord', 'A reason that is long enough.']);
|
|
129
|
+
const details = body['response content']['details about your order'] as any;
|
|
130
|
+
assert.equal(details.error, undefined);
|
|
131
|
+
const disposition = body['response content']['disposition of your order'] as any;
|
|
132
|
+
assert.ok(
|
|
133
|
+
disposition['what happens next']
|
|
134
|
+
.includes('a request to test a page with the same URL is already approved.')
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
finally {
|
|
138
|
+
await fs.unlink(queuedFilePath);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('orderRetest rejects an order once the job queue is full', async () => {
|
|
143
|
+
const fillerPath = path.join(queuePath, 'fil.json');
|
|
144
|
+
await fs.writeFile(fillerPath, JSON.stringify({target: {what: 'Filler', url: 'https://example.com/filler'}}));
|
|
145
|
+
process.env.JOB_QUEUE_MAX = '1';
|
|
146
|
+
try {
|
|
147
|
+
const body = await response(['260101T0004', 'ord', 'A reason that is long enough.']);
|
|
148
|
+
const details = body['response content']['details about your order'] as any;
|
|
149
|
+
assert.equal(details.error, undefined);
|
|
150
|
+
const disposition = body['response content']['disposition of your order'] as any;
|
|
151
|
+
assert.ok(disposition['what happens next'].includes('too many requests are awaiting approval right now.'));
|
|
152
|
+
} finally {
|
|
153
|
+
delete process.env.JOB_QUEUE_MAX;
|
|
154
|
+
await fs.unlink(fillerPath);
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('orderRetest accepts a valid retest order and enqueues a job directly', async () => {
|
|
159
|
+
const body = await response(['260101T0004', 'ord', 'A reason that is long enough.']);
|
|
160
|
+
const details = body['response content']['details about your order'] as any;
|
|
161
|
+
assert.equal(details.error, undefined);
|
|
162
|
+
assert.equal(details['page to be retested'].description, 'Order Retest Fixture Page');
|
|
163
|
+
const disposition = body['response content']['disposition of your order'] as any;
|
|
164
|
+
assert.ok(disposition['report identifier']);
|
|
165
|
+
assert.ok(disposition['what happens next'].includes('Testing has begun.'));
|
|
166
|
+
assert.ok(disposition['do you want to wait for completion'].includes('awaitTest'));
|
|
167
|
+
const queuedFiles = await fs.readdir(queuePath);
|
|
168
|
+
assert.equal(queuedFiles.length, 1);
|
|
169
|
+
const job = JSON.parse(await fs.readFile(path.join(queuePath, queuedFiles[0]!), 'utf8'));
|
|
170
|
+
assert.equal(job.target.what, 'Order Retest Fixture Page');
|
|
171
|
+
assert.equal(job.sources.reason, 'A reason that is long enough.');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('orderRetest response omits a web UI URL for this request but includes the ancestor', async () => {
|
|
175
|
+
const body = await response(['260101T0004', 'ord', 'A reason that is long enough.']);
|
|
176
|
+
const similarWeb = body['URLs of similar requests for web users'] as any;
|
|
177
|
+
assert.equal(similarWeb['this request'], null);
|
|
178
|
+
assert.ok(similarWeb['closest ancestor request'].includes('listIssues.html'));
|
|
179
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/*
|
|
2
|
+
orderRetest.ts
|
|
3
|
+
Processes an order to retest a page immediately, without manual approval, and returns an acknowledgement.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// IMPORTS
|
|
7
|
+
|
|
8
|
+
import {z} from 'zod';
|
|
9
|
+
import {getRequestFailureReason, getResponseMetadata, getThisHost, getToolsFacts} from './util.ts';
|
|
10
|
+
import {checkLength, isJobID, isTimeStamp, orderJob} from '../util.ts';
|
|
11
|
+
import {orderRetestResponseSchema} from './schemas.ts';
|
|
12
|
+
|
|
13
|
+
// TYPES
|
|
14
|
+
|
|
15
|
+
// The response content defined by the response schema.
|
|
16
|
+
type ResponseContent = z.infer<typeof orderRetestResponseSchema>['response content'];
|
|
17
|
+
|
|
18
|
+
// FUNCTIONS
|
|
19
|
+
|
|
20
|
+
// Returns the response body.
|
|
21
|
+
export const response = async (args: string[]) => {
|
|
22
|
+
const [timeStamp = '', jobID = '', reason = ''] = args;
|
|
23
|
+
const thisHost = getThisHost();
|
|
24
|
+
const reasonCheck = checkLength(reason, 20, 100, 'reason');
|
|
25
|
+
// Initialize the response-content properties.
|
|
26
|
+
let requestDetails: ResponseContent['details about your order'];
|
|
27
|
+
let requestDisposition: ResponseContent['disposition of your order'] = null;
|
|
28
|
+
// If the timestamp or job ID is not syntactically valid:
|
|
29
|
+
if (!isTimeStamp(timeStamp) || !isJobID(jobID)) {
|
|
30
|
+
requestDetails = {
|
|
31
|
+
error: 'order invalid: the report timestamp or job identifier is malformed'
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
// Otherwise, if the encoded reason is too short or too long:
|
|
35
|
+
else if (reasonCheck.status === 'error') {
|
|
36
|
+
requestDetails = {
|
|
37
|
+
error: `order invalid: your ${reasonCheck.message}`
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
// Otherwise, i.e. if the order is facially valid:
|
|
41
|
+
else {
|
|
42
|
+
// Order the retest. Unlike processTestRequest, this enqueues the job directly, with
|
|
43
|
+
// no manual-approval step, gated only by the lightweight checks orderJob itself
|
|
44
|
+
// performs (the target is not already claimed or queued, and the job queue is not
|
|
45
|
+
// already full). This resolves the cited report's description and URL itself.
|
|
46
|
+
const orderResult = await orderJob({timeStamp, jobID}, reason);
|
|
47
|
+
// If the cited report does not exist:
|
|
48
|
+
if (orderResult.result === 'nonreport') {
|
|
49
|
+
requestDetails = {
|
|
50
|
+
error: 'order invalid: the specified report does not exist'
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
// Otherwise, i.e. if the cited report was found:
|
|
54
|
+
else {
|
|
55
|
+
// Add details about the order.
|
|
56
|
+
requestDetails = {
|
|
57
|
+
'date and time received': new Date().toISOString(),
|
|
58
|
+
'page to be retested': {
|
|
59
|
+
description: orderResult.description,
|
|
60
|
+
URL: orderResult.url
|
|
61
|
+
},
|
|
62
|
+
'reason why the page should be retested': reason
|
|
63
|
+
};
|
|
64
|
+
// Add information about the disposition of the order.
|
|
65
|
+
if (orderResult.result === 'ok') {
|
|
66
|
+
const [reportTimeStamp, reportJobID] = orderResult.jobID.split('-') as [string, string];
|
|
67
|
+
requestDisposition = {
|
|
68
|
+
'what happens next': 'Testing has begun. Once it finishes, the report will be available via the listIssues tool.',
|
|
69
|
+
'report identifier': {timeStamp: reportTimeStamp, jobID: reportJobID},
|
|
70
|
+
'how you can check for completion': `Call the listIssues tool with timeStamp "${reportTimeStamp}" and jobID "${reportJobID}" to learn whether the report is ready.`,
|
|
71
|
+
'do you want to wait for completion': 'Testing typically takes about 2 minutes and occasionally up to 4 minutes. Do you want to wait (and be notified of completion) instead of checking back later? If so, call the awaitTest tool with the same timeStamp and jobID.'
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
requestDisposition = {
|
|
76
|
+
'what happens next': `Your order will not be processed, because ${getRequestFailureReason(
|
|
77
|
+
orderResult.result,
|
|
78
|
+
{result: 'superseded', reason: 'a later report about a page with the same description is available.'}
|
|
79
|
+
)}`,
|
|
80
|
+
'report identifier': null,
|
|
81
|
+
'how you can check for completion': 'Not applicable.',
|
|
82
|
+
'do you want to wait for completion': 'Not applicable.'
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// Create the response content.
|
|
88
|
+
const responseContent: ResponseContent = {
|
|
89
|
+
'details about your order': requestDetails,
|
|
90
|
+
'disposition of your order': requestDisposition
|
|
91
|
+
};
|
|
92
|
+
// Create a response body.
|
|
93
|
+
const body = {
|
|
94
|
+
'tool collection': getToolsFacts(),
|
|
95
|
+
'tool name': 'orderRetest',
|
|
96
|
+
'this request': {
|
|
97
|
+
description: 'Process my order to retest a page, immediately and without manual approval. The timeStamp and jobID parameters identify the latest available report about the page. Those parameters were in the response to my earlier listIssues request. The reason property of the request body is the reason why the page should be retested.',
|
|
98
|
+
method: 'POST',
|
|
99
|
+
URL: `${thisHost}/api/orderRetest/${timeStamp}/${jobID}`,
|
|
100
|
+
body: {
|
|
101
|
+
reason
|
|
102
|
+
},
|
|
103
|
+
'closest ancestor request': {
|
|
104
|
+
'tool name': 'listIssues',
|
|
105
|
+
description: 'Provide details about one report, including basics about the issues reported in it.',
|
|
106
|
+
method: 'GET',
|
|
107
|
+
URL: `${thisHost}/api/listIssues/${timeStamp}/${jobID}`
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
'URLs of similar requests for web users': {
|
|
111
|
+
'this request': null,
|
|
112
|
+
'closest ancestor request': `${thisHost}/listIssues.html/${timeStamp}/${jobID}`
|
|
113
|
+
},
|
|
114
|
+
'response metadata': getResponseMetadata(),
|
|
115
|
+
'response content': responseContent
|
|
116
|
+
};
|
|
117
|
+
// Return it.
|
|
118
|
+
return body;
|
|
119
|
+
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/*
|
|
2
|
-
|
|
3
|
-
Tests for api/
|
|
2
|
+
requestNewTest.test.ts
|
|
3
|
+
Tests for api/requestNewTest.ts using the fixture corpus.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
// IMPORTS
|
|
@@ -11,7 +11,7 @@ import fs from 'node:fs/promises';
|
|
|
11
11
|
import path from 'node:path';
|
|
12
12
|
import {fixtureDBDir} from '../test/dbFixture.ts';
|
|
13
13
|
|
|
14
|
-
// Blank the alert configuration unconditionally, before ./
|
|
14
|
+
// Blank the alert configuration unconditionally, before ./requestNewTest.ts (whose
|
|
15
15
|
// processTestRequest calls sendAlert) is ever imported below, so this file sends no
|
|
16
16
|
// real alert emails even when run directly rather than via `npm test`.
|
|
17
17
|
for (const key of ['MANAGER_EMAIL', 'ALERT_API_HOST', 'ALERT_API_PATH', 'ALERT_API_KEY', 'ALERT_FROM']) {
|
|
@@ -41,7 +41,7 @@ beforeEach(async () => {
|
|
|
41
41
|
console.log = (...args) => logged.push(args.join(' '));
|
|
42
42
|
});
|
|
43
43
|
|
|
44
|
-
import {response} from './
|
|
44
|
+
import {response} from './requestNewTest.ts';
|
|
45
45
|
|
|
46
46
|
after(() => {
|
|
47
47
|
console.log = originalLog;
|
|
@@ -55,45 +55,45 @@ after(() => {
|
|
|
55
55
|
|
|
56
56
|
// TESTS
|
|
57
57
|
|
|
58
|
-
test('
|
|
58
|
+
test('requestNewTest rejects an empty description', async () => {
|
|
59
59
|
const body = await response(['', 'https://example.com/test', 'A reason that is long enough.']);
|
|
60
60
|
assert.ok((body['response content']['details about your request'] as any).error);
|
|
61
|
-
assert.ok(!logged.some(line => line.includes('new test
|
|
61
|
+
assert.ok(!logged.some(line => line.includes('request for a new test awaits approval')));
|
|
62
62
|
});
|
|
63
63
|
|
|
64
|
-
test('
|
|
64
|
+
test('requestNewTest rejects a description longer than 100 characters', async () => {
|
|
65
65
|
const longWhat = 'x'.repeat(101);
|
|
66
66
|
const body = await response([longWhat, 'https://example.com/test', 'A reason that is long enough.']);
|
|
67
67
|
assert.ok((body['response content']['details about your request'] as any).error);
|
|
68
|
-
assert.ok(!logged.some(line => line.includes('new test
|
|
68
|
+
assert.ok(!logged.some(line => line.includes('request for a new test awaits approval')));
|
|
69
69
|
});
|
|
70
70
|
|
|
71
|
-
test('
|
|
71
|
+
test('requestNewTest rejects a URL shorter than 12 characters', async () => {
|
|
72
72
|
const body = await response(['Test Page', 'short', 'A reason that is long enough.']);
|
|
73
73
|
assert.ok((body['response content']['details about your request'] as any).error);
|
|
74
|
-
assert.ok(!logged.some(line => line.includes('new test
|
|
74
|
+
assert.ok(!logged.some(line => line.includes('request for a new test awaits approval')));
|
|
75
75
|
});
|
|
76
76
|
|
|
77
|
-
test('
|
|
77
|
+
test('requestNewTest rejects a syntactically invalid URL with the correct length', async () => {
|
|
78
78
|
const body = await response(['Test Page', 'not-a-valid-url', 'A reason that is long enough.']);
|
|
79
79
|
const details = body['response content']['details about your request'] as any;
|
|
80
80
|
assert.ok(details.error.includes('invalid URL'));
|
|
81
|
-
assert.ok(!logged.some(line => line.includes('new test
|
|
81
|
+
assert.ok(!logged.some(line => line.includes('request for a new test awaits approval')));
|
|
82
82
|
});
|
|
83
83
|
|
|
84
|
-
test('
|
|
84
|
+
test('requestNewTest rejects a private-address URL unless internal targets are allowed', async () => {
|
|
85
85
|
delete process.env.ALLOW_INTERNAL_TARGETS;
|
|
86
86
|
try {
|
|
87
87
|
const body = await response(['Internal Page', 'https://192.168.1.1/page', 'A reason that is long enough.']);
|
|
88
88
|
const details = body['response content']['details about your request'] as any;
|
|
89
89
|
assert.ok(details.error.includes('does not resolve to a page that this deployment can test'));
|
|
90
|
-
assert.ok(!logged.some(line => line.includes('new test
|
|
90
|
+
assert.ok(!logged.some(line => line.includes('request for a new test awaits approval')));
|
|
91
91
|
} finally {
|
|
92
92
|
process.env.ALLOW_INTERNAL_TARGETS = 'true';
|
|
93
93
|
}
|
|
94
94
|
});
|
|
95
95
|
|
|
96
|
-
test('
|
|
96
|
+
test('requestNewTest rejects a URL that redirects to a disallowed target, and alerts a manager', async (t) => {
|
|
97
97
|
delete process.env.ALLOW_INTERNAL_TARGETS;
|
|
98
98
|
t.mock.method(globalThis, 'fetch', async () => ({url: 'https://10.0.0.5/page'}) as Response);
|
|
99
99
|
try {
|
|
@@ -109,21 +109,21 @@ test('requestTest rejects a URL that redirects to a disallowed target, and alert
|
|
|
109
109
|
}
|
|
110
110
|
});
|
|
111
111
|
|
|
112
|
-
test('
|
|
112
|
+
test('requestNewTest rejects an already-tested page', async () => {
|
|
113
113
|
const body = await response(['Mixed Outcomes Page', 'https://example.com/mixed', 'A reason that is long enough.']);
|
|
114
114
|
const details = body['response content']['details about your request'] as any;
|
|
115
115
|
// A report for this description and URL already exists, so processTestRequest
|
|
116
|
-
// returns '
|
|
116
|
+
// returns 'reportExists' and the failure is reported via the disposition, not details.error.
|
|
117
117
|
assert.equal(details.error, undefined);
|
|
118
118
|
const disposition = body['response content']['disposition of your request'] as any;
|
|
119
119
|
assert.ok(
|
|
120
120
|
disposition['what happens next']
|
|
121
121
|
.includes('a report about a page with the same description and URL is available.')
|
|
122
122
|
);
|
|
123
|
-
assert.ok(!logged.some(line => line.includes('new test
|
|
123
|
+
assert.ok(!logged.some(line => line.includes('request for a new test awaits approval')));
|
|
124
124
|
});
|
|
125
125
|
|
|
126
|
-
test('
|
|
126
|
+
test('requestNewTest rejects a page matching a claimed job by description', async () => {
|
|
127
127
|
const claimedPath = path.join(fixtureDBDir, 'jobs', 'claimed', 'clm.json');
|
|
128
128
|
await fs.writeFile(claimedPath, JSON.stringify({
|
|
129
129
|
target: {what: 'Claimed Test Page', url: 'https://example.com/claimed-job'}
|
|
@@ -143,7 +143,7 @@ test('requestTest rejects a page matching a claimed job by description', async (
|
|
|
143
143
|
}
|
|
144
144
|
});
|
|
145
145
|
|
|
146
|
-
test('
|
|
146
|
+
test('requestNewTest rejects a page matching a queued job by URL', async () => {
|
|
147
147
|
const queuedPath = path.join(fixtureDBDir, 'jobs', 'queue', 'que.json');
|
|
148
148
|
await fs.writeFile(queuedPath, JSON.stringify({
|
|
149
149
|
target: {what: 'Some Other Page', url: 'https://example.com/queued-url'}
|
|
@@ -163,7 +163,7 @@ test('requestTest rejects a page matching a queued job by URL', async () => {
|
|
|
163
163
|
}
|
|
164
164
|
});
|
|
165
165
|
|
|
166
|
-
test('
|
|
166
|
+
test('requestNewTest rejects a duplicate request', async () => {
|
|
167
167
|
await response(['Duplicate API Page', 'https://example.com/dup-api', 'A reason that is long enough.']);
|
|
168
168
|
const body = await response(['Duplicate API Page', 'https://example.com/dup-api', 'A reason that is long enough.']);
|
|
169
169
|
const details = body['response content']['details about your request'] as any;
|
|
@@ -172,13 +172,13 @@ test('requestTest rejects a duplicate request', async () => {
|
|
|
172
172
|
assert.ok(disposition['what happens next'].includes('an identical request is already awaiting approval.'));
|
|
173
173
|
});
|
|
174
174
|
|
|
175
|
-
test('
|
|
175
|
+
test('requestNewTest accepts a valid new page request', async () => {
|
|
176
176
|
const body = await response(['Brand New Page', 'https://example.com/brandnew', 'A reason that is long enough.']);
|
|
177
177
|
const details = body['response content']['details about your request'] as any;
|
|
178
178
|
assert.equal(details.error, undefined);
|
|
179
179
|
assert.equal(details['page to be tested'].description, 'Brand New Page');
|
|
180
180
|
assert.ok(logged.some(line =>
|
|
181
|
-
line.startsWith('WARNING (Kilotest: new test
|
|
181
|
+
line.startsWith('WARNING (Kilotest: request for a new test awaits approval)')
|
|
182
182
|
&& line.includes('Brand New Page')
|
|
183
183
|
&& line.includes('https://example.com/brandnew')
|
|
184
184
|
// The alert reports the resulting queue size, so a maintainer who has been away can
|
|
@@ -187,14 +187,14 @@ test('requestTest accepts a valid new page request', async () => {
|
|
|
187
187
|
));
|
|
188
188
|
});
|
|
189
189
|
|
|
190
|
-
test('
|
|
190
|
+
test('requestNewTest includes disposition information for a valid request', async () => {
|
|
191
191
|
const body = await response(['Brand New Page', 'https://example.com/brandnew', 'A reason that is long enough.']);
|
|
192
192
|
const disposition = body['response content']['disposition of your request'];
|
|
193
193
|
assert.ok(disposition);
|
|
194
194
|
assert.ok(disposition['what happens next']);
|
|
195
195
|
});
|
|
196
196
|
|
|
197
|
-
test('
|
|
197
|
+
test('requestNewTest rejects a request with a fallback channel when the queue is full', async () => {
|
|
198
198
|
// Fill testRequests.json with 20 pending requests, all for one URL, to reach the cap.
|
|
199
199
|
const filler = Array.from({length: 20}, (_, i) => ({
|
|
200
200
|
timeStamp: '260101T0000', description: `Filler Page ${i}`, reason: 'Filler reason.'
|
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
/*
|
|
2
|
-
|
|
2
|
+
requestNewTest.ts
|
|
3
3
|
Processes a request to test an untested page and returns an acknowledgement.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
// IMPORTS
|
|
7
7
|
|
|
8
8
|
import {z} from 'zod';
|
|
9
|
-
import {getResponseMetadata, getThisHost, getToolsFacts} from './util.ts';
|
|
9
|
+
import {buildRequestDisposition, getResponseMetadata, getThisHost, getToolsFacts} from './util.ts';
|
|
10
10
|
import {checkLength, isAllowedRedirectTarget, isAllowedTarget, isURL, processTestRequest} from '../util.ts';
|
|
11
|
-
import {
|
|
11
|
+
import {requestNewTestResponseSchema} from './schemas.ts';
|
|
12
12
|
import {sendAlert} from '../alerts.ts';
|
|
13
13
|
|
|
14
14
|
// TYPES
|
|
15
15
|
|
|
16
16
|
// The response content defined by the response schema.
|
|
17
|
-
type ResponseContent = z.infer<typeof
|
|
17
|
+
type ResponseContent = z.infer<typeof requestNewTestResponseSchema>['response content'];
|
|
18
18
|
|
|
19
19
|
// FUNCTIONS
|
|
20
20
|
|
|
@@ -77,38 +77,15 @@ export const response = async (args: string[]) => {
|
|
|
77
77
|
'reason why the page should be tested': reason
|
|
78
78
|
};
|
|
79
79
|
// Add information about the disposition of the request.
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
'
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
let failureReason: string;
|
|
90
|
-
if (requestResult === 'description') {
|
|
91
|
-
failureReason = 'a request to test a page with the same description is already approved.'
|
|
92
|
-
}
|
|
93
|
-
else if (requestResult === 'url') {
|
|
94
|
-
failureReason = 'a request to test a page with the same URL is already approved.'
|
|
95
|
-
}
|
|
96
|
-
else if (requestResult === 'retest') {
|
|
97
|
-
failureReason = 'a report about a page with the same description and URL is available.'
|
|
98
|
-
}
|
|
99
|
-
else if (requestResult === 'queueFull') {
|
|
100
|
-
failureReason = 'too many requests are awaiting approval right now. Please try again later, ' +
|
|
101
|
-
'or post your request at https://github.com/jrpool/kilotest/issues or email info@kilotest.com.'
|
|
102
|
-
}
|
|
103
|
-
else {
|
|
104
|
-
failureReason = 'an identical request is already awaiting approval.'
|
|
105
|
-
}
|
|
106
|
-
requestDisposition = {
|
|
107
|
-
'what happens next': `${failureFact}${failureReason}`,
|
|
108
|
-
'how you can check for completion': 'Not applicable.',
|
|
109
|
-
'how a web user can check for completion': 'Not applicable.'
|
|
110
|
-
}
|
|
111
|
-
}
|
|
80
|
+
requestDisposition = buildRequestDisposition(
|
|
81
|
+
requestResult,
|
|
82
|
+
{
|
|
83
|
+
whatHappensNext: 'Your request is likely to be approved and processed within 1 hour to 1 day.',
|
|
84
|
+
howToCheck: 'You can call the listReports tool to learn whether the page has been tested and a report is available.',
|
|
85
|
+
howWebUserChecks: `A web user can visit ${thisHost}/listReports.html to learn whether the page has been tested and a report is available.`
|
|
86
|
+
},
|
|
87
|
+
{result: 'reportExists', reason: 'a report about a page with the same description and URL is available.'}
|
|
88
|
+
);
|
|
112
89
|
}
|
|
113
90
|
// Create the response content.
|
|
114
91
|
const responseContent: ResponseContent = {
|
|
@@ -118,11 +95,11 @@ export const response = async (args: string[]) => {
|
|
|
118
95
|
// Create a response body.
|
|
119
96
|
const body = {
|
|
120
97
|
'tool collection': getToolsFacts(),
|
|
121
|
-
'tool name': '
|
|
98
|
+
'tool name': 'requestNewTest',
|
|
122
99
|
'this request': {
|
|
123
100
|
description: 'Process my request to test a page about which no report is available yet. I have provided a description and the URL of the page and a reason why it should be tested.',
|
|
124
101
|
method: 'POST',
|
|
125
|
-
URL: `${thisHost}/api/
|
|
102
|
+
URL: `${thisHost}/api/requestNewTest`,
|
|
126
103
|
body: {
|
|
127
104
|
description,
|
|
128
105
|
URL: url,
|
|
@@ -136,7 +113,7 @@ export const response = async (args: string[]) => {
|
|
|
136
113
|
}
|
|
137
114
|
},
|
|
138
115
|
'URLs of similar requests for web users': {
|
|
139
|
-
'this request': `${thisHost}/
|
|
116
|
+
'this request': `${thisHost}/requestNewTestForm.html`,
|
|
140
117
|
'closest ancestor request': `${thisHost}/listReports.html`
|
|
141
118
|
},
|
|
142
119
|
'response metadata': getResponseMetadata(),
|
|
@@ -74,14 +74,14 @@ test('requestRetest rejects a nonexistent report', async () => {
|
|
|
74
74
|
const body = await response(['251231T0000', 'zzz', 'A reason that is long enough.']);
|
|
75
75
|
const details = body['response content']['details about your request'] as any;
|
|
76
76
|
assert.ok(details.error.includes('does not exist'));
|
|
77
|
-
assert.ok(!logged.some(line => line.includes('
|
|
77
|
+
assert.ok(!logged.some(line => line.includes('request for a retest awaits approval')));
|
|
78
78
|
});
|
|
79
79
|
|
|
80
80
|
test('requestRetest rejects a malformed timestamp or job identifier', async () => {
|
|
81
81
|
const body = await response(['999999T9999', 'xyz', 'A reason that is long enough.']);
|
|
82
82
|
const details = body['response content']['details about your request'] as any;
|
|
83
83
|
assert.ok(details.error.includes('malformed'));
|
|
84
|
-
assert.ok(!logged.some(line => line.includes('
|
|
84
|
+
assert.ok(!logged.some(line => line.includes('request for a retest awaits approval')));
|
|
85
85
|
});
|
|
86
86
|
|
|
87
87
|
test('requestRetest rejects a superseded report', async () => {
|
|
@@ -95,14 +95,14 @@ test('requestRetest rejects a superseded report', async () => {
|
|
|
95
95
|
assert.ok(
|
|
96
96
|
disposition['what happens next'].includes('a later report about a page with the same description is available.')
|
|
97
97
|
);
|
|
98
|
-
assert.ok(!logged.some(line => line.includes('
|
|
98
|
+
assert.ok(!logged.some(line => line.includes('request for a retest awaits approval')));
|
|
99
99
|
});
|
|
100
100
|
|
|
101
101
|
test('requestRetest rejects a reason shorter than 20 characters', async () => {
|
|
102
102
|
const body = await response(['260101T0003', 'ret', 'short']);
|
|
103
103
|
const details = body['response content']['details about your request'] as any;
|
|
104
104
|
assert.ok(details.error.includes('reason'));
|
|
105
|
-
assert.ok(!logged.some(line => line.includes('
|
|
105
|
+
assert.ok(!logged.some(line => line.includes('request for a retest awaits approval')));
|
|
106
106
|
});
|
|
107
107
|
|
|
108
108
|
test('requestRetest rejects a reason longer than 100 characters', async () => {
|
|
@@ -110,7 +110,7 @@ test('requestRetest rejects a reason longer than 100 characters', async () => {
|
|
|
110
110
|
const body = await response(['260101T0003', 'ret', longReason]);
|
|
111
111
|
const details = body['response content']['details about your request'] as any;
|
|
112
112
|
assert.ok(details.error.includes('reason'));
|
|
113
|
-
assert.ok(!logged.some(line => line.includes('
|
|
113
|
+
assert.ok(!logged.some(line => line.includes('request for a retest awaits approval')));
|
|
114
114
|
});
|
|
115
115
|
|
|
116
116
|
test('requestRetest rejects a report matching a claimed job by description', async () => {
|
|
@@ -170,7 +170,7 @@ test('requestRetest accepts a valid retest request for the latest report of a pa
|
|
|
170
170
|
assert.equal(details.error, undefined);
|
|
171
171
|
assert.equal(details['page to be retested'].description, 'Mixed Outcomes Page');
|
|
172
172
|
assert.ok(logged.some(line =>
|
|
173
|
-
line.startsWith('WARNING (Kilotest:
|
|
173
|
+
line.startsWith('WARNING (Kilotest: request for a retest awaits approval)')
|
|
174
174
|
&& line.includes('Mixed Outcomes Page')
|
|
175
175
|
&& line.includes('https://example.com/mixed')
|
|
176
176
|
// The alert reports the resulting queue size, so a maintainer who has been away can
|