@jrpool/kilotest 49.0.0 → 51.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/api/getReport.ts +3 -1
- package/api/requestFeature.test.ts +100 -4
- package/api/requestFeature.ts +57 -13
- package/api/requestRetest.test.ts +46 -13
- package/api/requestRetest.ts +11 -5
- package/api/requestTest.test.ts +7 -0
- package/api/requestTest.ts +6 -6
- package/api/schemas.ts +2 -2
- package/api/util.test.ts +9 -0
- package/apiIntegration.test.ts +17 -0
- package/docs/BACKLOG.md +14 -2
- package/docs/SERVICE.md +50 -7
- package/index.test.ts +171 -7
- package/index.ts +103 -23
- package/linkValidation.test.ts +1 -0
- package/mcp.test.ts +1 -1
- package/package.json +1 -1
- package/robots.txt +1 -0
- package/smokeTest.ts +10 -5
- package/util.test.ts +78 -13
- package/util.ts +35 -16
- package/web/ai0BalanceForm/index.ts +2 -2
- package/web/deleteNotesForm/deleteNotesForm.test.ts +237 -0
- package/web/deleteNotesForm/index.html +29 -0
- package/web/deleteNotesForm/index.ts +167 -0
- package/web/enqueue/index.ts +33 -42
- package/web/hideReportForm/index.ts +4 -2
- package/web/manage/index.html +1 -0
- package/web/metrics/index.ts +3 -2
- package/web/reannotate/index.ts +2 -1
- package/web/renewWCAG/index.ts +2 -2
- package/web/reportDeletion.ts +3 -2
- package/web/showHiddenReportsForm/index.ts +2 -2
- package/web/tutorialAI/index.ts +6 -11
- package/web/tutorialAI/tutorialAI.test.ts +9 -3
- package/web/tutorialWeb/tutorialWeb.test.ts +8 -2
- package/web/unhideReportForm/index.ts +4 -2
- package/.claude/scheduled_tasks.lock +0 -1
package/api/getReport.ts
CHANGED
|
@@ -25,7 +25,9 @@ type ResponseContent = z.infer<typeof getReportResponseSchema>['response content
|
|
|
25
25
|
export const response = async (args: string[]) => {
|
|
26
26
|
const [timeStamp = '', jobID = ''] = args;
|
|
27
27
|
const thisHost = getThisHost();
|
|
28
|
-
// Get the report size.
|
|
28
|
+
// Get the report size. A malformed timestamp or job ID resolves to a nonexistent file
|
|
29
|
+
// path, which getReportStats already reports as unavailable, so no separate syntactic
|
|
30
|
+
// check is needed here.
|
|
29
31
|
const reportStats = await getReportStats(timeStamp, jobID);
|
|
30
32
|
// Create the response content.
|
|
31
33
|
const responseContent: ResponseContent = reportStats
|
|
@@ -7,6 +7,20 @@
|
|
|
7
7
|
|
|
8
8
|
import {test, beforeEach, after} from 'node:test';
|
|
9
9
|
import assert from 'node:assert/strict';
|
|
10
|
+
import fs from 'node:fs/promises';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
|
|
13
|
+
// Blank the alert configuration unconditionally, before ./requestFeature.ts (which
|
|
14
|
+
// calls sendAlert) is ever imported below, so this file sends no real alert emails
|
|
15
|
+
// even when run directly rather than via `npm test`.
|
|
16
|
+
for (const key of ['MANAGER_EMAIL', 'ALERT_API_HOST', 'ALERT_API_PATH', 'ALERT_API_KEY', 'ALERT_FROM']) {
|
|
17
|
+
process.env[key] = '';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// ENVIRONMENT
|
|
21
|
+
|
|
22
|
+
const testDir = path.join(import.meta.dirname, '../test/fixtures/featureRequests');
|
|
23
|
+
process.env.FEATURE_REQUESTS_PATH = path.join(testDir, 'featureRequests.json');
|
|
10
24
|
|
|
11
25
|
// SETUP AND TEARDOWN
|
|
12
26
|
|
|
@@ -14,16 +28,19 @@ let logged: any[] = [];
|
|
|
14
28
|
const originalLog = console.log;
|
|
15
29
|
|
|
16
30
|
// Capture console.log so the alert sendAlert emits can be observed. The alert
|
|
17
|
-
// configuration is
|
|
18
|
-
beforeEach(() => {
|
|
31
|
+
// configuration is blanked above, so sendAlert logs a WARNING instead of sending.
|
|
32
|
+
beforeEach(async () => {
|
|
19
33
|
logged = [];
|
|
20
34
|
console.log = (...args) => logged.push(args.join(' '));
|
|
35
|
+
await fs.mkdir(testDir, {recursive: true});
|
|
36
|
+
await fs.writeFile(process.env.FEATURE_REQUESTS_PATH!, '[]\n');
|
|
21
37
|
});
|
|
22
38
|
|
|
23
39
|
import {response} from './requestFeature.ts';
|
|
24
40
|
|
|
25
|
-
after(() => {
|
|
41
|
+
after(async () => {
|
|
26
42
|
console.log = originalLog;
|
|
43
|
+
await fs.rm(testDir, {recursive: true, force: true});
|
|
27
44
|
});
|
|
28
45
|
|
|
29
46
|
// TESTS
|
|
@@ -37,6 +54,24 @@ test('requestFeature rejects an empty feature request', async () => {
|
|
|
37
54
|
);
|
|
38
55
|
});
|
|
39
56
|
|
|
57
|
+
test('requestFeature rejects a feature request shorter than 20 characters', async () => {
|
|
58
|
+
const body = await response(['Too short']);
|
|
59
|
+
const details = body['response content']['details about your request'] as any;
|
|
60
|
+
assert.ok(details.error.includes('feature description'));
|
|
61
|
+
assert.ok(
|
|
62
|
+
!logged.some(line => line.startsWith('WARNING (Kilotest: MCP feature request received)'))
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('requestFeature rejects a feature request longer than 1000 characters', async () => {
|
|
67
|
+
const body = await response(['x'.repeat(1001)]);
|
|
68
|
+
const details = body['response content']['details about your request'] as any;
|
|
69
|
+
assert.ok(details.error.includes('feature description'));
|
|
70
|
+
assert.ok(
|
|
71
|
+
!logged.some(line => line.startsWith('WARNING (Kilotest: MCP feature request received)'))
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
40
75
|
test('requestFeature accepts a non-empty feature request and notifies the manager', async () => {
|
|
41
76
|
const body = await response(['Add a dark mode toggle']);
|
|
42
77
|
const details = body['response content']['details about your request'] as any;
|
|
@@ -50,7 +85,68 @@ test('requestFeature accepts a non-empty feature request and notifies the manage
|
|
|
50
85
|
});
|
|
51
86
|
|
|
52
87
|
test('requestFeature includes tool name and metadata', async () => {
|
|
53
|
-
const body = await response(['
|
|
88
|
+
const body = await response(['A sufficiently long feature description']);
|
|
54
89
|
assert.equal(body['tool name'], 'requestFeature');
|
|
55
90
|
assert.ok(body['response metadata'].identifier);
|
|
56
91
|
});
|
|
92
|
+
|
|
93
|
+
test('requestFeature records a valid feature request to the feature-requests file', async () => {
|
|
94
|
+
await response(['Add a dark mode toggle to the reports page']);
|
|
95
|
+
const featureRequests = JSON.parse(await fs.readFile(process.env.FEATURE_REQUESTS_PATH!, 'utf8'));
|
|
96
|
+
assert.equal(featureRequests.length, 1);
|
|
97
|
+
assert.equal(featureRequests[0].content, 'Add a dark mode toggle to the reports page');
|
|
98
|
+
assert.ok(featureRequests[0].timeStamp);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('requestFeature appends to, rather than overwrites, existing feature requests', async () => {
|
|
102
|
+
await response(['First feature request of at least 20 characters']);
|
|
103
|
+
await response(['Second feature request of at least 20 characters']);
|
|
104
|
+
const featureRequests = JSON.parse(await fs.readFile(process.env.FEATURE_REQUESTS_PATH!, 'utf8'));
|
|
105
|
+
assert.equal(featureRequests.length, 2);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('requestFeature does not record an invalid feature request', async () => {
|
|
109
|
+
await response(['short']);
|
|
110
|
+
const featureRequests = JSON.parse(await fs.readFile(process.env.FEATURE_REQUESTS_PATH!, 'utf8'));
|
|
111
|
+
assert.equal(featureRequests.length, 0);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('requestFeature creates the feature-requests file when it does not exist', async () => {
|
|
115
|
+
await fs.unlink(process.env.FEATURE_REQUESTS_PATH!);
|
|
116
|
+
await response(['A feature request when no file yet exists']);
|
|
117
|
+
const featureRequests = JSON.parse(await fs.readFile(process.env.FEATURE_REQUESTS_PATH!, 'utf8'));
|
|
118
|
+
assert.equal(featureRequests.length, 1);
|
|
119
|
+
assert.equal(featureRequests[0].content, 'A feature request when no file yet exists');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('requestFeature rejects a feature request identical to one already on file', async () => {
|
|
123
|
+
const feature = 'Add a dark mode toggle to the reports page';
|
|
124
|
+
const firstBody = await response([feature]);
|
|
125
|
+
const firstDetails = firstBody['response content']['details about your request'] as any;
|
|
126
|
+
assert.equal(firstDetails.error, undefined);
|
|
127
|
+
const secondBody = await response([feature]);
|
|
128
|
+
const secondDetails = secondBody['response content']['details about your request'] as any;
|
|
129
|
+
assert.ok(secondDetails.error.includes('identical to one already submitted'));
|
|
130
|
+
const featureRequests = JSON.parse(await fs.readFile(process.env.FEATURE_REQUESTS_PATH!, 'utf8'));
|
|
131
|
+
// The duplicate was not recorded a second time.
|
|
132
|
+
assert.equal(featureRequests.length, 1);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('requestFeature does not notify the manager of a duplicate feature request', async () => {
|
|
136
|
+
const feature = 'Add a dark mode toggle to the settings page';
|
|
137
|
+
await response([feature]);
|
|
138
|
+
logged = [];
|
|
139
|
+
await response([feature]);
|
|
140
|
+
assert.ok(
|
|
141
|
+
!logged.some(line => line.startsWith('WARNING (Kilotest: MCP feature request received)'))
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('requestFeature accepts a feature request that differs from an existing one', async () => {
|
|
146
|
+
await response(['An existing feature request on file']);
|
|
147
|
+
const body = await response(['A different feature request entirely']);
|
|
148
|
+
const details = body['response content']['details about your request'] as any;
|
|
149
|
+
assert.equal(details.error, undefined);
|
|
150
|
+
const featureRequests = JSON.parse(await fs.readFile(process.env.FEATURE_REQUESTS_PATH!, 'utf8'));
|
|
151
|
+
assert.equal(featureRequests.length, 2);
|
|
152
|
+
});
|
package/api/requestFeature.ts
CHANGED
|
@@ -8,35 +8,79 @@
|
|
|
8
8
|
import {z} from 'zod';
|
|
9
9
|
import {getResponseMetadata, getThisHost, getToolsFacts} from './util.ts';
|
|
10
10
|
import {sendAlert} from '../alerts.ts';
|
|
11
|
+
import {checkCommentDuplicate, checkLength, getJSON, getNowStamp} from '../util.ts';
|
|
11
12
|
import {requestFeatureResponseSchema} from './schemas.ts';
|
|
13
|
+
import fs from 'node:fs/promises';
|
|
14
|
+
import path from 'node:path';
|
|
12
15
|
|
|
13
16
|
// TYPES
|
|
14
17
|
|
|
15
18
|
// The response content defined by the response schema.
|
|
16
19
|
type ResponseContent = z.infer<typeof requestFeatureResponseSchema>['response content'];
|
|
17
20
|
|
|
21
|
+
// FUNCTIONS (helpers)
|
|
22
|
+
|
|
23
|
+
const getFeatureRequestsPath = () => process.env.FEATURE_REQUESTS_PATH || path.join(import.meta.dirname, '../db/featureRequests.json');
|
|
24
|
+
|
|
18
25
|
// FUNCTIONS
|
|
19
26
|
|
|
20
27
|
// Returns the response body.
|
|
21
28
|
export const response = async (args: string[]) => {
|
|
22
29
|
const [feature = ''] = args;
|
|
23
30
|
const thisHost = getThisHost();
|
|
31
|
+
const lengthCheck = checkLength(feature, 20, 1000, 'feature description');
|
|
24
32
|
// Initialize the response content.
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
// Notify the manager.
|
|
33
|
-
await sendAlert('Kilotest: MCP feature request received', feature);
|
|
34
|
-
// Add the disposition to the response content.
|
|
35
|
-
responseContent['details about your request'] = {
|
|
36
|
-
'date and time received': new Date().toISOString(),
|
|
37
|
-
disposition: 'received and logged; manager notified'
|
|
33
|
+
let responseContent: ResponseContent;
|
|
34
|
+
// If the feature description is invalid:
|
|
35
|
+
if (lengthCheck.status === 'error') {
|
|
36
|
+
responseContent = {
|
|
37
|
+
'details about your request': {
|
|
38
|
+
error: `request invalid: ${lengthCheck.message}`
|
|
39
|
+
}
|
|
38
40
|
};
|
|
39
41
|
}
|
|
42
|
+
// Otherwise, i.e. if it is valid:
|
|
43
|
+
else {
|
|
44
|
+
// Get the feature requests already on file.
|
|
45
|
+
const featureRequestsPath = getFeatureRequestsPath();
|
|
46
|
+
let featureRequests: {timeStamp: string; content: string}[] = [];
|
|
47
|
+
try {
|
|
48
|
+
const existing = await fs.readFile(featureRequestsPath, 'utf8');
|
|
49
|
+
featureRequests = JSON.parse(existing);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// Initialize an empty feature-requests array.
|
|
53
|
+
}
|
|
54
|
+
// If the request is identical to one already on file:
|
|
55
|
+
const duplicateCheck = checkCommentDuplicate(featureRequests, feature);
|
|
56
|
+
if (duplicateCheck.status === 'error') {
|
|
57
|
+
responseContent = {
|
|
58
|
+
'details about your request': {
|
|
59
|
+
error: 'request invalid: your feature request is identical to one already submitted, ' +
|
|
60
|
+
'but you are welcome to submit a different one'
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
// Otherwise, i.e. if it is not a duplicate:
|
|
65
|
+
else {
|
|
66
|
+
// Record the feature request alongside the existing ones.
|
|
67
|
+
featureRequests.push({
|
|
68
|
+
timeStamp: getNowStamp(),
|
|
69
|
+
content: feature
|
|
70
|
+
});
|
|
71
|
+
await fs.mkdir(path.dirname(featureRequestsPath), {recursive: true});
|
|
72
|
+
await fs.writeFile(featureRequestsPath, getJSON(featureRequests));
|
|
73
|
+
// Notify the manager.
|
|
74
|
+
await sendAlert('Kilotest: MCP feature request received', feature);
|
|
75
|
+
// Add the disposition to the response content.
|
|
76
|
+
responseContent = {
|
|
77
|
+
'details about your request': {
|
|
78
|
+
'date and time received': new Date().toISOString(),
|
|
79
|
+
disposition: 'received and logged; manager notified'
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
}
|
|
40
84
|
// Create a response body.
|
|
41
85
|
const body = {
|
|
42
86
|
'tool collection': getToolsFacts(),
|
|
@@ -11,15 +11,40 @@ 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 ./requestRetest.ts (whose
|
|
15
|
+
// processTestRequest calls sendAlert) is ever imported below, so this file sends no
|
|
16
|
+
// real alert emails even when run directly rather than via `npm test`.
|
|
17
|
+
for (const key of ['MANAGER_EMAIL', 'ALERT_API_HOST', 'ALERT_API_PATH', 'ALERT_API_KEY', 'ALERT_FROM']) {
|
|
18
|
+
process.env[key] = '';
|
|
19
|
+
}
|
|
20
|
+
|
|
14
21
|
// SETUP AND TEARDOWN
|
|
15
22
|
|
|
16
23
|
const savedDBDir = process.env.DB_DIR;
|
|
17
24
|
const testRequestsPath = path.join(fixtureDBDir, 'jobs', 'testRequests.json');
|
|
25
|
+
// A report added only to this test's disposable copy of the fixture database (never to
|
|
26
|
+
// the tracked test/fixtures/db corpus), so its 3-character job ID satisfies isJobID
|
|
27
|
+
// without perturbing report-count tests elsewhere that copy the whole corpus. Its job ID
|
|
28
|
+
// intentionally differs from the tracked corpus's 2-character 260101T0001-ct.json, which
|
|
29
|
+
// isJobID would reject.
|
|
30
|
+
const retestFixturePath = path.join(fixtureDBDir, 'reports', '260101T0003-ret.json');
|
|
31
|
+
const retestFixtureReport = {
|
|
32
|
+
id: '260101T0003-ret',
|
|
33
|
+
what: 'Retest Fixture Page',
|
|
34
|
+
target: {what: 'Retest Fixture Page', url: 'https://example.com/retestfixture'},
|
|
35
|
+
sources: {worker: 'test-worker'},
|
|
36
|
+
acts: [],
|
|
37
|
+
jobData: {startTime: '26-01-01T00:00', endTime: '26-01-01T00:10', elapsedSeconds: 600, preventions: {}, issuelessRules: []},
|
|
38
|
+
catalog: {},
|
|
39
|
+
images: {},
|
|
40
|
+
checkpoints: []
|
|
41
|
+
};
|
|
18
42
|
let logged: any[] = [];
|
|
19
43
|
const originalLog = console.log;
|
|
20
44
|
|
|
21
|
-
before(() => {
|
|
45
|
+
before(async () => {
|
|
22
46
|
process.env.DB_DIR = fixtureDBDir;
|
|
47
|
+
await fs.writeFile(retestFixturePath, JSON.stringify(retestFixtureReport));
|
|
23
48
|
});
|
|
24
49
|
|
|
25
50
|
// Reset the test-requests file and capture console.log, so the alert that
|
|
@@ -32,8 +57,9 @@ beforeEach(async () => {
|
|
|
32
57
|
|
|
33
58
|
import {response} from './requestRetest.ts';
|
|
34
59
|
|
|
35
|
-
after(() => {
|
|
60
|
+
after(async () => {
|
|
36
61
|
console.log = originalLog;
|
|
62
|
+
await fs.unlink(retestFixturePath);
|
|
37
63
|
if (savedDBDir !== undefined) {
|
|
38
64
|
process.env.DB_DIR = savedDBDir;
|
|
39
65
|
}
|
|
@@ -45,12 +71,19 @@ after(() => {
|
|
|
45
71
|
// TESTS
|
|
46
72
|
|
|
47
73
|
test('requestRetest rejects a nonexistent report', async () => {
|
|
48
|
-
const body = await response(['
|
|
74
|
+
const body = await response(['251231T0000', 'zzz', 'A reason that is long enough.']);
|
|
49
75
|
const details = body['response content']['details about your request'] as any;
|
|
50
76
|
assert.ok(details.error.includes('does not exist'));
|
|
51
77
|
assert.ok(!logged.some(line => line.includes('new retest request awaits approval')));
|
|
52
78
|
});
|
|
53
79
|
|
|
80
|
+
test('requestRetest rejects a malformed timestamp or job identifier', async () => {
|
|
81
|
+
const body = await response(['999999T9999', 'xyz', 'A reason that is long enough.']);
|
|
82
|
+
const details = body['response content']['details about your request'] as any;
|
|
83
|
+
assert.ok(details.error.includes('malformed'));
|
|
84
|
+
assert.ok(!logged.some(line => line.includes('new retest request awaits approval')));
|
|
85
|
+
});
|
|
86
|
+
|
|
54
87
|
test('requestRetest rejects a superseded report', async () => {
|
|
55
88
|
// 260101T0000-mix is an earlier report of "Mixed Outcomes Page" than 260202T0000-new,
|
|
56
89
|
// so it is superseded and processTestRequest returns 'superseded', reported via the
|
|
@@ -66,7 +99,7 @@ test('requestRetest rejects a superseded report', async () => {
|
|
|
66
99
|
});
|
|
67
100
|
|
|
68
101
|
test('requestRetest rejects a reason shorter than 20 characters', async () => {
|
|
69
|
-
const body = await response(['
|
|
102
|
+
const body = await response(['260101T0003', 'ret', 'short']);
|
|
70
103
|
const details = body['response content']['details about your request'] as any;
|
|
71
104
|
assert.ok(details.error.includes('reason'));
|
|
72
105
|
assert.ok(!logged.some(line => line.includes('new retest request awaits approval')));
|
|
@@ -74,20 +107,20 @@ test('requestRetest rejects a reason shorter than 20 characters', async () => {
|
|
|
74
107
|
|
|
75
108
|
test('requestRetest rejects a reason longer than 100 characters', async () => {
|
|
76
109
|
const longReason = 'x'.repeat(101);
|
|
77
|
-
const body = await response(['
|
|
110
|
+
const body = await response(['260101T0003', 'ret', longReason]);
|
|
78
111
|
const details = body['response content']['details about your request'] as any;
|
|
79
112
|
assert.ok(details.error.includes('reason'));
|
|
80
113
|
assert.ok(!logged.some(line => line.includes('new retest request awaits approval')));
|
|
81
114
|
});
|
|
82
115
|
|
|
83
116
|
test('requestRetest rejects a report matching a claimed job by description', async () => {
|
|
84
|
-
//
|
|
117
|
+
// 260101T0003-ret is the "Retest Fixture Page" report.
|
|
85
118
|
const claimedPath = path.join(fixtureDBDir, 'jobs', 'claimed', 'clm.json');
|
|
86
119
|
await fs.writeFile(claimedPath, JSON.stringify({
|
|
87
|
-
target: {what: '
|
|
120
|
+
target: {what: 'Retest Fixture Page', url: 'https://example.com/unrelated'}
|
|
88
121
|
}));
|
|
89
122
|
try {
|
|
90
|
-
const body = await response(['
|
|
123
|
+
const body = await response(['260101T0003', 'ret', 'A reason that is long enough.']);
|
|
91
124
|
const details = body['response content']['details about your request'] as any;
|
|
92
125
|
assert.equal(details.error, undefined);
|
|
93
126
|
const disposition = body['response content']['disposition of your request'] as any;
|
|
@@ -102,13 +135,13 @@ test('requestRetest rejects a report matching a claimed job by description', asy
|
|
|
102
135
|
});
|
|
103
136
|
|
|
104
137
|
test('requestRetest rejects a report matching a queued job by URL', async () => {
|
|
105
|
-
//
|
|
138
|
+
// 260101T0003-ret's URL is https://example.com/retestfixture.
|
|
106
139
|
const queuedPath = path.join(fixtureDBDir, 'jobs', 'queue', 'que.json');
|
|
107
140
|
await fs.writeFile(queuedPath, JSON.stringify({
|
|
108
|
-
target: {what: 'Some Other Page', url: 'https://example.com/
|
|
141
|
+
target: {what: 'Some Other Page', url: 'https://example.com/retestfixture'}
|
|
109
142
|
}));
|
|
110
143
|
try {
|
|
111
|
-
const body = await response(['
|
|
144
|
+
const body = await response(['260101T0003', 'ret', 'A reason that is long enough.']);
|
|
112
145
|
const details = body['response content']['details about your request'] as any;
|
|
113
146
|
assert.equal(details.error, undefined);
|
|
114
147
|
const disposition = body['response content']['disposition of your request'] as any;
|
|
@@ -123,8 +156,8 @@ test('requestRetest rejects a report matching a queued job by URL', async () =>
|
|
|
123
156
|
});
|
|
124
157
|
|
|
125
158
|
test('requestRetest rejects a duplicate request', async () => {
|
|
126
|
-
await response(['
|
|
127
|
-
const body = await response(['
|
|
159
|
+
await response(['260101T0003', 'ret', 'A reason that is long enough.']);
|
|
160
|
+
const body = await response(['260101T0003', 'ret', 'A reason that is long enough.']);
|
|
128
161
|
const details = body['response content']['details about your request'] as any;
|
|
129
162
|
assert.equal(details.error, undefined);
|
|
130
163
|
const disposition = body['response content']['disposition of your request'] as any;
|
package/api/requestRetest.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import {z} from 'zod';
|
|
9
9
|
import {getResponseMetadata, getThisHost, getToolsFacts} from './util.ts';
|
|
10
|
-
import {processTestRequest} from '../util.ts';
|
|
10
|
+
import {checkLength, isJobID, isTimeStamp, processTestRequest} from '../util.ts';
|
|
11
11
|
import {requestRetestResponseSchema} from './schemas.ts';
|
|
12
12
|
|
|
13
13
|
// TYPES
|
|
@@ -21,14 +21,20 @@ type ResponseContent = z.infer<typeof requestRetestResponseSchema>['response con
|
|
|
21
21
|
export const response = async (args: string[]) => {
|
|
22
22
|
const [timeStamp = '', jobID = '', reason = ''] = args;
|
|
23
23
|
const thisHost = getThisHost();
|
|
24
|
-
const
|
|
24
|
+
const reasonCheck = checkLength(reason, 20, 100, 'reason');
|
|
25
25
|
// Initialize the response-content properties.
|
|
26
26
|
let requestDetails: ResponseContent['details about your request'];
|
|
27
27
|
let requestDisposition: ResponseContent['disposition of your request'] = null;
|
|
28
|
-
// If the
|
|
29
|
-
if (
|
|
28
|
+
// If the timestamp or job ID is not syntactically valid:
|
|
29
|
+
if (!isTimeStamp(timeStamp) || !isJobID(jobID)) {
|
|
30
30
|
requestDetails = {
|
|
31
|
-
error: 'request invalid:
|
|
31
|
+
error: 'request 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: `request invalid: your ${reasonCheck.message}`
|
|
32
38
|
}
|
|
33
39
|
}
|
|
34
40
|
// Otherwise, i.e. if the request is facially valid:
|
package/api/requestTest.test.ts
CHANGED
|
@@ -11,6 +11,13 @@ 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 ./requestTest.ts (whose
|
|
15
|
+
// processTestRequest calls sendAlert) is ever imported below, so this file sends no
|
|
16
|
+
// real alert emails even when run directly rather than via `npm test`.
|
|
17
|
+
for (const key of ['MANAGER_EMAIL', 'ALERT_API_HOST', 'ALERT_API_PATH', 'ALERT_API_KEY', 'ALERT_FROM']) {
|
|
18
|
+
process.env[key] = '';
|
|
19
|
+
}
|
|
20
|
+
|
|
14
21
|
// SETUP AND TEARDOWN
|
|
15
22
|
|
|
16
23
|
const savedDBDir = process.env.DB_DIR;
|
package/api/requestTest.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import {z} from 'zod';
|
|
9
9
|
import {getResponseMetadata, getThisHost, getToolsFacts} from './util.ts';
|
|
10
|
-
import {isURL, processTestRequest} from '../util.ts';
|
|
10
|
+
import {checkLength, isURL, processTestRequest} from '../util.ts';
|
|
11
11
|
import {requestTestResponseSchema} from './schemas.ts';
|
|
12
12
|
|
|
13
13
|
// TYPES
|
|
@@ -24,14 +24,14 @@ export const response = async (args: string[]) => {
|
|
|
24
24
|
// Initialize the response-content properties.
|
|
25
25
|
let requestDetails: ResponseContent['details about your request'];
|
|
26
26
|
let requestDisposition: ResponseContent['disposition of your request'] = null;
|
|
27
|
-
const
|
|
28
|
-
// If the description is
|
|
29
|
-
if (
|
|
27
|
+
const descriptionCheck = checkLength(description, 1, 100, 'description of the page');
|
|
28
|
+
// If the description is invalid:
|
|
29
|
+
if (descriptionCheck.status === 'error') {
|
|
30
30
|
requestDetails = {
|
|
31
|
-
error:
|
|
31
|
+
error: `request invalid: your ${descriptionCheck.message}`
|
|
32
32
|
};
|
|
33
33
|
}
|
|
34
|
-
// Otherwise, i.e. if the URL is too short or too long
|
|
34
|
+
// Otherwise, i.e. if the URL is too short or too long:
|
|
35
35
|
else if (url.length < 12 || url.length > 300) {
|
|
36
36
|
requestDetails = {
|
|
37
37
|
error: 'request invalid: you specified a URL for the page that is not between 12 and 300 characters long'
|
package/api/schemas.ts
CHANGED
|
@@ -43,7 +43,7 @@ export const getReportSchema = {
|
|
|
43
43
|
|
|
44
44
|
// requestTest: POST /api/requestTest
|
|
45
45
|
export const requestTestSchema = {
|
|
46
|
-
description: z.string().describe('
|
|
46
|
+
description: z.string().describe('1- to 100-character description of the page conforming to the naming convention used in the listReports output'),
|
|
47
47
|
URL: z.string().describe('12- to 300-character URL of the page, including the https:// scheme and any query'),
|
|
48
48
|
reason: z.string().describe('20- to 100-character reason why the page should be tested')
|
|
49
49
|
};
|
|
@@ -57,7 +57,7 @@ export const requestRetestSchema = {
|
|
|
57
57
|
|
|
58
58
|
// requestFeature: POST /api/requestFeature
|
|
59
59
|
export const requestFeatureSchema = {
|
|
60
|
-
feature: z.string().describe('description of requested feature improvement or new feature')
|
|
60
|
+
feature: z.string().describe('20- to 1000-character description of requested feature improvement or new feature')
|
|
61
61
|
};
|
|
62
62
|
|
|
63
63
|
// listReports takes no input; omitted (mcp.ts already uses inputSchema: {}).
|
package/api/util.test.ts
CHANGED
|
@@ -7,6 +7,15 @@
|
|
|
7
7
|
|
|
8
8
|
import {test} from 'node:test';
|
|
9
9
|
import assert from 'node:assert/strict';
|
|
10
|
+
|
|
11
|
+
// Blank the alert configuration unconditionally, before ../util.ts (which
|
|
12
|
+
// processTestRequest calls sendAlert from) is ever imported below, so this file sends
|
|
13
|
+
// no real alert emails even when run directly (e.g. `npx tsx --test api/util.test.ts`)
|
|
14
|
+
// rather than via `npm test`, which normally guards against this via test/setup.ts.
|
|
15
|
+
for (const key of ['MANAGER_EMAIL', 'ALERT_API_HOST', 'ALERT_API_PATH', 'ALERT_API_KEY', 'ALERT_FROM']) {
|
|
16
|
+
process.env[key] = '';
|
|
17
|
+
}
|
|
18
|
+
|
|
10
19
|
import {getReportBasics, getResponseMetadata, getRuleEngineFacts, getRuleEnginesFacts, getIssueSpec, getToolsFacts} from './util.ts';
|
|
11
20
|
import {processTestRequest} from '../util.ts';
|
|
12
21
|
|
package/apiIntegration.test.ts
CHANGED
|
@@ -8,19 +8,29 @@
|
|
|
8
8
|
import {test, before, after} from 'node:test';
|
|
9
9
|
import assert from 'node:assert/strict';
|
|
10
10
|
import http from 'node:http';
|
|
11
|
+
import fs from 'node:fs/promises';
|
|
12
|
+
import path from 'node:path';
|
|
11
13
|
import {requestHandler} from './index.ts';
|
|
12
14
|
|
|
13
15
|
// CONSTANTS
|
|
14
16
|
|
|
15
17
|
const port = 3998;
|
|
18
|
+
const featureRequestsPath = path.join(import.meta.dirname, 'test/fixtures/apiIntegration/featureRequests.json');
|
|
16
19
|
|
|
17
20
|
// SETUP AND TEARDOWN
|
|
18
21
|
|
|
19
22
|
const savedDBDir = process.env.DB_DIR;
|
|
23
|
+
const savedFeatureRequestsPath = process.env.FEATURE_REQUESTS_PATH;
|
|
20
24
|
let server: http.Server;
|
|
21
25
|
|
|
22
26
|
before(async () => {
|
|
23
27
|
process.env.DB_DIR = (await import('./test/dbFixture.ts')).fixtureDBDir;
|
|
28
|
+
// Use a fixture path, rather than the real default, for feature requests, so this
|
|
29
|
+
// test's submission neither pollutes real data nor is rejected as a duplicate of
|
|
30
|
+
// whatever a prior run of this same test already left on disk.
|
|
31
|
+
process.env.FEATURE_REQUESTS_PATH = featureRequestsPath;
|
|
32
|
+
await fs.mkdir(path.dirname(featureRequestsPath), {recursive: true});
|
|
33
|
+
await fs.writeFile(featureRequestsPath, '[]\n');
|
|
24
34
|
server = http.createServer(requestHandler);
|
|
25
35
|
await new Promise<void>(resolve => server.listen(port, () => resolve()));
|
|
26
36
|
});
|
|
@@ -32,6 +42,13 @@ after(async () => {
|
|
|
32
42
|
else {
|
|
33
43
|
delete process.env.DB_DIR;
|
|
34
44
|
}
|
|
45
|
+
if (savedFeatureRequestsPath !== undefined) {
|
|
46
|
+
process.env.FEATURE_REQUESTS_PATH = savedFeatureRequestsPath;
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
delete process.env.FEATURE_REQUESTS_PATH;
|
|
50
|
+
}
|
|
51
|
+
await fs.rm(path.dirname(featureRequestsPath), {recursive: true, force: true});
|
|
35
52
|
server.closeAllConnections?.();
|
|
36
53
|
await new Promise<void>(resolve => {
|
|
37
54
|
const timer = setTimeout(() => {
|
package/docs/BACKLOG.md
CHANGED
|
@@ -4,9 +4,21 @@ Engineering tasks and risks that are not yet scheduled.
|
|
|
4
4
|
|
|
5
5
|
Items marked completed or not adopted are preserved for about 2 weeks in case of production bugs.
|
|
6
6
|
|
|
7
|
-
## Make validation locations consistent
|
|
7
|
+
## Make validation locations consistent (completed)
|
|
8
8
|
|
|
9
|
-
Inventory where request validations occur, e.g. in `index.ts` versus in the modules to which it routes requests. Find any arbitrary inconsistencies in the locations. If any are found, decide where it is most parsimonious and maintainable for the validation to take place and standardize on that.
|
|
9
|
+
Inventory where request validations occur, e.g. in `index.ts` versus in the modules to which it routes requests. Find any arbitrary inconsistencies in the locations. If any are found, decide where it is most parsimonious and maintainable for the validation to take place and, when the user approves, standardize on that.
|
|
10
|
+
|
|
11
|
+
### Inventory findings
|
|
12
|
+
|
|
13
|
+
Validation was spread across `index.ts`, the `web/*` modules it dispatches to, and the `api/*.ts` modules shared by the HTTP API and the MCP tools (`mcp.ts` itself performs no validation; it delegates entirely to `api/*.ts`, whose zod `inputSchema`s in `api/schemas.ts` are shape-only, documenting length/format bounds in `.describe()` prose rather than enforcing them). Several arbitrary inconsistencies were found and fixed:
|
|
14
|
+
|
|
15
|
+
- **A URL was validated three different, inconsistent ways** for what is logically the same field: a loose inline `url.startsWith('https://')` check in `index.ts` (for both `requestTest.html` and `requestAction.html`), and the correct, stricter shared `isURL()` helper (already used by `api/requestTest.ts` and `web/enqueue/index.ts`) nowhere in `index.ts` itself. Fixed by having `index.ts` call `isURL()` at both sites.
|
|
16
|
+
- **`requestAction.html`'s URL and authorization code were validated twice**: once in `index.ts` before dispatch, and again inside `web/enqueue/index.ts`'s own `answer()`, using different strictness for the URL check. Once both checks used the same `isURL()`/`isValidAuthCode()` helpers, the inner check in `enqueue` became strictly unreachable-as-false (its only caller, `index.ts`, already guarantees both conditions), so it was removed; `enqueue` now trusts its caller, consistent with `web/requestTest/` and `web/requestRetest/`.
|
|
17
|
+
- **The authorization-code check (`authCode === process.env.AUTH_CODE`) was copy-pasted verbatim across 8 separate modules** (`hideReportForm`, `unhideReportForm`, `ai0BalanceForm`, `showHiddenReportsForm`, `metrics`, `renewWCAG`, `reannotate`, and, via the already-shared `web/reportDeletion.ts`, `pruneReportsForm`/`expungeReportsForm`/`rewindReportsForm`). Consolidated into one shared `isValidAuthCode()` helper in `util.ts`, called everywhere the check occurs.
|
|
18
|
+
- **`api/requestRetest.ts` never validated `timeStamp`/`jobID` syntactically**, unlike the equivalent web path (`index.ts`, `requestRetest.html`) and the equivalent worker path, both of which call `isTimeStamp`/`isJobID` before proceeding; it relied solely on the report lookup failing gracefully. Added the same `isTimeStamp`/`isJobID` pre-check, matching the web path, with its own dedicated (non-corpus) test fixture since the shared 2-character fixture job IDs used elsewhere in the test suite do not satisfy the real, production 3-character job ID format `isJobID` enforces.
|
|
19
|
+
- **`api/requestFeature.ts` had no length bound at all** (only a truthiness check), unlike every other free-text field in the codebase (comments: 20-1000 characters; `reason`/`why`: 20-100). Added a matching 20-1000 character bound via a new, generic `checkLength()` helper (generalized from the existing `checkCommentLength()`, which was already comment-specific and left unchanged), and corrected `api/schemas.ts`'s description accordingly.
|
|
20
|
+
- **A documentation/code mismatch**: `api/schemas.ts` described `requestTest`'s `description` field as "10- to 100-character" while the code actually enforced (and still enforces) 1-100. Corrected the description to match the code's real, existing behavior.
|
|
21
|
+
- **`api/getReport.ts`, `api/listIssues.ts`, `api/listViolators.ts`, `api/listDiagnoses.ts` perform no syntactic validation of `timeStamp`/`jobID`/`issueID`/`catalogIndex`**, unlike routes elsewhere that call `isTimeStamp`/`isJobID` before proceeding. Investigated as a candidate gap, but not changed: these four read-only operations resolve straight to a file-path lookup (`getReport`/`getReportStats`) that already fails gracefully for any malformed or nonexistent input, so a syntactic pre-check would produce an identical outcome through a different code path, with no functional benefit. This is a legitimate, already-consistent "fail-on-use" validation strategy for pure lookups, distinct from (and not required to match) the "fail-fast" strategy used for mutating requests elsewhere.
|
|
10
22
|
|
|
11
23
|
## Protect hidden report list (completed)
|
|
12
24
|
|